From 2576783d4dc4c25f5c3ed004767967985b867571 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:24:45 -0700 Subject: [PATCH 01/31] fix(agents): keep ~/.copilot/config.json owner-only when Orca trusts a folder (#25087) * fix(agents): keep ~/.copilot/config.json owner-only when Orca trusts a folder Marking a folder trusted for Copilot rewrote ~/.copilot/config.json through a temp file created with the default umask mode (usually 0644), so an owner-only file that can hold copilotTokens became readable by other local users. Since the folder-trust change this write also runs on SSH hosts, where other users exist. The rewrite now always writes the file owner-only (0600). * test(agents): cover a fresh owner-only Copilot config.json under a permissive umask --------- Co-authored-by: m4air --- src/main/agent-trust-presets.test.ts | 39 ++++++++++++++++++++++++++++ src/main/agent-trust-presets.ts | 3 ++- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/main/agent-trust-presets.test.ts b/src/main/agent-trust-presets.test.ts index e956494fc76..e0052aa8b45 100644 --- a/src/main/agent-trust-presets.test.ts +++ b/src/main/agent-trust-presets.test.ts @@ -6,6 +6,7 @@ import { readFileSync, realpathSync, rmSync, + statSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -122,6 +123,44 @@ describe('markCopilotFolderTrusted', () => { } }) + it('keeps a token-bearing config.json owner-only', () => { + if (process.platform === 'win32') { + return + } + const workspace = mkdtempSync(join(tmpdir(), 'orca-copilot-ws-')) + const configPath = join(testState.fakeHomeDir, '.copilot', 'config.json') + // Why: a restrictive runner umask would mask a dropped mode. + const originalUmask = process.umask(0o022) + try { + mkdirSync(join(testState.fakeHomeDir, '.copilot'), { recursive: true }) + writeFileSync(configPath, JSON.stringify({ copilotTokens: { a: 'secret' } }), { + mode: 0o600 + }) + markCopilotFolderTrusted(workspace, testState.fakeHomeDir) + expect(statSync(configPath).mode & 0o777).toBe(0o600) + expect(JSON.parse(readFileSync(configPath, 'utf-8')).copilotTokens).toEqual({ a: 'secret' }) + } finally { + process.umask(originalUmask) + rmSync(workspace, { recursive: true, force: true }) + } + }) + + it('creates a missing config.json owner-only', () => { + if (process.platform === 'win32') { + return + } + const workspace = mkdtempSync(join(tmpdir(), 'orca-copilot-ws-')) + const originalUmask = process.umask(0o022) + try { + markCopilotFolderTrusted(workspace, testState.fakeHomeDir) + const configPath = join(testState.fakeHomeDir, '.copilot', 'config.json') + expect(statSync(configPath).mode & 0o777).toBe(0o600) + } finally { + process.umask(originalUmask) + rmSync(workspace, { recursive: true, force: true }) + } + }) + it('preserves existing config keys and dedups already-trusted folders', () => { const workspace = mkdtempSync(join(tmpdir(), 'orca-copilot-ws-')) const realpath = realpathSync(workspace) diff --git a/src/main/agent-trust-presets.ts b/src/main/agent-trust-presets.ts index 2d52ee005e4..eac0591728b 100644 --- a/src/main/agent-trust-presets.ts +++ b/src/main/agent-trust-presets.ts @@ -97,7 +97,8 @@ export function markCopilotFolderTrusted(workspacePath: string, home: string): v if (!existsSync(configDir)) { mkdirSync(configDir, { recursive: true }) } - writeFileAtomically(configPath, `${JSON.stringify(config, null, 2)}\n`) + // Why: config.json can hold copilotTokens, so it must stay owner-only (also on shared SSH hosts). + writeFileAtomically(configPath, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 }) } /** From 2b3b692d29102b87389042a5614d80b93956d5e0 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:25:47 -0700 Subject: [PATCH 02/31] Reduce terminal test overhead while preserving full parity checks (#25151) * Speed up terminal test oracles without reducing replay coverage * Call asynchronous parser through its checked test interface * Preserve evidence document final newline for concurrent merges --- docs/reference/ci-runner-efficiency.md | 41 +++ .../serialize-grid-cell-descriptors.test.ts | 282 +++++++++++++----- .../daemon/serialize-grid-cell-descriptors.ts | 164 ++++++++-- src/main/daemon/serialize-grid-roundtrip.ts | 29 +- ...al-output-frame-chunks-equivalence.test.ts | 41 +-- src/shared/terminal-restore-parity-fixture.ts | 15 +- .../terminal-restore-parity-writes.test.ts | 132 ++++++++ 7 files changed, 553 insertions(+), 151 deletions(-) create mode 100644 src/shared/terminal-restore-parity-writes.test.ts diff --git a/docs/reference/ci-runner-efficiency.md b/docs/reference/ci-runner-efficiency.md index 1efd247e7bb..600651a4457 100644 --- a/docs/reference/ci-runner-efficiency.md +++ b/docs/reference/ci-runner-efficiency.md @@ -1912,6 +1912,47 @@ The source was restored afterward. Extra collection turns therefore preserve the strong-retention oracle. Hosted qualification is still required; these observations do not prove a particular VM-retention cause or quantify avoided retries. +## October 4 terminal oracle execution + +Three measured test-support changes preserve the original seeds, payloads, +chunk boundaries and meaningful assertions. Serializer comparisons reuse cells +and format only the first mismatch instead of allocating descriptors for every +cell. The terminal parity writer submits every original chunk in FIFO order and +awaits the final parser callback. The independent legacy frame oracle memoizes +measured code-point widths. Its discarded algebra-only case never called +production and still passed when production always threw. + +Three alternating one-worker hosted ARM pairs measured complete invocations: + +| Cohort | Baseline median | Candidate median | Saving | +| --- | --- | --- | --- | +| Serializer replay/fuzz/descriptor checks | 71.675s | 46.581s | 35.0% | +| Emulator/reconciliation/color parity | 24.095s | 5.411s | 77.5% | +| Frame equivalence | 18.472s | 13.736s | 25.6% | + +[37180517143](https://github.com/stablyai/orca/actions/runs/37180517143) +retained 116 timed serializer passes and three existing/paired-control skips. +Separate captures matched all 190,796,645 raw bytes over 1,611 scenarios and +8,617 checkpoints (SHA256 `00ab219cfb31456af2ecd5e766d1b82d47abc751f6f2de0d7f795e36a936d3c7`), +including complete outputs and diagnostic payloads. Twenty candidate controls +passed; formatting/color/blank/clipping fault controls detected regressions. + +[37181073275](https://github.com/stablyai/orca/actions/runs/37181073275) +retained all 16 parity cases and default fuzz counts. Captures matched 2,325 +batches, 28,182 original chunks and 1,698,285 input bytes, with identical +terminal state and serialization per terminal/batch. Independent terminal +completion order differs, so comparison uses canonical per-terminal ordering +(SHA256 `c38ac1dbbefb9f6dc33ecfe7c495d65b707c1664614544622af93cfc1850e421`). +All 73 callback/parser/other-consumer controls passed; first-callback, reversed +chunks, missing empty boundary and early-completion faults failed. + +The frame candidate passed all 19 retained cases directly against the original +uncached legacy oracle, preserving 4,000 short and 800 near-cap seeded trials. +Sequence, surrogate width, byte width and span-transform faults failed real +assertions. A part-array alternative was rejected after adding time locally. +Hosted Node typecheck passed. These are focused workload savings, not measured +whole-shard or queue-delay improvements; application behavior is unchanged. + ## October 3 unit-selection evidence: include failed references The caller's `needs.test.result == 'success'` condition prevented the advisory diff --git a/src/main/daemon/serialize-grid-cell-descriptors.test.ts b/src/main/daemon/serialize-grid-cell-descriptors.test.ts index 490b40a1c9d..8aec878b790 100644 --- a/src/main/daemon/serialize-grid-cell-descriptors.test.ts +++ b/src/main/daemon/serialize-grid-cell-descriptors.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import type { Terminal } from '@xterm/headless' -import { bufferRows, cellDescriptor } from './serialize-grid-cell-descriptors' +import { cellDescriptor, compareBufferRows } from './serialize-grid-cell-descriptors' import { createFuzzTerminal, writeTerminal } from './serialize-grid-roundtrip' // Frozen allocating oracle from f69052e; a reused cell must preserve every descriptor. @@ -78,48 +78,6 @@ function allocatingBufferRows( } describe('serialize oracle cell reuse', () => { - it.each([false, true])( - 'matches allocating cells across SGR, wide text, buffers and resize (ConPTY=%s)', - (conpty) => { - const terminal = createFuzzTerminal({ cols: 14, rows: 4, scrollback: 30, conpty }) - try { - const writes = [ - 'plain \x1b[1;2;3;4;7;8;9mstyled\x1b[0m\r\n', - '\x1b[38;5;2;48;5;10m palette \x1b[38;2;11;22;33;48;2;44;55;66m RGB \x1b[0m\r\n', - '\x1b[4:3;9;53m \x1b[0m\x1b[7m \x1b[0m界👩‍💻é\r\n', - 'scroll1\r\nscroll2\r\nscroll3\r\n', - '\x1b[?1049h\x1b[1;2;3;4;7;8;9malt界\x1b[0m', - '\x1b[?1049l\x1b[2J\x1b[Hclear' - ] - for (const data of writes) { - writeTerminal(terminal, data) - for (const buffer of [ - terminal.buffer.normal, - terminal.buffer.alternate, - terminal.buffer.active - ]) { - expect(bufferRows(buffer, -1, buffer.length + 1, terminal.cols + 2)).toEqual( - allocatingBufferRows(buffer, -1, buffer.length + 1, terminal.cols + 2) - ) - } - terminal.resize(terminal.cols === 14 ? 9 : 14, 4) - expect( - bufferRows(terminal.buffer.active, 0, terminal.buffer.active.length, terminal.cols) - ).toEqual( - allocatingBufferRows( - terminal.buffer.active, - 0, - terminal.buffer.active.length, - terminal.cols - ) - ) - } - } finally { - terminal.dispose() - } - } - ) - it('preserves the order of every text flag combination while reloading a plain cell', () => { const terminal = createFuzzTerminal({ cols: 4, rows: 1, scrollback: 0 }) try { @@ -167,50 +125,11 @@ describe('serialize oracle cell reuse', () => { const scratch = terminal.buffer.active.getNullCell() expect(cellDescriptor(line, 3, 4, scratch)).toBe('CLIPPED') expect(cellDescriptor(line, 3, 4, scratch)).toBe(allocatingCellDescriptor(line, 3, 4)) - expect(bufferRows(terminal.buffer.active, 0, 1, 4)).toEqual( - allocatingBufferRows(terminal.buffer.active, 0, 1, 4) - ) } finally { terminal.dispose() } }) - it('loads every cell into one traversal-local scratch object and retains immutable descriptors', () => { - const terminal = createFuzzTerminal({ cols: 4, rows: 2, scrollback: 0 }) - try { - writeTerminal(terminal, '\x1b[1mA\x1b[0m B界') - const buffer = terminal.buffer.active - const scratch = buffer.getNullCell() - const allocate = vi.spyOn(buffer, 'getNullCell').mockReturnValue(scratch) - const getLine = buffer.getLine.bind(buffer) - const loaded: unknown[] = [] - vi.spyOn(buffer, 'getLine').mockImplementation((y) => { - const line = getLine(y) - if (line) { - const getCell = line.getCell.bind(line) - vi.spyOn(line, 'getCell').mockImplementation((x, cell) => { - loaded.push(cell) - return getCell(x, cell) - }) - } - return line - }) - const expected = allocatingBufferRows(buffer, 0, 2, 4) - loaded.length = 0 - const actual = bufferRows(buffer, 0, 2, 4) - expect(actual).toEqual(expected) - expect(allocate).toHaveBeenCalledTimes(1) - expect(loaded).toHaveLength(8) - expect(loaded.every((cell) => cell === scratch)).toBe(true) - writeTerminal(terminal, '\x1b[2J\x1b[Hnew') - bufferRows(buffer, 0, 2, 4) - expect(actual).toEqual(expected) - } finally { - terminal.dispose() - vi.restoreAllMocks() - } - }) - it('keeps missing lines and invalid columns blank after a styled cell occupied the scratch', () => { const terminal = createFuzzTerminal({ cols: 4, rows: 2, scrollback: 0 }) try { @@ -230,3 +149,202 @@ describe('serialize oracle cell reuse', () => { } }) }) + +function allocatingRowDiff(stage: string, expected: string[][], actual: string[][]) { + for (let y = 0; y < Math.max(expected.length, actual.length); y++) { + const expectedRow = expected[y] + const actualRow = actual[y] + if ( + !expectedRow || + !actualRow || + expectedRow.length !== actualRow.length || + !expectedRow.every( + (cell, x) => cell === actualRow[x] || (cell === CLIPPED && actualRow[x]?.startsWith('▯')) + ) + ) { + return { stage, row: y, expected: expectedRow?.join('|'), actual: actualRow?.join('|') } + } + } + return null +} + +function expectComparisonParity( + expected: Buffer, + actual: Buffer, + cols: number, + expectedStart = 0, + expectedEnd = expected.length, + actualStart = 0, + actualEnd = actual.length +): ReturnType { + const frozen = allocatingRowDiff( + 'parity', + allocatingBufferRows(expected, expectedStart, expectedEnd, cols), + allocatingBufferRows(actual, actualStart, actualEnd, cols) + ) + expect( + compareBufferRows( + 'parity', + expected, + expectedStart, + expectedEnd, + actual, + actualStart, + actualEnd, + cols + ) + ).toEqual(frozen) + return frozen +} + +describe('serialize oracle streaming comparison', () => { + it('reuses one cell per buffer and stops before rows after the first difference', () => { + const source = createFuzzTerminal({ cols: 8, rows: 4, scrollback: 0 }) + const replay = createFuzzTerminal({ cols: 8, rows: 4, scrollback: 0 }) + try { + writeTerminal(source, 'first\r\nsecond\r\nthird\r\nlast') + writeTerminal(replay, 'wrong\r\nsecond\r\nthird\r\nlast') + for (const buffer of [source.buffer.active, replay.buffer.active]) { + const scratch = buffer.getNullCell() + vi.spyOn(buffer, 'getNullCell').mockReturnValue(scratch) + const getLine = buffer.getLine.bind(buffer) + vi.spyOn(buffer, 'getLine').mockImplementation((y) => { + const line = getLine(y) + if (line) { + const getCell = line.getCell.bind(line) + vi.spyOn(line, 'getCell').mockImplementation((x, cell) => { + expect(cell).toBe(scratch) + return getCell(x, cell) + }) + } + return line + }) + } + const diff = compareBufferRows( + 'visible-grid', + source.buffer.active, + 0, + 4, + replay.buffer.active, + 0, + 4, + 8 + ) + expect(diff?.row).toBe(0) + for (const buffer of [source.buffer.active, replay.buffer.active]) { + expect(buffer.getNullCell).toHaveBeenCalledTimes(1) + expect(buffer.getLine).toHaveBeenCalledTimes(2) + expect(buffer.getLine).toHaveBeenCalledWith(0) + expect(buffer.getLine).toHaveBeenCalledWith(3) + } + writeTerminal(source, '\x1b[2J\x1b[Hchanged') + expect(diff?.expected).toContain('f·w1·f0:-1·b0:-1·0000000') + } finally { + source.dispose() + replay.dispose() + vi.restoreAllMocks() + } + }) + + it.each([false, true])( + 'preserves first-row diagnostics through buffer changes (ConPTY=%s)', + (conpty) => { + const source = createFuzzTerminal({ cols: 14, rows: 4, scrollback: 30, conpty }) + const replay = createFuzzTerminal({ cols: 14, rows: 4, scrollback: 30, conpty }) + try { + for (const [index, data] of [ + 'plain \x1b[1;2;3;4;7;8;9mstyled\x1b[0m\r\n', + '\x1b[38;5;2;48;5;10m palette \x1b[38;2;11;22;33;48;2;44;55;66m RGB \x1b[0m\r\n', + '\x1b[4:3;9;53m \x1b[0m\x1b[7m \x1b[0m界👩‍💻é\r\n', + 'scroll1\r\nscroll2\r\nscroll3\r\n', + '\x1b[?1049h\x1b[1;2;3;4;7;8;9malt界\x1b[0m', + '\x1b[?1049l\x1b[2J\x1b[Hclear' + ].entries()) { + writeTerminal(source, data) + writeTerminal(replay, data) + for (const [expected, actual] of [ + [source.buffer.active, replay.buffer.active], + [source.buffer.normal, replay.buffer.normal], + [source.buffer.alternate, replay.buffer.alternate] + ]) { + expect(expectComparisonParity(expected!, actual!, source.cols)).toBeNull() + expectComparisonParity(expected!, actual!, source.cols + 2, -1, expected!.length + 1) + } + const corruption = `\x1b[H\x1b[0mFAULT${index}` + writeTerminal(replay, corruption) + expect( + expectComparisonParity(source.buffer.active, replay.buffer.active, source.cols) + ).not.toBeNull() + writeTerminal(source, corruption) + source.resize(source.cols === 14 ? 9 : 14, 4) + replay.resize(source.cols, 4) + expectComparisonParity(source.buffer.active, replay.buffer.active, source.cols) + } + } finally { + source.dispose() + replay.dispose() + } + } + ) + + it('detects every text-flag loss and compares all combinations against the allocating oracle', () => { + const source = createFuzzTerminal({ cols: 4, rows: 1, scrollback: 0 }) + const replay = createFuzzTerminal({ cols: 4, rows: 1, scrollback: 0 }) + try { + const sgr = [1, 2, 3, 4, 7, 8, 9] + const writeFlags = (terminal: Terminal, mask: number): void => { + const codes = sgr.filter((_code, bit) => (mask & (1 << bit)) !== 0) + writeTerminal(terminal, `\x1b[H\x1b[0m\x1b[${codes.length ? codes.join(';') : 0}mA\x1b[0mB`) + } + for (let mask = 0; mask < 128; mask++) { + writeFlags(source, mask) + writeFlags(replay, mask) + expect(expectComparisonParity(source.buffer.active, replay.buffer.active, 4)).toBeNull() + for (let bit = 0; bit < sgr.length; bit++) { + if ((mask & (1 << bit)) !== 0) { + writeFlags(replay, mask & ~(1 << bit)) + expect( + expectComparisonParity(source.buffer.active, replay.buffer.active, 4) + ).not.toBeNull() + } + } + } + } finally { + source.dispose() + replay.dispose() + } + }) + + it.each([ + ['abc界', 'abc ', 4, false], + ['abc界', 'abc\x1b[48;2;1;2;3m ', 4, false], + ['abc ', 'abc界', 4, true], + ['é', 'è', 8, true], + ['\x1b[32mA', '\x1b[38;5;2mA', 8, false], + ['\x1b[42m ', '\x1b[48;5;2m ', 8, false], + ['\x1b[7;31m ', '\x1b[7;32m ', 8, true], + ['\x1b[4m\x1b[2J', '\x1b[2J', 8, false], + ['\x1b[4m ', ' ', 8, true], + ['\x1b[4m \x1b[0mB', ' B', 8, true], + ['text\r\n', 'text', 8, false], + ['text\r\n\x1b[48;2;1;2;3m\x1b[2K', 'text', 8, true] + ] as const)( + 'preserves blank, clipped and color policy for %j / %j', + (expected, actual, cols, differs) => { + const source = createFuzzTerminal({ cols: 8, rows: 4, scrollback: 10 }) + const replay = createFuzzTerminal({ cols: 8, rows: 4, scrollback: 10 }) + try { + writeTerminal(source, expected) + writeTerminal(replay, actual) + expect( + Boolean(expectComparisonParity(source.buffer.active, replay.buffer.active, cols)) + ).toBe(differs) + expectComparisonParity(source.buffer.active, replay.buffer.active, cols, -1, 6, -1, 5) + expectComparisonParity(source.buffer.active, replay.buffer.active, cols, 1, 5, 0, 4) + } finally { + source.dispose() + replay.dispose() + } + } + ) +}) diff --git a/src/main/daemon/serialize-grid-cell-descriptors.ts b/src/main/daemon/serialize-grid-cell-descriptors.ts index 8b6c331f5fc..9adbbc6c694 100644 --- a/src/main/daemon/serialize-grid-cell-descriptors.ts +++ b/src/main/daemon/serialize-grid-cell-descriptors.ts @@ -7,6 +7,7 @@ export type GridDiff = { stage: string; row?: number; expected: unknown; actual: type BufferLine = NonNullable> type Buffer = Terminal['buffer']['active'] +type Cell = ReturnType const COLOR_MODE_P16 = 16777216 const COLOR_MODE_P256 = 33554432 @@ -64,33 +65,158 @@ function cellsMatch(expected: string, actual: string): boolean { return expected === actual || (expected === CLIPPED && actual.startsWith('▯')) } -function rowsMatch(expected: string[], actual: string[]): boolean { - return expected.length === actual.length && expected.every((e, i) => cellsMatch(e, actual[i]!)) +function sameColor( + expectedMode: number, + expectedColor: number, + actualMode: number, + actualColor: number +): boolean { + return ( + expectedColor === actualColor && + canonicalColorMode(expectedMode, expectedColor) === canonicalColorMode(actualMode, actualColor) + ) } -export function bufferRows(buffer: Buffer, start: number, end: number, cols: number): string[][] { - const rows: string[][] = [] - const reusableCell = buffer.getNullCell() - for (let y = start; y < end; y++) { - rows.push(rowCells(buffer.getLine(y), cols, reusableCell)) +// Equal public fields imply identical descriptors; differing fields still use the frozen policy. +function sameCellFields(expected: Cell, actual: Cell): boolean { + if ( + expected.getWidth() !== actual.getWidth() || + !sameColor( + expected.getBgColorMode(), + expected.getBgColor(), + actual.getBgColorMode(), + actual.getBgColor() + ) + ) { + return false } - while (rows.length > 0 && rows.at(-1)!.every((c) => c === DEFAULT_BLANK)) { - rows.pop() + const expectedChars = expected.getChars() + const actualChars = actual.getChars() + const expectedBlank = expectedChars === '' || expectedChars === ' ' + const actualBlank = actualChars === '' || actualChars === ' ' + if (expectedBlank || actualBlank) { + return ( + expectedBlank && + actualBlank && + (expectedChars === ' ' && expected.isUnderline() !== 0) === + (actualChars === ' ' && actual.isUnderline() !== 0) && + (expectedChars === ' ' && expected.isStrikethrough() !== 0) === + (actualChars === ' ' && actual.isStrikethrough() !== 0) && + (expectedChars === ' ' && expected.isOverline() !== 0) === + (actualChars === ' ' && actual.isOverline() !== 0) && + (expected.isInverse() !== 0) === (actual.isInverse() !== 0) && + (expected.isInverse() === 0 || + sameColor( + expected.getFgColorMode(), + expected.getFgColor(), + actual.getFgColorMode(), + actual.getFgColor() + )) + ) } - return rows + return ( + expectedChars === actualChars && + sameColor( + expected.getFgColorMode(), + expected.getFgColor(), + actual.getFgColorMode(), + actual.getFgColor() + ) && + (expected.isBold() !== 0) === (actual.isBold() !== 0) && + (expected.isDim() !== 0) === (actual.isDim() !== 0) && + (expected.isItalic() !== 0) === (actual.isItalic() !== 0) && + (expected.isUnderline() !== 0) === (actual.isUnderline() !== 0) && + (expected.isInverse() !== 0) === (actual.isInverse() !== 0) && + (expected.isInvisible() !== 0) === (actual.isInvisible() !== 0) && + (expected.isStrikethrough() !== 0) === (actual.isStrikethrough() !== 0) + ) } -export function compareRowSets( +function trimmedEnd( + buffer: Buffer, + start: number, + end: number, + cols: number, + scratch: Cell +): number { + while (end > start) { + const line = buffer.getLine(end - 1) + let blank = true + for (let x = 0; x < cols; x++) { + if (cellDescriptor(line, x, cols, scratch) !== DEFAULT_BLANK) { + blank = false + break + } + } + if (!blank) { + break + } + end-- + } + return end +} + +function lineCellsMatch( + expected: BufferLine | undefined, + actual: BufferLine | undefined, + cols: number, + expectedScratch: Cell, + actualScratch: Cell +): boolean { + for (let x = 0; x < cols; x++) { + const expectedCell = + expected && x < expected.length ? expected.getCell(x, expectedScratch) : null + const actualCell = actual && x < actual.length ? actual.getCell(x, actualScratch) : null + const clipped = + x === cols - 1 && + ((expected && expected.length > cols && (expectedCell?.getWidth() ?? 0) > 1) || + (actual && actual.length > cols && (actualCell?.getWidth() ?? 0) > 1)) + if (expectedCell && actualCell && !clipped && sameCellFields(expectedCell, actualCell)) { + continue + } + if ( + !cellsMatch( + cellDescriptor(expected, x, cols, expectedScratch), + cellDescriptor(actual, x, cols, actualScratch) + ) + ) { + return false + } + } + return true +} + +/** Compares in place and formats only the first differing row, preserving diagnostic bytes. */ +export function compareBufferRows( stage: string, - expected: string[][], - actual: string[][] + expected: Buffer, + expectedStart: number, + expectedEnd: number, + actual: Buffer, + actualStart: number, + actualEnd: number, + cols: number ): GridDiff | null { - const length = Math.max(expected.length, actual.length) - for (let y = 0; y < length; y++) { - const e = expected[y] - const a = actual[y] - if (!e || !a || !rowsMatch(e, a)) { - return { stage, row: y, expected: e?.join('|'), actual: a?.join('|') } + const expectedScratch = expected.getNullCell() + const actualScratch = actual.getNullCell() + const expectedLength = + trimmedEnd(expected, expectedStart, expectedEnd, cols, expectedScratch) - expectedStart + const actualLength = trimmedEnd(actual, actualStart, actualEnd, cols, actualScratch) - actualStart + for (let y = 0; y < Math.max(expectedLength, actualLength); y++) { + const expectedLine = expected.getLine(expectedStart + y) + const actualLine = actual.getLine(actualStart + y) + if ( + y >= expectedLength || + y >= actualLength || + !lineCellsMatch(expectedLine, actualLine, cols, expectedScratch, actualScratch) + ) { + return { + stage, + row: y, + expected: + y < expectedLength ? rowCells(expectedLine, cols, expectedScratch).join('|') : undefined, + actual: y < actualLength ? rowCells(actualLine, cols, actualScratch).join('|') : undefined + } } } return null diff --git a/src/main/daemon/serialize-grid-roundtrip.ts b/src/main/daemon/serialize-grid-roundtrip.ts index d22bee6e511..26176c9ff5a 100644 --- a/src/main/daemon/serialize-grid-roundtrip.ts +++ b/src/main/daemon/serialize-grid-roundtrip.ts @@ -18,12 +18,7 @@ import { variantTrailingBackgroundRows } from './serialize-grid-variant-scope' import type { GridDiff } from './serialize-grid-cell-descriptors' -import { - bufferRows, - cellDescriptor, - CLIPPED, - compareRowSets -} from './serialize-grid-cell-descriptors' +import { cellDescriptor, CLIPPED, compareBufferRows } from './serialize-grid-cell-descriptors' export type NamedSerializer = { name: string; create: () => SerializeAddon } @@ -156,15 +151,25 @@ async function compareReplay( (src.type === dst.type ? null : { stage: 'active-buffer', expected: src.type, actual: dst.type }) ?? - compareRowSets( + compareBufferRows( 'visible-grid', - bufferRows(src, src.baseY, src.baseY + rows, cols), - bufferRows(dst, dst.baseY, dst.baseY + rows, cols) + src, + src.baseY, + src.baseY + rows, + dst, + dst.baseY, + dst.baseY + rows, + cols ) ?? - compareRowSets( + compareBufferRows( 'normal-buffer', - bufferRows(source.buffer.normal, normalStart, source.buffer.normal.length, cols), - bufferRows(replay.buffer.normal, 0, replay.buffer.normal.length, cols) + source.buffer.normal, + normalStart, + source.buffer.normal.length, + replay.buffer.normal, + 0, + replay.buffer.normal.length, + cols ) ?? (src.cursorX === dst.cursorX && src.cursorY === dst.cursorY ? null diff --git a/src/main/runtime/rpc/terminal-output-frame-chunks-equivalence.test.ts b/src/main/runtime/rpc/terminal-output-frame-chunks-equivalence.test.ts index 85e01116c3c..e143535d659 100644 --- a/src/main/runtime/rpc/terminal-output-frame-chunks-equivalence.test.ts +++ b/src/main/runtime/rpc/terminal-output-frame-chunks-equivalence.test.ts @@ -14,9 +14,7 @@ import { type TerminalOutputMeta } from './terminal-output-frame-chunks' -// Byte-for-byte reference: the pre-optimization implementation, copied verbatim. -// It accumulates `chunk += part` over `for (const part of data)` and measures each -// code point through the shared clipboard measurer. +// The legacy splitter, caching only its pure byte counts for repeated code points. function legacyByteLength(data: string): number { return measureClipboardTextByteLength(data).byteLength } @@ -64,6 +62,7 @@ function* legacyIterateTerminalOutputFrameChunks( let chunkStartOffset = 0 let offset = 0 let delayedChunk: { text: string; seq?: number } | null = null + const partByteLengths = new Map() const takeChunk = (): { text: string; seq?: number } | null => { if (!chunk) { @@ -78,7 +77,11 @@ function* legacyIterateTerminalOutputFrameChunks( } for (const part of data) { - const partBytes = legacyByteLength(part) + let partBytes = partByteLengths.get(part) + if (partBytes === undefined) { + partBytes = legacyByteLength(part) + partByteLengths.set(part, partBytes) + } if (chunkBytes > 0 && chunkBytes + partBytes > TERMINAL_STREAM_CHUNK_BYTES) { const nextChunk = takeChunk() if (nextChunk) { @@ -522,34 +525,4 @@ describe('iterateTerminalOutputFrameChunks equivalence with the pre-optimization expect([...iterateTerminalOutputFrameChunks(overByOne)].length).toBeGreaterThan(1) expect([...legacyIterateTerminalOutputFrameChunks(overByOne)].length).toBeGreaterThan(1) }) - - // The chunking loop is only reached when rawLength === data.length and transformed - // is falsy, which makes canPreserveChunkSeq === (typeof meta.seq === 'number') and - // therefore shouldDelayFinalSeq unconditionally false. The branch survives here - // (it also survived in the pre-optimization code) purely as defence in depth. - it('never reaches the delayed-final-seq branch for any meta shape', () => { - for (const data of ['', 'a', 'abc', 'x'.repeat(200)]) { - for (const seq of [undefined, 0, 5] as (number | undefined)[]) { - for (const rawDelta of [undefined, 0, 1, -1] as (number | undefined)[]) { - for (const transformed of [undefined, false, true] as (boolean | undefined)[]) { - const meta: TerminalOutputMeta = {} - if (seq !== undefined) { - meta.seq = seq - } - if (rawDelta !== undefined) { - meta.rawLength = data.length + rawDelta - } - if (transformed !== undefined) { - meta.transformed = transformed - } - const rawLength = meta.rawLength ?? data.length - const reachesChunkLoop = !meta.transformed && rawLength === data.length - const canPreserveChunkSeq = typeof meta.seq === 'number' && rawLength === data.length - const shouldDelayFinalSeq = !canPreserveChunkSeq && typeof meta.seq === 'number' - expect(reachesChunkLoop && shouldDelayFinalSeq, JSON.stringify(meta)).toBe(false) - } - } - } - } - }) }) diff --git a/src/shared/terminal-restore-parity-fixture.ts b/src/shared/terminal-restore-parity-fixture.ts index 10fb0cd6e58..4fab319ab5b 100644 --- a/src/shared/terminal-restore-parity-fixture.ts +++ b/src/shared/terminal-restore-parity-fixture.ts @@ -47,10 +47,17 @@ export function writeToTerminal(terminal: Terminal, data: string): Promise return new Promise((resolve) => terminal.write(data, resolve)) } -export async function writeChunksToTerminal(terminal: Terminal, chunks: string[]): Promise { - for (const chunk of chunks) { - await writeToTerminal(terminal, chunk) - } +export function writeChunksToTerminal(terminal: Terminal, chunks: string[]): Promise { + return new Promise((resolve) => { + if (chunks.length === 0) { + resolve() + return + } + // The final FIFO callback also fences async parser handlers in earlier chunks. + for (let index = 0; index < chunks.length; index++) { + terminal.write(chunks[index]!, index === chunks.length - 1 ? resolve : undefined) + } + }) } /** Bottom-anchored visible screen rows (baseY, not viewportY — scroll intent diff --git a/src/shared/terminal-restore-parity-writes.test.ts b/src/shared/terminal-restore-parity-writes.test.ts new file mode 100644 index 00000000000..342e7effd54 --- /dev/null +++ b/src/shared/terminal-restore-parity-writes.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it, vi } from 'vitest' +import { + createRendererParityTerminal, + cursorPosition, + normalBufferRowsTrimmed, + normalBufferStylesTrimmed, + visibleRowStyles, + visibleRowWraps, + visibleRows, + writeChunksToTerminal +} from './terminal-restore-parity-fixture' + +type AsyncOscParser = { + registerOscHandler( + identifier: number, + callback: (data: string) => boolean | Promise + ): { dispose(): void } +} + +describe('terminal parity fixture writes', () => { + it('queues every original chunk and resolves only after the last callback', async () => { + const { terminal } = createRendererParityTerminal({ cols: 8, rows: 2 }) + const writes: { data: string | Uint8Array; callback?: () => void }[] = [] + try { + vi.spyOn(terminal, 'write').mockImplementation((data, callback) => { + writes.push({ data, callback }) + }) + const chunks = ['A\x1b[', '', '31mB', '\x1b[0mC'] + let completed = false + const pending = writeChunksToTerminal(terminal, chunks).then(() => { + completed = true + }) + expect(writes.map(({ data }) => data)).toEqual(chunks) + expect(writes.slice(0, -1).every(({ callback }) => callback === undefined)).toBe(true) + await Promise.resolve() + expect(completed).toBe(false) + writes.at(-1)?.callback?.() + await pending + expect(completed).toBe(true) + } finally { + terminal.dispose() + vi.restoreAllMocks() + } + }) + + it('completes an empty batch without writing or scheduling work', async () => { + const { terminal } = createRendererParityTerminal({ cols: 8, rows: 2 }) + try { + const write = vi.spyOn(terminal, 'write') + await writeChunksToTerminal(terminal, []) + expect(write).not.toHaveBeenCalled() + } finally { + terminal.dispose() + vi.restoreAllMocks() + } + }) + + it('waits for an asynchronous parser barrier before exposing later chunks', async () => { + const { terminal } = createRendererParityTerminal({ cols: 8, rows: 2 }) + let signalEntered = (): void => {} + const entered = new Promise((resolve) => { + signalEntered = resolve + }) + let releaseParser = (): void => {} + const barrier = new Promise((resolve) => { + releaseParser = () => resolve(true) + }) + // xterm supports async handlers; its shipped public typings still say boolean. + const parser: AsyncOscParser = terminal.parser + const registration = parser.registerOscHandler(777, () => { + signalEntered() + return barrier + }) + try { + let completed = false + const pending = writeChunksToTerminal(terminal, [ + 'A\x1b]777;pause', + '\x07B', + '\x1b[31mC', + 'D' + ]).then(() => { + completed = true + }) + await entered + expect(visibleRows(terminal)[0]).toBe('A') + expect(completed).toBe(false) + releaseParser() + await pending + expect(visibleRows(terminal)[0]).toBe('ABCD') + expect(terminal.buffer.active.getLine(0)?.getCell(3)?.getFgColor()).toBe(1) + } finally { + releaseParser() + registration.dispose() + terminal.dispose() + } + }) + + it.each([ + ['A\x1b[', '38;2;11;22;33m界', 'é👩‍💻', '\x1b[0m\r\nnext'], + ['history\r\n'.repeat(8), '\x1b[?1049h\x1b[2J\x1b[H', '\x1b[48;5;2m ALT ', '\x1b[0m'], + ['\x1b[?1049hALT', '\x1b[?1049l', '\x1b[7;4;9;53m ', '\x1b[0mnormal'], + ['\x1b]8;;https://example.com\x07', 'link', '\x1b]8;;', '\x07 tail'], + ['0123456789ABCDEFGHIJKLMN', '\x1b[2A\x1b[2K', '\x1b[3;5H', 'end\x1b[?2004h'] + ])( + 'matches serial callbacks for split controls, styles and buffer transitions: %j', + async (...chunks) => { + const batched = createRendererParityTerminal({ cols: 12, rows: 4 }) + const serial = createRendererParityTerminal({ cols: 12, rows: 4 }) + try { + for (const chunk of chunks) { + await new Promise((resolve) => serial.terminal.write(chunk, resolve)) + } + await writeChunksToTerminal(batched.terminal, chunks) + expect(visibleRows(batched.terminal)).toEqual(visibleRows(serial.terminal)) + expect(visibleRowStyles(batched.terminal)).toEqual(visibleRowStyles(serial.terminal)) + expect(visibleRowWraps(batched.terminal)).toEqual(visibleRowWraps(serial.terminal)) + expect(normalBufferRowsTrimmed(batched.terminal)).toEqual( + normalBufferRowsTrimmed(serial.terminal) + ) + expect(normalBufferStylesTrimmed(batched.terminal)).toEqual( + normalBufferStylesTrimmed(serial.terminal) + ) + expect(cursorPosition(batched.terminal)).toEqual(cursorPosition(serial.terminal)) + expect(batched.terminal.modes).toEqual(serial.terminal.modes) + expect(batched.serializeAddon.serialize()).toBe(serial.serializeAddon.serialize()) + } finally { + batched.terminal.dispose() + serial.terminal.dispose() + } + } + ) +}) From 58bd15fa3ffa65942d94aee8137e35c27f4368c8 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 4 Oct 2026 01:27:30 -0700 Subject: [PATCH 03/31] Fix ripgrep result completeness, filename handling, and search errors (#25156) * Preserve ripgrep search results, filename identity, and failure diagnostics * Fix adversarial Unicode and Explorer filename findings * Register search failure localization fallback * Preserve host filename identity through document and watcher consumers --- .../patches/@streamparser__json@0.0.26.patch | 66 +++++++++ pnpm-lock.yaml | 5 +- pnpm-workspace.yaml | 1 + .../filesystem-list-files-name-filter.test.ts | 8 +- src/main/ipc/filesystem-list-files.test.ts | 71 ++++++--- src/main/ipc/filesystem-list-files.ts | 67 ++++----- .../ipc/filesystem-search-file-paths.test.ts | 76 ++++++++-- src/main/ipc/filesystem-search-file-paths.ts | 38 ++--- .../ipc/filesystem-search-rg-timeout.test.ts | 32 ++++- .../filesystem/filesystem-search-handlers.ts | 50 ++++--- .../markdown-documents-remote-paths.test.ts | 47 ++++++ .../ipc/markdown-documents-ripgrep.test.ts | 18 +++ src/main/ipc/markdown-documents.test.ts | 36 ++++- src/main/ipc/markdown-documents.ts | 71 +++++---- .../ripgrep-filename-identity-real.test.ts | 62 ++++++++ .../ripgrep/text-search-unicode-real.test.ts | 123 ++++++++++++++++ .../runtime/ripgrep-filename-identity.test.ts | 82 +++++++++++ ...ile-commands-search-local-runtime-files.ts | 41 ++++-- src/main/runtime/runtime-relative-paths.ts | 12 +- .../fs-handler-list-files-cancel.test.ts | 6 +- .../fs-handler-list-files-ignored.test.ts | 91 ++++++------ ...fs-handler-list-files-path-framing.test.ts | 57 ++++++++ src/relay/fs-handler-list-files.ts | 29 ++-- src/relay/fs-handler-utils.ts | 42 +++--- src/relay/fs-search-errors-real.test.ts | 39 +++++ src/relay/relay-bundled-ripgrep.test.ts | 2 +- src/relay/relay-bundled-ripgrep.ts | 34 +++-- src/relay/relay-ripgrep-cwd-env.test.ts | 2 +- .../relay-windows-path-ripgrep-cache.test.ts | 79 ++++++++++ src/relay/relay-windows-path-ripgrep.test.ts | 9 +- .../editor-external-watch-path-index.test.ts | 30 ++++ .../right-sidebar/SearchResultsPane.tsx | 16 ++- ...plorer-directory-filename-identity.test.ts | 54 +++++++ .../file-explorer-directory-listing.ts | 6 +- .../right-sidebar/file-explorer-entries.ts | 5 +- ...le-explorer-name-filter-projection.test.ts | 73 ++++++++++ .../file-explorer-name-filter-projection.ts | 17 +-- .../right-sidebar/file-explorer-paths.test.ts | 25 ++-- .../right-sidebar/file-explorer-paths.ts | 19 +-- ...e-explorer-watch-filename-identity.test.ts | 45 ++++++ .../right-sidebar/file-explorer-watch-path.ts | 11 +- .../file-explorer-watcher-reconcile.ts | 8 +- .../src/components/right-sidebar/path-tree.ts | 6 +- .../right-sidebar/status-display.ts | 9 +- .../useFileExplorerVisibleRowProjection.ts | 15 +- .../right-sidebar/useFileSearchPanel.ts | 4 +- .../useFileSearchRunner.test.tsx | 49 +++++++ .../right-sidebar/useFileSearchRunner.ts | 9 +- src/renderer/src/i18n/locales/en.json | 3 + src/renderer/src/lib/path.test.ts | 8 ++ src/renderer/src/lib/path.ts | 21 ++- .../editor/actions/file-search-actions.ts | 3 + .../slices/editor/search/file-search-state.ts | 1 + .../types/file-search-worktree-state.ts | 1 + src/shared/quick-open-filter.test.ts | 16 ++- src/shared/quick-open-filter.ts | 25 ++-- src/shared/quick-open-ripgrep-output-mode.ts | 14 ++ src/shared/ripgrep-dense-match-json.test.ts | 78 ++++++++++ src/shared/ripgrep-dense-match-json.ts | 135 ++++++++++++++++++ src/shared/ripgrep-filename-decoder.test.ts | 44 ++++++ src/shared/ripgrep-filename-decoder.ts | 51 +++++++ src/shared/ripgrep-line-decoding.test.ts | 61 ++++++++ src/shared/ripgrep-line-decoding.ts | 60 ++++++++ src/shared/ripgrep-match-offsets.test.ts | 36 +++++ src/shared/ripgrep-match-offsets.ts | 37 +++++ src/shared/ripgrep-search-diagnostics.test.ts | 42 ++++++ src/shared/ripgrep-search-diagnostics.ts | 34 +++++ src/shared/text-search-dense-matches.test.ts | 126 ++++++++++++++++ .../text-search-invalid-filename.test.ts | 19 +++ src/shared/text-search-paths.test.ts | 36 +++++ src/shared/text-search-paths.ts | 14 +- src/shared/text-search.ts | 59 ++++---- 72 files changed, 2260 insertions(+), 361 deletions(-) create mode 100644 config/patches/@streamparser__json@0.0.26.patch create mode 100644 src/main/ipc/markdown-documents-remote-paths.test.ts create mode 100644 src/main/ripgrep/ripgrep-filename-identity-real.test.ts create mode 100644 src/main/ripgrep/text-search-unicode-real.test.ts create mode 100644 src/main/runtime/ripgrep-filename-identity.test.ts create mode 100644 src/relay/fs-handler-list-files-path-framing.test.ts create mode 100644 src/relay/fs-search-errors-real.test.ts create mode 100644 src/relay/relay-windows-path-ripgrep-cache.test.ts create mode 100644 src/renderer/src/components/right-sidebar/file-explorer-directory-filename-identity.test.ts create mode 100644 src/renderer/src/components/right-sidebar/file-explorer-watch-filename-identity.test.ts create mode 100644 src/shared/quick-open-ripgrep-output-mode.ts create mode 100644 src/shared/ripgrep-dense-match-json.test.ts create mode 100644 src/shared/ripgrep-dense-match-json.ts create mode 100644 src/shared/ripgrep-filename-decoder.test.ts create mode 100644 src/shared/ripgrep-filename-decoder.ts create mode 100644 src/shared/ripgrep-line-decoding.test.ts create mode 100644 src/shared/ripgrep-line-decoding.ts create mode 100644 src/shared/ripgrep-match-offsets.test.ts create mode 100644 src/shared/ripgrep-match-offsets.ts create mode 100644 src/shared/ripgrep-search-diagnostics.test.ts create mode 100644 src/shared/ripgrep-search-diagnostics.ts create mode 100644 src/shared/text-search-dense-matches.test.ts create mode 100644 src/shared/text-search-invalid-filename.test.ts create mode 100644 src/shared/text-search-paths.test.ts diff --git a/config/patches/@streamparser__json@0.0.26.patch b/config/patches/@streamparser__json@0.0.26.patch new file mode 100644 index 00000000000..c93998f85fe --- /dev/null +++ b/config/patches/@streamparser__json@0.0.26.patch @@ -0,0 +1,66 @@ +diff --git a/dist/cjs/utils/bufferedString.js b/dist/cjs/utils/bufferedString.js +index 82f710a018f9771fe10335e2dcacd75d707d9062..f3cfa64cefa967d7a83c328e1abb9246135b067b 100644 +--- a/dist/cjs/utils/bufferedString.js ++++ b/dist/cjs/utils/bufferedString.js +@@ -16,7 +16,7 @@ class NonBufferedString { + constructor() { + // fatal: true makes invalid byte sequences (e.g. a lead byte followed by a + // non-continuation byte) throw instead of silently decoding to U+FFFD. +- this.decoder = new TextDecoder("utf-8", { fatal: true }); ++ this.decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }); + // Pieces appended since the last toString(), not yet folded into `string`. + this.pending = []; + this.string = ""; +@@ -66,7 +66,7 @@ class BufferedString { + constructor(bufferSize) { + // fatal: true makes invalid byte sequences (e.g. a lead byte followed by a + // non-continuation byte) throw instead of silently decoding to U+FFFD. +- this.decoder = new TextDecoder("utf-8", { fatal: true }); ++ this.decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }); + this.bufferOffset = 0; + this.string = ""; + this.byteLength = 0; +diff --git a/dist/mjs/utils/bufferedString.js b/dist/mjs/utils/bufferedString.js +index 0fb208d8615f5e20a086f75a37bef928b155c47c..0d8ca407d594d61798eca7f7253e1dfc1770d291 100644 +--- a/dist/mjs/utils/bufferedString.js ++++ b/dist/mjs/utils/bufferedString.js +@@ -13,7 +13,7 @@ export class NonBufferedString { + constructor() { + // fatal: true makes invalid byte sequences (e.g. a lead byte followed by a + // non-continuation byte) throw instead of silently decoding to U+FFFD. +- this.decoder = new TextDecoder("utf-8", { fatal: true }); ++ this.decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }); + // Pieces appended since the last toString(), not yet folded into `string`. + this.pending = []; + this.string = ""; +@@ -62,7 +62,7 @@ export class BufferedString { + constructor(bufferSize) { + // fatal: true makes invalid byte sequences (e.g. a lead byte followed by a + // non-continuation byte) throw instead of silently decoding to U+FFFD. +- this.decoder = new TextDecoder("utf-8", { fatal: true }); ++ this.decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }); + this.bufferOffset = 0; + this.string = ""; + this.byteLength = 0; +diff --git a/src/utils/bufferedString.ts b/src/utils/bufferedString.ts +index 482c7402899bb157249cfb7882d327b7d9477912..4e45ef5d5bd7ec66776ece54e917282441cededb 100644 +--- a/src/utils/bufferedString.ts ++++ b/src/utils/bufferedString.ts +@@ -40,7 +40,7 @@ export interface StringBuilder { + export class NonBufferedString implements StringBuilder { + // fatal: true makes invalid byte sequences (e.g. a lead byte followed by a + // non-continuation byte) throw instead of silently decoding to U+FFFD. +- private decoder = new TextDecoder("utf-8", { fatal: true }); ++ private decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }); + // Pieces appended since the last toString(), not yet folded into `string`. + private pending: string[] = []; + private string = ""; +@@ -90,7 +90,7 @@ export class NonBufferedString implements StringBuilder { + export class BufferedString implements StringBuilder { + // fatal: true makes invalid byte sequences (e.g. a lead byte followed by a + // non-continuation byte) throw instead of silently decoding to U+FFFD. +- private decoder = new TextDecoder("utf-8", { fatal: true }); ++ private decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }); + private buffer: Uint8Array; + private bufferOffset = 0; + private string = ""; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9fb1428002d..2c58ae7a17a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -166,6 +166,7 @@ overrides: monaco-editor>dompurify: 3.4.16 patchedDependencies: + '@streamparser/json@0.0.26': b2cf43861e5b4e485e97ffa7449ea65acab4d65dd983ab8c0508f1c68f7d80c9 '@vscode/windows-process-tree@0.8.0': 9da74aa3d17243aa53dcdc95c9f06e97437e7fbccf098aeb017579e2d24cbac2 '@xterm/addon-image@0.10.0-beta.300': e5254a46d6f57bef4a8a19683bfa685afa0ca0127545aea53b54a48104ca3562 '@xterm/addon-ligatures@0.11.0-beta.300': 47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920 @@ -198,7 +199,7 @@ importers: version: 2.5.6 '@streamparser/json': specifier: 0.0.26 - version: 0.0.26 + version: 0.0.26(patch_hash=b2cf43861e5b4e485e97ffa7449ea65acab4d65dd983ab8c0508f1c68f7d80c9) '@xterm/addon-serialize': specifier: 0.15.0-beta.300 version: 0.15.0-beta.300(patch_hash=b35533fe252e7e45433150170348889f4e08a6c17f7017ac34ea694d831fec7f)(@xterm/xterm@6.1.0-beta.303(patch_hash=dd0ccc59cd1ccf99f4d76e5aa2456da165fa0804dce19a833d7638bd07ffa393)) @@ -10208,7 +10209,7 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@streamparser/json@0.0.26': {} + '@streamparser/json@0.0.26(patch_hash=b2cf43861e5b4e485e97ffa7449ea65acab4d65dd983ab8c0508f1c68f7d80c9)': {} '@swc/core-darwin-arm64@1.15.46': optional: true diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e3c133b29ee..f00e23d7f2f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -63,3 +63,4 @@ patchedDependencies: lint-staged@16.4.0: config/patches/lint-staged@16.4.0.patch '@vscode/windows-process-tree@0.8.0': config/patches/@vscode__windows-process-tree@0.8.0.patch i18next-cli@1.74.2: config/patches/i18next-cli@1.74.2.patch + '@streamparser/json@0.0.26': config/patches/@streamparser__json@0.0.26.patch diff --git a/src/main/ipc/filesystem-list-files-name-filter.test.ts b/src/main/ipc/filesystem-list-files-name-filter.test.ts index b529dd34b39..e22043a4d69 100644 --- a/src/main/ipc/filesystem-list-files-name-filter.test.ts +++ b/src/main/ipc/filesystem-list-files-name-filter.test.ts @@ -62,7 +62,7 @@ describe('listQuickOpenFiles name filter', () => { it('counts only matches against the ripgrep cap', async () => { wslAwareSpawnMock - .mockImplementationOnce(() => fakeRipgrep('a.ts\nb.ts\nc.ts\nios/AppDelegate.swift\n')) + .mockImplementationOnce(() => fakeRipgrep('a.ts\0b.ts\0c.ts\0ios/AppDelegate.swift\0')) .mockImplementationOnce(() => fakeRipgrep('')) const files = await listQuickOpenFiles( @@ -80,7 +80,7 @@ describe('listQuickOpenFiles name filter', () => { it('rejects with the bundled-ripgrep error when the filtered ignored pass cannot start', async () => { wslAwareSpawnMock - .mockImplementationOnce(() => fakeRipgrep('ios/AppDelegate.swift\n')) + .mockImplementationOnce(() => fakeRipgrep('ios/AppDelegate.swift\0')) .mockImplementationOnce(() => fakeRipgrep('', null, -2)) await expect( @@ -98,7 +98,7 @@ describe('listQuickOpenFiles name filter', () => { it('keeps primary matches when the ignored-file pass fails during a filtered scan', async () => { wslAwareSpawnMock - .mockImplementationOnce(() => fakeRipgrep('ios/AppDelegate.swift\n')) + .mockImplementationOnce(() => fakeRipgrep('ios/AppDelegate.swift\0')) .mockImplementationOnce(() => fakeRipgrep('', 'SIGKILL')) await expect( @@ -116,7 +116,7 @@ describe('listQuickOpenFiles name filter', () => { it('still rejects an ignored-pass failure for unfiltered listings', async () => { wslAwareSpawnMock - .mockImplementationOnce(() => fakeRipgrep('a.ts\n')) + .mockImplementationOnce(() => fakeRipgrep('a.ts\0')) .mockImplementationOnce(() => fakeRipgrep('', 'SIGKILL')) await expect( diff --git a/src/main/ipc/filesystem-list-files.test.ts b/src/main/ipc/filesystem-list-files.test.ts index 84567c85419..b6fe3453c22 100644 --- a/src/main/ipc/filesystem-list-files.test.ts +++ b/src/main/ipc/filesystem-list-files.test.ts @@ -88,6 +88,33 @@ describe('filesystem-list-files', () => { ) }) + it.each(['invalid', 'incomplete'] as const)('rejects %s UTF-8 filename bytes', async (kind) => { + const child = createMockProcess() + spawnMock.mockReturnValue(child) + const store: Store = Object.create(null) + const promise = listQuickOpenFiles('/repo', store) + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(1)) + child.stdout?.emit('data', Buffer.from(kind === 'invalid' ? [0xff] : [0xe2, 0x82])) + if (kind === 'incomplete') { + child.emit('close', 0, null) + } + await expect(promise).rejects.toThrow('not valid UTF-8') + if (kind === 'invalid') { + expect(child.kill).toHaveBeenCalled() + } + }) + + it('counts NUL-delimited filenames containing newlines as one result each', async () => { + const child = createMockProcess() + spawnMock.mockReturnValue(child) + const store: Store = Object.create(null) + const promise = listQuickOpenFiles('/repo', store, undefined, undefined, 2) + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(1)) + child.stdout?.emit('data', 'first\nsecond.ts\0trailing\r\0third.ts\0') + await expect(promise).resolves.toEqual(['first\nsecond.ts', 'trailing\r']) + expect(child.kill).toHaveBeenCalled() + }) + it('rejects a synchronous launch failure before cleanup has been initialized', async () => { spawnMock.mockImplementationOnce(() => { throw Object.assign(new Error('spawn EMFILE'), { code: 'EMFILE' }) @@ -122,7 +149,7 @@ describe('filesystem-list-files', () => { ) setTimeout(() => { - ;(p1.stdout as unknown as EventEmitter).emit('data', 'one.ts\ntwo.ts') + p1.stdout?.emit('data', 'one.ts\0two.ts') p1.emit('close', 0, null) }, 0) const result = await promise @@ -154,12 +181,12 @@ describe('filesystem-list-files', () => { await flushMicrotasks() expect(spawnMock).toHaveBeenCalledTimes(1) expect(spawnMock.mock.calls[0]?.[1]).not.toContain('--no-ignore-vcs') - source.stdout?.emit('data', 'source.ts\n') + source.stdout?.emit('data', 'source.ts\0') source.emit('close', 0, null) await flushMicrotasks() expect(spawnMock).toHaveBeenCalledTimes(2) expect(spawnMock.mock.calls[1]?.[1]).toContain('--no-ignore-vcs') - broad.stdout?.emit('data', 'ignored-file.ts\n') + broad.stdout?.emit('data', 'ignored-file.ts\0') await expect(listing).resolves.toEqual(['source.ts']) expect(broad.kill).toHaveBeenCalledOnce() }) @@ -177,18 +204,18 @@ describe('filesystem-list-files', () => { // Simulate stdout output for normal files setTimeout(() => { - p1.stdout?.emit('data', 'file1.ts\n') - p1.stdout?.emit('data', 'node_modules/bad.js\n') - p1.stdout?.emit('data', '.git/config\n') - p1.stdout?.emit('data', '.github/workflows/ci.yml\n') + p1.stdout?.emit('data', 'file1.ts\0') + p1.stdout?.emit('data', 'node_modules/bad.js\0') + p1.stdout?.emit('data', '.git/config\0') + p1.stdout?.emit('data', '.github/workflows/ci.yml\0') p1.stdout?.emit('data', 'dir1/') // incomplete line - p1.stdout?.emit('data', 'file2.js\n') + p1.stdout?.emit('data', 'file2.js\0') // The broad pass includes ignored files too. - p1.stdout?.emit('data', '.env.local\n') - p1.stdout?.emit('data', 'dist/generated.js\n') - p1.stdout?.emit('data', 'file1.ts\n') // Duplicate - p1.stdout?.emit('data', 'node_modules/ignored.js\n') + p1.stdout?.emit('data', '.env.local\0') + p1.stdout?.emit('data', 'dist/generated.js\0') + p1.stdout?.emit('data', 'file1.ts\0') // Duplicate + p1.stdout?.emit('data', 'node_modules/ignored.js\0') p1.emit('close', 0, null) }, 10) @@ -213,7 +240,7 @@ describe('filesystem-list-files', () => { const promise = listQuickOpenFiles('C:\\repo', storeMock) setTimeout(() => { - ;(p1.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\n') + p1.stdout?.emit('data', 'src/index.ts\0') p1.emit('close', 0, null) }, 10) @@ -237,7 +264,7 @@ describe('filesystem-list-files', () => { const promise = listQuickOpenFiles('C:\\repo', storeMock) setTimeout(() => { - ;(p1.stdout as unknown as EventEmitter).emit('data', '/mnt/c/repo/src/index.ts\n') + p1.stdout?.emit('data', '/mnt/c/repo/src/index.ts\0') p1.emit('close', 0, null) }, 10) @@ -310,7 +337,7 @@ describe('filesystem-list-files', () => { const promise = listQuickOpenFiles('/mock/root', storeMock) setTimeout(() => { - ;(p1.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\n') + p1.stdout?.emit('data', 'src/index.ts\0') p1.emit('close', 2, null) }, 10) @@ -332,7 +359,7 @@ describe('filesystem-list-files', () => { await Promise.resolve() await Promise.resolve() - ;(p1.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\npartial') + p1.stdout?.emit('data', 'src/index.ts\0partial') const rejection = expect(promise).rejects.toThrow('rg list timed out') await vi.advanceTimersByTimeAsync(10000) @@ -376,12 +403,12 @@ describe('filesystem-list-files', () => { const promise = listQuickOpenFiles('/mock/root', storeMock) setTimeout(() => { - ;(p1.stdout as unknown as EventEmitter).emit('data', '.next/cache/1.js\n') - ;(p1.stdout as unknown as EventEmitter).emit('data', '.cache/data.json\n') - ;(p1.stdout as unknown as EventEmitter).emit('data', '.stably/config.json\n') - ;(p1.stdout as unknown as EventEmitter).emit('data', '.vscode/settings.json\n') - ;(p1.stdout as unknown as EventEmitter).emit('data', '.idea/workspace.xml\n') - ;(p1.stdout as unknown as EventEmitter).emit('data', 'valid.ts\n') + p1.stdout?.emit('data', '.next/cache/1.js\0') + p1.stdout?.emit('data', '.cache/data.json\0') + p1.stdout?.emit('data', '.stably/config.json\0') + p1.stdout?.emit('data', '.vscode/settings.json\0') + p1.stdout?.emit('data', '.idea/workspace.xml\0') + p1.stdout?.emit('data', 'valid.ts\0') p1.emit('close', 0, null) }, 10) diff --git a/src/main/ipc/filesystem-list-files.ts b/src/main/ipc/filesystem-list-files.ts index 4e3ea49dbe8..9d9042584d9 100644 --- a/src/main/ipc/filesystem-list-files.ts +++ b/src/main/ipc/filesystem-list-files.ts @@ -1,3 +1,5 @@ +import { getQuickOpenRgOutputMode } from '../../shared/quick-open-ripgrep-output-mode' +import { RipgrepFilenameDecoder, RipgrepFilenameError } from '../../shared/ripgrep-filename-decoder' import { sep } from 'node:path' import type { ChildProcess } from 'node:child_process' import type { Store } from '../persistence' @@ -8,7 +10,6 @@ import { buildExcludePathPrefixes, buildRgArgsForQuickOpen, normalizeQuickOpenRgLine, - type RgOutputMode, shouldExcludeQuickOpenRelPath, shouldIncludeQuickOpenPath } from '../../shared/quick-open-filter' @@ -79,6 +80,10 @@ export async function listQuickOpenFiles( const runRg = (args: string[]): Promise => { return new Promise((resolve, reject) => { + const filenameDecoder = new RipgrepFilenameDecoder((error) => { + killSpawnedRipgrepProcess(child) + finish(error) + }, Boolean(wslDistroForOutput)) let buf = '' let done = false let parseablePathCount = 0 @@ -138,18 +143,22 @@ export async function listQuickOpenFiles( return } let timer: ReturnType - const handleStdoutData = (chunk: string): void => { - buf += chunk + const handleStdoutData = (chunk: Buffer | string): void => { + const decoded = filenameDecoder.decode(chunk) + if (decoded === null) { + return + } + buf += decoded let start = 0 - let newlineIdx = buf.indexOf('\n', start) - while (newlineIdx !== -1) { - if (processLine(buf.substring(start, newlineIdx))) { + let delimiterIdx = buf.indexOf('\0', start) + while (delimiterIdx !== -1) { + if (processLine(buf.substring(start, delimiterIdx))) { buf = '' finishAtLimit() return } - start = newlineIdx + 1 - newlineIdx = buf.indexOf('\n', start) + start = delimiterIdx + 1 + delimiterIdx = buf.indexOf('\0', start) } buf = start < buf.length ? buf.substring(start) : '' } @@ -211,14 +220,15 @@ export async function listQuickOpenFiles( finish(new Error(`rg killed by ${signal}`)) return } + if (!filenameDecoder.finish()) { + return + } if (buf && processLine(buf)) { buf = '' finishAtLimit() return } - if (code === 0 || code === 1) { - finish() - } else if (code === 2 && parseablePathCount > 0) { + if (code === 0 || code === 1 || (code === 2 && parseablePathCount > 0)) { // rg can return 2 for unreadable subdirectories while still listing // usable files from the rest of the root. finish() @@ -257,7 +267,6 @@ export async function listQuickOpenFiles( children.push({ child, isDone: () => done, finish }) - child.stdout?.setEncoding('utf-8') child.stdout?.on('data', handleStdoutData) child.stderr?.on('data', handleStderrData) child.once('error', handleError) @@ -290,16 +299,9 @@ export async function listQuickOpenFiles( } function finishAtLimit(): void { - for (const entry of children) { - if (entry.isDone()) { - continue - } - entry.finish() - if (entry.child.exitCode === null && entry.child.signalCode === null) { - killSpawnedRipgrepProcess(entry.child) - } - } + killSurvivors() } + try { if (maxResults === undefined && maxSerializedBytes === undefined) { // The broader pass already includes source files; an unbounded listing needs only one scan. @@ -314,7 +316,12 @@ export async function listQuickOpenFiles( ) { // Why: a filtered scan walks the whole tree; an ignored-pass timeout keeps primary matches. await runRg(ignoredPass).catch((err: unknown) => { - if (!pathFilter || signal?.aborted || err instanceof RipgrepUnavailableError) { + if ( + !pathFilter || + signal?.aborted || + err instanceof RipgrepUnavailableError || + err instanceof RipgrepFilenameError + ) { throw err } }) @@ -329,19 +336,3 @@ export async function listQuickOpenFiles( ? result : limitQuickOpenFilesBySerializedBytes(result, maxSerializedBytes) } - -function getQuickOpenRgOutputMode( - rawLine: string, - translatedLine: string, - rootPath: string -): RgOutputMode { - if ( - translatedLine !== rawLine || - rawLine.startsWith('/') || - /^[A-Za-z]:[\\/]/.test(rawLine) || - rawLine.startsWith('\\\\') - ) { - return { kind: 'absolute', rootPath } - } - return { kind: 'cwd-relative' } -} diff --git a/src/main/ipc/filesystem-search-file-paths.test.ts b/src/main/ipc/filesystem-search-file-paths.test.ts index e9415d66431..57c817c3b40 100644 --- a/src/main/ipc/filesystem-search-file-paths.test.ts +++ b/src/main/ipc/filesystem-search-file-paths.test.ts @@ -81,6 +81,64 @@ describe('searchQuickOpenFilePaths', () => { ) }) + it('preserves cancellation while an incomplete UTF-8 scalar is buffered', async () => { + const child = createMockProcess() + wslAwareSpawnMock.mockReturnValue(child) + const controller = new AbortController() + const promise = searchQuickOpenFilePaths('/repo', UNUSED_STORE, { + query: 'file', + limit: 2, + signal: controller.signal + }) + await flushMicrotasks() + child.stdout?.emit('data', Buffer.from([0xf0, 0x9f])) + controller.abort() + await expect(promise).rejects.toSatisfy(isFileListingCancellation) + }) + + it.each(['invalid', 'incomplete'] as const)('rejects %s UTF-8 filename bytes', async (kind) => { + const child = createMockProcess() + wslAwareSpawnMock.mockReturnValue(child) + const promise = searchQuickOpenFilePaths('/repo', UNUSED_STORE, { query: 'file', limit: 2 }) + await flushMicrotasks() + child.stdout?.emit('data', Buffer.from(kind === 'invalid' ? [0xff] : [0xe2, 0x82])) + if (kind === 'incomplete') { + child.emit('close', 0, null) + } + await expect(promise).rejects.toThrow('not valid UTF-8') + if (kind === 'invalid') { + expect(child.kill).toHaveBeenCalled() + } + }) + + it('preserves control characters within ranked paths', async () => { + const child = createMockProcess() + wslAwareSpawnMock.mockReturnValue(child) + const promise = searchQuickOpenFilePaths('/repo', UNUSED_STORE, { + query: 'target', + limit: 2 + }) + await flushMicrotasks() + child.stdout?.emit('data', 'first\ntarget.ts\0target.ts\r\0') + child.emit('close', 0, null) + expect((await promise).paths.sort()).toEqual(['first\ntarget.ts', 'target.ts\r'].sort()) + }) + + it('rejects and stops an oversized path rather than ranking a truncated suffix', async () => { + const child = createMockProcess() + wslAwareSpawnMock.mockReturnValue(child) + const promise = searchQuickOpenFilePaths('/repo', UNUSED_STORE, { + query: 'target', + limit: 2 + }) + await flushMicrotasks() + child.stdout?.emit('data', 'x'.repeat(64 * 1024 + 1)) + await expect(promise).rejects.toThrow('file path exceeds the listing limit') + expect(child.kill).toHaveBeenCalledTimes(1) + child.stdout?.emit('data', 'target.ts\0') + child.emit('close', 0, null) + }) + it('finds fuzzy matches after 100k paths without returning excluded worktrees', async () => { const child = createMockProcess() wslAwareSpawnMock.mockReturnValue(child) @@ -94,14 +152,11 @@ describe('searchQuickOpenFilePaths', () => { expect(wslAwareSpawnMock).toHaveBeenCalledTimes(1) expect(wslAwareSpawnMock.mock.calls[0][0]).toBe('/bundled/rg') expect(wslAwareSpawnMock.mock.calls[0][1]).toContain('--no-ignore-vcs') - ;(child.stdout as unknown as EventEmitter).emit( + child.stdout?.emit( 'data', - `${Array.from({ length: 100_100 }, (_, index) => `data/payload-${index}.bin`).join('\n')}\n` - ) - ;(child.stdout as unknown as EventEmitter).emit( - 'data', - 'nested/src/sta-4354-target.ts\nsrc/sta-4354-target.ts\n' + `${Array.from({ length: 100_100 }, (_, index) => `data/payload-${index}.bin`).join('\0')}\0` ) + child.stdout?.emit('data', 'nested/src/sta-4354-target.ts\0src/sta-4354-target.ts\0') child.emit('close', 0, null) await expect(promise).resolves.toEqual({ @@ -138,7 +193,7 @@ describe('searchQuickOpenFilePaths', () => { limit: 32 }) await flushMicrotasks() - ;(child.stdout as unknown as EventEmitter).emit('data', 'src/target.ts\n') + child.stdout?.emit('data', 'src/target.ts\0') child.emit('close', 0, null) await expect(promise).resolves.toMatchObject({ paths: ['src/target.ts'] }) @@ -164,10 +219,7 @@ describe('searchQuickOpenFilePaths', () => { await flushMicrotasks() expect(wslAwareSpawnMock).toHaveBeenCalledTimes(2) - ;(succeeded.stdout as unknown as EventEmitter).emit( - 'data', - 'data/chunk-077568/sta-4354-gitignored-target.bin\n' - ) + succeeded.stdout?.emit('data', 'data/chunk-077568/sta-4354-gitignored-target.bin\0') succeeded.emit('close', 0, null) await expect(promise).resolves.toEqual({ @@ -189,7 +241,7 @@ describe('searchQuickOpenFilePaths', () => { limit: 32 }) await flushMicrotasks() - ;(succeeded.stdout as unknown as EventEmitter).emit('data', 'src/target.ts\n') + succeeded.stdout?.emit('data', 'src/target.ts\0') succeeded.emit('close', 0, null) await expect(promise).resolves.toMatchObject({ paths: ['src/target.ts'] }) diff --git a/src/main/ipc/filesystem-search-file-paths.ts b/src/main/ipc/filesystem-search-file-paths.ts index 4a003aa17c5..3de6f4c6342 100644 --- a/src/main/ipc/filesystem-search-file-paths.ts +++ b/src/main/ipc/filesystem-search-file-paths.ts @@ -1,3 +1,5 @@ +import { getQuickOpenRgOutputMode } from '../../shared/quick-open-ripgrep-output-mode' +import { RipgrepFilenameDecoder } from '../../shared/ripgrep-filename-decoder' import { sep } from 'node:path' import type { Store } from '../persistence' import { fileListingCancellationError } from '../../shared/file-listing-cancellation' @@ -6,8 +8,7 @@ import { buildRgArgsForQuickOpen, normalizeQuickOpenRgLine, shouldExcludeQuickOpenRelPath, - shouldIncludeQuickOpenPath, - type RgOutputMode + shouldIncludeQuickOpenPath } from '../../shared/quick-open-filter' import { isQuickOpenQueryTooLarge, QuickOpenPathRanker } from '../../shared/quick-open-path-search' import { @@ -110,7 +111,11 @@ function scanRipgrepPaths(args: { return Promise.reject(fileListingCancellationError(args.signal)) } return new Promise((resolve, reject) => { - const pathAccumulator = new QuickOpenSubprocessPathAccumulator(0x0a) + const filenameDecoder = new RipgrepFilenameDecoder((error) => { + killSpawnedRipgrepProcess(child) + finish(error) + }, Boolean(args.wslDistroForOutput)) + const pathAccumulator = new QuickOpenSubprocessPathAccumulator(0) let done = false let parseablePathCount = 0 let processErrorObserved = false @@ -141,7 +146,7 @@ function scanRipgrepPaths(args: { : rawLine const relPath = normalizeQuickOpenRgLine( translated, - getOutputMode(rawLine, translated, args.authorizedRootPath) + getQuickOpenRgOutputMode(rawLine, translated, args.authorizedRootPath) ) if (relPath === null) { return @@ -178,11 +183,19 @@ function scanRipgrepPaths(args: { resolve() } } - const handleStdoutData = (chunk: string): void => { - pathAccumulator.push(chunk, (path) => { + const handleStdoutData = (chunk: Buffer | string): void => { + const decoded = filenameDecoder.decode(chunk) + if (decoded === null) { + return + } + const result = pathAccumulator.push(decoded, (path) => { processLine(path) return true }) + if (result === 'path-too-large') { + killSpawnedRipgrepProcess(child) + finish(new Error('Quick Open file path exceeds the listing limit')) + } } const handleStderrData = (): void => { /* drain */ @@ -233,6 +246,9 @@ function scanRipgrepPaths(args: { finish(new Error(`rg killed by ${signal}`)) return } + if (!filenameDecoder.finish()) { + return + } const trailingPath = pathAccumulator.finish() if (trailingPath) { processLine(trailingPath) @@ -249,7 +265,6 @@ function scanRipgrepPaths(args: { finish(fileListingCancellationError(args.signal)) } - child.stdout?.setEncoding('utf-8') child.stdout?.on('data', handleStdoutData) child.stderr?.on('data', handleStderrData) child.once('error', handleError) @@ -265,12 +280,3 @@ function scanRipgrepPaths(args: { } }) } - -function getOutputMode(rawLine: string, translatedLine: string, rootPath: string): RgOutputMode { - return translatedLine !== rawLine || - rawLine.startsWith('/') || - /^[A-Za-z]:[\\/]/.test(rawLine) || - rawLine.startsWith('\\\\') - ? { kind: 'absolute', rootPath } - : { kind: 'cwd-relative' } -} diff --git a/src/main/ipc/filesystem-search-rg-timeout.test.ts b/src/main/ipc/filesystem-search-rg-timeout.test.ts index 9c37bacb20a..d4096500381 100644 --- a/src/main/ipc/filesystem-search-rg-timeout.test.ts +++ b/src/main/ipc/filesystem-search-rg-timeout.test.ts @@ -199,7 +199,7 @@ describe('filesystem rg search timeout', () => { } ) - it('keeps post-spawn errors on the existing empty-result path', async () => { + it('rejects post-spawn errors instead of returning an empty result', async () => { const child = createMockProcess() Object.defineProperty(child, 'pid', { value: 1 }) wslAwareSpawnMock.mockReturnValue(child) @@ -212,7 +212,7 @@ describe('filesystem rg search timeout', () => { await flushMicrotasks() child.emit('error', new Error('post-spawn failure')) - await expect(promise).resolves.toMatchObject({ files: [] }) + await expect(promise).rejects.toThrow('post-spawn failure') }) // Why close(97): the WSL wrapper's "cd failed" code. It is above rg's own 0/1/2, so a handler @@ -298,6 +298,34 @@ describe('filesystem rg search timeout', () => { expect(wslAwareSpawnMock.mock.calls[0]?.[0]).toBe('/bundled/linux/rg') }) + it('marks WSL filenames that UNC cannot represent as incomplete', async () => { + const child = createMockProcess() + wslAwareSpawnMock.mockReturnValue(child) + getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({ wslDistro: 'Ubuntu' }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: all store access is mocked for this handler. + registerFilesystemHandlers({} as never) + const promise = handlers.get('fs:search')!( + { sender: { id: 7 } }, + { rootPath: 'C:\\repo', query: 'hello' } + ) + await flushMicrotasks() + child.stdout?.emit( + 'data', + `${JSON.stringify({ + type: 'match', + data: { + path: { text: './a\\b.txt' }, + lines: { text: 'hello\n' }, + line_number: 1, + submatches: [{ start: 0, end: 5 }] + } + })}\n` + ) + child.emit('close', 0, null) + await expect(promise).resolves.toMatchObject({ files: [], truncated: true }) + expect(toWindowsWslPathMock).not.toHaveBeenCalled() + }) + it('translates WSL rg output for Windows-path project search results', async () => { const child = createMockProcess() wslAwareSpawnMock.mockReturnValue(child) diff --git a/src/main/ipc/filesystem/filesystem-search-handlers.ts b/src/main/ipc/filesystem/filesystem-search-handlers.ts index 9212200e151..94cc07a2f1f 100644 --- a/src/main/ipc/filesystem/filesystem-search-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-search-handlers.ts @@ -1,3 +1,4 @@ +import { RipgrepSearchDiagnostics } from '../../../shared/ripgrep-search-diagnostics' import { SearchSubprocessLineAccumulator } from '../../../shared/search-subprocess-lines' import { ipcMain } from 'electron' import type { ChildProcess } from 'node:child_process' @@ -65,7 +66,7 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte const wslDistroForOutput = parseWslPath(rootPath)?.distro ?? localGitOptions.wslDistro return new Promise((resolvePromise, rejectPromise) => { - const rgArgs = buildRgArgs(args.query, rootPath, args) + const rgArgs = buildRgArgs(args.query, '.', args) // Why: kill the prior rg so it stops parsing thousands of matches on the main thread (the large-repo freeze) after the UI moved on. const previousChild = activeTextSearches.get(searchKey) if (previousChild) { @@ -73,7 +74,8 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte } const acc = createAccumulator() - const lines = new SearchSubprocessLineAccumulator(Number.MAX_SAFE_INTEGER) + const lines = new SearchSubprocessLineAccumulator() + const diagnostics = new RipgrepSearchDiagnostics() let resolved = false let processErrorObserved = false let unavailableExitObserved = false @@ -81,8 +83,12 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte let killTimeout: ReturnType const transformAbsPath = wslDistroForOutput - ? (path: string): string => - path.startsWith('/') ? toWindowsWslPath(path, wslDistroForOutput) : path + ? (path: string): string | null => + path.includes('\\') + ? null + : path.startsWith('/') + ? toWindowsWslPath(path, wslDistroForOutput) + : path : undefined const finish = (result: SearchResult | PromiseLike): void => { @@ -108,7 +114,10 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte } resolvePromise(result) } - const resolveOnce = (): void => finish(finalize(acc)) + const resolveOnce = (code = 0, signal: NodeJS.Signals | null = null): void => { + const error = diagnostics.failure(code, signal, acc) + finish(error ? Promise.reject(error) : finalize(acc)) + } const rejectUnavailable = (): void => finish(Promise.reject(bundledRipgrepUnavailableError())) const processLine = (line: string): void => { @@ -138,10 +147,16 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte activeTextSearches.set(searchKey, nextChild) const handleStdoutData = (chunk: string): void => { - lines.push(chunk, processLine) + if (!lines.push(chunk, processLine)) { + acc.truncated = true + if (child) { + killSpawnedRipgrepProcess(child) + } + resolveOnce() + } } - const handleStderrData = (): void => { - // Drain stderr so rg cannot block on a full pipe. + const handleStderrData = (chunk: Buffer): void => { + diagnostics.append(chunk) } const handleError = (error: NodeJS.ErrnoException): void => { processErrorObserved = true @@ -151,18 +166,12 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte return } if (child && isRipgrepUnavailableExit(child, null, null)) { - // Why the cwd check first: spawn reports a missing cwd as ENOENT too, and blaming the - // binary for it tells the user to reinstall Orca over a workspace that simply moved. - // Why detach close first: a failed spawn emits error THEN close(code < 0), and - // close settles synchronously, so this probe would otherwise race it on a sub-ms - // margin -- two measurements disagreed on which wins. Detaching makes it deterministic. + // Distinguish a missing workspace from a missing binary before close can settle. child.off('close', handleClose) - // Why catch: a failed probe must not strand the search; fall back to the prior verdict. void isRipgrepSpawnCwdUsable(rootPath) .catch(() => true) .then((usable) => { - // Why re-check: finish() drops its argument once settled, so a rejected promise - // built after the close handler already won would go unhandled. + // A late rejected promise must not escape after close settles the search. if (resolved) { return } @@ -174,7 +183,10 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte }) return } - resolveOnce() + finish(Promise.reject(error)) + if (child) { + killSpawnedRipgrepProcess(child) + } } const handleClose = (code: number | null, signal: NodeJS.Signals | null): void => { // Why first: this code is above rg's own 0/1/2, so the unavailable check would otherwise @@ -193,11 +205,11 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte rejectUnavailable() return } - const tail = lines.finish() + const tail = !signal && (code === 0 || code === 1) ? lines.finish() : null if (tail !== null) { processLine(tail) } - resolveOnce() + resolveOnce(code ?? -1, signal) } nextChild.stdout?.setEncoding('utf-8') diff --git a/src/main/ipc/markdown-documents-remote-paths.test.ts b/src/main/ipc/markdown-documents-remote-paths.test.ts new file mode 100644 index 00000000000..0d5dc66983a --- /dev/null +++ b/src/main/ipc/markdown-documents-remote-paths.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it, vi } from 'vitest' +import type * as NodePath from 'node:path' + +vi.mock('node:path', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, extname: actual.win32.extname } +}) + +import { isMarkdownDocumentName, markdownDocumentFromRelativePath } from './markdown-documents' + +describe('remote Markdown filenames on a Windows client', () => { + it('keeps the local filename helper on Windows semantics', () => { + expect(isMarkdownDocumentName('notes\\.md')).toBe(false) + expect(isMarkdownDocumentName('notes.md\\')).toBe(true) + }) + + it.each(['notes\\.md', 'a\\b.MDX', '..\\a.markdown', 'nested/README.md'])( + 'preserves POSIX filename %s and its extension', + (relativePath) => { + const basename = relativePath.slice(relativePath.lastIndexOf('/') + 1) + expect(markdownDocumentFromRelativePath('/home/repo', relativePath)).toEqual({ + filePath: `/home/repo/${relativePath}`, + relativePath, + basename, + name: basename.slice(0, basename.lastIndexOf('.')) + }) + } + ) + + it.each(['notes.md\\', 'nested/.md', '../outside.md'])( + 'rejects non-Markdown or escaping POSIX filename %s', + (relativePath) => { + expect(markdownDocumentFromRelativePath('/home/repo', relativePath)).toBeNull() + } + ) + + it('normalizes Windows remote separators before reading the basename', () => { + expect(markdownDocumentFromRelativePath('C:\\repo', 'notes\\README.MD')).toEqual({ + filePath: 'C:\\repo/notes/README.MD', + relativePath: 'notes/README.MD', + basename: 'README.MD', + name: 'README' + }) + expect(markdownDocumentFromRelativePath('C:\\repo', 'notes\\.md')).toBeNull() + expect(markdownDocumentFromRelativePath('C:\\repo', '..\\outside.md')).toBeNull() + }) +}) diff --git a/src/main/ipc/markdown-documents-ripgrep.test.ts b/src/main/ipc/markdown-documents-ripgrep.test.ts index daee482bae1..13f48b78e17 100644 --- a/src/main/ipc/markdown-documents-ripgrep.test.ts +++ b/src/main/ipc/markdown-documents-ripgrep.test.ts @@ -47,6 +47,24 @@ describe('Markdown document ripgrep lifecycle', () => { expect(child.listenerCount('close')).toBe(0) }) + it('preserves timeout when an incomplete UTF-8 scalar is abandoned', async () => { + vi.useFakeTimers() + const result = listMarkdownDocuments(root) + const outcome = expect(result).rejects.toThrow('timed out') + child.stdout.write(Buffer.from([0xf0, 0x9f])) + await vi.advanceTimersByTimeAsync(15_000) + await outcome + }) + + it.each(['invalid', 'incomplete'] as const)('rejects %s UTF-8 filename bytes', async (kind) => { + const result = listMarkdownDocuments(root) + child.stdout.write(Buffer.from(kind === 'invalid' ? [0xff] : [0xe2, 0x82])) + if (kind === 'incomplete') { + child.emit('close', 0, null) + } + await expect(result).rejects.toThrow('not valid UTF-8') + }) + it('accepts an empty listing', async () => { const result = listMarkdownDocuments(root) child.emit('close', 1, null) diff --git a/src/main/ipc/markdown-documents.test.ts b/src/main/ipc/markdown-documents.test.ts index b23f5748cb4..698935b1f62 100644 --- a/src/main/ipc/markdown-documents.test.ts +++ b/src/main/ipc/markdown-documents.test.ts @@ -1,7 +1,41 @@ import { describe, expect, it } from 'vitest' -import { markdownDocumentFromFilePath } from './markdown-documents' +import { + markdownDocumentFromFilePath, + markdownDocumentFromRelativePath +} from './markdown-documents' describe('markdownDocumentFromFilePath', () => { + it.skipIf(process.platform === 'win32')('preserves local literal backslash names', () => { + expect(markdownDocumentFromFilePath('/repo\\', '/repo\\/a\\b.md')).toMatchObject({ + filePath: '/repo\\/a\\b.md', + relativePath: 'a\\b.md', + basename: 'a\\b.md' + }) + }) + + it('preserves POSIX remote filenames and trailing root backslashes', () => { + for (const name of ['a\\b.md', 'a/b.md', '..\\a.md']) { + expect(markdownDocumentFromRelativePath('/repo\\', name)).toMatchObject({ + filePath: `/repo\\/${name}`, + relativePath: name, + basename: name.slice(name.lastIndexOf('/') + 1) + }) + } + expect(markdownDocumentFromRelativePath('/repo\\', '../a.md')).toBeNull() + }) + + it.each(['C:\\repo\\', '\\\\server\\share\\repo\\'])( + 'preserves Windows separator handling under %s', + (root) => { + expect(markdownDocumentFromRelativePath(root, 'a\\b.md')).toMatchObject({ + filePath: `${root.slice(0, -1)}/a/b.md`, + relativePath: 'a/b.md', + basename: 'b.md' + }) + expect(markdownDocumentFromRelativePath(root, '..\\a.md')).toBeNull() + } + ) + it('keeps in-root path segments that merely start with parent traversal text', () => { expect(markdownDocumentFromFilePath('/workspace', '/workspace/..notes/file.md')).toMatchObject({ filePath: '/workspace/..notes/file.md', diff --git a/src/main/ipc/markdown-documents.ts b/src/main/ipc/markdown-documents.ts index 9bf263d56d4..1f457dba97e 100644 --- a/src/main/ipc/markdown-documents.ts +++ b/src/main/ipc/markdown-documents.ts @@ -1,4 +1,15 @@ -import { basename as pathBasename, extname, isAbsolute, join, relative, resolve } from 'node:path' +import { RipgrepFilenameDecoder } from '../../shared/ripgrep-filename-decoder' +import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' +import { normalizeRelativePath } from '../../shared/text-search-paths' +import { + basename as pathBasename, + extname, + isAbsolute, + join, + posix, + relative, + resolve +} from 'node:path' import type { FileDocument, MarkdownDocument } from '../../shared/filesystem-entry-types' import { spawnBundledRipgrep } from '../ripgrep/bundled-ripgrep-spawn' import { parseWslPath } from '../wsl' @@ -7,36 +18,34 @@ import { ripgrepMissingCwdError } from '../../shared/ripgrep-process-availability' -function normalizeRelativePath(path: string): string { - return path.replace(/[\\/]+/g, '/').replace(/^\/+/, '') +export function isMarkdownDocumentName(name: string): boolean { + return isMarkdownExtension(extname(name)) } -export function isMarkdownDocumentName(name: string): boolean { - const extension = extname(name).toLowerCase() - return extension === '.md' || extension === '.mdx' || extension === '.markdown' +function isMarkdownExtension(extension: string): boolean { + const normalized = extension.toLowerCase() + return normalized === '.md' || normalized === '.mdx' || normalized === '.markdown' } function basenameFromRelativePath(relativePath: string): string { - const normalizedPath = relativePath.replaceAll('\\', '/') - return normalizedPath.slice(normalizedPath.lastIndexOf('/') + 1) + return relativePath.slice(relativePath.lastIndexOf('/') + 1) } function isSafeRelativePath(relativePath: string): boolean { return !relativePath.split('/').includes('..') } -function hasParentTraversalSegment(relativePath: string): boolean { - return relativePath.split(/[\\/]+/).includes('..') -} - function rootRelativePath(rootPath: string, filePath: string): string | null { const resolvedRoot = resolve(rootPath) const resolvedFile = resolve(filePath) const relativePath = relative(resolvedRoot, resolvedFile) - if (hasParentTraversalSegment(relativePath) || isAbsolute(relativePath)) { + if ( + !isSafeRelativePath(normalizeRelativePath(relativePath, rootPath)) || + isAbsolute(relativePath) + ) { return null } - return normalizeRelativePath(relativePath) + return normalizeRelativePath(relativePath, rootPath) } export function fileDocumentFromFilePath( @@ -50,7 +59,7 @@ export function fileDocumentFromFilePath( rootRelativePath(rootPath, filePath) ?? (options.outsideRootRelativePath === 'basename' ? basename - : normalizeRelativePath(relative(rootPath, filePath))) + : normalizeRelativePath(relative(rootPath, filePath), rootPath)) return { filePath, relativePath, @@ -65,18 +74,22 @@ export function markdownDocumentFromRelativePath( rootPath: string, relativePath: string ): MarkdownDocument | null { - const normalizedRelativePath = normalizeRelativePath(relativePath) + const normalizedRelativePath = normalizeRelativePath(relativePath, rootPath) // Why: SSH providers should return root-relative paths; reject escape // segments before building a synthetic absolute path for renderer use. if (!isSafeRelativePath(normalizedRelativePath)) { return null } const basename = basenameFromRelativePath(normalizedRelativePath) - if (!isMarkdownDocumentName(basename)) { + // Remote separators are already normalized; a POSIX backslash stays part of the name. + const extension = posix.extname(basename) + if (!isMarkdownExtension(extension)) { return null } - const extension = extname(basename) - const normalizedRoot = rootPath.replace(/[\\/]+$/, '') + const normalizedRoot = rootPath.replace( + isWindowsAbsolutePathLike(rootPath) ? /[\\/]+$/ : /\/+$/, + '' + ) return { filePath: `${normalizedRoot}/${normalizedRelativePath}`, relativePath: normalizedRelativePath, @@ -131,6 +144,10 @@ export async function listMarkdownDocuments( ) return new Promise((resolveListing, reject) => { + const filenameDecoder = new RipgrepFilenameDecoder( + (error) => finish(error), + Boolean(parseWslPath(rootPath)?.distro ?? options.wslDistro) + ) const documents: MarkdownDocument[] = [] let carry = '' let stderr = '' @@ -172,8 +189,12 @@ export async function listMarkdownDocuments( const onStderr = (chunk: string): void => { stderr = (stderr + chunk).slice(0, 4096) } - const onData = (chunk: string): void => { - carry += chunk + const onData = (chunk: Buffer | string): void => { + const decoded = filenameDecoder.decode(chunk) + if (decoded === null) { + return + } + carry += decoded let start = 0 let end: number while ((end = carry.indexOf('\0', start)) !== -1) { @@ -201,10 +222,11 @@ export async function listMarkdownDocuments( finish(ripgrepMissingCwdError(rootPath)) } else if (signal || (code !== 0 && code !== 1)) { finish(new Error(`Markdown document listing failed (${signal ?? code}): ${stderr.trim()}`)) - } else if (carry) { - finish(new Error('Incomplete path in Markdown document listing')) } else { - finish() + if (!filenameDecoder.finish()) { + return + } + finish(carry ? new Error('Incomplete path in Markdown document listing') : undefined) } } const timer = setTimeout( @@ -212,7 +234,6 @@ export async function listMarkdownDocuments( MARKDOWN_LISTING_TIMEOUT_MS ) timer.unref?.() - child.stdout?.setEncoding('utf8') child.stderr?.setEncoding('utf8') child.stdout?.on('data', onData) child.stderr?.on('data', onStderr) diff --git a/src/main/ripgrep/ripgrep-filename-identity-real.test.ts b/src/main/ripgrep/ripgrep-filename-identity-real.test.ts new file mode 100644 index 00000000000..6fc7116bd7d --- /dev/null +++ b/src/main/ripgrep/ripgrep-filename-identity-real.test.ts @@ -0,0 +1,62 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { spawnBundledRipgrep } from './bundled-ripgrep-spawn' +import { buildRgArgsForQuickOpen, normalizeQuickOpenRgLine } from '../../shared/quick-open-filter' +import { buildRgArgs, createAccumulator, ingestRgJsonLine } from '../../shared/text-search' +import { joinWorktreeRelativePath } from '../runtime/runtime-relative-paths' + +async function capture(root: string, args: string[]): Promise { + const child = spawnBundledRipgrep(args, { cwd: root, stdio: ['ignore', 'pipe', 'pipe'] }) + let output = '' + child.stdout?.setEncoding('utf8').on('data', (chunk: string) => { + output += chunk + }) + child.stderr?.resume() + await new Promise((resolve, reject) => { + child.once('error', reject) + child.once('close', (code) => (code === 0 ? resolve() : reject(new Error(`rg exit ${code}`)))) + }) + return output +} + +describe.skipIf(process.platform === 'win32')('real ripgrep POSIX filename identities', () => { + it('lists, searches, and opens literal-backslash and nested names independently', async () => { + const parent = await mkdtemp(join(tmpdir(), 'orca-rg-identity-')) + const root = join(parent, 'repo\\root') + try { + await mkdir(join(root, 'a'), { recursive: true }) + await writeFile(join(root, 'a\\b.txt'), 'needle literal') + await writeFile(join(root, 'a/b.txt'), 'needle nested') + const args = buildRgArgsForQuickOpen({ + searchRoot: '.', + excludePathPrefixes: [], + forceSlashSeparator: false + }) + const listing = await capture(root, args.primary) + const names = listing + .split('\0') + .filter(Boolean) + .map((line) => normalizeQuickOpenRgLine(line, { kind: 'cwd-relative' })) + expect(names.sort()).toEqual(['a/b.txt', 'a\\b.txt'].sort()) + for (const name of names) { + expect(name).not.toBeNull() + if (name === null) { + throw new Error('invalid name') + } + expect(await readFile(joinWorktreeRelativePath(root, name), 'utf8')).toBe( + name === 'a\\b.txt' ? 'needle literal' : 'needle nested' + ) + } + const acc = createAccumulator() + for (const line of (await capture(root, buildRgArgs('needle', '.', {}))).split('\n')) { + ingestRgJsonLine(line, root, acc, 10) + } + expect([...acc.fileMap.values()].map((file) => file.relativePath).sort()).toEqual(names) + expect(acc.truncated).toBe(false) + } finally { + await rm(parent, { recursive: true, force: true }) + } + }) +}) diff --git a/src/main/ripgrep/text-search-unicode-real.test.ts b/src/main/ripgrep/text-search-unicode-real.test.ts new file mode 100644 index 00000000000..b56c1794a19 --- /dev/null +++ b/src/main/ripgrep/text-search-unicode-real.test.ts @@ -0,0 +1,123 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { spawnBundledRipgrep } from './bundled-ripgrep-spawn' +import { + buildRgArgs, + createAccumulator, + finalize, + ingestRgJsonLine +} from '../../shared/text-search' + +describe('ripgrep Unicode match coordinates', () => { + let root: string + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-rg-unicode-')) + }) + afterEach(async () => { + vi.unstubAllEnvs() + await rm(root, { recursive: true, force: true }) + }) + + async function search( + query: string, + useRegex = false, + includePattern?: string, + excludePattern?: string + ) { + const child = spawnBundledRipgrep( + buildRgArgs(query, '.', { useRegex, includePattern, excludePattern }), + { + cwd: root, + stdio: ['ignore', 'pipe', 'pipe'] + } + ) + let output = '' + child.stdout?.setEncoding('utf8').on('data', (chunk: string) => { + output += chunk + }) + child.stderr?.resume() + await new Promise((resolve, reject) => { + child.once('error', reject) + child.once('close', (code) => + code === 0 || code === 1 ? resolve() : reject(new Error(`rg exit ${code}`)) + ) + }) + const acc = createAccumulator() + for (const line of output.split('\n')) { + ingestRgJsonLine(line, root, acc, 2000) + } + return finalize(acc) + } + + it('uses UTF16 columns and lengths for multibyte prefixes and astral matches', async () => { + await writeFile(join(root, 'example.txt'), '😀 café 日本 needle 😀 needle\r\n') + const result = await search('needle|😀', true) + expect( + result.files[0]?.matches.map(({ column, matchLength }) => [column, matchLength]) + ).toEqual([ + [1, 2], + [12, 6], + [19, 2], + [22, 6] + ]) + }) + + it.each(['src/**', '/src/**'])('honors root-relative include glob %s', async (includePattern) => { + await mkdir(join(root, 'src')) + await mkdir(join(root, 'other')) + await writeFile(join(root, 'src', 'match.txt'), 'needle') + await writeFile(join(root, 'src', 'skip.txt'), 'needle') + await writeFile(join(root, 'other', 'match.txt'), 'needle') + const result = await search('needle', false, includePattern, '/src/skip.txt') + expect(result.files.map((file) => [file.filePath, file.relativePath])).toEqual([ + [join(root, 'src', 'match.txt'), 'src/match.txt'] + ]) + }) + + it('keeps navigation and clamped display coordinates aligned', async () => { + const prefix = '日本😀'.repeat(200) + await writeFile(join(root, 'long.txt'), `${prefix}needle`) + const match = (await search('needle')).files[0]?.matches[0] + expect(match?.column).toBe(prefix.length + 1) + expect( + match?.lineContent.slice( + (match.displayColumn ?? 1) - 1, + (match.displayColumn ?? 1) - 1 + match.matchLength + ) + ).toBe('needle') + }) + + it('decodes malformed UTF8 context and maps matches using the original bytes', async () => { + const bytes = Buffer.concat([ + Buffer.from('😀 '), + Buffer.from([0xe2, 0x82, 0xff]), + Buffer.from(' needle é needle\r\n') + ]) + await writeFile(join(root, 'malformed.txt'), bytes) + const content = bytes.toString('utf8').replace(/\n$/, '') + const result = await search('needle') + expect(result.totalMatches).toBe(2) + expect( + result.files[0]?.matches.map((match) => [match.column, match.matchLength, match.lineContent]) + ).toEqual([ + [content.indexOf('needle') + 1, 6, content], + [content.lastIndexOf('needle') + 1, 6, content] + ]) + expect(result.truncated).toBe(false) + }) + + it('ignores external rg config while retaining workspace ignore files', async () => { + const config = join(root, 'config') + await writeFile(config, '--invert-match\n') + vi.stubEnv('RIPGREP_CONFIG_PATH', config) + await writeFile(join(root, '.ignore'), 'ignored.txt\n') + await writeFile(join(root, 'ignored.txt'), 'needle\n') + await mkdir(join(root, 'docs')) + await writeFile(join(root, 'docs', 'visible.txt'), 'needle\nother\n') + const result = await search('needle') + expect(result.files.map((file) => file.relativePath)).toEqual(['docs/visible.txt']) + expect(result.files[0]?.matches[0]?.lineContent).toBe('needle') + }) +}) diff --git a/src/main/runtime/ripgrep-filename-identity.test.ts b/src/main/runtime/ripgrep-filename-identity.test.ts new file mode 100644 index 00000000000..b8ef50a76b6 --- /dev/null +++ b/src/main/runtime/ripgrep-filename-identity.test.ts @@ -0,0 +1,82 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { joinWorktreeRelativePath, normalizeRuntimeRelativePath } from './runtime-relative-paths' +import { buildExcludePathPrefixes, normalizeQuickOpenRgLine } from '../../shared/quick-open-filter' +import { createAccumulator, ingestRgJsonLine } from '../../shared/text-search' + +it.each(['/native/repo\\root', '/ssh/repo\\root'])( + 'preserves POSIX search identities under %s independently of client platform', + (root) => { + const acc = createAccumulator() + for (const name of ['a\\b.txt', 'a/b.txt']) { + const relative = normalizeQuickOpenRgLine(`./${name}`, { kind: 'cwd-relative' }) + expect(relative).toBe(name) + expect( + normalizeQuickOpenRgLine(`${root}/${name}`, { kind: 'absolute', rootPath: root }) + ).toBe(name) + expect(joinWorktreeRelativePath(root, normalizeRuntimeRelativePath(name, root))).toBe( + `${root}/${name}` + ) + ingestRgJsonLine( + JSON.stringify({ + type: 'match', + data: { + path: { text: `${root}/${name}` }, + lines: { text: 'needle' }, + line_number: 1, + submatches: [{ start: 0, end: 6 }] + } + }), + root, + acc, + 10 + ) + } + expect([...acc.fileMap.values()].map((file) => file.relativePath)).toEqual([ + 'a\\b.txt', + 'a/b.txt' + ]) + expect(buildExcludePathPrefixes(root, [`${root}/a\\b`])).toEqual(['a\\b']) + } +) + +it.each(['C:\\repo', '\\\\server\\share\\repo'])( + 'preserves Windows separator compatibility under %s', + (root) => { + expect(joinWorktreeRelativePath(root, normalizeRuntimeRelativePath('a\\b.txt', root))).toBe( + `${root}\\a\\b.txt` + ) + expect( + normalizeQuickOpenRgLine(`${root}\\a\\b.txt`, { kind: 'absolute', rootPath: root }) + ).toBe('a/b.txt') + } +) + +describe.skipIf(process.platform === 'win32')('real POSIX filename collision', () => { + it('opens both literal-backslash and nested files without changing identity', async () => { + const parent = await mkdtemp(join(tmpdir(), 'orca-rg-path-')) + const root = join(parent, 'repo\\root') + try { + await mkdir(join(root, 'a'), { recursive: true }) + await writeFile(join(root, 'a\\b.txt'), 'literal') + await writeFile(join(root, 'a/b.txt'), 'nested') + for (const [name, expected] of [ + ['a\\b.txt', 'literal'], + ['a/b.txt', 'nested'] + ]) { + const relative = normalizeQuickOpenRgLine(`./${name}`, { kind: 'cwd-relative' }) + expect(relative).toBe(name) + expect( + await readFile( + joinWorktreeRelativePath(root, normalizeRuntimeRelativePath(name, root)), + 'utf8' + ) + ).toBe(expected) + } + } finally { + await rm(parent, { recursive: true, force: true }) + } + }) +}) diff --git a/src/main/runtime/runtime-file-commands-search-local-runtime-files.ts b/src/main/runtime/runtime-file-commands-search-local-runtime-files.ts index 8ed435fddc7..83e30a15f47 100644 --- a/src/main/runtime/runtime-file-commands-search-local-runtime-files.ts +++ b/src/main/runtime/runtime-file-commands-search-local-runtime-files.ts @@ -1,4 +1,5 @@ // @ts-nocheck -- mechanically split class members. +import { RipgrepSearchDiagnostics } from '../../shared/ripgrep-search-diagnostics' import { SearchSubprocessLineAccumulator } from '../../shared/search-subprocess-lines' import { RuntimeFileCommandsWithSearchRuntimeFiles } from './runtime-file-commands-search-runtime-files' import type { SearchOptions, SearchResult } from '../../shared/code-search-types' @@ -50,20 +51,26 @@ export class RuntimeFileCommandsWithSearchLocalRuntimeFiles extends RuntimeFileC return new Promise((resolvePromise, rejectPromise) => { const searchKey = `${this.host.getRuntimeId()}:${authorizedRootPath}` - const rgArgs = buildRgArgs(options.query, authorizedRootPath, options) + const rgArgs = buildRgArgs(options.query, '.', options) const previousChild = this.activeRuntimeTextSearches.get(searchKey) if (previousChild) { killSpawnedRipgrepProcess(previousChild) } const acc = createAccumulator() - const lines = new SearchSubprocessLineAccumulator(Number.MAX_SAFE_INTEGER) + const lines = new SearchSubprocessLineAccumulator() + const diagnostics = new RipgrepSearchDiagnostics() let resolved = false let processErrorObserved = false let unavailableExitObserved = false let child: ChildProcessHandle | null = null const transformAbsPath = wslDistroForOutput - ? (p: string): string => (p.startsWith('/') ? toWindowsWslPath(p, wslDistroForOutput) : p) + ? (p: string): string | null => + p.includes('\\') + ? null + : p.startsWith('/') + ? toWindowsWslPath(p, wslDistroForOutput) + : p : undefined const finish = (result: SearchResult | PromiseLike): void => { @@ -77,7 +84,10 @@ export class RuntimeFileCommandsWithSearchLocalRuntimeFiles extends RuntimeFileC cleanupListeners() resolvePromise(result) } - const resolveOnce = (): void => finish(finalize(acc)) + const resolveOnce = (code = 0, signal: NodeJS.Signals | null = null): void => { + const error = diagnostics.failure(code, signal, acc) + finish(error ? Promise.reject(error) : finalize(acc)) + } const rejectUnavailable = (): void => finish(Promise.reject(bundledRipgrepUnavailableError())) let killTimeout: ReturnType | null = null @@ -133,10 +143,16 @@ export class RuntimeFileCommandsWithSearchLocalRuntimeFiles extends RuntimeFileC nextChild.stdout?.setEncoding('utf-8') const onStdoutData = (chunk: string): void => { - lines.push(chunk, processLine) + if (!lines.push(chunk, processLine)) { + acc.truncated = true + if (child) { + killSpawnedRipgrepProcess(child) + } + resolveOnce() + } } - const onStderrData = (): void => { - // Drain stderr so rg cannot block on a full pipe. + const onStderrData = (chunk: Buffer): void => { + diagnostics.append(chunk) } const onError = (error: NodeJS.ErrnoException): void => { processErrorObserved = true @@ -171,7 +187,10 @@ export class RuntimeFileCommandsWithSearchLocalRuntimeFiles extends RuntimeFileC }) return } - resolveOnce() + finish(Promise.reject(error)) + if (child) { + killSpawnedRipgrepProcess(child) + } } const onClose = (code: number | null, signal: NodeJS.Signals | null): void => { // Why first: this code is above rg's own 0/1/2, so the unavailable check would otherwise @@ -190,11 +209,11 @@ export class RuntimeFileCommandsWithSearchLocalRuntimeFiles extends RuntimeFileC rejectUnavailable() return } - const tail = lines.finish() + const tail = !signal && (code === 0 || code === 1) ? lines.finish() : null if (tail !== null) { processLine(tail) } - resolveOnce() + resolveOnce(code ?? -1, signal) } nextChild.stdout?.on('data', onStdoutData) @@ -229,7 +248,7 @@ export class RuntimeFileCommandsWithSearchLocalRuntimeFiles extends RuntimeFileC worktree: target.worktree, path: joinWorktreeRelativePath( target.worktree.path, - normalizeRuntimeRelativePath(relativePath) + normalizeRuntimeRelativePath(relativePath, target.worktree.path) ), executionHostId: target.executionHostId })) diff --git a/src/main/runtime/runtime-relative-paths.ts b/src/main/runtime/runtime-relative-paths.ts index 1ec252e24f2..1f5ab8e71cb 100644 --- a/src/main/runtime/runtime-relative-paths.ts +++ b/src/main/runtime/runtime-relative-paths.ts @@ -2,15 +2,21 @@ import { posix, win32 } from 'node:path' import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' export function joinWorktreeRelativePath(rootPath: string, relativePath: string): string { - const normalizedRelativePath = relativePath.replace(/\\/g, '/') + const normalizedRelativePath = isWindowsAbsolutePathLike(rootPath) + ? relativePath.replace(/\\/g, '/') + : relativePath if (isWindowsAbsolutePathLike(rootPath)) { return win32.join(rootPath.replace(/\//g, '\\'), ...normalizedRelativePath.split('/')) } return posix.join(rootPath, ...normalizedRelativePath.split('/')) } -export function normalizeRuntimeRelativePath(relativePath: string): string { - const normalized = relativePath.replace(/\\/g, '/').replace(/\/+$/, '') +export function normalizeRuntimeRelativePath(relativePath: string, rootPath?: string): string { + const path = + rootPath !== undefined && !isWindowsAbsolutePathLike(rootPath) + ? relativePath + : relativePath.replace(/\\/g, '/') + const normalized = path.replace(/\/+$/, '') if (normalized === '') { return '' } diff --git a/src/relay/fs-handler-list-files-cancel.test.ts b/src/relay/fs-handler-list-files-cancel.test.ts index 97bea1b0813..b6829921cb4 100644 --- a/src/relay/fs-handler-list-files-cancel.test.ts +++ b/src/relay/fs-handler-list-files-cancel.test.ts @@ -52,7 +52,7 @@ describe('relay list-files cancellation', () => { const promise = listFilesWithRg('/remote/root', [], { signal: controller.signal }) // Partial output before the abort — must be discarded, not resolved. - ignoredProc.stdout?.emit('data', 'src/index.ts\n') + ignoredProc.stdout?.emit('data', 'src/index.ts\0') controller.abort() await expect(promise).rejects.toSatisfy(isFileListingCancellation) @@ -81,8 +81,8 @@ describe('relay list-files cancellation', () => { const promise = listFilesWithRg('/remote/root', [], { signal: controller.signal }) setTimeout(() => { - ignoredProc.stdout?.emit('data', 'src/index.ts\n') - ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'dist/out.js\n') + ignoredProc.stdout?.emit('data', 'src/index.ts\0') + ignoredProc.stdout?.emit('data', 'dist/out.js\0') ignoredProc.emit('close', 0, null) }, 5) diff --git a/src/relay/fs-handler-list-files-ignored.test.ts b/src/relay/fs-handler-list-files-ignored.test.ts index 3f8b8c2b883..36a3914d021 100644 --- a/src/relay/fs-handler-list-files-ignored.test.ts +++ b/src/relay/fs-handler-list-files-ignored.test.ts @@ -71,6 +71,21 @@ describe('relay quick open ignored file listing', () => { await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))) }) + it.each(['invalid', 'incomplete'] as const)('rejects %s UTF-8 filename bytes', async (kind) => { + const child = createMockProcess() + spawnMock.mockReturnValue(child) + const promise = listFilesWithRg('/remote/root') + + child.stdout?.emit('data', Buffer.from(kind === 'invalid' ? [0xff] : [0xe2, 0x82])) + if (kind === 'incomplete') { + child.emit('close', 0, null) + } + await expect(promise).rejects.toThrow('not valid UTF-8') + if (kind === 'invalid') { + expect(child.kill).toHaveBeenCalled() + } + }) + it('uses one broad rg pass for unbounded listings and keeps blocklists/excludes', async () => { const ignoredProc = createMockProcess() @@ -80,10 +95,10 @@ describe('relay quick open ignored file listing', () => { expect(spawnMock).toHaveBeenCalledTimes(1) setTimeout(() => { - ignoredProc.stdout?.emit('data', 'src/index.ts\n') - ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'dist/generated.js\n') - ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'node_modules/pkg/index.js\n') - ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'packages/other/src/x.ts\n') + ignoredProc.stdout?.emit('data', 'src/index.ts\0') + ignoredProc.stdout?.emit('data', 'dist/generated.js\0') + ignoredProc.stdout?.emit('data', 'node_modules/pkg/index.js\0') + ignoredProc.stdout?.emit('data', 'packages/other/src/x.ts\0') ignoredProc.emit('close', 0, null) }, 10) @@ -106,10 +121,7 @@ describe('relay quick open ignored file listing', () => { spawnMock.mockImplementation(() => (++callIndex === 1 ? primaryProc : ignoredProc)) const promise = listFilesWithRg('/remote/root', [], { maxResults: 2 }) - ;(primaryProc.stdout as unknown as EventEmitter).emit( - 'data', - 'src/one.ts\nsrc/two.ts\nsrc/three.ts\n' - ) + primaryProc.stdout?.emit('data', 'src/one.ts\0src/two.ts\0src/three.ts\0') await expect(promise).resolves.toEqual(['src/one.ts', 'src/two.ts']) expect(primaryProc.kill).toHaveBeenCalled() @@ -127,14 +139,11 @@ describe('relay quick open ignored file listing', () => { expect(spawnMock).toHaveBeenCalledTimes(1) expect(spawnMock.mock.calls[0][1]).toContain('--no-ignore-vcs') - ;(ignoredProc.stdout as unknown as EventEmitter).emit( + ignoredProc.stdout?.emit( 'data', - `${Array.from({ length: 100_100 }, (_, index) => `data/payload-${index}.bin`).join('\n')}\n` - ) - ;(ignoredProc.stdout as unknown as EventEmitter).emit( - 'data', - 'src/components/target.ts\nscripts/check-target.ts\n' + `${Array.from({ length: 100_100 }, (_, index) => `data/payload-${index}.bin`).join('\0')}\0` ) + ignoredProc.stdout?.emit('data', 'src/components/target.ts\0scripts/check-target.ts\0') ignoredProc.emit('close', 0, null) await expect(promise).resolves.toEqual(['scripts/check-target.ts', 'src/components/target.ts']) @@ -148,11 +157,11 @@ describe('relay quick open ignored file listing', () => { const promise = listFilesWithRg('/remote/root', [], { maxResults: 2 }) expect(spawnMock).toHaveBeenCalledTimes(1) expect(spawnMock.mock.calls[0][1]).not.toContain('--no-ignore-vcs') - primary.stdout?.emit('data', 'src/index.ts\n') + primary.stdout?.emit('data', 'src/index.ts\0') primary.emit('close', 0, null) await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(2)) expect(spawnMock.mock.calls[1][1]).toContain('--no-ignore-vcs') - broad.stdout?.emit('data', 'src/index.ts\ndist/generated.js\ndist/extra.js\n') + broad.stdout?.emit('data', 'src/index.ts\0dist/generated.js\0dist/extra.js\0') await expect(promise).resolves.toEqual(['src/index.ts', 'dist/generated.js']) expect(broad.kill).toHaveBeenCalled() @@ -168,14 +177,11 @@ describe('relay quick open ignored file listing', () => { maxResults: 32, searchQuery: 'target' }) - ;(failed.stdout as unknown as EventEmitter).emit('data', 'src/target.ts\n') + failed.stdout?.emit('data', 'src/target.ts\0') failed.emit('error', Object.assign(new Error('spawn rg EAGAIN'), { code: 'EAGAIN' })) await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(2)) - ;(succeeded.stdout as unknown as EventEmitter).emit( - 'data', - 'src/target.ts\nsrc/another-target.ts\n' - ) + succeeded.stdout?.emit('data', 'src/target.ts\0src/another-target.ts\0') succeeded.emit('close', 0, null) await expect(promise).resolves.toEqual(['src/target.ts', 'src/another-target.ts']) @@ -193,7 +199,7 @@ describe('relay quick open ignored file listing', () => { searchQuery: 'target' }) await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(2)) - ;(succeeded.stdout as unknown as EventEmitter).emit('data', 'src/target.ts\n') + succeeded.stdout?.emit('data', 'src/target.ts\0') succeeded.emit('close', 0, null) await expect(promise).resolves.toEqual(['src/target.ts']) @@ -209,7 +215,7 @@ describe('relay quick open ignored file listing', () => { failed.emit('error', Object.assign(new Error('spawn rg EAGAIN'), { code: 'EAGAIN' })) await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(2)) - succeeded.stdout?.emit('data', 'src/index.ts\ndist/generated.js\n') + succeeded.stdout?.emit('data', 'src/index.ts\0dist/generated.js\0') succeeded.emit('close', 0, null) await expect(promise).resolves.toEqual(['src/index.ts', 'dist/generated.js']) @@ -305,18 +311,12 @@ describe('relay quick open ignored file listing', () => { const promise = listFilesWithGit(root, ['packages/other']) setTimeout(() => { - ;(primaryProc.stdout as unknown as EventEmitter).emit( - 'data', - `${staged('100644', 'src/index.ts')}\0` - ) - ;(primaryProc.stdout as unknown as EventEmitter).emit( - 'data', - `${staged('100644', 'tab\tfile.txt')}\0` - ) + primaryProc.stdout?.emit('data', `${staged('100644', 'src/index.ts')}\0`) + primaryProc.stdout?.emit('data', `${staged('100644', 'tab\tfile.txt')}\0`) primaryProc.emit('close', 0, null) - ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'dist/\0') - ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'packages/other/src/x.ts\0') + ignoredProc.stdout?.emit('data', 'dist/\0') + ignoredProc.stdout?.emit('data', 'packages/other/src/x.ts\0') ignoredProc.emit('close', 0, null) }, 10) @@ -346,7 +346,7 @@ describe('relay quick open ignored file listing', () => { spawnMock.mockImplementation(() => (++callIndex === 1 ? primaryProc : ignoredProc)) const promise = listFilesWithGit('/remote/root', [], { maxResults: 2 }) - ;(primaryProc.stdout as unknown as EventEmitter).emit('data', 'src/one.ts\0src/two.ts') + primaryProc.stdout?.emit('data', 'src/one.ts\0src/two.ts') primaryProc.emit('close', 0, null) await expect(promise).resolves.toEqual(['src/one.ts', 'src/two.ts']) expect(primaryProc.kill).toHaveBeenCalled() @@ -359,10 +359,7 @@ describe('relay quick open ignored file listing', () => { spawnMock.mockReturnValue(primaryProc) const promise = listFilesWithGit('/remote/root', [], { maxResults: 1 }) - ;(primaryProc.stdout as unknown as EventEmitter).emit( - 'data', - `discarded/\0${staged('100644', 'src/kept.ts')}\0` - ) + primaryProc.stdout?.emit('data', `discarded/\0${staged('100644', 'src/kept.ts')}\0`) await expect(promise).resolves.toEqual(['src/kept.ts']) expect(primaryProc.kill).toHaveBeenCalled() @@ -389,7 +386,7 @@ describe('relay quick open ignored file listing', () => { const promise = listFilesWithGit(root) setTimeout(() => { - ;(primaryProc.stdout as unknown as EventEmitter).emit( + primaryProc.stdout?.emit( 'data', `${staged('100644', 'README.md')}\0${staged('160000', 'packages/app')}\0packages/lib/\0` ) @@ -419,11 +416,11 @@ describe('relay quick open ignored file listing', () => { const promise = listFilesWithGit('/remote/root') setTimeout(() => { - ;(primaryProc.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\0') + primaryProc.stdout?.emit('data', 'src/index.ts\0') primaryProc.emit('close', 0, null) // Entries streamed before the kill are kept alongside the primary pass. - ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'dist/generated.js\0') + ignoredProc.stdout?.emit('data', 'dist/generated.js\0') ignoredProc.emit('close', null, 'SIGTERM') }, 10) @@ -449,7 +446,7 @@ describe('relay quick open ignored file listing', () => { const promise = listFilesWithGit('/remote/root') setTimeout(() => { - ;(primaryProc.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\0') + primaryProc.stdout?.emit('data', 'src/index.ts\0') primaryProc.emit('close', 0, null) ignoredProc.emit('close', 128, null) @@ -475,10 +472,10 @@ describe('relay quick open ignored file listing', () => { const promise = listFilesWithGit('/remote/root') setTimeout(() => { - ;(primaryProc.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\0') + primaryProc.stdout?.emit('data', 'src/index.ts\0') primaryProc.emit('close', null, 'SIGTERM') - ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'dist/generated.js\0') + ignoredProc.stdout?.emit('data', 'dist/generated.js\0') ignoredProc.emit('close', 0, null) }, 10) @@ -500,7 +497,7 @@ describe('relay quick open ignored file listing', () => { setTimeout(() => { primaryProc.emit('close', 128, null) - ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'dist/generated.js\0') + ignoredProc.stdout?.emit('data', 'dist/generated.js\0') ignoredProc.emit('close', 0, null) }, 10) @@ -733,12 +730,12 @@ describe('relay quick open ignored file listing', () => { expect(() => probe.emit('error', error)).not.toThrow() }) - it('keeps post-spawn rg search errors on the existing empty-result path', async () => { + it('rejects post-spawn rg search errors instead of returning an empty result', async () => { const started = createMockProcess() Object.defineProperty(started, 'pid', { value: 1 }) spawnMock.mockReturnValueOnce(started) const ordinaryFailure = searchWithRg('/remote/root', 'ok', { maxResults: 100 }) started.emit('error', new Error('post-spawn failure')) - await expect(ordinaryFailure).resolves.toMatchObject({ files: [], totalMatches: 0 }) + await expect(ordinaryFailure).rejects.toThrow('post-spawn failure') }) }) diff --git a/src/relay/fs-handler-list-files-path-framing.test.ts b/src/relay/fs-handler-list-files-path-framing.test.ts new file mode 100644 index 00000000000..037bbc97a41 --- /dev/null +++ b/src/relay/fs-handler-list-files-path-framing.test.ts @@ -0,0 +1,57 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { rgPath } from '@vscode/ripgrep-universal' +import { configureRelayBundledRipgrep } from './relay-bundled-ripgrep' +import { listFilesWithRg } from './fs-handler-list-files' + +let root: string | undefined + +afterEach(async () => { + configureRelayBundledRipgrep(undefined) + vi.unstubAllEnvs() + if (root) { + await rm(root, { recursive: true, force: true }) + } +}) + +it('ignores user rg configuration when listing files', async () => { + root = await mkdtemp(join(tmpdir(), 'orca-rg-config-')) + configureRelayBundledRipgrep(rgPath) + const config = join(root, 'config') + await writeFile(config, '--glob\n!*.ts\n') + await writeFile(join(root, 'visible.ts'), '') + vi.stubEnv('RIPGREP_CONFIG_PATH', config) + + expect(await listFilesWithRg(root)).toContain('visible.ts') + expect(await listFilesWithRg(root, [], { maxResults: 10 })).toContain('visible.ts') +}) + +it.skipIf(process.platform === 'win32')( + 'preserves newline, carriage return and Unicode filenames', + async () => { + root = await mkdtemp(join(tmpdir(), 'orca-rg-filenames-')) + configureRelayBundledRipgrep(rgPath) + const names = ['first\nsecond.ts', 'trailing\r', 'résumé-😀.ts', 'spaces .ts '] + const fixtureRoot = root + await Promise.all(names.map((name) => writeFile(join(fixtureRoot, name), ''))) + + expect((await listFilesWithRg(root)).sort()).toEqual([...names].sort()) + expect((await listFilesWithRg(root, [], { maxResults: 10 })).sort()).toEqual([...names].sort()) + expect(await listFilesWithRg(root, [], { searchQuery: 'second', maxResults: 1 })).toEqual([ + 'first\nsecond.ts' + ]) + } +) + +it.skipIf(process.platform !== 'linux')( + 'rejects real non-UTF8 filenames instead of fabricating paths', + async () => { + root = await mkdtemp(join(tmpdir(), 'orca-rg-invalid-name-')) + configureRelayBundledRipgrep(rgPath) + const invalidPath = Buffer.concat([Buffer.from(join(root, 'bad-')), Buffer.from([0xff])]) + await writeFile(invalidPath, '') + await expect(listFilesWithRg(root)).rejects.toThrow('not valid UTF-8') + } +) diff --git a/src/relay/fs-handler-list-files.ts b/src/relay/fs-handler-list-files.ts index 506693fa0c8..ca25cd85e9e 100644 --- a/src/relay/fs-handler-list-files.ts +++ b/src/relay/fs-handler-list-files.ts @@ -1,3 +1,4 @@ +import { RipgrepFilenameDecoder } from '../shared/ripgrep-filename-decoder' /** * Ripgrep-based file listing for Quick Open. * Why a full rewrite vs. the older execFile+maxBuffer version: on a home-dir @@ -97,6 +98,10 @@ export function listFilesWithRg( new Promise((passResolve, passReject) => { const attemptRanker = searchQuery === undefined ? null : new QuickOpenPathRanker(searchQuery, maxResults ?? 16) + const filenameDecoder = new RipgrepFilenameDecoder((error) => { + killSpawnedRipgrepProcess(child) + rejectPass(error) + }) let passBuf = '' let passDone = false let passFileCount = 0 @@ -126,11 +131,9 @@ export function listFilesWithRg( ) : error } - let timer: ReturnType | null = null const cleanup = (): void => { if (timer) { clearTimeout(timer) - timer = null } child.stdout?.off('data', handleStdoutData) child.stderr?.off('data', handleStderrData) @@ -188,17 +191,21 @@ export function listFilesWithRg( } children.push({ child, isDone: () => passDone, reject: rejectPass }) - timer = setTimeout(() => { + const timer = setTimeout(() => { // Discard residual buffer on abnormal exit — a truncated byte // sequence could look like a valid path. killSpawnedRipgrepProcess(child) rejectPass(new Error('rg list timed out')) }, LIST_FILES_TIMEOUT_MS) - function handleStdoutData(chunk: string): void { - passBuf += chunk + function handleStdoutData(chunk: Buffer | string): void { + const decoded = filenameDecoder.decode(chunk) + if (decoded === null) { + return + } + passBuf += decoded let start = 0 - let idx = passBuf.indexOf('\n', start) + let idx = passBuf.indexOf('\0', start) while (idx !== -1) { if (processLine(passBuf.substring(start, idx), attemptRanker)) { passFileCount++ @@ -207,7 +214,7 @@ export function listFilesWithRg( return } start = idx + 1 - idx = passBuf.indexOf('\n', start) + idx = passBuf.indexOf('\0', start) } passBuf = start < passBuf.length ? passBuf.substring(start) : '' } @@ -248,6 +255,9 @@ export function listFilesWithRg( rejectPass(new Error(`rg killed by ${signal}`)) return } + if (!filenameDecoder.finish()) { + return + } // Flush residual line only on clean exit. if (passBuf) { if (processLine(passBuf, attemptRanker)) { @@ -259,16 +269,13 @@ export function listFilesWithRg( // (e.g. EACCES on .ssh), but rg also returns 2 for fatal errors // (bad flag, invalid glob). Only trust exit 2 when rg emitted at // least one parseable path — otherwise treat it as a real failure. - if (code === 0 || code === 1) { - resolvePass() - } else if (code === 2 && passFileCount > 0) { + if (code === 0 || code === 1 || (code === 2 && passFileCount > 0)) { resolvePass() } else { rejectPass(new Error(`rg exited with code ${code}`)) } } - child.stdout?.setEncoding('utf-8') child.stdout?.on('data', handleStdoutData) child.stderr?.on('data', handleStderrData) child.once('error', handleError) diff --git a/src/relay/fs-handler-utils.ts b/src/relay/fs-handler-utils.ts index a3b2234ced0..ec2715c9b1a 100644 --- a/src/relay/fs-handler-utils.ts +++ b/src/relay/fs-handler-utils.ts @@ -1,3 +1,4 @@ +import { RipgrepSearchDiagnostics } from '../shared/ripgrep-search-diagnostics' /** * Pure helpers and child-process search utilities extracted from fs-handler.ts. * @@ -111,18 +112,15 @@ export function searchWithRg( return Promise.reject(abortSignalReason(signal)) } return new Promise((resolve, reject) => { - const rgArgs = buildRgArgs(query, rootPath, opts) + const rgArgs = buildRgArgs(query, '.', opts) const acc = createAccumulator() - const lines = new SearchSubprocessLineAccumulator(Number.MAX_SAFE_INTEGER) + const lines = new SearchSubprocessLineAccumulator() + const diagnostics = new RipgrepSearchDiagnostics() let resolved = false let processErrorObserved = false let unavailableExitObserved = false let launchFailureCheck: Promise | null = null - // Why: spawn can throw synchronously on invalid options (e.g. bad cwd), - // which would leak out of the `new Promise` executor and leave the - // promise forever pending. Treat a synchronous throw as a clean - // "no results" fallback, the same way an async 'error' event is handled. const resolvedRgCommand = resolveRelayRipgrepCommand() // Why not spawn a bare name when this is null: on Windows CreateProcessW searches the spawn // cwd -- the user's repo -- before PATH, so a planted rg.exe would run instead. @@ -142,8 +140,8 @@ export function searchWithRg( env, stdio: ['ignore', 'pipe', 'pipe'] }) - } catch { - resolve(finalize(acc)) + } catch (error) { + reject(error) return } @@ -181,9 +179,14 @@ export function searchWithRg( } } - function resolveOnce(): void { + function resolveOnce(code = 0, signal: NodeJS.Signals | null = null): void { if (settle()) { - resolve(finalize(acc)) + const error = diagnostics.failure(code, signal, acc) + if (error) { + reject(error) + } else { + resolve(finalize(acc)) + } } } @@ -235,11 +238,15 @@ export function searchWithRg( } function handleStdoutData(chunk: string): void { - lines.push(chunk, processLine) + if (!lines.push(chunk, processLine)) { + acc.truncated = true + killSpawnedRipgrepProcess(child) + resolveOnce() + } } - function handleStderrData(): void { - /* drain */ + function handleStderrData(chunk: Buffer): void { + diagnostics.append(chunk) } function handleError(error: Error): void { @@ -248,7 +255,10 @@ export function searchWithRg( settleLaunchFailure(error) return } - resolveOnce() + if (settle()) { + reject(error) + } + killSpawnedRipgrepProcess(child) } function handleClose(code: number | null, signal: NodeJS.Signals | null): void { @@ -261,11 +271,11 @@ export function searchWithRg( settleLaunchFailure() return } - const tail = lines.finish() + const tail = !signal && (code === 0 || code === 1) ? lines.finish() : null if (tail !== null) { processLine(tail) } - resolveOnce() + resolveOnce(code ?? -1, signal) } child.stdout?.setEncoding('utf-8') diff --git a/src/relay/fs-search-errors-real.test.ts b/src/relay/fs-search-errors-real.test.ts new file mode 100644 index 00000000000..eaf7fb9633c --- /dev/null +++ b/src/relay/fs-search-errors-real.test.ts @@ -0,0 +1,39 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it } from 'vitest' +import { rgPath } from '@vscode/ripgrep-universal' +import { configureRelayBundledRipgrep } from './relay-bundled-ripgrep' +import { searchWithRg } from './fs-handler-utils' + +let root: string +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-search-errors-')) + configureRelayBundledRipgrep(rgPath) + await writeFile(join(root, 'source.txt'), 'needle\n') +}) +afterEach(async () => { + configureRelayBundledRipgrep(undefined) + await rm(root, { recursive: true, force: true }) +}) + +it('reports an invalid regular expression as an error', async () => { + await expect(searchWithRg(root, '[', { useRegex: true, maxResults: 100 })).rejects.toThrow( + /regex parse error|unclosed character class/ + ) +}) + +it('distinguishes no matches from invalid syntax', async () => { + await expect(searchWithRg(root, 'absent', { maxResults: 100 })).resolves.toEqual({ + files: [], + totalMatches: 0, + truncated: false + }) +}) + +it('retains intentional capped results after stopping the process', async () => { + await writeFile(join(root, 'source.txt'), 'needle\n'.repeat(1000)) + const result = await searchWithRg(root, 'needle', { maxResults: 2 }) + expect(result.totalMatches).toBe(2) + expect(result.truncated).toBe(true) +}) diff --git a/src/relay/relay-bundled-ripgrep.test.ts b/src/relay/relay-bundled-ripgrep.test.ts index 5f667ad1429..93c4e56490f 100644 --- a/src/relay/relay-bundled-ripgrep.test.ts +++ b/src/relay/relay-bundled-ripgrep.test.ts @@ -104,7 +104,7 @@ describe('relay bundled ripgrep', () => { return child } const child = createProcess(42) - succeedWith(child, 'src/index.ts\n') + succeedWith(child, 'src/index.ts\0') return child }) diff --git a/src/relay/relay-bundled-ripgrep.ts b/src/relay/relay-bundled-ripgrep.ts index 787176e3f42..5c2dd89f3be 100644 --- a/src/relay/relay-bundled-ripgrep.ts +++ b/src/relay/relay-bundled-ripgrep.ts @@ -4,12 +4,13 @@ * the answer whenever that binary is absent or cannot launch on this host. */ import { existsSync, statSync } from 'node:fs' -import { delimiter, join, win32 } from 'node:path' +import { win32 } from 'node:path' import { isRipgrepSpawnCwdUsable, isTransientRipgrepSpawnError } from '../shared/ripgrep-process-availability' import { relayLogLine } from './relay-diagnostic-log' +import { buildRelayCommandEnv } from './relay-command-env' export const PATH_RIPGREP_COMMAND = 'rg' @@ -31,13 +32,13 @@ export function isDriveRootedWindowsPath(dir: string): boolean { // Why only rg.exe, though libuv honours %PATHEXT%: `.bat`/`.cmd` shims are not spawnable without // `shell: true`, and every ripgrep installer (winget, choco, scoop, cargo) lays down rg.exe. -function resolveWindowsPathRipgrep(): string | null { - for (const entry of (process.env.PATH ?? '').split(delimiter)) { +function resolveWindowsPathRipgrep(path: string): string | null { + for (const entry of path.split(';')) { const dir = entry.replace(/^"|"$/g, '').trim() if (!dir || !isDriveRootedWindowsPath(dir)) { continue } - const candidate = join(dir, 'rg.exe') + const candidate = win32.join(dir, 'rg.exe') try { if (statSync(candidate).isFile()) { return candidate @@ -49,7 +50,8 @@ function resolveWindowsPathRipgrep(): string | null { return null } -let windowsPathRipgrep: string | null | undefined +let windowsPathRipgrep: { path: string; command: string | null; expiresAt: number } | undefined +const WINDOWS_PATH_MISS_RETRY_MS = 60_000 let bundledRipgrepPath: string | null = null // Why a back-off, not forever: Windows AV often locks a just-installed rg.exe for its first spawns. @@ -69,13 +71,23 @@ export function pathRipgrepCommand(): string | null { if (process.platform !== 'win32') { return PATH_RIPGREP_COMMAND } - // Why the explicit undefined check and not `??=`: a miss resolves to null, which is nullish, so - // `??=` would re-walk every PATH entry on each call -- and a miss is the expensive case, since - // it stats every directory instead of stopping at the first hit. - if (windowsPathRipgrep === undefined) { - windowsPathRipgrep = resolveWindowsPathRipgrep() + const env = buildRelayCommandEnv() + const path = env.PATH ?? env.Path ?? '' + const now = Date.now() + // Cache the effective PATH; retry misses so an install can become visible without restarting. + if ( + !windowsPathRipgrep || + windowsPathRipgrep.path !== path || + now >= windowsPathRipgrep.expiresAt + ) { + const command = resolveWindowsPathRipgrep(path) + windowsPathRipgrep = { + path, + command, + expiresAt: command === null ? now + WINDOWS_PATH_MISS_RETRY_MS : Infinity + } } - return windowsPathRipgrep + return windowsPathRipgrep.command } /** Null means this host has no usable rg, so the caller falls back to git/readdir. */ diff --git a/src/relay/relay-ripgrep-cwd-env.test.ts b/src/relay/relay-ripgrep-cwd-env.test.ts index 38734ca63e6..c30590b4ad1 100644 --- a/src/relay/relay-ripgrep-cwd-env.test.ts +++ b/src/relay/relay-ripgrep-cwd-env.test.ts @@ -16,7 +16,7 @@ describe.runIf(process.platform !== 'win32')( writeFileSync(join(dir, 'found.txt'), 'needle') const cargo = join(dir, 'cargo') mkdirSync(join(cargo, 'bin'), { recursive: true }) - writeFileSync(join(cargo, 'bin', 'rg'), '#!/bin/sh\necho found.txt\n', { mode: 0o755 }) + writeFileSync(join(cargo, 'bin', 'rg'), "#!/bin/sh\nprintf 'found.txt\\0'\n", { mode: 0o755 }) vi.stubEnv('PATH', join(dir, 'empty-path')) vi.stubEnv('CARGO_HOME', cargo) configureRelayBundledRipgrep(undefined) diff --git a/src/relay/relay-windows-path-ripgrep-cache.test.ts b/src/relay/relay-windows-path-ripgrep-cache.test.ts new file mode 100644 index 00000000000..bea305bd741 --- /dev/null +++ b/src/relay/relay-windows-path-ripgrep-cache.test.ts @@ -0,0 +1,79 @@ +import type * as fs from 'node:fs' +import { statSync } from 'node:fs' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { pathRipgrepCommand, resetRelayRipgrepPathCacheForTests } from './relay-bundled-ripgrep' + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, statSync: vi.fn(actual.statSync) } +}) + +const originalPlatform = process.platform +const present = new Set() +const statMock = vi.mocked(statSync) +const fileStats = statSync(new URL(import.meta.url)) + +describe('Windows relay ripgrep effective PATH cache', () => { + beforeEach(() => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + vi.stubEnv('Path', undefined) + vi.stubEnv('PATH', 'C:\\tools') + vi.stubEnv('CARGO_HOME', 'C:\\cargo') + vi.useFakeTimers() + vi.setSystemTime(1000) + present.clear() + resetRelayRipgrepPathCacheForTests() + statMock.mockImplementation((path) => { + if (!present.has(String(path))) { + throw Object.assign(new Error('missing'), { code: 'ENOENT' }) + } + return fileStats + }) + statMock.mockClear() + }) + + afterEach(() => { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + vi.unstubAllEnvs() + vi.useRealTimers() + statMock.mockReset() + resetRelayRipgrepPathCacheForTests() + }) + + it('finds rg in the Cargo fallback appended to the command environment', () => { + present.add('C:\\cargo\\bin\\rg.exe') + expect(pathRipgrepCommand()).toBe('C:\\cargo\\bin\\rg.exe') + const calls = statMock.mock.calls.length + expect(pathRipgrepCommand()).toBe('C:\\cargo\\bin\\rg.exe') + expect(statMock).toHaveBeenCalledTimes(calls) + }) + + it('supports mixed-case Path and never probes cwd-relative entries', () => { + vi.stubEnv('PATH', undefined) + vi.stubEnv('Path', '.;node_modules\\.bin;\\tools;C:tools;C:\\safe') + present.add('C:\\safe\\rg.exe') + expect(pathRipgrepCommand()).toBe('C:\\safe\\rg.exe') + expect(statMock.mock.calls.map(([path]) => path)).toEqual(['C:\\safe\\rg.exe']) + }) + + it('invalidates both successful and missing resolutions when effective PATH changes', () => { + expect(pathRipgrepCommand()).toBeNull() + vi.stubEnv('CARGO_HOME', 'D:\\cargo') + present.add('D:\\cargo\\bin\\rg.exe') + expect(pathRipgrepCommand()).toBe('D:\\cargo\\bin\\rg.exe') + vi.stubEnv('PATH', 'E:\\tools') + present.add('E:\\tools\\rg.exe') + expect(pathRipgrepCommand()).toBe('E:\\tools\\rg.exe') + }) + + it('retries cached misses after a bounded delay without rescanning on each call', () => { + expect(pathRipgrepCommand()).toBeNull() + const calls = statMock.mock.calls.length + present.add('C:\\tools\\rg.exe') + vi.advanceTimersByTime(59_999) + expect(pathRipgrepCommand()).toBeNull() + expect(statMock).toHaveBeenCalledTimes(calls) + vi.advanceTimersByTime(1) + expect(pathRipgrepCommand()).toBe('C:\\tools\\rg.exe') + }) +}) diff --git a/src/relay/relay-windows-path-ripgrep.test.ts b/src/relay/relay-windows-path-ripgrep.test.ts index a6dee0e14e8..aa8601fdab5 100644 --- a/src/relay/relay-windows-path-ripgrep.test.ts +++ b/src/relay/relay-windows-path-ripgrep.test.ts @@ -1,7 +1,7 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { delimiter, join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { configureRelayBundledRipgrep, isDriveRootedWindowsPath, @@ -9,6 +9,9 @@ import { resetRelayRipgrepPathCacheForTests } from './relay-bundled-ripgrep' +// These tests isolate PATH safety; effective-environment coverage lives in the cache suite. +vi.mock('./relay-command-env', () => ({ buildRelayCommandEnv: () => ({ ...process.env }) })) + const originalPlatform = process.platform const originalPath = process.env.PATH @@ -68,7 +71,7 @@ describe('relay PATH ripgrep resolution', () => { // while the spawn -- running with the user's repo as cwd -- resolves them against the repo's. it('skips rooted PATH entries that carry no drive', () => { setPlatform('win32') - process.env.PATH = `\\tools${delimiter}/tools${delimiter}C:tools` + process.env.PATH = '\\tools;/tools;C:tools' resetRelayRipgrepPathCacheForTests() expect(pathRipgrepCommand()).toBeNull() @@ -77,7 +80,7 @@ describe('relay PATH ripgrep resolution', () => { // Why a relative PATH entry is skipped: it resolves against the cwd, the hazard being avoided. it('ignores relative PATH entries on Windows', () => { setPlatform('win32') - process.env.PATH = `.${delimiter}node_modules/.bin` + process.env.PATH = '.;node_modules/.bin' resetRelayRipgrepPathCacheForTests() expect(pathRipgrepCommand()).toBeNull() diff --git a/src/renderer/src/components/editor/editor-external-watch-path-index.test.ts b/src/renderer/src/components/editor/editor-external-watch-path-index.test.ts index 68e33bc6baf..18bc3b6ce2c 100644 --- a/src/renderer/src/components/editor/editor-external-watch-path-index.test.ts +++ b/src/renderer/src/components/editor/editor-external-watch-path-index.test.ts @@ -22,6 +22,36 @@ function file(overrides: Partial & Pick): } describe('editor external watch path batch index', () => { + it('routes POSIX literal-backslash updates only to the matching tab', () => { + const scope = { worktreeId: 'wt-posix', worktreePath: '/repo', runtimeEnvironmentId: null } + const literal = file({ + id: 'literal', + worktreeId: scope.worktreeId, + filePath: '/repo/a\\b.txt', + relativePath: 'a\\b.txt' + }) + const nested = file({ + id: 'nested', + worktreeId: scope.worktreeId, + filePath: '/repo/a/b.txt', + relativePath: 'a/b.txt' + }) + const index = indexEditorExternalWatchBatchPaths( + { + worktreePath: scope.worktreePath, + events: [ + { kind: 'update', absolutePath: literal.filePath }, + { kind: 'update', absolutePath: nested.filePath } + ] + }, + [literal, nested], + scope + ) + expect(index.changes.map((change) => change.relativePath)).toEqual(['a\\b.txt', 'a/b.txt']) + expect(index.matchingOpenFiles(index.changes[0])).toEqual([literal]) + expect(index.matchingOpenFiles(index.changes[1])).toEqual([nested]) + }) + it('matches UNC aliases for updates, deletes, and restored tombstones', () => { const restored = file({ id: 'restored', diff --git a/src/renderer/src/components/right-sidebar/SearchResultsPane.tsx b/src/renderer/src/components/right-sidebar/SearchResultsPane.tsx index b1fc60b7d5e..f0b5b27d486 100644 --- a/src/renderer/src/components/right-sidebar/SearchResultsPane.tsx +++ b/src/renderer/src/components/right-sidebar/SearchResultsPane.tsx @@ -13,6 +13,7 @@ const SEARCH_VIRTUAL_OVERSCAN = 12 type SearchResultsPaneProps = { results: SearchResult | null + error?: string | null hasCommittedResults: boolean query: string loading: boolean @@ -25,6 +26,7 @@ type SearchResultsPaneProps = { export function SearchResultsPane({ results, hasCommittedResults, + error, query, loading, rows, @@ -68,7 +70,7 @@ export function SearchResultsPane({ <> {/* Why: the summary is rendered outside the virtualizer so it stays pinned at the top while the user scrolls through results. */} - {results && rows.length > 0 && ( + {!error && results && (rows.length > 0 || results.truncated) && (
{results.totalMatches}{' '} {translate('auto.components.right.sidebar.Search.6aeda362ed', 'result')} @@ -83,7 +85,15 @@ export function SearchResultsPane({ )}
- {rows.length > 0 && ( + {error && ( +
+ {error} +
+ )} + {!error && rows.length > 0 && (
{virtualizer.getVirtualItems().map((virtualRow) => { const row = rows[virtualRow.index] @@ -117,7 +127,7 @@ export function SearchResultsPane({
)} - {!hasCommittedResults && query && !loading && ( + {!error && !hasCommittedResults && query && !loading && (
{translate('auto.components.right.sidebar.Search.d56d140747', 'Press Enter to search')}
diff --git a/src/renderer/src/components/right-sidebar/file-explorer-directory-filename-identity.test.ts b/src/renderer/src/components/right-sidebar/file-explorer-directory-filename-identity.test.ts new file mode 100644 index 00000000000..3dbc25998d2 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/file-explorer-directory-filename-identity.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest' +import { fileExplorerEntriesToTreeNodes } from './file-explorer-directory-listing' + +describe('Explorer directory filename identity', () => { + it.each(['/', '/repo/', 'C:\\', 'C:\\repo\\', '\\\\server\\share\\'])( + 'preserves the first filename character when the root ends in a separator: %s', + (root) => { + const [node] = fileExplorerEntriesToTreeNodes( + [{ name: 'abc.txt', isDirectory: false, isSymlink: false }], + root, + -1, + root, + { kind: 'local' } + ) + expect(node.relativePath).toBe('abc.txt') + } + ) + + it.each(['/repo', '/ssh/repo\\root'])( + 'keeps literal and nested POSIX paths distinct under %s', + (root) => { + const literal = fileExplorerEntriesToTreeNodes( + [{ name: 'a\\b.txt', isDirectory: false, isSymlink: false }], + root, + -1, + root, + { kind: 'local' } + )[0] + const nested = fileExplorerEntriesToTreeNodes( + [{ name: 'b.txt', isDirectory: false, isSymlink: false }], + `${root}/a`, + 0, + root, + { kind: 'local' } + )[0] + expect(literal).toMatchObject({ path: `${root}/a\\b.txt`, relativePath: 'a\\b.txt' }) + expect(nested).toMatchObject({ path: `${root}/a/b.txt`, relativePath: 'a/b.txt' }) + } + ) + + it.each(['C:\\repo', '\\\\server\\share\\repo'])( + 'keeps Windows relative paths canonical under %s', + (root) => { + const [node] = fileExplorerEntriesToTreeNodes( + [{ name: 'b.txt', isDirectory: false, isSymlink: false }], + `${root}\\a`, + 0, + root, + { kind: 'local' } + ) + expect(node).toMatchObject({ path: `${root}\\a\\b.txt`, relativePath: 'a/b.txt' }) + } + ) +}) diff --git a/src/renderer/src/components/right-sidebar/file-explorer-directory-listing.ts b/src/renderer/src/components/right-sidebar/file-explorer-directory-listing.ts index d904c910e0b..e94a32e3fa5 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-directory-listing.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-directory-listing.ts @@ -1,4 +1,4 @@ -import { joinPath, normalizeRelativePath } from '@/lib/path' +import { getRelativePathInsideRoot, joinPath } from '@/lib/path' import type { DirEntry } from '../../../../shared/filesystem-entry-types' import { sortDirEntries } from '../../../../shared/file-name-sort' import { readRuntimeDirectory } from '@/runtime/runtime-file-client' @@ -27,9 +27,7 @@ export function fileExplorerEntriesToTreeNodes( return { name: entry.name, path, - relativePath: worktreePath - ? normalizeRelativePath(path.slice(worktreePath.length + 1)) - : entry.name, + relativePath: getRelativePathInsideRoot(path, worktreePath) ?? entry.name, isDirectory: entry.isDirectory, isSymlink: entry.isSymlink, depth: depth + 1, diff --git a/src/renderer/src/components/right-sidebar/file-explorer-entries.ts b/src/renderer/src/components/right-sidebar/file-explorer-entries.ts index f21116c7822..d909224675b 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-entries.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-entries.ts @@ -1,4 +1,5 @@ import type { DirEntry } from '../../../../shared/filesystem-entry-types' +import { splitPathSegments } from './path-tree' export function shouldIncludeFileExplorerEntry(entry: DirEntry): boolean { return entry.name !== '.git' && entry.name !== 'node_modules' @@ -8,6 +9,6 @@ function isDotfileSegment(segment: string): boolean { return segment.length > 1 && segment !== '..' && segment.startsWith('.') } -export function isDotfileRelativePath(relativePath: string): boolean { - return relativePath.split(/[\\/]+/).some(isDotfileSegment) +export function isDotfileRelativePath(relativePath: string, rootPath?: string | null): boolean { + return splitPathSegments(relativePath, rootPath).some(isDotfileSegment) } diff --git a/src/renderer/src/components/right-sidebar/file-explorer-name-filter-projection.test.ts b/src/renderer/src/components/right-sidebar/file-explorer-name-filter-projection.test.ts index 6075b8c36a5..f883ec8544a 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-name-filter-projection.test.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-name-filter-projection.test.ts @@ -1,8 +1,11 @@ import { describe, expect, it } from 'vitest' import { + createNameFilteredFileExplorerProjection, + getFileExplorerNameFilterIgnoredQueryRelativePaths, getNameFilterCollapsedPathsAfterExpand, getNextNameFilterCollapsedPaths } from './file-explorer-name-filter-projection' +import { buildIgnoredSet } from './status-display' describe('getNextNameFilterCollapsedPaths', () => { it('collapses expanded filtered folders and expands collapsed filtered folders', () => { @@ -22,3 +25,73 @@ describe('getNextNameFilterCollapsedPaths', () => { expect([...expanded]).toEqual(['/repo/docs']) }) }) + +describe('name-filter filename identity', () => { + function project( + worktreePath: string, + relativePaths: string[], + options: { displayRootPath?: string; ignoredPaths?: string[]; showDotfiles?: boolean } = {} + ) { + return createNameFilteredFileExplorerProjection({ + worktreePath, + displayRootPath: options.displayRootPath, + nameFilter: { query: 'txt', relativePaths }, + ignoredSet: buildIgnoredSet(options.ignoredPaths, worktreePath), + showDotfiles: options.showDotfiles ?? true, + showGitIgnoredFiles: false + }).getVisibleSlice(0, 100) + } + + it.each(['/native/repo', '/ssh/repo\\root'])( + 'keeps literal backslashes separate from directories under %s', + (root) => { + const paths = ['a\\b.txt', 'a/b.txt', 'C:\\foo/a\\b.txt', '\\\\server/a\\b.txt'] + const files = project(root, paths).filter((row) => !row.isDirectory) + expect(files.map((row) => row.relativePath).sort()).toEqual([...paths].sort()) + expect(files.map((row) => row.path).sort()).toEqual( + paths.map((path) => `${root}/${path}`).sort() + ) + } + ) + + it('scopes a literal-backslash directory without selecting its nested counterpart', () => { + const rows = project('/repo', ['a\\b/file.txt', 'a/b/other.txt'], { + displayRootPath: '/repo/a\\b' + }) + expect(rows.map((row) => row.relativePath)).toEqual(['a\\b/file.txt']) + }) + + it('keeps ignored and dotfile identities distinct on POSIX', () => { + const paths = ['a\\b.txt', 'a/b.txt', 'a\\.hidden.txt', 'a/.hidden.txt'] + const rows = project('/repo', paths, { ignoredPaths: ['a/b.txt'], showDotfiles: false }) + expect(rows.map((row) => row.relativePath).sort()).toEqual(['a\\.hidden.txt', 'a\\b.txt']) + expect( + getFileExplorerNameFilterIgnoredQueryRelativePaths( + { query: 'txt', relativePaths: paths }, + false, + '/repo' + ) + ).toEqual(['a\\b.txt', 'a/b.txt', 'a\\.hidden.txt']) + expect( + project('/repo', paths, { ignoredPaths: ['a\\b.txt'] }).map((row) => row.relativePath) + ).not.toContain('a\\b.txt') + }) + + it.each(['C:\\repo', '\\\\server\\share\\repo'])( + 'keeps Windows separator semantics under %s', + (root) => { + const paths = ['a\\b.txt', 'a/b.txt', 'a\\.hidden.txt'] + const files = project(root, paths, { showDotfiles: false }).filter((row) => !row.isDirectory) + expect(files.map((row) => row.relativePath)).toEqual(['a/b.txt']) + expect(files.map((row) => row.path)).toEqual([`${root}\\a\\b.txt`]) + expect(project(root, paths, { ignoredPaths: ['a\\'], showDotfiles: false })).toEqual([]) + expect( + getFileExplorerNameFilterIgnoredQueryRelativePaths( + { query: 'txt', relativePaths: paths }, + false, + root + ) + ).toEqual(['a/b.txt', 'a/b.txt']) + } + ) +}) diff --git a/src/renderer/src/components/right-sidebar/file-explorer-name-filter-projection.ts b/src/renderer/src/components/right-sidebar/file-explorer-name-filter-projection.ts index b8d9256219e..5aa8b3c73e9 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-name-filter-projection.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-name-filter-projection.ts @@ -65,7 +65,8 @@ export function getFileExplorerNameFilterTokens(query: string | undefined): stri export function getFileExplorerNameFilterIgnoredQueryRelativePaths( source: FileExplorerNameFilterProjectionSource, - showDotfiles: boolean + showDotfiles: boolean, + worktreePath?: string | null ): string[] { if (isFileExplorerNameFilterQueryTooLarge(source.query)) { return [] @@ -75,11 +76,11 @@ export function getFileExplorerNameFilterIgnoredQueryRelativePaths( } const tokens = getFileExplorerNameFilterTokens(source.query) return source.relativePaths - .map((relativePath) => normalizeRelativePath(relativePath)) + .map((relativePath) => normalizeRelativePath(relativePath, worktreePath)) .filter( (relativePath) => Boolean(relativePath) && - (showDotfiles || !isDotfileRelativePath(relativePath)) && + (showDotfiles || !isDotfileRelativePath(relativePath, worktreePath)) && pathMatchesFileNameFilterTokens(relativePath, tokens) ) } @@ -139,14 +140,14 @@ export function createNameFilteredFileExplorerProjection({ const rootChildren = new Map() for (const rawRelativePath of nameFilter.relativePaths) { - const relativePath = normalizeRelativePath(rawRelativePath) + const relativePath = normalizeRelativePath(rawRelativePath, worktreePath) if ( !relativePath || getRelativePathInsideRoot(joinPath(worktreePath, relativePath), displayRootPath) === null ) { continue } - if (!showDotfiles && isDotfileRelativePath(relativePath)) { + if (!showDotfiles && isDotfileRelativePath(relativePath, worktreePath)) { continue } if (!showGitIgnoredFiles && isPathIgnored(ignoredSet, relativePath)) { @@ -156,12 +157,12 @@ export function createNameFilteredFileExplorerProjection({ continue } - const segments = splitPathSegments(relativePath) + const segments = splitPathSegments(relativePath, worktreePath) let currentChildren = rootChildren let currentRelativePath = '' for (let index = 0; index < segments.length; index += 1) { const name = segments[index] - currentRelativePath = currentRelativePath ? joinPath(currentRelativePath, name) : name + currentRelativePath = currentRelativePath ? `${currentRelativePath}/${name}` : name const isDirectory = index < segments.length - 1 let entry = currentChildren.get(name) if (!entry) { @@ -186,7 +187,7 @@ export function createNameFilteredFileExplorerProjection({ let displayChildren = rootChildren const scope = getRelativePathInsideRoot(displayRootPath, worktreePath) - for (const segment of scope ? splitPathSegments(scope) : []) { + for (const segment of scope ? splitPathSegments(scope, worktreePath) : []) { const entry = displayChildren.get(segment) if (!entry) { return createFileExplorerRowProjectionFromParts(visibleFlatRows, rowsByPath) diff --git a/src/renderer/src/components/right-sidebar/file-explorer-paths.test.ts b/src/renderer/src/components/right-sidebar/file-explorer-paths.test.ts index bbc373d6ad9..fa96214d0a4 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-paths.test.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-paths.test.ts @@ -1,15 +1,7 @@ import { describe, expect, it } from 'vitest' -import { - getRevealAncestorDirs, - isPathEqualOrDescendant, - normalizeAbsolutePath -} from './file-explorer-paths' +import { getRevealAncestorDirs, isPathEqualOrDescendant } from './file-explorer-paths' describe('file explorer path helpers', () => { - it('preserves UNC roots while normalizing separators', () => { - expect(normalizeAbsolutePath('\\\\Server\\Share\\Repo\\')).toBe('//Server/Share/Repo') - }) - it('matches Windows drive paths case-insensitively with segment boundaries', () => { expect(isPathEqualOrDescendant('c:\\repo\\src\\a.ts', 'C:\\Repo')).toBe(true) expect(isPathEqualOrDescendant('C:\\Repository\\src\\a.ts', 'C:\\Repo')).toBe(false) @@ -42,4 +34,19 @@ describe('file explorer path helpers', () => { 'C:\\repo\\src' ]) }) + + it.each(['/repo', '/ssh/repo\\root'])( + 'reveals POSIX literal-backslash names without inventing directories under %s', + (root) => { + expect(getRevealAncestorDirs(root, `${root}/a\\b.txt`)).toEqual([]) + expect(getRevealAncestorDirs(root, `${root}/a/b.txt`)).toEqual([`${root}/a`]) + expect(getRevealAncestorDirs(root, `${root}/a\\b/c\\d.txt`)).toEqual([`${root}/a\\b`]) + } + ) + + it('reveals Windows UNC paths with native separators', () => { + expect( + getRevealAncestorDirs('\\\\server\\share\\repo', '\\\\server\\share\\repo\\a\\b.txt') + ).toEqual(['\\\\server\\share\\repo\\a']) + }) }) diff --git a/src/renderer/src/components/right-sidebar/file-explorer-paths.ts b/src/renderer/src/components/right-sidebar/file-explorer-paths.ts index 1c2fa044117..ce2f64d8fc3 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-paths.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-paths.ts @@ -1,26 +1,11 @@ -import { joinPath, normalizeRelativePath } from '@/lib/path' +import { joinPath } from '@/lib/path' import { isPathInsideOrEqual, normalizeRuntimePathForComparison, - normalizeRuntimePathSeparators, relativePathInsideRoot } from '../../../../shared/cross-platform-path' import { splitPathSegments } from './path-tree' -export function normalizeAbsolutePath(path: string): string { - const normalizedPath = normalizeRuntimePathSeparators(path) - - if (normalizedPath === '/') { - return normalizedPath - } - - if (/^[A-Za-z]:\/$/.test(normalizedPath)) { - return normalizedPath - } - - return normalizedPath.replace(/\/+$/, '') -} - export function normalizeAbsolutePathForComparison(path: string): string { return normalizeRuntimePathForComparison(path) } @@ -35,7 +20,7 @@ export function getRevealAncestorDirs(worktreePath: string, filePath: string): s return null } - const segments = splitPathSegments(normalizeRelativePath(relativePath)) + const segments = splitPathSegments(relativePath, worktreePath) const ancestorDirs: string[] = [] let currentPath = worktreePath diff --git a/src/renderer/src/components/right-sidebar/file-explorer-watch-filename-identity.test.ts b/src/renderer/src/components/right-sidebar/file-explorer-watch-filename-identity.test.ts new file mode 100644 index 00000000000..f28474f5913 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/file-explorer-watch-filename-identity.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import { useAppStore } from '@/store' +import { clearStalePendingReveal } from './file-explorer-watcher-reconcile' +import { + canonicalizeFileExplorerWatchPath, + getExternalFileChangeRelativePath, + normalizeExplorerAbsolutePath, + parentDirForWatchPath +} from './file-explorer-watch-path' + +describe('Explorer watcher filename identity', () => { + it('does not clear a nested pending reveal when a distinct literal-backslash path is deleted', () => { + const previous = useAppStore.getState().pendingExplorerReveal + const pending = { worktreeId: 'wt-posix', filePath: '/repo/a/b/file.txt', requestId: 1 } + try { + useAppStore.setState({ pendingExplorerReveal: pending }) + clearStalePendingReveal('/repo/a\\b') + expect(useAppStore.getState().pendingExplorerReveal).toEqual(pending) + clearStalePendingReveal('/repo/a/b') + expect(useAppStore.getState().pendingExplorerReveal).toBeNull() + } finally { + useAppStore.setState({ pendingExplorerReveal: previous }) + } + }) + + it.each(['/repo', '/ssh/repo\\'])( + 'preserves POSIX backslashes in watched roots, filenames and parents under %s', + (root) => { + expect(normalizeExplorerAbsolutePath(`${root}/`)).toBe(root) + expect(canonicalizeFileExplorerWatchPath(root, `${root}/a\\b.txt`)).toBe(`${root}/a\\b.txt`) + expect(getExternalFileChangeRelativePath(root, `${root}/a\\b.txt`, false)).toBe('a\\b.txt') + expect(parentDirForWatchPath(`${root}/a\\b.txt`)).toBe(root) + expect(parentDirForWatchPath(`${root}/a\\/b.txt`)).toBe(`${root}/a\\`) + } + ) + + it.each(['C:\\repo', '\\\\server\\share\\repo'])( + 'retains Windows watcher separator semantics under %s', + (root) => { + expect(normalizeExplorerAbsolutePath(`${root}\\`)).toBe(root) + expect(getExternalFileChangeRelativePath(root, `${root}\\a\\b.txt`, false)).toBe('a/b.txt') + expect(parentDirForWatchPath(`${root}\\a\\b.txt`)).toBe(`${root}\\a`) + } + ) +}) diff --git a/src/renderer/src/components/right-sidebar/file-explorer-watch-path.ts b/src/renderer/src/components/right-sidebar/file-explorer-watch-path.ts index 4066bca2d81..9988d9a9387 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-watch-path.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-watch-path.ts @@ -1,11 +1,14 @@ import { joinPath, dirname, normalizeRelativePath } from '@/lib/path' import { + isWindowsAbsolutePathLike, normalizeRuntimePathForComparison, relativePathInsideRoot } from '../../../../shared/cross-platform-path' export function normalizeExplorerAbsolutePath(path: string): string { - return path === '/' || /^[A-Za-z]:[\\/]$/.test(path) ? path : path.replace(/[\\/]+$/, '') + return path === '/' || /^[A-Za-z]:[\\/]$/.test(path) + ? path + : path.replace(isWindowsAbsolutePathLike(path) ? /[\\/]+$/ : /\/+$/, '') } export function getExternalFileChangeRelativePath( @@ -23,7 +26,7 @@ export function getExternalFileChangeRelativePath( } // Why: EditorPanel reloads tabs only from a worktree-relative path, not the watcher's absolute one; normalize or contents go stale. - return normalizeRelativePath(relativePath) + return normalizeRelativePath(relativePath, worktreePath) } export function canonicalizeFileExplorerWatchPath( @@ -87,6 +90,10 @@ export function resolveCachedDirPath( } export function parentDirForWatchPath(normalizedPath: string): string { + if (!isWindowsAbsolutePathLike(normalizedPath)) { + const separator = normalizedPath.lastIndexOf('/') + return separator === -1 ? '.' : normalizedPath.slice(0, separator) || '/' + } const parentPath = dirname(normalizedPath) if (/^[A-Za-z]:$/.test(parentPath)) { return `${parentPath}${normalizedPath.includes('\\') ? '\\' : '/'}` diff --git a/src/renderer/src/components/right-sidebar/file-explorer-watcher-reconcile.ts b/src/renderer/src/components/right-sidebar/file-explorer-watcher-reconcile.ts index 0f151c54295..6072cdb201f 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-watcher-reconcile.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-watcher-reconcile.ts @@ -1,6 +1,6 @@ import type { Dispatch, SetStateAction } from 'react' import type { DirCache } from './file-explorer-types' -import { normalizeAbsolutePath, isPathEqualOrDescendant } from './file-explorer-paths' +import { isPathEqualOrDescendant } from './file-explorer-paths' import { useAppStore } from '@/store' import { normalizeRuntimePathForComparison } from '../../../../shared/cross-platform-path' @@ -103,14 +103,10 @@ export function purgeExpandedDirsSubtrees( // would cause the reveal logic to expand stale ancestor directories. export function clearStalePendingReveal(deletedPath: string): void { - const normalized = normalizeAbsolutePath(deletedPath) useAppStore.setState((state) => { if ( state.pendingExplorerReveal && - isPathEqualOrDescendant( - normalizeAbsolutePath(state.pendingExplorerReveal.filePath), - normalized - ) + isPathEqualOrDescendant(state.pendingExplorerReveal.filePath, deletedPath) ) { return { pendingExplorerReveal: null } } diff --git a/src/renderer/src/components/right-sidebar/path-tree.ts b/src/renderer/src/components/right-sidebar/path-tree.ts index 133d2ec50a7..c983d4d4859 100644 --- a/src/renderer/src/components/right-sidebar/path-tree.ts +++ b/src/renderer/src/components/right-sidebar/path-tree.ts @@ -1,3 +1,5 @@ -export function splitPathSegments(path: string): string[] { - return path.split(/[\\/]+/).filter(Boolean) +import { normalizeRelativePath } from '@/lib/path' + +export function splitPathSegments(path: string, rootPath?: string | null): string[] { + return normalizeRelativePath(path, rootPath).split('/').filter(Boolean) } diff --git a/src/renderer/src/components/right-sidebar/status-display.ts b/src/renderer/src/components/right-sidebar/status-display.ts index 2af97edd531..978d4cc98aa 100644 --- a/src/renderer/src/components/right-sidebar/status-display.ts +++ b/src/renderer/src/components/right-sidebar/status-display.ts @@ -124,14 +124,17 @@ export function shouldShowIgnoredDecoration( return !nodeStatus && isPathIgnored(ignored, relativePath) } -export function buildIgnoredSet(ignoredPaths: readonly string[] | undefined): Set { +export function buildIgnoredSet( + ignoredPaths: readonly string[] | undefined, + rootPath?: string | null +): Set { const set = new Set() if (!ignoredPaths) { return set } for (const rawPath of ignoredPaths) { - const trimmed = rawPath.endsWith('/') ? rawPath.slice(0, -1) : rawPath - set.add(normalizeRelativePath(trimmed)) + const path = normalizeRelativePath(rawPath, rootPath) + set.add(path.endsWith('/') ? path.slice(0, -1) : path) } return set } diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.ts b/src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.ts index afefa839b41..8c0e039b187 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.ts @@ -49,7 +49,7 @@ export function getFileExplorerIgnoredQueryRelativePaths( return } for (const row of cached.children) { - if (!showDotfiles && isDotfileRelativePath(row.relativePath)) { + if (!showDotfiles && isDotfileRelativePath(row.relativePath, worktreePath)) { continue } relativePaths.push(row.relativePath) @@ -88,7 +88,7 @@ export function createVisibleFileExplorerRowProjection( } const shouldHideRow = (row: TreeNode): boolean => { - if (!options.showDotfiles && isDotfileRelativePath(row.relativePath)) { + if (!options.showDotfiles && isDotfileRelativePath(row.relativePath, worktreePath)) { return true } return !options.showGitIgnoredFiles && isPathIgnored(options.ignoredSet, row.relativePath) @@ -179,7 +179,11 @@ export function useFileExplorerVisibleRowProjection( () => activeRepoSupportsGit ? nameFilter - ? getFileExplorerNameFilterIgnoredQueryRelativePaths(nameFilter, showDotfiles) + ? getFileExplorerNameFilterIgnoredQueryRelativePaths( + nameFilter, + showDotfiles, + worktreePath + ) : getFileExplorerIgnoredQueryRelativePaths( { dirCache, expanded, worktreePath, displayRootPath }, showDotfiles @@ -211,7 +215,10 @@ export function useFileExplorerVisibleRowProjection( shouldDebounceIgnoredQuery, worktreePath }) - const ignoredSet = useMemo(() => buildIgnoredSet(effectiveIgnoredPaths), [effectiveIgnoredPaths]) + const ignoredSet = useMemo( + () => buildIgnoredSet(effectiveIgnoredPaths, worktreePath), + [effectiveIgnoredPaths, worktreePath] + ) const rowProjection = useMemo( () => createVisibleFileExplorerRowProjection( diff --git a/src/renderer/src/components/right-sidebar/useFileSearchPanel.ts b/src/renderer/src/components/right-sidebar/useFileSearchPanel.ts index e66e647c26f..a1ff15bf55b 100644 --- a/src/renderer/src/components/right-sidebar/useFileSearchPanel.ts +++ b/src/renderer/src/components/right-sidebar/useFileSearchPanel.ts @@ -21,6 +21,7 @@ export type FileSearchPanelModel = { filtersProps: SearchFiltersProps resultsProps: { results: SearchResult | null + error?: string | null hasCommittedResults: boolean query: string loading: boolean @@ -132,7 +133,7 @@ export function useFileSearchPanel(explorerView: 'files' | 'search'): FileSearch useEffect(() => { if (!worktreePath) { cancelPendingSearch() - updateActiveSearchState({ results: null, resultOwner: null }) + updateActiveSearchState({ results: null, resultOwner: null, error: null }) } }, [worktreePath, cancelPendingSearch, updateActiveSearchState]) @@ -281,6 +282,7 @@ export function useFileSearchPanel(explorerView: 'files' | 'search'): FileSearch }, resultsProps: { results: deferredSearchResults.results, + error: searchState?.error, hasCommittedResults: fileSearchResults !== null, query: fileSearchQuery, loading: fileSearchLoading, diff --git a/src/renderer/src/components/right-sidebar/useFileSearchRunner.test.tsx b/src/renderer/src/components/right-sidebar/useFileSearchRunner.test.tsx index c68f5effa4e..5615a0e4ab8 100644 --- a/src/renderer/src/components/right-sidebar/useFileSearchRunner.test.tsx +++ b/src/renderer/src/components/right-sidebar/useFileSearchRunner.test.tsx @@ -163,4 +163,53 @@ describe('useFileSearchRunner result ownership', () => { resultOwner: { worktreeId, runtimeEnvironmentId: null } }) }) + + it('shows an active failure and clears it when the next search succeeds or is empty', async () => { + const worktreeId = 'missing-repo::/repo' + const { hook, updates } = renderSearchRunner( + { settings: {}, repos: [], worktreesByRepo: {}, fileSearchStateByWorktree: {} }, + worktreeId + ) + const log = vi.spyOn(console, 'error').mockImplementation(() => {}) + mocks.searchRuntimeFiles.mockRejectedValueOnce( + new Error("Error invoking remote method 'search': Error: regex parse error\nUnclosed group") + ) + await finishSearch(hook.result.current.executeSearch) + expect(Object.assign({}, ...updates)).toMatchObject({ + error: 'regex parse error\nUnclosed group', + results: null, + loading: false + }) + + await finishSearch(hook.result.current.executeSearch) + expect(Object.assign({}, ...updates)).toMatchObject({ error: null, results: RESULTS }) + act(() => hook.result.current.executeSearch('')) + expect(Object.assign({}, ...updates)).toMatchObject({ error: null, results: null }) + log.mockRestore() + }) + + it('ignores an older rejection after a newer search has succeeded', async () => { + const { hook, updates } = renderSearchRunner( + { settings: {}, repos: [], worktreesByRepo: {}, fileSearchStateByWorktree: {} }, + 'missing-repo::/repo' + ) + const log = vi.spyOn(console, 'error').mockImplementation(() => {}) + let rejectOldSearch: (reason: Error) => void = () => {} + mocks.searchRuntimeFiles.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectOldSearch = reject + }) + ) + await finishSearch(hook.result.current.executeSearch) + await finishSearch(hook.result.current.executeSearch) + await act(async () => rejectOldSearch(new Error('old failure'))) + expect(Object.assign({}, ...updates)).toMatchObject({ + error: null, + results: RESULTS, + loading: false + }) + expect(updates.some((update) => update.error === 'old failure')).toBe(false) + log.mockRestore() + }) }) diff --git a/src/renderer/src/components/right-sidebar/useFileSearchRunner.ts b/src/renderer/src/components/right-sidebar/useFileSearchRunner.ts index 7950a86114f..24d44d7a3aa 100644 --- a/src/renderer/src/components/right-sidebar/useFileSearchRunner.ts +++ b/src/renderer/src/components/right-sidebar/useFileSearchRunner.ts @@ -1,3 +1,5 @@ +import { readIpcErrorDetail } from '@/lib/ipc-error' +import { translate } from '@/i18n/i18n' import { useCallback, useEffect, useRef } from 'react' import { getConnectionId } from '@/lib/connection-context' import { @@ -17,6 +19,7 @@ const SEARCH_DEBOUNCE_MS = 300 const SEARCH_MAX_RESULTS = 2000 type UpdateSearchState = (updates: { + error?: string | null loading?: boolean results?: SearchResult | null resultOwner?: FileSearchResultOwner | null @@ -54,6 +57,7 @@ export function useFileSearchRunner({ (query: string) => { latestSearchIdRef.current += 1 const searchId = latestSearchIdRef.current + updateActiveSearchState({ error: null }) if (searchTimerRef.current) { clearTimeout(searchTimerRef.current) @@ -138,7 +142,10 @@ export function useFileSearchRunner({ console.error('Search failed:', err) if (latestSearchIdRef.current === searchId) { updateActiveSearchState({ - results: { files: [], totalMatches: 0, truncated: false }, + results: null, + error: + readIpcErrorDetail(err) ?? + translate('fileSearch.failed', 'Search failed. Try again.'), resultOwner }) } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 6c160e408dc..9bc69d7501d 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18888,5 +18888,8 @@ "removeDescription": "Stop agents using this profile first. Removal deletes its saved credentials and conversation data. Your system login stays unchanged.", "cancel": "Cancel" } + }, + "fileSearch": { + "failed": "Search failed. Try again." } } diff --git a/src/renderer/src/lib/path.test.ts b/src/renderer/src/lib/path.test.ts index 42f4d59581e..080427be04e 100644 --- a/src/renderer/src/lib/path.test.ts +++ b/src/renderer/src/lib/path.test.ts @@ -49,3 +49,11 @@ describe('getRelativePathInsideRoot', () => { ) }) }) + +it.each(['/native/repo\\root', '/ssh/repo\\root'])( + 'joins POSIX names without merging literal-backslash identities under %s', + (root) => { + expect(joinPath(root, 'a\\b.txt')).toBe(`${root}/a\\b.txt`) + expect(joinPath(root, 'a/b.txt')).toBe(`${root}/a/b.txt`) + } +) diff --git a/src/renderer/src/lib/path.ts b/src/renderer/src/lib/path.ts index 284ee247ad2..6e03992cd6e 100644 --- a/src/renderer/src/lib/path.ts +++ b/src/renderer/src/lib/path.ts @@ -1,4 +1,7 @@ -import { relativePathInsideRoot } from '../../../shared/cross-platform-path' +import { + isWindowsAbsolutePathLike, + relativePathInsideRoot +} from '../../../shared/cross-platform-path' function stripTrailingSeparators(path: string): string { return path.replace(/[\\/]+$/, '') @@ -12,7 +15,10 @@ function getSeparator(path: string): '/' | '\\' { return path.includes('\\') ? '\\' : '/' } -export function normalizeRelativePath(path: string): string { +export function normalizeRelativePath(path: string, rootPath?: string | null): string { + if (rootPath && !isWindowsAbsolutePathLike(rootPath)) { + return path.replace(/^\/+/, '').replace(/\/+/g, '/') + } return stripLeadingSeparators(path).replace(/[\\/]+/g, '/') } @@ -74,9 +80,14 @@ export function joinPath(basePath: string, relativePath: string): string { return basePath } - const separator = getSeparator(basePath) - const normalizedBasePath = stripTrailingSeparators(basePath) - const normalizedRelativePath = stripLeadingSeparators(relativePath).replace(/[\\/]+/g, separator) + const windowsPath = isWindowsAbsolutePathLike(basePath) + const separator = windowsPath ? getSeparator(basePath) : '/' + const normalizedBasePath = windowsPath + ? stripTrailingSeparators(basePath) + : basePath.replace(/\/+$/, '') + const normalizedRelativePath = windowsPath + ? stripLeadingSeparators(relativePath).replace(/[\\/]+/g, separator) + : relativePath.replace(/^\/+/, '').replace(/\/+/g, '/') return `${normalizedBasePath}${separator}${normalizedRelativePath}` } diff --git a/src/renderer/src/store/slices/editor/actions/file-search-actions.ts b/src/renderer/src/store/slices/editor/actions/file-search-actions.ts index d68a7618298..4e8ef519ceb 100644 --- a/src/renderer/src/store/slices/editor/actions/file-search-actions.ts +++ b/src/renderer/src/store/slices/editor/actions/file-search-actions.ts @@ -39,6 +39,7 @@ export function createFileSearchActions( results: null, resultOwner: null, loading: false, + error: null, collapsedFiles: new Set(), seedRequestId: (current.seedRequestId ?? 0) + 1 } @@ -57,6 +58,7 @@ export function createFileSearchActions( results: null, resultOwner: null, loading: false, + error: null, collapsedFiles: new Set(), seedRequestId: (current.seedRequestId ?? 0) + 1 } @@ -112,6 +114,7 @@ export function createFileSearchActions( results: null, resultOwner: null, loading: false, + error: null, collapsedFiles: new Set() } } diff --git a/src/renderer/src/store/slices/editor/search/file-search-state.ts b/src/renderer/src/store/slices/editor/search/file-search-state.ts index 063bb379fad..dba481e1f09 100644 --- a/src/renderer/src/store/slices/editor/search/file-search-state.ts +++ b/src/renderer/src/store/slices/editor/search/file-search-state.ts @@ -10,6 +10,7 @@ const DEFAULT_FILE_SEARCH_STATE = { results: null, resultOwner: null, loading: false, + error: null, collapsedFiles: new Set() } satisfies Omit diff --git a/src/renderer/src/store/slices/editor/types/file-search-worktree-state.ts b/src/renderer/src/store/slices/editor/types/file-search-worktree-state.ts index a59d9c1e5c6..d70c0399ce4 100644 --- a/src/renderer/src/store/slices/editor/types/file-search-worktree-state.ts +++ b/src/renderer/src/store/slices/editor/types/file-search-worktree-state.ts @@ -10,6 +10,7 @@ export type FileSearchWorktreeState = { excludePattern: string results: SearchResult | null resultOwner: FileSearchResultOwner | null + error?: string | null loading: boolean collapsedFiles: Set seedRequestId?: number diff --git a/src/shared/quick-open-filter.test.ts b/src/shared/quick-open-filter.test.ts index e142c8ef765..e6b65c8d2bb 100644 --- a/src/shared/quick-open-filter.test.ts +++ b/src/shared/quick-open-filter.test.ts @@ -225,6 +225,18 @@ describe('buildRgArgsForQuickOpen', () => { }) }) +it('pins both Quick Open passes to config-independent NUL output', () => { + const { primary, ignoredPass } = buildRgArgsForQuickOpen({ + searchRoot: '.', + excludePathPrefixes: [], + forceSlashSeparator: true + }) + for (const args of [primary, ignoredPass]) { + expect(args).toContain('--no-config') + expect(args).toContain('--null') + } +}) + describe('normalizeQuickOpenRgLine', () => { it('strips absolute root prefix', () => { expect( @@ -266,9 +278,9 @@ describe('normalizeQuickOpenRgLine', () => { expect(normalizeQuickOpenRgLine('..', { kind: 'cwd-relative' })).toBeNull() }) - it('strips CRLF', () => { + it('preserves a trailing carriage return in a NUL-delimited filename', () => { expect(normalizeQuickOpenRgLine('/root/a.ts\r', { kind: 'absolute', rootPath: '/root' })).toBe( - 'a.ts' + 'a.ts\r' ) }) diff --git a/src/shared/quick-open-filter.ts b/src/shared/quick-open-filter.ts index ffb1ac346ef..7c4da971a01 100644 --- a/src/shared/quick-open-filter.ts +++ b/src/shared/quick-open-filter.ts @@ -6,7 +6,7 @@ * Centralized to stop local/relay listFiles from drifting on blocklist, ignores, exclusions, * timeouts, and buffering. See docs/design/share-quick-open-file-listing.md. */ -import { relativePathInsideRoot } from './cross-platform-path' +import { isWindowsAbsolutePathLike, relativePathInsideRoot } from './cross-platform-path' // ─── Hidden-dir blocklist ──────────────────────────────────────────── @@ -93,7 +93,7 @@ export function buildExcludePathPrefixes(rootPath: string, excludePaths?: unknow if (relativePath === null) { continue } - let rel = relativePath.replace(/\\/g, '/') + let rel = isWindowsAbsolutePathLike(rootPath) ? relativePath.replace(/\\/g, '/') : relativePath if (!rel || isParentRelativePath(rel) || rel.startsWith('/')) { continue } @@ -204,6 +204,8 @@ export function buildRgArgsForQuickOpen(opts: RgArgsOptions): RgArgs { const primary = [ '--files', + '--no-config', + '--null', '--hidden', ...sepArgs, ...hiddenDirGlobs, @@ -214,6 +216,8 @@ export function buildRgArgsForQuickOpen(opts: RgArgsOptions): RgArgs { // Ignored pass: --no-ignore-vcs broadens to gitignored/parent/global ignored files; blocklist globs still guard. const ignoredPass = [ '--files', + '--no-config', + '--null', '--hidden', '--no-ignore-vcs', ...sepArgs, @@ -234,20 +238,18 @@ export type RgOutputMode = | { kind: 'cwd-relative' } /** - * Convert one rg --files stdout line into a root-relative, `/`-separated path. - * Returns `null` for lines that escape the root (symlink edge cases) or can't be normalized. + * Convert one NUL-delimited rg --files record into a root-relative, `/`-separated path. + * Returns `null` for records that escape the root (symlink edge cases) or can't be normalized. * Callers do any WSL translation first, keeping WSL out of the shared module. */ export function normalizeQuickOpenRgLine(rawLine: string, outputMode: RgOutputMode): string | null { - let line = rawLine - // Strip CR so CRLF from rg on Windows doesn't leak into results. - if (line.length > 0 && line.charCodeAt(line.length - 1) === 13) { - line = line.substring(0, line.length - 1) - } + const line = rawLine if (!line) { return null } - const normalized = line.replace(/\\/g, '/') + const windowsPath = + outputMode.kind === 'absolute' && isWindowsAbsolutePathLike(outputMode.rootPath) + const normalized = windowsPath ? line.replace(/\\/g, '/') : line if (outputMode.kind === 'cwd-relative') { let rel = normalized if (rel.startsWith('./')) { @@ -262,7 +264,8 @@ export function normalizeQuickOpenRgLine(rawLine: string, outputMode: RgOutputMo } // Absolute mode: strip the root prefix. // Why: only replace backslashes; collapsing repeated slashes would break Windows UNC roots (`\\server\share`). - const normalizedRoot = `${outputMode.rootPath.replace(/\\/g, '/').replace(/\/+$/, '')}/` + const root = windowsPath ? outputMode.rootPath.replace(/\\/g, '/') : outputMode.rootPath + const normalizedRoot = `${root.replace(/\/+$/, '')}/` if (normalized.startsWith(normalizedRoot)) { const rel = normalized.substring(normalizedRoot.length) if (!rel || isParentRelativePath(rel) || rel.startsWith('/')) { diff --git a/src/shared/quick-open-ripgrep-output-mode.ts b/src/shared/quick-open-ripgrep-output-mode.ts new file mode 100644 index 00000000000..e3a8f1541a1 --- /dev/null +++ b/src/shared/quick-open-ripgrep-output-mode.ts @@ -0,0 +1,14 @@ +import type { RgOutputMode } from './quick-open-filter' + +export function getQuickOpenRgOutputMode( + rawLine: string, + translatedLine: string, + rootPath: string +): RgOutputMode { + return translatedLine !== rawLine || + rawLine.startsWith('/') || + /^[A-Za-z]:[\\/]/.test(rawLine) || + rawLine.startsWith('\\\\') + ? { kind: 'absolute', rootPath } + : { kind: 'cwd-relative' } +} diff --git a/src/shared/ripgrep-dense-match-json.test.ts b/src/shared/ripgrep-dense-match-json.test.ts new file mode 100644 index 00000000000..5a4cdd41e29 --- /dev/null +++ b/src/shared/ripgrep-dense-match-json.test.ts @@ -0,0 +1,78 @@ +import { expect, it } from 'vitest' +import { JSONParser } from '@streamparser/json' +import { parseDenseRipgrepMatchJson } from './ripgrep-dense-match-json' + +it.each([0, 64 * 1024])('preserves literal and escaped BOMs with buffer size %i', (size) => { + const source = { '\ufeffkey': `\ufeffstart${'\n'.repeat(64 * 1024)}\ufeffend` } + const literal = JSON.stringify(source) + for (const record of [literal, literal.replaceAll('\ufeff', '\\uFEFF')]) { + const parser = new JSONParser({ stringBufferSize: size }) + let parsed: unknown + parser.onValue = ({ value, stack }) => { + if (stack.length === 0) { + parsed = value + } + } + parser.write(record) + expect(parsed).toEqual(JSON.parse(record)) + } +}) + +it.each(['', '\\', '\n', '\n'.repeat(64 * 1024), `${'x'.repeat(64 * 1024)}\n`])( + 'preserves U+FEFF in text and filenames across string-buffer boundaries (%#)', + (prefix) => { + const text = `${prefix}\ufeff😀x` + const source = { + type: 'match', + data: { + path: { text }, + lines: { text }, + line_number: 1, + submatches: [{ start: 0, end: 1 }] + } + } + const record = JSON.stringify(source) + expect(parseDenseRipgrepMatchJson(record, 1, 16)).toEqual(JSON.parse(record)) + } +) + +it('retains only exact match fields and the remaining range budget', () => { + const ranges = [ + { start: 0, end: 1 }, + { start: 2, end: 3 } + ] + const source = { + type: 'match', + submatches: [{ start: 99, end: 100 }], + data: { + nested: { submatches: [{ start: 88, end: 89 }] }, + lines: { text: 'a "submatches" b', bytes: 'YQ==' }, + path: { text: '/root/a.ts' }, + line_number: 12, + submatches: ranges + } + } + expect(parseDenseRipgrepMatchJson(JSON.stringify(source), 1, 16)).toEqual({ + type: 'match', + data: { + lines: source.data.lines, + path: source.data.path, + line_number: 12, + submatches: ranges.slice(0, 1) + } + }) +}) + +it('rejects depth overflow and invalid submatch shapes', () => { + expect(() => parseDenseRipgrepMatchJson('['.repeat(17), 2, 16)).toThrow() + expect(() => parseDenseRipgrepMatchJson('{"data":{"submatches":[null]}}', 2, 16)).toThrow() +}) + +it.each(['{}', '{"type":"begin","data":{}}', '{"data":null}', '{"data":[]}'])( + 'does not fabricate a match from %s', + (source) => { + const projected = parseDenseRipgrepMatchJson(source, 2, 16) + expect(projected.data?.path).toBeUndefined() + expect(projected.data?.submatches).toEqual([]) + } +) diff --git a/src/shared/ripgrep-dense-match-json.ts b/src/shared/ripgrep-dense-match-json.ts new file mode 100644 index 00000000000..821415a0748 --- /dev/null +++ b/src/shared/ripgrep-dense-match-json.ts @@ -0,0 +1,135 @@ +import { + assertJsonTextStructureWithinLimits, + JsonTextStructureCapacityError, + type JsonTextStructureLimits +} from './json-text-structure-limit' +import { JSONParser, TokenType } from '@streamparser/json' + +export type RipgrepMatchMessage = { + type?: string + data?: { + path?: { text?: string } + lines?: { text?: string; bytes?: string } + line_number?: number + submatches?: { start: number; end: number }[] + } +} + +export function parseRipgrepMatchJson( + line: string, + maxMatches: number, + limits: JsonTextStructureLimits +): RipgrepMatchMessage { + try { + assertJsonTextStructureWithinLimits(line, limits) + return JSON.parse(line) + } catch (error) { + if ( + !(error instanceof JsonTextStructureCapacityError) || + error.resource !== 'structuralTokens' + ) { + throw error + } + return parseDenseRipgrepMatchJson(line, maxMatches, limits.nestingDepth) + } +} + +/** Dense rg records retain only the requested ranges while validating the entire record. */ +export function parseDenseRipgrepMatchJson( + line: string, + maxMatches: number, + nestingDepth: number +): RipgrepMatchMessage { + const parser = new JSONParser({ + paths: [ + '$.type', + '$.data.path.text', + '$.data.lines.text', + '$.data.lines.bytes', + '$.data.line_number', + '$.data.submatches.*' + ], + keepStack: false, + stringBufferSize: 64 * 1024 + }) + const data: NonNullable = { submatches: [] } + const result: RipgrepMatchMessage = { data } + const containers: { object: boolean; expectingKey: boolean; keys?: Set }[] = [] + let elementTokens = 0 + parser.onToken = ({ token, value }) => { + const current = containers.at(-1) + if (current?.object && current.expectingKey && token === TokenType.STRING) { + if (typeof value !== 'string') { + throw new SyntaxError('Invalid rg object key') + } + if (current.keys?.has(value)) { + throw new SyntaxError('Duplicate rg object key') + } + current.keys?.add(value) + if ((current.keys?.size ?? 0) > 128) { + throw new Error('Too many rg record fields') + } + current.expectingKey = false + } + if (token === TokenType.LEFT_BRACE || token === TokenType.LEFT_BRACKET) { + containers.push({ + object: token === TokenType.LEFT_BRACE, + expectingKey: token === TokenType.LEFT_BRACE, + // rg's envelope keys are unique; reject duplicates instead of mixing projections. + keys: containers.length < 2 ? new Set() : undefined + }) + if (containers.length > nestingDepth) { + throw new Error('rg record nesting exceeds limit') + } + if (containers.length === 4) { + elementTokens = 0 + } + } else if (token === TokenType.RIGHT_BRACE || token === TokenType.RIGHT_BRACKET) { + containers.pop() + } else if (token === TokenType.COMMA && current?.object) { + current.expectingKey = true + } + if (containers.length >= 4 && ++elementTokens > 32 * 1024) { + throw new Error('rg submatch structure exceeds limit') + } + } + parser.onValue = ({ key, value, parent, stack }) => { + if (stack.length === 1 && key === 'type' && typeof value === 'string') { + result.type = value + } else if (stack.length === 2 && stack[1].key === 'data' && key === 'line_number') { + if (typeof value === 'number') { + data.line_number = value + } + } else if (stack.length === 3 && stack[1].key === 'data') { + if (stack[2].key === 'submatches' && Array.isArray(parent)) { + if ( + !value || + typeof value !== 'object' || + Array.isArray(value) || + typeof value.start !== 'number' || + typeof value.end !== 'number' + ) { + throw new SyntaxError('Invalid rg submatch') + } + if (data.submatches && data.submatches.length < maxMatches) { + data.submatches.push({ start: value.start, end: value.end }) + } + parent.pop() + } else if (stack[2].key === 'path' && key === 'text' && typeof value === 'string') { + data.path = { text: value } + } else if (stack[2].key === 'lines' && typeof value === 'string') { + if (key === 'text') { + data.lines = { ...data.lines, text: value } + } + if (key === 'bytes') { + data.lines = { ...data.lines, bytes: value } + } + } + } + } + parser.write(line) + if (!parser.isEnded) { + parser.end() + } + return result +} diff --git a/src/shared/ripgrep-filename-decoder.test.ts b/src/shared/ripgrep-filename-decoder.test.ts new file mode 100644 index 00000000000..57af67e1abe --- /dev/null +++ b/src/shared/ripgrep-filename-decoder.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import { RipgrepFilenameDecoder, RipgrepFilenameEncodingError } from './ripgrep-filename-decoder' + +describe('ripgrep filename decoding', () => { + it('preserves split scalars, BOMs and literal replacement characters', () => { + const decoder = new RipgrepFilenameDecoder() + const text = '\uFEFF日本語😀\uFFFD\0' + let decoded = '' + for (const byte of Buffer.from(text)) { + decoded += decoder.decode(Buffer.from([byte])) + } + decoder.finish() + expect(decoded).toBe(text) + }) + + it.each([[0xff], [0x80], [0xc0, 0xaf], [0xed, 0xa0, 0x80]])( + 'refuses invalid filename bytes %j', + (...bytes) => { + expect(() => new RipgrepFilenameDecoder().decode(Buffer.from(bytes))).toThrow( + RipgrepFilenameEncodingError + ) + } + ) + + it('rejects an incomplete scalar at EOF', () => { + const decoder = new RipgrepFilenameDecoder() + expect(decoder.decode(Buffer.from([0xf0, 0x9f]))).toBe('') + expect(() => decoder.finish()).toThrow('not valid UTF-8') + }) + + it('accepts string fixtures without losing decoder state', () => { + const decoder = new RipgrepFilenameDecoder() + expect(decoder.decode('literal-�\0')).toBe('literal-�\0') + decoder.finish() + }) +}) + +it('refuses literal WSL backslashes only when Windows translation is required', () => { + const errors: Error[] = [] + const decoder = new RipgrepFilenameDecoder((error) => errors.push(error), true) + expect(decoder.decode(Buffer.from('./a\\b.txt\0'))).toBeNull() + expect(errors[0]?.message).toContain('WSL filenames containing a backslash') + expect(new RipgrepFilenameDecoder().decode(Buffer.from('./a\\b.txt\0'))).toBe('./a\\b.txt\0') +}) diff --git a/src/shared/ripgrep-filename-decoder.ts b/src/shared/ripgrep-filename-decoder.ts new file mode 100644 index 00000000000..7f903b3832d --- /dev/null +++ b/src/shared/ripgrep-filename-decoder.ts @@ -0,0 +1,51 @@ +export class RipgrepFilenameError extends Error {} + +export class RipgrepFilenameEncodingError extends RipgrepFilenameError { + constructor() { + super('File listing contains a filename that is not valid UTF-8') + this.name = 'RipgrepFilenameEncodingError' + } +} + +/** Replacement decoding would return a different, potentially existing filename. */ +export class RipgrepFilenameDecoder { + private readonly decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }) + + constructor( + private readonly onError: (error: Error) => void = throwFilenameError, + private readonly rejectBackslash = false + ) {} + + decode(chunk: Buffer | string): string | null { + try { + const decoded = this.decoder.decode(typeof chunk === 'string' ? Buffer.from(chunk) : chunk, { + stream: true + }) + if (this.rejectBackslash && decoded.includes('\\')) { + throw new RipgrepFilenameError( + 'WSL filenames containing a backslash cannot be opened through Windows paths' + ) + } + return decoded + } catch (error) { + this.onError( + error instanceof RipgrepFilenameError ? error : new RipgrepFilenameEncodingError() + ) + return null + } + } + + finish(): boolean { + try { + this.decoder.decode() + return true + } catch { + this.onError(new RipgrepFilenameEncodingError()) + return false + } + } +} + +function throwFilenameError(error: Error): never { + throw error +} diff --git a/src/shared/ripgrep-line-decoding.test.ts b/src/shared/ripgrep-line-decoding.test.ts new file mode 100644 index 00000000000..3c28d76a37c --- /dev/null +++ b/src/shared/ripgrep-line-decoding.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest' +import { decodeRipgrepLine } from './ripgrep-line-decoding' +import { ripgrepMatchRanges } from './ripgrep-match-offsets' + +describe('ripgrep byte line decoding', () => { + it.each([ + [0xff], + [0xc0, 0xaf], + [0xe2, 0x82], + [0xf0, 0x90, 0x80], + [0xed, 0xa0, 0x80], + [0xf4, 0x90, 0x80, 0x80], + [0x80, 0xbf], + [0xe0, 0x80, 0xaf] + ])('maps text after malformed sequence %j like Node UTF8 decoding', (...prefix) => { + const bytes = Buffer.concat([ + Buffer.from('😀é'), + Buffer.from(prefix), + Buffer.from('needle\r\n') + ]) + const decoded = decodeRipgrepLine({ bytes: bytes.toString('base64') }) + expect(decoded.text).toBe(bytes.toString('utf8').replace(/\n$/, '')) + expect(decoded.readOffset(bytes.length - 8)).toBe(decoded.text.indexOf('needle')) + expect(decoded.readOffset(bytes.length - 2)).toBe(decoded.text.indexOf('needle') + 6) + }) + + it('rejects partial replacement boundaries and overlapping ranges without inventing coordinates', () => { + const decoded = decodeRipgrepLine({ + bytes: Buffer.from([0xe2, 0x82, 0x20, 0x78]).toString('base64') + }) + let invalid = 0 + const ranges = [ + ...ripgrepMatchRanges( + decoded.text, + [ + { start: 0, end: 1 }, + { start: 2, end: 3 }, + { start: 2, end: 4 }, + { start: 4, end: 4 } + ], + decoded.readOffset, + () => invalid++ + ) + ] + expect(ranges).toEqual([ + { start: 1, end: 2 }, + { start: 3, end: 3 } + ]) + expect(invalid).toBe(2) + }) + + it('agrees with Node on all two-byte prefixes followed by ASCII', () => { + for (let first = 0; first < 256; first++) { + for (let second = 0; second < 256; second++) { + const bytes = Buffer.from([first, second, 0x78]) + const decoded = decodeRipgrepLine({ bytes: bytes.toString('base64') }) + expect(decoded.readOffset(2)).toBe(bytes.subarray(0, 2).toString('utf8').length) + } + } + }) +}) diff --git a/src/shared/ripgrep-line-decoding.ts b/src/shared/ripgrep-line-decoding.ts new file mode 100644 index 00000000000..c94091855f4 --- /dev/null +++ b/src/shared/ripgrep-line-decoding.ts @@ -0,0 +1,60 @@ +import { createRipgrepOffsetReader } from './ripgrep-match-offsets' + +function utf8SequenceWidth(bytes: Buffer, start: number): number { + const lead = bytes[start]! + const expected = + lead >= 0xc2 && lead <= 0xdf + ? 2 + : lead >= 0xe0 && lead <= 0xef + ? 3 + : lead >= 0xf0 && lead <= 0xf4 + ? 4 + : 1 + for (let offset = 1; offset < expected; offset++) { + const next = bytes[start + offset] + if ( + next === undefined || + next < 0x80 || + next > 0xbf || + (offset === 1 && + ((lead === 0xe0 && next < 0xa0) || + (lead === 0xed && next >= 0xa0) || + (lead === 0xf0 && next < 0x90) || + (lead === 0xf4 && next >= 0x90))) + ) { + return offset + } + } + return expected +} + +function createDecodedByteOffsetReader(bytes: Buffer): (offset: number) => number | null { + let position = 0 + let column = 0 + return (offset) => { + if (!Number.isSafeInteger(offset) || offset < position) { + return null + } + while (position < offset && position < bytes.length) { + const width = utf8SequenceWidth(bytes, position) + position += width + column += width === 4 ? 2 : 1 + } + return offset === position ? column : null + } +} + +/** Decode once; malformed sequences retain their original byte widths for match coordinates. */ +export function decodeRipgrepLine(data: { text?: string; bytes?: string } | undefined): { + text: string + readOffset: (offset: number) => number | null +} { + if (typeof data?.text === 'string') { + return { text: data.text.replace(/\n$/, ''), readOffset: createRipgrepOffsetReader(data.text) } + } + const bytes = Buffer.from(data?.bytes ?? '', 'base64') + return { + text: bytes.toString('utf8').replace(/\n$/, ''), + readOffset: createDecodedByteOffsetReader(bytes) + } +} diff --git a/src/shared/ripgrep-match-offsets.test.ts b/src/shared/ripgrep-match-offsets.test.ts new file mode 100644 index 00000000000..198af0b9ab8 --- /dev/null +++ b/src/shared/ripgrep-match-offsets.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import { createRipgrepOffsetReader, ripgrepMatchRanges } from './ripgrep-match-offsets' + +describe('ripgrep match offsets', () => { + it('converts ordered ASCII, accented, CJK and astral boundaries to UTF16', () => { + const text = 'aé日😀z' + const read = createRipgrepOffsetReader(text) + expect([0, 1, 3, 6, 10, 11].map(read)).toEqual([0, 1, 2, 3, 5, 6]) + expect(read(11)).toBe(6) + }) + + it('rejects invalid, backwards and partial codepoint offsets', () => { + const read = createRipgrepOffsetReader('é😀') + expect(read(-1)).toBeNull() + expect(read(0.5)).toBeNull() + expect(read(1)).toBeNull() + expect(read(2)).toBe(1) + expect(read(0)).toBeNull() + expect(read(6)).toBe(3) + expect(read(7)).toBeNull() + }) + + it('handles many adjacent matches in one pass', () => { + const read = createRipgrepOffsetReader('😀x'.repeat(10_000)) + for (let index = 0; index < 10_000; index++) { + expect(read(index * 5)).toBe(index * 3) + expect(read(index * 5 + 4)).toBe(index * 3 + 2) + } + }) + + it('preserves zero-length matches and whole-codepoint line fallbacks', () => { + expect([...ripgrepMatchRanges('😀é', [{ start: 4, end: 4 }])]).toEqual([{ start: 2, end: 2 }]) + expect([...ripgrepMatchRanges('😀é', [])]).toEqual([{ start: 0, end: 2 }]) + expect([...ripgrepMatchRanges('', [])]).toEqual([{ start: 0, end: 0 }]) + }) +}) diff --git a/src/shared/ripgrep-match-offsets.ts b/src/shared/ripgrep-match-offsets.ts new file mode 100644 index 00000000000..fe667349701 --- /dev/null +++ b/src/shared/ripgrep-match-offsets.ts @@ -0,0 +1,37 @@ +/** Converts ordered ripgrep byte offsets without rescanning each match's prefix. */ +export function createRipgrepOffsetReader(text: string): (byteOffset: number) => number | null { + let bytePosition = 0 + let column = 0 + return (byteOffset) => { + if (!Number.isSafeInteger(byteOffset) || byteOffset < bytePosition) { + return null + } + while (bytePosition < byteOffset && column < text.length) { + const point = text.codePointAt(column)! + bytePosition += point <= 0x7f ? 1 : point <= 0x7ff ? 2 : point <= 0xffff ? 3 : 4 + column += point > 0xffff ? 2 : 1 + } + return bytePosition === byteOffset ? column : null + } +} + +export function* ripgrepMatchRanges( + text: string, + submatches: readonly { start: number; end: number }[], + readOffset = createRipgrepOffsetReader(text), + onInvalidRange?: () => void +): Generator<{ start: number; end: number }> { + if (submatches.length === 0) { + yield { start: 0, end: text.length > 0 ? (text.codePointAt(0)! > 0xffff ? 2 : 1) : 0 } + return + } + for (const submatch of submatches) { + const start = readOffset(submatch.start) + const end = readOffset(submatch.end) + if (start !== null && end !== null) { + yield { start, end } + } else { + onInvalidRange?.() + } + } +} diff --git a/src/shared/ripgrep-search-diagnostics.test.ts b/src/shared/ripgrep-search-diagnostics.test.ts new file mode 100644 index 00000000000..3bacf4678b8 --- /dev/null +++ b/src/shared/ripgrep-search-diagnostics.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' +import { createAccumulator } from './text-search' +import { RipgrepSearchDiagnostics } from './ripgrep-search-diagnostics' + +describe('ripgrep search diagnostics', () => { + it('reports invalid regex diagnostics instead of an empty successful search', () => { + const diagnostics = new RipgrepSearchDiagnostics() + diagnostics.append(Buffer.from('regex parse error: unclosed character class')) + expect(diagnostics.failure(2, null, createAccumulator())?.message).toContain( + 'unclosed character class' + ) + }) + + it('limits retained diagnostics even for large stderr chunks', () => { + const diagnostics = new RipgrepSearchDiagnostics() + diagnostics.append('x'.repeat(100_000)) + diagnostics.append('must not be retained') + const error = diagnostics.failure(2, null, createAccumulator()) + expect(error?.message).toBe(`Search failed (2): ${'x'.repeat(4096)}`) + }) + + it('marks permission-error results as incomplete', () => { + const acc = createAccumulator() + acc.totalMatches = 1 + expect(new RipgrepSearchDiagnostics().failure(2, null, acc)).toBeNull() + expect(acc.truncated).toBe(true) + }) + + it('distinguishes a killed search from intentional truncation', () => { + const diagnostics = new RipgrepSearchDiagnostics() + const acc = createAccumulator() + expect(diagnostics.failure(null, 'SIGTERM', acc)).toBeInstanceOf(Error) + acc.truncated = true + expect(diagnostics.failure(null, 'SIGTERM', acc)).toBeNull() + }) + + it.each([0, 1])('accepts normal exit %i without inventing truncation', (code) => { + const acc = createAccumulator() + expect(new RipgrepSearchDiagnostics().failure(code, null, acc)).toBeNull() + expect(acc.truncated).toBe(false) + }) +}) diff --git a/src/shared/ripgrep-search-diagnostics.ts b/src/shared/ripgrep-search-diagnostics.ts new file mode 100644 index 00000000000..4b5445a79eb --- /dev/null +++ b/src/shared/ripgrep-search-diagnostics.ts @@ -0,0 +1,34 @@ +import type { SearchAccumulator } from './text-search' + +const MAX_SEARCH_ERROR_BYTES = 4096 + +export class RipgrepSearchDiagnostics { + private readonly bytes = Buffer.alloc(MAX_SEARCH_ERROR_BYTES) + private length = 0 + + append(chunk: Buffer | string): void { + if (this.length >= this.bytes.length) { + return + } + this.length += + typeof chunk === 'string' + ? this.bytes.write(chunk, this.length, this.bytes.length - this.length, 'utf8') + : chunk.copy(this.bytes, this.length, 0, this.bytes.length - this.length) + } + + failure( + code: number | null, + signal: NodeJS.Signals | null, + acc: SearchAccumulator + ): Error | null { + if (code === 0 || code === 1 || (signal && acc.truncated)) { + return null + } + if (acc.totalMatches > 0) { + acc.truncated = true + return null + } + const detail = this.bytes.toString('utf8', 0, this.length).trim() + return new Error(`Search failed (${signal ?? code})${detail ? `: ${detail}` : ''}`) + } +} diff --git a/src/shared/text-search-dense-matches.test.ts b/src/shared/text-search-dense-matches.test.ts new file mode 100644 index 00000000000..e32cd6b35e4 --- /dev/null +++ b/src/shared/text-search-dense-matches.test.ts @@ -0,0 +1,126 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { runProcess } from './child-process/run-process' +import { buildRgArgs, createAccumulator, ingestRgJsonLine } from './text-search' + +describe('text search match budgets', () => { + it('keeps dense-line columns after a leading U+FEFF from real rg', async () => { + const { rgPath } = await import('@vscode/ripgrep-universal') + const root = await mkdtemp(join(tmpdir(), 'orca-rg-dense-bom-')) + const filename = join(root, '\ufeffdense.txt') + try { + await writeFile(filename, `header\n\ufeff${'x '.repeat(10_000)}`) + const result = await runProcess({ + program: rgPath, + args: buildRgArgs('x', '.', {}), + cwd: root + }) + expect(result.code).toBe(0) + const accumulator = createAccumulator() + for (const line of result.stdout.split('\n')) { + if (ingestRgJsonLine(line, root, accumulator, 2000) === 'stop') { + break + } + } + expect(accumulator.totalMatches).toBe(2000) + expect(accumulator.fileMap.get(filename)?.matches[0]).toMatchObject({ + line: 2, + column: 2, + matchLength: 1 + }) + expect(accumulator.fileMap.get(filename)?.matches[1999]?.column).toBe(4000) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + it('preserves the requested budget from real rg dense-line output', async () => { + const { rgPath } = await import('@vscode/ripgrep-universal') + const root = await mkdtemp(join(tmpdir(), 'orca-rg-dense-')) + try { + await writeFile(join(root, 'dense.txt'), 'x '.repeat(10_000)) + const result = await runProcess({ + program: rgPath, + args: buildRgArgs('x', root, {}), + cwd: root + }) + expect(result.code).toBe(0) + const accumulator = createAccumulator() + for (const line of result.stdout.split('\n')) { + if (ingestRgJsonLine(line, root, accumulator, 2000) === 'stop') { + break + } + } + expect(accumulator.totalMatches).toBe(2000) + expect(accumulator.truncated).toBe(true) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + it('allows more than 100 matching lines in one file under the global budget', async () => { + const { rgPath } = await import('@vscode/ripgrep-universal') + const root = await mkdtemp(join(tmpdir(), 'orca-rg-lines-')) + try { + await writeFile(join(root, 'many.txt'), 'needle\n'.repeat(150)) + const result = await runProcess({ + program: rgPath, + args: buildRgArgs('needle', root, {}), + cwd: root + }) + expect(result.code).toBe(0) + const accumulator = createAccumulator() + for (const line of result.stdout.split('\n')) { + ingestRgJsonLine(line, root, accumulator, 2000) + } + expect(accumulator.totalMatches).toBe(150) + expect(accumulator.truncated).toBe(false) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) +}) + +function denseRecord(count: number): string { + return JSON.stringify({ + type: 'match', + data: { + path: { text: '/root/dense.txt' }, + lines: { text: 'x '.repeat(count) }, + line_number: 1, + submatches: Array.from({ length: count }, (_, index) => ({ + match: { text: 'x' }, + start: index * 2, + end: index * 2 + 1 + })) + } + }) +} + +it('retains the first 2000 matches from a dense line beyond the normal JSON budget', () => { + const accumulator = createAccumulator() + expect(ingestRgJsonLine(denseRecord(10_000), '/root', accumulator, 2000)).toBe('stop') + expect(accumulator.totalMatches).toBe(2000) + expect(accumulator.truncated).toBe(true) + const matches = accumulator.fileMap.get('/root/dense.txt')?.matches + expect(matches?.map((match) => match.column)).toEqual( + Array.from({ length: 2000 }, (_, index) => index * 2 + 1) + ) +}) + +it.each([ + (record: string) => record.slice(0, -1), + (record: string) => `${record.slice(0, -2)},invalid}`, + (record: string) => `${record.slice(0, -1)},"data":{}}`, + (record: string) => `${record.slice(0, -2)},"submatches":[]}}` +])( + 'rejects invalid tails or duplicate envelope keys without retaining early matches', + (corrupt) => { + const accumulator = createAccumulator() + ingestRgJsonLine(corrupt(denseRecord(10_000)), '/root', accumulator, 2000) + expect(accumulator.totalMatches).toBe(0) + expect(accumulator.truncated).toBe(true) + } +) diff --git a/src/shared/text-search-invalid-filename.test.ts b/src/shared/text-search-invalid-filename.test.ts new file mode 100644 index 00000000000..d9faa51efde --- /dev/null +++ b/src/shared/text-search-invalid-filename.test.ts @@ -0,0 +1,19 @@ +import { expect, it } from 'vitest' +import { createAccumulator, ingestRgJsonLine } from './text-search' + +it('reports byte-only filenames as incomplete without inventing a replacement-character path', () => { + const acc = createAccumulator() + const line = JSON.stringify({ + type: 'match', + data: { + path: { bytes: Buffer.from([0xff, 0x2e, 0x74, 0x78, 0x74]).toString('base64') }, + lines: { text: 'needle\n' }, + line_number: 1, + submatches: [{ start: 0, end: 6 }] + } + }) + expect(ingestRgJsonLine(line, '/root', acc, 20)).toBe('continue') + expect(acc.truncated).toBe(true) + expect(acc.totalMatches).toBe(0) + expect(acc.fileMap.size).toBe(0) +}) diff --git a/src/shared/text-search-paths.test.ts b/src/shared/text-search-paths.test.ts new file mode 100644 index 00000000000..b6fdc3bb1c2 --- /dev/null +++ b/src/shared/text-search-paths.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import { createAccumulator, ingestRgJsonLine } from './text-search' +import { resolveSearchResultPath } from './text-search-paths' + +describe('text search result paths', () => { + it.each([ + ['/root/repo', './src/a.ts', '/root/repo/src/a.ts'], + ['/root/repo', '/root/repo/src/a.ts', '/root/repo/src/a.ts'], + ['C:\\repo', './src/a.ts', 'C:\\repo\\src\\a.ts'], + ['C:\\repo', 'C:/repo/src/a.ts', 'C:/repo/src/a.ts'], + ['\\\\wsl.localhost\\Ubuntu\\repo', './src/a.ts', '\\\\wsl.localhost\\Ubuntu\\repo\\src\\a.ts'] + ])('resolves %s and %s on the owning host', (root, reported, expected) => { + expect(resolveSearchResultPath(root, reported)).toBe(expected) + }) + + it('translates an absolute WSL path before resolving its host path', () => { + const acc = createAccumulator() + ingestRgJsonLine( + JSON.stringify({ + type: 'match', + data: { + path: { text: '/repo/src/a.ts' }, + lines: { text: 'match\n' }, + line_number: 1, + submatches: [{ start: 0, end: 5 }] + } + }), + '\\\\wsl.localhost\\Ubuntu\\repo', + acc, + 20, + (path) => `\\\\wsl.localhost\\Ubuntu${path.replaceAll('/', '\\')}` + ) + expect([...acc.fileMap.values()][0]?.relativePath).toBe('src/a.ts') + expect([...acc.fileMap.keys()]).toEqual(['\\\\wsl.localhost\\Ubuntu\\repo\\src\\a.ts']) + }) +}) diff --git a/src/shared/text-search-paths.ts b/src/shared/text-search-paths.ts index c556a10dedf..4e7cd154158 100644 --- a/src/shared/text-search-paths.ts +++ b/src/shared/text-search-paths.ts @@ -1,20 +1,28 @@ import { posix, win32 } from 'node:path' +import { isWindowsAbsolutePathLike } from './cross-platform-path' function pathFlavor(rootPath: string): typeof posix | typeof win32 { - if (/^[a-zA-Z]:[\\/]/.test(rootPath) || rootPath.startsWith('\\\\')) { + if (isWindowsAbsolutePathLike(rootPath)) { return win32 } return posix } -export function normalizeRelativePath(path: string): string { - return path.replace(/[\\/]+/g, '/').replace(/^\/+/, '') +export function normalizeRelativePath(path: string, rootPath?: string): string { + const separators = + rootPath !== undefined && !isWindowsAbsolutePathLike(rootPath) ? /\/+/g : /[\\/]+/g + return path.replace(separators, '/').replace(/^\/+/, '') } export function relativeToSearchRoot(rootPath: string, absolutePath: string): string { return pathFlavor(rootPath).relative(rootPath, absolutePath) } +export function resolveSearchResultPath(rootPath: string, reportedPath: string): string { + const paths = pathFlavor(rootPath) + return paths.isAbsolute(reportedPath) ? reportedPath : paths.resolve(rootPath, reportedPath) +} + export function joinSearchRoot(rootPath: string, relativePath: string): string { return pathFlavor(rootPath).join(rootPath, relativePath) } diff --git a/src/shared/text-search.ts b/src/shared/text-search.ts index b6f9e9e6b23..c811425a3d0 100644 --- a/src/shared/text-search.ts +++ b/src/shared/text-search.ts @@ -7,13 +7,20 @@ * can't re-diverge (notably the relay's old execFile maxBuffer that dropped matches). * Design doc: docs/design/share-text-search.md. */ -import { assertJsonTextStructureWithinLimits } from './json-text-structure-limit' +import { parseRipgrepMatchJson, type RipgrepMatchMessage } from './ripgrep-dense-match-json' import { normalizeSearchResult } from './search-match-count' import { escapeRegex } from './string-utils' import type { SearchFileResult, SearchOptions, SearchResult } from './code-search-types' import { pushSearchMatch } from './text-search-match-accumulator' import { splitSearchGlobPatterns, toGitGlobPathspecs } from './text-search-glob-patterns' -import { joinSearchRoot, normalizeRelativePath, relativeToSearchRoot } from './text-search-paths' +import { + joinSearchRoot, + normalizeRelativePath, + relativeToSearchRoot, + resolveSearchResultPath +} from './text-search-paths' +import { ripgrepMatchRanges } from './ripgrep-match-offsets' +import { decodeRipgrepLine } from './ripgrep-line-decoding' export type SearchAccumulator = { fileMap: Map @@ -27,7 +34,6 @@ export function createAccumulator(): SearchAccumulator { // ─── Constants shared by both callers ──────────────────────────────── -export const MAX_MATCHES_PER_FILE = 100 export const DEFAULT_SEARCH_MAX_RESULTS = 2000 export const SEARCH_TIMEOUT_MS = 15_000 export const SEARCH_JSON_STRUCTURE_LIMITS = { @@ -51,17 +57,15 @@ export type SearchOptionsLike = Pick< /** * Build the complete rg argv (flags + `--` + query + target) for both callers to spawn as-is. * - * Constraint: pass `rootPath` unchanged as `target` — do NOT WSL-translate it; only the rg - * invocation is routed through `wslAwareSpawn`, and output paths are translated back in `ingestRgJsonLine`. + * Use target `.` with cwd set to the search root so anchored globs match root-relative paths. */ export function buildRgArgs(query: string, target: string, opts: SearchOptionsLike): string[] { const args: string[] = [ + '--no-config', '--json', '--hidden', '--glob', '!.git', - '--max-count', - String(MAX_MATCHES_PER_FILE), '--max-filesize', `${Math.floor(SEARCH_MAX_FILE_SIZE / 1024 / 1024)}M` ] @@ -101,7 +105,7 @@ export function ingestRgJsonLine( rootPath: string, acc: SearchAccumulator, maxResults: number, - transformAbsPath?: (p: string) => string + transformAbsPath?: (p: string) => string | null ): 'continue' | 'stop' { if (acc.totalMatches >= maxResults) { return 'stop' @@ -109,19 +113,11 @@ export function ingestRgJsonLine( if (!line) { return 'continue' } - let msg: { - type?: string - data?: { - path?: { text?: string } - submatches?: { start: number; end: number }[] - line_number?: number - lines?: { text?: string } - } - } + let msg: RipgrepMatchMessage try { - assertJsonTextStructureWithinLimits(line, SEARCH_JSON_STRUCTURE_LIMITS) - msg = JSON.parse(line) + msg = parseRipgrepMatchJson(line, maxResults - acc.totalMatches, SEARCH_JSON_STRUCTURE_LIMITS) } catch { + acc.truncated = true return 'continue' } if (msg.type !== 'match' || !msg.data) { @@ -130,19 +126,22 @@ export function ingestRgJsonLine( const data = msg.data const rawPath = data.path?.text if (typeof rawPath !== 'string') { + // File APIs accept strings, so byte-only filenames cannot be opened losslessly. + acc.truncated = true return 'continue' } - const absPath = transformAbsPath ? transformAbsPath(rawPath) : rawPath - const relPath = normalizeRelativePath(relativeToSearchRoot(rootPath, absPath)) - const lineContent = (data.lines?.text ?? '').replace(/\n$/, '') - const lineNumber = data.line_number ?? 0 - let submatches = data.submatches ?? [] - if (submatches.length === 0) { - // Why: some rg matches report a line but no submatch ranges; surface a navigable line-level result instead of a count-0 row. - submatches = [{ start: 0, end: lineContent.length > 0 ? 1 : 0 }] + const mappedPath = transformAbsPath ? transformAbsPath(rawPath) : rawPath + if (mappedPath === null) { + acc.truncated = true + return 'continue' } - - for (const sub of submatches) { + const absPath = resolveSearchResultPath(rootPath, mappedPath) + const relPath = normalizeRelativePath(relativeToSearchRoot(rootPath, absPath), rootPath) + const { text: lineContent, readOffset } = decodeRipgrepLine(data.lines) + const lineNumber = data.line_number ?? 0 + for (const sub of ripgrepMatchRanges(lineContent, data.submatches ?? [], readOffset, () => { + acc.truncated = true + })) { let fileResult = acc.fileMap.get(absPath) if (!fileResult) { fileResult = { filePath: absPath, relativePath: relPath, matches: [], matchCount: 0 } @@ -263,7 +262,7 @@ export function ingestGitGrepLine( if (nullIdx === -1) { return 'continue' } - const relPath = normalizeRelativePath(line.substring(0, nullIdx)) + const relPath = normalizeRelativePath(line.substring(0, nullIdx), rootPath) const rest = line.substring(nullIdx + 1) const secondNullIdx = rest.indexOf('\0') let lineNumberText: string From e8310d5a4f4df2e49329d4934ff89ce982979c72 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 4 Oct 2026 01:41:01 -0700 Subject: [PATCH 04/31] fix(opencode): submit admitted native startup briefs without overwriting input (#24762) * fix: wait for OpenCode worker composer before first dispatch Reuse captured composer readiness on local and paired execution hosts and revoke launching-shell paste anchors. Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> * feat(opencode): probe execution-host CLI capabilities * fix(opencode): select plugin default for execution host loader * fix(opencode): limit prompt prefill capability to verified release * feat(opencode): probe launch capabilities on the execution host * fix(opencode): select plugin loader for the launched host binary * fix(opencode): match WSL probe cwd and declared guest environment * fix(opencode): preserve launch environment deletion boundaries * wip(opencode): authorize native startup prompt intent at execution owner * fix(opencode): atomically replace status plugin entrypoints * fix(opencode): retain plugin permissions across restrictive umasks * test(opencode): resolve permission fixture from primary cwd * feat(opencode): install startup prompt plugin independently of status hooks * fix(opencode): wait for admitted startup intent and preserve failed-launch briefs * fix(opencode): unsubscribe hook settings during async host shutdown * STRICT launch CI contract correction * CAPS launch CI contract correction * INTENT launch CI contract correction * test: initialize Claude prompt state in output retention fixture * Wait for OpenCode location hydration in intent startup * Bind OpenCode startup readiness to the current location in intent startup * Retry interrupted OpenCode startup prompt claims --------- Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Co-authored-by: Ahmed Nagy Co-authored-by: Orca startup hydration review Co-authored-by: Orca --- .../server-startup-prompt-control.test.ts | 146 ++++ .../server-transport-interference.test.ts | 45 +- .../agent-hooks/server/server-lifecycle.ts | 76 +- .../agent-hooks/server/server-listeners.ts | 8 + .../agent-hooks/server/server-runtime-env.ts | 2 +- src/main/agent-hooks/server/server-state.ts | 3 + .../server/server-status-hook-lifecycle.ts | 52 ++ src/main/global-fetch-call-site-audit.test.ts | 2 + .../pty-controller-process-inventory.test.ts | 14 +- src/main/ipc/pty-ipc-mock-registry.ts | 3 + src/main/ipc/pty/host-env/assembly.ts | 71 +- src/main/ipc/pty/host-env/opencode-config.ts | 84 ++ .../opencode-hook-installation.test.ts | 22 + src/main/ipc/pty/ipc/spawn-commit.ts | 6 + src/main/ipc/pty/ipc/spawn-options.ts | 4 +- src/main/ipc/pty/runtime/spawn-commit.ts | 6 + src/main/ipc/pty/runtime/spawn-options.ts | 4 +- src/main/opencode/hook-service.ts | 104 +-- .../opencode/opencode-overlay-manifest.ts | 29 + .../opencode/opencode-plugin-config-writer.ts | 45 ++ src/main/opencode/opencode-pty-launch.test.ts | 197 ++++- src/main/opencode/opencode-pty-launch.ts | 102 ++- .../opencode-startup-prompt-claims.test.ts | 171 ++++ .../opencode-startup-prompt-claims.ts | 120 +++ .../opencode-startup-prompt-installer.test.ts | 253 ++++++ .../opencode-startup-prompt-installer.ts | 82 ++ .../opencode-startup-prompt-owner.test.ts | 172 ++++ .../opencode/opencode-startup-prompt-owner.ts | 111 +++ ...de-startup-prompt-runtime-identity.test.ts | 80 ++ .../opencode-startup-prompt-source.test.ts | 761 ++++++++++++++++++ .../opencode-startup-prompt-source.ts | 152 ++++ src/main/orcad/orcad-entry.ts | 16 +- src/main/orcad/orcad-push-startup.test.ts | 29 +- src/main/providers/provider-dispatch.test.ts | 19 +- ...-authoritative-terminal-wait-permission.ts | 19 + src/main/runtime/terminal-run-facts.test.ts | 15 + src/main/runtime/terminal-run-facts.ts | 41 +- .../headless-pty-hydration-ordering.test.ts | 18 +- src/main/startup/main-process-pty-startup.ts | 4 +- .../startup/main-process-ready-foundation.ts | 2 + src/relay/opencode-overlay-mirror.ts | 40 + .../opencode-startup-prompt-install.test.ts | 86 ++ src/relay/plugin-overlay.ts | 96 +-- src/relay/pty-handler.ts | 17 + src/relay/relay-agent-hook-runtime.ts | 7 + src/relay/wsl-install-plugins-handler.ts | 24 + src/shared/opencode-headless-command.test.ts | 64 ++ src/shared/opencode-headless-command.ts | 105 ++- src/shared/opencode-startup-prompt-install.ts | 17 + src/shared/opencode-startup-prompt.test.ts | 52 ++ src/shared/opencode-startup-prompt.ts | 39 + src/shared/opencode-tui-plugin-install.ts | 18 +- src/shared/tui-agent-startup.ts | 3 +- 53 files changed, 3406 insertions(+), 252 deletions(-) create mode 100644 src/main/agent-hooks/server-startup-prompt-control.test.ts create mode 100644 src/main/agent-hooks/server/server-status-hook-lifecycle.ts create mode 100644 src/main/ipc/pty/host-env/opencode-config.ts create mode 100644 src/main/opencode/opencode-overlay-manifest.ts create mode 100644 src/main/opencode/opencode-plugin-config-writer.ts create mode 100644 src/main/opencode/opencode-startup-prompt-claims.test.ts create mode 100644 src/main/opencode/opencode-startup-prompt-claims.ts create mode 100644 src/main/opencode/opencode-startup-prompt-installer.test.ts create mode 100644 src/main/opencode/opencode-startup-prompt-installer.ts create mode 100644 src/main/opencode/opencode-startup-prompt-owner.test.ts create mode 100644 src/main/opencode/opencode-startup-prompt-owner.ts create mode 100644 src/main/opencode/opencode-startup-prompt-runtime-identity.test.ts create mode 100644 src/main/opencode/opencode-startup-prompt-source.test.ts create mode 100644 src/main/opencode/opencode-startup-prompt-source.ts create mode 100644 src/relay/opencode-overlay-mirror.ts create mode 100644 src/relay/opencode-startup-prompt-install.test.ts create mode 100644 src/shared/opencode-startup-prompt-install.ts create mode 100644 src/shared/opencode-startup-prompt.test.ts create mode 100644 src/shared/opencode-startup-prompt.ts diff --git a/src/main/agent-hooks/server-startup-prompt-control.test.ts b/src/main/agent-hooks/server-startup-prompt-control.test.ts new file mode 100644 index 00000000000..19a6efd5a89 --- /dev/null +++ b/src/main/agent-hooks/server-startup-prompt-control.test.ts @@ -0,0 +1,146 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { AgentHookServer } from './server' +import { parseAgentHookEndpointFile } from '../../shared/agent-hook-endpoint-file' +import { OPENCODE_STARTUP_PROMPT_CLAIM_PATH } from '../../shared/opencode-startup-prompt' +import { PANE } from './server.test-fixtures' + +describe('startup prompt control with status hooks disabled', () => { + it.each([false, true])( + 'persists a pending terminal status at shutdown after starting with hooks %s', + async (statusHooksEnabled) => { + const dir = mkdtempSync(join(tmpdir(), 'orca-prompt-terminal-persist-')) + const server = new AgentHookServer() + try { + await server.start({ userDataPath: dir, statusHooksEnabled }) + server.setStatusHooksEnabled(false) + server.ingestTerminalStatus({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'folder-1', + payload: { state: 'done', prompt: 'terminal status survives quit', agentType: 'opencode' } + }) + expect(server.getStatusSnapshotForPane(PANE)[0]?.state).toBe('done') + const statusPath = server.lastStatusPath + if (!statusPath) { + throw new Error('missing status persistence path') + } + server.stop() + expect(existsSync(statusPath)).toBe(true) + expect(JSON.parse(readFileSync(statusPath, 'utf8')).entries[PANE]).toMatchObject({ + paneKey: PANE, + worktreeId: 'folder-1', + payload: { state: 'done', prompt: 'terminal status survives quit', agentType: 'opencode' } + }) + } finally { + server.stop() + rmSync(dir, { recursive: true, force: true }) + } + } + ) + + it('enables and disables status without restarting the control listener', async () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-prompt-toggle-')) + class IsolatedHookServer extends AgentHookServer { + constructor() { + super() + this._setOpenCodeBinderDepsForTests({ + dbPath: () => join(dir, 'no-user-db'), + listSessions: async () => [], + listPanes: () => [], + sweep: async () => [] + }) + } + } + const server = new IsolatedHookServer() + try { + await server.start({ userDataPath: dir, statusHooksEnabled: false }) + const endpoint = server.endpointFilePath + if (!endpoint) { + throw new Error('missing control endpoint') + } + const coords = parseAgentHookEndpointFile(readFileSync(endpoint, 'utf8')) + const post = (path: string) => + fetch(`http://127.0.0.1:${coords.port}${path}`, { + method: 'POST', + headers: { 'x-orca-agent-hook-token': coords.token }, + body: '{}' + }) + expect((await post('/hook/opencode')).status).toBe(404) + await server.start({ statusHooksEnabled: true }) + expect(server.buildPtyEnv()).toHaveProperty('ORCA_AGENT_HOOK_PORT', coords.port) + expect((await post('/hook/opencode')).status).toBe(204) + server.setStatusHooksEnabled(false) + expect(server.buildPtyEnv()).toEqual({}) + expect((await post('/hook/opencode')).status).toBe(404) + await server.start() + expect((await post('/hook/opencode')).status).toBe(404) + server.setStartupPromptClaimListener( + () => 'pending', + () => {} + ) + expect(await (await post(OPENCODE_STARTUP_PROMPT_CLAIM_PATH)).json()).toEqual({ + allowed: false, + pending: true + }) + server.setStatusHooksEnabled(true) + expect((await post('/hook/opencode')).status).toBe(204) + expect(server.endpointFilePath).toBe(endpoint) + expect(parseAgentHookEndpointFile(readFileSync(endpoint, 'utf8'))).toEqual(coords) + } finally { + server.stop() + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('authenticates claims, denies malformed or missing handlers, and refuses status posts', async () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-prompt-control-')) + const server = new AgentHookServer() + try { + await server.start({ userDataPath: dir, statusHooksEnabled: false }) + expect(server.buildPtyEnv()).toEqual({}) + const statusPath = server.lastStatusPath + if (!statusPath) { + throw new Error('missing status persistence path') + } + writeFileSync(statusPath, 'existing status must survive control-only shutdown') + const endpoint = server.endpointFilePath + if (!endpoint) { + throw new Error('missing control endpoint') + } + const coords = parseAgentHookEndpointFile(readFileSync(endpoint, 'utf8')) + const post = (path: string, body: string, token = coords.token) => + fetch(`http://127.0.0.1:${coords.port}${path}`, { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-orca-agent-hook-token': token }, + body + }) + expect((await post(OPENCODE_STARTUP_PROMPT_CLAIM_PATH, '{}', 'wrong')).status).toBe(403) + expect(await (await post(OPENCODE_STARTUP_PROMPT_CLAIM_PATH, '{}')).json()).toEqual({ + allowed: false + }) + const clear = vi.fn() + const claim = vi.fn(() => true) + server.setStartupPromptClaimListener(claim, clear) + expect(await (await post(OPENCODE_STARTUP_PROMPT_CLAIM_PATH, '{}')).json()).toEqual({ + allowed: true + }) + expect(await (await post(OPENCODE_STARTUP_PROMPT_CLAIM_PATH, '{')).json()).toEqual({ + allowed: false + }) + expect(claim).toHaveBeenCalledTimes(1) + expect((await post('/hook/opencode', '{}')).status).toBe(404) + expect((await post('/statusline/claude', '{}')).status).toBe(404) + server.stop() + expect(clear).toHaveBeenCalledTimes(1) + expect(readFileSync(statusPath, 'utf8')).toBe( + 'existing status must survive control-only shutdown' + ) + } finally { + server.stop() + rmSync(dir, { recursive: true, force: true }) + } + }) +}) diff --git a/src/main/agent-hooks/server-transport-interference.test.ts b/src/main/agent-hooks/server-transport-interference.test.ts index 37c80087bdb..e8d5a8e357b 100644 --- a/src/main/agent-hooks/server-transport-interference.test.ts +++ b/src/main/agent-hooks/server-transport-interference.test.ts @@ -2,9 +2,11 @@ // short of its own Content-Length. The listener fails open on every request error, so the only // way this stays diagnosable is if the truncation is classified before it is swallowed. import { connect } from 'node:net' +import { ServerResponse } from 'node:http' import { afterEach, describe, expect, it, vi } from 'vitest' import type { HookTransportInterferenceReport } from '../../shared/agent-hook-transport-interference' import { AgentHookServer } from './server' +import { OPENCODE_STARTUP_PROMPT_CLAIM_PATH } from '../../shared/opencode-startup-prompt' async function postTruncatedHook( port: number, @@ -40,11 +42,15 @@ async function postTruncatedHook( } /** Opens a POST that announces a body and then never sends it, so Orca's own slowloris cap ends it. */ -async function postStalledHook(port: number, token: string): Promise { +async function postStalledHook( + port: number, + token: string, + pathname = '/hook/claude' +): Promise { const socket = connect({ port, host: '127.0.0.1' }) await new Promise((resolve) => socket.on('connect', () => resolve())) socket.write( - `POST /hook/claude HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/x-www-form-urlencoded\r\nX-Orca-Agent-Hook-Token: ${token}\r\nContent-Length: 100000\r\n\r\n` + `POST ${pathname} HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/x-www-form-urlencoded\r\nX-Orca-Agent-Hook-Token: ${token}\r\nContent-Length: 100000\r\n\r\n` ) await new Promise((resolve) => { socket.on('close', () => resolve()) @@ -118,6 +124,41 @@ describe('AgentHookServer transport interference', () => { expect(reports).toHaveLength(1) }, 20_000) + it('classifies an interrupted startup claim as retryable without consuming it', async () => { + const { server, port, token, reports } = await startServer() + const claim = vi.fn(() => true) + server.setStartupPromptClaimListener(claim, () => {}) + const writeHead = vi.spyOn(ServerResponse.prototype, 'writeHead') + try { + await postTruncatedHook(port, token, { + pathname: OPENCODE_STARTUP_PROMPT_CLAIM_PATH, + sentBytes: '{"nonce":', + announcedLength: 1000 + }) + // The reset peer cannot receive this response; observe the real handler's classification. + expect(writeHead.mock.calls).toEqual([[503]]) + expect(claim).not.toHaveBeenCalled() + expect(reports).toEqual([]) + } finally { + writeHead.mockRestore() + } + }) + + it('keeps a startup claim stopped by its own slowloris cap as a denial', async () => { + const { server, port, token, reports } = await startServer() + const claim = vi.fn(() => true) + server.setStartupPromptClaimListener(claim, () => {}) + const writeHead = vi.spyOn(ServerResponse.prototype, 'writeHead') + try { + await postStalledHook(port, token, OPENCODE_STARTUP_PROMPT_CLAIM_PATH) + expect(writeHead.mock.calls).toEqual([[200, { 'content-type': 'application/json' }]]) + expect(claim).not.toHaveBeenCalled() + expect(reports).toEqual([]) + } finally { + writeHead.mockRestore() + } + }, 30_000) + it('never reports for POSTs that deliver their whole body', async () => { const { port, token, reports } = await startServer() diff --git a/src/main/agent-hooks/server/server-lifecycle.ts b/src/main/agent-hooks/server/server-lifecycle.ts index 80c81f09142..825b51b8a63 100644 --- a/src/main/agent-hooks/server/server-lifecycle.ts +++ b/src/main/agent-hooks/server/server-lifecycle.ts @@ -11,21 +11,26 @@ import { readRequestBody } from '../../../shared/agent-hook-listener/request-bod import { resolveHookSource } from '../../../shared/agent-hook-listener/source-routing' import { HOOK_REQUEST_SLOWLORIS_MS } from '../../../shared/agent-hook-listener/listener-limits' import { isHookRequestTruncatedError } from '../../../shared/agent-hook-transport-interference' -import { drainAgentHookSpool, type SpoolRecord } from '../../../shared/agent-hook-spool' import { clearAllListenerCaches } from '../../../shared/agent-hook-listener/listener-state' import { trackEmptyPaneKeyHook } from './server-transport-rules' -import { AgentHookServerRuntimeEnv } from './server-runtime-env' +import { AgentHookServerStatusHookLifecycle } from './server-status-hook-lifecycle' +import { OPENCODE_STARTUP_PROMPT_CLAIM_PATH } from '../../../shared/opencode-startup-prompt' -export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv { +export abstract class AgentHookServerLifecycle extends AgentHookServerStatusHookLifecycle { /** Start the loopback listener after hydration and spool replay have settled. */ async start(options?: { env?: string userDataPath?: string endpointNamespace?: string + statusHooksEnabled?: boolean }): Promise { if (this.server) { + if (options?.statusHooksEnabled !== undefined) { + this.setStatusHooksEnabled(options.statusHooksEnabled) + } return } + this.statusHooksEnabled = options?.statusHooksEnabled !== false if (options?.env) { this.env = options.env @@ -37,30 +42,8 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv this.token = randomUUID() this.endpointFileWritten = false this.lastWrittenJson = null - if (!this.ownerStateInitialized) { - // Why: hydrate before binding the listener so an early hook POST runs against a populated map. - if (this.lastStatusFilePath) { - this.hydrateLastStatusFromDisk() - } - this.captureHydratedAuthorityCommitments() - // Drain before binding the listener so replay cannot race a live hook during startup. - if (this.endpointDir) { - const replayedPaneKeys = new Set() - drainAgentHookSpool({ - endpointDir: this.endpointDir, - getPersistedLaunchTokenHash: (paneKey) => - this.hydratedLaunchTokenHashByPaneKey.get(this.resolvePaneKeyAlias(paneKey)), - ingest: (record: SpoolRecord) => { - this.ingestSpoolRecord(record) - replayedPaneKeys.add(this.resolvePaneKeyAlias(record.paneKey)) - } - }) - // Why: the owner may have died while Orca was down; check each replayed pane once. - for (const paneKey of replayedPaneKeys) { - void this.checkAgentPresence(paneKey) - } - } - this.ownerStateInitialized = true + if (this.statusHooksEnabled) { + this.initializeStatusHookOwner() } const handleRequest = async (req: IncomingMessage, res: ServerResponse): Promise => { if (req.method !== 'POST') { @@ -84,6 +67,22 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv const pathname = new URL(req.url ?? '/', 'http://127.0.0.1').pathname try { const body = await readRequestBody(req) + if (pathname === OPENCODE_STARTUP_PROMPT_CLAIM_PATH) { + res.writeHead(200, { 'content-type': 'application/json' }) + const claim = this.onStartupPromptClaim?.(body) + res.end( + JSON.stringify({ + allowed: claim === true, + ...(claim === 'pending' ? { pending: true } : {}) + }) + ) + return + } + if (!this.statusHooksEnabled) { + res.writeHead(404) + res.end() + return + } if (pathname === CLAUDE_STATUSLINE_PATHNAME) { const statusLineEvent = parseClaudeStatusLineBody(body) if (statusLineEvent) { @@ -152,6 +151,16 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv res.writeHead(204) res.end() } catch (error) { + if (pathname === OPENCODE_STARTUP_PROMPT_CLAIM_PATH) { + if (isHookRequestTruncatedError(error) && !destroyedBySlowlorisCap) { + res.writeHead(503) + res.end() + return + } + res.writeHead(200, { 'content-type': 'application/json' }) + res.end('{"allowed":false}') + return + } // Why (#11217): an authenticated POST whose body dies short of its own Content-Length was cut // by something on the loopback path, not by a bad payload. Fail open as before, but count it — // this is the one failure mode that silently stops status for every runtime at once. @@ -192,7 +201,9 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv this.rollbackTransportStart() throw error } - this.startOpenCodeBinderLoop() + if (this.statusHooksEnabled) { + this.startOpenCodeBinderLoop() + } } private rollbackTransportStart(): void { @@ -204,14 +215,19 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv } stop(): void { - // Why: flush the pending debounced write before clearing the map, else a hook <250ms before quit is lost on relaunch. - this.flushStatusPersistSync() + // Terminal status may still have a pending write while hook ingress is disabled. + if (this.statusHooksEnabled || this.statusPersistTimer) { + this.flushStatusPersistSync() + } this.stopOpenCodeBinderLoop() this.stopTmuxStatus() this.rollbackTransportStart() this.env = 'production' this.onAgentStatus = null this.onClaudeStatusLine = null + this.clearStartupPromptClaims?.() + this.clearStartupPromptClaims = null + this.onStartupPromptClaim = null this.onPaneStatusCleared = null this.onTransportInterference = null this.transportInterference.reset() diff --git a/src/main/agent-hooks/server/server-listeners.ts b/src/main/agent-hooks/server/server-listeners.ts index 1de09c3ebdf..14b78262734 100644 --- a/src/main/agent-hooks/server/server-listeners.ts +++ b/src/main/agent-hooks/server/server-listeners.ts @@ -26,6 +26,14 @@ import { structuredStatusLegacyEvent } from './server-structured-status-row' const UNORDERED_STATUS_ROW = Number.MAX_SAFE_INTEGER export abstract class AgentHookServerListeners extends AgentHookServerState { + setStartupPromptClaimListener( + listener: (body: unknown) => boolean | 'pending', + clear: () => void + ): void { + this.onStartupPromptClaim = listener + this.clearStartupPromptClaims = clear + } + protected emitEnrichedStatus(enriched: EnrichedAgentHookEventPayload): void { this.onAgentStatus?.(enriched) for (const listener of this.enrichedStatusListeners) { diff --git a/src/main/agent-hooks/server/server-runtime-env.ts b/src/main/agent-hooks/server/server-runtime-env.ts index 7bf7eaaee30..2f94bdd4b94 100644 --- a/src/main/agent-hooks/server/server-runtime-env.ts +++ b/src/main/agent-hooks/server/server-runtime-env.ts @@ -11,7 +11,7 @@ import { AgentHookServerIngestRemote } from './server-ingest-remote' export abstract class AgentHookServerRuntimeEnv extends AgentHookServerIngestRemote { buildPtyEnv(): Record { - if (this.port <= 0 || !this.token) { + if (!this.statusHooksEnabled || this.port <= 0 || !this.token) { return {} } const env: Record = { diff --git a/src/main/agent-hooks/server/server-state.ts b/src/main/agent-hooks/server/server-state.ts index f429c0e6350..172eaeaf1ef 100644 --- a/src/main/agent-hooks/server/server-state.ts +++ b/src/main/agent-hooks/server/server-state.ts @@ -89,6 +89,9 @@ export abstract class AgentHookServerState { protected env = 'production' protected onAgentStatus: ServerAgentStatusListener = null protected onClaudeStatusLine: ServerStatusLineListener = null + protected onStartupPromptClaim: ((body: unknown) => boolean | 'pending') | null = null + protected clearStartupPromptClaims: (() => void) | null = null + protected statusHooksEnabled = true protected onPaneStatusCleared: PaneStatusClearListener | null = null protected paneStatusClearListeners = new Set() protected statusDropListeners = new Set() diff --git a/src/main/agent-hooks/server/server-status-hook-lifecycle.ts b/src/main/agent-hooks/server/server-status-hook-lifecycle.ts new file mode 100644 index 00000000000..15a43182ab2 --- /dev/null +++ b/src/main/agent-hooks/server/server-status-hook-lifecycle.ts @@ -0,0 +1,52 @@ +import { drainAgentHookSpool, type SpoolRecord } from '../../../shared/agent-hook-spool' +import { AgentHookServerRuntimeEnv } from './server-runtime-env' + +export abstract class AgentHookServerStatusHookLifecycle extends AgentHookServerRuntimeEnv { + setStatusHooksEnabled(enabled: boolean): void { + if (enabled === this.statusHooksEnabled) { + return + } + if (!enabled) { + this.flushStatusPersistSync() + this.stopOpenCodeBinderLoop() + for (const timer of this.assistantMessageRetryTimers.values()) { + clearTimeout(timer) + } + this.assistantMessageRetryTimers.clear() + this.clearAllTranscriptPolls() + } + this.statusHooksEnabled = enabled + if (enabled && this.server) { + this.initializeStatusHookOwner() + this.startOpenCodeBinderLoop() + } + } + + protected initializeStatusHookOwner(): void { + if (!this.ownerStateInitialized) { + // Why: hydrate before binding the listener so an early hook POST runs against a populated map. + if (this.lastStatusFilePath) { + this.hydrateLastStatusFromDisk() + } + this.captureHydratedAuthorityCommitments() + // Drain before binding the listener so replay cannot race a live hook during startup. + if (this.endpointDir) { + const replayedPaneKeys = new Set() + drainAgentHookSpool({ + endpointDir: this.endpointDir, + getPersistedLaunchTokenHash: (paneKey) => + this.hydratedLaunchTokenHashByPaneKey.get(this.resolvePaneKeyAlias(paneKey)), + ingest: (record: SpoolRecord) => { + this.ingestSpoolRecord(record) + replayedPaneKeys.add(this.resolvePaneKeyAlias(record.paneKey)) + } + }) + // Why: the owner may have died while Orca was down; check each replayed pane once. + for (const paneKey of replayedPaneKeys) { + void this.checkAgentPresence(paneKey) + } + } + this.ownerStateInitialized = true + } + } +} diff --git a/src/main/global-fetch-call-site-audit.test.ts b/src/main/global-fetch-call-site-audit.test.ts index 907092515f5..664bbaf1aa3 100644 --- a/src/main/global-fetch-call-site-audit.test.ts +++ b/src/main/global-fetch-call-site-audit.test.ts @@ -20,6 +20,8 @@ const AUDITED_GLOBAL_FETCH_LINES = new Map([ ['main/bitbucket/client.ts', 1], ['main/bitbucket/user-request.ts', 1], ['main/gitea/client.ts', 1], + // Generated OpenCode claim source consumes JSON or cancels its body in finally. + ['main/opencode/opencode-startup-prompt-source.ts', 1], ['main/orca-profiles/profile-cloud-client.ts', 1], ['main/orca-profiles/profile-cloud-org-members-client.ts', 1], ['main/rate-limits/codex-fetcher.ts', 3], diff --git a/src/main/ipc/pty-controller-process-inventory.test.ts b/src/main/ipc/pty-controller-process-inventory.test.ts index 004735c61cb..50b67963ab9 100644 --- a/src/main/ipc/pty-controller-process-inventory.test.ts +++ b/src/main/ipc/pty-controller-process-inventory.test.ts @@ -1,3 +1,4 @@ +import { openCodeHookServiceModuleMock } from './pty-ipc-mock-registry' import { afterEach, describe, expect, it, vi } from 'vitest' const { handleMock, onMock, removeHandlerMock, removeAllListenersMock } = vi.hoisted(() => ({ @@ -46,18 +47,7 @@ vi.mock('node-pty', () => ({ }) })) -vi.mock('../opencode/hook-service', () => ({ - openCodeHookService: { - buildPtyEnv: () => ({}), - refreshLegacySharedPlugin: vi.fn(), - clearPty: vi.fn() - }, - openCode2HookService: { - buildPtyEnv: () => ({}), - refreshLegacySharedPlugin: vi.fn(), - clearPty: vi.fn() - } -})) +vi.mock('../opencode/hook-service', () => openCodeHookServiceModuleMock()) vi.mock('../pi/titlebar-extension-service', () => ({ piTitlebarExtensionService: { buildPtyEnv: () => ({}), clearPty: vi.fn() } diff --git a/src/main/ipc/pty-ipc-mock-registry.ts b/src/main/ipc/pty-ipc-mock-registry.ts index f49978843d5..4eba8d0b164 100644 --- a/src/main/ipc/pty-ipc-mock-registry.ts +++ b/src/main/ipc/pty-ipc-mock-registry.ts @@ -106,6 +106,9 @@ export const childProcessModuleMock = (original: Record) => ({ }) export const openCodeHookServiceModuleMock = () => ({ + OpenCodeHookService: class { + buildPtyEnv = vi.fn(() => ({})) + }, openCodeHookService: { buildPtyEnv: openCodeBuildPtyEnvMock, refreshLegacySharedPlugin: vi.fn<() => void>(), diff --git a/src/main/ipc/pty/host-env/assembly.ts b/src/main/ipc/pty/host-env/assembly.ts index 1cae20bfcbd..8b921b4ebde 100644 --- a/src/main/ipc/pty/host-env/assembly.ts +++ b/src/main/ipc/pty/host-env/assembly.ts @@ -1,15 +1,10 @@ import { resolveSetupAgentSequenceLaunchCommand } from '../../../../shared/setup-agent-sequencing' -import { selectOpenCodeHookAgent } from '../../../../shared/opencode-launch-command' import { detectExplicitPiAgentKindFromCommand, isPiCompatibleAgentType } from '../../../../shared/pi-agent-kind' import { applyTerminalGitCredentialPromptGuard } from '../../terminal-git-credential-guard' -import { openCode2HookService, openCodeHookService } from '../../../opencode/hook-service' -import { - OPENCODE_CONFIG_DIR_ENV_KEYS, - isOpenCodeLegacySharedConfigDir -} from '../../../opencode/legacy-shared-config-dir' +import { ensureOpenCodeStartupPromptForLaunch } from '../../../opencode/opencode-startup-prompt-installer' import { mimoCodeHookService } from '../../../mimo/hook-service' import { agentHookServer } from '../../../agent-hooks/server' import { wslHookRelayManager } from '../../../agent-hooks/wsl-hook-relay-manager' @@ -28,13 +23,13 @@ import { exposePiManagedExtensionEnv, isMimoLaunchCommand, resolveMimocodeSourceHome, - resolveOpenCodeSourceConfigDir, resolvePiAgentSourceDir, resolveScopedPiAgentSourceDir, restoreOrStripOverlayEnv } from './pi-agent' import { AGENT_HOOK_RUNTIME_ENV_KEYS } from './spawn-env-keys' import { applyManagedDataAccountEnvironment } from '../../../managed-data-accounts/launch-environment' +import { applyOpenCodeStatusPluginEnv, captureOpenCodeSourceConfig } from './opencode-config' /** * Mutates `baseEnv` in place with all host-local PTY env vars and returns it. @@ -50,32 +45,9 @@ export function buildPtyHostEnv( mergePersistedWindowsPath(baseEnv) Object.assign(baseEnv, buildConfiguredProxyEnv(opts.networkProxySettings)) - // Why: pre-1.4.209 panes exported Orca's retired shared hooks dir; inheriting it hides the user's global OpenCode config. - const isLegacyOpenCodeHooksDir = (dir: string | undefined): boolean => - isOpenCodeLegacySharedConfigDir(dir, opts.userDataPath) - const inheritedOpenCodeEnv: NodeJS.ProcessEnv = {} - for (const key of OPENCODE_CONFIG_DIR_ENV_KEYS) { - if (isLegacyOpenCodeHooksDir(baseEnv[key])) { - delete baseEnv[key] - } - if (!isLegacyOpenCodeHooksDir(process.env[key])) { - inheritedOpenCodeEnv[key] = process.env[key] - } - } - // A daemon or sibling shell can retain a retired path that main no longer sees. - openCodeHookService.refreshLegacySharedPlugin() - openCode2HookService.refreshLegacySharedPlugin() - const resolvedOpenCodeConfigDir = resolveOpenCodeSourceConfigDir(baseEnv, inheritedOpenCodeEnv) - const preexistingOpenCodeConfigDir = isLegacyOpenCodeHooksDir(resolvedOpenCodeConfigDir) - ? undefined - : resolvedOpenCodeConfigDir + const openCodeConfig = captureOpenCodeSourceConfig(baseEnv, opts.userDataPath) const launchCommandHint = resolveSetupAgentSequenceLaunchCommand(baseEnv, opts.launchCommand) applyManagedDataAccountEnvironment(baseEnv, { ...opts, launchCommand: launchCommandHint }) - const openCodeAgent = selectOpenCodeHookAgent( - opts.launchAgent, - launchCommandHint, - (agent) => opts.agentStatusHooksEnabled && isTuiAgentEnabled(agent, opts.disabledTuiAgents) - ) const explicitPiAgentKind = isPiCompatibleAgentType(opts.launchAgent) ? opts.launchAgent : opts.launchAgent === undefined @@ -110,37 +82,13 @@ export function buildPtyHostEnv( ? resolvePiAgentSourceDir(baseEnv, 'prime-agent') : resolveScopedPiAgentSourceDir(baseEnv, 'prime-agent') - restoreOrStripOverlayEnv( + const openCodeAgent = applyOpenCodeStatusPluginEnv( + id, baseEnv, - { - primary: 'OPENCODE_CONFIG_DIR', - overlay: 'ORCA_OPENCODE_CONFIG_DIR', - source: 'ORCA_OPENCODE_SOURCE_CONFIG_DIR', - preserveExplicitPrimary: true - }, - inheritedOpenCodeEnv + openCodeConfig, + opts, + launchCommandHint ) - delete baseEnv.ORCA_OPENCODE_AGENT - if (openCodeAgent) { - // Why: OPENCODE_CONFIG_DIR is a single path, not a colon-list; mirror the user's value into an overlay so their plugins and Orca's status plugin coexist. See docs/opencode-config-dir-collision.md. - const openCodeStatusService = - openCodeAgent === 'opencode2' ? openCode2HookService : openCodeHookService - baseEnv.ORCA_OPENCODE_AGENT = openCodeAgent - // WSL owns its config writes; only the guest overlay may enter a WSL pane. - if (!opts.isWsl) { - Object.assign(baseEnv, openCodeStatusService.buildPtyEnv(id, preexistingOpenCodeConfigDir)) - } - if (baseEnv.OPENCODE_CONFIG_DIR) { - // Why: ~/.zshrc can re-export the user's default after spawn; shell-ready wrappers restore this PTY-scoped value. - baseEnv.ORCA_OPENCODE_CONFIG_DIR = baseEnv.OPENCODE_CONFIG_DIR - if (preexistingOpenCodeConfigDir) { - // Why: nested Orca terminals inherit the overlay as OPENCODE_CONFIG_DIR; keep the real source so overlays don't mirror overlays. - baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR = preexistingOpenCodeConfigDir - } else { - delete baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR - } - } - } if (opts.agentStatusHooksEnabled) { if (isMimoLaunchCommand(launchCommandHint)) { const preexistingMimocodeHome = resolveMimocodeSourceHome(baseEnv) @@ -343,5 +291,8 @@ export function buildPtyHostEnv( // process.env when baseEnv carries none, which is the daemon path's normal shape. stripLegacyTerminalShimEnv(baseEnv, process.platform) + if (!opts.isWsl) { + ensureOpenCodeStartupPromptForLaunch(baseEnv) + } return baseEnv } diff --git a/src/main/ipc/pty/host-env/opencode-config.ts b/src/main/ipc/pty/host-env/opencode-config.ts new file mode 100644 index 00000000000..88dafabc88e --- /dev/null +++ b/src/main/ipc/pty/host-env/opencode-config.ts @@ -0,0 +1,84 @@ +import { + OPENCODE_CONFIG_DIR_ENV_KEYS, + isOpenCodeLegacySharedConfigDir +} from '../../../opencode/legacy-shared-config-dir' +import { openCode2HookService, openCodeHookService } from '../../../opencode/hook-service' +import { resolveOpenCodeSourceConfigDir, restoreOrStripOverlayEnv } from './pi-agent' +import { selectOpenCodeHookAgent } from '../../../../shared/opencode-launch-command' +import { isTuiAgentEnabled } from '../../../../shared/tui-agent-selection' +import type { BuildPtyHostEnvOptions } from './types' + +type OpenCodeSourceConfig = { + inheritedEnv: NodeJS.ProcessEnv + directory: string | undefined +} + +export function captureOpenCodeSourceConfig( + env: Record, + userDataPath: string +): OpenCodeSourceConfig { + const isLegacyDirectory = (dir: string | undefined): boolean => + isOpenCodeLegacySharedConfigDir(dir, userDataPath) + const inheritedEnv: NodeJS.ProcessEnv = {} + for (const key of OPENCODE_CONFIG_DIR_ENV_KEYS) { + if (isLegacyDirectory(env[key])) { + delete env[key] + } + if (!isLegacyDirectory(process.env[key])) { + inheritedEnv[key] = process.env[key] + } + } + // A daemon or sibling shell can retain a retired path that main no longer sees. + openCodeHookService.refreshLegacySharedPlugin() + openCode2HookService.refreshLegacySharedPlugin() + const directory = resolveOpenCodeSourceConfigDir(env, inheritedEnv) + return { inheritedEnv, directory: isLegacyDirectory(directory) ? undefined : directory } +} + +export function applyOpenCodeStatusPluginEnv( + id: string, + env: Record, + config: OpenCodeSourceConfig, + options: Pick< + BuildPtyHostEnvOptions, + 'launchAgent' | 'agentStatusHooksEnabled' | 'disabledTuiAgents' | 'isWsl' + >, + command: string | undefined +): 'opencode' | 'opencode2' | null { + const agent = selectOpenCodeHookAgent( + options.launchAgent, + command, + (candidate) => + options.agentStatusHooksEnabled && isTuiAgentEnabled(candidate, options.disabledTuiAgents) + ) + restoreOrStripOverlayEnv( + env, + { + primary: 'OPENCODE_CONFIG_DIR', + overlay: 'ORCA_OPENCODE_CONFIG_DIR', + source: 'ORCA_OPENCODE_SOURCE_CONFIG_DIR', + preserveExplicitPrimary: true + }, + config.inheritedEnv + ) + delete env.ORCA_OPENCODE_AGENT + if (!agent) { + return null + } + const service = agent === 'opencode2' ? openCode2HookService : openCodeHookService + env.ORCA_OPENCODE_AGENT = agent + // WSL owns its config writes; only the guest overlay may enter a WSL pane. + if (!options.isWsl) { + Object.assign(env, service.buildPtyEnv(id, config.directory)) + } + if (env.OPENCODE_CONFIG_DIR) { + // Shell startup can re-export the default; preserve this pane's overlay and original source. + env.ORCA_OPENCODE_CONFIG_DIR = env.OPENCODE_CONFIG_DIR + if (config.directory) { + env.ORCA_OPENCODE_SOURCE_CONFIG_DIR = config.directory + } else { + delete env.ORCA_OPENCODE_SOURCE_CONFIG_DIR + } + } + return agent +} diff --git a/src/main/ipc/pty/host-env/opencode-hook-installation.test.ts b/src/main/ipc/pty/host-env/opencode-hook-installation.test.ts index a52a9d70360..ddae2771aba 100644 --- a/src/main/ipc/pty/host-env/opencode-hook-installation.test.ts +++ b/src/main/ipc/pty/host-env/opencode-hook-installation.test.ts @@ -77,6 +77,28 @@ afterEach(() => { }) describe('OpenCode installation uses the current enabled agents', () => { + it('refuses spawn if a prepared startup intent loses its owned installer', () => { + mkdirSync(fixture.userData, { recursive: true }) + writeFileSync( + join(fixture.userData, 'opencode-startup-prompt-overlays'), + 'blocked fixture root' + ) + expect(() => + buildPtyHostEnv( + 'owned-launch', + { + OPENCODE_CONFIG_DIR: custom, + ORCA_OPENCODE_PLUGIN_API: 'v2', + ORCA_OPENCODE_STARTUP_PROMPT_NONCE: 'fixture-nonce', + ORCA_OPENCODE_STARTUP_PROMPT_BODY: 'original caller brief' + }, + { ...options, agentStatusHooksEnabled: false } + ) + ).toThrow('launch was canceled') + expect(readFileSync(join(custom, 'opencode.json'), 'utf8')).toBe('{"model":"fixture"}') + expect(readFileSync(join(custom, 'plugins', 'user.js'), 'utf8')).toBe('// user plugin') + }) + const combinations = [ { disabled: [], fallback: 'opencode' }, { disabled: ['opencode'], fallback: 'opencode2' }, diff --git a/src/main/ipc/pty/ipc/spawn-commit.ts b/src/main/ipc/pty/ipc/spawn-commit.ts index 77491c9fb11..24df59c1201 100644 --- a/src/main/ipc/pty/ipc/spawn-commit.ts +++ b/src/main/ipc/pty/ipc/spawn-commit.ts @@ -22,8 +22,13 @@ import { admitPtyReattachOwnership, registerPersistedPtySpawn } from '../pane/sp import { reflowHeadlessTerminalToCommittedGrid } from '../delivery/attached-pty-size' import { seedHeadlessTerminalFromSpawnResult } from '../pane/terminal-spawn-restore' import { markNativeWindowsConptyPty } from '../../../runtime/terminal-model-query-authority' +import { commitPtyWithOpenCodePromptIntent } from '../../../opencode/opencode-startup-prompt-owner' export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise { + return commitPtyWithOpenCodePromptIntent(ctx, () => commitReservedPtyIpcSpawn(ctx)) +} + +async function commitReservedPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise { const args = ctx.args admitPtyReattachOwnership(ctx.deps.runtime, ctx.result, args.connectionId) if (ctx.nativeWindowsConptySpawn) { @@ -100,6 +105,7 @@ export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise commitReservedRuntimePtySpawn(ctx)) +} + +async function commitReservedRuntimePtySpawn(ctx: RuntimePtySpawnState) { const args = ctx.args admitPtyReattachOwnership(ctx.deps.runtime, ctx.result, args.connectionId) const providerReattachLaunchIdentity = admitProviderReattachLaunchIdentity(ctx.result) @@ -205,6 +210,7 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) { if (ctx.result.incarnationId) { ptyIncarnationById.set(ctx.result.id, ctx.result.incarnationId) } + claimSshPaneLease({ store: ctx.deps.store, connectionId: args.connectionId, diff --git a/src/main/ipc/pty/runtime/spawn-options.ts b/src/main/ipc/pty/runtime/spawn-options.ts index 2dfdf2d0811..26c3856b45e 100644 --- a/src/main/ipc/pty/runtime/spawn-options.ts +++ b/src/main/ipc/pty/runtime/spawn-options.ts @@ -104,7 +104,7 @@ export async function buildRuntimePtySpawnOptions( env: ctx.env, envToDelete: ctx.spawnOptions.envToDelete }) - ctx.env = await prepareOpenCodePtyLaunch({ + const openCodeLaunch = await prepareOpenCodePtyLaunch({ command: ctx.launchCommand, agent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined, env: ctx.env, @@ -116,6 +116,8 @@ export async function buildRuntimePtySpawnOptions( ? { wsl: { distro: ctx.expectedWslDistro ?? undefined } } : {}) }) + ctx.env = openCodeLaunch.env + ctx.launchCommand = openCodeLaunch.command ctx.spawnOptions.env = ctx.env promoteAgentTeamsShimPath(ctx.env, ctx.requestedAgentTeamsPath) const noDaemonLaunch = planCodexNoDaemonLaunch({ diff --git a/src/main/opencode/hook-service.ts b/src/main/opencode/hook-service.ts index 41f65243ab9..9162499472f 100644 --- a/src/main/opencode/hook-service.ts +++ b/src/main/opencode/hook-service.ts @@ -2,6 +2,7 @@ import { getAppEnvironment } from '../../shared/app-environment' import { join } from 'node:path' import { existsSync, + lstatSync, mkdirSync, readFileSync, readdirSync, @@ -10,22 +11,23 @@ import { writeFileSync } from 'node:fs' import { createHash } from 'node:crypto' -import { mirrorEntry, safeRemoveTree } from '../pty/overlay-mirror' +import { isSafeDescendCandidate, mirrorEntry, safeRemoveTree } from '../pty/overlay-mirror' import { getOpenCode2PluginSource, getOpenCodeFamilyPluginSource, getOpenCodePluginSource } from './status-plugin-module-source' +import { + readOpenCodeOverlayManifest, + OPENCODE_OVERLAY_MANIFEST_FILE, + type OpenCodeOverlayManifest +} from './opencode-overlay-manifest' import { resolveOpenCodeConfigDirectory } from '../../shared/opencode-config-directory' import { getOpenCodeLegacySharedConfigDir, OPENCODE2_LEGACY_HOOKS_DIR, OPENCODE_LEGACY_HOOKS_DIR } from './legacy-shared-config-dir' -import { - isInstalledOpenCodePluginCurrent, - isOverlayOpenCodePluginCurrent -} from '../../shared/opencode-installed-plugin' import { openCodeTuiPluginDirName, writeOpenCodeTuiPlugin @@ -34,19 +36,11 @@ import { writeLegacyOpenCodePluginWithAclRetry } from './legacy-plugin-acl-retry export { getOpenCode2PluginSource, getOpenCodeFamilyPluginSource, getOpenCodePluginSource } -import { - writeCanonicalOpenCodePluginAtomically, - writeOverlayOpenCodePluginAtomically -} from '../../shared/opencode-plugin-atomic-write' +import { writeCanonicalOpenCodePluginAtomically } from '../../shared/opencode-plugin-atomic-write' +import { writeOpenCodePluginConfig } from './opencode-plugin-config-writer' const ORCA_OPENCODE_PLUGIN_FILE = 'orca-opencode-status.js' const OPENCODE_OVERLAY_DIR = 'opencode-config-overlays' -const OPENCODE_OVERLAY_MANIFEST_FILE = '.orca-opencode-overlay-manifest.json' - -type OpenCodeOverlayManifest = { - topLevelEntries: string[] - pluginEntries: string[] -} type OpenCodeHookVariant = { pluginFileName: string @@ -55,6 +49,7 @@ type OpenCodeHookVariant = { pluginSource: () => string /** Also install the module as an OpenCode 2 TUI plugin (never for forks without one). */ installsTuiPlugin?: boolean + tuiOnlyDirectory?: string } // Why: session IDs may contain path separators and are hashed downstream; cap pathological input. @@ -74,6 +69,7 @@ export class OpenCodeHookService { private readonly legacyHooksDir: string private readonly overlayDir: string private readonly installsTuiPlugin: boolean + private readonly tuiOnlyDirectory: string | undefined constructor(variant?: OpenCodeHookVariant | (() => string)) { const config: OpenCodeHookVariant = @@ -93,6 +89,7 @@ export class OpenCodeHookService { }) this.pluginSource = config.pluginSource this.installsTuiPlugin = config.installsTuiPlugin === true + this.tuiOnlyDirectory = config.tuiOnlyDirectory this.pluginFileName = config.pluginFileName this.legacyHooksDir = config.legacyHooksDir this.overlayDir = config.overlayDir @@ -102,6 +99,27 @@ export class OpenCodeHookService { // Why: no-op — config dirs are app/source-scoped now, and recursive delete on the main-process hot path could freeze on Windows. } + installIntoSourceOverlay( + directory: string, + sourceConfigDir: string, + owner: OpenCodeHookService + ): 'unmatched' | 'installed' | 'failed' { + if (directory !== owner.getSourceOverlayDir(sourceConfigDir)) { + return 'unmatched' + } + try { + for (const path of [owner.getOverlayRoot(), directory, join(directory, 'plugins')]) { + if (!isSafeDescendCandidate(lstatSync(path))) { + return 'failed' + } + } + this.writePluginIntoOverlay(directory) + return 'installed' + } catch { + return 'failed' + } + } + buildPtyEnv(ptyId: string, existingConfigDir?: string | undefined): Record { if (!isUsableId(ptyId)) { // Why: on a bad id, still preserve a user-set OPENCODE_CONFIG_DIR; only the Orca status plugin is forfeited. @@ -118,13 +136,15 @@ export class OpenCodeHookService { return {} } } - if (!existsSync(existingConfigDir)) { + if (!existsSync(existingConfigDir) && !this.tuiOnlyDirectory) { return { OPENCODE_CONFIG_DIR: existingConfigDir } } const overlayDir = this.getSourceOverlayDir(existingConfigDir) try { mkdirSync(overlayDir, { recursive: true }) - this.mirrorUserConfig(existingConfigDir, overlayDir) + if (existsSync(existingConfigDir)) { + this.mirrorUserConfig(existingConfigDir, overlayDir) + } this.writePluginIntoOverlay(overlayDir) return { OPENCODE_CONFIG_DIR: overlayDir } } catch { @@ -135,6 +155,9 @@ export class OpenCodeHookService { // Why: pre-1.4.209 Orca left a server()-only plugin here that OpenCode 2 rejects. Only helps // processes that load it later; a running OpenCode 2 service keeps its cached module until restarted. refreshLegacySharedPlugin(): void { + if (this.tuiOnlyDirectory) { + return + } const pluginsDir = join(this.getSharedConfigDir(), 'plugins') const pluginPath = join(pluginsDir, this.pluginFileName) try { @@ -198,20 +221,6 @@ export class OpenCodeHookService { ) } - private readOverlayManifest(overlayDir: string): OpenCodeOverlayManifest { - try { - const parsed = JSON.parse( - readFileSync(join(overlayDir, OPENCODE_OVERLAY_MANIFEST_FILE), 'utf8') - ) as Partial - return { - topLevelEntries: Array.isArray(parsed.topLevelEntries) ? parsed.topLevelEntries : [], - pluginEntries: Array.isArray(parsed.pluginEntries) ? parsed.pluginEntries : [] - } - } catch { - return { topLevelEntries: [], pluginEntries: [] } - } - } - private writeOverlayManifest(overlayDir: string, manifest: OpenCodeOverlayManifest): void { writeFileSync( join(overlayDir, OPENCODE_OVERLAY_MANIFEST_FILE), @@ -235,7 +244,7 @@ export class OpenCodeHookService { // Why: mirror user config entries as symlinks so edits propagate live; only plugins/ becomes a real overlay dir so Orca can drop a sibling plugin file. private mirrorUserConfig(sourceDir: string, overlayDir: string): void { - const previousManifest = this.readOverlayManifest(overlayDir) + const previousManifest = readOpenCodeOverlayManifest(overlayDir) // Why: overlays persist across terminals; remove only Orca-mirrored paths so stale user config clears but OpenCode runtime dirs (node_modules) survive. this.clearManifestEntries(overlayDir, previousManifest) @@ -266,6 +275,7 @@ export class OpenCodeHookService { // Why: skip a user plugin sharing Orca's filename; mirroring it would let writePluginIntoOverlay clobber the user's file. if ( pluginEntry.name === this.pluginFileName || + pluginEntry.name === this.tuiOnlyDirectory || (this.installsTuiPlugin && pluginEntry.name === openCodeTuiPluginDirName(this.pluginFileName)) ) { @@ -288,27 +298,23 @@ export class OpenCodeHookService { this.writeOverlayManifest(overlayDir, nextManifest) } - // Atomic replacement detaches mirrored links without touching user plugins. private writePluginIntoOverlay(overlayDir: string): void { - const pluginsDir = join(overlayDir, 'plugins') - mkdirSync(pluginsDir, { recursive: true }) - const pluginPath = join(pluginsDir, this.pluginFileName) - const source = this.pluginSource() - this.writeTuiPlugin(pluginsDir, source, 'overlay') - if (!isOverlayOpenCodePluginCurrent(pluginPath, source)) { - writeOverlayOpenCodePluginAtomically(pluginPath, source) - } + this.writePluginToDirectory(overlayDir, 'overlay') } private writePluginToConfigDir(configDir: string): void { - const pluginsDir = join(configDir, 'plugins') - mkdirSync(pluginsDir, { recursive: true }) - const pluginPath = join(pluginsDir, this.pluginFileName) - const source = this.pluginSource() - this.writeTuiPlugin(pluginsDir, source) - if (!isInstalledOpenCodePluginCurrent(pluginPath, source)) { - writeCanonicalOpenCodePluginAtomically(pluginPath, source) - } + this.writePluginToDirectory(configDir, 'canonical') + } + + private writePluginToDirectory(configDir: string, ownership: 'canonical' | 'overlay'): void { + writeOpenCodePluginConfig({ + configDir, + ownership, + pluginFileName: this.pluginFileName, + getSource: () => this.pluginSource(), + installsTuiPlugin: this.installsTuiPlugin, + tuiOnlyDirectory: this.tuiOnlyDirectory + }) } private writeTuiPlugin( diff --git a/src/main/opencode/opencode-overlay-manifest.ts b/src/main/opencode/opencode-overlay-manifest.ts new file mode 100644 index 00000000000..5abd0328f25 --- /dev/null +++ b/src/main/opencode/opencode-overlay-manifest.ts @@ -0,0 +1,29 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' + +export const OPENCODE_OVERLAY_MANIFEST_FILE = '.orca-opencode-overlay-manifest.json' +export type OpenCodeOverlayManifest = { topLevelEntries: string[]; pluginEntries: string[] } + +export function readOpenCodeOverlayManifest(overlayDir: string): OpenCodeOverlayManifest { + const empty = { topLevelEntries: [], pluginEntries: [] } + try { + const parsed: unknown = JSON.parse( + readFileSync(join(overlayDir, OPENCODE_OVERLAY_MANIFEST_FILE), 'utf8') + ) + if (!parsed || typeof parsed !== 'object') { + return empty + } + return { + topLevelEntries: + 'topLevelEntries' in parsed && Array.isArray(parsed.topLevelEntries) + ? parsed.topLevelEntries.filter((entry): entry is string => typeof entry === 'string') + : [], + pluginEntries: + 'pluginEntries' in parsed && Array.isArray(parsed.pluginEntries) + ? parsed.pluginEntries.filter((entry): entry is string => typeof entry === 'string') + : [] + } + } catch { + return empty + } +} diff --git a/src/main/opencode/opencode-plugin-config-writer.ts b/src/main/opencode/opencode-plugin-config-writer.ts new file mode 100644 index 00000000000..98cfe1e7088 --- /dev/null +++ b/src/main/opencode/opencode-plugin-config-writer.ts @@ -0,0 +1,45 @@ +import { mkdirSync } from 'node:fs' +import { join } from 'node:path' +import { + isInstalledOpenCodePluginCurrent, + isOverlayOpenCodePluginCurrent +} from '../../shared/opencode-installed-plugin' +import { + writeCanonicalOpenCodePluginAtomically, + writeOverlayOpenCodePluginAtomically +} from '../../shared/opencode-plugin-atomic-write' +import { + writeOpenCodeTuiPlugin, + writeOpenCodeTuiPluginDirectory +} from '../../shared/opencode-tui-plugin-install' + +export function writeOpenCodePluginConfig(options: { + configDir: string + pluginFileName: string + getSource: () => string + installsTuiPlugin: boolean + tuiOnlyDirectory: string | undefined + ownership: 'canonical' | 'overlay' +}): void { + const pluginsDir = join(options.configDir, 'plugins') + mkdirSync(pluginsDir, { recursive: true }) + const pluginPath = join(pluginsDir, options.pluginFileName) + const source = options.getSource() + if (options.tuiOnlyDirectory) { + writeOpenCodeTuiPluginDirectory(pluginsDir, options.tuiOnlyDirectory, source, options.ownership) + return + } + if (options.installsTuiPlugin) { + writeOpenCodeTuiPlugin(pluginsDir, options.pluginFileName, source, options.ownership) + } + const overlay = options.ownership === 'overlay' + const current = overlay + ? isOverlayOpenCodePluginCurrent(pluginPath, source) + : isInstalledOpenCodePluginCurrent(pluginPath, source) + if (!current) { + const write = overlay + ? writeOverlayOpenCodePluginAtomically + : writeCanonicalOpenCodePluginAtomically + write(pluginPath, source) + } +} diff --git a/src/main/opencode/opencode-pty-launch.test.ts b/src/main/opencode/opencode-pty-launch.test.ts index f7813ddc969..854882d287f 100644 --- a/src/main/opencode/opencode-pty-launch.test.ts +++ b/src/main/opencode/opencode-pty-launch.test.ts @@ -1,6 +1,12 @@ +import { createHash } from 'node:crypto' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getOpenCodeCliCapabilities } from '../../shared/opencode-cli-version' import { prepareOpenCodePtyLaunch } from './opencode-pty-launch' +import { + OPENCODE_STARTUP_PROMPT_SHA256_ENV, + OPENCODE_STARTUP_PROMPT_BODY_ENV, + OPENCODE_STARTUP_PROMPT_SHELL_ENV +} from '../../shared/opencode-startup-prompt' import { buildLocalPtySpawnEnvironment, enforceLocalPtySpawnEnvironmentOverrides @@ -28,20 +34,195 @@ const plan: LocalPtyLaunchPlan = { launchWslDistro: null } +const hookServer = vi.hoisted(() => { + const server: { endpointFilePath: string | null } = { endpointFilePath: '/private/endpoint.env' } + return server +}) +vi.mock('../agent-hooks/server', () => ({ agentHookServer: hookServer })) +const reserve = vi.hoisted(() => vi.fn(() => true)) +vi.mock('./opencode-startup-prompt-owner', () => ({ reserveOpenCodeStartupPrompt: reserve })) + +async function prepare(options: Parameters[0]) { + return (await prepareOpenCodePtyLaunch(options)).env +} + const probe = vi.hoisted(() => vi.fn()) +const install = vi.hoisted(() => vi.fn()) +vi.mock('./opencode-startup-prompt-installer', () => ({ + installOpenCodeStartupPromptForLaunch: install +})) vi.mock('./opencode-launch-capabilities', () => ({ probeOpenCodeLaunchCapabilities: probe })) -beforeEach(() => probe.mockReset()) +beforeEach(() => { + probe.mockReset() + reserve.mockReset().mockReturnValue(true) + install.mockReset() + hookServer.endpointFilePath = '/private/endpoint.env' +}) afterEach(() => vi.unstubAllEnvs()) describe('execution-host OpenCode launch preparation', () => { + it('retains fresh owned source provenance through the final provider deletion pass', async () => { + probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16')) + install.mockImplementation((env) => { + env.OPENCODE_CONFIG_DIR = '/private/owned-overlay' + env.ORCA_OPENCODE_SOURCE_CONFIG_DIR = '/private/real-source' + return true + }) + const envToDelete = ['OPENCODE_CONFIG_DIR', 'ORCA_OPENCODE_SOURCE_CONFIG_DIR'] + const env = await prepare({ + command: 'opencode --prompt task', + isFreshLaunch: true, + envToDelete, + env: { + ORCA_AGENT_LAUNCH_TOKEN: 'admitted-launch', + ORCA_OPENCODE_STARTUP_PROMPT_SHA256: createHash('sha256').update('task').digest('hex'), + ORCA_OPENCODE_STARTUP_PROMPT_BODY: 'task', + ORCA_OPENCODE_STARTUP_PROMPT_SHELL: 'posix' + } + }) + const finalEnv = await buildLocalPtySpawnEnvironment({ + id: 'source-proof', + spawn: { cols: 80, rows: 24, env, envToDelete }, + getOptions: () => ({}), + plan + }) + enforceLocalPtySpawnEnvironmentOverrides({ cols: 80, rows: 24, env, envToDelete }, finalEnv) + expect(finalEnv.OPENCODE_CONFIG_DIR).toBe('/private/owned-overlay') + expect(finalEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBe('/private/real-source') + }) + it('removes only the automatic verified v2 prompt argument, retaining explicit run and manual flags', async () => { + probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16')) + const env = { + ORCA_AGENT_LAUNCH_TOKEN: 'admitted-launch', + [OPENCODE_STARTUP_PROMPT_SHA256_ENV]: createHash('sha256').update('task').digest('hex'), + [OPENCODE_STARTUP_PROMPT_BODY_ENV]: 'task', + [OPENCODE_STARTUP_PROMPT_SHELL_ENV]: 'posix' + } + const options = { env, envToDelete: [], isFreshLaunch: true } + expect( + ( + await prepareOpenCodePtyLaunch({ + ...options, + command: "opencode --standalone --prompt 'task'" + }) + ).command + ).toBe('opencode --standalone') + expect( + (await prepareOpenCodePtyLaunch({ ...options, command: "opencode run --prompt 'task'" })) + .command + ).toBe("opencode run --prompt 'task'") + expect( + (await prepareOpenCodePtyLaunch({ ...options, env: {}, command: "opencode --prompt 'task'" })) + .command + ).toBe("opencode --prompt 'task'") + }) + it.each(['inherited', 'explicit', 'deleted'] as const)( + 'passes the %s config environment used by the execution-host version probe to the prompt installer', + async (selection) => { + vi.stubEnv('XDG_CONFIG_HOME', '/ambient/config') + probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16')) + await prepareOpenCodePtyLaunch({ + command: 'opencode --prompt task', + envToDelete: selection === 'deleted' ? ['XDG_CONFIG_HOME'] : [], + isFreshLaunch: true, + env: { + ORCA_AGENT_LAUNCH_TOKEN: 'admitted-launch', + [OPENCODE_STARTUP_PROMPT_SHA256_ENV]: createHash('sha256').update('task').digest('hex'), + [OPENCODE_STARTUP_PROMPT_BODY_ENV]: 'task', + [OPENCODE_STARTUP_PROMPT_SHELL_ENV]: 'posix', + ...(selection === 'explicit' ? { XDG_CONFIG_HOME: '/selected/config' } : {}) + } + }) + expect(install).toHaveBeenCalledTimes(1) + const resolved = install.mock.calls[0][2] + expect(resolved).toEqual(probe.mock.calls[0][0].env) + expect(resolved.XDG_CONFIG_HOME).toBe( + selection === 'deleted' + ? undefined + : selection === 'explicit' + ? '/selected/config' + : '/ambient/config' + ) + } + ) + + it.each(['endpoint', 'installer', 'capacity', 'identity'])( + 'keeps the editable brief when automatic preparation lacks %s', + async (failure) => { + probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16')) + if (failure === 'endpoint') { + hookServer.endpointFilePath = null + } + if (failure === 'installer') { + install.mockImplementation((env) => { + delete env.ORCA_OPENCODE_STARTUP_PROMPT_NONCE + }) + } + if (failure === 'capacity') { + reserve.mockReturnValue(false) + } + const original = "opencode --standalone --prompt 'task'" + const result = await prepareOpenCodePtyLaunch({ + command: original, + envToDelete: [], + isFreshLaunch: true, + env: { + ...(failure === 'identity' ? {} : { ORCA_AGENT_LAUNCH_TOKEN: 'admitted-launch' }), + [OPENCODE_STARTUP_PROMPT_SHA256_ENV]: createHash('sha256').update('task').digest('hex'), + [OPENCODE_STARTUP_PROMPT_BODY_ENV]: 'task', + [OPENCODE_STARTUP_PROMPT_SHELL_ENV]: 'posix' + } + }) + expect(result.command).toBe(original) + expect(result.env).not.toHaveProperty('ORCA_OPENCODE_STARTUP_PROMPT_NONCE') + } + ) + it.each(['1.1.23', '2.0.16', '2.0.17', 'unknown'])( + 'gates native intent against the executing %s capability', + async (version) => { + probe.mockResolvedValue(getOpenCodeCliCapabilities(version)) + const envToDelete: string[] = [] + const fingerprint = createHash('sha256').update('task').digest('hex') + const env = await prepare({ + command: 'opencode --prompt task', + env: { + ORCA_AGENT_LAUNCH_TOKEN: 'admitted-launch', + [OPENCODE_STARTUP_PROMPT_SHA256_ENV]: fingerprint, + [OPENCODE_STARTUP_PROMPT_BODY_ENV]: 'task', + [OPENCODE_STARTUP_PROMPT_SHELL_ENV]: 'posix' + }, + envToDelete, + isFreshLaunch: true + }) + expect(env?.[OPENCODE_STARTUP_PROMPT_SHA256_ENV]).toBe( + version === '2.0.16' ? fingerprint : undefined + ) + expect(envToDelete.includes(OPENCODE_STARTUP_PROMPT_SHA256_ENV)).toBe(version !== '2.0.16') + } + ) + + it('refuses remote automatic intent without execution-owned driving input', async () => { + const fingerprint = 'b'.repeat(64) + const envToDelete: string[] = [] + const env = await prepare({ + command: 'opencode --prompt task', + connectionId: 'remote', + isFreshLaunch: true, + env: { [OPENCODE_STARTUP_PROMPT_SHA256_ENV]: fingerprint }, + envToDelete + }) + expect(env?.[OPENCODE_STARTUP_PROMPT_SHA256_ENV]).toBeUndefined() + expect(envToDelete).toContain(OPENCODE_STARTUP_PROMPT_SHA256_ENV) + expect(probe).not.toHaveBeenCalled() + }) it('keeps deleted credentials and config absent from the probe and final provider environment', async () => { vi.stubEnv('ANTHROPIC_API_KEY', 'dummy-deleted-key') vi.stubEnv('OPENCODE_CONFIG_DIR', '/dummy/deleted-config') vi.stubEnv('ORCA_OPENCODE_PLUGIN_API', 'v1') probe.mockResolvedValue(getOpenCodeCliCapabilities(null)) const envToDelete = ['ANTHROPIC_API_KEY', 'OPENCODE_CONFIG_DIR'] - const env = await prepareOpenCodePtyLaunch({ + const env = await prepare({ command: 'opencode', env: {}, envToDelete, @@ -67,7 +248,7 @@ describe('execution-host OpenCode launch preparation', () => { vi.stubEnv('ORCA_OPENCODE_PLUGIN_API', 'v1') probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16')) const envToDelete = ['KEEP_DELETED', 'ORCA_OPENCODE_PLUGIN_API'] - const env = await prepareOpenCodePtyLaunch({ + const env = await prepare({ command: 'opencode', env: {}, envToDelete, @@ -94,7 +275,7 @@ describe('execution-host OpenCode launch preparation', () => { KEEP: '1', ORCA_OPENCODE_PLUGIN_API: 'stale' } - const result = await prepareOpenCodePtyLaunch({ + const result = await prepare({ command: 'opencode --prompt test', agent: 'opencode', env, @@ -117,7 +298,7 @@ describe('execution-host OpenCode launch preparation', () => { it('creates a launch environment for a known binary without caller env', async () => { probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16')) expect( - await prepareOpenCodePtyLaunch({ + await prepare({ command: 'opencode', env: undefined, envToDelete: [], @@ -129,7 +310,7 @@ describe('execution-host OpenCode launch preparation', () => { it('forwards WSL plugin selection through WSLENV after a guest probe', async () => { probe.mockResolvedValue(getOpenCodeCliCapabilities('1.1.23')) const env = { KEEP: '1' } - const result = await prepareOpenCodePtyLaunch({ + const result = await prepare({ command: 'opencode', agent: 'opencode', env, @@ -148,9 +329,7 @@ describe('execution-host OpenCode launch preparation', () => { 'never probes the client for an attach or SSH launch', async (route) => { const env = { ORCA_OPENCODE_PLUGIN_API: 'v1' } - expect( - await prepareOpenCodePtyLaunch({ command: 'opencode', env, envToDelete: [], ...route }) - ).toEqual({}) + expect(await prepare({ command: 'opencode', env, envToDelete: [], ...route })).toEqual({}) expect(probe).not.toHaveBeenCalled() expect(env).toEqual({ ORCA_OPENCODE_PLUGIN_API: 'v1' }) } diff --git a/src/main/opencode/opencode-pty-launch.ts b/src/main/opencode/opencode-pty-launch.ts index 5aa5adab072..bf7d8bced1f 100644 --- a/src/main/opencode/opencode-pty-launch.ts +++ b/src/main/opencode/opencode-pty-launch.ts @@ -1,7 +1,29 @@ import { addWslEnvKeys } from '../../shared/wsl-env' import type { TuiAgent } from '../../shared/tui-agent' +import { randomUUID, createHash } from 'node:crypto' +import { agentHookServer } from '../agent-hooks/server' +import { tokenizeStartupCommand } from '../../shared/tui-agent-startup-shell' +import { isOpenCodeRunCommand } from '../../shared/opencode-headless-command' +import { + OPENCODE_STARTUP_PROMPT_SHA256_ENV, + OPENCODE_STARTUP_PROMPT_NONCE_ENV, + OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV, + OPENCODE_STARTUP_PROMPT_BODY_ENV, + OPENCODE_STARTUP_PROMPT_SHELL_ENV +} from '../../shared/opencode-startup-prompt' + +const intentKeys = [ + OPENCODE_STARTUP_PROMPT_SHA256_ENV, + OPENCODE_STARTUP_PROMPT_NONCE_ENV, + OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV, + OPENCODE_STARTUP_PROMPT_BODY_ENV, + OPENCODE_STARTUP_PROMPT_SHELL_ENV +] + import { deleteRequestedEnvKeys } from '../ipc/pty/host-env/path' import { probeOpenCodeLaunchCapabilities } from './opencode-launch-capabilities' +import { reserveOpenCodeStartupPrompt } from './opencode-startup-prompt-owner' +import { installOpenCodeStartupPromptForLaunch } from './opencode-startup-prompt-installer' export async function prepareOpenCodePtyLaunch(options: { command: string | undefined @@ -12,17 +34,29 @@ export async function prepareOpenCodePtyLaunch(options: { connectionId?: string | null isFreshLaunch: boolean wsl?: { distro?: string } -}): Promise | undefined> { +}): Promise<{ env: Record | undefined; command: string | undefined }> { + let command = options.command const env = options.env ? { ...options.env } : undefined + const requestedPrompt = env?.[OPENCODE_STARTUP_PROMPT_SHA256_ENV] + const body = env?.[OPENCODE_STARTUP_PROMPT_BODY_ENV] + const shell = env?.[OPENCODE_STARTUP_PROMPT_SHELL_ENV] if (env) { delete env.ORCA_OPENCODE_PLUGIN_API + for (const key of intentKeys) { + delete env[key] + } } // Providers merge their own ambient environment after this preparation. if (!options.envToDelete.includes('ORCA_OPENCODE_PLUGIN_API')) { options.envToDelete.push('ORCA_OPENCODE_PLUGIN_API') } + for (const key of intentKeys) { + if (!options.envToDelete.includes(key)) { + options.envToDelete.push(key) + } + } if (options.connectionId || !options.isFreshLaunch) { - return env + return { env, command } } const probeEnv: Record = {} for (const [key, value] of Object.entries({ ...process.env, ...env })) { @@ -36,12 +70,70 @@ export async function prepareOpenCodePtyLaunch(options: { env: probeEnv }) if (!capabilities || capabilities.pluginApi === 'unknown') { - return env + return { env, command } + } + const launchEnv: Record = { + ...env, + ORCA_OPENCODE_PLUGIN_API: capabilities.pluginApi } - const launchEnv = { ...env, ORCA_OPENCODE_PLUGIN_API: capabilities.pluginApi } options.envToDelete.splice(options.envToDelete.indexOf('ORCA_OPENCODE_PLUGIN_API'), 1) + if ( + capabilities.promptMode === 'prefill' && + !options.wsl && + launchEnv.ORCA_AGENT_LAUNCH_TOKEN && + requestedPrompt && + body && + command && + (shell === 'posix' || shell === 'powershell' || shell === 'cmd') && + createHash('sha256').update(body).digest('hex') === requestedPrompt + ) { + const parsed = tokenizeStartupCommand(command, shell) + const last = parsed.ok ? parsed.tokens.length - 1 : -1 + if ( + parsed.ok && + !isOpenCodeRunCommand(parsed.tokens, shell) && + parsed.tokens.filter((token) => token === '--prompt').length === 1 && + parsed.tokens[last - 1] === '--prompt' && + parsed.tokens[last] === body && + !parsed.spans[last].divergesFromShell && + !parsed.spans[last - 1].divergesFromShell + ) { + const endpoint = agentHookServer.endpointFilePath + if (endpoint) { + launchEnv[OPENCODE_STARTUP_PROMPT_SHA256_ENV] = requestedPrompt + launchEnv[OPENCODE_STARTUP_PROMPT_BODY_ENV] = body + launchEnv[OPENCODE_STARTUP_PROMPT_NONCE_ENV] = randomUUID() + launchEnv[OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV] = endpoint + if (installOpenCodeStartupPromptForLaunch(launchEnv, false, probeEnv)) { + for (const key of [ + 'OPENCODE_CONFIG_DIR', + 'ORCA_OPENCODE_CONFIG_DIR', + 'ORCA_OPENCODE_SOURCE_CONFIG_DIR' + ]) { + const deletion = options.envToDelete.indexOf(key) + if (launchEnv[key] && deletion !== -1) { + options.envToDelete.splice(deletion, 1) + } + } + } + const nonce = launchEnv[OPENCODE_STARTUP_PROMPT_NONCE_ENV] + if (nonce && reserveOpenCodeStartupPrompt(nonce, requestedPrompt)) { + command = command.slice(0, parsed.spans[last - 1].start).trimEnd() + } else { + for (const key of intentKeys) { + delete launchEnv[key] + } + } + for (const key of intentKeys) { + if (launchEnv[key]) { + options.envToDelete.splice(options.envToDelete.indexOf(key), 1) + } + } + } + } + } if (options.wsl) { addWslEnvKeys(launchEnv, ['ORCA_OPENCODE_PLUGIN_API']) } - return launchEnv + return { env: launchEnv, command } } diff --git a/src/main/opencode/opencode-startup-prompt-claims.test.ts b/src/main/opencode/opencode-startup-prompt-claims.test.ts new file mode 100644 index 00000000000..95f2f51fcbd --- /dev/null +++ b/src/main/opencode/opencode-startup-prompt-claims.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it, vi } from 'vitest' +import type { TerminalRunFacts } from '../runtime/terminal-run-facts' +import { OpenCodeStartupPromptClaims } from './opencode-startup-prompt-claims' + +describe('execution-owned startup prompt claims', () => { + it('consumes each nonce once and checks owner facts at claim time', () => { + const claims = new OpenCodeStartupPromptClaims() + let facts: TerminalRunFacts | null = { freshSpawn: true, firstUserInputAt: null } + claims.register('first', 'hash', () => facts) + facts.firstUserInputAt = 1 + expect(claims.claim({ nonce: 'first', digest: 'hash' })).toBe(false) + facts.firstUserInputAt = null + expect(claims.claim({ nonce: 'first', digest: 'hash' })).toBe(false) + claims.register('second', 'hash', () => facts) + expect(claims.claim({ nonce: 'second', digest: 'hash' })).toBe(true) + expect(claims.claim({ nonce: 'second', digest: 'hash' })).toBe(false) + claims.register('missing', 'hash', () => facts) + facts = null + expect(claims.claim({ nonce: 'missing', digest: 'hash' })).toBe(false) + }) + + it('refuses reattachment, mismatched hashes, expired claims and malformed bodies', () => { + let now = 0 + const claims = new OpenCodeStartupPromptClaims(() => now) + claims.register('reattach', 'hash', () => ({ freshSpawn: false, firstUserInputAt: null })) + expect(claims.claim({ nonce: 'reattach', digest: 'hash' })).toBe(false) + claims.register('mismatch', 'hash', () => ({ freshSpawn: true, firstUserInputAt: null })) + expect(claims.claim({ nonce: 'mismatch', digest: 'other' })).toBe(false) + expect(claims.claim({ nonce: 'mismatch', digest: 'hash' })).toBe(false) + claims.register('expired', 'hash', () => ({ freshSpawn: true, firstUserInputAt: null })) + now = 20000 + expect(claims.claim({ nonce: 'expired', digest: 'hash' })).toBe(false) + for (const body of [null, 1, {}, { nonce: 1 }, { nonce: 'absent' }]) { + expect(claims.claim(body)).toBe(false) + } + }) + + it('keeps pending admission bounded and never recreates canceled or consumed claims', () => { + let now = 0 + const claims = new OpenCodeStartupPromptClaims(() => now) + claims.register('waiting', 'hash', () => 'pending') + expect(claims.claim({ nonce: 'waiting', digest: 'hash' })).toBe('pending') + expect(claims.claim({ nonce: 'waiting', digest: 'hash' })).toBe('pending') + expect(claims.admit('waiting', () => ({ freshSpawn: true, firstUserInputAt: null }))).toBe(true) + expect(claims.claim({ nonce: 'waiting', digest: 'hash' })).toBe(true) + expect(claims.admit('waiting', () => ({ freshSpawn: true, firstUserInputAt: null }))).toBe( + false + ) + claims.register('canceled', 'hash', () => 'pending') + claims.cancel('canceled') + expect(claims.admit('canceled', () => ({ freshSpawn: true, firstUserInputAt: null }))).toBe( + false + ) + claims.register('expired', 'hash', () => 'pending') + now = 20000 + expect(claims.claim({ nonce: 'expired', digest: 'hash' })).toBe(false) + expect(claims.admit('expired', () => ({ freshSpawn: true, firstUserInputAt: null }))).toBe( + false + ) + claims.clear() + }) + + it('bounds pending claims and reclaims expired capacity without timers', () => { + let now = 0 + const claims = new OpenCodeStartupPromptClaims(() => now) + for (let index = 0; index < 129; index++) { + claims.register(String(index), 'hash', () => ({ freshSpawn: true, firstUserInputAt: null })) + } + expect(claims.claim({ nonce: '128', digest: 'hash' })).toBe(false) + now = 20000 + claims.register('new', 'hash', () => ({ freshSpawn: true, firstUserInputAt: null })) + expect(claims.claim({ nonce: 'new', digest: 'hash' })).toBe(true) + }) + it('starts a fresh bounded claim window after delayed spawn admission', () => { + vi.useFakeTimers() + try { + const claims = new OpenCodeStartupPromptClaims() + claims.register('slow-spawn', 'hash', () => 'pending') + vi.advanceTimersByTime(18000) + const cleanup = vi.fn() + expect( + claims.admit('slow-spawn', () => ({ freshSpawn: true, firstUserInputAt: null }), cleanup) + ).toBe(true) + vi.advanceTimersByTime(8000) + expect(claims.claim({ nonce: 'slow-spawn', digest: 'hash' })).toBe(true) + expect(cleanup).toHaveBeenCalledTimes(1) + claims.clear() + } finally { + vi.useRealTimers() + } + }) + + it('replays only the same operation while execution facts remain authorized', () => { + let now = 0 + const claims = new OpenCodeStartupPromptClaims(() => now) + let facts: TerminalRunFacts | null = { freshSpawn: true, firstUserInputAt: null } + const cleanup = vi.fn() + claims.register('retry', 'hash', () => facts, cleanup) + const body = { nonce: 'retry', digest: 'hash', requestId: 'stable-operation' } + expect(claims.claim(body)).toBe(true) + expect(claims.claim(body)).toBe(true) + expect(claims.claim({ ...body, requestId: 'another-operation' })).toBe(false) + expect(claims.claim({ nonce: 'retry', digest: 'hash' })).toBe(false) + expect(cleanup).not.toHaveBeenCalled() + expect(claims.claim(body)).toBe(true) + facts.firstUserInputAt = 1 + expect(claims.claim(body)).toBe(false) + expect(cleanup).toHaveBeenCalledTimes(1) + facts = { freshSpawn: true, firstUserInputAt: null } + expect(claims.claim(body)).toBe(false) + claims.register('expires', 'hash', () => facts, cleanup) + expect(claims.claim({ ...body, nonce: 'expires' })).toBe(true) + now = 20000 + expect(claims.claim({ ...body, nonce: 'expires' })).toBe(false) + expect(cleanup).toHaveBeenCalledTimes(2) + }) + + it('does not renew admission, retain unbounded IDs or replay retired owners', () => { + let now = 0 + const claims = new OpenCodeStartupPromptClaims(() => now) + let facts: TerminalRunFacts | null = { freshSpawn: true, firstUserInputAt: null } + claims.register('bound', 'hash', () => 'pending') + now = 18000 + expect(claims.admit('bound', () => facts)).toBe(true) + now = 37000 + expect(claims.admit('bound', () => facts)).toBe(false) + expect(claims.claim({ nonce: 'bound', digest: 'hash', requestId: 'x'.repeat(129) })).toBe(false) + expect(claims.claim({ nonce: 'bound', digest: 'hash', requestId: 'operation' })).toBe(true) + facts = null + expect(claims.claim({ nonce: 'bound', digest: 'hash', requestId: 'operation' })).toBe(false) + claims.register('clear', 'hash', () => ({ freshSpawn: true, firstUserInputAt: null })) + expect(claims.claim({ nonce: 'clear', digest: 'hash', requestId: 'operation' })).toBe(true) + claims.clear() + expect(claims.claim({ nonce: 'clear', digest: 'hash', requestId: 'operation' })).toBe(false) + }) + + it.each([null, 1, '', 'with spaces', 'x'.repeat(129)])( + 'rejects malformed operation ID %j without consuming valid authorization', + (requestId) => { + const claims = new OpenCodeStartupPromptClaims() + claims.register('valid', 'hash', () => ({ freshSpawn: true, firstUserInputAt: null })) + expect(claims.claim({ nonce: 'valid', digest: 'hash', requestId })).toBe(false) + expect(claims.claim({ nonce: 'valid', digest: 'hash', requestId: 'valid-operation' })).toBe( + true + ) + claims.clear() + } + ) + + it('expires the renewed window without permitting repeated admission to extend it', () => { + vi.useFakeTimers() + try { + const claims = new OpenCodeStartupPromptClaims() + claims.register('bounded', 'hash', () => 'pending') + vi.advanceTimersByTime(18000) + const owner = () => ({ freshSpawn: true, firstUserInputAt: null }) + const cleanup = vi.fn() + expect(claims.admit('bounded', owner, cleanup)).toBe(true) + expect(claims.claim({ nonce: 'bounded', digest: 'hash', requestId: 'operation' })).toBe(true) + vi.advanceTimersByTime(19999) + expect(claims.admit('bounded', owner)).toBe(false) + expect(claims.claim({ nonce: 'bounded', digest: 'hash', requestId: 'operation' })).toBe(true) + vi.advanceTimersByTime(1) + expect(cleanup).toHaveBeenCalledTimes(1) + expect(claims.claim({ nonce: 'bounded', digest: 'hash', requestId: 'operation' })).toBe(false) + claims.clear() + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/main/opencode/opencode-startup-prompt-claims.ts b/src/main/opencode/opencode-startup-prompt-claims.ts new file mode 100644 index 00000000000..3a8dca6053f --- /dev/null +++ b/src/main/opencode/opencode-startup-prompt-claims.ts @@ -0,0 +1,120 @@ +import type { TerminalRunFacts } from '../runtime/terminal-run-facts' + +type PromptClaim = { + digest: string + expiresAt: number + admitted: boolean + grantedRequestId?: string + readOwner: () => TerminalRunFacts | 'pending' | null + cleanup: () => void + expiry: ReturnType +} + +/** Authorizes one startup operation against current execution-owner facts. */ +export class OpenCodeStartupPromptClaims { + private readonly pending = new Map() + + constructor(private readonly now: () => number = Date.now) {} + + register( + nonce: string, + digest: string, + readOwner: PromptClaim['readOwner'], + cleanup = () => {} + ): boolean { + const now = this.now() + for (const [key, claim] of this.pending) { + if (claim.expiresAt <= now) { + this.cancel(key) + } + } + if (this.pending.size >= 128 || this.pending.has(nonce)) { + return false + } + const expiry = setTimeout(() => this.cancel(nonce), 20000) + expiry.unref?.() + this.pending.set(nonce, { + digest, + readOwner, + expiresAt: now + 20000, + admitted: false, + cleanup, + expiry + }) + return true + } + + admit(nonce: string, readOwner: PromptClaim['readOwner'], cleanup = () => {}): boolean { + const pending = this.pending.get(nonce) + if (!pending || pending.expiresAt <= this.now()) { + this.cancel(nonce) + return false + } + if (pending.admitted || pending.grantedRequestId !== undefined) { + return false + } + clearTimeout(pending.expiry) + pending.expiresAt = this.now() + 20000 + pending.expiry = setTimeout(() => this.cancel(nonce), 20000) + pending.expiry.unref?.() + pending.admitted = true + pending.readOwner = readOwner + pending.cleanup = cleanup + return true + } + + cancel(nonce: string): void { + const pending = this.pending.get(nonce) + this.pending.delete(nonce) + if (pending) { + clearTimeout(pending.expiry) + } + pending?.cleanup() + } + + clear(): void { + for (const nonce of this.pending.keys()) { + this.cancel(nonce) + } + } + + claim(body: unknown): boolean | 'pending' { + if (!body || typeof body !== 'object' || !('nonce' in body) || typeof body.nonce !== 'string') { + return false + } + const pending = this.pending.get(body.nonce) + if ( + !pending || + pending.expiresAt <= this.now() || + !('digest' in body) || + body.digest !== pending.digest + ) { + this.cancel(body.nonce) + return false + } + const requestId = 'requestId' in body ? body.requestId : undefined + if ( + requestId !== undefined && + (typeof requestId !== 'string' || !/^[a-zA-Z0-9_-]{1,128}$/.test(requestId)) + ) { + return false + } + if (pending.grantedRequestId !== undefined && pending.grantedRequestId !== requestId) { + return false + } + const owner = pending.readOwner() + if (owner === 'pending') { + return 'pending' + } + if (owner?.freshSpawn !== true || owner.firstUserInputAt !== null) { + this.cancel(body.nonce) + return false + } + if (requestId === undefined) { + this.cancel(body.nonce) + } else { + pending.grantedRequestId = requestId + } + return true + } +} diff --git a/src/main/opencode/opencode-startup-prompt-installer.test.ts b/src/main/opencode/opencode-startup-prompt-installer.test.ts new file mode 100644 index 00000000000..69ff1aa14af --- /dev/null +++ b/src/main/opencode/opencode-startup-prompt-installer.test.ts @@ -0,0 +1,253 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { setAppEnvironment } from '../../shared/app-environment' +import { + createOpenCodeStartupPromptInstaller, + installOpenCodeStartupPromptForLaunch +} from './opencode-startup-prompt-installer' +import { + captureOpenCodeSourceConfig, + applyOpenCodeStatusPluginEnv +} from '../ipc/pty/host-env/opencode-config' + +let root: string +let originalXdg: string | undefined +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'opencode-prompt-install-')) + originalXdg = process.env.XDG_CONFIG_HOME + process.env.XDG_CONFIG_HOME = join(root, 'config') + setAppEnvironment({ + getPath: () => join(root, 'profile'), + getAppPath: () => root, + getVersion: () => 'test', + isPackaged: () => false, + onWillQuit: () => {}, + exit: () => {}, + getAppMetrics: () => [] + }) +}) +afterEach(() => { + vi.unstubAllEnvs() + if (originalXdg === undefined) { + delete process.env.XDG_CONFIG_HOME + } else { + process.env.XDG_CONFIG_HOME = originalXdg + } + rmSync(root, { recursive: true, force: true }) +}) +describe('OpenCode startup prompt installer', () => { + it('refuses a replaced status overlay instead of writing through its symlink or losing status hooks', () => { + const source = join(root, 'safe-source') + const foreign = join(root, 'foreign') + mkdirSync(source) + mkdirSync(foreign) + writeFileSync(join(foreign, 'untouched.txt'), 'foreign bytes') + const env: Record = { + OPENCODE_CONFIG_DIR: source, + ORCA_OPENCODE_PLUGIN_API: 'v2', + ORCA_OPENCODE_STARTUP_PROMPT_NONCE: 'replaced-overlay' + } + const config = captureOpenCodeSourceConfig(env, join(root, 'profile')) + applyOpenCodeStatusPluginEnv( + 'pane', + env, + config, + { + launchAgent: 'opencode', + agentStatusHooksEnabled: true + }, + 'opencode' + ) + rmSync(env.OPENCODE_CONFIG_DIR, { recursive: true }) + symlinkSync(foreign, env.OPENCODE_CONFIG_DIR, 'junction') + expect(installOpenCodeStartupPromptForLaunch(env)).toBe(false) + expect(env.ORCA_OPENCODE_STARTUP_PROMPT_NONCE).toBeUndefined() + expect(readFileSync(join(foreign, 'untouched.txt'), 'utf8')).toBe('foreign bytes') + expect(existsSync(join(foreign, 'plugins'))).toBe(false) + }) + it.each(['opencode', 'opencode2'] as const)( + 'keeps real source provenance and composes the %s status and prompt in one final overlay', + (agent) => { + const source = join(root, 'real-source') + mkdirSync(join(source, 'plugins'), { recursive: true }) + writeFileSync(join(source, 'plugins', 'user.js'), 'user bytes') + writeFileSync(join(source, 'opencode.json'), '{"model":"selected/model"}') + const env: Record = { + OPENCODE_CONFIG_DIR: source, + ORCA_OPENCODE_PLUGIN_API: 'v2', + ORCA_OPENCODE_STARTUP_PROMPT_NONCE: 'source-provenance' + } + expect(installOpenCodeStartupPromptForLaunch(env, false)).toBe(true) + expect(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBe(source) + const captured = captureOpenCodeSourceConfig(env, join(root, 'profile')) + expect(captured.directory).toBe(source) + applyOpenCodeStatusPluginEnv( + 'pane', + env, + captured, + { + launchAgent: agent, + agentStatusHooksEnabled: true + }, + `${agent} --standalone` + ) + const statusOverlay = env.OPENCODE_CONFIG_DIR + expect(installOpenCodeStartupPromptForLaunch(env)).toBe(true) + expect(env.OPENCODE_CONFIG_DIR).toBe(statusOverlay) + expect(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBe(source) + expect( + readFileSync(join(statusOverlay, 'plugins', `orca-${agent}-status.js`), 'utf8') + ).toContain('ORCA_STATUS_AGENT') + expect( + existsSync(join(statusOverlay, 'plugins', 'orca-opencode-startup-prompt', 'tui.js')) + ).toBe(true) + expect(readFileSync(join(statusOverlay, 'plugins', 'user.js'), 'utf8')).toBe('user bytes') + const nested: Record = { + ...env, + ORCA_OPENCODE_STARTUP_PROMPT_NONCE: 'nested-source-provenance' + } + expect(installOpenCodeStartupPromptForLaunch(nested)).toBe(true) + expect(nested.OPENCODE_CONFIG_DIR).toBe(statusOverlay) + expect(nested.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBe(source) + expect(readFileSync(join(source, 'plugins', 'user.js'), 'utf8')).toBe('user bytes') + expect(existsSync(join(source, 'plugins', `orca-${agent}-status.js`))).toBe(false) + } + ) + it.each(['inherited', 'explicit'] as const)( + 'preserves status and user plugins from the %s XDG config when launch env is sparse', + (selection) => { + const configHome = join(root, selection === 'explicit' ? 'selected-config' : 'config') + const config = join(configHome, 'opencode') + mkdirSync(join(config, 'plugins'), { recursive: true }) + writeFileSync(join(config, 'plugins', 'orca-opencode-status.js'), 'status entry') + writeFileSync(join(config, 'plugins', 'user.js'), 'user entry') + writeFileSync(join(config, 'opencode.json'), '{"model":"selected/model"}') + const env: Record = { + ORCA_OPENCODE_PLUGIN_API: 'v2', + ORCA_OPENCODE_STARTUP_PROMPT_NONCE: 'sparse-launch', + ...(selection === 'explicit' ? { XDG_CONFIG_HOME: configHome } : {}) + } + expect(installOpenCodeStartupPromptForLaunch(env)).toBe(true) + expect( + readFileSync(join(env.OPENCODE_CONFIG_DIR, 'plugins', 'orca-opencode-status.js'), 'utf8') + ).toBe('status entry') + expect(readFileSync(join(env.OPENCODE_CONFIG_DIR, 'plugins', 'user.js'), 'utf8')).toBe( + 'user entry' + ) + expect(readFileSync(join(env.OPENCODE_CONFIG_DIR, 'opencode.json'), 'utf8')).toContain( + 'selected/model' + ) + } + ) + + it("uses the execution owner's resolved environment instead of reintroducing a deleted ambient XDG home", () => { + const selected = join(root, 'resolved-config') + mkdirSync(join(selected, 'opencode', 'plugins'), { recursive: true }) + writeFileSync(join(selected, 'opencode', 'plugins', 'user.js'), 'resolved entry') + const env: Record = { + ORCA_OPENCODE_PLUGIN_API: 'v2', + ORCA_OPENCODE_STARTUP_PROMPT_NONCE: 'resolved-launch' + } + expect(installOpenCodeStartupPromptForLaunch(env, false, { XDG_CONFIG_HOME: selected })).toBe( + true + ) + expect(readFileSync(join(env.OPENCODE_CONFIG_DIR, 'plugins', 'user.js'), 'utf8')).toBe( + 'resolved entry' + ) + expect(env.ORCA_OPENCODE_CONFIG_DIR).toBeUndefined() + }) + + it.each(['inherited', 'explicit'] as const)( + 'preserves the %s OPENCODE_CONFIG_DIR ahead of the XDG default', + (selection) => { + const ambient = join(root, 'ambient-source') + const selected = join(root, 'selected-source') + for (const config of [ambient, selected]) { + mkdirSync(join(config, 'plugins'), { recursive: true }) + writeFileSync(join(config, 'plugins', 'user.js'), config) + } + vi.stubEnv('OPENCODE_CONFIG_DIR', ambient) + const env: Record = { + ORCA_OPENCODE_PLUGIN_API: 'v2', + ORCA_OPENCODE_STARTUP_PROMPT_NONCE: 'custom-config-launch', + ...(selection === 'explicit' ? { OPENCODE_CONFIG_DIR: selected } : {}) + } + expect(installOpenCodeStartupPromptForLaunch(env)).toBe(true) + expect(readFileSync(join(env.OPENCODE_CONFIG_DIR, 'plugins', 'user.js'), 'utf8')).toBe( + selection === 'explicit' ? selected : ambient + ) + } + ) + + it('keeps a missing user config untouched and installs the launch into an owned overlay', () => { + const source = join(root, 'missing-user-config') + const env: Record = { + ORCA_OPENCODE_PLUGIN_API: 'v2', + ORCA_OPENCODE_STARTUP_PROMPT_NONCE: 'private-launch', + OPENCODE_CONFIG_DIR: source, + OPENCODE_CONFIG_CONTENT: '{"model":"opencode/model"}' + } + installOpenCodeStartupPromptForLaunch(env) + expect(existsSync(source)).toBe(false) + expect(env.OPENCODE_CONFIG_DIR).toContain( + join(root, 'profile', 'opencode-startup-prompt-overlays') + ) + expect(env.ORCA_OPENCODE_CONFIG_DIR).toBe(env.OPENCODE_CONFIG_DIR) + expect(env.OPENCODE_CONFIG_CONTENT).toBe('{"model":"opencode/model"}') + expect( + existsSync(join(env.OPENCODE_CONFIG_DIR, 'plugins', 'orca-opencode-startup-prompt', 'tui.js')) + ).toBe(true) + expect(existsSync(join(env.OPENCODE_CONFIG_DIR, 'plugins', 'orca-opencode-status.js'))).toBe( + false + ) + }) + it('installs only a TUI entry without installing status hooks or a v1/server entry', () => { + const service = createOpenCodeStartupPromptInstaller(() => 'prompt source') + expect(service.buildPtyEnv('pane')).toEqual({}) + const plugins = join(root, 'config', 'opencode', 'plugins') + expect(readFileSync(join(plugins, 'orca-opencode-startup-prompt', 'tui.js'), 'utf8')).toBe( + 'prompt source' + ) + expect(existsSync(join(plugins, 'orca-opencode-startup-prompt.js'))).toBe(false) + expect(existsSync(join(plugins, 'orca-opencode-status.js'))).toBe(false) + expect(existsSync(join(root, 'profile', 'opencode-startup-prompt-hooks'))).toBe(false) + }) + it('preserves user config and plugins across source-scoped overlay refreshes', () => { + const config = join(root, 'custom') + mkdirSync(join(config, 'plugins'), { recursive: true }) + writeFileSync(join(config, 'opencode.json'), '{"model":"user/model"}') + writeFileSync(join(config, 'plugins', 'user.js'), 'user source') + mkdirSync(join(config, 'plugins', 'orca-opencode-startup-prompt')) + writeFileSync( + join(config, 'plugins', 'orca-opencode-startup-prompt', 'tui.js'), + 'user collision' + ) + let source = 'first prompt source' + const service = createOpenCodeStartupPromptInstaller(() => source) + const first = service.buildPtyEnv('pane-a', config).OPENCODE_CONFIG_DIR + expect(first).toBeDefined() + source = 'next prompt source' + expect(service.buildPtyEnv('pane-b', config).OPENCODE_CONFIG_DIR).toBe(first) + if (!first) { + throw new Error('Missing overlay') + } + expect( + readFileSync(join(first, 'plugins', 'orca-opencode-startup-prompt', 'tui.js'), 'utf8') + ).toBe(source) + expect(readFileSync(join(first, 'opencode.json'), 'utf8')).toContain('user/model') + expect(readFileSync(join(first, 'plugins', 'user.js'), 'utf8')).toBe('user source') + expect( + readFileSync(join(config, 'plugins', 'orca-opencode-startup-prompt', 'tui.js'), 'utf8') + ).toBe('user collision') + }) +}) diff --git a/src/main/opencode/opencode-startup-prompt-installer.ts b/src/main/opencode/opencode-startup-prompt-installer.ts new file mode 100644 index 00000000000..6ae79cc58d2 --- /dev/null +++ b/src/main/opencode/opencode-startup-prompt-installer.ts @@ -0,0 +1,82 @@ +import { OpenCodeHookService, openCodeHookService, openCode2HookService } from './hook-service' +import { OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY } from '../../shared/opencode-startup-prompt-install' +import { join } from 'node:path' +import { resolveOpenCodeConfigDirectory } from '../../shared/opencode-config-directory' +import { isOverlayOpenCodePluginCurrent } from '../../shared/opencode-installed-plugin' +import { getOpenCodeStartupPromptSource } from './opencode-startup-prompt-source' +import { resolveOpenCodeSourceConfigDir } from '../ipc/pty/host-env/pi-agent' +import { + OPENCODE_STARTUP_PROMPT_NONCE_ENV, + OPENCODE_STARTUP_PROMPT_SHA256_ENV, + OPENCODE_STARTUP_PROMPT_BODY_ENV, + OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV +} from '../../shared/opencode-startup-prompt' + +export function createOpenCodeStartupPromptInstaller(source: () => string): OpenCodeHookService { + return new OpenCodeHookService({ + pluginFileName: `${OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY}.js`, + legacyHooksDir: 'opencode-startup-prompt-hooks', + overlayDir: 'opencode-startup-prompt-overlays', + pluginSource: source, + tuiOnlyDirectory: OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY + }) +} + +const installer = createOpenCodeStartupPromptInstaller(getOpenCodeStartupPromptSource) + +export function installOpenCodeStartupPromptForLaunch( + env: Record, + restoreAfterShellStartup = true, + sourceEnvironment: NodeJS.ProcessEnv = { ...process.env, ...env } +): boolean { + const nonce = env[OPENCODE_STARTUP_PROMPT_NONCE_ENV] + if (!nonce || env.ORCA_OPENCODE_PLUGIN_API !== 'v2') { + return false + } + const source = + resolveOpenCodeSourceConfigDir(env, sourceEnvironment) || + resolveOpenCodeConfigDirectory(sourceEnvironment) + const statusOwner = + env.ORCA_OPENCODE_AGENT === 'opencode2' ? openCode2HookService : openCodeHookService + const existing = + env.OPENCODE_CONFIG_DIR && env.OPENCODE_CONFIG_DIR === env.ORCA_OPENCODE_CONFIG_DIR + ? installer.installIntoSourceOverlay(env.OPENCODE_CONFIG_DIR, source, statusOwner) + : 'unmatched' + const overlay = + existing === 'installed' + ? env.OPENCODE_CONFIG_DIR + : existing === 'failed' + ? undefined + : installer.buildPtyEnv(nonce, source).OPENCODE_CONFIG_DIR + if ( + !overlay || + !isOverlayOpenCodePluginCurrent( + join(overlay, 'plugins', OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY, 'tui.js'), + getOpenCodeStartupPromptSource() + ) + ) { + for (const key of [ + OPENCODE_STARTUP_PROMPT_NONCE_ENV, + OPENCODE_STARTUP_PROMPT_SHA256_ENV, + OPENCODE_STARTUP_PROMPT_BODY_ENV, + OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV + ]) { + delete env[key] + } + return false + } + env.OPENCODE_CONFIG_DIR = overlay + env.ORCA_OPENCODE_SOURCE_CONFIG_DIR = source + if (restoreAfterShellStartup || existing === 'installed') { + env.ORCA_OPENCODE_CONFIG_DIR = overlay + } else { + delete env.ORCA_OPENCODE_CONFIG_DIR + } + return true +} + +export function ensureOpenCodeStartupPromptForLaunch(env: Record): void { + if (env[OPENCODE_STARTUP_PROMPT_NONCE_ENV] && !installOpenCodeStartupPromptForLaunch(env)) { + throw new Error('Cannot prepare OpenCode startup prompt; launch was canceled.') + } +} diff --git a/src/main/opencode/opencode-startup-prompt-owner.test.ts b/src/main/opencode/opencode-startup-prompt-owner.test.ts new file mode 100644 index 00000000000..40aa8eabe93 --- /dev/null +++ b/src/main/opencode/opencode-startup-prompt-owner.test.ts @@ -0,0 +1,172 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { TerminalRunFactsRegister } from '../runtime/terminal-run-facts' +import { + reserveOpenCodeStartupPrompt, + commitPtyWithOpenCodePromptIntent +} from './opencode-startup-prompt-owner' + +const control = vi.hoisted(() => ({ + claim: (_body: unknown): boolean | 'pending' => false, + clear: () => {} +})) +const ownership = vi.hoisted(() => ({ + ptyOwnership: new Map(), + ptyIncarnationById: new Map() +})) +vi.mock('../agent-hooks/server', () => ({ + agentHookServer: { + setStartupPromptClaimListener: (claim: typeof control.claim, clear: () => void) => { + control.claim = claim + control.clear = clear + } + } +})) +vi.mock('../ipc/pty/provider/ownership-state', () => ownership) +beforeEach(() => { + control.clear() + ownership.ptyOwnership.clear() + ownership.ptyIncarnationById.clear() +}) + +function fixture() { + const facts = new TerminalRunFactsRegister() + const result = { id: 'owned', incarnationId: 'incarnation' } + let identityReady = false + let release = () => {} + const waiting = new Promise((resolve) => { + release = resolve + }) + const runtime = { + terminalRunFacts: facts, + readOpenCodeStartupPromptOwner: () => + identityReady ? facts.read(result.id, result.incarnationId) : ('pending' as const), + isPtyStopRequested: () => false, + subscribeToPtyExit: (_ptyId: string, _listener: () => void) => () => {} + } + ownership.ptyOwnership.set(result.id, null) + ownership.ptyIncarnationById.set(result.id, result.incarnationId) + const context = { + env: { + ORCA_OPENCODE_STARTUP_PROMPT_NONCE: 'nonce', + ORCA_OPENCODE_STARTUP_PROMPT_SHA256: 'digest', + ORCA_AGENT_LAUNCH_TOKEN: 'launch' + }, + deps: { runtime }, + result, + provider: { hasPty: () => true }, + args: {} + } + reserveOpenCodeStartupPrompt('nonce', 'digest') + const body = { nonce: 'nonce', digest: 'digest' } + return { + facts, + result, + context, + waiting, + release, + body, + admit: () => { + identityReady = true + } + } +} + +describe('native prompt admission after spawn commit', () => { + it('waits through delayed persistence and later runtime identity admission', async () => { + const f = fixture() + const committed = commitPtyWithOpenCodePromptIntent(f.context, async () => { + await f.waiting + f.facts.recordSpawnCommit(f.result) + return f.result + }) + expect(control.claim(f.body)).toBe('pending') + f.release() + await committed + expect(control.claim(f.body)).toBe('pending') + f.admit() + expect(control.claim(f.body)).toBe(true) + expect(control.claim(f.body)).toBe(false) + }) + + it('keeps pre-commit driving input sticky even if the draft is erased', async () => { + const f = fixture() + const committed = commitPtyWithOpenCodePromptIntent(f.context, async () => { + await f.waiting + f.facts.recordSpawnCommit(f.result) + return f.result + }) + f.facts.recordInput(f.result.id, 'driving', 'x') + f.facts.recordInput(f.result.id, 'driving', '\u007f') + expect(control.claim(f.body)).toBe('pending') + f.release() + await committed + f.admit() + expect(control.claim(f.body)).toBe(false) + }) + + it('cancels a failed commit without allowing later admission', async () => { + const f = fixture() + await expect( + commitPtyWithOpenCodePromptIntent(f.context, async () => { + throw new Error('persistence rejected') + }) + ).rejects.toThrow('persistence rejected') + f.admit() + expect(control.claim(f.body)).toBe(false) + }) + it.each(['incarnation', 'provider', 'stop', 'exit', 'clear'])( + 'invalidates granted replay after %s', + async (reason) => { + const f = fixture() + let exited = () => {} + const unsubscribe = vi.fn() + vi.spyOn(f.context.deps.runtime, 'subscribeToPtyExit').mockImplementation( + (_id, listener: () => void) => { + exited = listener + return unsubscribe + } + ) + await commitPtyWithOpenCodePromptIntent(f.context, async () => { + f.facts.recordSpawnCommit(f.result) + return f.result + }) + f.admit() + const body = { ...f.body, requestId: 'stable-operation' } + expect(control.claim(body)).toBe(true) + expect(control.claim(body)).toBe(true) + if (reason === 'incarnation') { + ownership.ptyIncarnationById.set(f.result.id, 'replacement') + } + if (reason === 'provider') { + vi.spyOn(f.context.provider, 'hasPty').mockReturnValue(false) + } + if (reason === 'stop') { + vi.spyOn(f.context.deps.runtime, 'isPtyStopRequested').mockReturnValue(true) + } + if (reason === 'exit') { + exited() + } + if (reason === 'clear') { + control.clear() + } + expect(control.claim(body)).toBe(false) + expect(unsubscribe).toHaveBeenCalledTimes(1) + f.facts.recordSpawnCommit(f.result) + expect(control.claim(body)).toBe(false) + } + ) + + it('retains sticky input cancellation after a lost grant response', async () => { + const f = fixture() + await commitPtyWithOpenCodePromptIntent(f.context, async () => { + f.facts.recordSpawnCommit(f.result) + return f.result + }) + f.admit() + const body = { ...f.body, requestId: 'stable-operation' } + expect(control.claim(body)).toBe(true) + f.facts.recordInput(f.result.id, 'driving', 'x') + f.facts.recordInput(f.result.id, 'driving', '\u007f') + expect(control.claim(body)).toBe(false) + }) +}) diff --git a/src/main/opencode/opencode-startup-prompt-owner.ts b/src/main/opencode/opencode-startup-prompt-owner.ts new file mode 100644 index 00000000000..be1dacba25d --- /dev/null +++ b/src/main/opencode/opencode-startup-prompt-owner.ts @@ -0,0 +1,111 @@ +import { agentHookServer } from '../agent-hooks/server' +import type { OrcaRuntimeService } from '../runtime/orca-runtime' +import type { IPtyProvider, PtySpawnResult } from '../providers/types' +import { ptyIncarnationById, ptyOwnership } from '../ipc/pty/provider/ownership-state' +import { isPtyIncarnationId } from '../../shared/pty-incarnation' +import { + OPENCODE_STARTUP_PROMPT_NONCE_ENV, + OPENCODE_STARTUP_PROMPT_SHA256_ENV +} from '../../shared/opencode-startup-prompt' +import { OpenCodeStartupPromptClaims } from './opencode-startup-prompt-claims' + +type OpenCodePromptRuntime = Pick< + OrcaRuntimeService, + | 'terminalRunFacts' + | 'readOpenCodeStartupPromptOwner' + | 'isPtyStopRequested' + | 'subscribeToPtyExit' +> +const claims = new OpenCodeStartupPromptClaims() + +export function reserveOpenCodeStartupPrompt(nonce: string, digest: string): boolean { + agentHookServer.setStartupPromptClaimListener( + (body) => claims.claim(body), + () => claims.clear() + ) + return claims.register(nonce, digest, () => 'pending') +} + +export async function commitPtyWithOpenCodePromptIntent( + context: { + env?: Record + spawnEnv?: Record + deps: { runtime?: OpenCodePromptRuntime } + result: PtySpawnResult + provider: Pick + args: { connectionId?: string | null } + }, + commit: () => Promise +): Promise { + const options = { + env: context.spawnEnv ?? context.env, + runtime: context.deps.runtime, + result: context.result, + provider: context.provider, + connectionId: context.args.connectionId + } + const facts = options.runtime?.terminalRunFacts + facts?.reserveSpawnCommit(options.result) + try { + const result = await commit() + bindOpenCodeStartupPromptOwner({ ...options, result }) + return result + } catch (error) { + const nonce = options.env?.[OPENCODE_STARTUP_PROMPT_NONCE_ENV] + if (nonce) { + claims.cancel(nonce) + } + throw error + } finally { + facts?.discardSpawnCommit(options.result) + } +} + +export function bindOpenCodeStartupPromptOwner(options: { + env: Record | undefined + result: PtySpawnResult + runtime: OpenCodePromptRuntime | undefined + provider: Pick + connectionId?: string | null +}): void { + const { env, result, runtime, provider } = options + const nonce = env?.[OPENCODE_STARTUP_PROMPT_NONCE_ENV] + const digest = env?.[OPENCODE_STARTUP_PROMPT_SHA256_ENV] + const launchToken = env?.ORCA_AGENT_LAUNCH_TOKEN + const incarnation = result.incarnationId + if ( + options.connectionId || + !runtime || + result.isReattach || + result.agentSessionEnsure?.disposition === 'adopted' || + !isPtyIncarnationId(incarnation) || + !nonce || + !digest || + !launchToken + ) { + if (nonce) { + claims.cancel(nonce) + } + return + } + let unsubscribe = () => {} + if ( + claims.admit( + nonce, + () => { + if ( + ptyOwnership.get(result.id) !== null || + ptyIncarnationById.get(result.id) !== incarnation || + provider.hasPty?.(result.id) !== true || + runtime.isPtyStopRequested(result.id) + ) { + return null + } + return runtime.readOpenCodeStartupPromptOwner(result.id, incarnation, launchToken) + }, + () => unsubscribe() + ) + ) { + unsubscribe = runtime.subscribeToPtyExit(result.id, () => claims.cancel(nonce)) + } +} diff --git a/src/main/opencode/opencode-startup-prompt-runtime-identity.test.ts b/src/main/opencode/opencode-startup-prompt-runtime-identity.test.ts new file mode 100644 index 00000000000..8f4759957a1 --- /dev/null +++ b/src/main/opencode/opencode-startup-prompt-runtime-identity.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it, vi } from 'vitest' +import { OpenCodeStartupPromptClaims } from './opencode-startup-prompt-claims' +import { + createTranscriptPane, + TRANSCRIPT_PANE_PTY_ID +} from '../runtime/agent-transcript-pane-test-harness' + +vi.mock('electron', () => ({ + BrowserWindow: { fromId: vi.fn(() => null) }, + webContents: { fromId: vi.fn(() => null) }, + ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + app: { getPath: vi.fn(() => '/tmp') } +})) + +async function fixture(launchAgent?: 'opencode' | 'opencode2' | 'zcode') { + const { runtime } = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: null, + data: '', + ...(launchAgent ? { launchAgent } : {}) + }) + runtime.terminalRunFacts.recordSpawnCommit({ id: TRANSCRIPT_PANE_PTY_ID, incarnationId: 'inc-1' }) + const read = ( + ptyId = TRANSCRIPT_PANE_PTY_ID, + incarnation = 'inc-1', + token = 'transcript-launch' + ) => runtime.readOpenCodeStartupPromptOwner(ptyId, incarnation, token) + return { runtime, read } +} + +describe('runtime-owned startup prompt identity', () => { + it('keeps launch admission pending before runtime identity is assigned', async () => { + const f = await fixture() + expect(f.read()).toBe('pending') + expect(f.read('missing')).toBeNull() + expect(f.read(TRANSCRIPT_PANE_PTY_ID, 'previous-incarnation')).toBeNull() + }) + + it.each(['opencode', 'opencode2'] as const)( + 'reads only the admitted %s launch', + async (agent) => { + const f = await fixture(agent) + expect(f.read()).toEqual({ freshSpawn: true, firstUserInputAt: null }) + expect(f.read(TRANSCRIPT_PANE_PTY_ID, 'inc-1', 'another-launch')).toBeNull() + expect(f.read(TRANSCRIPT_PANE_PTY_ID, 'previous-incarnation')).toBeNull() + f.runtime.terminalRunFacts.recordInput(TRANSCRIPT_PANE_PTY_ID, 'driving', 'x', 123) + expect(f.read()).toEqual({ freshSpawn: true, firstUserInputAt: 123 }) + } + ) + + it('denies another agent even with the exact incarnation and launch token', async () => { + expect((await fixture('zcode')).read()).toBeNull() + }) + it('refuses same-ID replay after the execution owner or launch token changes', async () => { + const f = await fixture('opencode2') + const claims = new OpenCodeStartupPromptClaims() + claims.register('owned-operation', 'digest', () => f.read()) + const body = { nonce: 'owned-operation', digest: 'digest', requestId: 'stable-operation' } + expect(claims.claim(body)).toBe(true) + expect(claims.claim(body)).toBe(true) + await f.runtime.onPtyExit(TRANSCRIPT_PANE_PTY_ID, 0, 'inc-1') + f.runtime.registerPty(TRANSCRIPT_PANE_PTY_ID, 'wt-1', null, { + tabId: 'tab-1', + leafId: '11111111-1111-4111-8111-111111111111', + incarnationId: 'inc-2', + agentLaunchAuthority: { launchToken: 'replacement-launch', launchAgent: 'opencode2' } + }) + f.runtime.terminalRunFacts.recordSpawnCommit({ + id: TRANSCRIPT_PANE_PTY_ID, + incarnationId: 'inc-2' + }) + expect(f.read(TRANSCRIPT_PANE_PTY_ID, 'inc-2', 'replacement-launch')).toEqual({ + freshSpawn: true, + firstUserInputAt: null + }) + expect(f.read(TRANSCRIPT_PANE_PTY_ID, 'inc-2', 'transcript-launch')).toBeNull() + expect(claims.claim(body)).toBe(false) + claims.clear() + }) +}) diff --git a/src/main/opencode/opencode-startup-prompt-source.test.ts b/src/main/opencode/opencode-startup-prompt-source.test.ts new file mode 100644 index 00000000000..2e505ee2a8f --- /dev/null +++ b/src/main/opencode/opencode-startup-prompt-source.test.ts @@ -0,0 +1,761 @@ +import { EventEmitter } from 'node:events' +import { createServer } from 'node:http' +import { createHash } from 'node:crypto' +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + OPENCODE_STARTUP_PROMPT_SHA256_ENV, + OPENCODE_STARTUP_PROMPT_NONCE_ENV, + OPENCODE_STARTUP_PROMPT_BODY_ENV, + OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV +} from '../../shared/opencode-startup-prompt' +import { getOpenCodeStartupPromptSource } from './opencode-startup-prompt-source' +import { OpenCodeStartupPromptClaims } from './opencode-startup-prompt-claims' +import { cancelTrackingResponse } from '../lib/unread-response-body.test-fixtures' + +type PluginModule = { default: { setup: (ctx: unknown) => Promise<() => Promise> } } +const prompt = 'exact startup brief\nwith unicode é' +const digest = createHash('sha256').update(prompt).digest('hex') +let dir: string +let setup: PluginModule['default']['setup'] +let claim: ReturnType + +class Editor extends EventEmitter { + traits: { owner: string; role: string; capture: string[]; status?: string } = { + owner: 'opencode', + role: 'prompt', + capture: ['tab'] + } + plainText = '' + focused = true + insertText(text: string) { + this.replace(this.plainText + text) + } + replace(text: string) { + this.plainText = text + this.emit('line-info-change') + } +} + +type FixtureLocation = { directory: string; workspaceID?: string } + +function fixture() { + const editor = new Editor() + const input = new EventEmitter() + const memory = { settled: false, expiresAt: Date.now() + 20000 } + const route = { type: 'home' } + const agent = vi.fn((): unknown[] | undefined => [{}]) + const model = vi.fn((): unknown[] | undefined => [{}]) + const sync = vi.fn(async (_location: FixtureLocation) => {}) + const dispatch = vi.fn(() => editor.replace('')) + const ctx = { + app: { version: '2.0.16' }, + renderer: { keyInput: input, currentFocusedEditor: editor }, + storage: { memory: () => [memory, (mutate: (draft: typeof memory) => void) => mutate(memory)] }, + keymap: { dispatch }, + ui: { router: { current: () => route } }, + location: { directory: '/private' }, + data: { location: { sync, agent: { list: agent }, model: { list: model } } } + } + return { ctx, editor, input, memory, route, agent, model, sync, dispatch } +} + +beforeEach(async () => { + dir = mkdtempSync(join(tmpdir(), 'orca-opencode-prompt-')) + const path = join(dir, 'prompt.mjs') + writeFileSync(path, getOpenCodeStartupPromptSource()) + const module: PluginModule = await import(pathToFileURL(path).href) + setup = module.default.setup + vi.useFakeTimers() + vi.stubEnv(OPENCODE_STARTUP_PROMPT_SHA256_ENV, digest) + vi.stubEnv(OPENCODE_STARTUP_PROMPT_BODY_ENV, prompt) + vi.stubEnv(OPENCODE_STARTUP_PROMPT_NONCE_ENV, 'single-use-nonce') + const endpoint = join(dir, 'endpoint.cmd') + writeFileSync( + endpoint, + 'set ORCA_AGENT_HOOK_PORT=12345\nset ORCA_AGENT_HOOK_TOKEN=private-token\nset ORCA_AGENT_HOOK_ENV=test\nset ORCA_AGENT_HOOK_VERSION=1\n' + ) + vi.stubEnv(OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV, endpoint) + claim = vi.fn(async () => ({ ok: true, json: async () => ({ allowed: true }) })) + vi.stubGlobal('fetch', claim) +}) +afterEach(() => { + vi.useRealTimers() + vi.unstubAllEnvs() + vi.unstubAllGlobals() + rmSync(dir, { recursive: true, force: true }) +}) + +describe('installed-version native prompt intent plugin', () => { + it('waits for the current home location after the startup directory changes', async () => { + const f = fixture() + let location: FixtureLocation = { directory: '/private/home/private-folder' } + Object.defineProperty(f.ctx, 'location', { get: () => location }) + let releaseStartup = () => {} + let releaseHome = () => {} + let selectedModel = 'opencode/mimo-v2.6-flash-free' + const selections: string[] = [] + f.sync.mockImplementation( + (target) => + new Promise((resolve) => { + if (target.directory.endsWith('/private-folder')) { + releaseStartup = resolve + } else { + releaseHome = () => { + selectedModel = 'private-proof/model-a' + resolve() + } + } + }) + ) + f.dispatch.mockImplementation(() => { + selections.push(selectedModel) + f.editor.replace('') + }) + const dispose = await setup(f.ctx) + try { + await vi.advanceTimersByTimeAsync(100) + location = { directory: '/private/home' } + releaseStartup() + await vi.advanceTimersByTimeAsync(300) + expect(claim).not.toHaveBeenCalled() + expect(selections).toEqual([]) + expect(f.sync).toHaveBeenCalledTimes(2) + expect(f.sync).toHaveBeenLastCalledWith(location) + releaseHome() + await vi.advanceTimersByTimeAsync(500) + await vi.waitFor(() => expect(f.dispatch).toHaveBeenCalledTimes(1)) + expect(selections).toEqual(['private-proof/model-a']) + expect(claim).toHaveBeenCalledTimes(1) + } finally { + await dispose() + } + }) + + it('waits for a concrete location before starting authoritative hydration', async () => { + const f = fixture() + let location: FixtureLocation | undefined + Object.defineProperty(f.ctx, 'location', { get: () => location }) + const dispose = await setup(f.ctx) + try { + await vi.advanceTimersByTimeAsync(300) + expect(f.sync).not.toHaveBeenCalled() + expect(claim).not.toHaveBeenCalled() + location = { directory: '/private/home' } + await vi.advanceTimersByTimeAsync(500) + await vi.waitFor(() => expect(f.dispatch).toHaveBeenCalledTimes(1)) + expect(f.sync).toHaveBeenCalledExactlyOnceWith(location) + } finally { + await dispose() + } + }) + + it('keeps readiness scoped to the workspace as well as the directory', async () => { + const f = fixture() + let location: FixtureLocation = { directory: '/private', workspaceID: 'first' } + Object.defineProperty(f.ctx, 'location', { get: () => location }) + let release = () => {} + f.sync.mockImplementationOnce( + () => + new Promise((resolve) => { + release = resolve + }) + ) + const dispose = await setup(f.ctx) + try { + await vi.advanceTimersByTimeAsync(100) + location = { directory: '/private', workspaceID: 'second' } + release() + await vi.advanceTimersByTimeAsync(500) + await vi.waitFor(() => expect(f.dispatch).toHaveBeenCalledTimes(1)) + expect(f.sync).toHaveBeenCalledTimes(2) + expect(f.sync).toHaveBeenLastCalledWith(location) + expect(claim).toHaveBeenCalledTimes(1) + } finally { + await dispose() + } + }) + + it('refuses a granted claim after the composer location changes', async () => { + const f = fixture() + let location: FixtureLocation = { directory: '/private' } + Object.defineProperty(f.ctx, 'location', { get: () => location }) + let grant = () => {} + claim.mockImplementation( + () => + new Promise((resolve) => { + grant = () => resolve({ ok: true, json: async () => ({ allowed: true }) }) + }) + ) + const insert = vi.spyOn(f.editor, 'insertText') + const dispose = await setup(f.ctx) + try { + await vi.advanceTimersByTimeAsync(500) + await vi.waitFor(() => expect(claim).toHaveBeenCalledTimes(1)) + location = { directory: '/private/other' } + grant() + await vi.advanceTimersByTimeAsync(500) + expect(insert).not.toHaveBeenCalled() + expect(f.dispatch).not.toHaveBeenCalled() + expect(f.memory.settled).toBe(true) + } finally { + await dispose() + } + }) + + it('waits for authoritative location config before claiming or selecting a model', async () => { + const f = fixture() + let configuredModel = 'unavailable-fallback' + let release = () => {} + f.sync.mockImplementation( + () => + new Promise((resolve) => { + release = () => { + configuredModel = 'configured-model' + resolve() + } + }) + ) + const selections: string[] = [] + f.dispatch.mockImplementation(() => { + selections.push(configuredModel) + f.editor.replace('') + }) + const insert = vi.spyOn(f.editor, 'insertText') + const dispose = await setup(f.ctx) + try { + await vi.advanceTimersByTimeAsync(500) + expect(claim).not.toHaveBeenCalled() + expect(insert).not.toHaveBeenCalled() + expect(selections).toEqual([]) + expect(f.sync).toHaveBeenCalledExactlyOnceWith(f.ctx.location) + release() + await vi.advanceTimersByTimeAsync(500) + await vi.waitFor(() => expect(f.dispatch).toHaveBeenCalledExactlyOnceWith('prompt.submit')) + expect(selections).toEqual(['configured-model']) + expect(insert).toHaveBeenCalledExactlyOnceWith(prompt) + expect(claim).toHaveBeenCalledTimes(1) + expect(f.sync).toHaveBeenCalledTimes(1) + } finally { + await dispose() + } + }) + + it.each(['keypress', 'paste', 'edit', 'route', 'expiry', 'dispose', 'editor', 'focus'])( + 'cancels delayed location hydration on %s before any claim', + async (reason) => { + const f = fixture() + let release = () => {} + f.sync.mockImplementation( + () => + new Promise((resolve) => { + release = resolve + }) + ) + const dispose = await setup(f.ctx) + try { + await vi.advanceTimersByTimeAsync(100) + expect(claim).not.toHaveBeenCalled() + if (reason === 'keypress' || reason === 'paste') { + f.input.emit(reason) + } + if (reason === 'edit') { + f.editor.replace('typed') + f.editor.replace('') + } + if (reason === 'route') { + f.route.type = 'session' + } + if (reason === 'expiry') { + f.memory.expiresAt = Date.now() + } + if (reason === 'dispose') { + await dispose() + } + if (reason === 'editor') { + f.ctx.renderer.currentFocusedEditor = new Editor() + } + if (reason === 'focus') { + f.editor.focused = false + } + await vi.advanceTimersByTimeAsync(100) + release() + await vi.advanceTimersByTimeAsync(500) + expect(claim).not.toHaveBeenCalled() + expect(f.dispatch).not.toHaveBeenCalled() + expect(f.editor.plainText).toBe('') + if (reason !== 'focus') { + expect(f.memory.settled).toBe(true) + } + } finally { + await dispose() + } + expect(f.input.listenerCount('keypress')).toBe(0) + expect(f.editor.listenerCount('line-info-change')).toBe(0) + } + ) + + it('fails closed when authoritative location sync rejects', async () => { + const f = fixture() + f.sync.mockRejectedValue(new Error('location unavailable')) + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + expect(f.memory.settled).toBe(true) + expect(claim).not.toHaveBeenCalled() + expect(f.dispatch).not.toHaveBeenCalled() + expect(f.input.listenerCount('keypress')).toBe(0) + await dispose() + }) + + it('fails closed when authoritative location sync is missing', async () => { + const f = fixture() + const { sync: _sync, ...location } = f.ctx.data.location + const dispose = await setup({ ...f.ctx, data: { location } }) + await vi.advanceTimersByTimeAsync(500) + expect(claim).not.toHaveBeenCalled() + expect(f.dispatch).not.toHaveBeenCalled() + expect(f.input.listenerCount('keypress')).toBe(0) + await dispose() + }) + + it.each(['non-ok', 'json-rejected'])('cancels unread %s claim bodies', async (reason) => { + const cancelled = vi.fn() + const response = cancelTrackingResponse(reason === 'non-ok' ? 503 : 200, cancelled) + if (reason === 'json-rejected') { + vi.spyOn(response, 'json').mockRejectedValue(new Error('decoder rejected')) + } + claim.mockResolvedValue(response) + const f = fixture() + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + await vi.waitFor(() => expect(cancelled).toHaveBeenCalled()) + expect(f.memory.settled).toBe(false) + expect(f.dispatch).not.toHaveBeenCalled() + await dispose() + expect(f.memory.settled).toBe(true) + }) + + it('consumes an allowed claim body before dispatching the prompt', async () => { + const response = Response.json({ allowed: true }) + claim.mockResolvedValue(response) + const f = fixture() + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + await vi.waitFor(() => expect(f.dispatch).toHaveBeenCalledExactlyOnceWith('prompt.submit')) + expect(response.bodyUsed).toBe(true) + await dispose() + }) + + it('consumes malformed JSON and retries without delivering until expiry', async () => { + const response = new Response('{invalid', { status: 200 }) + claim.mockResolvedValue(response) + const f = fixture() + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + await vi.waitFor(() => { + expect(response.bodyUsed).toBe(true) + expect(claim.mock.calls.length).toBeGreaterThanOrEqual(2) + }) + expect(f.memory.settled).toBe(false) + f.memory.expiresAt = Date.now() + await vi.advanceTimersByTimeAsync(100) + expect(response.bodyUsed).toBe(true) + expect(f.dispatch).not.toHaveBeenCalled() + expect(f.memory.settled).toBe(true) + await dispose() + }) + + it.each(['dialog', 'shell', 'autocomplete'])('does not populate a %s editor', async (kind) => { + const f = fixture() + if (kind === 'dialog') { + f.editor.traits.role = 'dialog' + } + if (kind === 'shell') { + f.editor.traits.status = 'SHELL' + } + if (kind === 'autocomplete') { + f.editor.traits.capture = ['escape', 'navigate', 'submit', 'tab'] + } + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + expect(f.editor.plainText).toBe('') + expect(claim).not.toHaveBeenCalled() + expect(f.dispatch).not.toHaveBeenCalled() + await dispose() + }) + it('retries pending admission, then submits once', async () => { + claim.mockResolvedValueOnce({ ok: true, json: async () => ({ allowed: false, pending: true }) }) + const f = fixture() + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(100) + await vi.waitFor(() => expect(claim).toHaveBeenCalledTimes(1)) + expect(f.dispatch).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(300) + await vi.waitFor(() => expect(f.dispatch).toHaveBeenCalledTimes(1)) + expect(claim).toHaveBeenCalledTimes(2) + await dispose() + }) + + it('cancels pending admission on input without retrying a consumed denial', async () => { + claim.mockResolvedValue({ ok: true, json: async () => ({ allowed: false, pending: true }) }) + const f = fixture() + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(100) + await vi.waitFor(() => expect(claim).toHaveBeenCalledTimes(1)) + f.input.emit('keypress', { name: 'x' }) + await vi.advanceTimersByTimeAsync(1000) + expect(claim).toHaveBeenCalledTimes(1) + expect(f.dispatch).not.toHaveBeenCalled() + await dispose() + }) + + it('preserves typing that predates plugin setup without requesting owner permission', async () => { + const f = fixture() + f.editor.replace('early typing') + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + expect(f.editor.plainText).toBe('early typing') + expect(claim).not.toHaveBeenCalled() + expect(f.dispatch).not.toHaveBeenCalled() + await dispose() + }) + it('waits for catalogs and settles before a single dispatch', async () => { + const f = fixture() + f.model.mockReturnValue(undefined) + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + expect(f.dispatch).not.toHaveBeenCalled() + f.model.mockReturnValue([{}]) + await vi.advanceTimersByTimeAsync(500) + await vi.waitFor(() => expect(f.dispatch).toHaveBeenCalledExactlyOnceWith('prompt.submit')) + expect(claim).toHaveBeenCalledTimes(1) + expect(f.memory.settled).toBe(true) + f.editor.replace(prompt) + await vi.advanceTimersByTimeAsync(500) + expect(f.dispatch).toHaveBeenCalledTimes(1) + await dispose() + const reloadDispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + expect(f.dispatch).toHaveBeenCalledTimes(1) + await reloadDispose() + }) + + it.each(['keypress', 'paste'])('cancels on physical %s before catalogs finish', async (event) => { + const f = fixture() + f.agent.mockReturnValue(undefined) + const dispose = await setup(f.ctx) + f.input.emit(event) + f.agent.mockReturnValue([{}]) + await vi.advanceTimersByTimeAsync(500) + expect(f.dispatch).not.toHaveBeenCalled() + expect(f.memory.settled).toBe(true) + await dispose() + }) + + it('cancels a changed draft even when it is restored before the next tick', async () => { + const f = fixture() + f.model.mockReturnValue(undefined) + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(100) + f.editor.replace('edited') + f.editor.replace('') + f.model.mockReturnValue([{}]) + await vi.advanceTimersByTimeAsync(500) + expect(f.dispatch).not.toHaveBeenCalled() + await dispose() + }) + + it('cancels pending intent on route changes, expiration and disposal', async () => { + for (const reason of ['route', 'expiration', 'dispose']) { + const f = fixture() + f.model.mockReturnValue(undefined) + const dispose = await setup(f.ctx) + if (reason === 'route') { + f.route.type = 'session' + } + if (reason === 'expiration') { + f.memory.expiresAt = Date.now() + } + if (reason === 'dispose') { + await dispose() + } + await vi.advanceTimersByTimeAsync(100) + f.model.mockReturnValue([{}]) + await vi.advanceTimersByTimeAsync(500) + expect(f.dispatch).not.toHaveBeenCalled() + expect(f.memory.settled).toBe(true) + await dispose() + expect(f.input.listenerCount('keypress')).toBe(0) + } + }) + + it('leaves unverified versions and mismatched drafts unsubmitted', async () => { + const f = fixture() + f.ctx.app.version = '2.0.17' + await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + expect(f.dispatch).not.toHaveBeenCalled() + f.ctx.app.version = '2.0.16' + f.editor.replace('another brief') + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + expect(f.dispatch).not.toHaveBeenCalled() + await dispose() + }) + + it.each(['canceled', 'unavailable'])( + 'fails closed when the execution owner is %s', + async (reason) => { + claim.mockImplementation(async () => { + if (reason === 'unavailable') { + throw new Error('contact lost') + } + return { ok: true, json: async () => ({ allowed: false }) } + }) + const f = fixture() + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + expect(f.dispatch).not.toHaveBeenCalled() + if (reason === 'unavailable') { + await vi.waitFor(() => expect(claim).toHaveBeenCalled()) + expect(f.memory.settled).toBe(false) + f.memory.expiresAt = Date.now() + await vi.advanceTimersByTimeAsync(100) + } + await vi.waitFor(() => expect(f.memory.settled).toBe(true)) + await dispose() + } + ) + + it('rechecks physical cancellation after the owner response', async () => { + const f = fixture() + claim.mockImplementation(async () => { + f.input.emit('keypress') + return { ok: true, json: async () => ({ allowed: true }) } + }) + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + await vi.waitFor(() => expect(claim).toHaveBeenCalledTimes(1)) + await vi.waitFor(() => expect(f.memory.settled).toBe(true)) + expect(f.dispatch).not.toHaveBeenCalled() + await dispose() + }) + it('settles before insertion and never repeats dispatch when the draft is retained', async () => { + const f = fixture() + f.dispatch.mockImplementation(() => {}) + const insert = vi.spyOn(f.editor, 'insertText') + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(1500) + await vi.waitFor(() => expect(f.dispatch).toHaveBeenCalledTimes(1)) + expect(insert).toHaveBeenCalledExactlyOnceWith(prompt) + expect(f.memory.settled).toBe(true) + expect(f.editor.plainText).toBe(prompt) + await dispose() + const reloadDispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + expect(f.dispatch).toHaveBeenCalledTimes(1) + await reloadDispose() + }) + + it.each(['before-grant', 'after-grant', 'timeout-after-grant'])( + 'recovers actual HTTP response loss %s exactly once', + async (phase) => { + vi.useRealTimers() + vi.unstubAllGlobals() + const claims = new OpenCodeStartupPromptClaims() + claims.register('single-use-nonce', digest, () => ({ + freshSpawn: true, + firstUserInputAt: null + })) + let requests = 0 + const bodies: unknown[] = [] + const grants: (boolean | 'pending')[] = [] + let heldResponse: ReturnType | undefined + const server = createServer(async (request, response) => { + let text = '' + for await (const chunk of request) { + text += chunk.toString() + } + const body: unknown = JSON.parse(text) + bodies.push(body) + requests++ + if (requests === 1 && phase === 'before-grant') { + response.writeHead(503).end('temporarily unavailable') + return + } + const allowed = claims.claim(body) + grants.push(allowed) + if (requests === 1 && phase === 'after-grant') { + response.destroy() + return + } + response.writeHead(200, { 'content-type': 'application/json' }) + if (requests === 1 && phase === 'timeout-after-grant') { + response.write('{"allowed":') + heldResponse = setTimeout(() => response.end('true}'), 1300) + return + } + response.end(JSON.stringify({ allowed: allowed === true, pending: allowed === 'pending' })) + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (!address || typeof address === 'string') { + throw new Error('missing loopback address') + } + writeFileSync( + join(dir, 'endpoint.cmd'), + `set ORCA_AGENT_HOOK_PORT=${address.port}\nset ORCA_AGENT_HOOK_TOKEN=private-token\nset ORCA_AGENT_HOOK_ENV=test\nset ORCA_AGENT_HOOK_VERSION=1\n` + ) + const f = fixture() + const dispose = await setup(f.ctx) + try { + await vi.waitFor( + () => + expect( + f.dispatch, + JSON.stringify({ phase, requests, bodies, grants }) + ).toHaveBeenCalledTimes(1), + { timeout: 3000 } + ) + await new Promise((resolve) => setTimeout(resolve, 300)) + expect(requests).toBe(2) + expect(grants).toEqual(phase === 'before-grant' ? [true] : [true, true]) + expect(bodies[1]).toEqual(bodies[0]) + expect(bodies[0]).toHaveProperty('requestId', expect.any(String)) + expect(f.memory.settled).toBe(true) + expect(f.editor.plainText).toBe('') + } finally { + await dispose() + claims.clear() + clearTimeout(heldResponse) + server.closeAllConnections() + await new Promise((resolve) => server.close(() => resolve())) + } + } + ) + + it.each([408, 429, 503])( + 'retries transient HTTP %s with one stable operation ID', + async (status) => { + claim.mockResolvedValueOnce({ ok: false, status }) + const f = fixture() + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + await vi.waitFor(() => expect(f.dispatch).toHaveBeenCalledTimes(1)) + const firstBody = JSON.parse(claim.mock.calls[0][1].body) + expect(firstBody.requestId).toMatch(/^[a-f0-9-]{36}$/) + expect(JSON.parse(claim.mock.calls[1][1].body)).toEqual(firstBody) + expect(claim).toHaveBeenCalledTimes(2) + await dispose() + } + ) + + it.each([401, 403, 404])('settles HTTP %s denial without retrying', async (status) => { + claim.mockResolvedValue({ ok: false, status }) + const f = fixture() + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + await vi.waitFor(() => expect(f.memory.settled).toBe(true)) + expect(claim).toHaveBeenCalledTimes(1) + expect(f.dispatch).not.toHaveBeenCalled() + await dispose() + }) + + it.each(['input', 'route', 'dispose', 'expiry', 'editor'])( + 'rejects late grants after %s changes', + async (reason) => { + const f = fixture() + let release = (_response: unknown) => {} + claim.mockImplementation( + () => + new Promise((resolve) => { + release = resolve + }) + ) + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(100) + await vi.waitFor(() => expect(claim).toHaveBeenCalledTimes(1)) + if (reason === 'input') { + f.input.emit('paste') + } + if (reason === 'route') { + f.route.type = 'session' + } + if (reason === 'dispose') { + await dispose() + } + if (reason === 'expiry') { + f.memory.expiresAt = Date.now() + } + if (reason === 'editor') { + f.ctx.renderer.currentFocusedEditor = new Editor() + } + release({ ok: true, json: async () => ({ allowed: true }) }) + await vi.advanceTimersByTimeAsync(500) + expect(f.dispatch).not.toHaveBeenCalled() + expect(f.editor.plainText).toBe('') + await dispose() + } + ) + + it.each(['input', 'route', 'dispose'])( + 'ends delivery when %s changes during insertion', + async (reason) => { + const f = fixture() + let dispose = async () => {} + const insert = f.editor.insertText.bind(f.editor) + vi.spyOn(f.editor, 'insertText').mockImplementation((text) => { + expect(f.memory.settled).toBe(true) + insert(text) + if (reason === 'input') { + f.input.emit('keypress') + } + if (reason === 'route') { + f.route.type = 'session' + } + if (reason === 'dispose') { + void dispose() + } + }) + dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + await vi.waitFor(() => expect(claim).toHaveBeenCalledTimes(1)) + expect(f.dispatch).not.toHaveBeenCalled() + expect(f.memory.settled).toBe(true) + await dispose() + } + ) + + it.each(['allow', 'lost-response'])( + 'degrades safely against a legacy host with %s', + async (reason) => { + const claims = new OpenCodeStartupPromptClaims() + claims.register('single-use-nonce', digest, () => ({ + freshSpawn: true, + firstUserInputAt: null + })) + let lost = false + claim.mockImplementation(async (_url, init) => { + const body = JSON.parse(init.body) + const allowed = claims.claim({ nonce: body.nonce, digest: body.digest }) + if (reason === 'lost-response' && !lost) { + lost = true + throw new Error('legacy grant response lost') + } + return { ok: true, json: async () => ({ allowed }) } + }) + const f = fixture() + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + await vi.waitFor(() => expect(f.memory.settled).toBe(true)) + expect(f.dispatch).toHaveBeenCalledTimes(reason === 'allow' ? 1 : 0) + expect(claim).toHaveBeenCalledTimes(reason === 'allow' ? 1 : 2) + expect(f.editor.plainText).toBe('') + claims.clear() + await dispose() + } + ) +}) diff --git a/src/main/opencode/opencode-startup-prompt-source.ts b/src/main/opencode/opencode-startup-prompt-source.ts new file mode 100644 index 00000000000..4bb37861102 --- /dev/null +++ b/src/main/opencode/opencode-startup-prompt-source.ts @@ -0,0 +1,152 @@ +import { cancelUnreadResponseBody } from '../lib/unread-response-body' +import { parseAgentHookEndpointFile } from '../../shared/agent-hook-endpoint-file' +import { + OPENCODE_STARTUP_PROMPT_SHA256_ENV, + OPENCODE_STARTUP_PROMPT_NONCE_ENV, + OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV, + OPENCODE_STARTUP_PROMPT_CLAIM_PATH, + OPENCODE_STARTUP_PROMPT_BODY_ENV +} from '../../shared/opencode-startup-prompt' + +export function getOpenCodeStartupPromptSource(): string { + return String.raw` +const parseEndpoint = ${parseAgentHookEndpointFile.toString()}; +const cancelUnreadResponseBody = ${cancelUnreadResponseBody.toString()}; +async function claimStartupPrompt(nonce, digest, endpoint, requestId) { + let response; + try { + const { readFile, stat } = await import("node:fs/promises"); + if ((await stat(endpoint)).size > 4096) return false; + const coords = parseEndpoint(await readFile(endpoint, "utf8")); + const port = Number(coords.port); + if (!Number.isInteger(port) || port < 1 || port > 65535) return false; + response = await fetch("http://127.0.0.1:" + port + "${OPENCODE_STARTUP_PROMPT_CLAIM_PATH}", { + method: "POST", headers: { "content-type": "application/json", "x-orca-agent-hook-token": coords.token }, + body: JSON.stringify({ nonce, digest, requestId }), signal: AbortSignal.timeout(1000) + }); + if (!response.ok) return response.status === 408 || response.status === 429 || response.status >= 500 ? "pending" : false; + const result = await response.json(); + return result.allowed === true ? true : result.pending === true ? "pending" : false; + } catch { + // A lost grant response can be replayed with the same operation ID until expiry. + return "pending"; + } finally { + if (response) await cancelUnreadResponseBody(response); + } +} +async function submitStartupPrompt(ctx) { + const noop = async () => {}; + const digest = process.env.${OPENCODE_STARTUP_PROMPT_SHA256_ENV}; + const nonce = process.env.${OPENCODE_STARTUP_PROMPT_NONCE_ENV}; + const endpoint = process.env.${OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV}; + const prompt = process.env.${OPENCODE_STARTUP_PROMPT_BODY_ENV}; + if (ctx?.app?.version !== "2.0.16" || !/^[a-f0-9]{64}$/.test(digest || "") || !nonce || !endpoint || !prompt) return noop; + const input = ctx.renderer?.keyInput; + if (typeof ctx.storage?.memory !== "function" || typeof input?.on !== "function" || + typeof input?.off !== "function" || typeof ctx.keymap?.dispatch !== "function" || + typeof ctx.ui?.router?.current !== "function" || + typeof ctx.data?.location?.sync !== "function" || + typeof ctx.data?.location?.agent?.list !== "function" || + typeof ctx.data?.location?.model?.list !== "function") return noop; + const [memory, setMemory] = ctx.storage.memory("startup-prompt", { + initial: { settled: false, expiresAt: Date.now() + 20000 } + }); + if (memory.settled) return noop; + let timer, editor, seen = false, disposed = false, canceled = false, createHash, requestId, claiming = false, hydrating = false, readyLocation; + // Home can change location after plugin setup. + const locationKey = (location) => typeof location?.directory === "string" && location.directory ? + JSON.stringify([location.directory, location.workspaceID]) : undefined; + const isComposer = (candidate) => candidate?.traits?.owner === "opencode" && + candidate.traits.role === "prompt" && !candidate.traits.status && + candidate.traits.capture?.length === 1 && candidate.traits.capture[0] === "tab"; + const matches = (candidate) => typeof candidate?.plainText === "string" && + createHash("sha256").update(candidate.plainText).digest("hex") === digest; + const cleanup = () => { + clearInterval(timer); + input.off("keypress", cancel); + input.off("paste", cancel); + editor?.off("line-info-change", changed); + }; + const settle = () => { + setMemory((draft) => { draft.settled = true; }); + cleanup(); + }; + const cancel = () => { canceled = true; settle(); }; + // Any edit before delivery ends this startup operation. + const changed = () => { if (seen && editor.plainText !== "") settle(); }; + input.on("keypress", cancel); + input.on("paste", cancel); + const dispose = async () => { disposed = true; settle(); }; + try { + const crypto = await import("node:crypto"); + createHash = crypto.createHash; + setMemory((draft) => { draft.requestId ??= crypto.randomUUID(); }); + requestId = memory.requestId; + if (createHash("sha256").update(prompt).digest("hex") !== digest) { await dispose(); return noop; } + if (memory.settled) return dispose; + timer = setInterval(async () => { + if (disposed || memory.settled) return; + try { + if (Date.now() >= memory.expiresAt || ctx.ui.router.current()?.type !== "home") return settle(); + const current = ctx.renderer.currentFocusedEditor; + if (!isComposer(current)) { if (seen) settle(); return; } + if (editor !== current) { + if (seen) return settle(); + editor?.off("line-info-change", changed); + editor = current; + editor?.on("line-info-change", changed); + } + if (typeof editor?.plainText !== "string" || typeof editor?.insertText !== "function") return; + if (editor.plainText !== "") return settle(); + seen = true; + const location = ctx.location; + const key = locationKey(location); + if (!key) return; + if (readyLocation !== key) { + readyLocation = undefined; + if (!hydrating) { + hydrating = true; + const ref = { directory: location.directory, workspaceID: location.workspaceID }; + void ctx.data.location.sync(ref).then(() => { + hydrating = false; + if (!disposed && !memory.settled && locationKey(ctx.location) === key) readyLocation = key; + }, settle); + } + return; + } + const agents = ctx.data.location.agent.list(location); + const models = ctx.data.location.model.list(location); + if (!editor.focused || !agents?.length || !models?.length) return; + if (claiming) return; + claiming = true; + const allowed = await claimStartupPrompt(nonce, digest, endpoint, requestId); + claiming = false; + if (allowed === "pending") return; + if (!allowed) return settle(); + if (disposed || memory.settled || Date.now() >= memory.expiresAt || + locationKey(ctx.location) !== key || + ctx.ui.router.current()?.type !== "home" || ctx.renderer.currentFocusedEditor !== editor || + !editor.focused || !isComposer(editor) || editor.plainText !== "") return settle(); + setMemory((draft) => { draft.settled = true; }); + clearInterval(timer); + editor.off("line-info-change", changed); + try { + editor.insertText(prompt); + if (disposed || canceled || Date.now() >= memory.expiresAt || + locationKey(ctx.location) !== key || + ctx.ui.router.current()?.type !== "home" || ctx.renderer.currentFocusedEditor !== editor || + !editor.focused || !isComposer(editor) || !matches(editor)) return; + ctx.keymap.dispatch("prompt.submit"); + } finally { cleanup(); } + } catch { settle(); } + }, 100); + timer.unref?.(); + return dispose; + } catch { + settle(); + return noop; + } +} +export default { id: "orca-opencode-startup-prompt", setup: submitStartupPrompt }; +`.trimStart() +} diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index ad9f51e7ad6..a71307a8df5 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -151,6 +151,7 @@ async function startOrcadRuntime( | undefined let uninstallHookStatusRepublish = (): void => {} let uninstallObservedStatusIdentity = (): void => {} + let removeStatusHookSettingsListener = (): void => {} registerCleanup(async () => { try { await rpc?.stop() @@ -167,6 +168,7 @@ async function startOrcadRuntime( // orcad restart goes back to killing every running terminal. await stopOrcadDaemon() } finally { + removeStatusHookSettingsListener() uninstallObservedStatusIdentity() uninstallHookStatusRepublish() agentHookServer.stop() @@ -194,9 +196,17 @@ async function startOrcadRuntime( uninstallObservedStatusIdentity = agentHookServer.subscribeEnrichedStatus((enriched) => observedStatusCapture.observe(enriched) ) - if (isAgentStatusHooksEnabled(profileStore.getSettings())) { - await agentHookServer.start({ env: 'production', userDataPath: runtimeUserDataPath }) - } + await agentHookServer.start({ + env: 'production', + userDataPath: runtimeUserDataPath, + statusHooksEnabled: isAgentStatusHooksEnabled(profileStore.getSettings()) + }) + + removeStatusHookSettingsListener = profileStore.onSettingsChanged((updates, settings) => { + if ('agentStatusHooksEnabled' in updates) { + agentHookServer.setStatusHooksEnabled(isAgentStatusHooksEnabled(settings)) + } + }) // Why before the runtime and the PTY handlers: `setLocalPtyProvider` installs the daemon // adapter as THE local provider, and the registry's contract is that it lands before diff --git a/src/main/orcad/orcad-push-startup.test.ts b/src/main/orcad/orcad-push-startup.test.ts index 530d06f4ee8..41bba43d4cf 100644 --- a/src/main/orcad/orcad-push-startup.test.ts +++ b/src/main/orcad/orcad-push-startup.test.ts @@ -1,7 +1,8 @@ import { mkdtempSync, readdirSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { ProfilePreferences } from '../persistence/loading-store/profile-preferences' import { DeviceRegistry } from '../runtime/device-registry' import { RuntimeMobileNotificationController } from '../runtime/runtime-mobile-notification-controller' import { PushUnregisterOutbox } from '../runtime/push/push-unregister-outbox' @@ -14,6 +15,9 @@ const state = vi.hoisted(() => ({ controller: null as RuntimeMobileNotificationController | null, registry: null as DeviceRegistry | null, rpcStarted: false, + onSettingsChanged: vi.fn(), + removeSettingsListener: vi.fn(), + startDaemon: vi.fn(async () => {}), browserProvider: vi.fn(async () => null), register: vi.fn(async () => ({ ok: true, registrationId: 'headless-registration' })), send: vi.fn(async () => ({ ok: true, results: [] })) @@ -26,7 +30,7 @@ vi.mock('./orcad-app-paths', () => ({ vi.mock('./orcad-browser-provider', () => ({ resolveOrcadBrowserProvider: state.browserProvider })) vi.mock('./orcad-instance-lock', () => ({ acquireOrcadInstanceLock: () => ({ release() {} }) })) vi.mock('./orcad-daemon-supervision', () => ({ - startOrcadDaemon: async () => {}, + startOrcadDaemon: state.startDaemon, stopOrcadDaemon: async () => {} })) vi.mock('./orcad-health', () => ({ collectOrcadHealth: async () => ({}) })) @@ -44,6 +48,7 @@ vi.mock('./orcad-profile-state-startup', () => ({ createOrcadProfileStateStartup: async () => ({ store: { getSettings: () => ({}), + onSettingsChanged: state.onSettingsChanged, flushFinalOrThrowAsync: async () => {}, freezeWritesAsync: async () => {} }, @@ -124,6 +129,10 @@ vi.mock('../runtime/push/push-gateway-client', () => ({ } })) +beforeEach(() => { + state.onSettingsChanged.mockReturnValue(state.removeSettingsListener) +}) + afterEach(() => { rmSync(state.root, { recursive: true, force: true }) vi.clearAllMocks() @@ -176,6 +185,11 @@ it('starts push after RPC identity is available and stops dispatch on shutdown', expect(readdirSync(profileStateAccessPaths(state.root).participants)).toEqual([]) acquireProfileStateMaintenance(state.root).release() expect(state.controller.getListenerCount()).toBe(0) + expect(state.rpcStarted).toBe(false) + expect(state.onSettingsChanged).toHaveBeenCalledOnce() + expect(state.removeSettingsListener).toHaveBeenCalledOnce() + await host.stop() + expect(state.removeSettingsListener).toHaveBeenCalledOnce() expect(await state.controller.registerPushDevice({} as never)).toMatchObject({ registered: false }) @@ -189,3 +203,14 @@ it('releases admission when host setup fails before a runtime exists', async () expect(readdirSync(profileStateAccessPaths(state.root).participants)).toEqual([]) acquireProfileStateMaintenance(state.root).release() }) + +it('unsubscribes settings when daemon startup fails after hook setup', async () => { + state.root = mkdtempSync(join(tmpdir(), 'orca-headless-daemon-failure-')) + state.startDaemon.mockRejectedValueOnce(new Error('daemon setup failed')) + const { startOrcad } = await import('./orcad-entry') + await expect(startOrcad()).rejects.toThrow('daemon setup failed') + expect(state.onSettingsChanged).toHaveBeenCalledOnce() + expect(state.removeSettingsListener).toHaveBeenCalledOnce() + expect(readdirSync(profileStateAccessPaths(state.root).participants)).toEqual([]) + acquireProfileStateMaintenance(state.root).release() +}) diff --git a/src/main/providers/provider-dispatch.test.ts b/src/main/providers/provider-dispatch.test.ts index 12b259f5b9f..18ac3b4683e 100644 --- a/src/main/providers/provider-dispatch.test.ts +++ b/src/main/providers/provider-dispatch.test.ts @@ -1,3 +1,4 @@ +import { openCodeHookServiceModuleMock } from '../ipc/pty-ipc-mock-registry' import { settledWriteStub } from './settled-pty-write-stub' import { describe, expect, it, vi } from 'vitest' import { setPtyHostBindings } from '../ipc/pty-host-bindings' @@ -48,18 +49,7 @@ vi.mock('node-pty', () => ({ }) })) -vi.mock('../opencode/hook-service', () => ({ - openCodeHookService: { - buildPtyEnv: () => ({}), - refreshLegacySharedPlugin: vi.fn(), - clearPty: vi.fn() - }, - openCode2HookService: { - buildPtyEnv: () => ({}), - refreshLegacySharedPlugin: vi.fn(), - clearPty: vi.fn() - } -})) +vi.mock('../opencode/hook-service', () => openCodeHookServiceModuleMock()) vi.mock('../pi/titlebar-extension-service', () => ({ piTitlebarExtensionService: { buildPtyEnv: () => ({}), clearPty: vi.fn() } @@ -176,6 +166,11 @@ describe('PTY provider dispatch', () => { 'CLAUDE_CODE_SESSION_ID', 'CLAUDE_CODE_BRIDGE_SESSION_ID', 'ORCA_OPENCODE_PLUGIN_API', + 'ORCA_OPENCODE_STARTUP_PROMPT_BODY', + 'ORCA_OPENCODE_STARTUP_PROMPT_ENDPOINT', + 'ORCA_OPENCODE_STARTUP_PROMPT_NONCE', + 'ORCA_OPENCODE_STARTUP_PROMPT_SHA256', + 'ORCA_OPENCODE_STARTUP_PROMPT_SHELL', 'ORCA_PI_STATUS_OWNED', 'ORCA_PRIME_AGENT_STATUS_OWNED', 'ORCA_PI_TITLE_MARKER_OWNED', diff --git a/src/main/runtime/orca-runtime-resolve-authoritative-terminal-wait-permission.ts b/src/main/runtime/orca-runtime-resolve-authoritative-terminal-wait-permission.ts index 3265d8ac166..29c1a636ffa 100644 --- a/src/main/runtime/orca-runtime-resolve-authoritative-terminal-wait-permission.ts +++ b/src/main/runtime/orca-runtime-resolve-authoritative-terminal-wait-permission.ts @@ -19,6 +19,25 @@ import type { AgentPromptActivity } from './agent-prompt-submission-verification import { readTuiIdleHookTurn, type TuiIdleHookTurn } from './tui-idle-hook-lane' export class OrcaRuntimeWithResolveAuthoritativeTerminalWaitPermission extends OrcaRuntimeWithAgentPromptRequestCorrelation { + readOpenCodeStartupPromptOwner(ptyId: string, incarnationId: string, launchToken: string) { + const pty = this.ptysById.get(ptyId) + if (!pty || pty.incarnationId !== incarnationId) { + return null + } + // The runtime launch route admits identity immediately after low-level spawn returns. + if (pty.launchToken === null && pty.launchAgent === null && pty.launchIncarnationId === null) { + return 'pending' as const + } + if ( + pty.launchIncarnationId !== incarnationId || + pty.launchToken !== launchToken || + (pty.launchAgent !== 'opencode' && pty.launchAgent !== 'opencode2') + ) { + return null + } + return this.terminalRunFacts.read(ptyId, incarnationId) + } + protected resolveAuthoritativeTerminalWaitPermission( terminal: RuntimeTerminalAgentStatusSnapshot, explicitStatus: { status: AgentStatus; updatedAt: number } | null, diff --git a/src/main/runtime/terminal-run-facts.test.ts b/src/main/runtime/terminal-run-facts.test.ts index 85460b9fc27..1966a405949 100644 --- a/src/main/runtime/terminal-run-facts.test.ts +++ b/src/main/runtime/terminal-run-facts.test.ts @@ -2,6 +2,21 @@ import { describe, expect, it } from 'vitest' import { TerminalRunFactsRegister } from './terminal-run-facts' describe('terminal run facts', () => { + it('keeps driving input before publication across the exact reserved commit', () => { + const facts = new TerminalRunFactsRegister() + facts.recordInput('pending', 'driving', 'x', 100) + facts.reserveSpawnCommit({ id: 'pending', incarnationId: 'inc-1' }) + facts.recordSpawnCommit({ id: 'pending', incarnationId: 'inc-1' }) + expect(facts.read('pending', 'inc-1').firstUserInputAt).toBe(100) + facts.reserveSpawnCommit({ id: 'pending', incarnationId: 'inc-2' }) + facts.recordInput('pending', 'driving', 'x', 200) + facts.recordSpawnCommit({ id: 'pending', incarnationId: 'inc-2' }) + expect(facts.read('pending', 'inc-2').firstUserInputAt).toBe(200) + facts.delete('pending') + facts.reserveSpawnCommit({ id: 'pending', incarnationId: 'inc-3' }) + facts.recordSpawnCommit({ id: 'pending', incarnationId: 'inc-3' }) + expect(facts.read('pending', 'inc-3').firstUserInputAt).toBeNull() + }) it('reads a run main never saw committed as not fresh', () => { expect(new TerminalRunFactsRegister().read('pty-1', 'inc-1')).toEqual({ freshSpawn: false, diff --git a/src/main/runtime/terminal-run-facts.ts b/src/main/runtime/terminal-run-facts.ts index b20f64985e4..b1c029a767c 100644 --- a/src/main/runtime/terminal-run-facts.ts +++ b/src/main/runtime/terminal-run-facts.ts @@ -42,12 +42,38 @@ export class TerminalRunFactsRegister { private readonly runsByPtyId = new Map() // Why apart from the run record: input must count on a PTY main adopted without a commit. private readonly lastInputAtByPtyId = new Map() + private readonly pendingByPtyId = new Map< + string, + { incarnationId: string | null; firstInputAt: number | null } + >() + + reserveSpawnCommit(commit: TerminalSpawnCommit): void { + if (!commit.incarnationId) { + return + } + const prior = this.pendingByPtyId.get(commit.id) + this.pendingByPtyId.set(commit.id, { + incarnationId: commit.incarnationId, + firstInputAt: + prior?.incarnationId === null || prior?.incarnationId === commit.incarnationId + ? prior.firstInputAt + : null + }) + } + + discardSpawnCommit(commit: TerminalSpawnCommit): void { + if (this.pendingByPtyId.get(commit.id)?.incarnationId === (commit.incarnationId ?? null)) { + this.pendingByPtyId.delete(commit.id) + } + } /** Once per process: a re-registration of the same incarnation keeps its facts, and so does a * reattach or adoption of the running process unless its incarnation shows another process. */ recordSpawnCommit(commit: TerminalSpawnCommit, expectedSourceBinding?: unknown): void { const incarnationId = commit.incarnationId ?? null const previous = this.runsByPtyId.get(commit.id) + const pending = this.pendingByPtyId.get(commit.id) + this.pendingByPtyId.delete(commit.id) if (incarnationId !== null && previous?.incarnationId === incarnationId) { return } @@ -60,7 +86,11 @@ export class TerminalRunFactsRegister { this.runsByPtyId.set(commit.id, { incarnationId, spawnOrigin: origin === 'spawn' && commit.coldRestore !== undefined ? 'cold-restore' : origin, - firstUserInputAt: sameProcess ? (previous?.firstUserInputAt ?? null) : null + firstUserInputAt: sameProcess + ? (previous?.firstUserInputAt ?? null) + : pending?.incarnationId === incarnationId + ? pending.firstInputAt + : null }) } @@ -73,6 +103,14 @@ export class TerminalRunFactsRegister { } this.lastInputAtByPtyId.set(ptyId, now) const run = this.runsByPtyId.get(ptyId) + if (inputKind === 'driving') { + const pending = this.pendingByPtyId.get(ptyId) + if (pending) { + pending.firstInputAt ??= now + } else if (!run) { + this.pendingByPtyId.set(ptyId, { incarnationId: null, firstInputAt: now }) + } + } if (run && inputKind === 'driving') { run.firstUserInputAt ??= now } @@ -99,5 +137,6 @@ export class TerminalRunFactsRegister { delete(ptyId: string): void { this.runsByPtyId.delete(ptyId) this.lastInputAtByPtyId.delete(ptyId) + this.pendingByPtyId.delete(ptyId) } } diff --git a/src/main/startup/headless-pty-hydration-ordering.test.ts b/src/main/startup/headless-pty-hydration-ordering.test.ts index c761ccf0409..be618a8605d 100644 --- a/src/main/startup/headless-pty-hydration-ordering.test.ts +++ b/src/main/startup/headless-pty-hydration-ordering.test.ts @@ -78,14 +78,28 @@ describe('headless PTY registry hydration ordering', () => { const runtime = source.indexOf('const runtime = new OrcaRuntimeService(') const identityReader = source.indexOf('readObservedAgentStatusPaneIdentity:', runtime) const identitySubscription = source.indexOf('agentHookServer.subscribeEnrichedStatus(') - const hooksEnabled = source.indexOf('if (isAgentStatusHooksEnabled(', identitySubscription) + const hookStart = source.indexOf('await agentHookServer.start(', identitySubscription) + const settingsListener = source.indexOf('profileStore.onSettingsChanged(', hookStart) + const daemon = source.indexOf('await startOrcadDaemon()', hookStart) const identityFlush = source.indexOf('observedStatusCapture.attach(runtime)', runtime) expect(runtime).toBeGreaterThanOrEqual(0) expect(identityReader).toBeGreaterThan(runtime) expect(identitySubscription).toBeGreaterThanOrEqual(0) expect(identitySubscription).toBeLessThan(runtime) - expect(hooksEnabled).toBeGreaterThan(identitySubscription) + expect(hookStart).toBeGreaterThan(identitySubscription) + expect(settingsListener).toBeGreaterThan(hookStart) + expect(daemon).toBeGreaterThan(settingsListener) + expect(runtime).toBeGreaterThan(daemon) + expect(source.slice(identitySubscription, hookStart)).not.toContain( + 'if (isAgentStatusHooksEnabled(' + ) + expect(source.slice(hookStart, settingsListener)).toContain( + 'statusHooksEnabled: isAgentStatusHooksEnabled(profileStore.getSettings())' + ) + expect(source.slice(settingsListener, daemon)).toContain( + 'agentHookServer.setStatusHooksEnabled(isAgentStatusHooksEnabled(settings))' + ) expect(identityFlush).toBeGreaterThan(runtime) expect(source.slice(identitySubscription, runtime)).toContain( 'observedStatusCapture.observe(enriched)' diff --git a/src/main/startup/main-process-pty-startup.ts b/src/main/startup/main-process-pty-startup.ts index 03cef473299..b5cb284db08 100644 --- a/src/main/startup/main-process-pty-startup.ts +++ b/src/main/startup/main-process-pty-startup.ts @@ -171,9 +171,6 @@ export function startTerminalRuntimeStartupServices(): WindowsDesktopStartupServ // Why: PTY spawn env reads ORCA_AGENT_HOOK_* from live server state, so the renderer awaits this before restored terminals reconnect. startAgentHookServer: async () => { const settings = state.store?.getSettings() - if (!isAgentStatusHooksEnabled(settings)) { - return - } logStartupMilestone('startup-service-start', { service: 'agent-hook-server' }) // Why (#11217): the hook listener fails open on every request error, so an IDS resetting // loopback POSTs mid-body stops agent status for every runtime with no symptom but staleness. @@ -182,6 +179,7 @@ export function startTerminalRuntimeStartupServices(): WindowsDesktopStartupServ track('agent_hook_transport_blocked', { count: report.count }) }) await agentHookServer.start({ + statusHooksEnabled: isAgentStatusHooksEnabled(settings), env: app.isPackaged ? 'production' : 'development', // Why: hooks source this endpoint file at invocation time so old PTY env reaches the current process after restart; dev namespaces it (worktrees share `orca-dev`). userDataPath: app.getPath('userData'), diff --git a/src/main/startup/main-process-ready-foundation.ts b/src/main/startup/main-process-ready-foundation.ts index 238998bac49..c9ec3b30368 100644 --- a/src/main/startup/main-process-ready-foundation.ts +++ b/src/main/startup/main-process-ready-foundation.ts @@ -49,6 +49,7 @@ import { syncMacMenuBarIcon } from './main-window-actions' import { updateGpuAccelerationAboutPanel } from './gpu-lifecycle' import { reconcileManagedWslCliRegistrations } from '../cli/wsl-cli-registration-reconciliation' import { createWslCliReconciliationStartupBarrier } from './wsl-cli-reconciliation-startup-barrier' +import { agentHookServer } from '../agent-hooks/server' import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' import { reportProfileStateWriteFailure } from './profile-state-write-failure' @@ -248,6 +249,7 @@ export async function initializeReadyFoundation(): Promise { syncMacMenuBarIcon(settings.showMenuBarIcon !== false) } if ('agentStatusHooksEnabled' in updates) { + agentHookServer.setStatusHooksEnabled(isAgentStatusHooksEnabled(settings)) // Why both directions: the ensure gate only blocks NEW relays, so off must stop the running // guest process and timers, and on must restart them — otherwise open WSL panes report no // status until their next spawn. diff --git a/src/relay/opencode-overlay-mirror.ts b/src/relay/opencode-overlay-mirror.ts new file mode 100644 index 00000000000..0bd5e0b0f6c --- /dev/null +++ b/src/relay/opencode-overlay-mirror.ts @@ -0,0 +1,40 @@ +import { mkdirSync, readdirSync, realpathSync, statSync } from 'node:fs' +import { join } from 'node:path' +import { mirrorEntry } from '../main/pty/overlay-mirror' + +export function mirrorOpenCodeConfig( + sourceDir: string, + overlayDir: string, + excludedPluginEntries: ReadonlySet +): void { + for (const entry of readdirSync(sourceDir, { withFileTypes: true })) { + const sourcePath = join(sourceDir, entry.name) + if (entry.name === 'plugins') { + const isSymlink = entry.isSymbolicLink() + let isLinkPointingToDir = false + if (isSymlink) { + try { + isLinkPointingToDir = statSync(sourcePath).isDirectory() + } catch { + isLinkPointingToDir = false + } + } + if ((!isSymlink && entry.isDirectory()) || isLinkPointingToDir) { + const resolvedSource = isLinkPointingToDir ? realpathSync(sourcePath) : sourcePath + const overlayPluginsDir = join(overlayDir, 'plugins') + mkdirSync(overlayPluginsDir, { recursive: true }) + for (const pluginEntry of readdirSync(resolvedSource, { withFileTypes: true })) { + if (excludedPluginEntries.has(pluginEntry.name)) { + continue + } + mirrorEntry( + join(resolvedSource, pluginEntry.name), + join(overlayPluginsDir, pluginEntry.name) + ) + } + continue + } + } + mirrorEntry(sourcePath, join(overlayDir, entry.name)) + } +} diff --git a/src/relay/opencode-startup-prompt-install.test.ts b/src/relay/opencode-startup-prompt-install.test.ts new file mode 100644 index 00000000000..71cb373511e --- /dev/null +++ b/src/relay/opencode-startup-prompt-install.test.ts @@ -0,0 +1,86 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { PluginOverlayManager } from './plugin-overlay' +import { createInstallPluginsHandler } from './wsl-install-plugins-handler' + +let root: string +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'relay-prompt-install-')) +}) +afterEach(() => { + rmSync(root, { recursive: true, force: true }) +}) +const promptPath = (config: string) => + join(config, 'plugins', 'orca-opencode-startup-prompt', 'tui.js') +describe('execution-host startup prompt installation', () => { + it('caches prompt source independently and revokes future installs with an empty source', () => { + const manager = new PluginOverlayManager({ homeDir: root }) + const config = join(root, 'custom') + manager.setSources({ opencodeStartupPromptSource: 'prompt source' }) + expect(manager.hasOpenCodeSource()).toBe(false) + expect(manager.installOpenCodeStartupPromptPlugin({}, config)).toBe(true) + manager.setSources({ opencodePluginSource: '' }) + expect(manager.installOpenCodeStartupPromptPlugin({}, config)).toBe(true) + expect(readFileSync(promptPath(config), 'utf8')).toBe('prompt source') + manager.setSources({ opencodeStartupPromptSource: '' }) + expect(manager.installOpenCodeStartupPromptPlugin({}, join(root, 'not-installed'))).toBe(false) + expect(existsSync(join(root, 'not-installed'))).toBe(false) + }) + it('materializes a prompt-only overlay without overwriting a same-named user plugin', () => { + const manager = new PluginOverlayManager({ homeDir: root }) + const config = join(root, 'custom') + mkdirSync(join(config, 'plugins', 'orca-opencode-startup-prompt'), { recursive: true }) + writeFileSync(promptPath(config), 'user collision') + writeFileSync(join(config, 'opencode.json'), '{"model":"user/model"}') + manager.setSources({ opencodeStartupPromptSource: 'prompt source' }) + const overlay = manager.materializeOpenCode('pane', config) + if (!overlay) { + throw new Error('Missing prompt overlay') + } + expect(readFileSync(promptPath(overlay), 'utf8')).toBe('prompt source') + expect(readFileSync(promptPath(config), 'utf8')).toBe('user collision') + expect(readFileSync(join(overlay, 'opencode.json'), 'utf8')).toContain('user/model') + expect(existsSync(join(overlay, 'plugins', 'orca-opencode-status.js'))).toBe(false) + }) + it('installs through WSL with both status sources disabled and preserves explicit config', () => { + const config = join(root, 'custom') + const manager = new PluginOverlayManager({ homeDir: root }) + const install = createInstallPluginsHandler(manager, { + HOME: root, + OPENCODE_CONFIG_DIR: config + }) + const result = install({ + opencodeStartupPromptSource: 'first', + opencodePluginSource: '', + opencode2PluginSource: '' + }) + expect(result.installed).toMatchObject({ + opencodeStartupPrompt: true, + opencode: false, + opencode2: false + }) + expect(result.overlayDirs).toEqual({}) + expect(readFileSync(promptPath(config), 'utf8')).toBe('first') + install({ opencodeStartupPromptSource: 'second' }) + expect(readFileSync(promptPath(config), 'utf8')).toBe('second') + }) + it('refreshes prompt source in both cached status overlays without rebuilding them', () => { + const manager = new PluginOverlayManager({ homeDir: root }) + const install = createInstallPluginsHandler(manager, { HOME: root }) + const first = install({ + opencodeStartupPromptSource: 'first', + opencodePluginSource: 'status v1', + opencode2PluginSource: 'status v2' + }) + const second = install({ opencodeStartupPromptSource: 'second' }) + expect(second.overlayDirs).toEqual(first.overlayDirs) + for (const config of [second.overlayDirs.opencode, second.overlayDirs.opencode2]) { + if (!config) { + throw new Error('Missing status overlay') + } + expect(readFileSync(promptPath(config), 'utf8')).toBe('second') + } + }) +}) diff --git a/src/relay/plugin-overlay.ts b/src/relay/plugin-overlay.ts index 6d78918c46e..08c9f772bed 100644 --- a/src/relay/plugin-overlay.ts +++ b/src/relay/plugin-overlay.ts @@ -1,3 +1,9 @@ +import { mirrorOpenCodeConfig } from './opencode-overlay-mirror' +import { + writeOpenCodeStartupPromptPlugin, + OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY +} from '../shared/opencode-startup-prompt-install' +import { resolveOpenCodeConfigDirectory } from '../shared/opencode-config-directory' import { materializeOmpFreshConfig } from '../shared/omp-fresh-config' // Why: relay-side equivalent of Orca's local agent integration installers. // OpenCode still needs a config overlay, while Pi/OMP now get Orca-managed @@ -17,19 +23,11 @@ import { materializeOmpFreshConfig } from '../shared/omp-fresh-config' // implementation rooted at $HOME/.orca-relay/ for OpenCode and at the remote // Pi/OMP homes for those agents. import { createHash } from 'node:crypto' -import { - existsSync, - mkdirSync, - readFileSync, - readdirSync, - realpathSync, - statSync, - writeFileSync -} from 'node:fs' +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { writeOverlayOpenCodePluginAtomically } from '../shared/opencode-plugin-atomic-write' import { homedir } from 'node:os' import { join } from 'node:path' -import { mirrorEntry, safeRemoveOverlay } from '../main/pty/overlay-mirror' +import { safeRemoveOverlay } from '../main/pty/overlay-mirror' import type { PiAgentKind } from '../shared/pi-agent-kind' import { installOpenCodePluginInCanonicalConfig, @@ -52,9 +50,10 @@ const PI_OVERLAY_SUBDIR_BY_KIND: Record = { const OPENCODE_PLUGIN_FILE = 'orca-opencode-status.js' const OPENCODE2_PLUGIN_FILE = 'orca-opencode2-status.js' // Orca's own entries (either major, file and TUI copy) are never mirrored from user config. -const ORCA_OPENCODE_PLUGIN_ENTRIES = new Set( - [OPENCODE_PLUGIN_FILE, OPENCODE2_PLUGIN_FILE].flatMap((f) => [f, openCodeTuiPluginDirName(f)]) -) +const ORCA_OPENCODE_PLUGIN_ENTRIES = new Set([ + OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY, + ...[OPENCODE_PLUGIN_FILE, OPENCODE2_PLUGIN_FILE].flatMap((f) => [f, openCodeTuiPluginDirName(f)]) +]) const PI_EXTENSION_FILE = 'orca-agent-status.ts' const PI_AGENT_SUBDIR = 'agent' const OMP_MANAGED_STATUS_EXTENSION_DIR = 'omp-managed-status-extension' @@ -87,6 +86,7 @@ function isUsableId(id: string): boolean { return typeof id === 'string' && id.length > 0 && id.length <= 1024 } export type PluginSources = { + opencodeStartupPromptSource?: string /** Empty string revokes future installs; omission preserves the cached source. */ opencodePluginSource?: string /** Source body of OpenCode 2's status plugin. */ @@ -118,6 +118,7 @@ export function getRelayOpenCodePluginPath( ) } export class PluginOverlayManager { + private opencodeStartupPromptSource: string | null = null private opencodePluginSource: string | null = null private opencode2PluginSource: string | null = null private piExtensionSources: Record = { @@ -147,6 +148,9 @@ export class PluginOverlayManager { * process start. Future PTYs pick up the refreshed source when the relay * writes plugin/extension files before spawn. */ setSources(sources: PluginSources): void { + if (typeof sources.opencodeStartupPromptSource === 'string') { + this.opencodeStartupPromptSource = sources.opencodeStartupPromptSource + } if (typeof sources.opencodePluginSource === 'string') { this.opencodePluginSource = sources.opencodePluginSource } @@ -178,38 +182,6 @@ export class PluginOverlayManager { const source = this.piExtensionSources[kind] return source ?? (kind === 'omp' ? this.piExtensionSources.pi : null) } - private mirrorOpenCodeConfig(sourceDir: string, overlayDir: string): void { - for (const entry of readdirSync(sourceDir, { withFileTypes: true })) { - const sourcePath = join(sourceDir, entry.name) - if (entry.name === 'plugins') { - const isSymlink = entry.isSymbolicLink() - let isLinkPointingToDir = false - if (isSymlink) { - try { - isLinkPointingToDir = statSync(sourcePath).isDirectory() - } catch { - isLinkPointingToDir = false - } - } - if ((!isSymlink && entry.isDirectory()) || isLinkPointingToDir) { - const resolvedSource = isLinkPointingToDir ? realpathSync(sourcePath) : sourcePath - const overlayPluginsDir = join(overlayDir, 'plugins') - mkdirSync(overlayPluginsDir, { recursive: true }) - for (const pluginEntry of readdirSync(resolvedSource, { withFileTypes: true })) { - if (ORCA_OPENCODE_PLUGIN_ENTRIES.has(pluginEntry.name)) { - continue - } - mirrorEntry( - join(resolvedSource, pluginEntry.name), - join(overlayPluginsDir, pluginEntry.name) - ) - } - continue - } - } - mirrorEntry(sourcePath, join(overlayDir, entry.name)) - } - } private writeOpenCodePlugin(overlayDir: string, pluginFileName: string, source: string): void { const pluginsDir = join(overlayDir, 'plugins') mkdirSync(pluginsDir, { recursive: true }) @@ -230,7 +202,7 @@ export class PluginOverlayManager { agent: 'opencode' | 'opencode2' = 'opencode' ): string | null { const source = agent === 'opencode2' ? this.opencode2PluginSource : this.opencodePluginSource - if (!source || !isUsableId(id)) { + if ((!source && !this.opencodeStartupPromptSource) || !isUsableId(id)) { return null } const pluginFileName = agent === 'opencode2' ? OPENCODE2_PLUGIN_FILE : OPENCODE_PLUGIN_FILE @@ -246,9 +218,14 @@ export class PluginOverlayManager { // Why: OPENCODE_CONFIG_DIR is a single config root. Mirror the user's // remote root into the overlay before adding Orca's plugin so status // reporting does not hide their auth, models, keybinds, or plugins. - this.mirrorOpenCodeConfig(existingConfigDir, dir) + mirrorOpenCodeConfig(existingConfigDir, dir, ORCA_OPENCODE_PLUGIN_ENTRIES) + } + if (source) { + this.writeOpenCodePlugin(dir, pluginFileName, source) + } + if (this.opencodeStartupPromptSource) { + writeOpenCodeStartupPromptPlugin(dir, this.opencodeStartupPromptSource, 'overlay') } - this.writeOpenCodePlugin(dir, pluginFileName, source) return dir } catch (err) { process.stderr.write( @@ -258,6 +235,29 @@ export class PluginOverlayManager { } } + installOpenCodeStartupPromptPlugin( + environment: NodeJS.ProcessEnv | Record, + configDir?: string + ): boolean { + if (!this.opencodeStartupPromptSource) { + return false + } + try { + writeOpenCodeStartupPromptPlugin( + configDir ?? + environment.OPENCODE_CONFIG_DIR ?? + resolveOpenCodeConfigDirectory(environment, this.homeDir), + this.opencodeStartupPromptSource + ) + return true + } catch (error) { + process.stderr.write( + `[plugin-overlay] failed to install OpenCode startup prompt: ${error instanceof Error ? error.message : String(error)}\n` + ) + return false + } + } + hasOpenCode2Source(): boolean { return this.hasOpenCodeSource('opencode2') } diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 4f86f8a3508..b96781818e7 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -34,6 +34,13 @@ import { getRelayShellLaunchConfig, isRelayWslShell } from './pty-shell-launch' import { RetiredPaneSurfaceRegistry } from './retired-pane-surfaces' import { applyScrubSafeAgentEnvAliases } from '../shared/agent-hook-scrub-safe-env' import { addWslEnvKeys } from '../shared/wsl-env' +import { + OPENCODE_STARTUP_PROMPT_SHA256_ENV, + OPENCODE_STARTUP_PROMPT_NONCE_ENV, + OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV, + OPENCODE_STARTUP_PROMPT_BODY_ENV, + OPENCODE_STARTUP_PROMPT_SHELL_ENV +} from '../shared/opencode-startup-prompt' import { ORCA_IMAGE_PROTOCOL_ENV, ORCA_IMAGE_PROTOCOL_VALUE @@ -2043,6 +2050,16 @@ export class PtyHandler { envToDelete ) delete spawnEnv.ORCA_OPENCODE_PLUGIN_API + // Relay input streams lack driving-input provenance, so native intent is unavailable. + for (const key of [ + OPENCODE_STARTUP_PROMPT_SHA256_ENV, + OPENCODE_STARTUP_PROMPT_NONCE_ENV, + OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV, + OPENCODE_STARTUP_PROMPT_BODY_ENV, + OPENCODE_STARTUP_PROMPT_SHELL_ENV + ]) { + delete spawnEnv[key] + } const openCodeCapabilities = await probeOpenCodeLaunchCapabilities({ command, agent: launchAgent, diff --git a/src/relay/relay-agent-hook-runtime.ts b/src/relay/relay-agent-hook-runtime.ts index e0d9a95ae68..4ee9113919a 100644 --- a/src/relay/relay-agent-hook-runtime.ts +++ b/src/relay/relay-agent-hook-runtime.ts @@ -186,17 +186,20 @@ export class RelayAgentHookRuntime { })) registerManagedHookInstaller(this.dispatcher) this.dispatcher.onRequest(AGENT_HOOK_INSTALL_PLUGINS_METHOD, async (params) => { + const startupPrompt = params.opencodeStartupPromptSource const opencode = params.opencodePluginSource const opencode2 = params.opencode2PluginSource const pi = params.piExtensionSource const omp = params.ompExtensionSource const primeAgent = params.primeAgentExtensionSource + assertPluginSourceUnderByteCap('opencodeStartupPromptSource', startupPrompt) assertPluginSourceUnderByteCap('opencodePluginSource', opencode) assertPluginSourceUnderByteCap('opencode2PluginSource', opencode2) assertPluginSourceUnderByteCap('piExtensionSource', pi) assertPluginSourceUnderByteCap('ompExtensionSource', omp) assertPluginSourceUnderByteCap('primeAgentExtensionSource', primeAgent) this.pluginOverlay.setSources({ + opencodeStartupPromptSource: typeof startupPrompt === 'string' ? startupPrompt : undefined, opencodePluginSource: typeof opencode === 'string' ? opencode : undefined, opencode2PluginSource: typeof opencode2 === 'string' ? opencode2 : undefined, piExtensionSource: typeof pi === 'string' ? pi : undefined, @@ -213,8 +216,12 @@ export class RelayAgentHookRuntime { installOpenCodePluginInCanonicalConfig(source, agent, process.env, homedir(), true) } } + const startupPromptInstalled = this.pluginOverlay.installOpenCodeStartupPromptPlugin( + process.env + ) return { installed: { + opencodeStartupPrompt: startupPromptInstalled, opencode: this.pluginOverlay.hasOpenCodeSource(), opencode2: this.pluginOverlay.hasOpenCode2Source(), pi: this.pluginOverlay.hasPiSource('pi'), diff --git a/src/relay/wsl-install-plugins-handler.ts b/src/relay/wsl-install-plugins-handler.ts index b602d353ea8..e66ff7d20a5 100644 --- a/src/relay/wsl-install-plugins-handler.ts +++ b/src/relay/wsl-install-plugins-handler.ts @@ -16,6 +16,7 @@ import { export type InstallPluginsResult = { installed: { + opencodeStartupPrompt?: boolean opencode: boolean opencode2?: boolean pi: boolean @@ -43,18 +44,21 @@ export function createInstallPluginsHandler( let materialized2: { source: string; sourceDir: string | undefined; dir: string } | null = null return (params) => { + const startupPrompt = params.opencodeStartupPromptSource const opencode = params.opencodePluginSource const opencode2 = params.opencode2PluginSource const pi = params.piExtensionSource const omp = params.ompExtensionSource const primeAgent = params.primeAgentExtensionSource // Why: bound per-source bytes so a buggy/hostile host can't OOM the guest relay. + assertPluginSourceUnderByteCap('opencodeStartupPromptSource', startupPrompt) assertPluginSourceUnderByteCap('opencodePluginSource', opencode) assertPluginSourceUnderByteCap('opencode2PluginSource', opencode2) assertPluginSourceUnderByteCap('piExtensionSource', pi) assertPluginSourceUnderByteCap('ompExtensionSource', omp) assertPluginSourceUnderByteCap('primeAgentExtensionSource', primeAgent) pluginOverlay.setSources({ + opencodeStartupPromptSource: typeof startupPrompt === 'string' ? startupPrompt : undefined, opencodePluginSource: typeof opencode === 'string' ? opencode : undefined, opencode2PluginSource: typeof opencode2 === 'string' ? opencode2 : undefined, piExtensionSource: typeof pi === 'string' ? pi : undefined, @@ -135,8 +139,28 @@ export function createInstallPluginsHandler( } } } + const promptConfigDirs = [opencodeDir, opencode2Dir].filter( + (dir): dir is string => typeof dir === 'string' + ) + if (promptConfigDirs.length === 0) { + promptConfigDirs.push( + resolveOpenCodeSourceConfigDir( + Object.fromEntries( + Object.entries(env).flatMap(([key, value]) => + typeof value === 'string' ? [[key, value]] : [] + ) + ), + env.SHELL + ) ?? resolveOpenCodeConfigDirectory(env, env.HOME) + ) + } + const promptInstallResults = promptConfigDirs.map((dir) => + pluginOverlay.installOpenCodeStartupPromptPlugin(env, dir) + ) + const startupPromptInstalled = promptInstallResults.every(Boolean) return { installed: { + opencodeStartupPrompt: startupPromptInstalled, opencode: pluginOverlay.hasOpenCodeSource(), opencode2: pluginOverlay.hasOpenCode2Source(), pi: pluginOverlay.hasPiSource('pi'), diff --git a/src/shared/opencode-headless-command.test.ts b/src/shared/opencode-headless-command.test.ts index 408895a1dbb..09d243a4bcf 100644 --- a/src/shared/opencode-headless-command.test.ts +++ b/src/shared/opencode-headless-command.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { tokenizeStartupCommand } from './tui-agent-startup-shell' import { tokenizeCommandLine } from './agent-command-line-entrypoint' import { isOpenCodeRunCommand } from './opencode-headless-command' @@ -24,3 +25,66 @@ describe('isOpenCodeRunCommand', () => { expect(matches('opencode --log-level run')).toBe(false) }) }) + +describe('wrapped OpenCode run command position', () => { + it.each([ + 'CUSTOM_CONFIG=private opencode --log-level debug run --standalone', + 'env CUSTOM_CONFIG=private opencode run --standalone', + 'CUSTOM_CONFIG=private /usr/bin/env -- EXTRA_CONFIG=kept opencode run --standalone' + ])('recognizes only the executable behind supported POSIX prefixes: %s', (command) => { + expect(matches(command)).toBe(true) + }) + + it('recognizes a PowerShell call operator before a quoted executable', () => { + const parsed = tokenizeStartupCommand( + '& "C:\\Program Files\\opencode\\opencode.exe" run --standalone', + 'powershell' + ) + expect(parsed.ok).toBe(true) + if (!parsed.ok) { + throw new Error(parsed.error) + } + expect(isOpenCodeRunCommand(parsed.tokens, 'powershell')).toBe(true) + expect(isOpenCodeRunCommand(parsed.tokens, 'cmd')).toBe(false) + }) + + it.each([ + 'env -u FOO opencode run', + 'env -uFOO opencode run', + 'env --unset FOO opencode run', + 'env --unset=FOO opencode run', + 'env -i PRIVATE_CONFIG=kept opencode run', + 'env --ignore-environment PRIVATE_CONFIG=kept opencode run', + 'env - PRIVATE_CONFIG=kept opencode run', + 'env -C /workspace opencode run', + 'env -C/workspace opencode run', + 'env --chdir /workspace opencode run', + 'env --chdir=/workspace opencode run', + 'env -P /private/bin opencode run', + 'env -P/private/bin opencode run', + 'CONFIG=kept /usr/bin/env -i -u FOO -C /workspace -- OTHER=kept opencode run' + ])('recognizes an executable after env options: %s', (command) => { + expect(matches(command)).toBe(true) + }) + + it.each([ + 'env -u', + 'env -C', + 'env -u opencode run', + 'env -C opencode run', + 'env -u FOO echo opencode run', + 'env --unset=FOO echo run', + 'env -S "opencode run"', + 'env --unknown opencode run' + ])('does not mistake env option arguments for the executable: %s', (command) => { + expect(matches(command)).toBe(false) + }) + + it('does not find executable names inside other commands or prompt arguments', () => { + expect(matches('env CUSTOM_CONFIG=private echo opencode run')).toBe(false) + expect(matches('env CUSTOM_CONFIG=private echo run')).toBe(false) + expect(matches('CUSTOM_CONFIG=private opencode --prompt "opencode run"')).toBe(false) + expect(matches('env -- opencode serve --title run')).toBe(false) + expect(matches('opencode attach http://host/run')).toBe(false) + }) +}) diff --git a/src/shared/opencode-headless-command.ts b/src/shared/opencode-headless-command.ts index 5328f7931fc..766d70642fa 100644 --- a/src/shared/opencode-headless-command.ts +++ b/src/shared/opencode-headless-command.ts @@ -1,16 +1,105 @@ -// Why: `opencode run` answers one prompt and exits, so its process lifetime is its turn. -// Not in agent-headless-command's table: that would also drop OpenCode 1 `run`'s identity, -// whose in-process plugin reports it. Only `--log-level` takes a separate value before the -// subcommand; any other valued option makes the value the first positional, which fails safe. -export function isOpenCodeRunCommand(tokens: readonly string[]): boolean { - for (let index = 1; index < tokens.length; index += 1) { +import { extractLeadingEnvAssignments } from './command-environment' +import { getCommandTokenPathBasename } from './command-token-scanner' +import type { AgentStartupShell } from './tui-agent-startup-shell' + +const RUN_VALUE_FLAGS = new Set([ + '--log-level', + '--completions', + '--server', + '--session', + '-s', + '--model', + '-m', + '--agent', + '--format', + '--file', + '-f', + '--title' +]) + +const ENV_VALUE_FLAGS = new Set(['-u', '--unset', '-C', '--chdir', '-P']) +const ENV_EMPTY_FLAGS = new Set(['-i', '--ignore-environment', '-']) + +function envCommandPosition(tokens: readonly string[], offset: number): number { + let index = offset + while (index < tokens.length) { + const token = tokens[index] + if (token === '--') { + index += 1 + break + } + if (ENV_EMPTY_FLAGS.has(token)) { + index += 1 + } else if (ENV_VALUE_FLAGS.has(token)) { + index += 2 + } else if (/^(?:-[uCP].+|--(?:unset|chdir)=)/.test(token)) { + index += 1 + } else if (token.startsWith('-')) { + // Split-string options need another parse before their executable is known. + return tokens.length + } else { + break + } + } + return tokens.length - extractLeadingEnvAssignments(tokens.slice(index)).rest.length +} + +function openCodeCommandPosition(tokens: readonly string[], shell: AgentStartupShell): number { + if (shell === 'powershell' && tokens[0] === '&') { + return 1 + } + if (shell !== 'posix') { + return 0 + } + let index = tokens.length - extractLeadingEnvAssignments(tokens.slice()).rest.length + if (getCommandTokenPathBasename(tokens[index] ?? '') === 'env') { + index = envCommandPosition(tokens, index + 1) + } + return index +} + +export function findOpenCodeRunCommand( + tokens: readonly string[], + shell: AgentStartupShell = 'posix' +): { runIndex: number; messageSeparatorIndex: number | null } | null { + const commandPosition = openCodeCommandPosition(tokens, shell) + if (commandPosition > 0) { + const binary = getCommandTokenPathBasename(tokens[commandPosition] ?? '') + .toLowerCase() + .replace(/\.(?:exe|cmd)$/, '') + if (binary !== 'opencode' && binary !== 'opencode2') { + return null + } + } + for (let index = commandPosition + 1; index < tokens.length; index += 1) { const token = tokens[index] if (!token.startsWith('-')) { - return token === 'run' + if (token !== 'run') { + return null + } + for (let argumentIndex = index + 1; argumentIndex < tokens.length; argumentIndex += 1) { + const argument = tokens[argumentIndex] + if (argument === '--') { + return { runIndex: index, messageSeparatorIndex: argumentIndex } + } + if (RUN_VALUE_FLAGS.has(argument)) { + argumentIndex += 1 + } + } + return { runIndex: index, messageSeparatorIndex: null } } + // Other valued global options fail closed at their first positional value. if (token === '--log-level') { index += 1 } } - return false + return null +} + +// OpenCode run's process lifetime is its turn; v1 still reports through its plugin. +export function isOpenCodeRunCommand( + tokens: readonly string[], + shell: AgentStartupShell = 'posix' +): boolean { + return findOpenCodeRunCommand(tokens, shell) !== null } diff --git a/src/shared/opencode-startup-prompt-install.ts b/src/shared/opencode-startup-prompt-install.ts new file mode 100644 index 00000000000..6427a9e9886 --- /dev/null +++ b/src/shared/opencode-startup-prompt-install.ts @@ -0,0 +1,17 @@ +import { join } from 'node:path' +import { writeOpenCodeTuiPluginDirectory } from './opencode-tui-plugin-install' + +export const OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY = 'orca-opencode-startup-prompt' + +export function writeOpenCodeStartupPromptPlugin( + configDir: string, + source: string, + ownership: 'canonical' | 'overlay' = 'canonical' +): void { + writeOpenCodeTuiPluginDirectory( + join(configDir, 'plugins'), + OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY, + source, + ownership + ) +} diff --git a/src/shared/opencode-startup-prompt.test.ts b/src/shared/opencode-startup-prompt.test.ts new file mode 100644 index 00000000000..e4aab26d065 --- /dev/null +++ b/src/shared/opencode-startup-prompt.test.ts @@ -0,0 +1,52 @@ +import { createHash } from 'node:crypto' +import { describe, expect, it } from 'vitest' +import { buildAgentStartupPlan, buildAgentDraftLaunchPlan } from './tui-agent-startup' +import { + OPENCODE_STARTUP_PROMPT_SHA256_ENV, + OPENCODE_STARTUP_PROMPT_BODY_ENV, + OPENCODE_STARTUP_PROMPT_SHELL_ENV +} from './opencode-startup-prompt' + +describe('native OpenCode startup submission intent', () => { + it('binds the exact trimmed native prompt to host-selectable transport', () => { + const plan = buildAgentStartupPlan({ + agent: 'opencode', + prompt: ' task\nwith unicode é ', + cmdOverrides: {}, + platform: 'linux' + }) + expect(plan?.env).toEqual({ + [OPENCODE_STARTUP_PROMPT_SHA256_ENV]: createHash('sha256') + .update('task\nwith unicode é') + .digest('hex'), + [OPENCODE_STARTUP_PROMPT_BODY_ENV]: 'task\nwith unicode é', + [OPENCODE_STARTUP_PROMPT_SHELL_ENV]: 'posix' + }) + expect(plan?.launchCommand).toContain('--prompt') + }) + + it('preserves explicit run commands without a submission intent', () => { + const plan = buildAgentStartupPlan({ + agent: 'opencode', + prompt: 'task', + cmdOverrides: { opencode: 'opencode --log-level debug run' }, + platform: 'linux' + }) + expect(plan?.env).toBeUndefined() + expect(plan?.launchCommand).toContain('run --prompt') + }) + + it('never gives an editable draft or empty launch an automatic submission intent', () => { + const args = { agent: 'opencode' as const, cmdOverrides: {}, platform: 'linux' as const } + expect( + buildAgentDraftLaunchPlan({ ...args, draft: 'draft' })?.env?.[ + OPENCODE_STARTUP_PROMPT_SHA256_ENV + ] + ).toBeUndefined() + expect( + buildAgentStartupPlan({ ...args, prompt: '', allowEmptyPromptLaunch: true })?.env?.[ + OPENCODE_STARTUP_PROMPT_SHA256_ENV + ] + ).toBeUndefined() + }) +}) diff --git a/src/shared/opencode-startup-prompt.ts b/src/shared/opencode-startup-prompt.ts new file mode 100644 index 00000000000..1dbe9c2719d --- /dev/null +++ b/src/shared/opencode-startup-prompt.ts @@ -0,0 +1,39 @@ +import { isOpenCodeRunCommand } from './opencode-headless-command' +import { sha256 } from './sha256' +import { tokenizeStartupCommand, type AgentStartupShell } from './tui-agent-startup-shell' +import type { TuiAgent } from './tui-agent' + +export const OPENCODE_STARTUP_PROMPT_SHA256_ENV = 'ORCA_OPENCODE_STARTUP_PROMPT_SHA256' +export const OPENCODE_STARTUP_PROMPT_NONCE_ENV = 'ORCA_OPENCODE_STARTUP_PROMPT_NONCE' +export const OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV = 'ORCA_OPENCODE_STARTUP_PROMPT_ENDPOINT' +export const OPENCODE_STARTUP_PROMPT_CLAIM_PATH = '/opencode/startup-prompt/claim' +export const OPENCODE_STARTUP_PROMPT_BODY_ENV = 'ORCA_OPENCODE_STARTUP_PROMPT_BODY' +export const OPENCODE_STARTUP_PROMPT_SHELL_ENV = 'ORCA_OPENCODE_STARTUP_PROMPT_SHELL' + +export function openCodeStartupPromptEnv( + agent: TuiAgent, + command: string, + shell: AgentStartupShell, + prompt: string, + env: Record | null | undefined +): { env?: Record } { + const parsed = tokenizeStartupCommand(command, shell) + if ( + (agent !== 'opencode' && agent !== 'opencode2') || + !parsed.ok || + isOpenCodeRunCommand(parsed.tokens) + ) { + return env ? { env: { ...env } } : {} + } + const digest = Array.from(sha256(new TextEncoder().encode(prompt)), (byte) => + byte.toString(16).padStart(2, '0') + ).join('') + return { + env: { + ...env, + [OPENCODE_STARTUP_PROMPT_SHA256_ENV]: digest, + [OPENCODE_STARTUP_PROMPT_BODY_ENV]: prompt, + [OPENCODE_STARTUP_PROMPT_SHELL_ENV]: shell + } + } +} diff --git a/src/shared/opencode-tui-plugin-install.ts b/src/shared/opencode-tui-plugin-install.ts index 3a8306473c8..54e06df8883 100644 --- a/src/shared/opencode-tui-plugin-install.ts +++ b/src/shared/opencode-tui-plugin-install.ts @@ -32,13 +32,27 @@ export function writeOpenCodeTuiPlugin( source: string, ownership: 'canonical' | 'overlay' = 'canonical' ): void { - const dir = join(pluginsDir, openCodeTuiPluginDirName(pluginFileName)) + writeOpenCodeTuiPluginDirectory( + pluginsDir, + openCodeTuiPluginDirName(pluginFileName), + source, + ownership + ) +} + +export function writeOpenCodeTuiPluginDirectory( + pluginsDir: string, + directoryName: string, + source: string, + ownership: 'canonical' | 'overlay' = 'canonical' +): void { + const dir = join(pluginsDir, directoryName) const entry = join(dir, 'tui.js') // The 1.x TUI loader rejects a default object that also exposes server(). const tuiSource = source.includes('const ORCA_STATUS_AGENT = "opencode";') && source.includes('async function setupLegacyOpenCodeTui(') - ? `${source.replace(/^export default /m, 'const orcaServerPlugin = ')}\nconst { server: _orcaServerOnly, ...orcaTuiPlugin } = orcaServerPlugin;\nexport default { id: ${JSON.stringify(pluginFileName.replace(/\.js$/, ''))}, setup: setupOpenCode2Status, ...orcaTuiPlugin, tui: setupLegacyOpenCodeTui };\n` + ? `${source.replace(/^export default /m, 'const orcaServerPlugin = ')}\nconst { server: _orcaServerOnly, ...orcaTuiPlugin } = orcaServerPlugin;\nexport default { id: ${JSON.stringify(directoryName.replace(/-tui$/, ''))}, setup: setupOpenCode2Status, ...orcaTuiPlugin, tui: setupLegacyOpenCodeTui };\n` : source const isCurrent = ownership === 'canonical' ? isInstalledOpenCodePluginCurrent : isOverlayOpenCodePluginCurrent diff --git a/src/shared/tui-agent-startup.ts b/src/shared/tui-agent-startup.ts index c381454e9c4..ce8974a8cc3 100644 --- a/src/shared/tui-agent-startup.ts +++ b/src/shared/tui-agent-startup.ts @@ -17,6 +17,7 @@ import { inlineAgentDraftFitsPlatform } from './agent-draft-platform-limit' import type { TuiAgent } from './tui-agent' import type { SessionOptionValue } from './native-chat-session-options' import { resolveAgentLaunchCommand } from './tui-agent-launch-command' +import { openCodeStartupPromptEnv } from './opencode-startup-prompt' export { buildAgentResumeStartupPlan } from './tui-agent-resume-startup' @@ -123,7 +124,7 @@ export function buildAgentStartupPlan(args: { followupPrompt: null, launchConfig, ...appliedSessionOptionProps(baseCommand.appliedSessionOptions), - ...(args.agentEnv ? { env: { ...args.agentEnv } } : {}) + ...openCodeStartupPromptEnv(agent, launchCommand, shell, trimmedPrompt, args.agentEnv) } } From f62bd7dc209822345b70d46f9b4eb095224a1cab Mon Sep 17 00:00:00 2001 From: Kevin Saliou Date: Sun, 4 Oct 2026 10:41:51 +0200 Subject: [PATCH 05/31] feat(csv-viewer): detect semicolon-separated CSVs (#19894) Co-authored-by: Claude Opus 5 Co-authored-by: Neil --- .../editor/CsvViewer.delimiter.test.tsx | 89 +++++++++++++ .../src/components/editor/CsvViewer.tsx | 28 +++- .../editor/csv-delimiter-picker.tsx | 68 ++++++++++ .../src/components/editor/csv-parse.test.ts | 124 ++++++++++++++++++ .../src/components/editor/csv-parse.ts | 82 +++++++++++- src/renderer/src/i18n/locales/en.json | 7 +- 6 files changed, 387 insertions(+), 11 deletions(-) create mode 100644 src/renderer/src/components/editor/CsvViewer.delimiter.test.tsx create mode 100644 src/renderer/src/components/editor/csv-delimiter-picker.tsx diff --git a/src/renderer/src/components/editor/CsvViewer.delimiter.test.tsx b/src/renderer/src/components/editor/CsvViewer.delimiter.test.tsx new file mode 100644 index 00000000000..73f4091df0a --- /dev/null +++ b/src/renderer/src/components/editor/CsvViewer.delimiter.test.tsx @@ -0,0 +1,89 @@ +// @vitest-environment happy-dom +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import CsvViewer from './CsvViewer' + +vi.mock('@tanstack/react-virtual', () => ({ + useVirtualizer: ({ count }: { count: number }) => ({ + getVirtualItems: () => + Array.from({ length: Math.min(count, 3) }, (_, index) => ({ + index, + key: index, + start: index * 28 + })), + getTotalSize: () => count * 28 + }) +})) + +afterEach(cleanup) + +async function chooseDelimiter(label: string): Promise { + fireEvent.keyDown(screen.getByRole('combobox', { name: 'Delimiter' }), { key: 'ArrowDown' }) + fireEvent.click(await screen.findByRole('option', { name: label })) +} + +describe('CSV delimiter selection', () => { + it('lets the reader resolve ambiguous headerless decimal data', async () => { + render() + expect(screen.getByRole('combobox', { name: 'Delimiter' }).textContent).toBe('Auto (Comma)') + expect(screen.getByRole('columnheader', { name: '50;coffee' })).toBeTruthy() + + await chooseDelimiter('Semicolon (;)') + + expect(screen.getByRole('columnheader', { name: '1,50' })).toBeTruthy() + expect(screen.getByRole('cell', { name: '2,75' })).toBeTruthy() + expect(screen.getByRole('cell', { name: 'tea' })).toBeTruthy() + }) + + it('applies the selected separator to quoted multiline records', async () => { + render( + + ) + await chooseDelimiter('Semicolon (;)') + + expect(screen.getAllByRole('columnheader').map((cell) => cell.textContent)).toEqual([ + '#', + 'multi\nline', + 'value' + ]) + expect(screen.getAllByRole('cell').map((cell) => cell.textContent)).toEqual([ + 'first\nsecond', + 'a;b' + ]) + }) + + it('allows an explicit comma separator even when the extension is tsv', async () => { + render() + expect(screen.getByRole('combobox', { name: 'Delimiter' }).textContent).toBe('Auto (Tab)') + + await chooseDelimiter('Comma (,)') + + expect(screen.getByRole('columnheader', { name: 'amount' })).toBeTruthy() + expect(screen.getByRole('cell', { name: '1,50' })).toBeTruthy() + }) + + it('keeps an explicit choice on refresh and can return to the current auto choice', async () => { + const { rerender } = render() + await chooseDelimiter('Semicolon (;)') + + rerender() + + expect(screen.getByRole('combobox', { name: 'Delimiter' }).textContent).toBe('Semicolon (;)') + expect(screen.getAllByRole('columnheader')).toHaveLength(2) + await chooseDelimiter('Auto (Tab)') + expect(screen.getByRole('columnheader', { name: 'value' })).toBeTruthy() + expect(screen.getByRole('cell', { name: '2' })).toBeTruthy() + }) + + it('resets to Auto when the editor mounts a different file identity', async () => { + const { rerender } = render( + + ) + await chooseDelimiter('Comma (,)') + + rerender() + + expect(screen.getByRole('combobox', { name: 'Delimiter' }).textContent).toBe('Auto (Tab)') + expect(screen.getByRole('columnheader', { name: 'value' })).toBeTruthy() + }) +}) diff --git a/src/renderer/src/components/editor/CsvViewer.tsx b/src/renderer/src/components/editor/CsvViewer.tsx index e424a98c938..49d3a2ba51e 100644 --- a/src/renderer/src/components/editor/CsvViewer.tsx +++ b/src/renderer/src/components/editor/CsvViewer.tsx @@ -1,6 +1,7 @@ -import React, { useMemo, useRef } from 'react' +import React, { useMemo, useRef, useState } from 'react' import { useVirtualizer } from '@tanstack/react-virtual' import { detectCsvDelimiter, parseCsv } from './csv-parse' +import { CsvDelimiterPicker, type CsvDelimiterChoice } from './csv-delimiter-picker' import { translate } from '@/i18n/i18n' type CsvViewerProps = { @@ -23,11 +24,21 @@ const CHAR_PX = 7 // independently of the body, leaving values squashed together. export default function CsvViewer({ content, filePath }: CsvViewerProps): React.JSX.Element { const scrollRef = useRef(null) + const [delimiterChoice, setDelimiterChoice] = useState('auto') + const detectedDelimiter = useMemo( + () => detectCsvDelimiter(filePath, content), + [filePath, content] + ) + const delimiter = + delimiterChoice === 'auto' + ? detectedDelimiter + : delimiterChoice === 'comma' + ? ',' + : delimiterChoice === 'semicolon' + ? ';' + : '\t' - const parsed = useMemo(() => { - const delimiter = detectCsvDelimiter(filePath, content) - return parseCsv(content, delimiter) - }, [content, filePath]) + const parsed = useMemo(() => parseCsv(content, delimiter), [content, delimiter]) // Why: memoize header/body split so their references stay stable across // renders that don't change content. A top-level rest-destructure would @@ -178,7 +189,7 @@ export default function CsvViewer({ content, filePath }: CsvViewerProps): React.
-
+
{bodyRows.length.toLocaleString()}{' '} {translate('auto.components.editor.CsvViewer.ac31d2cd60', 'rows')} @@ -186,6 +197,11 @@ export default function CsvViewer({ content, filePath }: CsvViewerProps): React. {columnCount} {translate('auto.components.editor.CsvViewer.eedd0d37a7', 'columns')} +
) diff --git a/src/renderer/src/components/editor/csv-delimiter-picker.tsx b/src/renderer/src/components/editor/csv-delimiter-picker.tsx new file mode 100644 index 00000000000..d345283fd39 --- /dev/null +++ b/src/renderer/src/components/editor/csv-delimiter-picker.tsx @@ -0,0 +1,68 @@ +import { useId } from 'react' +import { Label } from '@/components/ui/label' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from '@/components/ui/select' +import { translate } from '@/i18n/i18n' + +export type CsvDelimiterChoice = 'auto' | 'comma' | 'semicolon' | 'tab' + +export function CsvDelimiterPicker({ + value, + detectedDelimiter, + onChange +}: { + value: CsvDelimiterChoice + detectedDelimiter: string + onChange: (choice: CsvDelimiterChoice) => void +}): React.JSX.Element { + const id = useId() + const comma = translate('auto.components.editor.CsvViewer.delimiterComma', 'Comma') + const semicolon = translate('auto.components.editor.CsvViewer.delimiterSemicolon', 'Semicolon') + const tab = translate('auto.components.editor.CsvViewer.delimiterTab', 'Tab') + const detectedName = + detectedDelimiter === ',' ? comma : detectedDelimiter === ';' ? semicolon : tab + const choices = [ + { + value: 'auto', + label: translate('auto.components.editor.CsvViewer.delimiterAuto', 'Auto ({{delimiter}})', { + delimiter: detectedName + }) + }, + { value: 'comma', label: `${comma} (,)` }, + { value: 'semicolon', label: `${semicolon} (;)` }, + { value: 'tab', label: tab } + ] as const + + return ( +
+ + +
+ ) +} diff --git a/src/renderer/src/components/editor/csv-parse.test.ts b/src/renderer/src/components/editor/csv-parse.test.ts index 267be8b1028..f2e935e4937 100644 --- a/src/renderer/src/components/editor/csv-parse.test.ts +++ b/src/renderer/src/components/editor/csv-parse.test.ts @@ -80,6 +80,130 @@ describe('detectCsvDelimiter', () => { expect(detectCsvDelimiter('data.csv', 'a,b,c\n1,2,3')).toBe(',') }) + it('sniffs semicolon exports that use commas as the decimal mark', () => { + const content = 'amount;label\n1,50;"Roe, John"\n2,75;lunch\n' + + expect(detectCsvDelimiter('expenses.csv', content)).toBe(';') + expect(parseCsv(content, detectCsvDelimiter('expenses.csv', content)).rows).toEqual([ + ['amount', 'label'], + ['1,50', 'Roe, John'], + ['2,75', 'lunch'] + ]) + }) + + it('keeps comma when semicolons only appear inside quoted fields', () => { + expect(detectCsvDelimiter('x.csv', '"a"";b;c",d,e\n1,2,3\n')).toBe(',') + }) + + it('prefers tab over semicolon when tabs dominate the first line', () => { + expect(detectCsvDelimiter('x.csv', 'a\tb;c\td\n')).toBe('\t') + }) + + it('uses tab for .tsv files that contain semicolons', () => { + expect(detectCsvDelimiter('data.TSV', 'a;b;c')).toBe('\t') + }) + + it.each([ + ['a;b,c', ','], + ['a;b,c\td', ','], + ['a;b\tc', '\t'], + ['a,b\tc', ','], + ['', ','], + ['single', ','] + ])('preserves existing delimiter precedence for %j', (content, delimiter) => { + expect(detectCsvDelimiter('x.csv', content)).toBe(delimiter) + }) + + it('sniffs semicolons after a BOM and leading whitespace-only lines', () => { + expect(detectCsvDelimiter('x.csv', '\uFEFF\n \t \r\na;b\n1;2')).toBe(';') + }) + + it('parses semicolon fields with quoted separators, escaped quotes and newlines', () => { + const content = '\uFEFFnote;amount\r\n"she said ""hi"";\nnext";1,50\r\n' + + expect(parseCsv(content, detectCsvDelimiter('x.csv', content))).toEqual({ + rows: [ + ['note', 'amount'], + ['she said "hi";\nnext', '1,50'] + ], + maxColumns: 2 + }) + }) + + it('keeps consistent comma columns when only the header has extra semicolons', () => { + const content = 'notes;one;two,value\nplain,1\nother,2' + + expect(detectCsvDelimiter('x.csv', content)).toBe(',') + expect(parseCsv(content, detectCsvDelimiter('x.csv', content)).rows).toEqual([ + ['notes;one;two', 'value'], + ['plain', '1'], + ['other', '2'] + ]) + }) + + it.each([',', '\t'])('keeps literal quotes inside unquoted %j fields', (delimiter) => { + const content = `notes;one;two${delimiter}value\n6" bolts${delimiter}1\nplain${delimiter}2` + + expect(detectCsvDelimiter('x.csv', content)).toBe(delimiter) + expect(parseCsv(content, detectCsvDelimiter('x.csv', content)).rows).toEqual([ + ['notes;one;two', 'value'], + ['6" bolts', '1'], + ['plain', '2'] + ]) + }) + + it('keeps consistent tab columns when a header contains extra semicolons', () => { + expect(detectCsvDelimiter('x.csv', 'notes;one;two\tvalue\nplain\t1')).toBe('\t') + }) + + it('keeps a semicolon export whose unquoted comma counts vary between rows', () => { + expect(detectCsvDelimiter('x.csv', 'name;amount;note\nAda;1,50;lunch\nBo;2;plain')).toBe(';') + }) + + it('ignores separator-like punctuation in a multiline quoted field', () => { + const content = 'notes;one;two,value\n"line one\nline;two",1' + expect(detectCsvDelimiter('x.csv', content)).toBe(',') + }) + + it('keeps first-line inference when the rows are ambiguous or ragged', () => { + expect(detectCsvDelimiter('x.csv', 'a;b;c,value\nx;y;z,1')).toBe(';') + expect(detectCsvDelimiter('x.csv', 'a;b;c\nx;y\nz')).toBe(';') + expect(detectCsvDelimiter('x.csv', '1,50;coffee\n2,75;lunch')).toBe(',') + }) + + it('uses at most eight logical records to corroborate the existing delimiter', () => { + const firstEight = ['notes;one;two,value', ...Array.from({ length: 7 }, () => 'plain,1')] + const content = [...firstEight, 'ragged,one,two,three'].join('\n') + expect(detectCsvDelimiter('x.csv', content)).toBe(',') + }) + + it('does not use an unfinished quoted record at the scan boundary as corroboration', () => { + const prefix = 'notes;one;two,value\nplain,"' + const content = `${prefix}${'x'.repeat(CSV_DELIMITER_SNIFF_SCAN_CODE_UNITS)}",1` + expect(detectCsvDelimiter('x.csv', content)).toBe(';') + }) + + it('does not use a partial unquoted record at the scan boundary as corroboration', () => { + const prefix = 'notes;one;two,value\nplain,' + const content = `${prefix}${'x'.repeat(CSV_DELIMITER_SNIFF_SCAN_CODE_UNITS)}\nother,2` + expect(detectCsvDelimiter('x.csv', content)).toBe(';') + }) + + it('does not corroborate an unfinished quoted record at EOF', () => { + expect(detectCsvDelimiter('x.csv', 'notes;one;two,value\nplain,"unfinished')).toBe(';') + }) + + it('corroborates complete records with CRLF, escaped quotes and a quoted newline', () => { + const content = 'notes;one;two,value\r\n"say ""hi"";\r\nnext",1\r\nplain,2\r\n' + expect(detectCsvDelimiter('x.csv', content)).toBe(',') + }) + + it('does not sniff semicolons beyond the first-line scan limit', () => { + const content = `${'a'.repeat(CSV_DELIMITER_SNIFF_SCAN_CODE_UNITS)};b;c` + + expect(detectCsvDelimiter('x.csv', content)).toBe(',') + }) + it('skips leading blank lines when sniffing', () => { expect(detectCsvDelimiter('x.csv', '\n\na\tb\tc')).toBe('\t') }) diff --git a/src/renderer/src/components/editor/csv-parse.ts b/src/renderer/src/components/editor/csv-parse.ts index 009e9646320..6f52edbf5b7 100644 --- a/src/renderer/src/components/editor/csv-parse.ts +++ b/src/renderer/src/components/editor/csv-parse.ts @@ -107,9 +107,7 @@ export function detectCsvDelimiter(filePath: string, content: string): string { if (filePath.toLowerCase().endsWith('.tsv')) { return '\t' } - // Why: sniff the first non-empty line for tab vs comma to handle CSVs that - // were saved with a different extension. Semicolons/pipes are out of scope; - // this tool is a viewer, not a general data importer. + // Include semicolon spreadsheet exports without adding general importer heuristics. // Why: strip a leading UTF-8 BOM so it doesn't get counted as part of the // first cell's characters (and so BOM-prefixed TSVs still sniff correctly). let text = content @@ -121,8 +119,84 @@ export function detectCsvDelimiter(filePath: string, content: string): string { // (0 tabs vs 0 commas, tie goes to comma), misdetecting blank-leading TSVs. const firstLine = findFirstNonEmptyCsvSniffLine(text) const tabs = countDelimiterOutsideQuotes(firstLine, '\t') + const semicolons = countDelimiterOutsideQuotes(firstLine, ';') const commas = countDelimiterOutsideQuotes(firstLine, ',') - return tabs > commas ? '\t' : ',' + // Keep the existing comma/tab choice unless semicolon strictly wins. + const existingDelimiter = tabs > commas ? '\t' : ',' + if (semicolons > commas && semicolons > tabs) { + return hasConsistentExistingCsvColumns(text, existingDelimiter) ? existingDelimiter : ';' + } + return existingDelimiter +} + +// Header punctuation should not replace an otherwise consistent comma/tab table. +function hasConsistentExistingCsvColumns(text: string, delimiter: string): boolean { + const scanLength = Math.min(text.length, CSV_DELIMITER_SNIFF_SCAN_CODE_UNITS) + const records: { delimiters: number; semicolons: number }[] = [] + let delimiters = 0 + let semicolons = 0 + let inQuotes = false + let fieldIsEmpty = true + let hasContent = false + const pushRecord = (): void => { + if (hasContent) { + records.push({ delimiters, semicolons }) + } + delimiters = 0 + semicolons = 0 + fieldIsEmpty = true + hasContent = false + } + + for (let index = 0; index < scanLength && records.length < 8; index += 1) { + const ch = text[index] + if (inQuotes) { + if (ch === '"') { + if (text[index + 1] === '"' && index + 1 < scanLength) { + fieldIsEmpty = false + index += 1 + } else { + inQuotes = false + } + } else { + fieldIsEmpty = false + } + continue + } + if (ch === '"' && fieldIsEmpty) { + inQuotes = true + hasContent = true + continue + } + if (ch === '\r' || ch === '\n') { + pushRecord() + if (ch === '\r' && text[index + 1] === '\n') { + index += 1 + } + continue + } + if (ch === delimiter) { + delimiters += 1 + fieldIsEmpty = true + } else { + fieldIsEmpty = false + } + if (ch === ';') { + semicolons += 1 + } + hasContent ||= !isCsvSniffWhitespace(text.charCodeAt(index)) + } + if (scanLength === text.length && records.length < 8 && !inQuotes) { + pushRecord() + } + const first = records[0] + return Boolean( + first && + first.delimiters > 0 && + records.length > 1 && + records.every((record) => record.delimiters === first.delimiters) && + records.some((record) => record.semicolons !== first.semicolons) + ) } function findFirstNonEmptyCsvSniffLine(text: string): string { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 9bc69d7501d..7b497327dac 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -15126,7 +15126,12 @@ "CsvViewer": { "eedd0d37a7": "columns", "ac31d2cd60": "rows", - "a233d55b77": "Empty file" + "a233d55b77": "Empty file", + "delimiterComma": "Comma", + "delimiterSemicolon": "Semicolon", + "delimiterTab": "Tab", + "delimiterAuto": "Auto ({{delimiter}})", + "delimiter": "Delimiter" }, "DiffNotesSendMenu": { "f1aa04b5cf": "This file", From b407d06c1ecd9d237010984ac02d9cd3abe37df0 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 4 Oct 2026 01:58:47 -0700 Subject: [PATCH 06/31] Reuse buffer cells during terminal cursor context scans (#25161) --- docs/reference/ci-runner-efficiency.md | 26 ++++++ .../terminal-cursor-line-context.test.ts | 89 +++++++++++++++++++ src/shared/terminal-cursor-line-context.ts | 50 +++++++---- 3 files changed, 148 insertions(+), 17 deletions(-) diff --git a/docs/reference/ci-runner-efficiency.md b/docs/reference/ci-runner-efficiency.md index 600651a4457..d714947325a 100644 --- a/docs/reference/ci-runner-efficiency.md +++ b/docs/reference/ci-runner-efficiency.md @@ -1858,6 +1858,32 @@ the imported helper has no top-level side effects, and the Linux rebuild branch is unchanged. Focused tests verify its Linux/macOS no-op behavior. Final-head PR checks qualify separately. +## October 4 reusable cells for terminal context scans + +Terminal cursor-context scans now request one reusable cell per invocation when +the adapter offers getNullCell, and pass it through all unchanged text/style +scans. Adapters without that optional method keep the existing allocating path. +The scratch cell is local and no cell reference escapes into returned context. +Browser composer/readiness text, colors, bold flags and wrapping are unchanged. + +Three alternating one-worker ARM pairs in +[37182789677](https://github.com/stablyai/orca/actions/runs/37182789677) +ran all 19 original cases from readiness census suite 2. Baseline complete +invocations were 37.141 / 37.879 / 37.090 seconds; candidate invocations were +33.887 / 33.387 / 32.916 seconds. Median 37.141 to 33.387 seconds saves 10.1%. +This is a focused workload measurement, not a whole-shard or queue-delay claim. + +Separate baseline/candidate captures retained all 192 cases across six census +suites. Every context and visible projection matched: 643,926 of each, with +7,465,308,324 complete length-prefixed payload bytes hashed per test/type/order. +The canonical capture digest was +`f7440c0f1b5bbb57127cd29245530029415c8e9e243c1744359f330b3c7ace19`. +These captures run outside the timing samples. All 41 cursor/composer/browser +consumer checks passed. Seven faults for lost dim filtering, wide continuation, +bold prompt, custom foreground, wrap preservation, adapter fallback and scratch +reuse failed their intended assertions. Node and web typecheck, lint and format +passed. Two added controls prove per-call scratch lifetime and adapter parity. + ## October 3 producer follow-up: automatic selection for the measured profile The first producer rollout in [#24927](https://github.com/stablyai/orca/pull/24927) diff --git a/src/main/daemon/terminal-cursor-line-context.test.ts b/src/main/daemon/terminal-cursor-line-context.test.ts index 86066b95b8c..29afe00618b 100644 --- a/src/main/daemon/terminal-cursor-line-context.test.ts +++ b/src/main/daemon/terminal-cursor-line-context.test.ts @@ -9,7 +9,96 @@ function writeSync(terminal: Terminal, data: string): void { core.writeSync(data) } +type Cell = ReturnType + +function observeCellReads(terminal: Terminal) { + const allocated: Cell[] = [] + const targets: (Cell | undefined)[] = [] + const active = terminal.buffer.active + const source = { + rows: terminal.rows, + modes: terminal.modes, + buffer: { + active: { + baseY: active.baseY, + cursorX: active.cursorX, + cursorY: active.cursorY, + viewportY: active.viewportY, + getNullCell: () => { + const cell = active.getNullCell() + allocated.push(cell) + return cell + }, + getLine: (row: number) => { + const line = active.getLine(row) + if (!line) { + return undefined + } + return { + isWrapped: line.isWrapped, + length: line.length, + translateToString: line.translateToString.bind(line), + getCell: (column: number, reusableCell?: Cell) => { + targets.push(reusableCell) + return line.getCell(column, reusableCell) + } + } + } + } + } + } + return { source, allocated, targets } +} + describe('readTerminalCursorLineContext', () => { + it('reuses one cell across every scan without retaining it between reads', () => { + const terminal = new Terminal({ cols: 12, rows: 5, allowProposedApi: true }) + try { + writeSync(terminal, '\x1b[1m❯\x1b[22m 界e\u0301\x1b7\r\n\x1b[31mfooter\x1b8') + const rig = observeCellReads(terminal) + const first = readTerminalCursorLineContext(rig.source, terminal.rows) + expect(rig.allocated).toHaveLength(1) + expect(rig.targets.length).toBeGreaterThan(terminal.cols * 2) + expect(rig.targets.every((cell) => cell === rig.allocated[0])).toBe(true) + rig.targets.length = 0 + expect(readTerminalCursorLineContext(rig.source, terminal.rows)).toEqual(first) + expect(rig.allocated).toHaveLength(2) + expect(rig.allocated[1]).not.toBe(rig.allocated[0]) + expect(rig.targets.every((cell) => cell === rig.allocated[1])).toBe(true) + } finally { + terminal.dispose() + } + }) + + it('preserves full context for an adapter without reusable cells', () => { + const terminal = new Terminal({ cols: 12, rows: 5, allowProposedApi: true }) + try { + writeSync( + terminal, + '\x1b[1m❯\x1b[22m typed\x1b7\x1b[2m hint\x1b[22m\r\n\x1b[38;2;1;2;3m界e\u0301\x1b[0m\x1b8' + ) + const rig = observeCellReads(terminal) + const source = { + ...rig.source, + buffer: { active: { ...rig.source.buffer.active, getNullCell: undefined } } + } + const context = readTerminalCursorLineContext(source, terminal.rows) + expect(context).toEqual(readTerminalCursorLineContext(terminal, terminal.rows)) + expect(context?.typedRows).toEqual(['❯ typed']) + expect(context?.typedRowsBelow[0]).toBe('界e\u0301') + expect(context?.beforeCursor).toBe('❯ typed') + expect(context?.afterCursor).toBe('') + expect(context?.rawAfterCursor).toBe(' hint') + expect(context?.promptGlyphBoldRows).toEqual([true]) + expect(context?.rowsBelowCustomForeground?.[0]).toBe(true) + expect(rig.allocated).toEqual([]) + expect(rig.targets.length).toBeGreaterThan(terminal.cols * 2) + expect(rig.targets.every((cell) => cell === undefined)).toBe(true) + } finally { + terminal.dispose() + } + }) + it.each([ { cols: 19, cursorRowTail: 'proceed with the ', continuation: 'release' }, { cols: 18, cursorRowTail: 'proceed with the', continuation: ' release' } diff --git a/src/shared/terminal-cursor-line-context.ts b/src/shared/terminal-cursor-line-context.ts index 230c85c7895..28fc2402fe2 100644 --- a/src/shared/terminal-cursor-line-context.ts +++ b/src/shared/terminal-cursor-line-context.ts @@ -8,14 +8,14 @@ type TerminalCursorCell = { isFgDefault(): boolean | number } -type TerminalCursorLine = { +type TerminalCursorLine = { readonly isWrapped: boolean readonly length: number - getCell(column: number): TerminalCursorCell | undefined + getCell(column: number, reusableCell?: Cell): Cell | undefined translateToString(trimRight?: boolean, startColumn?: number, endColumn?: number): string } -export type TerminalCursorContextSource = { +export type TerminalCursorContextSource = { readonly rows: number readonly modes: { readonly showCursor: boolean } readonly buffer: { @@ -24,15 +24,21 @@ export type TerminalCursorContextSource = { readonly cursorX: number readonly cursorY: number readonly viewportY: number - getLine(row: number): TerminalCursorLine | undefined + getLine(row: number): TerminalCursorLine | undefined + getNullCell?(): Cell } } } -function undimmedText(line: TerminalCursorLine, fromX = 0, trimRight = true): string { +function undimmedText( + line: TerminalCursorLine, + fromX = 0, + trimRight = true, + reusableCell?: Cell +): string { let text = '' for (let x = fromX; x < line.length; x += 1) { - const cell = line.getCell(x) + const cell = line.getCell(x, reusableCell) if (!cell || cell.isDim() || cell.getWidth() === 0) { continue } @@ -41,9 +47,12 @@ function undimmedText(line: TerminalCursorLine, fromX = 0, trimRight = true): st return trimRight ? text.trimEnd() : text } -function firstVisibleCellIsBold(line: TerminalCursorLine): boolean { +function firstVisibleCellIsBold( + line: TerminalCursorLine, + reusableCell?: Cell +): boolean { for (let x = 0; x < line.length; x += 1) { - const cell = line.getCell(x) + const cell = line.getCell(x, reusableCell) if (!cell || cell.getWidth() === 0 || !cell.getChars().trim()) { continue } @@ -52,9 +61,12 @@ function firstVisibleCellIsBold(line: TerminalCursorLine): boolean { return false } -function firstVisibleCellHasCustomForeground(line: TerminalCursorLine): boolean { +function firstVisibleCellHasCustomForeground( + line: TerminalCursorLine, + reusableCell?: Cell +): boolean { for (let x = 0; x < line.length; x += 1) { - const cell = line.getCell(x) + const cell = line.getCell(x, reusableCell) if (!cell || cell.getWidth() === 0 || !cell.getChars().trim()) { continue } @@ -63,8 +75,8 @@ function firstVisibleCellHasCustomForeground(line: TerminalCursorLine): boolean return false } -export function readTerminalCursorLineContext( - terminal: TerminalCursorContextSource, +export function readTerminalCursorLineContext( + terminal: TerminalCursorContextSource, rowsAroundCursor: number ): TerminalCursorContext | null { const buffer = terminal.buffer.active @@ -73,6 +85,7 @@ export function readTerminalCursorLineContext( if (!cursorLine) { return null } + const reusableCell = buffer.getNullCell?.() const rows: string[] = [] const typedRows: string[] = [] const promptGlyphBoldRows: boolean[] = [] @@ -83,8 +96,8 @@ export function readTerminalCursorLineContext( const line = buffer.getLine(row) const nextLineIsWrapped = buffer.getLine(row + 1)?.isWrapped ?? false rows.push(line?.translateToString(!nextLineIsWrapped) ?? '') - typedRows.push(line ? undimmedText(line, 0, !nextLineIsWrapped) : '') - promptGlyphBoldRows.push(line ? firstVisibleCellIsBold(line) : false) + typedRows.push(line ? undimmedText(line, 0, !nextLineIsWrapped, reusableCell) : '') + promptGlyphBoldRows.push(line ? firstVisibleCellIsBold(line, reusableCell) : false) rowsWrapped.push(line?.isWrapped ?? false) } const rowsBelow: string[] = [] @@ -96,9 +109,11 @@ export function readTerminalCursorLineContext( const line = buffer.getLine(row) const nextLineIsWrapped = buffer.getLine(row + 1)?.isWrapped ?? false rowsBelow.push(line?.translateToString(!nextLineIsWrapped) ?? '') - typedRowsBelow.push(line ? undimmedText(line, 0, !nextLineIsWrapped) : '') + typedRowsBelow.push(line ? undimmedText(line, 0, !nextLineIsWrapped, reusableCell) : '') rowsBelowWrapped.push(line?.isWrapped ?? false) - rowsBelowCustomForeground.push(line ? firstVisibleCellHasCustomForeground(line) : false) + rowsBelowCustomForeground.push( + line ? firstVisibleCellHasCustomForeground(line, reusableCell) : false + ) } return { rows, @@ -113,7 +128,8 @@ export function readTerminalCursorLineContext( afterCursor: undimmedText( cursorLine, buffer.cursorX, - !(buffer.getLine(cursorRow + 1)?.isWrapped ?? false) + !(buffer.getLine(cursorRow + 1)?.isWrapped ?? false), + reusableCell ), rawAfterCursor: cursorLine.translateToString( !(buffer.getLine(cursorRow + 1)?.isWrapped ?? false), From c4e8735f455393254016be1e7104366d4c398926 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 4 Oct 2026 02:05:05 -0700 Subject: [PATCH 07/31] Share PR preflight setup to reduce runner demand (#25150) * Share PR static analysis and compiler runner * Preserve evidence document final newline for concurrent merges * Keep readiness reuse contracts aligned with the physical preflight gate --- .github/workflows/pr.yml | 117 ++++++++++++------ .../scripts/check-node-runtime-pin.test.mjs | 2 +- .../ci-background-step-barriers.test.mjs | 7 +- .../scripts/ci-cache-warmup-workflow.test.mjs | 4 +- ...alization-extraction-change-scope.test.mjs | 4 +- ...orcad-terminal-smoke-change-scope.test.mjs | 2 +- config/scripts/pr-code-change-scope.test.mjs | 20 +-- config/scripts/pr-e2e-gate-contract.test.mjs | 4 +- config/scripts/pr-preflight-gates.test.mjs | 86 ++++++++----- config/scripts/pr-ready-check-gate.test.mjs | 10 +- config/scripts/pr-ready-check-reuse.test.mjs | 6 +- .../scripts/pr-workflow-parallelism.test.mjs | 17 ++- docs/reference/ci-runner-efficiency.md | 30 +++++ 13 files changed, 205 insertions(+), 104 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 9558255e482..f9f945b3bf3 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -154,15 +154,17 @@ jobs: echo "No specs requiring the reusable E2E workflow" fi - static_analysis: - name: static analysis + preflight: + name: static analysis and typecheck needs: [code_paths] - if: needs.code_paths.outputs.static_analysis == 'true' + if: needs.code_paths.outputs.static_analysis == 'true' || needs.code_paths.outputs.typecheck == 'true' # Why ARM: measured 128s against 172s on ubuntu-latest, with every compute step faster -- # type-aware 24s->15s, anti-slop 28->19s, localization extraction 67->46s, the orcad smoke # 39->14s. Both lint engines ship linux-arm64 and the Bun target follows process.arch, so # the whole toolchain resolves. Free for public repositories, same as the typecheck job. runs-on: ubuntu-24.04-arm + outputs: + shards: ${{ steps.unit-plan.outputs.shards }} steps: - name: Checkout @@ -197,21 +199,35 @@ jobs: # Keep each check in its own log while sharing this runner. - name: Lint + if: '!cancelled()' id: root-lint background: true - run: pnpm exec oxlint --format github + env: + PREFLIGHT_PHASE_SELECTED: ${{ needs.code_paths.outputs.static_analysis == 'true' }} + PREFLIGHT_PRIOR_SUCCESS: ${{ job.status == 'success' }} + run: | + if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi + pnpm exec oxlint --format github - name: Reject low-evidence patterns + if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm run audit:anti-slop - wait: root-lint - name: Enforce focused code-quality plugins + if: '!cancelled()' id: native-code-quality background: true - run: pnpm run audit:code-quality:native + env: + PREFLIGHT_PHASE_SELECTED: ${{ needs.code_paths.outputs.static_analysis == 'true' }} + PREFLIGHT_PRIOR_SUCCESS: ${{ job.status == 'success' }} + run: | + if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi + pnpm run audit:code-quality:native - name: Enforce type-aware code-quality baseline + if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm run audit:code-quality:type-aware # Mobile installation changes import resolution for the native cycle check. @@ -221,28 +237,39 @@ jobs: # resolves types from mobile/node_modules. Without the install every mobile type # degrades to an `error` type — reported as phantom findings against the changed lines. - uses: ./.github/actions/install-mobile-dependencies - if: needs.code_paths.outputs.mobile_dependencies == 'true' + if: needs.code_paths.outputs.static_analysis == 'true' && needs.code_paths.outputs.mobile_dependencies == 'true' - name: Enforce changed-code quality + if: '!cancelled()' id: changed-code-quality background: true - run: pnpm run check:code-quality:changed -- "${{ github.event.pull_request.base.sha }}" + env: + PREFLIGHT_PHASE_SELECTED: ${{ needs.code_paths.outputs.static_analysis == 'true' }} + PREFLIGHT_PRIOR_SUCCESS: ${{ job.status == 'success' }} + run: | + if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi + pnpm run check:code-quality:changed -- "${{ github.event.pull_request.base.sha }}" - name: Enforce React Doctor on changed lines + if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm run check:react-doctor:changed -- "${{ github.event.pull_request.base.sha }}" - wait: changed-code-quality - name: Check Zustand selector fan-out budget + if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm run check:zustand-selector-fanout - name: Check reliability gate manifest + if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm run check:reliability-gates - name: Enforce dead design-system classes + if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm run check:dead-classes - name: Check VM runtime rollback compatibility + if: needs.code_paths.outputs.static_analysis == 'true' env: BASE_SHA: ${{ github.event.pull_request.base.sha }} run: | @@ -264,25 +291,33 @@ jobs: config/scripts/ephemeral-vm-runtime-store-cross-version.test.ts - name: Enforce max-lines ratchet + if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm run check:max-lines-ratchet - name: Enforce ts-nocheck ratchet + if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm run check:ts-nocheck-ratchet - name: Enforce runtime Electron-import ratchet + if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm run check:runtime-electron-ratchet - name: Check Node runtime pin + if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm run check:node-runtime-pin # Why: extraction writes sorted evidence to an isolated temporary path, # so feature PRs need one normalized AST pass rather than a three-OS matrix. - name: Verify localization extraction + if: '!cancelled()' id: localization-extraction background: true env: + PREFLIGHT_PHASE_SELECTED: ${{ needs.code_paths.outputs.static_analysis == 'true' }} + PREFLIGHT_PRIOR_SUCCESS: ${{ job.status == 'success' }} BASE_SHA: ${{ github.event.pull_request.base.sha }} run: | + if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi # Detection failures run the full check; renames retain the removed input path. DIFF_BASE="$(node config/scripts/git-pull-request-diff-base.mjs "$BASE_SHA")" if git diff --name-only --no-renames -z "$DIFF_BASE" HEAD > "$RUNNER_TEMP/localization-changes" && @@ -296,6 +331,7 @@ jobs: # which is a property of the import graph. This proves the Node artifact it enables # actually boots, pairs, creates a worktree and round-trips a real PTY. - name: Boot orcad and round-trip a terminal + if: needs.code_paths.outputs.static_analysis == 'true' env: BASE_SHA: ${{ github.event.pull_request.base.sha }} ORCA_BACKGROUND_LAUNCH: '1' @@ -310,20 +346,30 @@ jobs: fi - name: Verify the generated RPC params catalog + if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm run verify:rpc-params-catalog - name: Verify bundled skill guides + if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm run verify:bundled-skill-guides - name: Verify skill freshness manifest + if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm run verify:skill-bundle-manifest - name: Verify localization catalogs + if: '!cancelled()' id: localization-catalogs background: true - run: pnpm run verify:localization-catalogs + env: + PREFLIGHT_PHASE_SELECTED: ${{ needs.code_paths.outputs.static_analysis == 'true' }} + PREFLIGHT_PRIOR_SUCCESS: ${{ job.status == 'success' }} + run: | + if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi + pnpm run verify:localization-catalogs - name: Verify localization coverage + if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm run verify:localization-coverage - wait: [localization-catalogs, localization-extraction] @@ -334,6 +380,7 @@ jobs: # in .d.ts to `any`, which is how #1186 shipped a broken IPC signature # past typecheck. See .github/CONTRIBUTING.md#type-declarations-prefer-ts-over-dts. - name: Guard against project-owned .d.ts in preload/shared + if: needs.code_paths.outputs.static_analysis == 'true' run: | matches=$(find src/preload src/shared -name '*.d.ts' 2>/dev/null || true) if [ -n "$matches" ]; then @@ -346,33 +393,19 @@ jobs: fi - name: Check feature wall asset budget + if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm check:feature-wall-assets - name: Verify macOS entitlements + if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm verify:macos-entitlements - typecheck: - needs: [code_paths] - if: needs.code_paths.outputs.typecheck == 'true' - # Typechecking uses no native runtime, so it can use the free public ARM runner. - runs-on: ubuntu-24.04-arm - outputs: - shards: ${{ steps.unit-plan.outputs.shards }} - - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - fetch-depth: 2 - persist-credentials: false - - - uses: ./.github/actions/install-node-dependencies - # Why: every project is `composite`, so tsc already writes a .tsbuildinfo that lets # the next run skip unchanged files. Share one cache entry across commits while the # PR base stays stable; actions/cache keeps the first successful graph and the # compiler still invalidates stale files from its content hashes. - name: Cache TypeScript incremental state + if: needs.code_paths.outputs.typecheck == 'true' uses: actions/cache@v5 with: path: config/*.tsbuildinfo @@ -382,17 +415,24 @@ jobs: # Planning shares setup and stays off the compiler's critical path. - name: Plan unit selection + if: '!cancelled()' id: unit-plan background: true env: + PREFLIGHT_PHASE_SELECTED: ${{ needs.code_paths.outputs.typecheck == 'true' }} + PREFLIGHT_PRIOR_SUCCESS: ${{ job.status == 'success' }} ORCA_UNIT_SELECTION_MODE: ${{ vars.ORCA_UNIT_SELECTION_MODE || 'shadow' }} - run: node config/scripts/ci-unit-plan.mjs + run: | + if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi + node config/scripts/ci-unit-plan.mjs - run: pnpm run typecheck + if: needs.code_paths.outputs.typecheck == 'true' - wait: unit-plan - uses: actions/upload-artifact@v7 + if: needs.code_paths.outputs.typecheck == 'true' with: name: unit-selection-attempt-${{ github.run_attempt }} path: ci-shards/unit-selection.json @@ -691,20 +731,19 @@ jobs: src/shared/startup-shell-portability.live-shell.test.ts \ src/shared/posix-command-path-lookup.test.ts - # Static analysis saves the Node cache; typecheck publishes the plan before tests fan out. + # Preflight saves the Node cache and publishes the plan before tests fan out. test: - needs: [code_paths, static_analysis, typecheck] + needs: [code_paths, preflight] # Cancellation and failed prerequisites stop the expensive matrix. if: >- !cancelled() && needs.code_paths.outputs.test == 'true' && - needs.static_analysis.result == 'success' && - needs.typecheck.result == 'success' + needs.preflight.result == 'success' uses: ./.github/workflows/unit-tests.yml with: node_versions: '["24"]' runner: ubuntu-24.04-arm - shards: ${{ needs.typecheck.outputs.shards }} + shards: ${{ needs.preflight.outputs.shards }} # Why a sibling and not part of the test workflow: it is advisory, so it must not delay the # gate. Inside unit-tests.yml a caller's `needs: test` waited for it, holding verify ~36s @@ -902,7 +941,7 @@ jobs: package: name: package - needs: [code_paths, static_analysis, typecheck] + needs: [code_paths, preflight] if: needs.code_paths.outputs.package == 'true' runs-on: ubuntu-latest # Let the serial Docker gates reach their own deadlines and report cleanup failures. @@ -1059,7 +1098,7 @@ jobs: package_windows: name: package (windows) - needs: [code_paths, static_analysis, typecheck] + needs: [code_paths, preflight] if: needs.code_paths.outputs.package_windows == 'true' runs-on: windows-2022 timeout-minutes: 30 @@ -1267,8 +1306,7 @@ jobs: if: ${{ !cancelled() }} needs: - code_paths - - static_analysis - - typecheck + - preflight - git_compatibility - codex_index_heal_contract - xterm_patch_sync @@ -1297,10 +1335,8 @@ jobs: env: CODE_PATHS: ${{ needs.code_paths.result }} SHOULD_RUN: ${{ needs.code_paths.outputs.should_run }} - STATIC_ANALYSIS: ${{ needs.static_analysis.result }} - STATIC_ANALYSIS_SHOULD_RUN: ${{ needs.code_paths.outputs.static_analysis }} - TYPECHECK: ${{ needs.typecheck.result }} - TYPECHECK_SHOULD_RUN: ${{ needs.code_paths.outputs.typecheck }} + PREFLIGHT: ${{ needs.preflight.result }} + PREFLIGHT_SHOULD_RUN: ${{ needs.code_paths.outputs.static_analysis == 'true' || needs.code_paths.outputs.typecheck == 'true' }} GIT_COMPATIBILITY: ${{ needs.git_compatibility.result }} GIT_COMPATIBILITY_SHOULD_RUN: ${{ needs.code_paths.outputs.git_compatibility }} CODEX_INDEX_HEAL_CONTRACT: ${{ needs.codex_index_heal_contract.result }} @@ -1346,8 +1382,7 @@ jobs: fi } # Require success when the PR has code-relevant changes - check_job static_analysis "$STATIC_ANALYSIS" "$STATIC_ANALYSIS_SHOULD_RUN" - check_job typecheck "$TYPECHECK" "$TYPECHECK_SHOULD_RUN" + check_job preflight "$PREFLIGHT" "$PREFLIGHT_SHOULD_RUN" check_job git_compatibility "$GIT_COMPATIBILITY" "$GIT_COMPATIBILITY_SHOULD_RUN" check_job codex_index_heal_contract "$CODEX_INDEX_HEAL_CONTRACT" "$CODEX_INDEX_HEAL_CONTRACT_SHOULD_RUN" check_job xterm_patch_sync "$XTERM_PATCH_SYNC" "$XTERM_PATCH_SYNC_SHOULD_RUN" diff --git a/config/scripts/check-node-runtime-pin.test.mjs b/config/scripts/check-node-runtime-pin.test.mjs index e1be15f0f7f..a805985d966 100644 --- a/config/scripts/check-node-runtime-pin.test.mjs +++ b/config/scripts/check-node-runtime-pin.test.mjs @@ -189,7 +189,7 @@ describe('committed pin', () => { it('runs in the static analysis job', () => { const workflow = parse(readFileSync(path.join(projectDir, '.github/workflows/pr.yml'), 'utf8')) - const commands = workflow.jobs.static_analysis.steps.map((step) => step.run ?? '') + const commands = workflow.jobs.preflight.steps.map((step) => step.run ?? '') expect(commands).toContain('pnpm run check:node-runtime-pin') }) }) diff --git a/config/scripts/ci-background-step-barriers.test.mjs b/config/scripts/ci-background-step-barriers.test.mjs index 329e6c0bbce..e005c60b989 100644 --- a/config/scripts/ci-background-step-barriers.test.mjs +++ b/config/scripts/ci-background-step-barriers.test.mjs @@ -20,8 +20,7 @@ function assertJoinedBefore(steps, id, consumer) { describe('CI background step barriers', () => { it('joins every background check without suppressing failures', () => { for (const job of [ - pr.jobs.static_analysis, - pr.jobs.typecheck, + pr.jobs.preflight, pr.jobs.mobile_web_app, pr.jobs.package, pr.jobs.shell_contracts, @@ -52,7 +51,7 @@ describe('CI background step barriers', () => { it('joins planning before publishing the unit artifact', () => { assertJoinedBefore( - pr.jobs.typecheck.steps, + pr.jobs.preflight.steps, 'unit-plan', (step) => step.uses === 'actions/upload-artifact@v7' ) @@ -88,7 +87,7 @@ describe('CI background step barriers', () => { }) it('finishes native import-cycle analysis before mobile installation changes resolution', () => { - const steps = pr.jobs.static_analysis.steps + const steps = pr.jobs.preflight.steps assertJoinedBefore(steps, 'native-code-quality', (step) => step.uses?.endsWith('/install-mobile-dependencies') ) diff --git a/config/scripts/ci-cache-warmup-workflow.test.mjs b/config/scripts/ci-cache-warmup-workflow.test.mjs index 469800edfac..01b6c214e5d 100644 --- a/config/scripts/ci-cache-warmup-workflow.test.mjs +++ b/config/scripts/ci-cache-warmup-workflow.test.mjs @@ -28,7 +28,7 @@ it('warms the same Linux Node runtime the PR shards restore', () => { const install = arm.steps.find( (step) => step.uses === './.github/actions/install-node-dependencies' ) - const primer = readWorkflow('pr').jobs.static_analysis + 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) @@ -51,7 +51,7 @@ it('populates shared Electron archives on both Linux architectures without chang 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.typecheck.steps.find((step) => step.name === cache.name) + 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( diff --git a/config/scripts/localization-extraction-change-scope.test.mjs b/config/scripts/localization-extraction-change-scope.test.mjs index 8852965ad30..b1c142b8cd0 100644 --- a/config/scripts/localization-extraction-change-scope.test.mjs +++ b/config/scripts/localization-extraction-change-scope.test.mjs @@ -39,10 +39,10 @@ it('preserves deleted and renamed inputs and falls back to extraction on detecti const workflow = parse( readFileSync(new URL('../../.github/workflows/pr.yml', import.meta.url), 'utf8') ) - const step = workflow.jobs.static_analysis.steps.find( + const step = workflow.jobs.preflight.steps.find( (candidate) => candidate.name === 'Verify localization extraction' ) - expect(step.env).toEqual({ + expect(step.env).toMatchObject({ BASE_SHA: '${{ github.event.pull_request.base.sha }}' }) // The base side comes from the merge ref's first parent, so the gate needs no merge base and diff --git a/config/scripts/orcad-terminal-smoke-change-scope.test.mjs b/config/scripts/orcad-terminal-smoke-change-scope.test.mjs index 5ee88ff0492..e3108d782be 100644 --- a/config/scripts/orcad-terminal-smoke-change-scope.test.mjs +++ b/config/scripts/orcad-terminal-smoke-change-scope.test.mjs @@ -92,7 +92,7 @@ it('only skips the unchanged smoke after a successful diff and dependency analys const workflow = parse( readFileSync(new URL('../../.github/workflows/pr.yml', import.meta.url), 'utf8') ) - const step = workflow.jobs.static_analysis.steps.find( + const step = workflow.jobs.preflight.steps.find( (candidate) => candidate.name === 'Boot orcad and round-trip a terminal' ) expect(step.env).toEqual({ diff --git a/config/scripts/pr-code-change-scope.test.mjs b/config/scripts/pr-code-change-scope.test.mjs index 297d1f8e671..9bcc7974513 100644 --- a/config/scripts/pr-code-change-scope.test.mjs +++ b/config/scripts/pr-code-change-scope.test.mjs @@ -587,14 +587,16 @@ describe('PR Checks skip wiring', () => { expect(prWorkflow.jobs.code_paths.outputs.mobile_dependencies).toBe( '${{ steps.filter.outputs.mobile_dependencies }}' ) - const steps = prWorkflow.jobs.static_analysis.steps + const steps = prWorkflow.jobs.preflight.steps const install = steps.findIndex( (step) => step.uses === './.github/actions/install-mobile-dependencies' ) const gate = steps.findIndex((step) => step.name === 'Enforce changed-code quality') expect(install).toBeGreaterThan(-1) expect(install).toBeLessThan(gate) - expect(steps[install].if).toBe("needs.code_paths.outputs.mobile_dependencies == 'true'") + expect(steps[install].if).toBe( + "needs.code_paths.outputs.static_analysis == 'true' && needs.code_paths.outputs.mobile_dependencies == 'true'" + ) // The install itself moved into the action the packaging jobs share; assert it there so // this job cannot keep the step while the action stops installing anything. const action = parse( @@ -621,21 +623,21 @@ describe('PR Checks skip wiring', () => { }) it('gates each expensive job on its classifier and cache prerequisite', () => { - for (const jobName of expensiveJobs.filter((jobName) => jobName !== 'test')) { + for (const jobName of expensiveJobs.filter( + (jobName) => !['test', 'static_analysis', 'typecheck'].includes(jobName) + )) { expect(prWorkflow.jobs[jobName].needs, jobName).toEqual( ['package', 'package_windows'].includes(jobName) - ? ['code_paths', 'static_analysis', 'typecheck'] + ? ['code_paths', 'preflight'] : ['code_paths'] ) expect(prWorkflow.jobs[jobName].if, jobName).toBe( `needs.code_paths.outputs.${jobName} == 'true'` ) } - expect(prWorkflow.jobs.test.needs).toEqual(['code_paths', 'static_analysis', 'typecheck']) - expect(prWorkflow.jobs.test.if).toContain("needs.static_analysis.result == 'success'") - expect(prWorkflow.jobs.test.if).toContain("needs.typecheck.result == 'success'") + expect(prWorkflow.jobs.test.if).toContain("needs.preflight.result == 'success'") expect(prWorkflow.jobs.test.if).toContain("needs.code_paths.outputs.test == 'true'") - expect(prWorkflow.jobs.test.with.shards).toBe('${{ needs.typecheck.outputs.shards }}') + expect(prWorkflow.jobs.test.with.shards).toBe('${{ needs.preflight.outputs.shards }}') expect(prWorkflow.jobs.unit_plan).toBeUndefined() expect(prWorkflow.jobs.test_native_cache).toBeUndefined() }) @@ -659,7 +661,7 @@ describe('PR Checks skip wiring', () => { expect(verifyStep.run).toContain('expected skipped') expect(verifyStep.run).toContain('expected success') for (const job of prWorkflow.jobs.verify.needs) { - if (job === 'code_paths') { + if (job === 'code_paths' || job === 'preflight') { continue } const envVar = `${job.replaceAll('-', '_').toUpperCase()}_SHOULD_RUN` diff --git a/config/scripts/pr-e2e-gate-contract.test.mjs b/config/scripts/pr-e2e-gate-contract.test.mjs index 4c1268cd7a2..c661327a40a 100644 --- a/config/scripts/pr-e2e-gate-contract.test.mjs +++ b/config/scripts/pr-e2e-gate-contract.test.mjs @@ -45,7 +45,7 @@ const nativeImeSpec = readFileSync( const filterStep = prWorkflow.jobs.code_paths.steps.find( (step) => step.name === 'Filter changed E2E specs' ) -const rollbackStep = prWorkflow.jobs.static_analysis.steps.find( +const rollbackStep = prWorkflow.jobs.preflight.steps.find( (step) => step.name === 'Check VM runtime rollback compatibility' ) const verifyStep = prWorkflow.jobs.verify.steps.find( @@ -133,7 +133,7 @@ describe('PR E2E gate contract', () => { 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') { + if (job === 'code_paths' || job === 'preflight') { continue } expect(successLoop).toContain(`"$${envVar}"`) diff --git a/config/scripts/pr-preflight-gates.test.mjs b/config/scripts/pr-preflight-gates.test.mjs index 84345ee9360..152610f561b 100644 --- a/config/scripts/pr-preflight-gates.test.mjs +++ b/config/scripts/pr-preflight-gates.test.mjs @@ -1,29 +1,21 @@ import { readFileSync } from 'node:fs' +import { runInNewContext } from 'node:vm' import { expect, it } from 'vitest' import { parse } from 'yaml' import { classifyPrJobs } from './pr-code-change-scope.mjs' const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8')) -const typecheck = workflow.jobs.typecheck -const steps = typecheck.steps +const preflight = workflow.jobs.preflight +const steps = preflight.steps const compiler = steps.find((step) => step.run === 'pnpm run typecheck') const plan = steps.find((step) => step.id === 'unit-plan') -it('shares planning setup while keeping the heavy checks on separate runners', () => { - expect(workflow.jobs.unit_plan).toBeUndefined() - expect(workflow.jobs.test_native_cache).toBeUndefined() - expect(typecheck.needs).toEqual(['code_paths']) - expect(workflow.jobs.static_analysis.needs).toEqual(['code_paths']) - expect( - steps.filter((step) => step.uses === './.github/actions/install-node-dependencies') - ).toHaveLength(1) - expect(steps[0].with['fetch-depth']).toBeGreaterThanOrEqual(2) - expect(compiler.background).toBeUndefined() - expect(plan.background).toBe(true) - expect(plan.run).toBe('node config/scripts/ci-unit-plan.mjs') - expect(plan.env.ORCA_UNIT_SELECTION_MODE).toContain('vars.ORCA_UNIT_SELECTION_MODE') - expect(steps.indexOf(plan)).toBeLessThan(steps.indexOf(compiler)) - const installs = workflow.jobs.static_analysis.steps.filter( +it('shares one setup and runs the unchanged compiler after static checks finish', () => { + expect(workflow.jobs.static_analysis).toBeUndefined() + expect(workflow.jobs.typecheck).toBeUndefined() + expect(preflight.needs).toEqual(['code_paths']) + expect(preflight['runs-on']).toBe('ubuntu-24.04-arm') + const installs = steps.filter( (step) => step.uses === './.github/actions/install-node-dependencies' ) expect(installs).toHaveLength(2) @@ -32,35 +24,71 @@ it('shares planning setup while keeping the heavy checks on separate runners', ( expect(install.with['node-version']).toBe('24') expect(install.with['persist-native-cache']).not.toBe('false') } + expect(steps[0].with['fetch-depth']).toBeGreaterThanOrEqual(2) + expect(compiler.background).toBeUndefined() + expect(plan.background).toBe(true) + expect(plan.run).toContain('node config/scripts/ci-unit-plan.mjs') + expect(plan.env.ORCA_UNIT_SELECTION_MODE).toContain('vars.ORCA_UNIT_SELECTION_MODE') + expect(steps.indexOf(plan)).toBeLessThan(steps.indexOf(compiler)) + expect(steps.indexOf(compiler)).toBeGreaterThan( + steps.findIndex((step) => step.wait?.includes('localization-extraction')) + ) }) -it('requires compiler success and joined planning before publishing shards and admitting tests', () => { +it('requires physical preflight success before publishing shards and admitting consumers', () => { const join = steps.findIndex((step) => step.wait === 'unit-plan') const upload = steps.findIndex((step) => step.uses === 'actions/upload-artifact@v7') expect(join).toBeGreaterThan(steps.indexOf(compiler)) expect(upload).toBeGreaterThan(join) expect(steps[upload]['continue-on-error']).toBeUndefined() expect(steps[upload].with.name).toBe('unit-selection-attempt-${{ github.run_attempt }}') - expect(typecheck.outputs.shards).toBe('${{ steps.unit-plan.outputs.shards }}') - expect(workflow.jobs.test.with.shards).toBe('${{ needs.typecheck.outputs.shards }}') + expect(preflight.outputs.shards).toBe('${{ steps.unit-plan.outputs.shards }}') + expect(workflow.jobs.test.with.shards).toBe('${{ needs.preflight.outputs.shards }}') for (const job of ['test', 'package', 'package_windows']) { - expect(workflow.jobs[job].needs).toEqual(['code_paths', 'static_analysis', 'typecheck']) + expect(workflow.jobs[job].needs).toEqual(['code_paths', 'preflight']) } - expect(workflow.jobs.test.if).toContain("needs.static_analysis.result == 'success'") - expect(workflow.jobs.test.if).toContain("needs.typecheck.result == 'success'") - expect(workflow.jobs.verify.needs).toContain('static_analysis') - expect(workflow.jobs.verify.needs).toContain('typecheck') + for (const result of ['success', 'failure', 'cancelled', 'skipped']) { + const admitted = runInNewContext(workflow.jobs.test.if, { + cancelled: () => false, + needs: { code_paths: { outputs: { test: 'true' } }, preflight: { result } } + }) + expect(admitted, result).toBe(result === 'success') + } + const verify = workflow.jobs.verify.steps.find( + (step) => step.name === 'Require successful checks' + ) + expect(verify.env.PREFLIGHT).toBe('${{ needs.preflight.result }}') + expect(verify.env.PREFLIGHT_SHOULD_RUN).toBe( + "${{ needs.code_paths.outputs.static_analysis == 'true' || needs.code_paths.outputs.typecheck == 'true' }}" + ) + expect(verify.run).toContain('check_job preflight "$PREFLIGHT" "$PREFLIGHT_SHOULD_RUN"') + expect(workflow.jobs.verify.needs).toContain('preflight') + expect(workflow.jobs.verify.needs).not.toContain('typecheck') }) it.each( [['README.md'], ['mobile/src/App.tsx'], ['cloud/package.json'], ['src/main/index.ts'], []].map( (changed) => ({ changed }) ) -)('keeps desktop typechecking and planning off unrelated paths: $changed', ({ changed }) => { +)('preserves phase selection for unrelated paths: $changed', ({ changed }) => { const scope = classifyPrJobs(changed) - expect(typecheck.if).toBe("needs.code_paths.outputs.typecheck == 'true'") + const needs = { + code_paths: { + outputs: Object.fromEntries(Object.entries(scope).map(([key, value]) => [key, String(value)])) + } + } + expect(runInNewContext(preflight.if, { needs })).toBe(scope.static_analysis || scope.typecheck) + expect(runInNewContext(compiler.if, { needs })).toBe(scope.typecheck) expect(scope.test).toBe(scope.typecheck) - if (scope.test) { - expect(scope.static_analysis).toBe(true) +}) + +it('registers successful no-op background work when a phase is unselected or already failed', () => { + for (const step of steps.filter((step) => step.background)) { + expect(step.if).toBe('!cancelled()') + expect(step.env.PREFLIGHT_PHASE_SELECTED).toContain('needs.code_paths.outputs.') + expect(step.env.PREFLIGHT_PRIOR_SUCCESS).toBe("${{ job.status == 'success' }}") + expect(step.run.split('\n')[0]).toBe( + 'if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi' + ) } }) diff --git a/config/scripts/pr-ready-check-gate.test.mjs b/config/scripts/pr-ready-check-gate.test.mjs index 27e5cf99103..ade7fada96a 100644 --- a/config/scripts/pr-ready-check-gate.test.mjs +++ b/config/scripts/pr-ready-check-gate.test.mjs @@ -7,10 +7,14 @@ import { PR_CHECK_JOBS } from './pr-code-change-scope.mjs' const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8')) const gate = workflow.jobs.verify.steps.find((step) => step.name === 'Require successful checks') const variable = (job) => job.replaceAll('-', '_').toUpperCase() +const requiredJobs = [ + 'preflight', + ...PR_CHECK_JOBS.filter((job) => job !== 'static_analysis' && job !== 'typecheck') +] function requiredResults(shouldRun) { return Object.fromEntries( - PR_CHECK_JOBS.flatMap((job) => [ + requiredJobs.flatMap((job) => [ [variable(job), shouldRun ? 'success' : 'skipped'], [`${variable(job)}_SHOULD_RUN`, String(shouldRun)] ]) @@ -42,7 +46,7 @@ describe.skipIf(process.platform === 'win32')( }) it('rejects every missing, failed or cancelled required result when reuse is unavailable', async () => { - for (const job of PR_CHECK_JOBS) { + for (const job of requiredJobs) { for (const result of ['', 'skipped', 'failure', 'cancelled']) { const verdict = await verify({ ...requiredResults(true), [variable(job)]: result }) expect(verdict.code, `${job}: ${result}`).toBe(1) @@ -57,7 +61,7 @@ describe.skipIf(process.platform === 'win32')( }) it('rejects unexpected downstream execution when the proven plan requires skips', async () => { - for (const job of PR_CHECK_JOBS) { + for (const job of requiredJobs) { const verdict = await verify({ ...requiredResults(false), [variable(job)]: 'success' }) expect(verdict.code, job).toBe(1) } diff --git a/config/scripts/pr-ready-check-reuse.test.mjs b/config/scripts/pr-ready-check-reuse.test.mjs index 19fb2407233..11f171a2b25 100644 --- a/config/scripts/pr-ready-check-reuse.test.mjs +++ b/config/scripts/pr-ready-check-reuse.test.mjs @@ -157,7 +157,11 @@ describe('ready-for-review required check reuse', () => { "github.event.pull_request.draft != true && steps.filter.outputs.should_run == 'true'" ) expect(workflow.jobs.verify.if).toBe('${{ !cancelled() }}') - expect(workflow.jobs.verify.needs).toEqual(['code_paths', ...PR_CHECK_JOBS]) + expect(workflow.jobs.verify.needs).toEqual([ + 'code_paths', + 'preflight', + ...PR_CHECK_JOBS.filter((job) => job !== 'static_analysis' && job !== 'typecheck') + ]) }) it.each(['pr-test-loc.yml', 'mobile.yml'])( diff --git a/config/scripts/pr-workflow-parallelism.test.mjs b/config/scripts/pr-workflow-parallelism.test.mjs index 91156e59bf0..cb9545ae7e3 100644 --- a/config/scripts/pr-workflow-parallelism.test.mjs +++ b/config/scripts/pr-workflow-parallelism.test.mjs @@ -54,7 +54,7 @@ const realZshUsage = describe('PR workflow parallelism', () => { it('keeps lightweight orchestration jobs on the free slim runner', () => { expect(workflow.jobs.code_paths['runs-on']).toBe('ubuntu-slim') - expect(workflow.jobs.typecheck['runs-on']).toBe('ubuntu-24.04-arm') + 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') @@ -79,7 +79,7 @@ describe('PR workflow parallelism', () => { const installStep = sharedTest.steps.find( (step) => step.uses === './.github/actions/install-node-dependencies' ) - const staticInstall = workflow.jobs.static_analysis.steps.find( + const staticInstall = workflow.jobs.preflight.steps.find( (step) => step.uses === './.github/actions/install-node-dependencies' ) const nodeNextPrimerInstall = nodeNextWorkflow.jobs.test_native_cache.steps.find( @@ -91,7 +91,7 @@ describe('PR workflow parallelism', () => { 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.static_analysis['runs-on']).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() @@ -121,7 +121,7 @@ describe('PR workflow parallelism', () => { } expect(staticInstall.with['native-runtime']).toBe('node') expect(staticInstall.with['node-version']).toBe('24') - expect(workflow.jobs.test.needs).toContain('static_analysis') + 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 }}') @@ -348,11 +348,11 @@ describe('PR workflow parallelism', () => { (step) => step.uses === './.github/actions/install-node-dependencies' ) - for (const jobName of ['typecheck', 'git_compatibility']) { + for (const jobName of ['git_compatibility']) { expect(installFor(jobName).with, jobName).toBeUndefined() } expect(installFor('xterm_patch_sync')).toBeUndefined() - expect(installFor('static_analysis').with['native-runtime']).toBe('node') + 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') @@ -478,7 +478,7 @@ describe('PR workflow parallelism', () => { }) it('reuses TypeScript incremental state across typecheck runs', () => { - const steps = workflow.jobs.typecheck.steps + 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') @@ -543,8 +543,7 @@ describe('PR workflow parallelism', () => { it('keeps verify as the aggregate required check', () => { expect(workflow.jobs.verify.needs).toEqual([ 'code_paths', - 'static_analysis', - 'typecheck', + 'preflight', 'git_compatibility', 'codex_index_heal_contract', 'xterm_patch_sync', diff --git a/docs/reference/ci-runner-efficiency.md b/docs/reference/ci-runner-efficiency.md index d714947325a..f2dc21aa66c 100644 --- a/docs/reference/ci-runner-efficiency.md +++ b/docs/reference/ci-runner-efficiency.md @@ -1921,6 +1921,36 @@ automatic selection and cold publication, not a new timing result. Local verification passed eight suites / 184 tests, the changed-code quality gate and compiled-composite actionlint. +## October 4 shared PR preflight capacity + +Static analysis and the unchanged compiler now share one ARM runner and guarded +Node 24 install. Static checks finish and all background work joins before the +compiler starts; unit planning still overlaps compilation. Each phase keeps its +classifier output. Successful no-op background bodies register every required +join when a phase is unselected or an earlier step failed. Unit and package +consumers depend on physical job success, including action cleanup. + +Three counterbalanced pairs in +[37180613601](https://github.com/stablyai/orca/actions/runs/37180613601) +used the same frozen checkout `f199a20c3acd`, Node 24.21.0, pnpm 12.8.1, +policy hashes, native cache hits, warm TypeScript cache and 10,787-file unit plan. +Both arms used the PR root-only download-store policy. Total active job time was +152 / 153 / 151 seconds separately and 138 / 133 / 129 combined. Excluding the +extra measurement-only evidence steps gives 151 / 151 / 149 versus +136 / 132 / 128 seconds: median 151 to 132, saving 19 seconds (12.6%). +Two heavy runner admissions become one. This saves capacity; it does not prove a +whole-PR latency or queue gain. The median active dependency barrier increases +from 116 to 132 seconds because compilation follows static checks. + +The separate physical-failure run +[37180755694](https://github.com/stablyai/orca/actions/runs/37180755694) +proved that an included TypeScript error failed the actual compiler, its planner +still joined, and unit/package admissions skipped. A registered late action post +failure also blocked both consumers after successful foreground checks and +published shards. All 12 unselected/prior-failure no-op backgrounds joined, and +the downstream audit passed. Local workflow contracts passed 239 tests across +12 suites; lint and formatting passed. + ## October 3 retired-cache collection observation The same owner-collection assertion failed in unit shard 3 of From d9173ffbdbfa10ed7a24af893d15f44f5baf7c35 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 4 Oct 2026 02:55:59 -0700 Subject: [PATCH 08/31] Keep Orca CLI first after shell startup (#25130) * Restore the owning Orca CLI path after shell profiles * Use a literal marker for the Bash lookup regression * Preserve plain panes and initialize zsh after prompt hook replacement * Preserve user line-editor dispatchers during deferred startup * fix: retain CLI startup when global Zsh replaces prompt hooks * test: replay global Zsh hook replacement after host startup * test: isolate controlled Zsh widgets from distro keyboard setup * fix(shell): preserve user hooks during deferred zsh initialization * Keep completed Zsh startup hooks retired when the wrapper is sourced again --------- Co-authored-by: Codex Co-authored-by: Orca maintenance Co-authored-by: Orca campaign --- .github/workflows/pr.yml | 1 + config/scripts/ci-unit-files.mjs | 1 + .../scripts/pr-workflow-parallelism.test.mjs | 1 + .../daemon-bash-rcfile.txt | 6 + .../daemon-zsh-zshenv.txt | 67 ++++- .../local-bash-rcfile.txt | 6 + .../local-zsh-zshenv.txt | 67 ++++- .../relay-bash-rcfile.txt | 6 + .../relay-zsh-zshenv.txt | 66 +++- src/main/cli/orca-cli-child-path.test.ts | 16 +- src/main/cli/orca-cli-child-path.ts | 8 + ...codex-structured-child-environment.test.ts | 15 +- .../codex-structured-session-adapter.test.ts | 3 + .../daemon/daemon-bash-shell-ready-rcfile.ts | 2 + .../daemon/shell-ready-bash-wrapper.test.ts | 44 ++- .../ipc/pty-spawn-env-terminal-basics.test.ts | 36 +++ .../local-pty-shell-ready-bash-rcfile.ts | 2 + .../providers/ssh-pty-provider-spawn.test.ts | 2 + src/main/providers/ssh-pty-spawn-env.test.ts | 43 +++ src/main/providers/ssh-pty-spawn-env.ts | 5 + ...uctured-session-child-identity-env.test.ts | 2 + .../shell-startup-feature-channel.test.ts | 41 +++ src/main/shell-templates.ts | 7 +- ...erred-startup-line-init.live-shell.test.ts | 281 ++++++++++++++++++ src/main/zsh-deferred-startup-line-init.ts | 56 ++++ .../zsh-scoped-histfile.live-shell.test.ts | 15 +- src/main/zsh-startup-wrapper-builder.ts | 16 +- src/relay/pty-shell-overlay-wrappers.ts | 2 + src/shared/orca-cli-shell-path.ts | 6 + 29 files changed, 793 insertions(+), 30 deletions(-) create mode 100644 src/main/providers/ssh-pty-spawn-env.test.ts create mode 100644 src/main/zsh-deferred-startup-line-init.live-shell.test.ts create mode 100644 src/main/zsh-deferred-startup-line-init.ts create mode 100644 src/shared/orca-cli-shell-path.ts diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index f9f945b3bf3..d5e4a710442 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -720,6 +720,7 @@ jobs: src/main/pty/omp-shell-wrapper.node-pty.test.ts \ src/main/fish-xdg-data-dirs-handoff.test.ts \ src/main/shell-startup-feature-channel.test.ts \ + src/main/zsh-deferred-startup-line-init.live-shell.test.ts \ src/main/terminal-history-fish-session.node-pty.test.ts \ src/main/zsh-scoped-histfile.live-shell.test.ts \ src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts \ diff --git a/config/scripts/ci-unit-files.mjs b/config/scripts/ci-unit-files.mjs index 80252973e22..3e0cdb67cd8 100644 --- a/config/scripts/ci-unit-files.mjs +++ b/config/scripts/ci-unit-files.mjs @@ -23,6 +23,7 @@ export const UNIT_EXCLUDE = [ '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/terminal-history-fish-session.node-pty.test.ts', 'src/main/zsh-scoped-histfile.live-shell.test.ts', 'src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts', diff --git a/config/scripts/pr-workflow-parallelism.test.mjs b/config/scripts/pr-workflow-parallelism.test.mjs index cb9545ae7e3..7bc063b1d42 100644 --- a/config/scripts/pr-workflow-parallelism.test.mjs +++ b/config/scripts/pr-workflow-parallelism.test.mjs @@ -27,6 +27,7 @@ const shellContractFiles = [ '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', diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt index 2d161a01289..c8de61a9fda 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt @@ -30,6 +30,12 @@ __orca_restore_agent_teams_path() { export PATH="${ORCA_AGENT_TEAMS_SHIM_DIR}:$PATH" } __orca_restore_agent_teams_path +if [ -n "${ORCA_CLI_BIN_DIR:-}" ]; then + case "${PATH:-}" in + "$ORCA_CLI_BIN_DIR"|"$ORCA_CLI_BIN_DIR":*) ;; + *) export PATH="$ORCA_CLI_BIN_DIR${PATH:+:$PATH}" ;; + esac +fi # Why: user startup files may set the default OpenCode config after Orca's # spawn env; restore the Orca-managed config dir before the first prompt. [[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}" diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt index 2187aaca0f0..18850b4a0ef 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt @@ -45,6 +45,40 @@ __orca_osc133_preexec() { # which prints a warning above every command under warn_create_global. builtin typeset -g __orca_in_command=1 } +__orca_deferred_line_init() { + builtin emulate -L zsh + (( ${+functions[__orca_deferred_init]} )) || return 0 + local __orca_direct_line_init=0 + [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]] && __orca_direct_line_init=1 + __orca_deferred_init + if (( __orca_direct_line_init && ${+widgets[zle-line-init]} )); then + zle zle-line-init "$@" + elif [[ "${widgets[zle-line-init]:-}" == user:__orca_prompt_mark ]]; then + local __orca_prev_line_init_fn="" + __orca_prompt_mark "$@" + fi +} +# Why: scheduled callbacks run after user prompt hooks without copying their function metadata. +__orca_deferred_sched_init() { + local __orca_prompt_status=$? + builtin emulate -L zsh + (( ${+functions[__orca_deferred_init]} )) && __orca_deferred_init + builtin unset __orca_deferred_sched_armed + builtin unfunction __orca_deferred_sched_init + return $__orca_prompt_status +} +__orca_arm_deferred_line_init() { + builtin emulate -L zsh + if [[ "${widgets[zle-line-init]:-}" != user:__orca_deferred_line_init ]]; then + if (( ${+widgets[zle-line-init]} )); then + zle -A zle-line-init __orca_saved_line_init + fi + zle -N zle-line-init __orca_deferred_line_init + fi + if (( ! $+__orca_deferred_sched_armed )) && builtin zmodload -F zsh/sched b:sched 2>/dev/null; then + builtin sched +0 __orca_deferred_sched_init && builtin typeset -g __orca_deferred_sched_armed=1 + fi +} __orca_deferred_init() { # Why first: this body runs after the user's own config, so it would otherwise # inherit whatever options that config left set. Under NO_UNSET an unset @@ -54,12 +88,27 @@ __orca_deferred_init() { (( $+_orca_deferred_init_done )) && return 0 builtin typeset -g _orca_deferred_init_done=1 builtin typeset -g precmd_functions + if (( ${+widgets[__orca_saved_line_init]} )); then + if [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then + zle -A __orca_saved_line_init zle-line-init + fi + zle -D __orca_saved_line_init + elif [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then + zle -D zle-line-init + fi if __orca_has_feature markers; then precmd_functions=(${precmd_functions:/__orca_deferred_init/__orca_osc133_precmd}) + (( ${precmd_functions[(Ie)__orca_osc133_precmd]} )) || precmd_functions+=(__orca_osc133_precmd) preexec_functions=(__orca_osc133_preexec ${preexec_functions[@]}) else precmd_functions=(${precmd_functions:#__orca_deferred_init}) fi + if [ -n "${ORCA_CLI_BIN_DIR:-}" ]; then + case "${PATH:-}" in + "$ORCA_CLI_BIN_DIR"|"$ORCA_CLI_BIN_DIR":*) ;; + *) export PATH="$ORCA_CLI_BIN_DIR${PATH:+:$PATH}" ;; + esac + fi if __orca_has_feature overlay; then # Why: ~/.zshrc can export the user's default OpenCode config after spawn. __orca_restore_agent_teams_path() { @@ -202,13 +251,25 @@ __orca_deferred_init() { # the permanent hook has not run yet and the first prompt would lose its mark. __orca_has_feature markers && __orca_osc133_precmd builtin unset _orca_shell_features _orca_histfile - builtin unfunction __orca_deferred_init __orca_has_feature + (( $+__orca_deferred_sched_armed )) || builtin unfunction __orca_deferred_sched_init + local __orca_widget __orca_line_init_bound=0 + for __orca_widget in "${(v)widgets[@]}"; do + if [[ "$__orca_widget" == user:__orca_deferred_line_init ]]; then + __orca_line_init_bound=1 + break + fi + done + (( __orca_line_init_bound )) || builtin unfunction __orca_deferred_line_init + builtin unfunction __orca_deferred_init __orca_has_feature __orca_arm_deferred_line_init } { builtin typeset _orca_user_zshenv="${ZDOTDIR-$HOME}/.zshenv" [[ ! -r "$_orca_user_zshenv" ]] || builtin source -- "$_orca_user_zshenv" } always { builtin unset _orca_user_zshenv - builtin typeset -ag precmd_functions - (( ${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init) + if (( ! $+_orca_deferred_init_done )); then + builtin typeset -ag precmd_functions + (( ${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init) + __orca_arm_deferred_line_init + fi } diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt index fd151e03b12..05f3da594e0 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt @@ -33,6 +33,12 @@ __orca_restore_agent_teams_path() { export PATH="${ORCA_AGENT_TEAMS_SHIM_DIR}:$PATH" } __orca_restore_agent_teams_path +if [ -n "${ORCA_CLI_BIN_DIR:-}" ]; then + case "${PATH:-}" in + "$ORCA_CLI_BIN_DIR"|"$ORCA_CLI_BIN_DIR":*) ;; + *) export PATH="$ORCA_CLI_BIN_DIR${PATH:+:$PATH}" ;; + esac +fi if [ -n "${ORCA_WSL_CLI_DIR:-}" ]; then if [ -x "$ORCA_WSL_CLI_DIR/${ORCA_CLI_COMMAND:-}" ]; then export PATH="$ORCA_WSL_CLI_DIR${PATH:+:$PATH}" diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt index 7172b28d2dc..b3c8a92869c 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt @@ -45,6 +45,40 @@ __orca_osc133_preexec() { # which prints a warning above every command under warn_create_global. builtin typeset -g __orca_in_command=1 } +__orca_deferred_line_init() { + builtin emulate -L zsh + (( ${+functions[__orca_deferred_init]} )) || return 0 + local __orca_direct_line_init=0 + [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]] && __orca_direct_line_init=1 + __orca_deferred_init + if (( __orca_direct_line_init && ${+widgets[zle-line-init]} )); then + zle zle-line-init "$@" + elif [[ "${widgets[zle-line-init]:-}" == user:__orca_prompt_mark ]]; then + local __orca_prev_line_init_fn="" + __orca_prompt_mark "$@" + fi +} +# Why: scheduled callbacks run after user prompt hooks without copying their function metadata. +__orca_deferred_sched_init() { + local __orca_prompt_status=$? + builtin emulate -L zsh + (( ${+functions[__orca_deferred_init]} )) && __orca_deferred_init + builtin unset __orca_deferred_sched_armed + builtin unfunction __orca_deferred_sched_init + return $__orca_prompt_status +} +__orca_arm_deferred_line_init() { + builtin emulate -L zsh + if [[ "${widgets[zle-line-init]:-}" != user:__orca_deferred_line_init ]]; then + if (( ${+widgets[zle-line-init]} )); then + zle -A zle-line-init __orca_saved_line_init + fi + zle -N zle-line-init __orca_deferred_line_init + fi + if (( ! $+__orca_deferred_sched_armed )) && builtin zmodload -F zsh/sched b:sched 2>/dev/null; then + builtin sched +0 __orca_deferred_sched_init && builtin typeset -g __orca_deferred_sched_armed=1 + fi +} __orca_deferred_init() { # Why first: this body runs after the user's own config, so it would otherwise # inherit whatever options that config left set. Under NO_UNSET an unset @@ -54,8 +88,17 @@ __orca_deferred_init() { (( $+_orca_deferred_init_done )) && return 0 builtin typeset -g _orca_deferred_init_done=1 builtin typeset -g precmd_functions + if (( ${+widgets[__orca_saved_line_init]} )); then + if [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then + zle -A __orca_saved_line_init zle-line-init + fi + zle -D __orca_saved_line_init + elif [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then + zle -D zle-line-init + fi if __orca_has_feature markers; then precmd_functions=(${precmd_functions:/__orca_deferred_init/__orca_osc133_precmd}) + (( ${precmd_functions[(Ie)__orca_osc133_precmd]} )) || precmd_functions+=(__orca_osc133_precmd) preexec_functions=(__orca_osc133_preexec ${preexec_functions[@]}) else precmd_functions=(${precmd_functions:#__orca_deferred_init}) @@ -67,6 +110,12 @@ __orca_deferred_init() { printf 'Orca CLI unavailable: cannot run %s. Check WSL Windows-drive mount options.\n' "$ORCA_WSL_CLI_DIR/${ORCA_CLI_COMMAND:-}" >&2 fi fi + if [ -n "${ORCA_CLI_BIN_DIR:-}" ]; then + case "${PATH:-}" in + "$ORCA_CLI_BIN_DIR"|"$ORCA_CLI_BIN_DIR":*) ;; + *) export PATH="$ORCA_CLI_BIN_DIR${PATH:+:$PATH}" ;; + esac + fi if __orca_has_feature overlay; then # Why: ~/.zshrc can export the user's default OpenCode config after spawn. __orca_restore_agent_teams_path() { @@ -219,13 +268,25 @@ __orca_deferred_init() { # the permanent hook has not run yet and the first prompt would lose its mark. __orca_has_feature markers && __orca_osc133_precmd builtin unset _orca_shell_features _orca_histfile - builtin unfunction __orca_deferred_init __orca_has_feature + (( $+__orca_deferred_sched_armed )) || builtin unfunction __orca_deferred_sched_init + local __orca_widget __orca_line_init_bound=0 + for __orca_widget in "${(v)widgets[@]}"; do + if [[ "$__orca_widget" == user:__orca_deferred_line_init ]]; then + __orca_line_init_bound=1 + break + fi + done + (( __orca_line_init_bound )) || builtin unfunction __orca_deferred_line_init + builtin unfunction __orca_deferred_init __orca_has_feature __orca_arm_deferred_line_init } { builtin typeset _orca_user_zshenv="${ZDOTDIR-$HOME}/.zshenv" [[ ! -r "$_orca_user_zshenv" ]] || builtin source -- "$_orca_user_zshenv" } always { builtin unset _orca_user_zshenv - builtin typeset -ag precmd_functions - (( ${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init) + if (( ! $+_orca_deferred_init_done )); then + builtin typeset -ag precmd_functions + (( ${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init) + __orca_arm_deferred_line_init + fi } diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/relay-bash-rcfile.txt b/src/main/__fixtures__/shell-wrapper-snapshots/relay-bash-rcfile.txt index 3042bb11df4..e9cc47d8361 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/relay-bash-rcfile.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/relay-bash-rcfile.txt @@ -26,6 +26,12 @@ fi [[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}" [[ -n "${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="${ORCA_MIMOCODE_HOME}" [[ -n "${ORCA_REMOTE_CLI_BIN_DIR:-}" ]] && case ":$PATH:" in *:"${ORCA_REMOTE_CLI_BIN_DIR}":*) ;; *) export PATH="${ORCA_REMOTE_CLI_BIN_DIR}:$PATH" ;; esac +if [ -n "${ORCA_CLI_BIN_DIR:-}" ]; then + case "${PATH:-}" in + "$ORCA_CLI_BIN_DIR"|"$ORCA_CLI_BIN_DIR":*) ;; + *) export PATH="$ORCA_CLI_BIN_DIR${PATH:+:$PATH}" ;; + esac +fi # Why: OMP does not auto-load Orca's managed status extension; wrap only # interactive launch invocations so subcommands such as `omp config` keep # their normal argv shape. diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt index 102534564b7..21dce8480e6 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt @@ -31,6 +31,40 @@ builtin typeset -g _orca_histfile="${ORCA_HISTFILE:-}" builtin unset ORCA_HISTFILE __orca_has_feature() { (( ${_orca_shell_features[(Ie)$1]} )) } __orca_has_feature identity && printf "\033]777;orca-shell-start:%s\007" "$$" +__orca_deferred_line_init() { + builtin emulate -L zsh + (( ${+functions[__orca_deferred_init]} )) || return 0 + local __orca_direct_line_init=0 + [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]] && __orca_direct_line_init=1 + __orca_deferred_init + if (( __orca_direct_line_init && ${+widgets[zle-line-init]} )); then + zle zle-line-init "$@" + elif [[ "${widgets[zle-line-init]:-}" == user:__orca_prompt_mark ]]; then + local __orca_prev_line_init_fn="" + __orca_prompt_mark "$@" + fi +} +# Why: scheduled callbacks run after user prompt hooks without copying their function metadata. +__orca_deferred_sched_init() { + local __orca_prompt_status=$? + builtin emulate -L zsh + (( ${+functions[__orca_deferred_init]} )) && __orca_deferred_init + builtin unset __orca_deferred_sched_armed + builtin unfunction __orca_deferred_sched_init + return $__orca_prompt_status +} +__orca_arm_deferred_line_init() { + builtin emulate -L zsh + if [[ "${widgets[zle-line-init]:-}" != user:__orca_deferred_line_init ]]; then + if (( ${+widgets[zle-line-init]} )); then + zle -A zle-line-init __orca_saved_line_init + fi + zle -N zle-line-init __orca_deferred_line_init + fi + if (( ! $+__orca_deferred_sched_armed )) && builtin zmodload -F zsh/sched b:sched 2>/dev/null; then + builtin sched +0 __orca_deferred_sched_init && builtin typeset -g __orca_deferred_sched_armed=1 + fi +} __orca_deferred_init() { # Why first: this body runs after the user's own config, so it would otherwise # inherit whatever options that config left set. Under NO_UNSET an unset @@ -40,7 +74,21 @@ __orca_deferred_init() { (( $+_orca_deferred_init_done )) && return 0 builtin typeset -g _orca_deferred_init_done=1 builtin typeset -g precmd_functions + if (( ${+widgets[__orca_saved_line_init]} )); then + if [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then + zle -A __orca_saved_line_init zle-line-init + fi + zle -D __orca_saved_line_init + elif [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then + zle -D zle-line-init + fi precmd_functions=(${precmd_functions:#__orca_deferred_init}) + if [ -n "${ORCA_CLI_BIN_DIR:-}" ]; then + case "${PATH:-}" in + "$ORCA_CLI_BIN_DIR"|"$ORCA_CLI_BIN_DIR":*) ;; + *) export PATH="$ORCA_CLI_BIN_DIR${PATH:+:$PATH}" ;; + esac + fi if __orca_has_feature overlay; then # Why: remote startup files can re-export user defaults after relay spawn. [[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}" @@ -170,13 +218,25 @@ __orca_deferred_init() { zle -N zle-line-init __orca_prompt_mark fi builtin unset _orca_shell_features _orca_histfile - builtin unfunction __orca_deferred_init __orca_has_feature + (( $+__orca_deferred_sched_armed )) || builtin unfunction __orca_deferred_sched_init + local __orca_widget __orca_line_init_bound=0 + for __orca_widget in "${(v)widgets[@]}"; do + if [[ "$__orca_widget" == user:__orca_deferred_line_init ]]; then + __orca_line_init_bound=1 + break + fi + done + (( __orca_line_init_bound )) || builtin unfunction __orca_deferred_line_init + builtin unfunction __orca_deferred_init __orca_has_feature __orca_arm_deferred_line_init } { builtin typeset _orca_user_zshenv="${ZDOTDIR-$HOME}/.zshenv" [[ ! -r "$_orca_user_zshenv" ]] || builtin source -- "$_orca_user_zshenv" } always { builtin unset _orca_user_zshenv - builtin typeset -ag precmd_functions - (( ${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init) + if (( ! $+_orca_deferred_init_done )); then + builtin typeset -ag precmd_functions + (( ${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init) + __orca_arm_deferred_line_init + fi } diff --git a/src/main/cli/orca-cli-child-path.test.ts b/src/main/cli/orca-cli-child-path.test.ts index 727732dd64c..7440b1a9ad8 100644 --- a/src/main/cli/orca-cli-child-path.test.ts +++ b/src/main/cli/orca-cli-child-path.test.ts @@ -27,6 +27,7 @@ describe('prependOrcaCliDirToChildPath', () => { platform: 'linux' }) expect(env.PATH).toBe(`${SHIM_DIR}:/usr/local/bin:/usr/bin`) + expect(env.ORCA_CLI_BIN_DIR).toBe(SHIM_DIR) expect(shim.ensureLinuxTerminalOrcaCliShimDir).toHaveBeenCalledWith({ userDataPath: USER_DATA }) @@ -44,13 +45,14 @@ describe('prependOrcaCliDirToChildPath', () => { it('leaves packaged Linux PATH untouched when no shim could be written', () => { shim.ensureLinuxTerminalOrcaCliShimDir.mockReturnValue(null) - const env: Record = { PATH: '/usr/bin' } + const env: Record = { PATH: '/usr/bin', ORCA_CLI_BIN_DIR: '/old-host/cli' } prependOrcaCliDirToChildPath(env, { isPackaged: true, userDataPath: USER_DATA, platform: 'linux' }) expect(env.PATH).toBe('/usr/bin') + expect(env.ORCA_CLI_BIN_DIR).toBeUndefined() }) it('leads packaged macOS PATH with the bundled CLI dir', () => { @@ -62,11 +64,16 @@ describe('prependOrcaCliDirToChildPath', () => { platform: 'darwin' }) expect(env.PATH).toBe(`${join(RESOURCES, 'bin')}:/usr/bin`) + expect(env.ORCA_CLI_BIN_DIR).toBe(join(RESOURCES, 'bin')) expect(shim.ensureLinuxTerminalOrcaCliShimDir).not.toHaveBeenCalled() }) it('leads packaged Windows PATH with the bundled CLI dir under the env block spelling', () => { - const env: Record = { Path: 'C:\\Windows\\System32' } + const env: Record = { + Path: 'C:\\Windows\\System32', + ORCA_CLI_BIN_DIR: '/parent-host/cli', + ORCA_WSL_CLI_DIR: '/guest/orca/bin' + } prependOrcaCliDirToChildPath(env, { isPackaged: true, userDataPath: USER_DATA, @@ -75,6 +82,8 @@ describe('prependOrcaCliDirToChildPath', () => { }) expect(env.Path).toBe(`${join(RESOURCES, 'bin')};C:\\Windows\\System32`) expect(env.PATH).toBeUndefined() + expect(env.ORCA_CLI_BIN_DIR).toBeUndefined() + expect(env.ORCA_WSL_CLI_DIR).toBe('/guest/orca/bin') }) it('leaves a packaged darwin/win32 PATH alone with no resources root', () => { @@ -101,6 +110,9 @@ describe('prependOrcaCliDirToChildPath', () => { platform }) expect(env.PATH).toBe(`${join(USER_DATA, 'cli', 'bin')}${pathDelimiter}/usr/bin`) + expect(env.ORCA_CLI_BIN_DIR).toBe( + platform === 'win32' ? undefined : join(USER_DATA, 'cli', 'bin') + ) expect(shim.ensureLinuxTerminalOrcaCliShimDir).not.toHaveBeenCalled() }) diff --git a/src/main/cli/orca-cli-child-path.ts b/src/main/cli/orca-cli-child-path.ts index 2a8136c367f..a15943827cc 100644 --- a/src/main/cli/orca-cli-child-path.ts +++ b/src/main/cli/orca-cli-child-path.ts @@ -39,12 +39,16 @@ export function prependOrcaCliDirToChildPath( opts: OrcaCliChildPathOptions ): string | null { const platform = opts.platform ?? process.platform + delete env.ORCA_CLI_BIN_DIR // Why: matches node:path's `delimiter` for the running platform, but stays correct when a test // drives a foreign platform through the seam. const pathDelimiter = platform === 'win32' ? ';' : delimiter // Why: dev mode needs the launcher PATH override so `orca` resolves to the dev build instead of the production binary at /usr/local/bin/orca. if (!opts.isPackaged) { const devCliBin = join(opts.userDataPath, 'cli', 'bin') + if (platform !== 'win32') { + env.ORCA_CLI_BIN_DIR = devCliBin + } const inheritedPath = readInheritedPath(env, platform) // Why: an empty PATH segment resolves as `.` in some shells (commands run from cwd); avoid a trailing delimiter. env[resolvePathEnvKey(env, platform)] = inheritedPath @@ -55,6 +59,7 @@ export function prependOrcaCliDirToChildPath( // Why: bare-`orca` shim scoped to Orca PTYs — Linux CLI installs as `orca-ide` to avoid shadowing GNOME's /usr/bin/orca screen reader (stablyai/orca#7904). const shimDir = ensureLinuxTerminalOrcaCliShimDir({ userDataPath: opts.userDataPath }) if (shimDir) { + env.ORCA_CLI_BIN_DIR = shimDir const inheritedEntries = readInheritedPath(env, platform) .split(pathDelimiter) .filter((entry) => entry.length > 0 && entry !== shimDir) @@ -64,6 +69,9 @@ export function prependOrcaCliDirToChildPath( } else if (opts.resourcesPath && (platform === 'darwin' || platform === 'win32')) { // Why: global CLI registration is optional, but agents in Orca-managed PTYs must always reach this app's bundled CLI. const bundledCliBin = join(opts.resourcesPath, 'bin') + if (platform === 'darwin') { + env.ORCA_CLI_BIN_DIR = bundledCliBin + } const inheritedPath = readInheritedPath(env, platform) env[resolvePathEnvKey(env, platform)] = inheritedPath ? `${bundledCliBin}${pathDelimiter}${inheritedPath}` diff --git a/src/main/codex/codex-structured-child-environment.test.ts b/src/main/codex/codex-structured-child-environment.test.ts index 4dcd49e9645..517f9effb90 100644 --- a/src/main/codex/codex-structured-child-environment.test.ts +++ b/src/main/codex/codex-structured-child-environment.test.ts @@ -10,6 +10,7 @@ import { } from '../runtime/structured-worker-identity' const DEV_CLI_BIN_FIRST = /^[^:;]*[\\/]cli[\\/]bin[:;]/ +const DEV_CLI_BIN_DIR = /^[^:;]*[\\/]cli[\\/]bin$/ // The dev launcher by absolute path: a login shell's profile cannot reorder it behind a global. const DEV_CLI_LAUNCHER = /^[^:;]*[\\/]cli[\\/]bin[\\/]orca-dev$/ @@ -23,7 +24,11 @@ describe('buildCodexStructuredChildEnvironment', () => { cwd: '/worktree', codexHome: '/pinned/home', resumeThreadId: null, - env: { EXAMPLE_GATEWAY_TOKEN: 'shell-exported', CODEX_HOME: '/shell/home' } + env: { + EXAMPLE_GATEWAY_TOKEN: 'shell-exported', + CODEX_HOME: '/shell/home', + ORCA_CLI_BIN_DIR: '/inherited/unowned-cli' + } }, 'spawn-token', 'session-not-a-worker' @@ -35,6 +40,9 @@ describe('buildCodexStructuredChildEnvironment', () => { ORCA_AGENT_SESSION_ID: 'session-not-a-worker', ORCA_STRUCTURED_SESSION: '1', ORCA_CLI_COMMAND: expect.stringMatching(DEV_CLI_LAUNCHER), + ...(process.platform !== 'win32' + ? { ORCA_CLI_BIN_DIR: expect.stringMatching(DEV_CLI_BIN_DIR) } + : {}), ORCA_USER_DATA_PATH: expect.any(String), // The test host is unpackaged, so this app's CLI is the dev launcher dir, first on PATH. PATH: expect.stringMatching(DEV_CLI_BIN_FIRST) @@ -57,6 +65,9 @@ describe('buildCodexStructuredChildEnvironment', () => { ORCA_AGENT_SESSION_ID: sessionId, ORCA_STRUCTURED_SESSION: '1', ORCA_CLI_COMMAND: expect.stringMatching(DEV_CLI_LAUNCHER), + ...(process.platform !== 'win32' + ? { ORCA_CLI_BIN_DIR: expect.stringMatching(DEV_CLI_BIN_DIR) } + : {}), ORCA_USER_DATA_PATH: expect.any(String), PATH: expect.stringMatching(DEV_CLI_BIN_FIRST) }) @@ -97,6 +108,7 @@ const ENV_REPORTING_APP_SERVER = String.raw` result: { sessionId: process.env.ORCA_AGENT_SESSION_ID ?? null, cliCommand: process.env.ORCA_CLI_COMMAND ?? null, + cliBinDir: process.env.ORCA_CLI_BIN_DIR ?? null, path: process.env.PATH ?? process.env.Path ?? null } }) @@ -135,6 +147,7 @@ describe('the spawned Codex child', () => { await expect(connection.request('test/env')).resolves.toEqual({ sessionId, cliCommand: expect.stringMatching(DEV_CLI_LAUNCHER), + cliBinDir: process.platform === 'win32' ? null : expect.stringMatching(DEV_CLI_BIN_DIR), path: expect.stringMatching(DEV_CLI_BIN_FIRST) }) } finally { diff --git a/src/main/codex/codex-structured-session-adapter.test.ts b/src/main/codex/codex-structured-session-adapter.test.ts index 70b2ecd7147..9276d670b4c 100644 --- a/src/main/codex/codex-structured-session-adapter.test.ts +++ b/src/main/codex/codex-structured-session-adapter.test.ts @@ -38,6 +38,9 @@ describe('CodexStructuredSessionAdapter.acquire', () => { ORCA_AGENT_SESSION_ID: 'session-1', ORCA_STRUCTURED_SESSION: '1', ORCA_CLI_COMMAND: expect.stringMatching(/^[^:;]*[\\/]cli[\\/]bin[\\/]orca-dev$/), + ...(process.platform !== 'win32' + ? { ORCA_CLI_BIN_DIR: expect.stringMatching(/^[^:;]*[\\/]cli[\\/]bin$/) } + : {}), ORCA_USER_DATA_PATH: expect.any(String), // The test host is unpackaged, so this app's CLI is the dev launcher dir, first on PATH. PATH: expect.stringMatching(/^[^:;]*[\\/]cli[\\/]bin[:;]/) diff --git a/src/main/daemon/daemon-bash-shell-ready-rcfile.ts b/src/main/daemon/daemon-bash-shell-ready-rcfile.ts index 6ae5c7f4c63..bf2e2545102 100644 --- a/src/main/daemon/daemon-bash-shell-ready-rcfile.ts +++ b/src/main/daemon/daemon-bash-shell-ready-rcfile.ts @@ -1,4 +1,5 @@ import { getPosixOmpShellWrapper } from '../pty/omp-shell-wrapper' +import { ORCA_CLI_POSIX_PATH_RESTORE } from '../../shared/orca-cli-shell-path' import { MANAGED_DATA_ACCOUNT_POSIX_RESTORE } from '../../shared/managed-data-account-shell' import { getPosixCodexShellLaunchPreflight } from '../../shared/codex-shell-function' import { BASH_PROMPT_COMMAND_COMPOSITION_BLOCK } from '../bash-prompt-command-composition' @@ -35,6 +36,7 @@ __orca_restore_agent_teams_path() { export PATH="\${ORCA_AGENT_TEAMS_SHIM_DIR}:$PATH" } __orca_restore_agent_teams_path +${ORCA_CLI_POSIX_PATH_RESTORE} # Why: user startup files may set the default OpenCode config after Orca's # spawn env; restore the Orca-managed config dir before the first prompt. [[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" diff --git a/src/main/daemon/shell-ready-bash-wrapper.test.ts b/src/main/daemon/shell-ready-bash-wrapper.test.ts index 0fbdce87029..059612f5a4b 100644 --- a/src/main/daemon/shell-ready-bash-wrapper.test.ts +++ b/src/main/daemon/shell-ready-bash-wrapper.test.ts @@ -2,8 +2,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { spawnSync } from 'node:child_process' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import type * as DaemonBashRcfileModule from './daemon-bash-shell-ready-rcfile' +import { getBashShellReadyRcfileContent } from '../providers/local-pty-shell-ready-bash-rcfile' +import { getDaemonBashShellReadyRcfileContent } from './daemon-bash-shell-ready-rcfile' +import { prependOrcaCliDirToChildPath } from '../cli/orca-cli-child-path' import { OVERLAY_ONLY_FEATURES, STARTUP_COMMAND_FEATURES @@ -57,6 +60,45 @@ describePosix('daemon shell-ready bash wrapper', () => { vi.restoreAllMocks() }) + itWithBash.each([ + ['daemon', getDaemonBashShellReadyRcfileContent], + ['local', getBashShellReadyRcfileContent] + ] as const)('keeps this app CLI first after %s Bash profiles reset PATH', (_lane, content) => { + const cliBin = join(userDataPath, 'cli', 'bin') + const ambientBin = join(userDataPath, 'ambient-bin') + mkdirSync(cliBin, { recursive: true }) + mkdirSync(ambientBin) + for (const bin of [cliBin, ambientBin]) { + const launcher = join(bin, 'orca-dev') + writeFileSync(launcher, '#!/bin/sh\nexit 0\n') + chmodSync(launcher, 0o755) + } + const env: Record = { + HOME: userDataPath, + USERPROFILE: userDataPath, + PATH: `${ambientBin}:/usr/bin:/bin`, + ORCA_BACKGROUND_LAUNCH: '1' + } + const expectedLauncher = prependOrcaCliDirToChildPath(env, { + isPackaged: false, + userDataPath + }) + writeFileSync( + join(userDataPath, '.bash_profile'), + 'export PATH="$HOME/ambient-bin:/usr/bin:/bin:$HOME/cli/bin"\n' + ) + const rcfile = join(userDataPath, 'cli-path-rcfile') + writeFileSync(rcfile, content()) + const result = spawnSync('bash', ['-c', '. "$1"; command -v orca-dev', 'bash', rcfile], { + env, + encoding: 'utf8', + timeout: 5000 + }) + expect(result.error).toBeUndefined() + expect(result.status).toBe(0) + expect(result.stdout.split('\x1b]133;C\x07').join('').trim()).toBe(expectedLauncher) + }) + // Why: regression guard for issue #2422 — bash wrapper must emit OSC 133 C/D so SSH sessions clear stale 'working' agent rows. it('emits OSC 133 C/D markers in the daemon bash wrapper', async () => { const { getShellReadyLaunchConfig } = await importFreshShellReady() diff --git a/src/main/ipc/pty-spawn-env-terminal-basics.test.ts b/src/main/ipc/pty-spawn-env-terminal-basics.test.ts index 224238bff9a..d530a9cd194 100644 --- a/src/main/ipc/pty-spawn-env-terminal-basics.test.ts +++ b/src/main/ipc/pty-spawn-env-terminal-basics.test.ts @@ -11,6 +11,7 @@ import { wslHookRelayManager } from '../agent-hooks/wsl-hook-relay-manager' import { registerPtyHandlers, buildPtyHostEnv, clearProviderPtyState } from './pty' import { buildJcodeRuntimeDir, shouldInjectJcodeRuntimeDir } from '../../shared/jcode-runtime-dir' import { makePaneKey } from '../../shared/stable-pane-id' +import { selectShellStartupFeatures } from '../shell-startup-features' vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock())) @@ -60,6 +61,41 @@ describe('registerPtyHandlers', () => { const { handlers, mainWindow, spawnAndGetEnv, withBundledCli } = setupPtyIpcSuite() describe('spawn environment', () => { + it.each(['/bin/bash', '/bin/zsh'])( + 'does not wrap a bare %s pane merely to expose this app CLI', + (shellPath) => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) + try { + const env = buildPtyHostEnv( + 'bare-cli-pane', + {}, + { + isPackaged: false, + userDataPath: '/tmp/orca-user-data', + selectedCodexHomePath: null, + agentStatusHooksEnabled: false + } + ) + expect(env.ORCA_CLI_BIN_DIR).toBe('/tmp/orca-user-data/cli/bin') + expect( + selectShellStartupFeatures({ + shellPath, + env, + hasStartupCommand: false, + waitsForShellReady: false, + emitsStartupIdentity: false + }) + ).toEqual([]) + } finally { + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + } + } + ) + it('does not install managed Pi extensions when Pi is disabled', () => { piBuildPtyEnvMock.mockClear() diff --git a/src/main/providers/local-pty-shell-ready-bash-rcfile.ts b/src/main/providers/local-pty-shell-ready-bash-rcfile.ts index d16715ed496..169773edbd5 100644 --- a/src/main/providers/local-pty-shell-ready-bash-rcfile.ts +++ b/src/main/providers/local-pty-shell-ready-bash-rcfile.ts @@ -5,6 +5,7 @@ * startup-file chain, OSC 133 hooks, and the shell-ready marker all live here. */ import { BASH_PROMPT_COMMAND_COMPOSITION_BLOCK } from '../bash-prompt-command-composition' +import { ORCA_CLI_POSIX_PATH_RESTORE } from '../../shared/orca-cli-shell-path' import { MANAGED_DATA_ACCOUNT_POSIX_RESTORE } from '../../shared/managed-data-account-shell' import { WSL_MANAGED_CLI_PATH_RESTORE } from '../wsl-managed-cli-path-restore' import { getPosixOmpShellWrapper } from '../pty/omp-shell-wrapper' @@ -47,6 +48,7 @@ __orca_restore_agent_teams_path() { export PATH="\${ORCA_AGENT_TEAMS_SHIM_DIR}:$PATH" } __orca_restore_agent_teams_path +${ORCA_CLI_POSIX_PATH_RESTORE} ${WSL_MANAGED_CLI_PATH_RESTORE} # Why: user startup files may set the default OpenCode config after Orca's # spawn env; restore the Orca-managed config dir before the first prompt. diff --git a/src/main/providers/ssh-pty-provider-spawn.test.ts b/src/main/providers/ssh-pty-provider-spawn.test.ts index 06eb32d586f..6626a238ce0 100644 --- a/src/main/providers/ssh-pty-provider-spawn.test.ts +++ b/src/main/providers/ssh-pty-provider-spawn.test.ts @@ -460,6 +460,7 @@ describe('spawn', () => { PATH: '/home/user/.orca-relay/bin:/usr/bin', ORCA_TERMINAL_HANDLE: 'term_ssh', [POWERLEVEL10K_WIZARD_DISABLE_ENV]: 'true', + ORCA_CLI_BIN_DIR: '/home/user/.orca-relay/bin', ORCA_REMOTE_CLI_BIN_DIR: '/home/user/.orca-relay/bin', ORCA_RELAY_DIR: '/home/user/.orca-relay/relay-v1', ORCA_RELAY_NODE_PATH: '/usr/bin/node', @@ -490,6 +491,7 @@ describe('spawn', () => { env: { ORCA_TERMINAL_HANDLE: 'term_ssh', [POWERLEVEL10K_WIZARD_DISABLE_ENV]: 'true', + ORCA_CLI_BIN_DIR: '/home/user/.orca-relay/bin', ORCA_REMOTE_CLI_BIN_DIR: '/home/user/.orca-relay/bin', ORCA_RELAY_DIR: '/home/user/.orca-relay/relay-v1', ORCA_RELAY_NODE_PATH: '/usr/bin/node', diff --git a/src/main/providers/ssh-pty-spawn-env.test.ts b/src/main/providers/ssh-pty-spawn-env.test.ts new file mode 100644 index 00000000000..b1fba08b0ca --- /dev/null +++ b/src/main/providers/ssh-pty-spawn-env.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import { buildSshPtySpawnEnv } from './ssh-pty-spawn-env' +import type { RemoteCliBridgeEnv } from './ssh-pty-provider-contract' + +const bridge: RemoteCliBridgeEnv = { + binDir: '/remote/orca/bin', + relayDir: '/remote/orca', + nodePath: '/remote/node', + sockPath: '/remote/orca/socket' +} + +describe('SSH CLI path ownership', () => { + it('replaces the client restore directory with the remote bridge', () => { + const env = { PATH: '/usr/bin', ORCA_CLI_BIN_DIR: '/client/orca/bin' } + const result = buildSshPtySpawnEnv({ env, remoteCliBridgeEnv: bridge }) + expect(result.ORCA_CLI_BIN_DIR).toBe(bridge.binDir) + expect(result.PATH).toBe(`${bridge.binDir}:/usr/bin`) + expect(env.ORCA_CLI_BIN_DIR).toBe('/client/orca/bin') + }) + + it('clears a client path when no remote bridge is available', () => { + const result = buildSshPtySpawnEnv({ env: { ORCA_CLI_BIN_DIR: '/client/orca/bin' } }) + expect(result.ORCA_CLI_BIN_DIR).toBeUndefined() + }) + + it('does not give a POSIX wrapper a Windows bridge directory', () => { + const result = buildSshPtySpawnEnv({ + env: { Path: 'C:\\Windows', ORCA_CLI_BIN_DIR: '/client/orca/bin' }, + remoteCliBridgeEnv: { ...bridge, binDir: 'C:\\Orca\\bin', pathDelimiter: ';' } + }) + expect(result.ORCA_CLI_BIN_DIR).toBeUndefined() + expect(result.Path).toBe('C:\\Orca\\bin;C:\\Windows') + }) + + it('preserves an explicitly deleted restore key', () => { + const result = buildSshPtySpawnEnv({ + env: { PATH: '/usr/bin' }, + remoteCliBridgeEnv: bridge, + envToDelete: ['ORCA_CLI_BIN_DIR'] + }) + expect(result.ORCA_CLI_BIN_DIR).toBeUndefined() + }) +}) diff --git a/src/main/providers/ssh-pty-spawn-env.ts b/src/main/providers/ssh-pty-spawn-env.ts index 595e750c90d..45e2abca8bd 100644 --- a/src/main/providers/ssh-pty-spawn-env.ts +++ b/src/main/providers/ssh-pty-spawn-env.ts @@ -7,8 +7,13 @@ export function buildSshPtySpawnEnv(args: { remoteCliBridgeEnv?: RemoteCliBridgeEnv }): Record { const merged = { ...args.env } + // The client CLI path cannot be restored on the execution host. + delete merged.ORCA_CLI_BIN_DIR if (args.remoteCliBridgeEnv) { const pathDelimiter = args.remoteCliBridgeEnv.pathDelimiter ?? ':' + if (pathDelimiter === ':') { + merged.ORCA_CLI_BIN_DIR = args.remoteCliBridgeEnv.binDir + } const pathKey = merged.PATH !== undefined ? 'PATH' : merged.Path !== undefined ? 'Path' : null if (pathKey) { const pathValue = merged[pathKey] ?? '' diff --git a/src/main/runtime/structured-session-child-identity-env.test.ts b/src/main/runtime/structured-session-child-identity-env.test.ts index 1289e979ad6..095aad2a8a5 100644 --- a/src/main/runtime/structured-session-child-identity-env.test.ts +++ b/src/main/runtime/structured-session-child-identity-env.test.ts @@ -70,6 +70,7 @@ describe('structuredSessionChildIdentityEnv', () => { // For a CLI that predates the id, which refuses on it instead of guessing a sibling. ORCA_STRUCTURED_SESSION: '1', ORCA_CLI_COMMAND: join(SHIM_DIR, 'orca'), + ORCA_CLI_BIN_DIR: SHIM_DIR, // The instance that minted the id, so any current CLI dials it rather than the default. ORCA_USER_DATA_PATH: USER_DATA }) @@ -131,6 +132,7 @@ describe('structuredSessionChildIdentityEnv', () => { expect(env.PATH).toBeUndefined() // The native launcher: `orca.cmd` refuses message bodies cmd.exe would mangle. expect(env.ORCA_CLI_COMMAND).toBe(join(RESOURCES, 'bin', 'orca.exe')) + expect(env.ORCA_CLI_BIN_DIR).toBeUndefined() }) it('unpackaged, through the dev launcher dir', () => { diff --git a/src/main/shell-startup-feature-channel.test.ts b/src/main/shell-startup-feature-channel.test.ts index e21269bfa91..0001a98641e 100644 --- a/src/main/shell-startup-feature-channel.test.ts +++ b/src/main/shell-startup-feature-channel.test.ts @@ -17,6 +17,7 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { POSIX_SHELL_STARTUP_COMMAND_ENV } from './pty/posix-shell-startup-command' import { selectShellStartupFeatures } from './shell-startup-features' +import { prependOrcaCliDirToChildPath } from './cli/orca-cli-child-path' import { runZshPty } from './zsh-startup-hook-pty-harness' import { ZSH_WRAPPER_DIR_MARKER_FILE } from './shell-templates' import { @@ -119,6 +120,46 @@ describePosix('zsh launch config', () => { rmSync(userDataPath, { recursive: true, force: true }) }) + itWithZsh.each([false, true])( + 'restores this app CLI after zsh startup resets PATH and replaces prompt hooks %s', + async (replacePromptHooks) => { + const cliBin = join(userDataPath, 'cli', 'bin') + const ambientBin = join(userDataPath, 'ambient-bin') + mkdirSync(cliBin, { recursive: true }) + mkdirSync(ambientBin) + for (const bin of [cliBin, ambientBin]) { + const launcher = join(bin, 'orca-dev') + writeFileSync(launcher, '#!/bin/sh\nexit 0\n') + chmodSync(launcher, 0o755) + } + writeFileSync( + join(userDataPath, '.zshrc'), + `export PATH="$HOME/ambient-bin:/usr/bin:/bin:$HOME/cli/bin"\n${replacePromptHooks ? 'precmd_functions=()\n' : ''}` + ) + const env: Record = { + ...process.env, + HOME: userDataPath, + USERPROFILE: userDataPath, + PATH: `${ambientBin}:/usr/bin:/bin` + } + const launcher = prependOrcaCliDirToChildPath(env, { isPackaged: false, userDataPath }) + const features = selectShellStartupFeatures({ + shellPath: ZSH_PATH, + env, + ...PLAIN_PANE, + hasStartupCommand: true + }) + const { getShellLaunchConfig } = await importFreshLocalPtyShellReady() + const config = getShellLaunchConfig(ZSH_PATH, features) + const result = await runZshPty({ + env: { ...env, ...config.env }, + commands: ['ORCA_LOOKUP=$(command -v orca-dev)'], + report: ['ORCA_LOOKUP'] + }) + expect(result.values.ORCA_LOOKUP).toBe(launcher) + } + ) + it('publishes exactly the selected features, whatever process.env holds', async () => { process.env.ORCA_SHELL_FEATURES = 'overlay,markers,ready,identity' const { getShellLaunchConfig } = await importFreshLocalPtyShellReady() diff --git a/src/main/shell-templates.ts b/src/main/shell-templates.ts index cb7c2fca2e9..7b5f922e89f 100644 --- a/src/main/shell-templates.ts +++ b/src/main/shell-templates.ts @@ -102,8 +102,11 @@ export const ZSH_USER_ZSHENV_SOURCE_BLOCK = `{ [[ ! -r "$_orca_user_zshenv" ]] || builtin source -- "$_orca_user_zshenv" } always { builtin unset _orca_user_zshenv - builtin typeset -ag precmd_functions - (( \${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init) + if (( ! $+_orca_deferred_init_done )); then + builtin typeset -ag precmd_functions + (( \${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init) + __orca_arm_deferred_line_init + fi }` // Why: daemon, local, and relay wrappers must preserve one Bash prompt-hook contract. diff --git a/src/main/zsh-deferred-startup-line-init.live-shell.test.ts b/src/main/zsh-deferred-startup-line-init.live-shell.test.ts new file mode 100644 index 00000000000..48246e10573 --- /dev/null +++ b/src/main/zsh-deferred-startup-line-init.live-shell.test.ts @@ -0,0 +1,281 @@ +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { prependOrcaCliDirToChildPath } from './cli/orca-cli-child-path' +import { POSIX_SHELL_STARTUP_COMMAND_ENV } from './pty/posix-shell-startup-command' +import { getZshShellReadyWrapperFile } from './providers/local-pty-shell-ready-wrapper-generation' +import { encodeShellStartupFeatures, selectShellStartupFeatures } from './shell-startup-features' +import { ZSH_WRAPPER_DIR_MARKER_FILE } from './shell-templates' +import { hasZsh, MARKERS, runZshPty, ZSH_PATH } from './zsh-startup-hook-pty-harness' + +const itWithZsh = hasZsh ? it : it.skip +const USER_WIDGET = `orca_test_line_init() { + O_UC=$((\${O_UC:-0}+1)) + O_UN="$WIDGET" + builtin printf 'ORCA_TEST_USER_WIDGET_CALL\\n' + return 1 +} +zle -N zle-line-init orca_test_line_init +` +const USER_REDRAW = `orca_test_redraw() { + O_RC=$((\${O_RC:-0}+1)) + O_RN="$WIDGET" + return 1 +} +zle -N zle-line-pre-redraw orca_test_redraw +` +const USER_PRECMD = `precmd() { + O_PCALLS=$((\${O_PCALLS:-0}+1)) + O_PN="$0" + O_PO="\${O_PO:-\${options[ksharrays]}:\${options[nounset]}}" + return 1 +} +` + +describe('zsh deferred startup after prompt-hook replacement', () => { + const roots: string[] = [] + + afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } + }) + + itWithZsh.each([ + 'history', + 'startup', + 'chained-startup', + 'stock-history', + 'stock-startup', + 'stock-chained-redraw', + 'stock-nonzero-precmd', + 'ordered-startup', + 'replaced-precmd-startup', + 'status-startup', + 'sticky-startup', + 'scheduled-startup', + 'unavailable-startup', + 'repeat-history', + 'repeat-startup' + ] as const)( + 'restores CLI precedence and preserves the user widget in a %s pane', + async (intent) => { + const historyOnly = intent.endsWith('history') + const repeatSource = intent.startsWith('repeat-') + const stockBinding = intent.startsWith('stock-') + const chainedLineInit = intent === 'chained-startup' + const chainedRedraw = intent === 'stock-chained-redraw' + const nonzeroPrecmd = intent === 'stock-nonzero-precmd' + const orderedPrecmd = intent === 'ordered-startup' + const replacedPrecmd = intent === 'replaced-precmd-startup' + const statusPrecmd = intent === 'status-startup' + const stickyPrecmd = intent === 'sticky-startup' + const scheduledPrecmd = intent === 'scheduled-startup' + const unavailableSched = intent === 'unavailable-startup' + const scheduleCleanup = scheduledPrecmd || unavailableSched || orderedPrecmd + const home = mkdtempSync(join(tmpdir(), 'orca-deferred-line-init-')) + roots.push(home) + const cliBin = join(home, 'cli', 'bin') + const ambientBin = join(home, 'ambient-bin') + const wrapperDir = join(home, 'wrapper') + for (const bin of [cliBin, ambientBin, wrapperDir]) { + mkdirSync(bin, { recursive: true }) + } + for (const bin of [cliBin, ambientBin]) { + writeFileSync(join(bin, 'orca-dev'), '#!/bin/sh\nexit 0\n') + chmodSync(join(bin, 'orca-dev'), 0o755) + } + writeFileSync( + join(home, '.zshenv'), + (chainedLineInit || chainedRedraw || repeatSource + ? '' + : stockBinding + ? USER_REDRAW + : USER_WIDGET) + + (statusPrecmd + ? 'precmd() { O_FIRST_IN=${O_FIRST_IN:-$?}; }\n' + : stickyPrecmd + ? `emulate sh -c 'precmd() { O_FIRST_IN="\${O_FIRST_IN:-$?:\${options[shwordsplit]}:\${options[ksharrays]}}"; return 1; }'\n` + : scheduledPrecmd + ? 'zmodload zsh/sched\nO_EVENT() { O_EO=${_orca_deferred_init_done:-0}; }\nsched +0 O_EVENT\nsched +3600 O_EVENT\n' + : unavailableSched + ? 'zmodload zsh/zleparameter zsh/terminfo\nO_MP=("${module_path[@]}"); module_path=()\n' + : nonzeroPrecmd || replacedPrecmd + ? USER_PRECMD + : orderedPrecmd + ? USER_PRECMD.replace('return 1', 'return 0') + : '') + ) + if (statusPrecmd || stickyPrecmd) { + writeFileSync( + join(home, '.zlogin'), + 'orca_test_status() { return 42; }; orca_test_status\n' + ) + } + // Replay Ubuntu's later widget binding before the user's own startup changes. + const stockWidget = + stockBinding || stickyPrecmd || scheduledPrecmd + ? USER_WIDGET.replaceAll('orca_test_line_init', 'zle-line-init') + : '' + writeFileSync( + join(home, '.zshrc'), + `${statusPrecmd || stickyPrecmd ? 'PS1="ORCA_FIRST_PROMPT:%? "\n' : ''}${repeatSource ? USER_WIDGET : ''}${unavailableSched ? 'module_path=("${O_MP[@]}")\n' : ''}${stockWidget}export PATH="$HOME/ambient-bin:/usr/bin:/bin:$HOME/cli/bin"\n${orderedPrecmd ? '' : 'precmd_functions=()\n'}${ + chainedLineInit + ? `${USER_WIDGET.replace('zle -N zle-line-init orca_test_line_init', 'zle -N orca_test_line_init')}autoload -Uz add-zle-hook-widget\nadd-zle-hook-widget line-init orca_test_line_init\n` + : chainedRedraw + ? `${USER_REDRAW.replace('zle -N zle-line-pre-redraw orca_test_redraw', 'zle -N orca_test_redraw')}autoload -Uz add-zle-hook-widget\nadd-zle-hook-widget line-pre-redraw orca_test_redraw\n` + : '' + }${nonzeroPrecmd ? 'orca_test_array() { O_AC=called; }\nprecmd_functions=(orca_test_array)\nsetopt KSH_ARRAYS NO_UNSET\n' : orderedPrecmd ? 'orca_test_array() { O_AO=${O_AO:-${_orca_deferred_init_done:-0}}; }\nprecmd_functions=(orca_test_array "${precmd_functions[@]}")\n' : replacedPrecmd ? 'precmd() { O_NPC=$((${O_NPC:-0}+1)); O_NPN="$0"; }\n' : ''}` + ) + writeFileSync(join(wrapperDir, '.zshenv'), getZshShellReadyWrapperFile()) + writeFileSync(join(wrapperDir, ZSH_WRAPPER_DIR_MARKER_FILE), '') + const env: Record = { + ...process.env, + HOME: home, + USERPROFILE: home, + PATH: `${ambientBin}:/usr/bin:/bin`, + // Stock cases replay this replacement after the fixture installs its own widgets. + DEBIAN_PREVENT_KEYBOARD_CHANGES: '1', + ZDOTDIR: wrapperDir, + ORCA_HISTFILE: join(home, 'scoped-history') + } + const launcher = prependOrcaCliDirToChildPath(env, { isPackaged: false, userDataPath: home }) + const features = selectShellStartupFeatures({ + shellPath: ZSH_PATH, + env, + hasStartupCommand: !historyOnly, + waitsForShellReady: !historyOnly, + emitsStartupIdentity: false + }) + env.ORCA_SHELL_FEATURES = encodeShellStartupFeatures(features) + if (!historyOnly) { + env[POSIX_SHELL_STARTUP_COMMAND_ENV] = 'O_SU=$((${O_SU:-0}+1))' + } + + if (statusPrecmd || stickyPrecmd) { + const baseline = await runZshPty({ env: { ...env, ZDOTDIR: home }, report: ['O_FIRST_IN'] }) + expect(baseline.values.O_FIRST_IN).toBe(stickyPrecmd ? '42:on:on' : '42') + } + + const result = await runZshPty({ + env, + commands: [ + ...(repeatSource + ? ['source -- "$HOME/wrapper/.zshenv"', 'source -- "$HOME/wrapper/.zshenv"'] + : []), + 'O_LK=$(command -v orca-dev)', + 'O_IR=${+functions[__orca_deferred_line_init]}', + 'O_SR=${+widgets[__orca_saved_line_init]}', + ...(scheduleCleanup + ? [ + 'O_SC=${+functions[__orca_deferred_sched_init]}', + 'O_SE=${zsh_scheduled_events[*]:-UNSET}' + ] + : []), + ...(scheduledPrecmd ? ['O_EV=${#zsh_scheduled_events}'] : []), + ...(stockBinding ? ['O_RW=${widgets[zle-line-pre-redraw]:-none}'] : []), + 'O_LI=${widgets[zle-line-init]:-none}', + 'O_PC="${precmd_functions[*]}"', + ...(nonzeroPrecmd ? ['precmd; O_PS=$?'] : []) + ], + report: [ + 'O_LK', + 'O_UC', + 'O_UN', + 'O_IR', + 'O_SR', + ...(scheduleCleanup ? ['O_SC', 'O_SE'] : []), + ...(scheduledPrecmd ? ['O_EV', 'O_EO'] : []), + ...(stockBinding ? ['O_RW', 'O_RC', 'O_RN'] : []), + ...(nonzeroPrecmd ? ['O_PCALLS', 'O_PN', 'O_PO', 'O_AC', 'O_PS'] : []), + ...(orderedPrecmd ? ['O_PCALLS', 'O_PN', 'O_AO'] : []), + ...(replacedPrecmd ? ['O_PCALLS', 'O_NPC', 'O_NPN'] : []), + ...(statusPrecmd || stickyPrecmd ? ['O_FIRST_IN'] : []), + 'O_LI', + 'O_PC', + 'O_SU', + 'HISTFILE' + ] + }) + + expect(result.values.O_LK).toBe(launcher) + expect(Number(result.values.O_UC)).toBeGreaterThan(0) + if (!chainedLineInit) { + expect(result.values.O_UN).toBe('zle-line-init') + } + if (!chainedLineInit && !chainedRedraw && !repeatSource) { + expect(result.values.O_IR).toBe('0') + } + expect(result.values.O_SR).toBe('0') + if (repeatSource) { + expect(result.output).not.toContain('job table full or recursion limit exceeded') + expect(result.values.O_PC).not.toContain('__orca_deferred_init') + } + if (scheduleCleanup) { + expect(result.values.O_SC).toBe('0') + expect(result.values.O_SE).not.toContain('orca') + } + if (unavailableSched) { + // Stock completion modules can fail before the fixture restores module_path. + expect(result.output).not.toContain('zsh/sched') + expect(result.output).not.toContain('__orca_arm_deferred_line_init:') + expect(result.values.O_SE).toBe('UNSET') + } + if (scheduledPrecmd) { + expect(result.values.O_EV).toBe('1') + expect(result.values.O_EO).toBe('0') + expect(result.values.O_SE).toContain('O_EVENT') + } + if (stockBinding) { + expect(Number(result.values.O_RC)).toBeGreaterThan(0) + expect(result.values.O_RN).toBe(chainedRedraw ? 'orca_test_redraw' : 'zle-line-pre-redraw') + if (!chainedRedraw) { + expect(result.values.O_RW).toBe('user:orca_test_redraw') + } + } + expect(result.values.HISTFILE).toBe(join(home, 'scoped-history')) + if (nonzeroPrecmd) { + expect(Number(result.values.O_PCALLS)).toBeGreaterThan(0) + expect(result.values.O_PN).toBe('precmd') + expect(result.values.O_PO).toBe('on:on') + expect(result.values.O_AC).toBe('called') + expect(result.values.O_PS).toBe('1') + } + if (orderedPrecmd) { + expect(Number(result.values.O_PCALLS)).toBeGreaterThan(0) + expect(result.values.O_PN).toBe('precmd') + expect(result.values.O_AO).toBe('0') + } + if (replacedPrecmd) { + expect(result.values.O_PCALLS).toBe('UNSET') + expect(Number(result.values.O_NPC)).toBeGreaterThan(0) + expect(result.values.O_NPN).toBe('precmd') + } + if (statusPrecmd || stickyPrecmd) { + expect(result.values.O_FIRST_IN).toBe(stickyPrecmd ? '42:on:on' : '42') + expect(result.output).toContain('ORCA_FIRST_PROMPT:42 ') + } + if (historyOnly) { + expect(result.output).not.toContain('\x1b]133;') + expect(result.values.O_LI).toBe( + stockBinding ? 'user:zle-line-init' : 'user:orca_test_line_init' + ) + expect(result.values.O_PC).not.toContain('orca') + expect(result.values.O_SU).toBe('UNSET') + } else { + expect(result.output).toContain(MARKERS.ready) + expect(result.values.O_LI).toBe('user:__orca_prompt_mark') + expect(result.values.O_PC).toBe( + nonzeroPrecmd || orderedPrecmd + ? 'orca_test_array __orca_osc133_precmd' + : '__orca_osc133_precmd' + ) + expect(result.values.O_SU).toBe('1') + expect(result.output.split('ORCA_TEST_USER_WIDGET_CALL\r\n').length - 1).toBe( + result.output.split(MARKERS.ready).length - 1 + ) + } + } + ) +}) diff --git a/src/main/zsh-deferred-startup-line-init.ts b/src/main/zsh-deferred-startup-line-init.ts new file mode 100644 index 00000000000..95b9da94df6 --- /dev/null +++ b/src/main/zsh-deferred-startup-line-init.ts @@ -0,0 +1,56 @@ +// Why: stock zshrc can replace line-init after the user clears the prompt-hook array. +export const ZSH_DEFERRED_LINE_INIT_BLOCK = `__orca_deferred_line_init() { + builtin emulate -L zsh + (( \${+functions[__orca_deferred_init]} )) || return 0 + local __orca_direct_line_init=0 + [[ "\${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]] && __orca_direct_line_init=1 + __orca_deferred_init + if (( __orca_direct_line_init && \${+widgets[zle-line-init]} )); then + zle zle-line-init "$@" + elif [[ "\${widgets[zle-line-init]:-}" == user:__orca_prompt_mark ]]; then + local __orca_prev_line_init_fn="" + __orca_prompt_mark "$@" + fi +} +# Why: scheduled callbacks run after user prompt hooks without copying their function metadata. +__orca_deferred_sched_init() { + local __orca_prompt_status=$? + builtin emulate -L zsh + (( \${+functions[__orca_deferred_init]} )) && __orca_deferred_init + builtin unset __orca_deferred_sched_armed + builtin unfunction __orca_deferred_sched_init + return $__orca_prompt_status +} +__orca_arm_deferred_line_init() { + builtin emulate -L zsh + if [[ "\${widgets[zle-line-init]:-}" != user:__orca_deferred_line_init ]]; then + if (( \${+widgets[zle-line-init]} )); then + zle -A zle-line-init __orca_saved_line_init + fi + zle -N zle-line-init __orca_deferred_line_init + fi + if (( ! $+__orca_deferred_sched_armed )) && builtin zmodload -F zsh/sched b:sched 2>/dev/null; then + builtin sched +0 __orca_deferred_sched_init && builtin typeset -g __orca_deferred_sched_armed=1 + fi +}` + +// Why: restore the exact prior widget before the existing readiness hook captures it. +export const ZSH_DEFERRED_LINE_INIT_RETIRE_BLOCK = ` if (( \${+widgets[__orca_saved_line_init]} )); then + if [[ "\${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then + zle -A __orca_saved_line_init zle-line-init + fi + zle -D __orca_saved_line_init + elif [[ "\${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then + zle -D zle-line-init + fi` + +// Why: add-zle-hook-widget can keep an alias of the bootstrap in its own chain. +export const ZSH_DEFERRED_LINE_INIT_CLEANUP_BLOCK = ` (( $+__orca_deferred_sched_armed )) || builtin unfunction __orca_deferred_sched_init + local __orca_widget __orca_line_init_bound=0 + for __orca_widget in "\${(v)widgets[@]}"; do + if [[ "$__orca_widget" == user:__orca_deferred_line_init ]]; then + __orca_line_init_bound=1 + break + fi + done + (( __orca_line_init_bound )) || builtin unfunction __orca_deferred_line_init` diff --git a/src/main/zsh-scoped-histfile.live-shell.test.ts b/src/main/zsh-scoped-histfile.live-shell.test.ts index 68ee27d24e8..5c5020c96bc 100644 --- a/src/main/zsh-scoped-histfile.live-shell.test.ts +++ b/src/main/zsh-scoped-histfile.live-shell.test.ts @@ -333,25 +333,14 @@ describe.skipIf(process.platform === 'win32')( }) itWithZsh( - 'degrades to an unwrapped pane, leaking nothing, when a config drops precmd_functions', + 'still scopes history without leaking it when a config drops precmd_functions', withHome({ ...USER_FILES, '.zshrc': 'precmd_functions=()\n' }, async (home) => { const scoped = join(home, 'orca-history', 'zsh_history') const { env, launch } = launchPane(home, scoped) const report = ['HISTFILE', 'ORCA_HISTFILE', 'ZDOTDIR'] const { values } = await runZshPty({ env, report }) - // Why compared against an unwrapped run rather than asserted to differ - // from the scoped path: whether the scoped value survives at all is the - // host's call, not Orca's. macOS /etc/zshrc overwrites HISTFILE, so it - // does not; a host with no such assignment keeps whatever the spawn env - // set. The contract on both is the same — this pane is the pane the user - // would have had unwrapped. - const unwrapped = await runZshPty({ - env: { PATH: '/usr/bin:/bin', HOME: home, HISTFILE: scoped }, - report - }) - - expect(values.HISTFILE).toBe(unwrapped.values.HISTFILE) + expect(values.HISTFILE).toBe(scoped) expect(values.HISTFILE).not.toContain(launch.env.ZDOTDIR) // ORCA_HISTFILE was consumed in .zshenv precisely so a dropped hook // leaks nothing to the pane's children. diff --git a/src/main/zsh-startup-wrapper-builder.ts b/src/main/zsh-startup-wrapper-builder.ts index d6d6ef39a30..6ec28b647c2 100644 --- a/src/main/zsh-startup-wrapper-builder.ts +++ b/src/main/zsh-startup-wrapper-builder.ts @@ -1,3 +1,4 @@ +import { ORCA_CLI_POSIX_PATH_RESTORE } from '../shared/orca-cli-shell-path' import { MANAGED_DATA_ACCOUNT_POSIX_RESTORE } from '../shared/managed-data-account-shell' /** * The single `.zshenv` Orca writes for every transport: local PTY, daemon/SSH, @@ -14,7 +15,8 @@ import { MANAGED_DATA_ACCOUNT_POSIX_RESTORE } from '../shared/managed-data-accou * dir shared by two installed builds could mix files from both. * * This shape gives ZDOTDIR back before anything else can observe it, then defers - * Orca's work to a `precmd` hook that runs at the first prompt — after + * Orca's work to a `precmd` hook, with a one-shot line-editor fallback if the + * user's config replaces its hook array. Both run at the first prompt — after * `.zprofile`, `/etc/zshrc`, `.zshrc` and `.zlogin`, all of which zsh now reads * from the user's own directory exactly as in an unwrapped shell. #11044 becomes * unreachable rather than repaired, and the emulation and mixed-build classes @@ -30,6 +32,11 @@ import { MANAGED_DATA_ACCOUNT_POSIX_RESTORE } from '../shared/managed-data-accou import { getPosixOmpShellWrapper } from './pty/omp-shell-wrapper' import { WSL_MANAGED_CLI_PATH_RESTORE } from './wsl-managed-cli-path-restore' import { getPosixCodexShellLaunchPreflight } from '../shared/codex-shell-function' +import { + ZSH_DEFERRED_LINE_INIT_BLOCK, + ZSH_DEFERRED_LINE_INIT_CLEANUP_BLOCK, + ZSH_DEFERRED_LINE_INIT_RETIRE_BLOCK +} from './zsh-deferred-startup-line-init' import { getZshShellReadyMarkerRegistrationBlock, SHELL_STARTUP_IDENTITY_MARKER_BLOCK, @@ -146,6 +153,7 @@ function buildDeferredInit(spec: ZshStartupHookSpec): string { const permanentPrecmd = spec.osc133CommandMarkers ? ` if __orca_has_feature markers; then precmd_functions=(\${precmd_functions:/__orca_deferred_init/__orca_osc133_precmd}) + (( \${precmd_functions[(Ie)__orca_osc133_precmd]} )) || precmd_functions+=(__orca_osc133_precmd) preexec_functions=(__orca_osc133_preexec \${preexec_functions[@]}) else precmd_functions=(\${precmd_functions:#__orca_deferred_init}) @@ -170,9 +178,11 @@ ${indentBlock(getZshShellReadyMarkerRegistrationBlock(spec.readyMarkerEscaped, t (( $+_orca_deferred_init_done )) && return 0 builtin typeset -g _orca_deferred_init_done=1 builtin typeset -g precmd_functions +${ZSH_DEFERRED_LINE_INIT_RETIRE_BLOCK} ${permanentPrecmd} ${joinBlocks([ spec.restores.managedWslCli ? indentBlock(WSL_MANAGED_CLI_PATH_RESTORE, ' ') : null, + indentBlock(ORCA_CLI_POSIX_PATH_RESTORE, ' ').replace(/\n$/, ''), featureGuard('overlay', getOverlayRestoreBlocks(spec)), // Why outside the overlay guard: a system-default Codex home carries no overlay key. indentBlock(getPosixCodexShellLaunchPreflight(), ' ').replace(/\n$/, ''), @@ -190,7 +200,8 @@ ${ __orca_has_feature markers && __orca_osc133_precmd\n` : '' } builtin unset _orca_shell_features _orca_histfile - builtin unfunction __orca_deferred_init __orca_has_feature +${ZSH_DEFERRED_LINE_INIT_CLEANUP_BLOCK} + builtin unfunction __orca_deferred_init __orca_has_feature __orca_arm_deferred_line_init }` } @@ -201,6 +212,7 @@ export function buildZshStartupHook(spec: ZshStartupHookSpec): string { ZSH_FEATURE_CHANNEL_BLOCK, SHELL_STARTUP_IDENTITY_MARKER_BLOCK, spec.osc133CommandMarkers ? ZSH_OSC133_FUNCTION_BLOCK : null, + ZSH_DEFERRED_LINE_INIT_BLOCK, buildDeferredInit(spec), ZSH_USER_ZSHENV_SOURCE_BLOCK ])}\n` diff --git a/src/relay/pty-shell-overlay-wrappers.ts b/src/relay/pty-shell-overlay-wrappers.ts index c8d699f36be..82ad3a7fb85 100644 --- a/src/relay/pty-shell-overlay-wrappers.ts +++ b/src/relay/pty-shell-overlay-wrappers.ts @@ -1,6 +1,7 @@ import { readFileSync, statSync } from 'node:fs' import { join } from 'node:path' import { getPosixOmpShellWrapper } from '../main/pty/omp-shell-wrapper' +import { ORCA_CLI_POSIX_PATH_RESTORE } from '../shared/orca-cli-shell-path' import { getPosixCodexShellLaunchPreflight } from '../shared/codex-shell-function' import { BASH_FEATURE_CHANNEL_BLOCK, @@ -73,6 +74,7 @@ fi [[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" [[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}" [[ -n "\${ORCA_REMOTE_CLI_BIN_DIR:-}" ]] && case ":$PATH:" in *:"\${ORCA_REMOTE_CLI_BIN_DIR}":*) ;; *) export PATH="\${ORCA_REMOTE_CLI_BIN_DIR}:$PATH" ;; esac +${ORCA_CLI_POSIX_PATH_RESTORE} ${getPosixOmpShellWrapper()} ${getPosixCodexShellLaunchPreflight()}${BASH_HISTFILE_RESTORE_BLOCK} # Why: SSH bash sessions need the same command lifecycle markers as local diff --git a/src/shared/orca-cli-shell-path.ts b/src/shared/orca-cli-shell-path.ts new file mode 100644 index 00000000000..6bbe825a45e --- /dev/null +++ b/src/shared/orca-cli-shell-path.ts @@ -0,0 +1,6 @@ +export const ORCA_CLI_POSIX_PATH_RESTORE = `if [ -n "\${ORCA_CLI_BIN_DIR:-}" ]; then + case "\${PATH:-}" in + "$ORCA_CLI_BIN_DIR"|"$ORCA_CLI_BIN_DIR":*) ;; + *) export PATH="$ORCA_CLI_BIN_DIR\${PATH:+:$PATH}" ;; + esac +fi` From 8c617301f71b42e133f9e5fa44451d63e651dfc6 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 4 Oct 2026 03:15:12 -0700 Subject: [PATCH 09/31] fix(opencode): keep overlay manifest cleanup inside owned directories (#24763) * fix: wait for OpenCode worker composer before first dispatch Reuse captured composer readiness on local and paired execution hosts and revoke launching-shell paste anchors. Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> * feat(opencode): probe execution-host CLI capabilities * fix(opencode): select plugin default for execution host loader * fix(opencode): limit prompt prefill capability to verified release * feat(opencode): probe launch capabilities on the execution host * fix(opencode): select plugin loader for the launched host binary * fix(opencode): match WSL probe cwd and declared guest environment * fix(opencode): preserve launch environment deletion boundaries * wip(opencode): authorize native startup prompt intent at execution owner * fix(opencode): atomically replace status plugin entrypoints * fix(opencode): retain plugin permissions across restrictive umasks * test(opencode): resolve permission fixture from primary cwd * feat(opencode): install startup prompt plugin independently of status hooks * fix(opencode): wait for admitted startup intent and preserve failed-launch briefs * fix(opencode): confine overlay manifest cleanup to owned directories Co-authored-by: Adnan Khan * fix: wait for OpenCode worker composer before first dispatch Reuse captured composer readiness on local and paired execution hosts and revoke launching-shell paste anchors. Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> * feat(opencode): probe execution-host CLI capabilities * fix(opencode): select plugin default for execution host loader * fix(opencode): limit prompt prefill capability to verified release * feat(opencode): probe launch capabilities on the execution host * fix(opencode): select plugin loader for the launched host binary * fix(opencode): match WSL probe cwd and declared guest environment * fix(opencode): preserve launch environment deletion boundaries * wip(opencode): authorize native startup prompt intent at execution owner * fix(opencode): atomically replace status plugin entrypoints * fix(opencode): retain plugin permissions across restrictive umasks * test(opencode): resolve permission fixture from primary cwd * feat(opencode): install startup prompt plugin independently of status hooks * fix(opencode): wait for admitted startup intent and preserve failed-launch briefs * fix(opencode): unsubscribe hook settings during async host shutdown * STRICT launch CI contract correction * CAPS launch CI contract correction * INTENT launch CI contract correction * test: initialize Claude prompt state in output retention fixture * Wait for OpenCode location hydration in intent startup * Bind OpenCode startup readiness to the current location in intent startup --------- Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Co-authored-by: Ahmed Nagy Co-authored-by: Adnan Khan Co-authored-by: Orca startup hydration review --- .../hook-service-overlay-confinement.test.ts | 98 +++++++++++++++++++ src/main/opencode/hook-service.ts | 14 ++- .../opencode-overlay-manifest.test.ts | 32 ++++++ src/main/pty/overlay-mirror.test.ts | 29 +++++- src/main/pty/overlay-mirror.ts | 35 ++++++- 5 files changed, 202 insertions(+), 6 deletions(-) create mode 100644 src/main/opencode/hook-service-overlay-confinement.test.ts create mode 100644 src/main/opencode/opencode-overlay-manifest.test.ts diff --git a/src/main/opencode/hook-service-overlay-confinement.test.ts b/src/main/opencode/hook-service-overlay-confinement.test.ts new file mode 100644 index 00000000000..46a5bee5884 --- /dev/null +++ b/src/main/opencode/hook-service-overlay-confinement.test.ts @@ -0,0 +1,98 @@ +import { + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + symlinkSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, dirname, join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { setAppEnvironment } from '../../shared/app-environment' +import { safeRemoveTree } from '../pty/overlay-mirror' +import { OpenCodeHookService } from './hook-service' +import { OPENCODE_OVERLAY_MANIFEST_FILE } from './opencode-overlay-manifest' + +let root: string +let source: string +let service: OpenCodeHookService + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orca-overlay-confinement-')) + source = join(root, 'source') + mkdirSync(join(source, 'plugins'), { recursive: true }) + writeFileSync(join(source, 'plugins', 'user.js'), 'export default {}') + vi.stubEnv('XDG_CONFIG_HOME', join(root, 'xdg')) + setAppEnvironment({ + getPath: () => join(root, 'profile'), + getAppPath: () => process.cwd(), + getVersion: () => '0.0.0-test', + isPackaged: () => false, + onWillQuit: () => {}, + exit: () => {}, + getAppMetrics: () => [] + }) + service = new OpenCodeHookService({ + pluginFileName: 'orca-test.js', + legacyHooksDir: 'legacy-test', + overlayDir: 'overlay-test', + pluginSource: () => 'export default {}' + }) +}) + +afterEach(() => { + vi.unstubAllEnvs() + rmSync(root, { recursive: true, force: true }) +}) + +it.each(['overlay-root', 'source-overlay', 'plugins'] as const)( + 'preserves outside files when the owned %s is replaced by a directory link', + (boundary) => { + const overlay = service.buildPtyEnv('pane-1', source).OPENCODE_CONFIG_DIR + if (!overlay) { + throw new Error('Expected an isolated overlay') + } + const outside = join(root, 'outside') + const externalOverlay = boundary === 'overlay-root' ? join(outside, basename(overlay)) : outside + mkdirSync(join(externalOverlay, 'plugins'), { recursive: true }) + const sentinel = join(externalOverlay, 'plugins', 'keep.js') + writeFileSync(sentinel, 'export const keep = true') + writeFileSync( + join(externalOverlay, OPENCODE_OVERLAY_MANIFEST_FILE), + JSON.stringify({ topLevelEntries: [], pluginEntries: ['keep.js'] }) + ) + const replaced = + boundary === 'overlay-root' + ? dirname(overlay) + : boundary === 'source-overlay' + ? overlay + : join(overlay, 'plugins') + const target = boundary === 'plugins' ? join(outside, 'plugins') : outside + if (boundary === 'plugins') { + writeFileSync( + join(overlay, OPENCODE_OVERLAY_MANIFEST_FILE), + JSON.stringify({ topLevelEntries: [], pluginEntries: ['keep.js'] }) + ) + } + safeRemoveTree(replaced) + symlinkSync(target, replaced, process.platform === 'win32' ? 'junction' : 'dir') + const before = readdirSync(outside, { recursive: true }).toSorted() + + const result = service.buildPtyEnv('pane-2', source) + expect(readFileSync(sentinel, 'utf8')).toBe('export const keep = true') + expect(readdirSync(outside, { recursive: true }).toSorted()).toEqual(before) + expect(result).toEqual({ OPENCODE_CONFIG_DIR: source }) + } +) + +it('still removes a stale mirrored entry from a real owned overlay', () => { + const overlay = service.buildPtyEnv('pane-1', source).OPENCODE_CONFIG_DIR + if (!overlay) { + throw new Error('Expected an isolated overlay') + } + rmSync(join(source, 'plugins', 'user.js')) + expect(service.buildPtyEnv('pane-2', source)).toEqual({ OPENCODE_CONFIG_DIR: overlay }) + expect(readdirSync(join(overlay, 'plugins'))).toEqual(['orca-test.js']) +}) diff --git a/src/main/opencode/hook-service.ts b/src/main/opencode/hook-service.ts index 9162499472f..1461b8773dc 100644 --- a/src/main/opencode/hook-service.ts +++ b/src/main/opencode/hook-service.ts @@ -11,7 +11,7 @@ import { writeFileSync } from 'node:fs' import { createHash } from 'node:crypto' -import { isSafeDescendCandidate, mirrorEntry, safeRemoveTree } from '../pty/overlay-mirror' +import { isSafeDescendCandidate, mirrorEntry, safeRemoveOverlay } from '../pty/overlay-mirror' import { getOpenCode2PluginSource, getOpenCodeFamilyPluginSource, @@ -141,7 +141,13 @@ export class OpenCodeHookService { } const overlayDir = this.getSourceOverlayDir(existingConfigDir) try { - mkdirSync(overlayDir, { recursive: true }) + // Owned directories must stay real; a replaced parent redirects both cleanup and writes. + for (const directory of [this.getOverlayRoot(), overlayDir, join(overlayDir, 'plugins')]) { + mkdirSync(directory, { recursive: true }) + if (!isSafeDescendCandidate(lstatSync(directory))) { + return { OPENCODE_CONFIG_DIR: existingConfigDir } + } + } if (existsSync(existingConfigDir)) { this.mirrorUserConfig(existingConfigDir, overlayDir) } @@ -230,7 +236,7 @@ export class OpenCodeHookService { private clearManifestEntries(overlayDir: string, manifest: OpenCodeOverlayManifest): void { for (const entryName of manifest.topLevelEntries) { - safeRemoveTree(join(overlayDir, entryName)) + safeRemoveOverlay(join(overlayDir, entryName), overlayDir) } const overlayPluginsDir = join(overlayDir, 'plugins') @@ -238,7 +244,7 @@ export class OpenCodeHookService { if (entryName === this.pluginFileName) { continue } - safeRemoveTree(join(overlayPluginsDir, entryName)) + safeRemoveOverlay(join(overlayPluginsDir, entryName), overlayPluginsDir) } } diff --git a/src/main/opencode/opencode-overlay-manifest.test.ts b/src/main/opencode/opencode-overlay-manifest.test.ts new file mode 100644 index 00000000000..7dc0404a53e --- /dev/null +++ b/src/main/opencode/opencode-overlay-manifest.test.ts @@ -0,0 +1,32 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, it } from 'vitest' +import { + readOpenCodeOverlayManifest, + OPENCODE_OVERLAY_MANIFEST_FILE +} from './opencode-overlay-manifest' + +it('accepts only string manifest entries and tolerates invalid persisted shapes', () => { + const root = mkdtempSync(join(tmpdir(), 'orca-overlay-manifest-')) + try { + const path = join(root, OPENCODE_OVERLAY_MANIFEST_FILE) + for (const text of ['null', '1', '{']) { + writeFileSync(path, text) + expect(readOpenCodeOverlayManifest(root)).toEqual({ topLevelEntries: [], pluginEntries: [] }) + } + writeFileSync( + path, + JSON.stringify({ + topLevelEntries: ['valid', 1, null], + pluginEntries: [{ bad: true }, 'plugin.js'] + }) + ) + expect(readOpenCodeOverlayManifest(root)).toEqual({ + topLevelEntries: ['valid'], + pluginEntries: ['plugin.js'] + }) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) diff --git a/src/main/pty/overlay-mirror.test.ts b/src/main/pty/overlay-mirror.test.ts index 5499d226422..1a741552719 100644 --- a/src/main/pty/overlay-mirror.test.ts +++ b/src/main/pty/overlay-mirror.test.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, rmSync, writeFileSync, symlinkSync } from 'node:fs' import { mkdtemp } from 'node:fs/promises' import { tmpdir } from 'node:os' import type * as NodePath from 'node:path' @@ -19,6 +19,33 @@ afterEach(() => { }) describe('safeRemoveOverlay', () => { + it('keeps missing owned paths a silent no-op', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-overlay-missing-')) + tempRoots.push(root) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + safeRemoveOverlay(join(root, 'missing', 'leaf'), join(root, 'missing')) + expect(warn).not.toHaveBeenCalled() + }) + + it('refuses cleanup through an intermediate symlink and unlinks only a leaf symlink', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-overlay-symlink-')) + tempRoots.push(root) + const overlay = join(root, 'overlay') + const outside = join(root, 'outside') + mkdirSync(overlay) + mkdirSync(outside) + const sentinel = join(outside, 'keep.txt') + writeFileSync(sentinel, 'private sentinel') + const linked = join(overlay, 'linked') + symlinkSync(outside, linked, process.platform === 'win32' ? 'junction' : 'dir') + vi.spyOn(console, 'warn').mockImplementation(() => {}) + safeRemoveOverlay(join(linked, 'keep.txt'), overlay) + expect(existsSync(sentinel)).toBe(true) + safeRemoveOverlay(linked, overlay) + expect(existsSync(linked)).toBe(false) + expect(existsSync(sentinel)).toBe(true) + }) + it('removes valid overlay children whose names start with dot-dot', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-overlay-root-')) tempRoots.push(root) diff --git a/src/main/pty/overlay-mirror.ts b/src/main/pty/overlay-mirror.ts index ec1b99d2b92..6a35844f871 100644 --- a/src/main/pty/overlay-mirror.ts +++ b/src/main/pty/overlay-mirror.ts @@ -138,6 +138,33 @@ export function safeRemoveTree(path: string): void { } } +// Why: cleanup must not traverse a symlink parent inside the owned overlay. +function hasSafeOverlayAncestors(root: string, relativeTarget: string): boolean { + let current = root + try { + if (lstatSync(current).isSymbolicLink()) { + return false + } + } catch (error) { + return !!error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT' + } + const segments = relativeTarget.split(sep).filter(Boolean) + for (const [index, segment] of segments.entries()) { + current = join(current, segment) + if (index === segments.length - 1) { + break + } + try { + if (lstatSync(current).isSymbolicLink()) { + return false + } + } catch (error) { + return !!error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT' + } + } + return true +} + // Why: last-line guard against an overlay-root constant ever being // mis-resolved. Any caller that points safeRemoveTree at a path outside its // designated overlay root is refused so a misconfiguration cannot turn into @@ -147,7 +174,13 @@ export function safeRemoveOverlay(overlayDir: string, overlayRoot: string): void const resolvedRoot = resolve(overlayRoot) const resolvedTarget = resolve(overlayDir) const rel = relative(resolvedRoot, resolvedTarget) - if (rel === '' || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) { + if ( + rel === '' || + rel === '..' || + rel.startsWith(`..${sep}`) || + isAbsolute(rel) || + !hasSafeOverlayAncestors(resolvedRoot, rel) + ) { console.warn( `[overlay-mirror] refusing to remove overlay outside root: target=${resolvedTarget} root=${resolvedRoot}` ) From 87bc51d3710332ea8b0f7f0e5610a31413a79582 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 4 Oct 2026 04:11:03 -0700 Subject: [PATCH 10/31] Reduce test deadline waits and exact byte comparison costs (#25187) --- docs/reference/ci-runner-efficiency.md | 105 ++++++++ .../managed-hook-script-refresh.test.ts | 7 + .../antigravity/native-account-store.test.ts | 6 +- ...t-session-claude-stop-ends-session.test.ts | 44 +++- ...gent-session-stop-deadline.test-fixture.ts | 89 +++++++ .../transcript-opencode-subscribe.test.ts | 100 +++++--- ...ook-plugin-opencode2-tui-ownership.test.ts | 232 +++++++++++++----- .../profile-state-sqlite-authority.test.ts | 8 +- .../profile-state-store-backups.test.ts | 6 +- .../profile-state-authority-bootstrap.test.ts | 8 +- .../profile-state-backup-worker.test.ts | 4 +- .../profile-state-database-snapshot.test.ts | 8 +- .../profile-state-database.test.ts | 14 +- ...er-circle-title-send-authorization.test.ts | 82 ++++++- ...-session-codex-turn-end-settlement.test.ts | 75 +++++- .../ssh/ssh-relay-gc-listing.test-fixture.ts | 148 +++++++++++ src/main/ssh/ssh-remote-commands.test.ts | 43 +++- .../attach-main-window-services.test.ts | 132 +++++----- .../main-window-service-stubs.test-fixture.ts | 80 ++++++ ...fCommentDecorator.range-selection.test.tsx | 16 +- ...tiveChatStructuredSessionDelivery.test.tsx | 120 +++++---- 21 files changed, 1050 insertions(+), 277 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-stop-deadline.test-fixture.ts create mode 100644 src/main/ssh/ssh-relay-gc-listing.test-fixture.ts create mode 100644 src/main/window/main-window-service-stubs.test-fixture.ts diff --git a/docs/reference/ci-runner-efficiency.md b/docs/reference/ci-runner-efficiency.md index f2dc21aa66c..00d1d0cf39f 100644 --- a/docs/reference/ci-runner-efficiency.md +++ b/docs/reference/ci-runner-efficiency.md @@ -46,6 +46,111 @@ used 42 aggregate runner-minutes across 11 test jobs. The estimates 34.9 headless runner-hours, including 23.4 in cancelled runs. These are baseline observations; post-merge savings have not yet been measured. +## October 4 clock, byte and import test fixtures + +These changes retain production behavior, original case names and platform +outcomes. The paired pilots use three alternating one-worker Node 24 invocations +on Ubuntu 24 ARM. Every median below is a complete focused test invocation; +they do not establish whole-shard savings or queue-delay improvements. + +| Workload | Baseline median | Candidate median | Reduction | Hosted evidence | +| ---------------------------------------------------- | --------------- | ---------------- | --------- | ------------------------------------------------------------------------ | +| Codex settlement and Claude stop deadlines, 35 cases | 35.914s | 10.142s | 71.8% | [37186232658](https://github.com/stablyai/orca/actions/runs/37186232658) | +| Native-chat delivery, 15 cases | 22.321s | 7.376s | 67.0% | [37183731823](https://github.com/stablyai/orca/actions/runs/37183731823) | +| Six profile-storage byte suites, 118 cases | 12.629s | 9.978s | 21.0% | [37183141654](https://github.com/stablyai/orca/actions/runs/37183141654) | +| Encrypted account storage, six cases | 18.277s | 0.958s | 94.8% | [37184007241](https://github.com/stablyai/orca/actions/runs/37184007241) | +| SSH remote commands, 27 cases | 6.840s | 6.078s | 11.1% | [37184454532](https://github.com/stablyai/orca/actions/runs/37184454532) | +| OpenCode subscription, 28 cases | 47.347s | 6.284s | 86.7% | [37184858823](https://github.com/stablyai/orca/actions/runs/37184858823) | +| Window-service attachment, 31 cases | 11.910s | 1.619s | 86.4% | [37186340840](https://github.com/stablyai/orca/actions/runs/37186340840) | +| OpenCode 2 TUI ownership, 41 cases | 16.811s | 2.429s | 85.6% | [37187699312](https://github.com/stablyai/orca/actions/runs/37187699312) | +| Title-send authorization, nine cases | 29.545s | 12.995s | 56.0% | [37188423318](https://github.com/stablyai/orca/actions/runs/37188423318) | +| Range selection, 15 cases | 9.737s | 7.130s | 26.8% | [37188996198](https://github.com/stablyai/orca/actions/runs/37188996198) | + +Provider tests wait for the real fake-child write/ready barrier and drain the +host stream before installing a scoped parent clock. The original 2.5/5-second +Codex and 3-second Claude deadlines remain; before/at assertions check their +boundaries. Early rejection and refusal resolve without waiting on an unreachable +barrier; finally and suite teardown restore clocks and spies even after a native +Vitest timeout. Four healthy failure-path controls pass; old-helper hangs and +removed teardown are caught. Missing-child failure retains a real ten-second observation window. +Six faults for early/late stop deadlines, late queued resend and missing child +output fail the intended assertions. Native-chat tests still use the real React +outbox hooks. Their scoped clock retains probe, retry, churn and target-switch +windows, drains async act work, and unmounts before clock restoration. All eight +hook faults fail; two boundary faults pass the old coarse tests and fail the new +before/at assertions. Node/web typecheck, lint and formatting pass. + +Native Buffer.equals replaces deep per-byte assertion traversal. It checks the +complete original bytes and length; fixtures, SQLite operations, encryption and +processes are unchanged. The six profile suites retain 114 passes and four +existing Linux case-sensitivity skips; all 118 pass locally on macOS. Seven +profile last-byte/length faults and two encrypted-vault last-byte/length faults +fail their exact byte assertions. All six encrypted-vault cases still exercise +52 accounts near the 4 MiB encrypted cap, refused growth, restart/readback and +private permissions. + +The old SSH fixture created 15,197 short stage paths but never exceeded the real +1,048,576 UTF-16-character transport tail cap; its two valid entries also left +the 64-result assertion vacuous. The replacement uses about 1,300 real excluded +stage directories under long Unicode path components, a real shell channel and +the production execCommand limiter. Actual find output exceeds that cap, while +the generated filter retains both original valid entries. A separate population +of 65 valid directories proves the first-64 limit and native find ordering. File, +symlink, nested-install and failed-enumeration checks remain. All 27 case outcomes +match across treatments (23 passes and four unavailable PowerShell 5.1 skips on +the hosted image). Eight cap, ordering, filtering and failure faults are caught. +Paths use platform utilities; local PowerShell availability retains its original +skip policy. The deadline and SSH fixtures reuse existing process/stream code. +OpenCode subscription tests batch only uninterrupted fixture writes between the +original observation barriers. Both SQLite schema versions keep every row, rowid, +read cap, poll, clock position and case. No durability pragma or production code +changes. All 28 cases pass in every pair. Separate captures compare ordered +schema and rows, transaction state, pragmas, signals, page requests/results and +subscriber callbacks: 124,754,101 payload bytes match, canonical digest +`ba0eb6f09281746071d73fae88e2e8eb45332f36892b90998b374b1b8c59b3e3`. +Missing rows, collapsed frontier rowids, read-cap overruns, missing commit and +missing rollback each fail the intended case in both schemas. Positive rollback +controls pass. The unmeasured full-suite timing report ranked this fixture at +134.229 seconds; the paired 47.347-second figure above is the relevant focused +baseline, and the two figures must not be mixed into a claimed saving. + +Window attachment tests mock five unrelated registrar modules using their actual +types. The existing window ownership, reload, media permission, native file drop, +hydration barrier and updater scheduling cases remain real. Exact store/runtime/ +window arguments and daemon registration after the PTY handler are now asserted; +nine actual production wiring/order faults fail. The registrar implementations +retain separate handler tests. Concrete existing stubs moved to one fixture to +stay within the line limit. All 31 cases and statuses match across all pairs. The +final source differs from the timed source only by a required type-assertion +safety comment. +OpenCode 2 TUI tests use a scoped async clock only after the first real native +module import. The unchanged generated plugin runs against the existing fake TUI +and fetch. Original poll, permission, retry, preview, slow POST and endpoint +windows remain. Before/at assertions pin the 100 ms poll, 500 ms permission, +120 ms slow POST and 5-second endpoint boundaries. All 41 original case outcomes +match. Separate ordered captures preserve 678 TUI/event rows and 362 complete +POST start/completion rows; wall timestamps and cross-stream interleaving are +excluded from equivalence. Eleven generated-source faults fail their intended +identity, reload, order, deadline or timer-cleanup assertions. Four import/setup/ +disposer rejection and timeout controls confirm restoration of clocks, fetch, +argv and environment. Teardown restores real clocks before its bounded wait. +Title-send authorization tests retain real terminal creation, graph binding and +positive evidence paths. Negative process-evidence probes use scoped clocks +after setup: both 150 ms polling loops retain their 6,500 ms budget, crossed at +6,600 ms, and the send guard retains its 1,050 ms deadline. Before/at assertions +check 6,599/6,600 and 1,049/1,050 ms. All nine original cases remain. Actual +early/late wrapper and guard deadlines, false spinner identity and unknown-agent +authorization faults fail their intended assertions; native clocks and runtime +instance spies are restored after each failure. +Range-selection tests stub only the saved-note send menu, which their empty +comment populations never render. A facade typed from the actual menu props +throws if invoked, and an afterEach assertion verifies no call with unconditional +mock clearing in finally. The real hook, Monaco constants/model, line and range +drag behavior, draft-card lifecycle and open-inline-card chord remain. All 15 +original test bodies are byte-unchanged. Three actual range/hunk/draft faults +fail their original assertions; a real draft-render menu call hits the facade +and sentinel, and an outer cleanup check proves mock state cleared after failure. +The actual saved-note menu retains its independent component tests. ## October 2 headless detector compiler cache The deferred detector already avoids dependency setup for known build inputs. diff --git a/src/main/agent-hooks/managed-hook-script-refresh.test.ts b/src/main/agent-hooks/managed-hook-script-refresh.test.ts index 4cd1905d8aa..30fe4a658a9 100644 --- a/src/main/agent-hooks/managed-hook-script-refresh.test.ts +++ b/src/main/agent-hooks/managed-hook-script-refresh.test.ts @@ -147,8 +147,10 @@ describe('managed hook script refresh', () => { homedirMock.mockReturnValue(home) const previousGrokHome = process.env.GROK_HOME const previousKimiHome = process.env.KIMI_CODE_HOME + const previousXdgConfigHome = process.env.XDG_CONFIG_HOME delete process.env.GROK_HOME delete process.env.KIMI_CODE_HOME + delete process.env.XDG_CONFIG_HOME try { await withPlatform('win32', () => { for (const [, install] of MANAGED_AGENT_HOOK_INSTALLERS) { @@ -187,6 +189,11 @@ describe('managed hook script refresh', () => { } else { process.env.KIMI_CODE_HOME = previousKimiHome } + if (previousXdgConfigHome === undefined) { + delete process.env.XDG_CONFIG_HOME + } else { + process.env.XDG_CONFIG_HOME = previousXdgConfigHome + } rmSync(home, { recursive: true, force: true }) } }) diff --git a/src/main/antigravity/native-account-store.test.ts b/src/main/antigravity/native-account-store.test.ts index f6d3398c679..9fbd8ab183f 100644 --- a/src/main/antigravity/native-account-store.test.ts +++ b/src/main/antigravity/native-account-store.test.ts @@ -67,11 +67,11 @@ describe('protected Antigravity account snapshots', () => { const service = new AntigravityAccountService(store, h.backend) h.setNative(paddedCredential('new-account', 60000)) await expect(service.addCurrentAccount()).rejects.toThrow('could not be saved') - expect(readFileSync(path)).toEqual(before) + expect(readFileSync(path).equals(before)).toBe(true) expect(store.read().accounts).toHaveLength(52) h.setNative(paddedCredential('large-51', 65000)) await expect(service.listAccounts()).rejects.toThrow('could not be saved') - expect(readFileSync(path)).toEqual(before) + expect(readFileSync(path).equals(before)).toBe(true) expect(store.read().accounts).toHaveLength(52) }) @@ -115,7 +115,7 @@ describe('protected Antigravity account snapshots', () => { 'Protected secret storage' ) expect(() => store.read()).toThrow('Protected secret storage') - expect(readFileSync(path)).toEqual(before) + expect(readFileSync(path).equals(before)).toBe(true) } ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts index ddef0ed0bb6..9c920b45eb3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts @@ -32,6 +32,7 @@ import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-rec import { structuredClaudeLifecycleEvent } from '../../runtime/structured-claude-runtime-adapter' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { createStoppedClaudeDeadline } from './structured-agent-session-stop-deadline.test-fixture' import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' import { HOST_TEST_NOW as NOW, @@ -133,6 +134,8 @@ beforeEach(async () => { }) afterEach(async () => { + vi.restoreAllMocks() + vi.useRealTimers() await adapter.closeAll() await host.flushAllStreamedEvents() await rm(root, { recursive: true, force: true }) @@ -214,6 +217,18 @@ function stop(turnId?: string) { return host.cancel(CALLER, { envelope: envelope('agentSession.cancel', fields), ...fields }) } +/** Resolves once everything queued on the session's lane so far has run: a Stop's second step. */ +const laneDrained = (): Promise => host['tasks'].serialize(SESSION, async () => {}) + +const stopAcrossGrace = createStoppedClaudeDeadline({ + host: () => host, + adapter: () => adapter, + claude: () => claude, + sessionId: SESSION, + stop, + laneDrained +}) + /** How many person's Stop events the journal holds when the child's close begins. */ function stopEventsAtClose(connection: FakeConnection): () => number | undefined { let atClose: number | undefined @@ -231,11 +246,6 @@ function stopEventsAtClose(connection: FakeConnection): () => number | undefined return () => atClose } -/** Resolves once everything queued on the session's lane so far has run: a Stop's second step. */ -function laneDrained(): Promise { - return host['tasks'].serialize(SESSION, async () => {}) -} - function wrote(connection: FakeConnection, text: string): boolean { return connection.sent.some((message) => JSON.stringify(message).includes(text)) } @@ -369,14 +379,15 @@ it('ends the child once the grace runs out when Claude says nothing after a Stop claude.routes.interrupt = () => ({ still_queued: [], cancelled: [] }) const clientMessageId = await sendUnechoed(connection) - const asked = Date.now() - await expect(stop()).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) + await expect(stopAcrossGrace(connection, 'request-end')).resolves.toMatchObject({ + ok: true, + value: { cancelled: true } + }) await laneDrained() // As before the wait: the send Claude never answered is doubt once its child ends. expect(connection.closed).toBe(true) expect(eventsAtClose()).toBe(1) - expect(Date.now() - asked).toBeLessThan(CLAUDE_STOP_GRACE_MS + 1_500) expect(await dispatch(clientMessageId)).toMatchObject({ state: 'unknown' }) }, 15_000) @@ -447,13 +458,14 @@ it('ends the child within the grace when Claude never answers the interrupt', as const eventsAtClose = stopEventsAtClose(connection) await openTurn(connection) - const asked = Date.now() - await expect(stop()).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) + await expect(stopAcrossGrace(connection, 'interrupt')).resolves.toMatchObject({ + ok: true, + value: { cancelled: true } + }) await laneDrained() expect(connection.closed).toBe(true) expect(eventsAtClose()).toBe(1) - expect(Date.now() - asked).toBeLessThan(CLAUDE_STOP_GRACE_MS + 1_500) expect(await turnOutcome()).toBe('cancellation') expect(await statusTexts()).toEqual(['Cancellation requested.']) }, 15_000) @@ -672,7 +684,15 @@ it.each([ await eventually(() => expect(wrote(connection, 'Follow-up.')).toBe(true)) // As the phone sends it: the turn it last saw working. - await expect(stop(ended)).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) + await expect( + _answer === 'fails' + ? stop(ended) + : stopAcrossGrace( + connection, + interrupt === NEVER_ANSWERS ? 'interrupt' : 'request-end', + ended + ) + ).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) await laneDrained() expect(connection.calls.some((call) => call.subtype === 'interrupt')).toBe(true) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-deadline.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-deadline.test-fixture.ts new file mode 100644 index 00000000000..4d33a998a59 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-deadline.test-fixture.ts @@ -0,0 +1,89 @@ +import { expect, vi } from 'vitest' +import { CLAUDE_STOP_GRACE_MS } from '../../claude/claude-request-end-wait' +import type { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' +import type { + fakeClaude, + FakeConnection +} from '../../claude/claude-structured-session-test-support' +import type { StructuredAgentSessionHost } from './structured-agent-session-host' + +export function createStoppedClaudeDeadline(deps: { + host: () => Pick + adapter: () => Pick + claude: () => ReturnType + sessionId: string + stop: (turnId?: string) => ReturnType + laneDrained: () => Promise +}) { + return async ( + connection: FakeConnection, + deadline: 'interrupt' | 'request-end', + turnId?: string + ) => { + await deps.host().flushStreamedEvents(deps.sessionId) + const reachedDeadline = Promise.withResolvers< + Awaited> | undefined + >() + let deadlineSettled = false + const claude = deps.claude() + const adapter = deps.adapter() + const interrupt = claude.routes.interrupt + const requestEnd = adapter.awaitStoppedRequestEnd + const waiting = vi + .spyOn(adapter, 'awaitStoppedRequestEnd') + .mockImplementation((sessionId, at) => { + const pending = requestEnd(sessionId, at) + reachedDeadline.resolve(undefined) + return pending.then(() => { + deadlineSettled = true + }) + }) + if (deadline === 'interrupt') { + claude.routes.interrupt = (params) => { + const pending = interrupt?.(params) + if (!(pending instanceof Promise)) { + throw new Error('Expected an unanswered interrupt promise') + } + void pending.then( + () => { + deadlineSettled = true + }, + () => { + deadlineSettled = true + } + ) + reachedDeadline.resolve(undefined) + return pending + } + } + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }) + try { + const asked = Date.now() + const stopping = deps.stop(turnId) + void stopping.then((result) => { + if (!result.ok || !result.value.cancelled) { + reachedDeadline.resolve(result) + } + }, reachedDeadline.reject) + const early = await reachedDeadline.promise + if (early !== undefined) { + return early + } + await vi.advanceTimersByTimeAsync(CLAUDE_STOP_GRACE_MS - 1) + expect(deadlineSettled).toBe(false) + expect(connection.closed).toBe(false) + await vi.advanceTimersByTimeAsync(1) + expect(deadlineSettled).toBe(true) + const result = await stopping + await deps.laneDrained() + expect(Date.now() - asked).toBeLessThan(CLAUDE_STOP_GRACE_MS + 1_500) + return result + } finally { + waiting.mockRestore() + if (interrupt) { + claude.routes.interrupt = interrupt + } + vi.useRealTimers() + } + } +} diff --git a/src/main/native-chat/transcript-opencode-subscribe.test.ts b/src/main/native-chat/transcript-opencode-subscribe.test.ts index b8e0fb01e78..cce4b243cf8 100644 --- a/src/main/native-chat/transcript-opencode-subscribe.test.ts +++ b/src/main/native-chat/transcript-opencode-subscribe.test.ts @@ -82,9 +82,21 @@ function watchFixture( db.prepare('DELETE FROM session_message WHERE id = ?').run(String(index)) } } - for (let index = 1; index <= messageCount; index++) { - insert(index, hiddenFirst && index === 1 ? '' : undefined) + const batch = (mutate: () => void) => { + db.exec('BEGIN') + try { + mutate() + db.exec('COMMIT') + } catch (error) { + db.exec('ROLLBACK') + throw error + } } + batch(() => { + for (let index = 1; index <= messageCount; index++) { + insert(index, hiddenFirst && index === 1 ? '' : undefined) + } + }) let displayed: NativeChatMessage[] = [] const onReplace = vi.fn((messages: NativeChatMessage[]) => { displayed = messages @@ -115,7 +127,17 @@ function watchFixture( } ) fixtures.push({ db, root, stop: subscription.unsubscribe }) - return { db, insert, update, remove, displayed: () => displayed, onReplace, onAppend, readPage } + return { + db, + insert, + update, + remove, + batch, + displayed: () => displayed, + onReplace, + onAppend, + readPage + } } describe.each(['v1', 'v2'] as const)('OpenCode %s watch reconciliation', (version) => { @@ -123,8 +145,10 @@ describe.each(['v1', 'v2'] as const)('OpenCode %s watch reconciliation', (versio const f = watchFixture(version, false, 1000) await vi.advanceTimersByTimeAsync(0) expect(f.displayed()).toHaveLength(300) - f.remove(701) - f.insert(1001) + f.batch(() => { + f.remove(701) + f.insert(1001) + }) await vi.advanceTimersByTimeAsync(10) expect(f.displayed()).toHaveLength(300) expect(f.displayed()[0]?.blocks).toEqual([{ type: 'text', text: 'message 702' }]) @@ -170,9 +194,11 @@ describe.each(['v1', 'v2'] as const)('OpenCode %s watch reconciliation', (versio it('replaces an empty session and permits a reused provider cursor to append', async () => { const f = watchFixture(version) await vi.advanceTimersByTimeAsync(0) - for (let index = 1; index <= 300; index++) { - f.remove(index) - } + f.batch(() => { + for (let index = 1; index <= 300; index++) { + f.remove(index) + } + }) await vi.advanceTimersByTimeAsync(10) expect(f.displayed()).toEqual([]) f.insert(1, 'after revert') @@ -194,9 +220,11 @@ describe.each(['v1', 'v2'] as const)('OpenCode %s watch reconciliation', (versio it('continues checking previously appended history and bounds every read', async () => { const f = watchFixture(version) await vi.advanceTimersByTimeAsync(0) - for (let index = 301; index <= 450; index++) { - f.insert(index) - } + f.batch(() => { + for (let index = 301; index <= 450; index++) { + f.insert(index) + } + }) await vi.advanceTimersByTimeAsync(10) f.update(1, 'edited after append') await vi.advanceTimersByTimeAsync(10) @@ -210,9 +238,11 @@ describe.each(['v1', 'v2'] as const)('OpenCode %s watch reconciliation', (versio it('holds the frontier and retries when a burst cannot bridge within the read cap', async () => { const f = watchFixture(version) await vi.advanceTimersByTimeAsync(0) - for (let index = 301; index <= 2800; index++) { - f.insert(index) - } + f.batch(() => { + for (let index = 301; index <= 2800; index++) { + f.insert(index) + } + }) await vi.advanceTimersByTimeAsync(10) expect(f.displayed()).toHaveLength(300) expect(f.onReplace).not.toHaveBeenCalled() @@ -220,10 +250,12 @@ describe.each(['v1', 'v2'] as const)('OpenCode %s watch reconciliation', (versio await vi.advanceTimersByTimeAsync(10) expect(f.readPage.mock.calls.length).toBeGreaterThan(reads) expect(f.readPage.mock.calls.every(([args]) => args.limit <= 2400)).toBe(true) - for (let index = 301; index <= 2800; index++) { - f.remove(index) - } - f.insert(301, 'after discarded burst') + f.batch(() => { + for (let index = 301; index <= 2800; index++) { + f.remove(index) + } + f.insert(301, 'after discarded burst') + }) await vi.advanceTimersByTimeAsync(10) expect(f.displayed()).toHaveLength(301) expect(f.displayed().at(-1)?.blocks).toEqual([{ type: 'text', text: 'after discarded burst' }]) @@ -233,9 +265,11 @@ describe.each(['v1', 'v2'] as const)('OpenCode %s watch reconciliation', (versio const f = watchFixture(version) await vi.advanceTimersByTimeAsync(0) for (let start = 301; start <= 2400; start += 300) { - for (let index = start; index < start + 300; index++) { - f.insert(index) - } + f.batch(() => { + for (let index = start; index < start + 300; index++) { + f.insert(index) + } + }) await vi.advanceTimersByTimeAsync(10) } f.onReplace.mockClear() @@ -253,9 +287,11 @@ describe.each(['v1', 'v2'] as const)('OpenCode %s watch reconciliation', (versio it('keeps appending after a bridged burst fills the cap', async () => { const f = watchFixture(version) await vi.advanceTimersByTimeAsync(0) - for (let index = 301; index <= 2600; index++) { - f.insert(index) - } + f.batch(() => { + for (let index = 301; index <= 2600; index++) { + f.insert(index) + } + }) await vi.advanceTimersByTimeAsync(10) expect(f.onReplace).toHaveBeenCalledOnce() expect(f.displayed()).toHaveLength(2400) @@ -272,13 +308,15 @@ describe.each(['v1', 'v2'] as const)('OpenCode %s watch reconciliation', (versio async (operation) => { const f = watchFixture(version, false, 2500, 2400) await vi.advanceTimersByTimeAsync(0) - if (operation === 'delete') { - f.remove(101) - } else { - f.update(101, operation === 'hide' ? '' : 'edited first tail row') - } - f.insert(2501) - f.insert(2502) + f.batch(() => { + if (operation === 'delete') { + f.remove(101) + } else { + f.update(101, operation === 'hide' ? '' : 'edited first tail row') + } + f.insert(2501) + f.insert(2502) + }) await vi.advanceTimersByTimeAsync(10) expect(f.onReplace).toHaveBeenCalledOnce() expect(f.onAppend).not.toHaveBeenCalled() diff --git a/src/main/opencode/hook-plugin-opencode2-tui-ownership.test.ts b/src/main/opencode/hook-plugin-opencode2-tui-ownership.test.ts index cca4712e971..b99ee05ff97 100644 --- a/src/main/opencode/hook-plugin-opencode2-tui-ownership.test.ts +++ b/src/main/opencode/hook-plugin-opencode2-tui-ownership.test.ts @@ -31,6 +31,8 @@ type PluginModule = { default?: { setup?: (ctx: unknown) => Promise<(() => Promise) | undefined> } } +const { setTimeout: realSetTimeout, clearTimeout: realClearTimeout } = globalThis + const PANE_A = 'tabA:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' const PANE_B = 'tabB:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' const SES_A = 'ses_f161fd85fffeieT0zYt80ZKorS' @@ -85,6 +87,8 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { let allPosts: Post[] let failPosts: boolean let postDelayMs: number + const cleanups = new Set<() => Promise>() + const delayedPosts = new Set<() => void>() beforeEach(() => { tempDir = mkdtempSync(join(tmpdir(), 'orca-opencode-tui-adapter-')) @@ -105,7 +109,15 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { globalThis.fetch = vi.fn(async (_input, init) => { const body = JSON.parse(String(init?.body)) if (postDelayMs > 0) { - await new Promise((resolve) => setTimeout(resolve, postDelayMs)) + await new Promise((resolve) => { + const finish = (): void => { + clearTimeout(timer) + delayedPosts.delete(finish) + resolve() + } + const timer = setTimeout(finish, postDelayMs) + delayedPosts.add(finish) + }) } if (failPosts) { return new Response('{}', { status: 500 }) @@ -122,17 +134,47 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { }) }) - afterEach(() => { - globalThis.fetch = savedFetch - process.argv = savedArgv - for (const key of ENV_KEYS) { - if (savedEnv[key] === undefined) { - delete process.env[key] - } else { - process.env[key] = savedEnv[key] + afterEach(async () => { + let deadline: ReturnType | undefined + try { + postDelayMs = 0 + for (const finish of delayedPosts) { + finish() + } + const closing = Promise.allSettled([...cleanups].map((cleanup) => cleanup())) + expect( + await Promise.race([ + closing.then((results) => results.every((result) => result.status === 'fulfilled')), + new Promise((resolve) => { + deadline = realSetTimeout(() => resolve(false), 2000) + }) + ]) + ).toBe(true) + } finally { + realClearTimeout(deadline) + try { + if (vi.isFakeTimers()) { + expect(vi.getTimerCount()).toBe(0) + } + } finally { + if (vi.isFakeTimers()) { + vi.clearAllTimers() + } + vi.useRealTimers() + cleanups.clear() + delayedPosts.clear() + globalThis.fetch = savedFetch + process.argv = savedArgv + for (const key of ENV_KEYS) { + if (savedEnv[key] === undefined) { + delete process.env[key] + } else { + process.env[key] = savedEnv[key] + } + } + rmSync(tempDir, { recursive: true, force: true }) } } - rmSync(tempDir, { recursive: true, force: true }) }) async function loadPlugin(dir = tempDir): Promise { @@ -140,7 +182,35 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { const pluginPath = join(dir, `orca-opencode-status-${Math.random().toString(36).slice(2)}.mjs`) writeFileSync(pluginPath, _internals.getOpenCodePluginSource()) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the generated module's default export is exercised below and fails the test if absent. - return (await import(pathToFileURL(pluginPath).href)) as PluginModule + const plugin = (await import(pathToFileURL(pluginPath).href)) as PluginModule + if (!vi.isFakeTimers()) { + vi.useFakeTimers({ + toFake: ['Date', 'setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] + }) + } + const setup = plugin.default?.setup + return { + default: { + setup: async (ctx) => { + const cleanup = await setup?.(ctx) + if (!cleanup) { + return + } + const close = async (): Promise => { + try { + await cleanup() + if (vi.isFakeTimers()) { + expect(vi.getTimerCount()).toBe(0) + } + } finally { + cleanups.delete(close) + } + } + cleanups.add(close) + return close + } + } + } } const summary = (list: Post[]): string[] => @@ -156,14 +226,40 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { const tui = fakeTui() const cleanup = await (await loadPlugin()).default?.setup?.(tui.ctx) await script(tui) - await vi.waitFor(() => { + await waitFor(() => { expect(summary(posts.slice(start)).at(-1)).toBe(`SessionIdle:${ownSession}`) }) await cleanup?.() return posts.slice(start) } - const tick = (ms = 20): Promise => new Promise((resolve) => setTimeout(resolve, ms)) + const tick = async (ms = 20): Promise => { + await (vi.isFakeTimers() + ? vi.advanceTimersByTimeAsync(ms) + : new Promise((resolve) => setTimeout(resolve, ms))) + } + const waitFor = async ( + check: () => void, + { timeout = 1000 }: { timeout?: number } = {} + ): Promise => { + if (!vi.isFakeTimers()) { + await vi.waitFor(check, { timeout }) + return + } + const deadline = Date.now() + timeout + await tick(0) + for (;;) { + try { + check() + return + } catch (error) { + if (Date.now() >= deadline) { + throw error + } + await tick(Math.min(50, deadline - Date.now())) + } + } + } const pump = async (tui: ReturnType, events: BusEvent[]): Promise => { for (const event of events) { tui.emit(event) @@ -237,7 +333,7 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { await pump(tui, b.start) expect(posts).toHaveLength(0) tui.navigate(SES_B) - await vi.waitFor(() => { + await waitFor(() => { expect(summary(posts)).toContain(`SessionBusy:${SES_B}`) }) await pump(tui, b.finish) @@ -284,9 +380,10 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { tui.navigate(SES_A) await pump(tui, turn(SES_A, 'A').start) tui.loseEnd(SES_A) - await vi.waitFor(() => { - expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`) - }) + await tick(99) + expect(summary(posts).at(-1)).toBe(`SessionBusy:${SES_A}`) + await tick(1) + expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`) await cleanup?.() }) @@ -301,7 +398,7 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { for (const event of [...a.start, ...a.finish]) { tui.emit(event) } - await vi.waitFor(() => { + await waitFor(() => { expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`) }) await tick(250) @@ -319,15 +416,15 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { tui.navigate(SES_A) const first = turn(SES_A, 'A') await pump(tui, [...first.start, ...first.finish]) - await vi.waitFor(() => { + await waitFor(() => { expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`) }) tui.loseStart(SES_A) - await vi.waitFor(() => { + await waitFor(() => { expect(summary(posts).at(-1)).toBe(`SessionBusy:${SES_A}`) }) tui.loseEnd(SES_A) - await vi.waitFor(() => { + await waitFor(() => { expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`) }) await cleanup?.() @@ -360,7 +457,7 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { tui.navigate(SES_A) await pump(tui, [...turn(SES_A, 'root').start, ...childStart(SES_A)]) await pump(tui, [{ type: 'session.execution.failed', data: { sessionID: SES_A } }]) - await vi.waitFor(() => { + await waitFor(() => { expect(posts.at(-1)?.payload).toMatchObject({ hook_event_name: 'SessionBusy', root_state: 'done', @@ -388,9 +485,7 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { tui.navigate(SES_A) await pump(tui, turn(SES_A, 'root').start) await pump(tui, [{ type: 'session.execution.failed', data: { sessionID: SES_A } }]) - await vi.waitFor(() => - expect(posts.at(-1)?.payload?.root_turn_error_name).toBe('UnknownError') - ) + await waitFor(() => expect(posts.at(-1)?.payload?.root_turn_error_name).toBe('UnknownError')) await first?.() const before = posts.length const second = await start(tui) @@ -414,7 +509,7 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { { type: finishType, data: { sessionID: SES_A, reason: 'user' } } ]) const second = await start(tui) - await vi.waitFor(() => expect(posts.at(-1)?.payload?.root_turn_error_name).toBeUndefined()) + await waitFor(() => expect(posts.at(-1)?.payload?.root_turn_error_name).toBeUndefined()) expect(posts.at(-1)?.payload).toMatchObject({ hook_event_name: 'SessionIdle', root_state: 'done' @@ -430,13 +525,13 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { tui.navigate(SES_A) const a = turn(SES_A, 'A') await pump(tui, a.start) - await vi.waitFor(() => expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`])) + await waitFor(() => expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`])) const beforeReload = posts.length await firstGeneration?.() expect(posts).toHaveLength(beforeReload) await pump(tui, a.finish) const secondGeneration = await start(tui) - await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`)) + await waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`)) await secondGeneration?.() expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`, `SessionIdle:${SES_A}`]) }) @@ -454,7 +549,7 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { await tick(150) expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`]) await pump(tui, a.finish) - await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`)) + await waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`)) await secondGeneration?.() }) @@ -468,7 +563,7 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { await firstGeneration?.() failPosts = false const secondGeneration = await start(tui) - await vi.waitFor(() => expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`])) + await waitFor(() => expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`])) await secondGeneration?.() }) @@ -483,7 +578,11 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { for (const event of [...a.start, ...b.start, ...b.finish, ...a.finish]) { tui.emit(event) } - await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`), { + await tick(119) + expect(allPosts).toEqual([]) + await tick(1) + expect(summary(allPosts)).toEqual(['SessionStart:undefined']) + await waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`), { timeout: 3000 }) await tick(300) @@ -505,11 +604,11 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { it('lands one start boundary in a freshly started TUI, and none on a reload after Done', async () => { const tui = fakeTui() const firstGeneration = await start(tui) - await vi.waitFor(() => expect(summary(allPosts)).toEqual(['SessionStart:undefined'])) + await waitFor(() => expect(summary(allPosts)).toEqual(['SessionStart:undefined'])) tui.navigate(SES_A) const a = turn(SES_A, 'A') await pump(tui, [...a.start, ...a.finish]) - await vi.waitFor(() => expect(summary(allPosts).at(-1)).toBe(`SessionIdle:${SES_A}`)) + await waitFor(() => expect(summary(allPosts).at(-1)).toBe(`SessionIdle:${SES_A}`)) await firstGeneration?.() const secondGeneration = await start(tui) await tick(250) @@ -524,7 +623,7 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { it('lands the start boundary only once across reloads of an idle pane', async () => { const tui = fakeTui() const firstGeneration = await start(tui) - await vi.waitFor(() => expect(summary(allPosts)).toEqual(['SessionStart:undefined'])) + await waitFor(() => expect(summary(allPosts)).toEqual(['SessionStart:undefined'])) await firstGeneration?.() const secondGeneration = await start(tui) await tick(150) @@ -536,7 +635,7 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { const tui = fakeTui() tui.navigate(SES_B) const cleanup = await start(tui) - await vi.waitFor(() => expect(summary(allPosts)).toEqual([`SessionStart:${SES_B}`])) + await waitFor(() => expect(summary(allPosts)).toEqual([`SessionStart:${SES_B}`])) await cleanup?.() }) @@ -545,7 +644,7 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { tui.navigate(SES_A) tui.loseStart(SES_A) const cleanup = await start(tui) - await vi.waitFor(() => expect(summary(allPosts)).toEqual([`SessionBusy:${SES_A}`])) + await waitFor(() => expect(summary(allPosts)).toEqual([`SessionBusy:${SES_A}`])) await tick(150) await cleanup?.() expect(summary(allPosts)).toEqual([`SessionBusy:${SES_A}`]) @@ -571,7 +670,7 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`, `SessionBusy:${SES_A}`]) expect(posts.at(-1)?.payload).toMatchObject({ root_state: 'done' }) tui.loseEnd(SES_CHILD) - await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`)) + await waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`)) await cleanup?.() expect(posts.every((post) => post.payload?.sessionID === SES_A)).toBe(true) }) @@ -603,14 +702,19 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { const cleanup = await start(tui) tui.navigate(SES_A) const a = turn(SES_A, 'A') - await pump(tui, [...a.start, ...childStart(SES_A), permission(SES_CHILD)]) - await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`PermissionRequest:${SES_A}`)) + await pump(tui, [...a.start, ...childStart(SES_A)]) + await tick(60) + await pump(tui, [permission(SES_CHILD)]) + await tick(479) + expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`]) + await tick(1) + expect(summary(posts).at(-1)).toBe(`PermissionRequest:${SES_A}`) await pump(tui, [ { type: 'permission.replied', data: { sessionID: SES_CHILD, requestID: 'per_1' } }, { type: 'session.execution.succeeded', data: { sessionID: SES_CHILD } }, ...a.finish ]) - await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`)) + await waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`)) await cleanup?.() expect(statuses(posts)).toEqual([ `SessionBusy:${SES_A}`, @@ -627,7 +731,7 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { tui.navigate(SES_A) const a = turn(SES_A, 'A spawns a background task') await pump(tui, [...a.start, ...childStart(SES_A), permission(SES_CHILD)]) - await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`PermissionRequest:${SES_A}`)) + await waitFor(() => expect(summary(posts).at(-1)).toBe(`PermissionRequest:${SES_A}`)) await pump(tui, a.finish) await tick(300) expect(summary(posts).at(-1)).toBe(`PermissionRequest:${SES_A}`) @@ -639,11 +743,11 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { const cleanup = await start(tui) tui.navigate(SES_A) await pump(tui, [...turn(SES_A, 'A').start, permission(SES_A)]) - await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`PermissionRequest:${SES_A}`)) + await waitFor(() => expect(summary(posts).at(-1)).toBe(`PermissionRequest:${SES_A}`)) // The user browses away; the reply lands while this TUI is disconnected. tui.navigate(SES_B) await pump(tui, [{ type: 'server.connected', data: {} }]) - await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionBusy:${SES_A}`)) + await waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionBusy:${SES_A}`)) await cleanup?.() }) @@ -652,7 +756,7 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { const cleanup = await start(tui) tui.navigate(SES_A) await pump(tui, [...turn(SES_A, 'A').start, permission(SES_A)]) - await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`PermissionRequest:${SES_A}`)) + await waitFor(() => expect(summary(posts).at(-1)).toBe(`PermissionRequest:${SES_A}`)) const release = tui.holdPermissionFetch() tui.serverPermissions.set(SES_A, [...(tui.permissions.get(SES_A) ?? [])]) await pump(tui, [ @@ -668,26 +772,22 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { // Why: an Orca restart moves the hook endpoint while the pane's level stays the same. it('re-posts the current level once the hook endpoint moves', async () => { - vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] }) - try { - const tui = fakeTui() - const cleanup = await start(tui) - tui.navigate(SES_A) - await pump(tui, turn(SES_A, 'A').start) - vi.advanceTimersByTime(200) - await vi.waitFor(() => expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`])) - process.env.ORCA_AGENT_HOOK_PORT = '59998' - vi.advanceTimersByTime(5000) - await vi.waitFor(() => - expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`, `SessionBusy:${SES_A}`]) - ) - vi.advanceTimersByTime(5000) - await tick(50) - expect(statuses(posts)).toHaveLength(2) - await cleanup?.() - } finally { - vi.useRealTimers() - } + const tui = fakeTui() + const cleanup = await start(tui) + tui.navigate(SES_A) + await pump(tui, turn(SES_A, 'A').start) + await tick(200) + await waitFor(() => expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`])) + process.env.ORCA_AGENT_HOOK_PORT = '59998' + await tick(4699) + expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`]) + await tick(1) + expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`, `SessionBusy:${SES_A}`]) + await tick(300) + await tick(5000) + await tick(50) + expect(statuses(posts)).toHaveLength(2) + await cleanup?.() }) it('does not pin Needs input on a request the data kept after its turn ended', async () => { @@ -696,9 +796,9 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { tui.navigate(SES_A) const a = turn(SES_A, 'A') await pump(tui, [...a.start, permission(SES_A)]) - await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`PermissionRequest:${SES_A}`)) + await waitFor(() => expect(summary(posts).at(-1)).toBe(`PermissionRequest:${SES_A}`)) await pump(tui, a.finish) - await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`)) + await waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`)) await tick(250) expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`) await cleanup?.() diff --git a/src/main/persistence/loading-store/profile-state-sqlite-authority.test.ts b/src/main/persistence/loading-store/profile-state-sqlite-authority.test.ts index 5f679d3128d..eff2cb3e6a5 100644 --- a/src/main/persistence/loading-store/profile-state-sqlite-authority.test.ts +++ b/src/main/persistence/loading-store/profile-state-sqlite-authority.test.ts @@ -127,14 +127,14 @@ describe('Store with an injected SQLite profile-state authority', () => { store.updateSettings({ terminalFontSize: store.getSettings().terminalFontSize + 1 }) await store.flushPendingOrThrowAsync() - expect(readFileSync(dataFile)).toEqual(legacyBytes) + expect(readFileSync(dataFile).equals(legacyBytes)).toBe(true) expect(existsSync(databaseFile)).toBe(true) const reloaded = new Store({ dataFile, profileStateAuthority: authority }) expect(reloaded.getSettings().theme).toBe('dark') expect(reloaded.getSettings().terminalFontSize).toBe(store.getSettings().terminalFontSize) expect(reloaded.getSettings().opencodeSessionCookie).toBe('authority-secret') - expect(readFileSync(dataFile)).toEqual(legacyBytes) + expect(readFileSync(dataFile).equals(legacyBytes)).toBe(true) reloaded.freezeWrites() }) @@ -848,7 +848,7 @@ describe('Store with an injected SQLite profile-state authority', () => { 'store-recovery-test' ) - expect(readFileSync(join(result.directory, 'profile-state.db'))).toEqual(sourceBytes) + expect(readFileSync(join(result.directory, 'profile-state.db')).equals(sourceBytes)).toBe(true) expect(readFileSync(join(result.directory, 'profile-state.db-wal'), 'utf8')).toBe( 'wal-preservation-sentinel' ) @@ -856,7 +856,7 @@ describe('Store with an injected SQLite profile-state authority', () => { profileId: 'profile-authority-test', reason: 'store-recovery-test' }) - expect(readFileSync(databasePath)).toEqual(sourceBytes) + expect(readFileSync(databasePath).equals(sourceBytes)).toBe(true) }) it('prepares frozen JSON imports without permitting file publication', () => { diff --git a/src/main/persistence/loading-store/profile-state-store-backups.test.ts b/src/main/persistence/loading-store/profile-state-store-backups.test.ts index aa60618cc42..f712bc691ce 100644 --- a/src/main/persistence/loading-store/profile-state-store-backups.test.ts +++ b/src/main/persistence/loading-store/profile-state-store-backups.test.ts @@ -129,7 +129,7 @@ describe('Store automatic SQLite recovery snapshots', () => { expect(readSnapshot(backups[0].path).state.workspaceSession.activeWorktreeId).toBe( 'backup-worktree' ) - expect(readFileSync(state.retained[0].path)).toEqual(state.retainedBytes) + expect(readFileSync(state.retained[0].path).equals(state.retainedBytes)).toBe(true) expect(readFileSync(state.dataFile, 'utf8')).toBe(state.legacyBytes) expect( readdirSync(state.directory) @@ -208,7 +208,7 @@ describe('Store automatic SQLite recovery snapshots', () => { const backups = profileStateDatabaseBackups(state.databasePath) expect(backups).toHaveLength(2) expect(readSnapshot(backups[0].path).state.settings.theme).toBe('dark') - expect(readFileSync(state.retained[0].path)).toEqual(state.retainedBytes) + expect(readFileSync(state.retained[0].path).equals(state.retainedBytes)).toBe(true) expect(JSON.parse(readFileSync(state.dataFile, 'utf8'))).toEqual( readSnapshot(state.databasePath).state ) @@ -241,7 +241,7 @@ describe('Store automatic SQLite recovery snapshots', () => { ) expect(readSnapshot(state.databasePath).state.settings.theme).toBe('dark') expect(profileStateDatabaseBackups(state.databasePath)).toEqual(state.retained) - expect(readFileSync(state.retained[0].path)).toEqual(state.retainedBytes) + expect(readFileSync(state.retained[0].path).equals(state.retainedBytes)).toBe(true) expect(readSnapshot(state.retained[0].path).state.settings.theme).toBe('light') expect(readFileSync(state.dataFile, 'utf8')).toBe(state.legacyBytes) expect(existsSync(`${state.dataFile}.bak.0`)).toBe(false) diff --git a/src/main/persistence/profile-state/profile-state-authority-bootstrap.test.ts b/src/main/persistence/profile-state/profile-state-authority-bootstrap.test.ts index 8d7b7f72674..21c13c3808f 100644 --- a/src/main/persistence/profile-state/profile-state-authority-bootstrap.test.ts +++ b/src/main/persistence/profile-state/profile-state-authority-bootstrap.test.ts @@ -197,7 +197,7 @@ describe('profile state authority bootstrap', () => { expect(() => bootstrapProfileStateAuthority(options)).toThrow( ProfileStateRecoveryRequiredError ) - expect(readFileSync(options.databaseFile)).toEqual(before) + expect(readFileSync(options.databaseFile).equals(before)).toBe(true) } const exportPath = profileStateJsonExportPath(options.dataFile, 1) writeFileSync(exportPath, raw) @@ -502,7 +502,7 @@ describe('profile state authority bootstrap', () => { expect.arrayContaining([options.databaseFile, `${options.databaseFile}-wal`]) ) expect(existsSync(options.databaseFile)).toBe(false) - expect(readFileSync(options.dataFile)).toEqual(restoredJson) + expect(readFileSync(options.dataFile).equals(restoredJson)).toBe(true) expect(existsSync(exportPath)).toBe(false) expect(readFileSync(join(result.quarantine.directory, 'profile-state.db'), 'utf8')).toBe( 'corrupt sqlite primary' @@ -538,7 +538,7 @@ describe('profile state authority bootstrap', () => { exportPath }) ).toThrow('Profile state JSON is invalid') - expect(readFileSync(options.databaseFile)).toEqual(databaseBytes) - expect(readFileSync(options.dataFile)).toEqual(dataBytes) + expect(readFileSync(options.databaseFile).equals(databaseBytes)).toBe(true) + expect(readFileSync(options.dataFile).equals(dataBytes)).toBe(true) }) }) diff --git a/src/main/persistence/profile-state/profile-state-backup-worker.test.ts b/src/main/persistence/profile-state/profile-state-backup-worker.test.ts index 9e1d09b761f..a2d40b8328c 100644 --- a/src/main/persistence/profile-state/profile-state-backup-worker.test.ts +++ b/src/main/persistence/profile-state/profile-state-backup-worker.test.ts @@ -161,7 +161,7 @@ describe('profile state backup worker', () => { await expect(runProfileStateBackupWorker(job, { workerPath })).rejects.toThrow(expectedError) expect(existsSync(job.targetPath)).toBe(false) expect(readFileSync(retained, 'utf8')).toBe('previous recovery point') - expect(readFileSync(job.databasePath)).toEqual(before) + expect(readFileSync(job.databasePath).equals(before)).toBe(true) }) it.each(['true', 'false'])('waits for actual exit after an ok=%s response', async (ok) => { @@ -251,7 +251,7 @@ describe('profile state backup worker', () => { await expect( runProfileStateBackupWorker(job, { workerPath: join(directory, 'missing.js') }) ).rejects.toThrow() - expect(readFileSync(job.databasePath)).toEqual(before) + expect(readFileSync(job.databasePath).equals(before)).toBe(true) }) it('coalesces desktop work and drains a started backup before allowing quarantine', async () => { diff --git a/src/main/persistence/profile-state/profile-state-database-snapshot.test.ts b/src/main/persistence/profile-state/profile-state-database-snapshot.test.ts index da0ebcf7ac5..238e067e9a3 100644 --- a/src/main/persistence/profile-state/profile-state-database-snapshot.test.ts +++ b/src/main/persistence/profile-state/profile-state-database-snapshot.test.ts @@ -80,7 +80,7 @@ describe('asynchronous profile-state database snapshots', () => { } expect(exportProfileStateJson(db)).toBe(originalJson) expect(db.pragma('journal_mode', { simple: true })).toBe('wal') - expect(readFileSync(databasePath)).toEqual(sourceFile) + expect(readFileSync(databasePath).equals(sourceFile)).toBe(true) importProfileStateJson(db, JSON.stringify({ settings: { theme: 'dark' } })) expect(readSnapshot(targetPath)).toBe(originalJson) expectNoTemporaryFiles(directory) @@ -150,7 +150,7 @@ describe('asynchronous profile-state database snapshots', () => { 'injected native backup failure' ) - expect(readFileSync(targetPath)).toEqual(previous) + expect(readFileSync(targetPath).equals(previous)).toBe(true) expect(exportProfileStateJson(db)).toBe(originalJson) expectNoTemporaryFiles(directory) }) @@ -184,7 +184,7 @@ describe('asynchronous profile-state database snapshots', () => { 'injected rename failure' ) - expect(readFileSync(targetPath)).toEqual(previous) + expect(readFileSync(targetPath).equals(previous)).toBe(true) expectNoTemporaryFiles(directory) }) @@ -202,7 +202,7 @@ describe('asynchronous profile-state database snapshots', () => { db.exec('ROLLBACK') } - expect(readFileSync(targetPath)).toEqual(previous) + expect(readFileSync(targetPath).equals(previous)).toBe(true) expect(exportProfileStateJson(db)).toBe(originalJson) expectNoTemporaryFiles(directory) }) diff --git a/src/main/persistence/profile-state/profile-state-database.test.ts b/src/main/persistence/profile-state/profile-state-database.test.ts index 245806b8ad0..b96858346d5 100644 --- a/src/main/persistence/profile-state/profile-state-database.test.ts +++ b/src/main/persistence/profile-state/profile-state-database.test.ts @@ -162,7 +162,7 @@ describe('profile state database', () => { } const after = statSync(dbPath) expect(after.size).toBe(before.size) - expect(readFileSync(dbPath)).toEqual(beforeBytes) + expect(readFileSync(dbPath).equals(beforeBytes)).toBe(true) }) it('opens the current schema read-only without changing its bytes', () => { @@ -179,7 +179,7 @@ describe('profile state database', () => { } finally { opened.db.close() } - expect(readFileSync(dbPath)).toEqual(before) + expect(readFileSync(dbPath).equals(before)).toBe(true) }) it('rejects a database whose profile identity does not match', () => { @@ -192,7 +192,7 @@ describe('profile state database', () => { expect(() => openProfileStateDatabase(dbPath, 'profile-b')).toThrowError( expect.objectContaining({ code: 'identity-mismatch' }) ) - expect(readFileSync(dbPath)).toEqual(before) + expect(readFileSync(dbPath).equals(before)).toBe(true) }) it('rejects malformed database bytes without replacing them', () => { @@ -204,7 +204,7 @@ describe('profile state database', () => { expect(() => openProfileStateDatabase(dbPath, 'profile-a')).toThrowError( expect.objectContaining({ code: 'unreadable' }) ) - expect(readFileSync(dbPath)).toEqual(bytes) + expect(readFileSync(dbPath).equals(bytes)).toBe(true) }) it('quarantines the database family without touching live recovery sources', () => { @@ -254,7 +254,7 @@ describe('profile state database', () => { expect(() => openProfileStateDatabase(dbPath, 'profile-a')).toThrowError( expect.objectContaining({ code: 'unreadable' }) ) - expect(readFileSync(dbPath)).toEqual(before) + expect(readFileSync(dbPath).equals(before)).toBe(true) }) it('rejects an incomplete current schema without mutating it', () => { @@ -268,7 +268,7 @@ describe('profile state database', () => { expect(() => openProfileStateDatabase(dbPath, 'profile-a')).toThrowError( expect.objectContaining({ code: 'unreadable' }) ) - expect(readFileSync(dbPath)).toEqual(before) + expect(readFileSync(dbPath).equals(before)).toBe(true) }) it('rejects current-version tables with incompatible columns without mutating them', () => { @@ -290,7 +290,7 @@ describe('profile state database', () => { expect(() => openProfileStateDatabase(dbPath, 'profile-a')).toThrowError( expect.objectContaining({ code: 'unreadable' }) ) - expect(readFileSync(dbPath)).toEqual(before) + expect(readFileSync(dbPath).equals(before)).toBe(true) }) it('does not create an empty database when the parent directory is absent', () => { diff --git a/src/main/runtime/quarter-circle-title-send-authorization.test.ts b/src/main/runtime/quarter-circle-title-send-authorization.test.ts index 8def14f0d99..afaa8362409 100644 --- a/src/main/runtime/quarter-circle-title-send-authorization.test.ts +++ b/src/main/runtime/quarter-circle-title-send-authorization.test.ts @@ -1,7 +1,7 @@ // STA-4028 (regression from #13925): quarter circles are ordinary progress glyphs — // ora, installers, any TUI animates them — so a title carrying nothing else must not // authorize a guarded send, which auto-submits with Enter into whatever owns the pane. -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService } from './orca-runtime' import { assertTerminalAgentSendable } from './rpc/terminal-agent-send-guard' import { detectAgentStatusFromTitle } from '../../shared/agent-detection' @@ -18,6 +18,11 @@ const TAB_ID = 'tab-1' const WORKTREE_ID = 'wt-1' const PTY_ID = 'pty-1' +afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() +}) + // Captured from Claude Code 2.1.228 while it read this repository's package.json. const SPINNER_ONLY_TITLE = '◑ Check package version in package.json' const SPINNER_WITH_IDENTITY_TITLE = '◐ Claude Code' @@ -91,6 +96,42 @@ async function createRuntimeWithTitle( const AUTHORIZED = 'authorized' +// The 150ms foreground tick first crosses its unchanged 6,500ms budget at 6,600ms. +const WRAPPER_REJECTION_TICK_MS = 6_600 +const NO_AGENT_GUARD_DEADLINE_MS = 1_050 + +async function readAgentEvidenceAtDeadline( + read: () => Promise, + deadlineMs: number +): Promise { + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }) + try { + let settled = false + const result = read() + void result.then( + () => { + settled = true + }, + () => { + settled = true + } + ) + await vi.advanceTimersByTimeAsync(deadlineMs - 1) + expect(settled, 'agent evidence settled before its deadline').toBe(false) + await vi.advanceTimersByTimeAsync(1) + expect(settled, 'agent evidence did not settle at its deadline').toBe(true) + expect(vi.getTimerCount()).toBe(0) + return await result + } finally { + try { + await vi.runOnlyPendingTimersAsync() + } finally { + vi.clearAllTimers() + vi.useRealTimers() + } + } +} + async function guardedSendResult(runtime: OrcaRuntimeService, handle: string): Promise { try { await assertTerminalAgentSendable({ runtime, handle, assertWritable: () => {} }) @@ -104,10 +145,18 @@ describe('quarter-circle title send authorization (STA-4028)', () => { it('refuses a guarded send when a quarter-circle spinner is the only agent evidence', async () => { const { runtime, handle } = await createRuntimeWithTitle(SPINNER_ONLY_TITLE, 'node') - await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ - isRunningAgent: false - }) - await expect(guardedSendResult(runtime, handle)).resolves.toBe('terminal_guard_no_agent') + expect( + await readAgentEvidenceAtDeadline( + () => runtime.getTerminalAgentStatus(handle), + WRAPPER_REJECTION_TICK_MS + ) + ).toMatchObject({ isRunningAgent: false }) + expect( + await readAgentEvidenceAtDeadline( + () => guardedSendResult(runtime, handle), + WRAPPER_REJECTION_TICK_MS + ) + ).toBe('terminal_guard_no_agent') }) it('refuses a guarded send when the foreground process cannot be read at all', async () => { @@ -115,7 +164,12 @@ describe('quarter-circle title send authorization (STA-4028)', () => { // stays a refusal rather than falling back to the glyph. const { runtime, handle } = await createRuntimeWithTitle(SPINNER_ONLY_TITLE, null) - await expect(guardedSendResult(runtime, handle)).resolves.toBe('terminal_guard_no_agent') + expect( + await readAgentEvidenceAtDeadline( + () => guardedSendResult(runtime, handle), + NO_AGENT_GUARD_DEADLINE_MS + ) + ).toBe('terminal_guard_no_agent') }) it('authorizes a guarded send when the foreground process is a recognized agent', async () => { @@ -158,7 +212,12 @@ describe('quarter-circle title send authorization (STA-4028)', () => { false ) - await expect(guardedSendResult(runtime, handle)).resolves.toBe('terminal_guard_no_agent') + expect( + await readAgentEvidenceAtDeadline( + () => guardedSendResult(runtime, handle), + NO_AGENT_GUARD_DEADLINE_MS + ) + ).toBe('terminal_guard_no_agent') }) it('does not carry managed Claude identity into a replacement PTY incarnation', async () => { @@ -198,9 +257,12 @@ describe('quarter-circle title send authorization (STA-4028)', () => { await expect(runtime.getTerminalAgentStatus(replacementHandle)).resolves.toMatchObject({ isRunningAgent: false }) - await expect(guardedSendResult(runtime, replacementHandle)).resolves.toBe( - 'terminal_guard_no_agent' - ) + expect( + await readAgentEvidenceAtDeadline( + () => guardedSendResult(runtime, replacementHandle), + NO_AGENT_GUARD_DEADLINE_MS + ) + ).toBe('terminal_guard_no_agent') }) it('authorizes a guarded send when the busy title itself names the agent', async () => { diff --git a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts index 36ab233a915..57e632a6c85 100644 --- a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts +++ b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts @@ -35,11 +35,17 @@ import { stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import { createCoordinatorMailObservationClock } from './structured-chat-coordinator-observation-clock.test-fixture' // The turns a send or Stop is waiting on to open, so a test knows the wait began. const openWaits = vi.hoisted(() => { - const turnIds: string[] = [] - return { turnIds } + const state: { + turnIds: string[] + ended: string[] + began: (() => void) | null + releaseAll: (() => void) | null + } = { turnIds: [], ended: [], began: null, releaseAll: null } + return state }) vi.mock('../codex/codex-structured-turn-open-wait', async (importOriginal) => { const actual = await importOriginal() @@ -47,11 +53,16 @@ vi.mock('../codex/codex-structured-turn-open-wait', async (importOriginal) => { ...actual, createCodexTurnOpenWaits: () => { const waits = actual.createCodexTurnOpenWaits() + openWaits.releaseAll = waits.releaseAll return { ...waits, wait: (turnId: string, withinMs: number) => { openWaits.turnIds.push(turnId) - return waits.wait(turnId, withinMs) + const pending = waits.wait(turnId, withinMs) + openWaits.began?.() + return pending.then(() => { + openWaits.ended.push(turnId) + }) } } } @@ -170,6 +181,8 @@ function verdictOf(submissions: readonly AgentJournalSubmission[], clientMessage beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-codex-turn-end-')) openWaits.turnIds.length = 0 + openWaits.ended.length = 0 + openWaits.began = null answers = 0 steers = 0 interrupts = 0 @@ -239,6 +252,8 @@ beforeEach(async () => { }) afterEach(async () => { + openWaits.releaseAll?.() + vi.useRealTimers() await stopStructuredAgentSessionRuntime() await rm(root, { recursive: true, force: true }) }) @@ -341,7 +356,14 @@ describe('a queued card sent now into the turn a Stop ends', () => { ) // A drain ignoring the pause re-sends only after the stopped turn ends: watch past that. await vi.waitFor(() => expect(turns.turnId).toBeNull()) - await new Promise((resolve) => setTimeout(resolve, 2_500)) + const clock = createCoordinatorMailObservationClock(() => host, SESSION) + clock.start() + try { + await clock.observe(2_500) + await host.collaboratorsForTests().serialize(SESSION, async () => {}) + } finally { + clock.restore() + } expect(steers + answers).toBe(2) expect(await sends(cardId)).toEqual([{ origin: 'client', verdict: 'withdrawn' }]) }, 20_000) @@ -454,9 +476,17 @@ describe('a Stop sent after Codex answered a cold send, before it opened the tur const sent = await send('look around') await vi.waitFor(() => expect(answers).toBe(1)) + await host.flushStreamedEvents(SESSION) + const began = Promise.withResolvers() + openWaits.began = began.resolve + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }) const stopping = stop() + await began.promise // Without the wait, it would reach Codex now, which would refuse it, and be done. - expect(await settledWithin(stopping, 1_000)).toBe('held') + const held = settledWithin(stopping, 1_000) + await vi.advanceTimersByTimeAsync(1_000) + expect(openWaits.ended).toEqual([]) + expect(await held).toBe('held') expect(interrupts).toBe(0) turns.start() await stopping @@ -480,8 +510,15 @@ describe('a Stop in that window that the turn never opens for', () => { async function waitingStop(): Promise<{ stopping: Promise }> { await send('look around') await vi.waitFor(() => expect(answers).toBe(1)) + await host.flushStreamedEvents(SESSION) + const began = Promise.withResolvers() + openWaits.began = began.resolve + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }) const stopping = stop() - expect(await settledWithin(stopping, 200)).toBe('held') + await began.promise + const held = settledWithin(stopping, 200) + await vi.advanceTimersByTimeAsync(200) + expect(await held).toBe('held') return { stopping } } @@ -499,10 +536,17 @@ describe('a Stop in that window that the turn never opens for', () => { const { stopping } = await waitingStop() const closing = host.close(SESSION, 'evict') + const closedWithinBound = settledWithin( + closing, + CODEX_TURN_OPEN_WAIT_MS + CHILD_EVICTION_TIMEOUT_MS + ) + await vi.advanceTimersByTimeAsync(CODEX_TURN_OPEN_WAIT_MS - 201) + expect(openWaits.ended).toEqual([]) + expect(childCloses).toBe(0) + await vi.advanceTimersByTimeAsync(1) + expect(openWaits.ended).toEqual(['turn-1']) - expect( - await settledWithin(closing, CODEX_TURN_OPEN_WAIT_MS + CHILD_EVICTION_TIMEOUT_MS) - ).not.toBe('held') + expect(await closedWithinBound).not.toBe('held') expect(await settledWithin(stopping, 0)).not.toBe('held') expect(childCloses).toBe(1) expect(interrupts).toBe(0) @@ -512,9 +556,16 @@ describe('a Stop in that window that the turn never opens for', () => { it('lets the app quit behind it within the eviction budget, and still close the child', async () => { await waitingStop() - expect( - await settledWithin(stopStructuredAgentSessionRuntime(), CHILD_EVICTION_TIMEOUT_MS) - ).not.toBe('held') + const quitWithinBound = settledWithin( + stopStructuredAgentSessionRuntime(), + CHILD_EVICTION_TIMEOUT_MS + ) + await vi.advanceTimersByTimeAsync(CODEX_TURN_OPEN_WAIT_MS - 201) + expect(openWaits.ended).toEqual([]) + expect(childCloses).toBe(0) + await vi.advanceTimersByTimeAsync(1) + expect(openWaits.ended).toEqual(['turn-1']) + expect(await quitWithinBound).not.toBe('held') expect(childCloses).toBe(1) }) }) diff --git a/src/main/ssh/ssh-relay-gc-listing.test-fixture.ts b/src/main/ssh/ssh-relay-gc-listing.test-fixture.ts new file mode 100644 index 00000000000..d2e5da79387 --- /dev/null +++ b/src/main/ssh/ssh-relay-gc-listing.test-fixture.ts @@ -0,0 +1,148 @@ +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { PassThrough } from 'node:stream' +import type { ClientChannel } from 'ssh2' +import { runProcess, spawnProcess } from '../../shared/child-process/run-process' +import type { SshConnection } from './ssh-connection' +import { shellEscape } from './ssh-connection-utils' +import { execCommand } from './ssh-relay-exec-command' + +export const SSH_EXEC_OUTPUT_CAP_CHARS = 1024 * 1024 + +class RelayGcShellChannel extends PassThrough implements ClientChannel { + stdin: this = this + stdout: this = this + stderr: ReturnType['stderr'] + server = false as const + type = 'session' as const + subtype = 'exec' as const + incoming: unknown = null + outgoing: unknown = null + private readonly exited = Promise.withResolvers() + + constructor(private readonly child: ReturnType) { + super({ autoDestroy: false, emitClose: false }) + this.stderr = child.stderr + child.stdout.pipe(this) + child.stdout.on('error', (error) => this.emit('error', error)) + child.on('error', (error) => { + this.exited.resolve() + this.emit('error', error) + }) + child.on('close', (code) => { + this.exited.resolve() + this.emit('close', code) + }) + child.stdin.end() + } + + async dispose(): Promise { + this.close() + await this.exited.promise + this.destroy() + } + + close(): void { + this.child.kill() + } + + eof(): void { + this.end() + } + + setWindow(): void { + throw new Error('GC listing does not resize a terminal') + } + + signal(): void { + throw new Error('GC listing does not signal a terminal') + } + + exit(): void { + throw new Error('GC listing does not set a remote exit status') + } +} + +export async function runCappedRelayGcShellCommand(command: string): Promise { + let channel: RelayGcShellChannel | undefined + const connection: Pick = { + exec: async (shellCommand) => { + channel = new RelayGcShellChannel( + spawnProcess({ program: '/bin/sh', args: ['-c', shellCommand] }) + ) + return channel + }, + usesSystemSshTransport: () => false + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: execCommand only reads exec and usesSystemSshTransport; both methods are checked above. + const sshConnection = connection as SshConnection + try { + return await execCommand(sshConnection, command) + } finally { + await channel?.dispose() + } +} + +export function createRelayGcListingFixture(): { + root: string + findCommand: string + validNames: string[] + fillValidEntryBoundary: () => void + dispose: () => void +} { + const workspace = mkdtempSync(join(tmpdir(), 'orca-relay-gc-scale-')) + let root = workspace + try { + // Long Unicode paths cross the SSH character cap with fewer real directories. + for (let index = 0; index < 4; index += 1) { + root = join(root, `gc-context-${index}-ü-${'x'.repeat(150)}`) + mkdirSync(root) + } + const validNames = ['relay-0.1.0+aaa', 'relay-0.1.0+bbb'] + const stageName = (index: number): string => + `relay-9.9.9+abc.upload-${String(index).padStart(12, '0')}` + const stageCount = + Math.ceil(SSH_EXEC_OUTPUT_CAP_CHARS / (join(root, stageName(0)).length + 1)) + 1 + for (let index = 0; index < stageCount; index += 1) { + mkdirSync(join(root, stageName(index))) + } + for (const name of validNames) { + mkdirSync(join(root, name)) + } + mkdirSync(join(root, 'relay-0.1.0+abc.upload-000000000000')) + mkdirSync(join(root, 'relay-0.1.0+ggg')) + mkdirSync(join(root, 'orcad-0.1.0+aaa')) + mkdirSync(join(root, stageName(0), 'relay-0.1.0+ccc')) + writeFileSync(join(root, 'relay-0.1.0+ddd'), '') + symlinkSync(join(root, validNames[0]), join(root, 'relay-0.1.0+eee'), 'dir') + return { + root, + findCommand: `find ${shellEscape(root)} -mindepth 1 -maxdepth 1 -type d -name 'relay-*' -print`, + validNames, + fillValidEntryBoundary: () => { + for (let index = 0; index < 63; index += 1) { + const name = `relay-0.1.0+${index.toString(16)}` + validNames.push(name) + mkdirSync(join(root, name)) + } + }, + dispose: () => rmSync(workspace, { recursive: true, force: true }) + } + } catch (error) { + rmSync(workspace, { recursive: true, force: true }) + throw error + } +} + +export async function readUncappedRelayGcPaths(command: string): Promise { + const result = await runProcess({ + program: '/bin/sh', + args: ['-c', command], + maxOutputBytes: 4 * SSH_EXEC_OUTPUT_CAP_CHARS + }) + if (result.code !== 0 || result.timedOut || result.outputTruncated) { + throw new Error(`GC fixture enumeration failed: ${result.code}: ${result.stderr}`) + } + return result.stdout.trim().split('\n') +} diff --git a/src/main/ssh/ssh-remote-commands.test.ts b/src/main/ssh/ssh-remote-commands.test.ts index 758d4739b0f..76073d24d89 100644 --- a/src/main/ssh/ssh-remote-commands.test.ts +++ b/src/main/ssh/ssh-remote-commands.test.ts @@ -11,7 +11,7 @@ import { utimesSync } from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { basename, join } from 'node:path' import { describe, expect, it } from 'vitest' import { decodeRemotePowerShellScript } from './ssh-remote-powershell' import { @@ -31,6 +31,12 @@ import { relayLivenessProbeCommand } from './ssh-remote-commands' import { getRemoteHostPlatform } from './ssh-remote-platform' +import { + createRelayGcListingFixture, + readUncappedRelayGcPaths, + runCappedRelayGcShellCommand, + SSH_EXEC_OUTPUT_CAP_CHARS +} from './ssh-relay-gc-listing.test-fixture' import { RELAY_INSTALL_COMPLETE_FILENAME, relayArtifactFilenames @@ -256,22 +262,43 @@ describe('ssh remote command builders', () => { it.runIf(process.platform !== 'win32')( 'bounds real POSIX GC output with more than the exec-cap stage population', async () => { - const root = mkdtempSync(join(tmpdir(), 'orca-relay-gc-scale-')) + const fixture = createRelayGcListingFixture() try { - for (let index = 0; index < 15_197; index += 1) { - mkdirSync(join(root, `relay-0.1.0+abc.upload-${String(index).padStart(12, '0')}`)) + const paths = await readUncappedRelayGcPaths(fixture.findCommand) + expect(paths.join('\n').length).toBeGreaterThan(SSH_EXEC_OUTPUT_CAP_CHARS) + const cappedRaw = await runCappedRelayGcShellCommand( + `${fixture.findCommand} | LC_ALL=C sort` + ) + expect(cappedRaw.length).toBe(SSH_EXEC_OUTPUT_CAP_CHARS) + for (const name of fixture.validNames) { + expect(cappedRaw).not.toContain(join(fixture.root, name)) } - mkdirSync(join(root, 'relay-0.1.0+aaa')) - mkdirSync(join(root, 'relay-0.1.0+bbb')) - const output = await runShellCommand(listRelayBaseDirsCommand(posix, root)) + const output = await runCappedRelayGcShellCommand( + listRelayBaseDirsCommand(posix, fixture.root) + ) const entries = output.trim().split('\n') expect(entries).toEqual(['relay-0.1.0+aaa', 'relay-0.1.0+bbb']) expect(Buffer.byteLength(output)).toBeLessThan(1_024) expect(entries.length).toBeLessThanOrEqual(MAX_RELAY_GC_LISTING_ENTRIES) + + fixture.fillValidEntryBoundary() + const validNames = new Set(fixture.validNames) + const unboundedEntries = (await readUncappedRelayGcPaths(fixture.findCommand)) + .map((path) => basename(path)) + .filter((name) => validNames.has(name)) + expect(unboundedEntries).toHaveLength(65) + const boundedOutput = await runCappedRelayGcShellCommand( + listRelayBaseDirsCommand(posix, fixture.root) + ) + const boundedEntries = boundedOutput.trim().split('\n') + expect(boundedEntries).toEqual(unboundedEntries.slice(0, 64)) + expect(boundedEntries).toHaveLength(64) + expect(new Set(boundedEntries).size).toBe(64) + expect(Buffer.byteLength(boundedOutput)).toBeLessThan(1_024) } finally { - rmSync(root, { recursive: true, force: true }) + fixture.dispose() } }, 30_000 diff --git a/src/main/window/attach-main-window-services.test.ts b/src/main/window/attach-main-window-services.test.ts index 9e5c5493f09..6a685f2f982 100644 --- a/src/main/window/attach-main-window-services.test.ts +++ b/src/main/window/attach-main-window-services.test.ts @@ -1,6 +1,16 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { Store } from '../persistence' -import type { RuntimeNotifier } from '../runtime/runtime-notifier-contract' +import type { registerSshHandlers } from '../ipc/ssh' +import type { registerRemoteWorkspaceHandlers } from '../ipc/remote-workspace' +import type { registerDaemonManagementHandlers } from '../ipc/pty-management' +import type { registerWorkspaceCleanupHandlers } from '../ipc/workspace-cleanup' +import type { startFolderRepoGitUpgradeWatch } from '../ipc/folder-repo-git-upgrade' +import { + createMainWindowServiceStub, + createRuntime, + deferred, + type MainWindowStub +} from './main-window-service-stubs.test-fixture' const { onMock, @@ -17,6 +27,11 @@ const { setWorktreeCatalogRemoteClientNotifierMock, registerWorktreeHandlersMock, registerPtyHandlersMock, + registerSshHandlersMock, + registerRemoteWorkspaceHandlersMock, + registerDaemonManagementHandlersMock, + registerWorkspaceCleanupHandlersMock, + startFolderRepoGitUpgradeWatchMock, hydrateLocalPtyRegistryAtBootMock, setWorktreeBaseDirectoryWatcherSyncContextMock, scheduleWorktreeBaseDirectoryWatcherSyncMock, @@ -42,6 +57,12 @@ const { setWorktreeCatalogRemoteClientNotifierMock: vi.fn(), registerWorktreeHandlersMock: vi.fn(), registerPtyHandlersMock: vi.fn(), + registerSshHandlersMock: vi.fn<(...args: Parameters) => void>(), + registerRemoteWorkspaceHandlersMock: + vi.fn<(...args: Parameters) => void>(), + registerDaemonManagementHandlersMock: vi.fn(), + registerWorkspaceCleanupHandlersMock: vi.fn(), + startFolderRepoGitUpgradeWatchMock: vi.fn(), hydrateLocalPtyRegistryAtBootMock: vi.fn(), setWorktreeBaseDirectoryWatcherSyncContextMock: vi.fn(), scheduleWorktreeBaseDirectoryWatcherSyncMock: vi.fn(), @@ -99,6 +120,20 @@ vi.mock('../ipc/pty', () => ({ registerPtyHandlers: registerPtyHandlersMock })) +vi.mock('../ipc/ssh', () => ({ registerSshHandlers: registerSshHandlersMock })) +vi.mock('../ipc/remote-workspace', () => ({ + registerRemoteWorkspaceHandlers: registerRemoteWorkspaceHandlersMock +})) +vi.mock('../ipc/pty-management', () => ({ + registerDaemonManagementHandlers: registerDaemonManagementHandlersMock +})) +vi.mock('../ipc/workspace-cleanup', () => ({ + registerWorkspaceCleanupHandlers: registerWorkspaceCleanupHandlersMock +})) +vi.mock('../ipc/folder-repo-git-upgrade', () => ({ + startFolderRepoGitUpgradeWatch: startFolderRepoGitUpgradeWatchMock +})) + vi.mock('../memory/hydrate-local-pty-registry', () => ({ hydrateLocalPtyRegistryAtBoot: hydrateLocalPtyRegistryAtBootMock })) @@ -133,57 +168,16 @@ import { attachMainWindowServices } from './attach-main-window-services' type MockFn = ReturnType -type MainWindowStub = { - id?: number - isDestroyed?: MockFn - on: MockFn - once: MockFn - webContents: { - id?: number - getURL: MockFn - isDestroyed?: MockFn - isLoadingMainFrame: MockFn - on: MockFn - send?: MockFn - reload?: MockFn - session: { - setPermissionRequestHandler: MockFn - setPermissionCheckHandler: MockFn - } - } -} - -type RuntimeStub = { - attachWindow: MockFn - setNotifier: ReturnType void>> - markRendererReloading: MockFn - markRendererReloadCancelled: MockFn - markGraphReloadFailed: MockFn - markGraphUnavailable: MockFn -} - function createMainWindow( extraWebContents: { isLoadingMainFrame?: MockFn; on?: MockFn; send?: MockFn } = {} ): MainWindowStub { - return { - id: 1, - isDestroyed: vi.fn(() => false), - on: vi.fn(), - once: vi.fn(), - webContents: { - id: 1, - getURL: vi.fn(() => 'file:///opt/orca/renderer/index.html'), - isDestroyed: vi.fn(() => false), - isLoadingMainFrame: vi.fn(() => true), - on: vi.fn(), - reload: vi.fn(), - session: { - setPermissionRequestHandler: setPermissionRequestHandlerMock, - setPermissionCheckHandler: setPermissionCheckHandlerMock - }, - ...extraWebContents - } - } + return createMainWindowServiceStub( + { + setPermissionRequestHandler: setPermissionRequestHandlerMock, + setPermissionCheckHandler: setPermissionCheckHandlerMock + }, + extraWebContents + ) } function createStore(): Store & { flushPendingAsync: MockFn } { @@ -193,25 +187,6 @@ function createStore(): Store & { flushPendingAsync: MockFn } { } as unknown as Store & { flushPendingAsync: MockFn } } -function createRuntime(): RuntimeStub { - return { - attachWindow: vi.fn(), - setNotifier: vi.fn<(notifier: RuntimeNotifier | null) => void>(), - markRendererReloading: vi.fn(), - markRendererReloadCancelled: vi.fn(), - markGraphReloadFailed: vi.fn(), - markGraphUnavailable: vi.fn() - } -} - -function deferred(): { promise: Promise; resolve: () => void } { - let resolve!: () => void - const promise = new Promise((next) => { - resolve = next - }) - return { promise, resolve } -} - function getClosedHandlers(mainWindowOnMock: MockFn): (() => void)[] { return mainWindowOnMock.mock.calls .filter(([event]) => event === 'closed') @@ -239,11 +214,30 @@ describe('attachMainWindowServices', () => { it('gives host-local catalog notifiers the runtime', () => { const runtime = createRuntime() + const mainWindow = createMainWindow() + const store = createStore() - attachMainWindowServices(createMainWindow() as never, createStore(), runtime as never) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Stubs provide the window/runtime methods exercised by attachment. + attachMainWindowServices(mainWindow as never, store, runtime as never) expect(setRepoRemoteClientNotifierMock).toHaveBeenCalledWith(runtime) expect(setWorktreeCatalogRemoteClientNotifierMock).toHaveBeenCalledWith(runtime) + expect(registerSshHandlersMock).toHaveBeenCalledExactlyOnceWith( + store, + expect.any(Function), + runtime + ) + expect(registerSshHandlersMock.mock.calls[0]?.[1]()).toBe(mainWindow) + expect(registerRemoteWorkspaceHandlersMock).toHaveBeenCalledExactlyOnceWith( + store, + expect.any(Function), + runtime + ) + expect(registerRemoteWorkspaceHandlersMock.mock.calls[0]?.[1]()).toBe(mainWindow) + expect(registerDaemonManagementHandlersMock).toHaveBeenCalledExactlyOnceWith() + expect(registerDaemonManagementHandlersMock).toHaveBeenCalledAfter(registerPtyHandlersMock) + expect(registerWorkspaceCleanupHandlersMock).toHaveBeenCalledExactlyOnceWith(store) + expect(startFolderRepoGitUpgradeWatchMock).toHaveBeenCalledExactlyOnceWith(store, mainWindow) }) it('reloads the app renderer through main and marks expected renderer teardown', async () => { diff --git a/src/main/window/main-window-service-stubs.test-fixture.ts b/src/main/window/main-window-service-stubs.test-fixture.ts new file mode 100644 index 00000000000..2db0cbcc762 --- /dev/null +++ b/src/main/window/main-window-service-stubs.test-fixture.ts @@ -0,0 +1,80 @@ +import { vi } from 'vitest' +import type { RuntimeNotifier } from '../runtime/runtime-notifier-contract' + +type MockFn = ReturnType + +export type MainWindowStub = { + id?: number + isDestroyed?: MockFn + on: MockFn + once: MockFn + webContents: { + id?: number + getURL: MockFn + isDestroyed?: MockFn + isLoadingMainFrame: MockFn + on: MockFn + send?: MockFn + reload?: MockFn + session: { + setPermissionRequestHandler: MockFn + setPermissionCheckHandler: MockFn + } + } +} + +type RuntimeStub = { + attachWindow: MockFn + setNotifier: ReturnType void>> + markRendererReloading: MockFn + markRendererReloadCancelled: MockFn + markGraphReloadFailed: MockFn + markGraphUnavailable: MockFn +} + +export function createMainWindowServiceStub( + permissionHandlers: { + setPermissionRequestHandler: MockFn + setPermissionCheckHandler: MockFn + }, + extraWebContents: { isLoadingMainFrame?: MockFn; on?: MockFn; send?: MockFn } = {} +): MainWindowStub { + return { + id: 1, + isDestroyed: vi.fn(() => false), + on: vi.fn(), + once: vi.fn(), + webContents: { + id: 1, + getURL: vi.fn(() => 'file:///opt/orca/renderer/index.html'), + isDestroyed: vi.fn(() => false), + isLoadingMainFrame: vi.fn(() => true), + on: vi.fn(), + reload: vi.fn(), + session: { + setPermissionRequestHandler: permissionHandlers.setPermissionRequestHandler, + setPermissionCheckHandler: permissionHandlers.setPermissionCheckHandler + }, + ...extraWebContents + } + } +} + +export function createRuntime(): RuntimeStub { + return { + attachWindow: vi.fn(), + setNotifier: vi.fn<(notifier: RuntimeNotifier | null) => void>(), + markRendererReloading: vi.fn(), + markRendererReloadCancelled: vi.fn(), + markGraphReloadFailed: vi.fn(), + markGraphUnavailable: vi.fn() + } +} + +export function deferred(): { promise: Promise; resolve: () => void } { + let resolve!: () => void + const promise = new Promise((next) => { + resolve = next + }) + return { promise, resolve } +} diff --git a/src/renderer/src/components/diff-comments/useDiffCommentDecorator.range-selection.test.tsx b/src/renderer/src/components/diff-comments/useDiffCommentDecorator.range-selection.test.tsx index 2708e63a137..1c1e802e650 100644 --- a/src/renderer/src/components/diff-comments/useDiffCommentDecorator.range-selection.test.tsx +++ b/src/renderer/src/components/diff-comments/useDiffCommentDecorator.range-selection.test.tsx @@ -3,6 +3,16 @@ import { act, renderHook } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { Selection } from 'monaco-editor' import type * as DiffCommentZoneCardModule from './diff-comment-zone-card' +import type { NotesSendMenu } from '../editor/NotesSendMenu' + +const notesMenuFixture = vi.hoisted(() => ({ + NotesSendMenu: vi.fn(() => { + throw new Error('Range selection must not render the agent notes menu') + }) +})) + +// Saved-note delivery is outside the selection and inline-draft paths exercised here. +vi.mock('../editor/NotesSendMenu', () => ({ NotesSendMenu: notesMenuFixture.NotesSendMenu })) const storeFixture = vi.hoisted(() => ({ activeGroupIdByWorktree: {}, @@ -146,7 +156,11 @@ beforeEach(() => { afterEach(() => { vi.unstubAllGlobals() document.body.replaceChildren() - vi.clearAllMocks() + try { + expect(notesMenuFixture.NotesSendMenu).not.toHaveBeenCalled() + } finally { + vi.clearAllMocks() + } }) describe('useDiffCommentDecorator range highlight', () => { diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx index 460df23ef8b..c5553f00edb 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx @@ -185,9 +185,20 @@ vi.mock('./NativeChatQuestionCard', () => ({ import { NativeChatStructuredSession } from './NativeChatStructuredSession' import { appendStructuredAgentSessionOutboxMessage } from './structured-agent-session-outbox-storage' +function useProbeClock(): void { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) +} + +async function advanceProbeClock(milliseconds: number): Promise { + await act(async () => { + await vi.advanceTimersByTimeAsync(milliseconds) + }) +} + describe('NativeChatStructuredSession delivery', () => { afterEach(() => { cleanup() + vi.useRealTimers() mocks.call.mockReset() mocks.mode = 'static' mocks.messageListProps = null @@ -461,6 +472,7 @@ describe('NativeChatStructuredSession delivery', () => { }) it('resends a transport-unconfirmed head so later messages are not wedged', async () => { + useProbeClock() mocks.mode = 'outbox' mocks.submissions = [] mocks.call.mockRejectedValueOnce(new Error('socket closed')).mockResolvedValue({ @@ -482,17 +494,25 @@ describe('NativeChatStructuredSession delivery', () => { const send = mocks.composerProps?.structuredTransport?.send as | ((text: string, attachments: readonly { id: string; path: string }[]) => boolean) | undefined - expect(send?.('first', [])).toBe(true) - await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) - await waitFor(() => expect(screen.getByText('Message delivery is unconfirmed.')).toBeTruthy()) + await act(async () => { + expect(send?.('first', [])).toBe(true) + }) + expect(mocks.call).toHaveBeenCalledOnce() + expect(screen.getByText('Message delivery is unconfirmed.')).toBeTruthy() - expect(send?.('second', [])).toBe(true) + await act(async () => { + expect(send?.('second', [])).toBe(true) + }) + await advanceProbeClock(999) + expect(mocks.call).toHaveBeenCalledOnce() + await advanceProbeClock(1) // The head is probed automatically, clears, and the queue drains. - await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(3), { timeout: 10000 }) - await waitFor(() => expect(screen.queryByText('Message delivery is unconfirmed.')).toBeNull()) + expect(mocks.call).toHaveBeenCalledTimes(3) + expect(screen.queryByText('Message delivery is unconfirmed.')).toBeNull() }, 20000) it('probes the same operation without marking an explicit user retry', async () => { + useProbeClock() mocks.mode = 'outbox' mocks.submissions = [] mocks.call.mockRejectedValueOnce(new Error('socket closed')).mockResolvedValue({ @@ -514,8 +534,13 @@ describe('NativeChatStructuredSession delivery', () => { const send = mocks.composerProps?.structuredTransport?.send as | ((text: string, attachments: readonly { id: string; path: string }[]) => boolean) | undefined - expect(send?.('first', [])).toBe(true) - await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2), { timeout: 10000 }) + await act(async () => { + expect(send?.('first', [])).toBe(true) + }) + await advanceProbeClock(999) + expect(mocks.call).toHaveBeenCalledOnce() + await advanceProbeClock(1) + expect(mocks.call).toHaveBeenCalledTimes(2) const first = mocks.call.mock.calls[0]?.[2] as Record const probe = mocks.call.mock.calls[1]?.[2] as Record @@ -527,6 +552,7 @@ describe('NativeChatStructuredSession delivery', () => { }, 20000) it('parks a host-confirmed unknown instead of probing it', async () => { + useProbeClock() mocks.mode = 'outbox' mocks.call.mockRejectedValueOnce(new Error('socket closed')).mockResolvedValue({ ok: true, @@ -547,8 +573,10 @@ describe('NativeChatStructuredSession delivery', () => { const send = mocks.composerProps?.structuredTransport?.send as | ((text: string, attachments: readonly { id: string; path: string }[]) => boolean) | undefined - expect(send?.('first', [])).toBe(true) - await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) + await act(async () => { + expect(send?.('first', [])).toBe(true) + }) + expect(mocks.call).toHaveBeenCalledOnce() const sent = mocks.call.mock.calls[0]?.[2] as { envelope: { clientOperationId: string } } // The host now reports an unresolved unknown: another replay is the user's call. @@ -569,13 +597,12 @@ describe('NativeChatStructuredSession delivery', () => { await act(async () => { send?.('second', []) }) - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 3000)) - }) + await advanceProbeClock(3000) expect(mocks.call).toHaveBeenCalledOnce() }, 20000) it('still probes while streaming batches rebuild the submissions array', async () => { + useProbeClock() mocks.mode = 'outbox' mocks.submissions = [] mocks.call.mockRejectedValueOnce(new Error('socket closed')).mockResolvedValue({ @@ -598,8 +625,10 @@ describe('NativeChatStructuredSession delivery', () => { const send = mocks.composerProps?.structuredTransport?.send as | ((text: string, attachments: readonly { id: string; path: string }[]) => boolean) | undefined - expect(send?.('first', [])).toBe(true) - await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) + await act(async () => { + expect(send?.('first', [])).toBe(true) + }) + expect(mocks.call).toHaveBeenCalledOnce() // Each batch mints a fresh submissions array for an unrelated message. An // array-identity dependency restarts the backoff on every one of these, so a @@ -619,7 +648,7 @@ describe('NativeChatStructuredSession delivery', () => { ] await act(async () => { rerender(makeView()) - await new Promise((resolve) => setTimeout(resolve, 250)) + await vi.advanceTimersByTimeAsync(250) }) } @@ -629,6 +658,7 @@ describe('NativeChatStructuredSession delivery', () => { }, 20000) it('restarts probe delay when the runtime target changes', async () => { + useProbeClock() mocks.mode = 'outbox' mocks.call.mockRejectedValueOnce(new Error('socket closed')).mockResolvedValue({ ok: true, @@ -651,22 +681,24 @@ describe('NativeChatStructuredSession delivery', () => { const send = mocks.composerProps?.structuredTransport?.send as | ((text: string, attachments: readonly { id: string; path: string }[]) => boolean) | undefined - expect(send?.('first', [])).toBe(true) - await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) - await waitFor(() => expect(screen.getByText('Message delivery is unconfirmed.')).toBeTruthy()) - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 300)) - }) - rerender(makeView({ kind: 'environment', environmentId: 'env-1' })) - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 600)) + expect(send?.('first', [])).toBe(true) }) expect(mocks.call).toHaveBeenCalledOnce() - await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2), { timeout: 1500 }) + expect(screen.getByText('Message delivery is unconfirmed.')).toBeTruthy() + + await advanceProbeClock(300) + rerender(makeView({ kind: 'environment', environmentId: 'env-1' })) + await advanceProbeClock(600) + expect(mocks.call).toHaveBeenCalledOnce() + await advanceProbeClock(399) + expect(mocks.call).toHaveBeenCalledOnce() + await advanceProbeClock(1) + expect(mocks.call).toHaveBeenCalledTimes(2) }, 10000) it('never auto-probes an entry the user already force-retried', async () => { + useProbeClock() mocks.mode = 'outbox' mocks.submissions = [] // Both the original send and the user's explicit Retry fail at the transport. @@ -686,25 +718,28 @@ describe('NativeChatStructuredSession delivery', () => { const send = mocks.composerProps?.structuredTransport?.send as | ((text: string, attachments: readonly { id: string; path: string }[]) => boolean) | undefined - expect(send?.('first', [])).toBe(true) - await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) - await waitFor(() => expect(screen.getByText('Message delivery is unconfirmed.')).toBeTruthy()) + await act(async () => { + expect(send?.('first', [])).toBe(true) + }) + expect(mocks.call).toHaveBeenCalledOnce() + expect(screen.getByText('Message delivery is unconfirmed.')).toBeTruthy() // User retries with the same envelope and no legacy redelivery signal. - fireEvent.click(screen.getByRole('button', { name: /Retry/ })) - await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2)) + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /Retry/ })) + }) + expect(mocks.call).toHaveBeenCalledTimes(2) const forcedRequest = mocks.call.mock.calls[1]?.[2] as Record | undefined expect(forcedRequest?.retryUnknown).toBeUndefined() // That retry also failed at the transport. The probe must not repeat an // explicit retry automatically. - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 3000)) - }) + await advanceProbeClock(3000) expect(mocks.call).toHaveBeenCalledTimes(2) }, 20000) it('does not hot-loop when the host answers pending', async () => { + useProbeClock() mocks.mode = 'outbox' mocks.call.mockResolvedValue({ ok: true, @@ -725,15 +760,18 @@ describe('NativeChatStructuredSession delivery', () => { const send = mocks.composerProps?.structuredTransport?.send as | ((text: string, attachments: readonly { id: string; path: string }[]) => boolean) | undefined - expect(send?.('first', [])).toBe(true) - await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) - - // A pending row parks the entry under the backoff instead of re-dispatching - // immediately. Without that, this window is an unbounded back-to-back RPC flood. await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 2500)) + expect(send?.('first', [])).toBe(true) }) - expect(mocks.call.mock.calls.length).toBeLessThanOrEqual(3) + expect(mocks.call).toHaveBeenCalledOnce() + + // A host-pending entry stays parked until the journal answers it. + await advanceProbeClock(999) + expect(mocks.call).toHaveBeenCalledOnce() + await advanceProbeClock(1) + expect(mocks.call).toHaveBeenCalledOnce() + await advanceProbeClock(1500) + expect(mocks.call).toHaveBeenCalledOnce() }, 20000) it('keeps probing past the old five-attempt budget', async () => { From 70cf91299b2a30b16eaef3fa3ec4d3265fe82265 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 4 Oct 2026 05:40:05 -0700 Subject: [PATCH 11/31] Clean up retired OpenCode configuration copies safely (#25222) * fix: wait for OpenCode worker composer before first dispatch Reuse captured composer readiness on local and paired execution hosts and revoke launching-shell paste anchors. Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> * feat(opencode): probe execution-host CLI capabilities * fix(opencode): select plugin default for execution host loader * fix(opencode): limit prompt prefill capability to verified release * feat(opencode): probe launch capabilities on the execution host * fix(opencode): select plugin loader for the launched host binary * fix(opencode): match WSL probe cwd and declared guest environment * fix(opencode): preserve launch environment deletion boundaries * wip(opencode): authorize native startup prompt intent at execution owner * fix(opencode): atomically replace status plugin entrypoints * fix(opencode): retain plugin permissions across restrictive umasks * test(opencode): resolve permission fixture from primary cwd * feat(opencode): install startup prompt plugin independently of status hooks * fix(opencode): wait for admitted startup intent and preserve failed-launch briefs * fix(opencode): confine overlay manifest cleanup to owned directories Co-authored-by: Adnan Khan * fix: wait for OpenCode worker composer before first dispatch Reuse captured composer readiness on local and paired execution hosts and revoke launching-shell paste anchors. Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> * feat(opencode): probe execution-host CLI capabilities * fix(opencode): select plugin default for execution host loader * fix(opencode): limit prompt prefill capability to verified release * feat(opencode): probe launch capabilities on the execution host * fix(opencode): select plugin loader for the launched host binary * fix(opencode): match WSL probe cwd and declared guest environment * fix(opencode): preserve launch environment deletion boundaries * wip(opencode): authorize native startup prompt intent at execution owner * fix(opencode): atomically replace status plugin entrypoints * fix(opencode): retain plugin permissions across restrictive umasks * test(opencode): resolve permission fixture from primary cwd * feat(opencode): install startup prompt plugin independently of status hooks * fix(opencode): wait for admitted startup intent and preserve failed-launch briefs * fix(opencode): unsubscribe hook settings during async host shutdown * STRICT launch CI contract correction * CAPS launch CI contract correction * INTENT launch CI contract correction * test: initialize Claude prompt state in output retention fixture * Wait for OpenCode location hydration in intent startup * Bind OpenCode startup readiness to the current location in intent startup * Collect retired source-scoped OpenCode configuration overlays conservatively Credit brennanb2025 for the original bounded, delayed overlay garbage-collection contribution in PR #7627. Preserve ambiguous legacy and shared-service state. * Correct inaccessible-source fixture without spying on native ESM exports * Keep delayed OpenCode cleanup within existing file limits * Reuse the overlay manifest module for existing owned-entry operations * Use the existing filesystem import in the ownership mock * test(opencode): keep overlay GC link tests portable --------- Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Co-authored-by: Ahmed Nagy Co-authored-by: Adnan Khan Co-authored-by: Orca startup hydration review Co-authored-by: OpenCode Campaign --- src/main/opencode/hook-service.ts | 61 ++--- .../opencode-overlay-manifest.test.ts | 29 +++ .../opencode/opencode-overlay-manifest.ts | 39 ++- .../opencode/overlay-dir-gc-lifecycle.test.ts | 30 +++ src/main/opencode/overlay-dir-gc-lifecycle.ts | 46 ++++ src/main/opencode/overlay-dir-gc.test.ts | 227 ++++++++++++++++++ src/main/opencode/overlay-dir-gc.ts | 206 ++++++++++++++++ src/main/opencode/overlay-dir-names.ts | 12 + .../opencode/overlay-source-ownership.test.ts | 90 +++++++ src/main/opencode/overlay-source-ownership.ts | 63 +++++ .../window/attach-main-window-services.ts | 4 + 11 files changed, 767 insertions(+), 40 deletions(-) create mode 100644 src/main/opencode/overlay-dir-gc-lifecycle.test.ts create mode 100644 src/main/opencode/overlay-dir-gc-lifecycle.ts create mode 100644 src/main/opencode/overlay-dir-gc.test.ts create mode 100644 src/main/opencode/overlay-dir-gc.ts create mode 100644 src/main/opencode/overlay-dir-names.ts create mode 100644 src/main/opencode/overlay-source-ownership.test.ts create mode 100644 src/main/opencode/overlay-source-ownership.ts diff --git a/src/main/opencode/hook-service.ts b/src/main/opencode/hook-service.ts index 1461b8773dc..7a6d137ebfa 100644 --- a/src/main/opencode/hook-service.ts +++ b/src/main/opencode/hook-service.ts @@ -7,11 +7,9 @@ import { readFileSync, readdirSync, realpathSync, - statSync, - writeFileSync + statSync } from 'node:fs' -import { createHash } from 'node:crypto' -import { isSafeDescendCandidate, mirrorEntry, safeRemoveOverlay } from '../pty/overlay-mirror' +import { isSafeDescendCandidate, mirrorEntry } from '../pty/overlay-mirror' import { getOpenCode2PluginSource, getOpenCodeFamilyPluginSource, @@ -19,7 +17,8 @@ import { } from './status-plugin-module-source' import { readOpenCodeOverlayManifest, - OPENCODE_OVERLAY_MANIFEST_FILE, + clearOpenCodeOverlayManifestEntries, + writeOpenCodeOverlayManifest, type OpenCodeOverlayManifest } from './opencode-overlay-manifest' import { resolveOpenCodeConfigDirectory } from '../../shared/opencode-config-directory' @@ -33,15 +32,19 @@ import { writeOpenCodeTuiPlugin } from '../../shared/opencode-tui-plugin-install' import { writeLegacyOpenCodePluginWithAclRetry } from './legacy-plugin-acl-retry' +import { + OPENCODE_OVERLAY_DIR, + ORCA_OPENCODE_PLUGIN_FILE, + sourceOverlayDirName, + toSafeDirName +} from './overlay-dir-names' +import { OpenCodeDirGcLifecycle } from './overlay-dir-gc-lifecycle' export { getOpenCode2PluginSource, getOpenCodeFamilyPluginSource, getOpenCodePluginSource } import { writeCanonicalOpenCodePluginAtomically } from '../../shared/opencode-plugin-atomic-write' import { writeOpenCodePluginConfig } from './opencode-plugin-config-writer' -const ORCA_OPENCODE_PLUGIN_FILE = 'orca-opencode-status.js' -const OPENCODE_OVERLAY_DIR = 'opencode-config-overlays' - type OpenCodeHookVariant = { pluginFileName: string legacyHooksDir: string @@ -57,11 +60,6 @@ function isUsableId(id: string): boolean { return typeof id === 'string' && id.length > 0 && id.length <= 1024 } -function toSafeDirName(id: string): string { - // Why: 32 hex chars (128 bits) makes collisions negligible and stays filesystem-portable (no base64 padding or `/`). - return createHash('sha256').update(id).digest('hex').slice(0, 32) -} - // Why: installs the plugin into OpenCode's config discovery path so it POSTs to the shared agent-hooks server, unifying OpenCode status with Claude/Codex/Gemini. export class OpenCodeHookService { private readonly pluginSource: () => string @@ -70,6 +68,7 @@ export class OpenCodeHookService { private readonly overlayDir: string private readonly installsTuiPlugin: boolean private readonly tuiOnlyDirectory: string | undefined + readonly configDirGc: OpenCodeDirGcLifecycle constructor(variant?: OpenCodeHookVariant | (() => string)) { const config: OpenCodeHookVariant = @@ -93,6 +92,7 @@ export class OpenCodeHookService { this.pluginFileName = config.pluginFileName this.legacyHooksDir = config.legacyHooksDir this.overlayDir = config.overlayDir + this.configDirGc = new OpenCodeDirGcLifecycle(() => this.getOverlayRoot(), this.pluginFileName) } clearPty(_ptyId: string): void { @@ -114,6 +114,7 @@ export class OpenCodeHookService { } } this.writePluginIntoOverlay(directory) + owner.configDirGc.reference(directory) return 'installed' } catch { return 'failed' @@ -152,6 +153,7 @@ export class OpenCodeHookService { this.mirrorUserConfig(existingConfigDir, overlayDir) } this.writePluginIntoOverlay(overlayDir) + this.configDirGc.reference(overlayDir) return { OPENCODE_CONFIG_DIR: overlayDir } } catch { return { OPENCODE_CONFIG_DIR: existingConfigDir } @@ -217,7 +219,7 @@ export class OpenCodeHookService { } private getSourceOverlayDir(sourceConfigDir: string): string { - return join(this.getOverlayRoot(), toSafeDirName(`source:${sourceConfigDir}`)) + return join(this.getOverlayRoot(), sourceOverlayDirName(sourceConfigDir)) } private getSharedConfigDir(): string { @@ -227,34 +229,17 @@ export class OpenCodeHookService { ) } - private writeOverlayManifest(overlayDir: string, manifest: OpenCodeOverlayManifest): void { - writeFileSync( - join(overlayDir, OPENCODE_OVERLAY_MANIFEST_FILE), - `${JSON.stringify(manifest, null, 2)}\n` - ) - } - - private clearManifestEntries(overlayDir: string, manifest: OpenCodeOverlayManifest): void { - for (const entryName of manifest.topLevelEntries) { - safeRemoveOverlay(join(overlayDir, entryName), overlayDir) - } - - const overlayPluginsDir = join(overlayDir, 'plugins') - for (const entryName of manifest.pluginEntries) { - if (entryName === this.pluginFileName) { - continue - } - safeRemoveOverlay(join(overlayPluginsDir, entryName), overlayPluginsDir) - } - } - // Why: mirror user config entries as symlinks so edits propagate live; only plugins/ becomes a real overlay dir so Orca can drop a sibling plugin file. private mirrorUserConfig(sourceDir: string, overlayDir: string): void { const previousManifest = readOpenCodeOverlayManifest(overlayDir) // Why: overlays persist across terminals; remove only Orca-mirrored paths so stale user config clears but OpenCode runtime dirs (node_modules) survive. - this.clearManifestEntries(overlayDir, previousManifest) + clearOpenCodeOverlayManifestEntries(overlayDir, previousManifest, this.pluginFileName) - const nextManifest: OpenCodeOverlayManifest = { topLevelEntries: [], pluginEntries: [] } + const nextManifest: OpenCodeOverlayManifest = { + topLevelEntries: [], + pluginEntries: [], + sourceConfigDir: sourceDir + } for (const entry of readdirSync(sourceDir, { withFileTypes: true })) { const sourcePath = join(sourceDir, entry.name) @@ -301,7 +286,7 @@ export class OpenCodeHookService { nextManifest.topLevelEntries.push(entry.name) } - this.writeOverlayManifest(overlayDir, nextManifest) + writeOpenCodeOverlayManifest(overlayDir, nextManifest) } private writePluginIntoOverlay(overlayDir: string): void { diff --git a/src/main/opencode/opencode-overlay-manifest.test.ts b/src/main/opencode/opencode-overlay-manifest.test.ts index 7dc0404a53e..65c2c765bbb 100644 --- a/src/main/opencode/opencode-overlay-manifest.test.ts +++ b/src/main/opencode/opencode-overlay-manifest.test.ts @@ -30,3 +30,32 @@ it('accepts only string manifest entries and tolerates invalid persisted shapes' rmSync(root, { recursive: true, force: true }) } }) + +it('keeps source ownership optional for older manifests and rejects non-string metadata', () => { + const root = mkdtempSync(join(tmpdir(), 'orca-overlay-source-manifest-')) + try { + const file = join(root, OPENCODE_OVERLAY_MANIFEST_FILE) + for (const source of [undefined, null, 42, {}]) { + writeFileSync( + file, + JSON.stringify({ topLevelEntries: [], pluginEntries: [], sourceConfigDir: source }) + ) + expect(readOpenCodeOverlayManifest(root)).toEqual({ topLevelEntries: [], pluginEntries: [] }) + } + writeFileSync( + file, + JSON.stringify({ + topLevelEntries: ['opencode.json'], + pluginEntries: [], + sourceConfigDir: '/owned/source' + }) + ) + expect(readOpenCodeOverlayManifest(root)).toEqual({ + topLevelEntries: ['opencode.json'], + pluginEntries: [], + sourceConfigDir: '/owned/source' + }) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) diff --git a/src/main/opencode/opencode-overlay-manifest.ts b/src/main/opencode/opencode-overlay-manifest.ts index 5abd0328f25..a256c14f8ca 100644 --- a/src/main/opencode/opencode-overlay-manifest.ts +++ b/src/main/opencode/opencode-overlay-manifest.ts @@ -1,8 +1,13 @@ -import { readFileSync } from 'node:fs' +import { readFileSync, writeFileSync } from 'node:fs' import { join } from 'node:path' +import { safeRemoveOverlay } from '../pty/overlay-mirror' export const OPENCODE_OVERLAY_MANIFEST_FILE = '.orca-opencode-overlay-manifest.json' -export type OpenCodeOverlayManifest = { topLevelEntries: string[]; pluginEntries: string[] } +export type OpenCodeOverlayManifest = { + topLevelEntries: string[] + pluginEntries: string[] + sourceConfigDir?: string +} export function readOpenCodeOverlayManifest(overlayDir: string): OpenCodeOverlayManifest { const empty = { topLevelEntries: [], pluginEntries: [] } @@ -14,6 +19,9 @@ export function readOpenCodeOverlayManifest(overlayDir: string): OpenCodeOverlay return empty } return { + ...('sourceConfigDir' in parsed && typeof parsed.sourceConfigDir === 'string' + ? { sourceConfigDir: parsed.sourceConfigDir } + : {}), topLevelEntries: 'topLevelEntries' in parsed && Array.isArray(parsed.topLevelEntries) ? parsed.topLevelEntries.filter((entry): entry is string => typeof entry === 'string') @@ -27,3 +35,30 @@ export function readOpenCodeOverlayManifest(overlayDir: string): OpenCodeOverlay return empty } } + +export function writeOpenCodeOverlayManifest( + overlayDir: string, + manifest: OpenCodeOverlayManifest +): void { + writeFileSync( + join(overlayDir, OPENCODE_OVERLAY_MANIFEST_FILE), + `${JSON.stringify(manifest, null, 2)}\n` + ) +} + +export function clearOpenCodeOverlayManifestEntries( + overlayDir: string, + manifest: OpenCodeOverlayManifest, + pluginFileName: string +): void { + for (const entryName of manifest.topLevelEntries) { + safeRemoveOverlay(join(overlayDir, entryName), overlayDir) + } + const overlayPluginsDir = join(overlayDir, 'plugins') + for (const entryName of manifest.pluginEntries) { + if (entryName === pluginFileName) { + continue + } + safeRemoveOverlay(join(overlayPluginsDir, entryName), overlayPluginsDir) + } +} diff --git a/src/main/opencode/overlay-dir-gc-lifecycle.test.ts b/src/main/opencode/overlay-dir-gc-lifecycle.test.ts new file mode 100644 index 00000000000..6149c1094fe --- /dev/null +++ b/src/main/opencode/overlay-dir-gc-lifecycle.test.ts @@ -0,0 +1,30 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { OpenCodeDirGcLifecycle } from './overlay-dir-gc-lifecycle' + +afterEach(() => { + vi.restoreAllMocks() + vi.useRealTimers() +}) + +it('schedules one delayed sweep without holding the app open', async () => { + vi.useFakeTimers() + const timeout = vi.spyOn(globalThis, 'setTimeout') + const lifecycle = new OpenCodeDirGcLifecycle(() => '/unused-test-root', 'unused-plugin.js') + const run = vi.spyOn(lifecycle, 'run').mockResolvedValue({ + scanned: 0, + removed: 0, + failed: 0, + keptReferenced: 0, + keptYoung: 0, + keptSourcePresent: 0, + keptUnverifiable: 0 + }) + const inventory = vi.fn(async () => []) + lifecycle.schedule(inventory) + lifecycle.schedule(inventory) + expect(timeout.mock.results[0]?.value.hasRef()).toBe(false) + await vi.advanceTimersByTimeAsync(179999) + expect(run).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(1) + expect(run).toHaveBeenCalledExactlyOnceWith(inventory) +}) diff --git a/src/main/opencode/overlay-dir-gc-lifecycle.ts b/src/main/opencode/overlay-dir-gc-lifecycle.ts new file mode 100644 index 00000000000..df84b28bf18 --- /dev/null +++ b/src/main/opencode/overlay-dir-gc-lifecycle.ts @@ -0,0 +1,46 @@ +import { OPENCODE_CONFIG_DIR_ENV_KEYS } from './legacy-shared-config-dir' +import { sweepOrphanedOpenCodeDirs, type OpenCodeDirGcResult } from './overlay-dir-gc' + +type ReadLivePtyIds = () => Promise + +export class OpenCodeDirGcLifecycle { + private readonly references = new Set() + private scheduled = false + + constructor( + private readonly getRoot: () => string, + private readonly pluginFileName: string + ) {} + + reference(directory: string): void { + this.references.add(directory) + } + + schedule(readLivePtyIds: ReadLivePtyIds, delayMs = 3 * 60_000): void { + if (this.scheduled) { + return + } + this.scheduled = true + const timer = setTimeout(() => { + void this.run(readLivePtyIds).catch((error) => { + console.warn('[OpenCode] Overlay cleanup skipped:', error) + }) + }, delayMs) + timer.unref() + } + + async run(readLivePtyIds: ReadLivePtyIds): Promise { + for (const key of OPENCODE_CONFIG_DIR_ENV_KEYS) { + const value = process.env[key] + if (value) { + this.references.add(value) + } + } + return sweepOrphanedOpenCodeDirs({ + overlayRoot: this.getRoot(), + pluginFileName: this.pluginFileName, + referencedConfigDirs: this.references, + readLivePtyIds + }) + } +} diff --git a/src/main/opencode/overlay-dir-gc.test.ts b/src/main/opencode/overlay-dir-gc.test.ts new file mode 100644 index 00000000000..e2472477154 --- /dev/null +++ b/src/main/opencode/overlay-dir-gc.test.ts @@ -0,0 +1,227 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + symlinkSync, + utimesSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { setAppEnvironment } from '../../shared/app-environment' +import { OpenCodeHookService } from './hook-service' +import { OPENCODE_OVERLAY_MANIFEST_FILE } from './opencode-overlay-manifest' +import { OPENCODE_DIR_GC_MIN_AGE_MS, sweepOrphanedOpenCodeDirs } from './overlay-dir-gc' +import { ORCA_OPENCODE_PLUGIN_FILE, sourceOverlayDirName } from './overlay-dir-names' + +let root: string +let overlays: string +const now = Date.now() + +beforeEach(() => { + root = realpathSync(mkdtempSync(join(tmpdir(), 'orca-overlay-gc-'))) + overlays = join(root, 'opencode-config-overlays') + mkdirSync(overlays) + vi.stubEnv('XDG_CONFIG_HOME', join(root, 'xdg')) + for (const key of [ + 'OPENCODE_CONFIG_DIR', + 'ORCA_OPENCODE_CONFIG_DIR', + 'ORCA_OPENCODE_SOURCE_CONFIG_DIR' + ]) { + vi.stubEnv(key, '') + } + setAppEnvironment({ + getPath: () => root, + getAppPath: () => root, + getVersion: () => 'test', + isPackaged: () => false, + onWillQuit: () => {}, + exit: () => {}, + getAppMetrics: () => [] + }) +}) +afterEach(() => { + vi.useRealTimers() + vi.unstubAllEnvs() + vi.restoreAllMocks() + rmSync(root, { recursive: true, force: true }) +}) + +function age(directory: string): void { + const old = new Date(now - OPENCODE_DIR_GC_MIN_AGE_MS * 2) + for (const suffix of [ + '', + OPENCODE_OVERLAY_MANIFEST_FILE, + 'plugins', + join('plugins', ORCA_OPENCODE_PLUGIN_FILE) + ]) { + utimesSync(join(directory, suffix), old, old) + } +} + +function createOverlay(name: string): { source: string; directory: string } { + const source = join(root, name) + const directory = join(overlays, sourceOverlayDirName(source)) + mkdirSync(join(directory, 'plugins'), { recursive: true }) + writeFileSync(join(directory, 'plugins', ORCA_OPENCODE_PLUGIN_FILE), 'export default {}') + writeFileSync( + join(directory, OPENCODE_OVERLAY_MANIFEST_FILE), + JSON.stringify({ + topLevelEntries: [], + pluginEntries: [], + sourceConfigDir: source + }) + ) + age(directory) + return { source, directory } +} + +function sweep(extra: Partial[0]> = {}) { + return sweepOrphanedOpenCodeDirs({ + overlayRoot: overlays, + pluginFileName: ORCA_OPENCODE_PLUGIN_FILE, + referencedConfigDirs: new Set(), + readLivePtyIds: async () => [], + now, + yieldBetweenRemovals: async () => {}, + ...extra + }) +} + +it('collects only an old, owned missing source while retaining active and reusable sources', async () => { + const retired = createOverlay('retired') + const active = createOverlay('active') + const reusable = createOverlay('reusable') + mkdirSync(active.source) + mkdirSync(reusable.source) + const result = await sweep() + expect(result.removed).toBe(1) + expect(result.keptSourcePresent).toBe(2) + expect(existsSync(retired.directory)).toBe(false) + expect(existsSync(active.directory)).toBe(true) + expect(existsSync(reusable.directory)).toBe(true) +}) + +it.each([null, ['surviving-daemon-pty']] as const)( + 'keeps a retired source with an empty fresh-process reference cache when inventory is %s', + async (ids) => { + const retired = createOverlay('retired') + expect((await sweep({ readLivePtyIds: async () => ids })).removed).toBe(0) + expect(existsSync(retired.directory)).toBe(true) + } +) + +it('preserves a candidate when owning-host inventory fails', async () => { + const retired = createOverlay('retired') + expect( + ( + await sweep({ + readLivePtyIds: async () => { + throw new Error('host unavailable') + } + }) + ).keptUnverifiable + ).toBe(1) + expect(existsSync(retired.directory)).toBe(true) +}) + +it.each(['service.json', 'service-local.json'])( + 'preserves %s without guessing service process death', + async (file) => { + const retired = createOverlay('retired') + writeFileSync(join(retired.directory, file), '{}') + age(retired.directory) + const inventory = vi.fn(async () => []) + expect((await sweep({ readLivePtyIds: inventory })).keptUnverifiable).toBe(1) + expect(inventory).not.toHaveBeenCalled() + expect(existsSync(retired.directory)).toBe(true) + } +) + +it('keeps a young candidate and refreshes reference protection after an asynchronous inventory', async () => { + const young = createOverlay('young') + utimesSync( + join(young.directory, 'plugins', ORCA_OPENCODE_PLUGIN_FILE), + new Date(now), + new Date(now) + ) + const retired = createOverlay('retired') + const references = new Set() + const result = await sweep({ + referencedConfigDirs: references, + readLivePtyIds: async () => { + references.add(join(retired.directory, 'plugins')) + return [] + } + }) + expect(result.keptYoung).toBe(1) + expect(result.keptReferenced).toBe(1) + expect(existsSync(retired.directory)).toBe(true) +}) + +it('does not follow a replaced overlay root or a source ancestor link', async () => { + const retired = createOverlay('retired') + const target = join(root, 'elsewhere') + mkdirSync(target) + symlinkSync(target, retired.source, process.platform === 'win32' ? 'junction' : 'dir') + expect((await sweep()).removed).toBe(0) + rmSync(overlays, { recursive: true }) + symlinkSync(target, overlays, process.platform === 'win32' ? 'junction' : 'dir') + expect((await sweep()).scanned).toBe(0) + expect(existsSync(target)).toBe(true) +}) + +it('retains unowned names, ambiguous old manifests and mismatched source hashes', async () => { + for (const name of ['shared', '123', 'human']) { + mkdirSync(join(overlays, name)) + } + const ambiguous = createOverlay('ambiguous') + writeFileSync( + join(ambiguous.directory, OPENCODE_OVERLAY_MANIFEST_FILE), + JSON.stringify({ topLevelEntries: [], pluginEntries: [] }) + ) + const mismatch = createOverlay('mismatch') + writeFileSync( + join(mismatch.directory, OPENCODE_OVERLAY_MANIFEST_FILE), + JSON.stringify({ topLevelEntries: [], pluginEntries: [], sourceConfigDir: root }) + ) + expect((await sweep()).keptUnverifiable).toBe(5) +}) + +it('counts failed deletion attempts toward the bound and yields between them', async () => { + for (let i = 0; i < 4; i += 1) { + createOverlay(`retired-${i}`) + } + const remove = vi.fn(() => { + throw new Error('busy') + }) + const yieldBetween = vi.fn(async () => {}) + const result = await sweep({ + maxRemovals: 2, + removeTree: remove, + yieldBetweenRemovals: yieldBetween + }) + expect(result.failed).toBe(2) + expect(remove).toHaveBeenCalledTimes(2) + expect(yieldBetween).toHaveBeenCalledTimes(2) +}) + +it('retains handed-out and inherited source references through clearPty', async () => { + const source = join(root, 'source') + mkdirSync(source) + writeFileSync(join(source, 'opencode.json'), '{}') + const service = new OpenCodeHookService(() => 'export default {}') + const directory = service.buildPtyEnv('pane', source).OPENCODE_CONFIG_DIR + if (!directory) { + throw new Error('Expected overlay') + } + rmSync(source, { recursive: true }) + age(directory) + service.clearPty('pane') + expect((await service.configDirGc.run(async () => [])).keptReferenced).toBe(1) + vi.stubEnv('ORCA_OPENCODE_SOURCE_CONFIG_DIR', source) + expect((await new OpenCodeHookService().configDirGc.run(async () => [])).keptReferenced).toBe(1) +}) diff --git a/src/main/opencode/overlay-dir-gc.ts b/src/main/opencode/overlay-dir-gc.ts new file mode 100644 index 00000000000..6961c0c3c1a --- /dev/null +++ b/src/main/opencode/overlay-dir-gc.ts @@ -0,0 +1,206 @@ +import { lstat, readdir } from 'node:fs/promises' +import { join, resolve, sep } from 'node:path' +import { yieldToEventLoop } from '../../shared/event-loop-yield' +import { isSafeDescendCandidate, safeRemoveOverlay } from '../pty/overlay-mirror' +import { + OPENCODE_OVERLAY_MANIFEST_FILE, + readOpenCodeOverlayManifest +} from './opencode-overlay-manifest' +import { inspectSourceDirectory, resolveOwnedOverlaySource } from './overlay-source-ownership' + +export const OPENCODE_DIR_GC_MIN_AGE_MS = 30 * 24 * 60 * 60 * 1000 +export const OPENCODE_DIR_GC_MAX_REMOVALS_PER_SWEEP = 500 + +export type OpenCodeDirGcOptions = { + overlayRoot: string + pluginFileName: string + referencedConfigDirs: ReadonlySet + readLivePtyIds: () => Promise + now?: number + minAgeMs?: number + maxRemovals?: number + removeTree?: (directory: string, root: string) => void + yieldBetweenRemovals?: () => Promise +} + +export type OpenCodeDirGcResult = { + scanned: number + removed: number + failed: number + keptReferenced: number + keptYoung: number + keptSourcePresent: number + keptUnverifiable: number +} + +function normalized(path: string): string { + const absolute = resolve(path) + return process.platform === 'win32' ? absolute.toLowerCase() : absolute +} + +function isReferenced(directory: string, references: ReadonlySet): boolean { + const candidate = normalized(directory) + for (const reference of references) { + const value = normalized(reference) + if (value === candidate || value.startsWith(candidate + sep)) { + return true + } + } + return false +} + +async function oldEnough(directory: string, plugin: string, now: number, minAge: number) { + let newest = 0 + for (const path of [ + directory, + join(directory, OPENCODE_OVERLAY_MANIFEST_FILE), + join(directory, 'plugins'), + join(directory, 'plugins', plugin) + ]) { + try { + const stats = await lstat(path) + if (stats.isSymbolicLink()) { + return false + } + newest = Math.max(newest, stats.mtimeMs) + } catch { + return false + } + } + return newest > 0 && now - newest >= minAge +} + +async function hasNoServiceConfig(directory: string): Promise { + try { + const names = await readdir(directory) + // Service registration lives in a different profile; config absence proves no PID verdict. + return !names.some((name) => /^service(?:-[\w.-]+)?\.json$/i.test(name)) + } catch { + return false + } +} + +export async function sweepOrphanedOpenCodeDirs( + options: OpenCodeDirGcOptions +): Promise { + const result: OpenCodeDirGcResult = { + scanned: 0, + removed: 0, + failed: 0, + keptReferenced: 0, + keptYoung: 0, + keptSourcePresent: 0, + keptUnverifiable: 0 + } + if ((await inspectSourceDirectory(options.overlayRoot)) !== 'present') { + return result + } + const now = options.now ?? Date.now() + const minAge = options.minAgeMs ?? OPENCODE_DIR_GC_MIN_AGE_MS + const requestedLimit = options.maxRemovals ?? OPENCODE_DIR_GC_MAX_REMOVALS_PER_SWEEP + const limit = Number.isFinite(requestedLimit) + ? Math.min(OPENCODE_DIR_GC_MAX_REMOVALS_PER_SWEEP, Math.max(0, Math.floor(requestedLimit))) + : OPENCODE_DIR_GC_MAX_REMOVALS_PER_SWEEP + const removeTree = options.removeTree ?? safeRemoveOverlay + const yieldBetween = options.yieldBetweenRemovals ?? yieldToEventLoop + let entries + try { + entries = await readdir(options.overlayRoot, { withFileTypes: true }) + } catch { + return result + } + for (const entry of entries) { + if (result.removed + result.failed >= limit) { + break + } + result.scanned += 1 + const directory = join(options.overlayRoot, entry.name) + if (!/^[0-9a-f]{32}$/.test(entry.name) || !isSafeDescendCandidate(entry)) { + result.keptUnverifiable += 1 + continue + } + if (isReferenced(directory, options.referencedConfigDirs)) { + result.keptReferenced += 1 + continue + } + try { + const manifestStats = await lstat(join(directory, OPENCODE_OVERLAY_MANIFEST_FILE)) + if ( + !manifestStats.isFile() || + manifestStats.isSymbolicLink() || + manifestStats.size > 64 * 1024 + ) { + result.keptUnverifiable += 1 + continue + } + const source = await resolveOwnedOverlaySource( + directory, + readOpenCodeOverlayManifest(directory) + ) + if (!source) { + result.keptUnverifiable += 1 + continue + } + const presence = await inspectSourceDirectory(source) + if (presence === 'present') { + result.keptSourcePresent += 1 + continue + } + if (presence !== 'absent' || !(await hasNoServiceConfig(directory))) { + result.keptUnverifiable += 1 + continue + } + if (!(await oldEnough(directory, options.pluginFileName, now, minAge))) { + result.keptYoung += 1 + continue + } + // A restarted app's empty cache cannot establish surviving daemon terminals are gone. + const liveIds = await options.readLivePtyIds().catch(() => null) + if (liveIds === null || liveIds.length !== 0) { + result.keptUnverifiable += 1 + continue + } + if ( + isReferenced(directory, options.referencedConfigDirs) || + isReferenced(source, options.referencedConfigDirs) + ) { + result.keptReferenced += 1 + continue + } + if ( + (await inspectSourceDirectory(source)) !== 'absent' || + (await inspectSourceDirectory(options.overlayRoot)) !== 'present' || + !(await hasNoServiceConfig(directory)) + ) { + result.keptUnverifiable += 1 + continue + } + if ( + isReferenced(directory, options.referencedConfigDirs) || + isReferenced(source, options.referencedConfigDirs) + ) { + result.keptReferenced += 1 + continue + } + try { + removeTree(directory, options.overlayRoot) + try { + await lstat(directory) + result.failed += 1 + } catch (error) { + if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') { + result.removed += 1 + } else { + result.failed += 1 + } + } + } catch { + result.failed += 1 + } + await yieldBetween() + } catch { + result.keptUnverifiable += 1 + } + } + return result +} diff --git a/src/main/opencode/overlay-dir-names.ts b/src/main/opencode/overlay-dir-names.ts new file mode 100644 index 00000000000..1e3a3a9a9f0 --- /dev/null +++ b/src/main/opencode/overlay-dir-names.ts @@ -0,0 +1,12 @@ +import { createHash } from 'node:crypto' + +export const OPENCODE_OVERLAY_DIR = 'opencode-config-overlays' +export const ORCA_OPENCODE_PLUGIN_FILE = 'orca-opencode-status.js' + +export function toSafeDirName(id: string): string { + return createHash('sha256').update(id).digest('hex').slice(0, 32) +} + +export function sourceOverlayDirName(sourceConfigDir: string): string { + return toSafeDirName(`source:${sourceConfigDir}`) +} diff --git a/src/main/opencode/overlay-source-ownership.test.ts b/src/main/opencode/overlay-source-ownership.test.ts new file mode 100644 index 00000000000..78c5d1c02ce --- /dev/null +++ b/src/main/opencode/overlay-source-ownership.test.ts @@ -0,0 +1,90 @@ +import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import * as filesystem from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { inspectSourceDirectory, resolveOwnedOverlaySource } from './overlay-source-ownership' +import { sourceOverlayDirName } from './overlay-dir-names' + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, lstat: vi.fn(actual.lstat) } +}) + +let root: string +beforeEach(() => { + root = realpathSync(mkdtempSync(join(tmpdir(), 'orca-overlay-source-'))) +}) +afterEach(() => { + vi.restoreAllMocks() + rmSync(root, { recursive: true, force: true }) +}) + +it('distinguishes a missing source from an inaccessible source and a linked ancestor', async () => { + const source = join(root, 'source') + mkdirSync(source) + expect(await inspectSourceDirectory(source)).toBe('present') + expect(await inspectSourceDirectory(join(root, 'missing', 'config'))).toBe('absent') + expect(await inspectSourceDirectory('relative-config')).toBe('unverifiable') + writeFileSync(join(root, 'file'), '') + expect(await inspectSourceDirectory(join(root, 'file'))).toBe('unverifiable') + symlinkSync(source, join(root, 'link'), process.platform === 'win32' ? 'junction' : 'dir') + expect(await inspectSourceDirectory(join(root, 'link', 'missing'))).toBe('unverifiable') + const error = Object.assign(new Error('Denied'), { code: 'EACCES' }) + vi.mocked(filesystem.lstat).mockRejectedValueOnce(error) + expect(await inspectSourceDirectory(source)).toBe('unverifiable') +}) + +it('requires an absolute source whose hash matches the owned overlay', async () => { + const source = join(root, 'source') + const overlay = join(root, sourceOverlayDirName(source)) + const manifest = { topLevelEntries: [], pluginEntries: [], sourceConfigDir: source } + expect(await resolveOwnedOverlaySource(overlay, manifest)).toBe(source) + expect( + await resolveOwnedOverlaySource(overlay, { ...manifest, sourceConfigDir: join(root, 'other') }) + ).toBeUndefined() + expect( + await resolveOwnedOverlaySource(overlay, { ...manifest, sourceConfigDir: 'relative' }) + ).toBeUndefined() + expect( + await resolveOwnedOverlaySource(join(root, '123'), { + topLevelEntries: [], + pluginEntries: [], + sourceConfigDir: source + }) + ).toBeUndefined() +}) + +it.skipIf(process.platform === 'win32')( + 'recognizes an older source-scoped manifest only through its named mirrored link', + async () => { + const source = join(root, 'retired-source') + const overlay = join(root, sourceOverlayDirName(source)) + mkdirSync(overlay) + symlinkSync(join(source, 'opencode.json'), join(overlay, 'opencode.json')) + const manifest = { topLevelEntries: ['opencode.json'], pluginEntries: [] } + expect(await resolveOwnedOverlaySource(overlay, manifest)).toBe(source) + expect( + await resolveOwnedOverlaySource(overlay, { ...manifest, topLevelEntries: ['../other'] }) + ).toBeUndefined() + expect( + await resolveOwnedOverlaySource(overlay, { ...manifest, topLevelEntries: [] }) + ).toBeUndefined() + rmSync(join(overlay, 'opencode.json')) + writeFileSync(join(overlay, 'opencode.json'), '{}') + expect(await resolveOwnedOverlaySource(overlay, manifest)).toBeUndefined() + } +) + +it.skipIf(process.platform === 'win32')('rejects arbitrary legacy source links', async () => { + const source = join(root, 'source') + const overlay = join(root, sourceOverlayDirName(source)) + mkdirSync(overlay) + symlinkSync(join(root, 'other', 'opencode.json'), join(overlay, 'opencode.json')) + expect( + await resolveOwnedOverlaySource(overlay, { + topLevelEntries: ['opencode.json'], + pluginEntries: [] + }) + ).toBeUndefined() +}) diff --git a/src/main/opencode/overlay-source-ownership.ts b/src/main/opencode/overlay-source-ownership.ts new file mode 100644 index 00000000000..4f71cd191da --- /dev/null +++ b/src/main/opencode/overlay-source-ownership.ts @@ -0,0 +1,63 @@ +import { lstat, readlink } from 'node:fs/promises' +import { basename, dirname, isAbsolute, join, parse, relative, resolve, sep } from 'node:path' +import { isSafeDescendCandidate } from '../pty/overlay-mirror' +import type { OpenCodeOverlayManifest } from './opencode-overlay-manifest' +import { sourceOverlayDirName } from './overlay-dir-names' + +export type SourceDirectoryPresence = 'present' | 'absent' | 'unverifiable' + +function isAbsent(error: unknown): boolean { + return !!error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT' +} + +export async function inspectSourceDirectory(path: string): Promise { + if (!isAbsolute(path)) { + return 'unverifiable' + } + const absolute = resolve(path) + let current = parse(absolute).root + const segments = relative(current, absolute).split(sep).filter(Boolean) + for (const segment of ['', ...segments]) { + current = join(current, segment) + try { + if (!isSafeDescendCandidate(await lstat(current))) { + return 'unverifiable' + } + } catch (error) { + return isAbsent(error) ? 'absent' : 'unverifiable' + } + } + return 'present' +} + +export async function resolveOwnedOverlaySource( + directory: string, + manifest: OpenCodeOverlayManifest +): Promise { + const matches = (source: string): boolean => + isAbsolute(source) && sourceOverlayDirName(source) === basename(directory) + if (manifest.sourceConfigDir !== undefined) { + return matches(manifest.sourceConfigDir) ? manifest.sourceConfigDir : undefined + } + // Older manifests prove a source only through a named, source-hash-matching mirror. + let source: string | undefined + for (const entry of manifest.topLevelEntries) { + if (!entry || basename(entry) !== entry || entry === '.' || entry === '..') { + return undefined + } + try { + const target = await readlink(join(directory, entry)) + const candidate = dirname(target) + if (!isAbsolute(target) || basename(target) !== entry || !matches(candidate)) { + return undefined + } + if (source !== undefined && source !== candidate) { + return undefined + } + source = candidate + } catch { + return undefined + } + } + return source +} diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index 7bd3e41b378..b448681857e 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -26,6 +26,8 @@ import { hasSystemMediaAccess, requestSystemMediaAccess } from '../browser/brows import type { OrcaRuntimeService, RuntimeWorktreeLifecycleEvent } from '../runtime/orca-runtime' import type { PreQuitCleanupFailureMode, UpdateInstallMode } from '../updater' import { scheduleHistoryGc } from '../terminal-history-gc' +import { openCodeHookService, openCode2HookService } from '../opencode/hook-service' +import { listLiveDaemonPtyIds } from '../daemon/daemon-provider-state' import { hydrateLocalPtyRegistryAtBoot } from '../memory/hydrate-local-pty-registry' import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' import { getKnownWorktreeIdsForHistoryGc } from './history-gc-worktree-ids' @@ -107,6 +109,8 @@ export function attachMainWindowServices( scheduleHistoryGc(async () => { return getKnownWorktreeIdsForHistoryGc(store) }) + openCodeHookService.configDirGc.schedule(listLiveDaemonPtyIds) + openCode2HookService.configDirGc.schedule(listLiveDaemonPtyIds) const localPtyProviderStartupReady = options?.awaitLocalPtyProviderStartup?.() if (localPtyProviderStartupReady) { void localPtyProviderStartupReady From 53899251dbcbf5a5c756f17fe1c62b51c7f12c3b Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 4 Oct 2026 05:42:26 -0700 Subject: [PATCH 12/31] Preserve Windows SSH upload failures unless pwsh is missing (#25185) * test(ssh): reproduce missing-pwsh text in staging paths * fix(ssh): classify missing PowerShell from command exit evidence * test: expose generic Windows upload error misclassification * test: await armed SSH upload before advancing fake clock * test: retain real immediate delivery around upload timeout control * fix: classify PowerShell absence from command exits only * test: expose missing-command text inside SSH stderr paths * fix: require missing pwsh diagnostic command identity * test: keep mixed write errors out of missing-command fallback * fix: require complete missing PowerShell diagnostic * test: capture complete native missing pwsh diagnostics * fix: recognize complete missing pwsh native diagnostics * test(ssh): cover source-derived NormalView localization and wrapping * fix(ssh): identify complete missing-pwsh records across NormalView layouts * test(ssh): cover raw-wrap separators and repeated error headers * fix(ssh): preserve wrapped separators and reject repeated error headers --------- Co-authored-by: Orca Campaign --- .../ssh/system-ssh-operation-lifecycle.ts | 15 +- .../ssh/system-ssh-windows-upload.test.ts | 244 +++++++++++++++++- .../ssh/system-ssh-windows-write-strategy.ts | 42 ++- 3 files changed, 290 insertions(+), 11 deletions(-) diff --git a/src/main/ssh/system-ssh-operation-lifecycle.ts b/src/main/ssh/system-ssh-operation-lifecycle.ts index 5ebfb17ced1..148ccad8ff6 100644 --- a/src/main/ssh/system-ssh-operation-lifecycle.ts +++ b/src/main/ssh/system-ssh-operation-lifecycle.ts @@ -3,6 +3,18 @@ import type { SystemSshCommandChannel } from './system-ssh-command' export type ProcessResult = { label: string; stderr: string } +export class SystemSshCommandExitError extends Error { + constructor( + label: string, + readonly exitCode: number | null, + readonly stderr: string, + signal?: NodeJS.Signals | null + ) { + const detail = exitCode === null ? `signal ${signal ?? 'unknown'}` : `exit ${exitCode}` + super(`${label} failed (${detail}): ${stderr.trim()}`) + } +} + /** * `timeoutMs` bounds a remote consumer that never returns. Windows PowerShell 5.1 cannot drain a * large redirected stdin over a non-pty ssh exec (#16432): the remote process stays alive at idle @@ -52,8 +64,7 @@ export function waitForChannelClose( } const onClose = (code: number | null, signal?: NodeJS.Signals | null): void => { if (code !== 0) { - const detail = code === null ? `signal ${signal ?? 'unknown'}` : `exit ${code}` - settle(reject, new Error(`${label} failed (${detail}): ${stderr.trim()}`)) + settle(reject, new SystemSshCommandExitError(label, code, stderr, signal)) return } settle(resolve) diff --git a/src/main/ssh/system-ssh-windows-upload.test.ts b/src/main/ssh/system-ssh-windows-upload.test.ts index c3a5ff80276..bc339f24785 100644 --- a/src/main/ssh/system-ssh-windows-upload.test.ts +++ b/src/main/ssh/system-ssh-windows-upload.test.ts @@ -51,12 +51,16 @@ import { writeBufferViaSystemSsh } from './system-ssh-file-binary-transfer' import { waitForChannelClose } from './system-ssh-operation-lifecycle' +import * as windowsFileWrite from './system-ssh-windows-file-write' import { getRemoteHostPlatform } from './ssh-remote-platform' import { clearWindowsRemoteWriteCapabilitiesForTests, getWindowsRemoteWriteCapabilities } from './system-ssh-windows-write-capabilities' -import { explainWindowsPowerShellStdinFailure } from './system-ssh-windows-write-strategy' +import { + explainWindowsPowerShellStdinFailure, + writeWindowsRemoteFile +} from './system-ssh-windows-write-strategy' import type { SshTarget } from '../../shared/ssh-types' type FakeChannel = EventEmitter & { @@ -106,6 +110,8 @@ const sftpBatches: RecordedSftpBatch[] = [] const commands: RecordedCommand[] = [] /** Index of the exec that should report a non-zero exit, to model a chunk failing mid-file. */ let failAtSpawn = -1 +let failedWriteExit = 1 +let failedWriteStderr = '' let localDir: string const fileWrites = (): RecordedCommand[] => @@ -177,6 +183,8 @@ beforeEach(() => { commands.length = 0 sftpBatches.length = 0 failAtSpawn = -1 + failedWriteExit = 1 + failedWriteStderr = '' clearWindowsRemoteWriteCapabilitiesForTests() waitForChannelCloseSpy.mockClear() localDir = mkdtempSync(join(tmpdir(), 'orca-win-upload-')) @@ -192,9 +200,14 @@ beforeEach(() => { executable: command.split(' ')[0] ?? '', stdin: channel.written }) - setImmediate(() => - spawnIndex === failAtSpawn ? channel.emit('close', 1, null) : channel.emit('close', 0, null) - ) + setImmediate(() => { + if (spawnIndex === failAtSpawn) { + channel.stderr.write(failedWriteStderr) + channel.emit('close', failedWriteExit, null) + } else { + channel.emit('close', 0, null) + } + }) }) }) }) @@ -624,6 +637,229 @@ describe('Windows upload on a host with no sftp subsystem', () => { expect(fileWrites().map(writtenPath)).not.toContain(`${remoteRoot}/relay.js`) expect(commands.some((command) => command.script.includes('::Move('))).toBe(false) }) + + it.each([ + ['relay.js', 'aabb9009eeff'], + ['relay-9009.js', 'aabbccddeeff'], + ['relay-CommandNotFoundException.js', 'aabbccddeeff'], + ['is not recognized as an internal or external command.js', 'aabbccddeeff'] + ])('propagates a failed write whose path contains absence-like text: %s', async (file, nonce) => { + const remotePath = `${remoteRoot}/${file}` + const staging = vi + .spyOn(windowsFileWrite, 'makeWindowsStagingPath') + .mockImplementation((path) => `${path}${WINDOWS_STAGED_WRITE_SUFFIX}-${nonce}`) + writeFileSync(join(localDir, file), Buffer.alloc(WINDOWS_STDIN_WRITE_CHUNK_BYTES * 3)) + failAtSpawn = 0 + try { + await expect( + uploadFileViaSystemSsh(target, join(localDir, file), remotePath, { hostPlatform }) + ).rejects.toThrow('failed (exit 1)') + + expect(fileWrites().map((write) => write.executable)).toEqual(['pwsh.exe']) + expect(getWindowsRemoteWriteCapabilities(target).shouldTry('pwsh')).toBe(true) + expect(commands.some((command) => command.script.includes('::Move('))).toBe(false) + } finally { + staging.mockRestore() + } + }) + + it.each([ + 'relay-CommandNotFoundException.js', + 'is not recognized as an internal or external command.js' + ])('propagates a write-denied stderr naming an absence-like filename: %s', async (file) => { + const remotePath = `${remoteRoot}/${file}` + writeFileSync(join(localDir, file), 'x') + failAtSpawn = 0 + failedWriteStderr = `Access to the path '${remotePath}' is denied.` + + await expect( + uploadFileViaSystemSsh(target, join(localDir, file), remotePath, { hostPlatform }) + ).rejects.toThrow('Access to the path') + + expect(fileWrites().map((write) => write.executable)).toEqual(['pwsh.exe']) + expect(getWindowsRemoteWriteCapabilities(target).shouldTry('pwsh')).toBe(true) + expect(commands.some((command) => command.script.includes('::Move('))).toBe(false) + }) + + const capturedPowerShellMissingPwsh = + "pwsh.exe : The term 'pwsh.exe' is not recognized as the name of a cmdlet, function, script file, or operable program. \r\nCheck the spelling of the name, or if a path was included, verify that the path is correct and try again.\r\nAt line:1 char:1\r\n+ pwsh.exe -NoProfile -NonInteractive -Command exit\r\n+ ~~~~~~~~\r\n + CategoryInfo : ObjectNotFound: (pwsh.exe:String) [], CommandNotFoundException\r\n + FullyQualifiedErrorId : CommandNotFoundException" + + // Source-derived resource/layout controls; these are not captured localized Windows errors. + const sourceDerivedLocalizedMissingPwsh = [ + 'pwsh.exe : La commande est introuvable.', + 'Vérifiez le nom de la commande.', + 'À la ligne:1 caractère:1', + '+ pwsh.exe -NoProfile -NonInteractive -EncodedCommand JABwAGEAdABoAA== ...', + '+ ~~~~~~~~', + ' + CategoryInfo : CommandNotFoundException — cible pwsh.exe, type String', + ' + FullyQualifiedErrorId : CommandNotFoundException' + ].join('\r\n') + const sourceDerivedWrappedMissingPwsh = [ + 'pwsh.exe : The term', + "'pwsh.exe' is not recognized.", + 'At line:1 char:1', + '+ pwsh.exe -NoProfile ', + '-NonInteractive -EncodedCommand ', + 'JABwAGEAdABoAA== ...', + '+ ~~~~~~~~', + ' + CategoryInfo : ObjectNotFound: ', + '(pwsh.exe:String) [], CommandNotFoundExcept', + 'ion', + ' + FullyQualifiedErrorId : CommandNotFoundExcept', + 'ion' + ].join('\r\n') + + it.each([ + "'missing-tool.exe' is not recognized as an internal or external command", + `Access to the path 'relay.js' is denied.\n${capturedPowerShellMissingPwsh}`, + `${capturedPowerShellMissingPwsh}\nAccess to the path 'relay.js' is denied.`, + capturedPowerShellMissingPwsh.replaceAll('pwsh.exe', 'missing-tool.exe'), + capturedPowerShellMissingPwsh.replace(/.*CategoryInfo.*\r?\n/, ''), + capturedPowerShellMissingPwsh.replace('At line:', 'pwsh.exe : Another failure.\r\nAt line:'), + capturedPowerShellMissingPwsh.replace('At line:', 'another.exe : Another failure.\r\nAt line:'), + sourceDerivedLocalizedMissingPwsh.replace('cible pwsh.exe', 'cible relay-pwsh.exe.js'), + sourceDerivedLocalizedMissingPwsh.replace('cible pwsh.exe', 'cible C:\\bin\\pwsh.exe'), + sourceDerivedLocalizedMissingPwsh.replace('cible pwsh.exe', 'cible pwsh.exe-backup'), + sourceDerivedLocalizedMissingPwsh.replace('type String', 'type FileInfo'), + sourceDerivedWrappedMissingPwsh.replace('+ pwsh.exe', '+ missing-tool.exe'), + `${sourceDerivedWrappedMissingPwsh}\n${capturedPowerShellMissingPwsh}`, + `${sourceDerivedLocalizedMissingPwsh}\nAccess to the path 'relay.js' is denied.`, + `Access to the path 'relay.js' is denied.\n${sourceDerivedLocalizedMissingPwsh}`, + 'CommandNotFoundException: missing-tool.exe', + "Access to the path 'relay.js' is denied.\nCommandNotFoundException: pwsh.exe", + "Access to the path 'relay.js' is denied.\n'pwsh.exe' is not recognized as an internal or external command" + ])( + 'does not mark PowerShell 7 absent when its script reports another missing command: %s', + async (stderr) => { + writeFileSync(join(localDir, 'relay.js'), 'x') + failAtSpawn = 0 + failedWriteStderr = stderr + + await expect( + uploadFileViaSystemSsh(target, join(localDir, 'relay.js'), `${remoteRoot}/relay.js`, { + hostPlatform + }) + ).rejects.toThrow('failed (exit 1)') + + expect(fileWrites().map((write) => write.executable)).toEqual(['pwsh.exe']) + expect(getWindowsRemoteWriteCapabilities(target).shouldTry('pwsh')).toBe(true) + expect(commands.some((command) => command.script.includes('::Move('))).toBe(false) + } + ) + + it.each([ + [9009, ''], + [1, "'pwsh.exe' is not recognized as an internal or external command"], + [1, 'CommandNotFoundException: pwsh.exe'], + [ + 1, + "'pwsh.exe' is not recognized as an internal or external command,\r\noperable program or batch file." + ], + [1, capturedPowerShellMissingPwsh] + ])('falls back on an actual missing PowerShell 7 signal: %s %s', async (exit, stderr) => { + writeFileSync(join(localDir, 'relay.js'), Buffer.alloc(WINDOWS_STDIN_WRITE_CHUNK_BYTES * 3)) + failAtSpawn = 0 + failedWriteExit = exit + failedWriteStderr = stderr + + await uploadFileViaSystemSsh(target, join(localDir, 'relay.js'), `${remoteRoot}/relay.js`, { + hostPlatform + }) + + expect(fileWrites().map((write) => write.executable)).toEqual([ + 'pwsh.exe', + 'powershell.exe', + 'powershell.exe', + 'powershell.exe' + ]) + expect(getWindowsRemoteWriteCapabilities(target).shouldTry('pwsh')).toBe(false) + expect(commands.some((command) => command.script.includes('::Move('))).toBe(true) + }) + + it.each([ + sourceDerivedLocalizedMissingPwsh, + sourceDerivedWrappedMissingPwsh, + sourceDerivedLocalizedMissingPwsh.replace(' -NonInteractive', '\r\n -NonInteractive') + ])( + 'falls back on a source-derived localized or wrapped missing-pwsh record: %s', + async (stderr) => { + writeFileSync(join(localDir, 'relay.js'), 'x') + failAtSpawn = 0 + failedWriteStderr = stderr + + await uploadFileViaSystemSsh(target, join(localDir, 'relay.js'), `${remoteRoot}/relay.js`, { + hostPlatform + }) + + expect(fileWrites().map((write) => write.executable)).toEqual(['pwsh.exe', 'powershell.exe']) + expect(getWindowsRemoteWriteCapabilities(target).shouldTry('pwsh')).toBe(false) + expect(commands.some((command) => command.script.includes('::Move('))).toBe(true) + } + ) + + it.each([ + 'relay-CommandNotFoundException.js', + 'is not recognized as an internal or external command.js' + ])('propagates a timeout whose filename resembles a missing command: %s', async (file) => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const remotePath = `${remoteRoot}/${file}` + writeFileSync(join(localDir, file), 'x') + let noteWriteStarted: () => void = () => {} + const writeStarted = new Promise((resolve) => { + noteWriteStarted = resolve + }) + spawnSystemSshCommandMock.mockImplementation((_target: SshTarget, command: string) => { + const executable = command.split(' ')[0] ?? '' + return createFakeChannel((channel) => { + commands.push({ + script: decodePowerShellCommand(command), + executable, + stdin: channel.written + }) + if (executable !== 'pwsh.exe') { + setImmediate(() => channel.emit('close', 0, null)) + } else { + noteWriteStarted() + } + }) + }) + try { + const result = expect( + uploadFileViaSystemSsh(target, join(localDir, file), remotePath, { hostPlatform }) + ).rejects.toThrow('timed out') + await writeStarted + await vi.advanceTimersByTimeAsync(WINDOWS_STDIN_WRITE_TIMEOUT_MS + 1) + await result + + expect(fileWrites().map((write) => write.executable)).toEqual(['pwsh.exe']) + expect(getWindowsRemoteWriteCapabilities(target).shouldTry('pwsh')).toBe(true) + expect(commands.some((command) => command.script.includes('::Move('))).toBe(false) + } finally { + vi.useRealTimers() + } + }) + + it.each([ + 'relay-CommandNotFoundException.js', + 'is not recognized as an internal or external command.js' + ])('propagates a short source whose filename resembles a missing command: %s', async (file) => { + await expect( + writeWindowsRemoteFile( + target, + `${remoteRoot}/${file}`, + { + totalBytes: 1, + readChunk: async () => Buffer.alloc(0), + withLocalFile: async (send) => send(join(localDir, file)) + }, + {} + ) + ).rejects.toThrow('Source ran short') + + expect(fileWrites()).toHaveLength(0) + expect(getWindowsRemoteWriteCapabilities(target).shouldTry('pwsh')).toBe(true) + expect(commands.some((command) => command.script.includes('::Move('))).toBe(false) + }) }) describe('last-resort Windows PowerShell failure reporting', () => { diff --git a/src/main/ssh/system-ssh-windows-write-strategy.ts b/src/main/ssh/system-ssh-windows-write-strategy.ts index f2cdca12516..e8016db313c 100644 --- a/src/main/ssh/system-ssh-windows-write-strategy.ts +++ b/src/main/ssh/system-ssh-windows-write-strategy.ts @@ -3,6 +3,7 @@ import { getSystemSshBuildArgsFromOperationOptions } from './system-ssh-args' import { spawnSystemSshCommand } from './system-ssh-command' import { awaitWithSystemSshAbort, + SystemSshCommandExitError, throwIfAborted, waitForChannelClose } from './system-ssh-operation-lifecycle' @@ -267,11 +268,42 @@ export function explainWindowsPowerShellStdinFailure(error: unknown): unknown { } function isPwshUnavailableError(error: unknown): boolean { - const message = error instanceof Error ? error.message : String(error) - // cmd.exe's "not recognized" and sshd's exit 9009 both mean "no pwsh here". A timeout does not: - // that is the stdin defect, and PowerShell 7 does not have it, so it must not be cached as absent. - return /is not recognized as an internal or external command|9009|CommandNotFoundException/i.test( - message + if (!(error instanceof SystemSshCommandExitError)) { + return false + } + // A complete missing-pwsh diagnostic establishes absence; paths and mixed errors do not. + const stderr = error.stderr.trim() + return ( + error.exitCode === 9009 || + /^'pwsh\.exe' is not recognized as an internal or external command(?:,\r?\noperable program or batch file\.)?$/i.test( + stderr + ) || + /^CommandNotFoundException:[ \t]*pwsh\.exe$/i.test(stderr) || + isPowerShellMissingPwshDiagnostic(stderr) + ) +} + +function isPowerShellMissingPwshDiagnostic(stderr: string): boolean { + // NormalView wraps physical lines; its localized prose and category template are not identifiers. + const record = stderr.replace(/\r?\n/g, '') + const sections = + /^pwsh\.exe[ \t]*:[ \t]*([^+]+)\+[ \t]*pwsh\.exe([ \t]+-[^~]*?)\+[ \t]*~{8}[ \t]*\+[ \t]*CategoryInfo[ \t]*:[ \t]*([^+]+)\+[ \t]*FullyQualifiedErrorId[ \t]*:[ \t]*CommandNotFoundException[ \t]*$/.exec( + record + ) + if (!sections) { + return false + } + const prelude = sections[1] ?? '' + const source = sections[2] ?? '' + const category = sections[3] ?? '' + return ( + !/\b[\w.-]+\.exe[ \t]*:/.test(prelude) && + /^[ \t]+-NoProfile[ \t]+-NonInteractive[ \t]+-(?:Command[ \t]+exit|EncodedCommand[ \t]+[A-Za-z0-9+/=]+(?:[ \t]*\.{3})?)[ \t]*$/.test( + source + ) && + category.match(/(? Date: Sun, 4 Oct 2026 06:24:30 -0700 Subject: [PATCH 13/31] Verify shared preflight selection and record full unit timings (#25239) * Strengthen shared preflight contracts and record unit timing results * Record rejected shard-weight holdouts --- .github/workflows/pr.yml | 2 +- .../scripts/check-readme-local-links.test.mjs | 3 +- config/scripts/pr-preflight-gates.test.mjs | 97 ++++++++++- docs/reference/ci-runner-efficiency.md | 150 ++++++++++++++++-- 4 files changed, 227 insertions(+), 25 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index d5e4a710442..b370dff9929 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -161,7 +161,7 @@ jobs: # Why ARM: measured 128s against 172s on ubuntu-latest, with every compute step faster -- # type-aware 24s->15s, anti-slop 28->19s, localization extraction 67->46s, the orcad smoke # 39->14s. Both lint engines ship linux-arm64 and the Bun target follows process.arch, so - # the whole toolchain resolves. Free for public repositories, same as the typecheck job. + # the whole toolchain resolves. Free for public repositories. runs-on: ubuntu-24.04-arm outputs: shards: ${{ steps.unit-plan.outputs.shards }} diff --git a/config/scripts/check-readme-local-links.test.mjs b/config/scripts/check-readme-local-links.test.mjs index e1d69723ee5..c9128630f19 100644 --- a/config/scripts/check-readme-local-links.test.mjs +++ b/config/scripts/check-readme-local-links.test.mjs @@ -156,8 +156,7 @@ describe('README local link check', () => { ]) }) - // Why the ungated job: static_analysis is skipped for docs-only diffs, which is - // exactly the kind of PR that deletes a docs-site GIF the README embeds. + // Docs-only diffs skip preflight, so the detector must check README links. it('runs on every PR through the ungated detector and in the lint script', () => { const { scripts } = JSON.parse(readFileSync(path.join(projectDir, 'package.json'), 'utf8')) const workflow = parse(readFileSync(path.join(projectDir, '.github/workflows/pr.yml'), 'utf8')) diff --git a/config/scripts/pr-preflight-gates.test.mjs b/config/scripts/pr-preflight-gates.test.mjs index 152610f561b..250ea94ffe8 100644 --- a/config/scripts/pr-preflight-gates.test.mjs +++ b/config/scripts/pr-preflight-gates.test.mjs @@ -27,7 +27,10 @@ it('shares one setup and runs the unchanged compiler after static checks finish' expect(steps[0].with['fetch-depth']).toBeGreaterThanOrEqual(2) expect(compiler.background).toBeUndefined() expect(plan.background).toBe(true) - expect(plan.run).toContain('node config/scripts/ci-unit-plan.mjs') + expect(plan.run.trim().split('\n')).toEqual([ + 'if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi', + 'node config/scripts/ci-unit-plan.mjs' + ]) expect(plan.env.ORCA_UNIT_SELECTION_MODE).toContain('vars.ORCA_UNIT_SELECTION_MODE') expect(steps.indexOf(plan)).toBeLessThan(steps.indexOf(compiler)) expect(steps.indexOf(compiler)).toBeGreaterThan( @@ -66,20 +69,98 @@ it('requires physical preflight success before publishing shards and admitting c expect(workflow.jobs.verify.needs).not.toContain('typecheck') }) -it.each( - [['README.md'], ['mobile/src/App.tsx'], ['cloud/package.json'], ['src/main/index.ts'], []].map( - (changed) => ({ changed }) +it('pins every foreground and background step to its selected phase', () => { + const staticPhase = "needs.code_paths.outputs.static_analysis == 'true'" + const typePhase = "needs.code_paths.outputs.typecheck == 'true'" + const foreground = steps.filter( + (step) => !step.background && /outputs\.(static_analysis|typecheck)/.test(step.if ?? '') ) -)('preserves phase selection for unrelated paths: $changed', ({ changed }) => { + expect(foreground.map((step) => [step.name ?? step.run ?? step.uses, step.if])).toEqual([ + ['Reject low-evidence patterns', staticPhase], + ['Enforce type-aware code-quality baseline', staticPhase], + [ + './.github/actions/install-mobile-dependencies', + `${staticPhase} && needs.code_paths.outputs.mobile_dependencies == 'true'` + ], + ['Enforce React Doctor on changed lines', staticPhase], + ['Check Zustand selector fan-out budget', staticPhase], + ['Check reliability gate manifest', staticPhase], + ['Enforce dead design-system classes', staticPhase], + ['Check VM runtime rollback compatibility', staticPhase], + ['Enforce max-lines ratchet', staticPhase], + ['Enforce ts-nocheck ratchet', staticPhase], + ['Enforce runtime Electron-import ratchet', staticPhase], + ['Check Node runtime pin', staticPhase], + ['Boot orcad and round-trip a terminal', staticPhase], + ['Verify the generated RPC params catalog', staticPhase], + ['Verify bundled skill guides', staticPhase], + ['Verify skill freshness manifest', staticPhase], + ['Verify localization coverage', staticPhase], + ['Guard against project-owned .d.ts in preload/shared', staticPhase], + ['Check feature wall asset budget', staticPhase], + ['Verify macOS entitlements', staticPhase], + ['Cache TypeScript incremental state', typePhase], + ['pnpm run typecheck', typePhase], + ['actions/upload-artifact@v7', typePhase] + ]) + expect( + steps + .filter((step) => step.background) + .map((step) => [step.id, step.env.PREFLIGHT_PHASE_SELECTED]) + ).toEqual([ + ['root-lint', `\${{ ${staticPhase} }}`], + ['native-code-quality', `\${{ ${staticPhase} }}`], + ['changed-code-quality', `\${{ ${staticPhase} }}`], + ['localization-extraction', `\${{ ${staticPhase} }}`], + ['localization-catalogs', `\${{ ${staticPhase} }}`], + ['unit-plan', `\${{ ${typePhase} }}`] + ]) +}) + +it.each([ + { changed: ['README.md'], static_analysis: false, typecheck: false, mobile_dependencies: false }, + { + changed: ['mobile/src/App.tsx'], + static_analysis: true, + typecheck: false, + mobile_dependencies: true + }, + { + changed: ['cloud/package.json'], + static_analysis: false, + typecheck: false, + mobile_dependencies: false + }, + { + changed: ['src/main/index.ts'], + static_analysis: true, + typecheck: true, + mobile_dependencies: false + }, + { + changed: ['mobile/src/App.tsx', 'src/main/index.ts'], + static_analysis: true, + typecheck: true, + mobile_dependencies: true + }, + { changed: [], static_analysis: true, typecheck: true, mobile_dependencies: true } +])('preserves exact phase selection for unrelated paths: $changed', ({ changed, ...expected }) => { const scope = classifyPrJobs(changed) + expect({ + static_analysis: scope.static_analysis, + typecheck: scope.typecheck, + mobile_dependencies: scope.mobile_dependencies + }).toEqual(expected) const needs = { code_paths: { outputs: Object.fromEntries(Object.entries(scope).map(([key, value]) => [key, String(value)])) } } - expect(runInNewContext(preflight.if, { needs })).toBe(scope.static_analysis || scope.typecheck) - expect(runInNewContext(compiler.if, { needs })).toBe(scope.typecheck) - expect(scope.test).toBe(scope.typecheck) + expect(runInNewContext(preflight.if, { needs })).toBe( + expected.static_analysis || expected.typecheck + ) + expect(runInNewContext(compiler.if, { needs })).toBe(expected.typecheck) + expect(scope.test).toBe(expected.typecheck) }) it('registers successful no-op background work when a phase is unselected or already failed', () => { diff --git a/docs/reference/ci-runner-efficiency.md b/docs/reference/ci-runner-efficiency.md index 00d1d0cf39f..58cef59dde5 100644 --- a/docs/reference/ci-runner-efficiency.md +++ b/docs/reference/ci-runner-efficiency.md @@ -133,7 +133,7 @@ POST start/completion rows; wall timestamps and cross-stream interleaving are excluded from equivalence. Eleven generated-source faults fail their intended identity, reload, order, deadline or timer-cleanup assertions. Four import/setup/ disposer rejection and timeout controls confirm restoration of clocks, fetch, -argv and environment. Teardown restores real clocks before its bounded wait. +argv and environment. Teardown holds its fake clock through bounded native cleanup and pending-timer checks, then restores real clocks. The [teardown qualification](https://github.com/stablyai/orca/actions/runs/37195736834) passes all 41 cases and 12 failure and restoration control invocations, including interval and retry leaks missed by the earlier teardown. Title-send authorization tests retain real terminal creation, graph binding and positive evidence paths. Negative process-evidence probes use scoped clocks after setup: both 150 ms polling loops retain their 6,500 ms budget, crossed at @@ -151,6 +151,7 @@ original test bodies are byte-unchanged. Three actual range/hunk/draft faults fail their original assertions; a real draft-render menu call hits the facade and sentinel, and an outer cleanup check proves mock state cleared after failure. The actual saved-note menu retains its independent component tests. + ## October 2 headless detector compiler cache The deferred detector already avoids dependency setup for known build inputs. @@ -706,6 +707,8 @@ All commands passed and plans matched within each comparison. These command timings exclude setup and queues; compiler variation contributes to the cold difference. The retained arrangement showed no cold compiler penalty. +The sequential gate in [October 4 shared PR preflight capacity](#october-4-shared-pr-preflight-capacity) supersedes the earlier rejection below. + Combining static analysis too was rejected. An [alternating same-runner comparison](https://github.com/stablyai/orca/actions/runs/36835091650) saved runner occupancy, but cold compilation slowed from 61–64 to 83–89 seconds @@ -1844,6 +1847,8 @@ these local body measurements do not establish hosted or whole-PR time savings. ## Sequential static analysis and typecheck: retain separate jobs +The earlier recommendation below is superseded by [October 4 shared PR preflight capacity](#october-4-shared-pr-preflight-capacity). + A four-trial hosted screen kept the slim router unchanged and compared the two independent ARM jobs with one ARM job running their unchanged checks sequentially. The [compiler/planner census](https://github.com/stablyai/orca/actions/runs/37069472888) @@ -2085,11 +2090,11 @@ production and still passed when production always threw. Three alternating one-worker hosted ARM pairs measured complete invocations: -| Cohort | Baseline median | Candidate median | Saving | -| --- | --- | --- | --- | -| Serializer replay/fuzz/descriptor checks | 71.675s | 46.581s | 35.0% | -| Emulator/reconciliation/color parity | 24.095s | 5.411s | 77.5% | -| Frame equivalence | 18.472s | 13.736s | 25.6% | +| Cohort | Baseline median | Candidate median | Saving | +| ---------------------------------------- | --------------- | ---------------- | ------ | +| Serializer replay/fuzz/descriptor checks | 71.675s | 46.581s | 35.0% | +| Emulator/reconciliation/color parity | 24.095s | 5.411s | 77.5% | +| Frame equivalence | 18.472s | 13.736s | 25.6% | [37180517143](https://github.com/stablyai/orca/actions/runs/37180517143) retained 116 timed serializer passes and three existing/paired-control skips. @@ -2164,11 +2169,11 @@ Three alternating one-worker hosted ARM pairs in [37182181976](https://github.com/stablyai/orca/actions/runs/37182181976) measured these complete invocations: -| Cohort | Baseline seconds | Candidate seconds | Median saving | -| --- | --- | --- | --- | -| Three imports only, same 15 tests | 19.257 / 19.167 / 19.363 | 1.769 / 1.768 / 1.768 | 90.8% | -| Final four-file runtime cohort | 22.312 / 22.122 / 21.969 | 13.494 / 13.793 / 13.601 | 38.5% | -| Recovery crash boundaries | 24.082 / 24.075 / 24.814 | 8.061 / 8.105 / 9.074 | 66.3% | +| Cohort | Baseline seconds | Candidate seconds | Median saving | +| --------------------------------- | ------------------------ | ------------------------ | ------------- | +| Three imports only, same 15 tests | 19.257 / 19.167 / 19.363 | 1.769 / 1.768 / 1.768 | 90.8% | +| Final four-file runtime cohort | 22.312 / 22.122 / 21.969 | 13.494 / 13.793 / 13.601 | 38.5% | +| Recovery crash boundaries | 24.082 / 24.075 / 24.814 | 8.061 / 8.105 / 9.074 | 66.3% | The final runtime cohort has seven real cases versus 16 including the copied loops; its new runtime case is included in candidate timing. Recovery has 50 @@ -2250,9 +2255,9 @@ Three alternating one-worker hosted ARM pairs in [37180614492](https://github.com/stablyai/orca/actions/runs/37180614492) measured these complete focused invocations: -| Suite | Baseline seconds | Candidate seconds | Median saving | -| --- | --- | --- | --- | -| Git admission storm | 26.619 / 26.635 / 26.582 | 1.017 / 1.018 / 1.016 | 25.602s (96.2%) | +| Suite | Baseline seconds | Candidate seconds | Median saving | +| --------------------- | ------------------------ | ------------------------ | --------------- | +| Git admission storm | 26.619 / 26.635 / 26.582 | 1.017 / 1.018 / 1.016 | 25.602s (96.2%) | | Antigravity readiness | 27.347 / 27.910 / 27.550 | 13.855 / 13.894 / 13.800 | 13.695s (49.7%) | Each candidate passed its original meaningful checks. Hosted Node typecheck @@ -2261,3 +2266,120 @@ FIFO-only priority failed the queued-contention or interactive-start assertion. Two additional local transcript faults failed the original picker-rejection and repaint-readiness assertions. These are focused suite savings; whole-shard time and queue delay were not measured by this experiment. + +## October 4 aggregate unit-test comparison + +A [counterbalanced hosted comparison](https://github.com/stablyai/orca/actions/runs/37197643399) +measured 128.605 seconds less summed test-process time (4.36%) and a 37.694-second +reduction in the slowest shard (5.98%). It compares the accepted optimizations +with their original file snapshots on the same source, five fixed shard +assignments, Node 24, Ubuntu ARM and four workers per process. This is one paired +trial, not a population estimate or a measurement of PR queue delay. + +| Test process | Original snapshots | Accepted optimizations | +| ----------------------------- | ------------------ | ---------------------- | +| Shard 1 | 574.221s | 533.116s | +| Shard 2 | 572.553s | 569.593s | +| Shard 3 | 630.629s | 592.935s | +| Shard 4 | 565.412s | 546.759s | +| Shard 5 | 609.410s | 581.218s | +| Sum: runner time during tests | 2952.225s | 2823.621s | +| Maximum: test critical path | 630.629s | 592.935s | + +Both arms cover exactly 10,838 timed modules on source `9574c8adb253` and tree +`4d5487e8b827`. One added eight-case batching qualification passes in a separate +0.770-second invocation outside the table, completing the 10,839-module ordinary +census. The complete timed case and outcome +comparison accounts for eight approved coverage changes: 105,231 original cases +versus 105,242 candidate cases. Removed copied simulations and an algebra-only +case are accompanied by real runtime, retention, recovery and reusable-cell +regressions. No unexpected case or outcome difference is accepted. + +Each arm starts with distinct empty transform and result caches; Node compilation +caching is disabled. Cold-cache execution ordering remains Vitest's default and +can change with source size. Source snapshots, assignments, raw reports and case outcomes +are checked. Only three case-title fields containing random temporary paths or +a UUID use stable identities, bound to the exact two test-source hashes; raw +titles remain in the artifacts. + +The five dependency setups total 84.284 seconds and are shared by both arms. +The actual paired jobs consumed 5,969 seconds and spanned 2,031 seconds from the +first start to the last completion. Those job figures include both treatments, +setup, uploads and staggered starts; they cannot be assigned to either arm or +used as a workflow saving. The table measures test-process wall time, not CPU +time or the complete CI workflow. Focused-suite percentages elsewhere in this +report are separate measurements and must not be summed into these results. + +The [earlier aggregate trial](https://github.com/stablyai/orca/actions/runs/37193799646) +is rejected because a real test failed; its timings do not qualify a gain. Two +local invocations sharing one XDG directory reproduced the Muse refresher failure. +The fixture now isolates and restores that setting in both arms. The historical +serializer case ledger was also independently corrected from the original source +before this fresh trial; its seven original cases and twenty candidate cases are +an explicit coverage change rather than an assumed equal census. + +## October 4 shard-weight holdouts + +Fresh shard weights were generated with the production importer from a complete +successful run of the accepted source. Two subsequent hosted holdouts used those +same weights and assignments without retraining. The +[first pair](https://github.com/stablyai/orca/actions/runs/37199891967) alternated +existing and fresh assignments across the five jobs; the +[second pair](https://github.com/stablyai/orca/actions/runs/37201939057) reversed +each job's treatment order. + +| Test-process measurement | First: existing | First: fresh | Reversed: existing | Reversed: fresh | +| ------------------------ | --------------- | ------------ | ------------------ | --------------- | +| Sum across five shards | 2813.595s | 2776.421s | 2924.689s | 2878.481s | +| Maximum shard wall | 578.735s | 584.709s | 603.995s | 614.408s | + +Fresh weights reduced summed test-process time by 1.32% and 1.58%, but increased +the slowest shard's time by 1.03% and 1.72%. The small capacity saving comes with +a repeated critical-path regression, so the existing weights remain. The 3.01% +improvement projected from training module durations is not a measured speed +gain. + +Every arm covers the same 10,839 modules and 105,250 case outcomes on source +`9574c8adb253`, tree `4d5487e8b827`, Node 24.21.0, Ubuntu ARM and four workers. +Both trials use cold caches and the same training data, weights, plans and case +identity rules. Complete raw reports, assignments, hashes and opposite treatment +orders are checked before combining the results. Two pairs supply no statistical +confidence or account-wide queue measurement. The second run's jobs started 119 +seconds apart; that stagger and the paired jobs' setup and upload costs are +separate from the treatment timings above. + +## October 4 remaining unit-test opportunities + +The audit retained real child-process, PTY, SSH and crash-boundary tests. It +removed copied simulations or algebra-only cases after fault controls showed +that they could pass with production behavior broken. The retained or replacement +tests exercise production behavior directly. The focused timings in this report +use the final qualified checks. They must not be added together to estimate a +whole-workflow saving. + +Several further changes did not justify promotion: + +- A synchronous readiness-clock screen retained all 49 shard cases and their + outcomes, but complete invocation time changed only from 34.210 to 33.518 + seconds in one local pair. That 2% result was too small to ship without a + stronger result; the original implementation remains. +- A larger local transform-cache screen reduced warm test execution. Separately + measured medians for warm tests (17.251 seconds), extraction (3.539 seconds) and + archive creation (3.092 seconds) sum to 23.882 seconds, versus 23.473 seconds + with caching disabled. This component estimate excludes transfer costs; it is not an + end-to-end measurement. Unkeyed plugin options, inherited configuration and + import priority also produced stale reuse. Persisted test transforms remain + disabled. +- Two successful full-run shadow references identified about 1.9% of + recorded worker time as omittable. That is advisory worker time, not measured + runner occupancy. It does not supply the failed-reference evidence or a + complete selected-run comparison needed to enable test selection. +- Six sampled failed PR runs contained no failed unit job that could trigger + unit-matrix fail-fast. Successful unit siblings of failures in other jobs + cannot be counted as savings from that policy. The sample is too small to + establish a population-wide rate, and the policy remains unchanged. + +These screens reject the examined changes; they do not establish that every +future optimization is exhausted. Shorter admitted jobs and one shared preflight +reduce demand on the existing runner allowance. They do not increase that +allowance or prove lower queue delay under different account traffic. From ea6a6d60774ac2b74bb6692d1798e3ab13b99ae0 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 4 Oct 2026 06:39:39 -0700 Subject: [PATCH 14/31] Pass wrapped OpenCode run prompts as positional messages (#25001) * fix: wait for OpenCode worker composer before first dispatch Reuse captured composer readiness on local and paired execution hosts and revoke launching-shell paste anchors. Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> * feat(opencode): probe execution-host CLI capabilities * fix(opencode): select plugin default for execution host loader * fix(opencode): limit prompt prefill capability to verified release * feat(opencode): probe launch capabilities on the execution host * fix(opencode): select plugin loader for the launched host binary * fix(opencode): match WSL probe cwd and declared guest environment * fix(opencode): preserve launch environment deletion boundaries * wip(opencode): authorize native startup prompt intent at execution owner * fix(opencode): atomically replace status plugin entrypoints * fix(opencode): retain plugin permissions across restrictive umasks * test(opencode): resolve permission fixture from primary cwd * feat(opencode): install startup prompt plugin independently of status hooks * fix(opencode): wait for admitted startup intent and preserve failed-launch briefs * fix(opencode): confine overlay manifest cleanup to owned directories Co-authored-by: Adnan Khan * fix: wait for OpenCode worker composer before first dispatch Reuse captured composer readiness on local and paired execution hosts and revoke launching-shell paste anchors. Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> * feat(opencode): probe execution-host CLI capabilities * fix(opencode): select plugin default for execution host loader * fix(opencode): limit prompt prefill capability to verified release * feat(opencode): probe launch capabilities on the execution host * fix(opencode): select plugin loader for the launched host binary * fix(opencode): match WSL probe cwd and declared guest environment * fix(opencode): preserve launch environment deletion boundaries * wip(opencode): authorize native startup prompt intent at execution owner * fix(opencode): atomically replace status plugin entrypoints * fix(opencode): retain plugin permissions across restrictive umasks * test(opencode): resolve permission fixture from primary cwd * feat(opencode): install startup prompt plugin independently of status hooks * fix(opencode): wait for admitted startup intent and preserve failed-launch briefs * fix(opencode): unsubscribe hook settings during async host shutdown * fix(opencode): confine overlay manifest cleanup to owned directories Co-authored-by: Adnan Khan * test(readiness): census recorded OpenCode composer boots * fix(opencode): reject redirected overlay parents before cleanup * fix(orcad): retain runtime cleanup when subscribing to hook settings * refactor(launch): extract OpenCode config and attachment authority * fix(opencode): retain host version selection across relay restarts * fix(opencode): pass run prompts as positional messages Preserve run flags and use the existing shell quoting and run-command detector to append the initial message after --, reusing an existing separator. TUI launches retain their version-selected prompt transport and draft behavior. Original run-order work: @coelho-doti (#13065, tracked in #17551). * fix(opencode): keep wrapped run tasks positional Recognize supported environment prefixes and PowerShell call operators without mistaking prompt arguments for executables. Keep environment and run separators separate, preserve the task text and exclude run commands from native submission. Source-parent: 23fc08b4e939169f0f6bc80d6e2a92e88699258b Related-to: stablya/orca#17551 Credits: @coelho-doti (stablya/orca#13065) * Prepare complete private OpenCode launch validation source Integrate the complete reviewed readiness, capability, native prompt, overlay and positional-run source onto frozen main. Preserve canonical atomic ACL retry, status generator/disposal, restrictive-umask fixtures and unowned source. Keep supported wrapped run commands positional. Private-validation-source: a44345ce496482c2d9d5bcaf7bb890faa3d4d52e Original-full-source: 23fc08b4e939169f0f6bc80d6e2a92e88699258b Original-core-base: 8186ded0bd5549c7bcca6f69d83e18565e1c8648 Frozen-main: 08ee7ba9efa2f3842b7a057a1eb101c824ea0087 Owned-source-paths: 111 Publication-policy: private validation only; preserve the six separate PR boundaries and held model/provider drafts Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Co-authored-by: Adnan Khan Credits: juli-gonzalez readiness contribution; Ahmed Nagy atomic plugin writer; coelho-doti positional run contribution * Prepare private complete 111-path launch validation on current main Private validation only. Preserve main credential additions and original launch ownership. Held model and provider topics remain excluded. * Recognize env options before positional OpenCode run messages * STRICT launch CI contract correction * CAPS launch CI contract correction * INTENT launch CI contract correction * test(opencode): wait for malformed claim retries before expiring intent Observe real endpoint I/O completion under fake timers before forcing expiry. * test: initialize Claude prompt state in output retention fixture * Wait for OpenCode location hydration in intent startup * fix(opencode): bind startup readiness to the composer location * Bind OpenCode startup readiness to the current location in intent startup * Retry interrupted OpenCode startup prompt claims --------- Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Co-authored-by: Ahmed Nagy Co-authored-by: Adnan Khan Co-authored-by: Orca startup hydration review Co-authored-by: Orca --- src/shared/flag-prompt-startup.ts | 50 ++++++++++ src/shared/opencode-startup-prompt.test.ts | 107 ++++++++++++++++++++- src/shared/opencode-startup-prompt.ts | 2 +- src/shared/tui-agent-startup.ts | 21 ++-- 4 files changed, 166 insertions(+), 14 deletions(-) create mode 100644 src/shared/flag-prompt-startup.ts diff --git a/src/shared/flag-prompt-startup.ts b/src/shared/flag-prompt-startup.ts new file mode 100644 index 00000000000..50e1be306fa --- /dev/null +++ b/src/shared/flag-prompt-startup.ts @@ -0,0 +1,50 @@ +import type { AgentStartupPlan } from './tui-agent-startup' +import type { SleepingAgentLaunchConfig } from './agent-session-resume' +import type { SessionOptionValue } from './native-chat-session-options' +import type { TuiAgent } from './tui-agent' +import { TUI_AGENT_CONFIG } from './tui-agent-config' +import { tokenizeStartupCommand, type AgentStartupShell } from './tui-agent-startup-shell' +import { findOpenCodeRunCommand } from './opencode-headless-command' +import { openCodeStartupPromptEnv } from './opencode-startup-prompt' + +export function appliedSessionOptionProps(values: Record) { + return Object.keys(values).length > 0 ? { sessionOptions: { ...values } } : {} +} + +export function buildFlagPromptStartupPlan(args: { + agent: TuiAgent + launchCommand: string + quotedPrompt: string + prompt: string + shell: AgentStartupShell + launchConfig: SleepingAgentLaunchConfig + sessionOptions: Record + agentEnv: Record | null | undefined +}): AgentStartupPlan { + const parsed = tokenizeStartupCommand(args.launchCommand, args.shell) + const openCodeRun = + (args.agent === 'opencode' || args.agent === 'opencode2') && parsed.ok + ? findOpenCodeRunCommand(parsed.tokens, args.shell) + : null + // OpenCode run takes a positional message; --prompt belongs to its TUI. + const promptSuffix = openCodeRun + ? openCodeRun.messageSeparatorIndex !== null + ? ` ${args.quotedPrompt}` + : ` -- ${args.quotedPrompt}` + : ` --prompt ${args.quotedPrompt}` + return { + agent: args.agent, + launchCommand: `${args.launchCommand}${promptSuffix}`, + expectedProcess: TUI_AGENT_CONFIG[args.agent].expectedProcess, + followupPrompt: null, + launchConfig: args.launchConfig, + ...appliedSessionOptionProps(args.sessionOptions), + ...openCodeStartupPromptEnv( + args.agent, + args.launchCommand, + args.shell, + args.prompt, + args.agentEnv + ) + } +} diff --git a/src/shared/opencode-startup-prompt.test.ts b/src/shared/opencode-startup-prompt.test.ts index e4aab26d065..286f7e3ad2f 100644 --- a/src/shared/opencode-startup-prompt.test.ts +++ b/src/shared/opencode-startup-prompt.test.ts @@ -6,6 +6,7 @@ import { OPENCODE_STARTUP_PROMPT_BODY_ENV, OPENCODE_STARTUP_PROMPT_SHELL_ENV } from './opencode-startup-prompt' +import { tokenizeStartupCommand } from './tui-agent-startup-shell' describe('native OpenCode startup submission intent', () => { it('binds the exact trimmed native prompt to host-selectable transport', () => { @@ -33,9 +34,63 @@ describe('native OpenCode startup submission intent', () => { platform: 'linux' }) expect(plan?.env).toBeUndefined() - expect(plan?.launchCommand).toContain('run --prompt') + expect(plan?.launchCommand).toContain("run -- 'task'") }) + it.each(['posix', 'powershell', 'cmd'] as const)( + 'keeps run and its flags before a positional message in %s', + (shell) => { + for (const agent of ['opencode', 'opencode2'] as const) { + const prompt = '--literal task with unicode é' + const plan = buildAgentStartupPlan({ + agent, + prompt, + cmdOverrides: { [agent]: 'opencode --log-level debug run --standalone' }, + platform: shell === 'posix' ? 'linux' : 'win32', + shell, + agentEnv: { CUSTOM_CONFIG: 'kept' } + }) + expect(plan).not.toBeNull() + const parsed = tokenizeStartupCommand(plan?.launchCommand ?? '', shell) + expect(parsed.ok).toBe(true) + if (!parsed.ok) { + throw new Error(parsed.error) + } + expect(parsed.tokens).toEqual([ + 'opencode', + '--log-level', + 'debug', + 'run', + '--standalone', + '--', + prompt + ]) + expect(plan?.env).toEqual({ CUSTOM_CONFIG: 'kept' }) + expect(plan?.followupPrompt).toBeNull() + } + } + ) + + it.each(['posix', 'powershell', 'cmd'] as const)( + 'reuses an existing run message separator in %s', + (shell) => { + const plan = buildAgentStartupPlan({ + agent: 'opencode', + prompt: '--literal task', + cmdOverrides: { opencode: 'opencode run --standalone --' }, + platform: shell === 'posix' ? 'linux' : 'win32', + shell + }) + const parsed = tokenizeStartupCommand(plan?.launchCommand ?? '', shell) + expect(parsed.ok).toBe(true) + if (!parsed.ok) { + throw new Error(parsed.error) + } + expect(parsed.tokens).toEqual(['opencode', 'run', '--standalone', '--', '--literal task']) + expect(plan?.env).toBeUndefined() + } + ) + it('never gives an editable draft or empty launch an automatic submission intent', () => { const args = { agent: 'opencode' as const, cmdOverrides: {}, platform: 'linux' as const } expect( @@ -50,3 +105,53 @@ describe('native OpenCode startup submission intent', () => { ).toBeUndefined() }) }) + +describe('wrapped OpenCode run startup', () => { + it.each(['opencode', 'opencode2'] as const)( + 'keeps %s run flags and positional task behind POSIX prefixes', + (agent) => { + for (const command of [ + 'CUSTOM_CONFIG=private opencode run --standalone', + 'env CUSTOM_CONFIG=private opencode run --standalone', + 'env -- CUSTOM_CONFIG=private opencode run --standalone', + 'env -- CUSTOM_CONFIG=private opencode run --standalone --', + 'env CUSTOM_CONFIG=private opencode run --title "--"' + ]) { + const plan = buildAgentStartupPlan({ + agent, + prompt: '--literal task', + cmdOverrides: { [agent]: command }, + platform: 'linux', + shell: 'posix', + isRemote: true, + agentEnv: { CUSTOM_CONFIG: 'kept' } + }) + expect(plan?.launchCommand).toBe( + command.endsWith(' --') ? `${command} '--literal task'` : `${command} -- '--literal task'` + ) + expect(plan?.env).toEqual({ CUSTOM_CONFIG: 'kept' }) + expect(plan?.followupPrompt).toBeNull() + } + } + ) + + it.each(['opencode', 'opencode2'] as const)( + 'preserves %s PowerShell call syntax and its run separator', + (agent) => { + for (const separator of ['', ' --']) { + const command = `& "C:\\Program Files\\opencode\\opencode.exe" --log-level debug run --standalone${separator}` + const plan = buildAgentStartupPlan({ + agent, + prompt: "--task's é", + cmdOverrides: { [agent]: command }, + platform: 'win32', + shell: 'powershell', + agentEnv: { CUSTOM_CONFIG: 'kept' } + }) + expect(plan?.launchCommand).toBe(`${command}${separator ? ' ' : ' -- '}'--task''s é'`) + expect(plan?.env).toEqual({ CUSTOM_CONFIG: 'kept' }) + expect(plan?.followupPrompt).toBeNull() + } + } + ) +}) diff --git a/src/shared/opencode-startup-prompt.ts b/src/shared/opencode-startup-prompt.ts index 1dbe9c2719d..e3b1c462ffa 100644 --- a/src/shared/opencode-startup-prompt.ts +++ b/src/shared/opencode-startup-prompt.ts @@ -21,7 +21,7 @@ export function openCodeStartupPromptEnv( if ( (agent !== 'opencode' && agent !== 'opencode2') || !parsed.ok || - isOpenCodeRunCommand(parsed.tokens) + isOpenCodeRunCommand(parsed.tokens, shell) ) { return env ? { env: { ...env } } : {} } diff --git a/src/shared/tui-agent-startup.ts b/src/shared/tui-agent-startup.ts index ce8974a8cc3..8c56c1d9d95 100644 --- a/src/shared/tui-agent-startup.ts +++ b/src/shared/tui-agent-startup.ts @@ -17,7 +17,7 @@ import { inlineAgentDraftFitsPlatform } from './agent-draft-platform-limit' import type { TuiAgent } from './tui-agent' import type { SessionOptionValue } from './native-chat-session-options' import { resolveAgentLaunchCommand } from './tui-agent-launch-command' -import { openCodeStartupPromptEnv } from './opencode-startup-prompt' +import { appliedSessionOptionProps, buildFlagPromptStartupPlan } from './flag-prompt-startup' export { buildAgentResumeStartupPlan } from './tui-agent-resume-startup' @@ -36,10 +36,6 @@ export type AgentStartupPlan = { sessionOptions?: Record } -function appliedSessionOptionProps(values: Record) { - return Object.keys(values).length > 0 ? { sessionOptions: { ...values } } : {} -} - export function buildAgentStartupPlan(args: { agent: TuiAgent prompt: string @@ -117,15 +113,16 @@ export function buildAgentStartupPlan(args: { } if (config.promptInjectionMode === 'flag-prompt') { - return { + return buildFlagPromptStartupPlan({ agent, - launchCommand: `${launchCommand} --prompt ${quotedPrompt}`, - expectedProcess: config.expectedProcess, - followupPrompt: null, + launchCommand, + quotedPrompt, + prompt: trimmedPrompt, + shell, launchConfig, - ...appliedSessionOptionProps(baseCommand.appliedSessionOptions), - ...openCodeStartupPromptEnv(agent, launchCommand, shell, trimmedPrompt, args.agentEnv) - } + sessionOptions: baseCommand.appliedSessionOptions, + agentEnv: args.agentEnv + }) } if (config.promptInjectionMode === 'hermes-query') { From 8d87d2cf67d545d61bd28166f06716655647753f Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sun, 4 Oct 2026 11:19:02 -0700 Subject: [PATCH 15/31] feat(codex): tell Windows users once that Codex in Orca now shares ~/.codex (#24916) * feat(codex): tell Windows users once what stays behind when Codex moves onto ~/.codex When Windows' system-default Codex first runs on ~/.codex (launch prep or the usage poll), main decides once whether Orca's managed home was ever used and which MCP servers lived only there, and persists that in UI state. The renderer shows one dismissible toast when a Codex terminal exists, after the server-isolation notice rather than on top of it, and clears the notice when shown. The "kept only in the managed home" MCP rule is extracted into isRuntimeOnlyMcpServer, which the config mirror merge now uses too, so the notice names exactly the servers the mirror would have kept. * fix(codex): stop counting Orca's own config.toml as use of the old Codex home Orca's hook install writes that home's config.toml on every startup, so its presence was true for nearly every Windows user with Codex. The home now counts as used only with recorded sessions or an MCP server of its own. Resolver tests keep one case per input source. * refactor(codex): ask main for the shared-settings notice instead of persisting it The persisted missing/object/null field, written from launch prep and the usage poll, becomes a plain codexSharedSettingsNoticeSeen flag mirroring codexTerminalServerIsolationNoticeSeen. When a Codex terminal first appears and the flag is unset, the renderer asks codexConfigSync:sharedSettingsNotice once; main answers read-only (Windows, system default on ~/.codex, managed home path without mkdir) and maps any read error to null. Runtime-home routing, launch and the test harness return to main's code. The notice no longer waits for the server-isolation toast; they may stack. The Codex-terminal watch moves to codex-terminal-presence.ts. * refactor(codex): watch for the first Codex terminal in one place for both notices The server-isolation notice now passes its due check to whenCodexTerminalAppears instead of keeping its own copy of the presence scan, input filter and subscription loop. Its behaviour and tests are unchanged. * docs(codex): trim isRuntimeOnlyMcpServer's comment to why it is shared * refactor(codex): keep McpServerTomlOwnership private to its module * test(codex): cover the shared-settings notice channel without type assertions Handlers are looked up by channel now that two are registered, so the status tests no longer depend on registration order. * refactor(codex): show the Windows shared-settings notice without asking main Every way of detecting who relied on Orca's old Codex folder had false positives, so the renderer now shows one static toast on Windows the first time a Codex terminal exists. This drops the main-process resolver, its IPC channel, preload line, web stub and shared type, and the MCP-names variant of the description. * refactor(codex): restore the MCP server ownership helpers to main's shape The static notice no longer reads MCP servers, so the shared isRuntimeOnlyMcpServer extraction has no second caller. * refactor(codex): let each notice decide when it is due, so the Codex watcher only watches The isolation notice now selects its due predicate and starts the watcher only while due, so whenCodexTerminalAppears no longer takes an isDue or re-checks hydration and settings. The shared-settings notice uses isLocalWindowsDesktopClient, its test stubs the user agent instead of mocking pane-helpers, and the hydration safeguard it relies on is now tested on the UI slice itself. * test(codex): drive the Codex notices through a reactive store, and drop a redundant hydration gate The server-isolation notice now reads "is it due" through a store selector, but its test mocked the store without re-rendering, so a due change after mount (persisted UI loading, the setting turning off) was never exercised. The notice tests now share one harness backed by a real zustand store, the shared watcher gets its own test, and both notices cover the seen flag loading after mount. persistedUIReady is dropped from isNoticeDue: the seen flag defaults to true and only hydration clears it, in the same update that sets persistedUIReady. Both notices now gate the same way. * fix(codex): keep the shared-settings toast until dismissed, and shorten it It is marked seen before it shows, so a 15s auto-close could lose it for good while the user is typing in the Codex terminal that triggered it. Every other one-shot notice that marks itself seen on show stays until dismissed; this now does too. The text drops the sentence that repeated the title and keeps only what to expect and do. --------- Co-authored-by: Orca Worker --- .../src/app-shell/use-app-shell-services.ts | 2 + .../codex-notice-test-harness.ts | 37 ++++++ .../codex-shared-settings-notice.test.ts | 89 +++++++++++++ .../codex-shared-settings-notice.ts | 37 ++++++ .../codex-terminal-presence.test.ts | 56 ++++++++ .../terminal-pane/codex-terminal-presence.ts | 37 ++++++ ...x-terminal-server-isolation-notice.test.ts | 124 ++++++------------ .../codex-terminal-server-isolation-notice.ts | 61 ++------- src/renderer/src/i18n/locales/en.json | 4 + .../store/slices/ui-notice-dismissals.test.ts | 13 ++ .../slices/ui/ui-slice-contract-contextual.ts | 2 + .../slices/ui/ui-slice-hydration-actions.ts | 1 + .../store/slices/ui/ui-slice-trust-actions.ts | 9 ++ src/shared/persisted-ui-state-types.ts | 2 + src/shared/rpc-contract/client-ui-params.ts | 1 + 15 files changed, 335 insertions(+), 140 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/codex-notice-test-harness.ts create mode 100644 src/renderer/src/components/terminal-pane/codex-shared-settings-notice.test.ts create mode 100644 src/renderer/src/components/terminal-pane/codex-shared-settings-notice.ts create mode 100644 src/renderer/src/components/terminal-pane/codex-terminal-presence.test.ts create mode 100644 src/renderer/src/components/terminal-pane/codex-terminal-presence.ts diff --git a/src/renderer/src/app-shell/use-app-shell-services.ts b/src/renderer/src/app-shell/use-app-shell-services.ts index 6c52abb6518..879fcea5984 100644 --- a/src/renderer/src/app-shell/use-app-shell-services.ts +++ b/src/renderer/src/app-shell/use-app-shell-services.ts @@ -21,6 +21,7 @@ import { useRemoteRuntimeRecoveryTriggers } from '../runtime/use-remote-runtime- import { useTerminalViewerColorPublication } from './use-terminal-viewer-color-publication' import { useBrowserIdentityMigrationNotice } from '../components/browser-pane/browser-user-agent-migration-notice' import { useCodexTerminalServerIsolationNotice } from '../components/terminal-pane/codex-terminal-server-isolation-notice' +import { useCodexSharedSettingsNotice } from '../components/terminal-pane/codex-shared-settings-notice' /** * App-level subscriptions that must outlive any individual surface. Each one is here because @@ -56,4 +57,5 @@ export function useAppShellServices(options: { floatingPanelVisible: boolean }): useOsc52ClipboardDefaultOnNotice(persistedUIReady) useBrowserIdentityMigrationNotice() useCodexTerminalServerIsolationNotice() + useCodexSharedSettingsNotice() } diff --git a/src/renderer/src/components/terminal-pane/codex-notice-test-harness.ts b/src/renderer/src/components/terminal-pane/codex-notice-test-harness.ts new file mode 100644 index 00000000000..152f09d67f8 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/codex-notice-test-harness.ts @@ -0,0 +1,37 @@ +import { act, createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { useStore } from 'zustand' +import { createStore } from 'zustand/vanilla' + +type NoticeTestState = Record + +/** Stands in for `@/store`: a real zustand store, so selectors re-render and subscribe fires. */ +export const noticeTestStore = createStore()(() => ({})) + +export const useAppStore = Object.assign( + (selector: (state: NoticeTestState) => T): T => useStore(noticeTestStore, selector), + noticeTestStore +) + +const mountedRoots: Root[] = [] + +export async function mountHook(useHook: () => void): Promise { + function HookProbe(): null { + useHook() + return null + } + const root = createRoot(document.createElement('div')) + mountedRoots.push(root) + await act(async () => root.render(createElement(HookProbe))) +} + +export function unmountHooks(): void { + for (const root of mountedRoots.splice(0)) { + act(() => root.unmount()) + } +} + +/** Applies a store write and lets the hook re-render before returning. */ +export async function setNoticeState(patch: NoticeTestState): Promise { + await act(async () => noticeTestStore.setState(patch)) +} diff --git a/src/renderer/src/components/terminal-pane/codex-shared-settings-notice.test.ts b/src/renderer/src/components/terminal-pane/codex-shared-settings-notice.test.ts new file mode 100644 index 00000000000..a9de1beb870 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/codex-shared-settings-notice.test.ts @@ -0,0 +1,89 @@ +// @vitest-environment happy-dom + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + mountHook, + noticeTestStore, + setNoticeState, + unmountHooks +} from './codex-notice-test-harness' +import { useCodexSharedSettingsNotice } from './codex-shared-settings-notice' + +const { toastInfoMock } = vi.hoisted(() => ({ toastInfoMock: vi.fn() })) + +vi.mock('sonner', () => ({ toast: { info: toastInfoMock } })) +vi.mock('@/store', () => import('./codex-notice-test-harness')) + +const codexTab = { 'wt-1': [{ id: 'tab-1', launchAgent: 'codex' }] } + +function resetStore(overrides: Record = {}): void { + noticeTestStore.setState( + { + codexSharedSettingsNoticeSeen: false, + tabsByWorktree: {}, + agentStatusByPaneKey: {}, + paneForegroundAgentByPaneKey: {}, + markCodexSharedSettingsNoticeSeen: () => + noticeTestStore.setState({ codexSharedSettingsNoticeSeen: true }), + ...overrides + }, + true + ) +} + +const isSeen = (): unknown => noticeTestStore.getState().codexSharedSettingsNoticeSeen + +describe('useCodexSharedSettingsNotice', () => { + beforeEach(() => { + toastInfoMock.mockReset() + vi.stubGlobal('navigator', { userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' }) + resetStore() + }) + + afterEach(() => { + unmountHooks() + vi.unstubAllGlobals() + }) + + it('shows once on Windows when a Codex terminal appears, and marks it seen', async () => { + await mountHook(useCodexSharedSettingsNotice) + expect(toastInfoMock).not.toHaveBeenCalled() + + await setNoticeState({ tabsByWorktree: codexTab }) + await setNoticeState({ agentStatusByPaneKey: { 'tab-1:leaf': { agentType: 'codex' } } }) + + expect(toastInfoMock).toHaveBeenCalledTimes(1) + expect(toastInfoMock).toHaveBeenCalledWith('Codex in Orca now uses ~/.codex', { + id: 'codex-shared-settings-notice', + description: + 'Codex may ask again to trust folders or approve commands. Re-add any MCP servers you added only in Orca.', + duration: Infinity + }) + expect(isSeen()).toBe(true) + }) + + it('stays quiet off Windows', async () => { + vi.stubGlobal('navigator', { userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)' }) + resetStore({ tabsByWorktree: codexTab }) + await mountHook(useCodexSharedSettingsNotice) + expect(toastInfoMock).not.toHaveBeenCalled() + expect(isSeen()).toBe(false) + }) + + it('stays quiet in a paired web client window', async () => { + vi.stubGlobal('__ORCA_WEB_CLIENT__', true) + resetStore({ tabsByWorktree: codexTab }) + await mountHook(useCodexSharedSettingsNotice) + expect(toastInfoMock).not.toHaveBeenCalled() + }) + + it('shows once the persisted seen flag loads after mount', async () => { + // Why seen: true: the store's default until persisted UI arrives. + resetStore({ codexSharedSettingsNoticeSeen: true, tabsByWorktree: codexTab }) + await mountHook(useCodexSharedSettingsNotice) + expect(toastInfoMock).not.toHaveBeenCalled() + + await setNoticeState({ codexSharedSettingsNoticeSeen: false }) + expect(toastInfoMock).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/codex-shared-settings-notice.ts b/src/renderer/src/components/terminal-pane/codex-shared-settings-notice.ts new file mode 100644 index 00000000000..db05a38d4e7 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/codex-shared-settings-notice.ts @@ -0,0 +1,37 @@ +import { useEffect } from 'react' +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' +import { useAppStore } from '@/store' +import { isLocalWindowsDesktopClient } from '@/lib/desktop-window-chrome' +import { whenCodexTerminalAppears } from './codex-terminal-presence' + +function showCodexSharedSettingsNotice(): void { + // Why mark before showing: seen means shown, so a quit or reload never repeats it. + useAppStore.getState().markCodexSharedSettingsNoticeSeen() + toast.info( + translate('terminal.codexSharedSettingsNotice.title', 'Codex in Orca now uses ~/.codex'), + { + // Why a stable id: a late sync that resets the flag can't stack a second toast. + id: 'codex-shared-settings-notice', + description: translate( + 'terminal.codexSharedSettingsNotice.description', + 'Codex may ask again to trust folders or approve commands. Re-add any MCP servers you added only in Orca.' + ), + // Why no timeout: it is marked seen before showing, so an auto-close would lose it for good. + duration: Infinity + } + ) +} + +export function useCodexSharedSettingsNotice(): void { + // Why no hydration check: the flag defaults to true until the persisted value arrives. + const seen = useAppStore((s) => s.codexSharedSettingsNoticeSeen) + + useEffect(() => { + // Why skip paired web clients: the change is on the host, whose own window shows this. + if (seen || !isLocalWindowsDesktopClient()) { + return + } + return whenCodexTerminalAppears(showCodexSharedSettingsNotice) + }, [seen]) +} diff --git a/src/renderer/src/components/terminal-pane/codex-terminal-presence.test.ts b/src/renderer/src/components/terminal-pane/codex-terminal-presence.test.ts new file mode 100644 index 00000000000..51428c9d3f9 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/codex-terminal-presence.test.ts @@ -0,0 +1,56 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { noticeTestStore } from './codex-notice-test-harness' +import { whenCodexTerminalAppears } from './codex-terminal-presence' + +vi.mock('@/store', () => import('./codex-notice-test-harness')) + +describe('whenCodexTerminalAppears', () => { + beforeEach(() => { + noticeTestStore.setState( + { tabsByWorktree: {}, agentStatusByPaneKey: {}, paneForegroundAgentByPaneKey: {} }, + true + ) + }) + + it.each([ + ['an Orca-launched Codex tab', { tabsByWorktree: { 'wt-1': [{ launchAgent: 'codex' }] } }], + ['a hook-reported Codex', { agentStatusByPaneKey: { 'tab-1:leaf': { agentType: 'codex' } } }], + [ + 'a typed codex in the foreground', + { paneForegroundAgentByPaneKey: { 'tab-1:leaf': { agent: 'codex' } } } + ] + ])('calls back once for %s', (_name, patch) => { + const onAppear = vi.fn() + whenCodexTerminalAppears(onAppear) + + noticeTestStore.setState(patch) + noticeTestStore.setState({ tabsByWorktree: { 'wt-2': [{ launchAgent: 'codex' }] } }) + + expect(onAppear).toHaveBeenCalledTimes(1) + }) + + it('calls back at once when a Codex terminal already exists', () => { + noticeTestStore.setState({ tabsByWorktree: { 'wt-1': [{ launchAgent: 'codex' }] } }) + const onAppear = vi.fn() + whenCodexTerminalAppears(onAppear) + expect(onAppear).toHaveBeenCalledTimes(1) + }) + + it('ignores other agents', () => { + const onAppear = vi.fn() + whenCodexTerminalAppears(onAppear) + noticeTestStore.setState({ + tabsByWorktree: { 'wt-1': [{ launchAgent: 'claude' }] }, + agentStatusByPaneKey: { 'tab-1:leaf': { agentType: 'claude' } }, + paneForegroundAgentByPaneKey: { 'tab-1:leaf': { agent: 'opencode' } } + }) + expect(onAppear).not.toHaveBeenCalled() + }) + + it('stops watching once unsubscribed', () => { + const onAppear = vi.fn() + whenCodexTerminalAppears(onAppear)() + noticeTestStore.setState({ tabsByWorktree: { 'wt-1': [{ launchAgent: 'codex' }] } }) + expect(onAppear).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/codex-terminal-presence.ts b/src/renderer/src/components/terminal-pane/codex-terminal-presence.ts new file mode 100644 index 00000000000..6afcd315642 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/codex-terminal-presence.ts @@ -0,0 +1,37 @@ +import { useAppStore } from '@/store' +import type { AppState } from '@/store/types' + +// Why three sources: Orca-launched tabs, hook-reported agents (SSH too), and a typed `codex` seen locally. +function hasCodexTerminal(state: AppState): boolean { + return ( + Object.values(state.tabsByWorktree).some((tabs) => + tabs.some((tab) => tab.launchAgent === 'codex') + ) || + Object.values(state.agentStatusByPaneKey).some((entry) => entry.agentType === 'codex') || + Object.values(state.paneForegroundAgentByPaneKey).some((entry) => entry.agent === 'codex') + ) +} + +function didSourcesChange(state: AppState, previous: AppState): boolean { + return ( + state.tabsByWorktree !== previous.tabsByWorktree || + state.agentStatusByPaneKey !== previous.agentStatusByPaneKey || + state.paneForegroundAgentByPaneKey !== previous.paneForegroundAgentByPaneKey + ) +} + +/** Calls `onAppear` once, as soon as a Codex terminal exists. Returns the unsubscribe. */ +export function whenCodexTerminalAppears(onAppear: () => void): () => void { + if (hasCodexTerminal(useAppStore.getState())) { + onAppear() + return () => {} + } + // Why a filtered subscription: a selector would rescan every tab on each store write. + const unsubscribe = useAppStore.subscribe((state, previous) => { + if (didSourcesChange(state, previous) && hasCodexTerminal(state)) { + unsubscribe() + onAppear() + } + }) + return unsubscribe +} diff --git a/src/renderer/src/components/terminal-pane/codex-terminal-server-isolation-notice.test.ts b/src/renderer/src/components/terminal-pane/codex-terminal-server-isolation-notice.test.ts index ce7ee221898..87878d6fe4d 100644 --- a/src/renderer/src/components/terminal-pane/codex-terminal-server-isolation-notice.test.ts +++ b/src/renderer/src/components/terminal-pane/codex-terminal-server-isolation-notice.test.ts @@ -1,43 +1,27 @@ // @vitest-environment happy-dom -import { act, createElement } from 'react' -import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { CODEX_TERMINAL_SERVER_ISOLATION_SETTINGS_TARGET_ID } from '@/lib/settings-navigation-types' +import { + mountHook, + noticeTestStore, + setNoticeState, + unmountHooks +} from './codex-notice-test-harness' import { useCodexTerminalServerIsolationNotice } from './codex-terminal-server-isolation-notice' -// Why a real zustand store double: the hook relies on subscribe/setState semantics. -const { toastInfoMock, harness } = vi.hoisted(() => ({ - toastInfoMock: vi.fn(), - harness: { setState: (_patch: Record, _replace?: true): void => {} } -})) +const { toastInfoMock } = vi.hoisted(() => ({ toastInfoMock: vi.fn() })) vi.mock('sonner', () => ({ toast: { info: toastInfoMock } })) - -vi.mock('@/store', async () => { - const { createStore } = await import('zustand/vanilla') - const backing = createStore>()(() => ({})) - harness.setState = (patch, replace) => - replace ? backing.setState(patch, true) : backing.setState(patch) - const useAppStore = (selector: (state: Record) => T): T => - selector(backing.getState()) - return { useAppStore: Object.assign(useAppStore, backing) } -}) - -const store = { - setState: (patch: Record, replace?: true) => harness.setState(patch, replace) -} -let seen = false +vi.mock('@/store', () => import('./codex-notice-test-harness')) const openSettingsPage = vi.fn() const openSettingsTarget = vi.fn() -const mountedRoots: Root[] = [] +const codexTab = { 'wt-1': [{ id: 'tab-1', launchAgent: 'codex' }] } function resetStore(overrides: Record = {}): void { - seen = false - store.setState( + noticeTestStore.setState( { - persistedUIReady: true, codexTerminalServerIsolationNoticeSeen: false, settings: { codexTerminalServerIsolation: true }, tabsByWorktree: {}, @@ -45,33 +29,14 @@ function resetStore(overrides: Record = {}): void { paneForegroundAgentByPaneKey: {}, openSettingsPage, openSettingsTarget, - markCodexTerminalServerIsolationNoticeSeen: () => { - seen = true - store.setState({ codexTerminalServerIsolationNoticeSeen: true }) - }, + markCodexTerminalServerIsolationNoticeSeen: () => + noticeTestStore.setState({ codexTerminalServerIsolationNoticeSeen: true }), ...overrides }, true ) } -function HookProbe(): null { - useCodexTerminalServerIsolationNotice() - return null -} - -async function mountProbe(): Promise { - const container = document.createElement('div') - document.body.appendChild(container) - const root = createRoot(container) - mountedRoots.push(root) - await act(async () => { - root.render(createElement(HookProbe)) - }) -} - -const codexTab = { 'wt-1': [{ id: 'tab-1', launchAgent: 'codex' }] } - describe('useCodexTerminalServerIsolationNotice', () => { beforeEach(() => { toastInfoMock.mockReset() @@ -80,65 +45,50 @@ describe('useCodexTerminalServerIsolationNotice', () => { resetStore() }) - afterEach(() => { - for (const root of mountedRoots.splice(0)) { - act(() => root.unmount()) - } - document.body.innerHTML = '' - }) + afterEach(unmountHooks) it('shows once when the first Codex terminal starts, and marks it seen', async () => { - await mountProbe() + await mountHook(useCodexTerminalServerIsolationNotice) expect(toastInfoMock).not.toHaveBeenCalled() - act(() => store.setState({ tabsByWorktree: codexTab })) - act(() => store.setState({ agentStatusByPaneKey: { 'tab-2:leaf': { agentType: 'codex' } } })) + await setNoticeState({ tabsByWorktree: codexTab }) + await setNoticeState({ agentStatusByPaneKey: { 'tab-2:leaf': { agentType: 'codex' } } }) expect(toastInfoMock).toHaveBeenCalledTimes(1) expect(toastInfoMock.mock.calls[0]?.[1]).toMatchObject({ duration: Infinity }) - expect(seen).toBe(true) - }) - - it.each([ - [ - 'a typed codex seen by hooks', - { agentStatusByPaneKey: { 'tab-1:leaf': { agentType: 'codex' } } } - ], - [ - 'a typed codex in the foreground', - { paneForegroundAgentByPaneKey: { 'tab-1:leaf': { agent: 'codex' } } } - ] - ])('also triggers on %s', async (_name, patch) => { - await mountProbe() - act(() => store.setState(patch)) - expect(toastInfoMock).toHaveBeenCalledTimes(1) - }) - - it('never shows for other agents', async () => { - await mountProbe() - act(() => - store.setState({ - tabsByWorktree: { 'wt-1': [{ id: 'tab-1', launchAgent: 'claude' }] }, - agentStatusByPaneKey: { 'tab-1:leaf': { agentType: 'claude' } }, - paneForegroundAgentByPaneKey: { 'tab-1:leaf': { agent: 'opencode' } } - }) - ) - expect(toastInfoMock).not.toHaveBeenCalled() + expect(noticeTestStore.getState().codexTerminalServerIsolationNoticeSeen).toBe(true) }) it.each([ ['it was already seen', { codexTerminalServerIsolationNoticeSeen: true }], ['the user turned the setting off', { settings: { codexTerminalServerIsolation: false } }], - ['persisted UI has not hydrated', { persistedUIReady: false }] + ['settings have not loaded', { settings: null }] ])('stays quiet when %s', async (_name, overrides) => { resetStore({ ...overrides, tabsByWorktree: codexTab }) - await mountProbe() + await mountHook(useCodexTerminalServerIsolationNotice) + expect(toastInfoMock).not.toHaveBeenCalled() + }) + + it('shows once the persisted seen flag loads after mount', async () => { + // Why seen: true: the store's default until persisted UI arrives. + resetStore({ codexTerminalServerIsolationNoticeSeen: true, tabsByWorktree: codexTab }) + await mountHook(useCodexTerminalServerIsolationNotice) + expect(toastInfoMock).not.toHaveBeenCalled() + + await setNoticeState({ codexTerminalServerIsolationNoticeSeen: false }) + expect(toastInfoMock).toHaveBeenCalledTimes(1) + }) + + it('stops waiting when the user turns the setting off', async () => { + await mountHook(useCodexTerminalServerIsolationNotice) + await setNoticeState({ settings: { codexTerminalServerIsolation: false } }) + await setNoticeState({ tabsByWorktree: codexTab }) expect(toastInfoMock).not.toHaveBeenCalled() }) it('opens Settings at the Codex server setting', async () => { resetStore({ tabsByWorktree: codexTab }) - await mountProbe() + await mountHook(useCodexTerminalServerIsolationNotice) toastInfoMock.mock.calls[0]?.[1]?.action.onClick() diff --git a/src/renderer/src/components/terminal-pane/codex-terminal-server-isolation-notice.ts b/src/renderer/src/components/terminal-pane/codex-terminal-server-isolation-notice.ts index 4fe87294bdf..b04e63d8f00 100644 --- a/src/renderer/src/components/terminal-pane/codex-terminal-server-isolation-notice.ts +++ b/src/renderer/src/components/terminal-pane/codex-terminal-server-isolation-notice.ts @@ -6,46 +6,15 @@ import type { AppState } from '@/store/types' import { isPairedWebClientWindow } from '@/lib/desktop-window-chrome' import { CODEX_TERMINAL_SERVER_ISOLATION_SETTINGS_TARGET_ID } from '@/lib/settings-navigation-types' import { isCodexTerminalServerIsolationEnabled } from '../../../../shared/codex-terminal-server-isolation' +import { whenCodexTerminalAppears } from './codex-terminal-presence' -type CodexNoticeState = Pick< - AppState, - | 'persistedUIReady' - | 'codexTerminalServerIsolationNoticeSeen' - | 'settings' - | 'tabsByWorktree' - | 'agentStatusByPaneKey' - | 'paneForegroundAgentByPaneKey' -> - -// Why three sources: Orca-launched tabs, hook-reported agents (SSH too), and a typed `codex` seen locally. -function hasCodexTerminal(state: CodexNoticeState): boolean { +// Why no hydration check: the seen flag defaults to true until the persisted value arrives. +function isNoticeDue(state: AppState): boolean { return ( - Object.values(state.tabsByWorktree).some((tabs) => - tabs.some((tab) => tab.launchAgent === 'codex') - ) || - Object.values(state.agentStatusByPaneKey).some((entry) => entry.agentType === 'codex') || - Object.values(state.paneForegroundAgentByPaneKey).some((entry) => entry.agent === 'codex') - ) -} - -export function shouldShowCodexTerminalServerIsolationNotice(state: CodexNoticeState): boolean { - return ( - state.persistedUIReady && !state.codexTerminalServerIsolationNoticeSeen && state.settings !== null && // Why: a user who already opted out needs no announcement of the default. - isCodexTerminalServerIsolationEnabled(state.settings) && - hasCodexTerminal(state) - ) -} - -function didNoticeInputsChange(state: CodexNoticeState, previous: CodexNoticeState): boolean { - return ( - state.persistedUIReady !== previous.persistedUIReady || - state.settings !== previous.settings || - state.tabsByWorktree !== previous.tabsByWorktree || - state.agentStatusByPaneKey !== previous.agentStatusByPaneKey || - state.paneForegroundAgentByPaneKey !== previous.paneForegroundAgentByPaneKey + isCodexTerminalServerIsolationEnabled(state.settings) ) } @@ -93,27 +62,13 @@ function showCodexTerminalServerIsolationNotice(): void { } export function useCodexTerminalServerIsolationNotice(): void { - const seen = useAppStore((s) => s.codexTerminalServerIsolationNoticeSeen) + const due = useAppStore(isNoticeDue) useEffect(() => { // Why: a paired web client's terminals follow the host's setting, not this window's. - if (seen || isPairedWebClientWindow()) { + if (!due || isPairedWebClientWindow()) { return } - if (shouldShowCodexTerminalServerIsolationNotice(useAppStore.getState())) { - showCodexTerminalServerIsolationNotice() - return - } - // Why a filtered subscription: a selector would rescan every tab on each store write. - const unsubscribe = useAppStore.subscribe((state, previous) => { - if ( - didNoticeInputsChange(state, previous) && - shouldShowCodexTerminalServerIsolationNotice(state) - ) { - unsubscribe() - showCodexTerminalServerIsolationNotice() - } - }) - return unsubscribe - }, [seen]) + return whenCodexTerminalAppears(showCodexTerminalServerIsolationNotice) + }, [due]) } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 7b497327dac..cb7b6f4607f 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18755,6 +18755,10 @@ "description": "This makes agent status more reliable. You can turn it back on in Settings.", "openSettings": "Open Settings" }, + "codexSharedSettingsNotice": { + "title": "Codex in Orca now uses ~/.codex", + "description": "Codex may ask again to trust folders or approve commands. Re-add any MCP servers you added only in Orca." + }, "codexSharedServerBanner": { "title": "This Codex is sharing a server with your other Codex tabs", "body": "Sessions may end unexpectedly, and agent status may be wrong.", diff --git a/src/renderer/src/store/slices/ui-notice-dismissals.test.ts b/src/renderer/src/store/slices/ui-notice-dismissals.test.ts index 180f96e427a..a69a18999c5 100644 --- a/src/renderer/src/store/slices/ui-notice-dismissals.test.ts +++ b/src/renderer/src/store/slices/ui-notice-dismissals.test.ts @@ -251,6 +251,19 @@ describe('createUISlice browser import hint dismissal', () => { }) }) +describe('createUISlice Codex shared-settings notice', () => { + it('counts as seen until hydration, then follows the persisted flag', () => { + const store = createUIStore() + expect(store.getState().codexSharedSettingsNoticeSeen).toBe(true) + + store.getState().hydratePersistedUI(makePersistedUI({})) + expect(store.getState().codexSharedSettingsNoticeSeen).toBe(false) + + store.getState().hydratePersistedUI(makePersistedUI({ codexSharedSettingsNoticeSeen: true })) + expect(store.getState().codexSharedSettingsNoticeSeen).toBe(true) + }) +}) + describe('createUISlice clearOsc52ClipboardDefaultOnNotice', () => { it('restores the armed notice from persisted UI', () => { const store = createUIStore() diff --git a/src/renderer/src/store/slices/ui/ui-slice-contract-contextual.ts b/src/renderer/src/store/slices/ui/ui-slice-contract-contextual.ts index f964d6e048a..6ec88e1e4f3 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-contract-contextual.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-contract-contextual.ts @@ -116,4 +116,6 @@ export type UISliceContextual = { dismissUsageEmptyState: () => void codexTerminalServerIsolationNoticeSeen: boolean markCodexTerminalServerIsolationNoticeSeen: () => void + codexSharedSettingsNoticeSeen: boolean + markCodexSharedSettingsNoticeSeen: () => void } diff --git a/src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts index 74f45118b09..dea5b0c2e80 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts @@ -244,6 +244,7 @@ export function createUiHydrationActions(set: UISliceSet, _get: UISliceGet): Par usageEmptyStateDismissed: ui.usageEmptyStateDismissed === true, codexTerminalServerIsolationNoticeSeen: ui.codexTerminalServerIsolationNoticeSeen === true, + codexSharedSettingsNoticeSeen: ui.codexSharedSettingsNoticeSeen === true, ...hydrateAgentReadState(ui), workspaceCleanupDismissals: sanitizeWorkspaceCleanupDismissals( ui.workspaceCleanup?.dismissals diff --git a/src/renderer/src/store/slices/ui/ui-slice-trust-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-trust-actions.ts index dabf69bb29d..1e1c25573a2 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-trust-actions.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-trust-actions.ts @@ -148,6 +148,15 @@ export function createUiTrustActions(set: UISliceSet, _get: UISliceGet): Partial } window.api.ui.set({ codexTerminalServerIsolationNoticeSeen: true }).catch(console.error) return { codexTerminalServerIsolationNoticeSeen: true } + }), + codexSharedSettingsNoticeSeen: true, + markCodexSharedSettingsNoticeSeen: () => + set((s) => { + if (s.codexSharedSettingsNoticeSeen) { + return s + } + window.api.ui.set({ codexSharedSettingsNoticeSeen: true }).catch(console.error) + return { codexSharedSettingsNoticeSeen: true } }) } } diff --git a/src/shared/persisted-ui-state-types.ts b/src/shared/persisted-ui-state-types.ts index feb9a29d213..6eaa295eed4 100644 --- a/src/shared/persisted-ui-state-types.ts +++ b/src/shared/persisted-ui-state-types.ts @@ -172,6 +172,8 @@ export type PersistedUIState = { usageEmptyStateDismissed?: boolean /** One-shot toast announcing per-terminal Codex servers; set when shown, so absent means not yet seen. */ codexTerminalServerIsolationNoticeSeen?: boolean + /** Windows one-shot toast for Codex moving onto ~/.codex; set when shown, so absent means not yet seen. */ + codexSharedSettingsNoticeSeen?: boolean /** URL for new browser tabs; null = blank tab. */ browserDefaultUrl?: string | null browserDefaultSearchEngine?: 'google' | 'duckduckgo' | 'bing' | 'kagi' | null diff --git a/src/shared/rpc-contract/client-ui-params.ts b/src/shared/rpc-contract/client-ui-params.ts index a039594604f..0295247a2e8 100644 --- a/src/shared/rpc-contract/client-ui-params.ts +++ b/src/shared/rpc-contract/client-ui-params.ts @@ -239,6 +239,7 @@ export const UiUpdateFields = z usagePercentageDisplayChangeNoticeDismissed: z.boolean().optional(), usageEmptyStateDismissed: z.boolean().optional(), codexTerminalServerIsolationNoticeSeen: z.boolean().optional(), + codexSharedSettingsNoticeSeen: z.boolean().optional(), petVisible: z.boolean().optional(), petId: z.string().optional(), customPets: UnknownRecordArray.optional(), From 0f9bc5aaadde4c8ee831a5d17446b1b08b5ac750 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sun, 4 Oct 2026 11:47:49 -0700 Subject: [PATCH 16/31] fix(codex): keep Orca-only MCP servers when refreshing the retained shared home (#24983) * fix(codex): keep Orca-only MCP servers when refreshing the retained shared home The refresh for panes that outlive an update treated the old shared home's whole MCP root as owned by ~/.codex, so it deleted servers the user had added from an Orca terminal, which existed only there. Read the home's settings baseline instead, as the normal mirror does: drop only servers the last mirror copied from ~/.codex. No baseline keeps the old behaviour; an unreadable one skips the refresh. The baseline is not advanced, keeping the refresh one-way. STA-9109 * test(codex): type the MCP ownership baseline fixture --------- Co-authored-by: Orca Worker --- ...legacy-shared-config-compatibility.test.ts | 43 ++++++++++++ .../codex-config-mirror-mcp-ownership.test.ts | 68 +++++++++++++++++++ src/main/codex/codex-config-mirror.ts | 24 +++++-- 3 files changed, 131 insertions(+), 4 deletions(-) diff --git a/src/main/codex-accounts/legacy-shared-config-compatibility.test.ts b/src/main/codex-accounts/legacy-shared-config-compatibility.test.ts index 085777fdf8c..fc8f7b815a7 100644 --- a/src/main/codex-accounts/legacy-shared-config-compatibility.test.ts +++ b/src/main/codex-accounts/legacy-shared-config-compatibility.test.ts @@ -88,6 +88,49 @@ describe('legacy shared Codex config compatibility', () => { expect(readFileSync(join(sharedRuntimeHome, 'auth.json'), 'utf-8')).toBe(staleSharedAuth) }) + it('keeps an Orca-added MCP server while settings and trust still refresh', () => { + const baselinePath = join(sharedRuntimeHome, '.orca-config-settings-baseline.json') + const baseline = JSON.stringify({ version: 3, settings: {}, mcpServers: [] }) + writeFileSync(baselinePath, baseline) + writeFileSync( + join(systemCodexHome, 'config.toml'), + ['model = "canonical"', '', '[projects."/revoked"]', 'trust_level = "untrusted"', ''].join( + '\n' + ) + ) + writeFileSync( + join(sharedRuntimeHome, 'config.toml'), + [ + 'model = "stale"', + '', + '[projects."/trusted-in-orca"]', + 'trust_level = "trusted"', + '', + '[projects."/revoked"]', + 'trust_level = "trusted"', + '', + '[hooks.state."orca:stop:0:0"]', + 'enabled = true', + '', + '[mcp_servers.demo-mcp]', + 'command = "demo"', + '' + ].join('\n') + ) + + syncLegacySharedCodexConfigForRetainedPanes({ sharedRuntimeHome, systemCodexHome }) + + const sharedConfig = readFileSync(join(sharedRuntimeHome, 'config.toml'), 'utf-8') + expect(sharedConfig).toContain('model = "canonical"') + expect(sharedConfig).not.toContain('model = "stale"') + expect(sharedConfig).toContain('[projects."/trusted-in-orca"]\ntrust_level = "trusted"') + expect(sharedConfig).toContain('[projects."/revoked"]\ntrust_level = "untrusted"') + expect(sharedConfig).not.toContain('[projects."/revoked"]\ntrust_level = "trusted"') + expect(sharedConfig).toContain('[hooks.state."orca:stop:0:0"]') + expect(sharedConfig).toContain('[mcp_servers.demo-mcp]\ncommand = "demo"') + expect(readFileSync(baselinePath, 'utf-8')).toBe(baseline) + }) + it('does not delete config when the canonical source is transiently missing', () => { const staleConfig = 'model_provider = "stale-provider"\n' writeFileSync(join(sharedRuntimeHome, 'config.toml'), staleConfig, 'utf-8') diff --git a/src/main/codex/codex-config-mirror-mcp-ownership.test.ts b/src/main/codex/codex-config-mirror-mcp-ownership.test.ts index 2c8a86fd28b..44172582e21 100644 --- a/src/main/codex/codex-config-mirror-mcp-ownership.test.ts +++ b/src/main/codex/codex-config-mirror-mcp-ownership.test.ts @@ -21,6 +21,20 @@ beforeEach(() => { afterEach(() => rmSync(root, { recursive: true, force: true })) +const BASELINE_FILE = '.orca-config-settings-baseline.json' + +type StoredBaselineFixture = { + version: 1 | 3 + settings: Record + mcpServers?: string[] +} + +function writeBaseline(baseline: StoredBaselineFixture): string { + const serialized = JSON.stringify(baseline) + writeFileSync(join(runtimeHomePath, BASELINE_FILE), serialized) + return serialized +} + describe('canonical MCP ownership during config mirroring', () => { it('does not duplicate a server defined inline in the canonical MCP table', () => { writeFileSync( @@ -130,6 +144,60 @@ describe('MCP ownership migration', () => { ) }) + it('keeps the retained shared home canonical under a pre-ownership baseline', () => { + writeFileSync(join(runtimeHomePath, 'config.toml'), '[mcp_servers.removed]\ncommand = "old"\n') + writeFileSync(join(systemHomePath, 'config.toml'), 'model = "system"\n') + writeBaseline({ version: 1, settings: {} }) + + syncSystemConfigIntoLegacySharedCodexHome({ runtimeHomePath, systemHomePath }) + + expect(readFileSync(join(runtimeHomePath, 'config.toml'), 'utf-8')).not.toContain( + '[mcp_servers.' + ) + }) + + it('keeps Orca-only servers in the retained shared home and drops ones removed from ~/.codex', () => { + writeFileSync( + join(runtimeHomePath, 'config.toml'), + [ + '[mcp_servers.demo-mcp]', + 'command = "orca-only"', + '[mcp_servers.removed]', + 'command = "mirrored"', + '[mcp_servers.kept]', + 'command = "stale"', + '' + ].join('\n') + ) + writeFileSync( + join(systemHomePath, 'config.toml'), + 'model = "system"\n[mcp_servers.kept]\ncommand = "system"\n' + ) + const baseline = writeBaseline({ version: 3, settings: {}, mcpServers: ['removed', 'kept'] }) + + syncSystemConfigIntoLegacySharedCodexHome({ runtimeHomePath, systemHomePath }) + + const runtimeConfig = readFileSync(join(runtimeHomePath, 'config.toml'), 'utf-8') + expect(runtimeConfig).toContain('[mcp_servers.demo-mcp]\ncommand = "orca-only"') + expect(runtimeConfig).not.toContain('[mcp_servers.removed]') + expect(runtimeConfig).toContain('[mcp_servers.kept]\ncommand = "system"') + expect(runtimeConfig).not.toContain('"stale"') + expect(readFileSync(join(runtimeHomePath, BASELINE_FILE), 'utf-8')).toBe(baseline) + }) + + it('leaves the retained shared home untouched when its baseline cannot be read', () => { + const runtimeConfig = 'model = "retained"\n[mcp_servers.demo-mcp]\ncommand = "orca-only"\n' + writeFileSync(join(runtimeHomePath, 'config.toml'), runtimeConfig) + writeFileSync(join(systemHomePath, 'config.toml'), 'model = "system"\n') + mkdirSync(join(runtimeHomePath, BASELINE_FILE)) + + expect(() => + syncSystemConfigIntoLegacySharedCodexHome({ runtimeHomePath, systemHomePath }) + ).toThrow() + + expect(readFileSync(join(runtimeHomePath, 'config.toml'), 'utf-8')).toBe(runtimeConfig) + }) + it('tracks commented CRLF names so their later removal remains authoritative', () => { writeFileSync( join(runtimeHomePath, 'config.toml'), diff --git a/src/main/codex/codex-config-mirror.ts b/src/main/codex/codex-config-mirror.ts index a97a8b9644c..74f8b2f0b53 100644 --- a/src/main/codex/codex-config-mirror.ts +++ b/src/main/codex/codex-config-mirror.ts @@ -16,7 +16,7 @@ import { type CodexSettingsPromotionHomes, type CodexSettingsPromotionPlan } from './config-settings-promotion' -import { readCodexSettingsBaseline } from './config-settings-baseline' +import { observeCodexSettingsBaseline, readCodexSettingsBaseline } from './config-settings-baseline' import { getCodexConfigSyncStatus, reportCodexConfigSyncOutcome } from './config-sync-stall' import { preserveRuntimeConflictValues } from './codex-config-settings-preservation' import { applyCodexDaemonSocketGuard } from './codex-daemon-socket-path-guard' @@ -161,15 +161,13 @@ export function syncSystemConfigIntoLegacySharedCodexHome( let mirroredRuntimeConfig = runtimeConfigBeforeMirror ?? '' if (rawSystemConfig.trim() !== '') { const sourceConfigDir = resolveCodexConfigMirrorSourceDirectory(homes.systemHomePath) - // The retired home has no ownership baseline; its entire MCP root stays canonical. mirroredRuntimeConfig = runtimeConfigBeforeMirror !== null ? mergeSystemCodexConfigIntoRuntime( runtimeConfigBeforeMirror, prepareSystemConfigForRuntimeMirror(rawSystemConfig, sourceConfigDir), sourceConfigDir, - new Set(), - true + ...readLegacySharedHomeMcpOwnership(homes.runtimeHomePath) ) : prepareSystemConfigForFreshRuntimeMirror(rawSystemConfig, sourceConfigDir) } @@ -186,6 +184,24 @@ export function syncSystemConfigIntoLegacySharedCodexHome( writeFileAtomicallyIfUnchanged(runtimeConfigPath, runtimeConfigBeforeMirror, nextRuntimeConfig) } +/** + * Why: MCP servers added from an Orca terminal exist only in the retired home, + * so its last mirror's baseline decides which ones ~/.codex owns. With no + * baseline the whole root stays canonical, as before; an unreadable one throws + * rather than guess. Read-only: this one-way refresh never advances it. + */ +function readLegacySharedHomeMcpOwnership( + runtimeHomePath: string +): [mirroredMcpServerNames: ReadonlySet, mirroredMcpServerRoot: boolean] { + const observation = observeCodexSettingsBaseline(runtimeHomePath) + if (observation.kind === 'indeterminate') { + throw new Error('Codex settings baseline could not be read') + } + return observation.kind === 'present' + ? [observation.baseline.mcpServers, observation.baseline.mcpServerRoot] + : [new Set(), true] +} + type CodexConfigMirrorResult = | { status: 'skipped-missing-source' } | { status: 'refused-indeterminate'; error: unknown } From 95753a10c61642c0eb3911fa1531036c608b61a3 Mon Sep 17 00:00:00 2001 From: Nicholas Ting Date: Mon, 5 Oct 2026 06:18:43 +1100 Subject: [PATCH 17/31] fix(jcode): report missing and outdated managed hooks (#25135) --- docs/reference/jcode-hook-events.md | 36 +++++--- src/main/jcode/hook-config.test.ts | 3 - src/main/jcode/hook-service.test.ts | 122 +++++++++++++++++++++++++++- src/main/jcode/hook-service.ts | 22 +++-- 4 files changed, 160 insertions(+), 23 deletions(-) diff --git a/docs/reference/jcode-hook-events.md b/docs/reference/jcode-hook-events.md index 5eaf0d52463..6e5f81be35e 100644 --- a/docs/reference/jcode-hook-events.md +++ b/docs/reference/jcode-hook-events.md @@ -13,14 +13,14 @@ points. Five are **observers** — detached, fire-and-forget, they can never slo the agent. One, `pre_tool`, is a **gate**: jcode spawns it, writes the tool input to its stdin, and waits for it to exit before the tool runs. -| Event | When | Orca state | Notable payload fields | -| --------------- | -------------------------------------------- | ---------- | --------------------------------------------------------- | -| `session_start` | TUI open, attach, or `--resume` | none | `source` = `create`/`attach`/`resume`, `model` | -| `turn_start` | prompt submitted, before the model generates | `working` | `source`, `model` | -| `pre_tool` | before each tool call (gate) | `working` | `tool_name`, `tool_input` (argument JSON as a string) | -| `post_tool` | after each tool call | `working` | `tool_name`, `status`, `duration_ms`, `output_bytes`/`error` | -| `turn_end` | turn finished | `done` | `status`, `duration_ms`, `model`, `last_assistant_text`, `error` | -| `session_end` | session closed | `done` | `source` = `close` | +| Event | When | Orca state | Notable payload fields | +| --------------- | -------------------------------------------- | ---------- | ---------------------------------------------------------------- | +| `session_start` | TUI open, attach, or `--resume` | none | `source` = `create`/`attach`/`resume`, `model` | +| `turn_start` | prompt submitted, before the model generates | `working` | `source`, `model` | +| `pre_tool` | before each tool call (gate) | `working` | `tool_name`, `tool_input` (argument JSON as a string) | +| `post_tool` | after each tool call | `working` | `tool_name`, `status`, `duration_ms`, `output_bytes`/`error` | +| `turn_end` | turn finished | `done` | `status`, `duration_ms`, `model`, `last_assistant_text`, `error` | +| `session_end` | session closed | `done` | `source` = `close` | `session_start` is identity-only. jcode fires it on an idle TUI open, so mapping it to `working` would spin before the user has typed anything (same reason Devin @@ -59,12 +59,12 @@ Three consequences the mapping depends on: ## Why Orca subscribes to the gate -`pre_tool` is the only event that can report a tool *while it runs*. Without it a +`pre_tool` is the only event that can report a tool _while it runs_. Without it a three-minute `bash` shows no tool at all until it finishes. Two rules keep the gate from ever costing the agent anything: 1. **The POST is detached.** jcode calls `child.wait_with_output()`, which waits - for the process *and* reads its stderr to EOF — a backgrounded child that + for the process _and_ reads its stderr to EOF — a backgrounded child that inherited stderr would hold the gate open for as long as it ran. The managed script runs the POST as `orca_post_jcode_event >/dev/null 2>&1 &`, so the inherited pipes are closed and the script exits immediately. @@ -83,10 +83,10 @@ command outright or asks the model to justify it — both inside the tool, with human in the loop. There is therefore no hook, and no terminal-title state, for "jcode is waiting on you" during ordinary tool use. -The one tool a *human* answers is ambient mode's `request_permission` +The one tool a _human_ answers is ambient mode's `request_permission` (`crates/jcode-app-core/src/tool/ambient.rs`), resolved out of band with `jcode permissions`. Orca maps a `pre_tool` for it to `waiting` and publishes the -tool input as the question card. `post_tool` for the same tool is *not* mapped — +tool input as the question card. `post_tool` for the same tool is _not_ mapped — by then the human has already answered. Matching is by exact tool name. jcode's live tool set is `agentgrep, apply_patch, @@ -114,7 +114,7 @@ the parser. ## Per-pane daemons jcode runs one server/client daemon per runtime dir, and lifecycle hooks fire -*inside the daemon*. Every TUI client connects the daemon the first pane started, +_inside the daemon_. Every TUI client connects the daemon the first pane started, so without isolation a second jcode pane's events carry the first pane's `ORCA_PANE_KEY` and its status lands on the wrong tab. @@ -142,6 +142,16 @@ are refreshed on Orca startup without changing the user's hook configuration. Report: https://github.com/stablyai/orca/pull/22539#issuecomment-5809618574 Launcher fix: https://github.com/1jehuang/jcode/pull/1490 +## Hook installation health + +Orca's local hook status reports `installed` only when all six events use the +current shell-quoted managed command and the managed script exists. Missing scripts, +old home paths, platform-switched commands, and legacy unquoted commands report +`partial` with a repair reason, not a user-owned hook warning. Reinstalling hooks +recreates the script and updates only Orca-owned commands. User-owned commands +remain untouched. Removing the managed entries still reports `not_installed`, +even if the shared script remains on disk. + ## Config shape `[hooks]` values accept a string or an array of strings (`HookCommands` in diff --git a/src/main/jcode/hook-config.test.ts b/src/main/jcode/hook-config.test.ts index b82e6a417b9..17dbcb379c2 100644 --- a/src/main/jcode/hook-config.test.ts +++ b/src/main/jcode/hook-config.test.ts @@ -132,9 +132,6 @@ turn_end = "~/bin/mine" # replaces agent-hooks/jcode-hook.sh }) it('repoints a managed entry left behind by a copied home or a platform switch', () => { - // Why: isManaged matches any agent-hooks/jcode-hook path, but getStatus demands - // the exact script path — a stale entry stuck the install on `partial` forever - // with no Orca action able to repair it. const stale = '/Users/old/.orca/agent-hooks/jcode-hook.sh' const source = `[hooks]\nturn_end = ${tomlQuoteString(stale)}\n` const result = applyJcodeManagedHooks(source, EVENTS, MANAGED_COMMAND, 'jcode-hook.sh') diff --git a/src/main/jcode/hook-service.test.ts b/src/main/jcode/hook-service.test.ts index 3a87aa4ef6a..f86fe52444c 100644 --- a/src/main/jcode/hook-service.test.ts +++ b/src/main/jcode/hook-service.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' @@ -17,6 +17,7 @@ import { JcodeHookService } from './hook-service' import { getJcodeConfigPath, getJcodeManagedCommand, + getJcodeManagedScriptFileName, getJcodeManagedScriptPath, JCODE_HOOK_EVENTS } from './hook-settings' @@ -72,6 +73,123 @@ describe('JcodeHookService', () => { expect(script).toContain('payload="$JCODE_HOOK_PAYLOAD"') }) + it('reports a missing managed script and repairs it without changing config', () => { + const service = new JcodeHookService() + service.install() + const config = readFileSync(getJcodeConfigPath(), 'utf8') + rmSync(getJcodeManagedScriptPath()) + + expect(service.getStatus()).toMatchObject({ + state: 'partial', + managedHooksPresent: true, + detail: 'Managed hook script missing' + }) + expect(readFileSync(getJcodeConfigPath(), 'utf8')).toBe(config) + expect(service.install().state).toBe('installed') + expect(readFileSync(getJcodeManagedScriptPath(), 'utf8')).toContain('/hook/jcode') + expect(readFileSync(getJcodeConfigPath(), 'utf8')).toBe(config) + }) + + it('reports missing scripts alongside incomplete event coverage and user hooks', () => { + const service = new JcodeHookService() + service.install() + writeFileSync( + getJcodeConfigPath(), + `[hooks]\nsession_start = ${tomlQuoteString(getJcodeManagedCommand(getJcodeManagedScriptPath()))}\nturn_end = "~/bin/my-turn-notify"\n`, + 'utf8' + ) + rmSync(getJcodeManagedScriptPath()) + + const status = service.getStatus() + expect(status.state).toBe('partial') + expect(status.detail).toContain('Managed hook script missing') + expect(status.detail).toContain( + 'Managed hook missing for events: turn_start, pre_tool, post_tool, session_end' + ) + expect(status.detail).toContain('User-owned hooks kept for events: turn_end') + expect(service.install().state).toBe('partial') + expect(service.getStatus().detail).not.toContain('script missing') + expect(readFileSync(getJcodeConfigPath(), 'utf8')).toContain( + 'turn_end = "~/bin/my-turn-notify"' + ) + }) + + it.each([ + '/Users/previous/.orca/agent-hooks/jcode-hook.sh', + 'C:\\Users\\previous\\.orca\\agent-hooks\\jcode-hook.cmd' + ])('recognizes a stale managed command %s without a local script', (stalePath) => { + const service = new JcodeHookService() + const configPath = getJcodeConfigPath() + mkdirSync(dirname(configPath), { recursive: true }) + writeFileSync( + configPath, + `[hooks]\nturn_end = ${tomlQuoteString(getJcodeManagedCommand(stalePath))}\n`, + 'utf8' + ) + + const status = service.getStatus() + expect(status.state).toBe('partial') + expect(status.managedHooksPresent).toBe(true) + expect(status.detail).toContain('Managed hook command outdated for events: turn_end') + expect(status.detail).toContain('Managed hook script missing') + expect(status.detail).not.toContain('User-owned') + expect(service.install().state).toBe('installed') + expect(readFileSync(configPath, 'utf8')).not.toContain('previous') + }) + + it('does not report complete stale event coverage as installed when the current script exists', () => { + const service = new JcodeHookService() + service.install() + const staleCommand = getJcodeManagedCommand('/Users/previous/.orca/agent-hooks/jcode-hook.sh') + writeFileSync( + getJcodeConfigPath(), + `[hooks]\n${JCODE_HOOK_EVENTS.map((event) => `${event} = ${tomlQuoteString(staleCommand)}`).join('\n')}\n`, + 'utf8' + ) + + const status = service.getStatus() + expect(status.state).toBe('partial') + expect(status.managedHooksPresent).toBe(true) + expect(status.detail).toBe( + `Managed hook command outdated for events: ${JCODE_HOOK_EVENTS.join(', ')}` + ) + expect(service.install().state).toBe('installed') + }) + + it('reports legacy unquoted commands as outdated even when the script exists', () => { + const service = new JcodeHookService() + service.install() + writeFileSync( + getJcodeConfigPath(), + `[hooks]\n${JCODE_HOOK_EVENTS.map((event) => `${event} = ${tomlQuoteString(getJcodeManagedScriptPath())}`).join('\n')}\n`, + 'utf8' + ) + + const status = service.getStatus() + expect(status.state).toBe('partial') + expect(status.detail).toContain('Managed hook command outdated for events:') + expect(status.detail).not.toContain('script missing') + expect(status.detail).not.toContain('User-owned') + expect(service.install().state).toBe('installed') + }) + + it('does not mistake a user command mentioning the managed script in a comment for Orca ownership', () => { + const configPath = getJcodeConfigPath() + mkdirSync(dirname(configPath), { recursive: true }) + writeFileSync( + configPath, + `[hooks]\nturn_end = "~/bin/my-turn-notify" # replaces agent-hooks/${getJcodeManagedScriptFileName()}\n`, + 'utf8' + ) + + const status = new JcodeHookService().getStatus() + expect(status.state).toBe('partial') + expect(status.managedHooksPresent).toBe(false) + expect(status.detail).toContain('User-owned hooks kept for events: turn_end') + expect(status.detail).not.toContain('command outdated') + expect(status.detail).not.toContain('script missing') + }) + it('preserves unrelated config tables when installing hooks', () => { const configPath = getJcodeConfigPath() mkdirSync(dirname(configPath), { recursive: true }) @@ -107,6 +225,8 @@ describe('JcodeHookService', () => { expect(before).toContain('turn_end') const status = new JcodeHookService().remove() expect(status.state).toBe('not_installed') + expect(status.detail).toBeNull() + expect(existsSync(getJcodeManagedScriptPath())).toBe(true) const after = readFileSync(getJcodeConfigPath(), 'utf8') expect(after).not.toContain(getJcodeManagedScriptPath()) }) diff --git a/src/main/jcode/hook-service.ts b/src/main/jcode/hook-service.ts index ebe41a2dc36..eff119f91d5 100644 --- a/src/main/jcode/hook-service.ts +++ b/src/main/jcode/hook-service.ts @@ -4,6 +4,7 @@ import type { SFTPWrapper } from 'ssh2' import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types' import { buildWindowsAgentHookPostCommand, + createManagedCommandMatcher, writeManagedScript } from '../agent-hooks/installer-utils' import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh' @@ -128,33 +129,42 @@ export class JcodeHookService { } const scriptPresent = existsSync(scriptPath) const managedCommand = getJcodeManagedCommand(scriptPath) + const isManaged = createManagedCommandMatcher(getJcodeManagedScriptFileName()) const missing: string[] = [] + const outdated: string[] = [] const userOwned: string[] = [] let managedCount = 0 for (const event of JCODE_HOOK_EVENTS) { const value = table[event] - // Why both forms: installs before the quoting fix stored the bare path, and - // install() repoints those — reporting them user-owned would hide the repair. - if (value === managedCommand || value === scriptPath) { + if (value === managedCommand) { managedCount += 1 + } else if (isManaged(value)) { + outdated.push(event) } else if (value === undefined) { missing.push(event) } else { userOwned.push(event) } } - const managedHooksPresent = managedCount > 0 || scriptPresent + const hasManagedEntries = managedCount > 0 || outdated.length > 0 + const managedHooksPresent = hasManagedEntries || scriptPresent let state: AgentHookInstallState let detail: string | null - if (missing.length === 0 && userOwned.length === 0) { + if (managedCount === JCODE_HOOK_EVENTS.length && scriptPresent) { state = 'installed' detail = null - } else if (managedCount === 0 && missing.length === JCODE_HOOK_EVENTS.length) { + } else if (!hasManagedEntries && missing.length === JCODE_HOOK_EVENTS.length) { state = 'not_installed' detail = null } else { state = 'partial' const parts: string[] = [] + if (hasManagedEntries && !scriptPresent) { + parts.push('Managed hook script missing') + } + if (outdated.length > 0) { + parts.push(`Managed hook command outdated for events: ${outdated.join(', ')}`) + } if (missing.length > 0) { parts.push(`Managed hook missing for events: ${missing.join(', ')}`) } From a5b8b7e2bb77ee96c9039769920e9202ea146881 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 4 Oct 2026 12:19:32 -0700 Subject: [PATCH 18/31] Delete docs/reference/jcode-hook-events.md (#25288) --- docs/reference/jcode-hook-events.md | 173 ---------------------------- 1 file changed, 173 deletions(-) delete mode 100644 docs/reference/jcode-hook-events.md diff --git a/docs/reference/jcode-hook-events.md b/docs/reference/jcode-hook-events.md deleted file mode 100644 index 6e5f81be35e..00000000000 --- a/docs/reference/jcode-hook-events.md +++ /dev/null @@ -1,173 +0,0 @@ -# jcode hook events - -What jcode actually emits, and why Orca's status mapping is shaped the way it is. -Everything below was captured from jcode **v0.87.1 (944f747e9)** by pointing every -`[hooks]` entry in `config.toml` at a script that appends `$JCODE_HOOK_PAYLOAD` to -a log, then running real turns. Re-capture before changing the mapping; do not -edit it from memory. - -## The six events - -jcode's `[hooks]` table (`crates/jcode-base/src/hooks.rs`) has six lifecycle -points. Five are **observers** — detached, fire-and-forget, they can never slow -the agent. One, `pre_tool`, is a **gate**: jcode spawns it, writes the tool input -to its stdin, and waits for it to exit before the tool runs. - -| Event | When | Orca state | Notable payload fields | -| --------------- | -------------------------------------------- | ---------- | ---------------------------------------------------------------- | -| `session_start` | TUI open, attach, or `--resume` | none | `source` = `create`/`attach`/`resume`, `model` | -| `turn_start` | prompt submitted, before the model generates | `working` | `source`, `model` | -| `pre_tool` | before each tool call (gate) | `working` | `tool_name`, `tool_input` (argument JSON as a string) | -| `post_tool` | after each tool call | `working` | `tool_name`, `status`, `duration_ms`, `output_bytes`/`error` | -| `turn_end` | turn finished | `done` | `status`, `duration_ms`, `model`, `last_assistant_text`, `error` | -| `session_end` | session closed | `done` | `source` = `close` | - -`session_start` is identity-only. jcode fires it on an idle TUI open, so mapping -it to `working` would spin before the user has typed anything (same reason Devin -does not map its `SessionStart`). - -## Captured payloads - -A `jcode run` turn that read one file and wrote another: - -```json -{"cwd":"/private/tmp/jcode-work","event":"session_start","model":"claude-haiku-4-5","session_id":"session_pawprint_1790149117838_071c2a106812396c","source":"create"} -{"cwd":"/private/tmp/jcode-work","event":"pre_tool","session_id":"session_pawprint_…","tool_input":"{\"file_path\":\"sample.txt\",\"intent\":\"Read sample.txt to get its contents\"}","tool_name":"read"} -{"cwd":"/private/tmp/jcode-work","duration_ms":"0","event":"post_tool","output_bytes":"12","session_id":"session_pawprint_…","status":"ok","tool_name":"read"} -{"cwd":"/private/tmp/jcode-work","event":"pre_tool","session_id":"session_pawprint_…","tool_input":"{\"content\":\"HELLO\",\"file_path\":\"out.txt\",\"intent\":\"Write uppercased contents of sample.txt to out.txt\"}","tool_name":"write"} -{"cwd":"/private/tmp/jcode-work","duration_ms":"9","event":"post_tool","output_bytes":"137","session_id":"session_pawprint_…","status":"ok","tool_name":"write"} -``` - -A TUI turn that failed upstream (note `turn_start`, which the `run` path does not emit): - -```json -{"cwd":"/private/tmp/jcode-work","event":"session_start","model":"claude-opus-5","session_id":"session_snail_…","source":"create"} -{"cwd":"/private/tmp/jcode-work","event":"turn_start","model":"claude-opus-5","session_id":"session_snail_…","source":"chat"} -{"cwd":"/private/tmp/jcode-work","duration_ms":"6868","error":"Anthropic API error (503 Service Unavailable): …","event":"turn_end","model":"claude-opus-5","session_id":"session_snail_…","status":"error"} -``` - -Three consequences the mapping depends on: - -- **`turn_start` only fires on the streaming turn path** (TUI, desktop, swarm - workers, headless sessions), not `jcode run`. It is what fills the otherwise - blank window between a submitted prompt and the first tool call. -- **Only `pre_tool` carries `tool_input`.** `post_tool` reports the name and the - outcome, so the tool preview has to be held from the matching `pre_tool`. -- **Every jcode tool schema has an `intent` string** the model fills in. It is - the preview fallback when no tool-specific key (`file_path`, `command`, …) - matches. - -## Why Orca subscribes to the gate - -`pre_tool` is the only event that can report a tool _while it runs_. Without it a -three-minute `bash` shows no tool at all until it finishes. Two rules keep the -gate from ever costing the agent anything: - -1. **The POST is detached.** jcode calls `child.wait_with_output()`, which waits - for the process _and_ reads its stderr to EOF — a backgrounded child that - inherited stderr would hold the gate open for as long as it ran. The managed - script runs the POST as `orca_post_jcode_event >/dev/null 2>&1 &`, so the - inherited pipes are closed and the script exits immediately. -2. **stdin is drained first.** jcode `write_all`s the full tool input to the - hook's stdin. A tool input larger than the pipe buffer (a big `write`) would - block that write until the gate timed out if nobody read it, so the script - drains stdin before any exit path. - -Orca never blocks a jcode tool call: the script always exits 0. - -## Questions and permissions - -jcode has **no interactive per-tool approval prompt**. Its safety model -(`crates/jcode-app-core/src/tool/bash_destructive_gate.rs`) either denies a -command outright or asks the model to justify it — both inside the tool, with no -human in the loop. There is therefore no hook, and no terminal-title state, for -"jcode is waiting on you" during ordinary tool use. - -The one tool a _human_ answers is ambient mode's `request_permission` -(`crates/jcode-app-core/src/tool/ambient.rs`), resolved out of band with -`jcode permissions`. Orca maps a `pre_tool` for it to `waiting` and publishes the -tool input as the question card. `post_tool` for the same tool is _not_ mapped — -by then the human has already answered. - -Matching is by exact tool name. jcode's live tool set is `agentgrep, apply_patch, -bash, batch, bg, browser, compile_remote, conversation_search, edit, gmail, -integration_tools, ls, macos_computer_use, maintainer_feedback, mcp, memory, -multiedit, open, panel, patch, read, schedule, session_search, side_panel, -skill_manage, swarm, todo, webfetch, websearch, write` plus the ambient tools; -a substring rule over that set would be matching on coincidence. - -## Terminal titles - -jcode paints OSC 0 titles roughly once a second. Captured sequence from one TUI -session: - -``` -jcode → 🐍 jcode Snake → 🐍 jcode/creek Snake → 🌐 jcode Snake · work ~0s → … → 🌐 jcode Snake · last ~6s -``` - -The format is ` jcode [ · +N -M][ · work|last ~]` -(`crates/jcode-tui/src/tui/app/terminal_title.rs`). Orca uses it for tab-bar -identity only — status comes from hooks, never from a parsed title. Note there is -no "needs input" title state; that is the same gap as above, not an omission in -the parser. - -## Per-pane daemons - -jcode runs one server/client daemon per runtime dir, and lifecycle hooks fire -_inside the daemon_. Every TUI client connects the daemon the first pane started, -so without isolation a second jcode pane's events carry the first pane's -`ORCA_PANE_KEY` and its status lands on the wrong tab. - -jcode does forward a client's terminal identity to hooks -(`CLIENT_TERMINAL_ENV_VARS` in `crates/jcode-terminal-launch/src/lib.rs`), but -that allowlist covers tmux/zellij/herdr and the terminal emulators — not -`ORCA_PANE_KEY`. Until it does, Orca stamps a per-pane `JCODE_RUNTIME_DIR` so -each pane gets its own daemon, socket, and lock. The value is a 16-hex hash of -the pane key because the socket path is capped at `SUN_LEN` (104 bytes) and a -full pane key never fits. - -## Windows hook launcher - -Use Jcode **v0.89.0 or newer** on Windows. Earlier observer hooks launch with -`DETACHED_PROCESS`, leaving their children without a console to inherit. A -console program such as the managed hook's `curl.exe` can then open a Windows -Terminal tab on every event. Jcode's launcher fix uses `CREATE_NO_WINDOW` for -observer hooks and the `pre_tool` gate, keeping their descendants invisible. -Changing the managed script alone cannot repair an older Jcode launcher. - -The managed Windows hook redirects its payload file into curl directly, avoiding -the extra shells that a `type ... | curl` pipeline starts. Existing managed scripts -are refreshed on Orca startup without changing the user's hook configuration. - -Report: https://github.com/stablyai/orca/pull/22539#issuecomment-5809618574 -Launcher fix: https://github.com/1jehuang/jcode/pull/1490 - -## Hook installation health - -Orca's local hook status reports `installed` only when all six events use the -current shell-quoted managed command and the managed script exists. Missing scripts, -old home paths, platform-switched commands, and legacy unquoted commands report -`partial` with a repair reason, not a user-owned hook warning. Reinstalling hooks -recreates the script and updates only Orca-owned commands. User-owned commands -remain untouched. Removing the managed entries still reports `not_installed`, -even if the shared script remains on disk. - -## Config shape - -`[hooks]` values accept a string or an array of strings (`HookCommands` in -`crates/jcode-config-types/src/lib.rs`), and jcode re-reads the config on reload, -so hooks can be added without restarting. jcode parses a hook command line -shell-style but **executes it directly, not through a shell** — the managed value -must be the script path, never an `if [ -f … ]` wrapper. - -That shell-style parse is `parse_hook_command` -(`crates/jcode-terminal-launch/src/lib.rs`), and it is why Orca stores the path -**shell-quoted**. The tokenizer splits on unquoted whitespace and consumes every -unquoted backslash as an escape, so a bare Windows path reaches `exec` as -`C:Usersme.orcaagent-hooksjcode-hook.cmd` and no hook fires at all; a POSIX home -with a space splits into two arguments. Single quotes pass a path through -verbatim — backslashes are literal inside them — so Orca single-quotes by -default and falls back to double quotes (escaping `\` and `"`) only for a path -that itself contains a single quote. The value is then TOML-quoted on the way -into the file, so neither the raw path nor the shell-quoted string appears -alone. From 41cc77509f483230dd1243d5357886e43d20cf94 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 4 Oct 2026 12:44:26 -0700 Subject: [PATCH 19/31] Keep active notebook cells current after external reloads (#25172) --- .../IpynbCellEditor.external-sync.test.tsx | 308 ++++++++++++++++++ .../src/components/editor/IpynbCellEditor.tsx | 49 ++- tests/e2e/notebook-cell-external-sync.spec.ts | 106 ++++++ 3 files changed, 455 insertions(+), 8 deletions(-) create mode 100644 src/renderer/src/components/editor/IpynbCellEditor.external-sync.test.tsx create mode 100644 tests/e2e/notebook-cell-external-sync.spec.ts diff --git a/src/renderer/src/components/editor/IpynbCellEditor.external-sync.test.tsx b/src/renderer/src/components/editor/IpynbCellEditor.external-sync.test.tsx new file mode 100644 index 00000000000..d53b24baf9b --- /dev/null +++ b/src/renderer/src/components/editor/IpynbCellEditor.external-sync.test.tsx @@ -0,0 +1,308 @@ +// @vitest-environment happy-dom +import { useMemo, useState } from 'react' +import type { editor } from 'monaco-editor' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { IpynbCellSource } from './IpynbCellEditor' +import { parseIpynb } from './ipynb-parse' +import { + getIpynbCellKey, + hasIpynbSourceDraft, + useIpynbDocumentEditing +} from './useIpynbDocumentEditing' + +const liveModels = vi.hoisted(() => { + const models: editor.ITextModel[] = [] + return models +}) +const widgetCalls = vi.hoisted(() => ({ + focus: vi.fn(), + layout: vi.fn(), + updateOptions: vi.fn<(options: editor.IEditorOptions) => void>() +})) + +vi.mock('@/i18n/i18n', () => ({ + i18n: { language: 'en' }, + translate: (_key: string, fallback: string) => fallback +})) +vi.mock('@/store', () => ({ + useAppStore: ( + selector: (state: { settings: undefined; editorFontZoomLevel: number }) => unknown + ) => selector({ settings: undefined, editorFontZoomLevel: 0 }) +})) +vi.mock('@/hooks/use-document-dark-theme', () => ({ useDocumentDarkTheme: () => true })) +vi.mock('./MonacoCodeExcerpt', () => ({ useMonacoColorizedLines: () => [] })) +vi.mock('./editor-shortcuts', () => ({ installMonacoEditorFindShortcut: () => () => {} })) +vi.mock('@/lib/monaco-setup', async () => { + const actual = await import('monaco-editor/esm/vs/editor/editor.api.js') + for (const id of ['python', 'javascript', 'markdown']) { + actual.languages.register({ id }) + } + return { + monaco: { + ...actual, + editor: { + ...actual.editor, + setTheme: vi.fn(), + createModel: (...args: Parameters) => { + const model = actual.editor.createModel(...args) + liveModels.push(model) + return model + }, + create: (_container: HTMLElement, { model }: { model: editor.ITextModel }) => ({ + getModel: () => model, + getContentHeight: () => 28, + layout: widgetCalls.layout, + onDidContentSizeChange: () => ({ dispose: vi.fn() }), + restoreViewState: vi.fn(), + saveViewState: () => null, + getTargetAtClientPoint: () => null, + setPosition: vi.fn(), + focus: widgetCalls.focus, + onDidBlurEditorWidget: () => ({ dispose: vi.fn() }), + addCommand: vi.fn(), + updateOptions: widgetCalls.updateOptions, + pushUndoStop: () => { + model.pushStackElement() + return true + }, + dispose: vi.fn() + }) + } + } + } +}) + +function notebookContent( + cells: { id: string; source: string; kind?: 'code' | 'markdown' }[], + language = 'python' +): string { + return JSON.stringify({ + nbformat: 4, + nbformat_minor: 5, + metadata: { language_info: { name: language } }, + cells: cells.map(({ id, source, kind }) => ({ + id, + cell_type: kind ?? 'code', + metadata: {}, + execution_count: null, + outputs: [], + source: [source] + })) + }) +} + +function NotebookCells({ + content, + onContentChange, + onDirtyStateHint +}: { + content: string + onContentChange: (content: string) => void + onDirtyStateHint: (dirty: boolean) => void +}): React.JSX.Element { + const notebook = useMemo(() => parseIpynb(content), [content]) + const [editingCellKey, setEditingCellKey] = useState(null) + const editing = useIpynbDocumentEditing({ + content, + fileId: 'external-notebook', + notebook, + onContentChange, + onDirtyStateHint, + onDeactivateEditor: () => setEditingCellKey(null) + }) + return ( +
+ {notebook.cells.map((cell, index) => { + const key = getIpynbCellKey(cell, index) + return ( + setEditingCellKey(key)} + onDeactivate={() => setEditingCellKey(null)} + onChange={(source) => editing.updateCellSource(index, source)} + /> + ) + })} +
+ ) +} + +function activeModel(): editor.ITextModel { + const model = liveModels.at(-1) + if (!model) { + throw new Error('No notebook cell editor was created') + } + return model +} + +function appendText(model: editor.ITextModel, text: string): void { + const end = model.getFullModelRange() + act(() => { + model.pushEditOperations( + [], + [{ range: { ...end, startLineNumber: end.endLineNumber, startColumn: end.endColumn }, text }], + () => null + ) + vi.advanceTimersByTime(400) + }) +} + +beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() +}) +afterEach(() => { + cleanup() + for (const model of liveModels.splice(0)) { + if (!model.isDisposed()) { + model.dispose() + } + } + vi.useRealTimers() +}) + +describe('active notebook cell external reload', () => { + it('shows the new source without dirtying the clean notebook or recreating the model', () => { + const onContentChange = vi.fn<(content: string) => void>() + const onDirtyStateHint = vi.fn<(dirty: boolean) => void>() + const initialContent = notebookContent([{ id: 'a', source: 'original source' }]) + const { rerender } = render( + + ) + fireEvent.mouseDown(screen.getByRole('button', { name: 'original source' }), { button: 0 }) + const model = activeModel() + expect(model.getValue()).toBe('original source') + expect(onDirtyStateHint).not.toHaveBeenCalled() + + rerender( + + ) + + expect(liveModels).toEqual([model]) + expect(model.getValue()).toBe('updated from disk') + expect(onContentChange).not.toHaveBeenCalled() + expect(onDirtyStateHint).not.toHaveBeenCalled() + expect(widgetCalls.focus).toHaveBeenCalledOnce() + appendText(model, '!') + const committedContent = onContentChange.mock.calls.at(-1)?.[0] + expect(committedContent).toBeDefined() + expect(parseIpynb(committedContent ?? '').cells[0]?.source).toBe('updated from disk!') + }) + + it('writes edits to the same cell after a clean external reload reorders it', () => { + const onContentChange = vi.fn<(content: string) => void>() + const onDirtyStateHint = vi.fn<(dirty: boolean) => void>() + const first = { id: 'a', source: 'active source' } + const second = { id: 'b', source: 'other source' } + const { rerender } = render( + + ) + fireEvent.mouseDown(screen.getByRole('button', { name: first.source }), { button: 0 }) + const model = activeModel() + expect(onDirtyStateHint).not.toHaveBeenCalled() + + rerender( + + ) + expect(liveModels).toEqual([model]) + expect(onDirtyStateHint).not.toHaveBeenCalled() + appendText(model, '!') + + const committedContent = onContentChange.mock.calls.at(-1)?.[0] + expect(committedContent).toBeDefined() + const cells = parseIpynb(committedContent ?? '').cells + expect(cells.map(({ id, source }) => ({ id, source }))).toEqual([ + second, + { ...first, source: 'active source!' } + ]) + }) + + it('retains undo for external source changes and treats undo as a user edit', async () => { + const onContentChange = vi.fn<(content: string) => void>() + const onDirtyStateHint = vi.fn<(dirty: boolean) => void>() + const { rerender } = render( + + ) + fireEvent.mouseDown(screen.getByRole('button', { name: 'original source' }), { button: 0 }) + const model = activeModel() + rerender( + + ) + + expect(model.canUndo()).toBe(true) + expect(onDirtyStateHint).not.toHaveBeenCalled() + await act(async () => { + await model.undo() + vi.advanceTimersByTime(400) + }) + expect(model.getValue()).toBe('original source') + expect(onDirtyStateHint).toHaveBeenCalledWith(true) + const committedContent = onContentChange.mock.calls.at(-1)?.[0] + expect(parseIpynb(committedContent ?? '').cells[0]?.source).toBe('original source') + }) + + it('updates language, wrapping and height when a clean reload changes the active cell', () => { + const onContentChange = vi.fn<(content: string) => void>() + const onDirtyStateHint = vi.fn<(dirty: boolean) => void>() + const { rerender } = render( + + ) + fireEvent.mouseDown(screen.getByRole('button', { name: 'original source' }), { button: 0 }) + const model = activeModel() + rerender( + + ) + expect(model.getLanguageId()).toBe('javascript') + expect(widgetCalls.updateOptions).toHaveBeenLastCalledWith( + expect.objectContaining({ wordWrap: 'off' }) + ) + widgetCalls.layout.mockClear() + + rerender( + + ) + expect(liveModels).toEqual([model]) + expect(model.getLanguageId()).toBe('markdown') + expect(widgetCalls.updateOptions).toHaveBeenLastCalledWith( + expect.objectContaining({ wordWrap: 'on' }) + ) + expect(widgetCalls.layout).toHaveBeenCalled() + expect(model.getValue()).toBe('**Updated**\n\nParagraph') + expect(onDirtyStateHint).not.toHaveBeenCalled() + expect(onContentChange).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/editor/IpynbCellEditor.tsx b/src/renderer/src/components/editor/IpynbCellEditor.tsx index 228bf57f6e3..e9b29768e44 100644 --- a/src/renderer/src/components/editor/IpynbCellEditor.tsx +++ b/src/renderer/src/components/editor/IpynbCellEditor.tsx @@ -5,6 +5,7 @@ import { monaco } from '@/lib/monaco-setup' import { computeEditorFontSize, resolveEditorFontStack } from '@/lib/editor-font-zoom' import { useAppStore } from '@/store' import { installMonacoEditorFindShortcut } from './editor-shortcuts' +import { syncContentUpdate } from './monaco-content-sync' import { IPYNB_CODE_CELL_PREVIEW_MAX_LINES, getIpynbCodeCellPreviewLines @@ -155,18 +156,21 @@ function IpynbSourceEditor({ const fontSize = computeEditorFontSize(settings?.terminalFontSize ?? 13, editorFontZoomLevel) const containerRef = useRef(null) const editorRef = useRef(null) + const callbacksRef = useRef({ onChange, onDeactivate }) + const syncingSourceRef = useRef(false) + const fitHeightRef = useRef<(() => void) | null>(null) + + useLayoutEffect(() => { + callbacksRef.current = { onChange, onDeactivate } + }, [onChange, onDeactivate]) useLayoutEffect(() => { monaco.editor.setTheme(isDark ? 'vs-dark' : 'vs') }, [isDark]) - useLayoutEffect(() => { - editorRef.current?.updateOptions({ fontFamily, fontSize }) - }, [fontFamily, fontSize]) - // Why: created synchronously before paint (not via @monaco-editor/react's async loader), so the // swap from the preview never shows a placeholder, an unlaid-out editor or a guessed caret. - // Mount-once: the props it reads cannot change while the cell is being edited. + // Keep the model and undo history while external reloads update the cell below. useLayoutEffect(() => { const container = containerRef.current if (!container) { @@ -206,6 +210,7 @@ function IpynbSourceEditor({ container.style.height = `${Math.min(editorInstance.getContentHeight(), maxHeight)}px` editorInstance.layout() } + fitHeightRef.current = fitHeight fitHeight() editorInstance.onDidContentSizeChange(fitHeight) // Why: restoring a view state marks the visible lines stable, so Monaco tokenizes them now @@ -217,12 +222,16 @@ function IpynbSourceEditor({ editorInstance.setPosition(target.position) } editorInstance.focus() - model.onDidChangeContent(() => onChange(model.getValue())) - editorInstance.onDidBlurEditorWidget(onDeactivate) + model.onDidChangeContent(() => { + if (!syncingSourceRef.current) { + callbacksRef.current.onChange(model.getValue()) + } + }) + editorInstance.onDidBlurEditorWidget(() => callbacksRef.current.onDeactivate()) // Escape closes an open widget first; only a bare Escape leaves the cell. editorInstance.addCommand( monaco.KeyCode.Escape, - onDeactivate, + () => callbacksRef.current.onDeactivate(), '!suggestWidgetVisible && !findWidgetVisible && !parameterHintsVisible' ) const cleanupFindShortcut = installMonacoEditorFindShortcut(editorInstance) @@ -231,9 +240,33 @@ function IpynbSourceEditor({ editorInstance.dispose() model.dispose() editorRef.current = null + fitHeightRef.current = null } // oxlint-disable-next-line react-hooks/exhaustive-deps -- mount-once; see the Why above. }, []) + useLayoutEffect(() => { + const editorInstance = editorRef.current + const model = editorInstance?.getModel() + if (!editorInstance || !model) { + return + } + if (model.getLanguageId() !== cell.language) { + monaco.editor.setModelLanguage(model, cell.language) + } + editorInstance.updateOptions({ + fontFamily, + fontSize, + wordWrap: cell.kind === 'code' ? 'off' : 'on' + }) + syncingSourceRef.current = true + try { + syncContentUpdate(editorInstance, source) + } finally { + syncingSourceRef.current = false + } + fitHeightRef.current?.() + }, [source, cell.language, cell.kind, fontFamily, fontSize]) + return
} diff --git a/tests/e2e/notebook-cell-external-sync.spec.ts b/tests/e2e/notebook-cell-external-sync.spec.ts new file mode 100644 index 00000000000..7eb788e82ad --- /dev/null +++ b/tests/e2e/notebook-cell-external-sync.spec.ts @@ -0,0 +1,106 @@ +import { randomUUID } from 'node:crypto' +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import path from 'node:path' +import type { Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { getActiveWorktreeContext } from './helpers/markdown-editor-fixture' +import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { parseIpynb } from '../../src/renderer/src/components/editor/ipynb-parse' + +const FIRST = { id: 'first', source: 'print("original")' } +const SECOND = { id: 'second', source: 'print("other")' } + +function notebookContent(cells: { id: string; source: string }[]): string { + return JSON.stringify({ + nbformat: 4, + nbformat_minor: 5, + metadata: { language_info: { name: 'python' } }, + cells: cells.map(({ id, source }) => ({ + id, + cell_type: 'code', + metadata: {}, + execution_count: null, + outputs: [], + source: [source] + })) + }) +} + +async function openNotebook(page: Page, filePath: string): Promise { + const context = await getActiveWorktreeContext(page) + await page.evaluate( + ({ filePath, worktreeId, relativePath }) => { + window.__store?.getState().openFile({ + filePath, + relativePath, + worktreeId, + language: 'json', + mode: 'edit' + }) + }, + { + filePath, + worktreeId: context.worktreeId, + relativePath: path.relative(context.rootPath, filePath) + } + ) + await expect(page.locator('.ipynb-code-surface')).toHaveCount(2) +} + +async function fileIsDirty(page: Page): Promise { + return page.evaluate(() => { + const state = window.__store?.getState() + return state?.openFiles.find((file) => file.id === state.activeFileId)?.isDirty ?? null + }) +} + +test.beforeEach(async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) +}) + +for (const reload of ['source', 'reorder'] as const) { + test(`keeps the active cell current after an external notebook ${reload} reload`, async ({ + orcaPage, + registerPostElectronShutdownCleanup + }, testInfo) => { + const context = await getActiveWorktreeContext(orcaPage) + const directory = path.join(context.rootPath, '.orca-e2e-notebook-external-sync') + await mkdir(directory, { recursive: true }) + const filePath = path.join(directory, `${reload}-${testInfo.workerIndex}-${randomUUID()}.ipynb`) + registerPostElectronShutdownCleanup(() => rm(filePath, { force: true })) + await writeFile(filePath, notebookContent([FIRST, SECOND]), 'utf8') + await openNotebook(orcaPage, filePath) + const surfaces = orcaPage.locator('.ipynb-code-surface') + await surfaces.first().getByRole('button').click() + await expect(orcaPage.locator('.ipynb-code-surface .monaco-editor')).toBeVisible() + await expect.poll(() => fileIsDirty(orcaPage)).toBe(false) + + const updatedFirst = reload === 'source' ? { ...FIRST, source: 'print("updated")' } : FIRST + const updatedSecond = { ...SECOND, source: 'print("reload marker")' } + const externalCells = + reload === 'source' ? [updatedFirst, updatedSecond] : [updatedSecond, updatedFirst] + await writeFile(filePath, notebookContent(externalCells), 'utf8') + const preview = surfaces.nth(reload === 'source' ? 1 : 0) + await expect(preview).toHaveText(updatedSecond.source, { timeout: 25_000 }) + await expect.poll(() => fileIsDirty(orcaPage)).toBe(false) + await orcaPage.screenshot({ path: testInfo.outputPath('external-reload-clean.png') }) + const activeCell = surfaces.nth(reload === 'source' ? 0 : 1) + await expect.soft(activeCell.locator('.view-lines')).toHaveText(updatedFirst.source) + + await orcaPage.keyboard.press('End') + await orcaPage.keyboard.type('!', { delay: 100 }) + await expect.poll(() => fileIsDirty(orcaPage)).toBe(true) + await orcaPage.screenshot({ path: testInfo.outputPath('external-reload-cell-edit.png') }) + await orcaPage.getByRole('button', { name: 'Save notebook', exact: true }).click() + const editedFirst = { ...updatedFirst, source: `${updatedFirst.source}!` } + await expect + .poll(async () => { + const saved = parseIpynb(await readFile(filePath, 'utf8')) + return saved.cells.map(({ id, source }) => ({ id, source })) + }) + .toEqual(reload === 'source' ? [editedFirst, updatedSecond] : [updatedSecond, editedFirst]) + await expect.poll(() => fileIsDirty(orcaPage)).toBe(false) + await orcaPage.screenshot({ path: testInfo.outputPath('external-reload-cell-saved.png') }) + }) +} From b32462f2465c58490af26f49a314d7602578ca8c Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 4 Oct 2026 13:05:30 -0700 Subject: [PATCH 20/31] Replace patched JSON parser with stream-json (#25202) * Replace patched JSON parser with stream-json * Isolate dependencies for historical server compatibility builds --- .github/workflows/node-server-tests.yml | 4 +- .../patches/@streamparser__json@0.0.26.patch | 66 ------ .../json-parser-benchmark-fixtures.mjs | 65 ++++++ .../json-parser-migration-benchmark.mjs | 166 ++++++++++++++ .../scripts/node-server-change-scope.test.mjs | 4 + config/tsconfig.cli.json | 4 +- config/tsconfig.tc.cli.json | 2 +- electron.vite.config.ts | 3 +- package.json | 3 +- pnpm-lock.yaml | 27 ++- pnpm-workspace.yaml | 1 - .../session-document-stream-contract.test.ts | 166 ++++++++++++++ src/main/ai-vault/session-document-stream.ts | 205 +++++++++--------- src/shared/json-token-reader.test.ts | 47 ++++ src/shared/json-token-reader.ts | 48 ++++ src/shared/ripgrep-dense-match-json.test.ts | 51 +++-- src/shared/ripgrep-dense-match-json.ts | 186 +++++++++------- 17 files changed, 765 insertions(+), 283 deletions(-) delete mode 100644 config/patches/@streamparser__json@0.0.26.patch create mode 100644 config/scripts/json-parser-benchmark-fixtures.mjs create mode 100644 config/scripts/json-parser-migration-benchmark.mjs create mode 100644 src/main/ai-vault/session-document-stream-contract.test.ts create mode 100644 src/shared/json-token-reader.test.ts create mode 100644 src/shared/json-token-reader.ts diff --git a/.github/workflows/node-server-tests.yml b/.github/workflows/node-server-tests.yml index 0b121c5bb2e..90c3fc68473 100644 --- a/.github/workflows/node-server-tests.yml +++ b/.github/workflows/node-server-tests.yml @@ -164,7 +164,7 @@ jobs: cache-pnpm-store-lookup-only: 'true' # Design D7 upgrade and rollback: the last Bun orcad, built from a main commit that shipped # it, beside this checkout's Node slot; the live-terminal hand-over skips once PROTOCOL_VERSION - # moves past the Bun daemon's. Its build uses this checkout's installed dependencies. + # moves past the Bun daemon's. Install its pinned dependencies independently of this checkout. - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 if: runner.os == 'Linux' with: @@ -179,7 +179,7 @@ jobs: if [ "$RUNNER_OS" != Linux ]; then exit 0; fi git fetch --no-tags --depth=1 origin "$BUN_ORCAD_COMMIT" git worktree add --detach "$RUNNER_TEMP/bun-orcad-source" "$BUN_ORCAD_COMMIT" - ln -s "$GITHUB_WORKSPACE/node_modules" "$RUNNER_TEMP/bun-orcad-source/node_modules" + pnpm --dir "$RUNNER_TEMP/bun-orcad-source" install --frozen-lockfile --ignore-scripts node "$RUNNER_TEMP/bun-orcad-source/config/scripts/build-orcad-bun.mjs" --out-dir "$RUNNER_TEMP/bun-orcad" echo "slot=$RUNNER_TEMP/bun-orcad" >> "$GITHUB_OUTPUT" echo "executable=$(command -v bun)" >> "$GITHUB_OUTPUT" diff --git a/config/patches/@streamparser__json@0.0.26.patch b/config/patches/@streamparser__json@0.0.26.patch deleted file mode 100644 index c93998f85fe..00000000000 --- a/config/patches/@streamparser__json@0.0.26.patch +++ /dev/null @@ -1,66 +0,0 @@ -diff --git a/dist/cjs/utils/bufferedString.js b/dist/cjs/utils/bufferedString.js -index 82f710a018f9771fe10335e2dcacd75d707d9062..f3cfa64cefa967d7a83c328e1abb9246135b067b 100644 ---- a/dist/cjs/utils/bufferedString.js -+++ b/dist/cjs/utils/bufferedString.js -@@ -16,7 +16,7 @@ class NonBufferedString { - constructor() { - // fatal: true makes invalid byte sequences (e.g. a lead byte followed by a - // non-continuation byte) throw instead of silently decoding to U+FFFD. -- this.decoder = new TextDecoder("utf-8", { fatal: true }); -+ this.decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }); - // Pieces appended since the last toString(), not yet folded into `string`. - this.pending = []; - this.string = ""; -@@ -66,7 +66,7 @@ class BufferedString { - constructor(bufferSize) { - // fatal: true makes invalid byte sequences (e.g. a lead byte followed by a - // non-continuation byte) throw instead of silently decoding to U+FFFD. -- this.decoder = new TextDecoder("utf-8", { fatal: true }); -+ this.decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }); - this.bufferOffset = 0; - this.string = ""; - this.byteLength = 0; -diff --git a/dist/mjs/utils/bufferedString.js b/dist/mjs/utils/bufferedString.js -index 0fb208d8615f5e20a086f75a37bef928b155c47c..0d8ca407d594d61798eca7f7253e1dfc1770d291 100644 ---- a/dist/mjs/utils/bufferedString.js -+++ b/dist/mjs/utils/bufferedString.js -@@ -13,7 +13,7 @@ export class NonBufferedString { - constructor() { - // fatal: true makes invalid byte sequences (e.g. a lead byte followed by a - // non-continuation byte) throw instead of silently decoding to U+FFFD. -- this.decoder = new TextDecoder("utf-8", { fatal: true }); -+ this.decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }); - // Pieces appended since the last toString(), not yet folded into `string`. - this.pending = []; - this.string = ""; -@@ -62,7 +62,7 @@ export class BufferedString { - constructor(bufferSize) { - // fatal: true makes invalid byte sequences (e.g. a lead byte followed by a - // non-continuation byte) throw instead of silently decoding to U+FFFD. -- this.decoder = new TextDecoder("utf-8", { fatal: true }); -+ this.decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }); - this.bufferOffset = 0; - this.string = ""; - this.byteLength = 0; -diff --git a/src/utils/bufferedString.ts b/src/utils/bufferedString.ts -index 482c7402899bb157249cfb7882d327b7d9477912..4e45ef5d5bd7ec66776ece54e917282441cededb 100644 ---- a/src/utils/bufferedString.ts -+++ b/src/utils/bufferedString.ts -@@ -40,7 +40,7 @@ export interface StringBuilder { - export class NonBufferedString implements StringBuilder { - // fatal: true makes invalid byte sequences (e.g. a lead byte followed by a - // non-continuation byte) throw instead of silently decoding to U+FFFD. -- private decoder = new TextDecoder("utf-8", { fatal: true }); -+ private decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }); - // Pieces appended since the last toString(), not yet folded into `string`. - private pending: string[] = []; - private string = ""; -@@ -90,7 +90,7 @@ export class NonBufferedString implements StringBuilder { - export class BufferedString implements StringBuilder { - // fatal: true makes invalid byte sequences (e.g. a lead byte followed by a - // non-continuation byte) throw instead of silently decoding to U+FFFD. -- private decoder = new TextDecoder("utf-8", { fatal: true }); -+ private decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }); - private buffer: Uint8Array; - private bufferOffset = 0; - private string = ""; diff --git a/config/scripts/json-parser-benchmark-fixtures.mjs b/config/scripts/json-parser-benchmark-fixtures.mjs new file mode 100644 index 00000000000..385b4010895 --- /dev/null +++ b/config/scripts/json-parser-benchmark-fixtures.mjs @@ -0,0 +1,65 @@ +import { writeFileSync } from 'node:fs' +import { join } from 'node:path' + +export const JSON_PARSER_CASES = [ + { name: 'rg-10k', kind: 'rg', count: 10_000, cap: 2000 }, + { name: 'rg-100k', kind: 'rg', count: 100_000, cap: 2000 }, + { name: 'rg-100k-cap1', kind: 'rg', count: 100_000, cap: 1 }, + { name: 'rg-unicode', kind: 'rg', count: 10_000, cap: 2000, unicode: true }, + { name: 'rg-large-dense', kind: 'rg', count: 900_000, cap: 2000, large: true }, + { name: 'rg-fast-path', kind: 'rg', count: 1, cap: 2000, large: true }, + { name: 'session-messages', kind: 'session' }, + { name: 'session-skipped-objects', kind: 'session' }, + { name: 'session-skipped-string', kind: 'session' }, + { name: 'session-selected-string', kind: 'session' } +] + +export function writeJsonParserFixtures(directory) { + for (const fixture of JSON_PARSER_CASES) { + let value + if (fixture.kind === 'rg') { + const text = fixture.unicode + ? '\ufeff日本語😀x' + : fixture.large && fixture.count > 1 + ? 'xxxx' + : 'x' + const matchBytes = Buffer.byteLength(text) + value = { + type: 'match', + data: { + path: { text: `${text}.ts` }, + lines: { + text: text.repeat(fixture.count === 1 ? 4 * 1024 * 1024 : fixture.count) + }, + line_number: 1, + submatches: Array.from({ length: fixture.count }, (_, index) => ({ + match: { text }, + start: index * matchBytes, + end: (index + 1) * matchBytes + })) + } + } + } else { + value = { id: 'synthetic', messages: [{ text: 'one' }] } + if (fixture.name === 'session-messages') { + value.agent = { model: 'model', other: 'ignored' } + value.messages = Array.from({ length: 20_000 }, (_, index) => ({ + role: index % 2 ? 'assistant' : 'user', + text: '\ufeff日本語😀 hello world '.repeat(16), + timestamp: index, + metadata: { model: 'synthetic', tokens: 512 } + })) + } else if (fixture.name === 'session-skipped-objects') { + value.ignored = Array.from({ length: 200_000 }, (_, index) => ({ + id: index, + data: { text: 'x'.repeat(64), values: [1, 2, 3] } + })) + } else if (fixture.name === 'session-skipped-string') { + value.ignored = '日本語😀x'.repeat(1_500_000) + } else { + value.messages = [{ text: '日本語😀x'.repeat(1_500_000) }] + } + } + writeFileSync(join(directory, `${fixture.name}.json`), JSON.stringify(value)) + } +} diff --git a/config/scripts/json-parser-migration-benchmark.mjs b/config/scripts/json-parser-migration-benchmark.mjs new file mode 100644 index 00000000000..0aca1734e1b --- /dev/null +++ b/config/scripts/json-parser-migration-benchmark.mjs @@ -0,0 +1,166 @@ +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { spawnSync } from 'node:child_process' +import { createReadStream, readFileSync, statSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { buildCounterbalancedSchedule } from './counterbalanced-benchmark-schedule.mjs' +import { summarizeBenchmarkSamples } from './benchmark-sample-summary.mjs' +import { JSON_PARSER_CASES, writeJsonParserFixtures } from './json-parser-benchmark-fixtures.mjs' + +// Bundle each revision's two consumers as {baseline,candidate}-{rg,session}.mjs first. +const [bundleDirectory, workerFixture, workerArm] = process.argv.slice(2) +if (!bundleDirectory || !global.gc) { + throw new Error('Usage: node --expose-gc json-parser-migration-benchmark.mjs BUNDLE_DIRECTORY') +} + +async function load(arm, kind) { + return import(pathToFileURL(resolve(bundleDirectory, `${arm}-${kind}.mjs`)).href) +} + +function prepareRun(module, fixture, file) { + if (fixture.kind === 'rg') { + const text = readFileSync(file, 'utf8') + return () => + module.parseRipgrepMatchJson(text, fixture.cap, { + structuralTokens: 32 * 1024, + nestingDepth: 16 + }) + } + return () => + module.readStreamedSessionDocument({ + bytes: createReadStream(file, { highWaterMark: 64 * 1024 }), + arrayKey: 'messages', + fields: ['id'], + objectFields: { agent: ['model'] }, + create: () => ({ count: 0, textLength: 0 }), + consume(state, value) { + state.count++ + state.textLength += typeof value?.text === 'string' ? value.text.length : 0 + } + }) +} + +function digest(value) { + return createHash('sha256').update(JSON.stringify(value)).digest('hex') +} + +async function consumedContentDigest(module, file) { + const result = await module.readStreamedSessionDocument({ + bytes: createReadStream(file, { highWaterMark: 64 * 1024 }), + arrayKey: 'messages', + fields: ['id'], + objectFields: { agent: ['model'] }, + create: () => createHash('sha256'), + consume(hash, value) { + hash.update(JSON.stringify(value)).update('\n') + } + }) + return { record: result.record, consumedSha256: result.state.digest('hex') } +} + +if (workerFixture) { + const fixture = JSON_PARSER_CASES.find((item) => workerFixture.endsWith(`${item.name}.json`)) + assert(fixture) + const module = await load(workerArm, fixture.kind) + const run = prepareRun(module, fixture, workerFixture) + global.gc() + const before = process.memoryUsage() + let running = true + let maxLoopGapMs = 0 + let previous = performance.now() + const observe = () => { + const now = performance.now() + maxLoopGapMs = Math.max(maxLoopGapMs, now - previous) + previous = now + if (running) { + setImmediate(observe) + } + } + setImmediate(observe) + const started = performance.now() + const result = await run() + const elapsedMs = performance.now() - started + await new Promise((done) => setImmediate(done)) + running = false + const peakRssMiB = process.resourceUsage().maxRSS / 1024 + global.gc() + const retainedHeapDeltaMiB = (process.memoryUsage().heapUsed - before.heapUsed) / 1024 ** 2 + console.log( + JSON.stringify({ + elapsedMs, + peakRssMiB, + retainedHeapDeltaMiB, + maxLoopGapMs, + digest: digest(result) + }) + ) +} else { + const directory = mkdtempSync(join(tmpdir(), 'orca-json-parser-benchmark-')) + try { + writeJsonParserFixtures(directory) + global.gc() + const results = [] + for (const fixture of JSON_PARSER_CASES) { + const file = join(directory, `${fixture.name}.json`) + const runs = {} + const contents = {} + for (const arm of ['baseline', 'candidate']) { + const module = await load(arm, fixture.kind) + runs[arm] = prepareRun(module, fixture, file) + if (fixture.kind === 'session') { + contents[arm] = await consumedContentDigest(module, file) + } + } + assert.deepEqual(contents.candidate, contents.baseline) + for (let warmup = 0; warmup < 3; warmup++) { + assert.deepEqual(await runs.candidate(), await runs.baseline()) + } + const samples = { baseline: [], candidate: [] } + for (const pair of buildCounterbalancedSchedule(12, 'baseline', 'candidate')) { + for (const arm of pair) { + const started = performance.now() + await runs[arm]() + samples[arm].push(performance.now() - started) + } + } + const memory = { baseline: [], candidate: [] } + for (const pair of buildCounterbalancedSchedule(2, 'baseline', 'candidate')) { + for (const arm of pair) { + const child = spawnSync( + process.execPath, + ['--expose-gc', import.meta.filename, bundleDirectory, file, arm], + { + encoding: 'utf8', + env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' }, + windowsHide: true + } + ) + assert.equal(child.status, 0, child.stderr) + memory[arm].push(JSON.parse(child.stdout)) + } + } + for (const sample of [...memory.baseline, ...memory.candidate]) { + assert.equal(sample.digest, memory.baseline[0].digest) + } + results.push({ + name: fixture.name, + bytes: statSync(file).size, + baseline: summarizeBenchmarkSamples(samples.baseline), + candidate: summarizeBenchmarkSamples(samples.candidate), + samples, + memory + }) + } + console.log( + JSON.stringify( + { node: process.version, platform: process.platform, arch: process.arch, results }, + null, + 2 + ) + ) + } finally { + rmSync(directory, { recursive: true, force: true }) + } +} diff --git a/config/scripts/node-server-change-scope.test.mjs b/config/scripts/node-server-change-scope.test.mjs index 4216beb2556..38b56f0d600 100644 --- a/config/scripts/node-server-change-scope.test.mjs +++ b/config/scripts/node-server-change-scope.test.mjs @@ -310,6 +310,10 @@ it('runs the Bun and Node cross-runtime tests on Linux against pinned inputs', ( expect(build.if).toBeUndefined() expect(build['continue-on-error']).toBeUndefined() expect(build.run).toMatch(/^if \[ "\$RUNNER_OS" != Linux \]; then exit 0; fi\n/) + expect(build.run).toContain( + 'pnpm --dir "$RUNNER_TEMP/bun-orcad-source" install --frozen-lockfile --ignore-scripts' + ) + expect(build.run).not.toContain('"$GITHUB_WORKSPACE/node_modules"') expect(build.run).toContain('echo "slot=$RUNNER_TEMP/bun-orcad" >> "$GITHUB_OUTPUT"') expect(build.run).toContain('echo "executable=$(command -v bun)" >> "$GITHUB_OUTPUT"') expect(build.run).not.toContain('GITHUB_ENV') diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 44edcfd90b7..6afcf4f142f 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -258,8 +258,8 @@ ], "compilerOptions": { "composite": true, - // TypeScript 7 removed node10 resolution; Node16 preserves CommonJS emit for this package. - "module": "Node16", + // The CLI runs on Node 24; Node20 models synchronous ESM imports from CommonJS. + "module": "Node20", "moduleResolution": "Node16", "rootDir": "../src", "outDir": "../out" diff --git a/config/tsconfig.tc.cli.json b/config/tsconfig.tc.cli.json index bd59f47e7be..c613dd688d9 100644 --- a/config/tsconfig.tc.cli.json +++ b/config/tsconfig.tc.cli.json @@ -1,7 +1,7 @@ { "extends": "./tsconfig.cli.json", "compilerOptions": { - "module": "node16", + "module": "Node20", "moduleResolution": "node16" } } diff --git a/electron.vite.config.ts b/electron.vite.config.ts index a899fd97f9f..ac23e12afba 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -13,7 +13,8 @@ import { import packageJson from './package.json' with { type: 'json' } const BUNDLED_MAIN_DEPENDENCIES = new Set([ - '@streamparser/json', + 'stream-json', + 'stream-chain', '@xterm/headless', '@xterm/addon-serialize', 'tldts', diff --git a/package.json b/package.json index 3e67dfb4813..3c1979d5ff0 100644 --- a/package.json +++ b/package.json @@ -182,7 +182,6 @@ "@floating-ui/dom": "1.8.0", "@linear/sdk": "^97.0.0", "@parcel/watcher": "^2.5.6", - "@streamparser/json": "0.0.26", "@xterm/addon-serialize": "0.15.0-beta.300", "@xterm/headless": "6.1.0-beta.302", "agent-browser": "~0.27.0", @@ -198,6 +197,8 @@ "sherpa-onnx": "1.12.37", "smol-toml": "1.8.0", "ssh2": "^1.17.0", + "stream-chain": "4.2.6", + "stream-json": "3.7.0", "tldts": "7.4.16", "tweetnacl": "^1.0.3", "ws": "^8.22.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2c58ae7a17a..fbf12ea1ae1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -166,7 +166,6 @@ overrides: monaco-editor>dompurify: 3.4.16 patchedDependencies: - '@streamparser/json@0.0.26': b2cf43861e5b4e485e97ffa7449ea65acab4d65dd983ab8c0508f1c68f7d80c9 '@vscode/windows-process-tree@0.8.0': 9da74aa3d17243aa53dcdc95c9f06e97437e7fbccf098aeb017579e2d24cbac2 '@xterm/addon-image@0.10.0-beta.300': e5254a46d6f57bef4a8a19683bfa685afa0ca0127545aea53b54a48104ca3562 '@xterm/addon-ligatures@0.11.0-beta.300': 47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920 @@ -197,9 +196,6 @@ importers: '@parcel/watcher': specifier: ^2.5.6 version: 2.5.6 - '@streamparser/json': - specifier: 0.0.26 - version: 0.0.26(patch_hash=b2cf43861e5b4e485e97ffa7449ea65acab4d65dd983ab8c0508f1c68f7d80c9) '@xterm/addon-serialize': specifier: 0.15.0-beta.300 version: 0.15.0-beta.300(patch_hash=b35533fe252e7e45433150170348889f4e08a6c17f7017ac34ea694d831fec7f)(@xterm/xterm@6.1.0-beta.303(patch_hash=dd0ccc59cd1ccf99f4d76e5aa2456da165fa0804dce19a833d7638bd07ffa393)) @@ -245,6 +241,12 @@ importers: ssh2: specifier: ^1.17.0 version: 1.17.0 + stream-chain: + specifier: 4.2.6 + version: 4.2.6 + stream-json: + specifier: 3.7.0 + version: 3.7.0 tldts: specifier: 7.4.16 version: 7.4.16 @@ -3186,9 +3188,6 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@streamparser/json@0.0.26': - resolution: {integrity: sha512-46597LNFI+MFdUnzX2QJWwmdTRdq0XVD+vVNJTtGVzIrnCuhG9pFo1OAzbNBqci8UJgk/X5KJZ6LcV+y7PTuDQ==} - '@swc/core-darwin-arm64@1.15.46': resolution: {integrity: sha512-IsISIT22EfktVJrlvIpnAxG2u/A9aob9l99HMlx80x72WlFmFPk1V3UhkEzx86eJP8hw049KTFv/RISho2cq2Q==} engines: {node: '>=10'} @@ -7355,6 +7354,12 @@ packages: resolution: {integrity: sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A==} engines: {node: '>=18'} + stream-chain@4.2.6: + resolution: {integrity: sha512-1zeJ8CrtJfmiba26ui8jXkq/xLRFvhzkdH02D5QLO9Cnovgeb28IJxg88DdraWnjnkbOol/++uIAybJbJhk7ig==} + + stream-json@3.7.0: + resolution: {integrity: sha512-rCSBdcBP/bPk6T8QFcxAj1MSzAuc5i49cYW6IE7sYObNEPccBJIUiL6fU9c9BWt40aJK0siVynLX7lWaW8alXw==} + strict-event-emitter@0.5.1: resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==} @@ -10209,8 +10214,6 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@streamparser/json@0.0.26(patch_hash=b2cf43861e5b4e485e97ffa7449ea65acab4d65dd983ab8c0508f1c68f7d80c9)': {} - '@swc/core-darwin-arm64@1.15.46': optional: true @@ -14966,6 +14969,12 @@ snapshots: stdin-discarder@0.3.2: {} + stream-chain@4.2.6: {} + + stream-json@3.7.0: + dependencies: + stream-chain: 4.2.6 + strict-event-emitter@0.5.1: optional: true diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f00e23d7f2f..e3c133b29ee 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -63,4 +63,3 @@ patchedDependencies: lint-staged@16.4.0: config/patches/lint-staged@16.4.0.patch '@vscode/windows-process-tree@0.8.0': config/patches/@vscode__windows-process-tree@0.8.0.patch i18next-cli@1.74.2: config/patches/i18next-cli@1.74.2.patch - '@streamparser/json@0.0.26': config/patches/@streamparser__json@0.0.26.patch diff --git a/src/main/ai-vault/session-document-stream-contract.test.ts b/src/main/ai-vault/session-document-stream-contract.test.ts new file mode 100644 index 00000000000..3ab4053b303 --- /dev/null +++ b/src/main/ai-vault/session-document-stream-contract.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from 'vitest' +import { readStreamedSessionDocument } from './session-document-stream' + +async function* bytes(content: string, chunkSize = 7): AsyncGenerator { + const buffer = Buffer.from(content) + for (let offset = 0; offset < buffer.length; offset += chunkSize) { + yield buffer.subarray(offset, offset + chunkSize) + } +} + +function read( + content: string, + overrides: Partial>[0]> = {} +) { + return readStreamedSessionDocument({ + bytes: bytes(content), + arrayKey: 'messages', + fields: ['id'], + objectFields: { agent: ['model'] }, + create: (): unknown[] => [], + consume: (state, value) => { + state.push(value) + }, + ...overrides + }) +} + +describe('streamed session document contracts', () => { + it.each(['[]', 'null', 'false', '0', '"not an array"', '{}'])( + 'a later messages value %s clears an earlier fold', + async (last) => { + expect(await read(`{"messages":[1,2],"messages":${last}}`)).toEqual({ record: {}, state: [] }) + } + ) + + it('replaces a failed earlier array and continues with its successor', async () => { + const consume = (state: unknown[], value: unknown): void => { + if (value === 'bad') { + throw new Error('discarded failure') + } + state.push(value) + } + expect(await read('{"messages":["bad",1],"messages":[2,3]}', { consume })).toEqual({ + record: {}, + state: [2, 3] + }) + expect(await read('{"messages":["bad"],"messages":[]}', { consume })).toEqual({ + record: {}, + state: [] + }) + }) + + it('keeps the first consumer failure unless a later array replaces it', async () => { + const failure = new Error('consumer failed') + let calls = 0 + await expect( + read('{"messages":[1,2]}', { + consume: () => { + calls++ + throw failure + } + }) + ).rejects.toBe(failure) + expect(calls).toBe(1) + }) + + it('gives malformed trailing JSON precedence over a consumer failure', async () => { + await expect( + read('{"messages":[1]} trailing', { + consume: () => { + throw new Error('consumer') + } + }) + ).rejects.toBeInstanceOf(SyntaxError) + }) + + it('keeps projected object resets and full-field overlap precedence', async () => { + expect(await read('{"agent":{"model":"old"},"agent":null}')).toEqual({ + record: { agent: {} }, + state: [] + }) + expect(await read('{"agent":{"model":"m","extra":[1,2]}}', { fields: ['agent'] })).toEqual({ + record: { agent: { model: 'm', extra: [1, 2] } }, + state: [] + }) + expect(await read('{"messages":[1,2]}', { objectFields: { messages: ['model'] } })).toEqual({ + record: { messages: {} }, + state: [1, 2] + }) + expect( + await read('{"messages":{"model":"m","ignored":1}}', { + objectFields: { messages: ['model'] } + }) + ).toEqual({ record: { messages: { model: 'm' } }, state: [] }) + }) + + it('preserves selected prototype keys as own properties', async () => { + const content = + '{"id":{"__proto__":{"polluted":true}},"agent":{"model":{"constructor":"value","__proto__":{"x":1}}}}' + const parsed = await read(content) + expect(parsed?.record).toEqual(JSON.parse(content)) + expect(Object.getPrototypeOf(parsed?.record)).toBeNull() + expect(Object.prototype).not.toHaveProperty('polluted') + }) + + it.each(['', ' ', '{', '{"messages":[1,]}', '{"messages":[]}{"messages":[]}'])( + 'rejects malformed input %j', + async (content) => { + await expect(read(content)).rejects.toBeInstanceOf(SyntaxError) + } + ) + + it.each(['null', '[]', '123', '"text"'])( + 'does not publish a nonobject document %s', + async (content) => { + expect(await read(content)).toBeNull() + } + ) + + it('validates discarded subtrees and closes their source on malformed input', async () => { + let closed = false + async function* malformed() { + try { + yield Buffer.from('{"ignored":[{"deep":1},]}') + throw new Error('must not request another chunk') + } finally { + closed = true + } + } + await expect(read('', { bytes: malformed() })).rejects.toBeInstanceOf(SyntaxError) + expect(closed).toBe(true) + }) + + it('preserves source failure and closes the source even after a consumer failure', async () => { + const failure = new Error('disk failure') + let closed = false + async function* failing() { + try { + yield Buffer.from('{"messages":[1]') + throw failure + } finally { + closed = true + } + } + await expect( + read('', { + bytes: failing(), + consume: () => { + throw new Error('consumer') + } + }) + ).rejects.toBe(failure) + expect(closed).toBe(true) + }) + + it('preserves escaped astral text across large and byte-sized chunks', async () => { + const value = `${'x'.repeat(65530)}日本語😀\\literal` + const content = JSON.stringify({ messages: [value, '\ud800', '\udc00'] }) + for (const size of [1, 65536, content.length * 4]) { + expect(await read(content, { bytes: bytes(content, size) })).toEqual({ + record: {}, + state: [value, '\ud800', '\udc00'] + }) + } + }) +}) diff --git a/src/main/ai-vault/session-document-stream.ts b/src/main/ai-vault/session-document-stream.ts index 4d12ab0fcc2..4fcf24f7a55 100644 --- a/src/main/ai-vault/session-document-stream.ts +++ b/src/main/ai-vault/session-document-stream.ts @@ -1,6 +1,7 @@ import { StringDecoder } from 'node:string_decoder' -import { JSONParser, TokenizerError, TokenParserError, TokenType } from '@streamparser/json' +import { FlexAssembler, arrayRule, objectRule } from 'stream-json/core/utils/flex-assembler.js' import { setImmediate as yieldToEventLoop } from 'node:timers/promises' +import { createJsonTokenReader } from '../../shared/json-token-reader' import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation' /** Fold one root array while retaining only the root fields the agent parser uses. */ @@ -13,92 +14,108 @@ export async function readStreamedSessionDocument(args: { consume: (state: T, value: unknown) => void signal?: AbortSignal }): Promise<{ record: Record; state: T } | null> { - const parser = new JSONParser({ - paths: [ - ...args.fields.map((field) => `$.${field}`), - ...Object.entries(args.objectFields ?? {}).flatMap(([root, fields]) => - fields.map((field) => `$.${root}.${field}`) - ), - ...(args.arrayKey ? [`$.${args.arrayKey}`, `$.${args.arrayKey}.*`] : []) - ], - keepStack: false, - stringBufferSize: 64 * 1024 - }) const record: Record = Object.create(null) const fields = new Set(args.fields) - let depth = 0 - let expectingRootKey = false - parser.onToken = ({ token, value }) => { - if (depth === 1 && expectingRootKey && token === TokenType.STRING) { - if (typeof value === 'string' && Object.hasOwn(args.objectFields ?? {}, value)) { - record[value] = Object.create(null) - } - expectingRootKey = false - } - if (token === TokenType.LEFT_BRACE || token === TokenType.LEFT_BRACKET) { - if (depth === 0 && token === TokenType.LEFT_BRACE) { - expectingRootKey = true - } - depth++ - } else if (token === TokenType.RIGHT_BRACE || token === TokenType.RIGHT_BRACKET) { - depth-- - } else if (token === TokenType.COMMA && depth === 1) { - expectingRootKey = true - } - } + const objectFields = new Map( + Object.entries(args.objectFields ?? {}).map(([root, keys]) => [root, new Set(keys)]) + ) + const foldedArray = Symbol('folded session array') let state = args.create() - let currentArray: unknown = null let consumeFailure: { error: unknown } | undefined + let inFoldedArray = false + const reset = (): void => { + state = args.create() + consumeFailure = undefined + } + const retain = (path: (string | number)[]): boolean => { + const [root, child] = path + return ( + typeof root === 'string' && + ((root !== args.arrayKey && fields.has(root)) || + (inFoldedArray && root === args.arrayKey && path.length >= 2) || + (typeof child === 'string' && objectFields.get(root)?.has(child) === true)) + ) + } + // Every discarded container needs a rule; dropping only its parent still builds large children. + const discard = { + filter: (path: (string | number)[]) => !retain(path), + create: () => null, + add: () => {} + } + const assembler = new FlexAssembler({ + maxDepth: Infinity, + objectRules: [ + objectRule>({ + filter: (path) => path.length === 0, + create: () => record, + add: (target, key, value) => { + if (key === args.arrayKey) { + if (value !== foldedArray) { + reset() + } + } else if (fields.has(key)) { + target[key] = value + } + } + }), + objectRule>({ + filter: (path) => + path.length === 1 && + typeof path[0] === 'string' && + objectFields.has(path[0]) && + (!fields.has(path[0]) || path[0] === args.arrayKey), + create: (path) => { + const projected: Record = Object.create(null) + record[String(path[0])] = projected + return projected + }, + add: (target, key, value) => { + const root = assembler.path[0] + if (typeof root === 'string' && objectFields.get(root)?.has(key)) { + target[key] = value + } + } + }), + discard + ], + arrayRules: [ + arrayRule({ + filter: (path) => Boolean(args.arrayKey) && path.length === 1 && path[0] === args.arrayKey, + create: () => { + reset() + inFoldedArray = true + return null + }, + add: (_target, value) => { + if (!consumeFailure) { + try { + args.consume(state, value) + } catch (error) { + consumeFailure = { error } + } + } + }, + finalize: () => { + inFoldedArray = false + return foldedArray + } + }), + discard + ] + }) + const parser = createJsonTokenReader((token) => { + if ( + token.name === 'keyValue' && + assembler.depth === 1 && + !assembler.isArray && + objectFields.has(token.value) + ) { + record[token.value] = Object.create(null) + } + assembler.consume(token) + }) const decoder = new StringDecoder('utf8') let objectRoot: boolean | undefined - parser.onValue = ({ key, value, parent, stack }) => { - if (stack.length === 2 && stack[1].key === args.arrayKey && Array.isArray(parent)) { - if (parent !== currentArray) { - state = args.create() - consumeFailure = undefined - currentArray = parent - } - if (!consumeFailure) { - try { - args.consume(state, value) - } catch (error) { - consumeFailure = { error } - } - } - // The parser's array cursor is independent of retained array slots. - parent.pop() - } else if ( - stack.length === 2 && - typeof stack[1].key === 'string' && - typeof key === 'string' && - parent && - !Array.isArray(parent) - ) { - const root = stack[1].key - const projected = record[root] - if ( - Object.hasOwn(args.objectFields ?? {}, root) && - args.objectFields?.[root]?.includes(key) && - projected && - typeof projected === 'object' - ) { - Reflect.set(projected, key, value) - } - } else if (stack.length === 1 && typeof key === 'string') { - if (key === args.arrayKey) { - if (value !== currentArray || !Array.isArray(value)) { - state = args.create() - consumeFailure = undefined - } - currentArray = null - } else if (fields.has(key)) { - record[key] = value - } - if (parent && typeof parent === 'object') { - Reflect.deleteProperty(parent, key) - } - } - } for await (const chunk of args.bytes) { throwIfAiVaultScanCancelled(args.signal) if (objectRoot === undefined) { @@ -107,37 +124,17 @@ export async function readStreamedSessionDocument(args: { objectRoot = first === 123 } } - parseJson(() => parser.write(decoder.write(chunk))) + parser.write(decoder.write(chunk)) await yieldToEventLoop() } const tail = decoder.end() if (tail) { - parseJson(() => parser.write(tail)) - } - if (objectRoot === undefined) { - throw new SyntaxError('Unexpected end of JSON input') - } - if (!parser.isEnded) { - parseJson(() => parser.end(), true) + parser.write(tail) } + parser.end() throwIfAiVaultScanCancelled(args.signal) if (consumeFailure) { throw consumeFailure.error } return objectRoot ? { record, state } : null } - -function parseJson(run: () => void, ending = false): void { - try { - run() - } catch (error) { - if ( - error instanceof TokenizerError || - error instanceof TokenParserError || - (ending && error instanceof Error) - ) { - throw new SyntaxError(error.message) - } - throw error - } -} diff --git a/src/shared/json-token-reader.test.ts b/src/shared/json-token-reader.test.ts new file mode 100644 index 00000000000..9cba3e8a869 --- /dev/null +++ b/src/shared/json-token-reader.test.ts @@ -0,0 +1,47 @@ +import { expect, it } from 'vitest' +import { FlexAssembler } from 'stream-json/core/utils/flex-assembler.js' +import { createJsonTokenReader } from './json-token-reader' + +it.each([1, 7, 64 * 1024, Infinity])('preserves Unicode at chunk size %s', (size) => { + const value = { '\ufeffkey': `\ufeffstart${'\n'.repeat(64 * 1024)}\ufeff😀end` } + const literal = JSON.stringify(value) + for (const text of [literal, literal.replaceAll('\ufeff', '\\uFEFF')]) { + const assembler = new FlexAssembler() + const reader = createJsonTokenReader((token) => { + assembler.consume(token) + }) + for (let offset = 0; offset < text.length; offset += size) { + reader.write(text.slice(offset, offset + size)) + } + reader.end() + expect(assembler.current).toEqual(value) + } +}) + +it.each(['', ' ', '{', '{"a":1,}', '{}{}', '[01]', '\ufeff{}'])( + 'rejects malformed JSON %j as a syntax error', + (text) => { + const reader = createJsonTokenReader(() => {}) + expect(() => { + reader.write(text) + reader.end() + }).toThrow(SyntaxError) + } +) + +it('preserves consumer errors and completes numbers at EOF synchronously', () => { + const values: string[] = [] + const reader = createJsonTokenReader((token) => { + if (token.name === 'numberValue') { + values.push(token.value) + } + }) + reader.write('12') + reader.end() + expect(values).toEqual(['12']) + const failure = new RangeError('consumer failure') + const throwing = createJsonTokenReader(() => { + throw failure + }) + expect(() => throwing.write('{}')).toThrow(failure) +}) diff --git a/src/shared/json-token-reader.ts b/src/shared/json-token-reader.ts new file mode 100644 index 00000000000..c0081ee016f --- /dev/null +++ b/src/shared/json-token-reader.ts @@ -0,0 +1,48 @@ +import parser, { type Token } from 'stream-json/core/parser.js' +import exec from 'stream-chain/exec.js' +import { none } from 'stream-chain/defs.js' + +/** Bounds token batches while preserving caller-owned UTF-8 decoding. */ +export function createJsonTokenReader( + consume: (token: Token) => void, + batchCodeUnits = 64 * 1024 +): { + write: (text: string) => void + end: () => void +} { + if (!Number.isSafeInteger(batchCodeUnits) || batchCodeUnits < 1) { + throw new RangeError('JSON token batch size must be a positive safe integer') + } + const parse = exec(parser({ streamValues: false })) + function run(input: string | typeof none): void { + let consumerFailed = false + try { + const pending = parse(input, (token: Token) => { + try { + consume(token) + } catch (error) { + consumerFailed = true + throw error + } + }) + if (pending) { + throw new Error('JSON token reader requires a synchronous parser') + } + } catch (error) { + if (!consumerFailed && error instanceof Error) { + throw new SyntaxError(error.message) + } + throw error + } + } + return { + write(text) { + for (let offset = 0; offset < text.length; offset += batchCodeUnits) { + run(text.slice(offset, offset + batchCodeUnits)) + } + }, + end() { + run(none) + } + } +} diff --git a/src/shared/ripgrep-dense-match-json.test.ts b/src/shared/ripgrep-dense-match-json.test.ts index 5a4cdd41e29..ec62caddb2f 100644 --- a/src/shared/ripgrep-dense-match-json.test.ts +++ b/src/shared/ripgrep-dense-match-json.test.ts @@ -1,23 +1,6 @@ import { expect, it } from 'vitest' -import { JSONParser } from '@streamparser/json' import { parseDenseRipgrepMatchJson } from './ripgrep-dense-match-json' -it.each([0, 64 * 1024])('preserves literal and escaped BOMs with buffer size %i', (size) => { - const source = { '\ufeffkey': `\ufeffstart${'\n'.repeat(64 * 1024)}\ufeffend` } - const literal = JSON.stringify(source) - for (const record of [literal, literal.replaceAll('\ufeff', '\\uFEFF')]) { - const parser = new JSONParser({ stringBufferSize: size }) - let parsed: unknown - parser.onValue = ({ value, stack }) => { - if (stack.length === 0) { - parsed = value - } - } - parser.write(record) - expect(parsed).toEqual(JSON.parse(record)) - } -}) - it.each(['', '\\', '\n', '\n'.repeat(64 * 1024), `${'x'.repeat(64 * 1024)}\n`])( 'preserves U+FEFF in text and filenames across string-buffer boundaries (%#)', (prefix) => { @@ -32,7 +15,9 @@ it.each(['', '\\', '\n', '\n'.repeat(64 * 1024), `${'x'.repeat(64 * 1024)}\n`])( } } const record = JSON.stringify(source) - expect(parseDenseRipgrepMatchJson(record, 1, 16)).toEqual(JSON.parse(record)) + for (const encoded of [record, record.replaceAll('\ufeff', '\\uFEFF')]) { + expect(parseDenseRipgrepMatchJson(encoded, 1, 16)).toEqual(JSON.parse(record)) + } } ) @@ -76,3 +61,33 @@ it.each(['{}', '{"type":"begin","data":{}}', '{"data":null}', '{"data":[]}'])( expect(projected.data?.submatches).toEqual([]) } ) + +it('validates submatches after reaching the requested result cap', () => { + const source = JSON.stringify({ + type: 'match', + data: { + submatches: [ + { start: 0, end: 1 }, + { start: null, end: 2 } + ] + } + }) + expect(() => parseDenseRipgrepMatchJson(source, 1, 16)).toThrow('Invalid rg submatch') +}) + +it('rejects duplicate coordinate fields whose final value is not numeric', () => { + const source = '{"data":{"submatches":[{"start":0,"start":{},"end":1}]}}' + expect(() => parseDenseRipgrepMatchJson(source, 1, 16)).toThrow('Invalid rg submatch') +}) + +it.each([16_378, 16_379])('retains the existing per-element token budget at %i values', (count) => { + const source = JSON.stringify({ + data: { submatches: [{ start: 0, end: 1, other: Array(count).fill(0) }] } + }) + const parse = (): unknown => parseDenseRipgrepMatchJson(source, 1, 16) + if (count === 16_378) { + expect(parse()).toMatchObject({ data: { submatches: [{ start: 0, end: 1 }] } }) + } else { + expect(parse).toThrow('rg submatch structure exceeds limit') + } +}) diff --git a/src/shared/ripgrep-dense-match-json.ts b/src/shared/ripgrep-dense-match-json.ts index 821415a0748..5b54069fb10 100644 --- a/src/shared/ripgrep-dense-match-json.ts +++ b/src/shared/ripgrep-dense-match-json.ts @@ -3,7 +3,7 @@ import { JsonTextStructureCapacityError, type JsonTextStructureLimits } from './json-text-structure-limit' -import { JSONParser, TokenType } from '@streamparser/json' +import { createJsonTokenReader } from './json-token-reader' export type RipgrepMatchMessage = { type?: string @@ -34,102 +34,132 @@ export function parseRipgrepMatchJson( } } +type RipgrepJsonFrame = { + kind: 'object' | 'array' + context: 'root' | 'data' | 'path' | 'lines' | 'matches' | 'match' | null + key?: string + values: number + keys?: Set + start?: number + end?: number +} + /** Dense rg records retain only the requested ranges while validating the entire record. */ export function parseDenseRipgrepMatchJson( line: string, maxMatches: number, nestingDepth: number ): RipgrepMatchMessage { - const parser = new JSONParser({ - paths: [ - '$.type', - '$.data.path.text', - '$.data.lines.text', - '$.data.lines.bytes', - '$.data.line_number', - '$.data.submatches.*' - ], - keepStack: false, - stringBufferSize: 64 * 1024 - }) const data: NonNullable = { submatches: [] } const result: RipgrepMatchMessage = { data } - const containers: { object: boolean; expectingKey: boolean; keys?: Set }[] = [] + const frames: RipgrepJsonFrame[] = [] let elementTokens = 0 - parser.onToken = ({ token, value }) => { - const current = containers.at(-1) - if (current?.object && current.expectingKey && token === TokenType.STRING) { - if (typeof value !== 'string') { - throw new SyntaxError('Invalid rg object key') - } - if (current.keys?.has(value)) { - throw new SyntaxError('Duplicate rg object key') - } - current.keys?.add(value) - if ((current.keys?.size ?? 0) > 128) { - throw new Error('Too many rg record fields') - } - current.expectingKey = false - } - if (token === TokenType.LEFT_BRACE || token === TokenType.LEFT_BRACKET) { - containers.push({ - object: token === TokenType.LEFT_BRACE, - expectingKey: token === TokenType.LEFT_BRACE, - // rg's envelope keys are unique; reject duplicates instead of mixing projections. - keys: containers.length < 2 ? new Set() : undefined - }) - if (containers.length > nestingDepth) { - throw new Error('rg record nesting exceeds limit') - } - if (containers.length === 4) { - elementTokens = 0 - } - } else if (token === TokenType.RIGHT_BRACE || token === TokenType.RIGHT_BRACKET) { - containers.pop() - } else if (token === TokenType.COMMA && current?.object) { - current.expectingKey = true - } - if (containers.length >= 4 && ++elementTokens > 32 * 1024) { + const countTokens = (amount = 1): void => { + if (frames.length >= 4 && (elementTokens += amount) > 32 * 1024) { throw new Error('rg submatch structure exceeds limit') } } - parser.onValue = ({ key, value, parent, stack }) => { - if (stack.length === 1 && key === 'type' && typeof value === 'string') { - result.type = value - } else if (stack.length === 2 && stack[1].key === 'data' && key === 'line_number') { - if (typeof value === 'number') { - data.line_number = value + const parser = createJsonTokenReader((token) => { + const current = frames.at(-1) + if (token.name === 'keyValue') { + if (!current || current.kind !== 'object') { + throw new SyntaxError('Unexpected rg object key') } - } else if (stack.length === 3 && stack[1].key === 'data') { - if (stack[2].key === 'submatches' && Array.isArray(parent)) { - if ( - !value || - typeof value !== 'object' || - Array.isArray(value) || - typeof value.start !== 'number' || - typeof value.end !== 'number' - ) { + if (current.values++ > 0) { + countTokens() + } + // Packed tokens omit commas and colons; include them in the existing structure budget. + countTokens(2) + current.key = token.value + if (current.keys?.has(token.value)) { + throw new SyntaxError('Duplicate rg object key') + } + current.keys?.add(token.value) + if ((current.keys?.size ?? 0) > 128) { + throw new Error('Too many rg record fields') + } + return + } + if (token.name === 'endObject' || token.name === 'endArray') { + const frame = frames.pop() + countTokens() + if (frame?.context === 'match') { + if (typeof frame.start !== 'number' || typeof frame.end !== 'number') { throw new SyntaxError('Invalid rg submatch') } if (data.submatches && data.submatches.length < maxMatches) { - data.submatches.push({ start: value.start, end: value.end }) - } - parent.pop() - } else if (stack[2].key === 'path' && key === 'text' && typeof value === 'string') { - data.path = { text: value } - } else if (stack[2].key === 'lines' && typeof value === 'string') { - if (key === 'text') { - data.lines = { ...data.lines, text: value } - } - if (key === 'bytes') { - data.lines = { ...data.lines, bytes: value } + data.submatches.push({ start: frame.start, end: frame.end }) } } + return } - } + if (current?.kind === 'array' && current.values++ > 0) { + countTokens() + } + if (current?.context === 'match' && (current.key === 'start' || current.key === 'end')) { + current[current.key] = undefined + } + if (token.name === 'startObject' || token.name === 'startArray') { + const kind = token.name === 'startObject' ? 'object' : 'array' + let context: RipgrepJsonFrame['context'] = null + if (!current && kind === 'object') { + context = 'root' + } else if (current?.context === 'root' && current.key === 'data' && kind === 'object') { + context = 'data' + } else if (current?.context === 'data') { + if ((current.key === 'lines' || current.key === 'path') && kind === 'object') { + context = current.key + } + if (current.key === 'submatches' && kind === 'array') { + context = 'matches' + } + } else if (current?.context === 'matches') { + if (kind !== 'object') { + throw new SyntaxError('Invalid rg submatch') + } + context = 'match' + } + frames.push({ + kind, + context, + values: 0, + keys: kind === 'object' && frames.length < 2 ? new Set() : undefined + }) + if (frames.length > nestingDepth) { + throw new Error('rg record nesting exceeds limit') + } + if (frames.length === 4) { + elementTokens = 0 + } + countTokens() + return + } + countTokens() + if (current?.context === 'matches') { + throw new SyntaxError('Invalid rg submatch') + } + if (token.name === 'numberValue') { + const value = Number(token.value) + if (current?.context === 'match' && (current.key === 'start' || current.key === 'end')) { + current[current.key] = value + } + if (current?.context === 'data' && current.key === 'line_number') { + data.line_number = value + } + } else if (token.name === 'stringValue') { + if (current?.context === 'root' && current.key === 'type') { + result.type = token.value + } + if (current?.context === 'path' && current.key === 'text') { + data.path = { text: token.value } + } + if (current?.context === 'lines' && (current.key === 'text' || current.key === 'bytes')) { + data.lines ??= {} + data.lines[current.key] = token.value + } + } + }, 8 * 1024) parser.write(line) - if (!parser.isEnded) { - parser.end() - } + parser.end() return result } From cbe64383dc3dfce6090cd4740b3ecb9450517cf4 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 4 Oct 2026 13:19:34 -0700 Subject: [PATCH 21/31] Reuse source-line calculations for Markdown review selections (#25173) * Reuse source-line calculations for Markdown review selections * Use typed editor probes in review selection performance coverage --- .../rich-markdown-comment-blocks.test.ts | 216 ++++++++++++++++++ .../editor/rich-markdown-comment-blocks.ts | 82 +++++++ .../rich-markdown-review-annotations.ts | 76 +----- .../rich-markdown-review-note-positioning.ts | 4 +- ...ich-markdown-review-rail-benchmark.test.ts | 3 +- .../rich-markdown-review-rail-blocks.ts | 32 +-- ...kdown-review-selection-performance.spec.ts | 209 +++++++++++++++++ 7 files changed, 524 insertions(+), 98 deletions(-) create mode 100644 src/renderer/src/components/editor/rich-markdown-comment-blocks.test.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-comment-blocks.ts create mode 100644 tests/e2e/markdown-review-selection-performance.spec.ts diff --git a/src/renderer/src/components/editor/rich-markdown-comment-blocks.test.ts b/src/renderer/src/components/editor/rich-markdown-comment-blocks.test.ts new file mode 100644 index 00000000000..a416d6719f0 --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-comment-blocks.test.ts @@ -0,0 +1,216 @@ +// @vitest-environment happy-dom +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Editor } from '@tiptap/core' +import type { DiffComment } from '../../../../shared/diff-comment-types' +import { createRichMarkdownExtensions } from './rich-markdown-extensions' +import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport' +import { + buildRichMarkdownCommentBlocks, + getRichMarkdownCommentBlocks +} from './rich-markdown-comment-blocks' +import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html' +import { + createRichMarkdownHtmlSuperscriptLinkContext, + type RichMarkdownHtmlSuperscriptLinkContext +} from './rich-markdown-html-superscript-link-context' +import { + getRichMarkdownAnnotationHighlightRanges, + getRichMarkdownAnnotationHighlightRangesForComment, + getRichMarkdownAnnotationTarget, + getRichMarkdownCommentAtPos +} from './rich-markdown-review-annotations' +import { getRichMarkdownReviewRailBlocks } from './rich-markdown-review-rail-blocks' + +const editors: Editor[] = [] +const SOURCE = 'First paragraph\n\n```ts\none\n\ntwo\n```\n\nLast paragraph' + +function createEditor(source = SOURCE, context?: RichMarkdownHtmlSuperscriptLinkContext) { + const root = document.createElement('div') + document.body.append(root) + const codec = createRichMarkdownEditorCodec() + const editor = new Editor({ + element: root, + extensions: createRichMarkdownExtensions({ + codec, + htmlSuperscriptLinks: Boolean(context), + htmlSuperscriptLinkContext: context + }), + content: encodeRawMarkdownHtmlForRichEditor(source, codec, { + htmlSuperscriptLinks: Boolean(context) + }), + contentType: 'markdown' + }) + editors.push(editor) + editor.view.dispatch(editor.state.tr.setMeta('addToHistory', false)) + vi.spyOn(root, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 0, 600, 400)) + return { editor, root } +} + +function selectLastParagraph(editor: Editor, characters = 4) { + let from: number | undefined + editor.state.doc.forEach((node, offset) => { + if (node.textContent === 'Last paragraph') { + from = offset + 1 + } + }) + if (from === undefined) { + throw new Error('Last paragraph missing') + } + editor.commands.setTextSelection({ from, to: from + characters }) + const paragraph = Array.from(editor.view.dom.querySelectorAll('p')).find( + (element) => element.textContent === 'Last paragraph' + ) + const text = paragraph?.firstChild + if (!(text instanceof Text)) { + throw new Error('Last paragraph text missing') + } + const range = document.createRange() + range.setStart(text, 0) + range.setEnd(text, characters) + window.getSelection()?.removeAllRanges() + window.getSelection()?.addRange(range) + return from +} + +function comment(lineNumber = 29): DiffComment { + return { + id: 'note', + worktreeId: 'workspace', + filePath: 'notes.md', + source: 'markdown', + lineNumber, + selectedText: 'Last', + body: 'Review', + createdAt: 1, + side: 'modified' + } +} + +afterEach(() => { + for (const editor of editors.splice(0)) { + editor.destroy() + } + window.getSelection()?.removeAllRanges() + document.body.replaceChildren() + vi.restoreAllMocks() +}) + +describe('Markdown review source block reuse', () => { + it('preserves source lines when the document interaction host changes', () => { + const context = createRichMarkdownHtmlSuperscriptLinkContext({ + sourceFilePath: '/repo/notes.md', + worktreeId: 'workspace', + worktreeRoot: '/repo', + sourceOwner: { kind: 'local' } + }) + const { editor } = createEditor( + 'First [1]\n\nLast paragraph', + context + ) + const blocks = getRichMarkdownCommentBlocks(editor) + const doc = editor.state.doc + const serialize = vi.spyOn(editor.markdown!, 'serialize') + const instrumentedBlocks = getRichMarkdownCommentBlocks(editor) + serialize.mockClear() + context.update({ + sourceFilePath: '/remote/notes.md', + worktreeId: 'remote-workspace', + worktreeRoot: '/remote', + sourceOwner: { kind: 'ssh', connectionId: 'connection' } + }) + expect(editor.state.doc).toBe(doc) + expect(getRichMarkdownCommentBlocks(editor)).toBe(instrumentedBlocks) + expect(serialize).not.toHaveBeenCalled() + serialize.mockRestore() + expect(buildRichMarkdownCommentBlocks(editor)).toEqual(blocks) + }) + + it('shares one source map across highlights, comment clicks, selection targets and the rail', () => { + const { editor, root } = createEditor() + const from = selectLastParagraph(editor) + vi.spyOn(Range.prototype, 'getBoundingClientRect').mockReturnValue(new DOMRect(10, 20, 80, 20)) + const serialize = vi.spyOn(editor.markdown!, 'serialize') + const json = vi.spyOn(editor, 'getJSON') + const note = comment() + const expected = [{ from, to: from + 4 }] + + expect(getRichMarkdownAnnotationHighlightRanges(editor, [note], 20)).toEqual(expected) + expect(serialize).toHaveBeenCalledTimes(2 * editor.state.doc.childCount - 1) + expect(json).toHaveBeenCalledTimes(1) + serialize.mockClear() + json.mockClear() + + for (let index = 0; index < 20; index++) { + expect(getRichMarkdownAnnotationHighlightRanges(editor, [note], 20)).toEqual(expected) + expect(getRichMarkdownAnnotationHighlightRangesForComment(editor, note, 20)).toEqual(expected) + expect(getRichMarkdownCommentAtPos(editor, [note], 20, from + 2)).toBe(note) + expect(getRichMarkdownAnnotationTarget(editor, root)).toMatchObject({ + from, + to: from + 4, + selectedText: 'Last', + lineNumber: 9 + }) + expect(getRichMarkdownReviewRailBlocks(editor)).toBe(getRichMarkdownCommentBlocks(editor)) + } + expect(serialize).not.toHaveBeenCalled() + expect(json).not.toHaveBeenCalled() + }) + + it('updates selected text and viewport geometry without rebuilding source lines', () => { + const { editor, root } = createEditor() + const rect = vi.spyOn(Range.prototype, 'getBoundingClientRect') + rect.mockReturnValue(new DOMRect(10, 20, 80, 20)) + selectLastParagraph(editor) + const serialize = vi.spyOn(editor.markdown!, 'serialize') + expect(getRichMarkdownAnnotationTarget(editor, root)).toMatchObject({ + selectedText: 'Last', + buttonTop: 48, + lineNumber: 9 + }) + serialize.mockClear() + const doc = editor.state.doc + selectLastParagraph(editor, 9) + rect.mockReturnValue(new DOMRect(10, 120, 140, 20)) + expect(getRichMarkdownAnnotationTarget(editor, root)).toMatchObject({ + selectedText: 'Last para', + buttonTop: 148, + lineNumber: 9 + }) + expect(editor.state.doc).toBe(doc) + expect(serialize).not.toHaveBeenCalled() + }) + + it('rebuilds source lines after editing a multiline block and undoing it', () => { + const { editor, root } = createEditor() + vi.spyOn(Range.prototype, 'getBoundingClientRect').mockReturnValue(new DOMRect(10, 20, 80, 20)) + selectLastParagraph(editor) + expect(getRichMarkdownAnnotationTarget(editor, root)?.lineNumber).toBe(9) + const serialize = vi.spyOn(editor.markdown!, 'serialize') + let codeFrom: number | undefined + editor.state.doc.forEach((node, offset) => { + if (node.type.name === 'codeBlock') { + codeFrom = offset + 1 + } + }) + if (codeFrom === undefined) { + throw new Error('Code block missing') + } + editor.view.dispatch(editor.state.tr.insertText('new\n', codeFrom)) + selectLastParagraph(editor) + expect(getRichMarkdownAnnotationTarget(editor, root)?.lineNumber).toBe(10) + expect(serialize).toHaveBeenCalledTimes(2 * editor.state.doc.childCount - 1) + expect( + getRichMarkdownCommentAtPos(editor, [comment(30)], 20, editor.state.selection.from) + ).toEqual(comment(30)) + expect(serialize).toHaveBeenCalledTimes(2 * editor.state.doc.childCount - 1) + serialize.mockClear() + + expect(editor.commands.undo()).toBe(true) + selectLastParagraph(editor) + expect(getRichMarkdownAnnotationTarget(editor, root)?.lineNumber).toBe(9) + expect(serialize).toHaveBeenCalledTimes(2 * editor.state.doc.childCount - 1) + serialize.mockClear() + expect(getRichMarkdownAnnotationTarget(editor, root)?.lineNumber).toBe(9) + expect(serialize).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/editor/rich-markdown-comment-blocks.ts b/src/renderer/src/components/editor/rich-markdown-comment-blocks.ts new file mode 100644 index 00000000000..261b982f0bd --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-comment-blocks.ts @@ -0,0 +1,82 @@ +import type { Editor, JSONContent } from '@tiptap/core' +import { countRichMarkdownReviewMarkdownLines } from './rich-markdown-review-line-count' + +export type RichMarkdownCommentBlock = { + key: string + startLine: number + endLine: number + from: number + to: number +} + +function serializeRichMarkdownJson(editor: Editor, content: JSONContent[]): string { + return (editor.markdown?.serialize({ type: 'doc', content }) ?? '').trimEnd() +} + +export function buildRichMarkdownCommentBlocks(editor: Editor): RichMarkdownCommentBlock[] { + const jsonContent = editor.getJSON().content ?? [] + const blocks: RichMarkdownCommentBlock[] = [] + let nextLine = 1 + let previousNodeJson: JSONContent | null = null + let previousNodeLineCount = 0 + + editor.state.doc.forEach((node, nodeOffset, index) => { + const nodeJson = jsonContent[index] + if (!nodeJson) { + return + } + const nodeMarkdown = serializeRichMarkdownJson(editor, [nodeJson]) + const nodeLineCount = countRichMarkdownReviewMarkdownLines(nodeMarkdown) + if (previousNodeJson) { + const pairMarkdown = serializeRichMarkdownJson(editor, [previousNodeJson, nodeJson]) + const separatorLineCount = Math.max( + 0, + countRichMarkdownReviewMarkdownLines(pairMarkdown) - previousNodeLineCount - nodeLineCount + ) + nextLine += separatorLineCount + } + const startLine = nextLine + const endLine = Math.max(startLine, startLine + nodeLineCount - 1) + const from = nodeOffset + 1 + blocks.push({ + key: `${index}:${startLine}-${endLine}`, + startLine, + endLine, + from, + to: from + Math.max(0, node.nodeSize - 1) + }) + nextLine = endLine + 1 + previousNodeJson = nodeJson + previousNodeLineCount = nodeLineCount + }) + + if (blocks.length === 0) { + blocks.push({ key: 'empty:1-1', startLine: 1, endLine: 1, from: 1, to: 1 }) + } + + return blocks +} + +type RichMarkdownCommentBlocksSnapshot = { + doc: Editor['state']['doc'] + markdown: Editor['markdown'] + serialize: NonNullable['serialize'] | undefined + blocks: readonly RichMarkdownCommentBlock[] +} + +// Keep only the current document per editor; scrolling changes geometry, not source lines. +const blocksByEditor = new WeakMap() + +export function getRichMarkdownCommentBlocks(editor: Editor): readonly RichMarkdownCommentBlock[] { + const doc = editor.state.doc + const markdown = editor.markdown + const serialize = markdown?.serialize + const cached = blocksByEditor.get(editor) + if (cached?.doc === doc && cached.markdown === markdown && cached.serialize === serialize) { + return cached.blocks + } + + const blocks = buildRichMarkdownCommentBlocks(editor) + blocksByEditor.set(editor, { doc, markdown, serialize, blocks }) + return blocks +} diff --git a/src/renderer/src/components/editor/rich-markdown-review-annotations.ts b/src/renderer/src/components/editor/rich-markdown-review-annotations.ts index 90bd170a5c1..f466a38b321 100644 --- a/src/renderer/src/components/editor/rich-markdown-review-annotations.ts +++ b/src/renderer/src/components/editor/rich-markdown-review-annotations.ts @@ -1,6 +1,5 @@ import type { Dispatch, SetStateAction } from 'react' import type { Editor } from '@tiptap/react' -import type { JSONContent } from '@tiptap/core' import type { DiffComment } from '../../../../shared/diff-comment-types' import type { RichMarkdownAnnotationHighlightRange } from './rich-markdown-annotation-highlight' import { @@ -10,7 +9,14 @@ import { import type { RichMarkdownReviewNotePosition } from './rich-markdown-review-note-layout' import { findRichMarkdownSelectedTextRanges } from './rich-markdown-review-text-ranges' import { getRichMarkdownSelectionVisibleText } from './rich-markdown-visible-text-map' -import { countRichMarkdownReviewMarkdownLines } from './rich-markdown-review-line-count' +import { + getRichMarkdownCommentBlocks, + type RichMarkdownCommentBlock +} from './rich-markdown-comment-blocks' +export { + buildRichMarkdownCommentBlocks, + type RichMarkdownCommentBlock +} from './rich-markdown-comment-blocks' export { countRichMarkdownReviewMarkdownLines } from './rich-markdown-review-line-count' const RICH_MARKDOWN_ANNOTATION_BUTTON_SIZE_PX = 24 @@ -22,14 +28,6 @@ const RICH_MARKDOWN_ANNOTATION_POPOVER_WIDTH_PX = 420 const RICH_MARKDOWN_ANNOTATION_POPOVER_RIGHT_OFFSET_PX = 24 const RICH_MARKDOWN_ANNOTATION_POPOVER_MIN_HEIGHT_PX = 220 -export type RichMarkdownCommentBlock = { - key: string - startLine: number - endLine: number - from: number - to: number -} - export type RichMarkdownComposerState = { lineNumber: number startLine?: number @@ -45,54 +43,6 @@ export type RichMarkdownAnnotationTarget = RichMarkdownComposerState & { buttonLeft: number } -function serializeRichMarkdownJson(editor: Editor, content: JSONContent[]): string { - return (editor.markdown?.serialize({ type: 'doc', content }) ?? '').trimEnd() -} - -export function buildRichMarkdownCommentBlocks(editor: Editor): RichMarkdownCommentBlock[] { - const jsonContent = editor.getJSON().content ?? [] - const blocks: RichMarkdownCommentBlock[] = [] - let nextLine = 1 - let previousNodeJson: JSONContent | null = null - let previousNodeLineCount = 0 - - editor.state.doc.forEach((node, nodeOffset, index) => { - const nodeJson = jsonContent[index] - if (!nodeJson) { - return - } - const nodeMarkdown = serializeRichMarkdownJson(editor, [nodeJson]) - const nodeLineCount = countRichMarkdownReviewMarkdownLines(nodeMarkdown) - if (previousNodeJson) { - const pairMarkdown = serializeRichMarkdownJson(editor, [previousNodeJson, nodeJson]) - const separatorLineCount = Math.max( - 0, - countRichMarkdownReviewMarkdownLines(pairMarkdown) - previousNodeLineCount - nodeLineCount - ) - nextLine += separatorLineCount - } - const startLine = nextLine - const endLine = Math.max(startLine, startLine + nodeLineCount - 1) - const from = nodeOffset + 1 - blocks.push({ - key: `${index}:${startLine}-${endLine}`, - startLine, - endLine, - from, - to: from + Math.max(0, node.nodeSize - 1) - }) - nextLine = endLine + 1 - previousNodeJson = nodeJson - previousNodeLineCount = nodeLineCount - }) - - if (blocks.length === 0) { - blocks.push({ key: 'empty:1-1', startLine: 1, endLine: 1, from: 1, to: 1 }) - } - - return blocks -} - export function clampRichMarkdownAnnotationTarget( editor: Editor, target: RichMarkdownAnnotationTarget @@ -122,8 +72,7 @@ export function getRichMarkdownAnnotationHighlightRanges( if (comments.length === 0) { return [] } - // Why once: block resolution re-serializes the doc; per comment it was O(n*doc). - const blocks = buildRichMarkdownCommentBlocks(editor) + const blocks = getRichMarkdownCommentBlocks(editor) return comments.flatMap((comment) => getRichMarkdownAnnotationHighlightRangesForComment( editor, @@ -138,10 +87,9 @@ export function getRichMarkdownAnnotationHighlightRangesForComment( editor: Editor, comment: DiffComment, markdownSourceLineOffset: number, - // Why optional: callers looping over comments pass one shared build. prebuiltBlocks?: readonly RichMarkdownCommentBlock[] ): RichMarkdownAnnotationHighlightRange[] { - const blocks = prebuiltBlocks ?? buildRichMarkdownCommentBlocks(editor) + const blocks = prebuiltBlocks ?? getRichMarkdownCommentBlocks(editor) const selectedText = comment.selectedText?.trim() if (!selectedText) { return [] @@ -173,7 +121,7 @@ export function getRichMarkdownCommentAtPos( if (comments.length === 0) { return null } - const blocks = buildRichMarkdownCommentBlocks(editor) + const blocks = getRichMarkdownCommentBlocks(editor) return ( comments.find((comment) => getRichMarkdownAnnotationHighlightRangesForComment( @@ -216,7 +164,7 @@ export function getRichMarkdownCommentAnchorTop( } function getRichMarkdownSelectionRange(editor: Editor): RichMarkdownComposerState { - const blocks = buildRichMarkdownCommentBlocks(editor) + const blocks = getRichMarkdownCommentBlocks(editor) const { from, to, empty } = editor.state.selection const selectedBlocks = empty ? blocks.filter((block) => block.from <= from && from <= block.to) diff --git a/src/renderer/src/components/editor/rich-markdown-review-note-positioning.ts b/src/renderer/src/components/editor/rich-markdown-review-note-positioning.ts index 4fdc12a51b9..b7cc8bc63e2 100644 --- a/src/renderer/src/components/editor/rich-markdown-review-note-positioning.ts +++ b/src/renderer/src/components/editor/rich-markdown-review-note-positioning.ts @@ -1,7 +1,7 @@ import type { Editor } from '@tiptap/react' import type { DiffComment } from '../../../../shared/diff-comment-types' import { getRichMarkdownCommentAnchorTop } from './rich-markdown-review-annotations' -import { getRichMarkdownReviewRailBlocks } from './rich-markdown-review-rail-blocks' +import { getRichMarkdownCommentBlocks } from './rich-markdown-comment-blocks' import { stackRichMarkdownReviewNotePositions, type RichMarkdownReviewNotePosition @@ -21,7 +21,7 @@ export function measureRichMarkdownReviewNotePositions({ markdownSourceLineOffset }: MeasureRichMarkdownReviewNotePositionsOptions): RichMarkdownReviewNotePosition[] { const containerRect = container.getBoundingClientRect() - const blocks = getRichMarkdownReviewRailBlocks(editor) + const blocks = getRichMarkdownCommentBlocks(editor) const nextPositions = markdownComments .map((comment): RichMarkdownReviewNotePosition | null => { const bodyLineNumber = Math.max(1, comment.lineNumber - markdownSourceLineOffset) diff --git a/src/renderer/src/components/editor/rich-markdown-review-rail-benchmark.test.ts b/src/renderer/src/components/editor/rich-markdown-review-rail-benchmark.test.ts index 7090769f71f..d64fcc667b8 100644 --- a/src/renderer/src/components/editor/rich-markdown-review-rail-benchmark.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-review-rail-benchmark.test.ts @@ -48,7 +48,8 @@ function measureBaseline({ block, containerRect, container.scrollTop, - markdownSourceLineOffset + markdownSourceLineOffset, + buildRichMarkdownCommentBlocks(editor) ) return top === null ? null : { comment, top } }) diff --git a/src/renderer/src/components/editor/rich-markdown-review-rail-blocks.ts b/src/renderer/src/components/editor/rich-markdown-review-rail-blocks.ts index 35dd1bb9eb5..9cb8bf0b64d 100644 --- a/src/renderer/src/components/editor/rich-markdown-review-rail-blocks.ts +++ b/src/renderer/src/components/editor/rich-markdown-review-rail-blocks.ts @@ -1,31 +1 @@ -import type { Editor } from '@tiptap/core' -import { - buildRichMarkdownCommentBlocks, - type RichMarkdownCommentBlock -} from './rich-markdown-review-annotations' - -type ReviewRailBlocks = { - doc: Editor['state']['doc'] - markdown: Editor['markdown'] - serialize: NonNullable['serialize'] | undefined - blocks: readonly RichMarkdownCommentBlock[] -} - -// Keep only the current document per editor; scrolling changes geometry, not source lines. -const blocksByEditor = new WeakMap() - -export function getRichMarkdownReviewRailBlocks( - editor: Editor -): readonly RichMarkdownCommentBlock[] { - const doc = editor.state.doc - const markdown = editor.markdown - const serialize = markdown?.serialize - const cached = blocksByEditor.get(editor) - if (cached?.doc === doc && cached.markdown === markdown && cached.serialize === serialize) { - return cached.blocks - } - - const blocks = buildRichMarkdownCommentBlocks(editor) - blocksByEditor.set(editor, { doc, markdown, serialize, blocks }) - return blocks -} +export { getRichMarkdownCommentBlocks as getRichMarkdownReviewRailBlocks } from './rich-markdown-comment-blocks' diff --git a/tests/e2e/markdown-review-selection-performance.spec.ts b/tests/e2e/markdown-review-selection-performance.spec.ts new file mode 100644 index 00000000000..a9ee0035599 --- /dev/null +++ b/tests/e2e/markdown-review-selection-performance.spec.ts @@ -0,0 +1,209 @@ +import type { Locator, Page } from '@stablyai/playwright-test' +import type { Editor, JSONContent } from '@tiptap/core' +import type { MarkdownManager } from '@tiptap/markdown' +import { expect, test } from './helpers/orca-app' +import { + cleanupMarkdownFixture, + createMarkdownFixture, + getActiveWorktreeContext, + openMarkdownFixture, + waitForRichMarkdownEditor +} from './helpers/markdown-editor-fixture' + +const PARAGRAPH_COUNT = Number(process.env.ORCA_MARKDOWN_REVIEW_PERF_PARAGRAPHS ?? '500') +const targetIndex = Math.floor(PARAGRAPH_COUNT / 2) +const paragraph = (index: number) => + `Paragraph ${index}. Ordinary editing and selection of plain prose.` +const SOURCE = Array.from({ length: PARAGRAPH_COUNT }, (_, index) => paragraph(index)).join('\n\n') + +type ReviewSelectionMetrics = { + calls: number + jsonCalls: number + serializeMs: number + jsonMs: number + frameGaps: number[] +} + +type ReviewSelectionProbe = { + snapshot: () => ReviewSelectionMetrics + reset: () => void + restore: () => void +} + +type PageRichMarkdownReviewEditorElement = HTMLElement & { + editor?: Editor & { markdown?: MarkdownManager } + __reviewSelectionProbe?: ReviewSelectionProbe +} + +async function frames(page: Page) { + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())) + ) + ) +} + +async function measure(editor: Locator, reset = false): Promise { + return editor.evaluate((element, reset) => { + const editorElement = + element.closest('.rich-markdown-editor') + const probe = editorElement?.__reviewSelectionProbe + if (!probe) { + throw new Error('Review selection probe missing') + } + if (reset) { + probe.reset() + } + return probe.snapshot() + }, reset) +} + +test('selection and scrolling reuse Markdown review source lines', async ({ + orcaPage, + registerPostElectronShutdownCleanup +}, testInfo) => { + test.setTimeout(180_000) + expect(Buffer.byteLength(SOURCE)).toBeLessThan(600 * 1024) + const context = await getActiveWorktreeContext(orcaPage) + const filePath = await createMarkdownFixture( + context, + '.orca-e2e-markdown-review-perf', + 'review-selection', + testInfo.workerIndex, + SOURCE + ) + registerPostElectronShutdownCleanup(() => cleanupMarkdownFixture(filePath)) + await openMarkdownFixture(orcaPage, context, filePath) + const editor = await waitForRichMarkdownEditor(orcaPage) + await expect(editor.locator('p')).toHaveCount(PARAGRAPH_COUNT, { timeout: 60_000 }) + await editor.evaluate((element) => { + const editorElement = + element.closest('.rich-markdown-editor') + const instance = editorElement?.editor + if (!editorElement || !instance) { + throw new Error('Editor unavailable') + } + const markdown = instance.markdown + if (!markdown) { + throw new Error('Markdown manager missing') + } + const serialize = markdown.serialize + const getJSON = instance.getJSON + let calls = 0 + let jsonCalls = 0 + let serializeMs = 0 + let jsonMs = 0 + let frameGaps: number[] = [] + let previousFrame: number | null = null + let frameId = 0 + const tick = (now: number) => { + if (previousFrame !== null) { + frameGaps.push(now - previousFrame) + } + previousFrame = now + frameId = requestAnimationFrame(tick) + } + frameId = requestAnimationFrame(tick) + markdown.serialize = (content: JSONContent): string => { + const start = performance.now() + try { + calls++ + return serialize.call(markdown, content) + } finally { + serializeMs += performance.now() - start + } + } + instance.getJSON = (): ReturnType => { + const start = performance.now() + try { + jsonCalls++ + return getJSON.call(instance) + } finally { + jsonMs += performance.now() - start + } + } + editorElement.__reviewSelectionProbe = { + snapshot: () => ({ calls, jsonCalls, serializeMs, jsonMs, frameGaps }), + reset: () => { + calls = 0 + jsonCalls = 0 + serializeMs = 0 + jsonMs = 0 + frameGaps = [] + previousFrame = null + }, + restore: () => { + cancelAnimationFrame(frameId) + markdown.serialize = serialize + instance.getJSON = getJSON + } + } + }) + try { + const target = editor.getByText(paragraph(targetIndex), { exact: true }) + await target.evaluate((element) => element.scrollIntoView({ block: 'center' })) + const point = await target.evaluate((element) => { + const text = element.firstChild + if (!(text instanceof Text)) { + throw new Error('Paragraph text missing') + } + const range = document.createRange() + range.setStart(text, 0) + range.collapse(true) + const rect = range.getBoundingClientRect() + return { x: rect.left, y: rect.top + rect.height / 2 } + }) + await orcaPage.mouse.click(point.x, point.y) + await orcaPage.keyboard.press('Shift+ArrowRight') + await expect( + orcaPage.getByRole('button', { name: 'Add review note', exact: true }) + ).toBeVisible() + await frames(orcaPage) + const cold = await measure(editor) + await measure(editor, true) + for (let index = 0; index < 20; index++) { + await orcaPage.keyboard.press('Shift+ArrowRight') + await frames(orcaPage) + } + const selection = await measure(editor) + expect(await orcaPage.evaluate(() => window.getSelection()?.toString())).toBe( + paragraph(targetIndex).slice(0, 21) + ) + const viewport = orcaPage.locator('.rich-markdown-editor-shell .overflow-auto') + await measure(editor, true) + for (let index = 0; index < 10; index++) { + await viewport.evaluate((element, index) => { + element.scrollTop += index % 2 ? -8 : 8 + }, index) + await frames(orcaPage) + } + const scroll = await measure(editor) + const result = { + paragraphs: PARAGRAPH_COUNT, + sourceBytes: Buffer.byteLength(SOURCE), + cold, + selection, + scroll + } + await testInfo.attach('review-selection-metrics', { + body: JSON.stringify(result, null, 2), + contentType: 'application/json' + }) + process.stdout.write(`${JSON.stringify(result)}\n`) + await expect(editor).toBeFocused() + const tab = orcaPage.locator('[data-tab-id]').filter({ hasText: 'review-selection' }).last() + await expect(tab.locator('span.rounded-full')).toHaveCount(0) + await orcaPage.screenshot({ path: testInfo.outputPath('review-selection.png') }) + expect(selection.calls).toBe(0) + expect(selection.jsonCalls).toBe(0) + expect(scroll.calls).toBe(0) + expect(scroll.jsonCalls).toBe(0) + } finally { + await editor.evaluate((element) => { + const editorElement = + element.closest('.rich-markdown-editor') + editorElement?.__reviewSelectionProbe?.restore() + }) + } +}) From ddefd523e052cc83897937569d322a727f55dd78 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 4 Oct 2026 13:20:01 -0700 Subject: [PATCH 22/31] Keep selected text navigation from rewriting document links (#25175) * Keep selected text navigation from rewriting document links * Check model selection before document link arrow coverage --- .../rich-markdown-doc-link-keyboard.test.ts | 99 +++++++++++++++++++ .../editor/rich-markdown-doc-link.ts | 2 +- tests/e2e/markdown-doc-link-selection.spec.ts | 63 ++++++++++++ 3 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 src/renderer/src/components/editor/rich-markdown-doc-link-keyboard.test.ts create mode 100644 tests/e2e/markdown-doc-link-selection.spec.ts diff --git a/src/renderer/src/components/editor/rich-markdown-doc-link-keyboard.test.ts b/src/renderer/src/components/editor/rich-markdown-doc-link-keyboard.test.ts new file mode 100644 index 00000000000..add8be94d44 --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-doc-link-keyboard.test.ts @@ -0,0 +1,99 @@ +// @vitest-environment happy-dom + +import { Editor } from '@tiptap/react' +import StarterKit from '@tiptap/starter-kit' +import { NodeSelection } from '@tiptap/pm/state' +import { afterEach, describe, expect, it } from 'vitest' +import { createMarkdownDocLink } from './rich-markdown-doc-link' +import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport' + +const editors: Editor[] = [] + +function docLinkEditor(): Editor { + const editor = new Editor({ + extensions: [StarterKit, createMarkdownDocLink(createRichMarkdownEditorCodec().transport)], + content: { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { type: 'text', text: 'before' }, + { type: 'markdownDocLink', attrs: { target: 'Guide' } }, + { type: 'text', text: 'after' } + ] + } + ] + } + }) + editors.push(editor) + return editor +} + +function handleKey(editor: Editor, key: string, modifiers: KeyboardEventInit = {}): boolean { + const event = new KeyboardEvent('keydown', { key, ...modifiers }) + let handled = false + editor.view.someProp('handleKeyDown', (handler) => { + if (!handler(editor.view, event)) { + return false + } + handled = true + return true + }) + return handled +} + +afterEach(() => editors.splice(0).forEach((editor) => editor.destroy())) + +describe('document link arrow navigation', () => { + it.each([ + { key: 'ArrowLeft', from: 8, to: 13 }, + { key: 'ArrowLeft', from: 13, to: 8 }, + { key: 'ArrowRight', from: 7, to: 13 }, + { key: 'ArrowRight', from: 13, to: 7 } + ])('leaves $key selection $from..$to for native collapse', ({ key, from, to }) => { + const editor = docLinkEditor() + editor.commands.setTextSelection({ from, to }) + const before = editor.state.doc + const selection = editor.state.selection + expect(handleKey(editor, key)).toBe(false) + expect(editor.state.doc.eq(before)).toBe(true) + expect(editor.state.selection.eq(selection)).toBe(true) + }) + + it.each([ + { key: 'ArrowLeft', position: 8, expectedCaret: 14 }, + { key: 'ArrowRight', position: 7, expectedCaret: 9 } + ])( + 'opens an adjacent link for editing with $key at an empty caret', + ({ key, position, expectedCaret }) => { + const editor = docLinkEditor() + editor.commands.setTextSelection(position) + expect(handleKey(editor, key)).toBe(true) + expect(editor.state.doc.textContent).toBe('before[[Guide]]after') + expect(editor.state.selection.from).toBe(expectedCaret) + expect(editor.state.selection.empty).toBe(true) + } + ) + + it.each([{ shiftKey: true }, { altKey: true }, { metaKey: true }, { ctrlKey: true }])( + 'preserves modified arrow handling: %j', + (modifiers) => { + const editor = docLinkEditor() + editor.commands.setTextSelection(8) + const before = editor.state.doc + expect(handleKey(editor, 'ArrowLeft', modifiers)).toBe(false) + expect(editor.state.doc.eq(before)).toBe(true) + } + ) + + it('preserves node selection and keys away from a link', () => { + const editor = docLinkEditor() + editor.view.dispatch(editor.state.tr.setSelection(NodeSelection.create(editor.state.doc, 7))) + const before = editor.state.doc + expect(handleKey(editor, 'ArrowLeft')).toBe(false) + editor.commands.setTextSelection(1) + expect(handleKey(editor, 'ArrowRight')).toBe(false) + expect(editor.state.doc.eq(before)).toBe(true) + }) +}) diff --git a/src/renderer/src/components/editor/rich-markdown-doc-link.ts b/src/renderer/src/components/editor/rich-markdown-doc-link.ts index 1ae68d44bfe..4b26e88ac91 100644 --- a/src/renderer/src/components/editor/rich-markdown-doc-link.ts +++ b/src/renderer/src/components/editor/rich-markdown-doc-link.ts @@ -244,7 +244,7 @@ export function createMarkdownDocLink(transport: RichMarkdownSourceTransport) { return false } const { state } = view - if (!(state.selection instanceof TextSelection)) { + if (!(state.selection instanceof TextSelection) || !state.selection.empty) { return false } const { $from } = state.selection diff --git a/tests/e2e/markdown-doc-link-selection.spec.ts b/tests/e2e/markdown-doc-link-selection.spec.ts new file mode 100644 index 00000000000..2a79abe45e8 --- /dev/null +++ b/tests/e2e/markdown-doc-link-selection.spec.ts @@ -0,0 +1,63 @@ +import { test, expect } from './helpers/orca-app' +import type { Editor } from '@tiptap/core' +import { + cleanupMarkdownFixture, + createMarkdownFixture, + getActiveWorktreeContext, + openMarkdownFixture, + waitForRichMarkdownEditor +} from './helpers/markdown-editor-fixture' +import { waitForSessionReady, waitForActiveWorktree } from './helpers/store' + +type PageRichMarkdownLinkEditorElement = HTMLElement & { + editor?: Editor +} + +test('collapses a selection beside a document link without rewriting the link', async ({ + orcaPage, + registerPostElectronShutdownCleanup +}, testInfo) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + const context = await getActiveWorktreeContext(orcaPage) + const filePath = await createMarkdownFixture( + context, + '.orca-e2e-markdown-links', + 'selection', + testInfo.workerIndex, + '[[Guide]]after\n\nSelecting text beside a document link should keep the link intact.' + ) + registerPostElectronShutdownCleanup(() => cleanupMarkdownFixture(filePath)) + await openMarkdownFixture(orcaPage, context, filePath) + const editor = await waitForRichMarkdownEditor(orcaPage) + await expect(editor.locator('[data-doc-link-target="Guide"]')).toHaveCount(1) + await orcaPage.evaluate(() => { + const editorElement = + document.querySelector('.rich-markdown-editor') + const instance = editorElement?.editor + if (!editorElement || !instance) { + throw new Error('Editor unavailable') + } + editorElement.focus() + if (!instance.commands.setTextSelection({ from: 2, to: 7 })) { + throw new Error('Selection unavailable') + } + }) + await expect.poll(() => orcaPage.evaluate(() => window.getSelection()?.toString())).toBe('after') + await expect + .poll(() => + orcaPage.evaluate(() => { + const selection = + document.querySelector('.rich-markdown-editor')?.editor + ?.state.selection + return selection ? { from: selection.from, to: selection.to, empty: selection.empty } : null + }) + ) + .toEqual({ from: 2, to: 7, empty: false }) + await expect(editor).toBeFocused() + await orcaPage.keyboard.press('ArrowLeft') + await expect(editor.locator('[data-doc-link-target="Guide"]')).toHaveCount(1) + await expect(editor.locator('p').first()).toHaveText('Guideafter') + await expect.poll(() => orcaPage.evaluate(() => window.getSelection()?.toString())).toBe('') + await orcaPage.screenshot({ path: testInfo.outputPath('link-after-collapse.png') }) +}) From b99d28e32fd10b77352a7e0bba10b54bbd2e0f86 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:01:29 -0700 Subject: [PATCH 23/31] A resent chat message gets its recorded answer, never an early refusal or a made-up record (#25158) * fix(native-chat): a resent send id gets its recorded answer, never an early refusal or a made-up record The host now looks a resent send id up before preparing the session. A row that settled refused answers with its refusal before the chat is opened. A resend whose chat cannot be opened or made ready answers unknown instead of a refusal. A /clear in flight refuses only ids the ledger does not hold. A send row now records the journal epoch it was admitted into. An unsettled row with nothing written in that same epoch runs for the first time; under a later epoch the host answers unknown instead of reconstructing a submission it never had. The host advertises agent-session.send-answers-proof.v1. * test(native-chat): pass the ledger row to the thread-goal rerun check * fix(native-chat): a send's answer commits with its write, and a resend is answered before any write A send (and /compact) settles its ledger row `succeeded` in the same SQLite transaction as the submission or queued draft that accepts it, so a row still `pending` proves nothing was written and a resend runs it for the first time. The unknown-before-run mark and the per-row journal epoch go. A resent id is answered from its row and the journal before preparation starts an agent and before any write transaction: a recorded refusal with nothing opened; otherwise the conversation is opened (no agent start for a send) and replayed, and a conversation that will not open answers unknown. * fix(native-chat): a ledger refusal is answered first, and a /clear refuses only a send's first run An id the ledger refuses (expired, conflict, invalid, capacity) is answered as admission would, with no journal read, preparation or write, so a closed chat or a read-only store answers it too. A re-read after the replay open that comes back refused returns that refusal. Whether a /clear is in flight is read when a send arrives and applied in the send's preparation for a first run only: an id the ledger holds by the send's turn, including one whose earlier attempt was queued ahead of the clear, is answered from its record. MutationPlan makes settlesWithWrite and settledOutcome exclusive; the capability text no longer promises a refused id never sends. * docs(native-chat): say what a replay's preparation does for every plan --- .../journal-queued-messages.ts | 35 ++- .../journal-row-writer.ts | 23 +- .../agent-session-journal/journal-store.ts | 9 +- ...structured-agent-session-host-mutations.ts | 5 +- ...ctured-agent-session-mutation-admission.ts | 138 ++++++++-- ...structured-agent-session-mutation-plans.ts | 28 +- ...agent-session-operation-settlement.test.ts | 61 ++--- ...ured-agent-session-operation-settlement.ts | 75 ++++-- ...tured-agent-session-queued-command.test.ts | 4 +- ...tructured-agent-session-queued-messages.ts | 22 +- ...structured-agent-session-replay-outcome.ts | 19 +- ...ctured-agent-session-resend-answer.test.ts | 241 ++++++++++++++++++ ...gent-session-resend-ledger-refusal.test.ts | 157 ++++++++++++ ...ent-session-restart-failure-filing.test.ts | 17 +- ...ssion-restart-interruption-test-harness.ts | 16 ++ ...ed-agent-session-restart-ownership.test.ts | 55 ++-- ...red-agent-session-send-preparation.test.ts | 45 +++- ...ructured-agent-session-send-preparation.ts | 22 +- .../structured-agent-session-send.test.ts | 115 ++++----- .../structured-agent-session-turns.ts | 9 +- ...uctured-conversation-command-controller.ts | 8 +- .../runtime/agent-session-record-store.ts | 15 +- .../agent-session-store-transactions.test.ts | 51 ++++ .../agent-session-store-transactions.ts | 26 ++ src/shared/agent-session-mutation-envelope.ts | 21 +- src/shared/protocol-version.ts | 15 ++ 26 files changed, 957 insertions(+), 275 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-resend-answer.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-resend-ledger-refusal.test.ts diff --git a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts index a66d43697f6..0828740ca6f 100644 --- a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts +++ b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts @@ -13,7 +13,7 @@ import { import type { JournalHostDatabase } from './journal-host-database' import type { JournalReducerState } from './journal-reducer' import type { JournalRow } from './journal-row-schema' -import type { JournalRowTransactionHook } from './journal-row-writer' +import type { JournalOperationReceipt, JournalRowTransactionHook } from './journal-row-writer' import type { JournalSubmissionConsume } from './journal-store-contracts' import { adoptQueuedMessages, holdQueuedMessages } from './queued-message-holds' import { @@ -101,14 +101,18 @@ export class JournalQueuedMessages { return queuedMessagesSettledByOp(this.deps.database().db, this.deps.sessionId, settledByOp) } - /** `carriedFrom`: a /clear's carry. The card is its own 'cleared' pause, so it lands paused. */ - insert(input: { - messageId: string - body: AgentJournalMessageItem - fingerprint: string - hostInstance: string - carriedFrom?: string - }): Promise { + /** `carriedFrom`: a /clear's carry. The card is its own 'cleared' pause, so it lands paused. + * `receipt`: the send's ledger answer, committed with the draft only when this inserts it. */ + insert( + input: { + messageId: string + body: AgentJournalMessageItem + fingerprint: string + hostInstance: string + carriedFrom?: string + }, + receipt?: JournalOperationReceipt + ): Promise { const { sessionId } = this.deps let inserted = false return this.transact( @@ -121,14 +125,17 @@ export class JournalQueuedMessages { } inserted = true const { epoch, lastSequence } = this.deps.state() - return insertQueuedMessage(db, { + const row = insertQueuedMessage(db, { ...input, sessionId, queuedAt: { epoch, sequence: lastSequence }, now: this.deps.now() }) + receipt?.write(db) + return row }, - () => inserted + () => inserted, + receipt?.committed ) } @@ -206,15 +213,17 @@ export class JournalQueuedMessages { } /** One standalone draft-table transaction on the journal's queue; one that - * changed rows bumps the revision and notifies after COMMIT. */ + * changed rows bumps the revision and notifies after COMMIT, `adopted` first. */ private transact( run: (db: Database.Database) => JournalWriteResult, - changed: (result: T) => boolean + changed: (result: T) => boolean, + adopted?: () => void ): Promise { return this.deps.serialize(() => { assertJournalWritable(this.deps.readOnly(), this.deps.sessionId) const result = this.deps.database().transaction(run) if (changed(result)) { + adopted?.() this.changeRevision++ this.deps.committed() } diff --git a/src/main/native-chat/agent-session-journal/journal-row-writer.ts b/src/main/native-chat/agent-session-journal/journal-row-writer.ts index b07fda9459b..ca274120a7b 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-writer.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-writer.ts @@ -11,6 +11,14 @@ import type { JournalWriteBody } from './journal-write-queue' * nothing can interleave inside the transaction. */ export type JournalRowTransactionHook = (db: Database.Database, row: JournalRow) => void +/** An operation's ledger answer, committed with the journal write that makes it true: `write` runs + * inside that transaction on the same connection, `committed` synchronously right after its + * COMMIT and never after a rollback. */ +export type JournalOperationReceipt = { + write: (db: Database.Database) => void + committed: () => void +} + export type JournalRowWriterDeps = { sessionId: string now: () => number @@ -35,7 +43,8 @@ export class JournalRowWriter { enqueue( build: (seq: number, ts: number) => JournalRow, - hook?: JournalRowTransactionHook + hook?: JournalRowTransactionHook, + receipt?: JournalOperationReceipt ): Promise { return this.deps.serialize(() => { assertJournalWritable(this.deps.readOnly(), this.deps.sessionId) @@ -46,6 +55,7 @@ export class JournalRowWriter { this.deps.database().transaction((db) => { insertJournalRow(db, this.deps.sessionId, row) hook?.(db, row) + receipt?.write(db) this.runBookkeeping(db, row) }) } catch (error) { @@ -54,7 +64,8 @@ export class JournalRowWriter { } // COMMIT landed, so the row is durable: adopt it before anything that can // fail. Rejecting here instead would leave the next append reusing a - // sequence the table already holds. + // sequence the table already holds. The ledger first: it cannot throw, the fold can. + receipt?.committed() this.deps.commit(row) return row }) @@ -64,9 +75,13 @@ export class JournalRowWriter { * replay uses — all inside one serialized step — answering where the row landed. */ append( build: (seq: number, ts: number) => JournalRow, - hook?: JournalRowTransactionHook + hook?: JournalRowTransactionHook, + receipt?: JournalOperationReceipt ): Promise { - return this.enqueue(build, hook).then((row) => ({ epoch: row.epoch, sequence: row.seq })) + return this.enqueue(build, hook, receipt).then((row) => ({ + epoch: row.epoch, + sequence: row.seq + })) } private runBookkeeping(db: Database.Database, row: JournalRow): void { diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts index 5a4880155de..2349524ccfe 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -63,7 +63,7 @@ import { journalStopEventRowBuilder } from './journal-stop-and-resume-rows' import type { AgentJournalEpochReason, JournalStopEvent } from './journal-row-schema' -import type { JournalRowWriter } from './journal-row-writer' +import type { JournalOperationReceipt, JournalRowWriter } from './journal-row-writer' import type { JournalEpochController } from './journal-epoch-controller' import { JournalWriteQueue } from './journal-write-queue' import { createJournalStoreCollaborators } from './journal-store-collaborators' @@ -340,11 +340,14 @@ export class AgentSessionJournal { input: JournalSubmissionInput, /** Present: this submission is a queued draft's conversion, and the draft's * state transition commits in the SAME transaction — exactly-once consume. */ - consume?: JournalSubmissionConsume + consume?: JournalSubmissionConsume, + /** The send's ledger answer, committed with this row. */ + receipt?: JournalOperationReceipt ): Promise { return this.rowWriter.append( journalSubmissionRowBuilder(() => this.state, this.identity.providerHandle, input, consume), - consume && queuedMessageConsumeHook(this.queuedMessages, input.clientMessageId, consume) + consume && queuedMessageConsumeHook(this.queuedMessages, input.clientMessageId, consume), + receipt ) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index 3fbb96ddaf8..1133342270d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts @@ -61,7 +61,8 @@ export function sendStructuredAgentSessionTurn( * prompt never set it. */ userSend?: true beforeRun?: () => void - } + }, + arrival?: Parameters[2] ): Promise> { const plan = sendPlan(params) return mutateStructuredAgentSession( @@ -80,7 +81,7 @@ export function sendStructuredAgentSessionTurn( (await plan.run(ctx)) ) }, - sendPreparation(context, params.envelope) + sendPreparation(context, params.envelope, arrival) ) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts index 5bb072f2594..3ed04e1f2fe 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts @@ -4,16 +4,24 @@ // own admission rules by sitting next to the call site. // // Admission is two-phase for a call that brings a `prepareSession`. The ledger's -// answer comes first and places nothing; a call it will admit may then give the -// session an owner, and only after that are the row placed and the lease -// checked — against the lease as it stands once the owner is there. +// answer comes first and places nothing. An id it refuses is answered then, with +// nothing opened; so is a recorded id that settled refused. Any other recorded id +// is answered after the plan's own preparation for a replay (a send's only opens +// the conversation), from the journal, with no admit write. A call the ledger +// admits, or a replay that proves nothing landed, may then give the session an +// owner, and only after that are the row placed and the lease checked — against +// the lease as it stands once the owner is there. import { admitAgentSessionMutation, agentSessionFingerprintConflict, + agentSessionLedgerRefusal, computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import type { AgentSessionOperationDecision } from '../../../shared/agent-session-operation-ledger' +import type { + AgentSessionOperationDecision, + AgentSessionOperationRow +} from '../../../shared/agent-session-operation-ledger' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { refuse, @@ -36,7 +44,10 @@ import type { AgentSessionJournal } from '../agent-session-journal/journal-store import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import type { MutationPlan } from './structured-agent-session-mutation-plans' import { runSettledAgentSessionMutation } from './structured-agent-session-operation-settlement' -import { resolveAgentSessionReplayOutcome } from './structured-agent-session-replay-outcome' +import { + agentSessionOperationOutcomeUnknown, + resolveAgentSessionReplayOutcome +} from './structured-agent-session-replay-outcome' import type { AgentSessionTurnContext } from './structured-agent-session-turns' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' @@ -103,12 +114,27 @@ export async function admitAndRunAgentSessionMutation( if (!ledger) { return refuseAgentSessionMutation(AGENT_SESSION_NOT_ATTACHED) } - if (ledger.decision.decision !== 'refused') { - const prepared = await request.prepareSession(ledger.decision.decision, ledger.record) - if (!prepared.ok) { - return prepared + if (ledger.decision.decision === 'refused') { + // Nothing to read, prepare or write: a closed chat or a store that takes no write answers alike. + return refuseAgentSessionMutation(agentSessionLedgerRefusal(envelope, ledger.decision)) + } + if (ledger.decision.decision === 'replay') { + const answered = await answerRecordedOperation( + request, + request.prepareSession, + ledger.decision.row, + ledger.record, + hostFingerprint + ) + if (answered !== 'rerun') { + return answered } } + const record = request.store.getRecord(envelope.sessionId) ?? ledger.record + const prepared = await request.prepareSession('admit', record) + if (!prepared.ok) { + return prepared + } } const journal = request.journal() if (!journal) { @@ -151,18 +177,9 @@ export async function admitAndRunAgentSessionMutation( const fence = record.lease.runtimeFence const context = turnContext(request, journal, fence) if (admission.decision === 'replay') { - const replay = resolveAgentSessionReplayOutcome({ - operationId: envelope.clientOperationId, - outcome: admission.row.outcome, - reconstruct: () => plan.replay(context, admission.row.outcome), - rerunWhenReplayMissing: plan.rerunWhenReplayMissing?.(context), - recoverUnknownFromDurableState: plan.recoverUnknownFromDurableState - }) - if (replay.decision === 'refuse') { - return refuseAgentSessionMutation(replay.refusal) - } - if (replay.decision === 'replay') { - return { ok: true, replayed: true, fence, cursor: journal.cursor(), value: replay.value } + const replayed = replayRecordedOperation(request, context, admission.row) + if (replayed !== 'rerun') { + return replayed } // Nothing durable landed, so this id is about to run for the first time. A // refused call leaves its ledger row behind, and replaying past the lease @@ -197,6 +214,85 @@ export async function admitAndRunAgentSessionMutation( : refuseAgentSessionMutation(outcome.refusal) } +/** + * A resend of a recorded id, answered with no admit write: a refusal its first run recorded, with + * nothing opened; otherwise, after the plan's own preparation for a replay, from the conversation + * its run wrote to. `rerun` when nothing durable landed, so the call runs as a first run. + */ +async function answerRecordedOperation( + request: AgentSessionMutationRequest, + prepareSession: NonNullable['prepareSession']>, + row: AgentSessionOperationRow, + record: AgentSessionRecord, + hostFingerprint: string +): Promise | 'rerun'> { + const { plan, envelope } = request + if (row.outcome.status === 'failed') { + const replay = resolveAgentSessionReplayOutcome({ + operationId: envelope.clientOperationId, + outcome: row.outcome, + reconstruct: () => null + }) + if (replay.decision === 'refuse') { + return refuseAgentSessionMutation(replay.refusal) + } + } + const prepared = await prepareSession('replay', record) + if (!prepared.ok) { + return prepared + } + const journal = request.journal() + // Read again after the open: the row as it stands, against the record the open left. + const current = request.store.evaluateMutationOperation({ + callerKey: request.callerKey, + envelope, + hostFingerprint, + now: request.now(), + ...(plan.operationIdScope ? { operationIdScope: plan.operationIdScope } : {}) + }) + if (!journal || !current) { + return refuseAgentSessionMutation( + agentSessionOperationOutcomeUnknown(envelope.clientOperationId) + ) + } + if (current.decision.decision === 'refused') { + return refuseAgentSessionMutation(agentSessionLedgerRefusal(envelope, current.decision)) + } + if (current.decision.decision === 'admit') { + // The row is gone since: the first-run path decides it from scratch. + return 'rerun' + } + const context = turnContext(request, journal, current.record.lease.runtimeFence) + return replayRecordedOperation(request, context, current.decision.row) +} + +/** The recorded answer from the journal, or `rerun` when the plan says nothing durable landed. */ +function replayRecordedOperation( + { plan, envelope }: AgentSessionMutationRequest, + context: AgentSessionTurnContext, + row: AgentSessionOperationRow +): AgentSessionMutationResult | 'rerun' { + const replay = resolveAgentSessionReplayOutcome({ + operationId: envelope.clientOperationId, + outcome: row.outcome, + reconstruct: () => plan.replay(context, row.outcome), + rerunWhenReplayMissing: plan.rerunWhenReplayMissing?.(context), + recoverUnknownFromDurableState: plan.recoverUnknownFromDurableState + }) + if (replay.decision === 'refuse') { + return refuseAgentSessionMutation(replay.refusal) + } + return replay.decision === 'replay' + ? { + ok: true, + replayed: true, + fence: context.fence, + cursor: context.journal.cursor(), + value: replay.value + } + : 'rerun' +} + /** The committed ledger's admission, placing nothing: a failed commit left memory as it was. */ function admitWithoutLedgerRow( { store, logger }: Pick, 'store' | 'logger'>, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts index a81ea5270c8..6a2a6268ae9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts @@ -3,7 +3,9 @@ // // The replay half matters more than it looks. The ledger records only that an // operation happened, so the durable answer usually comes back out of the -// journal. Send is fail-closed: admission alone cannot prove non-delivery. +// journal. Send is fail-closed: admission alone cannot prove non-delivery, so +// its success commits with the row that accepts it, and a row still pending is +// one that wrote nothing. import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' @@ -51,13 +53,23 @@ export type MutationPlan = { conversationWrite?: true /** Still runs, decided from the committed ledger, when its ledger row cannot be written. */ runsWithoutLedgerRow?: true - markUnknownBeforeRun?: boolean run: (ctx: AgentSessionTurnContext) => Promise> replay: (ctx: AgentSessionTurnContext, outcome: AgentSessionOperationOutcome) => TValue | null rerunWhenReplayMissing?: (ctx: AgentSessionTurnContext) => boolean recoverUnknownFromDurableState?: boolean - settledOutcome?: (value: TValue) => AgentSessionOperationOutcome -} +} & ( + | { + /** Its success is the row its run writes, so it commits in that row's transaction + * (`AgentSessionTurnContext.operationReceipt`): a row left pending wrote nothing. That + * success is fixed before the value exists, so it records no `settledOutcome`. */ + settlesWithWrite: true + settledOutcome?: never + } + | { + settlesWithWrite?: never + settledOutcome?: (value: TValue) => AgentSessionOperationOutcome + } +) export function sendPlan(params: { envelope: AgentSessionMutationEnvelope @@ -74,7 +86,7 @@ export function sendPlan(params: { method: 'agentSession.send', operationIdScope: 'global', conversationWrite: true, - markUnknownBeforeRun: true, + settlesWithWrite: true, // `delivery` joins the OPERATION fingerprint only; the submission row keeps // the body-only fingerprint the reducer's echo-aliasing recomputes. fields: { body: params.body, ...(params.delivery ? { delivery: params.delivery } : {}) }, @@ -104,7 +116,9 @@ export function sendPlan(params: { if (submission) { return { clientMessageId, submission } } - if (outcome.status === 'failed') { + // A pending row wrote nothing, so the send runs for the first time. Succeeded: accepted, + // then a new epoch dropped its row. Unknown: only builds before this one wrote that. + if (outcome.status === 'failed' || outcome.status === 'pending') { return null } const resolvedAt = ctx.now() @@ -141,7 +155,7 @@ export function conversationCommandPlan(params: { return { method: 'agentSession.conversationCommand', conversationWrite: true, - markUnknownBeforeRun: true, + settlesWithWrite: true, fields: { command: STRUCTURED_AGENT_SESSION_COMPACT_COMMAND }, recoverUnknownFromDurableState: true, run: async (ctx) => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts index 203e31f4ea0..c7901edc1c1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts @@ -42,27 +42,18 @@ async function context(): Promise { } } -/** Longer than any bookkeeping bound: a refusal must already be answered by then. */ -const SETTLED_WAIT_MS = 2_000 - afterEach(() => { vi.useRealTimers() vi.restoreAllMocks() }) -it('returns a pre-dispatch refusal without waiting on redundant uncertainty persistence', async () => { +it('rethrows a throw from a plan answered by its write, writing nothing and leaving the row pending', async () => { const ctx = await context() const { store } = hostTestState() - const stalled = Promise.withResolvers() - const refusing = Promise.withResolvers() - const writes = vi - .spyOn(store, 'recordOperationOutcome') - .mockResolvedValueOnce() - .mockImplementation(() => stalled.promise) + const writes = vi.spyOn(store, 'recordOperationOutcome') const refusal = new AgentSessionPreDispatchError('agent_session_restart_work_superseded') - let returned = false vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - const result = runSettledAgentSessionMutation({ + const result = await runSettledAgentSessionMutation({ store, operationCallerKey: 'test', envelope: envelope('agentSession.send', {}), @@ -70,42 +61,30 @@ it('returns a pre-dispatch refusal without waiting on redundant uncertainty pers plan: { method: 'agentSession.send', fields: {}, - markUnknownBeforeRun: true, + settlesWithWrite: true, run: async () => { - refusing.resolve() throw refusal }, replay: () => null } - }).catch((error: unknown) => { - returned = true - return error - }) - try { - await refusing.promise - await vi.advanceTimersByTimeAsync(SETTLED_WAIT_MS) - expect(returned).toBe(true) - expect(writes).toHaveBeenCalledOnce() - expect(hostTestState().dispatch).not.toHaveBeenCalled() - expect(vi.getTimerCount()).toBe(0) - } finally { - stalled.resolve() - expect(await result).toBe(refusal) - } + }).catch((error: unknown) => error) + expect(result).toBe(refusal) + expect(writes).not.toHaveBeenCalled() + expect(hostTestState().dispatch).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) }) -it.each([1, 2])( - 'preserves a proven refusal through %s failed bookkeeping writes', - async (failures) => { +it.each([ + { plan: 'answered by its write', settlesWithWrite: true as const, failures: 1 }, + { plan: 'settled after its run', settlesWithWrite: undefined, failures: 2 } +])( + 'preserves a proven refusal of a plan $plan through $failures failed bookkeeping writes', + async ({ settlesWithWrite, failures }) => { const ctx = await context() const { store, dispatch } = hostTestState() const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}) - let writes = 0 - vi.spyOn(store, 'recordOperationOutcome').mockImplementation(async () => { - writes += 1 - if (writes > 1 && writes <= failures + 1) { - throw new Error('private unbounded disk detail') - } + const writes = vi.spyOn(store, 'recordOperationOutcome').mockImplementation(async () => { + throw new Error('private unbounded disk detail') }) const refusal = { ok: false as const, @@ -120,12 +99,13 @@ it.each([1, 2])( plan: { method: 'agentSession.send', fields: {}, - markUnknownBeforeRun: true, + ...(settlesWithWrite ? { settlesWithWrite } : {}), run, replay: () => null } }) expect(result).toEqual(refusal) + expect(writes).toHaveBeenCalledTimes(failures) expect(run).toHaveBeenCalledOnce() expect(dispatch).not.toHaveBeenCalled() expect(JSON.stringify(warning.mock.calls)).not.toContain('private unbounded disk detail') @@ -160,7 +140,7 @@ it('accepts without touching the provider', async () => { it('refuses a superseded send at acceptance, recording and dispatching nothing', async () => { const ctx = await context() const { store } = hostTestState() - vi.spyOn(store, 'recordOperationOutcome').mockResolvedValue() + const writes = vi.spyOn(store, 'recordOperationOutcome').mockResolvedValue() const beforeRun = vi.fn(() => { throw new AgentSessionPreDispatchError('agent_session_restart_work_superseded') }) @@ -175,6 +155,7 @@ it('refuses a superseded send at acceptance, recording and dispatching nothing', plan: sendPlan({ envelope: operation, body, beforeRun }) }).catch((error: unknown) => error) expect(result).toBeInstanceOf(AgentSessionPreDispatchError) + expect(writes).not.toHaveBeenCalled() expect(ctx.journal.submissions()).toEqual([]) expect(hostTestState().dispatch).not.toHaveBeenCalled() expect(vi.getTimerCount()).toBe(0) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts index 24de6ff708b..175afeaec35 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts @@ -1,4 +1,5 @@ import type { AgentSessionMutationEnvelope } from '../../../shared/agent-session-wire' +import type { JournalOperationReceipt } from '../agent-session-journal/journal-row-writer' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { MutationPlan } from './structured-agent-session-mutation-plans' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' @@ -18,20 +19,35 @@ export async function runSettledAgentSessionMutation(input: { plan: MutationPlan context: AgentSessionTurnContext }): Promise> { + const operation = { + callerKey: input.operationCallerKey, + operationId: input.envelope.clientOperationId + } const settle = ( outcome: Parameters[0]['outcome'] - ) => - input.store.recordOperationOutcome({ - callerKey: input.operationCallerKey, - operationId: input.envelope.clientOperationId, - outcome - }) + ) => input.store.recordOperationOutcome({ ...operation, outcome }) + const receipt = input.plan.settlesWithWrite + ? observedReceipt( + input.store.operationOutcomeReceipt({ + ...operation, + outcome: { status: 'succeeded', sessionId: input.envelope.sessionId } + }) + ) + : undefined + const context = receipt ? { ...input.context, operationReceipt: receipt } : input.context let outcome: TurnOutcome | undefined try { - if (input.plan.markUnknownBeforeRun) { - await settle({ status: 'unknown' }) + const ran = await input.plan.run(context) + if (receipt?.wasCommitted() && !ran.ok) { + // The row says accepted, so a refusal past it (a fold that failed after COMMIT) is a fault. + throw new Error( + `operation ${operation.operationId} was accepted, then refused: ${ran.refusal.code}` + ) } - outcome = await input.plan.run(input.context) + if (receipt?.wasCommitted()) { + return ran + } + outcome = ran await settle( outcome.ok ? (input.plan.settledOutcome?.(outcome.value) ?? { @@ -48,29 +64,42 @@ export async function runSettledAgentSessionMutation(input: { ) return outcome } catch (error) { - // The pre-run uncertainty is already durable; refusing before dispatch adds no new uncertainty. - if (input.plan.markUnknownBeforeRun && error instanceof AgentSessionPreDispatchError) { - throw error - } const { logger, sessionId } = input.context - try { - await settle({ status: 'unknown' }) - } catch { - // Bookkeeping must not replace the operation's proof of whether dispatch began. - logger.warn('recording an operation as unknown failed', { - scope: 'operation-unknown-settlement', - sessionId, - operationId: input.envelope.clientOperationId - }) + // Its success commits with its write, so a throw leaves the row pending: nothing was written. + if (!input.plan.settlesWithWrite) { + try { + await settle({ status: 'unknown' }) + } catch { + // Bookkeeping must not replace the operation's proof of whether dispatch began. + logger.warn('recording an operation as unknown failed', { + scope: 'operation-unknown-settlement', + sessionId, + operationId: operation.operationId + }) + } } if (outcome && !outcome.ok) { logger.warn('recording a refused operation failed', { scope: 'operation-refused-settlement', sessionId, - operationId: input.envelope.clientOperationId + operationId: operation.operationId }) return outcome } throw error } } + +function observedReceipt( + receipt: JournalOperationReceipt +): JournalOperationReceipt & { wasCommitted: () => boolean } { + let committed = false + return { + write: receipt.write, + committed: () => { + receipt.committed() + committed = true + }, + wasCommitted: () => committed + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-command.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-command.test.ts index 2be4a8ea12b..144a43302b7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-command.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-command.test.ts @@ -114,8 +114,10 @@ describe('/clear', () => { try { const cleared = command('clear') await eventually(() => expect(committing).toHaveBeenCalledOnce()) - expect(await rig.send('sent while clearing', 'queue-if-active').result).toEqual(WAIT_REFUSAL) + // Judged on arrival, answered on its turn: behind the clear. + const sent = rig.send('sent while clearing', 'queue-if-active').result release?.() + expect(await sent).toEqual(WAIT_REFUSAL) const done = await cleared const replacementId = done.ok ? done.value.replacementSessionId : undefined if (!replacementId) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts index ac1fdbb0617..176fd8e11fb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts @@ -23,6 +23,7 @@ import { isUnsettledQueuedMessage } from '../agent-session-journal/queued-messag import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { AgentSessionTurnContext } from './structured-agent-session-turns' import { QueuedMessageNotConsumableError } from '../agent-session-journal/journal-queued-messages' import type { QueuedMessageRow } from '../agent-session-journal/queued-message-table' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' @@ -185,11 +186,7 @@ export async function maybeQueueStructuredAgentSessionSend( context: { deps: { store: { getRecord: (sessionId: string) => AgentSessionRecord | null } } }, - ctx: { - sessionId: string - journal: AgentSessionJournal - fence: number - }, + ctx: Pick, params: { envelope: { clientOperationId: string } body: AgentJournalMessageItem @@ -231,12 +228,15 @@ export async function maybeQueueStructuredAgentSessionSend( } // The insert notifies through the journal's commit listener: publication and // the drain re-derive with no call here to forget. - const row = await ctx.journal.queuedMessages.insert({ - messageId: clientMessageId, - body: params.body, - fingerprint: queuedMessageFingerprint(ctx.sessionId, params.body), - hostInstance: structuredAgentSessionHostInstance() - }) + const row = await ctx.journal.queuedMessages.insert( + { + messageId: clientMessageId, + body: params.body, + fingerprint: queuedMessageFingerprint(ctx.sessionId, params.body), + hostInstance: structuredAgentSessionHostInstance() + }, + ctx.operationReceipt + ) return { ok: true, value: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-replay-outcome.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-replay-outcome.ts index 00fa33a30fb..cea36c6546a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-replay-outcome.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-replay-outcome.ts @@ -13,6 +13,16 @@ export type AgentSessionReplayOutcomeDecision = | { decision: 'rerun' } | { decision: 'refuse'; refusal: AgentSessionWireRefusal } +/** A recorded id this host cannot answer from what it holds, so it neither ran it again nor + * refused it: the caller resends under the same id. */ +export function agentSessionOperationOutcomeUnknown(operationId: string): AgentSessionWireRefusal { + return refuse( + 'agent_session_operation_unknown', + { reason: 'outcomeUnknown' }, + `The outcome of operation ${operationId} is unknown; it was not run again.` + ) +} + export function resolveAgentSessionReplayOutcome(input: { operationId: string outcome: AgentSessionOperationOutcome @@ -41,14 +51,7 @@ export function resolveAgentSessionReplayOutcome(input: { if (input.rerunWhenReplayMissing) { return { decision: 'rerun' } } - return { - decision: 'refuse', - refusal: refuse( - 'agent_session_operation_unknown', - { reason: 'outcomeUnknown' }, - `The outcome of operation ${operationId} is unknown; it was not run again.` - ) - } + return { decision: 'refuse', refusal: agentSessionOperationOutcomeUnknown(operationId) } } const recorded = input.reconstruct() if (recorded) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-resend-answer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-resend-answer.test.ts new file mode 100644 index 00000000000..74e4cfb4815 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-resend-answer.test.ts @@ -0,0 +1,241 @@ +// What a resend of a send id gets: the answer its record holds, never a refusal made before the +// host looked the id up, and never a made-up record. + +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { DISPATCH_DOUBT_SUBMISSION_MISSING } from '../agent-session-journal/journal-dispatch-doubt-reasons' +import { persistRewindRecord } from './structured-rewind-recovery' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import type { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + attach, + CALLER, + envelope, + hostTestState +} from './structured-agent-session-host-test-harness' +import { + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestMessage +} from './structured-agent-session-host-test-data' +import { createQueuedMessageTestRig } from './structured-agent-session-queued-message-rig.test-fixture' + +let root: string +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost +let dispatch: Mock +let acquire: Mock + +beforeEach(() => { + ;({ root, store, host, dispatch, acquire } = hostTestState()) +}) + +afterEach(() => vi.restoreAllMocks()) + +function hostJournal(): AgentSessionJournal { + return ( + host as unknown as { sessions: Map } + ).sessions.get(SESSION)!.journal +} + +function sendParams(text: string) { + const body = hostTestMessage(text) + return { envelope: envelope('agentSession.send', { body }), body } +} + +async function deliveredOnce(): Promise { + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledTimes(1)) +} + +describe('a resent send id', () => { + it('is answered from a refused row without opening the chat again', async () => { + await attach() + vi.spyOn(hostJournal(), 'appendSubmission').mockRejectedValueOnce(new Error('disk full')) + const params = sendParams('refused once') + const first = await host.send(CALLER, params) + expect(first).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_operation_invalid' } + }) + await host.close(SESSION, 'evict') + expect(host.hasSession(SESSION)).toBe(false) + + const resent = await host.send(CALLER, params) + + expect(resent).toMatchObject({ + ok: false, + refusal: { + code: 'agent_session_operation_invalid', + details: { reason: 'journalWriteFailed' } + } + }) + // The answer came from the ledger alone: the closed chat was not opened to give it. + expect(host.hasSession(SESSION)).toBe(false) + expect(dispatch).not.toHaveBeenCalled() + }) + + it('answers unknown, never a refusal, when the chat holding its answer cannot be opened', async () => { + await attach() + const params = sendParams('recorded, then the chat would not open') + await host.send(CALLER, params) + await deliveredOnce() + await host.close(SESSION, 'evict') + const connection = openTestJournalHostDatabase(root).db + const prepare = connection.prepare.bind(connection) + vi.spyOn(connection, 'prepare').mockImplementation((sql: string) => { + if (sql.includes('journal_')) { + throw Object.assign(new Error('database disk image is malformed'), { + code: 'ERR_SQLITE_ERROR', + errcode: 11 + }) + } + return prepare(sql) + }) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + await expect(host.send(CALLER, params)).resolves.toMatchObject({ + ok: false, + refusal: { code: 'agent_session_operation_unknown', details: { reason: 'outcomeUnknown' } } + }) + // A new id meets the same chat as a first run, and is refused for what it is. + await expect(host.send(CALLER, sendParams('a new message'))).resolves.toMatchObject({ + ok: false, + refusal: { code: 'agent_session_journal_unreadable' } + }) + expect(dispatch).toHaveBeenCalledTimes(1) + }) + + it('is answered from its record while a /clear is in flight; a new id is refused', async () => { + await attach() + const params = sendParams('sent before the clear') + expect(await host.send(CALLER, params)).toMatchObject({ ok: true, replayed: false }) + + const clearing = host.conversationCommand(CALLER, { + command: 'clear', + envelope: envelope('agentSession.conversationCommand', { command: 'clear' }) + }) + const resent = host.send(CALLER, params) + const fresh = host.send(CALLER, sendParams('typed during the clear')) + await clearing + + await expect(resent).resolves.toMatchObject({ + ok: true, + replayed: true, + value: { submission: { clientMessageId: params.envelope.clientOperationId } } + }) + await expect(fresh).resolves.toMatchObject({ + ok: false, + refusal: { details: { reason: 'conversationCommandInFlight' } } + }) + }) + + it('waits for an original still being accepted and answers with its submission', async () => { + await attach() + const journal = hostJournal() + const append = journal.appendSubmission.bind(journal) + let release!: () => void + const held = new Promise((resolve) => { + release = resolve + }) + vi.spyOn(journal, 'appendSubmission').mockImplementationOnce(async (...args) => { + await held + return append(...args) + }) + const params = sendParams('resent while the first is mid-write') + + const original = host.send(CALLER, params) + const resent = host.send(CALLER, params) + await vi.waitFor(() => expect(journal.appendSubmission).toHaveBeenCalledTimes(1)) + release() + + const [first, second] = await Promise.all([original, resent]) + expect(first).toMatchObject({ ok: true, replayed: false }) + expect(second).toMatchObject({ + ok: true, + replayed: true, + value: { submission: { clientMessageId: params.envelope.clientOperationId } } + }) + if (!second.ok || !('submission' in second.value)) { + throw new Error('expected the submission arm') + } + expect(second.value.submission.reason).not.toBe(DISPATCH_DOUBT_SUBMISSION_MISSING) + await deliveredOnce() + expect(journal.submissions()).toHaveLength(1) + expect( + store + .listOperationRows() + .filter((row) => row.operationId === params.envelope.clientOperationId) + ).toHaveLength(1) + }) + + it('is answered from the chat while a rewind is in doubt, starting no agent', async () => { + await attach() + const params = sendParams('sent before the rewind') + await host.send(CALLER, params) + await deliveredOnce() + await host.close(SESSION, 'evict') + // Only the provider can settle this rewind, so a send's first run would start it. + await persistRewindRecord(store, SESSION, store.getRecord(SESSION)!.lease.runtimeFence, { + operationId: 'rewind-op', + callerKey: CALLER.callerKey, + itemId: 'orca:rewound', + providerItemId: `codex:${THREAD}:turn-1:0`, + expectedEpoch: 'epoch-before', + phase: 'prepared', + retained: [] + }) + acquire.mockClear() + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + await expect(host.send(CALLER, params)).resolves.toMatchObject({ + ok: true, + replayed: true, + value: { submission: { clientMessageId: params.envelope.clientOperationId } } + }) + expect(acquire).not.toHaveBeenCalled() + expect(store.getRecord(SESSION)?.rewind?.phase).toBe('prepared') + }) + + it('is answered from a store a newer Orca wrote, which takes no write', async () => { + await attach() + const params = sendParams('sent, then a newer Orca wrote the store') + await host.send(CALLER, params) + await deliveredOnce() + await host.close(SESSION, 'evict') + Object.defineProperty(openTestJournalHostDatabase(root), 'readOnly', { value: true }) + expect(store.readOnly).toBe(true) + + await expect(host.send(CALLER, params)).resolves.toMatchObject({ + ok: true, + replayed: true, + value: { submission: { clientMessageId: params.envelope.clientOperationId } } + }) + expect(dispatch).toHaveBeenCalledTimes(1) + }) +}) + +describe('a send queued behind a running turn', () => { + it('commits its accepted row with its draft, so a failed settlement loses no answer', async () => { + const rig = await createQueuedMessageTestRig() + try { + await rig.workingSend() + const settle = vi + .spyOn(rig.store, 'recordOperationOutcome') + .mockRejectedValue(new Error('operation settlement failed')) + const queued = rig.send('queued behind the turn', 'queue-if-active') + + await expect(queued.result).resolves.toMatchObject({ + ok: true, + value: { queued: { messageId: queued.id, state: 'waiting' } } + }) + expect( + rig.store.listOperationRows().find((row) => row.operationId === queued.id) + ).toMatchObject({ outcome: { status: 'succeeded' } }) + expect(settle).not.toHaveBeenCalled() + } finally { + await rig.dispose() + } + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-resend-ledger-refusal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-resend-ledger-refusal.test.ts new file mode 100644 index 00000000000..e286c8a58ae --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-resend-ledger-refusal.test.ts @@ -0,0 +1,157 @@ +// A send id the ledger refuses is answered with that refusal and nothing else: no chat opened, no +// write. A /clear in flight refuses only a send's first run, judged when the send arrived. + +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS } from '../../../shared/agent-session-host-authority' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import type { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + attach, + CALLER, + envelope, + hostTestState +} from './structured-agent-session-host-test-harness' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + hostTestMessage +} from './structured-agent-session-host-test-data' + +const EXPIRED = { + ok: false, + refusal: { + code: 'agent_session_operation_expired', + details: { reason: 'operationExpired' } + } +} + +let root: string +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost +let dispatch: Mock + +beforeEach(() => { + ;({ root, store, host, dispatch } = hostTestState()) +}) + +afterEach(() => vi.restoreAllMocks()) + +function hostJournal(): AgentSessionJournal { + return ( + host as unknown as { sessions: Map } + ).sessions.get(SESSION)!.journal +} + +function sendParams(text: string, clientOperationId?: string) { + const body = hostTestMessage(text) + return { + envelope: envelope( + 'agentSession.send', + { body }, + clientOperationId ? { clientOperationId } : {} + ), + body + } +} + +/** An id minted more than a day ago, which no ledger row holds any more. */ +function expiredParams(text: string) { + return sendParams( + text, + `${NOW - AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS - 60_000}-${'e'.repeat(32)}` + ) +} + +function clear() { + return host.conversationCommand(CALLER, { + command: 'clear', + envelope: envelope('agentSession.conversationCommand', { command: 'clear' }) + }) +} + +describe('an expired send id', () => { + it('is answered expired while its chat is closed, not as a chat this host lacks', async () => { + await attach() + await host.close(SESSION, 'evict') + + await expect(host.send(CALLER, expiredParams('long gone'))).resolves.toMatchObject(EXPIRED) + expect(host.hasSession(SESSION)).toBe(false) + }) + + it('is answered expired by a store a newer Orca wrote', async () => { + await attach() + const database = openTestJournalHostDatabase(root) + Object.defineProperty(database, 'readOnly', { value: true }) + expect(store.readOnly).toBe(true) + try { + await expect(host.send(CALLER, expiredParams('long gone'))).resolves.toMatchObject(EXPIRED) + } finally { + // Teardown stops the live child, which writes. + Object.defineProperty(database, 'readOnly', { value: false }) + } + }) + + it('is answered expired when its row lapses while the chat opens for the resend', async () => { + await attach() + const params = sendParams('recorded, then it lapsed') + await host.send(CALLER, params) + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledTimes(1)) + const evaluate = store.evaluateMutationOperation + // The second read, after the open, sees the clock past the row's whole retention. + vi.spyOn(store, 'evaluateMutationOperation') + .mockImplementationOnce(evaluate) + .mockImplementationOnce((args) => + evaluate({ ...args, now: args.now + 3 * AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS }) + ) + + await expect(host.send(CALLER, params)).resolves.toMatchObject(EXPIRED) + expect(dispatch).toHaveBeenCalledTimes(1) + }) +}) + +describe('a /clear in flight', () => { + it('answers a resend from the attempt queued ahead of it, delivering once', async () => { + await attach() + const journal = hostJournal() + const append = journal.appendSubmission.bind(journal) + const held = Promise.withResolvers() + // An earlier send holds the session's queue, so attempt 1 waits there when the clear arrives. + vi.spyOn(journal, 'appendSubmission').mockImplementationOnce(async (...args) => { + await held.promise + return append(...args) + }) + const earlier = host.send(CALLER, sendParams('holds the queue')) + const params = sendParams('queued ahead of the clear') + const id = params.envelope.clientOperationId + const attempt = host.send(CALLER, params) + await vi.waitFor(() => expect(journal.appendSubmission).toHaveBeenCalledTimes(1)) + + const clearing = clear() + const resent = host.send(CALLER, params) + held.resolve() + const [first, second] = await Promise.all([attempt, resent, earlier, clearing]) + + expect(first).toMatchObject({ ok: true, replayed: false }) + expect(second).toMatchObject({ + ok: true, + replayed: true, + value: { submission: { clientMessageId: id } } + }) + expect(journal.submissions().filter((entry) => entry.clientMessageId === id)).toHaveLength(1) + expect( + dispatch.mock.calls.filter(([input]) => input.clientMessageId === id).length + ).toBeLessThanOrEqual(1) + }) + + it('answers an expired id expired', async () => { + await attach() + const clearing = clear() + const expired = host.send(CALLER, expiredParams('typed long ago')) + await clearing + + await expect(expired).resolves.toMatchObject(EXPIRED) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts index f1fe0a40b42..b1cbc83a48e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts @@ -5,10 +5,10 @@ import { AGENT_SESSION_RESTART_CONTINUATION_UNCONFIRMED_NOTE } from '../../../shared/agent-session-restart-continuation' import { StructuredAgentSessionResumeAdmission } from './structured-agent-session-restart-resume-runner' -import { STRUCTURED_AGENT_SESSION_RESTART_CONTINUATION_CALLER } from './structured-agent-session-restart-resume-wiring' import { interruptedRestart, - statusNotes + statusNotes, + throwAfterContinuationAccepted } from './structured-agent-session-restart-interruption-test-harness' import { CALLER, envelope } from './structured-agent-session-host-test-harness' import { @@ -66,19 +66,10 @@ it('says so in the chat when the agent cannot start for the continuation', async // A send that throws after Orca may have taken it cannot be proven undelivered: filed unconfirmed, // and it stays on record while the agent that may be carrying on keeps running. it('keeps an unconfirmed failure while the agent the continuation started keeps running', async () => { - const { host, store } = await interruptedRestart() + const { host } = await interruptedRestart() vi.spyOn(console, 'warn').mockImplementation(() => {}) await host.restartResume.list() - const settle = store.recordOperationOutcome.bind(store) - vi.spyOn(store, 'recordOperationOutcome').mockImplementation(async (input) => { - if ( - input.callerKey === STRUCTURED_AGENT_SESSION_RESTART_CONTINUATION_CALLER && - input.outcome.status === 'succeeded' - ) { - throw new Error('operation outcome could not be persisted') - } - return settle(input) - }) + throwAfterContinuationAccepted() const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts index 94746daf3ec..fe97834a189 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts @@ -13,6 +13,7 @@ import type { AgentSessionRecordStore } from '../../runtime/agent-session-record import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { parseAgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' +import { AgentSessionJournal } from '../agent-session-journal/journal-store' import { StructuredAgentSessionHost } from './structured-agent-session-host' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' import { StructuredAgentSessionResumeAdmission } from './structured-agent-session-restart-resume-runner' @@ -160,6 +161,21 @@ export async function interruptedRestart( return { ...hostTestState(), host, store, log, closeSession, marker, clock } } +/** The continuation's submission commits, then its send throws: a send Orca may have taken. */ +export function throwAfterContinuationAccepted(): void { + const append = AgentSessionJournal.prototype.appendSubmission + vi.spyOn(AgentSessionJournal.prototype, 'appendSubmission').mockImplementation(async function ( + this: AgentSessionJournal, + ...args: Parameters + ) { + const cursor = await append.apply(this, args) + if (args[0].origin === 'host') { + throw new Error('the accepted continuation could not be answered') + } + return cursor + }) +} + export async function statusNotes(host: StructuredAgentSessionHost) { return (await host.journalSnapshot(SESSION)).items.flatMap((item) => item.body.kind === 'status' ? [{ text: item.body.text, tone: item.body.tone }] : [] diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts index a46551b4500..4019582e1fd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts @@ -18,7 +18,8 @@ import { interruptedRestart, startAgent, statusNotes, - supersededRefusal + supersededRefusal, + throwAfterContinuationAccepted } from './structured-agent-session-restart-interruption-test-harness' import { attach, @@ -141,38 +142,30 @@ it('replays the same logical continuation through the durable send ledger', asyn }) // A send that throws after Orca may have taken it is not proof it was not delivered. -it.each([false, true])( - 'keeps a continuation unconfirmed when its acceptance cannot be recorded (uncertainty write fails: %s)', - async (uncertaintyFails) => { - const { host, store } = await interruptedRestart() - const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}) - expect(await host.restartResume.list()).toHaveLength(1) - const settle = store.recordOperationOutcome.bind(store) - const recording = vi.spyOn(store, 'recordOperationOutcome') - recording.mockImplementation(async (input) => { - // Only the continuation's own record: the start it makes records its attach as usual. - if ( - input.callerKey === STRUCTURED_AGENT_SESSION_RESTART_CONTINUATION_CALLER && - (input.outcome.status === 'succeeded' || uncertaintyFails) - ) { - throw new Error('operation outcome could not be persisted') - } - return settle(input) - }) +it('keeps a continuation unconfirmed when its send throws after acceptance', async () => { + const { host, store } = await interruptedRestart() + vi.spyOn(console, 'warn').mockImplementation(() => {}) + expect(await host.restartResume.list()).toHaveLength(1) + throwAfterContinuationAccepted() - const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') + const result = await host.restartResume + .continueAfterRestart([SESSION], 'modal') + .finally(() => vi.restoreAllMocks()) - expect(result.continued).toMatchObject([{ sessionId: SESSION, outcome: 'unknown' }]) - // Filed as unconfirmed, with a warning in the chat. - expect(result.failed).toMatchObject([{ sessionId: SESSION, outcome: 'unconfirmed' }]) - expect(await statusNotes(host)).toContainEqual({ - text: AGENT_SESSION_RESTART_CONTINUATION_UNCONFIRMED_NOTE, - tone: 'warning' - }) - recording.mockRestore() - warning.mockRestore() - } -) + expect(result.continued).toMatchObject([{ sessionId: SESSION, outcome: 'unknown' }]) + // Filed as unconfirmed, with a warning in the chat. + expect(result.failed).toMatchObject([{ sessionId: SESSION, outcome: 'unconfirmed' }]) + expect(await statusNotes(host)).toContainEqual({ + text: AGENT_SESSION_RESTART_CONTINUATION_UNCONFIRMED_NOTE, + tone: 'warning' + }) + // Its acceptance committed with its submission: a resend replays it. + expect( + store + .listOperationRows() + .find((row) => row.callerKey === STRUCTURED_AGENT_SESSION_RESTART_CONTINUATION_CALLER) + ).toMatchObject({ outcome: { status: 'succeeded' } }) +}) // The continuation is accepted, then its start fails: the message is rejected with the cause and // the failure is filed, and nothing is stopped because nothing started. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts index cf746e29b74..01f4868d380 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts @@ -274,10 +274,12 @@ describe('a send with no live owner', () => { expect(acquire).toHaveBeenCalledOnce() }) - it('restarts nothing for a send the ledger holds but the journal never saw', async () => { - const params = sendParams('claimed, then the host died') - // The row was claimed and the host went down before the journal write: on replay, admission - // reconstructs an unknown-outcome submission and never needs an owner. + /** A row admitted for this send, then the host died before the journal write, left as `outcome` + * says: `pending` by this build, `unknown` by a build that marked it before running. */ + async function admittedThenHostDied( + params: ReturnType, + outcome: 'pending' | 'unknown' + ) { await store.admitMutationOperation({ callerKey: CALLER.callerKey, envelope: params.envelope, @@ -285,11 +287,13 @@ describe('a send with no live owner', () => { now: NOW, operationIdScope: 'global' }) - await store.recordOperationOutcome({ - callerKey: CALLER.callerKey, - operationId: params.envelope.clientOperationId, - outcome: { status: 'unknown' } - }) + if (outcome === 'unknown') { + await store.recordOperationOutcome({ + callerKey: CALLER.callerKey, + operationId: params.envelope.clientOperationId, + outcome: { status: 'unknown' } + }) + } await host.handleAdapterEvent({ type: 'ended', sessionId: SESSION, @@ -300,16 +304,31 @@ describe('a send with no live owner', () => { }) expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') acquire.mockClear() - - const result = await host.send(CALLER, { + return { ...params, envelope: { ...params.envelope, expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 0 } - }) + } + } - expect(result).toMatchObject({ + it('runs a send admitted but never run for the first time, restarting the owner once', async () => { + const resent = await admittedThenHostDied(sendParams('admitted, then the host died'), 'pending') + + await expect(host.send(CALLER, resent)).resolves.toMatchObject({ + ok: true, + replayed: false, + value: { submission: { dispatchState: 'pending' } } + }) + await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) + expect(acquire).toHaveBeenCalledOnce() + }) + + it("restarts nothing for an older build's unknown row the journal never saw", async () => { + const resent = await admittedThenHostDied(sendParams('marked unknown, then died'), 'unknown') + + await expect(host.send(CALLER, resent)).resolves.toMatchObject({ ok: true, replayed: true, value: { submission: { dispatchState: 'unknown', recovered: true } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts index 77413f45634..e74947054e4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts @@ -22,7 +22,9 @@ import { AGENT_SESSION_NOT_ATTACHED, type AgentSessionMutationSessionPreparation } from './structured-agent-session-mutation-admission' +import { agentSessionOperationOutcomeUnknown } from './structured-agent-session-replay-outcome' import { rewindRefusal } from './structured-rewind-refusal' +import { conversationCommandInFlight } from './structured-conversation-command-admission' import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' @@ -132,17 +134,29 @@ export function openWithAgent( } /** A rewind still in doubt once the conversation is open is one only its provider can settle — - * the open settles every other — so a send starts the agent, whose attach recovers it. */ + * the open settles every other — so a send starts the agent, whose attach recovers it. A resend + * of a recorded id needs only the conversation, its answer's source: it starts nothing, and an + * open that fails leaves that answer unknown, never refused. `clearInFlight`: a /clear was running + * when this send arrived, which refuses only its first run. */ export function sendPreparation( context: Pick, - envelope: AgentSessionMutationEnvelope -): () => Promise { - return async () => { + envelope: AgentSessionMutationEnvelope, + arrival: { clearInFlight?: boolean } = {} +): (ledger: 'admit' | 'replay') => Promise { + return async (ledger) => { + if (ledger === 'admit' && arrival.clearInFlight) { + return { ok: false, refusal: conversationCommandInFlight() } + } const opened = await openConversationForWrite( context.openConversation, envelope, context.deps.logger ) + if (ledger === 'replay') { + return opened.ok + ? opened + : { ok: false, refusal: agentSessionOperationOutcomeUnknown(envelope.clientOperationId) } + } const phase = context.deps.store.getRecord(envelope.sessionId)?.rewind?.phase return opened.ok && (phase === 'prepared' || phase === 'provider-succeeded') ? context.ensureAgent(envelope.sessionId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts index f0163b22791..f382eda54e4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts @@ -303,23 +303,20 @@ describe('send', () => { expect(journal.submissions()).toHaveLength(1) }) - it('reconstructs an accepted send after the ledger settlement is lost', async () => { + it('answers a send whose ledger settlement fails, its row committed with the submission', async () => { await attach() - const persist = store.recordOperationOutcome.bind(store) - let failSettlement = true - vi.spyOn(store, 'recordOperationOutcome').mockImplementation(async (input) => { - if (failSettlement && input.outcome.status === 'succeeded') { - failSettlement = false - throw new Error('operation settlement failed') - } - return persist(input) - }) - const body = hostTestMessage('accepted before settlement failed') + vi.spyOn(store, 'recordOperationOutcome').mockRejectedValue( + new Error('operation settlement failed') + ) + const body = hostTestMessage('accepted while settlement fails') const params = { envelope: envelope('agentSession.send', { body }), body } + const clientMessageId = params.envelope.clientOperationId - await expect(host.send(CALLER, params)).rejects.toThrow('operation settlement failed') - // The submission was recorded before the ledger write failed, so it is still delivered. - await delivered(params.envelope.clientOperationId) + await expect(host.send(CALLER, params)).resolves.toMatchObject({ ok: true, replayed: false }) + expect( + store.listOperationRows().find((row) => row.operationId === clientMessageId) + ).toMatchObject({ outcome: { status: 'succeeded' } }) + await delivered(clientMessageId) await expect(host.send(CALLER, params)).resolves.toMatchObject({ ok: true, replayed: true, @@ -328,51 +325,54 @@ describe('send', () => { expect(dispatch).toHaveBeenCalledTimes(1) }) - it('never reruns an admission-only send after the caller changes', async () => { + it('runs a send that threw before writing for the first time when it is resent, once', async () => { await attach() - const settlement = vi - .spyOn(store, 'recordOperationOutcome') - .mockRejectedValue(new Error('operation settlement failed')) const body = hostTestMessage('first delivery after caller recovery') const params = { envelope: envelope('agentSession.send', { body }), body } + const clientMessageId = params.envelope.clientOperationId + const beforeRun = () => { + throw new Error('host fault before the write') + } - await expect(host.send(CALLER, params)).rejects.toThrow('operation settlement failed') - expect(dispatch).not.toHaveBeenCalled() - settlement.mockRestore() + await expect(host.send(CALLER, { ...params, beforeRun })).rejects.toThrow( + 'host fault before the write' + ) + expect(hostJournal().submissions()).toHaveLength(0) + // Its success would have committed with its write, so a row still pending wrote nothing. + expect( + store.listOperationRows().find((row) => row.operationId === clientMessageId) + ).toMatchObject({ outcome: { status: 'pending' } }) await expect(host.send({ callerKey: 'client-after-recovery' }, params)).resolves.toMatchObject({ ok: true, - replayed: true, - value: { - submission: { - dispatchState: 'unknown', - reason: DISPATCH_DOUBT_SUBMISSION_MISSING - } - } + replayed: false, + value: { submission: { clientMessageId, dispatchState: 'pending' } } }) - expect(dispatch).not.toHaveBeenCalled() + await delivered(clientMessageId) expect( - store.listOperationRows().find((row) => row.operationId === params.envelope.clientOperationId) - ).toMatchObject({ callerKey: CALLER.callerKey, outcome: { status: 'pending' } }) + store.listOperationRows().find((row) => row.operationId === clientMessageId) + ).toMatchObject({ callerKey: CALLER.callerKey, outcome: { status: 'succeeded' } }) + await expect(host.send(CALLER, params)).resolves.toMatchObject({ ok: true, replayed: true }) + expect(dispatch).toHaveBeenCalledTimes(1) }) - it('never redelivers after admission survives without its journal submission', async () => { + it("answers an older build's unknown row a new epoch emptied as recorded, never redelivering", async () => { await attach() - const persist = store.recordOperationOutcome.bind(store) - const settlement = vi - .spyOn(store, 'recordOperationOutcome') - .mockImplementation(async (input) => { - if (input.outcome.status === 'succeeded') { - throw new Error('operation settlement failed') - } - return persist(input) - }) const body = hostTestMessage('delivered before epoch recovery') const params = { envelope: envelope('agentSession.send', { body }), body } - - await expect(host.send(CALLER, params)).rejects.toThrow('operation settlement failed') - settlement.mockRestore() + await host.send(CALLER, params) await delivered(params.envelope.clientOperationId) + // What a build that marked a send unknown before running it leaves behind. + await store.recordOperationOutcome({ + callerKey: CALLER.callerKey, + operationId: params.envelope.clientOperationId, + outcome: { status: 'pending' } + }) + await store.recordOperationOutcome({ + callerKey: CALLER.callerKey, + operationId: params.envelope.clientOperationId, + outcome: { status: 'unknown' } + }) const journal = hostJournal() await journal.rollEpoch('schema_unreadable', store.getRecord(SESSION)?.lease.runtimeFence ?? 1) expect(journal.submissions()).toHaveLength(0) @@ -394,33 +394,22 @@ describe('send', () => { expect(journal.submissions()).toHaveLength(0) }) - it('fails closed when a legacy pending row survives without its submission', async () => { + it('answers an accepted send a new epoch dropped as recorded, never by running it again', async () => { await attach() - const body = hostTestMessage('legacy pending send after caller recovery') + const body = hostTestMessage('accepted, then the epoch was replaced') const params = { envelope: envelope('agentSession.send', { body }), body } - await host.send(CALLER, params) await delivered(params.envelope.clientOperationId) - expect(dispatch).toHaveBeenCalledTimes(1) - await store.recordOperationOutcome({ - callerKey: CALLER.callerKey, - operationId: params.envelope.clientOperationId, - outcome: { status: 'pending' } - }) + await hostJournal().rollEpoch( + 'schema_unreadable', + store.getRecord(SESSION)?.lease.runtimeFence ?? 1 + ) - const journal = ( - host as unknown as { sessions: Map } - ).sessions.get(SESSION)!.journal - await journal.rollEpoch('schema_unreadable', store.getRecord(SESSION)?.lease.runtimeFence ?? 1) - - await expect(host.send({ callerKey: 'client-after-recovery' }, params)).resolves.toMatchObject({ + await expect(host.send(CALLER, params)).resolves.toMatchObject({ ok: true, replayed: true, value: { - submission: { - dispatchState: 'unknown', - reason: DISPATCH_DOUBT_SUBMISSION_MISSING - } + submission: { dispatchState: 'unknown', reason: DISPATCH_DOUBT_SUBMISSION_MISSING } } }) expect(dispatch).toHaveBeenCalledTimes(1) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts index fa600a229dc..629d7e71790 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts @@ -24,6 +24,7 @@ import { import { isAgentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' import { DISPATCH_DOUBT_PERSISTENCE_FAILED } from '../agent-session-journal/journal-dispatch-doubt-reasons' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { JournalOperationReceipt } from '../agent-session-journal/journal-row-writer' import type { AgentSessionDispatchOutcome, StructuredAgentSessionAdapter, @@ -63,6 +64,8 @@ export type AgentSessionTurnContext = { providerChildPhase?: () => StructuredAgentSessionProviderChildPhase | undefined /** Who a Stop's refusal row names. */ failureTextContext?: AgentSessionFailureWordsContext + /** The operation's success, committed with the row that accepts it (`MutationPlan.settlesWithWrite`). */ + operationReceipt?: JournalOperationReceipt now: () => number } @@ -142,7 +145,11 @@ export async function performSend( } } try { - await ctx.journal.appendSubmission({ ...input, fence: ctx.fence, handoverRecorded: true }) + await ctx.journal.appendSubmission( + { ...input, fence: ctx.fence, handoverRecorded: true }, + undefined, + ctx.operationReceipt + ) } catch (error) { // Damage SQLite proves is the chat's, and no retry writes past it: say so, as an open does. So // does a chat holding a newer Orca's rows, which only an update writes past, and a refusal the diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command-controller.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command-controller.ts index 3c7f71dbe1f..338bb494a4c 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command-controller.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command-controller.ts @@ -17,13 +17,15 @@ export class StructuredConversationCommandController { private readonly context: () => StructuredAgentSessionMutationContext, private readonly host: Pick ) {} + /** Whether a clear is in flight is read as the send arrives; it refuses only a first run, so an + * id with a recorded answer by the send's turn gets that answer, behind the clear. */ send = ( caller: StructuredAgentSessionCaller, params: Parameters[2] ): ReturnType => - this.pending.has(params.envelope.sessionId) - ? Promise.resolve({ ok: false, refusal: conversationCommandInFlight() }) - : sendStructuredAgentSessionTurn(this.context(), caller, params) + sendStructuredAgentSessionTurn(this.context(), caller, params, { + clearInFlight: this.pending.has(params.envelope.sessionId) + }) run = (caller: StructuredAgentSessionCaller, params: ConversationCommandParams) => { if (params.command === 'compact') { diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts index cd87b024c0f..3ef04041a43 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -13,7 +13,6 @@ import { agentSessionOperationKey, type AgentSessionOperationClaim, type AgentSessionOperationDecision, - type AgentSessionOperationOutcome, type AgentSessionOperationRow } from '../../shared/agent-session-operation-ledger' import { @@ -68,9 +67,12 @@ import { import type { AgentSessionStoreState } from './agent-session-record-store-file' import { setAgentSessionTabVisibility, showAgentSessionTabs } from './agent-session-tab-table' import type { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' +import type { JournalOperationReceipt } from '../native-chat/agent-session-journal/journal-row-writer' import { loadAgentSessionStoreRows } from './agent-session-record-rows' import { AgentSessionStoreTransactions } from './agent-session-store-transactions' +type AgentSessionOperationSettlement = Parameters[1] + export const AGENT_SESSION_LEASE_TTL_MS = 30_000, AGENT_SESSION_LEASE_RENEW_INTERVAL_MS = 10_000 @@ -296,14 +298,15 @@ export class AgentSessionRecordStore { }): Promise => this.transact((draft) => claimAgentSessionOperationInto(draft, args)) - async recordOperationOutcome(args: { - callerKey?: string - operationId: string - outcome: AgentSessionOperationOutcome - }): Promise { + async recordOperationOutcome(args: AgentSessionOperationSettlement): Promise { await this.transact((draft) => settleAgentSessionOperationInto(draft, args)) } + /** The same settlement, committed by the journal write that makes it true. It changes only the + * ledger, so no record listener is owed. */ + operationOutcomeReceipt = (args: AgentSessionOperationSettlement): JournalOperationReceipt => + this.transactions.receipt((draft) => settleAgentSessionOperationInto(draft, args)) + replaceSessionOptions = (args: AgentSessionOptionsReplacement): Promise => this.mutate(args.sessionId, (record) => replaceAgentSessionRecordOptions(record, args)) diff --git a/src/main/runtime/agent-session-store-transactions.test.ts b/src/main/runtime/agent-session-store-transactions.test.ts index 5ab6cad9e02..ab268c08db7 100644 --- a/src/main/runtime/agent-session-store-transactions.test.ts +++ b/src/main/runtime/agent-session-store-transactions.test.ts @@ -140,6 +140,57 @@ describe('writing a transaction', () => { }) }) +describe('a receipt', () => { + const operationId = `${NOW}-${'7'.repeat(32)}` + const outcome = { status: 'succeeded' as const, sessionId: 'chat-a-0001' } + + async function pendingOperation(): Promise { + const store = await openTestAgentSessionRecordStore(root) + await liveChat(store, 'chat-a-0001') + await store.admitOperation({ callerKey: 'client-1', operationId, fingerprint: 'fp', now: NOW }) + return store + } + + async function persistedStatus(): Promise { + const reopened = await openTestAgentSessionRecordStore(root) + return reopened.getOperationRow('client-1', operationId)?.outcome.status + } + + // Written inside the caller's journal transaction, so it commits or rolls back with that write. + it('shows its rows in memory only once committed is called after the commit', async () => { + const store = await pendingOperation() + const receipt = store.operationOutcomeReceipt({ callerKey: 'client-1', operationId, outcome }) + + openTestJournalHostDatabase(root).transaction((db) => { + receipt.write(db) + expect(store.getOperationRow('client-1', operationId)?.outcome.status).toBe('pending') + }) + expect(store.getOperationRow('client-1', operationId)?.outcome.status).toBe('pending') + expect(await persistedStatus()).toBe('succeeded') + + receipt.committed() + expect(store.getOperationRow('client-1', operationId)?.outcome).toEqual(outcome) + }) + + it('leaves memory and rows as they were when the transaction rolls back', async () => { + const store = await pendingOperation() + const receipt = store.operationOutcomeReceipt({ callerKey: 'client-1', operationId, outcome }) + + expect(() => + openTestJournalHostDatabase(root).transaction((db) => { + receipt.write(db) + throw new Error('the journal row was refused') + }) + ).toThrow('the journal row was refused') + + expect(store.getOperationRow('client-1', operationId)?.outcome.status).toBe('pending') + expect(await persistedStatus()).toBe('pending') + // The next store transaction diffs from what committed, not from the discarded draft. + await store.setConversationName('chat-a-0001', 'after') + expect(await persistedStatus()).toBe('pending') + }) +}) + describe('a change a load would refuse', () => { it('rejects a tab id that could not prefix a pane key, and keeps memory and rows as they were', async () => { const store = await openTestAgentSessionRecordStore(root) diff --git a/src/main/runtime/agent-session-store-transactions.ts b/src/main/runtime/agent-session-store-transactions.ts index 879ff4a160e..31bddace873 100644 --- a/src/main/runtime/agent-session-store-transactions.ts +++ b/src/main/runtime/agent-session-store-transactions.ts @@ -8,6 +8,7 @@ // the async boundary every awaiting caller was written against. import type { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' +import type { JournalOperationReceipt } from '../native-chat/agent-session-journal/journal-row-writer' import { journalOpenRefusalError } from '../native-chat/agent-session-journal/journal-open-failure' import { AgentSessionJournalError } from '../native-chat/agent-session-journal/journal-write-guards' import type { AgentSessionStoreState } from './agent-session-record-store-file' @@ -104,6 +105,31 @@ export class AgentSessionStoreTransactions { return run } + /** + * `apply`'s rows, written inside a journal transaction the caller runs and adopted once it + * commits. Exact without the queue: `write` and `committed` run in one synchronous step, so no + * store transaction can commit between the draft's staging and its adoption. + */ + receipt(apply: (draft: AgentSessionStoreState) => void): JournalOperationReceipt { + let staged: StagedStoreTransaction | null = null + return { + write: (db) => { + if (this.journalDatabase.readOnly) { + throw readOnlyStoreRefusal() + } + staged = this.stage(apply) + const writes = staged.writes + if (writes) { + writeAgentSessionStoreRows(db, writes) + } + }, + committed: () => { + staged?.adopt() + staged = null + } + } + } + private commit(apply: (draft: AgentSessionStoreState) => T, inMemoryWhenReadOnly: boolean): T { const readOnly = this.journalDatabase.readOnly if (readOnly && !inMemoryWhenReadOnly) { diff --git a/src/shared/agent-session-mutation-envelope.ts b/src/shared/agent-session-mutation-envelope.ts index 770c184b562..a3acf31af4f 100644 --- a/src/shared/agent-session-mutation-envelope.ts +++ b/src/shared/agent-session-mutation-envelope.ts @@ -103,14 +103,7 @@ export function admitAgentSessionMutation(input: { return { decision: 'refused', refusal: mismatch } } if (ledger.decision === 'refused') { - return { - decision: 'refused', - refusal: refuse( - ledger.code, - ledger.details, - `Operation ${envelope.clientOperationId} was refused: ${ledger.code}.` - ) - } + return { decision: 'refused', refusal: agentSessionLedgerRefusal(envelope, ledger) } } if (ledger.decision === 'replay') { return { decision: 'replay', row: ledger.row } @@ -125,6 +118,18 @@ export function admitAgentSessionMutation(input: { return { decision: 'admit', row: ledger.row } } +/** What the ledger's own refusal of an operation id answers. */ +export function agentSessionLedgerRefusal( + envelope: Pick, + ledger: Extract +): AgentSessionWireRefusal { + return refuse( + ledger.code, + ledger.details, + `Operation ${envelope.clientOperationId} was refused: ${ledger.code}.` + ) +} + /** Why the single admission oracle said no, mapped to what the client can do * about it. The predicate itself is never re-implemented here. */ function refuseUnlessWriterAdmitted(lease: AgentSessionLease): AgentSessionWireRefusal | null { diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 47deda80d81..0ab7a210580 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -196,6 +196,20 @@ export const AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY = // mobile client lacks the capability; mobile must first show a rejected message in place. export const AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY = 'agent-session.accepted-send.v1' as const +// Why: a host advertising this answers a resent send id from its record before anything else may +// refuse it, so a refusal `agentSession.send` RETURNS is proof; a thrown error never is, a thrown +// refusal included (host not installed, journal database won't open, host disabled). Reading a +// returned `ok: false`: `agent_session_operation_unknown` with `outcomeUnknown` or `resultLost` — +// the host cannot tell yet, resend the same id; with `rewindUnconfirmed` — settled, nothing was +// written. `agent_session_operation_expired` — only the transcript can tell. An +// `agent_session_operation_conflict` or `messageIdReused` — the id holds a different payload, +// which proves nothing about this message; nor does `sessionNotAttached` (the chat's record is +// gone or unreadable on this host). Any other — the chat holds no message under that id and none +// is in flight, but a resend of that id may still run as a new send, so a client that hands the +// text back must not resend the old id. An older host may refuse an id it recorded: none of this +// holds there. +export const AGENT_SESSION_SEND_ANSWERS_PROOF_RUNTIME_CAPABILITY = + 'agent-session.send-answers-proof.v1' as const // Why: `agentSession.send`'s params are strict, so an older host rejects `delivery`; and only a // capable client can render the `queued` result arm, the draft list, and returned cards. DARK ON // PURPOSE — not in RUNTIME_CAPABILITIES: advertising still requires the integrated Codex steer @@ -400,6 +414,7 @@ export const RUNTIME_CAPABILITIES = [ // The host side: it accepts a send before any agent has it, and a Stop with no writer before a // turn starts, so a client may gate on either. AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY, + AGENT_SESSION_SEND_ANSWERS_PROOF_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_REVEAL_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RESUME_HISTORY_RUNTIME_CAPABILITY, From ab41610ba69446044f60e362edc0eda4e9756191 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:24:50 -0700 Subject: [PATCH 24/31] Fix reordering workspaces with collapsed children (#25302) --- .../visible-worktree-options-from-state.ts | 3 +- ...ble-worktrees-manual-lineage-order.test.ts | 84 ++++++++++++ .../components/sidebar/visible-worktrees.ts | 7 +- .../listing/use-visible-worktrees.ts | 2 + tests/e2e/worktree-parent-reorder.spec.ts | 128 ++++++++++++++++++ 5 files changed, 222 insertions(+), 2 deletions(-) create mode 100644 src/renderer/src/components/sidebar/visible-worktrees-manual-lineage-order.test.ts create mode 100644 tests/e2e/worktree-parent-reorder.spec.ts diff --git a/src/renderer/src/components/sidebar/visible-worktree-options-from-state.ts b/src/renderer/src/components/sidebar/visible-worktree-options-from-state.ts index d9a09639cdf..9ab318e26a3 100644 --- a/src/renderer/src/components/sidebar/visible-worktree-options-from-state.ts +++ b/src/renderer/src/components/sidebar/visible-worktree-options-from-state.ts @@ -48,6 +48,7 @@ export function buildVisibleWorktreeOptionsFromState( workspaceHostScope: state.workspaceHostScope, visibleWorkspaceHostIds: state.visibleWorkspaceHostIds, defaultHostId: getSettingsFocusedExecutionHostId(state.settings), - worktreeLineageById: state.worktreeLineageById + worktreeLineageById: state.worktreeLineageById, + preserveLineageParentOrder: state.sortBy === 'manual' } } diff --git a/src/renderer/src/components/sidebar/visible-worktrees-manual-lineage-order.test.ts b/src/renderer/src/components/sidebar/visible-worktrees-manual-lineage-order.test.ts new file mode 100644 index 00000000000..743f4acc7be --- /dev/null +++ b/src/renderer/src/components/sidebar/visible-worktrees-manual-lineage-order.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import type { WorktreeLineage } from '../../../../shared/worktree/lineage-types' +import { worktree, repoMap } from './worktree-list-groups-test-fixtures' +import { computeVisibleWorktrees, type VisibleWorktreeOptions } from './visible-worktrees' +import { buildRows } from './worktree-list/grouping/build-rows' + +function scenario(hostId: ExecutionHostId) { + const parent = { ...worktree, id: 'parent', instanceId: 'parent-instance', hostId } + const child = { ...worktree, id: 'child', instanceId: 'child-instance', hostId } + const sibling = { ...worktree, id: 'sibling', instanceId: 'sibling-instance', hostId } + const lineage: WorktreeLineage = { + worktreeId: child.id, + worktreeInstanceId: child.instanceId, + parentWorktreeId: parent.id, + parentWorktreeInstanceId: parent.instanceId, + origin: 'manual', + capture: { source: 'manual-action', confidence: 'explicit' }, + createdAt: 1 + } + const options: VisibleWorktreeOptions = { + filterRepoIds: [], + showSleepingWorkspaces: true, + tabsByWorktree: {}, + ptyIdsByTabId: {}, + worktreeIdsWithLiveAgent: new Set(), + hideDefaultBranchWorkspace: false, + hideAutomationGeneratedWorkspaces: false, + hideCliCreatedWorkspaces: false, + hideDetachedHeadWorkspaces: false, + hideWorkspacesFromOtherDevices: false, + pairedDeviceIdsByEnvironment: new Map(), + repoMap, + workspaceHostScope: 'all', + defaultHostId: 'local', + worktreeLineageById: { [child.id]: lineage } + } + const worktreesByRepo = { [worktree.repoId]: [parent, child, sibling] } + const sortedIds = [child.id, sibling.id, parent.id] + function renderedIds(overrides: Partial = {}): string[] { + const visible = computeVisibleWorktrees(worktreesByRepo, sortedIds, { + ...options, + ...overrides + }) + return buildRows( + 'none', + visible, + repoMap, + {}, + new Set(), + new Map(), + [], + undefined, + options.worktreeLineageById, + new Map([parent, child, sibling].map((row) => [row.id, row])), + true + ).flatMap((row) => (row.type === 'item' ? [row.worktree.id] : [])) + } + return { renderedIds } +} + +describe.each(['local', 'ssh:remote'] as const)('manual lineage order on %s', (hostId) => { + it('keeps a parent below its neighbor despite higher-ranked children', () => { + expect(scenario(hostId).renderedIds({ preserveLineageParentOrder: true })).toEqual([ + 'sibling', + 'parent', + 'child' + ]) + }) + + it('keeps a filtered structural parent in its manual position', () => { + expect( + scenario(hostId).renderedIds({ + preserveLineageParentOrder: true, + showSleepingWorkspaces: false, + worktreeIdsWithLiveAgent: new Set(['child', 'sibling']) + }) + ).toEqual(['sibling', 'parent', 'child']) + }) + + it('continues promoting a parent with its children for automatic sorts', () => { + expect(scenario(hostId).renderedIds()).toEqual(['parent', 'child', 'sibling']) + }) +}) diff --git a/src/renderer/src/components/sidebar/visible-worktrees.ts b/src/renderer/src/components/sidebar/visible-worktrees.ts index 37fb31890fb..581f2e1fef2 100644 --- a/src/renderer/src/components/sidebar/visible-worktrees.ts +++ b/src/renderer/src/components/sidebar/visible-worktrees.ts @@ -85,6 +85,7 @@ export type VisibleWorktreeOptions = { defaultHostId: ExecutionHostId worktreeLineageById: Record injectLineageAncestors?: boolean + preserveLineageParentOrder?: boolean forcedVisibleWorktreeIds?: readonly string[] } @@ -175,6 +176,10 @@ export function computeVisibleWorktrees( // Apply cached sort order. Items not yet in the cache (e.g. brand-new // worktrees before the next sortEpoch bump) are appended at the end. + // Manual placement belongs to the parent, even when a hidden child has a higher rank. + if (opts.injectLineageAncestors !== false && opts.preserveLineageParentOrder) { + all = addVisibleLineageAncestors(all, lineageAncestorById, opts.worktreeLineageById) + } const orderIndex = getSortedWorktreeRankIndex(sortedIds) all.sort((a, b) => { const ai = orderIndex.get(a.id) ?? Infinity @@ -182,7 +187,7 @@ export function computeVisibleWorktrees( return ai - bi }) - return opts.injectLineageAncestors === false + return opts.injectLineageAncestors === false || opts.preserveLineageParentOrder ? all : addVisibleLineageAncestors(all, lineageAncestorById, opts.worktreeLineageById) } diff --git a/src/renderer/src/components/sidebar/worktree-list/listing/use-visible-worktrees.ts b/src/renderer/src/components/sidebar/worktree-list/listing/use-visible-worktrees.ts index 1990fad3606..6b42ea04b22 100644 --- a/src/renderer/src/components/sidebar/worktree-list/listing/use-visible-worktrees.ts +++ b/src/renderer/src/components/sidebar/worktree-list/listing/use-visible-worktrees.ts @@ -106,6 +106,7 @@ export function useVisibleSidebarWorktrees(args: { visibleWorkspaceHostIds, defaultHostId, worktreeLineageById, + preserveLineageParentOrder: sortBy === 'manual', forcedVisibleWorktreeIds: args.agentSendTargetWorktreeId ? [args.agentSendTargetWorktreeId] : undefined @@ -130,6 +131,7 @@ export function useVisibleSidebarWorktrees(args: { ptyIdsByTabId, browserTabsByWorktree, sortedIds, + sortBy, worktreeLineageById, worktreesByRepo, pairedDeviceIdsByEnvironment, diff --git a/tests/e2e/worktree-parent-reorder.spec.ts b/tests/e2e/worktree-parent-reorder.spec.ts new file mode 100644 index 00000000000..21f96f0c0d3 --- /dev/null +++ b/tests/e2e/worktree-parent-reorder.spec.ts @@ -0,0 +1,128 @@ +import { test, expect } from './helpers/orca-app' +import { waitForSessionReady } from './helpers/store' + +for (const newCardStyle of [false, true]) { + test(`reorders a collapsed parent with 53 children (${newCardStyle ? 'new' : 'legacy'} cards)`, async ({ + orcaPage + }, testInfo) => { + await waitForSessionReady(orcaPage) + const ids = await orcaPage.evaluate(async (newCardStyle) => { + const store = window.__store + if (!store) { + throw new Error('Missing app store') + } + const state = store.getState() + const repo = state.repos[0]! + const template = state.worktreesByRepo[repo.id]![0]! + const makeRow = (name: string, rank: number) => ({ + ...template, + id: `${repo.id}::${name}`, + instanceId: name, + displayName: name, + branch: `refs/heads/${name}`, + isMainWorktree: false, + isPinned: false, + isArchived: false, + parentWorktreeId: null, + childWorktreeIds: [], + lineage: null, + manualOrder: rank, + sortOrder: rank + }) + const first = makeRow('First workspace', 100_000) + const parent = makeRow('Parent with 53 children', 90_000) + const last = makeRow('Last workspace', 10_000) + const children = Array.from({ length: 53 }, (_, index) => { + const child = makeRow(`Child ${index}`, 80_000 - index * 100) + const lineage = { + worktreeId: child.id, + worktreeInstanceId: child.instanceId, + parentWorktreeId: parent.id, + parentWorktreeInstanceId: parent.instanceId, + origin: 'manual' as const, + capture: { source: 'manual-action' as const, confidence: 'explicit' as const }, + createdAt: Date.now() + } + return { ...child, parentWorktreeId: parent.id, lineage } + }) + const collapsedGroups = [ + `lineage:${parent.hostId ? `${parent.hostId}|${parent.id}` : parent.id}` + ] + await window.api.ui.set({ groupBy: 'none', collapsedGroups }) + store.setState({ + groupBy: 'none', + sortBy: 'manual', + sidebarOpen: true, + settings: { ...state.settings, experimentalNewWorktreeCardStyle: newCardStyle }, + showActiveOnly: false, + showSleepingWorkspaces: true, + hideDefaultBranchWorkspace: false, + filterRepoIds: [], + collapsedGroups: new Set(collapsedGroups), + worktreesByRepo: { [repo.id]: [first, parent, ...children, last] }, + worktreeLineageById: Object.fromEntries(children.map((child) => [child.id, child.lineage])), + // Synthetic rows exercise the real drag path without persisting nonexistent checkouts. + updateWorktreesMeta: async (updates) => { + const byId = new Map(updates.map((update) => [update.worktreeId, update.updates])) + store.setState((current) => ({ + sortEpoch: current.sortEpoch + 1, + worktreesByRepo: Object.fromEntries( + Object.entries(current.worktreesByRepo).map(([repoId, rows]) => [ + repoId, + rows.map((row) => ({ ...row, ...byId.get(row.id) })) + ]) + ) + })) + } + }) + return { first: first.id, parent: parent.id, last: last.id } + }, newCardStyle) + const sidebar = orcaPage.locator('[data-worktree-sidebar]') + const parent = sidebar.locator(`[data-worktree-id=${JSON.stringify(ids.parent)}]`) + const last = sidebar.locator(`[data-worktree-id=${JSON.stringify(ids.last)}]`) + await expect(parent.getByRole('button', { name: 'Show 53 child workspaces' })).toBeVisible() + await sidebar.screenshot({ path: testInfo.outputPath('before.png') }) + const source = await parent.boundingBox() + const target = await last.boundingBox() + if (!source || !target) { + throw new Error('Missing card bounds') + } + await orcaPage.mouse.move(source.x + source.width / 2, source.y + 12) + await orcaPage.mouse.down() + await orcaPage.mouse.move(target.x + 2, target.y + target.height - 1, { steps: 12 }) + await expect(orcaPage.locator('[data-worktree-sidebar-drag-preview]')).toHaveCount(1) + await orcaPage.mouse.up() + try { + await expect + .poll(async () => { + const [parentBox, lastBox] = await Promise.all([parent.boundingBox(), last.boundingBox()]) + return Boolean(parentBox && lastBox && parentBox.y > lastBox.y) + }) + .toBe(true) + } finally { + await sidebar.screenshot({ path: testInfo.outputPath('after.png') }) + } + const first = sidebar.locator(`[data-worktree-id=${JSON.stringify(ids.first)}]`) + const movedSource = await parent.boundingBox() + const upwardTarget = await first.boundingBox() + if (!movedSource || !upwardTarget) { + throw new Error('Missing reordered card bounds') + } + await orcaPage.mouse.move(movedSource.x + movedSource.width / 2, movedSource.y + 12) + await orcaPage.mouse.down() + await orcaPage.mouse.move(upwardTarget.x + 2, upwardTarget.y + 1, { steps: 12 }) + await orcaPage.mouse.up() + await expect + .poll(async () => { + const [parentBox, firstBox] = await Promise.all([parent.boundingBox(), first.boundingBox()]) + return Boolean(parentBox && firstBox && parentBox.y < firstBox.y) + }) + .toBe(true) + // Wait for the drag's click suppression before exercising the children toggle. + await orcaPage.waitForTimeout(550) + await parent.getByRole('button', { name: 'Show 53 child workspaces' }).click() + await expect(parent.getByRole('button', { name: 'Hide 53 child workspaces' })).toBeVisible() + await expect(parent.locator('[role="option"]')).toHaveCount(53) + await expect(parent.locator('[role="option"]').first()).toContainText('Child 0') + }) +} From 97fa6aee746d5923962b3801b3c7274a61059e9a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:28:10 -0700 Subject: [PATCH 25/31] fix(native-chat): show a message Orca accepted and then failed to deliver as "Not sent" in the chat (#24710) * fix(native-chat): keep a message the host accepted then rejected in the desktop chat as not sent Draw it in place from the host's history, so a crash that loses the outbox no longer makes it vanish. A later copy of the same body supersedes it; the outbox row wins while it holds the message; the phone is unchanged. * test(native-chat): pin the same-id rule apart from the body match * test(native-chat): type the rejected-in-place fixture body as a text block * fix(native-chat): let the host's row own a message it recorded and then rejected Once the host's journal records a send as rejected, the desktop outbox lets it go, as it already does for delivered and Stop-withdrawn sends: the host's row shows it as not sent, with the host's reason and no Retry. The outbox keeps only sends the host refused before recording them, which keep their Retry. A send whose own reply says it was rejected is drawn by its outbox entry, with no Retry, until the journal carries the row; a copy left by an earlier session is dropped when the chat opens. - the transcript no longer hides a host row behind an outbox entry with the same id or the same text; those rules and their cache are gone - a rejected message the queue holds (a draft's hand-off, or a live card under its id) is drawn as its card, not as a row - a later copy of the same text hides a rejected row only when it was sent once the rejection was known, so a deliberate repeat stays - delivery notices read the same visibility rule as the transcript; a chat whose only rejection a Stop withdrew no longer rebuilds them per batch - the body fingerprint helper goes back to the host, its only user * test(native-chat): keep one row when copies of a rejected message share an instant * refactor(native-chat): let the host's notice replace the outbox's under the same id * test(native-chat): pass the queued card ids in the tool-stream cost transcript * fix(native-chat): keep the host's record as what lets a rejected message go - the outbox no longer drops a host-rejected message when a chat opens; the reconcile lets it go once the journal's submissions say it was rejected, and that drop is written to storage, so nothing reads as still owed - a message the host rejected while the chat watched waits for its journal row with no Retry; one read back from storage with no row loaded keeps its Retry under a new id, since the host may have lost it - the delivery notices keep the same map and notice objects across a batch that words every row the same, so a submission batch re-renders no row - a rejected command such as /compact stays hidden: its own reply reports it - the desktop transcript requires the queued card ids, with a controller-level test that a card holding a rejected message keeps its row hidden * fix(native-chat): draw a queued message where the host rejected it A message accepted to hand over later and rejected before any handover now sits at its rejection, as a handover places one: what the agent did while it waited happened before it, and the newest history page holds it. One handed over, or dispatched as it was recorded, keeps its place. An older host does not move it, so it stays at its submission, still drawn. A failed start now rejects the queued messages and writes its row in ONE journal append, the messages first: no reader ever meets one without the other, and the messages still draw above the row that says why. * test(native-chat): pin that rows written together roll back together * fix(native-chat): draw every rejected message where it was rejected Not only a queued message: one handed over into a turn and then rejected, or sent directly and rejected, also sits at its rejection, in no turn. A message in doubt stays where it was, a plain bubble: it may have reached the agent. * fix(native-chat): decide a rejected message's Retry from the host's stored fact - a message the host recorded and then rejected has no Retry on any mount, however that mount learned of it, and a Dismiss that clears it from storage; a send refused before the host recorded it keeps its Retry - the rule that keeps a rejected command such as /compact out of the transcript moves into the one visibility function rows and notices share - the outbox state docs say what lets a recorded message go: the client holding its rejected submission, whose row the host places at the rejection * fix(native-chat): write no start-failure row when a Stop withdrew every queued message first * test(native-chat): pass the Dismiss action in the delivery-notice hook tests * fix(native-chat): keep a rejected message's outbox copy until its row loads An older host leaves a rejected message where it was sent, which may be older than the loaded window: the chat then holds the rejected submission but not the row that draws it. The outbox copy now stays until that row loads, marked as the host recorded it (Dismiss, no Retry, in the host's words), and leaves once the page holding the row is loaded. Derived from the loaded rows each time. Tests that label their projection as the phone's now pass the phone's own setting. * test(native-chat): type the outbox hook props that carry loaded rows * fix(native-chat): write nothing when a journal batch settles nothing in the outbox The outbox re-reads the journal on every batch since it waits for a rejected message's row to load. Its reconcile now returns each unchanged entry, and the list, as themselves (a message left in doubt included), so a batch that changes nothing writes nothing to storage. The reconcile moves to its own module. A copy the host recorded and rejected owes no delivery, so it no longer keeps a hidden pane reading the journal. * test(native-chat): count storage writes on the outbox's own storage object * fix(native-chat): let a recorded rejected message's outbox copy leave on its own, with no Dismiss The outbox copy of a message the host recorded and then rejected draws it only while the host's row is not loaded, and leaves on the batch or page that loads that row. It owes no delivery and offers no control: sending it again is a new message. The Dismiss that let the user clear it is gone, from the outbox, the notices and the session controller. --- .../use-mobile-structured-agent-session.ts | 6 +- .../codex-collab-call-delegation.test.ts | 4 +- ...ab-call-rows-on-positional-clients.test.ts | 4 +- .../codex-structured-question-order.test.ts | 7 +- .../journal-dispatch-reducer.ts | 8 +- .../journal-lifecycle-batch-appender.ts | 29 ++ .../journal-pending-submission-recovery.ts | 21 + .../journal-queued-rejection-batch.test.ts | 129 +++++ .../journal-reducer.test.ts | 5 +- .../agent-session-journal/journal-reducer.ts | 6 +- ...journal-rejected-message-placement.test.ts | 158 ++++++ .../journal-row-writer.test.ts | 13 + .../journal-row-writer.ts | 34 ++ .../journal-store-collaborators.ts | 32 +- .../journal-store-contracts.ts | 4 + .../journal-submission-fold.ts | 22 + .../journal-turn-scope.test.ts | 4 +- ...d-agent-session-conversation-close.test.ts | 12 +- ...structured-agent-session-host-test-data.ts | 4 +- ...uctured-agent-session-start-failure-row.ts | 13 +- ...atMessageList.provider-retry-runs.test.tsx | 4 +- ...hatMessageList.stream-render.perf.test.tsx | 4 +- ...eChatMessageList.tool-stream-cost.test.tsx | 2 +- ...veChatMessageList.turn-membership.test.tsx | 4 +- ...iveChatMessageList.unsent-message.test.tsx | 94 +++- ...turedSession.start-failure-notice.test.tsx | 13 +- ...tiveChatStructuredSession.test-harness.tsx | 5 +- .../NativeChatStructuredSession.tsx | 51 +- ...tiveChatStructuredSessionDelivery.test.tsx | 73 ++- .../native-chat-rail-outline-parity.test.ts | 11 +- ...red-agent-session-delivery-notices.test.ts | 93 ++-- ...ructured-agent-session-delivery-notices.ts | 77 ++- ...sion-message-projection.older-host.test.ts | 161 ++++++ ...n-message-projection.recorded-copy.test.ts | 218 ++++++++ ...ssage-projection.rejected-in-place.test.ts | 465 ++++++++++++++++++ ...d-agent-session-message-projection.test.ts | 35 +- ...ctured-agent-session-message-projection.ts | 14 +- .../structured-agent-session-outbox-retry.ts | 3 +- ...structured-agent-session-outbox-storage.ts | 12 +- ...red-agent-session-transcript-order.test.ts | 4 +- ...session-withdrawn-message-restore.test.tsx | 27 +- ...ed-agent-session-delivery-notices.test.tsx | 120 +++++ ...ructured-agent-session-delivery-notices.ts | 120 +++++ ...structured-agent-session-messages.test.tsx | 18 +- .../use-structured-agent-session-messages.ts | 7 +- ...ed-agent-session-outbox-admission.test.tsx | 10 +- ...ctured-agent-session-outbox-fence.test.tsx | 5 + ...agent-session-outbox-owner-change.test.tsx | 5 + ...nt-session-outbox-rejection-cause.test.tsx | 322 +++++++++--- ...gent-session-outbox-relaunch-hold.test.tsx | 7 + ...d-agent-session-outbox-withdrawal.test.tsx | 9 +- ...-agent-session-outbox.batch-churn.test.tsx | 117 +++++ ...ent-session-outbox.draft-hand-off.test.tsx | 8 +- ...gent-session-outbox.external-send.test.tsx | 5 + ...ent-session-outbox.queue-delivery.test.tsx | 9 + ...ent-session-outbox.rejected-reply.test.tsx | 102 ++++ ...ession-outbox.stop-parks-in-doubt.test.tsx | 7 + ...e-structured-agent-session-outbox.test.tsx | 129 ++--- .../use-structured-agent-session-outbox.ts | 22 +- ...tured-agent-session.rejected-card.test.tsx | 116 +++++ .../use-structured-agent-session.ts | 6 +- .../agent-session-conversation-outline.ts | 3 +- .../native-chat-provider-retry-runs.test.ts | 8 +- src/shared/native-chat-types.ts | 4 +- ...tured-agent-session-draft-hand-off.test.ts | 4 +- ...structured-agent-session-draft-hand-off.ts | 21 +- ...ctured-agent-session-message-projection.ts | 119 ++++- ...red-agent-session-outbox-reconcile.test.ts | 100 ++++ ...ructured-agent-session-outbox-reconcile.ts | 69 +++ ...ed-agent-session-outbox-retry-hold.test.ts | 8 +- src/shared/structured-agent-session-outbox.ts | 68 +-- ...red-agent-session-send-disposition.test.ts | 10 +- ...ructured-agent-session-send-disposition.ts | 6 +- 73 files changed, 2959 insertions(+), 490 deletions(-) create mode 100644 src/main/native-chat/agent-session-journal/journal-queued-rejection-batch.test.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-rejected-message-placement.test.ts create mode 100644 src/renderer/src/components/native-chat/structured-agent-session-message-projection.older-host.test.ts create mode 100644 src/renderer/src/components/native-chat/structured-agent-session-message-projection.recorded-copy.test.ts create mode 100644 src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-delivery-notices.test.tsx create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-delivery-notices.ts create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-outbox.batch-churn.test.tsx create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-outbox.rejected-reply.test.tsx create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session.rejected-card.test.tsx create mode 100644 src/shared/structured-agent-session-outbox-reconcile.test.ts create mode 100644 src/shared/structured-agent-session-outbox-reconcile.ts diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts index 653d7fbaaa1..ae688ab9072 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -158,7 +158,11 @@ export function useMobileStructuredAgentSession(args: { }) const messages = useMemo( - () => projectStructuredAgentSessionMessages(state.items, [], state.submissions), + // Off: the phone hands a rejected message back to its composer, so a row would show it twice. + () => + projectStructuredAgentSessionMessages(state.items, [], state.submissions, { + rejectedInPlace: false + }), [state.items, state.submissions] ) const turnId = activeStructuredAgentSessionTurnId(state.items) diff --git a/src/main/codex/codex-collab-call-delegation.test.ts b/src/main/codex/codex-collab-call-delegation.test.ts index 96131248ddc..dbc116802e4 100644 --- a/src/main/codex/codex-collab-call-delegation.test.ts +++ b/src/main/codex/codex-collab-call-delegation.test.ts @@ -28,7 +28,9 @@ import { THREAD_ID } from './codex-structured-session-adapter-fixture' /** The delegation the parent's newest tool run reads as, the row a running chat's frontier judges. */ async function newestRunDelegation(frames: Frame[]): Promise { const { conversation } = projectNativeChatTranscript( - projectStructuredAgentSessionMessages(await publishedRows(frames), [], []) + projectStructuredAgentSessionMessages(await publishedRows(frames), [], [], { + rejectedInPlace: true + }) ) const runs = conversation.filter((message: NativeChatMessage) => message.blocks.some((block) => block.type === 'tool-call') diff --git a/src/main/codex/codex-collab-call-rows-on-positional-clients.test.ts b/src/main/codex/codex-collab-call-rows-on-positional-clients.test.ts index 795716ed553..ae7770b5beb 100644 --- a/src/main/codex/codex-collab-call-rows-on-positional-clients.test.ts +++ b/src/main/codex/codex-collab-call-rows-on-positional-clients.test.ts @@ -47,7 +47,9 @@ function positionalRuns(rows: AgentJournalRenderItem[]): { }) }) const transcript = projectNativeChatTranscript( - projectStructuredAgentSessionMessages(rows, [], []).map(withoutCallIds) + projectStructuredAgentSessionMessages(rows, [], [], { rejectedInPlace: true }).map( + withoutCallIds + ) ) // The conversation's runs, then each helper's section's. const runs = [ diff --git a/src/main/codex/codex-structured-question-order.test.ts b/src/main/codex/codex-structured-question-order.test.ts index 8ad4b75bd38..78c197644e2 100644 --- a/src/main/codex/codex-structured-question-order.test.ts +++ b/src/main/codex/codex-structured-question-order.test.ts @@ -206,6 +206,7 @@ function drawnPromptRows(): string[][] { client.items, [], client.submissions, + { rejectedInPlace: true }, projectStructuredQuestionMessages ) ) @@ -243,9 +244,9 @@ describe('a Codex ask with several questions', () => { ]) // Mobile draws the shared projection in journal order, one row per question. expect( - projectStructuredAgentSessionMessages(client.items, [], client.submissions).map( - ({ blocks }) => (blocks[0]?.type === 'text' ? blocks[0].text.split('\n')[0] : null) - ) + projectStructuredAgentSessionMessages(client.items, [], client.submissions, { + rejectedInPlace: false + }).map(({ blocks }) => (blocks[0]?.type === 'text' ? blocks[0].text.split('\n')[0] : null)) ).toEqual(ASKED.map(({ question }) => question)) }) diff --git a/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts b/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts index 89722959799..c7c2a671654 100644 --- a/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts +++ b/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts @@ -8,7 +8,11 @@ import { import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' import { journalDispatchRowApplies } from './journal-dispatch-settlement' import type { JournalReducerState } from './journal-reducer' -import { notePersonTurnAccepted, placeHandedOverMessage } from './journal-submission-fold' +import { + notePersonTurnAccepted, + placeHandedOverMessage, + placeRejectedMessage +} from './journal-submission-fold' import type { JournalRow } from './journal-row-schema' export function applyJournalDispatchRow( @@ -34,6 +38,8 @@ export function applyJournalDispatchRow( if (row.state === 'pending') { submission.handedOverAt = row.ts placeHandedOverMessage(state, submission, row) + } else if (row.state === 'rejected') { + placeRejectedMessage(state, submission, row) } if (row.recovered) { submission.recovered = row.recovered diff --git a/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.ts b/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.ts index c05d22a8745..68ba188e402 100644 --- a/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.ts +++ b/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.ts @@ -3,6 +3,7 @@ import type { JournalReducerState } from './journal-reducer' import { journalLifecycleBatchRowBuilder } from './journal-row-builders' import type { JournalLifecycleBatchInput } from './journal-store-contracts' import type { JournalRow } from './journal-row-schema' +import { journalQueuedRejectionRowBuilders } from './journal-pending-submission-recovery' const SETTLEMENT_ALREADY_APPLIED = new Error('journal_settlement_already_applied') @@ -12,10 +13,38 @@ export class JournalLifecycleBatchAppender { state: () => JournalReducerState cursor: () => AgentJournalCursor enqueue: (build: (seq: number, ts: number) => JournalRow) => Promise + enqueueRows: ( + plan: () => readonly ((seq: number, ts: number) => JournalRow)[] + ) => Promise } ) {} append(input: JournalLifecycleBatchInput): Promise { + const { rejectsQueued } = input + if (rejectsQueued) { + // Planned on the lane: the sends queued then, and this batch unless it already landed. With + // none left (a Stop withdrew them first) it failed no one, so nothing is written. + return this.deps + .enqueueRows(() => { + const rejections = journalQueuedRejectionRowBuilders( + this.deps.state, + input.fence, + rejectsQueued + ) + return rejections.length === 0 || this.wasApplied(input.settlementId) + ? rejections + : [ + ...rejections, + journalLifecycleBatchRowBuilder( + this.deps.state, + input.settlementId, + input.mutations, + input + ) + ] + }) + .then(() => this.deps.cursor()) + } if (this.wasApplied(input.settlementId)) { return Promise.resolve(this.deps.cursor()) } diff --git a/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts b/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts index 964ea42f931..b52e3648b72 100644 --- a/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts +++ b/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts @@ -2,6 +2,9 @@ import type { AgentJournalDispatchRejection } from '../../../shared/agent-sessio import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import { DISPATCH_DOUBT_HOST_RESTARTED } from './journal-dispatch-doubt-reasons' +import type { JournalReducerState } from './journal-reducer' +import { journalDispatchRowBuilder } from './journal-row-builders' +import type { JournalRow } from './journal-row-schema' import type { AgentSessionJournal } from './journal-store' /** Settles every submission a process fact left unanswerable. Doubt is never @@ -88,3 +91,21 @@ export async function rejectJournalQueuedSubmissions( ) return queued.map((entry) => entry.clientMessageId) } + +/** Rows rejecting every submission still queued, read from `state` when called: for an append + * that must carry them with what follows, in one transaction. */ +export function journalQueuedRejectionRowBuilders( + state: () => JournalReducerState, + fence: number, + rejection: AgentJournalDispatchRejection +): ((seq: number, ts: number) => JournalRow)[] { + return [...state().submissions.values()].filter(isQueuedAgentJournalSubmission).map((entry) => + journalDispatchRowBuilder(state, { + clientMessageId: entry.clientMessageId, + state: 'rejected', + ...rejection, + fence, + recovered: true + }) + ) +} diff --git a/src/main/native-chat/agent-session-journal/journal-queued-rejection-batch.test.ts b/src/main/native-chat/agent-session-journal/journal-queued-rejection-batch.test.ts new file mode 100644 index 00000000000..d218a356fae --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-queued-rejection-batch.test.ts @@ -0,0 +1,129 @@ +// A failed start's row and the queued messages it failed land in ONE append, the messages first: +// no reader meets one without the other, and the messages sit above the row that says why. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it } from 'vitest' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import type { AgentSessionJournal } from './journal-store' +import { + closeTestJournalHostDatabases, + createTrackedJournalOpener +} from './journal-host-database-test-support' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-start', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'claude', + providerHandle: { kind: 'claude', sessionId: 'native-1', leafUuid: null } +} + +const START_FAILED = agentSessionFailureWords(agentSessionFailureFact('providerStartFailed'), { + surface: 'rejection' +}) +const ERROR_ROW = { provider: 'orca', clientMessageId: 'start-failure:gen-1' } as const + +let root: string +let clock = 1_000 +const journals = createTrackedJournalOpener() + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-queued-rejection-batch-')) +}) + +afterEach(async () => { + await closeTestJournalHostDatabases() + await rm(root, { recursive: true, force: true }) +}) + +async function openWithQueued(...ids: string[]): Promise { + const journal = await journals.open({ + identity: IDENTITY, + stateDirectory: root, + now: () => (clock += 1), + mintEpoch: () => 'epoch-1' + }) + for (const id of ids) { + await journal.appendSubmission({ + clientMessageId: id, + payloadFingerprint: `fp-${id}`, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: id }] }, + fence: 0, + handoverRecorded: true + }) + } + return journal +} + +function startFailureBatch(mutations = 1) { + return { + settlementId: 'start-failure:gen-1', + fence: 0, + recovered: true as const, + mutations: Array.from({ length: mutations }, () => ({ + kind: 'item' as const, + identity: ERROR_ROW, + body: { kind: 'status' as const, tone: 'error' as const, text: 'Claude did not start.' }, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + })), + rejectsQueued: START_FAILED + } +} + +it('writes the rejections first and the row after them, and draws the messages above it', async () => { + const journal = await openWithQueued('first', 'second') + const before = journal.cursor().sequence + + await journal.appendLifecycleBatch(startFailureBatch()) + + expect(journal.cursor().sequence).toBe(before + 3) + expect(journal.submissions().map((entry) => entry.dispatchState)).toEqual([ + 'rejected', + 'rejected' + ]) + const order = journal.snapshot().items.map((item) => item.itemId) + expect(order).toEqual([ + agentJournalSubmissionKey('first'), + agentJournalSubmissionKey('second'), + 'orca:start-failure%3Agen-1' + ]) +}) + +it('writes neither when the row cannot be written', async () => { + const journal = await openWithQueued('first') + const before = journal.cursor().sequence + + // An empty batch breaks the row's bound, so the transaction rolls back as a whole. + await expect(journal.appendLifecycleBatch(startFailureBatch(0))).rejects.toThrow( + 'journal_lifecycle_batch_mutation_bound_exceeded' + ) + + expect(journal.cursor().sequence).toBe(before) + expect(journal.submissions().map((entry) => entry.dispatchState)).toEqual(['pending']) +}) + +// A Stop that reaches the lane first takes the message back; the failed start then failed no one. +it('writes nothing when a Stop withdrew every queued message first', async () => { + const journal = await openWithQueued('first') + const withdrawal = agentSessionFailureWords(agentSessionFailureFact('cancelled'), { + surface: 'rejection' + }) + + await Promise.all([ + journal.rejectQueuedSubmissions(0, withdrawal), + journal.appendLifecycleBatch(startFailureBatch()) + ]) + + expect(journal.submissions()[0]?.rejection).toEqual({ kind: 'cancelled' }) + expect(journal.snapshot().items.map((item) => item.itemId)).toEqual([ + agentJournalSubmissionKey('first') + ]) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-reducer.test.ts b/src/main/native-chat/agent-session-journal/journal-reducer.test.ts index 45681f32871..60d068ebfd8 100644 --- a/src/main/native-chat/agent-session-journal/journal-reducer.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-reducer.test.ts @@ -551,7 +551,7 @@ describe('submission and dispatch state machine', () => { expect(state.receipts.get('cm_1')).toBeTruthy() }) - it('keeps a refused write rejected and leaves its bubble where it was', () => { + it('keeps a refused write rejected, at its rejection, whatever comes after', () => { const state = fold([ submission, { @@ -579,7 +579,8 @@ describe('submission and dispatch state machine', () => { submittedAt: submission.ts, reason: 'provider_write_failed: closed before enqueue' }) - expect(renderJournalState(state).items[0]?.sequence).toBe(submission.seq) + // It sits where it was rejected; the late `pending` moves nothing. + expect(renderJournalState(state).items[0]?.sequence).toBe(2) }) it('ignores a dispatch for a submission this epoch never saw', () => { diff --git a/src/main/native-chat/agent-session-journal/journal-reducer.ts b/src/main/native-chat/agent-session-journal/journal-reducer.ts index a7d6f150e70..e2d1b1ef3ca 100644 --- a/src/main/native-chat/agent-session-journal/journal-reducer.ts +++ b/src/main/native-chat/agent-session-journal/journal-reducer.ts @@ -6,9 +6,9 @@ // dropped rather than resurrecting stale content, and ordering is by the // position (sequence, then place in the row) of the write that CREATED an item // (a later revision updates the body, it does not move the bubble) — except a -// queued message, which sits where its handover put it. Producer linkage is -// likewise the creating write's: a revision naming no producer keeps it, one -// naming any replaces it. +// queued message, which sits where its handover put it, and a rejected one, which +// sits where it was rejected. Producer linkage is likewise the creating write's: a +// revision naming no producer keeps it, one naming any replaces it. import type { AgentJournalAcceptanceReceipt, diff --git a/src/main/native-chat/agent-session-journal/journal-rejected-message-placement.test.ts b/src/main/native-chat/agent-session-journal/journal-rejected-message-placement.test.ts new file mode 100644 index 00000000000..ce5b407d3e8 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-rejected-message-placement.test.ts @@ -0,0 +1,158 @@ +// A rejected message sits where it was rejected, in no turn: queued, handed over or sent directly. +// One in doubt stays where it was: it may have reached the agent. + +import { describe, expect, it } from 'vitest' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalTurnScope +} from '../../../shared/agent-session-journal-types' +import { DISPATCH_REJECTED_HOST_RESTARTED } from '../../../shared/structured-agent-session-dispatch-rejection' +import { projectStructuredAgentSessionMessages } from '../../../shared/structured-agent-session-message-projection' +import { projectJournalBatch } from '../agent-session-wire/agent-session-journal-batch' +import { DISPATCH_DOUBT_HOST_RESTARTED } from './journal-dispatch-doubt-reasons' +import { applyJournalRow, createJournalReducerState, renderJournalState } from './journal-reducer' +import { buildJournalSubmissionRow, journalRowBase } from './journal-row-builders' +import type { JournalRow } from './journal-row-schema' + +function journal() { + const state = createJournalReducerState('session-1', 'epoch-1') + let seq = 0 + const push = (next: JournalRow): JournalRow => { + applyJournalRow(state, next) + return next + } + return { + state, + submission(clientMessageId: string, handoverRecorded = true) { + seq += 1 + return push( + buildJournalSubmissionRow({ + state, + clientMessageId, + payloadFingerprint: `fp-${clientMessageId}`, + providerHandle: { kind: 'codex', threadId: 'thread-1' }, + body: { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: clientMessageId }] + }, + seq, + fence: 1, + ts: 1_000 + seq, + ...(handoverRecorded ? { handoverRecorded: true } : {}) + }) + ) + }, + dispatch( + clientMessageId: string, + state_: 'pending' | 'rejected' | 'unknown', + turnScope: AgentJournalTurnScope = AGENT_JOURNAL_THREAD_SCOPE + ) { + seq += 1 + return push({ + kind: 'dispatch', + clientMessageId, + state: state_, + providerItemId: null, + reason: + state_ === 'rejected' + ? DISPATCH_REJECTED_HOST_RESTARTED + : state_ === 'unknown' + ? DISPATCH_DOUBT_HOST_RESTARTED + : null, + ...(state_ === 'rejected' ? { rejection: { kind: 'hostRestarted' } } : {}), + ...journalRowBase(state.epoch, seq, 1, 1_000 + seq), + turnScope + }) + }, + /** Other work between the send and its settlement, as a turn running ahead of it would write. */ + advance(rows: number) { + seq += rows + state.lastSequence = seq + } + } +} + +function placed(state: ReturnType['state'], clientMessageId: string) { + const item = state.items.get(agentJournalSubmissionKey(clientMessageId)) + return item && { sequence: item.sequence, observedAt: item.observedAt, scope: item.turnScope } +} + +const IN_TURN: AgentJournalTurnScope = { kind: 'turn', turnItemId: 'orca:turn-1' } + +describe('a rejected message', () => { + it('sits at the rejection, in no turn, when it was queued', () => { + const { state, submission, dispatch, advance } = journal() + submission('waiting') + advance(300) + dispatch('waiting', 'rejected') + + expect(placed(state, 'waiting')).toEqual({ + sequence: 302, + observedAt: 1_302, + scope: AGENT_JOURNAL_THREAD_SCOPE + }) + }) + + it('reaches a subscriber at the tail, so the newest page holds it', () => { + const { state, submission, dispatch, advance } = journal() + submission('waiting') + advance(300) + const rejection = dispatch('waiting', 'rejected') + + const projected = projectJournalBatch({ + rows: [rejection], + snapshot: renderJournalState(state), + afterSequence: 301 + }) + expect(projected.ok && projected.batch.items.map((item) => item.sequence)).toEqual([302]) + expect(renderJournalState(state).items.at(-1)?.itemId).toBe( + agentJournalSubmissionKey('waiting') + ) + }) + + it('sits at the rejection, out of the turn it was handed into, when it was a steer', () => { + const { state, submission, dispatch, advance } = journal() + submission('steer') + advance(10) + dispatch('steer', 'pending', IN_TURN) + expect(placed(state, 'steer')?.scope).toEqual(IN_TURN) + advance(10) + dispatch('steer', 'rejected') + + expect(placed(state, 'steer')).toEqual({ + sequence: 23, + observedAt: 1_023, + scope: AGENT_JOURNAL_THREAD_SCOPE + }) + }) + + it('sits at the rejection when it was sent directly', () => { + const { state, submission, dispatch, advance } = journal() + submission('direct', false) + advance(10) + dispatch('direct', 'rejected') + + expect(placed(state, 'direct')?.sequence).toBe(12) + }) +}) + +describe('a message in doubt', () => { + it('stays where it was handed over, a plain bubble in its turn: it may have reached the agent', () => { + const { state, submission, dispatch, advance } = journal() + submission('steer') + advance(10) + dispatch('steer', 'pending', IN_TURN) + advance(10) + dispatch('steer', 'unknown') + + expect(placed(state, 'steer')).toEqual({ sequence: 12, observedAt: 1_012, scope: IN_TURN }) + const { items, submissions } = renderJournalState(state) + const drawn = projectStructuredAgentSessionMessages(items, [], submissions, { + rejectedInPlace: true + }).find((message) => message.id === agentJournalSubmissionKey('steer')) + expect(drawn).toBeDefined() + expect(drawn?.unsent).toBeUndefined() + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-row-writer.test.ts b/src/main/native-chat/agent-session-journal/journal-row-writer.test.ts index c0824a2e4ca..3671ce46cfc 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-writer.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-writer.test.ts @@ -93,4 +93,17 @@ describe('journal row writer', () => { kind: 'item' }) }) + + it('writes several rows as one: a failure on the last leaves none', async () => { + const { writer, committedRows } = writerHarness() + // Sequence 2 is taken, so the second of the two rows violates the primary key. + insertTestJournalRow(database.db, SESSION_ID, row(2, 1)) + + await expect(writer.enqueueRows(() => [row, row])).rejects.toThrow() + + expect(committedRows).toHaveLength(0) + expect(readTestJournalRows(database.db, SESSION_ID, EPOCH).map((stored) => stored.seq)).toEqual( + [2] + ) + }) }) diff --git a/src/main/native-chat/agent-session-journal/journal-row-writer.ts b/src/main/native-chat/agent-session-journal/journal-row-writer.ts index ca274120a7b..dde1a66741d 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-writer.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-writer.ts @@ -71,6 +71,40 @@ export class JournalRowWriter { }) } + /** Several rows in ONE transaction, in order, planned once the lane is this append's: none is + * durable unless all are, so no reader ever meets some without the rest. */ + enqueueRows( + plan: () => readonly ((seq: number, ts: number) => JournalRow)[] + ): Promise { + return this.deps.serialize(() => { + assertJournalWritable(this.deps.readOnly(), this.deps.sessionId) + const first = this.deps.nextSequence() + const ts = this.deps.now() + const rows = plan().map((build, index) => build(first + index, ts)) + if (rows.length === 0) { + return rows + } + for (const row of rows) { + assertJournalFence(row.fence, this.deps.highestFence()) + } + try { + this.deps.database().transaction((db) => { + for (const row of rows) { + insertJournalRow(db, this.deps.sessionId, row) + this.runBookkeeping(db, row) + } + }) + } catch (error) { + this.deps.rolledBack?.() + throw error + } + for (const row of rows) { + this.deps.commit(row) + } + return rows + }) + } + /** Assign the next sequence, make the row durable, and fold it through the SAME reducer * replay uses — all inside one serialized step — answering where the row landed. */ append( diff --git a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts index 65ea665879b..ba879696469 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts @@ -92,6 +92,20 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal wroteBeforeOpen: (sequence) => host.journal().wroteBeforeOpen(sequence), committed: host.notifyCommitted }) + const rowWriter = new JournalRowWriter({ + sessionId: host.identity.sessionId, + now: host.now, + serialize: host.serialize, + database: host.database, + readOnly: host.readOnly, + highestFence: () => host.state().highestFence, + nextSequence: () => host.state().lastSequence + 1, + commit: host.commit, + // Every rejection is a dispatch row through this one writer; the draft + // returned-transition rides it so no path can bypass the hook. + inTransaction: (db, row) => queuedMessages.onRowInTransaction(db, row), + rolledBack: () => queuedMessages.invalidate() + }) return { epochController, queuedMessages, @@ -102,20 +116,7 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal restoreJournalStore(host, { epochController }).then(() => queuedMessages.repairAndPruneAtOpen() ), - rowWriter: new JournalRowWriter({ - sessionId: host.identity.sessionId, - now: host.now, - serialize: host.serialize, - database: host.database, - readOnly: host.readOnly, - highestFence: () => host.state().highestFence, - nextSequence: () => host.state().lastSequence + 1, - commit: host.commit, - // Every rejection is a dispatch row through this one writer; the draft - // returned-transition rides it so no path can bypass the hook. - inTransaction: (db, row) => queuedMessages.onRowInTransaction(db, row), - rolledBack: () => queuedMessages.invalidate() - }), + rowWriter, itemAppender: new JournalItemAppender({ state: host.state, enqueue: host.enqueue @@ -123,7 +124,8 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal lifecycleBatchAppender: new JournalLifecycleBatchAppender({ state: host.state, cursor: host.cursor, - enqueue: host.enqueue + enqueue: host.enqueue, + enqueueRows: (plan) => rowWriter.enqueueRows(plan) }) } } diff --git a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts index 85e6b99f902..cb86a7a8e5d 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts @@ -70,6 +70,10 @@ export type JournalLifecycleBatchInput = { mutations: readonly JournalLifecycleMutationInput[] fence: number recovered?: true + /** Rejects the sends still queued with this first, in the same append: a failed start's row + * follows the messages it failed, and no reader meets one without the other. With none still + * queued, the batch is not written either. */ + rejectsQueued?: AgentJournalDispatchRejection } export type JournalSubmissionInput = { diff --git a/src/main/native-chat/agent-session-journal/journal-submission-fold.ts b/src/main/native-chat/agent-session-journal/journal-submission-fold.ts index 2c9c6cfcd04..80e4e2c93e9 100644 --- a/src/main/native-chat/agent-session-journal/journal-submission-fold.ts +++ b/src/main/native-chat/agent-session-journal/journal-submission-fold.ts @@ -69,6 +69,28 @@ export function placeHandedOverMessage( }) } +/** A rejected message — queued, handed over, or sent directly — joins the conversation where it was + * rejected, in no turn: what happened before the rejection happened before it, and the newest page + * holds a recent one. Only a rejection: one in doubt may have reached the agent, so it stays. */ +export function placeRejectedMessage( + state: JournalReducerState, + submission: AgentJournalSubmission, + row: Extract +): void { + const itemId = agentJournalSubmissionKey(submission.clientMessageId) + const item = state.items.get(itemId) + if (row.state !== 'rejected' || !item) { + return + } + const { sequenceIndex: _placed, ...rest } = item + state.items.set(itemId, { + ...rest, + sequence: row.seq, + observedAt: row.ts, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) +} + export function acceptSubmissionFromProviderItem( state: JournalReducerState, providerItemId: string, diff --git a/src/main/native-chat/agent-session-journal/journal-turn-scope.test.ts b/src/main/native-chat/agent-session-journal/journal-turn-scope.test.ts index 2b8e4b80a76..4674f6de929 100644 --- a/src/main/native-chat/agent-session-journal/journal-turn-scope.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-turn-scope.test.ts @@ -163,7 +163,9 @@ describe('stated turn scope', () => { const expected = [agentJournalItemKey(row('result')), agentJournalSubmissionKey('held')] const onPhone = () => { const { items, submissions } = renderJournalState(state) - return projectStructuredAgentSessionMessages(items, [], submissions) + return projectStructuredAgentSessionMessages(items, [], submissions, { + rejectedInPlace: false + }) } // Still waiting: drawn after everything the agent did, the command's result included. expect(drawn(onPhone())).toEqual(expected) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts index c084d2b2e07..2dac4cacb7d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts @@ -241,16 +241,16 @@ describe('a start that never finishes (P2-15)', () => { providerChildPhase: 'starting' as const })) Object.assign(rig.host.deps.adapter, { awaitStarted: () => started.promise }) - const reject = AgentSessionJournal.prototype.rejectQueuedSubmissions - vi.spyOn(AgentSessionJournal.prototype, 'rejectQueuedSubmissions').mockImplementation(function ( + // The loop rejects the queued messages in the same append as its row. + const append = AgentSessionJournal.prototype.appendLifecycleBatch + vi.spyOn(AgentSessionJournal.prototype, 'appendLifecycleBatch').mockImplementation(function ( this: AgentSessionJournal, ...args ) { - // Not the open's sweep of an earlier process's leftovers. - if (args[1].rejection.kind !== 'hostRestarted') { - order.push(`rejected: ${args[1].reason}`) + if (args[0].rejectsQueued) { + order.push(`rejected: ${args[0].rejectsQueued.reason}`) } - return reject.apply(this, args) + return append.apply(this, args) }) const reader = collectSubscriber() const attached = await rig.host.attach(CALLER, hostTestAttachParams(null)) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-data.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-data.ts index 959a02919a2..a96388ac4ca 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-data.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-data.ts @@ -84,6 +84,8 @@ export function hostTestDrawnRowIds( state: 'dispatching' as const })) return projectNativeChatTranscriptMessages( - projectStructuredAgentSessionMessages(snapshot.items, outbox, snapshot.submissions) + projectStructuredAgentSessionMessages(snapshot.items, outbox, snapshot.submissions, { + rejectedInPlace: true + }) ).map(({ id }) => id) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts index 12fa05710f0..9206f95fde0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts @@ -42,9 +42,9 @@ export function hasStructuredAgentSessionStartFailureRow( } /** - * A start the delivery loop needed and did not get: the start's row, and every queued message - * rejected with the same words. Writes nothing when nothing is still queued: a start whose - * messages Stop withdrew did not fail anyone. + * A start the delivery loop needed and did not get: every queued message rejected with the same + * words, then the start's row. Writes nothing when nothing is still queued: a start whose messages + * Stop withdrew did not fail anyone. */ export async function recordStructuredAgentSessionStartFailure( session: Pick & { fence: number }, @@ -59,11 +59,8 @@ export async function recordStructuredAgentSessionStartFailure( settlementId: `start-failure:${startKey}`, fence: session.fence, recovered: true, - mutations: [structuredAgentSessionStartFailureRow(startKey, failure)] - }) - await session.journal.rejectQueuedSubmissions(session.fence, { - reason: failure.reason, - rejection: failure.rejection + mutations: [structuredAgentSessionStartFailureRow(startKey, failure)], + rejectsQueued: { reason: failure.reason, rejection: failure.rejection } }) } diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.provider-retry-runs.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.provider-retry-runs.test.tsx index eb71e3e6b54..db59afdb19d 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.provider-retry-runs.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.provider-retry-runs.test.tsx @@ -10,6 +10,8 @@ import { NativeChatMessageList } from './NativeChatMessageList' import { installNativeChatMessageListTestViewport } from './native-chat-message-list-test-viewport' import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection' +const NO_CARDS: readonly string[] = [] + let restoreViewport = (): void => {} beforeAll(() => { restoreViewport = installNativeChatMessageListTestViewport() @@ -42,7 +44,7 @@ function transcript(items: AgentJournalRenderItem[]) { return ( } const view = (items: AgentJournalRenderItem[]) => ( Promise.resolve('exhausted' as const) function Transcript({ items }: { items: AgentJournalRenderItem[] }) { - const messages = useStructuredAgentSessionMessages(items, EMPTY, EMPTY) + const messages = useStructuredAgentSessionMessages(items, EMPTY, EMPTY, EMPTY) const session: NativeChatLiveSession = { messages, status: 'working', 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 b01675c63a7..29f977046f6 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 @@ -88,7 +88,9 @@ function journalList( return ( {} beforeAll(() => { restoreViewport = installNativeChatMessageListTestViewport() @@ -135,7 +139,7 @@ function list(phase: Phase, scoped: boolean, outbox: StructuredAgentSessionOutbo return ( { + const LOST = agentJournalSubmissionKey('lost') + + function hostList(phase: Phase) { + const items = journal(phase, true) + const newIndex = items.findIndex((item) => item.itemId === NEW) + // Recorded between the seed's answer and the newer prompt; the next open rejected it. + items.splice(newIndex, 0, { + itemId: LOST, + revision: 0, + sequence: items[newIndex - 1]!.sequence, + sequenceIndex: 1, + observedAt: 1002.5, + body: said('user', 'LOST PROMPT'), + turnScope: { kind: 'thread' } + }) + const submissions: AgentJournalSubmission[] = [ + submission('seed', 'accepted'), + { + ...submission('lost', 'rejected'), + reason: DISPATCH_REJECTED_HOST_RESTARTED, + rejection: { kind: 'hostRestarted' } + }, + submission('new', phase === 'done' ? 'accepted' : 'pending') + ] + const settledTurns: NativeChatSettledTurns = new Map([ + [SEED, { startedAt: 1, workedSeconds: 3 }], + ...(phase === 'done' ? [[NEW, { startedAt: 2, workedSeconds: 5 }] as const] : []) + ]) + return ( + + ) + } + + it('draws it where it was sent, with its reason and no Retry, outside the newer turn', () => { + const { container, rerender } = render(hostList('running')) + expect(drawn(container)).toEqual([ + 'SEED PROMPT', + 'WORKED', + 'LOST PROMPT', + 'NEW PROMPT', + 'WORKING', + 'ACTIVITY' + ]) + expect(container.textContent).toContain('Orca restarted before this message was sent.') + expect(container.querySelector('button[aria-label="Retry"]')).toBeNull() + expect( + [...container.querySelectorAll('button')].map((button) => button.textContent) + ).not.toContain('Retry') + rerender(hostList('done')) + expect(drawn(container)).toEqual([ + 'SEED PROMPT', + 'WORKED', + 'LOST PROMPT', + 'NEW PROMPT', + 'WORKED' + ]) + expect(container.textContent).toContain('Worked for 5s') + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.start-failure-notice.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.start-failure-notice.test.tsx index 6f529c1d3c9..92548887f0a 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.start-failure-notice.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.start-failure-notice.test.tsx @@ -82,8 +82,8 @@ function rejected(clientMessageId: string, reason: string, rejection: AgentSessi } } +// The notices are the host's rows'; a copy an earlier session left in the outbox gives way to them. function renderPane(messages: ReturnType[]): void { - mocks.mode = 'outbox' mocks.submissions = messages.map((message) => message.submission) localStorage.setItem( `orca:desktopStructuredAgentSessionOutbox:v1:${encodeURIComponent(SESSION_ID)}`, @@ -113,8 +113,9 @@ async function notice(clientMessageId: string): Promise { }) } -// The start's own row says why, so its rejected messages say only that they were not sent. -it("says only 'not sent', with its Retry, on each message the failed start's row explains", async () => { +// The start's own row says why, so its rejected messages say only that they were not sent. Sending +// one again is a new message, so none offers a Retry. +it("says only 'not sent', with no Retry, on each message the failed start's row explains", async () => { mocks.journalItems = [startFailureRow(START_FAILED)] renderPane([ @@ -125,7 +126,7 @@ it("says only 'not sent', with its Retry, on each message the failed start's row for (const id of ['first', 'second']) { const row = await notice(id) expect(within(row).getByText('Your message was not sent.')).toBeTruthy() - expect(within(row).getByRole('button', { name: 'Retry' })).toBeTruthy() + expect(within(row).queryByRole('button', { name: 'Retry' })).toBeNull() } expect(screen.queryByText(/stopped before it finished starting/)).toBeNull() }) @@ -157,7 +158,5 @@ it('keeps the full notice on a message rejected for a reason no start-failure ro it("keeps the start failure's own words when its row is not loaded", async () => { renderPane([rejected('first', START_FAILED_REASON, START_FAILED)]) - expect( - within(await notice('first')).getByText('Claude stopped before it finished starting.') - ).toBeTruthy() + expect(within(await notice('first')).getByText(START_FAILED_REASON)).toBeTruthy() }) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx index 6e39ec0129d..ee876868c35 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx @@ -140,6 +140,7 @@ export function createStructuredSessionMocks() { }) => { mocks.controllerProps = props const outbox = useStructuredAgentSessionOutbox({ + journalItems: mocks.journalItems, sessionId: props.sessionId, target: props.target, fence: props.transportEnabled === false ? null : 1, @@ -150,7 +151,9 @@ export function createStructuredSessionMocks() { messages: mocks.messages ?? (mocks.mode === 'outbox' - ? projectStructuredAgentSessionMessages([], outbox.outbox, []) + ? projectStructuredAgentSessionMessages([], outbox.outbox, [], { + rejectedInPlace: true + }) : [ { id: 'message-1', diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index eca921ed086..e69056836c4 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useMemo, useRef, useState } from 'react' import { agentSessionPromptQuestions } from '../../../../shared/agent-session-question-answer' import { dispatchStructuredAgentSessionComposerCommand } from '../../../../shared/structured-agent-session-composer' import { structuredAgentSessionPaneKey } from '../../../../shared/structured-agent-session-projection' @@ -29,11 +29,7 @@ import { useAppStore } from '../../store' import { structuredAgentLabel } from '@/lib/structured-agent-session-launch-label' import { NativeChatThreadGoalBanner } from './NativeChatThreadGoalBanner' import { structuredAgentSessionReadFailureNotice } from './structured-agent-session-read-failure-notice' -import { useStructuredAgentSessionStartFailureFacts } from './use-structured-agent-session-start-failure-facts' -import { structuredAgentSessionDeliveryNotices } from './structured-agent-session-delivery-notices' -import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' - -const NO_SUBMISSIONS: readonly AgentJournalSubmission[] = [] +import { useStructuredAgentSessionDeliveryNotices } from './use-structured-agent-session-delivery-notices' export function NativeChatStructuredSession( props: Omit @@ -115,41 +111,16 @@ export function NativeChatStructuredSession( }), [controller, historyPhase, props.agent, props.sessionId] ) - // Read at click time, so the notices stay put while the outbox's Retry is rebuilt each render. - const retryRef = useRef(controller.retry) - useEffect(() => { - retryRef.current = controller.retry - }) - const retryDelivery = useCallback((clientMessageId: string) => { - retryRef.current(clientMessageId) - }, []) const agentLabel = structuredAgentLabel(props.agent === 'codex' ? 'codex' : 'claude') - // Only a rejected message reads the journal's rows, so a new batch of them re-renders no row else. - const hasRejected = controller.outbox.some((entry) => entry.state === 'rejected') - const rejectionRows = hasRejected ? controller.submissions : NO_SUBMISSIONS - const startFailures = useStructuredAgentSessionStartFailureFacts( - controller.journalItems, - hasRejected - ) - const deliveryNotices = useMemo( - () => - structuredAgentSessionDeliveryNotices( - controller.outbox, - agentLabel, - retryDelivery, - rejectionRows, - startFailures, - controller.failedHere - ), - [ - controller.outbox, - agentLabel, - retryDelivery, - rejectionRows, - startFailures, - controller.failedHere - ] - ) + const deliveryNotices = useStructuredAgentSessionDeliveryNotices({ + outbox: controller.outbox, + submissions: controller.submissions, + journalItems: controller.journalItems, + failedHere: controller.failedHere, + queuedMessageIds: controller.queuedMessageIds, + retry: controller.retry, + agentName: agentLabel + }) const viewState = selectNativeChatViewState(session, { readRetries: true }) // Nothing reads an unread history, so its pane stays blank beside the Retry line. const loadingPane = historyPhase === 'unread' ? null : diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx index c5553f00edb..64d47882740 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx @@ -26,6 +26,7 @@ const mocks = vi.hoisted(() => ({ }, questionCardProps: null as NativeChatQuestionCardProps | null, promptItems: [] as AgentJournalRenderItem[], + noJournalItems: Array.of(), respond: vi.fn(), handlePasteEvent: vi.fn(), pasteFromClipboard: vi.fn(), @@ -53,6 +54,7 @@ vi.mock('./use-structured-agent-session', async () => { target: { kind: 'local' } | { kind: 'environment'; environmentId: string } }) => { const outbox = useStructuredAgentSessionOutbox({ + journalItems: mocks.noJournalItems, sessionId: props.sessionId, target: props.target, fence: 1, @@ -62,7 +64,9 @@ vi.mock('./use-structured-agent-session', async () => { journalItems: [], messages: mocks.mode === 'outbox' - ? projectStructuredAgentSessionMessages([], outbox.outbox, []) + ? projectStructuredAgentSessionMessages([], outbox.outbox, [], { + rejectedInPlace: true + }) : [ { id: 'message-1', @@ -185,6 +189,8 @@ vi.mock('./NativeChatQuestionCard', () => ({ import { NativeChatStructuredSession } from './NativeChatStructuredSession' import { appendStructuredAgentSessionOutboxMessage } from './structured-agent-session-outbox-storage' +const REFUSED_RESTART = "The agent couldn't restart. Your message was not sent." + function useProbeClock(): void { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) } @@ -319,10 +325,11 @@ describe('NativeChatStructuredSession delivery', () => { }) seedOutbox('session-held-rejected', [ seededEntry('session-held-rejected', 'op-head', 'first', 'unconfirmed'), + // Refused before the host recorded it, so its Retry is the only way it goes again. { ...seededEntry('session-held-rejected', 'op-rejected', 'second', 'queued'), state: 'rejected', - lastFailure: { kind: 'rejected', reason: 'Claude messages support at most 20 images' } + lastFailure: { kind: 'refused', code: 'agent_session_owner_restart_failed' } } ]) @@ -338,7 +345,7 @@ describe('NativeChatStructuredSession delivery', () => { ) await waitFor(() => expect(screen.getByText('Message delivery is unconfirmed.')).toBeTruthy()) - expect(screen.getByText('Claude messages support at most 20 images')).toBeTruthy() + expect(screen.getByText(REFUSED_RESTART)).toBeTruthy() // One Retry, the stopped message's: it sends only that one. fireEvent.click(screen.getByRole('button', { name: /Retry/ })) await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) @@ -348,27 +355,38 @@ describe('NativeChatStructuredSession delivery', () => { // The queue moved, so the rejected message offers its own Retry again. await waitFor(() => expect(screen.queryByText('Message delivery is unconfirmed.')).toBeNull()) - expect(screen.getByText('Claude messages support at most 20 images')).toBeTruthy() + expect(screen.getByText(REFUSED_RESTART)).toBeTruthy() expect(screen.getAllByRole('button', { name: /Retry/ })).toHaveLength(1) expect(mocks.call).toHaveBeenCalledOnce() }) - it("words a failed start on each message by the chat's agent, leaving the resend to its Retry", async () => { + // Until the journal carries the row, the message's own copy words it; the host has it, so no Retry. + it("words a failed start its reply rejected by the chat's agent, with no Retry", async () => { mocks.mode = 'outbox' mocks.submissions = [] - const startFailed = (clientMessageId: string, text: string) => ({ - ...seededEntry('session-start-failed', clientMessageId, text, 'queued'), - state: 'rejected' as const, - lastFailure: { - kind: 'rejected' as const, - reason: 'Codex stopped before it finished starting. Send your message to try again.', - rejection: { kind: 'providerStartFailed' as const } - } - }) - seedOutbox('session-start-failed', [ - startFailed('op-first', 'first'), - startFailed('op-second', 'second') - ]) + mocks.call.mockImplementation( + async ( + _target: unknown, + _method: unknown, + params: { envelope: { clientOperationId: string } } + ) => ({ + ok: true, + value: { + clientMessageId: params.envelope.clientOperationId, + submission: { + clientMessageId: params.envelope.clientOperationId, + fence: 1, + payloadFingerprint: 'fingerprint', + dispatchState: 'rejected', + providerItemId: null, + reason: 'Codex stopped before it finished starting. Send your message to try again.', + rejection: { kind: 'providerStartFailed' }, + submittedAt: 1, + resolvedAt: 2 + } + } + }) + ) render( { /> ) + const send = mocks.composerProps?.structuredTransport?.send as + | ((text: string, attachments: readonly { id: string; path: string }[]) => boolean) + | undefined + expect(send?.('first', [])).toBe(true) await waitFor(() => - expect(screen.getAllByText('Codex stopped before it finished starting.')).toHaveLength(2) + expect( + screen.getByText( + 'Codex stopped before it finished starting. Send your message to try again.' + ) + ).toBeTruthy() ) - expect(screen.getAllByRole('button', { name: /Retry/ })).toHaveLength(2) - expect(screen.queryByText(/Send your message to try again/)).toBeNull() + expect(screen.queryByRole('button', { name: /Retry/ })).toBeNull() + expect(mocks.call).toHaveBeenCalledOnce() }) - it("words a rejected message from its loaded journal row, not the message's own copy", async () => { - mocks.mode = 'outbox' + // A copy an earlier session left in the outbox gives way to the host's row. + it("words a rejected message from its journal row, not the message's own copy, with no Retry", async () => { const reason = "Claude couldn't start. Send your message to try again." mocks.submissions = [ { @@ -430,6 +456,7 @@ describe('NativeChatStructuredSession delivery', () => { expect(screen.getByText("Codex couldn't start. Start a new chat to continue.")).toBeTruthy() ) expect(screen.queryByText(reason)).toBeNull() + expect(screen.queryByRole('button', { name: /Retry/ })).toBeNull() }) it('names the stuck message behind an admitted head, and its Retry sends that one', async () => { diff --git a/src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts b/src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts index e5254b63e94..16c9a30a397 100644 --- a/src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts @@ -19,6 +19,8 @@ import { buildNativeChatTranscriptSlots } from './native-chat-transcript-slots' import type { NativeChatTurnDiff } from './native-chat-turn-diffs' import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection' +const NO_CARDS: readonly string[] = [] + function row(sequence: number, body: AgentJournalItemBody, itemId = `item-${sequence}`) { return { itemId, revision: 1, sequence, observedAt: 1_000 + sequence, body } } @@ -68,7 +70,7 @@ const JOURNAL: AgentJournalRenderItem[] = [ /** The renderer's own path from journal items to rail items, as the list runs it. */ function loadedRailItems(items: AgentJournalRenderItem[], submissions: AgentJournalSubmission[]) { const projected = createNativeChatMessageListProjection()( - projectStructuredAgentSessionMessages(items, [], submissions) + projectStructuredAgentSessionMessages(items, [], submissions, NO_CARDS) ).conversation const messages = omitNativeChatThreadGoalRows(projectNativeChatTaskListFrames(projected)) let turn: string | undefined @@ -93,9 +95,14 @@ function loadedRailItems(items: AgentJournalRenderItem[], submissions: AgentJour } describe('conversation outline parity with the loaded rail', () => { + // Except a rejected message: the desktop draws it in place and ticks it once loaded, while the + // host's outline, which older clients read too, leaves it out. it('lists exactly the user messages the transcript gives a rail tick, with the same ids and previews', () => { const outline = projectAgentSessionConversationOutline(JOURNAL, [REJECTED]) - const loaded = loadedRailItems(JOURNAL, [REJECTED]) + const rejectedId = agentJournalSubmissionKey(REJECTED.clientMessageId) + const loadedWithRejected = loadedRailItems(JOURNAL, [REJECTED]) + expect(loadedWithRejected.filter((item) => item.id === rejectedId)).toHaveLength(1) + const loaded = loadedWithRejected.filter((item) => item.id !== rejectedId) expect(outline.map((entry) => entry.itemId)).toEqual(loaded.map((item) => item.id)) expect( diff --git a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts index 5dde72621ad..9e3cfedbdd1 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts @@ -56,7 +56,8 @@ function texts( } describe('the notice on each message that did not go through', () => { - it('gives two failed messages each their own reason and their own Retry', () => { + // Recorded by the host, so sending one again is a new message: no Retry. + it('gives two messages the host rejected each their own reason and no Retry', () => { const retry = vi.fn() const notices = structuredAgentSessionDeliveryNotices( [ @@ -77,7 +78,7 @@ describe('the notice on each message that did not go through', () => { retry, [], [], - NOT_FAILED_HERE + new Set(['first', 'second']) ) expect([...notices.keys()]).toEqual([ @@ -90,8 +91,47 @@ describe('the notice on each message that did not go through', () => { expect(notices.get(agentJournalSubmissionKey('second'))?.text).toBe( 'Claude never finished starting, so Orca stopped it.' ) - notices.get(agentJournalSubmissionKey('second'))?.onRetry?.() - expect(retry).toHaveBeenCalledExactlyOnceWith('second') + expect(notices.get(agentJournalSubmissionKey('first'))?.onRetry).toBeUndefined() + expect(notices.get(agentJournalSubmissionKey('second'))?.onRetry).toBeUndefined() + }) + + // However this chat learned of it: the host recorded it, so it has no control at all. + it('gives a message the host rejected before this chat opened no Retry and no Dismiss', () => { + const retry = vi.fn() + const notice = structuredAgentSessionDeliveryNotices( + [ + entry('earlier', { + state: 'rejected', + lastFailure: { kind: 'rejected', reason: 'Claude messages support at most 20 images' } + }) + ], + 'Claude', + retry, + [], + [], + NOT_FAILED_HERE + ).get(agentJournalSubmissionKey('earlier')) + expect(notice).toEqual({ text: 'Claude messages support at most 20 images' }) + expect(retry).not.toHaveBeenCalled() + }) + + // Refused before the host recorded it: only its Retry sends it, so it keeps one. + it('keeps the Retry on a message refused before the host recorded it', () => { + const notice = structuredAgentSessionDeliveryNotices( + [ + entry('refused', { + state: 'rejected', + lastFailure: { kind: 'refused', code: 'agent_session_owner_restart_failed' } + }) + ], + 'Claude', + () => {}, + [], + [], + NOT_FAILED_HERE + ).get(agentJournalSubmissionKey('refused')) + expect(notice?.onRetry).toBeDefined() + expect(notice?.onDismiss).toBeUndefined() }) it('chooses the words from the saved refusal on a refused message', () => { @@ -223,39 +263,15 @@ describe('the notice on each message that did not go through', () => { expect(retry.mock.calls).toEqual([['refused'], ['rejected'], ['stuck']]) }) - // Beside its own Retry the resend step is the button; without one the words keep it. - it('leaves out sending again only where the message has its own Retry', () => { - const startFailed = (clientMessageId: string): StructuredAgentSessionOutboxEntry => - entry(clientMessageId, { - state: 'rejected', - lastFailure: { - kind: 'rejected', - reason: 'Claude stopped before it finished starting. Send your message to try again.', - rejection: { kind: 'providerStartFailed' } - } - }) - expect(texts([startFailed('first'), startFailed('second')])).toEqual({ - [agentJournalSubmissionKey('first')]: 'Claude stopped before it finished starting.', - [agentJournalSubmissionKey('second')]: 'Claude stopped before it finished starting.' - }) - expect(texts([entry('held', { outlivedStop: true }), startFailed('rejected')])).toMatchObject({ - [agentJournalSubmissionKey('rejected')]: - 'Claude stopped before it finished starting. Send your message to try again.' - }) - }) - + // With no Retry beside it, the words keep the resend step. it.each([ [ - 'notDelivered', - 'This message was not delivered. Send it again to continue.', - 'This message was not delivered.' + 'providerStartFailed', + 'Claude stopped before it finished starting. Send your message to try again.' ], - [ - 'hostFault', - "Orca ran into a problem, so this didn't go through. Try again.", - "Orca ran into a problem, so this didn't go through." - ] - ] as const)('leaves the step to the Retry beside a %s message', (kind, reason, shown) => { + ['notDelivered', 'This message was not delivered. Send it again to continue.'], + ['hostFault', "Orca ran into a problem, so this didn't go through. Try again."] + ] as const)('keeps the step in the words of a %s message the host rejected', (kind, reason) => { expect( texts([ entry('rejected', { @@ -263,7 +279,7 @@ describe('the notice on each message that did not go through', () => { lastFailure: { kind: 'rejected', reason, rejection: { kind } } }) ]) - ).toEqual({ [agentJournalSubmissionKey('rejected')]: shown }) + ).toEqual({ [agentJournalSubmissionKey('rejected')]: reason }) }) // The journal holds the whole fact; the message's own copy keeps only its kind and attachment. @@ -281,7 +297,7 @@ describe('the notice on each message that did not go through', () => { const recorded = (id: string, rejection: AgentSessionFailureFact): AgentJournalSubmission => ({ clientMessageId: id, fence: 1, - payloadFingerprint: 'fingerprint', + payloadFingerprint: id, dispatchState: 'rejected', providerItemId: null, reason: "The agent couldn't be started.", @@ -312,7 +328,8 @@ describe('the notice on each message that did not go through', () => { [ 'resumable', { kind: 'startFailed', refusal: { code: 'agent_session_ownership_unknown' } }, - "Claude couldn't start." + // No Retry beside it, so the words keep the step. + "Claude couldn't start. Send your message to try again." ], [ 'provider', @@ -392,7 +409,7 @@ describe('the notice on each message that did not go through', () => { { clientMessageId: 'recorded', fence: 1, - payloadFingerprint: 'fingerprint', + payloadFingerprint: 'recorded', dispatchState: 'rejected', providerItemId: null, reason, diff --git a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts index 6ad655e8f80..1ef4067357d 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts @@ -7,10 +7,11 @@ // rejected or refused message holds nothing up, so it keeps its words and gets its Retry once the // queue moves. // -// A message the host recorded and then rejected is worded from the journal's own fact, found by id; -// the message keeps only a smaller copy, read when its submission is not loaded. A rejection that -// is a failed start's, the fact its loaded row states, says only that it was not sent: the row -// already says why. +// A message the host recorded and then rejected is drawn from the host's history, worded from the +// journal's own fact, with no Retry: sending it again is a new message. A rejection that is a +// failed start's, the fact its loaded row states, says only that it was not sent: the row already +// says why. Until its row loads, the outbox draws it from its smaller copy, which leaves on the batch +// or page that loads the row. import { readAgentSessionFailureFact, @@ -26,6 +27,7 @@ import { agentSessionWriteNotDoneParts } from '../../../../shared/agent-session- import { isStructuredAgentSessionStartFailureRow } from '../../../../shared/structured-agent-session-start-failure-row-key' import { structuredAgentSessionEntryIdExpired, + structuredAgentSessionEntryRejectedByHost, type StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' import { @@ -33,11 +35,17 @@ import { structuredAgentSessionEntryHeldForRetry } from '../../../../shared/structured-agent-session-outbox-admission' import type { AgentSessionFailureWordsContext } from '../../../../shared/agent-session-failure-words' -import { structuredAgentSessionAttemptFailureParts } from '../../../../shared/structured-agent-session-send-disposition' +import { + structuredAgentSessionAttemptFailureParts, + structuredAgentSessionRejectionParts +} from '../../../../shared/structured-agent-session-send-disposition' +import { structuredAgentSessionRejectedShownInPlace } from '../../../../shared/structured-agent-session-message-projection' import { translate } from '@/i18n/i18n' import { agentSessionWriteNoticeText } from './agent-session-write-notice-text' import type { NativeChatDeliveryNotice } from './NativeChatMessageRow' +const NO_COMMANDS: ReadonlySet = new Set() + /** The facts the chat's loaded start-failure rows state. */ export function structuredAgentSessionStartFailureFacts( items: readonly AgentJournalRenderItem[] @@ -87,8 +95,6 @@ export function agentSessionFailureStatedByStartRow( function deliveryNoticeText( entry: StructuredAgentSessionOutboxEntry, context: AgentSessionFailureWordsContext, - recorded: AgentJournalSubmission | undefined, - startFailures: readonly AgentSessionFailureFact[], failedHere: ReadonlySet ): string { // A send attempted before a Stop and then interrupted may already be with the host. @@ -114,17 +120,25 @@ function deliveryNoticeText( if (structuredAgentSessionEntryHeldForRetry(entry) && !failedHere.has(entry.clientMessageId)) { return agentSessionWriteNoticeText(agentSessionWriteNotDoneParts('send')) } - if ( - entry.state === 'rejected' && - agentSessionFailureStatedByStartRow(recorded?.rejection, startFailures) - ) { + return agentSessionWriteNoticeText( + structuredAgentSessionAttemptFailureParts(entry.lastFailure, context) + ) +} + +function hostRejectionNoticeText( + submission: AgentJournalSubmission, + agentName: string, + startFailures: readonly AgentSessionFailureFact[] +): string { + if (agentSessionFailureStatedByStartRow(submission.rejection, startFailures)) { return agentSessionWriteNoticeText(agentSessionWriteNotDoneParts('send')) } return agentSessionWriteNoticeText( - structuredAgentSessionAttemptFailureParts( - entry.lastFailure, - context, - readWholeAgentSessionFailureFact(recorded?.rejection) + structuredAgentSessionRejectionParts( + submission.reason, + 'send', + readWholeAgentSessionFailureFact(submission.rejection), + { agentName } ) ) } @@ -140,7 +154,11 @@ export function structuredAgentSessionDeliveryNotices( /** What the loaded start-failure rows state, from `structuredAgentSessionStartFailureFacts`. */ startFailures: readonly AgentSessionFailureFact[], /** Ids whose send failed or was refused while this chat was open: only they word their cause. */ - failedHere: ReadonlySet + failedHere: ReadonlySet, + /** The queue's live cards, which the transcript leaves a rejected message to. */ + queuedMessageIds: readonly string[] = [], + /** The loaded commands, from `structuredAgentSessionCommandItemIds`: they report their own. */ + commandItemIds: ReadonlySet = NO_COMMANDS ): ReadonlyMap { const admission = admitStructuredAgentSessionOutboxEntry(outbox) const held = admission.state === 'blocked' ? admission.entry.clientMessageId : null @@ -157,20 +175,29 @@ export function structuredAgentSessionDeliveryNotices( structuredAgentSessionEntryHeldForRetry(entry) || entry.clientMessageId === held ) { - // Its own Retry is the step, so the words leave out sending again. - const retryControl = stalledFrom === -1 || index <= stalledFrom - const text = deliveryNoticeText( - entry, - { agentName, retryControl }, - rejected.get(entry.clientMessageId), - startFailures, - failedHere - ) + // Its own Retry is the step, so the words leave out sending again. One the host recorded is + // the host's: sending it again is a new message, so it has no Retry. + const retryControl = + (stalledFrom === -1 || index <= stalledFrom) && + !structuredAgentSessionEntryRejectedByHost(entry) + const text = deliveryNoticeText(entry, { agentName, retryControl }, failedHere) notices.set( agentJournalSubmissionKey(entry.clientMessageId), retryControl ? { text, onRetry: () => retry(entry.clientMessageId) } : { text } ) } } + // After the outbox's: in the host's words, whether its row or the outbox's copy draws it. + const shown = structuredAgentSessionRejectedShownInPlace( + submissions, + queuedMessageIds, + commandItemIds + ) + for (const submission of rejected.values()) { + const id = agentJournalSubmissionKey(submission.clientMessageId) + if (shown.has(id)) { + notices.set(id, { text: hostRejectionNoticeText(submission, agentName, startFailures) }) + } + } return notices } diff --git a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.older-host.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.older-host.test.ts new file mode 100644 index 00000000000..7cc13a0a4eb --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.older-host.test.ts @@ -0,0 +1,161 @@ +// An older host leaves a rejected message where it was sent. When that is older than the loaded +// window, the chat holds its rejected submission but not its row: the outbox copy keeps drawing it, +// with no control, until the page holding the row loads, and then the host's row draws it instead. + +import { expect, it } from 'vitest' +import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../../shared/agent-session-journal-types' +import type { AgentSessionHistoryPage } from '../../../../shared/agent-session-wire' +import { DISPATCH_REJECTED_HOST_RESTARTED } from '../../../../shared/structured-agent-session-dispatch-rejection' +import { reconcileStructuredAgentSessionOutboxWithQueue } from '../../../../shared/structured-agent-session-draft-hand-off' +import { createStructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' +import { + EMPTY_STRUCTURED_AGENT_SESSION, + reduceStructuredAgentSession +} from '../../../../shared/structured-agent-session-reducer' +import { structuredAgentSessionDeliveryNotices } from './structured-agent-session-delivery-notices' +import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection' + +const NO_CARDS: readonly string[] = [] +const MESSAGE_ID = agentJournalSubmissionKey('m') + +function answer(sequence: number): AgentJournalRenderItem { + return { + itemId: `a-${sequence}`, + revision: 1, + sequence, + observedAt: sequence, + body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'ok' }] } + } +} + +/** Where the older host left it: at its submission, far behind the loaded window. */ +const SENT: AgentJournalRenderItem = { + itemId: MESSAGE_ID, + revision: 1, + sequence: 10, + observedAt: 10, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'queued msg' }] } +} + +const REJECTED: AgentJournalSubmission = { + clientMessageId: 'm', + fence: 1, + payloadFingerprint: 'x', + dispatchState: 'rejected', + providerItemId: null, + reason: DISPATCH_REJECTED_HOST_RESTARTED, + rejection: { kind: 'hostRestarted' }, + submittedAt: 10, + resolvedAt: 2000 +} + +function page( + items: AgentJournalRenderItem[], + submissions: AgentJournalSubmission[], + hasOlder: boolean +): AgentSessionHistoryPage { + const oldest = items[0]?.sequence ?? 0 + const newest = items.at(-1)?.sequence ?? 0 + return { + sessionId: 's', + epoch: 'e', + direction: 'tail', + items, + removedItemIds: [], + submissions, + window: { + oldest: { epoch: 'e', sequence: oldest }, + newest: { epoch: 'e', sequence: newest }, + nextCursor: { epoch: 'e', sequence: oldest } + }, + liveCursor: { epoch: 'e', sequence: 1099 }, + hasOlder, + hasNewer: false + } +} + +it("keeps the outbox copy of a rejected message whose row is outside the window, then the host's row takes over", () => { + let state = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, { + type: 'event', + event: { + type: 'snapshot', + sessionId: 's', + fence: 1, + page: page( + Array.from({ length: 100 }, (_, index) => answer(1000 + index)), + [], + true + ) + } + }) + // The rejection touches the row, which the older host left at its submission. + state = reduceStructuredAgentSession(state, { + type: 'event', + event: { + type: 'batch', + sessionId: 's', + batch: { + cursor: { epoch: 'e', sequence: 1100 }, + items: [SENT], + removedItemIds: [], + submissions: [REJECTED] + } + } + }) + expect(state.items.some((item) => item.itemId === MESSAGE_ID)).toBe(false) + expect(state.submissions.map((submission) => submission.clientMessageId)).toEqual(['m']) + + const sent = { + ...createStructuredAgentSessionOutboxEntry({ + clientMessageId: 'm', + sessionId: 's', + text: 'queued msg', + attachments: [], + queuedAt: 10 + }), + state: 'dispatching' as const + } + const kept = reconcileStructuredAgentSessionOutboxWithQueue( + [sent], + state.submissions, + state.items + ) + expect(kept).toMatchObject([{ clientMessageId: 'm', state: 'rejected' }]) + const drawn = (outbox: typeof kept) => + projectStructuredAgentSessionMessages(state.items, outbox, state.submissions, NO_CARDS) + .filter((message) => message.role === 'user') + .map(({ id, unsent }) => ({ id, unsent })) + expect(drawn(kept)).toEqual([{ id: MESSAGE_ID, unsent: true }]) + const notice = structuredAgentSessionDeliveryNotices( + kept, + 'Claude', + () => {}, + state.submissions, + [], + new Set() + ).get(MESSAGE_ID) + expect(notice).toEqual({ text: 'Orca restarted before this message was sent.' }) + + // Paging back loads the row: the outbox lets go, and the host's row is the one drawn. + state = reduceStructuredAgentSession(state, { + type: 'older-page', + requestedCursor: { epoch: 'e', sequence: 1000 }, + page: page( + [SENT, ...Array.from({ length: 10 }, (_, index) => answer(990 + index))], + [REJECTED], + false + ) + }) + expect(state.items.some((item) => item.itemId === MESSAGE_ID)).toBe(true) + const released = reconcileStructuredAgentSessionOutboxWithQueue( + kept, + state.submissions, + state.items + ) + expect(released).toEqual([]) + expect(drawn(released)).toEqual([{ id: MESSAGE_ID, unsent: true }]) +}) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.recorded-copy.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.recorded-copy.test.ts new file mode 100644 index 00000000000..84e10ca18b7 --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.recorded-copy.test.ts @@ -0,0 +1,218 @@ +// An outbox copy of a message the host recorded and then rejected draws it only while the host's row +// is not loaded, and leaves, with no user action, on the batch or page that loads that row. It never +// offers a control: sending it again is a new message. + +import { expect, it } from 'vitest' +import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../../shared/agent-session-journal-types' +import type { AgentSessionHistoryPage } from '../../../../shared/agent-session-wire' +import { DISPATCH_REJECTED_HOST_RESTARTED } from '../../../../shared/structured-agent-session-dispatch-rejection' +import { reconcileStructuredAgentSessionOutboxWithQueue } from '../../../../shared/structured-agent-session-draft-hand-off' +import { + createStructuredAgentSessionOutboxEntry, + structuredAgentSessionRejectedFailure, + type StructuredAgentSessionOutboxEntry +} from '../../../../shared/structured-agent-session-outbox' +import { admitStructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox-admission' +import { + EMPTY_STRUCTURED_AGENT_SESSION, + reduceStructuredAgentSession, + type StructuredAgentSessionState +} from '../../../../shared/structured-agent-session-reducer' +import { structuredAgentSessionDeliveryNotices } from './structured-agent-session-delivery-notices' +import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection' + +const NO_CARDS: readonly string[] = [] +const MESSAGE_ID = agentJournalSubmissionKey('m') +const WORDS = 'Orca restarted before this message was sent.' + +function answer(sequence: number): AgentJournalRenderItem { + return { + itemId: `a-${sequence}`, + revision: 1, + sequence, + observedAt: sequence, + body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'ok' }] } + } +} + +function hostRow(sequence: number): AgentJournalRenderItem { + return { + itemId: MESSAGE_ID, + revision: 1, + sequence, + observedAt: sequence, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'sent text' }] } + } +} + +const REJECTED: AgentJournalSubmission = { + clientMessageId: 'm', + fence: 1, + payloadFingerprint: 'm', + dispatchState: 'rejected', + providerItemId: null, + reason: DISPATCH_REJECTED_HOST_RESTARTED, + rejection: { kind: 'hostRestarted' }, + submittedAt: 10, + resolvedAt: 1100 +} + +function copy(patch: Partial = {}) { + return { + ...createStructuredAgentSessionOutboxEntry({ + clientMessageId: 'm', + sessionId: 's', + text: 'sent text', + attachments: [], + queuedAt: 10 + }), + ...patch + } +} + +/** A copy the host's own reply already marked rejected, as the outbox stores it. */ +const RECORDED_COPY = copy({ + state: 'rejected', + lastFailure: structuredAgentSessionRejectedFailure(REJECTED) +}) + +function page( + items: AgentJournalRenderItem[], + submissions: AgentJournalSubmission[] +): AgentSessionHistoryPage { + const oldest = items[0]?.sequence ?? 0 + return { + sessionId: 's', + epoch: 'e', + direction: 'tail', + items, + removedItemIds: [], + submissions, + window: { + oldest: { epoch: 'e', sequence: oldest }, + newest: { epoch: 'e', sequence: items.at(-1)?.sequence ?? 0 }, + nextCursor: { epoch: 'e', sequence: oldest } + }, + liveCursor: { epoch: 'e', sequence: items.at(-1)?.sequence ?? 0 }, + hasOlder: true, + hasNewer: false + } +} + +function opened(items: AgentJournalRenderItem[], submissions: AgentJournalSubmission[]) { + return reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, { + type: 'event', + event: { type: 'snapshot', sessionId: 's', fence: 1, page: page(items, submissions) } + }) +} + +const WINDOW = Array.from({ length: 100 }, (_, index) => answer(1000 + index)) + +/** What the chat draws for this message, and the notice its row carries. */ +function shown( + state: StructuredAgentSessionState, + outbox: readonly StructuredAgentSessionOutboxEntry[] +) { + const kept = reconcileStructuredAgentSessionOutboxWithQueue( + outbox, + state.submissions, + state.items + ) + const rows = projectStructuredAgentSessionMessages(state.items, kept, state.submissions, NO_CARDS) + .filter((message) => message.role === 'user') + .map(({ id, unsent }) => ({ id, unsent })) + const notice = structuredAgentSessionDeliveryNotices( + kept, + 'Claude', + () => {}, + state.submissions, + [], + new Set() + ).get(MESSAGE_ID) + return { kept, rows, notice } +} + +it('leaves on the live batch that rejects it on a host that places the row at the rejection', () => { + let state = opened(WINDOW, []) + const sent = copy({ state: 'dispatching', lastAttemptAt: 9 }) + state = reduceStructuredAgentSession(state, { + type: 'event', + event: { + type: 'batch', + sessionId: 's', + batch: { + cursor: { epoch: 'e', sequence: 1100 }, + items: [hostRow(1100)], + removedItemIds: [], + submissions: [REJECTED] + } + } + }) + + expect(shown(state, [sent])).toEqual({ + kept: [], + rows: [{ id: MESSAGE_ID, unsent: true }], + notice: { text: WORDS } + }) +}) + +it('leaves on the page that opens the chat when that page holds the row', () => { + const state = opened([...WINDOW, hostRow(1100)], [REJECTED]) + + expect(shown(state, [RECORDED_COPY])).toEqual({ + kept: [], + rows: [{ id: MESSAGE_ID, unsent: true }], + notice: { text: WORDS } + }) +}) + +it('draws once, with no control, while its row is outside the window, and leaves when that page loads', () => { + // An older host leaves the row where it was sent; the live batch brings only its record. + let state = reduceStructuredAgentSession(opened(WINDOW, []), { + type: 'event', + event: { + type: 'batch', + sessionId: 's', + batch: { + cursor: { epoch: 'e', sequence: 1100 }, + items: [hostRow(10)], + removedItemIds: [], + submissions: [REJECTED] + } + } + }) + const before = shown(state, [copy({ state: 'dispatching', lastAttemptAt: 9 })]) + expect(before.kept).toMatchObject([{ clientMessageId: 'm', state: 'rejected' }]) + expect(before.rows).toEqual([{ id: MESSAGE_ID, unsent: true }]) + expect(before.notice).toEqual({ text: WORDS }) + + // Scrolling back loads the page; no control on the copy is involved. + state = reduceStructuredAgentSession(state, { + type: 'older-page', + requestedCursor: { epoch: 'e', sequence: 1000 }, + page: page( + [hostRow(10), ...Array.from({ length: 5 }, (_, index) => answer(995 + index))], + [REJECTED] + ) + }) + expect(shown(state, before.kept)).toEqual({ + kept: [], + rows: [{ id: MESSAGE_ID, unsent: true }], + notice: { text: WORDS } + }) +}) + +it('keeps a stored copy whose record is not held drawn, with no control, and never sends it', () => { + const state = opened(WINDOW, []) + const after = shown(state, [RECORDED_COPY]) + + expect(after.kept).toEqual([RECORDED_COPY]) + expect(after.rows).toEqual([{ id: MESSAGE_ID, unsent: true }]) + expect(after.notice).toEqual({ text: WORDS }) + // The drain passes over it: nothing it holds is ever on its way. + expect(admitStructuredAgentSessionOutboxEntry(after.kept)).toEqual({ state: 'idle', entry: null }) +}) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts new file mode 100644 index 00000000000..4e757705225 --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts @@ -0,0 +1,465 @@ +// A message the host accepted and then rejected stays in the desktop's chat where it was sent, +// marked not sent, from the host's own history: a crash can lose the outbox, never the host's row. + +import { describe, expect, it } from 'vitest' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../../shared/agent-session-journal-types' +import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key' +import { + DISPATCH_REJECTED_CANCELLED, + DISPATCH_REJECTED_HOST_RESTARTED +} from '../../../../shared/structured-agent-session-dispatch-rejection' +import { + projectStructuredAgentSessionMessages as projectShared, + structuredAgentSessionCommandItemIds +} from '../../../../shared/structured-agent-session-message-projection' +import { + createStructuredAgentSessionOutboxEntry, + type StructuredAgentSessionOutboxEntry +} from '../../../../shared/structured-agent-session-outbox' +import { structuredAgentSessionDeliveryNotices } from './structured-agent-session-delivery-notices' +import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection' + +const NO_CARDS: readonly string[] = [] + +const SESSION = 'session-1' + +function body(text: string) { + return { + kind: 'message' as const, + role: 'user' as const, + blocks: [{ type: 'text' as const, text }] + } +} + +// Same text, same fingerprint, as the host's body-only hash gives. +function fingerprint(text: string): string { + return `body:${text}` +} + +function userItem(id: string, sequence: number, text: string): AgentJournalRenderItem { + return { + itemId: agentJournalSubmissionKey(id), + revision: 1, + sequence, + observedAt: sequence, + body: body(text) + } +} + +function answer(sequence: number): AgentJournalRenderItem { + return { + itemId: `answer-${sequence}`, + revision: 1, + sequence, + observedAt: sequence, + body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'ok' }] } + } +} + +function submission( + id: string, + text: string, + submittedAt: number, + patch: Partial = {} +): AgentJournalSubmission { + return { + clientMessageId: id, + fence: 1, + payloadFingerprint: fingerprint(text), + dispatchState: 'accepted', + providerItemId: `provider-${id}`, + reason: null, + submittedAt, + resolvedAt: submittedAt, + ...patch + } +} + +/** Rejected on the next open after a crash, before the agent ever got it. */ +function restartRejected(id: string, text: string, submittedAt: number): AgentJournalSubmission { + return submission(id, text, submittedAt, { + dispatchState: 'rejected', + providerItemId: null, + reason: DISPATCH_REJECTED_HOST_RESTARTED, + rejection: { kind: 'hostRestarted' } + }) +} + +function withdrawn(id: string, text: string, submittedAt: number): AgentJournalSubmission { + return submission(id, text, submittedAt, { + dispatchState: 'rejected', + providerItemId: null, + reason: DISPATCH_REJECTED_CANCELLED, + rejection: { kind: 'cancelled' } + }) +} + +function outboxEntry( + id: string, + text: string, + patch: Partial = {} +): StructuredAgentSessionOutboxEntry { + return { + ...createStructuredAgentSessionOutboxEntry({ + clientMessageId: id, + sessionId: SESSION, + text, + attachments: [], + queuedAt: 50 + }), + ...patch + } +} + +function rows(messages: ReturnType) { + return messages + .filter((message) => message.role === 'user') + .map((message) => ({ + id: message.id, + text: message.blocks[0]?.type === 'text' ? message.blocks[0].text : null, + unsent: message.unsent ?? false + })) +} + +const SEED = submission('seed', 'seed', 1) +const SEED_ROWS = [userItem('seed', 1, 'seed'), answer(2)] + +describe('a message the host accepted and then rejected, on the desktop', () => { + it('stays where it was sent, as not sent, with no outbox entry left after a crash', () => { + const items = [...SEED_ROWS, userItem('lost', 3, 'fix the parser')] + const messages = projectStructuredAgentSessionMessages( + items, + [], + [SEED, restartRejected('lost', 'fix the parser', 3)], + NO_CARDS + ) + + expect(rows(messages)).toEqual([ + { id: agentJournalSubmissionKey('seed'), text: 'seed', unsent: false }, + { id: agentJournalSubmissionKey('lost'), text: 'fix the parser', unsent: true } + ]) + // Its place is the host's: the row keeps the journal position it was recorded at. + expect( + messages.find((message) => message.id === agentJournalSubmissionKey('lost')) + ).toMatchObject({ journalPosition: { sequence: 3, index: 0 }, source: 'transcript' }) + }) + + // An older host never moves it to its rejection: it stays at its submission, still drawn. + it('keeps the position an older host gave it, however far back', () => { + const later = Array.from({ length: 300 }, (_, index) => answer(4 + index)) + const items = [...SEED_ROWS, userItem('waited', 3, 'fix the parser'), ...later] + const messages = projectStructuredAgentSessionMessages( + items, + [], + [SEED, restartRejected('waited', 'fix the parser', 3)], + NO_CARDS + ) + + expect( + messages.find((message) => message.id === agentJournalSubmissionKey('waited')) + ).toMatchObject({ + unsent: true, + journalPosition: { sequence: 3, index: 0 } + }) + }) + + it('says why from the host fact, with no Retry', () => { + const notices = structuredAgentSessionDeliveryNotices( + [], + 'Claude', + () => {}, + [SEED, restartRejected('lost', 'fix the parser', 3)], + [], + new Set() + ) + + const notice = notices.get(agentJournalSubmissionKey('lost')) + expect(notice?.text).toBe('Orca restarted before this message was sent.') + expect(notice?.onRetry).toBeUndefined() + expect([...notices.keys()]).toEqual([agentJournalSubmissionKey('lost')]) + }) + + it("says only that it was not sent when the failed start's row already says why", () => { + const failedStart = submission('first', 'hello', 3, { + dispatchState: 'rejected', + providerItemId: null, + reason: 'Claude is not signed in.', + rejection: { kind: 'notSignedIn' } + }) + const notices = structuredAgentSessionDeliveryNotices( + [], + 'Claude', + () => {}, + [failedStart], + [{ kind: 'notSignedIn' }], + new Set() + ) + + expect(notices.get(agentJournalSubmissionKey('first'))).toEqual({ + text: 'Your message was not sent.' + }) + }) + + it('is hidden by a copy of the same body sent once its rejection was known', () => { + // An earlier build's Retry resent it under a new id, and that copy was delivered. + const items = [...SEED_ROWS, userItem('old', 3, 'retry me'), userItem('resent', 4, 'retry me')] + const submissions = [ + SEED, + restartRejected('old', 'retry me', 3), + submission('resent', 'retry me', 4) + ] + + expect(rows(projectStructuredAgentSessionMessages(items, [], submissions, NO_CARDS))).toEqual([ + { id: agentJournalSubmissionKey('seed'), text: 'seed', unsent: false }, + { id: agentJournalSubmissionKey('resent'), text: 'retry me', unsent: false } + ]) + }) + + it('stays when the same text was sent again before it was rejected', () => { + // "continue", sent twice on purpose; a restart rejected the first only after the second went. + const items = [...SEED_ROWS, userItem('first', 3, 'continue'), userItem('again', 4, 'continue')] + const submissions = [ + SEED, + { ...restartRejected('first', 'continue', 3), resolvedAt: 5 }, + submission('again', 'continue', 4) + ] + + expect(rows(projectStructuredAgentSessionMessages(items, [], submissions, NO_CARDS))).toEqual([ + { id: agentJournalSubmissionKey('seed'), text: 'seed', unsent: false }, + { id: agentJournalSubmissionKey('again'), text: 'continue', unsent: false }, + // Listed after the delivered rows; its journal position keeps its place. + { id: agentJournalSubmissionKey('first'), text: 'continue', unsent: true } + ]) + }) + + // The host re-delivers its own message under new ids; however close their times, one row stays. + it('keeps the last of several copies rejected in the same instant', () => { + const items = [...SEED_ROWS, userItem('a', 3, 'pointer'), userItem('b', 4, 'pointer')] + const submissions = [ + SEED, + restartRejected('a', 'pointer', 5), + restartRejected('b', 'pointer', 5) + ] + + expect(rows(projectStructuredAgentSessionMessages(items, [], submissions, NO_CARDS))).toEqual([ + { id: agentJournalSubmissionKey('seed'), text: 'seed', unsent: false }, + { id: agentJournalSubmissionKey('b'), text: 'pointer', unsent: true } + ]) + }) + + it('stays when the same body was only sent before it, or by a copy a Stop withdrew', () => { + const items = [ + ...SEED_ROWS, + userItem('first', 3, 'again'), + userItem('failed', 4, 'again'), + userItem('stopped', 5, 'again') + ] + const submissions = [ + SEED, + submission('first', 'again', 3), + restartRejected('failed', 'again', 4), + withdrawn('stopped', 'again', 5) + ] + + expect(rows(projectStructuredAgentSessionMessages(items, [], submissions, NO_CARDS))).toEqual([ + { id: agentJournalSubmissionKey('seed'), text: 'seed', unsent: false }, + { id: agentJournalSubmissionKey('first'), text: 'again', unsent: false }, + { id: agentJournalSubmissionKey('failed'), text: 'again', unsent: true } + ]) + }) + + // Its own reply reports the rejection, in the composer, as a command's. + it('is not drawn when it was a command such as /compact', () => { + const compact = { + ...userItem('compact', 3, '/compact'), + body: { ...body('/compact'), command: { name: 'compact' } } + } + const submissions = [SEED, restartRejected('compact', '/compact', 3)] + + expect( + rows( + projectStructuredAgentSessionMessages([...SEED_ROWS, compact], [], submissions, NO_CARDS) + ) + ).toEqual([{ id: agentJournalSubmissionKey('seed'), text: 'seed', unsent: false }]) + // One rule decides for the rows and the notices. + expect( + structuredAgentSessionDeliveryNotices( + [], + 'Claude', + () => {}, + submissions, + [], + new Set(), + NO_CARDS, + structuredAgentSessionCommandItemIds([...SEED_ROWS, compact]) + ).size + ).toBe(0) + }) + + it('keeps a message a Stop withdrew hidden: it went back to its sender', () => { + const items = [...SEED_ROWS, userItem('stopped', 3, 'never mind')] + const submissions = [SEED, withdrawn('stopped', 'never mind', 3)] + + expect(rows(projectStructuredAgentSessionMessages(items, [], submissions, NO_CARDS))).toEqual([ + { id: agentJournalSubmissionKey('seed'), text: 'seed', unsent: false } + ]) + expect( + structuredAgentSessionDeliveryNotices([], 'Claude', () => {}, submissions, [], new Set()).size + ).toBe(0) + }) +}) + +describe("one row per rejected message, the host's once it records the rejection", () => { + const items = [...SEED_ROWS, userItem('held', 3, 'host copy')] + // Fingerprinted from the host's own copy, so only the shared id ties the two rows together. + const rejected = restartRejected('held', 'host copy', 3) + const held = outboxEntry('held', 'outbox copy', { + state: 'rejected', + lastFailure: { + kind: 'rejected', + reason: DISPATCH_REJECTED_HOST_RESTARTED, + rejection: { kind: 'hostRestarted' } + } + }) + const heldRow = { id: agentJournalSubmissionKey('held'), text: 'outbox copy', unsent: true } + const hostRow = { id: agentJournalSubmissionKey('held'), text: 'host copy', unsent: true } + const seedRow = { id: agentJournalSubmissionKey('seed'), text: 'seed', unsent: false } + + it('the reply first: the outbox draws it, saying why, with no Retry', () => { + const messages = projectStructuredAgentSessionMessages(SEED_ROWS, [held], [SEED], NO_CARDS) + expect(rows(messages)).toEqual([seedRow, heldRow]) + const notice = structuredAgentSessionDeliveryNotices( + [held], + 'Claude', + () => {}, + [SEED], + [], + new Set(['held']) + ).get(heldRow.id) + expect(notice?.text).toBe('Orca restarted before this message was sent.') + expect(notice?.onRetry).toBeUndefined() + }) + + it("the journal first, or next: the host's row replaces the outbox copy at once", () => { + const dispatching = { ...held, state: 'dispatching' as const, lastFailure: undefined } + expect( + rows(projectStructuredAgentSessionMessages(SEED_ROWS, [dispatching], [SEED], NO_CARDS)) + ).toEqual([seedRow, { ...heldRow, unsent: false }]) + for (const entry of [dispatching, held]) { + // Before the reconcile drops the entry, the host's row is already the one row. + const messages = projectStructuredAgentSessionMessages( + items, + [entry], + [SEED, rejected], + NO_CARDS + ) + expect(rows(messages)).toEqual([seedRow, hostRow]) + expect(messages.at(-1)?.journalPosition).toEqual({ sequence: 3, index: 0 }) + const notices = structuredAgentSessionDeliveryNotices( + [entry], + 'Claude', + () => {}, + [SEED, rejected], + [], + new Set() + ) + // In the host's words, with no control. + expect([...notices]).toEqual([ + [hostRow.id, { text: 'Orca restarted before this message was sent.' }] + ]) + } + }) + + it("keeps the old row beside an earlier build's resend until the host records the resend", () => { + const hostItems = [...SEED_ROWS, userItem('held', 3, 'outbox copy')] + const resent = restartRejected('held', 'outbox copy', 3) + const resend = outboxEntry('resend', 'outbox copy') + expect( + rows(projectStructuredAgentSessionMessages(hostItems, [resend], [SEED, resent], NO_CARDS)) + ).toEqual([ + seedRow, + { id: agentJournalSubmissionKey('held'), text: 'outbox copy', unsent: true }, + { id: agentJournalSubmissionKey('resend'), text: 'outbox copy', unsent: false } + ]) + + const recorded = submission('resend', 'outbox copy', 60, { + dispatchState: 'pending', + providerItemId: null, + resolvedAt: null + }) + expect( + rows( + projectStructuredAgentSessionMessages( + [...hostItems, userItem('resend', 4, 'outbox copy')], + [resend], + [SEED, resent, recorded], + NO_CARDS + ) + ) + ).toEqual([ + seedRow, + { id: agentJournalSubmissionKey('resend'), text: 'outbox copy', unsent: false } + ]) + }) +}) + +describe('a rejected message the queue holds', () => { + const seedRow = { id: agentJournalSubmissionKey('seed'), text: 'seed', unsent: false } + + // A hand-off the host rejected sends its draft back to the card list, which shows the text. + it('is not drawn when it was a queued draft handed off', () => { + const items = [...SEED_ROWS, userItem('handoff', 3, 'queued text')] + const submissions = [ + SEED, + { ...restartRejected('handoff', 'queued text', 3), queuedMessageId: 'card-1' } + ] + + expect(rows(projectStructuredAgentSessionMessages(items, [], submissions, NO_CARDS))).toEqual([ + seedRow + ]) + expect( + structuredAgentSessionDeliveryNotices([], 'Claude', () => {}, submissions, [], new Set()).size + ).toBe(0) + }) + + // A send kept across a restart comes back as a paused card under its own id. + it('is not drawn while a card holds it under its id, and is drawn once that card is gone', () => { + const items = [...SEED_ROWS, userItem('kept', 3, 'kept text')] + const submissions = [SEED, restartRejected('kept', 'kept text', 3)] + + expect(rows(projectStructuredAgentSessionMessages(items, [], submissions, ['kept']))).toEqual([ + seedRow + ]) + expect( + structuredAgentSessionDeliveryNotices([], 'Claude', () => {}, submissions, [], new Set(), [ + 'kept' + ]).size + ).toBe(0) + expect(rows(projectStructuredAgentSessionMessages(items, [], submissions, []))).toEqual([ + seedRow, + { id: agentJournalSubmissionKey('kept'), text: 'kept text', unsent: true } + ]) + }) +}) + +describe('the phone', () => { + it('still hides every accepted-then-rejected message: it gives the text back to its composer', () => { + const items = [ + ...SEED_ROWS, + userItem('lost', 3, 'fix the parser'), + userItem('stopped', 4, 'never mind') + ] + const submissions = [ + SEED, + restartRejected('lost', 'fix the parser', 3), + withdrawn('stopped', 'never mind', 4) + ] + + expect(rows(projectShared(items, [], submissions, { rejectedInPlace: false }))).toEqual([ + { id: agentJournalSubmissionKey('seed'), text: 'seed', unsent: false } + ]) + }) +}) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.test.ts index 3a3562458cb..bd99e8f8cbb 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.test.ts @@ -7,6 +7,8 @@ import { agentJournalSubmissionKey } from '../../../../shared/agent-session-jour import { createStructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection' +const NO_CARDS: readonly string[] = [] + function submission(index: number): AgentJournalSubmission { return { clientMessageId: `client-${index}`, @@ -31,16 +33,8 @@ function item(index: number): AgentJournalRenderItem { } describe('structured agent session message projection', () => { - it('does not render a rejected host submission as a sent user message', () => { - const rejected = { ...submission(0), dispatchState: 'rejected' as const, providerItemId: null } - const refusedItem = { ...item(0), itemId: agentJournalSubmissionKey(rejected.clientMessageId) } - const acceptedItem = item(1) - expect( - projectStructuredAgentSessionMessages([refusedItem, acceptedItem], [], [rejected]) - ).toMatchObject([{ id: acceptedItem.itemId, role: 'user' }]) - }) - - it('keeps a refused local draft available through its outbox', () => { + // The host recorded it, so its row is the message from here; the outbox copy gives way. + it("draws a send the host rejected from the host's row, not the outbox copy", () => { const rejected = { ...submission(0), dispatchState: 'rejected' as const, providerItemId: null } const refusedItem = { ...item(0), itemId: agentJournalSubmissionKey(rejected.clientMessageId) } const draft = createStructuredAgentSessionOutboxEntry({ @@ -50,9 +44,15 @@ describe('structured agent session message projection', () => { attachments: [], queuedAt: 1 }) - expect(projectStructuredAgentSessionMessages([refusedItem], [draft], [rejected])).toMatchObject( - [{ id: refusedItem.itemId, blocks: [{ text: 'An unsent draft' }] }] - ) + expect( + projectStructuredAgentSessionMessages([refusedItem], [draft], [rejected], NO_CARDS) + ).toEqual([ + expect.objectContaining({ + id: refusedItem.itemId, + blocks: [{ type: 'text', text: 'send 0' }], + unsent: true + }) + ]) }) it.each([5, 10])('renders %i rapid accepted desktop sends exactly once', (sendCount) => { @@ -68,7 +68,8 @@ describe('structured agent session message projection', () => { const messages = projectStructuredAgentSessionMessages( Array.from({ length: sendCount }, (_, index) => item(index)), outbox, - Array.from({ length: sendCount }, (_, index) => submission(sendCount - index - 1)) + Array.from({ length: sendCount }, (_, index) => submission(sendCount - index - 1)), + NO_CARDS ) expect(messages.filter((message) => message.role === 'user')).toHaveLength(sendCount) @@ -103,8 +104,8 @@ describe('structured agent session message projection', () => { resolvedAt: null } - const messages = projectStructuredAgentSessionMessages([walItem], outbox, [pending]) - const optimistic = projectStructuredAgentSessionMessages([], outbox, []) + const messages = projectStructuredAgentSessionMessages([walItem], outbox, [pending], NO_CARDS) + const optimistic = projectStructuredAgentSessionMessages([], outbox, [], NO_CARDS) expect(messages.filter((message) => message.role === 'user')).toHaveLength(1) expect(messages.map((message) => message.id)).toEqual([walItem.itemId]) @@ -122,7 +123,7 @@ describe('structured agent session message projection', () => { }) ] - expect(projectStructuredAgentSessionMessages([], outbox, [])).toMatchObject([ + expect(projectStructuredAgentSessionMessages([], outbox, [], NO_CARDS)).toMatchObject([ { id: agentJournalSubmissionKey('client-pending'), role: 'user' } ]) }) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.ts b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.ts index 0ecfd7aa2f5..f527442c2b1 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.ts @@ -6,12 +6,22 @@ import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/struc import { projectStructuredAgentSessionMessages as projectMessages } from '../../../../shared/structured-agent-session-message-projection' import { projectStructuredQuestionMessages } from './structured-agent-question-projection' +/** The desktop's transcript: a message the host accepted and then rejected stays where it was + * sent, as not sent, unless a queued card holds it. */ export function projectStructuredAgentSessionMessages( items: readonly AgentJournalRenderItem[], outbox: readonly StructuredAgentSessionOutboxEntry[], - submissions: readonly AgentJournalSubmission[] + submissions: readonly AgentJournalSubmission[], + /** The queue's live cards; required, since a rejected message a card holds must not draw twice. */ + queuedMessageIds: readonly string[] ) { - return projectMessages(items, outbox, submissions, projectStructuredQuestionMessages) + return projectMessages( + items, + outbox, + submissions, + { rejectedInPlace: true, queuedMessageIds }, + projectStructuredQuestionMessages + ) } export type StructuredPromptItem = AgentJournalRenderItem & { diff --git a/src/renderer/src/components/native-chat/structured-agent-session-outbox-retry.ts b/src/renderer/src/components/native-chat/structured-agent-session-outbox-retry.ts index 73619e71759..d431cccb036 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-outbox-retry.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-outbox-retry.ts @@ -4,6 +4,7 @@ import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' import { structuredAgentSessionEntryIdExpired, + structuredAgentSessionEntryRejectedByHost, type StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' import { @@ -30,7 +31,7 @@ export function retryStructuredAgentSessionOutboxEntry(args: { // settled the message already rotated it. An expired id is refused for good; its row told the // user to check the chat first. const recordedRejection = - current?.state === 'rejected' && current.lastFailure?.kind === 'rejected' + current !== undefined && structuredAgentSessionEntryRejectedByHost(current) if ( current && (recordedRejection || diff --git a/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.ts b/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.ts index ce270d60ddb..cbc74133d7a 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.ts @@ -1,6 +1,7 @@ import { createStructuredAgentSessionOutboxEntry, parseStructuredAgentSessionOutboxEntry, + structuredAgentSessionEntryRejectedByHost, type StructuredAgentSessionAttachment, type StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' @@ -54,9 +55,14 @@ function publishUndelivered(sessionId: string, undelivered: boolean): void { } } +/** Whether any entry still owes a delivery: a copy the host recorded and rejected owes none. */ +function owesDelivery(entries: readonly StructuredAgentSessionOutboxEntry[]): boolean { + return entries.some((entry) => !structuredAgentSessionEntryRejectedByHost(entry)) +} + /** Keep the journal subscription alive while this session still owes delivery. */ export function hasUndeliveredStructuredAgentSessionOutbox(sessionId: string): boolean { - return undeliveredSessions.get(sessionId)?.undelivered ?? readOutbox(sessionId).length > 0 + return undeliveredSessions.get(sessionId)?.undelivered ?? owesDelivery(readOutbox(sessionId)) } export function subscribeToUndeliveredStructuredAgentSessionOutbox( @@ -65,7 +71,7 @@ export function subscribeToUndeliveredStructuredAgentSessionOutbox( ): () => void { let subscription = undeliveredSessions.get(sessionId) if (!subscription) { - subscription = { undelivered: readOutbox(sessionId).length > 0, listeners: new Set() } + subscription = { undelivered: owesDelivery(readOutbox(sessionId)), listeners: new Set() } undeliveredSessions.set(sessionId, subscription) } const owned = subscription @@ -92,7 +98,7 @@ export function writeOutbox( } else { localStorage.setItem(storageKey(sessionId), JSON.stringify(entries)) } - publishUndelivered(sessionId, entries.length > 0) + publishUndelivered(sessionId, owesDelivery(entries)) return true } catch { return false diff --git a/src/renderer/src/components/native-chat/structured-agent-session-transcript-order.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-transcript-order.test.ts index aa774f7fbf3..3b12ed7b3dc 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-transcript-order.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-transcript-order.test.ts @@ -13,6 +13,8 @@ import { import { createNativeChatMessageListProjection } from './native-chat-message-list-projection' import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection' +const NO_CARDS: readonly string[] = [] + function journalItem( itemId: string, sequence: number, @@ -43,7 +45,7 @@ function drawn( submissions: AgentJournalSubmission[] = [] ): string[] { return createNativeChatMessageListProjection()( - projectStructuredAgentSessionMessages(items, outbox, submissions) + projectStructuredAgentSessionMessages(items, outbox, submissions, NO_CARDS) ).conversation.map(({ id }) => id) } diff --git a/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.test.tsx b/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.test.tsx index 150b520bcd3..dde230110dd 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.test.tsx +++ b/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.test.tsx @@ -75,6 +75,15 @@ import { import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox' import { useStructuredAgentSession } from './use-structured-agent-session' +/** What these hooks render with: the journal's submissions, and the rows loaded so far. */ +type OutboxProps = { submissions: AgentJournalSubmission[]; rows?: AgentJournalRenderItem[] } + +function outboxProps(submissions: AgentJournalSubmission[]): OutboxProps { + return { submissions } +} + +const NO_JOURNAL_ITEMS: readonly AgentJournalRenderItem[] = [] + const SESSION = 'session-1' const PANE = 'tab-1::session-1' const OTHER_PANE = 'tab-2::session-1' @@ -129,15 +138,16 @@ function answerSendsPending(): void { function renderOutbox(composerScopeKey: string | null = PANE) { return renderHook( - (props: { submissions: AgentJournalSubmission[] }) => + (props: OutboxProps) => useStructuredAgentSessionOutbox({ + journalItems: props.rows ?? NO_JOURNAL_ITEMS, sessionId: SESSION, target, fence: 1, submissions: props.submissions, ...(composerScopeKey ? { composerScopeKey } : {}) }), - { initialProps: { submissions: NONE } } + { initialProps: outboxProps(NONE) } ) } @@ -259,7 +269,7 @@ describe('a message the host withdrew at a Stop', () => { expect(readNativeChatDraftCache(PANE)).toBe('hello\n\nhello') }) - it('keeps a message refused for any other reason on its Retry, and gives nothing back', async () => { + it("leaves a message rejected for any other reason to the host's row, and gives nothing back", async () => { answerSendsPending() const { result, rerender } = renderOutbox() const id = await sendToHost(result, 'hello') @@ -267,10 +277,19 @@ describe('a message the host withdrew at a Stop', () => { rerender({ submissions: [ submission(id, { dispatchState: 'rejected', reason: DISPATCH_REJECTED_WRITE_FAILED }) + ], + rows: [ + { + itemId: `orca:${id}`, + revision: 1, + sequence: 1, + observedAt: 1, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] } + } ] }) - await waitFor(() => expect(result.current.outbox[0]?.state).toBe('rejected')) + await waitFor(() => expect(result.current.outbox).toEqual([])) expect(readNativeChatDraftCache(PANE)).toBe('') }) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-delivery-notices.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-delivery-notices.test.tsx new file mode 100644 index 00000000000..28ef88564b9 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-delivery-notices.test.tsx @@ -0,0 +1,120 @@ +// @vitest-environment happy-dom + +import { cleanup, renderHook } from '@testing-library/react' +import { afterEach, expect, it } from 'vitest' +import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' +import { DISPATCH_REJECTED_CANCELLED } from '../../../../shared/structured-agent-session-dispatch-rejection' +import { useStructuredAgentSessionDeliveryNotices } from './use-structured-agent-session-delivery-notices' + +afterEach(cleanup) + +const NONE = new Set() +const NO_CARDS: readonly string[] = [] +const EMPTY: never[] = [] + +function rejected( + clientMessageId: string, + kind: 'hostRestarted' | 'notDelivered' +): AgentJournalSubmission { + return { + clientMessageId, + fence: 1, + payloadFingerprint: clientMessageId, + dispatchState: 'rejected', + providerItemId: null, + reason: null, + rejection: { kind }, + submittedAt: 1, + resolvedAt: 2 + } +} + +function accepted(clientMessageId: string): AgentJournalSubmission { + return { + clientMessageId, + fence: 1, + payloadFingerprint: clientMessageId, + dispatchState: 'accepted', + providerItemId: `provider-${clientMessageId}`, + reason: null, + submittedAt: 3, + resolvedAt: 4 + } +} + +function withdrawn(clientMessageId: string): AgentJournalSubmission { + return { + clientMessageId, + fence: 1, + payloadFingerprint: clientMessageId, + dispatchState: 'rejected', + providerItemId: null, + reason: DISPATCH_REJECTED_CANCELLED, + rejection: { kind: 'cancelled' }, + submittedAt: 1, + resolvedAt: 2 + } +} + +// A Stop's withdrawn message draws no row, so a chat that has one rebuilds no notice per batch. +it('keeps the same notices across batches in a chat whose only rejection a Stop withdrew', () => { + const { result, rerender } = renderHook( + ({ submissions }: { submissions: readonly AgentJournalSubmission[] }) => + useStructuredAgentSessionDeliveryNotices({ + outbox: EMPTY, + submissions, + journalItems: EMPTY, + failedHere: NONE, + queuedMessageIds: NO_CARDS, + retry: () => {}, + agentName: 'Claude' + }), + { initialProps: { submissions: [withdrawn('stopped')] } } + ) + const first = result.current + + rerender({ submissions: [withdrawn('stopped')] }) + + expect(result.current).toBe(first) + expect(result.current.size).toBe(0) +}) + +function renderNotices(submissions: readonly AgentJournalSubmission[]) { + return renderHook( + ({ submissions: current }: { submissions: readonly AgentJournalSubmission[] }) => + useStructuredAgentSessionDeliveryNotices({ + outbox: EMPTY, + submissions: current, + journalItems: EMPTY, + failedHere: NONE, + queuedMessageIds: NO_CARDS, + retry: () => {}, + agentName: 'Claude' + }), + { initialProps: { submissions } } + ) +} + +// A batch for another message rebuilds the journal's rows; the rows already marked not sent keep +// the same notices, so no row wrapper re-renders. +it('keeps the same notices when a batch leaves every not-sent message as it was', () => { + const { result, rerender } = renderNotices([rejected('lost', 'hostRestarted')]) + const first = result.current + expect(first.size).toBe(1) + + rerender({ submissions: [rejected('lost', 'hostRestarted'), accepted('next')] }) + + expect(result.current).toBe(first) +}) + +it('keeps the notice of a row that did not change when another one does', () => { + const { result, rerender } = renderNotices([rejected('lost', 'hostRestarted')]) + const lost = result.current.get('orca:lost') + + rerender({ + submissions: [rejected('lost', 'hostRestarted'), rejected('other', 'notDelivered')] + }) + + expect(result.current.size).toBe(2) + expect(result.current.get('orca:lost')).toBe(lost) +}) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-delivery-notices.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-delivery-notices.ts new file mode 100644 index 00000000000..26cdd00fb81 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-delivery-notices.ts @@ -0,0 +1,120 @@ +import { useCallback, useEffect, useMemo, useRef } from 'react' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../../shared/agent-session-journal-types' +import { dispatchWasWithdrawn } from '../../../../shared/structured-agent-session-dispatch-rejection' +import { structuredAgentSessionCommandItemIds } from '../../../../shared/structured-agent-session-message-projection' +import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' +import { structuredAgentSessionDeliveryNotices } from './structured-agent-session-delivery-notices' +import { useStructuredAgentSessionStartFailureFacts } from './use-structured-agent-session-start-failure-facts' +import type { NativeChatDeliveryNotice } from './NativeChatMessageRow' + +const NO_SUBMISSIONS: readonly AgentJournalSubmission[] = [] + +/** The structured chat's delivery notices, by the message id each row renders under. */ +export function useStructuredAgentSessionDeliveryNotices(args: { + outbox: readonly StructuredAgentSessionOutboxEntry[] + submissions: readonly AgentJournalSubmission[] + journalItems: readonly AgentJournalRenderItem[] + failedHere: ReadonlySet + queuedMessageIds: readonly string[] + retry: (clientMessageId: string) => void + agentName: string +}): ReadonlyMap { + const { agentName, failedHere, outbox, queuedMessageIds, submissions } = args + // Read at click time, so the notices stay put while the outbox's Retry is rebuilt each render. + const retryRef = useRef(args.retry) + useEffect(() => { + retryRef.current = args.retry + }) + const retry = useCallback((clientMessageId: string) => { + retryRef.current(clientMessageId) + }, []) + // Only a message shown as not sent reads the journal's rows (a withdrawn one draws nothing), so + // in a chat without one a new batch of them re-renders no row. + const hasRejected = + outbox.some((entry) => entry.state === 'rejected') || + submissions.some( + (submission) => submission.dispatchState === 'rejected' && !dispatchWasWithdrawn(submission) + ) + const rejectionRows = hasRejected ? submissions : NO_SUBMISSIONS + const startFailures = useStructuredAgentSessionStartFailureFacts(args.journalItems, hasRejected) + const commandItemIds = useCommandItemIds(args.journalItems, hasRejected) + const notices = useMemo( + () => + structuredAgentSessionDeliveryNotices( + outbox, + agentName, + retry, + rejectionRows, + startFailures, + failedHere, + queuedMessageIds, + commandItemIds + ), + [ + outbox, + agentName, + retry, + rejectionRows, + startFailures, + failedHere, + queuedMessageIds, + commandItemIds + ] + ) + // A submission batch rebuilds the map; one that says the same keeps the old, so no row re-renders. + const previousRef = useRef(notices) + const stable = sameNoticesKept(previousRef.current, notices) + useEffect(() => { + previousRef.current = stable + }, [stable]) + return stable +} + +const NO_COMMANDS: ReadonlySet = new Set() + +/** The loaded commands, read only while `enabled`, and held while unchanged so a streaming turn + * rebuilds no notice. */ +function useCommandItemIds( + items: readonly AgentJournalRenderItem[], + enabled: boolean +): ReadonlySet { + const ids = useMemo( + () => (enabled ? structuredAgentSessionCommandItemIds(items) : NO_COMMANDS), + [enabled, items] + ) + const previousRef = useRef(ids) + const previous = previousRef.current + const stable = + previous.size === ids.size && [...ids].every((id) => previous.has(id)) ? previous : ids + useEffect(() => { + previousRef.current = stable + }, [stable]) + return stable +} + +/** `next`, reusing each notice `previous` words the same way, and `previous` itself when all are. */ +function sameNoticesKept( + previous: ReadonlyMap, + next: ReadonlyMap +): ReadonlyMap { + if (previous === next) { + return next + } + let allKept = previous.size === next.size + const kept = new Map() + for (const [id, notice] of next) { + const before = previous.get(id) + // Each Retry calls the stable `retry` with its own id, so one under the same key is the same. + const same = + before !== undefined && + before.text === notice.text && + (before.onRetry === undefined) === (notice.onRetry === undefined) && + (before.onDismiss === undefined) === (notice.onDismiss === undefined) + allKept &&= same + kept.set(id, same ? before : notice) + } + return allKept ? previous : kept +} diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-messages.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-messages.test.tsx index 68e388de58c..419e6a6cae9 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-messages.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-messages.test.tsx @@ -11,6 +11,7 @@ import { useStructuredAgentSessionMessages } from './use-structured-agent-sessio afterEach(cleanup) const EMPTY: never[] = [] +const NO_CARDS: readonly string[] = [] function tool(id: string, sequence: number): AgentJournalRenderItem { return { itemId: id, @@ -25,7 +26,8 @@ it('retains only unchanged item projections across updates, reorder, deletion, a const first = tool('first', 1) const second = tool('second', 2) const { result, rerender } = renderHook( - (items: AgentJournalRenderItem[]) => useStructuredAgentSessionMessages(items, EMPTY, EMPTY), + (items: AgentJournalRenderItem[]) => + useStructuredAgentSessionMessages(items, EMPTY, EMPTY, NO_CARDS), { initialProps: [first, second] } ) const initial = result.current @@ -50,7 +52,9 @@ it('retains only unchanged item projections across updates, reorder, deletion, a [structuredClone(completed)] ]) { rerender(items) - expect(result.current).toEqual(projectStructuredAgentSessionMessages(items, EMPTY, EMPTY)) + expect(result.current).toEqual( + projectStructuredAgentSessionMessages(items, EMPTY, EMPTY, NO_CARDS) + ) expect(result.current.find((message) => message.id === 'second')).not.toBe(initial[1]) } const replacement = { @@ -62,7 +66,9 @@ it('retains only unchanged item projections across updates, reorder, deletion, a } } rerender([replacement]) - expect(result.current).toEqual(projectStructuredAgentSessionMessages([replacement], EMPTY, EMPTY)) + expect(result.current).toEqual( + projectStructuredAgentSessionMessages([replacement], EMPTY, EMPTY, NO_CARDS) + ) expect(result.current[0]).not.toBe(initial[0]) }) @@ -91,14 +97,14 @@ it('keeps optimistic sends and their settlement identical to uncached projection }: { items: AgentJournalRenderItem[] submissions: AgentJournalSubmission[] - }) => useStructuredAgentSessionMessages(items, [entry], submissions), + }) => useStructuredAgentSessionMessages(items, [entry], submissions, NO_CARDS), { initialProps: { items: [tool('tool', 1)], submissions: [submission] } } ) for (const dispatchState of ['pending', 'unknown', 'accepted'] as const) { const props = { items: [tool('tool', 1)], submissions: [{ ...submission, dispatchState }] } rerender(props) expect(result.current).toEqual( - projectStructuredAgentSessionMessages(props.items, [entry], props.submissions) + projectStructuredAgentSessionMessages(props.items, [entry], props.submissions, NO_CARDS) ) } }) @@ -106,7 +112,7 @@ it('keeps optimistic sends and their settlement identical to uncached projection it('does no transcript projection work on a status-only render', () => { const items = [tool('tool', 1)] const { result, rerender } = renderHook(() => - useStructuredAgentSessionMessages(items, EMPTY, EMPTY) + useStructuredAgentSessionMessages(items, EMPTY, EMPTY, NO_CARDS) ) const initial = result.current rerender() diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-messages.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-messages.ts index f965c2d3eaa..af38cda41df 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-messages.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-messages.ts @@ -9,10 +9,11 @@ import { projectStructuredAgentSessionMessages } from './structured-agent-sessio export function useStructuredAgentSessionMessages( items: readonly AgentJournalRenderItem[], outbox: readonly StructuredAgentSessionOutboxEntry[], - submissions: readonly AgentJournalSubmission[] + submissions: readonly AgentJournalSubmission[], + queuedMessageIds: readonly string[] ) { return useMemo( - () => projectStructuredAgentSessionMessages(items, outbox, submissions), - [items, outbox, submissions] + () => projectStructuredAgentSessionMessages(items, outbox, submissions, queuedMessageIds), + [items, outbox, submissions, queuedMessageIds] ) } diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-admission.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-admission.test.tsx index 083a128ca0e..a810a331a2c 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-admission.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-admission.test.tsx @@ -7,7 +7,10 @@ import { act, cleanup, renderHook, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../../shared/agent-session-journal-types' import type { AgentSessionWireRefusalCode } from '../../../../shared/agent-session-wire' const mocks = vi.hoisted(() => ({ @@ -26,6 +29,8 @@ import { agentJournalSubmissionKey } from '../../../../shared/agent-session-jour import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' import { structuredAgentSessionEntryHeldForRetry } from '../../../../shared/structured-agent-session-outbox-admission' +const NO_JOURNAL_ITEMS: readonly AgentJournalRenderItem[] = [] + const LOCAL_TARGET = { kind: 'local' } as const function deferred() { @@ -93,6 +98,7 @@ function refusedResult(code: AgentSessionWireRefusalCode) { function renderOutbox() { return renderHook(() => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: LOCAL_TARGET, fence: 1, @@ -230,6 +236,7 @@ describe('structured agent session outbox admission', () => { const { result, rerender } = renderHook( ({ submissions }: { submissions: readonly AgentJournalSubmission[] }) => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: LOCAL_TARGET, fence: 1, @@ -311,6 +318,7 @@ describe('structured agent session outbox admission', () => { const { result, rerender } = renderHook( ({ submissions }: { submissions: readonly AgentJournalSubmission[] }) => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: LOCAL_TARGET, fence: 1, diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-fence.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-fence.test.tsx index 9545c083fa7..86d1ddf8322 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-fence.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-fence.test.tsx @@ -20,6 +20,10 @@ import { settleStructuredAgentLaunchPrompt } from '@/lib/structured-agent-sessio import { enqueueStructuredAgentSessionLaunchPrompt } from './structured-agent-session-outbox-storage' import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox' +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' + +const NO_JOURNAL_ITEMS: readonly AgentJournalRenderItem[] = [] + // Why: every hook here shares the session outbox store; one left mounted would drain the next test's. afterEach(cleanup) @@ -65,6 +69,7 @@ function render(fence: number | null = 1) { return renderHook( (props) => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: LOCAL_TARGET, fence: props.fence, diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-owner-change.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-owner-change.test.tsx index 926d77d4794..fcc0d2a6c62 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-owner-change.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-owner-change.test.tsx @@ -34,6 +34,10 @@ import { import { writeOutbox } from './structured-agent-session-outbox-storage' import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox' +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' + +const NO_JOURNAL_ITEMS: readonly AgentJournalRenderItem[] = [] + type SendRequest = { envelope?: { clientOperationId?: string; expectedRuntimeFence?: number } } // Stable, as the view passes it: a new object each render would re-run the owner-change requeue. @@ -82,6 +86,7 @@ function mount(fence: number) { return renderHook( ({ fence: current }: { fence: number }) => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: TARGET, fence: current, diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx index 8bf95a8a511..e32006c50d0 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx @@ -2,7 +2,10 @@ import { act, cleanup, renderHook, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../../shared/agent-session-journal-types' import { DISPATCH_REJECTED_CANCELLED } from '../../../../shared/structured-agent-session-dispatch-rejection' const mocks = vi.hoisted(() => ({ @@ -16,13 +19,28 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({ import { setLocalRuntimeCapabilitiesForTests } from '@/runtime/local-runtime-capabilities' import { RuntimeRpcCallError } from '@/runtime/runtime-rpc-result' import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox' +import { structuredAgentSessionDeliveryNotices } from './structured-agent-session-delivery-notices' +import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key' import { agentSessionWriteNoticeEnglish } from '../../../../shared/agent-session-refusal-notice' import { structuredAgentSessionAttemptFailureParts } from '../../../../shared/structured-agent-session-send-disposition' import { createStructuredAgentSessionOutboxEntry, type StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' -import { writeOutbox } from './structured-agent-session-outbox-storage' +import { + hasUndeliveredStructuredAgentSessionOutbox, + readOutbox, + writeOutbox +} from './structured-agent-session-outbox-storage' + +/** What these hooks render with: the journal's submissions, and the rows loaded so far. */ +type OutboxProps = { submissions: AgentJournalSubmission[]; rows?: AgentJournalRenderItem[] } + +function outboxProps(submissions: AgentJournalSubmission[]): OutboxProps { + return { submissions } +} + +const NO_JOURNAL_ITEMS: readonly AgentJournalRenderItem[] = [] // Why: every hook here shares the session outbox store; one left mounted would drain the next test's. afterEach(cleanup) @@ -88,7 +106,7 @@ describe('a send the host rejected because the agent never started', () => { localStorage.clear() }) - it('names the cause on the message and keeps it for Retry', async () => { + it('names the cause on the message until the journal row takes it over', async () => { mocks.call.mockImplementationOnce( async ( _target: unknown, @@ -98,6 +116,7 @@ describe('a send the host rejected because the agent never started', () => { ) const { result } = renderHook(() => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: { kind: 'local' }, fence: 1, @@ -108,7 +127,7 @@ describe('a send the host rejected because the agent never started', () => { act(() => expect(result.current.send('hello')).toBe(true)) await waitFor(() => expect(shownFailure(result.current.outbox[0])).toBe(REASON)) - // Settled as not delivered: it waits for Retry and holds no later message up. + // Settled as not delivered: it holds no later message up. expect(result.current.error).toBeNull() expect(result.current.outbox[0]?.state).toBe('rejected') }) @@ -126,6 +145,7 @@ describe('a send the host rejected because the agent never started', () => { }) const { result } = renderHook(() => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: { kind: 'local' }, fence: 1, @@ -151,7 +171,7 @@ describe('a send the host rejected because the agent never started', () => { ]) }) - it('keeps a message the host accepted and then could not deliver, with its reason and Retry', async () => { + it('leaves a message the host accepted and then could not deliver to its journal row', async () => { const reason = "Codex couldn't restart: spawn codex ENOENT." mocks.call.mockImplementation( async ( @@ -162,14 +182,15 @@ describe('a send the host rejected because the agent never started', () => { ) const target = { kind: 'local' } as const const { result, rerender } = renderHook( - (props: { submissions: AgentJournalSubmission[] }) => + (props: OutboxProps) => useStructuredAgentSessionOutbox({ + journalItems: props.rows ?? NO_JOURNAL_ITEMS, sessionId: 'session-1', target, fence: 1, submissions: props.submissions }), - { initialProps: { submissions: NO_SUBMISSIONS } } + { initialProps: outboxProps(NO_SUBMISSIONS) } ) act(() => expect(result.current.send('hello')).toBe(true)) @@ -177,22 +198,17 @@ describe('a send the host rejected because the agent never started', () => { const id = result.current.outbox[0]!.clientMessageId rerender({ - submissions: [{ ...pendingResultFor(id).value.submission, dispatchState: 'rejected', reason }] + submissions: [ + { ...pendingResultFor(id).value.submission, dispatchState: 'rejected', reason } + ], + rows: rowsFor(id) }) - await waitFor(() => expect(result.current.outbox[0]?.state).toBe('rejected')) - expect(shownFailure(result.current.outbox[0])).toBe(reason) + // The host's row shows it as not sent, with no Retry: nothing resends it. + await waitFor(() => expect(result.current.outbox).toEqual([])) expect(result.current.error).toBeNull() - - // Retry is a new message with the same text: a fresh id, sent once. - act(() => result.current.retry(id)) - await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2)) - const retried: { - envelope: { clientOperationId: string } - body: { blocks: { text?: string }[] } - } = mocks.call.mock.calls[1]![2] - expect(retried.envelope.clientOperationId).not.toBe(id) - expect(retried.body.blocks[0]?.text).toBe('hello') + await act(() => new Promise((resolve) => setTimeout(resolve, 50))) + expect(mocks.call).toHaveBeenCalledOnce() }) it('says nothing when a Stop withdrew the message', async () => { @@ -205,14 +221,15 @@ describe('a send the host rejected because the agent never started', () => { ) const target = { kind: 'local' } as const const { result, rerender } = renderHook( - (props: { submissions: AgentJournalSubmission[] }) => + (props: OutboxProps) => useStructuredAgentSessionOutbox({ + journalItems: props.rows ?? NO_JOURNAL_ITEMS, sessionId: 'session-1', target, fence: 1, submissions: props.submissions }), - { initialProps: { submissions: NO_SUBMISSIONS } } + { initialProps: outboxProps(NO_SUBMISSIONS) } ) act(() => expect(result.current.send('hello')).toBe(true)) @@ -233,7 +250,7 @@ describe('a send the host rejected because the agent never started', () => { expect(result.current.error).toBeNull() }) - it('reads a message rejected while the chat was closed as not sent, and sends past it', async () => { + it('leaves a message rejected while the chat was closed to its journal row, and sends past it', async () => { const reason = "Codex couldn't restart: spawn codex ENOENT." mocks.call.mockImplementation( async ( @@ -245,6 +262,7 @@ describe('a send the host rejected because the agent never started', () => { const target = { kind: 'local' } as const const first = renderHook(() => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target, fence: 1, @@ -260,8 +278,10 @@ describe('a send the host rejected because the agent never started', () => { const rejected = [ { ...pendingResultFor(id).value.submission, dispatchState: 'rejected' as const, reason } ] + const reopenedRows = rowsFor(id) const { result } = renderHook(() => useStructuredAgentSessionOutbox({ + journalItems: reopenedRows, sessionId: 'session-1', target, fence: 1, @@ -269,54 +289,192 @@ describe('a send the host rejected because the agent never started', () => { }) ) - await waitFor(() => expect(result.current.outbox[0]?.state).toBe('rejected')) - expect(shownFailure(result.current.outbox[0])).toBe(reason) + await waitFor(() => expect(result.current.outbox).toEqual([])) act(() => expect(result.current.send('second')).toBe(true)) await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2)) }) - // After a restart nothing in memory remembers the rejection, and its journal row may be older - // than the loaded page: the message's own state is what says a resend needs a new id. - it('retries a message rejected before a restart under a new id', async () => { - const rejected = createStructuredAgentSessionOutboxEntry({ - clientMessageId: 'rejected-before-restart', - sessionId: 'session-1', - text: 'first', - attachments: [], - queuedAt: 1 - }) + // One stored host fact, one rendering: a message the host recorded and rejected, whose record this + // chat does not hold, keeps showing why on every mount, with no control, and is never sent again. + it('keeps a message the host rejected, with no control, on every mount, and never resends it', async () => { writeOutbox('session-1', [ { - ...rejected, - state: 'rejected', - lastFailure: { - kind: 'rejected', - reason: 'The provider did not accept this message.', - rejection: { kind: 'providerRejected' } - } + ...rejectedBeforeRestart(), + state: 'dispatching', + lastFailure: undefined, + lastAttemptAt: 5 } ]) + // The host replays its rejection when the reopened chat asks about the send it left in doubt. mocks.call.mockImplementation(async (_target, _method, params) => - acceptedResultFor(String(params.envelope.clientOperationId)) + rejectedResultFor(String(params.envelope.clientOperationId)) ) - const { result } = renderHook(() => - useStructuredAgentSessionOutbox({ - sessionId: 'session-1', - target: { kind: 'local' }, - fence: 1, - submissions: [] - }) - ) - expect(result.current.outbox[0]?.state).toBe('rejected') + const notice = ( + outbox: readonly StructuredAgentSessionOutboxEntry[], + failedHere: ReadonlySet + ) => + structuredAgentSessionDeliveryNotices( + outbox, + 'Claude', + () => {}, + NO_SUBMISSIONS, + [], + failedHere + ).get(agentJournalSubmissionKey('rejected-before-restart')) + const mount = () => + renderHook(() => + useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, + sessionId: 'session-1', + target: { kind: 'local' }, + fence: 1, + submissions: NO_SUBMISSIONS + }) + ) - act(() => result.current.retry('rejected-before-restart')) - await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) - const sentId: unknown = mocks.call.mock.calls[0]![2].envelope.clientOperationId - expect(sentId).not.toBe('rejected-before-restart') - await waitFor(() => expect(result.current.outbox).toHaveLength(0)) + const first = mount() + await waitFor(() => expect(first.result.current.outbox[0]?.state).toBe('rejected'), { + timeout: 4000 + }) + const onFirst = notice(first.result.current.outbox, first.result.current.failedHere) + first.unmount() + const second = mount() + const onSecond = notice(second.result.current.outbox, second.result.current.failedHere) + + for (const shown of [onFirst, onSecond]) { + expect(shown).toEqual({ text: REASON }) + } + await act(() => new Promise((resolve) => setTimeout(resolve, 1500))) + // Only the first mount's question about the send it left in doubt; nothing resends it. + expect(mocks.call).toHaveBeenCalledOnce() + expect(second.result.current.outbox.map((entry) => entry.state)).toEqual(['rejected']) + expect(readOutbox('session-1')).toHaveLength(1) + expect(hasUndeliveredStructuredAgentSessionOutbox('session-1')).toBe(false) + }, 10000) + + // The host's own record is what lets it go, and storage forgets it too: nothing is owed. + it('drops a message rejected before a restart once the journal says so, from storage too', async () => { + writeOutbox('session-1', [rejectedBeforeRestart()]) + const { result, rerender } = renderHook( + (props: OutboxProps) => + useStructuredAgentSessionOutbox({ + journalItems: props.rows ?? NO_JOURNAL_ITEMS, + sessionId: 'session-1', + target: { kind: 'local' }, + fence: 1, + submissions: props.submissions + }), + { initialProps: outboxProps(NO_SUBMISSIONS) } + ) + expect(result.current.outbox).toHaveLength(1) + + rerender({ + submissions: [ + { + ...pendingResultFor('rejected-before-restart').value.submission, + dispatchState: 'rejected', + reason: 'The provider did not accept this message.', + resolvedAt: 2 + } + ], + rows: rowsFor('rejected-before-restart') + }) + + await waitFor(() => expect(result.current.outbox).toEqual([])) + expect(readOutbox('session-1')).toEqual([]) + expect(hasUndeliveredStructuredAgentSessionOutbox('session-1')).toBe(false) + expect(mocks.call).not.toHaveBeenCalled() }) - it('keeps the rejection when the journal settles the message before the send answers', async () => { + // An older host leaves a rejected message where it was sent, which may be outside the loaded + // window: the entry draws it, with no control, until the row that draws it loads. + it('keeps a rejected message whose row is not loaded, then lets it go once the row loads', async () => { + writeOutbox('session-1', [ + { ...rejectedBeforeRestart(), state: 'dispatching', lastFailure: undefined, lastAttemptAt: 5 } + ]) + const rejected = [ + { + ...pendingResultFor('rejected-before-restart').value.submission, + dispatchState: 'rejected' as const, + reason: REASON, + resolvedAt: 2 + } + ] + const { result, rerender } = renderHook( + (props: OutboxProps) => + useStructuredAgentSessionOutbox({ + journalItems: props.rows ?? NO_JOURNAL_ITEMS, + sessionId: 'session-1', + target: { kind: 'local' }, + fence: 1, + submissions: props.submissions + }), + { initialProps: outboxProps(rejected) } + ) + + await waitFor(() => expect(result.current.outbox[0]?.state).toBe('rejected')) + expect(shownFailure(result.current.outbox[0])).toBe(REASON) + expect(readOutbox('session-1')).toHaveLength(1) + + rerender({ submissions: rejected, rows: rowsFor('rejected-before-restart') }) + await waitFor(() => expect(result.current.outbox).toEqual([])) + expect(readOutbox('session-1')).toEqual([]) + expect(mocks.call).not.toHaveBeenCalled() + }) + + it('keeps a send its reply rejected without a Retry until the journal row takes it over', async () => { + const writeFailed = (clientMessageId: string): AgentJournalSubmission => ({ + clientMessageId, + fence: 1, + payloadFingerprint: 'fingerprint', + dispatchState: 'rejected', + providerItemId: null, + reason: 'provider_write_failed: broken pipe', + submittedAt: 10, + resolvedAt: 10 + }) + mocks.call.mockImplementationOnce( + async ( + _target: unknown, + _method: unknown, + params: { envelope: { clientOperationId: string } } + ) => ({ + ok: true, + replayed: false, + fence: 1, + cursor: { epoch: 'epoch-1', sequence: 10 }, + value: { + clientMessageId: params.envelope.clientOperationId, + submission: writeFailed(params.envelope.clientOperationId) + } + }) + ) + const { result, rerender } = renderHook( + (props: OutboxProps) => + useStructuredAgentSessionOutbox({ + journalItems: props.rows ?? NO_JOURNAL_ITEMS, + sessionId: 'session-1', + target: LOCAL_TARGET, + fence: 1, + submissions: props.submissions + }), + { initialProps: outboxProps(NO_SUBMISSIONS) } + ) + + act(() => expect(result.current.send('first')).toBe(true)) + await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) + const firstId = String(mocks.call.mock.calls[0]![2].envelope.clientOperationId) + + // Answered, not doubted: the entry draws the message, saying why, until the journal has it. + await waitFor(() => expect(result.current.outbox[0]?.lastFailure?.kind).toBe('rejected')) + expect(result.current.outbox[0]?.state).toBe('rejected') + + rerender({ submissions: [writeFailed(firstId)], rows: rowsFor(firstId) }) + await waitFor(() => expect(result.current.outbox).toHaveLength(0)) + expect(mocks.call).toHaveBeenCalledOnce() + }) + + it('lets the journal settle the message when its rejection lands before the send answers', async () => { const reason = "Codex couldn't restart: spawn codex ENOENT." let answer: (value: unknown) => void = () => undefined mocks.call.mockImplementationOnce( @@ -327,14 +485,15 @@ describe('a send the host rejected because the agent never started', () => { ) const target = { kind: 'local' } as const const { result, rerender } = renderHook( - (props: { submissions: AgentJournalSubmission[] }) => + (props: OutboxProps) => useStructuredAgentSessionOutbox({ + journalItems: props.rows ?? NO_JOURNAL_ITEMS, sessionId: 'session-1', target, fence: 1, submissions: props.submissions }), - { initialProps: { submissions: NO_SUBMISSIONS } } + { initialProps: outboxProps(NO_SUBMISSIONS) } ) act(() => expect(result.current.send('hello')).toBe(true)) @@ -343,19 +502,51 @@ describe('a send the host rejected because the agent never started', () => { // A start refused at once: the rejection frame lands before the send's own `pending` answer. rerender({ - submissions: [{ ...pendingResultFor(id).value.submission, dispatchState: 'rejected', reason }] + submissions: [ + { ...pendingResultFor(id).value.submission, dispatchState: 'rejected', reason } + ], + rows: rowsFor(id) }) - await waitFor(() => expect(result.current.outbox[0]?.state).toBe('rejected')) + await waitFor(() => expect(result.current.outbox).toEqual([])) + // The late `pending` answer must not bring it back. await act(async () => answer(pendingResultFor(id))) - expect(result.current.outbox[0]?.state).toBe('rejected') - expect(shownFailure(result.current.outbox[0])).toBe(reason) + expect(result.current.outbox).toEqual([]) expect(result.current.error).toBeNull() }) }) const NO_SUBMISSIONS: AgentJournalSubmission[] = [] +/** The host's rows for these messages, loaded. */ +function rowsFor(...ids: string[]): AgentJournalRenderItem[] { + return ids.map((id, index) => ({ + itemId: agentJournalSubmissionKey(id), + revision: 1, + sequence: index + 1, + observedAt: index + 1, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: id }] } + })) +} + +function rejectedBeforeRestart(): StructuredAgentSessionOutboxEntry { + return { + ...createStructuredAgentSessionOutboxEntry({ + clientMessageId: 'rejected-before-restart', + sessionId: 'session-1', + text: 'first', + attachments: [], + queuedAt: 1 + }), + state: 'rejected', + lastFailure: { + kind: 'rejected', + reason: 'The provider did not accept this message.', + rejection: { kind: 'providerRejected' } + } + } +} + function pendingResultFor(clientMessageId: string) { const submission: AgentJournalSubmission = { clientMessageId, @@ -426,6 +617,7 @@ describe('a send refused while its agent restarted', () => { const { result, rerender } = renderHook( ({ fence, submissions }: { fence: number; submissions: AgentJournalSubmission[] }) => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: LOCAL_TARGET, fence, @@ -485,6 +677,7 @@ describe('a send the host refused by throwing', () => { ) const { result } = renderHook(() => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: { kind: 'local' }, fence: 1, @@ -525,6 +718,7 @@ describe('a send refused on a journal a newer Orca wrote', () => { }) const { result } = renderHook(() => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: { kind: 'local' }, fence: 1, diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-relaunch-hold.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-relaunch-hold.test.tsx index 5ef47af4f69..c40123377ea 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-relaunch-hold.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-relaunch-hold.test.tsx @@ -28,6 +28,10 @@ import { createStructuredAgentSessionOutboxEntry } from '../../../../shared/stru import { writeOutbox } from './structured-agent-session-outbox-storage' import { structuredAgentSessionDeliveryNotices } from './structured-agent-session-delivery-notices' +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' + +const NO_JOURNAL_ITEMS: readonly AgentJournalRenderItem[] = [] + const SESSION = 'session-1' // Stable, as the view passes it: a new object each render would re-run the owner-change requeue. const LOCAL_TARGET = { kind: 'local' } as const @@ -96,6 +100,7 @@ function mount(fence = 1) { return renderHook( ({ fence: current }: { fence: number }) => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: SESSION, target: LOCAL_TARGET, fence: current, @@ -423,6 +428,7 @@ describe('a message whose send could not be saved before it went out', () => { const { result, rerender } = renderHook( ({ fence }: { fence: number | null }) => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: SESSION, target: LOCAL_TARGET, fence, @@ -541,6 +547,7 @@ describe('a held message whose id expired', () => { mocks.call.mockResolvedValue(expired()) const { result } = renderHook(() => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: SESSION, target: LOCAL_TARGET, fence: 1, diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-withdrawal.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-withdrawal.test.tsx index 1c7d69f1aa7..4e815c9252f 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-withdrawal.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-withdrawal.test.tsx @@ -5,7 +5,10 @@ import { act, cleanup, renderHook, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../../shared/agent-session-journal-types' import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' import { hasUnsentStructuredAgentSessionOutboxEntry, @@ -25,6 +28,8 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({ import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox' import { readOutbox } from './structured-agent-session-outbox-storage' +const NO_JOURNAL_ITEMS: readonly AgentJournalRenderItem[] = [] + // One object: a target rebuilt each render reads as a new owner, which re-sends what is on its way. const TARGET = { kind: 'local' } as const @@ -82,6 +87,7 @@ describe('a Stop withdrawing what the host does not hold', () => { ) const { result } = renderHook(() => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: TARGET, fence: 1, @@ -147,6 +153,7 @@ describe('a Stop withdrawing what the host does not hold', () => { mocks.call.mockRejectedValue(new Error('the host refused the frame')) const { result } = renderHook(() => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: TARGET, fence: 1, diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.batch-churn.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.batch-churn.test.tsx new file mode 100644 index 00000000000..26e46bd2bbf --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.batch-churn.test.tsx @@ -0,0 +1,117 @@ +// @vitest-environment happy-dom + +// The outbox re-reads the journal on every batch; a batch that settles nothing writes nothing, so a +// message left in doubt costs no storage write per streamed delta. And a copy the host recorded and +// rejected owes no delivery, so it keeps no hidden pane reading the journal. + +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../../shared/agent-session-journal-types' +import { createStructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' + +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: vi.fn(() => new Promise(() => {})) +})) + +import { + hasUndeliveredStructuredAgentSessionOutbox, + writeOutbox +} from './structured-agent-session-outbox-storage' +import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox' + +afterEach(cleanup) + +beforeEach(() => { + localStorage.clear() +}) + +const SESSION = 'session-churn' + +function stored( + id: string, + patch: Partial> +) { + return { + ...createStructuredAgentSessionOutboxEntry({ + clientMessageId: id, + sessionId: SESSION, + text: id, + attachments: [], + queuedAt: 1 + }), + ...patch + } +} + +function inDoubt(clientMessageId: string): AgentJournalSubmission { + return { + clientMessageId, + fence: 1, + payloadFingerprint: clientMessageId, + dispatchState: 'unknown', + providerItemId: null, + reason: 'in doubt', + submittedAt: 5, + resolvedAt: 6 + } +} + +function streamed(sequence: number): AgentJournalRenderItem[] { + return [ + { + itemId: `answer-${sequence}`, + revision: sequence, + sequence, + observedAt: sequence, + body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'streaming' }] } + } + ] +} + +it('writes nothing to storage across 50 batches while a message waits in doubt', async () => { + writeOutbox(SESSION, [stored('doubt', { state: 'dispatching', lastAttemptAt: 2 })]) + const { rerender } = renderHook( + (props: { items: AgentJournalRenderItem[]; submissions: AgentJournalSubmission[] }) => + useStructuredAgentSessionOutbox({ + sessionId: SESSION, + target: { kind: 'local' }, + fence: 1, + submissions: props.submissions, + journalItems: props.items + }), + { initialProps: { items: streamed(1), submissions: [inDoubt('doubt')] } } + ) + await act(async () => {}) + const writes = vi.spyOn(localStorage, 'setItem') + + for (let sequence = 2; sequence <= 51; sequence += 1) { + // Each batch is a new journal array and a new submissions array with the same content. + rerender({ items: streamed(sequence), submissions: [inDoubt('doubt')] }) + } + await act(async () => {}) + + expect(writes).not.toHaveBeenCalled() + writes.mockRestore() +}) + +it('counts a copy the host recorded and rejected as owing no delivery', () => { + writeOutbox(SESSION, [ + stored('recorded', { + state: 'rejected', + lastFailure: { kind: 'rejected', reason: 'Orca restarted before this message was sent.' } + }) + ]) + expect(hasUndeliveredStructuredAgentSessionOutbox(SESSION)).toBe(false) + + writeOutbox(SESSION, [ + stored('recorded', { + state: 'rejected', + lastFailure: { kind: 'rejected', reason: 'Orca restarted before this message was sent.' } + }), + stored('waiting', {}) + ]) + expect(hasUndeliveredStructuredAgentSessionOutbox(SESSION)).toBe(true) +}) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.draft-hand-off.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.draft-hand-off.test.tsx index ff13958f160..61fc0685816 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.draft-hand-off.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.draft-hand-off.test.tsx @@ -7,7 +7,10 @@ import { act, cleanup, renderHook, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../../shared/agent-session-journal-types' import { createStructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' import { DISPATCH_REJECTED_CANCELLED } from '../../../../shared/structured-agent-session-dispatch-rejection' import { @@ -28,6 +31,8 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({ import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox' import { writeOutbox } from './structured-agent-session-outbox-storage' +const NO_JOURNAL_ITEMS: readonly AgentJournalRenderItem[] = [] + // Why: every hook here shares the session outbox store; one left mounted would drain the next test's. afterEach(cleanup) @@ -77,6 +82,7 @@ function renderOutbox() { return renderHook( (props: Props) => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: TARGET, fence: 1, diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.external-send.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.external-send.test.tsx index 95d9021413a..0cb70aab14b 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.external-send.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.external-send.test.tsx @@ -15,6 +15,10 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({ import { appendStructuredAgentSessionOutboxMessage } from './structured-agent-session-outbox-storage' import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox' +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' + +const NO_JOURNAL_ITEMS: readonly AgentJournalRenderItem[] = [] + afterEach(cleanup) beforeEach(() => { @@ -39,6 +43,7 @@ it('delivers a message queued from outside the chat through the open outbox', as }) const { result } = renderHook(() => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: { kind: 'local' }, fence: 1, diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.queue-delivery.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.queue-delivery.test.tsx index 26e0fd71112..fadb4440534 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.queue-delivery.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.queue-delivery.test.tsx @@ -31,6 +31,10 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({ import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox' import { readOutbox } from './structured-agent-session-outbox-storage' +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' + +const NO_JOURNAL_ITEMS: readonly AgentJournalRenderItem[] = [] + // Why: every hook here shares the session outbox store; one left mounted would drain the next test's. afterEach(cleanup) @@ -53,6 +57,7 @@ function queuedReceipt(clientMessageId: string) { function renderOutbox(queue: boolean) { return renderHook(() => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: LOCAL_TARGET, fence: 1, @@ -154,6 +159,7 @@ describe('outbox queue delivery selection', () => { const first = renderHook( (props: { queuedMessageIds: string[] }) => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: LOCAL_TARGET, fence: 1, @@ -184,6 +190,7 @@ describe('outbox queue delivery selection', () => { const view = renderHook( (props: { queuedMessageIds: string[] }) => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: LOCAL_TARGET, fence: 1, @@ -252,6 +259,7 @@ describe('outbox queue delivery selection', () => { })) const { result } = renderHook(() => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: LOCAL_TARGET, fence: 1, @@ -306,6 +314,7 @@ async function attemptedQueueSend() { const view = renderHook( (props: { capability: StructuredAgentSessionQueueCapability }) => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: LOCAL_TARGET, fence: 1, diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.rejected-reply.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.rejected-reply.test.tsx new file mode 100644 index 00000000000..bfebc877b2c --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.rejected-reply.test.tsx @@ -0,0 +1,102 @@ +// @vitest-environment happy-dom + +// A send whose own reply says the host recorded it and then rejected it is shown once, in the chat +// where it was sent; its text never also goes back to the composer. + +import { act, cleanup, renderHook, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../../shared/agent-session-journal-types' +import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key' +import { DISPATCH_REJECTED_NOT_DELIVERED } from '../../../../shared/structured-agent-session-dispatch-rejection' + +type SendParams = { envelope: { clientOperationId: string; payloadFingerprint: string } } + +const mocks = vi.hoisted(() => ({ + call: vi.fn<(target: unknown, method: string, params: SendParams) => Promise>() +})) + +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: mocks.call +})) + +import { + clearNativeChatDraftCacheForTests, + readNativeChatDraftCache +} from './native-chat-draft-cache' +import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection' +import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox' + +const NO_JOURNAL_ITEMS: readonly AgentJournalRenderItem[] = [] + +const NO_CARDS: readonly string[] = [] + +afterEach(cleanup) + +beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + clearNativeChatDraftCacheForTests() +}) + +it('draws a send its reply rejected in place once, and leaves the composer empty', async () => { + const reply: { submission: AgentJournalSubmission | null } = { submission: null } + mocks.call.mockImplementationOnce(async (_target, _method, { envelope }) => { + const rejected: AgentJournalSubmission = { + clientMessageId: envelope.clientOperationId, + fence: 1, + payloadFingerprint: envelope.payloadFingerprint, + dispatchState: 'rejected', + providerItemId: null, + reason: DISPATCH_REJECTED_NOT_DELIVERED, + rejection: { kind: 'notDelivered' }, + submittedAt: 10, + resolvedAt: 11 + } + reply.submission = rejected + return { + ok: true, + replayed: false, + fence: 1, + cursor: { epoch: 'epoch-1', sequence: 10 }, + value: { clientMessageId: envelope.clientOperationId, submission: rejected } + } + }) + const { result } = renderHook(() => + useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, + sessionId: 'session-1', + target: { kind: 'local' }, + fence: 1, + submissions: [], + composerScopeKey: 'pane-1' + }) + ) + + act(() => expect(result.current.send('steer this way')).toBe(true)) + await waitFor(() => expect(result.current.outbox[0]?.state).toBe('rejected')) + expect(readNativeChatDraftCache('pane-1')).toBe('') + + const submission = reply.submission + if (!submission) { + throw new Error('the send was never answered') + } + const hostItem: AgentJournalRenderItem = { + itemId: agentJournalSubmissionKey(submission.clientMessageId), + revision: 1, + sequence: 10, + observedAt: 10, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'steer this way' }] } + } + const users = (outbox: typeof result.current.outbox) => + projectStructuredAgentSessionMessages([hostItem], outbox, [submission], NO_CARDS) + .filter((message) => message.role === 'user') + .map((message) => ({ id: message.id, unsent: message.unsent })) + + expect(users(result.current.outbox)).toEqual([{ id: hostItem.itemId, unsent: true }]) + // After a crash takes the outbox, the host's row is still the one row. + expect(users([])).toEqual([{ id: hostItem.itemId, unsent: true }]) + expect(readNativeChatDraftCache('pane-1')).toBe('') +}) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.stop-parks-in-doubt.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.stop-parks-in-doubt.test.tsx index 293d9719bc0..45d87f26f66 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.stop-parks-in-doubt.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.stop-parks-in-doubt.test.tsx @@ -25,6 +25,10 @@ import { readNativeChatDraftCache } from './native-chat-draft-cache' +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' + +const NO_JOURNAL_ITEMS: readonly AgentJournalRenderItem[] = [] + // Why: every hook here shares the session outbox store; one left mounted would drain the next test's. afterEach(cleanup) @@ -66,6 +70,7 @@ describe('a Stop with a queued send in doubt', () => { const view = renderHook( (props: { fence: number | null }) => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: REMOTE, fence: props.fence, @@ -110,6 +115,7 @@ describe('a Stop with a queued send in doubt', () => { mocks.call.mockImplementationOnce(() => answer.promise) const { result } = renderHook(() => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: TARGET, fence: 1, @@ -170,6 +176,7 @@ describe('a Stop with a queued send in doubt', () => { ]) const { result } = renderHook(() => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: TARGET, fence: 1, diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx index 422d94b618a..f9957ed41cc 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx @@ -4,7 +4,10 @@ import { act, cleanup, renderHook, waitFor } from '@testing-library/react' import { useLayoutEffect } from 'react' import { createRoot } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../../shared/agent-session-journal-types' import type { AgentSessionWireRefusalCode } from '../../../../shared/agent-session-wire' import { enqueueStructuredAgentSessionLaunchPrompt } from './structured-agent-session-outbox-storage' @@ -19,6 +22,16 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({ import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox' import { settleStructuredAgentLaunchPrompt } from '@/lib/structured-agent-session-launch-prompt' +const hostRow = (id: string): AgentJournalRenderItem => ({ + itemId: `orca:${id}`, + revision: 1, + sequence: 1, + observedAt: 1, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: id }] } +}) + +const NO_JOURNAL_ITEMS: readonly AgentJournalRenderItem[] = [] + // Why: every hook here shares the session outbox store; one left mounted would drain the next test's. afterEach(cleanup) @@ -149,6 +162,7 @@ describe('useStructuredAgentSessionOutbox', () => { const { result, rerender } = renderHook( ({ fence }) => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: LOCAL_TARGET, fence, @@ -190,6 +204,7 @@ describe('useStructuredAgentSessionOutbox', () => { const { result } = renderHook(() => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: LOCAL_TARGET, fence: 1, @@ -211,6 +226,7 @@ describe('useStructuredAgentSessionOutbox', () => { const { result, rerender } = renderHook( ({ fence }) => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: LOCAL_TARGET, fence, @@ -245,6 +261,7 @@ describe('useStructuredAgentSessionOutbox', () => { mocks.call.mockResolvedValueOnce(refusedResult(code)).mockResolvedValueOnce(acceptedResult(1)) const { result } = renderHook(() => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: LOCAL_TARGET, fence: 1, @@ -277,6 +294,7 @@ describe('useStructuredAgentSessionOutbox', () => { const { result, rerender } = renderHook( ({ submissions }: { submissions: readonly AgentJournalSubmission[] }) => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: LOCAL_TARGET, fence: 1, @@ -316,6 +334,7 @@ describe('useStructuredAgentSessionOutbox', () => { const { result, rerender } = renderHook( ({ submissions }: { submissions: readonly AgentJournalSubmission[] }) => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: LOCAL_TARGET, fence: 1, @@ -346,6 +365,7 @@ describe('useStructuredAgentSessionOutbox', () => { const { result, rerender } = renderHook( ({ submissions }: { submissions: readonly AgentJournalSubmission[] }) => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: LOCAL_TARGET, fence: 1, @@ -375,6 +395,7 @@ describe('useStructuredAgentSessionOutbox', () => { const { result, rerender } = renderHook( ({ submissions }: { submissions: readonly AgentJournalSubmission[] }) => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: LOCAL_TARGET, fence: 1, @@ -400,6 +421,7 @@ describe('useStructuredAgentSessionOutbox', () => { const { result, rerender } = renderHook( ({ submissions }: { submissions: readonly AgentJournalSubmission[] }) => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: LOCAL_TARGET, fence: 1, @@ -431,6 +453,7 @@ describe('useStructuredAgentSessionOutbox', () => { }) const first = renderHook(() => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: LOCAL_TARGET, fence: 1, @@ -445,6 +468,7 @@ describe('useStructuredAgentSessionOutbox', () => { const restored = renderHook(() => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: LOCAL_TARGET, fence: 1, @@ -476,6 +500,7 @@ describe('useStructuredAgentSessionOutbox', () => { const { result, rerender } = renderHook( ({ submissions }: { submissions: readonly AgentJournalSubmission[] }) => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: LOCAL_TARGET, fence: 1, @@ -515,6 +540,7 @@ describe('useStructuredAgentSessionOutbox', () => { .mockResolvedValueOnce(acceptedResult(1)) const { result } = renderHook(() => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: LOCAL_TARGET, fence: 1, @@ -544,6 +570,7 @@ describe('useStructuredAgentSessionOutbox', () => { }) const { result } = renderHook(() => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: LOCAL_TARGET, fence: 1, @@ -581,6 +608,7 @@ describe('useStructuredAgentSessionOutbox', () => { mocks.call.mockResolvedValue(acceptedResult(1)) const { result } = renderHook(() => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: LOCAL_TARGET, fence: 1, @@ -628,6 +656,7 @@ describe('useStructuredAgentSessionOutbox', () => { const { result, rerender } = renderHook( ({ submissions }: { submissions: readonly AgentJournalSubmission[] }) => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: LOCAL_TARGET, fence: 1, @@ -667,12 +696,7 @@ describe('useStructuredAgentSessionOutbox', () => { expect(retryParams?.retryUnknown).toBeUndefined() }) - it('rotates a history-rejected unknown head so the queued tail can advance', async () => { - // oxlint-disable-next-line no-restricted-properties -- stubbing the global the generator reads, to pin ids in this test - vi.mocked(globalThis.crypto.randomUUID) - .mockReturnValueOnce('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa') - .mockReturnValueOnce('bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb') - .mockReturnValueOnce('cccccccc-cccc-4ccc-8ccc-cccccccccccc') + it('lets a history-rejected unknown head go to the journal so the queued tail advances', async () => { mocks.call .mockImplementationOnce(async (_target, _method, params) => { const clientMessageId = (params as { envelope: { clientOperationId: string } }).envelope @@ -684,14 +708,11 @@ describe('useStructuredAgentSessionOutbox', () => { .clientOperationId return acceptedResultFor(clientMessageId, 11) }) - .mockImplementationOnce(async (_target, _method, params) => { - const clientMessageId = (params as { envelope: { clientOperationId: string } }).envelope - .clientOperationId - return acceptedResultFor(clientMessageId, 12) - }) + let rows = NO_JOURNAL_ITEMS const { result, rerender } = renderHook( ({ submissions }: { submissions: readonly AgentJournalSubmission[] }) => useStructuredAgentSessionOutbox({ + journalItems: rows, sessionId: 'session-1', target: LOCAL_TARGET, fence: 1, @@ -704,6 +725,7 @@ describe('useStructuredAgentSessionOutbox', () => { await waitFor(() => expect(result.current.outbox[0]?.state).toBe('unconfirmed')) const firstId = result.current.outbox[0]!.clientMessageId act(() => expect(result.current.send('second')).toBe(true)) + rows = [hostRow(firstId)] rerender({ submissions: [ { @@ -719,83 +741,13 @@ describe('useStructuredAgentSessionOutbox', () => { ] }) - act(() => result.current.retry(firstId)) - await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(3)) + // The host's row shows the first as not sent; nothing resends it. + await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2)) await waitFor(() => expect(result.current.outbox).toHaveLength(0)) - const retryParams = mocks.call.mock.calls[1]?.[2] as - | { envelope: { clientOperationId: string } } - | undefined - expect(retryParams?.envelope.clientOperationId).not.toBe(firstId) - }) - - it('rotates the id after a refused write and delivers the message exactly once', async () => { - // oxlint-disable-next-line no-restricted-properties -- stubbing the global the generator reads, to pin ids in this test - vi.mocked(globalThis.crypto.randomUUID) - .mockReturnValueOnce('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa') - .mockReturnValueOnce('bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb') - const writeFailed = (clientMessageId: string) => ({ - clientMessageId, - fence: 1, - payloadFingerprint: 'fingerprint', - dispatchState: 'rejected' as const, - providerItemId: null, - reason: 'provider_write_failed: broken pipe', - submittedAt: 10, - resolvedAt: 10 + expect(mocks.call.mock.calls[1]?.[2]).toMatchObject({ body: { blocks: [{ text: 'second' }] } }) + expect(mocks.call.mock.calls[1]?.[2]).not.toMatchObject({ + envelope: { clientOperationId: firstId } }) - mocks.call - .mockImplementationOnce(async (_target, _method, params) => ({ - ok: true, - replayed: false, - fence: 1, - cursor: { epoch: 'epoch-1', sequence: 10 }, - value: { - clientMessageId: (params as { envelope: { clientOperationId: string } }).envelope - .clientOperationId, - submission: writeFailed( - (params as { envelope: { clientOperationId: string } }).envelope.clientOperationId - ) - } - })) - .mockImplementationOnce(async (_target, _method, params) => - acceptedResultFor( - (params as { envelope: { clientOperationId: string } }).envelope.clientOperationId, - 11 - ) - ) - const { result } = renderHook( - ({ submissions }: { submissions: readonly AgentJournalSubmission[] }) => - useStructuredAgentSessionOutbox({ - sessionId: 'session-1', - target: LOCAL_TARGET, - fence: 1, - submissions - }), - { initialProps: { submissions: [] as readonly AgentJournalSubmission[] } } - ) - - act(() => expect(result.current.send('first')).toBe(true)) - await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) - const firstId = mocks.call.mock.calls[0]![2].envelope.clientOperationId as string - - // A refused write is answered, not doubted: the entry parks with its rejection rather than - // under the "delivery is unconfirmed" banner. The disposition tests pin its words. - await waitFor(() => expect(result.current.outbox[0]?.lastFailure?.kind).toBe('rejected')) - expect(result.current.outbox[0]?.state).toBe('rejected') - - // Retry immediately, before the journal subscription can publish the rejected row. - act(() => result.current.retry(firstId)) - await waitFor(() => expect(result.current.outbox).toHaveLength(0)) - - // Exactly one further delivery, under a new id, and with no `retryUnknown`: - // this is a first delivery of a new message, so it cannot duplicate. - expect(mocks.call).toHaveBeenCalledTimes(2) - const retryParams = mocks.call.mock.calls[1]?.[2] as { - envelope: { clientOperationId: string } - retryUnknown?: true - } - expect(retryParams.envelope.clientOperationId).not.toBe(firstId) - expect(retryParams.retryUnknown).toBeUndefined() }) it('loads the new session outbox when a pane switches sessions', async () => { @@ -808,6 +760,7 @@ describe('useStructuredAgentSessionOutbox', () => { const { result, rerender } = renderHook( ({ sessionId }: { sessionId: string }) => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId, target: LOCAL_TARGET, fence: 1, @@ -840,6 +793,7 @@ describe('useStructuredAgentSessionOutbox', () => { const { result, rerender } = renderHook( ({ sessionId }: { sessionId: string }) => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId, target: LOCAL_TARGET, fence: 1, @@ -869,6 +823,7 @@ describe('useStructuredAgentSessionOutbox', () => { } = { current: null } function Probe({ sessionId }: { sessionId: string }): null { controllerRef.current = useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId, target: LOCAL_TARGET, fence: 1, diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts index 28bf17b8aa7..46f2f482e52 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts @@ -6,7 +6,10 @@ import { useState, useSyncExternalStore } from 'react' -import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../../shared/agent-session-journal-types' import { createStructuredAgentSessionOperationId } from '../../../../shared/structured-agent-session-mutation' import { admitStructuredAgentSessionOutboxEntry, @@ -63,6 +66,8 @@ export function useStructuredAgentSessionOutbox(args: { target: RuntimeClientTarget fence: number | null submissions: readonly AgentJournalSubmission[] + /** The loaded journal rows: a rejected message stays here until the row that draws it loads. */ + journalItems: readonly AgentJournalRenderItem[] /** The composer that gets back what a Stop withdrew from this client's outbox. */ composerScopeKey?: string /** The host's queued-messages capability and the user's setting; a send stamped @@ -76,6 +81,7 @@ export function useStructuredAgentSessionOutbox(args: { const { composerScopeKey, fence, + journalItems, queueDelivery = NO_QUEUE_DELIVERY, queuedMessageIds, sessionId, @@ -147,15 +153,13 @@ export function useStructuredAgentSessionOutbox(args: { .map((submission) => submission.clientMessageId), ...handedOffQueuedMessageIds(submissions) ]) - const next = reconcileStructuredAgentSessionOutboxWithQueue(current, submissions) + const next = reconcileStructuredAgentSessionOutboxWithQueue(current, submissions, journalItems) const admittedInFlight = journalAnswersInFlightSend(submissions, inFlightIdRef.current) - if ( - admittedInFlight || - next.some((entry, index) => entry !== current[index]) || - next.length !== current.length - ) { + // The reconcile returns `current` itself when no entry changed, so a batch that changes + // nothing writes nothing. + if (admittedInFlight || next !== current) { restoreWithdrawn.byHost(current, submissions) - commitStructuredAgentSessionOutbox(sessionId, next) + commitStructuredAgentSessionOutbox(sessionId, [...next]) } // Keyed on the entry actually in flight, which is no longer always the head: the journal // owning it outranks a send promise that has not settled, so release single-flight and make @@ -174,7 +178,7 @@ export function useStructuredAgentSessionOutbox(args: { ) { setError(null) } - }, [restoreWithdrawn, sessionId, submissions]) + }, [journalItems, restoreWithdrawn, sessionId, submissions]) // The one place that owns the refs, the React state and the storage write. const applyDisposition = useCallback( diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.rejected-card.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session.rejected-card.test.tsx new file mode 100644 index 00000000000..b37350cb39d --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.rejected-card.test.tsx @@ -0,0 +1,116 @@ +// @vitest-environment happy-dom + +// The session controller hands the queue's live cards to the transcript: a rejected message one of +// them holds under its own id is drawn as that card, never also as a not-sent row. + +import { renderHook } from '@testing-library/react' +import { beforeEach, expect, it, vi } from 'vitest' +import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key' +import type { + AgentJournalMessageItem, + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../../shared/agent-session-journal-types' +import type { AgentSessionQueuedMessage } from '../../../../shared/agent-session-wire' +import { DISPATCH_REJECTED_HOST_RESTARTED } from '../../../../shared/structured-agent-session-dispatch-rejection' + +let queuedMessages: AgentSessionQueuedMessage[] = [] + +const KEPT_BODY: AgentJournalMessageItem = { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'kept text' }] +} + +const KEPT_ITEM: AgentJournalRenderItem = { + itemId: agentJournalSubmissionKey('kept'), + revision: 1, + sequence: 1, + observedAt: 1, + body: KEPT_BODY +} + +const KEPT_SUBMISSION: AgentJournalSubmission = { + clientMessageId: 'kept', + fence: 3, + payloadFingerprint: 'fingerprint', + dispatchState: 'rejected', + providerItemId: null, + reason: DISPATCH_REJECTED_HOST_RESTARTED, + rejection: { kind: 'hostRestarted' }, + submittedAt: 1, + resolvedAt: 2 +} + +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: vi.fn(async () => null), + supportsStructuredAgentSessionPromptCancel: vi.fn(async () => false) +})) + +vi.mock('./use-structured-agent-session-read', () => ({ + useStructuredAgentSessionRead: () => ({ + state: { + fence: 3, + items: [KEPT_ITEM], + submissions: [KEPT_SUBMISSION], + status: 'ready', + error: null, + hasOlder: false, + queuedMessages + }, + loadingOlder: false, + loadOlder: vi.fn() + }) +})) + +vi.mock('./use-structured-agent-session-outbox', () => ({ + structuredSessionOperationId: () => 'operation-1', + useStructuredAgentSessionOutbox: () => ({ + outbox: [], + error: null, + send: vi.fn(), + retry: vi.fn(), + withdrawUnsent: vi.fn() + }) +})) + +import { useStructuredAgentSession } from './use-structured-agent-session' + +function card(messageId: string): AgentSessionQueuedMessage { + return { + messageId, + position: 1, + body: KEPT_BODY, + state: 'waiting' + } +} + +function keptRows(): { id: string; unsent?: true }[] { + const { result, unmount } = renderHook(() => + useStructuredAgentSession({ + sessionId: 'session-1', + agent: 'claude', + target: { kind: 'local' }, + isVisible: true, + composerScopeKey: 'scope-1' + }) + ) + const rows = result.current.messages + .filter((message) => message.id === KEPT_ITEM.itemId) + .map(({ id, unsent }) => ({ id, ...(unsent ? { unsent } : {}) })) + unmount() + return rows +} + +beforeEach(() => { + queuedMessages = [] +}) + +it('draws no row for a rejected message while a card holds it under its id', () => { + queuedMessages = [card('kept')] + expect(keptRows()).toEqual([]) +}) + +it('draws it as not sent once no card holds it', () => { + expect(keptRows()).toEqual([{ id: KEPT_ITEM.itemId, unsent: true }]) +}) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts index 96ad805560d..46571fcf54f 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts @@ -120,6 +120,7 @@ export function useStructuredAgentSession(args: { target, fence: transportState.fence, submissions: transportState.submissions, + journalItems: transportState.journalItems, composerScopeKey, queueDelivery: { capability: queueCapability, enabled: queueFollowUps }, queuedMessageIds @@ -166,7 +167,8 @@ export function useStructuredAgentSession(args: { const messages = useStructuredAgentSessionMessages( transportState.journalItems, transcriptOutbox, - transportState.submissions + transportState.submissions, + queuedMessageIds ) const queuedController = useStructuredAgentSessionQueuedMessages({ enabled: queueCapable && transportState.fence !== null, @@ -216,6 +218,8 @@ export function useStructuredAgentSession(args: { failedHere: outboxController.failedHere, /** The journal's rows for sent messages, which carry a rejected message's whole fact. */ submissions: transportState.submissions, + /** The host's queued cards, which hold their own rejected hand-offs. */ + queuedMessageIds, // A message typed during a command queues behind it on the host. send: (...input: Parameters) => // Legacy: an older host refuses sends while a command runs; removable once those hosts age out. diff --git a/src/shared/agent-session-conversation-outline.ts b/src/shared/agent-session-conversation-outline.ts index 2b6bb846adb..4d6b814d64d 100644 --- a/src/shared/agent-session-conversation-outline.ts +++ b/src/shared/agent-session-conversation-outline.ts @@ -89,7 +89,8 @@ export function projectAgentSessionConversationOutline( } const entries: AgentSessionConversationOutlineEntry[] = [] const transcript = projectNativeChatTranscriptMessages( - projectStructuredAgentSessionMessages(items, [], submissions) + // Unchanged on the wire: a desktop's rejected rows tick once their page is loaded. + projectStructuredAgentSessionMessages(items, [], submissions, { rejectedInPlace: false }) ) for (const message of transcript) { const sequence = sequences.get(message.id) diff --git a/src/shared/native-chat-provider-retry-runs.test.ts b/src/shared/native-chat-provider-retry-runs.test.ts index 0dff50c5f1d..3bb0c2d0227 100644 --- a/src/shared/native-chat-provider-retry-runs.test.ts +++ b/src/shared/native-chat-provider-retry-runs.test.ts @@ -49,7 +49,7 @@ function texts(messages: readonly NativeChatMessage[]): string[] { } function drawn(items: AgentJournalRenderItem[]): string[] { - return texts(projectStructuredAgentSessionMessages(items, [], [])) + return texts(projectStructuredAgentSessionMessages(items, [], [], { rejectedInPlace: true })) } describe('a run of provider retry rows', () => { @@ -101,7 +101,8 @@ describe('a run of provider retry rows', () => { retry(2, 'subagent-1') ], [], - [] + [], + { rejectedInPlace: true } ) ) expect(texts(conversation)).toEqual([ @@ -136,7 +137,8 @@ describe('a run of provider retry rows', () => { resolvedAt: null, handoverRecorded: true } - ] + ], + { rejectedInPlace: true } ) expect(messages.map((message) => [message.id, message.queued ?? false])).toEqual([ [expect.stringMatching(/^item-/), false], diff --git a/src/shared/native-chat-types.ts b/src/shared/native-chat-types.ts index 127a9837910..7fd5fe66192 100644 --- a/src/shared/native-chat-types.ts +++ b/src/shared/native-chat-types.ts @@ -214,8 +214,8 @@ export type NativeChatMessage = AgentJournalProducerLinkage & { sentAs?: AgentJournalMessageSendMode /** Accepted but not yet handed to the agent: drawn after everything the agent has done. */ queued?: true - /** Shown as not sent, waiting for the user's Retry: in no turn, so a newer turn's bar and clock - * never land on it, and drawn after the conversation. */ + /** Shown as not sent: in no turn, so a newer turn's bar and clock never land on it. Drawn where + * the journal recorded it, or after the conversation when it holds no place there. */ unsent?: true /** Set only by the structured projection, on rows the journal holds, and ranks * them ahead of time. Terminal-backed messages never carry it, and worker reads strip it. */ diff --git a/src/shared/structured-agent-session-draft-hand-off.test.ts b/src/shared/structured-agent-session-draft-hand-off.test.ts index 6e0cbc7e644..1b42e1a93a1 100644 --- a/src/shared/structured-agent-session-draft-hand-off.test.ts +++ b/src/shared/structured-agent-session-draft-hand-off.test.ts @@ -44,7 +44,9 @@ function handOff(dispatchState: AgentJournalSubmission['dispatchState']): AgentJ describe('a queued draft handed off under a fresh submission id', () => { it('takes the outbox entry off the client, in every dispatch state', () => { for (const state of ['pending', 'accepted', 'rejected', 'unknown'] as const) { - expect(reconcileStructuredAgentSessionOutboxWithQueue([entry], [handOff(state)])).toEqual([]) + expect(reconcileStructuredAgentSessionOutboxWithQueue([entry], [handOff(state)], [])).toEqual( + [] + ) } }) diff --git a/src/shared/structured-agent-session-draft-hand-off.ts b/src/shared/structured-agent-session-draft-hand-off.ts index 29a14159038..4e756d331f7 100644 --- a/src/shared/structured-agent-session-draft-hand-off.ts +++ b/src/shared/structured-agent-session-draft-hand-off.ts @@ -2,11 +2,9 @@ // under a fresh submission id, so this link, never a draft id compared with a `clientMessageId`, // is how a client knows the host has taken a message over. -import type { AgentJournalSubmission } from './agent-session-journal-types' -import { - reconcileStructuredAgentSessionOutbox, - type StructuredAgentSessionOutboxEntry -} from './structured-agent-session-outbox' +import type { AgentJournalRenderItem, AgentJournalSubmission } from './agent-session-journal-types' +import type { StructuredAgentSessionOutboxEntry } from './structured-agent-session-outbox' +import { reconcileStructuredAgentSessionOutbox } from './structured-agent-session-outbox-reconcile' /** Ids of the queued drafts the journal shows handed off, in any dispatch state. An outbox entry * under one of these ids belongs to the host: its card or bubble carries the text from here. */ @@ -29,13 +27,14 @@ export function handedOffQueuedMessageIds( */ export function reconcileStructuredAgentSessionOutboxWithQueue( entries: readonly StructuredAgentSessionOutboxEntry[], - submissions: readonly AgentJournalSubmission[] -): StructuredAgentSessionOutboxEntry[] { + submissions: readonly AgentJournalSubmission[], + items: readonly AgentJournalRenderItem[] +): readonly StructuredAgentSessionOutboxEntry[] { const handedOff = handedOffQueuedMessageIds(submissions) + const ours = entries.filter((entry) => !handedOff.has(entry.clientMessageId)) return reconcileStructuredAgentSessionOutbox( - handedOff.size === 0 - ? entries - : entries.filter((entry) => !handedOff.has(entry.clientMessageId)), - submissions + ours.length === entries.length ? entries : ours, + submissions, + items ) } diff --git a/src/shared/structured-agent-session-message-projection.ts b/src/shared/structured-agent-session-message-projection.ts index 5515b6478a2..57a2f6fe15d 100644 --- a/src/shared/structured-agent-session-message-projection.ts +++ b/src/shared/structured-agent-session-message-projection.ts @@ -1,33 +1,110 @@ import type { AgentJournalRenderItem, AgentJournalSubmission } from './agent-session-journal-types' import { agentJournalSubmissionKey } from './agent-session-journal-item-key' -import { agentJournalItemPosition } from './agent-session-journal-position' import { isQueuedAgentJournalSubmission } from './agent-session-queued-submission' import { collapseProviderRetryRuns } from './native-chat-provider-retry-runs' import type { NativeChatMessage } from './native-chat-types' +import { dispatchWasWithdrawn } from './structured-agent-session-dispatch-rejection' import type { StructuredAgentSessionOutboxEntry } from './structured-agent-session-outbox' import { structuredAgentSessionEntryHeldForRetry } from './structured-agent-session-outbox-admission' import { reconcileStructuredAgentSessionOutboxWithQueue } from './structured-agent-session-draft-hand-off' import { projectStructuredItemsToNativeChat } from './structured-agent-session-projection' +export type StructuredAgentSessionMessageProjectionOptions = { + /** Draw a message the host accepted and then rejected where the host recorded it, as not sent. + * Off for a client that hands such a message back to its composer instead. */ + rejectedInPlace: boolean + /** The queue's live cards: a rejected message one of them holds is drawn there, not here. */ + queuedMessageIds?: readonly string[] +} + +/** The loaded items that are a conversation command such as `/compact`, by item id. */ +export function structuredAgentSessionCommandItemIds( + items: readonly AgentJournalRenderItem[] +): Set { + return new Set( + items + .filter((item) => item.body.kind === 'message' && item.body.command) + .map((item) => item.itemId) + ) +} + +/** + * The rejected submissions the host's history shows in place as not sent, by item id; `submissions` + * in submission order, as the client keeps them. A withdrawn one went back to its sender, one the + * queue holds (a draft's hand-off, or a card under its id) is drawn as its card, and a command such + * as `/compact` has its rejection reported as its own reply. + */ +export function structuredAgentSessionRejectedShownInPlace( + submissions: readonly AgentJournalSubmission[], + queuedMessageIds: readonly string[], + commandItemIds: ReadonlySet +): Set { + const cards = new Set(queuedMessageIds) + // Each body's copies, as positions in submission order. A withdrawn one is hidden too, so it + // supersedes nothing. + const copies = new Map() + for (const [index, submission] of submissions.entries()) { + if (!dispatchWasWithdrawn(submission)) { + const copy = { index, submittedAt: submission.submittedAt } + const same = copies.get(submission.payloadFingerprint) + if (same) { + same.push(copy) + } else { + copies.set(submission.payloadFingerprint, [copy]) + } + } + } + const shown = new Set() + for (const [index, submission] of submissions.entries()) { + const { resolvedAt } = submission + if ( + submission.dispatchState !== 'rejected' || + dispatchWasWithdrawn(submission) || + submission.queuedMessageId !== undefined || + cards.has(submission.clientMessageId) || + commandItemIds.has(agentJournalSubmissionKey(submission.clientMessageId)) || + // Collapses resends of a rejected message: past Retries resent it under a new id, and the + // host re-delivers its own messages under new ids. Only a later copy sent once the rejection + // was known counts, so a repeat sent before it is kept. + (resolvedAt !== null && + (copies.get(submission.payloadFingerprint) ?? []).some( + (copy) => copy.index > index && copy.submittedAt >= resolvedAt + )) + ) { + continue + } + shown.add(agentJournalSubmissionKey(submission.clientMessageId)) + } + return shown +} + export function projectStructuredAgentSessionMessages( items: readonly AgentJournalRenderItem[], outbox: readonly StructuredAgentSessionOutboxEntry[], submissions: readonly AgentJournalSubmission[], + options: StructuredAgentSessionMessageProjectionOptions, projectItems = projectStructuredItemsToNativeChat ): NativeChatMessage[] { - const optimistic = reconcileStructuredAgentSessionOutboxWithQueue(outbox, submissions) - // Refused sends are ledger evidence, not conversation history; local drafts remain in the outbox. + const optimistic = reconcileStructuredAgentSessionOutboxWithQueue(outbox, submissions, items) + // Refused sends are ledger evidence, not conversation history, unless drawn in place as not sent. const rejected = new Set( submissions .filter((submission) => submission.dispatchState === 'rejected') .map((submission) => agentJournalSubmissionKey(submission.clientMessageId)) ) + const inPlace = options.rejectedInPlace + ? structuredAgentSessionRejectedShownInPlace( + submissions, + options.queuedMessageIds ?? [], + structuredAgentSessionCommandItemIds(items) + ) + : new Set() const visibleItems: AgentJournalRenderItem[] = [] - const refused = new Map() + const unsentItems: AgentJournalRenderItem[] = [] for (const item of items) { - if (rejected.has(item.itemId)) { - refused.set(item.itemId, item) - } else { + if (inPlace.has(item.itemId)) { + unsentItems.push(item) + } else if (!rejected.has(item.itemId)) { visibleItems.push(item) } } @@ -52,23 +129,19 @@ export function projectStructuredAgentSessionMessages( // After the held sends leave: they are drawn after the conversation, never inside a run. ...collapseProviderRetryRuns(delivered), ...held, + // In no turn, like the outbox's not-sent rows; the journal position keeps their place. + ...projectItems(unsentItems).map((message) => ({ ...message, unsent: true as const })), ...optimistic .filter((entry) => !journalled.has(agentJournalSubmissionKey(entry.clientMessageId))) - .map((entry): NativeChatMessage => { - const id = agentJournalSubmissionKey(entry.clientMessageId) - const recorded = refused.get(id) - return { - id, - role: 'user', - source: 'transcript', - timestamp: entry.queuedAt, - blocks: entry.body.blocks, - ...(entry.state === 'rejected' || structuredAgentSessionEntryHeldForRetry(entry) - ? { unsent: true as const } - : {}), - // A send the journal recorded before refusing it keeps its place there. - ...(recorded ? { journalPosition: agentJournalItemPosition(recorded) } : {}) - } - }) + .map((entry): NativeChatMessage => ({ + id: agentJournalSubmissionKey(entry.clientMessageId), + role: 'user', + source: 'transcript', + timestamp: entry.queuedAt, + blocks: entry.body.blocks, + ...(entry.state === 'rejected' || structuredAgentSessionEntryHeldForRetry(entry) + ? { unsent: true as const } + : {}) + })) ] } diff --git a/src/shared/structured-agent-session-outbox-reconcile.test.ts b/src/shared/structured-agent-session-outbox-reconcile.test.ts new file mode 100644 index 00000000000..0336bd649e4 --- /dev/null +++ b/src/shared/structured-agent-session-outbox-reconcile.test.ts @@ -0,0 +1,100 @@ +// The reconcile runs on every journal batch, so reading an unchanged journal again must change +// nothing: the same entries, and the same list. + +import { expect, it } from 'vitest' +import { agentJournalSubmissionKey } from './agent-session-journal-item-key' +import type { + AgentJournalDispatchState, + AgentJournalRenderItem, + AgentJournalSubmission +} from './agent-session-journal-types' +import { + createStructuredAgentSessionOutboxEntry, + type StructuredAgentSessionOutboxEntry +} from './structured-agent-session-outbox' +import { reconcileStructuredAgentSessionOutbox } from './structured-agent-session-outbox-reconcile' + +function entry( + id: string, + patch: Partial = {} +): StructuredAgentSessionOutboxEntry { + return { + ...createStructuredAgentSessionOutboxEntry({ + clientMessageId: id, + sessionId: 'session-1', + text: id, + attachments: [], + queuedAt: 1 + }), + ...patch + } +} + +function submission(id: string, dispatchState: AgentJournalDispatchState): AgentJournalSubmission { + return { + clientMessageId: id, + fence: 1, + payloadFingerprint: id, + dispatchState, + providerItemId: null, + reason: dispatchState === 'unknown' ? 'in doubt' : null, + ...(dispatchState === 'rejected' ? { rejection: { kind: 'hostRestarted' } } : {}), + submittedAt: 5, + resolvedAt: dispatchState === 'pending' ? null : 6 + } +} + +function row(id: string): AgentJournalRenderItem { + return { + itemId: agentJournalSubmissionKey(id), + revision: 1, + sequence: 1, + observedAt: 1, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: id }] } + } +} + +it('returns every kept entry, and the list, as themselves when read again', () => { + const entries = [ + entry('queued'), + entry('landing', { state: 'unconfirmed', lastFailure: { kind: 'failed' } }), + entry('sending', { state: 'dispatching' }), + entry('doubt', { state: 'dispatching', lastFailure: { kind: 'failed' } }), + entry('rejected-unloaded', { state: 'dispatching' }), + entry('rejected-loaded', { state: 'dispatching' }), + entry('delivered', { state: 'dispatching' }), + entry('refused', { + state: 'rejected', + lastFailure: { kind: 'refused', code: 'agent_session_operation_invalid' } + }) + ] + const submissions = [ + submission('landing', 'pending'), + submission('sending', 'pending'), + submission('doubt', 'unknown'), + submission('rejected-unloaded', 'rejected'), + submission('rejected-loaded', 'rejected'), + submission('delivered', 'accepted') + ] + const items = [row('rejected-loaded')] + + const first = reconcileStructuredAgentSessionOutbox(entries, submissions, items) + expect(first.map((kept) => [kept.clientMessageId, kept.state])).toEqual([ + ['queued', 'queued'], + ['landing', 'dispatching'], + ['sending', 'dispatching'], + ['doubt', 'unconfirmed'], + ['rejected-unloaded', 'rejected'], + ['refused', 'rejected'] + ]) + const second = reconcileStructuredAgentSessionOutbox(first, submissions, items) + expect(second).toBe(first) + second.forEach((kept, index) => expect(kept).toBe(first[index])) +}) + +it('returns the list it was given when nothing changes', () => { + const entries = [entry('queued'), entry('doubt', { state: 'unconfirmed' })] + expect(reconcileStructuredAgentSessionOutbox(entries, [submission('doubt', 'unknown')], [])).toBe( + entries + ) +}) diff --git a/src/shared/structured-agent-session-outbox-reconcile.ts b/src/shared/structured-agent-session-outbox-reconcile.ts new file mode 100644 index 00000000000..9fa699eece1 --- /dev/null +++ b/src/shared/structured-agent-session-outbox-reconcile.ts @@ -0,0 +1,69 @@ +// How the journal's view of each submission settles the outbox: the sibling of +// `disposeStructuredAgentSessionSendResult`, which folds a single send's answer. + +import { agentJournalSubmissionKey } from './agent-session-journal-item-key' +import type { AgentJournalRenderItem, AgentJournalSubmission } from './agent-session-journal-types' +import { dispatchWasWithdrawn } from './structured-agent-session-dispatch-rejection' +import { + structuredAgentSessionEntryRejectedByHost, + structuredAgentSessionRejectedFailure, + type StructuredAgentSessionOutboxEntry +} from './structured-agent-session-outbox' + +export function reconcileStructuredAgentSessionOutbox( + entries: readonly StructuredAgentSessionOutboxEntry[], + submissions: readonly AgentJournalSubmission[], + /** The loaded journal rows: a rejected message leaves only once the row that draws it is here. */ + items: readonly AgentJournalRenderItem[] +): readonly StructuredAgentSessionOutboxEntry[] { + const settled = new Map(submissions.map((entry) => [entry.clientMessageId, entry])) + let loaded: Set | undefined + // An entry whose reconciled value is unchanged is returned as itself, and so is the list when + // none changed: a caller re-reading every journal batch writes nothing then. + const next = entries.flatMap((entry) => { + const submission = settled.get(entry.clientMessageId) + // Settled by the host: its history shows one delivered; a withdrawn one goes back to the + // composer. + if (submission?.dispatchState === 'accepted' || dispatchWasWithdrawn(submission)) { + return [] + } + if (submission?.dispatchState === 'rejected') { + // Its row draws it once loaded; until then the entry does, as the host recorded it. An older + // host leaves that row where it was sent, which may be outside the loaded window. + loaded ??= new Set(items.map((item) => item.itemId)) + if (loaded.has(agentJournalSubmissionKey(entry.clientMessageId))) { + return [] + } + const lastFailure = structuredAgentSessionRejectedFailure(submission) + return [ + structuredAgentSessionEntryRejectedByHost(entry) + ? entry + : { ...entry, state: 'rejected' as const, lastFailure } + ] + } + if (submission?.dispatchState === 'pending') { + if (entry.state === 'dispatching') { + return [entry] + } + // The host has it, so no failure of an earlier attempt describes it now. + const { lastFailure: _landed, ...landed } = entry + return [{ ...landed, state: 'dispatching' as const }] + } + if ( + submission?.dispatchState === 'unknown' && + entry.retryAfterUnknownSubmittedAt !== -1 && + entry.retryAfterUnknownSubmittedAt !== submission.submittedAt + ) { + // In doubt now, not failed: the probe's resend decides it, as for any unconfirmed send. + if (entry.state === 'unconfirmed' && entry.lastFailure === undefined) { + return [entry] + } + const { lastFailure: _superseded, ...inDoubt } = entry + return [{ ...inDoubt, state: 'unconfirmed' as const }] + } + return [entry] + }) + return next.length === entries.length && next.every((entry, index) => entry === entries[index]) + ? entries + : next +} diff --git a/src/shared/structured-agent-session-outbox-retry-hold.test.ts b/src/shared/structured-agent-session-outbox-retry-hold.test.ts index 3f0c439fc9a..cd2bb570b57 100644 --- a/src/shared/structured-agent-session-outbox-retry-hold.test.ts +++ b/src/shared/structured-agent-session-outbox-retry-hold.test.ts @@ -2,9 +2,9 @@ import { describe, expect, it } from 'vitest' import type { AgentJournalSubmission } from './agent-session-journal-types' import { createStructuredAgentSessionOutboxEntry, - reconcileStructuredAgentSessionOutbox, type StructuredAgentSessionOutboxEntry } from './structured-agent-session-outbox' +import { reconcileStructuredAgentSessionOutbox } from './structured-agent-session-outbox-reconcile' import { admitStructuredAgentSessionOutboxEntry, structuredAgentSessionEntryHeldForRetry @@ -71,7 +71,8 @@ describe('a message held for its Retry', () => { } const [landed] = reconcileStructuredAgentSessionOutbox( [entry('held', { lastFailure: REFUSED })], - [submission] + [submission], + [] ) expect(landed?.state).toBe('dispatching') expect(landed?.lastFailure).toBeUndefined() @@ -91,7 +92,8 @@ describe('a message held for its Retry', () => { } const reconciled = reconcileStructuredAgentSessionOutbox( [entry('held', { lastAttemptAt: 2, lastFailure: REFUSED }), entry('next')], - [submission] + [submission], + [] ) expect(reconciled[0]).toMatchObject({ state: 'unconfirmed' }) expect(reconciled[0]?.lastFailure).toBeUndefined() diff --git a/src/shared/structured-agent-session-outbox.ts b/src/shared/structured-agent-session-outbox.ts index 7b3606f3dc8..32dcab0e66b 100644 --- a/src/shared/structured-agent-session-outbox.ts +++ b/src/shared/structured-agent-session-outbox.ts @@ -1,6 +1,6 @@ import type { AgentSessionFailureFact } from './agent-session-failure' import { readWholeAgentSessionFailureFact } from './agent-session-failure' -import type { AgentJournalMessageItem, AgentJournalSubmission } from './agent-session-journal-types' +import type { AgentJournalMessageItem } from './agent-session-journal-types' import { parseAgentSessionWriteFailure, type AgentSessionWriteFailure, @@ -14,11 +14,11 @@ import { structuredAgentSessionMessageSendMutation, type StructuredAgentSessionSendMutation } from './structured-agent-session-send-mutation' -import { classifyDispatchRejection } from './structured-agent-session-dispatch-rejection' import { parseStructuredAgentSessionOutboxQueueFields } from './structured-agent-session-outbox-delivery' -/** `rejected`: the host settled the send as not delivered. The drain never sends it again on its - * own and nothing queues behind it; only the user's Retry does. */ +/** `rejected`: settled as not delivered. The drain never sends it again and nothing queues behind + * it. One the host refused unrecorded waits for the user's Retry. One it recorded owes no delivery + * and leaves on the batch or page that loads its row (`structured-agent-session-outbox-reconcile`). */ export type StructuredAgentSessionOutboxState = | 'queued' | 'dispatching' @@ -176,6 +176,14 @@ export function structuredAgentSessionEntryIdExpired( ) } +/** The host recorded this send and then rejected it: no Retry, since sending it again is a new + * message. The reconcile drops it once the client holds the rejected submission. */ +export function structuredAgentSessionEntryRejectedByHost( + entry: StructuredAgentSessionOutboxEntry +): boolean { + return entry.state === 'rejected' && entry.lastFailure?.kind === 'rejected' +} + export function requeueStructuredAgentSessionSendRefusal( entry: StructuredAgentSessionOutboxEntry, refusal: AgentSessionWriteRefusal, @@ -208,58 +216,6 @@ export function requeueStructuredAgentSessionSendRefusal( } } -export function reconcileStructuredAgentSessionOutbox( - entries: readonly StructuredAgentSessionOutboxEntry[], - submissions: readonly AgentJournalSubmission[] -): StructuredAgentSessionOutboxEntry[] { - const settled = new Map(submissions.map((entry) => [entry.clientMessageId, entry])) - return entries.flatMap((entry) => { - const submission = settled.get(entry.clientMessageId) - if (submission?.dispatchState === 'accepted') { - return [] - } - if ( - submission?.dispatchState === 'rejected' && - classifyDispatchRejection(submission).category === 'withdrawn' - ) { - return [] - } - if (submission?.dispatchState === 'pending') { - if (entry.state === 'dispatching') { - return [entry] - } - // The host has it, so no failure of an earlier attempt describes it now. - const { lastFailure: _landed, ...landed } = entry - return [{ ...landed, state: 'dispatching' as const }] - } - // Accepted, then not delivered — the agent never started, or its start was refused. The text - // and why stay here for the user's Retry, and nothing queues behind it. `unconfirmed` is how a - // remount reads an entry it left dispatching; the journal has since answered it. - if ( - submission?.dispatchState === 'rejected' && - (entry.state === 'dispatching' || entry.state === 'unconfirmed') - ) { - return [ - { - ...entry, - state: 'rejected' as const, - lastFailure: structuredAgentSessionRejectedFailure(submission) - } - ] - } - if ( - submission?.dispatchState === 'unknown' && - entry.retryAfterUnknownSubmittedAt !== -1 && - entry.retryAfterUnknownSubmittedAt !== submission.submittedAt - ) { - // In doubt now, not failed: the probe's resend decides it, as for any unconfirmed send. - const { lastFailure: _superseded, ...inDoubt } = entry - return [{ ...inDoubt, state: 'unconfirmed' as const }] - } - return [entry] - }) -} - export function parseStructuredAgentSessionOutboxEntry( value: unknown, sessionId: string diff --git a/src/shared/structured-agent-session-send-disposition.test.ts b/src/shared/structured-agent-session-send-disposition.test.ts index 98ea2ff8b67..9605764b017 100644 --- a/src/shared/structured-agent-session-send-disposition.test.ts +++ b/src/shared/structured-agent-session-send-disposition.test.ts @@ -19,9 +19,9 @@ import { } from './structured-agent-session-send-disposition' import { createStructuredAgentSessionOutboxEntry, - reconcileStructuredAgentSessionOutbox, type StructuredAgentSessionOutboxEntry } from './structured-agent-session-outbox' +import { reconcileStructuredAgentSessionOutbox } from './structured-agent-session-outbox-reconcile' import { structuredAgentSessionEntryHeldForRetry } from './structured-agent-session-outbox-admission' const entry: StructuredAgentSessionOutboxEntry = createStructuredAgentSessionOutboxEntry({ @@ -120,7 +120,9 @@ describe('what a rejection shows the user', () => { throw new Error('expected rejected submission fixture') } - expect(reconcileStructuredAgentSessionOutbox([entry], [result.value.submission])).toEqual([]) + expect(reconcileStructuredAgentSessionOutbox([entry], [result.value.submission], [])).toEqual( + [] + ) }) it('never puts the transport marker on screen', () => { @@ -168,7 +170,9 @@ describe('what a rejection shows the user', () => { if (!result.ok || !('submission' in result.value)) { throw new Error('expected rejected submission fixture') } - expect(reconcileStructuredAgentSessionOutbox([entry], [result.value.submission])).toEqual([]) + expect(reconcileStructuredAgentSessionOutbox([entry], [result.value.submission], [])).toEqual( + [] + ) expect( disposeStructuredAgentSessionSendResult({ entries: [entry], diff --git a/src/shared/structured-agent-session-send-disposition.ts b/src/shared/structured-agent-session-send-disposition.ts index d503b453b8e..77e1030d4b2 100644 --- a/src/shared/structured-agent-session-send-disposition.ts +++ b/src/shared/structured-agent-session-send-disposition.ts @@ -83,8 +83,8 @@ function dropEntry(input: SendDispositionInput): StructuredAgentSessionOutboxEnt * the outbox. Nothing is lost from the conversation: the durable submission row * already renders the message. * - * A `rejected` submission takes the other path — the message provably did not - * happen, so Retry rotates the id and sends it as a genuinely new message. + * A `rejected` submission is the host's to show, with no Retry: the reconcile + * drops its entry once the journal carries it. */ function refusedRedelivery( entry: StructuredAgentSessionOutboxEntry, @@ -274,6 +274,8 @@ export function disposeStructuredAgentSessionSendResult( error: null } } + // Recorded, so the journal's row shows it once it arrives; until then the entry draws it, saying + // why and offering no Retry. if (submission.dispatchState === 'rejected') { return { entries: replaceEntryState( From f0b5b8566ce27a8bc1c9035194a074f4cf92c71c Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:31:22 -0700 Subject: [PATCH 26/31] Bound OpenCode history reads and repeated worker failures (#25292) * fix(opencode): keep scan budgets across queue waits and batches Reuse the scan-owned lifetime proposed in #10708 by @AmethystLiang with the existing shared worker queue. * test(opencode): check nonempty session fixtures and lint scoped controls * Derive OpenCode scan deadline message from its budget --------- Co-authored-by: Neil Parker Co-authored-by: OpenCode issue campaign --- ...sion-scanner-opencode-cancellation.test.ts | 88 +++++++++- ...scanner-opencode-sqlite-scan-scope.test.ts | 138 +++++++++++++++ ...sion-scanner-opencode-sqlite-scan-scope.ts | 75 ++++++++ ...nner-opencode-sqlite-worker-client.test.ts | 62 ++++++- ...n-scanner-opencode-sqlite-worker-client.ts | 34 +++- ...on-scanner-opencode-sqlite-worker-spawn.ts | 31 +++- ...ssion-scanner-opencode-wsl-routing.test.ts | 47 ++++- src/main/ai-vault/session-scanner.ts | 5 + src/main/worker-thread-request-queue.test.ts | 160 +++++++++++++++++- src/main/worker-thread-request-queue.ts | 59 +++++-- 10 files changed, 666 insertions(+), 33 deletions(-) create mode 100644 src/main/ai-vault/session-scanner-opencode-sqlite-scan-scope.test.ts create mode 100644 src/main/ai-vault/session-scanner-opencode-sqlite-scan-scope.ts diff --git a/src/main/ai-vault/session-scanner-opencode-cancellation.test.ts b/src/main/ai-vault/session-scanner-opencode-cancellation.test.ts index 4f47af380cd..084a73cd661 100644 --- a/src/main/ai-vault/session-scanner-opencode-cancellation.test.ts +++ b/src/main/ai-vault/session-scanner-opencode-cancellation.test.ts @@ -1,4 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import type * as workerSpawn from './session-scanner-opencode-sqlite-worker-spawn' import type { SessionFileDiscovery } from './session-scanner-types' import type { TranscriptReadOutcome } from './session-transcript-consumers' @@ -24,6 +27,7 @@ import { registerTranscriptConsumer, resetTranscriptConsumersForTests } from './session-transcript-consumers' +import { runOpenCodeSqliteScanRequest } from './session-scanner-opencode-sqlite-scan-scope' const file = { path: '/fixture/opencode.db#session', @@ -40,6 +44,7 @@ beforeEach(() => { resetSessionParseCacheForTests() }) afterEach(() => { + vi.useRealTimers() resetTranscriptConsumersForTests() resetSessionParseCacheForTests() }) @@ -48,7 +53,11 @@ function configure(agent: 'opencode' | 'opencode2') { readers.discover.mockResolvedValue([{ agent, rootDir: '/fixture', files: [file] }]) const accumulator = createAccumulator({ agent, file, sessionId: 'session' }) accumulator.title = 'SQLite session' - return finalizeSession(accumulator, 'linux') + const session = finalizeSession(accumulator, 'linux') + if (!session) { + throw new Error('Configured SQLite session was empty') + } + return session } function untilAborted(signal: AbortSignal | undefined): Promise { @@ -61,6 +70,83 @@ function untilAborted(signal: AbortSignal | undefined): Promise { } describe.each(['opencode', 'opencode2'] as const)('%s scan cancellation', (agent) => { + it('reports a deadline while retaining completed sessions and other agents, then retries', async () => { + vi.useFakeTimers() + const root = mkdtempSync(join(tmpdir(), 'orca-scan-deadline-')) + try { + const session = configure(agent) + const blocked = { ...file, path: '/fixture/opencode.db#blocked' } + const claudePath = join(root, 'claude.jsonl') + writeFileSync( + claudePath, + `${JSON.stringify({ + type: 'user', + sessionId: 'retained-claude', + timestamp: '2026-05-01T10:00:00.000Z', + cwd: root, + message: { role: 'user', content: 'Retain this other-agent session' } + })}\n` + ) + readers.discover.mockResolvedValue([ + { agent, rootDir: '/fixture', files: [file, blocked] }, + { agent: 'claude', rootDir: root, files: [{ ...file, path: claudePath }] } + ]) + readers.parse.mockImplementation(({ sessionId, signal }) => + sessionId === 'blocked' + ? runOpenCodeSqliteScanRequest(signal, untilAborted) + : Promise.resolve(session) + ) + const pending = scanAiVaultSessions({ platform: 'linux' }) + await vi.waitFor(() => expect(readers.parse).toHaveBeenCalledTimes(2)) + await vi.advanceTimersByTimeAsync(45_000) + const result = await pending + expect(result.sessions.map((row) => row.sessionId)).toEqual( + expect.arrayContaining(['session', 'retained-claude']) + ) + expect(result.sessions).toHaveLength(2) + expect(result.issues).toEqual([ + expect.objectContaining({ + agent, + path: blocked.path, + message: expect.stringContaining('45s work budget') + }) + ]) + readers.parse.mockResolvedValue({ ...session, sessionId: 'blocked', filePath: blocked.path }) + const recovered = await scanAiVaultSessions({ platform: 'linux' }) + expect(recovered.sessions).toHaveLength(3) + expect( + readers.parse.mock.calls.filter(([args]) => args.sessionId === 'blocked') + ).toHaveLength(2) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('marks a deadline capture incomplete instead of caching a failed history', async () => { + vi.useFakeTimers() + const session = configure(agent) + const outcomes: TranscriptReadOutcome[] = [] + registerTranscriptConsumer({ + beginRead: () => ({ message() {}, finish: (outcome) => outcomes.push(outcome) }) + }) + readers.capture.mockImplementationOnce(({ signal }) => + runOpenCodeSqliteScanRequest(signal, untilAborted) + ) + const pending = scanAiVaultSessions({ platform: 'linux' }) + await vi.waitFor(() => expect(readers.capture).toHaveBeenCalledOnce()) + await vi.advanceTimersByTimeAsync(45_000) + const result = await pending + expect(result.sessions).toEqual([]) + expect(result.issues).toEqual([ + expect.objectContaining({ message: expect.stringContaining('45s work budget') }) + ]) + expect(outcomes).toEqual([{ session: null, byteOffset: 0, incomplete: true }]) + readers.capture.mockResolvedValue({ session, messages }) + expect((await scanAiVaultSessions({ platform: 'linux' })).sessions).toHaveLength(1) + expect(readers.capture).toHaveBeenCalledTimes(2) + expect(outcomes.at(-1)?.incomplete).toBe(false) + }) + it.each(['parse', 'capture'] as const)( 'cancels an active %s and retries the uncached read', async (mode) => { diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-scan-scope.test.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-scan-scope.test.ts new file mode 100644 index 00000000000..bc97f1aa388 --- /dev/null +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-scan-scope.test.ts @@ -0,0 +1,138 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { + OPENCODE_SQLITE_SCAN_BUDGET_MS, + runOpenCodeSqliteScanRequest, + withOpenCodeSqliteScanScope +} from './session-scanner-opencode-sqlite-scan-scope' + +afterEach(() => vi.useRealTimers()) + +function waitForAbort(signal: AbortSignal | undefined): Promise { + if (!signal) { + throw new Error('Missing scoped request signal') + } + return new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(signal.reason), { once: true }) + }) +} + +function wait(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +it('spends the budget while admission is pending and refuses later work in that scan', async () => { + vi.useFakeTimers() + const admitted = vi.fn() + const outcome = withOpenCodeSqliteScanScope(async () => { + const error = await runOpenCodeSqliteScanRequest(undefined, waitForAbort).catch((err) => err) + expect(error).toMatchObject({ name: 'OpenCodeSqliteScanDeadlineError' }) + await expect(runOpenCodeSqliteScanRequest(undefined, admitted)).rejects.toBe(error) + }) + await vi.advanceTimersByTimeAsync(OPENCODE_SQLITE_SCAN_BUDGET_MS) + await outcome + expect(admitted).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) +}) + +it('banks only outstanding work across legs and does not spend other-agent time', async () => { + vi.useFakeTimers() + const outcome = withOpenCodeSqliteScanScope(async () => { + await runOpenCodeSqliteScanRequest(undefined, () => wait(20_000)) + await wait(70_000) + return runOpenCodeSqliteScanRequest(undefined, waitForAbort) + }).catch((error) => error) + await vi.advanceTimersByTimeAsync(90_000) + let completed = false + void outcome.then(() => { + completed = true + }) + await vi.advanceTimersByTimeAsync(24_999) + expect(completed).toBe(false) + await vi.advanceTimersByTimeAsync(1) + expect(await outcome).toMatchObject({ name: 'OpenCodeSqliteScanDeadlineError' }) + expect(vi.getTimerCount()).toBe(0) +}) + +it('counts overlapping preparation and worker waits once', async () => { + vi.useFakeTimers() + const outcome = withOpenCodeSqliteScanScope(() => + runOpenCodeSqliteScanRequest(undefined, () => + Promise.all([ + runOpenCodeSqliteScanRequest(undefined, waitForAbort), + runOpenCodeSqliteScanRequest(undefined, waitForAbort) + ]) + ) + ).catch((error) => error) + await vi.advanceTimersByTimeAsync(OPENCODE_SQLITE_SCAN_BUDGET_MS - 1) + expect(vi.getTimerCount()).toBe(1) + await vi.advanceTimersByTimeAsync(1) + expect(await outcome).toMatchObject({ name: 'OpenCodeSqliteScanDeadlineError' }) + expect(vi.getTimerCount()).toBe(0) +}) + +it('keeps concurrent scans independent and gives the next scan a fresh owner and budget', async () => { + vi.useFakeTimers() + const owners: unknown[] = [] + const first = withOpenCodeSqliteScanScope(() => + runOpenCodeSqliteScanRequest(undefined, (signal, owner) => { + owners.push(owner) + return waitForAbort(signal) + }) + ).catch((error) => error) + await vi.advanceTimersByTimeAsync(30_000) + const second = withOpenCodeSqliteScanScope(() => + runOpenCodeSqliteScanRequest(undefined, (signal, owner) => { + owners.push(owner) + return waitForAbort(signal) + }) + ).catch((error) => error) + await vi.advanceTimersByTimeAsync(15_000) + expect(await first).toMatchObject({ name: 'OpenCodeSqliteScanDeadlineError' }) + expect(vi.getTimerCount()).toBe(1) + await vi.advanceTimersByTimeAsync(30_000) + expect(await second).toMatchObject({ name: 'OpenCodeSqliteScanDeadlineError' }) + await withOpenCodeSqliteScanScope(() => + runOpenCodeSqliteScanRequest(undefined, async (_signal, owner) => { + owners.push(owner) + }) + ) + expect(new Set(owners).size).toBe(3) + expect(vi.getTimerCount()).toBe(0) +}) + +it('preserves the caller cancellation reason and disposes its timer', async () => { + vi.useFakeTimers() + const controller = new AbortController() + const reason = new Error('caller cancelled') + const outcome = withOpenCodeSqliteScanScope(() => + runOpenCodeSqliteScanRequest(controller.signal, waitForAbort) + ).catch((error) => error) + controller.abort(reason) + expect(await outcome).toBe(reason) + expect(vi.getTimerCount()).toBe(0) +}) + +it('leaves unrelated native-chat and Zcode calls unscoped and retires the scan signal', async () => { + vi.useFakeTimers() + let scopedSignal: AbortSignal | undefined + const caller = new AbortController() + await withOpenCodeSqliteScanScope(async () => { + for (const agent of ['zcode', 'native-chat'] as const) { + await runOpenCodeSqliteScanRequest( + caller.signal, + async (signal, owner) => { + expect(signal).toBe(caller.signal) + expect(owner).toBeUndefined() + expect(vi.getTimerCount()).toBe(0) + }, + agent + ) + } + await runOpenCodeSqliteScanRequest(undefined, async (signal) => { + scopedSignal = signal + }) + }) + expect(scopedSignal?.aborted).toBe(true) + expect(caller.signal.aborted).toBe(false) + expect(vi.getTimerCount()).toBe(0) +}) diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-scan-scope.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-scan-scope.ts new file mode 100644 index 00000000000..e3a53a535b2 --- /dev/null +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-scan-scope.ts @@ -0,0 +1,75 @@ +import { AsyncLocalStorage } from 'node:async_hooks' +import { throwIfSignalAborted } from '../../shared/abort-signal-reason' +import type { WorkerThreadRequestOwner } from '../worker-thread-request-queue' + +export const OPENCODE_SQLITE_SCAN_BUDGET_MS = 45_000 + +class OpenCodeSqliteScanScope implements WorkerThreadRequestOwner { + private readonly controller = new AbortController() + readonly signal = this.controller.signal + private remainingMs = OPENCODE_SQLITE_SCAN_BUDGET_MS + private outstanding = 0 + private armedAt = 0 + private timer: NodeJS.Timeout | undefined + + async run( + callerSignal: AbortSignal | undefined, + fn: (signal: AbortSignal, owner: WorkerThreadRequestOwner) => Promise + ): Promise { + throwIfSignalAborted(callerSignal) + throwIfSignalAborted(this.signal) + if (this.outstanding++ === 0) { + this.armedAt = Date.now() + this.timer = setTimeout(() => { + const error = new Error( + `OpenCode SQLite scan exceeded its ${OPENCODE_SQLITE_SCAN_BUDGET_MS / 1000}s work budget` + ) + error.name = 'OpenCodeSqliteScanDeadlineError' + this.controller.abort(error) + }, this.remainingMs) + this.timer.unref?.() + } + const signal = callerSignal ? AbortSignal.any([callerSignal, this.signal]) : this.signal + try { + return await fn(signal, this) + } finally { + if (--this.outstanding === 0) { + this.pause() + } + } + } + + dispose(): void { + this.pause() + this.controller.abort(new Error('OpenCode SQLite scan ended')) + } + + private pause(): void { + if (this.timer) { + clearTimeout(this.timer) + this.timer = undefined + this.remainingMs = Math.max(0, this.remainingMs - (Date.now() - this.armedAt)) + } + } +} + +const scanScope = new AsyncLocalStorage() + +export async function withOpenCodeSqliteScanScope(fn: () => Promise): Promise { + const scope = new OpenCodeSqliteScanScope() + try { + return await scanScope.run(scope, fn) + } finally { + scope.dispose() + } +} + +// The clock covers outstanding SQLite work, including admission and WSL preparation. +export function runOpenCodeSqliteScanRequest( + signal: AbortSignal | undefined, + fn: (signal?: AbortSignal, owner?: WorkerThreadRequestOwner) => Promise, + agent?: 'opencode2' | 'zcode' | 'native-chat' +): Promise { + const scope = agent === 'zcode' || agent === 'native-chat' ? undefined : scanScope.getStore() + return scope ? scope.run(signal, fn) : fn(signal) +} diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.test.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.test.ts index 93df4ed987c..68fb8d09d90 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.test.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.test.ts @@ -12,6 +12,7 @@ import type { OpenCodeSqliteWorkerResponse } from './session-scanner-opencode-sqlite-worker-protocol' import type { AiVaultScanIssue } from '../../shared/ai-vault-types' +import { withOpenCodeSqliteScanScope } from './session-scanner-opencode-sqlite-scan-scope' // A worker_threads stand-in the tests drive directly: it records posted requests // and lets a test emit message/error/exit without a built worker bundle. @@ -75,6 +76,63 @@ function makeFactory(workers: FakeWorker[]): () => Worker { } describe('OpenCodeSqliteWorkerClient', () => { + it('expires a scan in the FIFO without cancelling ordinary reads or a later scan', async () => { + vi.useFakeTimers() + const workers: FakeWorker[] = [] + const client = new OpenCodeSqliteWorkerClient({ workerFactory: makeFactory(workers), log() {} }) + const issues: AiVaultScanIssue[] = [] + try { + const ordinary = [1, 2].map((id) => + client.list({ + dbPaths: [`/ordinary-${id}.db`], + limit: 1, + issues: [] + }) + ) + const scan = withOpenCodeSqliteScanScope(() => + client.list({ + dbPaths: ['/scan.db'], + limit: 1, + issues + }) + ) + await vi.advanceTimersByTimeAsync(25_000) + workers[0].emit('message', { + id: workers[0].lastId(), + ok: true, + value: { candidates: [], issues: [] } + }) + await vi.advanceTimersByTimeAsync(20_000) + await expect(scan).resolves.toEqual([]) + expect(issues).toEqual([ + expect.objectContaining({ + kind: 'scope', + message: expect.stringContaining('45s work budget') + }) + ]) + expect(workers[0].postedRequests).toHaveLength(2) + expect(workers[0].terminated).toBe(false) + workers[0].emit('message', { + id: workers[0].lastId(), + ok: true, + value: { candidates: [], issues: [] } + }) + await expect(Promise.all(ordinary)).resolves.toEqual([[], []]) + const next = withOpenCodeSqliteScanScope(() => + client.list({ dbPaths: ['/next.db'], limit: 1, issues: [] }) + ) + workers[0].emit('message', { + id: workers[0].lastId(), + ok: true, + value: { candidates: [], issues: [] } + }) + await expect(next).resolves.toEqual([]) + } finally { + client.dispose() + vi.useRealTimers() + } + }) + it('correlates responses by id and ignores stale ids', async () => { const workers: FakeWorker[] = [] const client = new OpenCodeSqliteWorkerClient({ workerFactory: makeFactory(workers), log() {} }) @@ -188,7 +246,7 @@ describe('OpenCodeSqliteWorkerClient', () => { client.list({ dbPaths: ['/tmp/opencode.db'], limit: 10, issues: listIssues }) ).resolves.toEqual([]) expect( - listIssues.some((issue) => /background scanner could not start/.test(issue.message)) + listIssues.some((issue) => issue.message.includes('background scanner could not start')) ).toBe(true) await expect( client.parse({ dbPath: '/tmp/opencode.db', sessionId: 'ses_skipped', platform: 'darwin' }) @@ -264,7 +322,7 @@ describe('OpenCodeSqliteWorkerClient', () => { const first = await client.list({ dbPaths: ['/db'], limit: 10, issues: firstIssues }) expect(first).toEqual([]) expect( - firstIssues.some((issue) => /background scanner could not start/.test(issue.message)) + firstIssues.some((issue) => issue.message.includes('background scanner could not start')) ).toBe(true) await expect( client.parse({ dbPath: '/db', sessionId: 'ses_heal', platform: 'darwin' }) diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.ts index f303de52443..c6a85cb58b0 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.ts @@ -12,6 +12,7 @@ import type { import { parseOpenCodeSqliteCaptureValue } from './session-scanner-opencode-sqlite-worker-response' import type { SessionFileCandidate } from './session-scanner-types' import { errorMessage } from './session-scanner-values' +import { runOpenCodeSqliteScanRequest } from './session-scanner-opencode-sqlite-scan-scope' // Why (#8864): a lazily-spawned, unref'd worker runs OpenCode SQLite reads off // the main-process event loop. This module owns only the OpenCode legs; the @@ -117,7 +118,8 @@ export class OpenCodeSqliteWorkerClient { ...(args.agent ? { agent: args.agent } : {}) }), LIST_TIMEOUT_MS, - args.signal + args.signal, + args.agent )) as OpenCodeSqliteListValue args.issues.push(...value.issues) return value.candidates @@ -178,7 +180,8 @@ export class OpenCodeSqliteWorkerClient { ...(args.agent ? { agent: args.agent } : {}) }), PARSE_TIMEOUT_MS, - args.signal + args.signal, + args.agent ) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the worker's parse leg returns exactly this, built by the repo's own reader on the other side of a structured clone. return value as AiVaultSession | null @@ -217,7 +220,8 @@ export class OpenCodeSqliteWorkerClient { ...(args.agent ? { agent: args.agent } : {}) }), CAPTURE_TIMEOUT_MS, - args.signal + args.signal, + args.agent ) return parseOpenCodeSqliteCaptureValue(value) } catch (err) { @@ -229,7 +233,12 @@ export class OpenCodeSqliteWorkerClient { args: Omit, signal?: AbortSignal ): Promise { - const value = await this.dispatch((id) => ({ ...args, id }), PARSE_TIMEOUT_MS, signal) + const value = await this.dispatch( + (id) => ({ ...args, id }), + PARSE_TIMEOUT_MS, + signal, + 'native-chat' + ) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Only this build's internal worker dispatch constructs page/signal results; they are not client-supplied paths or frames. return value as OpenCodeNativeChatReadValue } @@ -241,13 +250,20 @@ export class OpenCodeSqliteWorkerClient { private async dispatch( buildRequest: (id: number) => OpenCodeSqliteWorkerRequest, timeoutMs: number, - signal?: AbortSignal + signal?: AbortSignal, + agent?: 'opencode2' | 'zcode' | 'native-chat' ): Promise { const deadline = this.requestTimeoutMs ?? timeoutMs - const response = await this.requests.dispatch( - (id) => ({ ...buildRequest(id), timeoutMs: deadline }), - deadline, - signal + const response = await runOpenCodeSqliteScanRequest( + signal, + (requestSignal, owner) => + this.requests.dispatch( + (id) => ({ ...buildRequest(id), timeoutMs: deadline }), + deadline, + requestSignal, + owner + ), + agent ) if (!response.ok) { throw new Error(response.error) diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-spawn.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-spawn.ts index 13b9cd3cd5e..e89a6361b1e 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-spawn.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-spawn.ts @@ -16,6 +16,7 @@ import { openCodeWslPath } from './session-scanner-opencode-wsl-client' import { findForeignSqliteReaderEntry } from '../foreign-sqlite-readers/foreign-sqlite-reader-entry-path' +import { runOpenCodeSqliteScanRequest } from './session-scanner-opencode-sqlite-scan-scope' // Why: resolve the built worker entry + own the process-wide shared client so // the client class stays free of Electron (require'd lazily here) and the @@ -179,8 +180,14 @@ async function listForHost( const first = paths.values().next().value! const issues: AiVaultScanIssue[] = [] try { - const client = await openCodeWslClient(distro, first, args.signal) - const result = await client.list({ ...args, dbPaths: [...paths.keys()], issues }) + const result = await runOpenCodeSqliteScanRequest( + args.signal, + async (signal) => { + const client = await openCodeWslClient(distro, first, signal) + return client.list({ ...args, signal, dbPaths: [...paths.keys()], issues }) + }, + args.agent + ) return result.flatMap((candidate) => { const parsed = splitOpenCodeSqliteCandidate(candidate.file.path, args.agent) const original = parsed && paths.get(parsed.dbPath) @@ -223,8 +230,14 @@ async function parseForHost( if (!wsl) { return getSharedClient().parse(args) } - const client = await openCodeWslClient(wsl.distro, args.dbPath, args.signal) - const session = await client.parse({ ...args, dbPath: wsl.linuxPath, platform: 'linux' }) + const session = await runOpenCodeSqliteScanRequest( + args.signal, + async (signal) => { + const client = await openCodeWslClient(wsl.distro, args.dbPath, signal) + return client.parse({ ...args, signal, dbPath: wsl.linuxPath, platform: 'linux' }) + }, + args.agent + ) return mapOpenCodeWslSession(session, args.dbPath) } @@ -235,8 +248,14 @@ async function captureForHost( if (!wsl) { return getSharedClient().capture(args) } - const client = await openCodeWslClient(wsl.distro, args.dbPath, args.signal) - const capture = await client.capture({ ...args, dbPath: wsl.linuxPath, platform: 'linux' }) + const capture = await runOpenCodeSqliteScanRequest( + args.signal, + async (signal) => { + const client = await openCodeWslClient(wsl.distro, args.dbPath, signal) + return client.capture({ ...args, signal, dbPath: wsl.linuxPath, platform: 'linux' }) + }, + args.agent + ) return { ...capture, session: mapOpenCodeWslSession(capture.session, args.dbPath) } } diff --git a/src/main/ai-vault/session-scanner-opencode-wsl-routing.test.ts b/src/main/ai-vault/session-scanner-opencode-wsl-routing.test.ts index 754211c323c..9db4ba85e83 100644 --- a/src/main/ai-vault/session-scanner-opencode-wsl-routing.test.ts +++ b/src/main/ai-vault/session-scanner-opencode-wsl-routing.test.ts @@ -3,6 +3,7 @@ import type { AiVaultScanIssue } from '../../shared/ai-vault-types' import { createAccumulator, finalizeSession } from './session-scanner-accumulator' import type { SessionFileCandidate } from './session-scanner-types' import type * as wslClientModule from './session-scanner-opencode-wsl-client' +import { withOpenCodeSqliteScanScope } from './session-scanner-opencode-sqlite-scan-scope' const mocks = vi.hoisted(() => ({ native: { @@ -59,9 +60,53 @@ beforeEach(() => { vi.clearAllMocks() vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') }) -afterEach(() => vi.restoreAllMocks()) +afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() +}) describe('OpenCode SQLite execution-host routes', () => { + it('budgets WSL preparation while retaining a native source that already answered', async () => { + vi.useFakeTimers() + mocks.native.list.mockResolvedValueOnce([row(native)]) + mocks.guest.mockImplementationOnce((_distro, _path, signal: AbortSignal | undefined) => { + if (!signal) { + throw new Error('Missing scoped preparation signal') + } + return new Promise((_resolve, reject) => + signal.addEventListener('abort', () => reject(signal.reason), { once: true }) + ) + }) + const issues: AiVaultScanIssue[] = [] + const result = withOpenCodeSqliteScanScope(() => + listOpenCodeSqliteSessionsViaWorker({ + dbPaths: [native, ubuntu], + limit: 2, + issues + }) + ) + await vi.advanceTimersByTimeAsync(45_000) + expect((await result).map((entry) => entry.file.path)).toEqual([`${native}#same-session`]) + expect(issues).toEqual([ + expect.objectContaining({ + path: ubuntu, + kind: 'scope', + message: expect.stringContaining('45s work budget') + }) + ]) + mocks.guest.mockResolvedValueOnce({ list: vi.fn(async () => [row(guest)]) }) + const recoveredIssues: AiVaultScanIssue[] = [] + const recovered = await withOpenCodeSqliteScanScope(() => + listOpenCodeSqliteSessionsViaWorker({ + dbPaths: [ubuntu], + limit: 2, + issues: recoveredIssues + }) + ) + expect(recovered).toHaveLength(1) + expect(recoveredIssues).toEqual([]) + }) + it('separates native and distro databases and preserves equal IDs in different distros', async () => { const list = vi.fn(async (args) => [row(args.dbPaths[0])]) mocks.guest.mockResolvedValue({ list }) diff --git a/src/main/ai-vault/session-scanner.ts b/src/main/ai-vault/session-scanner.ts index 8716440a565..f0525c918c8 100644 --- a/src/main/ai-vault/session-scanner.ts +++ b/src/main/ai-vault/session-scanner.ts @@ -45,6 +45,7 @@ import { clampPositiveInteger, errorMessage } from './session-scanner-values' import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation' import { DEFAULT_AI_VAULT_SCAN_LIMIT } from '../../shared/ai-vault-session-depth' import { withDevinSessionsDbScan } from './session-scanner-devin-db' +import { withOpenCodeSqliteScanScope } from './session-scanner-opencode-sqlite-scan-scope' const SESSION_PARSE_CONCURRENCY = 8 const SESSION_PARSE_CANDIDATE_MULTIPLIER = 2 @@ -61,6 +62,10 @@ const SESSION_PARSE_CANDIDATE_MULTIPLIER = 2 export async function scanAiVaultSessions( options: AiVaultScanOptions = {} ): Promise { + return withOpenCodeSqliteScanScope(() => scanAiVaultSessionStores(options)) +} + +async function scanAiVaultSessionStores(options: AiVaultScanOptions): Promise { // The span makes scan cost visible in the local trace file: STA-1278-style // "one core pegged" reports need to show whether transcript scanning is the // subsystem burning CPU, and how much of each scan the cache absorbed. diff --git a/src/main/worker-thread-request-queue.test.ts b/src/main/worker-thread-request-queue.test.ts index 4bf2188dc54..2046267396c 100644 --- a/src/main/worker-thread-request-queue.test.ts +++ b/src/main/worker-thread-request-queue.test.ts @@ -92,9 +92,10 @@ function makeQueue( function send( queue: WorkerThreadRequestQueue, - label: string + label: string, + owner?: { readonly signal: AbortSignal } ): Promise { - return queue.dispatch((id) => ({ id, label }), TIMEOUT_MS) + return queue.dispatch((id) => ({ id, label }), TIMEOUT_MS, undefined, owner) } /** Resolve to the response or to the rejection, so a test can assert on either. */ @@ -328,6 +329,161 @@ describe('WorkerThreadRequestQueue', () => { expect(await behind).toMatchObject({ label: 'd' }) }) + it('keeps one owner fault budget across idle batches and refuses a fourth worker', async () => { + const workers: FakeWorker[] = [] + const queue = makeQueue(workers) + const owner = { signal: new AbortController().signal } + try { + for (let fault = 0; fault < 3; fault++) { + const call = settle(send(queue, `owned-${fault}`, owner)) + workers.at(-1)?.emit('error', new Error(`fault-${fault}`)) + expect(await call).toMatchObject({ message: `fault-${fault}` }) + } + const refused = settle(send(queue, 'fourth', owner)) + expect(workers).toHaveLength(3) + await expect(refused).resolves.toMatchObject({ message: 'crashed repeatedly (fault-2)' }) + const nextScan = send(queue, 'new-scan', { signal: new AbortController().signal }) + expect(workers).toHaveLength(4) + workers[3].respond() + await expect(nextScan).resolves.toMatchObject({ label: 'new-scan' }) + } finally { + queue.dispose() + } + }) + + it('resets only the successful owner and does not count idle worker exits', async () => { + const workers: FakeWorker[] = [] + const queue = makeQueue(workers) + const a = { signal: new AbortController().signal } + const b = { signal: new AbortController().signal } + try { + const initial = send(queue, 'initial', a) + workers[0].respond() + await initial + workers[0].emit('exit', 17) + for (let fault = 0; fault < 2; fault++) { + const call = settle(send(queue, `a-${fault}`, a)) + workers.at(-1)?.emit('error', new Error(`a-fault-${fault}`)) + await call + } + const peer = send(queue, 'b-success', b) + workers.at(-1)?.respond() + await peer + const third = settle(send(queue, 'a-third', a)) + workers.at(-1)?.emit('error', new Error('a-third-fault')) + await third + const refused = settle(send(queue, 'a-fourth', a)) + expect(workers).toHaveLength(4) + await expect(refused).resolves.toMatchObject({ + message: 'crashed repeatedly (a-third-fault)' + }) + const healthy = send(queue, 'b-still-healthy', b) + workers.at(-1)?.respond() + await expect(healthy).resolves.toMatchObject({ label: 'b-still-healthy' }) + } finally { + queue.dispose() + } + }) + + it('clears a successful owner budget while preserving another owner failure count', async () => { + const workers: FakeWorker[] = [] + const queue = makeQueue(workers) + const a = { signal: new AbortController().signal } + const b = { signal: new AbortController().signal } + try { + for (const owner of [a, b]) { + for (let fault = 0; fault < 2; fault++) { + const call = settle(send(queue, 'failure', owner)) + workers.at(-1)?.emit('error', new Error('failure')) + await call + } + } + const successful = send(queue, 'a-success', a) + workers.at(-1)?.respond() + await successful + const thirdB = settle(send(queue, 'b-third', b)) + workers.at(-1)?.emit('error', new Error('b-third-fault')) + await thirdB + for (let fault = 0; fault < 2; fault++) { + const call = settle(send(queue, 'a-new-failure', a)) + workers.at(-1)?.emit('error', new Error('a-new-failure')) + await call + } + const stillAllowed = send(queue, 'a-allowed', a) + expect(workers).toHaveLength(8) + workers.at(-1)?.respond() + await expect(stillAllowed).resolves.toMatchObject({ label: 'a-allowed' }) + await expect(settle(send(queue, 'b-refused', b))).resolves.toMatchObject({ + message: 'crashed repeatedly (b-third-fault)' + }) + expect(workers).toHaveLength(8) + } finally { + queue.dispose() + } + }) + + it('drains only the failing owner while queued peers keep their FIFO order', async () => { + const workers: FakeWorker[] = [] + const queue = makeQueue(workers) + const a = { signal: new AbortController().signal } + const b = { signal: new AbortController().signal } + try { + const failed = ['a1', 'a2', 'a3', 'a4'].map((label) => settle(send(queue, label, a))) + const peer = settle(send(queue, 'peer', b)) + const ordinary = settle(send(queue, 'ordinary')) + for (let fault = 0; fault < 3; fault++) { + workers.at(-1)?.emit('error', new Error(`fault-${fault}`)) + } + expect(workers).toHaveLength(4) + expect(labels(workers[3])).toEqual(['peer']) + expect((await Promise.all(failed)).at(-1)).toMatchObject({ + message: 'crashed repeatedly (fault-2)' + }) + workers[3].respond() + await expect(peer).resolves.toMatchObject({ label: 'peer' }) + expect(labels(workers[3])).toEqual(['peer', 'ordinary']) + workers[3].respond() + await expect(ordinary).resolves.toMatchObject({ label: 'ordinary' }) + } finally { + queue.dispose() + } + }) + + it('keeps retirement refusals out of the owner failure budget', async () => { + vi.useFakeTimers() + const workers: FakeWorker[] = [] + let finish: (code: number) => void = () => {} + const first = new FakeWorker() + first.exit = new Promise((resolve) => { + finish = resolve + }) + const queue = makeQueue(workers, { + awaitRetirement: true, + makeWorker: () => (workers.length === 0 ? first : new FakeWorker()) + }) + const owner = { signal: new AbortController().signal } + try { + const failed = settle(send(queue, 'first', owner)) + first.emit('error', new Error('first-fault')) + await failed + for (let attempt = 0; attempt < 4; attempt++) { + await expect(settle(send(queue, 'not-executed', owner))).resolves.toMatchObject({ + message: 'unavailable: previous worker still exiting' + }) + } + expect(workers).toHaveLength(1) + finish(1) + await vi.advanceTimersByTimeAsync(0) + const recovered = send(queue, 'recovered', owner) + expect(workers).toHaveLength(2) + workers[1].respond() + await expect(recovered).resolves.toMatchObject({ label: 'recovered' }) + } finally { + finish(1) + queue.dispose() + } + }) + describe('awaitRetirement', () => { function stalledExit(): { worker: FakeWorker; finish: (code: number) => void } { const worker = new FakeWorker() diff --git a/src/main/worker-thread-request-queue.ts b/src/main/worker-thread-request-queue.ts index 39561fa80a4..6793d597e27 100644 --- a/src/main/worker-thread-request-queue.ts +++ b/src/main/worker-thread-request-queue.ts @@ -42,6 +42,10 @@ export type WorkerThreadRequestQueueOptions = { awaitRetirement?: boolean } +export type WorkerThreadRequestOwner = { readonly signal: AbortSignal } + +type OwnerFailures = { consecutiveDeaths: number; refused: Error | null } + type PendingCall = { request: TRequest timeoutMs: number @@ -49,6 +53,7 @@ type PendingCall = { reject: (error: Error) => void timer: NodeJS.Timeout | null signal?: AbortSignal + owner?: WorkerThreadRequestOwner cleanupAbort: () => void } @@ -59,6 +64,7 @@ export class WorkerThreadRequestQueue< private active: PendingCall | null = null private queue: PendingCall[] = [] private consecutiveDeaths = 0 + private readonly ownerFailures = new WeakMap() private nextId = 1 private disposed = false private readonly host: LazyWorkerThreadHost @@ -85,7 +91,8 @@ export class WorkerThreadRequestQueue< dispatch( buildRequest: (id: number) => TRequest, timeoutMs: number, - signal?: AbortSignal + signal?: AbortSignal, + owner?: WorkerThreadRequestOwner ): Promise { return new Promise((resolve, reject) => { if (this.disposed) { @@ -96,6 +103,11 @@ export class WorkerThreadRequestQueue< reject(signal.reason ?? new Error('Worker request aborted')) return } + const refused = owner && this.ownerFailures.get(owner)?.refused + if (refused) { + reject(refused) + return + } // Built before the cap check so a rejection can name the dropped work; // the id it burns is only a correlation token, so a gap costs nothing. const request = buildRequest(this.nextId++) @@ -106,7 +118,7 @@ export class WorkerThreadRequestQueue< } // A fresh burst from full idle starts new work: clear any death count // carried from a prior burst so the respawn cap can't drain it early. - if (!this.active && this.queue.length === 0) { + if (!owner && !this.active && this.queue.length === 0) { this.consecutiveDeaths = 0 } const call: PendingCall = { @@ -116,6 +128,7 @@ export class WorkerThreadRequestQueue< reject, timer: null, signal, + owner, cleanupAbort: () => signal?.removeEventListener('abort', abort) } const abort = (): void => { @@ -201,7 +214,11 @@ export class WorkerThreadRequestQueue< this.armDeadline(call) return } - this.consecutiveDeaths = 0 + if (call.owner) { + this.failuresFor(call.owner).consecutiveDeaths = 0 + } else { + this.consecutiveDeaths = 0 + } this.settle(call, () => call.resolve(response)) this.afterSettle() } @@ -226,12 +243,16 @@ export class WorkerThreadRequestQueue< private onWorkerFault(error: Error): void { const failed = this.active this.host.destroy() - this.consecutiveDeaths++ - if (failed) { - this.settle(failed, () => failed.reject(error)) + if (!failed) { + this.pump() + return } - if (this.consecutiveDeaths >= this.options.maxConsecutiveDeaths) { - this.drainQueueAfterCrashLoop(error) + const deaths = failed.owner + ? ++this.failuresFor(failed.owner).consecutiveDeaths + : ++this.consecutiveDeaths + this.settle(failed, () => failed.reject(error)) + if (deaths >= this.options.maxConsecutiveDeaths) { + this.drainQueueAfterCrashLoop(error, failed.owner) return } if (this.queue.length > 0) { @@ -239,14 +260,28 @@ export class WorkerThreadRequestQueue< } } - private drainQueueAfterCrashLoop(error: Error): void { - const pending = this.queue - this.queue = [] - this.consecutiveDeaths = 0 + private drainQueueAfterCrashLoop(error: Error, owner?: WorkerThreadRequestOwner): void { + const pending = this.queue.filter((call) => call.owner === owner) + this.queue = this.queue.filter((call) => call.owner !== owner) const drainError = new Error(this.options.describeCrashLoop(error.message)) + if (owner) { + this.failuresFor(owner).refused = drainError + } else { + this.consecutiveDeaths = 0 + } for (const call of pending) { this.settle(call, () => call.reject(drainError)) } + this.afterSettle() + } + + private failuresFor(owner: WorkerThreadRequestOwner): OwnerFailures { + let failures = this.ownerFailures.get(owner) + if (!failures) { + failures = { consecutiveDeaths: 0, refused: null } + this.ownerFailures.set(owner, failures) + } + return failures } private failQueuedAsUnavailable(): void { From e42768fb2340d71f0d6d7e99964f13934a5252f1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:32:12 -0700 Subject: [PATCH 27/31] Update in-app Android APK links to mobile 0.0.52 (#25168) --- src/renderer/src/components/mobile/mobile-platform-copy.ts | 2 +- src/renderer/src/components/settings/MobileSettingsPane.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/components/mobile/mobile-platform-copy.ts b/src/renderer/src/components/mobile/mobile-platform-copy.ts index f33f9a42196..45149f90858 100644 --- a/src/renderer/src/components/mobile/mobile-platform-copy.ts +++ b/src/renderer/src/components/mobile/mobile-platform-copy.ts @@ -22,7 +22,7 @@ const IOS_CHANNEL_COPY: Record = { const ANDROID_COPY: InstallCopy = { ctaLabel: 'Download APK', - url: 'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.48/app-release.apk' + url: 'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.52/app-release.apk' } export function getInstallCopy(platform: Platform, iosChannel: IosChannel): InstallCopy { diff --git a/src/renderer/src/components/settings/MobileSettingsPane.tsx b/src/renderer/src/components/settings/MobileSettingsPane.tsx index 2dd10a006f4..203e95abbae 100644 --- a/src/renderer/src/components/settings/MobileSettingsPane.tsx +++ b/src/renderer/src/components/settings/MobileSettingsPane.tsx @@ -13,7 +13,7 @@ export { getMobileSettingsPaneSearchEntries } const ORCA_IOS_APP_STORE_URL = 'https://apps.apple.com/app/orca-ide/id6766130217' const ORCA_ANDROID_APK_URL = - 'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.48/app-release.apk' + 'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.52/app-release.apk' export function MobileSettingsPane(): React.JSX.Element { const showMobileButton = useAppStore((s) => s.settings?.showMobileButton !== false) From 0971479866f2dc625b5c32dbc260ca568d1460dc Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:35:16 -0700 Subject: [PATCH 28/31] Add shared workspace settings note and prevent filter modal expansion (#25300) * fix(mobile): say that workspace sort, grouping, and filters are shared The Manual sort option was subtitled 'Server order', but it orders by the desktop's drag ranks. Sort, grouping, and filters on the phone all write the host's shared view settings, so changing them also changes every other device on that host, which the screen never said. Relabel Manual as 'Desktop drag order' and add 'Shared with other devices on this host' under the Sort By, Group By, and Filter titles. The note avoids naming a desktop sidebar because headless hosts have none. * fix(mobile): prevent filter modal heading expansion Add flexShrink: 1 to allow the heading container to shrink when space is constrained. Update comment to clarify why workspace view is shared across devices. * update wording --- mobile/src/components/PickerModal.tsx | 8 ++++++++ mobile/src/host-screen/host-screen-overlays.tsx | 10 ++++++++-- mobile/src/host-screen/host-screen-secondary-styles.ts | 8 ++++++++ mobile/src/worktree/workspace-list-picker-options.ts | 5 ++++- mobile/src/worktree/workspace-view-settings.ts | 2 +- 5 files changed, 29 insertions(+), 4 deletions(-) diff --git a/mobile/src/components/PickerModal.tsx b/mobile/src/components/PickerModal.tsx index 9456307091f..45b6593c09f 100644 --- a/mobile/src/components/PickerModal.tsx +++ b/mobile/src/components/PickerModal.tsx @@ -15,6 +15,7 @@ export type PickerOption = { type Props = { visible: boolean title: string + subtitle?: string options: PickerOption[] selected: T onSelect: (value: T) => void @@ -32,6 +33,7 @@ type PickerModalContentProps = Pick< export function PickerModal({ visible, title, + subtitle, options, selected, onSelect, @@ -44,6 +46,7 @@ export function PickerModal({ {title} + {subtitle ? {subtitle} : null} state.setShowFilterModal(false)}> - Filter + + Filter + {WORKSPACE_VIEW_SHARED_NOTE} + {settings.activeFilterCount > 0 && ( Clear filters diff --git a/mobile/src/host-screen/host-screen-secondary-styles.ts b/mobile/src/host-screen/host-screen-secondary-styles.ts index af14aa2b97a..7f6c4f3f65c 100644 --- a/mobile/src/host-screen/host-screen-secondary-styles.ts +++ b/mobile/src/host-screen/host-screen-secondary-styles.ts @@ -47,11 +47,19 @@ export const hostScreenSecondaryStyles = StyleSheet.create({ paddingHorizontal: spacing.xs, marginBottom: spacing.md }, + filterModalHeading: { + flexShrink: 1 + }, filterModalTitle: { fontSize: 15, fontWeight: '600', color: colors.textPrimary }, + filterModalSubtitle: { + fontSize: 11, + color: colors.textMuted, + marginTop: 2 + }, clearFiltersText: { fontSize: 13, color: colors.textSecondary diff --git a/mobile/src/worktree/workspace-list-picker-options.ts b/mobile/src/worktree/workspace-list-picker-options.ts index 180d4038320..ca597ed38cc 100644 --- a/mobile/src/worktree/workspace-list-picker-options.ts +++ b/mobile/src/worktree/workspace-list-picker-options.ts @@ -1,6 +1,9 @@ import type { PickerOption } from '../components/PickerModal' import type { MobileGroupMode, MobileSortMode } from './workspace-view-settings' +// Why: the host may be headless, so the note can't promise a desktop sidebar. +export const WORKSPACE_VIEW_SHARED_NOTE = 'Synced across your devices' + export const WORKSPACE_SORT_OPTIONS: PickerOption[] = [ // Why: desktop and persisted state keep the `smart` key, while mobile shows the product label. { @@ -11,7 +14,7 @@ export const WORKSPACE_SORT_OPTIONS: PickerOption[] = [ { value: 'name', label: 'Name', subtitle: 'Alphabetical by name' }, { value: 'recent', label: 'Recent', subtitle: 'Most recent output first' }, { value: 'repo', label: 'Repo', subtitle: 'Repository, then workspace name' }, - { value: 'manual', label: 'Manual', subtitle: 'Server order' } + { value: 'manual', label: 'Manual', subtitle: 'Desktop drag order' } ] export const WORKSPACE_GROUP_OPTIONS: PickerOption[] = [ diff --git a/mobile/src/worktree/workspace-view-settings.ts b/mobile/src/worktree/workspace-view-settings.ts index f1d17189adc..221fa7211be 100644 --- a/mobile/src/worktree/workspace-view-settings.ts +++ b/mobile/src/worktree/workspace-view-settings.ts @@ -7,7 +7,7 @@ import type { WorkspaceStatusDefinition } from '../../../src/shared/worktree/typ import { coerceMobileWorkspaceStatuses } from './mobile-workspace-statuses' export type MobileGroupMode = 'none' | 'workspaceStatus' | 'repo' | 'prStatus' -// Desktop sort adds 'manual'; mobile renders it but sorts by server order. +// Desktop sort adds 'manual'; mobile orders it by the desktop's drag ranks. export type MobileSortMode = 'smart' | 'name' | 'recent' | 'repo' | 'manual' // Desktop PersistedUIState fields this screen syncs (a structural subset). From b1e12d7bb41db6c8de03951d8323cfff4a3e1329 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:37:40 -0700 Subject: [PATCH 29/31] fix(native-chat): a new structured chat's model list comes from the machine that runs it (#25143) * fix(native-chat): a new structured chat's model list comes from the host that runs it agentSession.modelCatalog builds the structured-session host like agentSession.options, so a host with no saved chats since it started answers instead of refusing. When the host answers unknown because its first listing runs in the background, the picker re-reads on a bounded schedule until that listing lands. Local terminal-backed chat skips the structured catalog when a custom launch command is configured. * fix(native-chat): wait on the host's first model listing instead of re-reading on a timer A new structured chat's picker read the host catalog once; on an account the host had never listed, the answer was "unknown" while a background listing ran, and the client re-read on a 1-30 s schedule. Replace the schedule with the host's own completion signal: - The host answers a cold read with `listingInProgress: true` (new optional field) once it has started or joined that listing. A read that passes the new optional `waitForListing` param awaits the same joined listing and answers with it, or a plain "unknown" if it failed. The at-rest options read never waits. A host that predates the field never sends it, so the client never sends the param to a host that would refuse it. - The picker reads once per open and attach; after the host's report it sends one waiting read, with a 90 s client timeout above the slowest listing. While that read is out, the model pill keeps its label but cannot open or be set (typed /model included). An answer, failure, timeout, hide, attach or the provider's own list releases it. - `agentSession.modelCatalog` builds the structured-session host only for a read that names a session (a structured chat). Terminal-backed chat's session-less read keeps the non-building gate, so a desktop that never runs structured chat never opens the session journal. * fix(native-chat): one waiting model-list read per chat, and no late menu open The waiting catalog read was owned by one run of the picker's effect. Attach (a new fence), hide/show or a send re-ran the effect: the cleanup released the model picker onto the built-in list for a round trip, and the new run sent a second waiting read while the first, which cannot be withdrawn, kept a remote call slot until the listing ended. The waiting read now belongs to the chat (runtime target + agent + session): a small registry keeps one in flight per chat, every re-run or remount joins it, and the entry is deleted when the read settles. The picker hold is derived from that entry being in flight, so it lasts across attach and hide/show and ends when the read settles, the provider reports its own list, or the pane switches to another session. Answers still pass the stale and record checks. A bare /model typed while the list loads no longer opens the model menu by itself when the list lands: the menu stays keyed on the request, and only its initial open is suppressed while pending, so the request is spent shut and the end of the pending period never remounts it. * fix(native-chat): release the model picker in the same commit as the host list When the waiting catalog read settled in the chat that started it, the registry dropped its entry and told subscribers first, and the host list was applied a few microtasks later. React committed once with the picker enabled on the built-in list, then again with the host's list. Joiners now hand the registry their apply callback, and the registry runs every joiner (with the answer, or nothing when the read failed or timed out) before it deletes the entry and notifies. The release and the list land in one commit. An effect cleanup leaves the wait instead of flagging itself stale. * fix(runtime): queue model catalog reads in the long-wait lane A model catalog read that waits on a host's first listing replies only when that listing ends, yet it took one of the 8 foreground call slots for its server. Enough chats opened during one cold listing would stall that server's sends and interrupts until a wait settled. agentSession.modelCatalog now joins worktree.rm in the long-wait lane: same concurrency, counted apart from the foreground calls. The queue classifies by method only, and a warm catalog read answers at once, so the whole method moves. --- .../agent-model-catalog-service.test.ts | 105 +++++- .../agent-model-catalog-service.ts | 32 +- ...uctured-agent-session-options-read.test.ts | 48 +++ ...uctured-agent-session-options-read.test.ts | 62 +++ .../structured-agent-session-options-read.ts | 10 +- ...SessionOptionPickers.menu-request.test.tsx | 109 ++++++ .../NativeChatSessionOptionPickers.test.tsx | 38 ++ .../NativeChatSessionOptionPickers.tsx | 7 +- .../native-chat/host-model-listing-waits.ts | 62 +++ .../native-chat-session-option-discovery.ts | 8 +- ...ive-chat-session-option-enrichment.test.ts | 18 + .../use-host-model-catalog-upgrade.test.tsx | 357 ++++++++++++++++++ .../use-host-model-catalog-upgrade.ts | 74 +++- .../use-structured-agent-session-options.ts | 13 +- .../structured-agent-session-client.test.ts | 24 ++ .../structured-agent-session-client.ts | 14 +- src/shared/agent-session-wire.ts | 7 +- src/shared/native-chat-session-options.ts | 2 + .../structured-agent-session-params.ts | 7 +- src/shared/runtime-rpc-call-queue.test.ts | 20 + src/shared/runtime-rpc-call-queue.ts | 7 +- .../structured-agent-session-options.ts | 11 + 22 files changed, 989 insertions(+), 46 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts create mode 100644 src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.menu-request.test.tsx create mode 100644 src/renderer/src/components/native-chat/host-model-listing-waits.ts create mode 100644 src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.test.tsx diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.test.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.test.ts index d51a98b3d8b..02025485151 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.test.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.test.ts @@ -5,7 +5,11 @@ import { agentModelCatalogFingerprintForRecord } from './agent-model-catalog-fingerprint' import { createAgentModelCatalogService } from './agent-model-catalog-service' -import { AgentModelCatalogStore, type AgentModelCatalogSuccess } from './agent-model-catalog-store' +import { + AGENT_MODEL_CATALOG_FRESH_MS, + AgentModelCatalogStore, + type AgentModelCatalogSuccess +} from './agent-model-catalog-store' function record(accountHomePath: string): AgentSessionRecord { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the service reads only provider, accountHome and location; the rest of the record is irrelevant here. @@ -55,7 +59,8 @@ describe('agent model catalog service', () => { probes: { codex: probe } }) expect(await service.read({ agent: 'codex', sessionId: 'session-1' })).toEqual({ - origin: 'unknown' + origin: 'unknown', + listingInProgress: true }) // A second read while the probe is in flight must not start another, and a // record-scoped read probes the RECORD's pinned home, not the selection. @@ -81,7 +86,10 @@ describe('agent model catalog service', () => { probes: { codex: probe } }) // The record-less read follows the CURRENT selection: unknown, never gpt-old. - expect(await service.read({ agent: 'codex' })).toEqual({ origin: 'unknown' }) + expect(await service.read({ agent: 'codex' })).toEqual({ + origin: 'unknown', + listingInProgress: true + }) expect(probe).toHaveBeenCalledWith('/homes/new') await vi.waitFor(async () => { const result = await service.read({ agent: 'codex' }) @@ -139,7 +147,8 @@ describe('agent model catalog service', () => { probes: { codex: probe } }) expect(await service.read({ agent: 'codex', sessionId: 'session-1' })).toEqual({ - origin: 'unknown' + origin: 'unknown', + listingInProgress: true }) await vi.waitFor(() => expect(probe).toHaveBeenCalledTimes(1)) // Still a clean unknown — and the failure TTL suppresses a probe storm. @@ -164,6 +173,94 @@ describe('agent model catalog service', () => { expect(probe).not.toHaveBeenCalled() }) + describe('a read that waits for the first listing', () => { + function deferredListing() { + let resolve!: (success: AgentModelCatalogSuccess) => void + let reject!: (error: Error) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } + } + + function coldService(probe: (home: string) => Promise) { + const store = new AgentModelCatalogStore() + const service = createAgentModelCatalogService({ + store, + getRecord: () => undefined, + resolveAccountHome: async () => CODEX_HOME('/homes/selected'), + probes: { codex: probe } + }) + return { store, service } + } + + it('joins the listing the first read started and answers with it', async () => { + const pending = deferredListing() + const probe = vi.fn(() => pending.promise) + const { service } = coldService(probe) + expect(await service.read({ agent: 'codex' })).toEqual({ + origin: 'unknown', + listingInProgress: true + }) + const waited = service.read({ agent: 'codex', waitForListing: true }) + pending.resolve(listing('gpt-listed')) + const result = await waited + expect(result.origin === 'unknown' ? null : result.models[0]!.id).toBe('gpt-listed') + expect(probe).toHaveBeenCalledTimes(1) + }) + + it('answers a plain unknown when the listing fails', async () => { + const pending = deferredListing() + const { service } = coldService(() => pending.promise) + const waited = service.read({ agent: 'codex', waitForListing: true }) + pending.reject(new Error('spawn failed')) + expect(await waited).toEqual({ origin: 'unknown' }) + }) + + it('does not wait or report a listing while a failure is inside its TTL', async () => { + const probe = vi.fn(async (): Promise => { + throw new Error('spawn failed') + }) + const { store, service } = coldService(probe) + store.recordFailure(selectedHomeFingerprint('/homes/selected'), 'spawn failed') + expect(await service.read({ agent: 'codex', waitForListing: true })).toEqual({ + origin: 'unknown' + }) + expect(await service.read({ agent: 'codex' })).toEqual({ origin: 'unknown' }) + expect(probe).not.toHaveBeenCalled() + }) + + it('reports no listing where the host has no lister for the account', async () => { + const store = new AgentModelCatalogStore() + const service = createAgentModelCatalogService({ + store, + getRecord: () => undefined, + resolveAccountHome: async () => CODEX_HOME('/homes/selected') + }) + expect(await service.read({ agent: 'codex', waitForListing: true })).toEqual({ + origin: 'unknown' + }) + }) + + it('serves an aged entry at once and refreshes it behind the answer', async () => { + let now = 0 + const store = new AgentModelCatalogStore({ now: () => now }) + store.recordSuccess(selectedHomeFingerprint('/homes/selected'), 'codex', listing('gpt-old')) + now = AGENT_MODEL_CATALOG_FRESH_MS + const probe = vi.fn(() => new Promise(() => {})) + const service = createAgentModelCatalogService({ + store, + getRecord: () => undefined, + resolveAccountHome: async () => CODEX_HOME('/homes/selected'), + probes: { codex: probe } + }) + const result = await service.read({ agent: 'codex', waitForListing: true }) + expect(result.origin === 'unknown' ? null : result.models[0]!.id).toBe('gpt-old') + expect(probe).toHaveBeenCalledTimes(1) + }) + }) + describe('a read for the workspace a new chat runs in', () => { function serviceWith(mayOverride: boolean) { const store = new AgentModelCatalogStore() diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts index 8c794331a2c..638b0f54272 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts @@ -35,6 +35,8 @@ export type AgentModelCatalogService = { sessionId?: string /** Where a new chat would run; null when one was named but is not a local directory. */ workspacePath?: string | null + /** With no entry yet, answer from the listing this read starts or joins instead of `unknown`. */ + waitForListing?: boolean }) => Promise } @@ -79,8 +81,9 @@ async function workspaceKeepsListedDefault( * launch); without one, the key is the account a launch would pin right now — * never "whichever account listed last". `unknown` tells the client to keep * its static seed, and a missing or aged entry kicks one joined background - * probe so the next read is warm. Failures are the store's 30s TTL, never an - * answer — a picker is a user surface and must not block. + * probe so the next read is warm. With no entry, the answer says that listing + * is running, and only a read that asks waits for it. Failures are the store's + * 30s TTL, never an answer: inside it a read answers `unknown` at once. */ export function createAgentModelCatalogService( deps: AgentModelCatalogServiceDeps @@ -110,14 +113,27 @@ export function createAgentModelCatalogService( }) accountHomePath = resolved.path } - const entry = deps.store.get(fingerprint) + let entry = deps.store.get(fingerprint) const probe = deps.probes?.[params.agent] - if (probe && accountHomePath && deps.store.shouldRefresh(fingerprint)) { - const home = accountHomePath - void deps.store.refresh(fingerprint, params.agent, () => probe(home)) - } + const home = accountHomePath + // Without an entry, join a running listing too: that is the one a waiting read answers from. + const listing = + probe && + home && + (entry ? deps.store.shouldRefresh(fingerprint) : !deps.store.hasActiveFailure(fingerprint)) + ? deps.store.refresh(fingerprint, params.agent, () => probe(home)) + : null if (!entry) { - return { origin: 'unknown' } + if (!listing) { + return { origin: 'unknown' } + } + if (!params.waitForListing) { + return { origin: 'unknown', listingInProgress: true } + } + entry = await listing + if (!entry) { + return { origin: 'unknown' } + } } return resultFromEntry( entry, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts new file mode 100644 index 00000000000..5657fcfb4d8 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts @@ -0,0 +1,48 @@ +// A chat's options at rest come from the host catalog without waiting on a listing. + +import { describe, expect, it, vi } from 'vitest' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { createAgentModelCatalogService } from '../agent-model-catalog/agent-model-catalog-service' +import { + AgentModelCatalogStore, + type AgentModelCatalogSuccess +} from '../agent-model-catalog/agent-model-catalog-store' +import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' +import { readStructuredAgentSessionOptions } from './structured-agent-session-options-read' + +const SESSION = 'session-1' + +function restingRecord(): AgentSessionRecord { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resting read and the catalog key touch only these fields. + return { + provider: 'codex', + accountHome: { variable: 'CODEX_HOME', path: '/homes/a' }, + location: { wslDistro: null }, + options: {} + } as unknown as AgentSessionRecord +} + +describe('options at rest', () => { + it('answers while the first catalog listing is still running', async () => { + const record = restingRecord() + const probe = vi.fn(() => new Promise(() => {})) + const modelCatalog = createAgentModelCatalogService({ + store: new AgentModelCatalogStore(), + getRecord: () => record, + resolveAccountHome: async () => ({ variable: 'CODEX_HOME', path: '/homes/a' }), + probes: { codex: probe } + }) + const resting = { child: null, params: { provider: 'codex' } } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resting read touches only these members. + const context = { + deps: { adapter: {}, store: { getRecord: () => record }, modelCatalog }, + serialize: (_sessionId: string, task: () => Promise) => task(), + openConversation: async () => resting, + conversation: async () => resting + } as unknown as StructuredAgentSessionMutationContext + + const result = await readStructuredAgentSessionOptions(context, SESSION) + expect(probe).toHaveBeenCalledTimes(1) + expect(result.models).toEqual([]) + }) +}) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-options-read.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-options-read.test.ts index 77d44ece745..459ae5c75a4 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-options-read.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-options-read.test.ts @@ -56,4 +56,66 @@ describe('agentSession.modelCatalog', () => { await call('agentSession.modelCatalog', { agent: 'claude' }, STRUCTURED_CLIENT) expect(read).toHaveBeenCalledWith({ agent: 'claude' }) }) + + it('passes a wait for the listing through to the catalog', async () => { + await call( + 'agentSession.modelCatalog', + { agent: 'codex', sessionId: SESSION, waitForListing: true }, + STRUCTURED_CLIENT + ) + expect(read).toHaveBeenCalledWith({ agent: 'codex', sessionId: SESSION, waitForListing: true }) + }) +}) + +describe('agentSession.modelCatalog before anything built the host', () => { + const read = vi.fn(async () => ({ + origin: 'probe' as const, + models: [{ id: 'gpt-host', label: 'GPT Host', isDefault: true, efforts: [] }], + fetchedAt: 1 + })) + const installHost = vi.fn(async () => { + setStructuredAgentSessionHost(Object.assign(hostStub(), { deps: { modelCatalog: { read } } })) + }) + + beforeEach(() => { + read.mockClear() + installHost.mockClear() + clearStructuredHostStub() + }) + + // A new chat's picker reads before its create lands; on a host with no saved chats nothing else + // has built the host yet, and a refusal here left the picker on the client's built-in list. + it('builds the host for a structured chat and answers from its catalog', async () => { + const reply = await call( + 'agentSession.modelCatalog', + { agent: 'codex', sessionId: SESSION }, + STRUCTURED_CLIENT, + { ensureStructuredAgentSessionHost: installHost } + ) + expect(installHost).toHaveBeenCalledTimes(1) + expect(reply).toMatchObject({ ok: true, result: { origin: 'probe' } }) + expect(read).toHaveBeenCalledWith({ agent: 'codex', sessionId: SESSION }) + }) + + // Terminal-backed chat reads with no session: a host that runs no structured chat keeps its + // journal closed, and the read falls back to the CLI listing. + it('does not build the host for a read that names no session', async () => { + const reply = await call('agentSession.modelCatalog', { agent: 'codex' }, STRUCTURED_CLIENT, { + ensureStructuredAgentSessionHost: installHost + }) + expect(installHost).not.toHaveBeenCalled() + expect(reply).toMatchObject({ ok: false }) + expect(read).not.toHaveBeenCalled() + }) + + it('does not build the host for a client that cannot read structured sessions', async () => { + const reply = await call( + 'agentSession.modelCatalog', + { agent: 'codex', sessionId: SESSION }, + { clientKind: 'runtime', clientCapabilities: [] }, + { ensureStructuredAgentSessionHost: installHost } + ) + expect(installHost).not.toHaveBeenCalled() + expect(reply).toMatchObject({ ok: false }) + }) }) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-options-read.ts b/src/main/runtime/rpc/methods/structured-agent-session-options-read.ts index 19e93689577..3cd86c876ad 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-options-read.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-options-read.ts @@ -11,7 +11,7 @@ import { defineMethod } from '../core' import { requireInstalledStructuredHost, - requireStructuredHost as requireHost + requireStructuredHost } from './structured-agent-session-gate' import { ModelCatalogParams, OptionsParams } from './structured-agent-session-schemas' @@ -25,8 +25,14 @@ export const STRUCTURED_AGENT_SESSION_OPTIONS_READ_METHODS = [ defineMethod({ name: 'agentSession.modelCatalog', params: ModelCatalogParams, + // A structured chat's read names its session and builds the host, since it may come first; + // terminal-backed chat's session-less read must not open the journal where none runs. handler: async ({ worktree, ...params }, ctx) => { - const catalog = requireHost(ctx).deps.modelCatalog + const host = + params.sessionId === undefined + ? requireStructuredHost(ctx) + : await requireInstalledStructuredHost(ctx) + const catalog = host.deps.modelCatalog if (!catalog) { return { origin: 'unknown' as const } } diff --git a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.menu-request.test.tsx b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.menu-request.test.tsx new file mode 100644 index 00000000000..90abdc3ef25 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.menu-request.test.tsx @@ -0,0 +1,109 @@ +// @vitest-environment happy-dom + +// Against the real menu: a `/model` request opens the menu once, and a period while the host still +// lists models neither opens it later nor reopens one the user closed. + +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { + SessionOptionDescriptor, + SessionOptionsSurface +} from '../../../../shared/native-chat-session-options' + +vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string, values?: Record) => + values + ? Object.entries(values).reduce( + (text, [name, value]) => text.replaceAll(`{{${name}}}`, String(value)), + fallback + ) + : fallback +})) + +import { TooltipProvider } from '@/components/ui/tooltip' +import { NativeChatSessionOptionPickers } from './NativeChatSessionOptionPickers' +import type { NativeChatOptionPickerRequest } from './native-chat-composer-types' + +function model(pending: boolean): SessionOptionDescriptor { + return { + id: 'model', + label: 'Model', + category: 'model', + kind: { + type: 'select', + currentValue: 'opus', + choices: [ + { value: 'opus', label: 'Opus 4.8' }, + { value: 'sonnet', label: 'Sonnet 5' } + ] + }, + valueSource: 'applied', + transport: 'agent-session', + settable: !pending, + ...(pending ? { choicesPending: true as const } : {}) + } +} + +const surface: SessionOptionsSurface = { + getSnapshot: () => [], + setOption: vi.fn(async () => ({ snapshot: [] })), + invokeAction: vi.fn(async () => ({ snapshot: [] })), + subscribe: () => () => {} +} + +function view(pending: boolean, request: NativeChatOptionPickerRequest | null): React.JSX.Element { + return ( + +