From ddc5b75ac7ba0079e53d8a0a3e9552fcad875c9a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 5 Sep 2026 14:03:45 -0700 Subject: [PATCH 01/23] feat(native-chat): label Codex tool rows by what the command actually did (#18760) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(native-chat): label Codex tool rows by what the command actually did Codex's app-server `commandExecution` item carries `commandActions`, which already classifies each command as a read, a search, or a directory listing with the target path, name, or query extracted. Orca ignored the field, so every shell call rendered as an undifferentiated row of raw argv. Read it and name the row by its class, keeping the raw command and cwd for the expanded view. Unclassified commands are untouched: absent, null, or malformed `commandActions` produces byte-identical output to before. Rank the search term above the command in the shared label keys so a classified search row reads by what it looked for rather than the shell text that ran it. No first-party tool input carries both keys today, so this only reaches the new rows; an MCP tool supplying both would prefer its search term. Note `commandActions` is the app-server spelling. `parsedCmd` is the rollout-file shape and never arrives on this lane; a test pins that it stays ignored. * feat(native-chat): give tool rows a category glyph beside their word A row named only by a word makes the reader parse text to tell a read from a search. Pair the word with an icon: icon for category, word for action, argument for target. Name the full eight-category vocabulary in `src/shared/native-chat-tool-icon.ts` now — read/search/listFiles/unknown/fileChange/webSearch/mcpToolCall/ subAgentActivity — even though only the classified shell categories reach a row today, so the MCP and web-search rows landing separately inherit these names rather than coining their own. Glyph ids are the lucide spelling shared by `lucide-react` and `lucide-react-native`, so mobile can resolve one name to its own component when it adopts this; mobile rows stay text-only for now. The glyph is decorative and `aria-hidden`: the word is the accessible name, and never renders without it. One glyph per category, fixed across running, completed, and failed — a row that swapped icons on completion would read as changing identity — so the run header's active row also takes its category glyph instead of the generic wrench it fell back to once these rows stopped being called `shell`. A word outside the vocabulary gets the terminal glyph rather than a blank slot, so rows stay left-aligned. Also stand `.` in for a `listFiles` action whose `path` is null, which is what a bare `ls` sends. The row named the action and then showed the raw argv as its target; now it names the directory it listed. * fix(native-chat): hold the tool run header's glyph fixed and size its slot to 16/14 The header swapped its leading glyph on settle: the active tool's icon while running, a check once done. That is the identity swap a fixed per-category glyph exists to prevent — the row appeared to become a different thing when it finished. Name the header by the run's latest tool in both states and move the completion check to the trailing edge, where the rest of the state signal already lives. Size both header slots to the mock's 16px slot with a 14px glyph, matching the tool rows beneath them and the subagent summary row landing separately. They were 24/16, so the icon columns sat 8px apart and broke the left alignment the icon treatment depends on. The fixity test walks running, completed, and failed and pins the leading glyph of every row by lucide's own class name, so a swap shows up as a different name rather than a still-present icon. * fix(codex): stop a classified shell row from asserting facts the command doesn't support Three claims the `commandActions` row model was making on its own: - `listFiles` with a null path was given `path: '.'`. Codex sends null for a recursive walk and for the repo root, and the invented path flows into `createToolInputDisplay().filePath`, which mobile turns into a tappable "open file" link onto a directory — an affordance that can only fail. The row now keeps the raw command, which is what the label logic already falls back to. - A command whose actions classify as two different things (`cat a.txt && ls src`) was named after the first one, silently dropping the rest. Recognized actions must now agree on one class; a repeat of one class keeps the class and only a target every entry names. - `read` lifted `name` into the journal payload, where no label ever reads it — `path` always wins — so it was bounded weight carrying nothing. * fix(native-chat): give an unmodelled tool row a generic glyph, not a terminal The row-word vocabulary named seven words, and everything else fell through to the terminal glyph — which reads as "a shell ran here" for rows where nothing says one did. Codex's own `apply_patch` row, `Grep`/`Glob`/`Task`/`WebFetch`/ `TodoWrite`, and every `mcp__*` tool all rendered a terminal, leaving the declared `mcpToolCall` and `subAgentActivity` categories unreachable. - Split the vocabulary: `unknown` stays the shell command Codex could not classify and keeps the terminal, while a new `other` carries the generic wrench that unmodelled words now fall back to. - Read the edit family from `EDIT_TOOL_NAMES` and the command tools from `isCommandToolName` rather than restating either. Command tools resolve first: `isEditToolName` counts `shell`/`exec` as possible patch carriers, and a shell row is not an edit. - Result rows get no category glyph. Their word is `translate(…, 'Result')`, so keying a category off it resolved a different glyph per locale; an empty slot keeps the rows aligned. - The header and the row now resolve through `NativeChatToolIcon`, so one `Grep` run can no longer show a wrench in the header and a terminal on its line. The glyph map and the unused `category` prop go with the duplication. * fix(native-chat): give the projected Diff row the file-change glyph Every Codex fileChange item projects to a tool call named `Diff`, which the edit set does not name — it names the tools that carry the edit in their own input. So a run whose body renders an edited-file card was headed by the generic wrench. * fix(codex): stop a classified shell row offering a folder as a file to open A listFiles action's path is a directory, and a search action's path is the root it scanned. Lifted under `path`, both became the row's file target, which mobile renders as a tappable open-file link that can only fail — the same dead link the removed `{ path: '.' }` stand-in would have produced. They lift to `directory` instead, which still labels the row but is never a file target. * fix(mobile): keep the terminal glyph on a classified Codex shell row Mobile's run header picks between a terminal and a generic glyph by tool name. Now that the host publishes `read`/`search`/`list` for the same commands it used to publish as `shell`, that name check answers false and a command that really ran heads its run with a wrench. Ask the shared category vocabulary instead. Mobile keeps its two icons — porting the full glyph set is a separate lane. * fix(native-chat): say what the run header's glyph actually guarantees The comment claimed the header names the same tool in both states, so its glyph cannot change on settle. It can: the live header names the running call while the settled one names the run's last tool call, and with out-of-order completion those differ. The glyph is fixed for whichever tool the header names — say that, and drop the never-taken running branch from the settled header's call. Also pin the other half of the file-target rule: `read` keeps `path`, so its row stays tappable, where `list`/`search` lift a folder to `directory` and offer no target at all. * fix(native-chat): give a rollout-transcript shell row the terminal glyph `exec` and `local_shell` are what the Codex rollout transcript names a shell call — `native-chat-edit-normalize` already treats those three words as the command tools — but the activity set the glyph vocabulary reuses carries neither, so both rows headed a real command with the generic-tool wrench. Named in the vocabulary rather than in that activity set, because that set also picks the running row's copy and this is only about the glyph. * fix(mobile): pick the run-header glyph from the call's input, not its word Codex now names a classified shell row `read` / `search` / `list`, which lowercase to Claude's own `Read` / `Grep` / `Glob`. Mobile has only a terminal and a wrench, so keying that choice on the row word gave Claude's filesystem tools a terminal for a shell that never ran. The input separates them: Codex keeps the raw command on a classified row, while Claude's `Read` carries only a file path. `isShellActivityToolCall` replaces `isShellActivityToolRow` and asks the command tool names first, then the call's input. * fix(native-chat): give the projected diff fixture its required digest * fix(native-chat): head a settled run with a glyph the whole run shares The settled run header drew the glyph of the run's last tool call while the text beside it summarizes the run's first three, so a ten-call run ending in a `read` showed an eye above "shell npm test · shell git status · …" — a category the summary never described. Resolve the header's glyph from every call in the run instead: the shared category's glyph when all agree, the generic tool glyph when the run spans categories, and no glyph when there are no tool calls. The running header still names the active call, whose glyph is true of it. --------- Co-authored-by: Merge Sim --- .../src/session/MobileNativeChatToolRun.tsx | 7 +- .../codex-structured-item-translation.test.ts | 274 ++++++++++++++++++ .../codex-structured-item-translation.ts | 75 ++++- .../native-chat/NativeChatToolIcon.tsx | 88 ++++++ .../native-chat/NativeChatToolRun.test.tsx | 242 ++++++++++++++++ .../native-chat/NativeChatToolRun.tsx | 39 ++- src/shared/native-chat-diff.ts | 3 +- src/shared/native-chat-tool-icon.test.ts | 247 ++++++++++++++++ src/shared/native-chat-tool-icon.ts | 155 ++++++++++ src/shared/native-chat-tool-summary.test.ts | 29 ++ src/shared/native-chat-tool-summary.ts | 32 +- 11 files changed, 1172 insertions(+), 19 deletions(-) create mode 100644 src/renderer/src/components/native-chat/NativeChatToolIcon.tsx create mode 100644 src/shared/native-chat-tool-icon.test.ts create mode 100644 src/shared/native-chat-tool-icon.ts diff --git a/mobile/src/session/MobileNativeChatToolRun.tsx b/mobile/src/session/MobileNativeChatToolRun.tsx index db3ddbea063..ccc732dab88 100644 --- a/mobile/src/session/MobileNativeChatToolRun.tsx +++ b/mobile/src/session/MobileNativeChatToolRun.tsx @@ -14,9 +14,9 @@ import { describeActiveToolCall, formatActiveToolLabel, formatToolCallCount, - isCommandToolName, selectActiveToolCall } from '../../../src/shared/native-chat-tool-activity' +import { isShellActivityToolCall } from '../../../src/shared/native-chat-tool-icon' import type { NativeChatBlock } from '../../../src/shared/native-chat-types' import { colors } from '../theme/mobile-theme' import { styles } from './mobile-native-chat-message-styles' @@ -195,7 +195,10 @@ export function ToolRun({ } callCount ||= pairs.length const summary = summarizeToolRun(blocks) - const ActiveToolIcon = activeCall && isCommandToolName(activeCall.name) ? SquareTerminal : Wrench + // The call's input, not its word: Codex names a classified shell row + // `read`/`search`/`list` and keeps the command it ran, while Claude's `Read` + // shares that word and ran none. + const ActiveToolIcon = activeCall && isShellActivityToolCall(activeCall) ? SquareTerminal : Wrench return ( diff --git a/src/main/codex/codex-structured-item-translation.test.ts b/src/main/codex/codex-structured-item-translation.test.ts index f0f843e4ed6..1d64158cb60 100644 --- a/src/main/codex/codex-structured-item-translation.test.ts +++ b/src/main/codex/codex-structured-item-translation.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { createToolInputDisplay } from '../../shared/native-chat-tool-summary' import { codexItemBody, codexItemIdentity, @@ -195,6 +196,279 @@ describe('codex item bodies', () => { }) }) + it('names a classified read command by its class and keeps the raw command', () => { + const body = codexItemBody({ + type: 'commandExecution', + id: 'item-read', + command: "sed -n '1,200p' notes.txt", + cwd: '/repo', + status: 'completed', + exitCode: 0, + commandActions: [ + { + type: 'read', + command: "sed -n '1,200p' notes.txt", + name: 'notes.txt', + path: '/repo/notes.txt' + } + ] + }) + + expect(body).toEqual({ + kind: 'tool-call', + name: 'read', + // `name` is the target's basename, which `path` already carries and no + // label ever reads, so it stays out of the bounded journal payload. + input: { command: "sed -n '1,200p' notes.txt", cwd: '/repo', path: '/repo/notes.txt' }, + state: 'completed' + }) + // `read` is the one class that keeps `path`, so its row stays a tappable + // file on mobile — the other half of the rule `list`/`search` obey below. + const display = createToolInputDisplay(body?.kind === 'tool-call' ? body.input : null) + expect(display.filePath).toBe('/repo/notes.txt') + expect(display.label).toBe('/repo/notes.txt') + }) + + it('carries a classified search query so the row labels by term, not scan root', () => { + expect( + codexItemBody({ + type: 'commandExecution', + id: 'item-search', + command: 'rg -n --no-heading beta .', + cwd: '/repo', + status: 'inProgress', + commandActions: [ + { type: 'search', command: 'rg -n --no-heading beta .', query: 'beta', path: '.' } + ] + }) + ).toEqual({ + kind: 'tool-call', + name: 'search', + input: { command: 'rg -n --no-heading beta .', cwd: '/repo', query: 'beta', directory: '.' }, + state: 'running' + }) + }) + + it('omits a null classified field rather than standing it in as a target', () => { + expect( + codexItemBody({ + type: 'commandExecution', + id: 'item-search-bare', + command: 'rg beta', + cwd: '/repo', + status: 'completed', + exitCode: 0, + commandActions: [{ type: 'search', command: 'rg beta', query: null, path: null }] + }) + ).toEqual({ + kind: 'tool-call', + name: 'search', + input: { command: 'rg beta', cwd: '/repo' }, + state: 'completed' + }) + }) + + it('names a classified listFiles command `list` and invents no target for a null path', () => { + const body = codexItemBody({ + type: 'commandExecution', + id: 'item-list', + command: 'ls', + cwd: '/repo', + status: 'completed', + exitCode: 0, + commandActions: [{ type: 'listFiles', command: 'ls', path: null }] + }) + + expect(body).toEqual({ + kind: 'tool-call', + name: 'list', + input: { command: 'ls', cwd: '/repo' }, + state: 'completed' + }) + // A stand-in `.` reaches mobile as a tappable "open file" link onto a + // directory, which can only fail. The raw command is the honest label. + const display = createToolInputDisplay(body?.kind === 'tool-call' ? body.input : null) + expect(display.filePath).toBeNull() + expect(display.label).toBe('ls') + }) + + it('keeps the shell row when one command did two different classified things', () => { + // `cat a.txt && ls src` classifies as a read and a listing; naming the row + // after either drops the other. + expect( + codexItemBody({ + type: 'commandExecution', + id: 'item-mixed', + command: 'cat a.txt && ls src', + cwd: '/repo', + status: 'completed', + exitCode: 0, + commandActions: [ + { type: 'read', command: 'cat a.txt', name: 'a.txt', path: 'a.txt' }, + { type: 'listFiles', command: 'ls src', path: 'src' } + ] + }) + ).toEqual({ + kind: 'tool-call', + name: 'shell', + input: { command: 'cat a.txt && ls src', cwd: '/repo' }, + state: 'completed' + }) + }) + + it('keeps one class run twice, naming no target when the two disagree', () => { + expect( + codexItemBody({ + type: 'commandExecution', + id: 'item-two-reads', + command: 'cat a.ts && cat b.ts', + cwd: '/repo', + status: 'completed', + exitCode: 0, + commandActions: [ + { type: 'read', command: 'cat a.ts', path: 'a.ts' }, + { type: 'read', command: 'cat b.ts', path: 'b.ts' } + ] + }) + ).toEqual({ + kind: 'tool-call', + name: 'read', + input: { command: 'cat a.ts && cat b.ts', cwd: '/repo' }, + state: 'completed' + }) + }) + + it('keeps a target both entries of one class name', () => { + expect( + codexItemBody({ + type: 'commandExecution', + id: 'item-same-read', + command: 'head a.ts && tail a.ts', + cwd: '/repo', + status: 'completed', + exitCode: 0, + commandActions: [ + { type: 'read', command: 'head a.ts', path: 'a.ts' }, + { type: 'read', command: 'tail a.ts', path: 'a.ts' } + ] + }) + ).toMatchObject({ name: 'read', input: { path: 'a.ts' } }) + }) + + it('keeps the listed directory as a label, never as a file target', () => { + const body = codexItemBody({ + type: 'commandExecution', + id: 'item-list-path', + command: 'ls src', + cwd: '/repo', + status: 'completed', + exitCode: 0, + commandActions: [{ type: 'listFiles', command: 'ls src', path: 'src' }] + }) + + expect(body).toMatchObject({ name: 'list', input: { directory: 'src' } }) + // Under `path` this reaches mobile as a tappable open-file link onto a + // directory — the same dead link a stand-in `.` would have produced. + const display = createToolInputDisplay(body?.kind === 'tool-call' ? body.input : null) + expect(display.filePath).toBeNull() + expect(display.label).toBe('src') + }) + + it('keeps a scan root off the file-target key even when the search has no term', () => { + const body = codexItemBody({ + type: 'commandExecution', + id: 'item-search-root', + command: 'rg --files src', + cwd: '/repo', + status: 'completed', + exitCode: 0, + commandActions: [{ type: 'search', command: 'rg --files src', query: null, path: 'src' }] + }) + + expect(body).toMatchObject({ name: 'search', input: { directory: 'src' } }) + // `path` is only excluded from the file target while a query is present, so + // a term-less search under it would link to the folder it scanned. + expect( + createToolInputDisplay(body?.kind === 'tool-call' ? body.input : null).filePath + ).toBeNull() + }) + + it('leaves the other classes without a stand-in target', () => { + expect( + codexItemBody({ + type: 'commandExecution', + id: 'item-read-null', + command: 'cat', + cwd: '/repo', + status: 'completed', + exitCode: 0, + commandActions: [{ type: 'read', command: 'cat', path: null, name: null }] + }) + ).toEqual({ + kind: 'tool-call', + name: 'read', + input: { command: 'cat', cwd: '/repo' }, + state: 'completed' + }) + }) + + it('skips unclassified actions to reach the first classified one', () => { + expect( + codexItemBody({ + type: 'commandExecution', + id: 'item-piped', + command: 'true && cat a.ts', + cwd: '/repo', + status: 'completed', + exitCode: 0, + commandActions: [ + { type: 'unknown', command: 'true' }, + { type: 'read', command: 'cat a.ts', name: 'a.ts', path: 'a.ts' } + ] + }) + ).toMatchObject({ name: 'read', input: { path: 'a.ts' } }) + }) + + it('falls back to the unclassified shell row for absent or malformed commandActions', () => { + const shellRow = { + kind: 'tool-call', + name: 'shell', + input: { command: 'ls', cwd: '/tmp' }, + state: 'completed' + } + const base = { + type: 'commandExecution', + id: 'item-fallback', + command: 'ls', + cwd: '/tmp', + status: 'completed', + exitCode: 0 + } + + expect(codexItemBody(base)).toEqual(shellRow) + expect(codexItemBody({ ...base, commandActions: null })).toEqual(shellRow) + expect(codexItemBody({ ...base, commandActions: [] })).toEqual(shellRow) + expect( + codexItemBody({ ...base, commandActions: [{ type: 'unknown', command: 'ls' }] }) + ).toEqual(shellRow) + expect(codexItemBody({ ...base, commandActions: 'read' })).toEqual(shellRow) + expect(codexItemBody({ ...base, commandActions: [null, 7, 'read', {}, { type: 5 }] })).toEqual( + shellRow + ) + // The classification table is a Map because an object index answers + // `__proto__`/`constructor` with a truthy non-string tool name. + expect( + codexItemBody({ ...base, commandActions: [{ type: '__proto__', command: 'ls' }] }) + ).toEqual(shellRow) + expect( + codexItemBody({ ...base, commandActions: [{ type: 'constructor', command: 'ls' }] }) + ).toEqual(shellRow) + // The rollout-file shape is a different lane and never reaches app-server. + expect( + codexItemBody({ ...base, parsedCmd: [{ type: 'read', cmd: 'ls', path: 'a.ts' }] }) + ).toEqual(shellRow) + }) + it('accepts snake-case command completion output and preserves blob evidence', () => { const output = 'x'.repeat(1_100_000) const translated = codexJournalItem({ diff --git a/src/main/codex/codex-structured-item-translation.ts b/src/main/codex/codex-structured-item-translation.ts index 19052dca365..b3609e076f5 100644 --- a/src/main/codex/codex-structured-item-translation.ts +++ b/src/main/codex/codex-structured-item-translation.ts @@ -175,15 +175,86 @@ export type CodexJournalItem = { handled: boolean } +/** + * Codex's own classification of a shell call: the tool name to show, and the + * fields worth lifting into `input` for the shared label helper (a file target, + * a search term, a scanned root). A `Map`, not an object — an object index + * answers `__proto__` with a truthy non-string. Every other action type stays an + * unclassified `shell` row. + * + * Nothing is invented for a field Codex sends as null: a stand-in path is a + * claim about a target, and the label helper turns any path into a file link. + */ +type CommandActionClass = { + name: string + /** Action field to the `input` key it lifts to. A scan root and a listed + * directory lift to `directory`, never `path`: the label helper reads `path` + * as a file target, which mobile turns into a tappable open-file link. */ + keys: Readonly> +} + +const COMMAND_ACTION_CLASSES = new Map([ + ['read', { name: 'read', keys: { path: 'path' } }], + ['search', { name: 'search', keys: { query: 'query', path: 'directory' } }], + ['listFiles', { name: 'list', keys: { path: 'directory' } }] +]) + +/** The one class every classified `commandActions` entry agrees on, with the + * fields they all agree on; null leaves the row exactly as a Codex that sends no + * classification renders it. `cat a.txt && ls src` classifies as two different + * things, and naming that row after either would drop the other, so it stays a + * `shell` row that shows the whole command. */ +function commandActionFacts( + item: CodexThreadItem +): { name: string; fields: Record } | null { + const actions = item.commandActions + if (!Array.isArray(actions)) { + return null + } + let matched: { class: CommandActionClass; fields: Record } | null = null + for (const action of actions) { + const record = readRecord(action) + const type = readString(record, 'type') + const classified = type === null ? undefined : COMMAND_ACTION_CLASSES.get(type) + if (classified === undefined) { + continue + } + if (matched === null) { + const fields: Record = {} + for (const [source, lifted] of Object.entries(classified.keys)) { + const value = readString(record, source) + if (value !== null) { + fields[lifted] = value + } + } + matched = { class: classified, fields } + continue + } + if (matched.class.name !== classified.name) { + return null + } + // The same class twice keeps the class, but only a target both entries name. + for (const [source, lifted] of Object.entries(matched.class.keys)) { + const kept = matched.fields[lifted] + if (kept !== undefined && readString(record, source) !== kept) { + delete matched.fields[lifted] + } + } + } + return matched === null ? null : { name: matched.class.name, fields: matched.fields } +} + function commandItem(item: CodexThreadItem): CodexJournalItem { const output = readFirstString(item, ['aggregatedOutput', 'aggregated_output']) const bounded = output === null ? null : boundInlineText(output, DEFAULT_JOURNAL_PAYLOAD_LIMITS) + const parsed = commandActionFacts(item) return { body: { kind: 'tool-call', - name: 'shell', + name: parsed?.name ?? 'shell', + // Raw command and cwd stay so the expanded view still shows what ran. input: boundToolInput( - { command: item.command ?? null, cwd: item.cwd ?? null }, + { command: item.command ?? null, cwd: item.cwd ?? null, ...parsed?.fields }, DEFAULT_JOURNAL_PAYLOAD_LIMITS ), state: commandState(item), diff --git a/src/renderer/src/components/native-chat/NativeChatToolIcon.tsx b/src/renderer/src/components/native-chat/NativeChatToolIcon.tsx new file mode 100644 index 00000000000..de18b89bae3 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatToolIcon.tsx @@ -0,0 +1,88 @@ +import { + Bot, + Eye, + Folder, + Globe, + ListChecks, + Pencil, + Plug, + Search, + SquareTerminal, + Wrench +} from 'lucide-react' +import type { LucideIcon } from 'lucide-react' +import { cn } from '@/lib/utils' +import { + nativeChatToolIconName, + type NativeChatToolIconName +} from '../../../../shared/native-chat-tool-icon' + +/** Glyph name to component. */ +const NATIVE_CHAT_TOOL_GLYPHS: Record = { + eye: Eye, + search: Search, + folder: Folder, + 'square-terminal': SquareTerminal, + pencil: Pencil, + globe: Globe, + plug: Plug, + bot: Bot, + 'list-checks': ListChecks, + wrench: Wrench +} + +/** The fixed 16px slot with a 14px glyph, which keeps every row left-aligned + * including rows whose category this vocabulary doesn't model. */ +function NativeChatGlyphSlot({ + glyph: Glyph, + className +}: { + glyph: LucideIcon + className?: string +}): React.JSX.Element { + return ( + + + + ) +} + +/** + * The category glyph on a tool row. Decorative — the word beside it is the + * accessible name — so it is `aria-hidden` and must never render without that + * word. + * + * A running run header names one call and so resolves its glyph through this + * same component, and can never disagree with the row it names. + */ +export function NativeChatToolIcon({ + rowWord, + className +}: { + /** The word the row renders, which is the row's whole identity. */ + rowWord: string + className?: string +}): React.JSX.Element { + return ( + + ) +} + +/** + * The glyph over a settled run, which speaks for every call in it rather than + * for one row, so its caller resolves the category and no row word names it. + * Same table and same slot as a row's glyph, so the two can never draw one + * category differently. + */ +export function NativeChatToolRunIcon({ + iconName, + className +}: { + iconName: NativeChatToolIconName + className?: string +}): React.JSX.Element { + return +} diff --git a/src/renderer/src/components/native-chat/NativeChatToolRun.test.tsx b/src/renderer/src/components/native-chat/NativeChatToolRun.test.tsx index 050b3f7c84b..e19c203ee5c 100644 --- a/src/renderer/src/components/native-chat/NativeChatToolRun.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatToolRun.test.tsx @@ -11,6 +11,18 @@ import { NativeChatToolRun } from './NativeChatToolRun' afterEach(cleanup) +/** The first glyph of every row — the run header, then each tool line. Named by + * lucide's own class, so an icon that swaps shows up as a different name. */ +function leadingGlyphs(container: HTMLElement): (string | null)[] { + return [...container.querySelectorAll('button')].map( + (button) => + button + .querySelector('svg') + ?.getAttribute('class') + ?.match(/lucide-[a-z0-9-]+/)?.[0] ?? null + ) +} + describe('NativeChatToolRun', () => { it('uses the shared clean label for a desktop tool row', () => { const blocks: NativeChatBlock[] = [ @@ -355,4 +367,234 @@ describe('NativeChatToolRun', () => { expect(container.querySelector('.lucide-check')).toBeInTheDocument() expect(container.querySelector('.lucide-circle-alert')).toBeNull() }) + + it('shows the category glyph beside the word a classified row is named by', () => { + const blocks: NativeChatBlock[] = [ + { + type: 'tool-call', + name: 'read', + input: { command: "sed -n '1,200p' notes.txt", path: 'notes.txt' }, + state: 'completed' + } + ] + + const { container } = render() + + const glyph = container.querySelector('.lucide-eye') + expect(glyph).toBeInTheDocument() + expect(glyph).toHaveAttribute('aria-hidden') + expect(screen.getByText('read')).toBeInTheDocument() + }) + + it('holds one glyph for a category across running, completed, and failed', () => { + const searchCall = (state: 'running' | 'completed' | 'failed'): NativeChatBlock[] => [ + { type: 'tool-call', name: 'search', input: { query: 'beta' }, state } + ] + const { container, rerender } = render( + + ) + + expect(leadingGlyphs(container)).toEqual(['lucide-search', 'lucide-search']) + + for (const settled of ['completed', 'failed'] as const) { + rerender( + + ) + + // A leading check here would read as the row changing identity on settle. + expect(leadingGlyphs(container)).toEqual(['lucide-search', 'lucide-search']) + } + }) + + it('falls back to the generic tool glyph, not the terminal, for an unmodelled row', () => { + const blocks: NativeChatBlock[] = [ + { + type: 'tool-call', + name: 'AskUserQuestion', + input: { prompt: 'which?' }, + state: 'completed' + } + ] + + const { container } = render() + + // A terminal here would assert a shell ran when nothing says one did. + expect(container.querySelector('.lucide-square-terminal')).toBeNull() + expect(container.querySelector('.lucide-wrench')).toBeInTheDocument() + }) + + it('agrees between the header and the row it names for an unmodelled tool', () => { + const blocks: NativeChatBlock[] = [ + { + type: 'tool-call', + name: 'AskUserQuestion', + input: { prompt: 'which?' }, + state: 'completed' + } + ] + + const { container } = render() + + // Header and row read the same function, so one run cannot show two glyphs. + expect(leadingGlyphs(container)).toEqual(['lucide-wrench', 'lucide-wrench']) + }) + + it('leaves a result row without a category glyph, its word being translated copy', () => { + const blocks: NativeChatBlock[] = [ + { type: 'tool-call', name: 'read', input: { path: 'notes.txt' }, state: 'completed' }, + { type: 'tool-result', output: 'first line' } + ] + + render() + + const resultRow = screen.getByText('Result').closest('button') + // Keying a category off 'Result' would resolve a different glyph per locale. + expect( + [...(resultRow?.querySelectorAll('svg') ?? [])].map( + (svg) => svg.getAttribute('class')?.match(/lucide-[a-z0-9-]+/)?.[0] + ) + ).toEqual(['lucide-chevron-right']) + }) + + it('heads a projected diff run with the file-change glyph, not the generic one', () => { + const projected = projectStructuredItemToNativeChat({ + itemId: 'file-change', + revision: 1, + sequence: 1, + observedAt: 1, + body: { + kind: 'diff', + path: 'src/a.ts', + patch: { + head: '@@ -1 +1 @@\n-was\n+now', + truncated: false, + byteLength: 24, + digest: 'a'.repeat(64) + } + } + }) + + const { container } = render( + + ) + + // The run renders an edited-file card, so a wrench above it reads as a tool + // this vocabulary does not model. + expect(container.querySelector('.lucide-pencil')).toBeInTheDocument() + expect(container.querySelector('.lucide-wrench')).toBeNull() + }) + + describe('the settled header glyph over a whole run', () => { + // The header's text summarizes the run's first calls, so its glyph has to + // describe the same run rather than whichever call happened to finish last. + const call = (name: string, input: unknown): NativeChatBlock => ({ + type: 'tool-call', + name, + input, + state: 'completed' + }) + + it('heads a run that is all reads with the read glyph', () => { + const blocks: NativeChatBlock[] = [ + call('read', { command: "sed -n '1,50p' a.ts", path: 'a.ts' }), + call('read', { command: "sed -n '1,50p' b.ts", path: 'b.ts' }) + ] + + const { container } = render( + + ) + + expect(leadingGlyphs(container)).toEqual(['lucide-eye', 'lucide-eye', 'lucide-eye']) + }) + + it('heads a run that is all shell with the terminal glyph, whatever each is named', () => { + const blocks: NativeChatBlock[] = [ + call('shell', { command: 'npm test' }), + call('Bash', { command: 'git status' }) + ] + + const { container } = render( + + ) + + expect(leadingGlyphs(container)).toEqual([ + 'lucide-square-terminal', + 'lucide-square-terminal', + 'lucide-square-terminal' + ]) + }) + + it('heads a run spanning categories with the generic tool glyph', () => { + const blocks: NativeChatBlock[] = [ + call('shell', { command: 'npm test' }), + call('read', { command: "sed -n '1,50p' a.ts", path: 'a.ts' }) + ] + + const { container } = render( + + ) + + // An eye here — the last call's glyph — would claim a category the summary + // beside it does not describe. + expect(leadingGlyphs(container)).toEqual([ + 'lucide-wrench', + 'lucide-square-terminal', + 'lucide-eye' + ]) + }) + + it('heads a single-call run with that call\u2019s own glyph', () => { + const { container } = render( + + ) + + expect(leadingGlyphs(container)).toEqual(['lucide-search', 'lucide-search']) + }) + + it('leaves a run with no tool calls headed by no category glyph', () => { + const blocks: NativeChatBlock[] = [{ type: 'tool-result', output: 'first line' }] + + const { container } = render( + + ) + + // Only the trailing check and the chevron; a wrench here would claim a + // tool category for a run holding no tool call. + expect(leadingGlyphs(container)).toEqual(['lucide-check', 'lucide-chevron-right']) + }) + + it('keeps naming the active call while the run is still running', () => { + const blocks: NativeChatBlock[] = [ + call('read', { command: "sed -n '1,50p' a.ts", path: 'a.ts' }), + { type: 'tool-call', name: 'shell', input: { command: 'npm test' }, state: 'running' } + ] + + const { container } = render( + + ) + + // The running header names one call, so its glyph is that call's. + expect(leadingGlyphs(container)[0]).toBe('lucide-square-terminal') + }) + }) + + it('labels a bare list row by the command it ran rather than an invented path', () => { + const blocks: NativeChatBlock[] = [ + { + type: 'tool-call', + name: 'list', + input: { command: 'ls', cwd: '/repo' }, + state: 'completed' + } + ] + + const { container } = render() + + expect(container.querySelector('.lucide-folder')).toBeInTheDocument() + expect(screen.getByTitle('ls')).toHaveTextContent('ls') + }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatToolRun.tsx b/src/renderer/src/components/native-chat/NativeChatToolRun.tsx index e91177b375a..faaab338c66 100644 --- a/src/renderer/src/components/native-chat/NativeChatToolRun.tsx +++ b/src/renderer/src/components/native-chat/NativeChatToolRun.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from 'react' -import { Check, ChevronRight, SquareTerminal, Wrench } from 'lucide-react' +import { Check, ChevronRight } from 'lucide-react' import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' import { @@ -23,11 +23,12 @@ import { } from './native-chat-tool-summary' import { describeActiveToolCall, - isCommandToolName, NATIVE_CHAT_TOOL_ACTIVITY_COPY, selectActiveToolCall } from '../../../../shared/native-chat-tool-activity' +import { nativeChatToolRunIconName } from '../../../../shared/native-chat-tool-icon' import { NativeChatDiffView } from './NativeChatDiffView' +import { NativeChatToolIcon, NativeChatToolRunIcon } from './NativeChatToolIcon' function activeToolLabel(call: Extract): string { const { key, toolName, preview } = describeActiveToolCall(call) @@ -60,8 +61,9 @@ function ToolLine({ let body: { output: string; isError?: boolean } | null = null let detail: string | null = null let inputHasDetail = false + const isCall = isToolCallBlock(block) - if (isToolCallBlock(block)) { + if (isCall) { name = block.name const inputDisplay = createToolInputDisplay(block.input) preview = inputDisplay.label @@ -90,6 +92,14 @@ function ToolLine({ )} aria-expanded={hasDetail ? expanded : undefined} > + {isCall ? ( + /* Decorative category glyph; the word beside it is the row's name. */ + + ) : ( + /* A result's word is translated copy, not a tool name, so there is no + category to read from it. The empty slot keeps rows aligned. */ + + )} {name} @@ -217,8 +227,13 @@ export function NativeChatToolRun({ () => (open ? buildEditCards(blocks) : NO_EDIT_CARDS), [open, blocks] ) - const ActiveToolIcon = - latestActiveCall && isCommandToolName(latestActiveCall.name) ? SquareTerminal : Wrench + // Only the settled header reads this. It stands over `summary`, which speaks + // for the run's first calls rather than its last, so a glyph taken from one + // call would assert a category the text beside it doesn't describe. A run that + // spans categories therefore heads with the generic tool glyph. The glyph is + // fixed once settled, so state rides on the trailing mark — a leading glyph + // that flipped to a check would read as a change of identity. + const settledHeaderIcon = nativeChatToolRunIconName(blocks.filter(isToolCallBlock)) const fallbackLabel = callCount === 1 ? translate('components.native-chat.tool.countOne', NATIVE_CHAT_TOOL_ACTIVITY_COPY.countOne) @@ -250,9 +265,7 @@ export function NativeChatToolRun({ aria-expanded={open} aria-live="polite" > - - - + {activeToolLabel(latestActiveCall)} @@ -265,10 +278,8 @@ export function NativeChatToolRun({ className="group flex min-h-6 w-full items-center gap-1.5 py-0.5 text-left" aria-expanded={open} > - {structuredActivityUi ? ( - - - + {structuredActivityUi && settledHeaderIcon ? ( + ) : null} {callCount}× @@ -276,6 +287,10 @@ export function NativeChatToolRun({ {summary || fallbackLabel} + {/* Completion reads as a trailing mark so the leading glyph can stay fixed. */} + {structuredActivityUi ? ( + + ) : null} {/* Chevron is revealed on hover when collapsed and points down when open. */} { + it('names a glyph for every category in the vocabulary', () => { + expect(NATIVE_CHAT_TOOL_ICON_NAMES).toEqual({ + read: 'eye', + search: 'search', + listFiles: 'folder', + unknown: 'square-terminal', + fileChange: 'pencil', + webSearch: 'globe', + mcpToolCall: 'plug', + subAgentActivity: 'bot', + todoList: 'list-checks', + other: 'wrench' + }) + expect(Object.keys(NATIVE_CHAT_TOOL_ICON_NAMES).sort()).toEqual([...ALL_CATEGORIES].sort()) + }) + + it('gives each category a distinct glyph so rows are told apart by icon', () => { + const glyphs = ALL_CATEGORIES.map((category) => NATIVE_CHAT_TOOL_ICON_NAMES[category]) + expect(new Set(glyphs).size).toBe(glyphs.length) + }) + + it('maps the row words the Codex lane renders to their category', () => { + expect(nativeChatToolCategory('read')).toBe('read') + expect(nativeChatToolCategory('search')).toBe('search') + expect(nativeChatToolCategory('list')).toBe('listFiles') + expect(nativeChatToolCategory('shell')).toBe('unknown') + expect(nativeChatToolCategory('apply_patch')).toBe('fileChange') + expect(nativeChatToolCategory('web search')).toBe('webSearch') + }) + + it('maps the tool names the Claude lane renders verbatim', () => { + expect(nativeChatToolIconName('Read')).toBe('eye') + expect(nativeChatToolIconName('Bash')).toBe('square-terminal') + expect(nativeChatToolIconName('Grep')).toBe('search') + expect(nativeChatToolIconName('Glob')).toBe('search') + expect(nativeChatToolIconName('Task')).toBe('bot') + expect(nativeChatToolIconName('WebFetch')).toBe('globe') + expect(nativeChatToolIconName('TodoWrite')).toBe('list-checks') + }) + + it('reads the whole edit family from the shared set, not a parallel list', () => { + for (const name of ['Edit', 'MultiEdit', 'Write', 'str_replace', 'apply_patch']) { + expect(nativeChatToolCategory(name)).toBe('fileChange') + expect(nativeChatToolIconName(name)).toBe('pencil') + } + }) + + it('reads the projected `Diff` row as a file change, which is what it renders', () => { + // Every Codex fileChange item projects to a call named `Diff`, so a wrench + // here headed a run whose body is an edited-file card. + expect(nativeChatToolCategory('Diff')).toBe('fileChange') + expect(nativeChatToolIconName('Diff')).toBe('pencil') + }) + + it('reads an MCP tool by its prefix, since the row is named after the tool', () => { + expect(nativeChatToolCategory('mcp__linear__create_issue')).toBe('mcpToolCall') + expect(nativeChatToolIconName('mcp__playwright__browser_click')).toBe('plug') + // Not a prefix match: a tool merely mentioning mcp is not an MCP call. + expect(nativeChatToolCategory('run_mcp__thing')).toBeNull() + }) + + it('resolves the glyph for each classified row word', () => { + expect(nativeChatToolIconName('read')).toBe('eye') + expect(nativeChatToolIconName('search')).toBe('search') + expect(nativeChatToolIconName('list')).toBe('folder') + expect(nativeChatToolIconName('shell')).toBe('square-terminal') + expect(nativeChatToolIconName('edit')).toBe('pencil') + expect(nativeChatToolIconName('web search')).toBe('globe') + }) + + it('reads a row word regardless of case or surrounding space', () => { + expect(nativeChatToolIconName(' Read ')).toBe('eye') + expect(nativeChatToolIconName('WebSearch')).toBe('globe') + }) + + it('falls back to the generic tool glyph, not the terminal, outside the vocabulary', () => { + // Claiming a terminal here would assert a shell ran when nothing says one did. + expect(nativeChatToolCategory('AskUserQuestion')).toBeNull() + expect(nativeChatToolIconName('AskUserQuestion')).toBe('wrench') + expect(nativeChatToolIconName('')).toBe('wrench') + }) + + it('keeps the terminal glyph for a row that really ran a command', () => { + // `exec` and `local_shell` are how the Codex rollout transcript names a + // shell call; `native-chat-edit-normalize` already calls the three command + // tools by those words, so a wrench on one would deny a command that ran. + for (const name of ['shell', 'bash', 'run_terminal_cmd', 'exec', 'local_shell']) { + expect(nativeChatToolCategory(name)).toBe('unknown') + expect(nativeChatToolIconName(name)).toBe('square-terminal') + } + }) + + describe('terminal activity for a two-glyph lane', () => { + // Mobile has only a terminal and a wrench, so it asks this instead of + // `nativeChatToolIconName`. The row word alone cannot answer it: Codex's + // classified `read` and Claude's `Read` are the same word lowercased. + + it('reads a classified Codex row as terminal activity, by the command it kept', () => { + for (const [name, fields] of [ + ['read', { path: 'src/app.ts' }], + ['search', { query: 'todo', directory: 'src' }], + ['list', { directory: 'src' }] + ] as const) { + expect( + isShellActivityToolCall({ + name, + input: { command: 'rg todo src', cwd: '/repo', ...fields } + }) + ).toBe(true) + } + }) + + it('reads an unclassified shell row as terminal activity, by its name', () => { + for (const name of ['shell', 'bash', 'Bash', 'run_terminal_cmd']) { + expect(isShellActivityToolCall({ name, input: null })).toBe(true) + } + }) + + it('reads a rollout-transcript shell call as terminal activity', () => { + // `exec` and `local_shell` are not command tool names, so only the argv + // command in their input says a shell ran. + expect( + isShellActivityToolCall({ name: 'exec', input: '{"command":["bash","-lc","ls"]}' }) + ).toBe(true) + expect( + isShellActivityToolCall({ name: 'local_shell', input: { command: ['bash', '-lc', 'ls'] } }) + ).toBe(true) + }) + + it('leaves a Claude filesystem tool a generic tool, since no command ran', () => { + expect( + isShellActivityToolCall({ name: 'Read', input: { file_path: '/repo/src/app.ts' } }) + ).toBe(false) + expect( + isShellActivityToolCall({ name: 'Grep', input: { pattern: 'todo', path: 'src' } }) + ).toBe(false) + expect(isShellActivityToolCall({ name: 'Glob', input: { pattern: '**/*.ts' } })).toBe(false) + }) + + it('leaves an unmodelled tool a generic tool', () => { + expect( + isShellActivityToolCall({ name: 'AskUserQuestion', input: { question: 'which?' } }) + ).toBe(false) + for (const name of ['Edit', 'Diff', 'Task', 'WebFetch', 'TodoWrite', '']) { + expect(isShellActivityToolCall({ name, input: { file_path: 'a.ts' } })).toBe(false) + } + }) + + it('answers false for an input that carries no command, whatever its shape', () => { + for (const input of [ + null, + undefined, + 'ls -la', + 42, + ['bash', '-lc', 'ls'], + {}, + { cwd: '/r' } + ]) { + expect(isShellActivityToolCall({ name: 'read', input })).toBe(false) + } + // A present-but-blank command is not a command that ran. + expect(isShellActivityToolCall({ name: 'read', input: { command: ' ' } })).toBe(false) + expect(isShellActivityToolCall({ name: 'read', input: { command: null } })).toBe(false) + }) + }) + + describe('the glyph over a whole run', () => { + // A run header stands over a summary of the run's first calls, so its glyph + // may only claim a category every call in the run shares. + + it('keeps the category when every call in the run is of it', () => { + const run = [{ name: 'Read' }, { name: 'read' }, { name: ' Read ' }] + + expect(nativeChatToolRunCategory(run)).toBe('read') + expect(nativeChatToolRunIconName(run)).toBe('eye') + }) + + it('reads a run of differently-named shell calls as one shell run', () => { + // The categories agree even though the words do not, so the run is still + // one thing and keeps the terminal. + const run = [{ name: 'shell' }, { name: 'Bash' }, { name: 'local_shell' }] + + expect(nativeChatToolRunCategory(run)).toBe('unknown') + expect(nativeChatToolRunIconName(run)).toBe('square-terminal') + }) + + it('falls back to the generic tool glyph when the run spans categories', () => { + const run = [{ name: 'shell' }, { name: 'Read' }] + + expect(nativeChatToolRunCategory(run)).toBeNull() + // Either category here would describe only part of the run. + expect(nativeChatToolRunIconName(run)).toBe('wrench') + // Order does not make one call speak for the rest. + expect(nativeChatToolRunIconName([{ name: 'Read' }, { name: 'shell' }])).toBe('wrench') + }) + + it('takes a single call at its own category', () => { + expect(nativeChatToolRunCategory([{ name: 'Grep' }])).toBe('search') + expect(nativeChatToolRunIconName([{ name: 'Grep' }])).toBe('search') + expect(nativeChatToolRunIconName([{ name: 'apply_patch' }])).toBe('pencil') + }) + + it('reads a run of unmodelled tools as the generic category, not as spanning', () => { + const run = [{ name: 'AskUserQuestion' }, { name: 'SomeOtherTool' }] + + expect(nativeChatToolRunCategory(run)).toBe('other') + expect(nativeChatToolRunIconName(run)).toBe('wrench') + }) + + it('has no glyph to give a run with no tool calls', () => { + expect(nativeChatToolRunCategory([])).toBeNull() + // Null, not a wrench: an empty header shows no glyph rather than a false one. + expect(nativeChatToolRunIconName([])).toBeNull() + }) + }) + + it('does not answer a prototype key with a glyph', () => { + expect(nativeChatToolCategory('__proto__')).toBeNull() + expect(nativeChatToolCategory('constructor')).toBeNull() + expect(nativeChatToolIconName('__proto__')).toBe('wrench') + }) +}) diff --git a/src/shared/native-chat-tool-icon.ts b/src/shared/native-chat-tool-icon.ts new file mode 100644 index 00000000000..60ca81bc901 --- /dev/null +++ b/src/shared/native-chat-tool-icon.ts @@ -0,0 +1,155 @@ +/** + * The category vocabulary for native-chat tool rows, and the one glyph each + * category keeps. A row is `icon + word + argument`: the icon is decorative and + * the word carries identity, so a renderer must never draw the glyph alone. + * + * The glyph is fixed per category across running/completed/failed — only tone + * changes, plus a trailing mark on failure. A row that swapped glyphs when it + * finished would read as changing identity. + */ +import { EDIT_TOOL_NAMES } from './native-chat-diff' +import { isCommandToolName } from './native-chat-tool-activity' +import { toolInputCommand } from './native-chat-tool-summary' + +export type NativeChatToolCategory = + | 'read' + | 'search' + | 'listFiles' + /** A shell command that ran unclassified — Codex's own word for one. */ + | 'unknown' + | 'fileChange' + | 'webSearch' + | 'mcpToolCall' + | 'subAgentActivity' + | 'todoList' + /** A tool this vocabulary doesn't model. Distinct from `unknown`: claiming a + * terminal for it would assert a shell ran when nothing says one did. */ + | 'other' + +/** lucide glyph ids. Spelled the same by `lucide-react` and `lucide-react-native`, + * so desktop and mobile can resolve one name to their own component. */ +export type NativeChatToolIconName = + | 'eye' + | 'search' + | 'folder' + | 'square-terminal' + | 'pencil' + | 'globe' + | 'plug' + | 'bot' + | 'list-checks' + | 'wrench' + +/** Category to glyph. */ +export const NATIVE_CHAT_TOOL_ICON_NAMES: Record = { + read: 'eye', + search: 'search', + listFiles: 'folder', + unknown: 'square-terminal', + fileChange: 'pencil', + webSearch: 'globe', + mcpToolCall: 'plug', + subAgentActivity: 'bot', + todoList: 'list-checks', + other: 'wrench' +} + +/** + * Row word to category, keyed by the word a lane actually renders rather than by + * the protocol type, because that word is all a row model carries. The edit + * family and the command tools come from their own shared sets below, so this + * table holds only what neither of those already names. + * A `Map`, not an object: an object index answers `__proto__` with a truthy value. + */ +const CATEGORY_BY_ROW_WORD = new Map([ + // Codex's classified shell rows. + ['read', 'read'], + ['search', 'search'], + ['list', 'listFiles'], + // Codex's rollout-transcript names for a shell call, which the activity set + // below does not carry: `isCommandToolName` also picks the running row's copy, + // and this vocabulary only picks a glyph. + ['exec', 'unknown'], + ['local_shell', 'unknown'], + // Every Codex file change projects as a `Diff` call, and the edit set below + // names the tools that carry the edit in their input, not that projection. + ['diff', 'fileChange'], + // Claude's tool names, which its lane renders verbatim. + ['grep', 'search'], + ['glob', 'search'], + ['task', 'subAgentActivity'], + ['webfetch', 'webSearch'], + ['todowrite', 'todoList'], + ['web search', 'webSearch'], + ['websearch', 'webSearch'] +]) + +/** The edit family, lowercased for row-word matching. Deliberately not + * `isEditToolName`: that predicate answers "could this input wrap a patch", + * which is true of command tools too, and a shell row is not an edit. */ +const EDIT_ROW_WORDS = new Set([...EDIT_TOOL_NAMES].map((name) => name.toLowerCase())) + +/** MCP tools arrive as `mcp____` and the row is named after the + * tool, so only the prefix identifies one. */ +const MCP_TOOL_PREFIX = 'mcp__' + +/** The category a row word names, or null when the lane emitted something this + * vocabulary doesn't model yet. */ +export function nativeChatToolCategory(rowWord: string): NativeChatToolCategory | null { + const word = rowWord.trim().toLowerCase() + if (word.startsWith(MCP_TOOL_PREFIX)) { + return 'mcpToolCall' + } + // Before the edit family: a command tool runs whatever it is handed, so a + // patch in its input is not evidence the row is an edit. + if (isCommandToolName(word)) { + return 'unknown' + } + return CATEGORY_BY_ROW_WORD.get(word) ?? (EDIT_ROW_WORDS.has(word) ? 'fileChange' : null) +} + +/** The glyph for a row word. Never empty, so rows stay left-aligned: a word + * outside the vocabulary takes the generic tool glyph, and only a row that + * really ran a command claims the terminal. */ +export function nativeChatToolIconName(rowWord: string): NativeChatToolIconName { + return NATIVE_CHAT_TOOL_ICON_NAMES[nativeChatToolCategory(rowWord) ?? 'other'] +} + +/** The one category every call in a run shares, or null when the run spans + * categories or holds no calls. A run header names the whole run, not any one + * call in it, so it may only claim a category true of all of them. */ +export function nativeChatToolRunCategory( + calls: readonly { name: string }[] +): NativeChatToolCategory | null { + let shared: NativeChatToolCategory | null = null + for (const call of calls) { + const category = nativeChatToolCategory(call.name) ?? 'other' + if (shared !== null && shared !== category) { + return null + } + shared = category + } + return shared +} + +/** The glyph for a run header: the shared category's glyph, the generic tool + * glyph for a run that spans categories, and null when the run has no tool + * call to describe and so heads with no glyph at all. */ +export function nativeChatToolRunIconName( + calls: readonly { name: string }[] +): NativeChatToolIconName | null { + if (calls.length === 0) { + return null + } + return NATIVE_CHAT_TOOL_ICON_NAMES[nativeChatToolRunCategory(calls) ?? 'other'] +} + +/** Whether a call reads as terminal activity, for a lane with no per-category + * glyph (mobile) that only chooses between a terminal and a generic tool. + * The row word cannot decide it alone: Codex names a classified shell row + * `read` / `search` / `list`, which lowercase to Claude's own `Read` / `Grep` / + * `Glob`, and those ran no command. So the input breaks the tie — Codex keeps + * the command it ran, while Claude's `Read` carries only a file path. */ +export function isShellActivityToolCall(call: { name: string; input?: unknown }): boolean { + return isCommandToolName(call.name) || toolInputCommand(call.input) !== null +} diff --git a/src/shared/native-chat-tool-summary.test.ts b/src/shared/native-chat-tool-summary.test.ts index 57cc5eff074..f11481b1136 100644 --- a/src/shared/native-chat-tool-summary.test.ts +++ b/src/shared/native-chat-tool-summary.test.ts @@ -133,6 +133,35 @@ describe('describeToolInput', () => { 'https://example.com' ) expect(briefToolArg({ cmd: '', query: 'needle' })).toBe('needle') + // Inverted, so the skip is still exercised now that the search keys rank first. + expect(describeToolInput({ query: '', command: 'git status' })).toBe('git status') + expect(briefToolArg({ pattern: ' ', cmd: 'git status' })).toBe('git status') + }) + + it('labels a classified search row by its term, not the command that ran it', () => { + // Codex `commandActions` rows are the only input carrying both keys: the + // search term identifies the row, the raw command stays for the detail view. + const search = { command: 'rg -n --no-heading beta .', cwd: '/repo', query: 'beta', path: '.' } + + expect(describeToolInput(search)).toBe('beta') + expect(briefToolArg(search)).toBe('beta') + expect(toolFilePath(search)).toBeNull() + }) + + it('labels by a listed directory without offering it as a file target', () => { + // A folder under `path` becomes a tappable open-file link on mobile. + const listing = { command: 'ls src', cwd: '/repo', directory: 'src' } + + expect(describeToolInput(listing)).toBe('src') + expect(briefToolArg(listing)).toBe('src') + expect(toolFilePath(listing)).toBeNull() + }) + + it('leaves a command-only input labelled by its command', () => { + // Bash and Codex's unclassified shell rows carry no search key at all. + expect(describeToolInput({ command: 'pnpm test', description: 'Run tests' })).toBe('pnpm test') + expect(briefToolArg({ command: 'pnpm test' })).toBe('pnpm test') + expect(describeToolInput({ cmd: 'git status --short' })).toBe('git status --short') }) }) diff --git a/src/shared/native-chat-tool-summary.ts b/src/shared/native-chat-tool-summary.ts index 9954521bed4..57b42607569 100644 --- a/src/shared/native-chat-tool-summary.ts +++ b/src/shared/native-chat-tool-summary.ts @@ -5,8 +5,24 @@ const MAX_PREVIEW_STRING_INPUT = 160 const MAX_PREVIEW_COLLECTION_ITEMS = 8 const MAX_PREVIEW_DEPTH = 2 const MAX_TOOL_RUN_SUMMARY_PARTS = 3 -const PRIMARY_ARG_KEYS = ['command', 'cmd', 'query', 'pattern', 'url', 'description'] as const -const BRIEF_ARG_KEYS = ['command', 'cmd', 'query', 'pattern'] as const +// Search term before command: a classified search row carries both, and the +// term is what identifies it. No other tool input supplies the two together. +// `directory` is a scan root or a listed folder — it labels a row but is +// deliberately absent from the file-target keys below, because a folder reaches +// mobile as a tappable open-file link that can only fail. +const PRIMARY_ARG_KEYS = [ + 'query', + 'pattern', + 'directory', + 'command', + 'cmd', + 'url', + 'description' +] as const +const BRIEF_ARG_KEYS = ['query', 'pattern', 'directory', 'command', 'cmd'] as const +// Only the keys that hold a shell command, so a search term or a listed folder +// cannot stand in for one. +const COMMAND_ARG_KEYS = ['command', 'cmd'] as const export const MAX_TOOL_DETAIL_LENGTH = 4000 export type ToolInputDisplay = { @@ -167,6 +183,18 @@ export function briefToolArg(input: unknown): string { return summarizeToolInput(normalized).slice(0, 28) } +/** The shell command a call carries in its input, or null when it carries none. + * Codex keeps the raw command on a classified `read`/`search`/`list` row, so + * this is what tells one apart from a Claude tool of the same lowercased word. */ +export function toolInputCommand(input: unknown): string | null { + const normalized = normalizeToolInput(input) + return isToolInputRecord(normalized) ? firstPrimaryToolArg(normalized, COMMAND_ARG_KEYS) : null +} + +function isToolInputRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + /** Codex delivers tool arguments as a JSON string. Parse those into the object * shape every helper below already understands; leave prose strings alone. */ function normalizeToolInput(input: unknown): unknown { From 5cec2c2dfcae0700ecfee5b7e1d67200bb74d375 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 5 Sep 2026 14:07:08 -0700 Subject: [PATCH 02/23] test: preserve Docker context in isolated VM recipes (#18884) --- tests/e2e/ephemeral-vm-provisioned-root.spec.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/e2e/ephemeral-vm-provisioned-root.spec.ts b/tests/e2e/ephemeral-vm-provisioned-root.spec.ts index 0dc51224bb3..394868e63be 100644 --- a/tests/e2e/ephemeral-vm-provisioned-root.spec.ts +++ b/tests/e2e/ephemeral-vm-provisioned-root.spec.ts @@ -1,6 +1,6 @@ import { execFileSync } from 'node:child_process' import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' +import { homedir, tmpdir } from 'node:os' import path from 'node:path' import { expect, test } from './helpers/orca-app' import { ensureDockerSshRelayImage } from './helpers/docker-ssh-relay-image' @@ -136,6 +136,8 @@ async function addRecipeRepo(page: Parameters[0], re function seedRecipeRepo(repoPath: string, target: DockerSshRelayTarget): string { const createScript = path.join(repoPath, 'create.sh') const destroyScript = path.join(repoPath, 'destroy.sh') + // The recipe's isolated HOME must still address the engine that owns the fixture container. + const docker = `docker --config ${shellQuote(process.env.DOCKER_CONFIG ?? path.join(homedir(), '.docker'))}` writeFileSync( createScript, `#!/usr/bin/env bash @@ -145,8 +147,8 @@ set -euo pipefail [ -n "\${ORCA_REPO_REF:-}" ] [ -n "\${ORCA_REPO_REF_HEAD:-}" ] [ -n "\${ORCA_REPO_BRANCH:-}" ] -docker exec ${shellQuote(target.containerName)} git -C ${shellQuote(DOCKER_SSH_RELAY_REMOTE_REPO_PATH)} cat-file -e "$ORCA_REPO_REF_HEAD^{commit}" -docker exec ${shellQuote(target.containerName)} git -C ${shellQuote(DOCKER_SSH_RELAY_REMOTE_REPO_PATH)} checkout -B "$ORCA_REPO_BRANCH" "$ORCA_REPO_REF_HEAD" >&2 +${docker} exec ${shellQuote(target.containerName)} git -C ${shellQuote(DOCKER_SSH_RELAY_REMOTE_REPO_PATH)} cat-file -e "$ORCA_REPO_REF_HEAD^{commit}" +${docker} exec ${shellQuote(target.containerName)} git -C ${shellQuote(DOCKER_SSH_RELAY_REMOTE_REPO_PATH)} checkout -B "$ORCA_REPO_BRANCH" "$ORCA_REPO_REF_HEAD" >&2 node -e 'console.log(JSON.stringify({schemaVersion:2,checkoutMode:"provisioned-root",connection:{type:"ssh",projectRoot:process.argv[1],target:{label:"Docker provisioned root",host:process.argv[2],port:Number(process.argv[3]),username:"root",identityFile:process.argv[4],identitiesOnly:true}}}))' ${shellQuote(DOCKER_SSH_RELAY_REMOTE_REPO_PATH)} ${shellQuote(target.host)} ${target.port} ${shellQuote(target.identityFile)} ` ) @@ -155,7 +157,7 @@ node -e 'console.log(JSON.stringify({schemaVersion:2,checkoutMode:"provisioned-r `#!/usr/bin/env bash set -euo pipefail cat >/dev/null -docker rm -f ${shellQuote(target.containerName)} >/dev/null +${docker} rm -f ${shellQuote(target.containerName)} >/dev/null ` ) chmodSync(createScript, 0o755) From cd70048092bb9665a88f32252772901eb9a14ec0 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 5 Sep 2026 14:10:43 -0700 Subject: [PATCH 03/23] Fix favicon retention across same-origin navigations (#18879) * fix: retain favicons across same-origin navigations Move favicon clearing from did-start-loading to did-start-navigation and only clear when origin changes. Chromium re-announces favicons only when the icon URL list changes, so clearing on every load orphans same-origin navigations. Extract favicon URL validation into a shared module. * fix: drop favicon on cross-origin redirects When a same-origin navigation redirects to a different origin, the favicon should be cleared to prevent stale icons from displaying the wrong site's identity. --- .../src/components/browser-favicon.test.tsx | 57 +++++++ .../src/components/browser-favicon.tsx | 28 ++-- .../describe-page/browser-favicon-url.test.ts | 90 +++++++++++ .../describe-page/browser-favicon-url.ts | 63 ++++++++ .../bind-browser-page-webview-listeners.ts | 3 + .../browser-page-favicon-retention.test.ts | 149 ++++++++++++++++++ .../browser-page-webview-loading-handlers.ts | 6 +- ...rowser-page-webview-navigation-handlers.ts | 45 +++++- .../src/components/tab-bar/BrowserTab.tsx | 1 + 9 files changed, 415 insertions(+), 27 deletions(-) create mode 100644 src/renderer/src/components/browser-favicon.test.tsx create mode 100644 src/renderer/src/components/browser-pane/describe-page/browser-favicon-url.test.ts create mode 100644 src/renderer/src/components/browser-pane/describe-page/browser-favicon-url.ts create mode 100644 src/renderer/src/components/browser-pane/host-guest/browser-page-favicon-retention.test.ts diff --git a/src/renderer/src/components/browser-favicon.test.tsx b/src/renderer/src/components/browser-favicon.test.tsx new file mode 100644 index 00000000000..362cf15b46f --- /dev/null +++ b/src/renderer/src/components/browser-favicon.test.tsx @@ -0,0 +1,57 @@ +// @vitest-environment happy-dom +import { createElement } from 'react' +import { cleanup, fireEvent, render } from '@testing-library/react' +import { afterEach, expect, it } from 'vitest' +import { BrowserFavicon } from './browser-favicon' + +afterEach(cleanup) + +const faviconUrl = 'https://example.test/favicon.ico' +const icon = (loading = false, url: string | null = faviconUrl) => + createElement(BrowserFavicon, { faviconUrl: url, loading }) + +it('retries a failed icon after a same-origin reload completes', () => { + const view = render(icon()) + fireEvent.error(view.container.querySelector('img')!) + expect(view.container.querySelector('img')).toBeNull() + view.rerender(icon(true)) + expect(view.container.querySelector('img')).toBeNull() + view.rerender(icon(false)) + expect(view.container.querySelector('img')?.getAttribute('src')).toBe(faviconUrl) + + fireEvent.error(view.container.querySelector('img')!) + view.rerender(icon(false)) + expect(view.container.querySelector('img')).toBeNull() + view.rerender(icon(true)) + view.rerender(icon(false)) + expect(view.container.querySelector('img')).not.toBeNull() +}) + +it('keeps a working image mounted throughout a reload', () => { + const view = render(icon()) + const image = view.container.querySelector('img') + view.rerender(icon(true)) + expect(view.container.querySelector('img')).toBe(image) + view.rerender(icon(false)) + expect(view.container.querySelector('img')).toBe(image) +}) + +it('retries an image that failed during initial loading when loading finishes', () => { + const view = render(icon(true)) + fireEvent.error(view.container.querySelector('img')!) + view.rerender(icon(false)) + expect(view.container.querySelector('img')).not.toBeNull() +}) + +it('still resets failures when the favicon URL changes or clears', () => { + const view = render(icon()) + fireEvent.error(view.container.querySelector('img')!) + view.rerender(icon(false, null)) + view.rerender(icon()) + expect(view.container.querySelector('img')).not.toBeNull() + fireEvent.error(view.container.querySelector('img')!) + view.rerender(icon(false, 'https://other.test/favicon.ico')) + expect(view.container.querySelector('img')?.getAttribute('src')).toBe( + 'https://other.test/favicon.ico' + ) +}) diff --git a/src/renderer/src/components/browser-favicon.tsx b/src/renderer/src/components/browser-favicon.tsx index ecde4065f03..92b14a6ad64 100644 --- a/src/renderer/src/components/browser-favicon.tsx +++ b/src/renderer/src/components/browser-favicon.tsx @@ -1,34 +1,30 @@ import { useState } from 'react' import { Globe } from 'lucide-react' import { cn } from '@/lib/utils' - -function displayableFaviconUrl(faviconUrl: string | null | undefined): string | null { - const trimmed = faviconUrl?.trim() - if (!trimmed) { - return null - } - if (trimmed.startsWith('data:image/')) { - return trimmed - } - try { - const url = new URL(trimmed) - return url.protocol === 'http:' || url.protocol === 'https:' ? trimmed : null - } catch { - return null - } -} +import { displayableFaviconUrl } from './browser-pane/describe-page/browser-favicon-url' export function BrowserFavicon({ faviconUrl, + loading = false, className, fallbackClassName }: { faviconUrl: string | null | undefined + loading?: boolean className?: string fallbackClassName?: string }): React.JSX.Element { const displayUrl = displayableFaviconUrl(faviconUrl) const [failedUrl, setFailedUrl] = useState(null) + const [previousLoading, setPreviousLoading] = useState(loading) + + // Retry after navigation settles, when cookies and connectivity may have recovered. + if (previousLoading !== loading) { + setPreviousLoading(loading) + if (!loading) { + setFailedUrl(null) + } + } // Why: reset during render on any favicon identity change — including a clear to null while // a page loads — so navigating back to the same url retries instead of keeping the fallback. diff --git a/src/renderer/src/components/browser-pane/describe-page/browser-favicon-url.test.ts b/src/renderer/src/components/browser-pane/describe-page/browser-favicon-url.test.ts new file mode 100644 index 00000000000..4c295309174 --- /dev/null +++ b/src/renderer/src/components/browser-pane/describe-page/browser-favicon-url.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest' +import { + browserNavigationLeavesFaviconOrigin, + displayableFaviconUrl, + pickDisplayableFaviconUrl +} from './browser-favicon-url' + +describe('displayableFaviconUrl', () => { + it('accepts http, https and image data urls', () => { + expect(displayableFaviconUrl('https://github.com/favicon.ico')).toBe( + 'https://github.com/favicon.ico' + ) + expect(displayableFaviconUrl('http://127.0.0.1:8765/favicon.ico')).toBe( + 'http://127.0.0.1:8765/favicon.ico' + ) + expect(displayableFaviconUrl(' data:image/png;base64,AAAA ')).toBe( + 'data:image/png;base64,AAAA' + ) + }) + + it('rejects the empty-icon sentinel and non-web schemes', () => { + expect(displayableFaviconUrl('data:,')).toBeNull() + expect(displayableFaviconUrl('chrome-extension://abc/icon.png')).toBeNull() + expect(displayableFaviconUrl('file:///tmp/icon.png')).toBeNull() + expect(displayableFaviconUrl('not a url')).toBeNull() + expect(displayableFaviconUrl(null)).toBeNull() + expect(displayableFaviconUrl(' ')).toBeNull() + }) +}) + +describe('pickDisplayableFaviconUrl', () => { + it('skips leading entries that cannot render', () => { + expect(pickDisplayableFaviconUrl(['data:,', 'https://example.com/icon.png'])).toBe( + 'https://example.com/icon.png' + ) + }) + + it('keeps the declaration order among usable entries', () => { + expect( + pickDisplayableFaviconUrl([ + 'https://github.githubassets.com/favicons/favicon.png', + 'https://github.githubassets.com/favicons/favicon.svg' + ]) + ).toBe('https://github.githubassets.com/favicons/favicon.png') + }) + + it('reports nothing for an absent or unusable list', () => { + expect(pickDisplayableFaviconUrl(undefined)).toBeNull() + expect(pickDisplayableFaviconUrl([])).toBeNull() + expect(pickDisplayableFaviconUrl(['data:,'])).toBeNull() + }) +}) + +describe('browserNavigationLeavesFaviconOrigin', () => { + it('keeps the icon across a same-origin navigation', () => { + expect( + browserNavigationLeavesFaviconOrigin( + 'https://github.com/alibaba/jvm-sandbox', + 'https://github.com/btraceio/btrace' + ) + ).toBe(false) + }) + + it('drops the icon when the origin changes', () => { + expect( + browserNavigationLeavesFaviconOrigin('https://github.com/nodejs/node', 'https://x.com/home') + ).toBe(true) + }) + + it('treats scheme and port as part of the origin', () => { + expect( + browserNavigationLeavesFaviconOrigin('http://localhost:3000/', 'http://localhost:4000/') + ).toBe(true) + expect( + browserNavigationLeavesFaviconOrigin('http://example.com/', 'https://example.com/') + ).toBe(true) + }) + + it('drops the icon when the destination cannot carry one', () => { + expect(browserNavigationLeavesFaviconOrigin('https://github.com/', 'about:blank')).toBe(true) + expect( + browserNavigationLeavesFaviconOrigin('https://github.com/', 'file:///tmp/report.html') + ).toBe(true) + }) + + it('keeps the icon when the document being left is unknown', () => { + expect(browserNavigationLeavesFaviconOrigin(null, 'https://github.com/nodejs/node')).toBe(false) + expect(browserNavigationLeavesFaviconOrigin('about:blank', 'https://github.com/')).toBe(false) + }) +}) diff --git a/src/renderer/src/components/browser-pane/describe-page/browser-favicon-url.ts b/src/renderer/src/components/browser-pane/describe-page/browser-favicon-url.ts new file mode 100644 index 00000000000..8e1c442068d --- /dev/null +++ b/src/renderer/src/components/browser-pane/describe-page/browser-favicon-url.ts @@ -0,0 +1,63 @@ +// Why this lives apart from the : Chromium only emits `page-favicon-updated` when a document's +// icon URL list *changes*, so both the chrome that renders an icon and the guest listeners that +// decide when to drop one have to agree on what counts as a usable icon and as a new site. + +export function displayableFaviconUrl(faviconUrl: string | null | undefined): string | null { + const trimmed = faviconUrl?.trim() + if (!trimmed) { + return null + } + // Why not a plain `data:` check: Chromium reports `data:,` for a page that declares no icon. + if (trimmed.startsWith('data:image/')) { + return trimmed + } + try { + const url = new URL(trimmed) + return url.protocol === 'http:' || url.protocol === 'https:' ? trimmed : null + } catch { + return null + } +} + +export function pickDisplayableFaviconUrl(favicons: readonly string[] | undefined): string | null { + // Why not favicons[0]: the first entry can be a `data:,` sentinel or a non-web scheme while a + // later entry is a real icon. + for (const candidate of favicons ?? []) { + const displayable = displayableFaviconUrl(candidate) + if (displayable) { + return displayable + } + } + return null +} + +function faviconOrigin(rawUrl: string | null | undefined): string | null { + if (!rawUrl) { + return null + } + try { + const url = new URL(rawUrl) + return url.protocol === 'http:' || url.protocol === 'https:' ? url.origin : null + } catch { + return null + } +} + +// Why the two sides are treated asymmetrically: a destination with no icon of its own (about:blank, +// file://, a doc preview) must drop the previous site's icon, but an unknown *origin* — a freshly +// attached guest that hasn't committed a document yet — is not evidence the icon is stale, and +// clearing there would strand a restored tab on the globe until its first paint. +export function browserNavigationLeavesFaviconOrigin( + fromUrl: string | null | undefined, + toUrl: string | null | undefined +): boolean { + const to = faviconOrigin(toUrl) + if (to === null) { + return true + } + const from = faviconOrigin(fromUrl) + if (from === null) { + return false + } + return from !== to +} diff --git a/src/renderer/src/components/browser-pane/host-guest/bind-browser-page-webview-listeners.ts b/src/renderer/src/components/browser-pane/host-guest/bind-browser-page-webview-listeners.ts index e80409fa821..f8699324889 100644 --- a/src/renderer/src/components/browser-pane/host-guest/bind-browser-page-webview-listeners.ts +++ b/src/renderer/src/components/browser-pane/host-guest/bind-browser-page-webview-listeners.ts @@ -116,6 +116,7 @@ export function bindBrowserPageWebviewListeners({ const { handleDidStartNavigation, + handleDidRedirectNavigation, handleFullDidNavigate, handleDidNavigateInPage, handleTitleUpdate, @@ -149,6 +150,7 @@ export function bindBrowserPageWebviewListeners({ webview.addEventListener('focus', dismissAddressBarSuggestions) webview.addEventListener('did-start-loading', handleDidStartLoading) webview.addEventListener('did-start-navigation', handleDidStartNavigation) + webview.addEventListener('did-redirect-navigation', handleDidRedirectNavigation) webview.addEventListener('did-stop-loading', handleDidStopLoading) // Why: close find only on full 'did-navigate', not the shared handler, which also fires on SPA in-page hash/pushState changes. const handleFindCloseOnNavigate = (): void => { @@ -186,6 +188,7 @@ export function bindBrowserPageWebviewListeners({ webview.removeEventListener('focus', dismissAddressBarSuggestions) webview.removeEventListener('did-start-loading', handleDidStartLoading) webview.removeEventListener('did-start-navigation', handleDidStartNavigation) + webview.removeEventListener('did-redirect-navigation', handleDidRedirectNavigation) webview.removeEventListener('did-stop-loading', handleDidStopLoading) webview.removeEventListener('did-navigate', handleFullDidNavigate) webview.removeEventListener('did-navigate', handleFindCloseOnNavigate) diff --git a/src/renderer/src/components/browser-pane/host-guest/browser-page-favicon-retention.test.ts b/src/renderer/src/components/browser-pane/host-guest/browser-page-favicon-retention.test.ts new file mode 100644 index 00000000000..924e7834319 --- /dev/null +++ b/src/renderer/src/components/browser-pane/host-guest/browser-page-favicon-retention.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it, vi } from 'vitest' +import { createBrowserPageWebviewNavigationHandlers } from './browser-page-webview-navigation-handlers' +import { createBrowserPageWebviewLoadingHandlers } from './browser-page-webview-loading-handlers' +import type { BrowserTabPageState } from '../describe-page/browser-page-types' + +const TAB_ID = 'tab-1' +const GITHUB_ICON = 'https://github.githubassets.com/favicons/favicon.png' + +function createHarness(startUrl: string) { + const updates: BrowserTabPageState[] = [] + const committedUrl = { current: startUrl } + const webview = { + getURL: () => committedUrl.current, + getTitle: () => 'title', + canGoBack: () => false, + canGoForward: () => false, + src: startUrl + } as unknown as Electron.WebviewTag + const faviconUrlRef = { current: null as string | null } + const onUpdatePageStateRef = { + current: (_tabId: string, next: BrowserTabPageState) => { + updates.push(next) + } + } + const ref = (value: T) => ({ current: value }) + const navigation = createBrowserPageWebviewNavigationHandlers({ + webview, + browserTabId: TAB_ID, + browserTabUrl: startUrl, + recoveryNavigationValidationRef: ref(null), + activeLoadFailureRef: ref(null), + // Why the destination, not the current document: Orca-driven navigations set this ref before + // assigning src, which is exactly the case the origin check must not read it for. + lastKnownWebviewUrlRef: ref(startUrl), + addressBarInputRef: ref(null), + onSetUrlRef: ref(vi.fn()), + onUpdatePageStateRef, + addBrowserHistoryEntryRef: ref(vi.fn()), + faviconUrlRef, + setAddressBarValue: vi.fn(), + annotationViewportBridgeTokenRef: ref('token'), + setBrowserOverlayViewport: vi.fn() + }) + const loading = createBrowserPageWebviewLoadingHandlers({ + webview, + browserTabId: TAB_ID, + faviconUrlRef, + browserTabUrlRef: ref(startUrl), + addressBarValueRef: ref(startUrl), + addressBarInputRef: ref(null), + activeLoadFailureRef: ref(null), + lastKnownWebviewUrlRef: ref(startUrl), + trackNextLoadingEventRef: ref(true), + keepAddressBarFocusRef: ref(false), + recoveryNavigationValidationRef: ref(null), + clearBrowserPageAnnotationsRef: ref(vi.fn()), + onUpdatePageStateRef, + onSetUrlRef: ref(vi.fn()), + setPendingAnnotationPayload: vi.fn(), + setBrowserOverlayViewport: vi.fn(), + setAddressBarValue: vi.fn(), + focusAddressBarNow: () => false + }) + + const navigateTo = (url: string): void => { + loading.handleDidStartLoading() + navigation.handleDidStartNavigation({ + isMainFrame: true, + isInPlace: false, + url + } as Electron.DidStartNavigationEvent) + committedUrl.current = url + } + + return { faviconUrlRef, updates, navigation, navigateTo, committedUrl } +} + +describe('favicon retention across navigations', () => { + it('keeps the icon when Chromium will not re-announce it for a same-origin load', () => { + const harness = createHarness('https://github.com/alibaba/jvm-sandbox') + harness.navigation.handleFaviconUpdate({ favicons: [GITHUB_ICON] }) + expect(harness.faviconUrlRef.current).toBe(GITHUB_ICON) + + // Chromium emits no page-favicon-updated here: the icon URL list is unchanged. + harness.navigateTo('https://github.com/btraceio/btrace') + + expect(harness.faviconUrlRef.current).toBe(GITHUB_ICON) + expect(harness.updates.some((update) => update.faviconUrl === null)).toBe(false) + }) + + it('drops the icon when the navigation leaves the origin', () => { + const harness = createHarness('https://github.com/nodejs/node') + harness.navigation.handleFaviconUpdate({ favicons: [GITHUB_ICON] }) + + harness.navigateTo('https://x.com/home') + + expect(harness.faviconUrlRef.current).toBeNull() + expect(harness.updates.at(-1)).toEqual({ faviconUrl: null }) + }) + + it('drops the icon when a same-origin navigation redirects to another origin', () => { + const harness = createHarness('https://github.com/nodejs/node') + harness.navigation.handleFaviconUpdate({ favicons: [GITHUB_ICON] }) + harness.navigateTo('https://github.com/login') + + harness.navigation.handleDidRedirectNavigation({ + isMainFrame: true, + isInPlace: false, + url: 'https://example.com/after-login' + } as Electron.DidRedirectNavigationEvent) + + expect(harness.faviconUrlRef.current).toBeNull() + expect(harness.updates.at(-1)).toEqual({ faviconUrl: null }) + }) + + it('does not clear on a same-document navigation', () => { + const harness = createHarness('https://github.com/nodejs/node') + harness.navigation.handleFaviconUpdate({ favicons: [GITHUB_ICON] }) + + harness.navigation.handleDidStartNavigation({ + isMainFrame: true, + isInPlace: true, + url: 'https://example.com/' + } as Electron.DidStartNavigationEvent) + + expect(harness.faviconUrlRef.current).toBe(GITHUB_ICON) + }) + + it('reports loading without touching the icon on did-start-loading', () => { + const harness = createHarness('https://github.com/nodejs/node') + harness.navigation.handleFaviconUpdate({ favicons: [GITHUB_ICON] }) + harness.updates.length = 0 + + harness.navigateTo('https://github.com/nodejs/undici') + + expect(harness.updates).toEqual([{ loading: true }]) + }) + + it('takes the first renderable icon rather than the first declared one', () => { + const harness = createHarness('https://example.com/') + harness.navigation.handleFaviconUpdate({ + favicons: ['data:,', 'https://example.com/icon.png'] + }) + expect(harness.faviconUrlRef.current).toBe('https://example.com/icon.png') + + harness.navigation.handleFaviconUpdate({ favicons: ['data:,'] }) + expect(harness.faviconUrlRef.current).toBeNull() + }) +}) diff --git a/src/renderer/src/components/browser-pane/host-guest/browser-page-webview-loading-handlers.ts b/src/renderer/src/components/browser-pane/host-guest/browser-page-webview-loading-handlers.ts index f263354e8c5..b6887f119eb 100644 --- a/src/renderer/src/components/browser-pane/host-guest/browser-page-webview-loading-handlers.ts +++ b/src/renderer/src/components/browser-pane/host-guest/browser-page-webview-loading-handlers.ts @@ -78,10 +78,10 @@ export function createBrowserPageWebviewLoadingHandlers({ if (!trackNextLoadingEventRef.current) { return } - faviconUrlRef.current = null + // Why the favicon isn't cleared here: it is dropped on the cross-origin did-start-navigation + // instead, because Chromium won't re-announce an unchanged icon for a same-origin load. onUpdatePageStateRef.current(browserTabId, { - loading: true, - faviconUrl: null + loading: true }) } diff --git a/src/renderer/src/components/browser-pane/host-guest/browser-page-webview-navigation-handlers.ts b/src/renderer/src/components/browser-pane/host-guest/browser-page-webview-navigation-handlers.ts index dbb47ae4acb..240e763cf63 100644 --- a/src/renderer/src/components/browser-pane/host-guest/browser-page-webview-navigation-handlers.ts +++ b/src/renderer/src/components/browser-pane/host-guest/browser-page-webview-navigation-handlers.ts @@ -13,6 +13,10 @@ import { isChromiumErrorPage, toDisplayUrl } from '../describe-page/browser-page-url-display' +import { + browserNavigationLeavesFaviconOrigin, + pickDisplayableFaviconUrl +} from '../describe-page/browser-favicon-url' import type { BrowserPageNavigateEvent, BrowserPageRecoveryNavigationValidation, @@ -41,6 +45,7 @@ export type BrowserPageWebviewNavigationHandlersArgs = { export type BrowserPageWebviewNavigationHandlers = { handleDidStartNavigation: (event: Electron.DidStartNavigationEvent) => void + handleDidRedirectNavigation: (event: Electron.DidRedirectNavigationEvent) => void handleFullDidNavigate: (event: BrowserPageNavigateEvent) => void handleDidNavigateInPage: (event: BrowserPageNavigateEvent) => void handleTitleUpdate: (event: { title?: string }) => void @@ -64,6 +69,28 @@ export function createBrowserPageWebviewNavigationHandlers({ annotationViewportBridgeTokenRef, setBrowserOverlayViewport }: BrowserPageWebviewNavigationHandlersArgs): BrowserPageWebviewNavigationHandlers { + const clearFaviconIfOriginChanges = ( + event: Electron.DidStartNavigationEvent | Electron.DidRedirectNavigationEvent + ): void => { + if (!event.isMainFrame || event.isInPlace || !event.url) { + return + } + const browserStartedUrl = redactKagiSessionToken(event.url) + const startedUrl = normalizeBrowserNavigationUrl(browserStartedUrl) ?? browserStartedUrl + // Why getURL() and not lastKnownWebviewUrlRef: Orca-driven navigations point that ref at the + // destination before assigning src, so it can't identify the document being left. + let committedUrl: string | null = null + try { + committedUrl = webview.getURL() || null + } catch { + // Why: a guest that hasn't attached yet rejects getURL(); an unknown origin keeps the icon. + } + if (browserNavigationLeavesFaviconOrigin(committedUrl, startedUrl)) { + faviconUrlRef.current = null + onUpdatePageStateRef.current(browserTabId, { faviconUrl: null }) + } + } + const handleDidStartNavigation = (event: Electron.DidStartNavigationEvent): void => { if (!event.isMainFrame || event.isInPlace || !event.url) { return @@ -74,6 +101,14 @@ export function createBrowserPageWebviewNavigationHandlers({ if (pendingRecoveryNavigation?.targetUrl === startedUrl) { pendingRecoveryNavigation.started = true } + // Why here and not on did-start-loading: Chromium re-announces a favicon only when the icon URL + // list changes, so clearing on every load strands same-origin navigations with no icon and no + // event that would ever restore one. + clearFaviconIfOriginChanges(event) + } + + const handleDidRedirectNavigation = (event: Electron.DidRedirectNavigationEvent): void => { + clearFaviconIfOriginChanges(event) } const handleDidNavigate = ( @@ -136,14 +171,7 @@ export function createBrowserPageWebviewNavigationHandlers({ } const handleFaviconUpdate = (event: { favicons?: string[] }): void => { - const faviconUrl = event.favicons?.[0] ?? null - faviconUrlRef.current = - faviconUrl && - (faviconUrl.startsWith('https://') || - faviconUrl.startsWith('http://') || - faviconUrl.startsWith('data:image/')) - ? faviconUrl - : null + faviconUrlRef.current = pickDisplayableFaviconUrl(event.favicons) onUpdatePageStateRef.current(browserTabId, { faviconUrl: faviconUrlRef.current }) } @@ -175,6 +203,7 @@ export function createBrowserPageWebviewNavigationHandlers({ return { handleDidStartNavigation, + handleDidRedirectNavigation, handleFullDidNavigate, handleDidNavigateInPage, handleTitleUpdate, diff --git a/src/renderer/src/components/tab-bar/BrowserTab.tsx b/src/renderer/src/components/tab-bar/BrowserTab.tsx index b72f27287f9..507b738318a 100644 --- a/src/renderer/src/components/tab-bar/BrowserTab.tsx +++ b/src/renderer/src/components/tab-bar/BrowserTab.tsx @@ -191,6 +191,7 @@ export default function BrowserTab({ muted-foreground made the icon read as "disabled" in practice. */} From dce5ebd83da1ab2fb613d063b8df5af8d95e00cb Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 5 Sep 2026 14:18:22 -0700 Subject: [PATCH 04/23] test: isolate native crash restoration and refresh stale fixtures (#18883) * test: isolate native crash restoration and seed current integration facts * test: await scoped GitLab preflight before URL transition checks --- tests/e2e/electron-home-isolation.spec.ts | 3 +- tests/e2e/feature-wall.spec.ts | 67 +++++++++++-------- .../github-url-smart-input-transition.spec.ts | 21 +++--- tests/e2e/helpers/electron-launch-args.ts | 4 ++ .../helpers/electron-launch-args.unit.test.ts | 13 +++- 5 files changed, 63 insertions(+), 45 deletions(-) diff --git a/tests/e2e/electron-home-isolation.spec.ts b/tests/e2e/electron-home-isolation.spec.ts index 65aa1b7dc10..2fae6f42eec 100644 --- a/tests/e2e/electron-home-isolation.spec.ts +++ b/tests/e2e/electron-home-isolation.spec.ts @@ -1,4 +1,5 @@ import type { ElectronApplication } from '@stablyai/playwright-test' +import { realpathSync } from 'node:fs' import path from 'node:path' import { expect, test } from './helpers/orca-app' @@ -23,7 +24,7 @@ async function readElectronHomeState(electronApp: ElectronApplication) { // HOME boundary and that real-home routing lands inside the disposable profile. test('isolates Electron and Codex from the developer home by default', async ({ electronApp }) => { const state = await readElectronHomeState(electronApp) - const expectedHome = path.join(state.userDataDir!, 'home') + const expectedHome = realpathSync.native(path.join(state.userDataDir!, 'home')) expect(state.appHome).toBe(expectedHome) expect(state.nodeHome).toBe(expectedHome) diff --git a/tests/e2e/feature-wall.spec.ts b/tests/e2e/feature-wall.spec.ts index 428ce5d7996..fb422ec8bf8 100644 --- a/tests/e2e/feature-wall.spec.ts +++ b/tests/e2e/feature-wall.spec.ts @@ -179,9 +179,40 @@ test.describe('Feature tour modal', () => { }) test('does not pre-check configured workflows until the user visits them', async ({ - orcaPage + orcaPage, + electronApp }) => { - await orcaPage.evaluate(() => { + await electronApp.evaluate( + ({ ipcMain }, preflightStatus) => { + ipcMain.removeHandler('preflight:check') + ipcMain.handle('preflight:check', () => preflightStatus) + ipcMain.removeHandler('linear:status') + ipcMain.handle('linear:status', () => ({ connected: false, viewer: null })) + ipcMain.removeHandler('jira:status') + ipcMain.handle('jira:status', () => ({ connected: false, viewer: null })) + }, + { + git: { installed: true }, + gh: { installed: true, authenticated: true }, + glab: { installed: false, authenticated: false }, + bitbucket: { configured: false, authenticated: false, account: null }, + azureDevOps: { + configured: false, + authenticated: false, + account: null, + baseUrl: null, + tokenConfigured: false + }, + gitea: { + configured: false, + authenticated: false, + account: null, + baseUrl: null, + tokenConfigured: false + } + } + ) + await orcaPage.evaluate(async () => { for (const key of [ 'orca.featureWall.visitedWorkflows.v1', 'orca.featureWall.visitedAgentSteps.v1', @@ -198,32 +229,12 @@ test.describe('Feature tour modal', () => { if (!store) { throw new Error('window.__store is not available') } - store.setState({ - preflightStatus: { - git: { installed: true }, - gh: { installed: true, authenticated: true }, - glab: { installed: false, authenticated: false }, - bitbucket: { configured: false, authenticated: false, account: null }, - azureDevOps: { - configured: false, - authenticated: false, - account: null, - baseUrl: null, - tokenConfigured: false - }, - gitea: { - configured: false, - authenticated: false, - account: null, - baseUrl: null, - tokenConfigured: false - } - }, - preflightStatusChecked: true, - preflightStatusLoading: false, - linearStatus: { connected: false, viewer: null }, - linearStatusChecked: true - }) + // Seed through the status actions so each result gets the current execution context. + await Promise.all([ + store.getState().refreshPreflightStatus({ force: true }), + store.getState().checkLinearConnection(true), + store.getState().checkJiraConnection() + ]) store.getState().openModal('feature-wall', { source: 'help_menu' }) }) diff --git a/tests/e2e/github-url-smart-input-transition.spec.ts b/tests/e2e/github-url-smart-input-transition.spec.ts index e078007bd78..f042c4ef676 100644 --- a/tests/e2e/github-url-smart-input-transition.spec.ts +++ b/tests/e2e/github-url-smart-input-transition.spec.ts @@ -203,6 +203,12 @@ async function installHeldGitLabLookup( __releaseGitLabUrlLookup?: () => void } fixture.__gitlabUrlLookupStarted = false + ipcMain.removeHandler('preflight:check') + ipcMain.handle('preflight:check', () => ({ + git: { installed: true }, + gh: { installed: true, authenticated: true }, + glab: { installed: true, authenticated: true } + })) ipcMain.removeHandler('gitlab:listMRs') ipcMain.handle('gitlab:listMRs', () => ({ items: [wrongItem], @@ -221,23 +227,12 @@ async function installHeldGitLabLookup( }, { wrongItem: GITLAB_WRONG_ITEM, targetItem: GITLAB_TARGET_ITEM } ) - await page.evaluate(() => { + await page.evaluate(async () => { const store = window.__store if (!store) { throw new Error('window.__store is not available') } - const state = store.getState() - if (!state.preflightStatusContextKey) { - throw new Error('preflight context is not ready') - } - store.setState({ - preflightStatus: { - git: state.preflightStatus?.git ?? { installed: true }, - gh: state.preflightStatus?.gh ?? { installed: true, authenticated: true }, - glab: { installed: true, authenticated: true } - }, - preflightStatusChecked: true - }) + await store.getState().refreshPreflightStatus({ force: true }) }) } diff --git a/tests/e2e/helpers/electron-launch-args.ts b/tests/e2e/helpers/electron-launch-args.ts index fc2ff1e81aa..9128a5fb551 100644 --- a/tests/e2e/helpers/electron-launch-args.ts +++ b/tests/e2e/helpers/electron-launch-args.ts @@ -7,6 +7,10 @@ export function getOrcaElectronLaunchArgs(mainPath: string, headful: boolean): s // these Chromium switches startup can block before the first renderer target. const keychainArgs = process.platform === 'darwin' ? ['--password-store=basic', '--use-mock-keychain'] : [] + if (process.platform === 'darwin') { + // Crash tests must not block later launches on AppKit's saved-window recovery dialog. + return [...keychainArgs, appPath, '-ApplePersistenceIgnoreState', 'YES'] + } if (headful || process.platform !== 'linux') { return [...keychainArgs, appPath] } diff --git a/tests/e2e/helpers/electron-launch-args.unit.test.ts b/tests/e2e/helpers/electron-launch-args.unit.test.ts index ed981951f14..636299fbc4e 100644 --- a/tests/e2e/helpers/electron-launch-args.unit.test.ts +++ b/tests/e2e/helpers/electron-launch-args.unit.test.ts @@ -8,10 +8,17 @@ describe('getOrcaElectronLaunchArgs', () => { const mainPath = join(root, 'out', 'main', 'index.js') const args = getOrcaElectronLaunchArgs(mainPath, true) - expect(args.at(-1)).toBe(root) if (process.platform === 'darwin') { - expect(args.slice(0, -1)).toEqual(['--password-store=basic', '--use-mock-keychain']) + expect(args).toEqual([ + '--password-store=basic', + '--use-mock-keychain', + root, + '-ApplePersistenceIgnoreState', + 'YES' + ]) + } else { + expect(args.at(-1)).toBe(root) } - expect(getOrcaElectronLaunchArgs(mainPath, false).at(-1)).toBe(root) + expect(getOrcaElectronLaunchArgs(mainPath, false)).toContain(root) }) }) From 2afc8b55ef41de499962ffb9b08760dce34144a7 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 5 Sep 2026 14:34:35 -0700 Subject: [PATCH 05/23] test: pin worker visibility fixture command and handle (#18897) --- ...tration-worker-terminal-visibility.spec.ts | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/tests/e2e/orchestration-worker-terminal-visibility.spec.ts b/tests/e2e/orchestration-worker-terminal-visibility.spec.ts index af603c8ca1f..32b0013ff5a 100644 --- a/tests/e2e/orchestration-worker-terminal-visibility.spec.ts +++ b/tests/e2e/orchestration-worker-terminal-visibility.spec.ts @@ -11,6 +11,10 @@ import { waitForSessionReady } from './helpers/store' import { waitForActivePaneHookDescriptor, waitForActivePanePtyId } from './helpers/terminal' +import { + buildFakeAgentCommandOverride, + FAKE_AGENT_WINDOWS_SHELL +} from './helpers/fake-agent-command-override' import { RuntimeClient } from '../../src/cli/runtime-client' import type { RuntimeTerminalListResult, RuntimeTerminalRead } from '../../src/shared/runtime-types' @@ -111,6 +115,22 @@ test('worker-start preserves one live inactive worker across workspace re-entry' electronApp }) => { await waitForSessionReady(orcaPage) + await orcaPage.evaluate( + async ({ command, windowsShell }) => { + const state = window.__store!.getState() + await state.updateSettings({ + agentCmdOverrides: { ...state.settings?.agentCmdOverrides, codex: command }, + terminalWindowsShell: windowsShell + }) + }, + { + command: buildFakeAgentCommandOverride( + path.join(fakeCliDir, process.platform === 'win32' ? 'codex.cmd' : 'codex') + ), + windowsShell: FAKE_AGENT_WINDOWS_SHELL + } + ) + const worktreeId = await waitForActiveWorktree(orcaPage) await ensureTerminalVisible(orcaPage) const coordinatorTabId = await getActiveTabId(orcaPage) @@ -160,7 +180,7 @@ test('worker-start preserves one live inactive worker across workspace re-entry' const terminals = await client.call('terminal.list') const workerTerminal = terminals.result.terminals.find( - (terminal) => terminal.title === 'Codex Ready' + (terminal) => terminal.handle === workerHandle ) expect(workerTerminal?.tabId).toBeTruthy() expect(workerTerminal?.leafId).toBeTruthy() From 51eed5a1bc6e7593076d6fe1ee3e911db6a3493b Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 5 Sep 2026 14:35:45 -0700 Subject: [PATCH 06/23] feat(cli): report SSH host platforms (#18896) * feat(cli): report SSH host platforms * feat(cli): include SSH connection status * fix(cli): preserve unknown SSH connection state --- docs/site/content/docs/cli/reference.mdx | 2 +- src/cli/format.ts | 20 +++++++- src/cli/handlers/environment.ts | 13 ++++- src/cli/host-selector-alternatives.test.ts | 18 +++++++ src/cli/host-selector-alternatives.ts | 38 ++++++++++++++- .../index-local-command-routing-flags.test.ts | 5 +- src/cli/specs/environment.ts | 2 + src/main/runtime/rpc/methods/ssh.test.ts | 48 +++++++++++++++++-- src/main/runtime/rpc/methods/ssh.ts | 17 +++++-- src/shared/ssh-types.ts | 11 ++++- 10 files changed, 157 insertions(+), 17 deletions(-) diff --git a/docs/site/content/docs/cli/reference.mdx b/docs/site/content/docs/cli/reference.mdx index 0f24ca34192..5cbf19b82f9 100644 --- a/docs/site/content/docs/cli/reference.mdx +++ b/docs/site/content/docs/cli/reference.mdx @@ -63,7 +63,7 @@ List every machine the current Orca host can target and the selector for each on orca host list --json ``` -The result includes this machine, its registered [SSH targets](/docs/ssh), and paired [Remote Orca Servers](/docs/remote-servers). Use `--host local` for this machine, `--host ssh:` for an SSH target, and `--environment ` for a paired server. SSH labels and paired-server names also resolve when they are unique; use the IDs from `host list` when names collide. If you put a machine name on the wrong selector, Orca reports the matching machine and the flag to use instead of returning an empty result. +The result includes this machine, its registered [SSH targets](/docs/ssh), and paired [Remote Orca Servers](/docs/remote-servers). Use `--host local` for this machine, `--host ssh:` for an SSH target, and `--environment ` for a paired server. SSH rows include the detected remote platform (`linux`, `darwin`, or `win32`) after the target connects; older or disconnected targets report `platform unknown`. They also include `connected` and, when known, the SSH lifecycle `connectionStatus`. SSH labels and paired-server names also resolve when they are unique; use the IDs from `host list` when names collide. If you put a machine name on the wrong selector, Orca reports the matching machine and the flag to use instead of returning an empty result. ## Runtime commands diff --git a/src/cli/format.ts b/src/cli/format.ts index 0a297138364..1487a69eea0 100644 --- a/src/cli/format.ts +++ b/src/cli/format.ts @@ -220,6 +220,9 @@ export type HostListEntry = { name: string id: string selector: string + platform?: string + connected?: boolean + connectionStatus?: string } // Why: the selector column is the point of this command — the name alone is what callers already @@ -231,10 +234,25 @@ export function formatHostList(result: { hosts: HostListEntry[] }): string { environment: 'orca server' } return result.hosts - .map((host) => `${kindLabel[host.kind].padEnd(11)} ${host.name} -> ${host.selector}`) + .map( + (host) => + `${kindLabel[host.kind].padEnd(11)} ${host.name} ${host.platform ?? 'platform unknown'} ${formatHostConnection(host)} -> ${host.selector}` + ) .join('\n') } +function formatHostConnection(host: HostListEntry): string { + if (host.kind !== 'ssh') { + return '' + } + if (host.connected === undefined) { + return `connection unknown${host.connectionStatus ? ` (${host.connectionStatus})` : ''}` + } + return host.connected + ? `connected${host.connectionStatus ? ` (${host.connectionStatus})` : ''}` + : `not connected${host.connectionStatus ? ` (${host.connectionStatus})` : ''}` +} + export function formatCliStatus(status: CliStatusResult): string { return [ ...(status.target && status.target.kind === 'environment' diff --git a/src/cli/handlers/environment.ts b/src/cli/handlers/environment.ts index 37b437af2c3..181b2947f98 100644 --- a/src/cli/handlers/environment.ts +++ b/src/cli/handlers/environment.ts @@ -50,10 +50,19 @@ export const ENVIRONMENT_HANDLERS: Record = { kind: 'ssh' as const, name: target.label, id: target.id, - selector: `--host ssh:${target.id}` + selector: `--host ssh:${target.id}`, + ...(target.connected === undefined ? {} : { connected: target.connected }), + ...(target.connectionStatus ? { connectionStatus: target.connectionStatus } : {}), + ...(target.remotePlatform ? { platform: target.remotePlatform } : {}) })) const hosts = [ - { kind: 'local' as const, name: 'this machine', id: 'local', selector: '--host local' }, + { + kind: 'local' as const, + name: 'this machine', + id: 'local', + selector: '--host local', + platform: process.platform + }, ...sshTargets, ...environments ] diff --git a/src/cli/host-selector-alternatives.test.ts b/src/cli/host-selector-alternatives.test.ts index bc4fadd05c1..9460931a083 100644 --- a/src/cli/host-selector-alternatives.test.ts +++ b/src/cli/host-selector-alternatives.test.ts @@ -118,6 +118,24 @@ describe('listSshTargets', () => { expect(call).toHaveBeenCalledWith('ssh.listTargets') }) + it('enriches legacy target rows from host-owned connection state', async () => { + const { RuntimeClientError } = await import('./runtime/types.js') + const call = vi.fn(async (method: string) => { + if (method === 'ssh.listTargetSummaries') { + throw new RuntimeClientError('method_not_found', 'Unknown method') + } + if (method === 'ssh.getState') { + return { result: { state: { status: 'connected', remotePlatform: 'win32' } } } + } + return { result: { targets: SSH_TARGETS } } + }) + + await expect(listSshTargets({ call } as unknown as RuntimeClient)).resolves.toEqual([ + { ...SSH_TARGETS[0], connected: true, connectionStatus: 'connected', remotePlatform: 'win32' } + ]) + expect(call).toHaveBeenCalledWith('ssh.getState', { targetId: SSH_TARGETS[0].id }) + }) + // Why: this only ever runs to enrich an error we are already reporting; a failure here must // not replace that error with a confusing one about SSH enumeration. it('returns nothing rather than masking the error it was enriching', async () => { diff --git a/src/cli/host-selector-alternatives.ts b/src/cli/host-selector-alternatives.ts index fd5abecbc18..f42fec88aee 100644 --- a/src/cli/host-selector-alternatives.ts +++ b/src/cli/host-selector-alternatives.ts @@ -1,6 +1,12 @@ import type { RuntimeClient } from './runtime-client' -export type SshTargetSummary = { id: string; label: string } +export type SshTargetSummary = { + id: string + label: string + remotePlatform?: 'linux' | 'darwin' | 'win32' + connected?: boolean + connectionStatus?: string +} export type EnvironmentSummary = { id: string; name: string } export type HostAlternatives = { @@ -106,7 +112,7 @@ export async function listSshTargets(client: RuntimeClient): Promise('ssh.listTargets') - return legacy.result.targets + return await enrichLegacySshTargetStates(client, legacy.result.targets) } catch { return [] } @@ -115,6 +121,34 @@ export async function listSshTargets(client: RuntimeClient): Promise { + return Promise.all( + targets.map(async (target) => { + try { + const response = await client.call<{ + state: { + status?: string + remotePlatform?: 'linux' | 'darwin' | 'win32' + } | null + }>('ssh.getState', { targetId: target.id }) + const state = response.result.state + return { + ...target, + ...(state?.status === undefined + ? {} + : { connected: state.status === 'connected', connectionStatus: state.status }), + ...(state?.remotePlatform === undefined ? {} : { remotePlatform: state.remotePlatform }) + } + } catch { + return target + } + }) + ) +} + // Why: `--host ssh:` was never validated, so an unknown target answered ok:true with an // empty list — the same silent wrong-machine answer that `runtime:` ids used to give. And since // target ids are machine-generated (`ssh--`), the label a caller actually diff --git a/src/cli/index-local-command-routing-flags.test.ts b/src/cli/index-local-command-routing-flags.test.ts index b8db44915d2..1a195cc52ff 100644 --- a/src/cli/index-local-command-routing-flags.test.ts +++ b/src/cli/index-local-command-routing-flags.test.ts @@ -48,7 +48,7 @@ import { main } from './index' import { okFixture, queueFixtures } from './test-fixtures' import { pairRuntimeEnvironment, useWorktreeAwarenessEnvironment } from './index-test-harness' -const SSH_TARGET = { id: 'ssh-1777360569033-yvz2mp', label: 'openclaw' } +const SSH_TARGET = { id: 'ssh-1777360569033-yvz2mp', label: 'openclaw', remotePlatform: 'win32' } /** Every SSH-target lookup answers with the one target only this machine's runtime knows about. */ function queueSshTargetLookups(count: number): void { @@ -82,6 +82,9 @@ describe('runtime-selector flags on locally pinned CLI commands', () => { SSH_TARGET.id, 'env-m4air' ]) + expect( + printed.result.hosts.find((host: { id: string }) => host.id === SSH_TARGET.id).platform + ).toBe('win32') // The tell: `runtimeId: local` is only honest if no routed client was ever built. expect(runtimeClientConstructorMock).toHaveBeenCalledWith(null, null) }) diff --git a/src/cli/specs/environment.ts b/src/cli/specs/environment.ts index 7bf90615270..64efbe802b9 100644 --- a/src/cli/specs/environment.ts +++ b/src/cli/specs/environment.ts @@ -10,6 +10,8 @@ export const ENVIRONMENT_COMMAND_SPECS: CommandSpec[] = [ notes: [ 'Answers "what can I target and what do I pass" in one place: this machine, the SSH targets registered on it, and the Orca servers paired with it.', 'The three kinds are reached differently. A paired Orca server is a connection, selected with --environment . An SSH target is a machine the connected Orca host reaches, selected with --host ssh:. Passing one where the other belongs is the most common way to get an empty or missing-host answer.', + 'SSH rows include the detected remote platform after that target has connected (linux, darwin, or win32); disconnected or older targets report platform unknown.', + 'SSH rows also include whether the target is currently connected and its lifecycle status when known.', "SSH targets are read from this machine's own Orca runtime, so this lists that machine's targets and not another server's. Run `orca host list` on the other machine to see the targets registered there.", '--environment and --pairing-code are rejected rather than ignored: paired servers come from this machine\u2019s pairing store, so a routed answer would describe two machines at once.' ], diff --git a/src/main/runtime/rpc/methods/ssh.test.ts b/src/main/runtime/rpc/methods/ssh.test.ts index 450375e48f8..04293d3cca4 100644 --- a/src/main/runtime/rpc/methods/ssh.test.ts +++ b/src/main/runtime/rpc/methods/ssh.test.ts @@ -116,6 +116,34 @@ describe('ssh RPC methods', () => { } ] listRegisteredSshTargetsMock.mockReturnValueOnce(targets) + getRegisteredSshStateMock.mockReturnValueOnce({ status: 'connected', remotePlatform: 'win32' }) + const runtime = { getRuntimeId: () => 'test-runtime' } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SSH_METHODS }) + + const response = await dispatcher.dispatch(makeRequest('ssh.listTargetSummaries')) + + expect(response).toMatchObject({ + ok: true, + result: { + targets: [ + { + id: 'ssh-1', + label: 'Dev box', + connected: true, + connectionStatus: 'connected', + remotePlatform: 'win32' + } + ] + } + }) + expect(JSON.stringify(response)).not.toContain('dev.internal') + expect(JSON.stringify(response)).not.toContain('/secret/key') + expect(JSON.stringify(response)).not.toContain('bastion') + }) + + it('does not invent a platform before the SSH host has been detected', async () => { + listRegisteredSshTargetsMock.mockReturnValueOnce([{ id: 'ssh-1', label: 'Dev box' }]) + getRegisteredSshStateMock.mockReturnValueOnce(undefined) const runtime = { getRuntimeId: () => 'test-runtime' } as unknown as OrcaRuntimeService const dispatcher = new RpcDispatcher({ runtime, methods: SSH_METHODS }) @@ -125,9 +153,23 @@ describe('ssh RPC methods', () => { ok: true, result: { targets: [{ id: 'ssh-1', label: 'Dev box' }] } }) - expect(JSON.stringify(response)).not.toContain('dev.internal') - expect(JSON.stringify(response)).not.toContain('/secret/key') - expect(JSON.stringify(response)).not.toContain('bastion') + expect(JSON.stringify(response)).not.toContain('remotePlatform') + }) + + it('reports disconnected lifecycle states without calling them connected', async () => { + listRegisteredSshTargetsMock.mockReturnValueOnce([{ id: 'ssh-1', label: 'Dev box' }]) + getRegisteredSshStateMock.mockReturnValueOnce({ status: 'reconnecting' }) + const runtime = { getRuntimeId: () => 'test-runtime' } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SSH_METHODS }) + + const response = await dispatcher.dispatch(makeRequest('ssh.listTargetSummaries')) + + expect(response).toMatchObject({ + ok: true, + result: { + targets: [{ id: 'ssh-1', connected: false, connectionStatus: 'reconnecting' }] + } + }) }) it('redacts the legacy target response for older clients', async () => { diff --git a/src/main/runtime/rpc/methods/ssh.ts b/src/main/runtime/rpc/methods/ssh.ts index 2e0a4e4f4ab..e6cb6b47b50 100644 --- a/src/main/runtime/rpc/methods/ssh.ts +++ b/src/main/runtime/rpc/methods/ssh.ts @@ -15,11 +15,18 @@ const SshTarget = z.object({ // Why: `generation` stays optional on the wire — an old server simply omits it and its rows key on target id alone. function listRegisteredSshTargetSummaries(): SshTargetSummary[] { - return listRegisteredSshTargets().map(({ id, label, generation }) => ({ - id, - label, - ...(generation === undefined ? {} : { generation }) - })) + return listRegisteredSshTargets().map(({ id, label, generation }) => { + const state = getRegisteredSshState(id) + const remotePlatform = state?.remotePlatform + return { + id, + label, + ...(generation === undefined ? {} : { generation }), + connected: state?.status === 'connected', + ...(state?.status === undefined ? {} : { connectionStatus: state.status }), + ...(remotePlatform === undefined ? {} : { remotePlatform }) + } + }) } export const SSH_METHODS: RpcMethod[] = [ diff --git a/src/shared/ssh-types.ts b/src/shared/ssh-types.ts index f234b1c578a..566bc5e17f5 100644 --- a/src/shared/ssh-types.ts +++ b/src/shared/ssh-types.ts @@ -65,8 +65,15 @@ export type SshTarget = { export type SshTargetCreateInput = Omit export type SshTargetUpdateInput = Partial -/** Public target identity safe to mirror to a paired client. */ -export type SshTargetSummary = Pick +/** Public target identity and observed host metadata safe to mirror to a paired client. */ +export type SshTargetSummary = Pick & { + /** The SSH host's OS, when it has connected and the relay has detected it. */ + remotePlatform?: SshRemotePlatform + /** Whether the target currently has a host-owned connected SSH lifecycle. */ + connected?: boolean + /** Current SSH lifecycle state, when the desktop has one for this target. */ + connectionStatus?: SshConnectionStatus +} /** Identity of a removed SSH target, recorded so that re-adding the same host * can re-point orphaned repos/worktrees from the old (deleted) target id to From 7bec98466bb314aa213c7ed7302e40451ea304dd Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:04:52 -0700 Subject: [PATCH 07/23] test: canonicalize setup fixture paths before worktree lookup (#18912) --- tests/e2e/setup-script-import.spec.ts | 4 ++-- ...script-prompt-unreadable-orca-yaml.spec.ts | 19 ++++++++----------- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/tests/e2e/setup-script-import.spec.ts b/tests/e2e/setup-script-import.spec.ts index 180b34f85aa..340258c884b 100644 --- a/tests/e2e/setup-script-import.spec.ts +++ b/tests/e2e/setup-script-import.spec.ts @@ -1,5 +1,5 @@ import { execFileSync } from 'node:child_process' -import { mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, realpathSync, rmSync, writeFileSync } from 'node:fs' import path from 'node:path' import type { Locator, Page } from '@stablyai/playwright-test' import { test, expect } from './helpers/orca-app' @@ -108,7 +108,7 @@ async function addAndActivateRepo(page: Page, repoPath: string): Promise state.setActiveWorktree(worktree.id) state.setSidebarOpen(true) return addedRepo.id - }, repoPath) + }, realpathSync.native(repoPath)) } async function openRepoSettings(page: Page, repoId: string): Promise { diff --git a/tests/e2e/setup-script-prompt-unreadable-orca-yaml.spec.ts b/tests/e2e/setup-script-prompt-unreadable-orca-yaml.spec.ts index 151a4b02bbc..199694b961a 100644 --- a/tests/e2e/setup-script-prompt-unreadable-orca-yaml.spec.ts +++ b/tests/e2e/setup-script-prompt-unreadable-orca-yaml.spec.ts @@ -1,5 +1,5 @@ import { execFileSync } from 'node:child_process' -import { mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, realpathSync, rmSync, writeFileSync } from 'node:fs' import path from 'node:path' import type { ElectronApplication, Page } from '@stablyai/playwright-test' import { test, expect } from './helpers/orca-app' @@ -112,17 +112,10 @@ async function addRepoAndActivateMainWorktree( if (!store) { throw new Error('window.__store is not available') } - const normalize = (value: string): string => - value.startsWith('/private/var/') ? value.slice('/private'.length) : value - const state = store.getState() const worktrees = state.worktreesByRepo[targetRepoId] ?? [] - const mainWorktree = worktrees.find( - (entry) => normalize(entry.path) === normalize(targetRepoPath) - ) - const featureWorktree = worktrees.find( - (entry) => normalize(entry.path) === normalize(targetFeaturePath) - ) + const mainWorktree = worktrees.find((entry) => entry.path === targetRepoPath) + const featureWorktree = worktrees.find((entry) => entry.path === targetFeaturePath) if (!mainWorktree || !featureWorktree) { throw new Error( `Missing worktrees for ${targetRepoPath}: ${worktrees.map((entry) => entry.path).join(', ')}` @@ -145,7 +138,11 @@ async function addRepoAndActivateMainWorktree( featureWorktreeId: featureWorktree.id } }, - { targetRepoId: repoId, targetRepoPath: repoPath, targetFeaturePath: featureWorktreePath } + { + targetRepoId: repoId, + targetRepoPath: realpathSync.native(repoPath), + targetFeaturePath: realpathSync.native(featureWorktreePath) + } ) } From d7767fb1960507a7ae3fc47d5858b06f8887bd6b Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:08:22 -0700 Subject: [PATCH 08/23] perf(worktree): remove redundant creation and terminal startup work (#18793) * perf(worktree): remove redundant creation and terminal startup work * test(worktree): cover optimized creation call signatures Preserve explicit branch adoption, WSL callback routing and sparse cleanup expectations. * perf: preserve user Git checkout worker settings * perf(git): skip malformed remote base probes * perf(cli): avoid loading other agent hooks for Codex preflight * fix(build): retain Codex preflight entry for packaged CLI * test(ssh): wait for replacement PTY before lease recovery input * test(ssh): verify recovered shell execution and lease ownership * test(electron): reap isolated macOS crash reporters on teardown * test: allow either observed self-exit snapshot ordering * test: capture frozen-host input recovery evidence --- config/reliability-gates.jsonc | 96 ++++++++++++++++++ electron.vite.config.ts | 3 + src/cli/handlers/agent-hooks.test.ts | 5 +- src/cli/handlers/agent-hooks.ts | 10 +- .../claude-stream-json-connection.test.ts | 11 ++- src/main/git/repo-branch-conflict.test.ts | 74 +++++++++++++- src/main/git/repo-branch-conflict.ts | 34 +++++-- src/main/git/runner-wsl-direct-read.test.ts | 28 ++++++ .../git/worktree-add-creation-config.test.ts | 4 +- .../worktree-add-local-base-refresh.test.ts | 5 +- ...worktree-add-local-base-suggestion.test.ts | 5 +- src/main/git/worktree-add.ts | 15 ++- ...rktree-create-preparation-real-wsl.test.ts | 77 +++++++++++++++ src/main/git/worktree-create-preparation.ts | 41 +++++--- .../git/worktree-preparation-base-oid.test.ts | 98 +++++++++++++++++++ src/main/ipc/worktree-logic-wsl.test.ts | 21 ++++ src/main/ipc/worktree-logic.ts | 9 +- src/main/ipc/worktree-remote.ts | 42 +++++--- .../ipc/worktrees-local-create-flow.test.ts | 36 +++++-- src/main/ipc/worktrees-test-module-mocks.ts | 23 +++-- src/main/ipc/worktrees-test-runtime-stub.ts | 2 + .../local-pty-provider-spawn-session.test.ts | 7 +- src/main/providers/local-pty-spawn-state.ts | 2 + src/main/runtime/fetch-remote-cache.test.ts | 13 ++- ...orca-runtime-refresh-repo-worktree-scan.ts | 5 + .../local-worktree-creation-part-02.spec.ts | 12 ++- .../local-worktree-creation.spec.ts | 6 +- ...orktree-removal-and-reconciliation.spec.ts | 13 ++- ...runtime-local-worktree-create-candidate.ts | 26 +++-- .../runtime-remote-fetch-controller.ts | 2 +- ...rktree-scan-admin-fingerprint-gate.test.ts | 16 +++ .../terminal-pane/ipc-pty-connect-result.ts | 4 + ...tion-deferred-reattach-live-output.test.ts | 35 +++++++ .../pty-connection/apply-reattach-payload.ts | 7 ++ .../pty-transport-connect-spawn.test.ts | 18 ++++ .../terminal-pane-manager-options.ts | 6 ++ .../lib/pane-manager/pane-lifecycle.test.ts | 28 +++++- .../src/lib/pane-manager/pane-lifecycle.ts | 6 +- .../pane-manager-pane-creation.ts | 2 +- .../lib/pane-manager/pane-manager-types.ts | 1 + .../src/lib/pane-manager/pane-split-close.ts | 2 +- src/shared/git-binary-compatibility.test.ts | 27 +++++ .../e2e/helpers/electron-crashpad-cleanup.ts | 46 +++++++++ .../electron-crashpad-cleanup.unit.test.ts | 45 +++++++++ .../e2e/helpers/electron-process-shutdown.ts | 2 + .../helpers/ssh-recovery-input-observation.ts | 53 ++++++++++ ...ssh-docker-transport-drop-recovery.spec.ts | 62 +++++++++--- 47 files changed, 971 insertions(+), 114 deletions(-) create mode 100644 src/main/git/worktree-create-preparation-real-wsl.test.ts create mode 100644 src/main/git/worktree-preparation-base-oid.test.ts create mode 100644 tests/e2e/helpers/electron-crashpad-cleanup.ts create mode 100644 tests/e2e/helpers/electron-crashpad-cleanup.unit.test.ts create mode 100644 tests/e2e/helpers/ssh-recovery-input-observation.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 2dd39ad7c3f..d48a239354f 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -10,6 +10,102 @@ } }, "gates": [ + { + "id": "terminal-output.prestarted-shell-snapshot-adoption", + "title": "Prestarted shell adoption paints covered output once", + "maturity": "experimental", + "protection": "partial", + "owner": "terminal-runtime", + "layer": "renderer-transport-and-live-electron", + "surfaces": [ + "backend-created first terminal", + "daemon snapshot adoption", + "deferred live output" + ], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "daemon", "wsl", "ssh", "remote-runtime"], + "coveredPlatforms": ["macos", "linux", "windows"], + "coveredProviders": ["local", "daemon", "wsl"], + "coverageNotes": "macOS daemon-backed Electron journey verifies same PID and terminal identity plus rendered output. Focused renderer contracts pass on Linux, Windows and WSL. Neighboring SSH model and replay contracts pass locally; no new live SSH or paired-runtime journey.", + "motivatingLinks": [ + "https://github.com/user-attachments/assets/e8c6d1dc-6150-4c3d-b55a-3d12efefdd04", + "https://github.com/user-attachments/assets/b0328f88-34ac-4d51-8119-9efe17072435" + ], + "invariant": "Adopting a prestarted terminal preserves its existing process and paints snapshot-covered startup output once while retaining subsequent live output. Missing sequence proof or blank snapshots must not authorize dropping output.", + "oracle": "Pass snapshot sequence and proven zero keyboard flags through real IPC transport projection. Deliver snapshot-covered and newer output before reattach resolves; drain replay parse callbacks and require one startup marker and the newer output. Repeat with no sequence and blank snapshot to retain unproven bytes. In Electron select a prestarted workspace, type a generated marker and compare PID and stable terminal identities before and after.", + "commands": [ + "pnpm test src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts src/renderer/src/components/terminal-pane/pty-connection-deferred-reattach-live-output.test.ts", + "pnpm test src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts src/renderer/src/components/terminal-pane/pty-connection-deferred-reattach-live-output.test.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-snapshot-live-overlap.test.ts src/renderer/src/components/terminal-pane/pty-connection-replay-payload-handling.test.ts src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-ssh-reconnect-model-paint.test.ts", + "pnpm test src/renderer/src/components/terminal-pane/pty-connection src/renderer/src/components/terminal-pane/pty-transport" + ], + "testFiles": [ + "src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts", + "src/renderer/src/components/terminal-pane/pty-connection-deferred-reattach-live-output.test.ts" + ], + "assertionRefs": [ + { + "file": "src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts", + "assertions": [ + "zero and nonzero snapshot sequence and proven zero keyboard flags survive IPC projection" + ] + }, + { + "file": "src/renderer/src/components/terminal-pane/pty-connection-deferred-reattach-live-output.test.ts", + "assertions": [ + "startup output covered by the snapshot is painted once", + "new output remains visible", + "legacy unsequenced and blank snapshots retain bytes" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-09-04", + "runner": "local", + "platform": "macos", + "command": "pnpm test src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts src/renderer/src/components/terminal-pane/pty-connection-deferred-reattach-live-output.test.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-snapshot-live-overlap.test.ts src/renderer/src/components/terminal-pane/pty-connection-replay-payload-handling.test.ts src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-ssh-reconnect-model-paint.test.ts", + "result": "passed", + "durationSeconds": 2.34, + "summary": "5 suites / 48 tests pass. Focused 2-suite runs independently pass 27 tests on Linux, Windows and WSL." + }, + { + "date": "2026-09-04", + "runner": "local", + "platform": "macos", + "command": "pnpm test src/renderer/src/components/terminal-pane/pty-connection src/renderer/src/components/terminal-pane/pty-transport", + "result": "passed", + "durationSeconds": 5.67, + "summary": "Broader connection/transport gate: 77 files and 813 tests passed, including neighboring restore, reconnect, input and replay behavior. Log: artifacts/worktree-create/orca-draft-replay-broader-gate.log." + } + ], + "runtimeBudget": { + "p95Seconds": 15, + "scope": "focused renderer transport and deferred-adoption contracts" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "Focused local and remote runs pass; no CI soak history." + }, + "redGreenEvidence": { + "status": "partial", + "evidence": "Metadata tests fail before forwarding. Corrected parse-draining regression observes two startup markers when the baseline installation is removed, and one after restoration. Initial missing-live-output failure was a harness parse-drain omission and is not red proof. Before/fixed Electron screenshots show duplicate/single startup output." + }, + "performanceBudget": { + "required": true, + "evidence": "Reuses existing snapshot baseline reconciliation with no new scan, timer or subprocess. Corrected daemon-backed rendered trial reaches replay at 116.6 ms and generated keyboard output at 177 ms after selecting the prestarted workspace. This measures selection/adoption, not ordinary composer creation." + }, + "promotionCriteria": [ + "Meet manifest CI and soak policy.", + "Retain intentional-break and rendered identity/output proof.", + "Exercise live SSH and paired-runtime snapshot adoption before claiming full provider coverage." + ], + "knownGaps": [ + "Composer draft creation and cancellation are not implemented by this gate.", + "No new live SSH, Windows or WSL UI run; remote evidence is focused contract tests.", + "Mixed-version snapshots without sequence proof intentionally retain legacy behavior." + ], + "demotionRule": "Keep experimental or demote if adoption duplicates covered output, drops newer or unproven output, changes terminal ownership, or flakes without explanation." + }, { "id": "cmd-j-tabs.host-qualified-candidate-ownership", "title": "Cmd-J tab candidates retain execution-host ownership", diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 4ed4641cde1..90dc637c204 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -253,6 +253,9 @@ export const electronViteConfig: UserConfig = { 'agent-hooks/managed-agent-hook-controls': resolve( 'src/main/agent-hooks/managed-agent-hook-controls.ts' ), + 'codex/managed-home-shell-preflight': resolve( + 'src/main/codex/managed-home-shell-preflight.ts' + ), // Why: account import mutates the user's macOS Keychain from the CLI. 'claude-accounts/keychain': resolve('src/main/claude-accounts/keychain.ts') }, diff --git a/src/cli/handlers/agent-hooks.test.ts b/src/cli/handlers/agent-hooks.test.ts index 4fcc186b0d8..279a8900bec 100644 --- a/src/cli/handlers/agent-hooks.test.ts +++ b/src/cli/handlers/agent-hooks.test.ts @@ -56,7 +56,10 @@ vi.mock('../runtime-client', () => { vi.mock('../../main/agent-hooks/managed-agent-hook-controls', () => ({ applyAgentStatusHooksEnabled: applyAgentStatusHooksEnabledMock, - getManagedAgentHookStatuses: getManagedAgentHookStatusesMock, + getManagedAgentHookStatuses: getManagedAgentHookStatusesMock +})) + +vi.mock('../../main/codex/managed-home-shell-preflight', () => ({ prepareManagedCodexHomeBeforeShellLaunch: prepareManagedCodexHomeBeforeShellLaunchMock })) diff --git a/src/cli/handlers/agent-hooks.ts b/src/cli/handlers/agent-hooks.ts index bf44211b4a7..4fcfe64f9b7 100644 --- a/src/cli/handlers/agent-hooks.ts +++ b/src/cli/handlers/agent-hooks.ts @@ -15,11 +15,7 @@ import { getDefaultPersistedState } from '../../shared/constants' import { normalizeDisabledTuiAgents } from '../../shared/tui-agent-selection' import type { GlobalSettings } from '../../shared/global-settings-types' import type { PersistedState } from '../../shared/persisted-state-types' -import { - applyAgentStatusHooksEnabled, - getManagedAgentHookStatuses, - prepareManagedCodexHomeBeforeShellLaunch -} from '../../main/agent-hooks/managed-agent-hook-controls' +import { prepareManagedCodexHomeBeforeShellLaunch } from '../../main/codex/managed-home-shell-preflight' type AgentHookCommandResult = { enabled: boolean @@ -194,6 +190,8 @@ async function setAgentHooksEnabled( client: RuntimeClient, enabled: boolean ): Promise { + const { applyAgentStatusHooksEnabled, getManagedAgentHookStatuses } = + await import('../../main/agent-hooks/managed-agent-hook-controls.js') const updatedRuntime = await updateRunningRuntime(client, enabled) const offlineUpdate = updatedRuntime ? null : updateEnabledOnDisk(enabled) const settingsPath = offlineUpdate?.settingsPath ?? getDataPath() @@ -234,6 +232,8 @@ export const AGENT_HOOK_HANDLERS: Record = { }) }, 'agent hooks status': async ({ json }) => { + const { getManagedAgentHookStatuses } = + await import('../../main/agent-hooks/managed-agent-hook-controls.js') const result: AgentHookCommandResult = { enabled: readHookSettingsFromDisk().agentStatusHooksEnabled, settingsPath: getDataPath(), diff --git a/src/main/claude/claude-stream-json-connection.test.ts b/src/main/claude/claude-stream-json-connection.test.ts index c4f1f9a6fca..eb69a66a897 100644 --- a/src/main/claude/claude-stream-json-connection.test.ts +++ b/src/main/claude/claude-stream-json-connection.test.ts @@ -566,7 +566,7 @@ describe('Claude stream-json connection', () => { ) }) - it('reports a self-exit with its status and stderr, and leaves its tree unverifiable', async () => { + it('reports a self-exit with its status, stderr, and observed tree verdict', async () => { const scenario = scriptScenario([{ stderr: 'claude: not signed in\n' }, { exit: 1 }]) let exit: Error | null = null const connection = await open(launchFor(scenario), { @@ -579,10 +579,11 @@ describe('Claude stream-json connection', () => { // The status and stderr are the only diagnostic a refused start leaves behind. expect((exit as unknown as Error).message).toMatch(/exited \(code 1\): claude: not signed in/) expect(connection.closed).toBe(true) - // The root's exit is first-hand, but it left before a descendant snapshot - // could be armed, so close() has no tree proof to offer and says so. - await expect(connection.close()).resolves.toBe(false) - expect(connection.exitVerdict).toEqual({ root: 'exited', tree: 'unverifiable' }) + // Stderr-triggered capture can win or lose the race with this real child's exit. + const closed = await connection.close() + expect(connection.exitVerdict.root).toBe('exited') + expect(['exited', 'unverifiable']).toContain(connection.exitVerdict.tree) + expect(closed).toBe(connection.exitVerdict.tree === 'exited') }) it.runIf(process.platform !== 'win32')( diff --git a/src/main/git/repo-branch-conflict.test.ts b/src/main/git/repo-branch-conflict.test.ts index 873c8bd3340..82bd1e65c9d 100644 --- a/src/main/git/repo-branch-conflict.test.ts +++ b/src/main/git/repo-branch-conflict.test.ts @@ -21,7 +21,7 @@ describe('getBranchConflictKindViaExec', () => { await expect(getBranchConflictKindViaExec(exec, 'feature/fix')).resolves.toBe('remote') expect(calls).toEqual([ - ['rev-parse', '--verify', 'refs/heads/feature/fix'], + ['rev-parse', '--verify', '--quiet', 'refs/heads/feature/fix'], ['remote'], ['show-ref', '--verify', '--quiet', '--', 'refs/remotes/foo/bar/feature/fix'], ['show-ref', '--verify', '--quiet', '--', 'refs/remotes/origin/feature/fix'] @@ -41,7 +41,10 @@ describe('getBranchConflictKindViaExec', () => { await expect( getBranchConflictKindViaExec(exec, 'feature/fix', 'origin/feature/fix') ).resolves.toBeNull() - expect(calls).toEqual([['rev-parse', '--verify', 'refs/heads/feature/fix'], ['remote']]) + expect(calls).toEqual([ + ['rev-parse', '--verify', '--quiet', 'refs/heads/feature/fix'], + ['remote'] + ]) }) it('keeps longest configured remote-name matching semantics', async () => { @@ -176,7 +179,7 @@ describe('getBranchConflictKindViaExec batched remote probe', () => { getBranchConflictKindViaExec(exec, 'feature', undefined, {}, batched) ).resolves.toBeNull() expect(calls).toEqual([ - ['rev-parse', '--verify', 'refs/heads/feature'], + ['rev-parse', '--verify', '--quiet', 'refs/heads/feature'], ['remote'], ['cat-file', '--batch-check'] ]) @@ -254,3 +257,68 @@ describe('getBranchConflictKindViaExec batched remote probe', () => { expect(calls.filter((argv) => argv[0] === 'show-ref')).toHaveLength(3) }) }) + +describe('branch conflict with existing-branch adoption', () => { + const absent = () => Object.assign(new Error('missing'), { code: 1, stderr: '' }) + + it('skips adoption and its commit probe for a proven missing local ref', async () => { + const exec = vi.fn(async (argv: string[]) => { + if (argv[0] === 'rev-parse') { + throw absent() + } + return { stdout: '' } + }) + const adopt = vi.fn(async () => false) + await expect( + getBranchConflictKindViaExec(exec, 'new', undefined, {}, undefined, adopt) + ).resolves.toBeNull() + expect(adopt).not.toHaveBeenCalled() + expect(exec).toHaveBeenCalledTimes(2) + }) + + it('allows an existing branch without querying remote refs', async () => { + const exec = vi.fn(async () => ({ stdout: 'a'.repeat(40) })) + const adopt = vi.fn(async () => true) + await expect( + getBranchConflictKindViaExec(exec, 'existing', undefined, {}, undefined, adopt) + ).resolves.toBeNull() + expect(adopt).toHaveBeenCalledOnce() + expect(exec).toHaveBeenCalledOnce() + }) + + it('retains conflicts for refs whose objects cannot be adopted as commits', async () => { + const exec = vi.fn(async () => ({ stdout: 'a'.repeat(40) })) + const adopt = vi.fn(async () => false) + await expect( + getBranchConflictKindViaExec(exec, 'dangling', undefined, {}, undefined, adopt) + ).resolves.toBe('local') + expect(adopt).toHaveBeenCalledOnce() + expect(exec).toHaveBeenCalledTimes(2) + }) + + it.each([ + Object.assign(new Error('transport'), { code: 1, stderr: 'transport failed' }), + Object.assign(new Error('timeout'), { code: 'ETIMEDOUT' }) + ])('still attempts adoption after an undecided ref probe: %s', async (error) => { + const exec = vi.fn(async () => { + throw error + }) + const adopt = vi.fn(async () => true) + await expect( + getBranchConflictKindViaExec(exec, 'existing', undefined, {}, undefined, adopt) + ).resolves.toBeNull() + expect(adopt).toHaveBeenCalledOnce() + }) + + it('rechecks a ref that disappeared while adoption was running', async () => { + const exec = vi + .fn() + .mockResolvedValueOnce({ stdout: 'a'.repeat(40) }) + .mockRejectedValueOnce(absent()) + .mockResolvedValueOnce({ stdout: '' }) + await expect( + getBranchConflictKindViaExec(exec, 'removed', undefined, {}, undefined, async () => false) + ).resolves.toBeNull() + expect(exec).toHaveBeenCalledTimes(3) + }) +}) diff --git a/src/main/git/repo-branch-conflict.ts b/src/main/git/repo-branch-conflict.ts index 5c6e03b94b0..f22fd97f99e 100644 --- a/src/main/git/repo-branch-conflict.ts +++ b/src/main/git/repo-branch-conflict.ts @@ -3,6 +3,7 @@ import { gitExecOptions, type LocalGitExecOptions } from './repo-default-base-re import { gitExecFileAsync } from './runner' import { isSafeGitRefName } from '../../shared/git-status-upstream-ref' import { + isShowRefNoMatchError, probeAnyExactRef, probeAnyExactRefBatched, type ExactRefProbeExec, @@ -29,16 +30,17 @@ function canQueryRemoteBranchName(branchName: string): boolean { return !branchName.startsWith('-') && isSafeGitRefName(`refs/heads/${branchName}`) } -async function hasGitRefAsync( +async function probeLocalBranchRef( exec: ExactRefProbeExec, ref: string, options: ExactRefProbeExecOptions -): Promise { +): Promise<'present' | 'absent' | 'unknown'> { try { - const { stdout } = await runGit(exec, ['rev-parse', '--verify', ref], options) - return stdout.trim().length > 0 - } catch { - return false + // Quiet absence avoids retrying the WSL probe through a login shell. + const { stdout } = await runGit(exec, ['rev-parse', '--verify', '--quiet', ref], options) + return stdout.trim().length > 0 ? 'present' : 'unknown' + } catch (error) { + return isShowRefNoMatchError(error) ? 'absent' : 'unknown' } } @@ -105,7 +107,8 @@ export async function getBranchConflictKindViaExec( branchName: string, allowedBaseRef?: string, options: ExactRefProbeExecOptions = {}, - batchedExec?: ExactRefProbeStdinExec + batchedExec?: ExactRefProbeStdinExec, + allowLocalBranch?: () => Promise ): Promise { if (!canQueryRemoteBranchName(branchName)) { return null @@ -114,7 +117,16 @@ export async function getBranchConflictKindViaExec( // are quiet, so introducing a smaller implicit cap would only make a large // remote configuration look like a missing conflict. const probeOptions: ExactRefProbeExecOptions = options - if (await hasGitRefAsync(exec, `refs/heads/${branchName}`, probeOptions)) { + const localRef = `refs/heads/${branchName}` + let presence = await probeLocalBranchRef(exec, localRef, probeOptions) + if (allowLocalBranch && presence !== 'absent') { + if (await allowLocalBranch()) { + return null + } + // Adoption can span ref changes; preserve the fresh conflict check after it fails. + presence = await probeLocalBranchRef(exec, localRef, probeOptions) + } + if (presence === 'present') { return 'local' } @@ -142,7 +154,8 @@ export function getBranchConflictKind( path: string, branchName: string, allowedBaseRef?: string, - options: LocalGitExecOptions = {} + options: LocalGitExecOptions = {}, + allowLocalBranch?: () => Promise ): Promise { const execOptions = gitExecOptions(path, options) const runLocalGit = ( @@ -168,7 +181,8 @@ export function getBranchConflictKind( // one `show-ref` subprocess per remote -- the exact cost the batch exists to remove. // `show-ref --verify --quiet` prints nothing and is read by exit code, so it needs // no fence; the capture wrapper preserves the payload's exit status either way. - (argv, commandOptions) => runLocalGit(argv, commandOptions, true) + (argv, commandOptions) => runLocalGit(argv, commandOptions, true), + allowLocalBranch ) } diff --git a/src/main/git/runner-wsl-direct-read.test.ts b/src/main/git/runner-wsl-direct-read.test.ts index 284cda55718..e7ea423f78e 100644 --- a/src/main/git/runner-wsl-direct-read.test.ts +++ b/src/main/git/runner-wsl-direct-read.test.ts @@ -17,6 +17,7 @@ vi.mock('../observability/instrumentation', () => ({ })) vi.mock('../diagnostics/main-thread-churn-probe', () => ({ recordSubprocessSpawn: vi.fn() })) +import { getBranchConflictKind } from './repo-branch-conflict' import { pendingWslDirectGitReadEnvironment } from './command-runner/git-command-resolution' import { gitExecFileAsync, gitSpawn, gitStreamStdout } from './runner' import { @@ -584,6 +585,33 @@ describe('WSL direct Git reads', () => { }) }) + it('checks a missing branch conflict without retrying through a login shell', async () => { + await withPlatform('win32', async () => { + seedWslGitReadEnvironmentForTests(DISTRO, LOGIN_ENVIRONMENT) + execFileMock.mockImplementation((_command, args: string[], _options, callback) => { + const child = createMockChild() + queueMicrotask(() => { + const missingRef = args.join(' ').includes('rev-parse') + const quiet = args.includes('--quiet') + const code = missingRef ? (quiet ? 1 : 128) : 0 + callback?.( + code ? Object.assign(new Error('missing ref'), { code }) : null, + '', + missingRef && !quiet ? 'fatal: Needed a single revision' : '' + ) + child.emit('close', code, null) + }) + return child + }) + + await expect( + getBranchConflictKind(String.raw`\\wsl.localhost\Ubuntu\repo`, 'new-feature') + ).resolves.toBeNull() + expect(execFileMock).toHaveBeenCalledTimes(2) + expect(execFileMock.mock.calls[0]?.[1]).toContain('--quiet') + }) + }) + it('keeps the fast path when direct and login Git both report an expected failure', async () => { await withPlatform('win32', async () => { seedWslGitReadEnvironmentForTests(DISTRO, LOGIN_ENVIRONMENT) diff --git a/src/main/git/worktree-add-creation-config.test.ts b/src/main/git/worktree-add-creation-config.test.ts index 44394b7728d..ff44ca7ac6d 100644 --- a/src/main/git/worktree-add-creation-config.test.ts +++ b/src/main/git/worktree-add-creation-config.test.ts @@ -199,7 +199,7 @@ describe('addWorktree', () => { }) const worktreeAddCall = gitExecFileAsyncMock.mock.calls.find( - ([argv]) => Array.isArray(argv) && argv[0] === 'worktree' && argv[1] === 'add' + ([argv]) => Array.isArray(argv) && argv.includes('worktree') && argv.includes('add') ) expect(worktreeAddCall?.[1]).toMatchObject({ timeout: WORKTREE_ADD_TIMEOUT_MS }) expect(WORKTREE_ADD_TIMEOUT_MS).toBeGreaterThan(0) @@ -214,7 +214,7 @@ describe('addWorktree', () => { }) const worktreeAddCall = gitExecFileAsyncMock.mock.calls.find( - ([argv]) => Array.isArray(argv) && argv[0] === 'worktree' && argv[1] === 'add' + ([argv]) => Array.isArray(argv) && argv.includes('worktree') && argv.includes('add') ) expect(worktreeAddCall?.[1]).toMatchObject({ timeout: 600_000 }) }) diff --git a/src/main/git/worktree-add-local-base-refresh.test.ts b/src/main/git/worktree-add-local-base-refresh.test.ts index dba4303647c..1ba2e6c16b8 100644 --- a/src/main/git/worktree-add-local-base-refresh.test.ts +++ b/src/main/git/worktree-add-local-base-refresh.test.ts @@ -1,5 +1,5 @@ // addWorktree: fast-forwarding the local base ref (reset --hard / update-ref) and its safety bailouts. -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { gitExecFileAsyncMock, @@ -32,11 +32,14 @@ import { registerWorktreeSuiteHooks } from './worktree-test-harness' registerWorktreeSuiteHooks() describe('addWorktree', () => { + afterEach(() => vi.restoreAllMocks()) const resolveCreationBaseConfigWrite = () => { gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch..base } beforeEach(() => { + // These branch-safety assertions use POSIX argv; Windows flags have separate coverage. + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') gitExecFileAsyncMock.mockReset() gitExecFileSyncMock.mockReset() translateWslOutputPathsMock.mockClear() diff --git a/src/main/git/worktree-add-local-base-suggestion.test.ts b/src/main/git/worktree-add-local-base-suggestion.test.ts index a65f77dd77d..3b449c339f6 100644 --- a/src/main/git/worktree-add-local-base-suggestion.test.ts +++ b/src/main/git/worktree-add-local-base-suggestion.test.ts @@ -1,5 +1,5 @@ // addWorktree: advisory local-base-ref update suggestions when the refresh setting is off. -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { gitExecFileAsyncMock, @@ -32,7 +32,10 @@ import { registerWorktreeSuiteHooks } from './worktree-test-harness' registerWorktreeSuiteHooks() describe('addWorktree', () => { + afterEach(() => vi.restoreAllMocks()) beforeEach(() => { + // These branch-safety assertions use POSIX argv; Windows flags have separate coverage. + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') gitExecFileAsyncMock.mockReset() gitExecFileSyncMock.mockReset() translateWslOutputPathsMock.mockClear() diff --git a/src/main/git/worktree-add.ts b/src/main/git/worktree-add.ts index ea6ec704b46..380f3a3cc34 100644 --- a/src/main/git/worktree-add.ts +++ b/src/main/git/worktree-add.ts @@ -12,7 +12,7 @@ import { getLocalBaseRefUpdateSuggestionForWorktreeCreate, refreshLocalBaseRefForWorktreeCreate } from './worktree-base-refresh' -import { hasWorktreeBaseCommitRef } from './worktree-base-ref-probe' +import { resolveWorktreeBaseCommitOid } from './worktree-base-ref-probe' import type { AddWorktreeOptions, AddWorktreeResult, @@ -23,6 +23,7 @@ import { bumpWorktreeScanGeneration } from './worktree-scan-cache' export type WorktreeAddBaseContext = AddWorktreeResult & { effectiveBase: string + effectiveBaseOid?: string } export async function resolveWorktreeAddBaseContext( @@ -31,9 +32,11 @@ export async function resolveWorktreeAddBaseContext( refreshLocalBaseRef: boolean, options: AddWorktreeOptions ): Promise { - const effectiveBase = await resolveWorktreeAddBaseRef(baseBranch, (qualifiedRef) => - hasWorktreeBaseCommitRef(repoPath, qualifiedRef, options) - ) + let effectiveBaseOid: string | null = null + const effectiveBase = await resolveWorktreeAddBaseRef(baseBranch, async (qualifiedRef) => { + effectiveBaseOid = await resolveWorktreeBaseCommitOid(repoPath, qualifiedRef, options) + return effectiveBaseOid !== null + }) const localBaseRefRefresh = refreshLocalBaseRef ? await refreshLocalBaseRefForWorktreeCreate( repoPath, @@ -55,6 +58,10 @@ export async function resolveWorktreeAddBaseContext( : undefined return { effectiveBase, + // Refresh/suggestion work can span ref changes; only reuse the immediate resolution probe. + ...(!refreshLocalBaseRef && !options.suggestLocalBaseRefUpdate && effectiveBaseOid + ? { effectiveBaseOid } + : {}), ...(localBaseRefRefresh ? { localBaseRefRefresh } : {}), ...(localBaseRefUpdateSuggestion ? { localBaseRefUpdateSuggestion } : {}) } diff --git a/src/main/git/worktree-create-preparation-real-wsl.test.ts b/src/main/git/worktree-create-preparation-real-wsl.test.ts new file mode 100644 index 00000000000..8ccaf3c23bf --- /dev/null +++ b/src/main/git/worktree-create-preparation-real-wsl.test.ts @@ -0,0 +1,77 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { expect, it } from 'vitest' +import { createWorktreePreparationLockReason } from '../../shared/worktree/create-preparation' +import { gitExecFileAsync } from './runner' +import { + discardPreparedWorktree, + finalizePreparedWorktree, + prepareWorktreeCreateCheckout +} from './worktree-create-preparation' + +// Opt in on Windows with a running distro; all Git commands use the production WSL router. +const wslDistro = process.env.ORCA_TEST_WSL_DISTRO + +it.skipIf(process.platform !== 'win32' || !wslDistro)( + 'prepares, retargets, moves and cleans up a real WSL checkout from Windows', + async () => { + const fixtureParent = process.env.ORCA_TEST_WSL_ROOT ?? `\\\\wsl.localhost\\${wslDistro}\\tmp` + const root = await mkdtemp(join(fixtureParent, 'orca-create-route-')) + const repoPath = join(root, 'repo') + const preparedPath = join(root, 'prepared checkout') + const finalPath = join(root, 'final checkout') + const options = { wslDistro, timeout: 60_000 } + const git = async (cwd: string, args: string[]): Promise => + (await gitExecFileAsync(args, { cwd, ...options })).stdout.trim() + + try { + await mkdir(repoPath) + await git(repoPath, ['init', '--quiet']) + expect(await git(repoPath, ['rev-parse', '--show-toplevel'])).toMatch(/^\/(?!\/)/) + await git(repoPath, ['symbolic-ref', 'HEAD', 'refs/heads/main']) + await git(repoPath, ['config', 'user.name', 'Test User']) + await git(repoPath, ['config', 'user.email', 'test@example.com']) + await writeFile(join(repoPath, 'version.txt'), 'one\n') + await git(repoPath, ['add', 'version.txt']) + await git(repoPath, ['commit', '--quiet', '-m', 'initial']) + await prepareWorktreeCreateCheckout( + repoPath, + preparedPath, + 'main', + createWorktreePreparationLockReason('real-wsl-test'), + options + ) + expect(await git(repoPath, ['worktree', 'list', '--porcelain'])).toContain( + 'locked orca-create-preparation:v1:' + ) + + await writeFile(join(repoPath, 'version.txt'), 'two\n') + await git(repoPath, ['commit', '--quiet', '-am', 'advance base']) + const target = await git(repoPath, ['rev-parse', 'HEAD']) + await finalizePreparedWorktree( + repoPath, + preparedPath, + finalPath, + 'feature/routed', + 'main', + false, + options + ) + expect(await git(finalPath, ['rev-parse', 'HEAD'])).toBe(target) + expect(await git(finalPath, ['symbolic-ref', '--short', 'HEAD'])).toBe('feature/routed') + expect(await git(finalPath, ['status', '--porcelain'])).toBe('') + expect(await readFile(join(finalPath, 'version.txt'), 'utf8')).toBe('two\n') + expect(await git(finalPath, ['config', '--get', 'branch.feature/routed.base'])).toBe( + 'refs/heads/main' + ) + expect(await git(repoPath, ['worktree', 'list', '--porcelain'])).not.toContain('locked ') + await discardPreparedWorktree(repoPath, finalPath, options) + expect( + (await git(repoPath, ['worktree', 'list', '--porcelain'])).match(/^worktree /gm) + ).toHaveLength(1) + } finally { + await rm(root, { recursive: true, force: true }) + } + }, + 120_000 +) diff --git a/src/main/git/worktree-create-preparation.ts b/src/main/git/worktree-create-preparation.ts index b60dc01ec33..78713957690 100644 --- a/src/main/git/worktree-create-preparation.ts +++ b/src/main/git/worktree-create-preparation.ts @@ -195,25 +195,38 @@ export async function finalizePreparedWorktree( } try { return await runWithGitReadCacheInvalidation(async () => { - const baseContext = await resolveWorktreeAddBaseContext( - repoPath, - baseBranch, - refreshLocalBaseRef, - finalizeGitOptions - ) - const [targetHeadResult, preparedHeadResult] = await Promise.all([ - gitExecFileAsync( - ['rev-parse', '--verify', `${baseContext.effectiveBase}^{commit}`], - gitExecOptions(repoPath, finalizeGitOptions) - ), + const [targetResult, preparedResult] = await Promise.allSettled([ + (async () => { + const baseContext = await resolveWorktreeAddBaseContext( + repoPath, + baseBranch, + refreshLocalBaseRef, + finalizeGitOptions + ) + const targetHead = + baseContext.effectiveBaseOid ?? + ( + await gitExecFileAsync( + ['rev-parse', '--verify', `${baseContext.effectiveBase}^{commit}`], + gitExecOptions(repoPath, finalizeGitOptions) + ) + ).stdout.trim() + return { baseContext, targetHead } + })(), gitExecFileAsync( ['rev-parse', '--verify', 'HEAD'], gitExecOptions(preparedPath, finalizeGitOptions) ) ]) - const { stdout: targetHeadOutput } = targetHeadResult - const targetHead = targetHeadOutput.trim() - const { stdout: preparedHeadOutput } = preparedHeadResult + // Settle both reads before failure cleanup can remove the prepared checkout. + if (targetResult.status === 'rejected') { + throw targetResult.reason + } + if (preparedResult.status === 'rejected') { + throw preparedResult.reason + } + const { baseContext, targetHead } = targetResult.value + const preparedHeadOutput = preparedResult.value.stdout if (preparedHeadOutput.trim() !== targetHead) { await gitExecFileAsync( [...windowsLongPathGitArgs(preparedPath), 'reset', '--hard', targetHead], diff --git a/src/main/git/worktree-preparation-base-oid.test.ts b/src/main/git/worktree-preparation-base-oid.test.ts new file mode 100644 index 00000000000..5864b9d305a --- /dev/null +++ b/src/main/git/worktree-preparation-base-oid.test.ts @@ -0,0 +1,98 @@ +import { beforeEach, expect, it, vi } from 'vitest' + +const gitExec = vi.hoisted(() => vi.fn()) +vi.mock('./runner', () => ({ gitExecFileAsync: gitExec })) +vi.mock('./worktree-base-refresh', () => ({ + refreshLocalBaseRefForWorktreeCreate: vi.fn(), + getLocalBaseRefUpdateSuggestionForWorktreeCreate: vi.fn() +})) +vi.mock('./status', () => ({ runWithGitReadCacheInvalidation: (run: () => unknown) => run() })) +vi.mock('./wsl-linked-worktree-git-routing', () => ({ + invalidateWslLinkedWorktreeGitRouting: vi.fn() +})) + +import { finalizePreparedWorktree } from './worktree-create-preparation' + +const originalOid = '1'.repeat(40) +const refreshedOid = '2'.repeat(40) + +beforeEach(() => { + gitExec.mockReset().mockImplementation(async (args: string[]) => ({ + stdout: + args[0] === 'rev-parse' + ? args.includes('--quiet') || args.at(-1) === 'HEAD' + ? originalOid + : refreshedOid + : '' + })) +}) + +it('reuses the current base-resolution oid and preserves WSL routing', async () => { + await finalizePreparedWorktree('/repo', '/prepared', '/final', 'feature', 'main', false, { + wslDistro: 'Ubuntu', + timeout: 8000 + }) + const revisions = gitExec.mock.calls.filter(([args]) => args[0] === 'rev-parse') + expect(revisions.map(([args]) => args)).toEqual([ + ['rev-parse', '--verify', '--quiet', 'refs/heads/main^{commit}'], + ['rev-parse', '--verify', 'HEAD'] + ]) + expect(gitExec.mock.calls.find(([args]) => args.includes('checkout'))?.[0]).toContain(originalOid) + expect(gitExec.mock.calls.some(([args]) => args.includes('reset'))).toBe(false) + for (const [, options] of gitExec.mock.calls) { + expect(options).toMatchObject({ wslDistro: 'Ubuntu', timeout: 8000 }) + } +}) + +it.each([ + { base: 'refs/heads/main', refresh: false, options: {} }, + { base: 'main', refresh: true, options: {} }, + { base: 'main', refresh: false, options: { suggestLocalBaseRefUpdate: true } } +])('re-reads the target for $base, refresh=$refresh, options=$options', async (test) => { + await finalizePreparedWorktree( + '/repo', + '/prepared', + '/final', + 'feature', + test.base, + test.refresh, + test.options + ) + expect(gitExec).toHaveBeenCalledWith( + ['rev-parse', '--verify', 'refs/heads/main^{commit}'], + expect.objectContaining({ cwd: '/repo' }) + ) + expect(gitExec.mock.calls.find(([args]) => args.includes('reset'))?.[0]).toContain(refreshedOid) + expect(gitExec.mock.calls.find(([args]) => args.includes('checkout'))?.[0]).toContain( + refreshedOid + ) +}) + +it('starts both independent probes before either resolves and settles them before failure', async () => { + let resolveBase!: (value: { stdout: string }) => void + let rejectPrepared!: (reason: Error) => void + gitExec.mockImplementation((args: string[]) => { + if (args.includes('--quiet')) { + return new Promise((resolve) => (resolveBase = resolve)) + } + if (args.at(-1) === 'HEAD') { + return new Promise((_, reject) => (rejectPrepared = reject)) + } + return Promise.resolve({ stdout: '' }) + }) + let settled = false + const error = new Error('prepared HEAD unreadable') + const result = finalizePreparedWorktree('/repo', '/prepared', '/final', 'feature', 'main') + const checked = expect(result).rejects.toBe(error) + void result.then( + () => (settled = true), + () => (settled = true) + ) + await vi.waitFor(() => expect(gitExec).toHaveBeenCalledTimes(2)) + rejectPrepared(error) + await Promise.resolve() + expect(settled).toBe(false) + resolveBase({ stdout: originalOid }) + await checked + expect(gitExec.mock.calls.some(([args]) => args.includes('move'))).toBe(false) +}) diff --git a/src/main/ipc/worktree-logic-wsl.test.ts b/src/main/ipc/worktree-logic-wsl.test.ts index c30c387263e..c21c20e0050 100644 --- a/src/main/ipc/worktree-logic-wsl.test.ts +++ b/src/main/ipc/worktree-logic-wsl.test.ts @@ -16,6 +16,7 @@ vi.mock('../wsl', () => ({ import { computeWorktreePath, computeWorktreePathAsync, + computeWorkspaceRootAsync, getWorktreePathSettings } from './worktree-logic' import { @@ -32,6 +33,26 @@ describe('computeWorktreePath WSL layout', () => { parseWslPathMock.mockReset() }) + it('reuses an asynchronously resolved root for every name candidate without a sync probe', async () => { + parseWslPathMock.mockReturnValue({ distro: 'Ubuntu', linuxPath: '/home/jin/repo' }) + const repoPath = String.raw`\\wsl.localhost\Ubuntu\home\jin\repo` + const home = String.raw`\\wsl.localhost\Ubuntu\home\jin` + const settings = { workspaceDir: 'C:\\workspaces', nestWorkspaces: true } + let resolveHome!: (home: string) => void + getWslHomeAsyncMock.mockReturnValue(new Promise((resolve) => (resolveHome = resolve))) + const pendingRoot = computeWorkspaceRootAsync(repoPath, settings) + expect(getWslHomeMock).not.toHaveBeenCalled() + resolveHome(home) + const root = await pendingRoot + for (const name of ['feature', 'feature-2', 'feature-3']) { + expect(computeWorktreePath(name, repoPath, settings, root)).toBe( + win32.join(home, 'orca', 'workspaces', 'repo', name) + ) + } + expect(getWslHomeAsyncMock).toHaveBeenCalledExactlyOnceWith('Ubuntu') + expect(getWslHomeMock).not.toHaveBeenCalled() + }) + it('places WSL repo worktrees under the distro home workspace root', () => { parseWslPathMock.mockReturnValue({ distro: 'Ubuntu', diff --git a/src/main/ipc/worktree-logic.ts b/src/main/ipc/worktree-logic.ts index 7a8fe175c89..744572f6e37 100644 --- a/src/main/ipc/worktree-logic.ts +++ b/src/main/ipc/worktree-logic.ts @@ -103,12 +103,13 @@ export function ensurePathWithinWorkspace(targetPath: string, workspaceDir: stri export function computeWorktreePath( sanitizedName: string, repoPath: string, - settings: WorktreePathSettings + settings: WorktreePathSettings, + workspaceRoot?: string ): string { return computeWorktreePathFromWorkspaceRoot( sanitizedName, repoPath, - computeWorkspaceRoot(repoPath, settings), + workspaceRoot ?? computeWorkspaceRoot(repoPath, settings), settings.nestWorkspaces ) } @@ -130,7 +131,7 @@ function computeWorktreePathFromWorkspaceRoot( } /** Async twin of computeWorktreePath. Same result; resolves the WSL home without blocking the main - * thread, so callers off the create path never freeze the app on a stopped distro. */ + * thread, so callers never freeze the app on a stopped distro. */ export async function computeWorktreePathAsync( sanitizedName: string, repoPath: string, @@ -147,7 +148,7 @@ export async function computeWorktreePathAsync( /** Async twin of computeWorkspaceRoot. Same result; the WSL home probe spawns `wsl.exe`, so * background preparation uses this variant rather than blocking the Electron main thread for up * to the probe timeout. The sync twin below still serves callers that cannot await (allowed-roots - * resolution, the create click, CLI create, watch targets, worktree trash). */ + * resolution, CLI create, watch targets, worktree trash). */ export async function computeWorkspaceRootAsync( repoPath: string, settings: { workspaceDir: string; wslMirrorDistro?: string } diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 27eaa9264db..ef65f2fcb53 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -87,7 +87,7 @@ import { computeValidatedBranchName, computeWorktreePath, computeRemoteWorktreePath, - computeWorkspaceRoot, + computeWorkspaceRootAsync, ensurePathWithinWorkspace, getWorktreeCreationLayout, getWorktreePathSettings, @@ -2444,7 +2444,7 @@ export async function createLocalWorktree( emitCreateWorktreeProgress(mainWindow, 'fetching', args.creationId) } } - const workspaceRoot = computeWorkspaceRoot(repo.path, worktreePathSettings) + const workspaceRoot = await computeWorkspaceRootAsync(repo.path, worktreePathSettings) // Why: this validation doesn't depend on remote refs, so it can overlap a required remote-tracking base refresh. const primarySetupScript = getEffectiveHooks(repo)?.scripts.setup @@ -2530,19 +2530,33 @@ export async function createLocalWorktree( username, localWorktreeGitOptions ) - checkoutExistingBranch = await canCheckoutExistingLocalBranch( - repo.path, - branchName, - baseBranch, - localWorktreeGitOptions - ) - if (checkoutExistingBranch && !selectedExistingLocalBranchName) { - // Why: suffix retries may need a new path, but an existing-branch checkout must keep the user-selected branch, not a sibling. - selectedExistingLocalBranchName = branchName + const tryExistingBranch = async (): Promise => { + checkoutExistingBranch = await canCheckoutExistingLocalBranch( + repo.path, + branchName, + baseBranch, + localWorktreeGitOptions + ) + return checkoutExistingBranch } + // Explicit branch selections retain the adoption-first path. + const preferExistingBranch = Boolean( + args.branchNameOverride || selectedExistingLocalBranchName + ) + checkoutExistingBranch = preferExistingBranch && (await tryExistingBranch()) lastBranchConflictKind = checkoutExistingBranch ? null - : await getBranchConflictKind(repo.path, branchName, baseBranch, localWorktreeGitOptions) + : await getBranchConflictKind( + repo.path, + branchName, + baseBranch, + localWorktreeGitOptions, + preferExistingBranch ? undefined : tryExistingBranch + ) + if (checkoutExistingBranch && !selectedExistingLocalBranchName) { + // Path retries must retain the adopted branch. + selectedExistingLocalBranchName = branchName + } const allowedPushTargetRemoteConflict = lastBranchConflictKind && isAllowedPushTargetRemoteConflict(lastBranchConflictKind, branchName, args) @@ -2604,7 +2618,7 @@ export async function createLocalWorktree( } worktreePath = ensurePathWithinWorkspace( - computeWorktreePath(effectiveSanitizedName, repo.path, worktreePathSettings), + computeWorktreePath(effectiveSanitizedName, repo.path, worktreePathSettings, workspaceRoot), workspaceRoot ) if (existsSync(worktreePath)) { @@ -3010,6 +3024,8 @@ export async function createLocalWorktree( } }) + // Startup resolves the new id before lifecycle notifications invalidate runtime caches. + runtime?.invalidateWorktreeCatalog?.(repo.id) const stagedStartup = await timing.time('spawn_startup_terminal', () => spawnLocalStartupAndSetupTerminals({ runtime, diff --git a/src/main/ipc/worktrees-local-create-flow.test.ts b/src/main/ipc/worktrees-local-create-flow.test.ts index abc711bbd9b..fc57c6969b6 100644 --- a/src/main/ipc/worktrees-local-create-flow.test.ts +++ b/src/main/ipc/worktrees-local-create-flow.test.ts @@ -2,6 +2,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { resolve } from 'node:path' import type { CreateWorktreeResult } from '../../shared/worktree/create-types' import { resolveRegisteredWorktreePath } from './registered-worktree-roots-cache' +import { computeWorkspaceRootAsync } from './worktree-logic' +import type * as WorktreeLogic from './worktree-logic' import { listWorktreesMock, describeCreatedWorktreeMock, @@ -78,11 +80,13 @@ vi.mock('../setup-hook-env-vars', async (importOriginal) => (await importOriginal()) as Record ) ) -vi.mock('./worktree-logic', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).worktreeLogicModuleMock( - (await importOriginal()) as Record - ) -) +vi.mock('./worktree-logic', async (importOriginal) => { + const actual = await importOriginal() + return { + ...(await import('./worktrees-test-module-mocks')).worktreeLogicModuleMock(actual), + computeWorkspaceRootAsync: vi.fn(actual.computeWorkspaceRootAsync) + } +}) vi.mock('../terminal-history-deletion', async () => (await import('./worktrees-test-module-mocks')).terminalHistoryDeletionModuleMock() ) @@ -409,15 +413,23 @@ describe('registerWorktreeHandlers', () => { } ]) - await handlers['worktrees:create'](null, { + const root = Promise.withResolvers() + vi.mocked(computeWorkspaceRootAsync).mockReturnValueOnce(root.promise) + const create = handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'feature' }) - expect(computeWorktreePathMock).toHaveBeenCalledWith('feature', '/workspace/repo', { - nestWorkspaces: false, - workspaceDir: '../worktrees' - }) + await vi.waitFor(() => expect(computeWorkspaceRootAsync).toHaveBeenCalled()) + expect(addWorktreeMock).not.toHaveBeenCalled() + root.resolve('/workspace/worktrees') + await create + expect(computeWorktreePathMock).toHaveBeenCalledWith( + 'feature', + '/workspace/repo', + { nestWorkspaces: false, workspaceDir: '../worktrees' }, + '/workspace/worktrees' + ) expect(addWorktreeMock).toHaveBeenCalledWith( '/workspace/repo', '../worktrees/feature', @@ -689,6 +701,10 @@ describe('registerWorktreeHandlers', () => { expect(setupCommand).toBe('bash /workspace/repo/.git/orca/setup-runner.sh') expect(result.setup).toBeUndefined() expect(result.startupTerminal).toEqual({ spawned: true, surface: 'visible' }) + expect(runtimeStub.invalidateWorktreeCatalog).toHaveBeenCalledWith('repo-1') + expect(runtimeStub.invalidateWorktreeCatalog.mock.invocationCallOrder[0]).toBeLessThan( + runtimeStub.createTerminal.mock.invocationCallOrder[0] + ) expect(result.timing?.phases.map((phase) => phase.phase)).toEqual( expect.arrayContaining([ 'git_worktree_add', diff --git a/src/main/ipc/worktrees-test-module-mocks.ts b/src/main/ipc/worktrees-test-module-mocks.ts index a1925d1ce72..8d2787fcf2f 100644 --- a/src/main/ipc/worktrees-test-module-mocks.ts +++ b/src/main/ipc/worktrees-test-module-mocks.ts @@ -1,4 +1,5 @@ import { type Mock, vi } from 'vitest' +import type { computeWorktreePath } from './worktree-logic' import type { HandlerMap } from './worktrees-test-ipc-surface' /** Loose signature: one mock stands in for many unrelated module exports. */ @@ -78,13 +79,7 @@ export const resolveSetupRunnerShellMock: ModuleMock = vi.fn() export const runHookMock: ModuleMock = vi.fn() export const hasHooksFileMock: ModuleMock = vi.fn() export const loadHooksMock: ModuleMock = vi.fn() -export const computeWorktreePathMock: Mock< - ( - sanitizedName: string, - repoPath: string, - settings: { nestWorkspaces: boolean; workspaceDir: string } - ) => string -> = vi.fn() +export const computeWorktreePathMock: Mock = vi.fn() export const ensurePathWithinWorkspaceMock: StringArgMock = vi.fn() export const gitExecFileAsyncMock: GitArgvMock = vi.fn() export const getSshGitProviderMock: StringArgMock = vi.fn() @@ -138,7 +133,19 @@ export const gitRepoModuleMock = () => ({ resolveDefaultBaseRefWithLocalGit: resolveDefaultBaseRefWithLocalGitMock, resolveDefaultBaseRefViaExec: resolveDefaultBaseRefViaExecMock, getDefaultRemote: getDefaultRemoteMock, - getBranchConflictKind: getBranchConflictKindMock + getBranchConflictKind: async ( + repoPath: string, + branch: string, + base?: string, + options?: { wslDistro?: string }, + allowLocalBranch?: () => Promise + ) => { + // These handler tests stub ref presence; policy tests cover the absent-ref fast path. + if (allowLocalBranch && (await allowLocalBranch())) { + return null + } + return getBranchConflictKindMock(repoPath, branch, base, options) + } }) export const githubClientModuleMock = () => ({ diff --git a/src/main/ipc/worktrees-test-runtime-stub.ts b/src/main/ipc/worktrees-test-runtime-stub.ts index 647d4801d13..bb2f33cb05e 100644 --- a/src/main/ipc/worktrees-test-runtime-stub.ts +++ b/src/main/ipc/worktrees-test-runtime-stub.ts @@ -12,6 +12,7 @@ export type WorktreeRuntimeStub = { clearOptimisticReconcileToken: ReturnType resolveManagedMrBase: ReturnType createTerminal: ReturnType + invalidateWorktreeCatalog: ReturnType splitTerminal: ReturnType notifyWorktreesChangedForRemoteClients: ReturnType closeFileWatchersForRemoval: ReturnType @@ -38,6 +39,7 @@ export function createWorktreeRuntimeStub(): WorktreeRuntimeStub { title: null, surface: 'visible' }), + invalidateWorktreeCatalog: vi.fn(), splitTerminal: vi.fn().mockResolvedValue({ handle: 'term-setup', tabId: 'tab-startup', diff --git a/src/main/providers/local-pty-provider-spawn-session.test.ts b/src/main/providers/local-pty-provider-spawn-session.test.ts index 7513d9c72cb..9dfa08d81bf 100644 --- a/src/main/providers/local-pty-provider-spawn-session.test.ts +++ b/src/main/providers/local-pty-provider-spawn-session.test.ts @@ -172,6 +172,7 @@ describe('LocalPtyProvider', () => { expect(second).toEqual({ id: 'serve-session-1', + incarnationId: first.incarnationId, pid: 12345, isReattach: true, // Why published: this attach really moved the PTY, unlike daemon/relay attach, so main @@ -220,7 +221,11 @@ describe('LocalPtyProvider', () => { attachOnly: true }) - expect(result).toMatchObject({ id: first.id, isReattach: true }) + expect(result).toMatchObject({ + id: first.id, + incarnationId: first.incarnationId, + isReattach: true + }) expect(spawnMock).not.toHaveBeenCalled() }) diff --git a/src/main/providers/local-pty-spawn-state.ts b/src/main/providers/local-pty-spawn-state.ts index d41f858cf98..2ab145f6c39 100644 --- a/src/main/providers/local-pty-spawn-state.ts +++ b/src/main/providers/local-pty-spawn-state.ts @@ -1,6 +1,7 @@ import type { PtySpawnResult } from './types' import { pendingLocalPtySpawns, + ptyIncarnations, ptyProcesses, ptyWslDistroById, type PendingLocalPtySpawn @@ -60,6 +61,7 @@ export function reattachLocalPty(id: string, cols: number, rows: number): PtySpa } return { id, + ...(ptyIncarnations.has(id) ? { incarnationId: ptyIncarnations.get(id) } : {}), pid: existing.pid, ...(ptyWslDistroById.has(id) ? { wslDistro: ptyWslDistroById.get(id) ?? null } : {}), isReattach: true, diff --git a/src/main/runtime/fetch-remote-cache.test.ts b/src/main/runtime/fetch-remote-cache.test.ts index 11cd2e0260d..5f0b8e249d6 100644 --- a/src/main/runtime/fetch-remote-cache.test.ts +++ b/src/main/runtime/fetch-remote-cache.test.ts @@ -176,8 +176,17 @@ describe('OrcaRuntimeService.fetchRemoteWithCache', () => { expect(caches.fetchLastCompletedAt.has('/repo/cache-0::origin')).toBe(false) }) - it.each(['main', 'a'.repeat(40), 'refs/remotes/main', ''])( - 'does not launch Git for a base without a remote/branch separator: %s', + it.each([ + 'main', + 'a'.repeat(40), + 'refs/remotes/main', + '', + 'origin/', + '/main', + 'refs/remotes/origin/', + 'refs/remotes//main' + ])( + 'does not launch Git for a base without both remote and branch components: %s', async (base) => { const runtime = new OrcaRuntimeService(null) await expect(runtime.resolveRemoteTrackingBase('/repo/e', base)).resolves.toBeNull() diff --git a/src/main/runtime/orca-runtime-refresh-repo-worktree-scan.ts b/src/main/runtime/orca-runtime-refresh-repo-worktree-scan.ts index 1964b5fdd2f..2df3f7b9f17 100644 --- a/src/main/runtime/orca-runtime-refresh-repo-worktree-scan.ts +++ b/src/main/runtime/orca-runtime-refresh-repo-worktree-scan.ts @@ -153,6 +153,11 @@ export class OrcaRuntimeWithRefreshRepoWorktreeScan extends OrcaRuntimeWithListK } } + invalidateWorktreeCatalog(repoId: string): void { + this.invalidateResolvedWorktreeCache() + this.invalidateWorktreeScanCacheForRepo(repoId) + } + protected invalidateSshWorktreeScanCacheInternal(targetId: string): void { const repos = this.store?.getRepos() ?? [] const affectedRepos = repos.filter((repo) => getRepoSshConnectionId(repo) === targetId) diff --git a/src/main/runtime/orca-runtime-tests/local-worktree-creation-part-02.spec.ts b/src/main/runtime/orca-runtime-tests/local-worktree-creation-part-02.spec.ts index fe6e8bfe3da..a12ffabcd61 100644 --- a/src/main/runtime/orca-runtime-tests/local-worktree-creation-part-02.spec.ts +++ b/src/main/runtime/orca-runtime-tests/local-worktree-creation-part-02.spec.ts @@ -50,7 +50,13 @@ describe('OrcaRuntimeService', () => { pushTarget: { remoteName: 'origin', branchName: 'feature/fix' } }) - expect(getBranchConflictKind).toHaveBeenCalledWith(TEST_REPO_PATH, 'feature/fix', 'abc123') + expect(getBranchConflictKind).toHaveBeenCalledWith( + TEST_REPO_PATH, + 'feature/fix', + 'abc123', + {}, + undefined + ) expect(getPRForBranchMock).toHaveBeenCalledWith(TEST_REPO_PATH, 'feature/fix') expect(addWorktree).toHaveBeenCalledWith( TEST_REPO_PATH, @@ -165,7 +171,9 @@ describe('OrcaRuntimeService', () => { expect(getBranchConflictKind).toHaveBeenCalledWith( TEST_REPO_PATH, 'feature/bitbucket', - 'abc123' + 'abc123', + {}, + undefined ) expect(getHostedReviewForBranchMock).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/src/main/runtime/orca-runtime-tests/local-worktree-creation.spec.ts b/src/main/runtime/orca-runtime-tests/local-worktree-creation.spec.ts index 17fe670a09c..3dcd2d0584c 100644 --- a/src/main/runtime/orca-runtime-tests/local-worktree-creation.spec.ts +++ b/src/main/runtime/orca-runtime-tests/local-worktree-creation.spec.ts @@ -533,10 +533,14 @@ describe('OrcaRuntimeService', () => { branchNameOverride: 'feature/something' }) + // Why: an explicit branch override adopts the local branch before the conflict + // probe, so no lazy adoption callback is handed to getBranchConflictKind. expect(getBranchConflictKind).toHaveBeenCalledWith( TEST_REPO_PATH, 'feature/something', - 'origin/feature/something' + 'origin/feature/something', + {}, + undefined ) expect(addWorktree).toHaveBeenCalledWith( TEST_REPO_PATH, diff --git a/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation.spec.ts index 65da9caccde..630eaebf007 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation.spec.ts @@ -376,7 +376,18 @@ describe('OrcaRuntimeService', () => { TEST_REPO_PATH, 'runtime-wsl', 'origin/main', - { wslDistro: 'Ubuntu' } + { wslDistro: 'Ubuntu' }, + expect.any(Function) + ) + // Why: the lazy adoption callback is only invoked when the conflict probe + // sees a local ref, so drive it here to prove adoption also routes via WSL. + const adoptLocalBranch = vi + .mocked(getBranchConflictKind) + .mock.calls.findLast((call) => call[1] === 'runtime-wsl')?.[4] + await expect(adoptLocalBranch?.()).resolves.toBe(false) + expect(gitSpy).toHaveBeenCalledWith( + ['rev-parse', '--verify', '--quiet', 'refs/heads/runtime-wsl^{commit}'], + { cwd: TEST_REPO_PATH, wslDistro: 'Ubuntu' } ) expect(getPRForBranchMock).toHaveBeenCalledWith( TEST_REPO_PATH, diff --git a/src/main/runtime/runtime-local-worktree-create-candidate.ts b/src/main/runtime/runtime-local-worktree-create-candidate.ts index 6c454666148..9de955c5cd1 100644 --- a/src/main/runtime/runtime-local-worktree-create-candidate.ts +++ b/src/main/runtime/runtime-local-worktree-create-candidate.ts @@ -110,23 +110,31 @@ export async function resolveRuntimeLocalWorktreeCreateCandidate(args: { args.username, args.localWorktreeGitOptions ) - checkoutExistingBranch = await canCheckoutExistingLocalBranch( - args.repo.path, - branchName, - args.baseBranch, - ...args.localWorktreeGitOptionArgs - ) - if (checkoutExistingBranch && !selectedExistingLocalBranchName) { - selectedExistingLocalBranchName = branchName + const tryExistingBranch = async (): Promise => { + checkoutExistingBranch = await canCheckoutExistingLocalBranch( + args.repo.path, + branchName, + args.baseBranch, + ...args.localWorktreeGitOptionArgs + ) + return checkoutExistingBranch } + const preferExistingBranch = Boolean( + args.request.branchNameOverride || selectedExistingLocalBranchName + ) + checkoutExistingBranch = preferExistingBranch && (await tryExistingBranch()) branchConflictKind = checkoutExistingBranch ? null : await getBranchConflictKind( args.repo.path, branchName, args.baseBranch, - ...args.localWorktreeGitOptionArgs + args.localWorktreeGitOptions, + preferExistingBranch ? undefined : tryExistingBranch ) + if (checkoutExistingBranch && !selectedExistingLocalBranchName) { + selectedExistingLocalBranchName = branchName + } const allowedPushTargetRemoteConflict = branchConflictKind && isAllowedPushTargetRemoteConflict(branchConflictKind, branchName, args.request) diff --git a/src/main/runtime/runtime-remote-fetch-controller.ts b/src/main/runtime/runtime-remote-fetch-controller.ts index f40f25f6c82..dbcc240525b 100644 --- a/src/main/runtime/runtime-remote-fetch-controller.ts +++ b/src/main/runtime/runtime-remote-fetch-controller.ts @@ -231,7 +231,7 @@ export class RuntimeRemoteFetchController { ? baseBranch.slice(remoteRefPrefix.length) : baseBranch // A remote-tracking base needs both a configured remote and a branch component. - if (!shortBaseBranch.includes('/')) { + if (shortBaseBranch.indexOf('/') <= 0 || shortBaseBranch.endsWith('/')) { return null } let remotes: string[] diff --git a/src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts b/src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts index 3750884f84f..7dfe0144406 100644 --- a/src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts +++ b/src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts @@ -273,6 +273,22 @@ describe('worktree scan admin-fingerprint gate', () => { } }) + it('resolves a just-created id after invalidation even within both cache TTLs', async () => { + const { runtime, list } = makeRuntime() + listWorktreesStrictMock.mockResolvedValueOnce([ + { path: REPO_PATH, head: 'abc', branch: 'main', isBare: false, isMainWorktree: true } + ]) + await list() + await expect(runtime.showManagedWorktree(`id:${WORKTREE_ID}`)).rejects.toThrow( + 'selector_not_found' + ) + runtime.invalidateWorktreeCatalog(REPO_ID) + await expect(runtime.showManagedWorktree(`id:${WORKTREE_ID}`)).resolves.toMatchObject({ + id: WORKTREE_ID + }) + expect(scanCount()).toBe(2) + }) + it('scans when the probe cannot describe the repo', async () => { vi.useFakeTimers() try { diff --git a/src/renderer/src/components/terminal-pane/ipc-pty-connect-result.ts b/src/renderer/src/components/terminal-pane/ipc-pty-connect-result.ts index 3dda2f83fec..bcfc0d3f67c 100644 --- a/src/renderer/src/components/terminal-pane/ipc-pty-connect-result.ts +++ b/src/renderer/src/components/terminal-pane/ipc-pty-connect-result.ts @@ -16,6 +16,10 @@ export function projectIpcPtyConnectResult( snapshot: spawnResult.snapshot, snapshotCols: spawnResult.snapshotCols, snapshotRows: spawnResult.snapshotRows, + ...(spawnResult.snapshotSeq !== undefined ? { snapshotSeq: spawnResult.snapshotSeq } : {}), + ...(spawnResult.snapshotKittyKeyboardFlags !== undefined + ? { snapshotKittyKeyboardFlags: spawnResult.snapshotKittyKeyboardFlags } + : {}), ...(spawnResult.snapshotPrefixAnsi !== undefined ? { snapshotPrefixAnsi: spawnResult.snapshotPrefixAnsi } : {}), diff --git a/src/renderer/src/components/terminal-pane/pty-connection-deferred-reattach-live-output.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-deferred-reattach-live-output.test.ts index fa110cdfe0e..ed38f6a31a4 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-deferred-reattach-live-output.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-deferred-reattach-live-output.test.ts @@ -231,6 +231,41 @@ describe('connectPanePty', () => { expect(transport.sendInput).not.toHaveBeenCalled() }) + it.each([ + { kind: 'covered backlog', snapshot: 'startup\r\n', seq: 9, count: 1 }, + { kind: 'legacy unsequenced snapshot', snapshot: 'startup\r\n', seq: undefined, count: 2 }, + { kind: 'blank snapshot', snapshot: '\x1b[2J', seq: 9, count: 1 } + ])( + 'preserves output while reconciling $kind on daemon adoption', + async ({ snapshot, seq, count }) => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('tab-pty') + transport.connect.mockImplementation( + async ({ sessionId, callbacks }: { sessionId?: string; callbacks?: ConnectCallbacks }) => { + callbacks?.onData?.('startup\r\n', { seq: 9, rawLength: 9 }) + callbacks?.onData?.('new output\r\n', { seq: 21, rawLength: 12 }) + return { id: sessionId, snapshot, snapshotSeq: seq } + } + ) + transportFactoryQueue.push(transport) + const pane = createPane(1) + const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) + const deps = createDeps({ + isVisibleRef: { current: true }, + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + connectPanePty(pane as never, createManager(1) as never, deps as never) + await flushAsyncTicks(20) + for (let step = 0; step < 40; step += 1) { + parseCallbacks.shift()?.() + await flushAsyncTicks(2) + } + expect(writes.join('').match(/startup/g)).toHaveLength(count) + expect(writes.join('')).toContain('new output') + } + ) + it('drains live bytes after transport confirms an explicit reattach', async () => { const { connectPanePty } = await import('./pty-connection') const { deliverTerminalDataWithDeferredCredit } = diff --git a/src/renderer/src/components/terminal-pane/pty-connection/apply-reattach-payload.ts b/src/renderer/src/components/terminal-pane/pty-connection/apply-reattach-payload.ts index cb0806900af..ad64d458711 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/apply-reattach-payload.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/apply-reattach-payload.ts @@ -100,6 +100,13 @@ export function createReattachPayloadHandlers( // Why last: re-arm the dangling mid-escape after the reset (whose ESC would abort it) so the live continuation completes it (#7329). session.writeReplayData(ctx.connectResult.pendingEscapeTailAnsi) } + // The initial attach backlog can contain bytes already painted by this snapshot. + session.setRestoredSnapshotBaseline( + ctx.ptyId, + { seq: ctx.connectResult.snapshotSeq }, + restoredSnapshotPaintsPrintableContent({ data: daemonSnapshotReplay }) + ) + session.recordRendererOrderedSeq({ seq: ctx.connectResult.snapshotSeq }) session.sendFocusedReattachFocusInAfterReplay(ctx.ptyId, ctx.attemptGeneration) if (ctx.connectResult.coldRestore) { // Snapshot superseded the cold-restore payload; ack so the daemon doesn't redeliver it. diff --git a/src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts b/src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts index 66dca98b360..138059f372b 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts @@ -26,6 +26,24 @@ describe('createIpcPtyTransport', () => { restorePtySpecWindow(originalWindow) }) + it.each([0, 420])( + 'preserves snapshot sequence and keyboard proof %s across IPC reattach', + async (seq) => { + const { createIpcPtyTransport } = await import('./pty-transport') + vi.mocked(window.api.pty.spawn).mockResolvedValue({ + id: 'existing', + isReattach: true, + snapshot: 'ready', + snapshotSeq: seq, + snapshotKittyKeyboardFlags: 0 + }) + const transport = createIpcPtyTransport({}) + const result = await transport.connect({ url: '', sessionId: 'existing', callbacks: {} }) + expect(result).toMatchObject({ snapshotSeq: seq, snapshotKittyKeyboardFlags: 0 }) + transport.detach?.() + } + ) + it('leaves title tracking to the PTY data stream (no OpenCode IPC channel)', async () => { // Why: the OpenCode status IPC channel is gone (now the agent-hooks server), so the transport has no per-agent status callback. const { createIpcPtyTransport } = await import('./pty-transport') diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-manager-options.ts b/src/renderer/src/components/terminal-pane/terminal-pane-manager-options.ts index 08eab4c0cf5..128b715a3c5 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-manager-options.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-manager-options.ts @@ -1,6 +1,7 @@ import type { IDisposable } from '@xterm/xterm' import type { PaneManagerOptions } from '@/lib/pane-manager/pane-manager' import { useAppStore } from '@/store' +import { resolveTerminalLigaturesEnabled } from '../../../../shared/terminal-ligatures' import { resolveTerminalFontWeights } from '../../../../shared/terminal-fonts' import { normalizeTerminalLineHeight } from '../../../../shared/terminal-line-height-settings' import { normalizeDesktopTerminalScrollbackRows } from '../../../../shared/terminal-scrollback-policy' @@ -102,6 +103,11 @@ export function createTerminalPaneManagerOptions( }, resolveExternalPaneDropTarget, onExternalPaneDrop, + terminalLigaturesEnabled: () => + resolveTerminalLigaturesEnabled( + settingsRef.current?.terminalLigatures, + settingsRef.current?.terminalFontFamily + ), terminalOptions: () => { const currentSettings = settingsRef.current const terminalFontWeights = resolveTerminalFontWeights( diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts index 3a174253d9c..e84638c33ea 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts @@ -7,7 +7,7 @@ import { primeTerminalWebglAddon, resetTerminalWebglSuggestion } from './pane-webgl-renderer' -import { attachLigatures, disposePane, openTerminal } from './pane-lifecycle' +import { attachLigatures, disposePane, openTerminal, setLigaturesEnabled } from './pane-lifecycle' import { ensureArabicShapingJoinerForText } from './terminal-arabic-shaping-joiner' import { buildDefaultTerminalOptions, @@ -531,6 +531,7 @@ describe('openTerminal — addon and provider wiring', () => { }), attachCustomWheelEventHandler: vi.fn(), onWriteParsed: vi.fn(() => ({ dispose: vi.fn() })), + refresh: vi.fn(), write: vi.fn(() => { events.push('write') }), @@ -588,6 +589,31 @@ describe('openTerminal — addon and provider wiring', () => { // unicode v11 is activated (still on default v6 width tables), wide chars // lay out as single cells. The bug surfaces as the broken `?`-style glyphs // users saw on worktree switch. + it('builds one initial WebGL atlas with ligatures and still rebuilds on a live toggle', async () => { + await primeTerminalWebglAddon() + resetTerminalWebglSuggestion() + vi.mocked(WebglAddon).mockClear() + webglMock.dispose.mockClear() + vi.stubGlobal('navigator', { platform: 'MacIntel', userAgent: 'Macintosh' }) + const { pane } = createOpenTerminalHarness() + pane.terminalGpuAcceleration = 'auto' + pane.gpuRenderingEnabled = true + + openTerminal(pane, true) + expect(pane.ligaturesAddon).not.toBeNull() + expect(pane.webglAddon).not.toBeNull() + const addons = vi.mocked(pane.terminal.loadAddon).mock.calls.map(([addon]) => addon) + expect(addons.indexOf(pane.ligaturesAddon!)).toBeLessThan(addons.indexOf(pane.webglAddon!)) + setLigaturesEnabled(pane, true) + expect(WebglAddon).toHaveBeenCalledTimes(1) + expect(webglMock.dispose).not.toHaveBeenCalled() + + setLigaturesEnabled(pane, false) + expect(WebglAddon).toHaveBeenCalledTimes(2) + expect(webglMock.dispose).toHaveBeenCalledTimes(1) + expect(pane.ligaturesAddon).toBeNull() + }) + it('activates unicode 11 before any caller-driven write would be possible', () => { const { pane, events } = createOpenTerminalHarness() diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts index 3c89a6ec723..cf4b50783d1 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts @@ -28,7 +28,7 @@ import { installTerminalImeCandidateAnchor } from './terminal-ime-candidate-anch export { createPaneDOM } from './pane-dom-creation' /** Open terminal into its container and load addons. Must be called after the container is in the DOM. */ -export function openTerminal(pane: ManagedPaneInternal): void { +export function openTerminal(pane: ManagedPaneInternal, ligaturesEnabled = false): void { const { terminal, container, @@ -100,6 +100,10 @@ export function openTerminal(pane: ManagedPaneInternal): void { pane.focusClassSyncCleanup = attachDomRendererFocusClassSync(terminal.element) + // Configure the first atlas with ligatures instead of immediately rebuilding it. + if (ligaturesEnabled) { + attachLigatures(pane) + } if (pane.gpuRenderingEnabled) { attachWebgl(pane) } diff --git a/src/renderer/src/lib/pane-manager/pane-manager-pane-creation.ts b/src/renderer/src/lib/pane-manager/pane-manager-pane-creation.ts index 483f49a1f55..c4c3a28c4ca 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager-pane-creation.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager-pane-creation.ts @@ -18,7 +18,7 @@ export function createInitialManagedPane( overflow: 'hidden' }) host.root.appendChild(pane.container) - openTerminal(pane) + openTerminal(pane, host.options.terminalLigaturesEnabled?.()) host.setActivePaneId(pane.id) applyPaneOpacity(host.panes.values(), host.getActivePaneId(), host.getStyleOptions()) diff --git a/src/renderer/src/lib/pane-manager/pane-manager-types.ts b/src/renderer/src/lib/pane-manager/pane-manager-types.ts index 7b23c177236..00637ae7f97 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager-types.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager-types.ts @@ -63,6 +63,7 @@ export type PaneManagerOptions = { resolveExternalPaneDropTarget?: PaneExternalDropResolver onExternalPaneDrop?: PaneExternalDropHandler terminalOptions?: (paneId: number) => Partial + terminalLigaturesEnabled?: () => boolean terminalTuiScrollSensitivity?: () => number | undefined onLinkClick?: (paneId: number, event: MouseEvent | undefined, url: string) => void /** Resolved per hover so link-routing setting changes apply without recreating panes. */ diff --git a/src/renderer/src/lib/pane-manager/pane-split-close.ts b/src/renderer/src/lib/pane-manager/pane-split-close.ts index df725156655..c5c71b03be6 100644 --- a/src/renderer/src/lib/pane-manager/pane-split-close.ts +++ b/src/renderer/src/lib/pane-manager/pane-split-close.ts @@ -141,7 +141,7 @@ function openSplitPane( newPane: ManagedPaneInternal, cwd?: string ): void { - openTerminal(newPane) + openTerminal(newPane, args.managerOptions.terminalLigaturesEnabled?.()) applyPaneOpacity(args.panes.values(), newPane.id, args.styleOptions) applyDividerStyles(args.root, args.styleOptions) newPane.terminal.focus() diff --git a/src/shared/git-binary-compatibility.test.ts b/src/shared/git-binary-compatibility.test.ts index 6387f200b4c..fb8161b9f90 100644 --- a/src/shared/git-binary-compatibility.test.ts +++ b/src/shared/git-binary-compatibility.test.ts @@ -111,6 +111,33 @@ describeBinaryCompatibility('real Git binary compatibility', () => { } }) + it('quietly distinguishes present and absent branch refs', async () => { + const head = (await runGit(['rev-parse', 'HEAD'])).stdout.trim() + await runGit(['branch', 'quiet-probe-present', head]) + await expect( + runGit(['rev-parse', '--verify', '--quiet', 'refs/heads/quiet-probe-present']) + ).resolves.toMatchObject({ stdout: `${head}\n`, stderr: '' }) + await expect( + runGit(['rev-parse', '--verify', '--quiet', 'refs/heads/quiet-probe-absent']) + ).rejects.toMatchObject({ code: 1, stdout: '', stderr: '' }) + }) + + it('distinguishes an absent branch from a ref pointing at a missing object', async () => { + const missingObject = 'a'.repeat(40) + const refPath = join(repoPath, '.git', 'refs', 'heads', 'quiet-probe-dangling') + await writeFile(refPath, `${missingObject}\n`) + try { + await expect( + runGit(['rev-parse', '--verify', '--quiet', 'refs/heads/quiet-probe-dangling']) + ).resolves.toMatchObject({ stdout: `${missingObject}\n`, stderr: '' }) + await expect( + runGit(['rev-parse', '--verify', '--quiet', 'refs/heads/quiet-probe-dangling^{commit}']) + ).rejects.toMatchObject({ code: 1, stdout: '', stderr: '' }) + } finally { + await rm(refPath) + } + }) + it('recognizes worktree-list and rev-parse compatibility boundaries', async () => { await expectPreferredOrRecognizedFallback( ['worktree', 'list', '--porcelain', '-z'], diff --git a/tests/e2e/helpers/electron-crashpad-cleanup.ts b/tests/e2e/helpers/electron-crashpad-cleanup.ts new file mode 100644 index 00000000000..8ec1a98b26b --- /dev/null +++ b/tests/e2e/helpers/electron-crashpad-cleanup.ts @@ -0,0 +1,46 @@ +import { execFileSync } from 'node:child_process' +import path from 'node:path' + +function ownsCrashpad(command: string, userDataDir: string): boolean { + return ( + command.includes('/chrome_crashpad_handler ') && + command.includes(` --database=${path.join(userDataDir, 'Crashpad')} `) + ) +} + +export function cleanupE2ECrashpad(userDataDir: string): void { + if (process.platform !== 'darwin') { + return + } + + // macOS reparents Crashpad before app exit; its inherited stderr can keep Playwright open. + try { + const table = execFileSync('ps', ['-axo', 'pid=,command='], { + encoding: 'utf8', + timeout: 5_000 + }) + for (const row of table.split('\n')) { + const match = row.match(/^\s*(\d+)\s+(.+)$/) + if (!match || !ownsCrashpad(match[2], userDataDir)) { + continue + } + const pid = Number(match[1]) + if (!Number.isSafeInteger(pid) || pid <= 1) { + continue + } + try { + const command = execFileSync('ps', ['-p', String(pid), '-o', 'command='], { + encoding: 'utf8', + timeout: 5_000 + }) + if (ownsCrashpad(command, userDataDir)) { + process.kill(pid, 'SIGTERM') + } + } catch { + // The test-owned reporter may already have exited. + } + } + } catch { + // Cleanup remains best-effort when process enumeration is unavailable. + } +} diff --git a/tests/e2e/helpers/electron-crashpad-cleanup.unit.test.ts b/tests/e2e/helpers/electron-crashpad-cleanup.unit.test.ts new file mode 100644 index 00000000000..cdf552e2548 --- /dev/null +++ b/tests/e2e/helpers/electron-crashpad-cleanup.unit.test.ts @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { execFileSync } from 'node:child_process' +import path from 'node:path' +import { cleanupE2ECrashpad } from './electron-crashpad-cleanup' + +vi.mock('node:child_process', () => ({ execFileSync: vi.fn() })) + +const profile = '/tmp/test profile' +const database = path.join(profile, 'Crashpad') +const reporter = `/Electron Framework/Helpers/chrome_crashpad_handler --database=${database} --annotation=prod=Electron` + +afterEach(() => vi.restoreAllMocks()) + +describe('test-owned macOS Crashpad cleanup', () => { + it('terminates only the reporter for the exact temporary profile after rechecking ownership', () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + const kill = vi.spyOn(process, 'kill').mockReturnValue(true) + vi.mocked(execFileSync) + .mockReturnValueOnce( + `111 ${reporter}\n222 ${reporter.replace('Crashpad ', 'Crashpad-old ')}\n333 ${reporter.replace('test profile', 'another profile')}\n444 /bin/echo --database=${database} \n` + ) + .mockReturnValueOnce(reporter) + cleanupE2ECrashpad(profile) + expect(kill).toHaveBeenCalledExactlyOnceWith(111, 'SIGTERM') + expect(execFileSync).toHaveBeenLastCalledWith('ps', ['-p', '111', '-o', 'command='], { + encoding: 'utf8', + timeout: 5_000 + }) + }) + + it('does not signal a PID whose ownership changed after enumeration', () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + const kill = vi.spyOn(process, 'kill').mockReturnValue(true) + vi.mocked(execFileSync).mockReturnValueOnce(`111 ${reporter}`).mockReturnValueOnce('/bin/sh') + cleanupE2ECrashpad(profile) + expect(kill).not.toHaveBeenCalled() + }) + + it.each(['win32', 'linux'] as const)('does not enumerate processes on %s', (platform) => { + vi.spyOn(process, 'platform', 'get').mockReturnValue(platform) + vi.mocked(execFileSync).mockClear() + cleanupE2ECrashpad(profile) + expect(execFileSync).not.toHaveBeenCalled() + }) +}) diff --git a/tests/e2e/helpers/electron-process-shutdown.ts b/tests/e2e/helpers/electron-process-shutdown.ts index 48ddb60bf43..f9b642a676e 100644 --- a/tests/e2e/helpers/electron-process-shutdown.ts +++ b/tests/e2e/helpers/electron-process-shutdown.ts @@ -2,6 +2,7 @@ import type { ChildProcess } from 'node:child_process' import { execFileSync } from 'node:child_process' import { existsSync, readFileSync, readdirSync } from 'node:fs' import path from 'node:path' +import { cleanupE2ECrashpad } from './electron-crashpad-cleanup' import type { ElectronApplication } from '@stablyai/playwright-test' const GRACEFUL_CLOSE_TIMEOUT_MS = 10_000 @@ -238,4 +239,5 @@ export async function cleanupE2EDaemons(userDataDir: string): Promise { for (const pid of readDaemonPidFiles(userDataDir)) { await forceKillPidTree(pid) } + cleanupE2ECrashpad(userDataDir) } diff --git a/tests/e2e/helpers/ssh-recovery-input-observation.ts b/tests/e2e/helpers/ssh-recovery-input-observation.ts new file mode 100644 index 00000000000..06b4bd7de3e --- /dev/null +++ b/tests/e2e/helpers/ssh-recovery-input-observation.ts @@ -0,0 +1,53 @@ +import type { Page, TestInfo } from '@playwright/test' +import type { RuntimeTerminalListResult } from '../../../src/shared/runtime-types' + +export async function attachSshRecoveryInputObservation( + page: Page, + testInfo: TestInfo, + targetId: string, + originalPtyId: string, + label: string +): Promise { + const observation = await page.evaluate( + async ({ targetId, originalPtyId }) => { + const state = window.__store?.getState() + const panes = [...(window.__paneManagers?.entries() ?? [])].flatMap(([tabId, manager]) => + manager.getPanes().map((pane) => ({ + tabId, + leafId: pane.leafId, + ptyId: pane.container.dataset.ptyId, + active: manager.getActivePane()?.id === pane.id + })) + ) + let timer: ReturnType | undefined + try { + const runtime = await Promise.race([ + window.api.runtime + .call({ method: 'terminal.list', params: { limit: 50, includeVisualLayouts: false } }) + .then((response) => + response.ok + ? { terminals: (response.result as RuntimeTerminalListResult).terminals } + : { error: response.error } + ), + new Promise<{ error: string }>((resolve) => { + timer = setTimeout(() => resolve({ error: 'Observation timed out' }), 1000) + }) + ]) + return { + originalPtyId, + authority: state?.sshConnectionStates.get(targetId), + activeWorktreeId: state?.activeWorktreeId, + panes, + runtime + } + } finally { + clearTimeout(timer) + } + }, + { targetId, originalPtyId } + ) + await testInfo.attach(`ssh-input-${label}.json`, { + body: JSON.stringify(observation, null, 2), + contentType: 'application/json' + }) +} diff --git a/tests/e2e/ssh-docker-transport-drop-recovery.spec.ts b/tests/e2e/ssh-docker-transport-drop-recovery.spec.ts index 42ac316790c..c64761ede80 100644 --- a/tests/e2e/ssh-docker-transport-drop-recovery.spec.ts +++ b/tests/e2e/ssh-docker-transport-drop-recovery.spec.ts @@ -4,6 +4,7 @@ import type { ElectronApplication } from '@playwright/test' import { test, expect } from './helpers/orca-app' import { DEFAULT_LOCAL_ORCA_PROFILE_ID } from '../../src/shared/orca-profiles' import { sshRemotePtyLeaseAllowsReattach, type SshRemotePtyLease } from '../../src/shared/ssh-types' +import { toRelaySshPtyId } from '../../src/shared/ssh-pty-id' import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' import { execInTerminal, @@ -29,6 +30,8 @@ import { withStalledDockerSshRelayTarget } from './helpers/docker-ssh-relay-faults' +import { attachSshRecoveryInputObservation } from './helpers/ssh-recovery-input-observation' + const RUN_DOCKER_SSH = process.env.ORCA_E2E_SSH_DOCKER === '1' /** @@ -338,16 +341,21 @@ test.describe('SSH transport drop recovery', () => { const generations: string[][] = [] for (let generation = 1; generation <= 5; generation++) { - const predecessor = await waitForActivePanePtyId(orcaPage, 60_000) + const previousPtyId = await waitForActivePanePtyId(orcaPage, 60_000) await recoverDockerSshRelayAfterFault(orcaPage, remote.targetId, () => { - expect(killDockerSshRelayDaemon(target!)).toBeGreaterThan(0) + expect( + killDockerSshRelayDaemon(target!), + 'no relay process was found to kill' + ).toBeGreaterThan(0) }) - await expect - .poll(() => waitForActivePanePtyId(orcaPage, 60_000), { timeout: 120_000 }) - .not.toBe(predecessor) await waitForActiveTerminalManager(orcaPage, 120_000) - // The pane must be usable again before the count is meaningful: recovery is what mints the - // successor lease that retires the generation before it. + // Transport status can still be connected while the pane retains its old binding. + await expect + .poll(() => waitForActivePanePtyId(orcaPage, 60_000).catch(() => previousPtyId), { + timeout: 120_000, + message: `pane kept its old PTY binding after relay kill ${generation}` + }) + .not.toBe(previousPtyId) const ptyId = await waitForActivePanePtyId(orcaPage, 120_000) const markerSuffix = `${generation}_${Date.now()}` const marker = `LEASE_GEN_${markerSuffix}` @@ -356,15 +364,14 @@ test.describe('SSH transport drop recovery', () => { try { await expect - .poll(() => readReattachablePtyIds(userDataDir, remote.targetId).length, { + .poll(() => readReattachablePtyIds(userDataDir, remote.targetId), { timeout: 60_000 }) - .toBe(1) + .toEqual([toRelaySshPtyId(remote.targetId, ptyId)]) } catch (error) { - // Why re-thrown with the rows: the count alone cannot say WHICH predecessor stayed - // reattachable, and the user-data dir is torn down before the report is read. + // Preserve lease ownership diagnostics before the user-data directory is removed. throw new Error( - `reattachable lease count never settled at 1 in generation ${generation}; leases: ${describeSshLeases(userDataDir, remote.targetId)}`, + `reattachable leases never settled at the active PTY ${ptyId} in generation ${generation}; leases: ${describeSshLeases(userDataDir, remote.targetId)}`, { cause: error } ) } @@ -432,6 +439,7 @@ test.describe('SSH transport drop recovery', () => { test('accepts input again after a frozen host resumes', async ({ orcaPage }, testInfo) => { test.slow() let target: DockerSshRelayTarget | null = null + let observationTarget: { targetId: string; ptyId: string } | undefined try { target = startDockerSshRelayTarget(testInfo) enableDockerSshRelayTargetShellTitle(target) @@ -444,6 +452,18 @@ test.describe('SSH transport drop recovery', () => { await waitForActiveTerminalManager(orcaPage, 60_000) const ptyId = await waitForActivePanePtyId(orcaPage, 60_000) + observationTarget = { targetId: remote.targetId, ptyId } + const beforeSuffix = Date.now() + await execInTerminal(orcaPage, ptyId, `printf 'STALL_BEFORE_%s\\n' ${beforeSuffix}`) + await waitForTerminalOutput(orcaPage, `STALL_BEFORE_${beforeSuffix}`, 60_000) + await attachSshRecoveryInputObservation( + orcaPage, + testInfo, + remote.targetId, + ptyId, + 'before-freeze' + ) + await recoverDockerSshRelayAfterFault(orcaPage, remote.targetId, async () => { await withStalledDockerSshRelayTarget(target!, async () => { await orcaPage.waitForTimeout(30_000) @@ -454,7 +474,25 @@ test.describe('SSH transport drop recovery', () => { const afterSuffix = Date.now() const afterMarker = `STALL_AFTER_${afterSuffix}` await execInTerminal(orcaPage, ptyId, `printf 'STALL_AFTER_%s\\n' ${afterSuffix}`) + await attachSshRecoveryInputObservation( + orcaPage, + testInfo, + remote.targetId, + ptyId, + 'after-write' + ) await waitForTerminalOutput(orcaPage, afterMarker, 60_000) + } catch (error) { + if (observationTarget) { + await attachSshRecoveryInputObservation( + orcaPage, + testInfo, + observationTarget.targetId, + observationTarget.ptyId, + 'failure-before-cleanup' + ).catch(() => undefined) + } + throw error } finally { if (target) { clearDockerSshRelayFaults(target) From 9faa27c5f4e3f476393aff1e17429242483e8038 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:12:19 -0700 Subject: [PATCH 09/23] test: align desktop platform oracles with native behavior (#18915) --- .../right-sidebar-windows-titlebar.spec.ts | 42 ++++-------- tests/e2e/settings-agent-awake.spec.ts | 68 ++++++++++++++----- 2 files changed, 64 insertions(+), 46 deletions(-) diff --git a/tests/e2e/right-sidebar-windows-titlebar.spec.ts b/tests/e2e/right-sidebar-windows-titlebar.spec.ts index 1d6d4b8981f..ce39d7de28d 100644 --- a/tests/e2e/right-sidebar-windows-titlebar.spec.ts +++ b/tests/e2e/right-sidebar-windows-titlebar.spec.ts @@ -6,41 +6,19 @@ type RightSidebarHeaderGeometry = { stripTop: number closeTop: number titlebarActivityButtonCount: number + activityButtonCount: number firstButtonCenterHitsFirst: boolean lastButtonCenterHitsLast: boolean } -test.describe('Right sidebar Windows titlebar spacing', () => { - test('top activity buttons render inside the sidebar instead of the titlebar', async ({ - orcaPage - }) => { - await orcaPage.addInitScript(() => { - const userAgent = - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/146 Safari/537.36' - Object.defineProperty(navigator, 'userAgent', { - get: () => userAgent, - configurable: true - }) - }) - await orcaPage.reload({ waitUntil: 'domcontentloaded' }) - await orcaPage.waitForFunction(() => Boolean(window.__store), null, { timeout: 30_000 }) +test.describe('Right sidebar native titlebar spacing', () => { + test('top activity buttons follow the native desktop chrome layout', async ({ orcaPage }) => { await waitForSessionReady(orcaPage) await waitForActiveWorktree(orcaPage) await ensureTerminalVisible(orcaPage) - await expect - .poll( - async () => - orcaPage.evaluate(() => ({ - hasWindowsUserAgent: navigator.userAgent.includes('Windows'), - hasWindowsTitlebarChrome: Boolean(document.querySelector('.window-controls')) - })), - { - timeout: 5_000, - message: 'Renderer did not switch to the Windows titlebar branch' - } - ) - .toEqual({ hasWindowsUserAgent: true, hasWindowsTitlebarChrome: true }) + const hasDesktopWindowChrome = process.platform !== 'darwin' + expect(await orcaPage.evaluate(() => window.api.platform.get().platform)).toBe(process.platform) await orcaPage.evaluate(() => { const store = window.__store @@ -95,6 +73,7 @@ test.describe('Right sidebar Windows titlebar spacing', () => { stripTop: stripRect.top, closeTop: closeRect.top, titlebarActivityButtonCount, + activityButtonCount: activityButtons.length, firstButtonCenterHitsFirst: elementAtFirstCenter !== null && firstButton.contains(elementAtFirstCenter), lastButtonCenterHitsLast: @@ -117,8 +96,13 @@ test.describe('Right sidebar Windows titlebar spacing', () => { .toBe(true) expect(headerGeometry).not.toBeNull() - expect(headerGeometry!.titlebarActivityButtonCount).toBe(0) - expect(headerGeometry!.stripTop).toBeGreaterThanOrEqual(headerGeometry!.headerBottom) + if (hasDesktopWindowChrome) { + expect(headerGeometry!.titlebarActivityButtonCount).toBe(0) + expect(headerGeometry!.stripTop).toBeGreaterThanOrEqual(headerGeometry!.headerBottom) + } else { + expect(headerGeometry!.titlebarActivityButtonCount).toBe(headerGeometry!.activityButtonCount) + expect(headerGeometry!.stripTop).toBeLessThan(headerGeometry!.headerBottom) + } expect(headerGeometry!.closeTop).toBeLessThan(headerGeometry!.headerBottom) expect(headerGeometry!.firstButtonCenterHitsFirst).toBe(true) expect(headerGeometry!.lastButtonCenterHitsLast).toBe(true) diff --git a/tests/e2e/settings-agent-awake.spec.ts b/tests/e2e/settings-agent-awake.spec.ts index 8a2ad840a14..ebea82a1241 100644 --- a/tests/e2e/settings-agent-awake.spec.ts +++ b/tests/e2e/settings-agent-awake.spec.ts @@ -1,4 +1,5 @@ import { randomUUID } from 'node:crypto' +import { runProcess } from '../../src/shared/child-process/run-process' import type { ElectronApplication, Page } from '@stablyai/playwright-test' import { test, expect } from './helpers/orca-app' import { waitForSessionReady } from './helpers/store' @@ -104,6 +105,19 @@ async function readPowerSaveBlockerProbe( }) } +async function readMacosSleepAssertionPids(electronApp: ElectronApplication): Promise { + const result = await runProcess({ + program: '/usr/bin/pgrep', + args: ['-P', String(electronApp.process().pid), '-f', '^/usr/bin/caffeinate -i -s$'], + maxOutputBytes: 4_096 + }) + if (result.code === 1) { + return [] + } + expect(result.code, result.stderr).toBe(0) + return result.stdout.trim().split(/\s+/).filter(Boolean).map(Number) +} + async function postCodexHookEvent( electronApp: ElectronApplication, options: { @@ -176,7 +190,9 @@ test.describe('Agent awake setting', () => { electronApp, orcaPage }) => { - await installPowerSaveBlockerProbe(electronApp) + if (process.platform !== 'darwin') { + await installPowerSaveBlockerProbe(electronApp) + } await setKeepAwake(orcaPage, true) const tabId = 'e2e-awake-tab' @@ -187,24 +203,33 @@ test.describe('Agent awake setting', () => { eventName: 'UserPromptSubmit' }) - await expect - .poll(async () => await readPowerSaveBlockerProbe(electronApp), { - timeout: 5_000, - message: 'powerSaveBlocker did not start for the working agent' - }) - .toEqual( - expect.objectContaining({ - activeIds: expect.arrayContaining([expect.any(Number)]), - starts: expect.arrayContaining([ - expect.objectContaining({ type: 'prevent-display-sleep' }) - ]) + await expect( + orcaPage.getByRole('button', { name: 'Keep computer awake, Agent · Active' }) + ).toBeVisible() + let startedIds: number[] = [] + if (process.platform === 'darwin') { + // macOS uses an app-owned caffeinate assertion instead of Electron's display blocker. + await expect + .poll(() => readMacosSleepAssertionPids(electronApp), { timeout: 5_000 }) + .not.toEqual([]) + } else { + await expect + .poll(async () => await readPowerSaveBlockerProbe(electronApp), { + timeout: 5_000, + message: 'powerSaveBlocker did not start for the working agent' }) - ) + .toEqual( + expect.objectContaining({ + activeIds: expect.arrayContaining([expect.any(Number)]), + starts: expect.arrayContaining([ + expect.objectContaining({ type: 'prevent-display-sleep' }) + ]) + }) + ) - const startedIds = (await readPowerSaveBlockerProbe(electronApp)).starts.map( - (start) => start.id - ) - expect(startedIds.length).toBeGreaterThan(0) + startedIds = (await readPowerSaveBlockerProbe(electronApp)).starts.map((start) => start.id) + expect(startedIds.length).toBeGreaterThan(0) + } await postCodexHookEvent(electronApp, { paneKey, @@ -212,6 +237,15 @@ test.describe('Agent awake setting', () => { eventName: 'Stop' }) + await expect( + orcaPage.getByRole('button', { name: 'Keep computer awake, Agent · Inactive' }) + ).toBeVisible() + if (process.platform === 'darwin') { + await expect + .poll(() => readMacosSleepAssertionPids(electronApp), { timeout: 5_000 }) + .toEqual([]) + return + } await expect .poll(async () => await readPowerSaveBlockerProbe(electronApp), { timeout: 5_000, From 239e3c7e0ba5b41545f440089f76e4b696385abd Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:29:46 -0700 Subject: [PATCH 10/23] test: select seeded workspace and confirm sidebar reveal (#18921) --- tests/e2e/worktree-scroll-to-current.spec.ts | 24 +++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/tests/e2e/worktree-scroll-to-current.spec.ts b/tests/e2e/worktree-scroll-to-current.spec.ts index 19c51005cfe..d61d96847a0 100644 --- a/tests/e2e/worktree-scroll-to-current.spec.ts +++ b/tests/e2e/worktree-scroll-to-current.spec.ts @@ -39,22 +39,30 @@ test.describe('Reveal active workspace button', () => { // the "outside the virtualized window" test below. test('clears sidebar filters before revealing a hidden current workspace', async ({ - orcaPage + orcaPage, + testRepoPath }) => { await prepareSidebarForScrollTest(orcaPage) - const renderedOptions = orcaPage.locator('[data-worktree-sidebar] [role="option"]') - await expect(renderedOptions).toHaveCount(2) - - const targetId = await renderedOptions.last().getAttribute('data-worktree-id') + // Other specs can add worktrees to the shared repository before this test runs. + const targetId = await orcaPage.evaluate((repoPath) => { + const state = window.__store!.getState() + const repo = state.repos.find((candidate) => candidate.path === repoPath) + return repo + ? state.worktreesByRepo[repo.id]?.find( + (worktree) => worktree.branch === 'refs/heads/e2e-secondary' + )?.id + : undefined + }, testRepoPath) if (!targetId) { - throw new Error('Bottom workspace row did not expose a data-worktree-id') + throw new Error('Seeded secondary worktree is missing') } const targetRows = orcaPage.locator( `[data-worktree-sidebar] [data-worktree-id=${JSON.stringify(targetId)}]` ) const targetRow = targetRows.first() + await expect(targetRows.and(orcaPage.getByRole('option'))).toHaveCount(1) const revealButton = orcaPage.getByRole('button', { name: 'Reveal active workspace' }) await orcaPage.evaluate((targetId) => { @@ -92,6 +100,10 @@ test.describe('Reveal active workspace button', () => { // contract under test is that reveal clears the filter (asserted below). await revealButton.click() + await orcaPage + .getByRole('dialog', { name: 'Reveal hidden workspace?' }) + .getByRole('button', { name: 'Clear filters and reveal' }) + .click() await expect(targetRow).toBeVisible() await expect(targetRow).toHaveAttribute('data-scroll-reveal-highlight', 'true') From 471a5f4aa795aaca11ea1a1a7b8dc17bd1330915 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:33:04 -0700 Subject: [PATCH 11/23] feat(native-chat): model Codex MCP and web-search items instead of leaking opcodes (#18763) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(native-chat): model Codex MCP and web-search items instead of leaking opcodes Codex's app-server sends 19 thread-item types; the structured translator handled six. The rest fell through to a generic gray `codex · item:` row, even though the disposition table's own comment says it exists so a new item type cannot leak like that — the table had one entry. Give `mcpToolCall` and `webSearch` real tool-call bodies, and chrome `sleep`, which carries only a duration and renders as nothing in Codex's own TUI. `subAgentActivity` and `collabAgentToolCall` deliberately keep their generic rows. They arrive in real sessions today and are currently the only visible sign a subagent is running; hiding them before the subagent UI lands would render minutes of work as an idle turn. Tests pin that they stay visible. MCP tool names pass through verbatim when they contain `:`, `.`, `/` or `__`, so `mcp__server__tool` survives instead of being title-cased into nonsense. * fix(native-chat): keep Codex MCP tool identity and web-search results on the row Four fixes to the Codex MCP / web-search item bodies: - Drop the title-casing display name. `get_forecast` became `Get Forecast`, which no longer matches the raw snake_case identifiers that the diff renderer, question parsers, and tool-input previews dispatch on, and does not match how the Claude lane or the sibling `shell`/`apply_patch`/`web_search` bodies name a tool. The row name is now `server/tool` verbatim, the bare `tool` when no server is given, and `mcp` when the item names no tool at all. Server-qualifying also stops an MCP tool that happens to be called `apply_patch` from hijacking the diff renderer. - Pass the MCP call's own `arguments` as the tool input instead of wrapping it in `{server, tool, arguments}`. Row-label derivation only reads top-level keys, so the wrapper degraded every MCP row to a truncated raw JSON blob. A non-object `arguments` stays addressable under a key rather than being dropped; an absent one becomes null, which labels as empty rather than `{}`. - Carry a web search's `results` as the call output, bounded like every other inline payload and omitted when there are none. They were being dropped entirely, which showed less than the generic fallback row it replaced. - No streaming branches were added for these two item types: the Codex delta stream is a closed set of six methods that neither can reach, so such branches would be unreachable. * fix(native-chat): label Codex web searches and argument-less MCP calls A row label is derived from top-level `input` keys only, so a webSearch whose detail lives inside `action` — an opened page, an in-page find, or a bare `other` — fell through to the raw JSON of the whole input, as did the empty `query` Codex leaves on a completed search. Hoist the action's `url`, `pattern` and `type` beside the query, keep the full `action` object so the expanded detail loses nothing, and emit no input at all for the start frame. An MCP tool that takes no arguments sends `arguments: {}`, which passed straight through and labelled the row a literal `{}`; treat it as absent so the row reads as a bare `server/tool`. Split the durable-identity half of the item translator into `codex-thread-item-identity.ts`, re-exported so every existing import is unchanged, to keep both files under the max-lines cap. --------- Co-authored-by: Merge Sim --- src/main/codex/codex-command-action-class.ts | 71 +++++ src/main/codex/codex-item-field-readers.ts | 45 +++ .../codex-structured-item-translation.test.ts | 235 ++++++++++++++- .../codex-structured-item-translation.ts | 278 +++++++----------- src/main/codex/codex-thread-item-identity.ts | 65 ++++ .../provider-frame-disposition.test.ts | 48 +++ .../provider-frame-disposition.ts | 7 +- 7 files changed, 567 insertions(+), 182 deletions(-) create mode 100644 src/main/codex/codex-command-action-class.ts create mode 100644 src/main/codex/codex-item-field-readers.ts create mode 100644 src/main/codex/codex-thread-item-identity.ts diff --git a/src/main/codex/codex-command-action-class.ts b/src/main/codex/codex-command-action-class.ts new file mode 100644 index 00000000000..81360691ef9 --- /dev/null +++ b/src/main/codex/codex-command-action-class.ts @@ -0,0 +1,71 @@ +import { readRecord, readString } from './codex-item-field-readers' +import type { CodexThreadItem } from './codex-thread-item-identity' + +/** + * Codex's own classification of a shell call: the tool name to show, and the + * fields worth lifting into `input` for the shared label helper (a file target, + * a search term, a scanned root). A `Map`, not an object — an object index + * answers `__proto__` with a truthy non-string. Every other action type stays an + * unclassified `shell` row. + * + * Nothing is invented for a field Codex sends as null: a stand-in path is a + * claim about a target, and the label helper turns any path into a file link. + */ +type CommandActionClass = { + name: string + /** Action field to the `input` key it lifts to. A scan root and a listed + * directory lift to `directory`, never `path`: the label helper reads `path` + * as a file target, which mobile turns into a tappable open-file link. */ + keys: Readonly> +} + +const COMMAND_ACTION_CLASSES = new Map([ + ['read', { name: 'read', keys: { path: 'path' } }], + ['search', { name: 'search', keys: { query: 'query', path: 'directory' } }], + ['listFiles', { name: 'list', keys: { path: 'directory' } }] +]) + +/** The one class every classified `commandActions` entry agrees on, with the + * fields they all agree on; null leaves the row exactly as a Codex that sends no + * classification renders it. `cat a.txt && ls src` classifies as two different + * things, and naming that row after either would drop the other, so it stays a + * `shell` row that shows the whole command. */ +export function commandActionFacts( + item: CodexThreadItem +): { name: string; fields: Record } | null { + const actions = item.commandActions + if (!Array.isArray(actions)) { + return null + } + let matched: { class: CommandActionClass; fields: Record } | null = null + for (const action of actions) { + const record = readRecord(action) + const type = readString(record, 'type') + const classified = type === null ? undefined : COMMAND_ACTION_CLASSES.get(type) + if (classified === undefined) { + continue + } + if (matched === null) { + const fields: Record = {} + for (const [source, lifted] of Object.entries(classified.keys)) { + const value = readString(record, source) + if (value !== null) { + fields[lifted] = value + } + } + matched = { class: classified, fields } + continue + } + if (matched.class.name !== classified.name) { + return null + } + // The same class twice keeps the class, but only a target both entries name. + for (const [source, lifted] of Object.entries(matched.class.keys)) { + const kept = matched.fields[lifted] + if (kept !== undefined && readString(record, source) !== kept) { + delete matched.fields[lifted] + } + } + } + return matched === null ? null : { name: matched.class.name, fields: matched.fields } +} diff --git a/src/main/codex/codex-item-field-readers.ts b/src/main/codex/codex-item-field-readers.ts new file mode 100644 index 00000000000..bbe2551615a --- /dev/null +++ b/src/main/codex/codex-item-field-readers.ts @@ -0,0 +1,45 @@ +// Field readers for the loosely-typed records Codex sends on thread items. + +export function readRecord(value: unknown): Record { + return typeof value === 'object' && value !== null ? (value as Record) : {} +} + +export function readString(source: Record, key: string): string | null { + const value = source[key] + return typeof value === 'string' && value.length > 0 ? value : null +} + +export function readFirstString( + source: Record, + keys: readonly string[] +): string | null { + for (const key of keys) { + const value = readString(source, key) + if (value !== null) { + return value + } + } + return null +} + +export function readTextContent(source: Record, key: string): string | null { + const direct = readString(source, key) + if (direct) { + return direct + } + const value = source[key] + if (!Array.isArray(value)) { + return null + } + const parts = value.flatMap((part) => { + if (typeof part === 'string') { + return part.length > 0 ? [part] : [] + } + if (typeof part !== 'object' || part === null) { + return [] + } + const text = readString(part as Record, 'text') + return text ? [text] : [] + }) + return parts.length > 0 ? parts.join('\n') : null +} diff --git a/src/main/codex/codex-structured-item-translation.test.ts b/src/main/codex/codex-structured-item-translation.test.ts index 1d64158cb60..2558f4b60de 100644 --- a/src/main/codex/codex-structured-item-translation.test.ts +++ b/src/main/codex/codex-structured-item-translation.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from 'vitest' import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' -import { createToolInputDisplay } from '../../shared/native-chat-tool-summary' +import { + briefToolArg, + createToolInputDisplay, + describeToolInput +} from '../../shared/native-chat-tool-summary' import { codexItemBody, codexItemIdentity, @@ -14,6 +18,13 @@ import { type CodexThreadItem } from './codex-structured-item-translation' +/** The tool-call input a Codex item lands on, which is what the row label and + * the collapsed run header are both derived from. */ +function toolCallInput(item: CodexThreadItem): unknown { + const body = codexItemBody(item) + return body !== null && body.kind === 'tool-call' ? body.input : null +} + const THREAD_ID = 'thread-abc' const TURN_ID = 'turn-1' @@ -572,10 +583,226 @@ describe('codex item bodies', () => { }) expect(codexItemBody({ type: 'reasoning', id: 'r' })).toBeNull() expect(codexItemBody({ type: 'agentMessage', id: 'm', text: '' })).toBeNull() - expect(codexItemBody({ type: 'webSearch', id: 'w' })).toMatchObject({ + expect(codexItemBody({ type: 'somethingCodexAddedLater', id: 'x' })).toMatchObject({ kind: 'status', - text: 'codex · item:webSearch', - providerFrame: { provider: 'codex', kind: 'item:webSearch' } + text: 'codex · item:somethingCodexAddedLater', + providerFrame: { provider: 'codex', kind: 'item:somethingCodexAddedLater' } + }) + }) + + it('gives an mcp tool call a typed body with its own arguments as input', () => { + expect( + codexItemBody({ + type: 'mcpToolCall', + id: 'mcp-1', + server: 'weather', + tool: 'get_forecast', + status: 'completed', + arguments: { city: 'Oslo' }, + result: { content: [{ type: 'text', text: '12C' }] } + }) + ).toEqual({ + kind: 'tool-call', + // Server-qualified, and the arguments stay top level so the row label can + // read `query`/`command`/`file_path` out of them. + name: 'weather/get_forecast', + input: { city: 'Oslo' }, + state: 'completed', + output: { head: '12C', byteLength: 3, truncated: false, digest: expect.any(String) } + }) + }) + + it('passes an mcp tool name through with no casing transform', () => { + // Downstream dispatch is exact-match on raw identifiers, so every shape — + // bare snake_case included — has to survive byte-identical. + for (const tool of ['get_forecast', 'mcp__server__tool', 'ns.tool', 'urn:tool', 'listTools']) { + expect( + codexItemBody({ type: 'mcpToolCall', id: 'm', tool, status: 'inProgress' }), + tool + ).toMatchObject({ kind: 'tool-call', name: tool, state: 'running' }) + expect( + codexItemBody({ type: 'mcpToolCall', id: 'm', server: 'srv', tool, status: 'inProgress' }), + tool + ).toMatchObject({ kind: 'tool-call', name: `srv/${tool}`, state: 'running' }) + } + }) + + it('falls back to the bare tool, then to `mcp`, when the item is under-specified', () => { + expect( + codexItemBody({ type: 'mcpToolCall', id: 'm', tool: 'get_forecast', status: 'inProgress' }) + ).toMatchObject({ name: 'get_forecast' }) + expect( + codexItemBody({ type: 'mcpToolCall', id: 'm', server: '', tool: 'ping', status: 'completed' }) + ).toMatchObject({ name: 'ping' }) + expect( + codexItemBody({ type: 'mcpToolCall', id: 'm', server: 'weather', status: 'completed' }) + ).toMatchObject({ name: 'mcp' }) + }) + + it('keeps non-object mcp arguments addressable and empty ones off the label', () => { + // `arguments` is arbitrary JSON upstream; a scalar or array must still reach + // the row rather than being dropped or unwrapped into a bare value. + expect( + codexItemBody({ type: 'mcpToolCall', id: 'm', tool: 't', arguments: 'raw text' }) + ).toMatchObject({ input: { arguments: 'raw text' } }) + expect( + codexItemBody({ type: 'mcpToolCall', id: 'm', tool: 't', arguments: [1, 2] }) + ).toMatchObject({ input: { arguments: [1, 2] } }) + // `arguments` is required on the wire, so `{}` — not an absent key — is what + // an argument-less MCP tool sends, and passing it through labels the row `{}`. + expect(codexItemBody({ type: 'mcpToolCall', id: 'm', tool: 't', arguments: {} })).toEqual({ + kind: 'tool-call', + name: 't', + input: null, + state: 'running' + }) + expect(codexItemBody({ type: 'mcpToolCall', id: 'm', tool: 't' })).toMatchObject({ + input: null + }) + expect( + codexItemBody({ type: 'mcpToolCall', id: 'm', tool: 't', arguments: null }) + ).toMatchObject({ input: null }) + }) + + it('renders an argument-less mcp call as a bare server/tool row', () => { + const input = toolCallInput({ + type: 'mcpToolCall', + id: 'm', + server: 'srv', + tool: 'list_tools', + arguments: {} + }) + expect(describeToolInput(input)).toBe('') + expect(briefToolArg(input)).toBe('') + }) + + it('reports an mcp error as a failed call carrying the server message', () => { + expect( + codexItemBody({ + type: 'mcpToolCall', + id: 'mcp-2', + server: 's', + tool: 'ping', + status: 'completed', + error: { message: 'server unreachable' } + }) + ).toMatchObject({ + kind: 'tool-call', + name: 's/ping', + state: 'failed', + output: { head: 'server unreachable', truncated: false } + }) + }) + + it('models a web search as a tool call that runs until codex sends the action', () => { + // The start frame Codex actually emits: empty query, no action. Nothing is + // labelable yet, so the input is absent rather than a hull of null keys. + expect(codexItemBody({ type: 'webSearch', id: 'w', query: '', action: null })).toEqual({ + kind: 'tool-call', + name: 'web_search', + input: null, + state: 'running' + }) + expect( + codexItemBody({ + type: 'webSearch', + id: 'w', + query: 'orca release notes', + action: { type: 'search', query: 'orca release notes', queries: null }, + results: null + }) + ).toEqual({ + kind: 'tool-call', + name: 'web_search', + input: { + query: 'orca release notes', + description: 'search', + action: { type: 'search', query: 'orca release notes', queries: null } + }, + state: 'completed' + }) + }) + + it('carries the web search hits as the call output', () => { + const results = [{ title: 'Orca 1.0', url: 'https://example.com/notes' }] + expect( + codexItemBody({ + type: 'webSearch', + id: 'w', + query: 'orca release notes', + action: { type: 'search', query: 'orca release notes', queries: null }, + results + }) + ).toMatchObject({ + kind: 'tool-call', + name: 'web_search', + state: 'completed', + output: { head: JSON.stringify(results), truncated: false } + }) + // Nothing to show is no output block at all, not an empty one. + for (const empty of [undefined, null, []]) { + expect( + codexItemBody({ + type: 'webSearch', + id: 'w', + query: 'q', + action: { type: 'search' }, + results: empty + }), + String(empty) + ).not.toHaveProperty('output') + } + }) + + it('labels every web search shape without falling back to raw JSON', () => { + // Both the row label and the run header read top-level input keys only, so a + // shape whose detail sits inside `action` renders as the input's raw JSON. + const url = 'https://example.com/docs/page' + const shapes: [string, unknown, string, string][] = [ + ['started', null, '', ''], + [ + 'search', + { type: 'search', query: 'a sample query', queries: null }, + 'a sample query', + 'a sample query' + ], + ['openPage', { type: 'openPage', url }, url, ''], + [ + 'findInPage', + { type: 'findInPage', url, pattern: 'a needle' }, + 'a sample query', + 'a sample query' + ], + ['other', { type: 'other' }, 'other', ''] + ] + for (const [name, action, label, brief] of shapes) { + // Codex leaves the item's own `query` empty on most completed searches. + const query = name === 'search' || name === 'findInPage' ? 'a sample query' : '' + const input = toolCallInput({ type: 'webSearch', id: 'w', query, action }) + expect(describeToolInput(input), name).toBe(label) + expect(briefToolArg(input), name).toBe(brief) + } + }) + + it('leaves subagent items on the generic row until a real renderer exists', () => { + expect( + codexJournalItem({ + type: 'subAgentActivity', + id: 'a-1', + kind: 'started', + agentThreadId: 'thread-child', + agentPath: '/root/list_directory' + }) + ).toMatchObject({ + handled: false, + body: { kind: 'status', providerFrame: { kind: 'item:subAgentActivity' } } + }) + }) + + it('drops the sleep item, which codex itself renders as nothing', () => { + expect(codexJournalItem({ type: 'sleep', id: 's-1', durationMs: 20_000 })).toEqual({ + body: null, + handled: true }) }) diff --git a/src/main/codex/codex-structured-item-translation.ts b/src/main/codex/codex-structured-item-translation.ts index b3609e076f5..ad08525a5f5 100644 --- a/src/main/codex/codex-structured-item-translation.ts +++ b/src/main/codex/codex-structured-item-translation.ts @@ -1,7 +1,4 @@ -import type { - AgentJournalItemBody, - AgentJournalItemIdentity -} from '../../shared/agent-session-journal-types' +import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' import type { NativeChatBlock } from '../../shared/native-chat-types' import { boundInlineText, @@ -9,116 +6,27 @@ import { DEFAULT_JOURNAL_PAYLOAD_LIMITS } from '../native-chat/agent-session-journal/journal-payload-bounds' import { unhandledProviderFrameJournalItem } from '../native-chat/agent-session-wire/unhandled-provider-frame' -import type { CodexTurnOrdinals } from './codex-turn-ordinals' +import { commandActionFacts } from './codex-command-action-class' +import { + readFirstString, + readRecord, + readString, + readTextContent +} from './codex-item-field-readers' +import type { CodexThreadItem } from './codex-thread-item-identity' +export { + codexItemIdentity, + isCodexMessageItemType, + readCodexThreadItem, + type CodexThreadItem +} from './codex-thread-item-identity' export { CodexTurnOrdinals, MAX_CODEX_TURN_ORDINAL_BYTES, MAX_CODEX_TURN_ORDINAL_ENTRIES } from './codex-turn-ordinals' -// Codex thread items → journal item bodies and durable identities. -// -// THE ORDINAL RULE, and why it is not "index within the turn". Codex renumbers -// item ids positionally on resume (`item-1`…`item-N` across the whole thread), -// and a resumed turn does NOT contain every item the live turn emitted — -// reasoning and command execution are dropped from persisted history. Numbering -// by live position would therefore shift every message after the first tool -// call and hand the user a duplicate of the assistant's answer after a resume. -// -// So the ordinal counts MESSAGE items only, and the same projection is applied -// to the live stream and to a resumed turn's item list. Any other item type — -// including ones this build does not model — is skipped identically on both -// sides, which is what makes the key survive a Codex release that adds one. - -/** Only these carry a durable `(threadId, turnId, ordinal)` identity. */ -const CODEX_MESSAGE_ITEM_TYPES = new Set(['userMessage', 'agentMessage']) - -export type CodexThreadItem = { - type: string - id: string - [key: string]: unknown -} - -export function isCodexMessageItemType(type: string): boolean { - return CODEX_MESSAGE_ITEM_TYPES.has(type) -} - -export function readCodexThreadItem(value: unknown): CodexThreadItem | null { - if (typeof value !== 'object' || value === null) { - return null - } - const record = value as Record - return typeof record.type === 'string' && typeof record.id === 'string' - ? (record as CodexThreadItem) - : null -} - -function readRecord(value: unknown): Record { - return typeof value === 'object' && value !== null ? (value as Record) : {} -} - -/** - * Durable identity for a Codex item, or null for one that has none. - * - * Non-message items fall back to the `orca` namespace keyed by the Codex item - * id. That id is unstable across resume, so those rows are live-session detail - * that a recovered journal simply will not contain — which is correct: Codex - * itself does not persist them either. - */ -export function codexItemIdentity(input: { - threadId: string - turnId: string | null - item: CodexThreadItem - ordinals: CodexTurnOrdinals -}): AgentJournalItemIdentity { - const { item, turnId } = input - if (turnId && isCodexMessageItemType(item.type)) { - return { - provider: 'codex', - threadId: input.threadId, - turnId, - ordinal: input.ordinals.ordinalFor(input.threadId, turnId, item.id) - } - } - return { provider: 'orca', clientMessageId: `codex-item:${input.threadId}:${item.id}` } -} - -function readString(source: Record, key: string): string | null { - const value = source[key] - return typeof value === 'string' && value.length > 0 ? value : null -} - -function readFirstString(source: Record, keys: readonly string[]): string | null { - for (const key of keys) { - const value = readString(source, key) - if (value !== null) { - return value - } - } - return null -} - -function readTextContent(source: Record, key: string): string | null { - const direct = readString(source, key) - if (direct) { - return direct - } - const value = source[key] - if (!Array.isArray(value)) { - return null - } - const parts = value.flatMap((part) => { - if (typeof part === 'string') { - return part.length > 0 ? [part] : [] - } - if (typeof part !== 'object' || part === null) { - return [] - } - const text = readString(part as Record, 'text') - return text ? [text] : [] - }) - return parts.length > 0 ? parts.join('\n') : null -} +// Codex thread items → journal item bodies. /** `userMessage` carries structured content parts; `agentMessage` a flat text. */ export function codexMessageBlocks(item: CodexThreadItem): NativeChatBlock[] { @@ -175,75 +83,6 @@ export type CodexJournalItem = { handled: boolean } -/** - * Codex's own classification of a shell call: the tool name to show, and the - * fields worth lifting into `input` for the shared label helper (a file target, - * a search term, a scanned root). A `Map`, not an object — an object index - * answers `__proto__` with a truthy non-string. Every other action type stays an - * unclassified `shell` row. - * - * Nothing is invented for a field Codex sends as null: a stand-in path is a - * claim about a target, and the label helper turns any path into a file link. - */ -type CommandActionClass = { - name: string - /** Action field to the `input` key it lifts to. A scan root and a listed - * directory lift to `directory`, never `path`: the label helper reads `path` - * as a file target, which mobile turns into a tappable open-file link. */ - keys: Readonly> -} - -const COMMAND_ACTION_CLASSES = new Map([ - ['read', { name: 'read', keys: { path: 'path' } }], - ['search', { name: 'search', keys: { query: 'query', path: 'directory' } }], - ['listFiles', { name: 'list', keys: { path: 'directory' } }] -]) - -/** The one class every classified `commandActions` entry agrees on, with the - * fields they all agree on; null leaves the row exactly as a Codex that sends no - * classification renders it. `cat a.txt && ls src` classifies as two different - * things, and naming that row after either would drop the other, so it stays a - * `shell` row that shows the whole command. */ -function commandActionFacts( - item: CodexThreadItem -): { name: string; fields: Record } | null { - const actions = item.commandActions - if (!Array.isArray(actions)) { - return null - } - let matched: { class: CommandActionClass; fields: Record } | null = null - for (const action of actions) { - const record = readRecord(action) - const type = readString(record, 'type') - const classified = type === null ? undefined : COMMAND_ACTION_CLASSES.get(type) - if (classified === undefined) { - continue - } - if (matched === null) { - const fields: Record = {} - for (const [source, lifted] of Object.entries(classified.keys)) { - const value = readString(record, source) - if (value !== null) { - fields[lifted] = value - } - } - matched = { class: classified, fields } - continue - } - if (matched.class.name !== classified.name) { - return null - } - // The same class twice keeps the class, but only a target both entries name. - for (const [source, lifted] of Object.entries(matched.class.keys)) { - const kept = matched.fields[lifted] - if (kept !== undefined && readString(record, source) !== kept) { - delete matched.fields[lifted] - } - } - } - return matched === null ? null : { name: matched.class.name, fields: matched.fields } -} - function commandItem(item: CodexThreadItem): CodexJournalItem { const output = readFirstString(item, ['aggregatedOutput', 'aggregated_output']) const bounded = output === null ? null : boundInlineText(output, DEFAULT_JOURNAL_PAYLOAD_LIMITS) @@ -296,6 +135,85 @@ function fileChangeItem(item: CodexThreadItem): CodexJournalItem { } } +/** The tool name reaches the row verbatim — downstream dispatch (diff renderer, + * question parsers, input previews) matches raw identifiers, so any casing + * transform would silently miss them. `server/` qualifies it so two servers + * exposing the same tool stay distinguishable and neither shadows a built-in. */ +function mcpToolCallName(item: CodexThreadItem): string { + const tool = readString(item, 'tool') + const server = readString(item, 'server') + return tool === null ? 'mcp' : server === null ? tool : `${server}/${tool}` +} + +/** Row-label derivation only reads top-level keys, so the call's own arguments + * have to be the input itself. `arguments` is arbitrary JSON upstream: a + * non-object stays addressable under a key rather than being dropped, while a + * no-argument call — `{}` on the wire, the shape every argument-less MCP tool + * sends — becomes null so the row reads as a bare `server/tool` instead of a + * literal `{}`. */ +function mcpToolArguments(value: unknown): unknown { + if (typeof value !== 'object' || value === null) { + return value === null || value === undefined ? null : { arguments: value } + } + return Array.isArray(value) ? { arguments: value } : Object.keys(value).length > 0 ? value : null +} + +function mcpToolCallItem(item: CodexThreadItem): CodexJournalItem { + const failure = readString(readRecord(item.error), 'message') + const text = failure ?? readTextContent(readRecord(item.result), 'content') + const bounded = text === null ? null : boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS) + return { + body: { + kind: 'tool-call', + name: mcpToolCallName(item), + input: boundToolInput(mcpToolArguments(item.arguments), DEFAULT_JOURNAL_PAYLOAD_LIMITS), + state: failure === null ? commandState(item) : 'failed', + ...(bounded === null ? {} : { output: bounded.bounded }) + }, + handled: true + } +} + +/** A row label is read off top-level keys only, so the action's own labelable + * fields are hoisted beside the query while `action` stays whole for the + * expanded detail. The action `type` lands on `description`, the lowest-ranked + * label key, so it names only an action that carries nothing better. */ +function webSearchInput(item: CodexThreadItem): Record | null { + const action = readRecord(item.action) + const fields: [string, unknown][] = [ + ['url', readString(action, 'url')], + ['pattern', readString(action, 'pattern')], + ['description', readString(action, 'type')], + ['action', item.action ?? null] + ] + const query = readString(item, 'query') ?? readString(action, 'query') + const present = fields.filter(([, value]) => value !== null) + // A blank `query` is the run header's "this call has no brief argument" + // signal; drop the key and the header stands the row's raw JSON in for one. + return query === null && present.length === 0 + ? null + : { query: query ?? '', ...Object.fromEntries(present) } +} + +/** `webSearch` carries no status: Codex starts it with an empty query and a null + * action, then sends the action, so `action` is the completion signal — a + * completed item's own `query` is routinely still empty. The hits arrive on + * `results` and are the call's output. */ +function webSearchItem(item: CodexThreadItem): CodexJournalItem { + const hits = Array.isArray(item.results) && item.results.length > 0 ? item.results : null + const bounded = hits && boundInlineText(JSON.stringify(hits), DEFAULT_JOURNAL_PAYLOAD_LIMITS) + return { + body: { + kind: 'tool-call', + name: 'web_search', + input: boundToolInput(webSearchInput(item), DEFAULT_JOURNAL_PAYLOAD_LIMITS), + state: item.action === null || item.action === undefined ? 'running' : 'completed', + ...(bounded === null ? {} : { output: bounded.bounded }) + }, + handled: true + } +} + /** * Journal body for a Codex item, or null for one with nothing to render. * @@ -319,6 +237,12 @@ export function codexJournalItem(item: CodexThreadItem): CodexJournalItem { if (item.type === 'fileChange') { return fileChangeItem(item) } + if (item.type === 'mcpToolCall') { + return mcpToolCallItem(item) + } + if (item.type === 'webSearch') { + return webSearchItem(item) + } if (item.type === 'reasoning' || item.type === 'plan') { const text = readTextContent(item, 'text') ?? diff --git a/src/main/codex/codex-thread-item-identity.ts b/src/main/codex/codex-thread-item-identity.ts new file mode 100644 index 00000000000..0488e5c00e9 --- /dev/null +++ b/src/main/codex/codex-thread-item-identity.ts @@ -0,0 +1,65 @@ +import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' +import type { CodexTurnOrdinals } from './codex-turn-ordinals' + +// Codex thread items → durable journal identities. +// +// THE ORDINAL RULE, and why it is not "index within the turn". Codex renumbers +// item ids positionally on resume (`item-1`…`item-N` across the whole thread), +// and a resumed turn does NOT contain every item the live turn emitted — +// reasoning and command execution are dropped from persisted history. Numbering +// by live position would therefore shift every message after the first tool +// call and hand the user a duplicate of the assistant's answer after a resume. +// +// So the ordinal counts MESSAGE items only, and the same projection is applied +// to the live stream and to a resumed turn's item list. Any other item type — +// including ones this build does not model — is skipped identically on both +// sides, which is what makes the key survive a Codex release that adds one. + +/** Only these carry a durable `(threadId, turnId, ordinal)` identity. */ +const CODEX_MESSAGE_ITEM_TYPES = new Set(['userMessage', 'agentMessage']) + +export type CodexThreadItem = { + type: string + id: string + [key: string]: unknown +} + +export function isCodexMessageItemType(type: string): boolean { + return CODEX_MESSAGE_ITEM_TYPES.has(type) +} + +export function readCodexThreadItem(value: unknown): CodexThreadItem | null { + if (typeof value !== 'object' || value === null) { + return null + } + const record = value as Record + return typeof record.type === 'string' && typeof record.id === 'string' + ? (record as CodexThreadItem) + : null +} + +/** + * Durable identity for a Codex item, or null for one that has none. + * + * Non-message items fall back to the `orca` namespace keyed by the Codex item + * id. That id is unstable across resume, so those rows are live-session detail + * that a recovered journal simply will not contain — which is correct: Codex + * itself does not persist them either. + */ +export function codexItemIdentity(input: { + threadId: string + turnId: string | null + item: CodexThreadItem + ordinals: CodexTurnOrdinals +}): AgentJournalItemIdentity { + const { item, turnId } = input + if (turnId && isCodexMessageItemType(item.type)) { + return { + provider: 'codex', + threadId: input.threadId, + turnId, + ordinal: input.ordinals.ordinalFor(input.threadId, turnId, item.id) + } + } + return { provider: 'orca', clientMessageId: `codex-item:${input.threadId}:${item.id}` } +} diff --git a/src/main/native-chat/agent-session-wire/provider-frame-disposition.test.ts b/src/main/native-chat/agent-session-wire/provider-frame-disposition.test.ts index 22bd645d8a6..9860aaa81d8 100644 --- a/src/main/native-chat/agent-session-wire/provider-frame-disposition.test.ts +++ b/src/main/native-chat/agent-session-wire/provider-frame-disposition.test.ts @@ -112,4 +112,52 @@ describe('provider frame classification catalog', () => { // An item type nobody has dispositioned still falls through visibly. expect(classifyProviderFrame('codex', 'item:futureThing', {})).toBe('timeline-substantive') }) + + it('chromes the one unmodelled codex item type that carries no content', () => { + expect(classifyProviderFrame('codex', 'item:sleep', { id: 's', durationMs: 20_000 })).toBe( + 'status-chrome' + ) + // Payload inspection still outranks the item catalog, so chroming a type + // cannot swallow one that reports a failure. + expect(classifyProviderFrame('codex', 'item:sleep', { id: 's', status: 'failed' })).toBe( + 'error-surface' + ) + }) + + it('keeps subagent items visible — the only evidence a spawned agent is working', () => { + expect( + classifyProviderFrame('codex', 'item:subAgentActivity', { + id: 'a-1', + kind: 'started', + agentThreadId: 'thread-child', + agentPath: '/root/list_directory' + }) + ).toBe('timeline-substantive') + expect( + classifyProviderFrame('codex', 'item:collabAgentToolCall', { + id: 'c-1', + tool: 'spawn', + status: 'inProgress', + senderThreadId: 'thread-root', + receiverThreadIds: ['thread-child'], + agentsStates: {} + }) + ).toBe('timeline-substantive') + }) + + it('leaves content-bearing codex item types on the visible fallback', () => { + // Each carries text or a path a user would want: review output, the image + // the agent looked at or generated, injected hook prompt text. + for (const type of [ + 'imageView', + 'imageGeneration', + 'enteredReviewMode', + 'exitedReviewMode', + 'hookPrompt' + ]) { + expect(classifyProviderFrame('codex', `item:${type}`, { id: 'i' }), type).toBe( + 'timeline-substantive' + ) + } + }) }) diff --git a/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts b/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts index 474b1385a4f..f05f4cd4c6c 100644 --- a/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts +++ b/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts @@ -197,7 +197,12 @@ function hasProviderError(payload: unknown): boolean { const CODEX_ITEM_CLASSIFICATIONS: Record = { // The `thread/compacted` notification is already chrome; its item form is the // same event and must not read as a mysterious opcode row. - contextCompaction: 'status-chrome' + contextCompaction: 'status-chrome', + // `{id, durationMs}` and nothing else — Codex's own transcript renders it as + // nothing at all. Every other item type this build does not model carries text + // a user would want (review output, an image path, hook prompt text, subagent + // progress), so those keep their visible fallback row. + sleep: 'status-chrome' } function notificationKind(kind: string): string { From 2513e2139043b3091ec8d61b60dcfef502c4af27 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:35:03 -0700 Subject: [PATCH 12/23] fix(native-chat): publish structured session status from the host so the sidebar never goes stale (#18776) * fix(native-chat): publish structured session status from the host The sidebar learned whether a structured chat was mid-turn by replaying the session journal in the renderer, through a reader whose lifetime was tied to the chat pane. Hiding the pane stopped the reader before the turn's settlement arrived, so the row stayed on "working" until the chat was reopened. The same coupling meant a tab never opened this session showed no status at all, and a reloaded renderer lost every settled row. The host owns the journal, so it now projects each session's status once per journal publication and fans the changes out on one stream per client (`agentSession.subscribeStatus`). The projection survives eviction of an idle session's provider child and is republished when readable sessions are restored. The renderer bridge subscribes to that feed per runtime target and never opens a transcript reader; the observation hook is gone. Additive wire surface behind the existing structured capability; old hosts reject the method and the renderer retries, showing no status. * fix(native-chat): negotiate the status feed and stop losing a change on subscribe The status stream is additive to a surface that already shipped, so a host advertising agent-session.structured.v1 can still answer subscribeStatus with method_not_found. Every renderer error path reconnected, so a remote host one release behind got a relay round-trip every 5s and no sidebar status at all. Give the method its own capability and probe it before subscribing; a failed probe still retries, an absent capability does not. Re-projecting on subscribe also wrote straight into the shared cache, so a second client could pin the first to a stale summary. Route those diffs through publish() before the arriving subscriber is registered. * fix(native-chat): bound the status prompt, merge snapshots, and prove the unread path One status frame carries every retained session and a send admits 256 KB per prompt, so ~16 large-prompt sessions could push the snapshot past the 4 MB outbound guard and into the retry loop. Bound latestPrompt to the same 200-char single-line preview every other agent-status row already carries. A snapshot also replaced the cached map wholesale, so the empty first frame from a restarting host retracted every row before restore republished them. Merge instead; the tab map, not this feed, decides which sessions are listed. Tests: the hidden-pane claim now sits at the host, where a journal with no transcript subscriber is driven from running to idle; the RPC test reads a real projection instead of its own stub. * fix(native-chat): merge the duplicated status-event type import * test(native-chat): pin the restart status publication, and log the unsupported host Startup restore indexes a readable session and publishes its status, which is what puts a never-reopened tab back in the sidebar. Only an Electron screenshot covered that wiring; a sitting status subscriber now pins it directly. The terminal "host too old" branch was silent, so a mixed-version report showed an empty sidebar with nothing in the log to explain it. --------- Co-authored-by: Merge Sim --- ...structured-agent-session-history-result.ts | 4 +- .../structured-agent-session-host-lifetime.ts | 23 ++ .../structured-agent-session-host.ts | 43 ++-- ...session-restart-status-publication.test.ts | 149 +++++++++++ ...ructured-agent-session-status-feed.test.ts | 242 ++++++++++++++++++ .../structured-agent-session-status-feed.ts | 130 ++++++++++ ...ructured-agent-session-subscribers.test.ts | 92 +++++++ .../structured-agent-session-subscribers.ts | 11 + .../structured-agent-session-status-stream.ts | 62 +++++ ...tructured-agent-session-subscription-id.ts | 29 +++ .../methods/structured-agent-session.test.ts | 79 +++++- .../rpc/methods/structured-agent-session.ts | 52 ++-- ...tructuredAgentSessionStatusBridge.test.tsx | 226 +++++++++------- .../StructuredAgentSessionStatusBridge.tsx | 73 +++--- ...use-structured-agent-session-read.test.tsx | 31 +-- .../use-structured-agent-session-read.ts | 7 - .../structured-agent-session-client.ts | 50 +++- ...ructured-agent-session-status-feed.test.ts | 140 ++++++++++ .../structured-agent-session-status-feed.ts | 211 +++++++++++++++ src/shared/agent-session-wire.ts | 30 ++- src/shared/protocol-version.ts | 5 + ...tructured-agent-session-projection.test.ts | 48 ++++ .../structured-agent-session-projection.ts | 29 +++ ...ss-version-agent-session-wire.unit.test.ts | 24 +- 24 files changed, 1556 insertions(+), 234 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts create mode 100644 src/main/runtime/rpc/methods/structured-agent-session-status-stream.ts create mode 100644 src/main/runtime/rpc/methods/structured-agent-session-subscription-id.ts create mode 100644 src/renderer/src/runtime/structured-agent-session-status-feed.test.ts create mode 100644 src/renderer/src/runtime/structured-agent-session-status-feed.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-history-result.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-history-result.ts index 70fc9a43ed2..b8e198c9b6b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-history-result.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-history-result.ts @@ -8,7 +8,7 @@ import type { import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { readAgentSessionHistory } from './agent-session-history-page' -function providerSessionMetadata( +export function structuredAgentSessionProviderSessionMetadata( record: AgentSessionRecord | null ): AgentProviderSessionMetadata | undefined { const head = record ? agentSessionProviderHandleChainHead(record.providerHandleChain) : null @@ -27,7 +27,7 @@ export function readStructuredAgentSessionHistoryResult(input: { }): AgentSessionHistoryResult { const result = readAgentSessionHistory(input.journal, input.request) const fence = input.record?.lease.runtimeFence - const providerSession = providerSessionMetadata(input.record) + const providerSession = structuredAgentSessionProviderSessionMetadata(input.record) if (fence === undefined) { return providerSession ? { ...result, providerSession } : result } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts index 2afd94ba128..ba62db1704e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts @@ -19,6 +19,8 @@ import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import { releaseStoredStructuredAgentSessionOwner } from './structured-agent-session-lease-release' +import { resumeHeldStructuredAgentSession } from './structured-agent-session-hold-resume' +import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' export type StructuredAgentSessionLifetimeContext = { deps: StructuredAgentSessionHostDeps @@ -67,6 +69,27 @@ export async function evictHeldStructuredAgentSession( ) } +/** The first hold on a childless session: reconcile the lease, settle recovery, then attach. */ +export async function resumeStructuredAgentSessionForHold( + context: StructuredAgentSessionLifetimeContext & { + reconcileLeases: (sessionId: string) => Promise + }, + sessionId: string, + attach: Parameters[0]['attach'] +): Promise { + const unreconciled = await context.reconcileLeases(sessionId) + if (unreconciled) { + throw new Error(unreconciled.code) + } + await context.runtimeState.resolveRecovery(sessionId) + await resumeHeldStructuredAgentSession({ + sessionId, + deps: context.deps, + now: context.now, + attach + }) +} + export function createStructuredAgentSessionHolds( context: StructuredAgentSessionLifetimeContext, input: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 8989f5e4d72..e4c7d191067 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -33,13 +33,13 @@ import { attachStructuredAgentSession } from './structured-agent-session-attach- import { createStructuredAgentSessionHolds, evictHeldStructuredAgentSession, + resumeStructuredAgentSessionForHold, type StructuredAgentSessionLifetimeContext } from './structured-agent-session-host-lifetime' import type { StructuredAgentSessionHolds, StructuredAgentSessionHoldOptions } from './structured-agent-session-holds' -import { resumeHeldStructuredAgentSession } from './structured-agent-session-hold-resume' import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' import { listStructuredAgentSessionTabs } from './structured-agent-session-host-tabs' import { @@ -57,6 +57,7 @@ import type { StructuredAgentSessionHostDeps, StructuredAgentSessionHostSession } from './structured-agent-session-host-types' +import { StructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed' import { StructuredAgentSessionEventRecovery } from './structured-agent-session-event-recovery' import { StructuredAgentSessionBackgroundTaskChannel } from './structured-agent-session-background-task-channel' import { withTimeout } from '../../../shared/promise-timeout-fallback' @@ -66,7 +67,14 @@ const HANDOFF_DRAIN_TIMEOUT_MS = 5_000 export class StructuredAgentSessionHost { private readonly sessions = new Map() - private readonly subscribers = new AgentSessionSubscribers() + private readonly statusFeed = new StructuredAgentSessionStatusFeed({ + sessions: this.sessions, + getRecord: (sessionId) => this.deps.store.getRecord(sessionId), + now: () => this.now() + }) + private readonly subscribers = new AgentSessionSubscribers({ + onJournalPublished: (sessionId, journal) => this.statusFeed.publish(sessionId, journal) + }) private readonly tasks = new StructuredAgentSessionTaskQueue() private readonly runtimeState: StructuredAgentSessionHostRuntimeState private readonly reconcileLeases: (sessionId: string) => Promise @@ -112,7 +120,12 @@ export class StructuredAgentSessionHost { now: this.now }) this.holds = createStructuredAgentSessionHolds(this.lifetimeContext(), { - resume: (sessionId) => this.resumeForHold(sessionId), + resume: (sessionId) => + resumeStructuredAgentSessionForHold( + { ...this.lifetimeContext(), reconcileLeases: this.reconcileLeases }, + sessionId, + (params) => this.attach({ callerKey: 'trusted-local:surface-hold' }, params) + ), evict: (sessionId) => this.close(sessionId) }) this.readableRestorer = new StructuredAgentSessionReadableRestorer({ @@ -125,7 +138,10 @@ export class StructuredAgentSessionHost { hasSession: (sessionId) => this.sessions.has(sessionId), // Site 10: cannot overwrite a live entry — the restorer returns early on // `hasSession` inside the same serialized step as this `set`. - onReadable: (sessionId, restored) => this.sessions.set(sessionId, restored), + onReadable: (sessionId, restored) => { + this.sessions.set(sessionId, restored) + this.statusFeed.publish(sessionId) + }, restoreHandoff: (sessionId) => this.handoffs.restore(sessionId) }) this.eventRecovery = new StructuredAgentSessionEventRecovery({ @@ -160,20 +176,6 @@ export class StructuredAgentSessionHost { /** That surface is gone. The child outlives it by the release grace, and by any running turn. */ release = (sessionId: string, holderId: string): void => this.holds.release(sessionId, holderId) - private async resumeForHold(sessionId: string): Promise { - const unreconciled = await this.reconcileLeases(sessionId) - if (unreconciled) { - throw new Error(unreconciled.code) - } - await this.runtimeState.resolveRecovery(sessionId) - await resumeHeldStructuredAgentSession({ - sessionId, - deps: this.deps, - now: () => this.now(), - attach: (params) => this.attach({ callerKey: 'trusted-local:surface-hold' }, params) - }) - } - handleAdapterEvent = (event: Parameters[0]) => this.eventRecovery.handle(event) @@ -342,6 +344,11 @@ export class StructuredAgentSessionHost { ) => this.backgroundTasks.publish(sessionId, state) unsubscribe = (sessionId: string, id: string): void => this.subscribers.close(sessionId, id) + /** Every session's projected status for session lists; unlike `subscribe`, retains nothing. */ + subscribeStatus = ( + subscriber: Parameters[0] + ): (() => void) => this.statusFeed.subscribe(subscriber) + private requireSession(sessionId: string): StructuredAgentSessionHostSession { const session = this.sessions.get(sessionId) if (!session) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts new file mode 100644 index 00000000000..884fba78a4e --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts @@ -0,0 +1,149 @@ +// Startup restore has to publish status, not just index the session. +// +// A tab nobody reopens after a restart still owes the sidebar a row. The host restores such a +// session read-only, without a provider child, so the only thing that can surface its state is +// the status publication the restore wiring makes. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import type { + AgentSessionMutationEnvelope, + AgentSessionStatusEvent +} from '../../../shared/agent-session-wire' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' + +const CALLER = { callerKey: 'client-1' } + +const hosts: StructuredAgentSessionHost[] = [] +let root = '' + +function adapter(): StructuredAgentSessionAdapter { + return { + acquire: async ({ fence, spawnToken }) => ({ + process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex', threadId: THREAD }, + origin: 'created', + mintedAtFence: fence, + observedAt: NOW + } + }), + dispatch: async () => ({ + state: 'accepted', + providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 1 } + }), + cancelTurn: async () => ({ cancelled: true }), + answerPrompt: async () => undefined, + setOption: async () => undefined + } +} + +function createHost(store: AgentSessionRecordStore): StructuredAgentSessionHost { + const host = new StructuredAgentSessionHost({ + store, + adapter: adapter(), + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + probeOwner: async () => ({ + outcome: 'indeterminate', + reason: 'read does not need ownership' + }), + now: () => NOW + }) + hosts.push(host) + return host +} + +function sendEnvelope( + store: AgentSessionRecordStore, + fields: Record +): AgentSessionMutationEnvelope { + return { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields + }) + } +} + +/** Persists one turn, then hands back a restarted host over the same directories. */ +async function restartWithPersistedTurn(): Promise { + root = await mkdtemp(join(tmpdir(), 'orca-restart-status-')) + resetHostTestOperationIds() + const directory = join(root, 'store') + const store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + const host = createHost(store) + expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) + const body = hostTestMessage('persisted conversation') + await host.send(CALLER, { envelope: sendEnvelope(store, { body }), body }) + await host.flushAllStreamedEvents() + return createHost(await AgentSessionRecordStore.open({ directory, hostId: 'local' })) +} + +afterEach(async () => { + await Promise.all(hosts.splice(0).map((host) => host.flushAllStreamedEvents())) + await rm(root, { recursive: true, force: true }) + root = '' +}) + +describe('structured session restart status publication', () => { + // Served by the subscribe-time re-projection rather than the restore's own publish, so this + // covers what a restored journal projects — not the restore wiring. The test below pins that. + it('projects the persisted turn of a session restored without a provider', async () => { + const restarted = await restartWithPersistedTurn() + + await restarted.restoreReadableSessions() + const events: AgentSessionStatusEvent[] = [] + restarted.subscribeStatus({ id: 'session-list', emit: (event) => events.push(event) }) + + expect(events).toEqual([ + { + type: 'snapshot', + sessions: [ + expect.objectContaining({ + sessionId: SESSION, + workspaceId: 'workspace-1', + agent: 'codex', + status: 'idle', + latestPrompt: 'persisted conversation' + }) + ] + } + ]) + }) + + it('publishes a restored session to a list already sitting on the stream', async () => { + const restarted = await restartWithPersistedTurn() + const events: AgentSessionStatusEvent[] = [] + restarted.subscribeStatus({ id: 'session-list', emit: (event) => events.push(event) }) + expect(events).toEqual([{ type: 'snapshot', sessions: [] }]) + + await restarted.restoreReadableSessions() + + // The restore wiring publishes; without it this list never hears about the session at all. + expect(events.at(-1)).toEqual({ + type: 'status', + session: expect.objectContaining({ sessionId: SESSION, status: 'idle' }) + }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts new file mode 100644 index 00000000000..7efd147c420 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts @@ -0,0 +1,242 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { AgentSessionStatusEvent } from '../../../shared/agent-session-wire' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import { StructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed' + +const SESSION = 'status-session' +const TURN_IDENTITY = { + provider: 'codex', + threadId: 'thread-1', + turnId: 'turn-1', + ordinal: 0 +} as const +const USER_IDENTITY = { + provider: 'codex', + threadId: 'thread-1', + turnId: 'turn-1', + ordinal: 1 +} as const + +let root: string +const journals = createTrackedJournalOpener() + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-agent-status-feed-')) +}) + +afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +async function openJournal(sessionId = SESSION) { + return journals.open({ + identity: { + sessionId, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } + }, + journalDir: join(root, sessionId) + }) +} + +function indexed(session: { journal: Awaited> }) { + return { + journal: session.journal, + params: { location: { workspaceId: 'workspace-1' }, provider: 'codex' as const } + } +} + +function feedFor(sessions: Map> }>) { + let now = 1_000 + const feed = new StructuredAgentSessionStatusFeed({ + sessions: { + get: (sessionId: string) => { + const session = sessions.get(sessionId) + return session ? indexed(session) : undefined + }, + [Symbol.iterator]: function* () { + for (const [sessionId, session] of sessions) { + yield [sessionId, indexed(session)] as const + } + } + } as unknown as ReadonlyMap>, + getRecord: () => null, + now: () => (now += 1) + }) + const events: AgentSessionStatusEvent[] = [] + const dispose = feed.subscribe({ id: 'list-1', emit: (event) => events.push(event) }) + return { feed, events, dispose } +} + +describe('StructuredAgentSessionStatusFeed', () => { + it('opens with every readable session and reports no status before a persisted turn', async () => { + const journal = await openJournal() + const { events } = feedFor(new Map([[SESSION, { journal }]])) + + expect(events).toEqual([ + { + type: 'snapshot', + sessions: [ + { + sessionId: SESSION, + workspaceId: 'workspace-1', + agent: 'codex', + status: null, + latestPrompt: '', + updatedAt: expect.any(Number) + } + ] + } + ]) + }) + + it('publishes working, then idle once the running marker is tombstoned, and never a repeat', async () => { + const journal = await openJournal() + const { feed, events } = feedFor(new Map([[SESSION, { journal }]])) + await journal.appendItem( + USER_IDENTITY, + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'write a poem' }] }, + { fence: 1 } + ) + await journal.appendItem( + TURN_IDENTITY, + { kind: 'status', text: 'Working', turnLifecycle: { turnId: 'turn-1', state: 'running' } }, + { fence: 1 } + ) + + feed.publish(SESSION) + feed.publish(SESSION) + expect(events.slice(1)).toEqual([ + { + type: 'status', + session: expect.objectContaining({ + sessionId: SESSION, + status: 'working', + latestPrompt: 'write a poem' + }) + } + ]) + + await journal.appendTombstone(TURN_IDENTITY, { fence: 1 }) + feed.publish(SESSION) + expect(events.at(-1)).toEqual({ + type: 'status', + session: expect.objectContaining({ sessionId: SESSION, status: 'idle' }) + }) + expect(events).toHaveLength(3) + }) + + it('reports a pending approval as attention', async () => { + const journal = await openJournal() + const { feed, events } = feedFor(new Map([[SESSION, { journal }]])) + await journal.appendItem( + USER_IDENTITY, + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'run it' }] }, + { fence: 1 } + ) + await journal.appendItem( + TURN_IDENTITY, + { + kind: 'approval', + title: 'Run command?', + detail: null, + options: [{ id: 'yes', label: 'Allow' }], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } + }, + { fence: 1 } + ) + + feed.publish(SESSION) + expect(events.at(-1)).toEqual({ + type: 'status', + session: expect.objectContaining({ status: 'attention' }) + }) + }) + + it('keeps the last projection for an evicted session and serves it to a new subscriber', async () => { + const journal = await openJournal() + const sessions = new Map([[SESSION, { journal }]]) + const { feed, events } = feedFor(sessions) + await journal.appendItem( + USER_IDENTITY, + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, + { fence: 1 } + ) + feed.publish(SESSION) + expect(events.at(-1)).toEqual({ + type: 'status', + session: expect.objectContaining({ status: 'idle', latestPrompt: 'hello' }) + }) + + // Eviction drops the host's index entry; the projection it already made stays true. + sessions.delete(SESSION) + feed.publish(SESSION) + const late: AgentSessionStatusEvent[] = [] + feed.subscribe({ id: 'list-late', emit: (event) => late.push(event) }) + + expect(events).toHaveLength(2) + expect(late).toEqual([ + { + type: 'snapshot', + sessions: [expect.objectContaining({ sessionId: SESSION, status: 'idle' })] + } + ]) + }) + + it('tells the sitting subscribers about a change a new subscriber re-projected', async () => { + const journal = await openJournal() + const { feed, events } = feedFor(new Map([[SESSION, { journal }]])) + // Journal appends and the feed's publish are separate queue submissions, so the journal + // can already hold the turn when a second client connects and re-projects it. + await journal.appendItem( + USER_IDENTITY, + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, + { fence: 1 } + ) + + const late: AgentSessionStatusEvent[] = [] + feed.subscribe({ id: 'list-late', emit: (event) => late.push(event) }) + + expect(events.at(-1)).toEqual({ + type: 'status', + session: expect.objectContaining({ status: 'idle', latestPrompt: 'hello' }) + }) + // The arriving subscriber reads that same state once, from its snapshot. + expect(late).toEqual([ + { + type: 'snapshot', + sessions: [expect.objectContaining({ status: 'idle', latestPrompt: 'hello' })] + } + ]) + // The cache is not left holding a value nobody was told about. + feed.publish(SESSION) + expect(events).toHaveLength(2) + }) + + it('ends a closed subscriber and keeps publishing to the rest', async () => { + const journal = await openJournal() + const { feed, events, dispose } = feedFor(new Map([[SESSION, { journal }]])) + const others: AgentSessionStatusEvent[] = [] + feed.subscribe({ id: 'list-2', emit: (event) => others.push(event) }) + + dispose() + await journal.appendItem( + USER_IDENTITY, + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, + { fence: 1 } + ) + feed.publish(SESSION) + + expect(events.at(-1)).toEqual({ type: 'end' }) + expect(others.at(-1)).toEqual({ + type: 'status', + session: expect.objectContaining({ status: 'idle' }) + }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts new file mode 100644 index 00000000000..5ad494cf830 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts @@ -0,0 +1,130 @@ +// The host's answer to "what is every structured session doing", fanned out to session lists. +// +// A client used to learn whether a turn was running by replaying the journal through its own +// reducer, which tied the answer to whichever surface happened to hold a reader open: hide the +// chat and the sidebar froze on the last thing it had heard. The host always has the journal, so +// it projects the status once per journal publication and sends only the changes. +// +// The last projection is kept after the session's provider child is evicted: an idle session is +// still idle without a process, and a renderer that reloads must not lose every settled row until +// each chat is reopened. Restart is the one boundary that forgets, and restoring readable sessions +// republishes them. + +import { agentProviderSessionsEqual } from '../../../shared/agent-session-resume' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { + AgentSessionStatusEvent, + AgentSessionStatusSummary +} from '../../../shared/agent-session-wire' +import { projectStructuredAgentSessionStatusSummary } from '../../../shared/structured-agent-session-projection' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { structuredAgentSessionProviderSessionMetadata } from './structured-agent-session-history-result' + +export type StructuredAgentSessionStatusSubscriber = { + id: string + emit: (event: AgentSessionStatusEvent) => void +} + +type StatusFeedSession = { + journal: AgentSessionJournal + params: { location: { workspaceId: string }; provider: AgentSessionRecord['provider'] } +} + +export type StructuredAgentSessionStatusFeedDeps = { + sessions: ReadonlyMap + getRecord: (sessionId: string) => AgentSessionRecord | null + now: () => number +} + +function summariesEqual(a: AgentSessionStatusSummary, b: AgentSessionStatusSummary): boolean { + return ( + a.workspaceId === b.workspaceId && + a.agent === b.agent && + a.status === b.status && + a.latestPrompt === b.latestPrompt && + agentProviderSessionsEqual(undefined, a.providerSession, b.providerSession) + ) +} + +export class StructuredAgentSessionStatusFeed { + private readonly subscribers = new Map() + private readonly published = new Map() + + constructor(private readonly deps: StructuredAgentSessionStatusFeedDeps) {} + + /** Opens with every session this host has projected, live ones re-read, then only changes. */ + subscribe(subscriber: StructuredAgentSessionStatusSubscriber): () => void { + // Re-project before registering: a change found here has to reach the subscribers that + // already read the old value, and the arriving one carries it in its snapshot instead. + for (const [sessionId] of this.deps.sessions) { + this.publish(sessionId) + } + this.subscribers.set(subscriber.id, subscriber) + this.emit(subscriber, { type: 'snapshot', sessions: [...this.published.values()] }) + return () => this.unsubscribe(subscriber.id) + } + + unsubscribe(id: string): void { + const subscriber = this.subscribers.get(id) + if (!subscriber) { + return + } + this.subscribers.delete(id) + try { + subscriber.emit({ type: 'end' }) + } catch { + // The transport is already gone; teardown must remain idempotent. + } + } + + /** Re-projects one session after its journal changed; equal projections are not re-sent. */ + publish(sessionId: string, journal?: AgentSessionJournal): void { + const session = this.deps.sessions.get(sessionId) + if (!session) { + return + } + const summary = this.summaryFor(sessionId, session, journal ?? session.journal) + const previous = this.published.get(sessionId) + if (previous && summariesEqual(previous, summary)) { + return + } + this.published.set(sessionId, summary) + this.broadcast({ type: 'status', session: summary }) + } + + private summaryFor( + sessionId: string, + session: StatusFeedSession, + journal: AgentSessionJournal + ): AgentSessionStatusSummary { + // An unreadable journal projects as "no turn": the chat itself shows the reset. + const items = journal.isReadOnly ? [] : journal.snapshot().items + const providerSession = structuredAgentSessionProviderSessionMetadata( + this.deps.getRecord(sessionId) + ) + return { + sessionId, + workspaceId: session.params.location.workspaceId, + agent: session.params.provider, + ...projectStructuredAgentSessionStatusSummary(items), + ...(providerSession ? { providerSession } : {}), + updatedAt: this.deps.now() + } + } + + private broadcast(event: AgentSessionStatusEvent): void { + // A Map skips entries deleted mid-iteration, so a failing subscriber can drop itself here. + for (const subscriber of this.subscribers.values()) { + this.emit(subscriber, event) + } + } + + /** A dead transport must not poison every later publication. */ + private emit(subscriber: StructuredAgentSessionStatusSubscriber, event: AgentSessionStatusEvent) { + try { + subscriber.emit(event) + } catch { + this.subscribers.delete(subscriber.id) + } + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts index 6e156d91532..81bcfa82b40 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION } from '../../../shared/agent-session-journal-types' import type { AgentSessionHandoffStatus, + AgentSessionStatusEvent, AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' import { @@ -16,6 +17,7 @@ import { journalDatabaseFile } from '../agent-session-journal/journal-paths' import { insertJournalRow } from '../agent-session-journal/journal-row-table' import type { JournalRow } from '../agent-session-journal/journal-row-schema' import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import { StructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed' import { AgentSessionSubscribers } from './structured-agent-session-subscribers' const SESSION = 'subscriber-session' @@ -70,6 +72,96 @@ describe('AgentSessionSubscribers', () => { ]) }) + it('reports every content publication to the journal hook, subscribed or not', async () => { + const journal = await journals.open({ + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } + }, + journalDir: join(root, 'hook-journal') + }) + const published: string[] = [] + const subscribers = new AgentSessionSubscribers({ + onJournalPublished: (sessionId, published_journal) => { + expect(published_journal).toBe(journal) + published.push(sessionId) + } + }) + + subscribers.publish(SESSION, journal) + subscribers.reset(SESSION, journal, 'epoch_changed', 1) + subscribers.snapshot(SESSION, journal, 1) + subscribers.handoff(SESSION, 1, { + owner: 'native', + direction: null, + phase: 'idle', + stage: null, + operationId: null + }) + + expect(published).toEqual([SESSION, SESSION, SESSION]) + }) + + it('settles a session nobody is reading, from running to idle', async () => { + // The defect this whole feed exists for: status used to come from a transcript reader, so a + // session with no open pane had no reader and froze on whatever it last said. Nothing here + // ever calls `subscribers.open`. + const journal = await journals.open({ + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } + }, + journalDir: join(root, 'unread-journal') + }) + const statusFeed = new StructuredAgentSessionStatusFeed({ + sessions: new Map([ + [ + SESSION, + { journal, params: { location: { workspaceId: 'workspace-1' }, provider: 'codex' } } + ] + ]), + getRecord: () => null, + now: () => 1_000 + }) + const subscribers = new AgentSessionSubscribers({ + onJournalPublished: (sessionId, published) => statusFeed.publish(sessionId, published) + }) + const statuses: AgentSessionStatusEvent[] = [] + statusFeed.subscribe({ id: 'session-list', emit: (event) => statuses.push(event) }) + const turn = { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 0 } as const + + await journal.appendItem( + { ...turn, ordinal: 1 }, + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'write a poem' }] }, + { fence: 1 } + ) + await journal.appendItem( + turn, + { kind: 'status', text: 'Working', turnLifecycle: { turnId: 'turn-1', state: 'running' } }, + { fence: 1 } + ) + subscribers.publish(SESSION, journal) + + expect(statuses.at(-1)).toEqual({ + type: 'status', + session: expect.objectContaining({ status: 'working', latestPrompt: 'write a poem' }) + }) + + await journal.appendTombstone(turn, { fence: 1 }) + subscribers.publish(SESSION, journal) + + expect(statuses.at(-1)).toEqual({ + type: 'status', + session: expect.objectContaining({ status: 'idle' }) + }) + }) + it('publishes handoff-only changes without serializing a transcript snapshot', async () => { const journal = await journals.open({ identity: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts index d3131505384..29dffa6a687 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts @@ -36,9 +36,17 @@ type Subscriber = { fence: number } +export type AgentSessionSubscribersHooks = { + /** Fires after any publication that can change journal content, whether or not anyone + * is subscribed to the transcript: session lists project status from this same edge. */ + onJournalPublished?: (sessionId: string, journal: AgentSessionJournal) => void +} + export class AgentSessionSubscribers { private readonly bySession = new Map>() + constructor(private readonly hooks: AgentSessionSubscribersHooks = {}) {} + /** Opens the stream with a bounded tail page or, when the client's cursor * still resolves, with the rows it missed. Returns the disposer. */ open(input: { @@ -99,6 +107,7 @@ export class AgentSessionSubscribers { for (const subscriber of this.subscribers(sessionId)) { this.deliver(subscriber, journal) } + this.hooks.onJournalPublished?.(sessionId, journal) } /** Force every subscriber back to a bounded tail page — recovery, epoch @@ -123,6 +132,7 @@ export class AgentSessionSubscribers { subscriber.cursor = page.liveCursor ?? page.window.nextCursor subscriber.fence = fence } + this.hooks.onJournalPublished?.(sessionId, journal) } snapshot( @@ -143,6 +153,7 @@ export class AgentSessionSubscribers { subscriber.cursor = page.liveCursor ?? page.window.nextCursor subscriber.fence = fence } + this.hooks.onJournalPublished?.(sessionId, journal) } handoff(sessionId: string, fence: number, handoff: AgentSessionHandoffStatus): void { diff --git a/src/main/runtime/rpc/methods/structured-agent-session-status-stream.ts b/src/main/runtime/rpc/methods/structured-agent-session-status-stream.ts new file mode 100644 index 00000000000..8637089c254 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-status-stream.ts @@ -0,0 +1,62 @@ +// `agentSession.subscribeStatus` — every structured session's projected status on one stream. +// +// Session lists read turn state from here instead of replaying transcripts: one stream per client +// covers every session, and unlike a transcript subscription it retains none of them. + +import { defineStreamingMethod, type RpcAnyMethod, type RpcContext } from '../core' +import { requireStructuredHost as requireHost } from './structured-agent-session-gate' +import { structuredAgentSessionStatusSubscriptionId } from './structured-agent-session-subscription-id' + +/** Ties a stream to both ends that can close it — the runtime's subscription registry and the + * transport abort — so either one runs `onClose` exactly once. */ +export function bindStructuredAgentSessionStream( + ctx: RpcContext, + subscriptionId: string, + onClose: () => void +): { isClosed: () => boolean } { + let closed = false + let releaseTransportSubscription = (): void => {} + const onTransportAbort = (): void => releaseTransportSubscription() + const cleanup = (): void => { + closed = true + ctx.signal?.removeEventListener('abort', onTransportAbort) + onClose() + } + let registration: { releaseIfCurrent: () => void } + if (typeof ctx.runtime.registerOwnedSubscriptionCleanup === 'function') { + registration = ctx.runtime.registerOwnedSubscriptionCleanup( + subscriptionId, + cleanup, + ctx.connectionId + ) + } else { + ctx.runtime.registerSubscriptionCleanup(subscriptionId, cleanup, ctx.connectionId) + registration = { releaseIfCurrent: () => ctx.runtime.cleanupSubscription(subscriptionId) } + } + releaseTransportSubscription = registration.releaseIfCurrent + ctx.signal?.addEventListener('abort', onTransportAbort, { once: true }) + if (ctx.signal?.aborted) { + onTransportAbort() + } + return { isClosed: () => closed } +} + +export const STRUCTURED_AGENT_SESSION_STATUS_METHODS: RpcAnyMethod[] = [ + defineStreamingMethod({ + name: 'agentSession.subscribeStatus', + params: null, + handler: async (_params, ctx, emit) => { + const host = requireHost(ctx) + const subscriptionId = structuredAgentSessionStatusSubscriptionId(ctx) + let dispose = (): void => {} + const stream = bindStructuredAgentSessionStream(ctx, subscriptionId, () => dispose()) + if (stream.isClosed()) { + return + } + dispose = host.subscribeStatus({ id: subscriptionId, emit }) + if (stream.isClosed()) { + dispose() + } + } + }) +] diff --git a/src/main/runtime/rpc/methods/structured-agent-session-subscription-id.ts b/src/main/runtime/rpc/methods/structured-agent-session-subscription-id.ts new file mode 100644 index 00000000000..f3d00232359 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-subscription-id.ts @@ -0,0 +1,29 @@ +// Subscription ids for the streaming `agentSession.*` methods. +// +// Shared control multiplexes several streams over one socket, so the frame id keeps one +// subscriber from evicting another. It is appended only when present: collapsing a missing +// frame id to a constant is the collision the rule exists to prevent. + +import type { RpcContext } from '../core' + +const SUBSCRIPTION_PREFIX = 'agentSession' + +function withFrameId(ctx: RpcContext, base: string): string { + return ctx.requestId ? `${base}:${ctx.requestId}` : base +} + +/** The id a session's streams share before the frame id. `unsubscribe` addresses this + * directly and sweeps `${base}:` to reach every frame under it. */ +export function structuredAgentSessionSubscriptionBase(ctx: RpcContext, sessionId: string): string { + return `${SUBSCRIPTION_PREFIX}:${ctx.connectionId ?? 'local'}:${sessionId}` +} + +/** One session's transcript stream. */ +export function structuredAgentSessionSubscriptionId(ctx: RpcContext, sessionId: string): string { + return withFrameId(ctx, structuredAgentSessionSubscriptionBase(ctx, sessionId)) +} + +/** The status feed, which is per connection rather than per session. */ +export function structuredAgentSessionStatusSubscriptionId(ctx: RpcContext): string { + return withFrameId(ctx, `${SUBSCRIPTION_PREFIX}.status:${ctx.connectionId ?? 'local'}`) +} diff --git a/src/main/runtime/rpc/methods/structured-agent-session.test.ts b/src/main/runtime/rpc/methods/structured-agent-session.test.ts index e4888e4a9df..69b04711960 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.test.ts @@ -2,8 +2,14 @@ // accepts once they can. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' +import type { AgentSessionJournal } from '../../../native-chat/agent-session-journal/journal-store' import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' +import { + StructuredAgentSessionStatusFeed, + type StructuredAgentSessionStatusSubscriber +} from '../../../native-chat/agent-session-wire/structured-agent-session-status-feed' import { RUNTIME_CAPABILITIES, RUNTIME_PROTOCOL_VERSION, @@ -64,6 +70,44 @@ function request(method: string, params: unknown): RpcRequest { let hostCalls: Record> let runtimeCalls: Record> +const STATUS_SESSION = 'session-status' +const STATUS_ITEMS: AgentJournalRenderItem[] = [ + { + itemId: 'user-1', + sequence: 1, + revision: 1, + observedAt: 1, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'write a poem' }] } + }, + { + itemId: 'turn-1', + sequence: 2, + revision: 1, + observedAt: 2, + body: { kind: 'status', text: 'Working', turnLifecycle: { turnId: 'turn-1', state: 'running' } } + } +] + +/** One indexed session over a journal that reads back fixed items; the projection is real. */ +function statusFeed(): StructuredAgentSessionStatusFeed { + return new StructuredAgentSessionStatusFeed({ + sessions: new Map([ + [ + STATUS_SESSION, + { + journal: { + isReadOnly: false, + snapshot: () => ({ items: STATUS_ITEMS }) + } as unknown as AgentSessionJournal, + params: { location: { workspaceId: 'workspace-1' }, provider: 'codex' as const } + } + ] + ]), + getRecord: () => null, + now: () => 1_000 + }) +} + function hostStub(): StructuredAgentSessionHost { hostCalls = { attach: vi.fn(async () => ({ @@ -122,6 +166,11 @@ function hostStub(): StructuredAgentSessionHost { })), history: vi.fn(() => ({ ok: true, page: { items: [] } })), subscribe: vi.fn(() => () => undefined), + // A real feed, so the snapshot this method hands back is a genuine projection rather + // than a shape the stub restated. + subscribeStatus: vi.fn((subscriber: StructuredAgentSessionStatusSubscriber) => + statusFeed().subscribe(subscriber) + ), unsubscribe: vi.fn() } return hostCalls as unknown as StructuredAgentSessionHost @@ -240,7 +289,7 @@ describe('capability gating', () => { } // Bump deliberately: the whole agentSession.* surface is behind the structured capability, // so an additive method is invisible to old clients and needs no protocol bump. - expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(17) + expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(18) }) it('hides the surface from a declared client that did not advertise it', async () => { @@ -583,3 +632,31 @@ describe('parameter validation', () => { expect(response).toMatchObject({ ok: true }) }) }) + +describe('agentSession.subscribeStatus', () => { + it('is invisible to a client without the structured capability', async () => { + const reply = await call('agentSession.subscribeStatus', null, { clientKind: 'runtime' }) + expect(reply.ok).toBe(false) + expect(hostCalls.subscribeStatus).not.toHaveBeenCalled() + }) + + it('opens the host status feed with a projected snapshot as its first reply', async () => { + const reply = await call('agentSession.subscribeStatus', null, STRUCTURED_CLIENT) + expect(reply).toMatchObject({ + ok: true, + result: { + type: 'snapshot', + sessions: [ + { + sessionId: STATUS_SESSION, + workspaceId: 'workspace-1', + agent: 'codex', + status: 'working', + latestPrompt: 'write a poem' + } + ] + } + }) + expect(hostCalls.subscribeStatus).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index b69ff6fd628..61b0f4dcf4f 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -21,6 +21,14 @@ import { import type { AgentSessionAttachParams } from '../../../native-chat/agent-session-wire/structured-agent-session-attach' import { STRUCTURED_AGENT_SESSION_HOLD_METHODS } from './structured-agent-session-hold' import { resolveUncommittedStructuredCreate } from './structured-agent-session-precommit-refusal' +import { + bindStructuredAgentSessionStream, + STRUCTURED_AGENT_SESSION_STATUS_METHODS +} from './structured-agent-session-status-stream' +import { + structuredAgentSessionSubscriptionBase as subscriptionBaseFor, + structuredAgentSessionSubscriptionId as subscriptionIdFor +} from './structured-agent-session-subscription-id' import { AttachParams, CancelParams, @@ -37,15 +45,6 @@ import { UnsubscribeParams } from './structured-agent-session-schemas' -const SUBSCRIPTION_PREFIX = 'agentSession' - -function subscriptionIdFor(ctx: RpcContext, sessionId: string): string { - const base = `${SUBSCRIPTION_PREFIX}:${ctx.connectionId ?? 'local'}:${sessionId}` - // Shared control multiplexes several streams over one socket; the frame id - // keeps one subscriber from evicting another on the same session. - return ctx.requestId ? `${base}:${ctx.requestId}` : base -} - /** * The attach-shaped entries take the location from the client instead of resolving it from a * worktree, so they never reach the worktree-resolving create-support check. Ask the executing @@ -245,33 +244,12 @@ export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [ // Retain-only: reading history must never be what starts a provider process. Current clients // explicitly hold every open surface before subscribing. const streamHolder = `subscription:${subscriptionId}` - let closed = false let dispose = (): void => {} - let releaseTransportSubscription = (): void => {} - const onTransportAbort = (): void => releaseTransportSubscription() - const cleanup = () => { - closed = true - ctx.signal?.removeEventListener('abort', onTransportAbort) + const stream = bindStructuredAgentSessionStream(ctx, subscriptionId, () => { dispose() host.release(params.sessionId, streamHolder) - } - let registration: { releaseIfCurrent: () => void } - if (typeof ctx.runtime.registerOwnedSubscriptionCleanup === 'function') { - registration = ctx.runtime.registerOwnedSubscriptionCleanup( - subscriptionId, - cleanup, - ctx.connectionId - ) - } else { - ctx.runtime.registerSubscriptionCleanup(subscriptionId, cleanup, ctx.connectionId) - registration = { releaseIfCurrent: () => ctx.runtime.cleanupSubscription(subscriptionId) } - } - releaseTransportSubscription = registration.releaseIfCurrent - ctx.signal?.addEventListener('abort', onTransportAbort, { once: true }) - if (ctx.signal?.aborted) { - onTransportAbort() - } - if (closed) { + }) + if (stream.isClosed()) { return } // The host emits the opening snapshot (or the missed batch) synchronously @@ -282,7 +260,7 @@ export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [ emit, ...(params.cursor ? { cursor: params.cursor } : {}) }) - if (closed) { + if (stream.isClosed()) { dispose() } else { // Fire-and-forget, but never unhandled: a resume that refuses leaves the stream holding a @@ -300,8 +278,7 @@ export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [ params: UnsubscribeParams, handler: async (params, ctx) => { requireHost(ctx) - const connection = ctx.connectionId ?? 'local' - const base = `${SUBSCRIPTION_PREFIX}:${connection}:${params.sessionId}` + const base = subscriptionBaseFor(ctx, params.sessionId) if (params.subscriptionId) { ctx.runtime.cleanupSubscription(`${base}:${params.subscriptionId}`) return { unsubscribed: true } @@ -311,5 +288,6 @@ export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [ return { unsubscribed: true } } }), - ...STRUCTURED_AGENT_SESSION_HOLD_METHODS + ...STRUCTURED_AGENT_SESSION_HOLD_METHODS, + ...STRUCTURED_AGENT_SESSION_STATUS_METHODS ] diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx index 8a61399d669..a8eb22ae7a5 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx @@ -2,17 +2,23 @@ import { act, cleanup, render, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + AgentSessionStatusEvent, + AgentSessionStatusSummary +} from '../../../../shared/agent-session-wire' import type { Tab } from '../../../../shared/tab-types' +import type * as RuntimeRpcClientModule from '@/runtime/runtime-rpc-client' const mocks = vi.hoisted(() => ({ - call: vi.fn(), removeAgentStatus: vi.fn(), setAgentStatus: vi.fn(), store: null as null | { getState: () => Record setState: (state: Record) => void }, - subscribe: vi.fn(), + subscribeStatus: vi.fn(), + subscribeTranscript: vi.fn(), + supportsCapability: vi.fn(), unsubscribe: vi.fn() })) @@ -73,17 +79,22 @@ vi.mock('@/lib/worktree-runtime-owner', () => ({ state.testRuntimeOwner ?? null })) +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => ({ + ...(await importOriginal()), + runtimeEnvironmentSupportsCapability: mocks.supportsCapability +})) + vi.mock('@/runtime/structured-agent-session-client', () => ({ - callStructuredAgentSession: mocks.call, - subscribeStructuredAgentSession: mocks.subscribe + callStructuredAgentSession: vi.fn(), + subscribeStructuredAgentSession: mocks.subscribeTranscript, + subscribeStructuredAgentSessionStatus: mocks.subscribeStatus })) import { getStructuredAgentSessionTabs, StructuredAgentSessionStatusBridge } from './StructuredAgentSessionStatusBridge' -import { resetStructuredAgentSessionReadOwnersForTests } from './structured-agent-session-read-owner' -import { useStructuredAgentSessionRead } from './use-structured-agent-session-read' +import { resetStructuredAgentSessionStatusFeedsForTests } from '@/runtime/structured-agent-session-status-feed' const structuredTab = { id: 'structured-tab-1', @@ -100,60 +111,40 @@ const structuredTab = { agentSessionAgent: 'codex' } satisfies Tab -const userItem = { - itemId: 'item-1', - revision: 1, - sequence: 1, - observedAt: 1, - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] } -} as const +const providerSession = { key: 'session_id', id: '01a002e9-9a1c-7d42-a642-e481f64446f1' } as const -const historyResult = { - ok: true, - providerSession: { key: 'session_id', id: '01a002e9-9a1c-7d42-a642-e481f64446f1' }, - page: { +function summary(overrides: Partial = {}): AgentSessionStatusSummary { + return { sessionId: 'session-1', - epoch: 'epoch-1', - fence: 1, - direction: 'tail', - items: [userItem], - removedItemIds: [], - submissions: [], - window: { - oldest: { epoch: 'epoch-1', sequence: 1 }, - newest: { epoch: 'epoch-1', sequence: 1 }, - nextCursor: { epoch: 'epoch-1', sequence: 1 } - }, - liveCursor: { epoch: 'epoch-1', sequence: 1 }, - hasOlder: false, - hasNewer: false + workspaceId: 'wt-1', + agent: 'codex', + status: 'working', + latestPrompt: 'hello', + providerSession, + updatedAt: 1, + ...overrides } } -function ActiveSessionRead(): null { - useStructuredAgentSessionRead({ - sessionId: structuredTab.entityId, - target: { kind: 'local' }, - isVisible: true - }) - return null +function statuses(): Record[] { + return Object.values(mocks.store?.getState().agentStatusByPaneKey ?? {}) } -function ActiveComposition(): React.JSX.Element { - return ( - <> - - - - ) +/** The host side of the most recent status subscription. */ +function feed(index = 0): { target: unknown; emit: (event: AgentSessionStatusEvent) => void } { + const call = mocks.subscribeStatus.mock.calls[index] + if (!call) { + throw new Error('status feed not subscribed') + } + return { target: call[0], emit: call[1] as (event: AgentSessionStatusEvent) => void } } describe('StructuredAgentSessionStatusBridge', () => { beforeEach(() => { vi.clearAllMocks() - resetStructuredAgentSessionReadOwnersForTests() - mocks.call.mockResolvedValue(historyResult) - mocks.subscribe.mockResolvedValue({ unsubscribe: mocks.unsubscribe }) + resetStructuredAgentSessionStatusFeedsForTests() + mocks.subscribeStatus.mockResolvedValue({ unsubscribe: mocks.unsubscribe }) + mocks.supportsCapability.mockResolvedValue(true) mocks.store?.setState({ agentStatusByPaneKey: {}, testRuntimeOwner: null, @@ -163,7 +154,7 @@ describe('StructuredAgentSessionStatusBridge', () => { afterEach(() => { cleanup() - resetStructuredAgentSessionReadOwnersForTests() + resetStructuredAgentSessionStatusFeedsForTests() }) it('reuses the structured-tab projection for an unchanged tab map', () => { @@ -194,65 +185,111 @@ describe('StructuredAgentSessionStatusBridge', () => { ]) }) - it('keeps restored inactive tabs transport-neutral', async () => { + it('projects the host status feed without opening a transcript reader', async () => { render() - await act(() => Promise.resolve()) + await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce()) + expect(feed().target).toEqual({ kind: 'local' }) + expect(mocks.subscribeTranscript).not.toHaveBeenCalled() - expect(mocks.call).not.toHaveBeenCalled() - expect(mocks.subscribe).not.toHaveBeenCalled() + act(() => feed().emit({ type: 'snapshot', sessions: [summary()] })) + + expect(statuses()).toEqual([ + expect.objectContaining({ + state: 'working', + prompt: 'hello', + agentType: 'codex', + sessionBoundary: false, + tabId: structuredTab.id, + worktreeId: 'wt-1', + terminalTitle: 'Codex Chat', + terminalResumeEligible: false, + providerSession + }) + ]) + }) + + // Hiddenness is the host's side of this: see structured-agent-session-subscribers.test.ts, + // which drives an unsubscribed journal through the feed. Here the transport is a mock, so + // only the summary-to-store mapping is under test. + it('maps each host status onto the sidebar agent state', async () => { + render() + await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce()) + act(() => feed().emit({ type: 'snapshot', sessions: [summary()] })) + expect(statuses()).toEqual([expect.objectContaining({ state: 'working' })]) + + act(() => feed().emit({ type: 'status', session: summary({ status: 'idle', updatedAt: 2 }) })) + expect(statuses()).toEqual([expect.objectContaining({ state: 'done', sessionBoundary: true })]) + + act(() => + feed().emit({ type: 'status', session: summary({ status: 'attention', updatedAt: 3 }) }) + ) + expect(statuses()).toEqual([expect.objectContaining({ state: 'blocked' })]) + }) + + it('shows no status before a persisted turn', async () => { + render() + await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce()) + + act(() => feed().emit({ type: 'snapshot', sessions: [summary({ status: null })] })) expect(mocks.setAgentStatus).not.toHaveBeenCalled() + + act(() => feed().emit({ type: 'status', session: summary({ updatedAt: 2 }) })) + expect(statuses()).toEqual([expect.objectContaining({ state: 'working' })]) }) - it('shares the visible pane subscriber with status projection', async () => { - render() - - await waitFor(() => expect(mocks.setAgentStatus).toHaveBeenCalledOnce()) - expect(mocks.call).toHaveBeenCalledOnce() - expect(mocks.subscribe).toHaveBeenCalledOnce() - expect(mocks.setAgentStatus.mock.calls[0]?.[5]).toEqual({ - providerSession: historyResult.providerSession, - terminalResumeEligible: false - }) - }) - - it('keeps the status map reference stable for coalesced assistant deltas', async () => { - render() - await waitFor(() => expect(mocks.setAgentStatus).toHaveBeenCalledOnce()) + it('keeps the status map reference stable for repeated equal summaries', async () => { + render() + await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce()) + act(() => feed().emit({ type: 'snapshot', sessions: [summary()] })) const before = mocks.store?.getState().agentStatusByPaneKey - const onEvent = mocks.subscribe.mock.calls[0]?.[2] as (event: unknown) => void act(() => { - for (let sequence = 2; sequence <= 12; sequence += 1) { - onEvent({ - type: 'batch', - sessionId: 'session-1', - batch: { - cursor: { epoch: 'epoch-1', sequence }, - items: [ - { - itemId: 'assistant-1', - revision: sequence, - sequence, - observedAt: sequence, - body: { - kind: 'message', - role: 'assistant', - blocks: [{ type: 'text', text: `delta-${sequence}` }] - } - } - ], - removedItemIds: [], - submissions: [] - } - }) + for (let updatedAt = 2; updatedAt <= 12; updatedAt += 1) { + feed().emit({ type: 'status', session: summary({ updatedAt }) }) } }) - await act(async () => new Promise((resolve) => setTimeout(resolve, 60))) expect(mocks.setAgentStatus).toHaveBeenCalledOnce() expect(mocks.store?.getState().agentStatusByPaneKey).toBe(before) }) + it('drops the status and the feed when the last structured tab closes', async () => { + render() + await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce()) + act(() => feed().emit({ type: 'snapshot', sessions: [summary()] })) + expect(statuses()).toHaveLength(1) + + act(() => mocks.store?.setState({ unifiedTabsByWorktree: { 'wt-1': [] } })) + + expect(statuses()).toEqual([]) + await waitFor(() => expect(mocks.unsubscribe).toHaveBeenCalledOnce()) + }) + + it('reconnects after the host ends the stream', async () => { + vi.useFakeTimers() + try { + render() + await act(() => Promise.resolve()) + expect(mocks.subscribeStatus).toHaveBeenCalledOnce() + + act(() => feed().emit({ type: 'end' })) + await act(() => vi.advanceTimersByTimeAsync(300)) + + expect(mocks.unsubscribe).toHaveBeenCalledOnce() + expect(mocks.subscribeStatus).toHaveBeenCalledTimes(2) + } finally { + vi.useRealTimers() + } + }) + + it('keys the feed by the worktree runtime environment', async () => { + mocks.store?.setState({ testRuntimeOwner: 'env-1' }) + render() + await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce()) + + expect(feed().target).toEqual({ kind: 'environment', environmentId: 'env-1' }) + }) + it('does not project an unknown provider as Codex', async () => { mocks.store?.setState({ unifiedTabsByWorktree: { @@ -262,8 +299,7 @@ describe('StructuredAgentSessionStatusBridge', () => { render() await act(() => Promise.resolve()) - expect(mocks.call).not.toHaveBeenCalled() - expect(mocks.subscribe).not.toHaveBeenCalled() + expect(mocks.subscribeStatus).not.toHaveBeenCalled() expect(mocks.setAgentStatus).not.toHaveBeenCalled() }) }) diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx index 8409858dd17..d4cb74ab93b 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx @@ -1,19 +1,14 @@ -import { useEffect, useMemo } from 'react' +import { useEffect, useMemo, useSyncExternalStore } from 'react' import { useShallow } from 'zustand/react/shallow' -import type { AgentProviderSessionMetadata } from '../../../../shared/agent-session-resume' import { agentProviderSessionsEqual } from '../../../../shared/agent-session-resume' -import { - hasPersistedStructuredAgentSessionTurn, - projectStructuredAgentSessionStatus, - structuredAgentSessionPaneKey -} from '../../../../shared/structured-agent-session-projection' -import type { StructuredAgentSessionState } from '../../../../shared/structured-agent-session-reducer' +import type { AgentSessionStatusSummary } from '../../../../shared/agent-session-wire' +import { structuredAgentSessionPaneKey } from '../../../../shared/structured-agent-session-projection' import type { Tab } from '../../../../shared/tab-types' import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { useAppStore } from '@/store' -import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' -import { useStructuredAgentSessionReadObservation } from './use-structured-agent-session-read' +import { getActiveRuntimeTarget, type RuntimeClientTarget } from '@/runtime/runtime-rpc-client' +import { getStructuredAgentSessionStatusFeed } from '@/runtime/structured-agent-session-status-feed' type StructuredTab = Tab & { contentType: 'agent-session' } @@ -47,35 +42,40 @@ export function getStructuredAgentSessionTabs( return tabs } -function latestPrompt(state: StructuredAgentSessionState): string { - for (let index = state.items.length - 1; index >= 0; index -= 1) { - const body = state.items[index]?.body - if (body?.kind === 'message' && body.role === 'user') { - return body.blocks.flatMap((block) => (block.type === 'text' ? [block.text] : [])).join('\n') - } - } - return '' +/** The host's projected status for one session, live while the caller is mounted. */ +function useStructuredAgentSessionStatusSummary( + sessionId: string, + target: RuntimeClientTarget +): AgentSessionStatusSummary | null { + const feed = useMemo(() => getStructuredAgentSessionStatusFeed(target), [target]) + useEffect(() => feed.activate(), [feed]) + return useSyncExternalStore( + feed.subscribe, + () => feed.getSnapshot().get(sessionId) ?? null, + () => null + ) } -function projectStatus( - tab: StructuredTab, - state: StructuredAgentSessionState, - providerSession: AgentProviderSessionMetadata | undefined -): void { +function projectStatus(tab: StructuredTab, summary: AgentSessionStatusSummary | null): void { const paneKey = structuredAgentSessionPaneKey(tab.id, tab.entityId) const store = useAppStore.getState() - if (!hasPersistedStructuredAgentSessionTurn(state.items)) { + // No persisted turn yet (or nothing known): the row shows no agent status at all. + if (!summary?.status) { if (store.agentStatusByPaneKey?.[paneKey]) { store.removeAgentStatus(paneKey) } return } - const projection = projectStructuredAgentSessionStatus(state.items) const desired = { - state: projection === 'working' ? 'working' : projection === 'attention' ? 'blocked' : 'done', - prompt: latestPrompt(state), + state: + summary.status === 'working' + ? 'working' + : summary.status === 'attention' + ? 'blocked' + : 'done', + prompt: summary.latestPrompt, agentType: tab.agentSessionAgent, - sessionBoundary: projection === 'idle' + sessionBoundary: summary.status === 'idle' } as const const current = store.agentStatusByPaneKey?.[paneKey] if ( @@ -87,7 +87,11 @@ function projectStatus( current.tabId === tab.id && current.worktreeId === tab.worktreeId && current.terminalResumeEligible === false && - agentProviderSessionsEqual(tab.agentSessionAgent, current.providerSession, providerSession) + agentProviderSessionsEqual( + tab.agentSessionAgent, + current.providerSession, + summary.providerSession + ) ) { return } @@ -98,7 +102,7 @@ function projectStatus( undefined, { tabId: tab.id, worktreeId: tab.worktreeId }, { - ...(providerSession ? { providerSession } : {}), + ...(summary.providerSession ? { providerSession: summary.providerSession } : {}), terminalResumeEligible: false } ) @@ -112,13 +116,10 @@ function StructuredAgentSessionStatusProjection({ tab }: { tab: StructuredTab }) () => getActiveRuntimeTarget({ activeRuntimeEnvironmentId: environmentId }), [environmentId] ) - const { providerSession, state } = useStructuredAgentSessionReadObservation({ - sessionId: tab.entityId, - target - }) + const summary = useStructuredAgentSessionStatusSummary(tab.entityId, target) useEffect(() => { - projectStatus(tab, state, providerSession) - }, [providerSession, state, tab]) + projectStatus(tab, summary) + }, [summary, tab]) useEffect( () => () => useAppStore.getState().removeAgentStatus(structuredAgentSessionPaneKey(tab.id, tab.entityId)), diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-read.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-read.test.tsx index 2d320694c9c..9e1ce8067b7 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-read.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-read.test.tsx @@ -19,10 +19,7 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({ subscribeStructuredAgentSession: mocks.subscribe })) -import { - useStructuredAgentSessionRead, - useStructuredAgentSessionReadObservation -} from './use-structured-agent-session-read' +import { useStructuredAgentSessionRead } from './use-structured-agent-session-read' import { resetStructuredAgentSessionReadOwnersForTests } from './structured-agent-session-read-owner' const LOCAL_TARGET = { kind: 'local' } as const @@ -357,32 +354,6 @@ describe('useStructuredAgentSessionRead history window', () => { second.unmount() }) - it('shares one subscriber when pane and projection observe the same visible session', async () => { - const unsubscribe = vi.fn() - mocks.call.mockResolvedValue({ ok: true, page: page('tail', [], false) }) - mocks.subscribe.mockResolvedValue({ unsubscribe }) - - const view = renderHook(() => { - const pane = useStructuredAgentSessionRead({ - sessionId: 'session-shared', - target: LOCAL_TARGET, - isVisible: true - }) - const projection = useStructuredAgentSessionReadObservation({ - sessionId: 'session-shared', - target: LOCAL_TARGET - }) - return { pane, projection } - }) - - await waitFor(() => expect(mocks.subscribe).toHaveBeenCalledOnce()) - expect(mocks.call).toHaveBeenCalledOnce() - expect(view.result.current.pane.state).toBe(view.result.current.projection.state) - - view.unmount() - expect(unsubscribe).toHaveBeenCalledOnce() - }) - it('preserves cached state while switching away and refreshes once on re-entry', async () => { const unsubscribe = vi.fn() mocks.call.mockImplementation((_target, _method, params) => { diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-read.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-read.ts index 894730f5e65..834d11d3fa1 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-read.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-read.ts @@ -20,13 +20,6 @@ function useReadOwnerSnapshot( return { owner, snapshot } } -export function useStructuredAgentSessionReadObservation(args: { - sessionId: string - target: RuntimeClientTarget -}): StructuredAgentSessionReadSnapshot { - return useReadOwnerSnapshot(args.sessionId, args.target).snapshot -} - export function useStructuredAgentSessionRead(args: { sessionId: string target: RuntimeClientTarget diff --git a/src/renderer/src/runtime/structured-agent-session-client.ts b/src/renderer/src/runtime/structured-agent-session-client.ts index 0e8d2ce16f2..71be3f3449d 100644 --- a/src/renderer/src/runtime/structured-agent-session-client.ts +++ b/src/renderer/src/runtime/structured-agent-session-client.ts @@ -1,5 +1,8 @@ import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' -import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' +import type { + AgentSessionStatusEvent, + AgentSessionSubscribeEvent +} from '../../../shared/agent-session-wire' import { getRuntimeEnvironmentRevision } from './runtime-environment-revision' import { callRuntimeRpc, type RuntimeClientTarget } from './runtime-rpc-client' @@ -11,10 +14,11 @@ export function callStructuredAgentSession( return callRuntimeRpc(target, method, params) } -export async function subscribeStructuredAgentSession( +async function subscribeStructuredAgentSessionMethod( target: RuntimeClientTarget, + method: string, params: unknown, - onEvent: (event: AgentSessionSubscribeEvent) => void, + onEvent: (event: TEvent) => void, onError: (error: unknown) => void, onClose: () => void ): Promise<{ unsubscribe: () => void }> { @@ -23,15 +27,15 @@ export async function subscribeStructuredAgentSession( onError(response.error) return } - onEvent(response.result as AgentSessionSubscribeEvent) + onEvent(response.result as TEvent) } if (target.kind === 'local') { - return window.api.runtime.subscribe({ method: 'agentSession.subscribe', params }, onResponse) + return window.api.runtime.subscribe({ method, params }, onResponse) } return window.api.runtimeEnvironments.subscribe( { selector: target.environmentId, - method: 'agentSession.subscribe', + method, params, timeoutMs: 15_000, expectedEnvironmentPairingRevision: getRuntimeEnvironmentRevision(target.environmentId) @@ -39,3 +43,37 @@ export async function subscribeStructuredAgentSession( { onResponse, onError, onClose } ) } + +export function subscribeStructuredAgentSession( + target: RuntimeClientTarget, + params: unknown, + onEvent: (event: AgentSessionSubscribeEvent) => void, + onError: (error: unknown) => void, + onClose: () => void +): Promise<{ unsubscribe: () => void }> { + return subscribeStructuredAgentSessionMethod( + target, + 'agentSession.subscribe', + params, + onEvent, + onError, + onClose + ) +} + +/** Every structured session's projected status on one runtime, as the host publishes it. */ +export function subscribeStructuredAgentSessionStatus( + target: RuntimeClientTarget, + onEvent: (event: AgentSessionStatusEvent) => void, + onError: (error: unknown) => void, + onClose: () => void +): Promise<{ unsubscribe: () => void }> { + return subscribeStructuredAgentSessionMethod( + target, + 'agentSession.subscribeStatus', + {}, + onEvent, + onError, + onClose + ) +} diff --git a/src/renderer/src/runtime/structured-agent-session-status-feed.test.ts b/src/renderer/src/runtime/structured-agent-session-status-feed.test.ts new file mode 100644 index 00000000000..d9cb4dd3d72 --- /dev/null +++ b/src/renderer/src/runtime/structured-agent-session-status-feed.test.ts @@ -0,0 +1,140 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + AgentSessionStatusEvent, + AgentSessionStatusSummary +} from '../../../shared/agent-session-wire' +import { AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' + +const mocks = vi.hoisted(() => ({ + subscribeStatus: vi.fn(), + supportsCapability: vi.fn(), + unsubscribe: vi.fn() +})) + +vi.mock('./structured-agent-session-client', () => ({ + subscribeStructuredAgentSessionStatus: mocks.subscribeStatus +})) + +vi.mock('./runtime-rpc-client', () => ({ + runtimeEnvironmentSupportsCapability: mocks.supportsCapability +})) + +import { + getStructuredAgentSessionStatusFeed, + resetStructuredAgentSessionStatusFeedsForTests +} from './structured-agent-session-status-feed' + +const REMOTE = { kind: 'environment', environmentId: 'env-1' } as const +const LOCAL = { kind: 'local' } as const + +function summary( + sessionId: string, + status: AgentSessionStatusSummary['status'] = 'idle' +): AgentSessionStatusSummary { + return { + sessionId, + workspaceId: 'wt-1', + agent: 'codex', + status, + latestPrompt: 'hello', + updatedAt: 1 + } +} + +/** The event callback the feed handed to the most recent subscription. */ +function hostEmit(index = 0): (event: AgentSessionStatusEvent) => void { + const call = mocks.subscribeStatus.mock.calls[index] + if (!call) { + throw new Error('status feed not subscribed') + } + return call[1] as (event: AgentSessionStatusEvent) => void +} + +describe('structured agent session status feed', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() + resetStructuredAgentSessionStatusFeedsForTests() + mocks.subscribeStatus.mockResolvedValue({ unsubscribe: mocks.unsubscribe }) + mocks.supportsCapability.mockResolvedValue(true) + }) + + afterEach(() => { + resetStructuredAgentSessionStatusFeedsForTests() + vi.useRealTimers() + }) + + it('never subscribes, and never retries, against a host without the status feed', async () => { + mocks.supportsCapability.mockResolvedValue(false) + getStructuredAgentSessionStatusFeed(REMOTE).activate() + + await vi.advanceTimersByTimeAsync(0) + expect(mocks.supportsCapability).toHaveBeenCalledWith( + 'env-1', + AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY + ) + expect(mocks.subscribeStatus).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + + await vi.advanceTimersByTimeAsync(60_000) + expect(mocks.subscribeStatus).not.toHaveBeenCalled() + expect(mocks.supportsCapability).toHaveBeenCalledOnce() + }) + + it('subscribes once the remote host advertises the status feed', async () => { + getStructuredAgentSessionStatusFeed(REMOTE).activate() + + await vi.advanceTimersByTimeAsync(0) + expect(mocks.subscribeStatus).toHaveBeenCalledOnce() + expect(mocks.subscribeStatus.mock.calls[0]?.[0]).toEqual(REMOTE) + }) + + it('reconnects when the capability probe fails, which is not an answer', async () => { + mocks.supportsCapability.mockRejectedValue(new Error('relay unreachable')) + getStructuredAgentSessionStatusFeed(REMOTE).activate() + + await vi.advanceTimersByTimeAsync(0) + expect(mocks.supportsCapability).toHaveBeenCalledOnce() + + await vi.advanceTimersByTimeAsync(300) + expect(mocks.supportsCapability).toHaveBeenCalledTimes(2) + expect(mocks.subscribeStatus).not.toHaveBeenCalled() + }) + + it('probes nothing for a local host, which is this build', async () => { + getStructuredAgentSessionStatusFeed(LOCAL).activate() + + await vi.advanceTimersByTimeAsync(0) + expect(mocks.supportsCapability).not.toHaveBeenCalled() + expect(mocks.subscribeStatus).toHaveBeenCalledOnce() + }) + + it('merges a snapshot over the cached rows instead of retracting them', async () => { + const feed = getStructuredAgentSessionStatusFeed(LOCAL) + feed.activate() + await vi.advanceTimersByTimeAsync(0) + hostEmit()({ type: 'snapshot', sessions: [summary('session-1'), summary('session-2')] }) + expect([...feed.getSnapshot().keys()]).toEqual(['session-1', 'session-2']) + + // A restarted host restores its readable sessions after the stream reopens. + hostEmit()({ type: 'snapshot', sessions: [] }) + expect([...feed.getSnapshot().keys()]).toEqual(['session-1', 'session-2']) + + hostEmit()({ type: 'snapshot', sessions: [summary('session-1', 'working')] }) + expect(feed.getSnapshot().get('session-1')?.status).toBe('working') + expect(feed.getSnapshot().get('session-2')?.status).toBe('idle') + }) + + it('stops a pending reconnect when the feeds are reset between tests', async () => { + getStructuredAgentSessionStatusFeed(LOCAL).activate() + await vi.advanceTimersByTimeAsync(0) + hostEmit()({ type: 'end' }) + expect(vi.getTimerCount()).toBe(1) + + resetStructuredAgentSessionStatusFeedsForTests() + + expect(vi.getTimerCount()).toBe(0) + await vi.advanceTimersByTimeAsync(10_000) + expect(mocks.subscribeStatus).toHaveBeenCalledOnce() + }) +}) diff --git a/src/renderer/src/runtime/structured-agent-session-status-feed.ts b/src/renderer/src/runtime/structured-agent-session-status-feed.ts new file mode 100644 index 00000000000..2b550679315 --- /dev/null +++ b/src/renderer/src/runtime/structured-agent-session-status-feed.ts @@ -0,0 +1,211 @@ +// One host status stream per runtime target, shared by every session-list projection. +// +// The feed is a read-only mirror: the host projects each session's status from its journal and +// this owner keeps the latest summary per session while anyone is looking. Losing the stream +// keeps the cached summaries and reconnects; a fresh snapshot merges over them. +// Which sessions are listed is the tab map's decision, so the feed never retracts a summary. + +import type { + AgentSessionStatusEvent, + AgentSessionStatusSummary +} from '../../../shared/agent-session-wire' +import { AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' +import { + runtimeEnvironmentSupportsCapability, + type RuntimeClientTarget +} from './runtime-rpc-client' +import { subscribeStructuredAgentSessionStatus } from './structured-agent-session-client' + +export type StructuredAgentSessionStatusSnapshot = ReadonlyMap + +export type StructuredAgentSessionStatusFeedOwner = { + activate: () => () => void + getSnapshot: () => StructuredAgentSessionStatusSnapshot + subscribe: (listener: () => void) => () => void +} + +const RECONNECT_MAX_DELAY_MS = 5_000 + +/** `stop` is the map's own teardown, not part of the owner contract callers hold. */ +type OwnedStatusFeed = StructuredAgentSessionStatusFeedOwner & { stop: () => void } + +const owners = new Map() + +export function structuredAgentSessionStatusFeedKey(target: RuntimeClientTarget): string { + return target.kind === 'local' ? 'local' : `environment:${target.environmentId}` +} + +function createOwner(target: RuntimeClientTarget): OwnedStatusFeed { + let snapshot: StructuredAgentSessionStatusSnapshot = new Map() + const listeners = new Set<() => void>() + const activations = new Set() + let generation = 0 + let handle: { unsubscribe: () => void } | null = null + let reconnectTimer: ReturnType | null = null + let reconnectAttempt = 0 + + const emit = (): void => { + for (const listener of listeners) { + listener() + } + } + const setSnapshot = (next: StructuredAgentSessionStatusSnapshot): void => { + snapshot = next + emit() + } + const applyEvent = (event: AgentSessionStatusEvent): void => { + if (event.type === 'snapshot') { + reconnectAttempt = 0 + // Merged, not replaced: a restarted host restores its readable sessions asynchronously, so + // the first snapshot can be empty and dropping those rows flickers every one to no-status. + const next = new Map(snapshot) + for (const session of event.sessions) { + next.set(session.sessionId, session) + } + setSnapshot(next) + return + } + if (event.type === 'status') { + const next = new Map(snapshot) + next.set(event.session.sessionId, event.session) + setSnapshot(next) + } + } + const active = (candidate: number): boolean => activations.size > 0 && candidate === generation + const clearReconnect = (): void => { + if (reconnectTimer) { + clearTimeout(reconnectTimer) + reconnectTimer = null + } + } + const dropHandle = (): void => { + handle?.unsubscribe() + handle = null + } + let open = (): void => {} + const scheduleReconnect = (candidate: number): void => { + if (!active(candidate) || reconnectTimer) { + return + } + const delay = Math.min(250 * 2 ** reconnectAttempt, RECONNECT_MAX_DELAY_MS) + reconnectAttempt += 1 + reconnectTimer = setTimeout(() => { + reconnectTimer = null + if (active(candidate)) { + open() + } + }, delay) + } + const subscribeToHost = (candidate: number): void => { + void subscribeStructuredAgentSessionStatus( + target, + (event) => { + if (!active(candidate)) { + return + } + if (event.type === 'end') { + dropHandle() + scheduleReconnect(candidate) + return + } + applyEvent(event) + }, + () => { + if (active(candidate)) { + dropHandle() + scheduleReconnect(candidate) + } + }, + () => { + if (active(candidate)) { + dropHandle() + scheduleReconnect(candidate) + } + } + ) + .then((opened) => { + if (active(candidate)) { + handle = opened + } else { + opened.unsubscribe() + } + }) + .catch(() => scheduleReconnect(candidate)) + } + open = (): void => { + const candidate = ++generation + dropHandle() + if (target.kind !== 'environment') { + // A local host is this build; only a remote one can predate the method. + subscribeToHost(candidate) + return + } + const environmentId = target.environmentId + void runtimeEnvironmentSupportsCapability( + environmentId, + AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY + ) + .then((supported) => { + if (!active(candidate)) { + return + } + // A host without the method is terminal, not a fault: retrying would relay-probe + // forever. A failed probe is not an answer, so that path still reconnects. + if (supported) { + subscribeToHost(candidate) + return + } + console.warn('[structured-session-status] host too old for the status feed', environmentId) + }) + .catch(() => scheduleReconnect(candidate)) + } + const stop = (): void => { + generation += 1 + clearReconnect() + dropHandle() + reconnectAttempt = 0 + } + + return { + activate: () => { + const token = Symbol('status-feed') + activations.add(token) + if (activations.size === 1) { + open() + } + return () => { + activations.delete(token) + if (activations.size === 0) { + stop() + } + } + }, + getSnapshot: () => snapshot, + subscribe: (listener) => { + listeners.add(listener) + return () => listeners.delete(listener) + }, + stop + } +} + +export function getStructuredAgentSessionStatusFeed( + target: RuntimeClientTarget +): StructuredAgentSessionStatusFeedOwner { + const key = structuredAgentSessionStatusFeedKey(target) + let owner = owners.get(key) + if (!owner) { + owner = createOwner(target) + owners.set(key, owner) + } + return owner +} + +export function resetStructuredAgentSessionStatusFeedsForTests(): void { + // Dropping the map alone leaves a live subscription and its pending reconnect running + // into the next test, where they reopen a stream nothing is holding. + for (const owner of owners.values()) { + owner.stop() + } + owners.clear() +} diff --git a/src/shared/agent-session-wire.ts b/src/shared/agent-session-wire.ts index 40a408df899..86c0e795d84 100644 --- a/src/shared/agent-session-wire.ts +++ b/src/shared/agent-session-wire.ts @@ -12,8 +12,13 @@ import type { AgentJournalResolution, AgentJournalSubmission } from './agent-session-journal-types' -import type { AgentSessionHandoffStage, AgentSessionOwnerRuntimeKind } from './agent-session-record' +import type { + AgentSessionHandoffStage, + AgentSessionOwnerRuntimeKind, + AgentSessionRecord +} from './agent-session-record' import type { AgentProviderSessionMetadata } from './agent-session-resume' +import type { StructuredAgentSessionProjectedStatus } from './structured-agent-session-projection' export type AgentSessionHandoffDirection = 'to-tui' | 'to-native' export type AgentSessionHandoffMode = 'now' | 'after-turn' | 'stop-turn' @@ -159,6 +164,29 @@ export type AgentSessionSubscribeEvent = } | { type: 'end' } +// ─── Status feed ──────────────────────────────────────────────────────────── + +/** What a session list needs to know about one session. The host projects it + * from the journal so no client has to replay a transcript to learn whether a + * turn is running. Additive surface: an older host has no such method. */ +export type AgentSessionStatusSummary = { + sessionId: string + workspaceId: string + agent: AgentSessionRecord['provider'] + /** Null until the journal holds a persisted user or assistant message. */ + status: StructuredAgentSessionProjectedStatus | null + latestPrompt: string + providerSession?: AgentProviderSessionMetadata + updatedAt: number +} + +/** A summary outlives its provider child: an evicted idle session is still idle, so the host + * keeps the last projection and never retracts one. Tabs, not this feed, decide what is listed. */ +export type AgentSessionStatusEvent = + | { type: 'snapshot'; sessions: AgentSessionStatusSummary[] } + | { type: 'status'; session: AgentSessionStatusSummary } + | { type: 'end' } + // ─── Mutation envelope ────────────────────────────────────────────────────── /** diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 76e1252640a..159bb84f7e3 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -133,6 +133,10 @@ export const CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY = // to stop provider children after the last surface closes without tying lifetime to a transport. export const STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY = 'agent-session.structured.hold.v1' as const +// Why: agentSession.subscribeStatus is additive to a surface that already shipped, so a host +// advertising agent-session.structured.v1 may still answer it with method_not_found. Clients must +// probe before subscribing or they reconnect forever and never show any status at all. +export const AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY = 'agent-session.status-feed.v1' as const // Why: adding kimi to RESUMABLE_TUI_AGENTS grows terminal.ensureAgentSession's enum, and an // older host answers the unknown member with invalid_argument — a code the launch fallback does // not retry on — so clients must probe before taking the host-authority path. @@ -229,6 +233,7 @@ export const RUNTIME_CAPABILITIES = [ AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY, + AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY, AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY, FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY, GITHUB_MARK_PR_READY_RUNTIME_CAPABILITY, diff --git a/src/shared/structured-agent-session-projection.test.ts b/src/shared/structured-agent-session-projection.test.ts index 048051e70a5..8bdce30577e 100644 --- a/src/shared/structured-agent-session-projection.test.ts +++ b/src/shared/structured-agent-session-projection.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { AGENT_STATUS_MAX_FIELD_LENGTH } from './agent-status-field-normalization' import type { AgentJournalRenderItem } from './agent-session-journal-types' import { parsePaneKey } from './stable-pane-id' import { @@ -6,6 +7,7 @@ import { hasPersistedStructuredAgentSessionTurn, projectStructuredItemToNativeChat, projectStructuredAgentSessionStatus, + projectStructuredAgentSessionStatusSummary, structuredAgentSessionPaneKey } from './structured-agent-session-projection' @@ -44,6 +46,52 @@ describe('structured agent session status projection', () => { expect(projectStructuredAgentSessionStatus([running, completed])).toBe('idle') }) + it('summarizes status with the newest user prompt, and null before any persisted turn', () => { + const running = item('running', 3, { + kind: 'status', + text: 'Working', + turnLifecycle: { turnId: 'turn-1', state: 'running' } + }) + const first = item('first', 1, { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'first' }] + }) + const second = item('second', 2, { + kind: 'message', + role: 'user', + blocks: [ + { type: 'text', text: 'second' }, + { type: 'text', text: 'line' } + ] + }) + + expect(projectStructuredAgentSessionStatusSummary([running])).toEqual({ + status: null, + latestPrompt: '' + }) + expect(projectStructuredAgentSessionStatusSummary([first, second, running])).toEqual({ + status: 'working', + latestPrompt: 'second line' + }) + expect(projectStructuredAgentSessionStatusSummary([first, second])).toEqual({ + status: 'idle', + latestPrompt: 'second line' + }) + }) + + it('bounds the wire prompt at the shared agent-status preview cap', () => { + const pasted = item('pasted', 1, { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'x'.repeat(AGENT_STATUS_MAX_FIELD_LENGTH * 40) }] + }) + + expect(projectStructuredAgentSessionStatusSummary([pasted]).latestPrompt).toHaveLength( + AGENT_STATUS_MAX_FIELD_LENGTH + ) + }) + it('creates a deterministic pane identity for status stores', () => { const paneKey = structuredAgentSessionPaneKey('structured-agent-session-1', 'session-1') diff --git a/src/shared/structured-agent-session-projection.ts b/src/shared/structured-agent-session-projection.ts index 94938dc955f..6a5f01ba9ea 100644 --- a/src/shared/structured-agent-session-projection.ts +++ b/src/shared/structured-agent-session-projection.ts @@ -1,3 +1,4 @@ +import { normalizePromptField } from './agent-status-field-normalization' import type { AgentJournalRenderItem } from './agent-session-journal-types' import type { NativeChatBlock, NativeChatMessage } from './native-chat-types' import { sha256 } from './sha256' @@ -162,6 +163,34 @@ export function projectStructuredAgentSessionStatus( return activeStructuredAgentSessionTurnId(items) ? 'working' : 'idle' } +/** The newest user prompt, as the sidebar quotes it. */ +export function latestStructuredAgentSessionPrompt( + items: readonly AgentJournalRenderItem[] +): string { + for (let index = items.length - 1; index >= 0; index -= 1) { + const body = items[index]?.body + if (body?.kind === 'message' && body.role === 'user') { + return body.blocks.flatMap((block) => (block.type === 'text' ? [block.text] : [])).join('\n') + } + } + return '' +} + +/** One projection shared by host and client: null status means "no turn yet", not idle. + * The prompt is bounded to the same preview every other agent-status row carries — a send + * admits 256 KB, and one status frame carries every retained session at once. */ +export function projectStructuredAgentSessionStatusSummary( + items: readonly AgentJournalRenderItem[] +): { status: StructuredAgentSessionProjectedStatus | null; latestPrompt: string } { + if (!hasPersistedStructuredAgentSessionTurn(items)) { + return { status: null, latestPrompt: '' } + } + return { + status: projectStructuredAgentSessionStatus(items), + latestPrompt: normalizePromptField(latestStructuredAgentSessionPrompt(items)) + } +} + export function structuredAgentSessionPaneKey(tabId: string, sessionId: string): string { const bytes = sha256(new TextEncoder().encode(sessionId)) const hex = Array.from(bytes.slice(0, 16), (byte) => byte.toString(16).padStart(2, '0')).join('') diff --git a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts index a2a64897fcb..ecf3072b39a 100644 --- a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts +++ b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts @@ -23,7 +23,10 @@ import { setStructuredAgentSessionHost } from '../../../src/main/native-chat/age import { AgentSessionRecordStore } from '../../../src/main/runtime/agent-session-record-store' import { computeAgentSessionPayloadFingerprint } from '../../../src/shared/agent-session-mutation-envelope' import type { AgentSessionSubscribeEvent } from '../../../src/shared/agent-session-wire' -import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version' +import { + AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY +} from '../../../src/shared/protocol-version' import { resolveBaselineReleaseRef } from './release-checkout' import { loadAgentSessionWireBuild, @@ -41,6 +44,7 @@ const WORKSPACE = 'workspace-1' const THREAD = '019fd532-7c11-7a90-b6de-4e1a2c3d5f60' const NOW = 1_800_000_000_000 const CLIENT_CAPABILITY_UPDATE_METHOD = 'runtime.clientCapabilities.update' +const STATUS_FEED_METHOD = 'agentSession.subscribeStatus' /** Every method the structured surface publishes: the host method it must reach, * and the result it must hand back. A gate that hides one method and leaks @@ -107,6 +111,12 @@ const STRUCTURED_CALLS: { // A subscription that opens with nothing to say answers with no reply at all, // so reaching the host is the only signal that the gate opened. { method: 'agentSession.subscribe', hostMethod: 'subscribe' }, + // The status feed opens with a snapshot of every session, so its first reply is the contract. + { + method: STATUS_FEED_METHOD, + hostMethod: 'subscribeStatus', + result: { type: 'snapshot', sessions: [] } + }, // Teardown runs through the runtime's subscription registry rather than the // host, so its reply is the only signal that the gate opened. { method: 'agentSession.unsubscribe', hostMethod: null, result: { unsubscribed: true } } @@ -320,6 +330,10 @@ function structuredHostStub(): Record> { readOptions: vi.fn(async () => ({ models: [], current: { model: 'gpt-live' } })), history: vi.fn(() => ({ ok: true, page: { items: [] } })), subscribe: vi.fn(() => () => undefined), + subscribeStatus: vi.fn((subscriber: { emit: (event: unknown) => void }) => { + subscriber.emit({ type: 'snapshot', sessions: [] }) + return () => undefined + }), unsubscribe: vi.fn() } } @@ -443,6 +457,14 @@ describe('cross-version structured agent sessions', () => { expect(baseline.capabilities.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY)).toBe( baselineStructuredMethods().length > 0 ) + // The status feed is additive to a surface that already shipped, so it carries its own + // capability or a client cannot tell "host too old" from "the call failed" — and it + // would relay-retry a method_not_found forever instead of degrading once. + for (const build of [current, baseline]) { + expect(build.capabilities.includes(AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY)).toBe( + build.methodNames.includes(STATUS_FEED_METHOD) + ) + } // Additive surface: bumping the protocol number would strand every paired // device on this release rather than degrade one feature. expect(current.protocolVersion).toBe(baseline.protocolVersion) From f8780a2c869dc84c543907e48de777b7e31d8f18 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:35:39 -0700 Subject: [PATCH 13/23] feat(native-chat): stop monitored tasks individually (#18807) * feat(native-chat): stop monitored tasks individually * test: expect Claude task stop capability --------- Co-authored-by: Merge Sim --- .../claude-structured-control-actions.test.ts | 38 +++++ .../claude-structured-control-actions.ts | 7 +- .../claude-structured-session-adapter.ts | 34 +++-- .../claude-structured-session-close.test.ts | 12 +- .../structured-agent-session-adapter.ts | 6 +- ...structured-agent-session-host-mutations.ts | 1 + ...structured-agent-session-mutation-plans.ts | 10 +- .../structured-agent-session-turns.test.ts | 36 +++++ .../structured-agent-session-turns.ts | 4 +- ...ude-structured-session-integration.test.ts | 40 +++++ .../structured-agent-session-schemas.ts | 6 +- .../methods/structured-agent-session.test.ts | 29 ++++ .../NativeChatBackgroundTasksStatus.tsx | 74 +++++++--- .../NativeChatStructuredSession.test.tsx | 139 +++++++++++++++++- .../NativeChatStructuredSession.tsx | 50 ++++++- .../use-structured-agent-session.test.tsx | 36 +++++ .../use-structured-agent-session.ts | 6 +- src/renderer/src/i18n/locales/en.json | 2 + src/shared/agent-session-wire.ts | 2 + .../structured-agent-session-reducer.test.ts | 33 +++++ .../structured-agent-session-reducer.ts | 7 +- 21 files changed, 519 insertions(+), 53 deletions(-) diff --git a/src/main/claude/claude-structured-control-actions.test.ts b/src/main/claude/claude-structured-control-actions.test.ts index a90cb7908ba..168ce558f53 100644 --- a/src/main/claude/claude-structured-control-actions.test.ts +++ b/src/main/claude/claude-structured-control-actions.test.ts @@ -158,4 +158,42 @@ describe('stopClaudeBackgroundTasks', () => { await stopClaudeBackgroundTasks(session, undefined, () => current) expect(stopTask).toHaveBeenCalledTimes(1) }) + + it('stops only the requested live task id', async () => { + const backgroundTasks = new ClaudeBackgroundTaskTracker() + backgroundTasks.observe({ + type: 'system', + subtype: 'background_tasks_changed', + tasks: [ + { task_id: 'task-one', task_type: 'local_agent' }, + { task_id: 'task-two', task_type: 'local_bash' } + ] + }) + const stopTask = vi.fn(async (_taskId: string) => {}) + const session = { backgroundTasks, connection: { stopTask } } as unknown as ClaudeSession + + await expect( + stopClaudeBackgroundTasks(session, 5_000, () => true, 'task-two') + ).resolves.toEqual({ cancelled: true }) + expect(stopTask).toHaveBeenCalledWith('task-two', { timeoutMs: 5_000 }) + expect(stopTask).toHaveBeenCalledTimes(1) + }) + + it('refuses a stale or unknown task id without a provider call', async () => { + const backgroundTasks = new ClaudeBackgroundTaskTracker() + backgroundTasks.observe({ + type: 'system', + subtype: 'task_started', + task_id: 'task-live', + task_type: 'local_agent', + is_backgrounded: true + }) + const stopTask = vi.fn(async (_taskId: string) => {}) + const session = { backgroundTasks, connection: { stopTask } } as unknown as ClaudeSession + + await expect( + stopClaudeBackgroundTasks(session, undefined, () => true, 'task-stale') + ).resolves.toEqual({ cancelled: false }) + expect(stopTask).not.toHaveBeenCalled() + }) }) diff --git a/src/main/claude/claude-structured-control-actions.ts b/src/main/claude/claude-structured-control-actions.ts index d216304c311..8b3bb94c7b5 100644 --- a/src/main/claude/claude-structured-control-actions.ts +++ b/src/main/claude/claude-structured-control-actions.ts @@ -46,9 +46,12 @@ export async function cancelClaudeTurn( export async function stopClaudeBackgroundTasks( session: ClaudeSession, timeoutMs: number | undefined, - isCurrent: ClaudeTurnCancellationGuard = () => true + isCurrent: ClaudeTurnCancellationGuard = () => true, + taskId?: string ): Promise<{ cancelled: boolean }> { - const taskIds = session.backgroundTasks.stoppableTaskIds + const stoppableTaskIds = session.backgroundTasks.stoppableTaskIds + const taskIds = + taskId === undefined ? stoppableTaskIds : stoppableTaskIds.includes(taskId) ? [taskId] : [] let cancelled = false for (const taskId of taskIds) { if (!isCurrent()) { diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index 9bd92e1e839..c00a588e891 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -30,6 +30,7 @@ import { settleClaudeExitedSession } from './claude-structured-session-close' import { readClaudeTranscriptLeafWithReproof } from './claude-transcript-branch-proof' +import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire' export type { ClaudeStructuredLaunch } from './claude-structured-launch-resolution' export type { @@ -40,6 +41,11 @@ export type { const DISPATCH_ACK_TIMEOUT_MS = 10_000 +function backgroundTaskState(session: ClaudeSession): AgentSessionBackgroundTaskState | null { + const state = session.backgroundTasks.state + return state ? { ...state, supportsTaskStop: true } : null +} + export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAdapter { private readonly sessions = new Map() private readonly acquisitions = new ClaudeAcquisitionRegistry() @@ -192,7 +198,10 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda session?.translator?.handle(event) this.deps.onEvent?.(event) if (backgroundTasksChanged) { - this.deps.onBackgroundTasksChanged?.(event.sessionId, session?.backgroundTasks.state ?? null) + this.deps.onBackgroundTasksChanged?.( + event.sessionId, + session ? backgroundTaskState(session) : null + ) } } @@ -233,18 +242,25 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda stopBackgroundTasks: StructuredAgentSessionAdapter['stopBackgroundTasks'] = (input) => { const session = this.session(input.sessionId) const acquisitionGeneration = session.acquisitionGeneration - return stopClaudeBackgroundTasks(session, this.deps.requestTimeoutMs, () => - Boolean( - this.sessions.get(input.sessionId) === session && - session.fence === input.fence && - session.acquisitionGeneration === acquisitionGeneration && - session.backgroundTasks.state - ) + return stopClaudeBackgroundTasks( + session, + this.deps.requestTimeoutMs, + () => + Boolean( + this.sessions.get(input.sessionId) === session && + session.fence === input.fence && + session.acquisitionGeneration === acquisitionGeneration && + session.backgroundTasks.state + ), + input.taskId ) } backgroundTaskState: NonNullable = ( sessionId - ) => this.sessions.get(sessionId)?.backgroundTasks.state + ) => { + const session = this.sessions.get(sessionId) + return session ? backgroundTaskState(session) : undefined + } answerPrompt: StructuredAgentSessionAdapter['answerPrompt'] = (input) => answerClaudePrompt(this.session(input.sessionId), input) setOption: StructuredAgentSessionAdapter['setOption'] = (input) => diff --git a/src/main/claude/claude-structured-session-close.test.ts b/src/main/claude/claude-structured-session-close.test.ts index 0df0e913e50..670c0daaf8c 100644 --- a/src/main/claude/claude-structured-session-close.test.ts +++ b/src/main/claude/claude-structured-session-close.test.ts @@ -53,7 +53,11 @@ describe('Claude published session close lifecycle', () => { is_backgrounded: true }) expect(backgroundStates).toEqual([ - { state: 'monitoring', tasks: [{ id: 'background-1', kind: 'agent' }] } + { + state: 'monitoring', + tasks: [{ id: 'background-1', kind: 'agent' }], + supportsTaskStop: true + } ]) const session = ( adapter as unknown as { @@ -68,7 +72,11 @@ describe('Claude published session close lifecycle', () => { expect(events.filter((event) => event.type === 'handle')).toHaveLength(0) expect(disposeTranslator).toHaveBeenCalledOnce() expect(backgroundStates).toEqual([ - { state: 'monitoring', tasks: [{ id: 'background-1', kind: 'agent' }] }, + { + state: 'monitoring', + tasks: [{ id: 'background-1', kind: 'agent' }], + supportsTaskStop: true + }, null ]) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index e44e8c39152..e6f8e478695 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -137,7 +137,11 @@ export type StructuredAgentSessionAdapter = { turnId: string fence: number }): Promise<{ cancelled: boolean }> - stopBackgroundTasks?(input: { sessionId: string; fence: number }): Promise<{ cancelled: boolean }> + stopBackgroundTasks?(input: { + sessionId: string + fence: number + taskId?: string + }): Promise<{ cancelled: boolean }> backgroundTaskState?(sessionId: string): AgentSessionBackgroundTaskState | null | undefined /** Fires the provider callback for an approval or a question. The wire calls * this only after the durable compare-and-set won, so it runs exactly once. */ 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 7d2648930c4..f4a0244d0af 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 @@ -78,6 +78,7 @@ export function cancelStructuredAgentSessionTurn( envelope: AgentSessionMutationEnvelope turnId: string scope?: 'background-tasks' + taskId?: string } ): Promise> { return mutate(context, caller, params.envelope, cancelPlan(params)) 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 d0eb905443c..1194f0c87ff 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 @@ -76,15 +76,21 @@ export function cancelPlan(params: { envelope: AgentSessionMutationEnvelope turnId: string scope?: 'background-tasks' + taskId?: string }): MutationPlan { return { method: 'agentSession.cancel', - fields: { turnId: params.turnId, ...(params.scope ? { scope: params.scope } : {}) }, + fields: { + turnId: params.turnId, + ...(params.scope ? { scope: params.scope } : {}), + ...(params.taskId ? { taskId: params.taskId } : {}) + }, run: (ctx) => performCancel(ctx, { clientOperationId: params.envelope.clientOperationId, turnId: params.turnId, - ...(params.scope ? { scope: params.scope } : {}) + ...(params.scope ? { scope: params.scope } : {}), + ...(params.taskId ? { taskId: params.taskId } : {}) }), // Interrupting twice would kill a turn the client never asked to stop, so a // replay reports the turn as already handled instead. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts index e8e6f998bdd..31df2c44551 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts @@ -104,4 +104,40 @@ describe('performCancel', () => { expect(cancelTurn).not.toHaveBeenCalled() expect(journal.snapshot().items).toEqual([]) }) + + it('routes one background task id without interrupting the foreground turn or writing a row', async () => { + root = await mkdtemp(join(tmpdir(), 'orca-background-task-targeted-cancel-')) + const journal = await journals.open({ identity: IDENTITY, journalDir: root }) + const cancelTurn = vi.fn(async () => ({ cancelled: true })) + const stopBackgroundTasks = vi.fn(async () => ({ cancelled: true })) + const ctx: AgentSessionTurnContext = { + sessionId: 'session-1', + journal, + fence: 1, + adapter: { cancelTurn, stopBackgroundTasks } as unknown as StructuredAgentSessionAdapter, + persistOptions: async () => undefined, + resolvedBy: 'client-1', + publish: vi.fn(), + now: () => 1 + } + + const result = await performCancel(ctx, { + clientOperationId: 'cancel-background-task-2', + turnId: 'background-tasks', + scope: 'background-tasks', + taskId: 'task-2' + }) + + expect(result).toEqual({ + ok: true, + value: { turnId: 'background-tasks', cancelled: true } + }) + expect(stopBackgroundTasks).toHaveBeenCalledWith({ + sessionId: 'session-1', + fence: 1, + taskId: 'task-2' + }) + expect(cancelTurn).not.toHaveBeenCalled() + expect(journal.snapshot().items).toEqual([]) + }) }) 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 76c4e8e89d6..3bd98a61735 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 @@ -150,6 +150,7 @@ export async function performCancel( clientOperationId: string turnId: string scope?: 'background-tasks' + taskId?: string } ): Promise> { let cancelled = false @@ -159,7 +160,8 @@ export async function performCancel( ? ( await ctx.adapter.stopBackgroundTasks?.({ sessionId: ctx.sessionId, - fence: ctx.fence + fence: ctx.fence, + ...(input.taskId ? { taskId: input.taskId } : {}) }) )?.cancelled === true : ( diff --git a/src/main/runtime/claude-structured-session-integration.test.ts b/src/main/runtime/claude-structured-session-integration.test.ts index 2e42d595b04..e9cba45ffa9 100644 --- a/src/main/runtime/claude-structured-session-integration.test.ts +++ b/src/main/runtime/claude-structured-session-integration.test.ts @@ -599,6 +599,46 @@ describe('a structured Claude session over agentSession.*', () => { `claude:${PROVIDER_SESSION}:assistant-leaf` ) + claude.live().handlers.onMessage?.({ + type: 'system', + subtype: 'background_tasks_changed', + session_id: PROVIDER_SESSION, + uuid: 'background-roster', + tasks: [ + { task_id: 'task-one', task_type: 'local_agent', description: 'First task' }, + { task_id: 'task-two', task_type: 'local_bash', description: 'Second task' } + ] + }) + const itemsBeforeTaskStop = itemsOf(stream) + const targetedStopFields = { + turnId: 'background-tasks', + scope: 'background-tasks', + taskId: 'task-two' + } + await expect( + ok('agentSession.cancel', { + envelope: envelope('agentSession.cancel', targetedStopFields, created.fence), + ...targetedStopFields + }) + ).resolves.toMatchObject({ turnId: 'background-tasks', cancelled: true }) + expect(claude.live().calls.filter((entry) => entry.subtype === 'stop_task')).toEqual([ + { subtype: 'stop_task', params: { taskId: 'task-two' } } + ]) + expect(itemsOf(stream)).toEqual(itemsBeforeTaskStop) + + const staleStopFields = { + turnId: 'background-tasks', + scope: 'background-tasks', + taskId: 'task-stale' + } + await expect( + ok('agentSession.cancel', { + envelope: envelope('agentSession.cancel', staleStopFields, created.fence), + ...staleStopFields + }) + ).resolves.toMatchObject({ turnId: 'background-tasks', cancelled: false }) + expect(claude.live().calls.filter((entry) => entry.subtype === 'stop_task')).toHaveLength(1) + const answeredPermission = Promise.resolve( claude.live().handlers.canUseTool?.('Bash', { command: 'ls' }, { requestId: 'permission-1', diff --git a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts index da66923c731..58dece2256c 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts @@ -152,9 +152,13 @@ export const CancelParams = z .object({ envelope: MutationEnvelope, turnId: Identifier('Invalid turn id'), - scope: z.literal('background-tasks').optional() + scope: z.literal('background-tasks').optional(), + taskId: Identifier('Invalid task id').optional() }) .strict() + .refine((value) => value.taskId === undefined || value.scope === 'background-tasks', { + message: 'A task id requires background-task scope' + }) export const RespondParams = z .object({ diff --git a/src/main/runtime/rpc/methods/structured-agent-session.test.ts b/src/main/runtime/rpc/methods/structured-agent-session.test.ts index 69b04711960..5220b3a00fe 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.test.ts @@ -529,6 +529,20 @@ describe('method routing', () => { ]) }) + it('routes an optional background task id through cancellation', async () => { + const params = { + envelope: envelope(), + turnId: 'background-tasks', + scope: 'background-tasks' as const, + taskId: 'task-2' + } + + const response = await call('agentSession.cancel', params, STRUCTURED_CLIENT) + + expect(response).toMatchObject({ ok: true }) + expect(hostCalls.cancel).toHaveBeenCalledWith(expect.anything(), params) + }) + it('routes the structured handoff mutation through the host', async () => { const response = await call('agentSession.requestHandoff', { envelope: envelope(), @@ -559,6 +573,21 @@ describe('parameter validation', () => { }) }) + it('rejects invalid or unscoped background task ids', async () => { + await rejects('agentSession.cancel', { + envelope: envelope(), + turnId: 'background-tasks', + scope: 'background-tasks', + taskId: ' task-2' + }) + await rejects('agentSession.cancel', { + envelope: envelope(), + turnId: 'turn-1', + taskId: 'task-2' + }) + expect(hostCalls.cancel).not.toHaveBeenCalled() + }) + it('refuses to let a client author anything but a user turn', async () => { await rejects( 'agentSession.send', diff --git a/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.tsx b/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.tsx index 5163d5f467b..9f8f08e1efd 100644 --- a/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.tsx +++ b/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.tsx @@ -25,8 +25,10 @@ function backgroundTaskLabel(task: AgentSessionBackgroundTask): string { export function NativeChatBackgroundTasksStatus(props: { tasks: readonly AgentSessionBackgroundTask[] - stopping: boolean - onStop: () => void + supportsTaskStop: boolean + stoppingTaskIds: ReadonlySet + stoppingAll: boolean + onStop: (taskId?: string) => void }): React.JSX.Element { const [expanded, setExpanded] = useState(false) const taskListId = useId() @@ -36,7 +38,7 @@ export function NativeChatBackgroundTasksStatus(props: { className="shrink-0 bg-background px-3 pt-2 sm:px-4" >
-
+
-
{expanded ? (
- {props.tasks.map((task) => ( -
  • -
  • - ))} + {props.tasks.map((task) => { + const label = backgroundTaskLabel(task) + return ( +
  • +
  • + ) + })} ) : (

    @@ -100,6 +115,23 @@ export function NativeChatBackgroundTasksStatus(props: { )}

    )} + {!props.supportsTaskStop ? ( +
    0 ? 'mt-2 border-t border-border pt-2' : 'mt-2'}> + +
    + ) : null}
    ) : null}
    diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx index afdf5ace7c5..2b4a9686aaf 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx @@ -29,8 +29,9 @@ const mocks = vi.hoisted(() => ({ pasteFromClipboard: vi.fn(), submissions: [] as unknown[], monitoringBackgroundTasks: false, + supportsBackgroundTaskStop: false, backgroundTasks: [] as AgentSessionBackgroundTask[], - stopBackgroundTasks: vi.fn() + stopBackgroundTask: vi.fn() })) vi.mock('@/runtime/structured-agent-session-client', () => ({ @@ -75,10 +76,11 @@ vi.mock('./use-structured-agent-session', async () => { retry: outbox.retry, isWorking: false, isMonitoringBackgroundTasks: mocks.monitoringBackgroundTasks, + supportsBackgroundTaskStop: mocks.supportsBackgroundTaskStop, backgroundTasks: mocks.backgroundTasks, turnId: null, cancel: vi.fn(), - stopBackgroundTasks: mocks.stopBackgroundTasks, + stopBackgroundTask: (taskId?: string) => mocks.stopBackgroundTask(props.sessionId, taskId), respond: mocks.respond, optionSnapshot: [ { @@ -166,7 +168,8 @@ describe('NativeChatStructuredSession', () => { mocks.pasteFromClipboard.mockReset() mocks.submissions = [] mocks.monitoringBackgroundTasks = false - mocks.stopBackgroundTasks.mockReset() + mocks.supportsBackgroundTaskStop = false + mocks.stopBackgroundTask.mockReset() mocks.backgroundTasks = [] }) @@ -225,11 +228,12 @@ describe('NativeChatStructuredSession', () => { it('places background monitoring above the usable composer and stops without an active turn', async () => { mocks.monitoringBackgroundTasks = true + mocks.supportsBackgroundTaskStop = true mocks.backgroundTasks = [ { id: 'task-command', kind: 'command', description: 'sleep 180' }, { id: 'task-agent', kind: 'agent' } ] - mocks.stopBackgroundTasks.mockResolvedValue({ cancelled: true }) + mocks.stopBackgroundTask.mockResolvedValue({ cancelled: true }) render( { expect(status.compareDocumentPosition(composer) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() expect(mocks.composerProps?.isWorking).toBe(false) expect(screen.queryByRole('list', { name: 'Running background tasks' })).toBeNull() + expect(screen.queryByRole('button', { name: /^Stop / })).toBeNull() const disclosure = screen.getByRole('button', { name: 'Monitoring background tasks' }) expect(disclosure.getAttribute('aria-expanded')).toBe('false') @@ -260,8 +265,130 @@ describe('NativeChatStructuredSession', () => { expect(screen.getByText('sleep 180')).toBeTruthy() expect(screen.getByText('Background agent')).toBeTruthy() - fireEvent.click(screen.getByRole('button', { name: 'Stop' })) - await waitFor(() => expect(mocks.stopBackgroundTasks).toHaveBeenCalledOnce()) + fireEvent.click(screen.getByRole('button', { name: 'Stop sleep 180' })) + await waitFor(() => + expect(mocks.stopBackgroundTask).toHaveBeenCalledWith('session-background', 'task-command') + ) + }) + + it('tracks concurrent task stops independently and clears each pending result', async () => { + mocks.monitoringBackgroundTasks = true + mocks.supportsBackgroundTaskStop = true + mocks.backgroundTasks = [ + { id: 'task-one', kind: 'command', description: 'First task' }, + { id: 'task-two', kind: 'command', description: 'Second task' } + ] + let finishFirst!: (value: unknown) => void + let finishSecond!: (value: unknown) => void + mocks.stopBackgroundTask.mockImplementation( + (_sessionId: string, taskId: string) => + new Promise((resolve) => { + if (taskId === 'task-one') { + finishFirst = resolve + } else { + finishSecond = resolve + } + }) + ) + + render( + + ) + fireEvent.click(screen.getByRole('button', { name: 'Monitoring background tasks' })) + const firstStop = screen.getByRole('button', { name: 'Stop First task' }) + const secondStop = screen.getByRole('button', { name: 'Stop Second task' }) + + fireEvent.click(firstStop) + fireEvent.click(secondStop) + expect((firstStop as HTMLButtonElement).disabled).toBe(true) + expect((secondStop as HTMLButtonElement).disabled).toBe(true) + + await act(async () => finishFirst({ cancelled: true })) + await waitFor(() => expect((firstStop as HTMLButtonElement).disabled).toBe(false)) + expect((secondStop as HTMLButtonElement).disabled).toBe(true) + + await act(async () => finishSecond(null)) + await waitFor(() => expect((secondStop as HTMLButtonElement).disabled).toBe(false)) + }) + + it('keeps a stale session stop result from clearing the current session pending state', async () => { + mocks.monitoringBackgroundTasks = true + mocks.supportsBackgroundTaskStop = true + mocks.backgroundTasks = [{ id: 'task-one', kind: 'command', description: 'Shared task' }] + let finishOld!: (value: unknown) => void + let finishCurrent!: (value: unknown) => void + mocks.stopBackgroundTask.mockImplementation( + (sessionId: string) => + new Promise((resolve) => { + if (sessionId === 'session-old') { + finishOld = resolve + } else { + finishCurrent = resolve + } + }) + ) + const { rerender } = render( + + ) + fireEvent.click(screen.getByRole('button', { name: 'Monitoring background tasks' })) + fireEvent.click(screen.getByRole('button', { name: 'Stop Shared task' })) + + rerender( + + ) + const currentStop = screen.getByRole('button', { name: 'Stop Shared task' }) + expect((currentStop as HTMLButtonElement).disabled).toBe(false) + fireEvent.click(currentStop) + expect((currentStop as HTMLButtonElement).disabled).toBe(true) + + await act(async () => finishOld({ cancelled: true })) + expect((currentStop as HTMLButtonElement).disabled).toBe(true) + await act(async () => finishCurrent({ cancelled: true })) + await waitFor(() => expect((currentStop as HTMLButtonElement).disabled).toBe(false)) + }) + + it('keeps the expanded all-task stop fallback for a taskless older host', async () => { + mocks.monitoringBackgroundTasks = true + mocks.stopBackgroundTask.mockResolvedValue({ cancelled: true }) + + render( + + ) + expect(screen.queryByRole('button', { name: 'Stop background tasks' })).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Monitoring background tasks' })) + expect(screen.getByText('Task details are unavailable for this session.')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Stop background tasks' })) + + await waitFor(() => + expect(mocks.stopBackgroundTask).toHaveBeenCalledWith( + 'session-taskless-background', + undefined + ) + ) }) it('routes a bare model command to the native option picker', async () => { diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 9ac354f8a73..87adb0bda73 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -22,6 +22,14 @@ import { useStructuredNativeChatPaneCommands } from './use-structured-native-cha import type { NativeChatStructuredViewProps } from './native-chat-view-types' import { NativeChatBackgroundTasksStatus } from './NativeChatBackgroundTasksStatus' +type StoppingBackgroundTasks = { + sessionId: string + taskIds: ReadonlySet + all: boolean +} + +const NO_STOPPING_TASKS: ReadonlySet = new Set() + function encodeQuestionAnswer(questionId: string, answer: string): string { return `${encodeURIComponent(questionId)}:${encodeURIComponent(answer)}` } @@ -31,7 +39,8 @@ export function NativeChatStructuredSession( ): React.JSX.Element { const controller = useStructuredAgentSession(props) const [composerError, setComposerError] = useState(null) - const [stoppingBackgroundTasks, setStoppingBackgroundTasks] = useState(false) + const [stoppingBackgroundTasks, setStoppingBackgroundTasks] = + useState(null) const [optionPickerRequest, setOptionPickerRequest] = useState<{ id: string sequence: number @@ -83,6 +92,8 @@ export function NativeChatStructuredSession( const fileLinkContext = useNativeChatFileLinkContext(props.tabId) const imageRuntimeContext = useNativeChatImageRuntimeContext(props.tabId) const fileLinkClick = useNativeChatFileLinkClick(fileLinkContext) + const activeStoppingBackgroundTasks = + stoppingBackgroundTasks?.sessionId === props.sessionId ? stoppingBackgroundTasks : null const prompt = controller.prompts[0] ?? null const questionBody = prompt?.body.kind === 'question' ? prompt.body : null const questions = @@ -277,10 +288,39 @@ export function NativeChatStructuredSession( {controller.isMonitoringBackgroundTasks ? ( { - setStoppingBackgroundTasks(true) - void controller.stopBackgroundTasks().finally(() => setStoppingBackgroundTasks(false)) + supportsTaskStop={controller.supportsBackgroundTaskStop} + stoppingTaskIds={activeStoppingBackgroundTasks?.taskIds ?? NO_STOPPING_TASKS} + stoppingAll={activeStoppingBackgroundTasks?.all ?? false} + onStop={(taskId) => { + const targetSessionId = props.sessionId + setStoppingBackgroundTasks((current) => { + const taskIds = new Set( + current?.sessionId === targetSessionId ? current.taskIds : NO_STOPPING_TASKS + ) + if (taskId) { + taskIds.add(taskId) + } + return { + sessionId: targetSessionId, + taskIds, + all: taskId ? current?.sessionId === targetSessionId && current.all : true + } + }) + void controller.stopBackgroundTask(taskId).finally(() => { + setStoppingBackgroundTasks((current) => { + if (current?.sessionId !== targetSessionId) { + return current + } + const taskIds = new Set(current.taskIds) + if (taskId) { + taskIds.delete(taskId) + } + const all = taskId ? current.all : false + return taskIds.size === 0 && !all + ? null + : { sessionId: targetSessionId, taskIds, all } + }) + }) }} /> ) : null} diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx index 2e611e4c726..9a42ccb6da6 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx @@ -296,4 +296,40 @@ describe('useStructuredAgentSession options', () => { expect(result.current.error).toBeNull() }) + + it('includes one background task id in the cancel fingerprint and payload', async () => { + mocks.call.mockImplementation((_target, method) => + method === 'agentSession.options' + ? Promise.resolve(OPTIONS) + : Promise.resolve({ + ok: true, + value: { turnId: 'background-tasks', cancelled: true } + }) + ) + const { result } = renderHook(() => + useStructuredAgentSession({ + sessionId: 'session-1', + target: LOCAL_TARGET, + agent: 'claude', + isVisible: true + }) + ) + + await act(async () => { + await expect(result.current.stopBackgroundTask('task-2')).resolves.toMatchObject({ + cancelled: true + }) + }) + + const mutation = mocks.call.mock.calls.find(([, method]) => method === 'agentSession.cancel') + expect(mutation?.[2]).toMatchObject({ + envelope: { + sessionId: 'session-1', + expectedRuntimeFence: 3 + }, + turnId: 'background-tasks', + scope: 'background-tasks', + taskId: 'task-2' + }) + }) }) 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 3ae45bc3f43..8af9a36e0c4 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 @@ -245,12 +245,14 @@ export function useStructuredAgentSession(args: { isWorking: turnId !== null, isMonitoringBackgroundTasks, backgroundTasks: state.backgroundTasks?.tasks ?? [], + supportsBackgroundTaskStop: state.backgroundTasks?.supportsTaskStop === true, turnId, cancel: (turnId: string) => mutate('agentSession.cancel', 'agentSession.cancel', { turnId }), - stopBackgroundTasks: () => + stopBackgroundTask: (taskId?: string) => mutate('agentSession.cancel', 'agentSession.cancel', { turnId: 'background-tasks', - scope: 'background-tasks' + scope: 'background-tasks', + ...(taskId ? { taskId } : {}) }), respond: (item: StructuredPromptItem, optionId: string) => mutate( diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 4f9919db5c4..b91c89cfa5a 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -16989,6 +16989,8 @@ "backgroundTasks": { "monitoring": "Monitoring background tasks", "stop": "Stop", + "stopTask": "Stop {{value0}}", + "stopAll": "Stop background tasks", "agent": "Background agent", "workflow": "Background workflow", "command": "Background command", diff --git a/src/shared/agent-session-wire.ts b/src/shared/agent-session-wire.ts index 86c0e795d84..a0af962583d 100644 --- a/src/shared/agent-session-wire.ts +++ b/src/shared/agent-session-wire.ts @@ -64,6 +64,8 @@ export type AgentSessionBackgroundTaskState = { state: 'monitoring' /** Optional so mixed-version clients can consume state-only hosts. */ tasks?: AgentSessionBackgroundTask[] + /** Optional so clients only send targeted stops to hosts that accept them. */ + supportsTaskStop?: boolean } /** Backward paging is the client's normal read; 40 matches the page size the diff --git a/src/shared/structured-agent-session-reducer.test.ts b/src/shared/structured-agent-session-reducer.test.ts index 99ced770641..d36b6717758 100644 --- a/src/shared/structured-agent-session-reducer.test.ts +++ b/src/shared/structured-agent-session-reducer.test.ts @@ -54,6 +54,39 @@ function hydrationPage( } describe('structured agent session reducer', () => { + it('applies an additive targeted-stop capability update without journal churn', () => { + const backgroundTasks = { + state: 'monitoring' as const, + tasks: [{ id: 'task-1', kind: 'agent' as const }] + } + const initial = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, { + type: 'event', + event: { + type: 'snapshot', + sessionId: 'session-a', + fence: 1, + page: { ...hydrationPage([]), backgroundTasks } + } + }) + const updated = reduceStructuredAgentSession(initial, { + type: 'event', + event: { + type: 'batch', + sessionId: 'session-a', + batch: { + cursor: { epoch: 'epoch-a', sequence: 0 }, + items: [], + removedItemIds: [], + submissions: [] + }, + backgroundTasks: { ...backgroundTasks, supportsTaskStop: true } + } + }) + + expect(updated.backgroundTasks).toEqual({ ...backgroundTasks, supportsTaskStop: true }) + expect(updated.items).toBe(initial.items) + }) + it('uses the bounded hydration page pagination boundary', () => { const restored = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, { type: 'event', diff --git a/src/shared/structured-agent-session-reducer.ts b/src/shared/structured-agent-session-reducer.ts index 0a330539548..88d41b2f8e5 100644 --- a/src/shared/structured-agent-session-reducer.ts +++ b/src/shared/structured-agent-session-reducer.ts @@ -51,7 +51,12 @@ function backgroundTaskStatesEqual( if (left === right) { return true } - if (!left || !right || left.state !== right.state) { + if ( + !left || + !right || + left.state !== right.state || + left.supportsTaskStop !== right.supportsTaskStop + ) { return false } if (left.tasks === right.tasks) { From 8ab8c950be799e1ba4ce485c47882e18aa9fba54 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:36:10 -0700 Subject: [PATCH 14/23] fix(native-chat): tell a pre-SQLite chat how to carry on (#18808) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A chat whose journal is still the pre-SQLite `log.jsonl` opened empty and indistinguishable from one created seconds ago. It now carries one status row naming the transcript still on disk and saying to send a message to continue, and read restore no longer drops such sessions — an unpublished chat had its tab pruned from persisted state, leaving nowhere for the message to appear. The notice survives a crash between the epoch commit and its append (re-offered while the epoch holds nothing) and stays out of a journal the same open just repaired, where it would have retired the unreconcilable_prefix marker and permanently ended provider-history recovery. No importer: the history is explained, not replayed. Nothing reads the remnant beyond its existence, and nothing moves or deletes it. --- .../journal-file-format-remnant.test.ts | 196 ++++++++++++++++++ .../journal-file-format-remnant.ts | 60 ++++++ .../journal-store-open.ts | 50 +++++ .../journal-store-restore.ts | 1 + ...uctured-agent-session-read-restore.test.ts | 110 ++++++++++ .../structured-agent-session-read-restore.ts | 19 +- 6 files changed, 433 insertions(+), 3 deletions(-) create mode 100644 src/main/native-chat/agent-session-journal/journal-file-format-remnant.test.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-file-format-remnant.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts diff --git a/src/main/native-chat/agent-session-journal/journal-file-format-remnant.test.ts b/src/main/native-chat/agent-session-journal/journal-file-format-remnant.test.ts new file mode 100644 index 00000000000..d6424ce7952 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-file-format-remnant.test.ts @@ -0,0 +1,196 @@ +// An empty chat beside a pre-SQLite journal explains itself. +// +// The SQLite move shipped no importer, so a session whose history is a +// `log.jsonl` founds a fresh empty journal beside it and looks exactly like a +// chat created seconds ago. One status row is the difference. + +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import Database from '../../sqlite/sync-database' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' +import { projectStructuredItemsToNativeChat } from '../../../shared/structured-agent-session-projection' +import { openJournalDatabase } from './journal-database' +import { JOURNAL_DB_SCHEMA_VERSION } from './journal-database-schema' +import { JOURNAL_FILE_FORMAT_REMNANT_DISCLOSURE_IDENTITY } from './journal-file-format-remnant' +import { loadJournal } from './journal-open' +import { journalDatabaseFile } from './journal-paths' +import type { AgentSessionJournal } from './journal-store' +import type { openAgentSessionJournal } from './journal-store-factory' +import { createTrackedJournalOpener } from './journal-store-test-open' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-1', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } +} + +const DISCLOSURE_ITEM_ID = agentJournalItemKey(JOURNAL_FILE_FORMAT_REMNANT_DISCLOSURE_IDENTITY) + +let root: string +let clock = 1_000 +const journals = createTrackedJournalOpener() + +function open(overrides: Partial[0]> = {}) { + return journals.open({ + identity: IDENTITY, + journalDir: root, + now: () => (clock += 1), + mintEpoch: () => `epoch-${clock}`, + ...overrides + }) +} + +function writeRemnant(name = 'log.jsonl'): Promise { + return writeFile(join(root, name), '{"kind":"epoch","v":1,"seq":1}\n', 'utf8') +} + +function disclosure(journal: AgentSessionJournal): string | null { + const row = journal.snapshot().items.find((entry) => entry.itemId === DISCLOSURE_ITEM_ID) + return row?.body.kind === 'status' ? row.body.text : null +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-journal-remnant-')) + clock = 1_000 +}) + +afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +describe('a chat whose history is still in the pre-SQLite format', () => { + it('says how to carry on, and where the transcript is', async () => { + await writeRemnant() + + const journal = await open() + + expect(disclosure(journal)).toContain('send a message to pick up where you left off') + expect(disclosure(journal)).toContain(join(root, 'log.jsonl')) + expect(disclosure(journal)).toContain('Codex') + }) + + // Both files is the normal shape of a pre-SQLite directory: every epoch roll + // staged a snapshot whether or not anything compacted into it, so preferring + // the snapshot would name an empty file for ~every affected chat. + it('names the log, not the snapshot staged beside it', async () => { + await writeRemnant('log.jsonl') + await writeRemnant('snapshot.json') + + const journal = await open() + + expect(disclosure(journal)).toContain(join(root, 'log.jsonl')) + expect(disclosure(journal)).not.toContain('snapshot.json') + }) + + it('falls back to the snapshot when a chat has no log beside it', async () => { + await writeRemnant('snapshot.json') + + const journal = await open() + + expect(disclosure(journal)).toContain(join(root, 'snapshot.json')) + }) + + it('says nothing to a chat that is genuinely new', async () => { + const journal = await open() + + expect(journal.snapshot().items).toEqual([]) + }) + + // Counting rows proves nothing here — the append upserts by identity, so a + // second append would still leave exactly one. The revision is what moves. + it('does not re-append the row on a later open', async () => { + await writeRemnant() + const first = await open() + const firstRevision = first + .snapshot() + .items.find((e) => e.itemId === DISCLOSURE_ITEM_ID)?.revision + await first.close() + + const reopened = await open() + + const row = reopened.snapshot().items.find((e) => e.itemId === DISCLOSURE_ITEM_ID) + expect(firstRevision).toBe(1) + expect(row?.revision).toBe(1) + expect(reopened.cursor().sequence).toBe(2) + }) + + // The epoch commit and this append are separate transactions; if the append is + // lost the epoch exists but holds nothing, and every later open takes the + // adopt branch. The offer has to survive that. + it('offers the message again when a committed epoch holds nothing', async () => { + const founded = await open() + await founded.close() + await writeRemnant() + + const reopened = await open() + + expect(disclosure(reopened)).toContain(join(root, 'log.jsonl')) + }) + + // A repair's epoch is the marker that history was deleted and never rebuilt, + // and any row that is not the repair's own disclosure retires it. Appending + // here would silently stop the session ever asking the provider for that + // history — with the journal still holding none. + it('stays out of a journal this open just repaired', async () => { + const journal = await open() + await journal.appendItem( + { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 0 }, + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'history' }] }, + { fence: 1 } + ) + await journal.close() + // Deleting the anchor leaves every row unanchored: replay keeps nothing, so + // the repair publishes an empty `unreconcilable_prefix` epoch and — costing + // no malformed row — appends no disclosure of its own. That is the one state + // where this branch and a repair meet. + const opened = openJournalDatabase(journalDatabaseFile(root)) + try { + opened.db.prepare('DELETE FROM journal_rows WHERE seq = ?').run(1) + } finally { + opened.db.close() + } + await writeRemnant() + + const repaired = await open() + + expect(disclosure(repaired)).toBeNull() + // Still asking the provider for the history the repair dropped. + expect(loadJournal(root, IDENTITY.sessionId)).toMatchObject({ corrupt: true }) + }) + + // A latched journal loads empty, so it reaches the same branch — and an append + // into one throws, which would make the session unopenable rather than read-only. + it('writes nothing into a journal latched by a newer schema', async () => { + const founded = await open() + await founded.close() + const db = new Database(journalDatabaseFile(root)) + try { + db.pragma(`user_version = ${JOURNAL_DB_SCHEMA_VERSION + 1}`) + } finally { + db.close() + } + await writeRemnant() + + const latched = await open() + + expect(latched.isReadOnly).toBe(true) + expect(disclosure(latched)).toBeNull() + }) + + // A row nothing projects is a row nobody reads. + it('renders in the transcript as a system line', async () => { + await writeRemnant() + + const journal = await open() + + const messages = projectStructuredItemsToNativeChat(journal.snapshot().items) + expect(messages).toHaveLength(1) + expect(messages[0]?.role).toBe('system') + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-file-format-remnant.ts b/src/main/native-chat/agent-session-journal/journal-file-format-remnant.ts new file mode 100644 index 00000000000..69dcc04bf64 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-file-format-remnant.ts @@ -0,0 +1,60 @@ +// A journal directory left behind by the pre-SQLite file format. +// +// Not `journal-legacy-import.ts`, which reads the PROVIDER's own transcript. +// This is Orca's own `log.jsonl`, which no build after the SQLite move reads. +// Nothing imports it, so the session it belonged to opens empty and is +// indistinguishable from a chat created seconds ago — same `session_created` +// epoch, same empty timeline. The remnant is the one durable fact that tells +// them apart, so the empty session says where its history went and how to carry +// on instead of silently claiming it never had any. + +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import type { AgentJournalItemIdentity } from '../../../shared/agent-session-journal-types' +import { boundJournalStatusText } from './journal-prompt-body-bounds' +import { formatAgentTypeLabel } from '../../../shared/agent-type-label' +import type { AgentType } from '../../../shared/agent-status-types' + +/** The remnant's transcript, or null when the directory never held one. + * + * `log.jsonl` first, and the order matters: every epoch roll staged a + * `snapshot.json` whether or not anything was ever compacted into it, so the + * file's existence says nothing about where the history lives. Measured across + * a real profile, `compactedThrough` was 0 in all 80 — the log holds the + * transcript and the snapshot is the fallback for a session that has no log. */ +export function findJournalFileFormatRemnant(journalDir: string): string | null { + for (const name of ['log.jsonl', 'snapshot.json']) { + const path = join(journalDir, name) + if (existsSync(path)) { + return path + } + } + return null +} + +/** One stable identity, so a reopen upserts the same row instead of adding one. */ +export const JOURNAL_FILE_FORMAT_REMNANT_DISCLOSURE_IDENTITY: AgentJournalItemIdentity = { + provider: 'orca', + clientMessageId: 'journal-file-format-remnant' +} + +/** How to carry on. The session attaches on the record's own provider handle, so it + * still names the conversation the transcript no longer shows — whether the provider + * itself still holds that thread is its own business, hence "points at". */ +export function journalFileFormatRemnantDisclosure(input: { + transcriptPath: string + agent: AgentType +}): { identity: AgentJournalItemIdentity; body: { kind: 'status'; text: string } } { + return { + identity: JOURNAL_FILE_FORMAT_REMNANT_DISCLOSURE_IDENTITY, + body: { + kind: 'status', + text: boundJournalStatusText( + `This chat's history was saved in an older format Orca no longer reads, so it starts ` + + `empty. The session still points at the same ${formatAgentTypeLabel(input.agent)} ` + + `conversation — send a message to pick up where you left off. The original ` + + `transcript is on the session's host at \`${input.transcriptPath}\`` + ) + } + } +} diff --git a/src/main/native-chat/agent-session-journal/journal-store-open.ts b/src/main/native-chat/agent-session-journal/journal-store-open.ts index e002048a094..721e5f4ba7f 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-open.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-open.ts @@ -1,7 +1,16 @@ import { mkdir } from 'node:fs/promises' +import type { AgentType } from '../../../shared/agent-status-types' +import { + findJournalFileFormatRemnant, + journalFileFormatRemnantDisclosure +} from './journal-file-format-remnant' import type { JournalLoad } from './journal-open' import { journalRepairDisclosure, type JournalRepairDisclosure } from './journal-repair-disclosure' +/** What any of this file's disclosures hands the store — a repair's, or the + * pre-SQLite notice's. Same shape, and neither is only a repair. */ +type JournalDisclosure = JournalRepairDisclosure + export async function ensureJournalDir(journalDir: string): Promise { await mkdir(journalDir, { recursive: true }) } @@ -32,6 +41,7 @@ export async function openJournalStoreState(input: { body: JournalRepairDisclosure['body'], fence: number ) => Promise + agent: AgentType highestFence: () => number malformedRows: () => number setMalformedRows: (count: number) => void @@ -40,6 +50,7 @@ export async function openJournalStoreState(input: { const loaded = input.loaded !== undefined ? input.loaded : input.replay() if (!loaded) { input.start() + await discloseFileFormatRemnant(input) return } input.adopt(loaded) @@ -59,4 +70,43 @@ export async function openJournalStoreState(input: { const disclosure = journalRepairDisclosure({ malformedRows: input.malformedRows() }) await input.appendDisclosure(disclosure.identity, disclosure.body, input.highestFence()) } + // Founding the epoch and appending the row are two transactions, and a + // committed epoch sends every later open down this branch instead. Anything + // that interrupts between them — a quit during startup restore, a failed + // append — would otherwise lose the message for good. An epoch holding nothing + // is exactly the state that append was owed, so offer it again. + // + // Never onto a repair, though: `loaded.state` is the PRE-repair load, so a + // journal this open just emptied looks identical. The repair's epoch is the + // marker that its history was deleted and never rebuilt, and any row that is + // not the repair's own disclosure retires it — this row would silently stop + // the session ever asking the provider for that history again. + if (!loaded.corrupt && loaded.state.items.size === 0 && loaded.state.submissions.size === 0) { + await discloseFileFormatRemnant(input) + } +} + +/** Says what happened to a chat whose history is in the abandoned file format. + * Upserts by a constant identity, so the offer above is exactly-once in effect: + * once the row exists the epoch is no longer empty. */ +async function discloseFileFormatRemnant(input: { + journalDir: string + agent: AgentType + appendDisclosure: ( + identity: JournalDisclosure['identity'], + body: JournalDisclosure['body'], + fence: number + ) => Promise + highestFence: () => number + readOnly: () => boolean +}): Promise { + if (input.readOnly()) { + return + } + const transcriptPath = findJournalFileFormatRemnant(input.journalDir) + if (!transcriptPath) { + return + } + const disclosure = journalFileFormatRemnantDisclosure({ transcriptPath, agent: input.agent }) + await input.appendDisclosure(disclosure.identity, disclosure.body, input.highestFence()) } diff --git a/src/main/native-chat/agent-session-journal/journal-store-restore.ts b/src/main/native-chat/agent-session-journal/journal-store-restore.ts index 53683a797d1..3a5d3c7ac6e 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-restore.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-restore.ts @@ -41,6 +41,7 @@ export function restoreJournalStore( adopt: host.adopt, appendDisclosure: (identity, body, fence) => host.journal().appendItem(identity, body, { fence }), + agent: host.identity.agent, highestFence: () => host.state().highestFence, malformedRows: host.malformedRows, setMalformedRows: host.setMalformedRows, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts new file mode 100644 index 00000000000..34e3fb8a4cf --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts @@ -0,0 +1,110 @@ +// Read restore decides whether a session comes back at all. +// +// A chat still in the pre-SQLite format has no `journal.db`, so the probe that +// loads one reports nothing. Reading that as "no session" is what removed these +// chats: an unpublished session is also what prunes its tab out of the saved +// workspace, so the tab is gone before anything can explain itself. + +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 } from 'vitest' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { journalDirectoryFor } from '../agent-session-journal/journal-paths' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { restoreStructuredAgentSessionRead } from './structured-agent-session-read-restore' + +const SESSION_ID = 'codex_read_restore_fixture' +const WORKSPACE_ID = 'repo-1::/tmp/workspace' + +const RECORD = { + schemaVersion: 2, + sessionId: SESSION_ID, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: WORKSPACE_ID, + workspaceKind: 'git-worktree' + }, + provider: 'codex', + providerHandleChain: [ + { + linkId: 'codex-1-thread-1', + handle: { provider: 'codex', threadId: 'thread-1' }, + origin: 'created', + mintedAtFence: 1, + observedAt: 1 + } + ], + accountHome: { variable: 'CODEX_HOME', path: '/tmp/codex-home' }, + createdAt: 1, + updatedAt: 2, + lease: { sessionId: SESSION_ID, runtimeKind: 'native', runtimeFence: 1 } +} as unknown as AgentSessionRecord + +const store = { + getRecord: (sessionId: string) => (sessionId === SESSION_ID ? RECORD : null) +} as unknown as AgentSessionRecordStore + +let journalRoot: string +const opened: AgentSessionJournal[] = [] + +async function writeRemnant(name: string): Promise { + const dir = journalDirectoryFor(journalRoot, { + workspaceId: WORKSPACE_ID, + sessionId: SESSION_ID + }) + await mkdir(dir, { recursive: true }) + await writeFile(join(dir, name), '{"kind":"epoch","v":1,"seq":1}\n', 'utf8') + return join(dir, name) +} + +beforeEach(async () => { + journalRoot = await mkdtemp(join(tmpdir(), 'orca-read-restore-')) +}) + +afterEach(async () => { + await Promise.allSettled(opened.splice(0).map((journal) => journal.close())) + await rm(journalRoot, { recursive: true, force: true }) +}) + +describe('a session whose journal is still the pre-SQLite format', () => { + it('is published, carrying the message that explains it', async () => { + const transcript = await writeRemnant('log.jsonl') + + const restored = await restoreStructuredAgentSessionRead(store, journalRoot, SESSION_ID) + + expect(restored).not.toBeNull() + opened.push(restored!.journal) + const disclosed = restored!.journal + .snapshot() + .items.map((entry) => (entry.body.kind === 'status' ? entry.body.text : '')) + expect(disclosed.join('')).toContain(transcript) + // Publishing it costs no agent process; acquisition still waits for the user. + expect(restored!.hasProviderChild).toBe(false) + }) + + it('is published for a remnant whose log is gone', async () => { + await writeRemnant('snapshot.json') + + const restored = await restoreStructuredAgentSessionRead(store, journalRoot, SESSION_ID) + + expect(restored).not.toBeNull() + opened.push(restored!.journal) + }) + + it('still drops a session with neither a journal nor a remnant', async () => { + const restored = await restoreStructuredAgentSessionRead(store, journalRoot, SESSION_ID) + + expect(restored).toBeNull() + }) + + it('still drops a session with no record', async () => { + await writeRemnant('log.jsonl') + + const restored = await restoreStructuredAgentSessionRead(store, journalRoot, 'unknown-session') + + expect(restored).toBeNull() + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.ts index 5bed8f2920e..34fd6452b08 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.ts @@ -3,6 +3,7 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { findJournalFileFormatRemnant } from '../agent-session-journal/journal-file-format-remnant' import { loadJournal } from '../agent-session-journal/journal-open' import { journalDirectoryFor } from '../agent-session-journal/journal-paths' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' @@ -40,15 +41,27 @@ export async function restoreStructuredAgentSessionRead( sessionId }) const loaded = loadJournal(journalDir, sessionId) - if (!loaded || loaded.corrupt) { + if (loaded?.corrupt) { + return null + } + // A session still in the pre-SQLite format has no `journal.db` to load. Dropping + // it here leaves it unpublished, which is also what prunes its tab out of the + // saved workspace — so the chat disappears with nowhere to explain itself. + if (!loaded && !findJournalFileFormatRemnant(journalDir)) { return null } const journal = await openAgentSessionJournal({ identity: journalIdentityFor(record, params), journalDir, - loaded + // Omitted, not `null`: the store reads `null` as "replay already ran and + // found nothing" and founds a fresh epoch. In process the probe above is the + // previous statement, so the window is zero-width; this holds the line for a + // database another process creates in between. + ...(loaded ? { loaded } : {}) }) - // Read restore opens the journal and nothing else: no adapter call, so no provider child. + // Read restore opens the journal and nothing else: no adapter call, so no + // provider child. Opening it can still write — a session whose history is in + // the old format founds its epoch and commits the row explaining that here. return { journal, params, From c58d7a0ecdc732dce615fbb548147e0e1a78a1ac Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:37:52 -0700 Subject: [PATCH 15/23] fix(agent-session): never open a sibling terminal on an unproven create (#18735) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An `agentSession.create` the host could not confirm — it committed the session but could not publish its tab, and answered `agent_session_operation_unknown` — was rejected with a bare `Error` carrying a `code`. Nothing in the type said "unknown", so the verdict lived only in the code string, and the shared transport matcher was still free to re-read that error's *message*: an unknown refusal whose text ends in a definitive token (`Owner check failed: method_not_found`) classified as definitive, which is exactly the answer that permits a legacy sibling terminal. Make the class the verdict. `StructuredAgentSessionCreateUnknownOutcomeError` is a sibling of `StructuredAgentSessionCreateRefusalError`, not a subclass, so the nine existing `instanceof` consumers keep reading "refusal" as "you may fall back" with zero edits, and an unknown outcome flows down the lost-reply path instead — replaying the same envelope, re-publishing the tab the host failed to publish, and parking as visibility-unknown rather than creating anything. Classification now short-circuits on our own classes, so a message we wrote can never invert the verdict we already reached. Adds an end-to-end guard that drives the real classifier through `startStructuredAgentLaunch`: an unknown outcome opens zero legacy terminals, a definitive refusal opens exactly one. Ablating the branch turns that green suite red with `['legacy-terminal']` — the duplicate session the guard exists to prevent. Co-authored-by: Merge Sim --- .../launch-structured-agent-session.test.ts | 23 +- .../lib/launch-structured-agent-session.ts | 50 ++++- ...nt-session-launch-refusal-fallback.test.ts | 208 ++++++++++++++++++ 3 files changed, 268 insertions(+), 13 deletions(-) create mode 100644 src/renderer/src/lib/structured-agent-session-launch-refusal-fallback.test.ts diff --git a/src/renderer/src/lib/launch-structured-agent-session.test.ts b/src/renderer/src/lib/launch-structured-agent-session.test.ts index e9f65f3477b..d9a75ee2827 100644 --- a/src/renderer/src/lib/launch-structured-agent-session.test.ts +++ b/src/renderer/src/lib/launch-structured-agent-session.test.ts @@ -5,7 +5,8 @@ import { createStructuredAgentSessionLaunchIntent, isDefinitiveStructuredAgentSessionCreateError, launchStructuredAgentSession, - StructuredAgentSessionCreateRefusalError + StructuredAgentSessionCreateRefusalError, + StructuredAgentSessionCreateUnknownOutcomeError } from './launch-structured-agent-session' vi.mock('@/runtime/structured-agent-session-client', () => ({ @@ -256,11 +257,31 @@ describe('structured agent session launch', () => { createStructuredAgentSessionLaunchIntent('workspace-unknown', 'codex') ).catch((caught: unknown) => caught) + expect(error).toBeInstanceOf(StructuredAgentSessionCreateUnknownOutcomeError) expect(error).not.toBeInstanceOf(StructuredAgentSessionCreateRefusalError) expect(error).toMatchObject({ code: 'agent_session_operation_unknown' }) expect(isDefinitiveStructuredAgentSessionCreateError(error)).toBe(false) }) + /** The class is the verdict, so a refusal message that happens to end in a definitive token + * must not be re-read into one by the transport-error matcher. */ + it('keeps an unknown outcome unknown even when its message ends in a definitive token', async () => { + vi.mocked(callStructuredAgentSession).mockResolvedValue({ + ok: false, + refusal: { + code: 'agent_session_ownership_unknown', + message: 'Owner check failed: method_not_found' + } + }) + + const error = await launchStructuredAgentSession( + createStructuredAgentSessionLaunchIntent('workspace-unknown-token', 'codex') + ).catch((caught: unknown) => caught) + + expect(error).toBeInstanceOf(StructuredAgentSessionCreateUnknownOutcomeError) + expect(isDefinitiveStructuredAgentSessionCreateError(error)).toBe(false) + }) + it('preserves a definitive refusal code for the fallback path', async () => { vi.mocked(callStructuredAgentSession).mockResolvedValue({ ok: false, diff --git a/src/renderer/src/lib/launch-structured-agent-session.ts b/src/renderer/src/lib/launch-structured-agent-session.ts index 6c2d1694437..3d7f94a1135 100644 --- a/src/renderer/src/lib/launch-structured-agent-session.ts +++ b/src/renderer/src/lib/launch-structured-agent-session.ts @@ -33,24 +33,52 @@ export type StructuredAgentSessionLaunchIntent = { params: StructuredAgentSessionCreateParams } -export class StructuredAgentSessionCreateRefusalError extends Error { +class StructuredAgentSessionCreateError extends Error { constructor( message: string, - readonly code: string = 'structured_agent_session_unsupported' + /** The wire refusal code, or the RPC error code when the create never reached a handler. */ + readonly code: string ) { super(message) + } +} + +/** + * The host proved it created nothing, so a caller may open a legacy terminal instead. The class + * itself is the verdict: `launchStructuredAgentSession` is the only place that decides it, against + * the shared allowlist, so no consumer has to remember to re-check a code. + */ +export class StructuredAgentSessionCreateRefusalError extends StructuredAgentSessionCreateError { + constructor(message: string, code: string = 'structured_agent_session_unsupported') { + super(message, code) this.name = 'StructuredAgentSessionCreateRefusalError' } } +/** + * Refused with a code that does not prove the session is absent. A sibling opened here would sit + * beside a session the host may already hold, so this deliberately is NOT a refusal error: it flows + * down the same path as a lost reply, which replays the intent and reconciles. + */ +export class StructuredAgentSessionCreateUnknownOutcomeError extends StructuredAgentSessionCreateError { + constructor(message: string, code: string) { + super(message, code) + this.name = 'StructuredAgentSessionCreateUnknownOutcomeError' + } +} + const DEFINITIVE_CREATE_FAILURE_CODES = [ 'structured_agent_session_unsupported', 'method_not_found' ] as const function definitiveStructuredAgentSessionCreateErrorCode(error: unknown): string | null { - if (error instanceof StructuredAgentSessionCreateRefusalError) { - return isDefinitiveAgentSessionCreateRefusal(error.code) ? error.code : null + if (error instanceof StructuredAgentSessionCreateError) { + // Our own classes already carry the verdict; message sniffing below could only invert it. + return error instanceof StructuredAgentSessionCreateRefusalError && + isDefinitiveAgentSessionCreateRefusal(error.code) + ? error.code + : null } for (const code of DEFINITIVE_CREATE_FAILURE_CODES) { if (hasRuntimeRpcErrorCode(error, code)) { @@ -200,15 +228,13 @@ export async function launchStructuredAgentSession( throw error } if (!result.ok) { - const error = new StructuredAgentSessionCreateRefusalError( - result.refusal.message, - result.refusal.code - ) - if (isDefinitiveStructuredAgentSessionCreateError(error)) { - abandonStructuredAgentSessionLaunchIntent(intent) - throw error + const { code, message } = result.refusal + if (!isDefinitiveAgentSessionCreateRefusal(code)) { + // Keep the focus intent: the session may exist, and recovery still has to adopt it. + throw new StructuredAgentSessionCreateUnknownOutcomeError(message, code) } - throw Object.assign(new Error(error.message), { code: error.code }) + abandonStructuredAgentSessionLaunchIntent(intent) + throw new StructuredAgentSessionCreateRefusalError(message, code) } return { sessionId: result.value.sessionId, fence: result.value.fence } } diff --git a/src/renderer/src/lib/structured-agent-session-launch-refusal-fallback.test.ts b/src/renderer/src/lib/structured-agent-session-launch-refusal-fallback.test.ts new file mode 100644 index 00000000000..45c41bd111e --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-launch-refusal-fallback.test.ts @@ -0,0 +1,208 @@ +// @vitest-environment happy-dom + +// The duplicate-session guard: which create refusals may open a legacy terminal beside the chat. +// Deliberately exercises the real `launch-structured-agent-session`, because the classification +// under test lives there — mocking it out would assert nothing. + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { toast } from 'sonner' +import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-session-contracts' +import { RuntimeRpcCallError } from '@/runtime/runtime-rpc-client' + +const mocks = vi.hoisted(() => ({ + call: vi.fn(), + refresh: vi.fn() +})) + +vi.mock('sonner', () => ({ + toast: { error: vi.fn(), message: vi.fn() } +})) + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string, options?: { value0?: string }) => + fallback.replace('{{value0}}', options?.value0 ?? '') +})) + +vi.mock('@/lib/agent-catalog', () => ({ + getAgentCatalog: () => [{ id: 'codex', label: 'Codex' }] +})) + +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: mocks.call +})) + +vi.mock('@/runtime/local-structured-session-tabs-sync', () => ({ + LOCAL_STRUCTURED_SESSION_OWNER: 'local', + refreshLocalStructuredSessionTabs: mocks.refresh +})) + +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => ({ unifiedTabsByWorktree: {} }), + subscribe: () => () => {} + } +})) + +import { + StructuredAgentSessionCreateRefusalError, + StructuredAgentSessionCreateUnknownOutcomeError +} from '@/lib/launch-structured-agent-session' +import { + getStructuredAgentLaunchStatus, + startStructuredAgentLaunch +} from './structured-agent-session-launch' + +type CreateReply = { ok: boolean; refusal?: { code: string; message: string } } + +/** Replies to every `agentSession.create` in turn, repeating the last reply thereafter. */ +function replyToCreates(...replies: CreateReply[]): void { + let index = 0 + mocks.call.mockImplementation(async (_target: unknown, method: string, params: unknown) => { + if (method !== 'agentSession.create') { + return { ok: true, page: { fence: 1 } } + } + const reply = replies[Math.min(index, replies.length - 1)] + index += 1 + if (!reply.ok) { + return reply + } + const sessionId = (params as { envelope: { sessionId: string } }).envelope.sessionId + return { ok: true, replayed: index > 1, fence: 1, value: { sessionId, fence: 1 } } + }) +} + +function refused(code: string): CreateReply { + return { ok: false, refusal: { code, message: `create refused: ${code}` } } +} + +function publishedSnapshot(worktreeId: string, sessionId: string): RuntimeMobileSessionTabsResult { + return { + worktree: worktreeId, + publicationEpoch: 'epoch-1', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [ + { + type: 'agent-session', + id: 'tab-1', + title: 'Codex', + sessionId, + agent: 'codex', + isActive: true + } + ] + } +} + +async function flushLaunchSettlement(): Promise { + for (let i = 0; i < 20; i += 1) { + await Promise.resolve() + } +} + +describe('legacy terminal fallback after a refused structured create', () => { + beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + mocks.refresh.mockResolvedValue([]) + }) + + it.each(['agent_session_operation_unknown', 'agent_session_ownership_unknown'])( + 'opens no sibling terminal when the host answers %s', + async (code) => { + const worktreeId = `wt-${code}` + const legacyTerminals: string[] = [] + replyToCreates(refused(code)) + + const launch = startStructuredAgentLaunch(worktreeId, 'codex') + void launch.claimDefinitiveRefusalFallback(() => { + legacyTerminals.push('legacy-terminal') + }) + + await expect(launch.launchResult).rejects.toBeInstanceOf( + StructuredAgentSessionCreateUnknownOutcomeError + ) + await flushLaunchSettlement() + + // The host may already hold the session, so the user keeps exactly one thing: no chat it + // could confirm, and no terminal beside a session it could not rule out. + expect(legacyTerminals).toEqual([]) + expect(launch.isVisibilityUnknown()).toBe(true) + expect(toast.error).toHaveBeenCalledOnce() + } + ) + + it('adopts the session an unknown outcome had already created, without a sibling', async () => { + const worktreeId = 'wt-unknown-then-published' + const legacyTerminals: string[] = [] + replyToCreates(refused('agent_session_operation_unknown'), { ok: true }) + + const launch = startStructuredAgentLaunch(worktreeId, 'codex') + const fallbackRan = launch.claimDefinitiveRefusalFallback(() => { + legacyTerminals.push('legacy-terminal') + }) + mocks.refresh + .mockResolvedValueOnce([]) + .mockResolvedValue([publishedSnapshot(worktreeId, launch.sessionId)]) + + await expect(launch.launchResult).resolves.toEqual({ + sessionId: launch.sessionId, + fence: 1 + }) + await expect(fallbackRan).resolves.toBe(false) + await flushLaunchSettlement() + + expect(legacyTerminals).toEqual([]) + expect(toast.error).not.toHaveBeenCalled() + }) + + it('opens exactly one legacy terminal when the refusal is on the definitive allowlist', async () => { + const worktreeId = 'wt-unsupported' + const legacyTerminals: string[] = [] + replyToCreates(refused('structured_agent_session_unsupported')) + + const launch = startStructuredAgentLaunch(worktreeId, 'codex') + const fallbackRan = launch.claimDefinitiveRefusalFallback(() => { + legacyTerminals.push('legacy-terminal') + }) + + await expect(launch.launchResult).rejects.toBeInstanceOf( + StructuredAgentSessionCreateRefusalError + ) + await expect(fallbackRan).resolves.toBe(true) + await flushLaunchSettlement() + + expect(legacyTerminals).toEqual(['legacy-terminal']) + // A proven "nothing was created" needs no replay, so the terminal is the only surface open. + expect( + mocks.call.mock.calls.filter(([, method]) => method === 'agentSession.create') + ).toHaveLength(1) + expect(launch.isVisibilityUnknown()).toBe(false) + }) + + it('opens exactly one legacy terminal when an older runtime has no create method', async () => { + const legacyTerminals: string[] = [] + mocks.call.mockRejectedValue( + new RuntimeRpcCallError({ + id: 'rpc-old-runtime', + ok: false, + error: { code: 'method_not_found', message: 'Unknown method: agentSession.create' } + }) + ) + + const launch = startStructuredAgentLaunch('wt-old-runtime', 'codex') + const fallbackRan = launch.claimDefinitiveRefusalFallback(() => { + legacyTerminals.push('legacy-terminal') + }) + + await expect(launch.launchResult).rejects.toBeInstanceOf( + StructuredAgentSessionCreateRefusalError + ) + await expect(fallbackRan).resolves.toBe(true) + expect(legacyTerminals).toEqual(['legacy-terminal']) + expect(mocks.call).toHaveBeenCalledOnce() + expect(getStructuredAgentLaunchStatus('wt-old-runtime', 'codex')).toBe('idle') + }) +}) From 6a5c1f9535ee8d6b433eca58e9268ebc41d33e1a Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 5 Sep 2026 16:02:14 -0700 Subject: [PATCH 16/23] refactor(agent-session): consolidate wire type imports below lint limit (#18930) --- .../structured-agent-session-host.ts | 31 +++++++------------ 1 file changed, 12 insertions(+), 19 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index e4c7d191067..aef76c16cdb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -2,17 +2,7 @@ // Mutations share one durable admission path and serialize per session. import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' -import type { - AgentSessionAttachResult, - AgentSessionHistoryRequest, - AgentSessionHistoryResult, - AgentSessionHandoffRequest, - AgentSessionHandoffResult, - AgentSessionHandoffStatus, - AgentSessionMutationResult, - AgentSessionOptionsResult, - AgentSessionWireRefusal -} from '../../../shared/agent-session-wire' +import type * as SessionWire from '../../../shared/agent-session-wire' import type { AgentSessionAttachParams } from './structured-agent-session-attach' import { AGENT_SESSION_NOT_ATTACHED } from './structured-agent-session-mutation-admission' import { createRestartReconciler } from './structured-agent-session-restart-reconcile' @@ -77,7 +67,9 @@ export class StructuredAgentSessionHost { }) private readonly tasks = new StructuredAgentSessionTaskQueue() private readonly runtimeState: StructuredAgentSessionHostRuntimeState - private readonly reconcileLeases: (sessionId: string) => Promise + private readonly reconcileLeases: ( + sessionId: string + ) => Promise private readonly handoffs: StructuredAgentSessionHostHandoff private readonly readableRestorer: StructuredAgentSessionReadableRestorer private readonly restartRestore = new StructuredAgentSessionRestartRestoreGate() @@ -252,7 +244,7 @@ export class StructuredAgentSessionHost { attach( caller: StructuredAgentSessionCaller, params: AgentSessionAttachParams - ): Promise> { + ): Promise> { return attachStructuredAgentSession(this.attachContext(), caller.callerKey, params) } @@ -318,22 +310,23 @@ export class StructuredAgentSessionHost { requestHandoff = ( caller: StructuredAgentSessionCaller, - params: AgentSessionHandoffRequest - ): Promise> => + params: SessionWire.AgentSessionHandoffRequest + ): Promise> => this.handoffs.request(caller.callerKey, params) - readOptions = (sessionId: string): Promise => + readOptions = (sessionId: string): Promise => readStructuredAgentSessionOptions(this.mutationContext(), sessionId) - async handoffStatus(sessionId: string): Promise { + async handoffStatus(sessionId: string): Promise { this.requireSession(sessionId) return this.serialize(sessionId, () => refreshRecoverableStructuredHandoffStatus(this.handoffs, this.deps.store, sessionId) ) } - history = (request: AgentSessionHistoryRequest): AgentSessionHistoryResult => - this.backgroundTasks.history(request) + history = ( + request: SessionWire.AgentSessionHistoryRequest + ): SessionWire.AgentSessionHistoryResult => this.backgroundTasks.history(request) subscribe = (input: AgentSessionSubscribeInput): (() => void) => this.backgroundTasks.subscribe(input) From 08c3e854403e184f1b5badac67389dacee09bfe4 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 5 Sep 2026 16:11:04 -0700 Subject: [PATCH 17/23] test(e2e): stabilize terminal launch and rename menu fixtures (#18928) --- ...ackground-terminal-mount-authority.spec.ts | 47 ++++++++++++------- tests/e2e/tab-rename.spec.ts | 2 +- 2 files changed, 31 insertions(+), 18 deletions(-) diff --git a/tests/e2e/live-background-terminal-mount-authority.spec.ts b/tests/e2e/live-background-terminal-mount-authority.spec.ts index ea6ffd1871a..a785454f695 100644 --- a/tests/e2e/live-background-terminal-mount-authority.spec.ts +++ b/tests/e2e/live-background-terminal-mount-authority.spec.ts @@ -23,6 +23,10 @@ import type { } from '../../src/shared/runtime-types' import { PROTOCOL_VERSION } from '../../src/main/daemon/types' import { makePaneKey } from '../../src/shared/stable-pane-id' +import { + buildFakeAgentCommandOverride, + FAKE_AGENT_WINDOWS_SHELL +} from './helpers/fake-agent-command-override' type SpawnEvent = { args: string[]; pid: number } type TerminalIdentity = Pick< @@ -70,6 +74,10 @@ if (process.platform === 'win32') { chmodSync(executable, 0o755) } +const fakeCodexCommand = buildFakeAgentCommandOverride( + path.join(fakeCliDir, process.platform === 'win32' ? 'codex.cmd' : 'codex') +) + const test = base.extend({ launchEnv: [ { @@ -535,23 +543,28 @@ test('adopts runtime-owned agent and Setup PTYs on first mount', async ({ const repoId = added.result.repo.id await expect .poll(() => - orcaPage.evaluate(async (repoId) => { - const state = window.__store?.getState() - await state?.fetchRepos() - const repo = window.__store?.getState().repos.find((candidate) => candidate.id === repoId) - if (!repo) { - return false - } - await window.__store?.getState().updateRepo(repoId, { - hookSettings: { ...repo.hookSettings, setupAgentStartupPolicy: 'start-immediately' } - }) - await window.__store?.getState().updateSettings({ - disabledTuiAgents: [], - setupScriptLaunchMode: 'new-tab', - terminalHiddenViewParking: false - }) - return true - }, repoId) + orcaPage.evaluate( + async ({ repoId, command, windowsShell }) => { + const state = window.__store?.getState() + await state?.fetchRepos() + const repo = window.__store?.getState().repos.find((candidate) => candidate.id === repoId) + if (!repo) { + return false + } + await window.__store?.getState().updateRepo(repoId, { + hookSettings: { ...repo.hookSettings, setupAgentStartupPolicy: 'start-immediately' } + }) + await window.__store?.getState().updateSettings({ + agentCmdOverrides: { codex: command }, + terminalWindowsShell: windowsShell, + disabledTuiAgents: [], + setupScriptLaunchMode: 'new-tab', + terminalHiddenViewParking: false + }) + return true + }, + { repoId, command: fakeCodexCommand, windowsShell: FAKE_AGENT_WINDOWS_SHELL } + ) ) .toBe(true) diff --git a/tests/e2e/tab-rename.spec.ts b/tests/e2e/tab-rename.spec.ts index 6e7f0a7fdc1..30cdb9175a9 100644 --- a/tests/e2e/tab-rename.spec.ts +++ b/tests/e2e/tab-rename.spec.ts @@ -126,7 +126,7 @@ test.describe('Tab Rename (Inline)', () => { expect(originalTitle.length).toBeGreaterThan(0) await tabLocatorByTitle(orcaPage, originalTitle).click({ button: 'right' }) - await orcaPage.getByRole('menuitem', { name: 'Change Title', exact: true }).click() + await orcaPage.getByRole('menuitem', { name: /^Change Title(?:\s|$)/ }).click() const renameInput = orcaPage.getByRole('textbox', { name: `Rename tab ${originalTitle}`, From a730becd7a6274b61b141b205b0d94f27f3a5e0b Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 5 Sep 2026 16:12:52 -0700 Subject: [PATCH 18/23] fix(automation): keep explicit background launches off screen (#18898) --- config/scripts/run-electron-vite-dev.mjs | 2 +- .../createMainWindow-startup-reveal.test.ts | 28 ++++++++++ src/main/window/focus-existing-window.test.ts | 27 +++++++++- src/main/window/focus-existing-window.ts | 8 ++- .../foreground-activation-policy.test.ts | 51 +++++++++++++------ .../window/foreground-activation-policy.ts | 22 ++++---- tests/AGENTS.md | 12 +++-- 7 files changed, 116 insertions(+), 34 deletions(-) diff --git a/config/scripts/run-electron-vite-dev.mjs b/config/scripts/run-electron-vite-dev.mjs index dfb0a0aceb7..c520083cb6a 100644 --- a/config/scripts/run-electron-vite-dev.mjs +++ b/config/scripts/run-electron-vite-dev.mjs @@ -616,7 +616,7 @@ if (!isHelpOrVersion && process.env.ORCA_DEV_INSTANCE_LABEL) { // Why: automation launches this app while someone is working; announce that the // window will come up without taking the foreground so the mode is visible in logs. if (!isHelpOrVersion && process.env.ORCA_BACKGROUND_LAUNCH === '1') { - console.error('[orca-dev] Background launch: window shows without stealing focus') + console.error('[orca-dev] Background launch: window stays off screen; automate through CDP') } let forwardedExtras = [] if (!userPassedPort && !isHelpOrVersion) { diff --git a/src/main/window/createMainWindow-startup-reveal.test.ts b/src/main/window/createMainWindow-startup-reveal.test.ts index 1200132d319..f103881ea83 100644 --- a/src/main/window/createMainWindow-startup-reveal.test.ts +++ b/src/main/window/createMainWindow-startup-reveal.test.ts @@ -78,6 +78,34 @@ describe('createMainWindow', () => { } } + it.each(['darwin', 'linux', 'win32'] as const)( + 'keeps explicit background startup hidden through ready/load/fallback on %s', + (platform) => { + vi.useFakeTimers() + vi.stubEnv('ORCA_BACKGROUND_LAUNCH', '1') + const { browserWindowInstance, windowHandlers } = createStartupRevealWindowFixture() + const showInactive = vi.fn() + Object.assign(browserWindowInstance, { showInactive }) + try { + withPlatform(platform, () => { + createMainWindow(createStartupRevealStore(true) as never, { revealOnDidFinishLoad: true }) + const revealAfterLoad = browserWindowInstance.webContents.on.mock.calls.find( + ([event]) => event === 'did-finish-load' + )?.[1] + expect(revealAfterLoad).toBeTypeOf('function') + revealAfterLoad?.() + windowHandlers['ready-to-show']() + vi.advanceTimersByTime(10_000) + expect(browserWindowInstance.show).not.toHaveBeenCalled() + expect(showInactive).not.toHaveBeenCalled() + expect(browserWindowInstance.maximize).not.toHaveBeenCalled() + }) + } finally { + vi.unstubAllEnvs() + } + } + ) + it('ignores duplicate ready-to-show events after startup maximize has already run', () => { const { browserWindowInstance, windowHandlers } = createStartupRevealWindowFixture() diff --git a/src/main/window/focus-existing-window.test.ts b/src/main/window/focus-existing-window.test.ts index babb9a15490..9f5dc522150 100644 --- a/src/main/window/focus-existing-window.test.ts +++ b/src/main/window/focus-existing-window.test.ts @@ -1,5 +1,5 @@ import type { App, BrowserWindow } from 'electron' -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { focusExistingMainWindow } from './focus-existing-window' type FakeWindowOptions = { @@ -78,7 +78,32 @@ function makeTimer(): { } } +afterEach(() => vi.unstubAllEnvs()) + describe('focusExistingMainWindow', () => { + it.each(['darwin', 'linux', 'win32'] as const)( + 'never restores or activates a background window on %s', + (platform) => { + vi.stubEnv('ORCA_BACKGROUND_LAUNCH', '1') + vi.stubEnv('ORCA_E2E_FOREGROUND', '1') + const app = makeFakeApp() + const window = makeFakeWindow({ minimized: true }) + const timer = makeTimer() + focusExistingMainWindow({ + app, + getWindow: () => window, + openWindow: vi.fn(), + platform, + setTimeout: timer.setTimeout + }) + expect(app.focus).not.toHaveBeenCalled() + for (const call of Object.values(window.calls)) { + expect(call).not.toHaveBeenCalled() + } + expect(timer.scheduledMs()).toEqual([]) + } + ) + it('aggressively foregrounds an existing Windows window on second launch', () => { const app = makeFakeApp() const window = makeFakeWindow() diff --git a/src/main/window/focus-existing-window.ts b/src/main/window/focus-existing-window.ts index 4cadb49a743..903e6a8b321 100644 --- a/src/main/window/focus-existing-window.ts +++ b/src/main/window/focus-existing-window.ts @@ -1,5 +1,9 @@ import type { App, BrowserWindow } from 'electron' -import { isBackgroundLaunch, showWindowWithoutStealingFocus } from './foreground-activation-policy' +import { + isBackgroundLaunch, + isWindowlessLaunch, + showWindowWithoutStealingFocus +} from './foreground-activation-policy' type FocusTimer = (callback: () => void, ms: number) => unknown @@ -34,7 +38,7 @@ function safelyFocusApp(app: Pick): void { } export function safelyRevealWindow(window: BrowserWindow): void { - if (window.isDestroyed()) { + if (window.isDestroyed() || isWindowlessLaunch()) { return } if (window.isMinimized()) { diff --git a/src/main/window/foreground-activation-policy.test.ts b/src/main/window/foreground-activation-policy.test.ts index 0a45f00387e..3b33c9be881 100644 --- a/src/main/window/foreground-activation-policy.test.ts +++ b/src/main/window/foreground-activation-policy.test.ts @@ -32,6 +32,10 @@ describe('isBackgroundLaunch', () => { expect(isBackgroundLaunch({})).toBe(false) }) + it('keeps an explicit background request despite inherited foreground flags', () => { + expect(isBackgroundLaunch({ ORCA_BACKGROUND_LAUNCH: '1', ORCA_E2E_FOREGROUND: '1' })).toBe(true) + }) + it('lets native-focus specs opt back into the foreground', () => { expect(isBackgroundLaunch({ ORCA_E2E_HEADFUL: '1', ORCA_E2E_FOREGROUND: '1' })).toBe(false) expect(isWindowlessLaunch({ ORCA_E2E_HEADLESS: '1', ORCA_E2E_FOREGROUND: '1' })).toBe(false) @@ -39,10 +43,17 @@ describe('isBackgroundLaunch', () => { }) describe('isWindowlessLaunch', () => { - it('is headless-only; a headful run still paints', () => { + it('keeps explicit background launches hidden while headful E2E can paint', () => { expect(isWindowlessLaunch({ ORCA_E2E_HEADLESS: '1' })).toBe(true) expect(isWindowlessLaunch({ ORCA_E2E_HEADLESS: '1', ORCA_E2E_HEADFUL: '1' })).toBe(false) - expect(isWindowlessLaunch({ ORCA_BACKGROUND_LAUNCH: '1' })).toBe(false) + expect(isWindowlessLaunch({ ORCA_BACKGROUND_LAUNCH: '1' })).toBe(true) + expect( + isWindowlessLaunch({ + ORCA_BACKGROUND_LAUNCH: '1', + ORCA_E2E_HEADFUL: '1', + ORCA_E2E_FOREGROUND: '1' + }) + ).toBe(true) }) }) @@ -54,9 +65,16 @@ describe('showWindowWithoutStealingFocus', () => { expect(window.showInactive).not.toHaveBeenCalled() }) - it('shows a background window without activating it', () => { + it('never reveals an explicitly background window', () => { const window = makeWindow() showWindowWithoutStealingFocus(window, { ORCA_BACKGROUND_LAUNCH: '1' }) + expect(window.showInactive).not.toHaveBeenCalled() + expect(window.show).not.toHaveBeenCalled() + }) + + it('still reveals explicitly headful E2E without activation', () => { + const window = makeWindow() + showWindowWithoutStealingFocus(window, { ORCA_E2E_HEADFUL: '1' }) expect(window.showInactive).toHaveBeenCalledOnce() expect(window.show).not.toHaveBeenCalled() }) @@ -83,18 +101,21 @@ describe('applyBackgroundActivationPolicy', () => { } } - it('drops the macOS Dock tile and menu bar for headless runs', () => { - const app = makeApp() - expect( - applyBackgroundActivationPolicy({ - app, - env: { ORCA_E2E_HEADLESS: '1' }, - platform: 'darwin' - }) - ).toBe(true) - expect(app.dock.hide).toHaveBeenCalledOnce() - expect(app.setActivationPolicy).toHaveBeenCalledWith('accessory') - }) + it.each(['ORCA_E2E_HEADLESS', 'ORCA_BACKGROUND_LAUNCH'])( + 'drops the macOS Dock tile and menu bar for %s', + (flag) => { + const app = makeApp() + expect( + applyBackgroundActivationPolicy({ + app, + env: { [flag]: '1' }, + platform: 'darwin' + }) + ).toBe(true) + expect(app.dock.hide).toHaveBeenCalledOnce() + expect(app.setActivationPolicy).toHaveBeenCalledWith('accessory') + } + ) it('leaves a headful or user launch with its normal Dock presence', () => { const headful = makeApp() diff --git a/src/main/window/foreground-activation-policy.ts b/src/main/window/foreground-activation-policy.ts index c2ee6b19e73..5d51f50e487 100644 --- a/src/main/window/foreground-activation-policy.ts +++ b/src/main/window/foreground-activation-policy.ts @@ -5,8 +5,8 @@ import { app as electronApp, type BrowserWindow } from 'electron' * validation). These runs may use the machine, but must never take the OS * foreground away from whatever the developer is doing. * - * ORCA_BACKGROUND_LAUNCH=1 opts a normal launch in; ORCA_E2E_FOREGROUND=1 opts - * back out for the few specs whose subject *is* native focus (IME, key events). + * ORCA_BACKGROUND_LAUNCH=1 keeps automation off screen. Native-focus specs + * can use ORCA_E2E_FOREGROUND=1 only without an explicit background request. */ type ActivationPolicyApp = { @@ -19,19 +19,21 @@ type PolicyEnv = Readonly> /** True when this process must not steal focus, raise windows, or activate the app. */ export function isBackgroundLaunch(env: PolicyEnv = process.env): boolean { + if (env.ORCA_BACKGROUND_LAUNCH === '1') { + return true + } if (env.ORCA_E2E_FOREGROUND === '1') { return false } - return ( - env.ORCA_BACKGROUND_LAUNCH === '1' || - env.ORCA_E2E_HEADLESS === '1' || - env.ORCA_E2E_HEADFUL === '1' - ) + return env.ORCA_E2E_HEADLESS === '1' || env.ORCA_E2E_HEADFUL === '1' } -/** True when no window should reach the screen at all (headless E2E; Playwright drives via CDP). */ +/** True when no window should reach the screen at all (background or headless E2E; Playwright drives via CDP). */ export function isWindowlessLaunch(env: PolicyEnv = process.env): boolean { - return isBackgroundLaunch(env) && env.ORCA_E2E_HEADLESS === '1' && env.ORCA_E2E_HEADFUL !== '1' + return ( + env.ORCA_BACKGROUND_LAUNCH === '1' || + (isBackgroundLaunch(env) && env.ORCA_E2E_HEADLESS === '1' && env.ORCA_E2E_HEADFUL !== '1') + ) } /** @@ -63,7 +65,7 @@ export function applyBackgroundActivationPolicy( /** * Reveal a window without taking the foreground: hidden entirely when windowless, - * `showInactive()` (visible, not raised over the active app) in background launches. + * `showInactive()` for explicitly headful E2E runs. */ export function showWindowWithoutStealingFocus( window: BrowserWindow, diff --git a/tests/AGENTS.md b/tests/AGENTS.md index f445415e7df..26987a87c25 100644 --- a/tests/AGENTS.md +++ b/tests/AGENTS.md @@ -6,16 +6,18 @@ take the foreground — no window raised over the editor, no focus stolen, no Do `src/main/window/foreground-activation-policy.ts` enforces this in the main process. It is on whenever `ORCA_E2E_HEADLESS=1`, `ORCA_E2E_HEADFUL=1`, or `ORCA_BACKGROUND_LAUNCH=1`: -- headless → the window never reaches the screen (Playwright drives it via CDP) -- headful / background → `showInactive()`, no `app.focus({ steal: true })`, no +- headless / explicit background → the window never reaches the screen (Playwright drives it via CDP) +- headful without explicit background → `showInactive()`, no `app.focus({ steal: true })`, no `moveTop()`/always-on-top reinforcement -- macOS headless → `accessory` activation policy, so no Dock tile and no menu-bar takeover +- macOS headless / explicit background → `accessory` activation policy, so no Dock tile and no menu-bar takeover Rules when adding tests or scripts: - Launch through `tests/e2e/helpers/orca-app.ts` (or `orca-restart.ts`) — they already set the env. - A raw `electron.launch()` outside those helpers must pass `ORCA_BACKGROUND_LAUNCH: '1'`. -- Call `showInactive()`, never `show()`, when an `app.evaluate()` block reveals a window. +- Do not reveal windows in explicit background or headless runs. Only an explicitly headful run + may call `showInactive()`; never call `show()` or `bringToFront()` in automated background checks. - Tag a spec `@headful` only when it needs real pixels; it still runs in the background. - `ORCA_E2E_FOREGROUND=1` is the only opt-out, for runs whose subject _is_ native focus (IME and - other OS-level key injection). Add a comment saying why. + other OS-level key injection). Clear `ORCA_BACKGROUND_LAUNCH` for that isolated run and add a + comment saying why; an explicit background request takes precedence. From abdee9ebd370d3e2a7eb968b642df6e625846b33 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 5 Sep 2026 16:16:08 -0700 Subject: [PATCH 19/23] feat(automations): restore column sorting on the list (#18885) The flat-table redesign in #16532 dropped the sort UI, orphaning AutomationListSortHeader, nextAutomationListSort and the whole AutomationListViewItem layer. Wire them back to the rendered list. Name and Last run become interactive header cells again; the other six columns stay plain text. Sorting now spans local and external rows as one list, so the panel renders per-row components from a single sorted collection instead of two independent sections. Two model fixes fall out of that: - View items key on the host-qualified row key, not the bare automation ID. The old builder predated automation-list-row-identity, so under All hosts two authorities returning the same ID collapsed in the sort tie-break. - sortAutomationListViewItems takes the locale as a parameter instead of reading getIntlLocale(). A hidden global read is invisible to a dependency array, and the list result is memoized. Keyboard traversal and focus recovery now read the sorted order, so arrow navigation matches what is on screen. The dead unified filter is removed in favor of the live row/entry filters the page already used. --- .../automations/AutomationListExternalRow.tsx | 260 +++++++++++ .../AutomationListExternalRows.tsx | 286 +----------- .../automations/AutomationListLocalRow.tsx | 391 +++++++++++++++++ .../automations/AutomationListLocalRows.tsx | 406 +----------------- .../automations/AutomationListSortHeader.tsx | 51 +++ .../AutomationListTableHeader.test.tsx | 45 +- .../automations/AutomationListTableHeader.tsx | 101 +++-- .../automations/AutomationsListPanel.test.tsx | 43 +- .../automations/AutomationsListPanel.tsx | 87 ++-- ...utomationsPage.create-destination.test.tsx | 2 +- ...tionsPage.cross-authority-actions.test.tsx | 9 +- .../AutomationsPage.external-scope.test.tsx | 7 +- .../AutomationsPage.notice-recovery.test.tsx | 2 +- ...AutomationsPage.refresh-selection.test.tsx | 7 +- .../AutomationsPage.run-visibility.test.tsx | 4 +- .../automations/AutomationsPage.test.tsx | 8 +- .../automations/AutomationsPageListPanel.tsx | 8 +- .../automation-list-view-sort.test.ts | 83 ++-- .../automations/automation-list-view.test.ts | 207 ++++----- .../automations/automation-list-view.ts | 90 ++-- .../automations-page-listed-items.ts | 32 ++ .../automations-page-test-harness.tsx | 65 ++- .../use-automations-page-list-state.ts | 22 +- .../use-automations-page-local-state.ts | 9 +- .../pane-agent-identity-inventory.test.ts | 2 +- 25 files changed, 1241 insertions(+), 986 deletions(-) create mode 100644 src/renderer/src/components/automations/AutomationListExternalRow.tsx create mode 100644 src/renderer/src/components/automations/AutomationListLocalRow.tsx create mode 100644 src/renderer/src/components/automations/AutomationListSortHeader.tsx create mode 100644 src/renderer/src/components/automations/automations-page-listed-items.ts diff --git a/src/renderer/src/components/automations/AutomationListExternalRow.tsx b/src/renderer/src/components/automations/AutomationListExternalRow.tsx new file mode 100644 index 00000000000..b26173467ab --- /dev/null +++ b/src/renderer/src/components/automations/AutomationListExternalRow.tsx @@ -0,0 +1,260 @@ +import React from 'react' +import { MoreHorizontal, Pause, Pencil, Play, Trash2 } from 'lucide-react' +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger +} from '@/components/ui/context-menu' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' +import type { + ExternalAutomationAction, + ExternalAutomationJob, + ExternalAutomationManager +} from '../../../../shared/automations-types' +import type { SshConnectionState } from '../../../../shared/ssh-types' +import type { ExternalAutomationListEntry } from './external-automation-list-entries' +import type { ExternalAutomationScope } from './external-automation-scope-client' +import { + formatExternalDate, + getExternalProviderLabel, + getExternalTargetKindLabel +} from './external-automation-display' +import { getExternalAutomationScheduleDisplay } from './external-automation-schedule-display' +import { getExternalAutomationActionDisabledMessage } from './external-automation-source-availability' +import { AUTOMATIONS_TABLE_GRID_CLASS } from './automations-table-layout' +import { + LIST_TABLE_ROW_CLASS, + LIST_TABLE_ROW_SELECTED_CLASS, + LIST_TABLE_STICKY_ROW_CELL_CLASS +} from '@/lib/list-table-layout' +import { isPortaledRowMenuClick, isRowActivationKey } from '@/lib/list-row-interaction' +import { getExternalAutomationLastRunSnapshot } from './automation-list-last-run' +import { AutomationListLastRunCell } from './AutomationListLastRunCell' +import { AutomationListStatusCell } from './AutomationListStatusCell' +import { translate } from '@/i18n/i18n' + +export type AutomationListExternalRowProps = { + entry: ExternalAutomationListEntry + selectedExternalKey: string | null | undefined + relativeNow: number + sshConnectionStates: ReadonlyMap> + externalActionKey: string | null + onSelect: (entryKey: string) => void + onRequestAction: ( + manager: ExternalAutomationManager, + job: ExternalAutomationJob, + action: ExternalAutomationAction, + scope: ExternalAutomationScope + ) => void + onEdit: ( + manager: ExternalAutomationManager, + job: ExternalAutomationJob, + scope: ExternalAutomationScope + ) => void +} + +export function AutomationListExternalRow({ + entry, + selectedExternalKey, + relativeNow, + sshConnectionStates, + externalActionKey, + onSelect, + onRequestAction, + onEdit +}: AutomationListExternalRowProps): React.JSX.Element { + const providerLabel = getExternalProviderLabel(entry.manager) + const targetKindLabel = getExternalTargetKindLabel(entry.manager) + const isSelected = selectedExternalKey === entry.key + const sshStatus = + entry.manager.target.type === 'ssh' + ? sshConnectionStates.get(entry.manager.target.connectionId)?.status + : undefined + const disabledMessage = getExternalAutomationActionDisabledMessage({ + manager: entry.manager, + providerLabel, + targetKindLabel, + sshStatus, + actionInProgress: externalActionKey !== null + }) + const actionDisabled = disabledMessage !== null + const scheduleLabel = getExternalAutomationScheduleDisplay(entry.manager, entry.job).label + const hostLabel = entry.manager.targetLabel || entry.manager.label || 'Local' + const projectLabel = entry.job.workdir ?? providerLabel + const nextRunLabel = entry.job.enabled + ? formatExternalDate(entry.job.nextRunAt, relativeNow) + : translate('auto.components.automations.AutomationsPage.paused', 'Paused') + const lastRunSnapshot = getExternalAutomationLastRunSnapshot(entry.job) + + return ( + + +
    { + // Why: Radix portals menus out of the row DOM, but React still + // bubbles those clicks here — ignore so menu actions don't open detail. + if (isPortaledRowMenuClick(event)) { + return + } + onSelect(entry.key) + }} + onKeyDown={(event) => { + if (!isRowActivationKey(event)) { + return + } + event.preventDefault() + onSelect(entry.key) + }} + className={cn( + AUTOMATIONS_TABLE_GRID_CLASS, + LIST_TABLE_ROW_CLASS, + isSelected && LIST_TABLE_ROW_SELECTED_CLASS + )} + > + + {entry.job.name} + + + {scheduleLabel} + + + {projectLabel} + + + {hostLabel} + + + {nextRunLabel} + + + + + {providerLabel} + + + + + + + onRequestAction(entry.manager, entry.job, 'run', entry.scope)} + > + + + {disabledMessage ?? + translate('auto.components.automations.AutomationsPage.2faecab10b', 'Run Now')} + + + {entry.manager.provider === 'hermes' ? ( + onEdit(entry.manager, entry.job, entry.scope)} + > + + {translate('auto.components.automations.AutomationsPage.f4612e3f78', 'Edit')} + + ) : null} + + onRequestAction( + entry.manager, + entry.job, + entry.job.enabled ? 'pause' : 'resume', + entry.scope + ) + } + > + {entry.job.enabled ? : } + {entry.job.enabled + ? translate('auto.components.automations.AutomationsPage.b457436d6a', 'Pause') + : translate('auto.components.automations.AutomationsPage.376631ef2b', 'Resume')} + + + onRequestAction(entry.manager, entry.job, 'delete', entry.scope)} + > + + {translate('auto.components.automations.AutomationsPage.15e0bfb13b', 'Delete')} + + + +
    +
    + + onRequestAction(entry.manager, entry.job, 'run', entry.scope)} + > + + + {disabledMessage ?? + translate('auto.components.automations.AutomationsPage.2faecab10b', 'Run Now')} + + + {entry.manager.provider === 'hermes' ? ( + onEdit(entry.manager, entry.job, entry.scope)} + > + + {translate('auto.components.automations.AutomationsPage.f4612e3f78', 'Edit')} + + ) : null} + + onRequestAction( + entry.manager, + entry.job, + entry.job.enabled ? 'pause' : 'resume', + entry.scope + ) + } + > + {entry.job.enabled ? : } + {entry.job.enabled + ? translate('auto.components.automations.AutomationsPage.b457436d6a', 'Pause') + : translate('auto.components.automations.AutomationsPage.376631ef2b', 'Resume')} + + + onRequestAction(entry.manager, entry.job, 'delete', entry.scope)} + > + + {translate('auto.components.automations.AutomationsPage.15e0bfb13b', 'Delete')} + + +
    + ) +} diff --git a/src/renderer/src/components/automations/AutomationListExternalRows.tsx b/src/renderer/src/components/automations/AutomationListExternalRows.tsx index 976a93b2433..3ed78fc2a69 100644 --- a/src/renderer/src/components/automations/AutomationListExternalRows.tsx +++ b/src/renderer/src/components/automations/AutomationListExternalRows.tsx @@ -1,285 +1,23 @@ import React from 'react' -import { MoreHorizontal, Pause, Pencil, Play, Trash2 } from 'lucide-react' -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuSeparator, - ContextMenuTrigger -} from '@/components/ui/context-menu' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger -} from '@/components/ui/dropdown-menu' -import { Button } from '@/components/ui/button' -import { cn } from '@/lib/utils' -import type { - ExternalAutomationAction, - ExternalAutomationJob, - ExternalAutomationManager -} from '../../../../shared/automations-types' -import type { SshConnectionState } from '../../../../shared/ssh-types' import type { ExternalAutomationListEntry } from './external-automation-list-entries' -import type { ExternalAutomationScope } from './external-automation-scope-client' import { - formatExternalDate, - getExternalProviderLabel, - getExternalTargetKindLabel -} from './external-automation-display' -import { getExternalAutomationScheduleDisplay } from './external-automation-schedule-display' -import { getExternalAutomationActionDisabledMessage } from './external-automation-source-availability' -import { AUTOMATIONS_TABLE_GRID_CLASS } from './automations-table-layout' -import { - LIST_TABLE_ROW_CLASS, - LIST_TABLE_ROW_SELECTED_CLASS, - LIST_TABLE_STICKY_ROW_CELL_CLASS -} from '@/lib/list-table-layout' -import { isPortaledRowMenuClick, isRowActivationKey } from '@/lib/list-row-interaction' -import { getExternalAutomationLastRunSnapshot } from './automation-list-last-run' -import { AutomationListLastRunCell } from './AutomationListLastRunCell' -import { AutomationListStatusCell } from './AutomationListStatusCell' -import { translate } from '@/i18n/i18n' + AutomationListExternalRow, + type AutomationListExternalRowProps +} from './AutomationListExternalRow' + +export type AutomationListExternalRowsProps = Omit & { + entries: readonly ExternalAutomationListEntry[] +} export function AutomationListExternalRows({ entries, - selectedExternalKey, - relativeNow, - sshConnectionStates, - externalActionKey, - onSelect, - onRequestAction, - onEdit -}: { - entries: readonly ExternalAutomationListEntry[] - selectedExternalKey: string | null | undefined - relativeNow: number - sshConnectionStates: ReadonlyMap> - externalActionKey: string | null - onSelect: (entryKey: string) => void - onRequestAction: ( - manager: ExternalAutomationManager, - job: ExternalAutomationJob, - action: ExternalAutomationAction, - scope: ExternalAutomationScope - ) => void - onEdit: ( - manager: ExternalAutomationManager, - job: ExternalAutomationJob, - scope: ExternalAutomationScope - ) => void -}): React.JSX.Element { + ...rowProps +}: AutomationListExternalRowsProps): React.JSX.Element { return ( <> - {entries.map((entry) => { - const providerLabel = getExternalProviderLabel(entry.manager) - const targetKindLabel = getExternalTargetKindLabel(entry.manager) - const isSelected = selectedExternalKey === entry.key - const sshStatus = - entry.manager.target.type === 'ssh' - ? sshConnectionStates.get(entry.manager.target.connectionId)?.status - : undefined - const disabledMessage = getExternalAutomationActionDisabledMessage({ - manager: entry.manager, - providerLabel, - targetKindLabel, - sshStatus, - actionInProgress: externalActionKey !== null - }) - const actionDisabled = disabledMessage !== null - const scheduleLabel = getExternalAutomationScheduleDisplay(entry.manager, entry.job).label - const hostLabel = entry.manager.targetLabel || entry.manager.label || 'Local' - const projectLabel = entry.job.workdir ?? providerLabel - const nextRunLabel = entry.job.enabled - ? formatExternalDate(entry.job.nextRunAt, relativeNow) - : translate('auto.components.automations.AutomationsPage.paused', 'Paused') - const lastRunSnapshot = getExternalAutomationLastRunSnapshot(entry.job) - - return ( - - -
    { - // Why: Radix portals menus out of the row DOM, but React still - // bubbles those clicks here — ignore so menu actions don't open detail. - if (isPortaledRowMenuClick(event)) { - return - } - onSelect(entry.key) - }} - onKeyDown={(event) => { - if (!isRowActivationKey(event)) { - return - } - event.preventDefault() - onSelect(entry.key) - }} - className={cn( - AUTOMATIONS_TABLE_GRID_CLASS, - LIST_TABLE_ROW_CLASS, - isSelected && LIST_TABLE_ROW_SELECTED_CLASS - )} - > - - {entry.job.name} - - - {scheduleLabel} - - - {projectLabel} - - - {hostLabel} - - - {nextRunLabel} - - - - - {providerLabel} - - - - - - - onRequestAction(entry.manager, entry.job, 'run', entry.scope)} - > - - - {disabledMessage ?? - translate( - 'auto.components.automations.AutomationsPage.2faecab10b', - 'Run Now' - )} - - - {entry.manager.provider === 'hermes' ? ( - onEdit(entry.manager, entry.job, entry.scope)} - > - - {translate( - 'auto.components.automations.AutomationsPage.f4612e3f78', - 'Edit' - )} - - ) : null} - - onRequestAction( - entry.manager, - entry.job, - entry.job.enabled ? 'pause' : 'resume', - entry.scope - ) - } - > - {entry.job.enabled ? ( - - ) : ( - - )} - {entry.job.enabled - ? translate( - 'auto.components.automations.AutomationsPage.b457436d6a', - 'Pause' - ) - : translate( - 'auto.components.automations.AutomationsPage.376631ef2b', - 'Resume' - )} - - - - onRequestAction(entry.manager, entry.job, 'delete', entry.scope) - } - > - - {translate( - 'auto.components.automations.AutomationsPage.15e0bfb13b', - 'Delete' - )} - - - -
    -
    - - onRequestAction(entry.manager, entry.job, 'run', entry.scope)} - > - - - {disabledMessage ?? - translate('auto.components.automations.AutomationsPage.2faecab10b', 'Run Now')} - - - {entry.manager.provider === 'hermes' ? ( - onEdit(entry.manager, entry.job, entry.scope)} - > - - {translate('auto.components.automations.AutomationsPage.f4612e3f78', 'Edit')} - - ) : null} - - onRequestAction( - entry.manager, - entry.job, - entry.job.enabled ? 'pause' : 'resume', - entry.scope - ) - } - > - {entry.job.enabled ? : } - {entry.job.enabled - ? translate('auto.components.automations.AutomationsPage.b457436d6a', 'Pause') - : translate('auto.components.automations.AutomationsPage.376631ef2b', 'Resume')} - - - onRequestAction(entry.manager, entry.job, 'delete', entry.scope)} - > - - {translate('auto.components.automations.AutomationsPage.15e0bfb13b', 'Delete')} - - -
    - ) - })} + {entries.map((entry) => ( + + ))} ) } diff --git a/src/renderer/src/components/automations/AutomationListLocalRow.tsx b/src/renderer/src/components/automations/AutomationListLocalRow.tsx new file mode 100644 index 00000000000..a9c1a5bc8b6 --- /dev/null +++ b/src/renderer/src/components/automations/AutomationListLocalRow.tsx @@ -0,0 +1,391 @@ +import React from 'react' +import { MoreHorizontal, Pause, Pencil, Play, Trash2 } from 'lucide-react' +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger +} from '@/components/ui/context-menu' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { AgentIcon } from '@/lib/agent-catalog' +import { cn } from '@/lib/utils' +import type { AutomationRun } from '../../../../shared/automations-types' +import { getAutomationRunRepoId } from '../../../../shared/automation-run-identity' +import { formatUiAutomationSchedule } from './automation-schedule-label' +import { + getExecutionHostLabel, + getLocalExecutionHostLabel, + getRepoExecutionHostId +} from '../../../../shared/execution-host' +import type { SshConnectionState } from '../../../../shared/ssh-types' +import type { ProjectHostSetup } from '../../../../shared/project-types' +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' +import type { RuntimeStatus } from '../../../../shared/runtime-types' +import type { TaskSourceHostAvailability } from '../task-source-context-summary' +import type { AutomationRowAction } from './automation-captured-owner' +import type { AutomationHostTarget } from './automation-host-client' +import { + getAutomationRowLastRunSnapshot, + getLocalAutomationLastRunSnapshot +} from './automation-list-last-run' +import { AutomationListLastRunCell } from './AutomationListLastRunCell' +import { formatAutomationDateTimeWithRelative } from './automation-page-parts' +import { getAutomationTargetAvailability } from './automation-target-availability' +import { getAgentLabel } from './automation-draft-model' +import type { AutomationListRow } from './automation-list-row-identity' +import { + formatAutomationCost, + formatAutomationTokens, + type AutomationUsageSummary +} from './automation-usage-model' +import { AUTOMATIONS_TABLE_GRID_CLASS } from './automations-table-layout' +import { + LIST_TABLE_ROW_CLASS, + LIST_TABLE_ROW_SELECTED_CLASS, + LIST_TABLE_STICKY_ROW_CELL_CLASS +} from '@/lib/list-table-layout' +import { isPortaledRowMenuClick, isRowActivationKey } from '@/lib/list-row-interaction' +import { AutomationListStatusCell } from './AutomationListStatusCell' +import { translate } from '@/i18n/i18n' + +export type AutomationListLocalRowProps = { + row: AutomationListRow + selectedRowKey: string | null | undefined + isSelectedLocal: boolean + lastRunByAutomationId: ReadonlyMap + relativeNow: number + repoMap: ReadonlyMap + worktreeMap: ReadonlyMap + repoForRow?: (row: AutomationListRow) => Repo | undefined + worktreeForRow?: (row: AutomationListRow, repo: Repo | undefined) => Worktree | undefined + projectHostSetups: readonly ProjectHostSetup[] + sshConnectionStates: ReadonlyMap> + runtimeStatusByEnvironmentId: ReadonlyMap< + string, + { status: RuntimeStatus | null; checkedAt: number } + > + hostTargetFor: (row: AutomationListRow) => AutomationHostTarget | null + automationSourceHostAvailabilityByRowKey: ReadonlyMap + hostLabelById?: ReadonlyMap + isActionEnabled?: (row: AutomationListRow, action: AutomationRowAction) => boolean + onSelect: (rowKey: string) => void + onRunNow: (row: AutomationListRow) => void + onEdit: (row: AutomationListRow) => void + onToggle: (row: AutomationListRow) => void + onDelete: (row: AutomationListRow) => void +} + +const EMPTY_HOST_LABELS: ReadonlyMap = new Map() + +function automationUsageText(summary: AutomationUsageSummary | undefined): string { + if (!summary || summary.unavailableRuns > 0) { + return summary?.knownRuns + ? usageAmountText(summary) + : translate( + 'auto.components.automations.AutomationsPage.usageUnavailable', + 'Usage unavailable' + ) + } + return summary.knownRuns > 0 + ? usageAmountText(summary) + : translate('auto.components.automations.AutomationsPage.noRunUsageYet', 'No run usage yet') +} + +function usageAmountText(summary: AutomationUsageSummary): string { + return translate( + 'auto.components.automations.AutomationsPage.runUsageSummary', + '{{cost}} est. · {{tokens}} tokens', + { + cost: formatAutomationCost(summary.estimatedCostUsd), + tokens: formatAutomationTokens(summary.totalTokens) + } + ) +} + +export function AutomationListLocalRow({ + row, + selectedRowKey, + isSelectedLocal, + lastRunByAutomationId, + relativeNow, + repoMap, + worktreeMap, + repoForRow, + worktreeForRow, + projectHostSetups, + sshConnectionStates, + runtimeStatusByEnvironmentId, + hostTargetFor, + automationSourceHostAvailabilityByRowKey, + hostLabelById = EMPTY_HOST_LABELS, + isActionEnabled, + onSelect, + onRunNow, + onEdit, + onToggle, + onDelete +}: AutomationListLocalRowProps): React.JSX.Element { + const allows = (row: AutomationListRow, action: AutomationRowAction): boolean => + isActionEnabled?.(row, action) ?? true + const { automation } = row + const automationRepo = repoForRow?.(row) ?? repoMap.get(getAutomationRunRepoId(automation)) + const automationWorktree = automation.workspaceId + ? (worktreeForRow?.(row, automationRepo) ?? worktreeMap.get(automation.workspaceId)) + : null + const automationRunAvailability = getAutomationTargetAvailability({ + automation, + repo: automationRepo, + workspace: automationWorktree, + projectHostSetups, + sshConnectionStates, + runtimeStatusByEnvironmentId, + automationHostTarget: hostTargetFor(row), + sourceHostAvailability: automationSourceHostAvailabilityByRowKey.get(row.key) + }) + const projectLabel = + automationRepo?.displayName ?? + translate('auto.components.automations.AutomationsPage.13118faadf', 'Unknown project') + const scheduleLabel = formatUiAutomationSchedule(automation.rrule) + const nextRunLabel = automation.enabled + ? formatAutomationDateTimeWithRelative(automation.nextRunAt, relativeNow) + : translate('auto.components.automations.enablement.paused', 'Paused') + const isSelected = isSelectedLocal && selectedRowKey === row.key + const agentLabel = getAgentLabel(automation.agentId) + const hostId = + automation.runContext?.hostId ?? + (automationRepo ? getRepoExecutionHostId(automationRepo) : null) + const hostLabel = + row.hostLabel || + (hostId + ? (hostLabelById.get(hostId) ?? getExecutionHostLabel(hostId)) + : getLocalExecutionHostLabel()) + const agentTooltipLabel = `${agentLabel} · ${hostLabel} · ${automationUsageText(row.usageSummary ?? undefined)}` + const canRunNow = automationRunAvailability.canRunNow && allows(row, 'run') + const lastRun = lastRunByAutomationId.get(automation.id) + // Without a fetched run, the row's projected summary carries the newest + // retained run's status — the list never downloads run history for this. + const lastRunSnapshot = lastRun + ? getLocalAutomationLastRunSnapshot(automation, lastRun) + : getAutomationRowLastRunSnapshot(row) + + const actionItems = ( + <> + onRunNow(row)} + /> + } + label={translate('auto.components.automations.AutomationsPage.f4612e3f78', 'Edit')} + onSelect={() => onEdit(row)} + /> + : } + label={ + automation.enabled + ? translate('auto.components.automations.AutomationsPage.b457436d6a', 'Pause') + : translate('auto.components.automations.AutomationsPage.376631ef2b', 'Resume') + } + onSelect={() => onToggle(row)} + /> + + } + label={translate('auto.components.automations.AutomationsPage.15e0bfb13b', 'Delete')} + variant="destructive" + onSelect={() => onDelete(row)} + /> + + ) + + return ( + + +
    { + // Why: Radix portals menus out of the row DOM, but React still + // bubbles those clicks here — ignore so menu actions don't open detail. + if (isPortaledRowMenuClick(event)) { + return + } + onSelect(row.key) + }} + onKeyDown={(event) => { + if (!isRowActivationKey(event)) { + return + } + event.preventDefault() + onSelect(row.key) + }} + className={cn( + AUTOMATIONS_TABLE_GRID_CLASS, + LIST_TABLE_ROW_CLASS, + isSelected && LIST_TABLE_ROW_SELECTED_CLASS + )} + > + + {automation.name} + + + {scheduleLabel} + + + {projectLabel} + + + {hostLabel} + + + {nextRunLabel} + + + + + + + + + + + {agentTooltipLabel} + + + + + + + + { + if (canRunNow) { + onRunNow(row) + } + }} + > + + + {automationRunAvailability.canRunNow + ? translate('auto.components.automations.AutomationsPage.2faecab10b', 'Run Now') + : automationRunAvailability.message} + + + onEdit(row)}> + + {translate('auto.components.automations.AutomationsPage.f4612e3f78', 'Edit')} + + onToggle(row)}> + {automation.enabled ? ( + + ) : ( + + )} + {automation.enabled + ? translate('auto.components.automations.AutomationsPage.b457436d6a', 'Pause') + : translate('auto.components.automations.AutomationsPage.376631ef2b', 'Resume')} + + + onDelete(row)} + > + + {translate('auto.components.automations.AutomationsPage.15e0bfb13b', 'Delete')} + + + +
    +
    + {actionItems} +
    + ) +} + +function MenuRunItem({ + disabled, + label, + onSelect +}: { + disabled: boolean + label: string + onSelect: () => void +}): React.JSX.Element { + return ( + { + if (disabled) { + event.preventDefault() + return + } + onSelect() + }} + > + + {label} + + ) +} + +function MenuItem({ + disabled, + icon, + label, + onSelect, + variant +}: { + disabled?: boolean + icon: React.ReactNode + label: string + onSelect: () => void + variant?: 'destructive' +}): React.JSX.Element { + return ( + + {icon} + {label} + + ) +} + +function MenuSeparator(): React.JSX.Element { + return +} diff --git a/src/renderer/src/components/automations/AutomationListLocalRows.tsx b/src/renderer/src/components/automations/AutomationListLocalRows.tsx index 292eb545b4d..3fa02cc1884 100644 --- a/src/renderer/src/components/automations/AutomationListLocalRows.tsx +++ b/src/renderer/src/components/automations/AutomationListLocalRows.tsx @@ -1,414 +1,20 @@ import React from 'react' -import { MoreHorizontal, Pause, Pencil, Play, Trash2 } from 'lucide-react' -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuSeparator, - ContextMenuTrigger -} from '@/components/ui/context-menu' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger -} from '@/components/ui/dropdown-menu' -import { Button } from '@/components/ui/button' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { AgentIcon } from '@/lib/agent-catalog' -import { cn } from '@/lib/utils' -import type { AutomationRun } from '../../../../shared/automations-types' -import { getAutomationRunRepoId } from '../../../../shared/automation-run-identity' -import { formatUiAutomationSchedule } from './automation-schedule-label' -import { - getExecutionHostLabel, - getLocalExecutionHostLabel, - getRepoExecutionHostId -} from '../../../../shared/execution-host' -import type { SshConnectionState } from '../../../../shared/ssh-types' -import type { ProjectHostSetup } from '../../../../shared/project-types' -import type { Repo } from '../../../../shared/repo-types' -import type { Worktree } from '../../../../shared/worktree/types' -import type { RuntimeStatus } from '../../../../shared/runtime-types' -import type { TaskSourceHostAvailability } from '../task-source-context-summary' -import type { AutomationRowAction } from './automation-captured-owner' -import type { AutomationHostTarget } from './automation-host-client' -import { - getAutomationRowLastRunSnapshot, - getLocalAutomationLastRunSnapshot -} from './automation-list-last-run' -import { AutomationListLastRunCell } from './AutomationListLastRunCell' -import { formatAutomationDateTimeWithRelative } from './automation-page-parts' -import { getAutomationTargetAvailability } from './automation-target-availability' -import { getAgentLabel } from './automation-draft-model' import type { AutomationListRow } from './automation-list-row-identity' -import { - formatAutomationCost, - formatAutomationTokens, - type AutomationUsageSummary -} from './automation-usage-model' -import { AUTOMATIONS_TABLE_GRID_CLASS } from './automations-table-layout' -import { - LIST_TABLE_ROW_CLASS, - LIST_TABLE_ROW_SELECTED_CLASS, - LIST_TABLE_STICKY_ROW_CELL_CLASS -} from '@/lib/list-table-layout' -import { isPortaledRowMenuClick, isRowActivationKey } from '@/lib/list-row-interaction' -import { AutomationListStatusCell } from './AutomationListStatusCell' -import { translate } from '@/i18n/i18n' +import { AutomationListLocalRow, type AutomationListLocalRowProps } from './AutomationListLocalRow' -export type AutomationListLocalRowsProps = { +export type AutomationListLocalRowsProps = Omit & { rows: readonly AutomationListRow[] - selectedRowKey: string | null | undefined - isSelectedLocal: boolean - lastRunByAutomationId: ReadonlyMap - relativeNow: number - repoMap: ReadonlyMap - worktreeMap: ReadonlyMap - repoForRow?: (row: AutomationListRow) => Repo | undefined - worktreeForRow?: (row: AutomationListRow, repo: Repo | undefined) => Worktree | undefined - projectHostSetups: readonly ProjectHostSetup[] - sshConnectionStates: ReadonlyMap> - runtimeStatusByEnvironmentId: ReadonlyMap< - string, - { status: RuntimeStatus | null; checkedAt: number } - > - hostTargetFor: (row: AutomationListRow) => AutomationHostTarget | null - automationSourceHostAvailabilityByRowKey: ReadonlyMap - hostLabelById?: ReadonlyMap - isActionEnabled?: (row: AutomationListRow, action: AutomationRowAction) => boolean - onSelect: (rowKey: string) => void - onRunNow: (row: AutomationListRow) => void - onEdit: (row: AutomationListRow) => void - onToggle: (row: AutomationListRow) => void - onDelete: (row: AutomationListRow) => void -} - -const EMPTY_HOST_LABELS: ReadonlyMap = new Map() - -function automationUsageText(summary: AutomationUsageSummary | undefined): string { - if (!summary || summary.unavailableRuns > 0) { - return summary?.knownRuns - ? usageAmountText(summary) - : translate( - 'auto.components.automations.AutomationsPage.usageUnavailable', - 'Usage unavailable' - ) - } - return summary.knownRuns > 0 - ? usageAmountText(summary) - : translate('auto.components.automations.AutomationsPage.noRunUsageYet', 'No run usage yet') -} - -function usageAmountText(summary: AutomationUsageSummary): string { - return translate( - 'auto.components.automations.AutomationsPage.runUsageSummary', - '{{cost}} est. · {{tokens}} tokens', - { - cost: formatAutomationCost(summary.estimatedCostUsd), - tokens: formatAutomationTokens(summary.totalTokens) - } - ) } export function AutomationListLocalRows({ rows, - selectedRowKey, - isSelectedLocal, - lastRunByAutomationId, - relativeNow, - repoMap, - worktreeMap, - repoForRow, - worktreeForRow, - projectHostSetups, - sshConnectionStates, - runtimeStatusByEnvironmentId, - hostTargetFor, - automationSourceHostAvailabilityByRowKey, - hostLabelById = EMPTY_HOST_LABELS, - isActionEnabled, - onSelect, - onRunNow, - onEdit, - onToggle, - onDelete + ...rowProps }: AutomationListLocalRowsProps): React.JSX.Element { - const allows = (row: AutomationListRow, action: AutomationRowAction): boolean => - isActionEnabled?.(row, action) ?? true return ( <> - {rows.map((row) => { - const { automation } = row - const automationRepo = repoForRow?.(row) ?? repoMap.get(getAutomationRunRepoId(automation)) - const automationWorktree = automation.workspaceId - ? (worktreeForRow?.(row, automationRepo) ?? worktreeMap.get(automation.workspaceId)) - : null - const automationRunAvailability = getAutomationTargetAvailability({ - automation, - repo: automationRepo, - workspace: automationWorktree, - projectHostSetups, - sshConnectionStates, - runtimeStatusByEnvironmentId, - automationHostTarget: hostTargetFor(row), - sourceHostAvailability: automationSourceHostAvailabilityByRowKey.get(row.key) - }) - const projectLabel = - automationRepo?.displayName ?? - translate('auto.components.automations.AutomationsPage.13118faadf', 'Unknown project') - const scheduleLabel = formatUiAutomationSchedule(automation.rrule) - const nextRunLabel = automation.enabled - ? formatAutomationDateTimeWithRelative(automation.nextRunAt, relativeNow) - : translate('auto.components.automations.enablement.paused', 'Paused') - const isSelected = isSelectedLocal && selectedRowKey === row.key - const agentLabel = getAgentLabel(automation.agentId) - const hostId = - automation.runContext?.hostId ?? - (automationRepo ? getRepoExecutionHostId(automationRepo) : null) - const hostLabel = - row.hostLabel || - (hostId - ? (hostLabelById.get(hostId) ?? getExecutionHostLabel(hostId)) - : getLocalExecutionHostLabel()) - const agentTooltipLabel = `${agentLabel} · ${hostLabel} · ${automationUsageText(row.usageSummary ?? undefined)}` - const canRunNow = automationRunAvailability.canRunNow && allows(row, 'run') - const lastRun = lastRunByAutomationId.get(automation.id) - // Without a fetched run, the row's projected summary carries the newest - // retained run's status — the list never downloads run history for this. - const lastRunSnapshot = lastRun - ? getLocalAutomationLastRunSnapshot(automation, lastRun) - : getAutomationRowLastRunSnapshot(row) - - const actionItems = ( - <> - onRunNow(row)} - /> - } - label={translate('auto.components.automations.AutomationsPage.f4612e3f78', 'Edit')} - onSelect={() => onEdit(row)} - /> - : - } - label={ - automation.enabled - ? translate('auto.components.automations.AutomationsPage.b457436d6a', 'Pause') - : translate('auto.components.automations.AutomationsPage.376631ef2b', 'Resume') - } - onSelect={() => onToggle(row)} - /> - - } - label={translate('auto.components.automations.AutomationsPage.15e0bfb13b', 'Delete')} - variant="destructive" - onSelect={() => onDelete(row)} - /> - - ) - - return ( - - -
    { - // Why: Radix portals menus out of the row DOM, but React still - // bubbles those clicks here — ignore so menu actions don't open detail. - if (isPortaledRowMenuClick(event)) { - return - } - onSelect(row.key) - }} - onKeyDown={(event) => { - if (!isRowActivationKey(event)) { - return - } - event.preventDefault() - onSelect(row.key) - }} - className={cn( - AUTOMATIONS_TABLE_GRID_CLASS, - LIST_TABLE_ROW_CLASS, - isSelected && LIST_TABLE_ROW_SELECTED_CLASS - )} - > - - {automation.name} - - - {scheduleLabel} - - - {projectLabel} - - - {hostLabel} - - - {nextRunLabel} - - - - - - - - - - - {agentTooltipLabel} - - - - - - - - { - if (canRunNow) { - onRunNow(row) - } - }} - > - - - {automationRunAvailability.canRunNow - ? translate( - 'auto.components.automations.AutomationsPage.2faecab10b', - 'Run Now' - ) - : automationRunAvailability.message} - - - onEdit(row)}> - - {translate('auto.components.automations.AutomationsPage.f4612e3f78', 'Edit')} - - onToggle(row)} - > - {automation.enabled ? ( - - ) : ( - - )} - {automation.enabled - ? translate( - 'auto.components.automations.AutomationsPage.b457436d6a', - 'Pause' - ) - : translate( - 'auto.components.automations.AutomationsPage.376631ef2b', - 'Resume' - )} - - - onDelete(row)} - > - - {translate( - 'auto.components.automations.AutomationsPage.15e0bfb13b', - 'Delete' - )} - - - -
    -
    - {actionItems} -
    - ) - })} + {rows.map((row) => ( + + ))} ) } - -function MenuRunItem({ - disabled, - label, - onSelect -}: { - disabled: boolean - label: string - onSelect: () => void -}): React.JSX.Element { - return ( - { - if (disabled) { - event.preventDefault() - return - } - onSelect() - }} - > - - {label} - - ) -} - -function MenuItem({ - disabled, - icon, - label, - onSelect, - variant -}: { - disabled?: boolean - icon: React.ReactNode - label: string - onSelect: () => void - variant?: 'destructive' -}): React.JSX.Element { - return ( - - {icon} - {label} - - ) -} - -function MenuSeparator(): React.JSX.Element { - return -} diff --git a/src/renderer/src/components/automations/AutomationListSortHeader.tsx b/src/renderer/src/components/automations/AutomationListSortHeader.tsx new file mode 100644 index 00000000000..2c24a344328 --- /dev/null +++ b/src/renderer/src/components/automations/AutomationListSortHeader.tsx @@ -0,0 +1,51 @@ +import React from 'react' +import { ArrowDown, ArrowUp } from 'lucide-react' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import type { AutomationListSort, AutomationListSortField } from './automation-list-view' + +export function AutomationListSortHeader({ + field, + label, + sort, + onSort +}: { + field: AutomationListSortField + label: string + sort: AutomationListSort | null + onSort: (field: AutomationListSortField) => void +}): React.JSX.Element { + const active = sort?.field === field + const direction = active ? sort.direction : null + // Why: one interpolated key per direction — word order and punctuation around + // the column name differ per language. + const sortedLabel = + direction === 'asc' + ? translate( + 'auto.components.automations.AutomationListSortHeader.sortedAscending', + '{{value0}}, sorted ascending', + { value0: label } + ) + : direction === 'desc' + ? translate( + 'auto.components.automations.AutomationListSortHeader.sortedDescending', + '{{value0}}, sorted descending', + { value0: label } + ) + : null + return ( + + ) +} diff --git a/src/renderer/src/components/automations/AutomationListTableHeader.test.tsx b/src/renderer/src/components/automations/AutomationListTableHeader.test.tsx index 5c5bbe8e568..638e96a23bc 100644 --- a/src/renderer/src/components/automations/AutomationListTableHeader.test.tsx +++ b/src/renderer/src/components/automations/AutomationListTableHeader.test.tsx @@ -1,7 +1,8 @@ // @vitest-environment happy-dom import { cleanup, render, screen } from '@testing-library/react' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' +import userEvent from '@testing-library/user-event' import { AutomationListTableHeader } from './AutomationListTableHeader' import { LIST_TABLE_HEADER_CLASS, @@ -43,3 +44,45 @@ describe('AutomationListTableHeader', () => { expect(nameCell.className).toBe(LIST_TABLE_STICKY_HEADER_CELL_CLASS) }) }) + +describe('AutomationListTableHeader sorting', () => { + afterEach(cleanup) + + it('exposes only the orderable columns as buttons', () => { + render( {}} />) + + expect(screen.getAllByRole('button').map((button) => button.textContent)).toEqual([ + 'Name', + 'Last run' + ]) + }) + + it('reports the sorted column and direction in the accessible name', () => { + const { rerender } = render( + {}} /> + ) + expect(screen.getByRole('button', { name: 'Name, sorted ascending' })).toBeDefined() + expect(screen.getByRole('button', { name: 'Last run' })).toBeDefined() + + rerender( + {}} /> + ) + expect(screen.getByRole('button', { name: 'Last run, sorted descending' })).toBeDefined() + expect(screen.getByRole('button', { name: 'Name' })).toBeDefined() + }) + + it('requests a sort for the clicked column', async () => { + const onSort = vi.fn() + render() + + await userEvent.click(screen.getByRole('button', { name: 'Last run' })) + + expect(onSort.mock.calls).toEqual([['lastRun']]) + }) + + it('stays non-interactive when the list cannot be sorted', () => { + render() + + expect(screen.queryAllByRole('button')).toEqual([]) + }) +}) diff --git a/src/renderer/src/components/automations/AutomationListTableHeader.tsx b/src/renderer/src/components/automations/AutomationListTableHeader.tsx index dcbd107fcbc..605baf8a945 100644 --- a/src/renderer/src/components/automations/AutomationListTableHeader.tsx +++ b/src/renderer/src/components/automations/AutomationListTableHeader.tsx @@ -5,34 +5,85 @@ import { LIST_TABLE_HEADER_CLASS, LIST_TABLE_STICKY_HEADER_CELL_CLASS } from '@/lib/list-table-layout' +import { AutomationListSortHeader } from './AutomationListSortHeader' +import type { AutomationListSort, AutomationListSortField } from './automation-list-view' -export function AutomationListTableHeader(): React.JSX.Element { - const labels = [ - ['auto.components.automations.AutomationsPage.tableName', 'Name'], - ['auto.components.automations.AutomationDetail.18763ded26', 'Schedule'], - ['auto.components.automations.AutomationsPage.tableProject', 'Project'], - ['auto.components.automations.AutomationsPage.tableHost', 'Host'], - ['auto.components.automations.AutomationDetail.578ff46987', 'Next run'], - ['auto.components.automations.AutomationsPage.tableLastRun', 'Last run'], - ['auto.components.automations.AutomationsPage.tableStatus', 'Status'], - ['auto.components.automations.AutomationDetail.2df8970cd5', 'Agent'] - ] as const +type HeaderColumn = { + key: string + fallback: string + /** Absent for columns the list cannot order by. */ + sortField?: AutomationListSortField +} + +const COLUMNS: readonly HeaderColumn[] = [ + { + key: 'auto.components.automations.AutomationsPage.tableName', + fallback: 'Name', + sortField: 'name' + }, + { + key: 'auto.components.automations.AutomationDetail.18763ded26', + fallback: 'Schedule' + }, + { + key: 'auto.components.automations.AutomationsPage.tableProject', + fallback: 'Project' + }, + { + key: 'auto.components.automations.AutomationsPage.tableHost', + fallback: 'Host' + }, + { + key: 'auto.components.automations.AutomationDetail.578ff46987', + fallback: 'Next run' + }, + { + key: 'auto.components.automations.AutomationsPage.tableLastRun', + fallback: 'Last run', + sortField: 'lastRun' + }, + { + key: 'auto.components.automations.AutomationsPage.tableStatus', + fallback: 'Status' + }, + { + key: 'auto.components.automations.AutomationDetail.2df8970cd5', + fallback: 'Agent' + } +] + +export function AutomationListTableHeader({ + sort = null, + onSort +}: { + sort?: AutomationListSort | null + onSort?: (field: AutomationListSortField) => void +} = {}): React.JSX.Element { return (
    - {labels.map(([key, fallback], index) => ( - - {translate(key, fallback)} - - ))} + {COLUMNS.map((column, index) => { + const label = translate(column.key, column.fallback) + const className = + index === 0 + ? LIST_TABLE_STICKY_HEADER_CELL_CLASS + : index === COLUMNS.length - 1 + ? 'text-center' + : undefined + return ( + + {column.sortField && onSort ? ( + + ) : ( + label + )} + + ) + })} {translate('auto.components.automations.AutomationsPage.tableActions', 'Actions')} diff --git a/src/renderer/src/components/automations/AutomationsListPanel.test.tsx b/src/renderer/src/components/automations/AutomationsListPanel.test.tsx index 2f772d2a7ed..f2362b83d0f 100644 --- a/src/renderer/src/components/automations/AutomationsListPanel.test.tsx +++ b/src/renderer/src/components/automations/AutomationsListPanel.test.tsx @@ -11,7 +11,12 @@ import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { TooltipProvider } from '@/components/ui/tooltip' import { AutomationsListPanel } from './AutomationsListPanel' -import { EMPTY_AUTOMATION_LIST_FILTER } from './automation-list-view' +import { + buildAutomationListViewItems, + EMPTY_AUTOMATION_LIST_FILTER, + type AutomationListSort, + type AutomationListSortField +} from './automation-list-view' import type { AutomationHostCatalogView } from './use-automation-host-catalog' import { makeAutomation, @@ -49,7 +54,13 @@ const HOST_CATALOG = { status: 'all', announceFallback: false }, - rows: { rows: [], automations: [], capturedOwners: new Map(), groups: [], answered: true }, + rows: { + rows: [], + automations: [], + capturedOwners: new Map(), + groups: [], + answered: true + }, loadCounts: { failedHostCount: 0, totalHostCount: 1 }, selectHost: () => undefined, recover: () => undefined, @@ -70,6 +81,8 @@ function renderPanel( selectExternalKey?: (key: string | null) => void externalEntries?: readonly ExternalAutomationListEntry[] setActivePaneTab?: (tab: AutomationPaneTab) => void + listSort?: AutomationListSort | null + onListSortChange?: (field: AutomationListSortField) => void } = {} ): void { const externalEntries = options.externalEntries ?? [] @@ -95,8 +108,12 @@ function renderPanel( externalManagersUncheckedNotice={uncheckedNotice} onSelectHost={() => undefined} onRecoverHost={() => undefined} - filteredRows={rows} - filteredExternalAutomationEntries={externalEntries} + sortedListItems={buildAutomationListViewItems({ + rows, + externalEntries + })} + listSort={options.listSort ?? null} + onListSortChange={options.onListSortChange ?? (() => undefined)} selectedRowKey={options.selectedRowKey ?? null} selectedExternalKey={options.selectedExternalKey ?? null} relativeNow={0} @@ -221,7 +238,11 @@ describe('AutomationsListPanel enter key navigation', () => { const input = searchField() expect(input).not.toBeNull() - const enter = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }) + const enter = new KeyboardEvent('keydown', { + key: 'Enter', + bubbles: true, + cancelable: true + }) input?.dispatchEvent(enter) expect(enter.defaultPrevented).toBe(true) @@ -252,7 +273,11 @@ describe('AutomationsListPanel enter key navigation', () => { const input = searchField() expect(input).not.toBeNull() - const enter = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }) + const enter = new KeyboardEvent('keydown', { + key: 'Enter', + bubbles: true, + cancelable: true + }) input?.dispatchEvent(enter) expect(enter.defaultPrevented).toBe(true) @@ -272,7 +297,11 @@ describe('AutomationsListPanel enter key navigation', () => { const input = searchField() expect(input).not.toBeNull() - const enter = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }) + const enter = new KeyboardEvent('keydown', { + key: 'Enter', + bubbles: true, + cancelable: true + }) input?.dispatchEvent(enter) expect(detailOpened).toBe(false) diff --git a/src/renderer/src/components/automations/AutomationsListPanel.tsx b/src/renderer/src/components/automations/AutomationsListPanel.tsx index 5943096756a..783eee57da6 100644 --- a/src/renderer/src/components/automations/AutomationsListPanel.tsx +++ b/src/renderer/src/components/automations/AutomationsListPanel.tsx @@ -23,13 +23,19 @@ import { import type { AutomationListRow } from './automation-list-row-identity' import type { AutomationPaneTab } from './automation-page-state' import { AutomationListFilterPills } from './AutomationListFilterMenu' -import { isAutomationListFilterActive, type AutomationListFilter } from './automation-list-view' +import { + isAutomationListFilterActive, + type AutomationListFilter, + type AutomationListSort, + type AutomationListSortField, + type AutomationListViewItem +} from './automation-list-view' import { automationHostFilterStableKey } from '../../../../shared/automation-host-filter' import type { AutomationTemplate } from './automation-templates' import type { ExternalAutomationListEntry } from './external-automation-list-entries' import type { ExternalAutomationScope } from './external-automation-scope-client' -import { AutomationListLocalRows } from './AutomationListLocalRows' -import { AutomationListExternalRows } from './AutomationListExternalRows' +import { AutomationListLocalRow } from './AutomationListLocalRow' +import { AutomationListExternalRow } from './AutomationListExternalRow' import { AutomationHostFilterNotice, AutomationHostLoadSummary } from './AutomationHostFilterNotice' import { AutomationListEmptyView } from './AutomationListEmptyView' import { resolveAutomationListEmptyState } from './automation-list-empty-state' @@ -63,8 +69,10 @@ type AutomationsListPanelProps = { action: AutomationHostRecoveryAction, entry?: AutomationHostCatalogEntry | null ) => void - filteredRows: readonly AutomationListRow[] - filteredExternalAutomationEntries: readonly ExternalAutomationListEntry[] + /** Both collections as one list in render order; the sort spans local and external rows. */ + sortedListItems: readonly AutomationListViewItem[] + listSort: AutomationListSort | null + onListSortChange: (field: AutomationListSortField) => void selectedRowKey: string | null selectedExternalKey: string | null selectedExternal?: ExternalAutomationListEntry | null @@ -124,8 +132,9 @@ export function AutomationsListPanel(props: AutomationsListPanelProps): React.JS externalManagersUncheckedNotice, onSelectHost, onRecoverHost, - filteredRows, - filteredExternalAutomationEntries, + sortedListItems, + listSort, + onListSortChange, selectedRowKey, selectedExternalKey, relativeNow, @@ -161,18 +170,20 @@ export function AutomationsListPanel(props: AutomationsListPanelProps): React.JS // Hosts moved into the Filters menu, so its toolbar row is the focus fallback now. const toolbarRef = useRef(null) const pendingKeyboardScrollRef = useRef(false) - const rowKeys = React.useMemo(() => filteredRows.map((row) => row.key), [filteredRows]) - const visibleItems = React.useMemo( - () => [ - ...filteredRows.map((row) => ({ kind: 'local' as const, id: row.key })), - ...filteredExternalAutomationEntries.map((entry) => ({ - kind: 'external' as const, - id: entry.key - })) - ], - [filteredExternalAutomationEntries, filteredRows] + // Why: keyboard traversal and focus recovery read render order, which the sort owns. + const rowKeys = React.useMemo( + () => sortedListItems.filter((item) => item.kind === 'local').map((item) => item.id), + [sortedListItems] ) - useAutomationListFocusRecovery({ rowKeys, containerRef: listRef, fallbackRef: toolbarRef }) + const visibleItems = React.useMemo( + () => sortedListItems.map((item) => ({ kind: item.kind, id: item.id })), + [sortedListItems] + ) + useAutomationListFocusRecovery({ + rowKeys, + containerRef: listRef, + fallbackRef: toolbarRef + }) const handleSearchArrowNavigate = React.useCallback( (key: AutomationListArrowKey) => { const next = getAutomationListArrowNavigationTarget({ @@ -331,24 +342,30 @@ export function AutomationsListPanel(props: AutomationsListPanelProps): React.JS > {hasFilteredListItems ? (
    - +
    - - { - selectAutomationRow(null) - selectExternalKey(entryKey) - setActivePaneTab('overview') - onOpenDetail() - }} - onRequestAction={requestExternalAction} - onEdit={openEditExternalDialog} - /> + {sortedListItems.map((item) => + item.kind === 'local' ? ( + + ) : ( + { + selectAutomationRow(null) + selectExternalKey(entryKey) + setActivePaneTab('overview') + onOpenDetail() + }} + onRequestAction={requestExternalAction} + onEdit={openEditExternalDialog} + /> + ) + )}
    ) : ( diff --git a/src/renderer/src/components/automations/AutomationsPage.create-destination.test.tsx b/src/renderer/src/components/automations/AutomationsPage.create-destination.test.tsx index a0778029ef9..0635ecdb6ff 100644 --- a/src/renderer/src/components/automations/AutomationsPage.create-destination.test.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.create-destination.test.tsx @@ -19,7 +19,6 @@ import { addRuntimeProject, api, installAutomationsPageHarness, - listedRow, mocks, renderPage, runtimeHost, @@ -30,6 +29,7 @@ import { scopedList, settleHostQueries } from './automations-page-test-harness' +import { listedRow } from './automations-page-listed-items' import { makeAutomation, REPO_ID, WORKSPACE_ID } from './automations-page-fixtures' import type { Repo } from '../../../../shared/repo-types' import type { ProjectHostSetup } from '../../../../shared/project-types' diff --git a/src/renderer/src/components/automations/AutomationsPage.cross-authority-actions.test.tsx b/src/renderer/src/components/automations/AutomationsPage.cross-authority-actions.test.tsx index 618c092b45a..8193502cb36 100644 --- a/src/renderer/src/components/automations/AutomationsPage.cross-authority-actions.test.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.cross-authority-actions.test.tsx @@ -22,6 +22,7 @@ import { SELF_PRECONDITION, settleHostQueries } from './automations-page-test-harness' +import { listedRows } from './automations-page-listed-items' import { makeAutomation } from './automations-page-fixtures' installAutomationsPageHarness() @@ -36,9 +37,7 @@ async function collidingHosts(): Promise { } function selectDesktopRow(): string { - const row = mocks.listPanel?.filteredRows.find( - (candidate) => candidate.automation.name === 'Desktop nightly' - ) + const row = listedRows().find((candidate) => candidate.automation.name === 'Desktop nightly') expect(row).toBeDefined() return row?.key ?? '' } @@ -58,9 +57,7 @@ describe('AutomationsPage row actions under a colliding automation id', () => { await renderPage() await settleHostQueries() - const remote = mocks.listPanel?.filteredRows.find( - (candidate) => candidate.automation.name === 'Remote nightly' - ) + const remote = listedRows().find((candidate) => candidate.automation.name === 'Remote nightly') await act(async () => { mocks.listPanel?.selectAutomationRow(remote?.key ?? '') }) diff --git a/src/renderer/src/components/automations/AutomationsPage.external-scope.test.tsx b/src/renderer/src/components/automations/AutomationsPage.external-scope.test.tsx index cd966dcbcd7..b4ea3413cb3 100644 --- a/src/renderer/src/components/automations/AutomationsPage.external-scope.test.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.external-scope.test.tsx @@ -20,6 +20,7 @@ import { RUNTIME_SELF_FILTER, settleHostQueries } from './automations-page-test-harness' +import { listedExternalEntries } from './automations-page-listed-items' import { makeExternalManager } from './automations-page-fixtures' installAutomationsPageHarness() @@ -117,7 +118,7 @@ describe('AutomationsPage external manager probes', () => { await renderPage() await settleHostQueries() - expect(mocks.listPanel?.filteredExternalAutomationEntries).toEqual([]) + expect(listedExternalEntries()).toEqual([]) }) it('drops the previous host rows when the selection moves, not when the new probe lands', async () => { @@ -127,7 +128,7 @@ describe('AutomationsPage external manager probes', () => { const { rerender } = await renderPage() await settleHostQueries() - expect(mocks.listPanel?.filteredExternalAutomationEntries.length).toBeGreaterThan(0) + expect(listedExternalEntries().length).toBeGreaterThan(0) // The new host never answers, so anything still listed belongs to the old one. api.automations.listExternalManagerForOwner.mockImplementation( @@ -137,7 +138,7 @@ describe('AutomationsPage external manager probes', () => { await rerender() await settleHostQueries() - expect(mocks.listPanel?.filteredExternalAutomationEntries).toEqual([]) + expect(listedExternalEntries()).toEqual([]) }) it('reports a host it could not check rather than showing it as clean', async () => { diff --git a/src/renderer/src/components/automations/AutomationsPage.notice-recovery.test.tsx b/src/renderer/src/components/automations/AutomationsPage.notice-recovery.test.tsx index d99cfb91dac..69c27640be1 100644 --- a/src/renderer/src/components/automations/AutomationsPage.notice-recovery.test.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.notice-recovery.test.tsx @@ -15,7 +15,6 @@ import { addRuntimeProject, api, installAutomationsPageHarness, - listedRow, mocks, renderPage, runtimeHost, @@ -26,6 +25,7 @@ import { scopedList, settleHostQueries } from './automations-page-test-harness' +import { listedRow } from './automations-page-listed-items' import { makeAutomation } from './automations-page-fixtures' installAutomationsPageHarness() diff --git a/src/renderer/src/components/automations/AutomationsPage.refresh-selection.test.tsx b/src/renderer/src/components/automations/AutomationsPage.refresh-selection.test.tsx index 523cc50df7d..f33c4d09d6c 100644 --- a/src/renderer/src/components/automations/AutomationsPage.refresh-selection.test.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.refresh-selection.test.tsx @@ -22,6 +22,7 @@ import { SELF_PRECONDITION, settleHostQueries } from './automations-page-test-harness' +import { listedRows } from './automations-page-listed-items' import { makeAutomation, makeRun } from './automations-page-fixtures' installAutomationsPageHarness() @@ -69,7 +70,7 @@ describe('AutomationsPage refresh', () => { await renderPage() - expect(mocks.listPanel?.filteredRows[0]?.usageSummary).toEqual(usageSummary) + expect(listedRows()[0]?.usageSummary).toEqual(usageSummary) }) it('does not re-list through the active runtime just because one is selected', async () => { @@ -231,9 +232,7 @@ describe('AutomationsPage multi-host selection', () => { ) ).toEqual(['Desktop nightly', 'Remote nightly']) - const remote = mocks.listPanel?.filteredRows.find( - (row) => row.automation.name === 'Remote nightly' - ) + const remote = listedRows().find((row) => row.automation.name === 'Remote nightly') await act(async () => { mocks.listPanel?.selectAutomationRow(remote?.key ?? '') }) diff --git a/src/renderer/src/components/automations/AutomationsPage.run-visibility.test.tsx b/src/renderer/src/components/automations/AutomationsPage.run-visibility.test.tsx index f7ad0be65a7..5e605a4487a 100644 --- a/src/renderer/src/components/automations/AutomationsPage.run-visibility.test.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.run-visibility.test.tsx @@ -16,12 +16,12 @@ import type { Automation } from '../../../../shared/automations-types' import { api, installAutomationsPageHarness, - listedRow, mocks, renderPage, scopedList, settleHostQueries } from './automations-page-test-harness' +import { listedRow, listedRows } from './automations-page-listed-items' import { makeAutomation } from './automations-page-fixtures' installAutomationsPageHarness() @@ -42,7 +42,7 @@ function desktopStoreHolds(automations: Automation[]): void { /** The next-run column reads this; the mocked list panel renders only names. */ function listedNextRunAt(): number | null | undefined { - return mocks.listPanel?.filteredRows[0]?.automation.nextRunAt + return listedRows()[0]?.automation.nextRunAt } describe('AutomationsPage run visibility', () => { diff --git a/src/renderer/src/components/automations/AutomationsPage.test.tsx b/src/renderer/src/components/automations/AutomationsPage.test.tsx index 768d3250d80..a62a433a4d1 100644 --- a/src/renderer/src/components/automations/AutomationsPage.test.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.test.tsx @@ -24,13 +24,13 @@ import { api, DESKTOP_SELF_OWNER, installAutomationsPageHarness, - listedRow, mocks, renderPage, rows, scopedList, SELF_PRECONDITION } from './automations-page-test-harness' +import { listedRow, listedExternalEntries } from './automations-page-listed-items' import { makeAutomation, makeExternalManager, @@ -147,7 +147,7 @@ describe('AutomationsPage list rendering', () => { api.automations.updateExternalForOwner.mockResolvedValue(undefined) await renderPage() - const entry = mocks.listPanel?.filteredExternalAutomationEntries[0] + const entry = listedExternalEntries()[0] if (!entry) { throw new Error('no external entry to edit') } @@ -177,7 +177,7 @@ describe('AutomationsPage list rendering', () => { api.automations.runExternalActionForOwner.mockResolvedValue(undefined) await renderPage() - const entry = mocks.listPanel?.filteredExternalAutomationEntries[0] + const entry = listedExternalEntries()[0] if (!entry) { throw new Error('no external entry to act on') } @@ -217,7 +217,7 @@ describe('AutomationsPage list rendering', () => { api.automations.listExternalRunsForOwner.mockResolvedValue({ runs: [], total: 0 }) const { container } = await renderPage() - const entry = mocks.listPanel?.filteredExternalAutomationEntries[0] + const entry = listedExternalEntries()[0] if (!entry) { throw new Error('no external entry to read runs for') } diff --git a/src/renderer/src/components/automations/AutomationsPageListPanel.tsx b/src/renderer/src/components/automations/AutomationsPageListPanel.tsx index 25c7ff7b88c..7adea56c608 100644 --- a/src/renderer/src/components/automations/AutomationsPageListPanel.tsx +++ b/src/renderer/src/components/automations/AutomationsPageListPanel.tsx @@ -1,6 +1,7 @@ import React from 'react' import type { AutomationsPageController } from './use-automations-page-controller' import { AutomationsListPanel } from './AutomationsListPanel' +import { nextAutomationListSort } from './automation-list-view' export function AutomationsPageListPanel({ controller, @@ -45,8 +46,6 @@ export function AutomationsPageListPanel({ hasListItems, hasFilteredListItems, isListSearchQueryTooLarge, - filteredRows, - filteredExternalAutomationEntries, selectedRow, selectedExternal, searchCounts @@ -79,8 +78,9 @@ export function AutomationsPageListPanel({ void pageRefresh.refresh() } }} - filteredRows={filteredRows} - filteredExternalAutomationEntries={filteredExternalAutomationEntries} + sortedListItems={list.sortedListItems} + listSort={local.listSort} + onListSortChange={(field) => local.setListSort(nextAutomationListSort(local.listSort, field))} selectedRowKey={selectedRow?.key ?? null} selectedExternalKey={local.selectedExternalKey} selectedExternal={selectedExternal} diff --git a/src/renderer/src/components/automations/automation-list-view-sort.test.ts b/src/renderer/src/components/automations/automation-list-view-sort.test.ts index d3dfbe63133..8fbef8a0590 100644 --- a/src/renderer/src/components/automations/automation-list-view-sort.test.ts +++ b/src/renderer/src/components/automations/automation-list-view-sort.test.ts @@ -5,32 +5,34 @@ import { type AutomationListSort, type AutomationListViewItem } from './automation-list-view' +import { unscopedAutomationListRows } from './automation-list-row-identity' import { makeAutomation } from './automations-page-fixtures' -const locale = vi.hoisted(() => ({ value: 'en' })) -vi.mock('@/i18n/i18n', () => ({ getIntlLocale: () => locale.value })) - afterEach(() => { vi.restoreAllMocks() - locale.value = 'en' }) -function rows(count = 512): AutomationListViewItem[] { +function items(count = 512): AutomationListViewItem[] { const names = ['Alpha', 'álpha', 'Ångström', 'Zebra', 'Örebro', 'I', 'ı', 'İ', 'job 10', 'job 2'] return buildAutomationListViewItems({ - automations: Array.from({ length: count }, (_, index) => - makeAutomation({ id: `job-${index}`, name: names[(index * 7) % names.length] }) + rows: unscopedAutomationListRows( + Array.from({ length: count }, (_, index) => + makeAutomation({ + id: `job-${index}`, + name: names[(index * 7) % names.length] + }) + ) ), - externalEntries: [], - runs: [] + externalEntries: [] }) } -function previousOrder(items: AutomationListViewItem[], sort: AutomationListSort) { +/** The pre-collator comparator, resolving options on every comparison. */ +function previousOrder(list: AutomationListViewItem[], sort: AutomationListSort, locale: string) { function compare(left: AutomationListViewItem, right: AutomationListViewItem) { const value = sort.field === 'name' - ? left.name.localeCompare(right.name, locale.value, { sensitivity: 'base' }) + ? left.name.localeCompare(right.name, locale, { sensitivity: 'base' }) : (left.lastRunAt ?? 0) - (right.lastRunAt ?? 0) return value !== 0 ? sort.direction === 'asc' @@ -38,37 +40,35 @@ function previousOrder(items: AutomationListViewItem[], sort: AutomationListSort : -value : left.id.localeCompare(right.id) } - return [...items].sort(compare) + return [...list].sort(compare) } describe('automation list collation', () => { it.each(['en', 'sv', 'tr', 'ja'])( 'preserves %s ordering, tie-breaks and input identity', - (language) => { - locale.value = language - const items = rows() - const original = [...items] + (locale) => { + const list = items() + const original = [...list] for (const direction of ['asc', 'desc'] as const) { const sort = { field: 'name', direction } as const - const expected = previousOrder(items, sort) - const result = sortAutomationListViewItems(items, sort) + const expected = previousOrder(list, sort, locale) + const result = sortAutomationListViewItems(list, sort, locale) expect(result).toEqual(expected) expect(result.every((row, index) => row === expected[index])).toBe(true) } - expect(items).toEqual(original) + expect(list).toEqual(original) } ) - it('resolves collation once per name sort and responds to locale changes', () => { - const items = rows() + it('resolves collation once per name sort and follows the locale it is given', () => { + const list = items() const OriginalCollator = Intl.Collator const construct = vi.spyOn(Intl, 'Collator').mockImplementation(function (locales, options) { return new OriginalCollator(locales, options) }) const compare = vi.spyOn(String.prototype, 'localeCompare') - sortAutomationListViewItems(items, { field: 'name', direction: 'asc' }) - locale.value = 'sv' - sortAutomationListViewItems(items, { field: 'name', direction: 'desc' }) + sortAutomationListViewItems(list, { field: 'name', direction: 'asc' }, 'en') + sortAutomationListViewItems(list, { field: 'name', direction: 'desc' }, 'sv') expect(construct.mock.calls).toEqual([ ['en', { sensitivity: 'base' }], ['sv', { sensitivity: 'base' }] @@ -76,16 +76,39 @@ describe('automation list collation', () => { expect(compare.mock.calls.filter((args) => args.length >= 3)).toHaveLength(0) }) + it('orders by row key, not the bare automation ID, so hosts cannot collapse', () => { + const duplicate = makeAutomation({ id: 'shared', name: 'Same' }) + const list = buildAutomationListViewItems({ + rows: [ + { + key: 'row|host-b|shared', + automation: duplicate, + hostLabel: 'b', + usageSummary: null + }, + { + key: 'row|host-a|shared', + automation: duplicate, + hostLabel: 'a', + usageSummary: null + } + ], + externalEntries: [] + }) + const sorted = sortAutomationListViewItems(list, { field: 'name', direction: 'asc' }, 'en') + expect(sorted.map((item) => item.id)).toEqual(['row|host-a|shared', 'row|host-b|shared']) + }) + it('does not construct collation for unsorted, time-sorted or trivial lists', () => { - const items = rows() + const list = items() const construct = vi.spyOn(Intl, 'Collator') - expect(sortAutomationListViewItems(items, null)).toEqual(items) + expect(sortAutomationListViewItems(list, null, 'en')).toEqual(list) const sort = { field: 'lastRun', direction: 'desc' } as const - expect(sortAutomationListViewItems(items, sort)).toEqual(previousOrder(items, sort)) - expect(sortAutomationListViewItems([], { field: 'name', direction: 'asc' })).toEqual([]) + expect(sortAutomationListViewItems(list, sort, 'en')).toEqual(previousOrder(list, sort, 'en')) + expect(sortAutomationListViewItems([], { field: 'name', direction: 'asc' }, 'en')).toEqual([]) expect( - sortAutomationListViewItems(items.slice(0, 1), { field: 'name', direction: 'asc' }) - ).toEqual(items.slice(0, 1)) + sortAutomationListViewItems(list.slice(0, 1), { field: 'name', direction: 'asc' }, 'en') + ).toEqual(list.slice(0, 1)) expect(construct).not.toHaveBeenCalled() }) }) diff --git a/src/renderer/src/components/automations/automation-list-view.test.ts b/src/renderer/src/components/automations/automation-list-view.test.ts index 188c94f3db7..169fbdd360a 100644 --- a/src/renderer/src/components/automations/automation-list-view.test.ts +++ b/src/renderer/src/components/automations/automation-list-view.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from 'vitest' import type { Automation, - AutomationRun, AutomationRunStatus, ExternalAutomationJob, ExternalAutomationManager @@ -48,31 +47,6 @@ function makeAutomation(overrides: Partial = {}): Automation { } } -function makeRun(overrides: Partial = {}): AutomationRun { - return { - id: 'run-1', - automationId: 'automation-1', - title: 'Zebra job', - scheduledFor: 10, - status: 'completed', - trigger: 'scheduled', - workspaceId: 'worktree-1', - sessionKind: 'terminal', - chatSessionId: null, - terminalSessionId: null, - terminalPaneKey: null, - terminalPtyId: null, - outputSnapshot: null, - precheckResult: null, - usage: null, - error: null, - startedAt: 20, - dispatchedAt: 30, - createdAt: 10, - ...overrides - } -} - function makeExternalEntry( overrides: Partial = {} ): ExternalAutomationListEntry { @@ -118,22 +92,69 @@ function makeExternalEntry( } } +/** A catalog row with an optional projected last-run status, keyed like a real host row. */ +function makeCatalogRow( + id: string, + overrides: Partial = {}, + lastRunStatus?: AutomationRunStatus +): AutomationListRow { + return { + key: `row|host|${id}`, + automation: makeAutomation({ id, ...overrides }), + hostLabel: 'This computer', + usageSummary: lastRunStatus + ? { + knownRuns: 1, + unavailableRuns: 0, + inputTokens: 0, + outputTokens: 0, + cacheTokens: 0, + reasoningOutputTokens: 0, + totalTokens: 0, + estimatedCostUsd: null, + lastRunStatus, + lastRunAt: 111 + } + : null + } +} + +const rowKey = (id: string): string => `row|host|${id}` + describe('automation-list-view', () => { it('counts and detects active filters', () => { - expect(isAutomationListFilterActive({ status: 'all', lastRun: 'all', agentIds: [] })).toBe( - false - ) - expect(isAutomationListFilterActive({ status: 'paused', lastRun: 'all', agentIds: [] })).toBe( - true - ) - expect(countAutomationListFilters({ status: 'paused', lastRun: 'failed', agentIds: [] })).toBe( - 2 - ) + expect( + isAutomationListFilterActive({ + status: 'all', + lastRun: 'all', + agentIds: [] + }) + ).toBe(false) + expect( + isAutomationListFilterActive({ + status: 'paused', + lastRun: 'all', + agentIds: [] + }) + ).toBe(true) + expect( + countAutomationListFilters({ + status: 'paused', + lastRun: 'failed', + agentIds: [] + }) + ).toBe(2) }) it('toggles sort direction and defaults last run to newest first', () => { - expect(nextAutomationListSort(null, 'name')).toEqual({ field: 'name', direction: 'asc' }) - expect(nextAutomationListSort(null, 'lastRun')).toEqual({ field: 'lastRun', direction: 'desc' }) + expect(nextAutomationListSort(null, 'name')).toEqual({ + field: 'name', + direction: 'asc' + }) + expect(nextAutomationListSort(null, 'lastRun')).toEqual({ + field: 'lastRun', + direction: 'desc' + }) expect(nextAutomationListSort({ field: 'name', direction: 'asc' }, 'name')).toEqual({ field: 'name', direction: 'desc' @@ -146,82 +167,62 @@ describe('automation-list-view', () => { it('filters by enabled state and last-run outcome', () => { const items = applyAutomationListView({ - automations: [ - makeAutomation({ id: 'paused', name: 'Paused', enabled: false }), - makeAutomation({ id: 'ok', name: 'Healthy' }) + rows: [ + makeCatalogRow('paused', { name: 'Paused', enabled: false }, 'completed'), + makeCatalogRow('ok', { name: 'Healthy' }, 'dispatch_failed') ], externalEntries: [makeExternalEntry()], - runs: [ - makeRun({ automationId: 'paused', status: 'completed' }), - makeRun({ automationId: 'ok', status: 'dispatch_failed' }) - ], filter: { status: 'enabled', lastRun: 'failed', agentIds: [] }, - sort: null + sort: null, + locale: 'en' }) - expect(items.map((item) => item.id)).toEqual(['ok', 'manager-1:job-1']) + expect(items.map((item) => item.id)).toEqual([rowKey('ok'), 'manager-1:job-1']) }) it('filters local rows by multiple agents and leaves external rows out of agent scopes', () => { const items = applyAutomationListView({ - automations: [ - makeAutomation({ id: 'codex-job', agentId: 'codex' }), - makeAutomation({ id: 'claude-job', agentId: 'claude' }) + rows: [ + makeCatalogRow('codex-job', { agentId: 'codex' }), + makeCatalogRow('claude-job', { agentId: 'claude' }) ], externalEntries: [makeExternalEntry()], - runs: [], filter: { status: 'all', lastRun: 'all', agentIds: ['codex', 'claude'] }, - sort: null + sort: null, + locale: 'en' }) - expect(items.map((item) => item.id)).toEqual(['codex-job', 'claude-job']) + expect(items.map((item) => item.id)).toEqual([rowKey('codex-job'), rowKey('claude-job')]) }) it('counts an agent filter alongside status and last-run filters', () => { - expect(isAutomationListFilterActive({ status: 'all', lastRun: 'all', agentIds: [] })).toBe( - false - ) expect( - countAutomationListFilters({ status: 'paused', lastRun: 'failed', agentIds: ['codex'] }) + isAutomationListFilterActive({ + status: 'all', + lastRun: 'all', + agentIds: [] + }) + ).toBe(false) + expect( + countAutomationListFilters({ + status: 'paused', + lastRun: 'failed', + agentIds: ['codex'] + }) ).toBe(3) }) it('sorts by name across local and external rows', () => { const items = applyAutomationListView({ - automations: [makeAutomation({ name: 'Zebra job' })], + rows: [makeCatalogRow('zebra', { name: 'Zebra job' })], externalEntries: [makeExternalEntry({ name: 'Alpha digest' })], - runs: [], filter: { status: 'all', lastRun: 'all', agentIds: [] }, - sort: { field: 'name', direction: 'asc' } + sort: { field: 'name', direction: 'asc' }, + locale: 'en' }) expect(items.map((item) => item.name)).toEqual(['Alpha digest', 'Zebra job']) }) it('filters catalog rows by status, agent, and the projected last-run status', () => { - function makeCatalogRow( - id: string, - overrides: Partial, - lastRunStatus?: AutomationRunStatus - ): AutomationListRow { - return { - key: `row|host|${id}`, - automation: makeAutomation({ id, ...overrides }), - hostLabel: 'This computer', - usageSummary: lastRunStatus - ? { - knownRuns: 1, - unavailableRuns: 0, - inputTokens: 0, - outputTokens: 0, - cacheTokens: 0, - reasoningOutputTokens: 0, - totalTokens: 0, - estimatedCostUsd: null, - lastRunStatus, - lastRunAt: 111 - } - : null - } - } const rows = [ makeCatalogRow('paused-codex', { enabled: false, agentId: 'codex' }), makeCatalogRow('failed-claude', { agentId: 'claude' }, 'dispatch_failed'), @@ -229,9 +230,10 @@ describe('automation-list-view', () => { makeCatalogRow('never-codex', { agentId: 'codex' }) ] const ids = (filter: Partial) => - filterAutomationListRows(rows, { ...EMPTY_AUTOMATION_LIST_FILTER, ...filter }).map( - (row) => row.automation.id - ) + filterAutomationListRows(rows, { + ...EMPTY_AUTOMATION_LIST_FILTER, + ...filter + }).map((row) => row.automation.id) expect(ids({ status: 'paused' })).toEqual(['paused-codex']) expect(ids({ agentIds: ['claude'] })).toEqual(['failed-claude']) @@ -249,7 +251,10 @@ describe('automation-list-view', () => { catalogRef: targetId === null ? null - : { authority: { kind: 'desktop' }, selector: { kind: 'ssh', targetId } }, + : { + authority: { kind: 'desktop' }, + selector: { kind: 'ssh', targetId } + }, hostLabel: targetId ?? '', usageSummary: null }) @@ -257,9 +262,10 @@ describe('automation-list-view', () => { const keyOf = (row: AutomationListRow): string => row.catalogRef ? hostStableKey(row.catalogRef) : '' const ids = (hostStableKeys: readonly string[]) => - filterAutomationListRows(rows, { ...EMPTY_AUTOMATION_LIST_FILTER, hostStableKeys }).map( - (row) => row.automation.id - ) + filterAutomationListRows(rows, { + ...EMPTY_AUTOMATION_LIST_FILTER, + hostStableKeys + }).map((row) => row.automation.id) // Multi-select is any-of; a pre-catalog row names no host and is excluded. expect(ids([keyOf(rows[0]), keyOf(rows[1])])).toEqual(['on-a', 'on-b']) @@ -290,15 +296,22 @@ describe('automation-list-view', () => { it('sorts by last run newest first and keeps never-run rows last', () => { const items = applyAutomationListView({ - automations: [ - makeAutomation({ id: 'old', name: 'Old' }), - makeAutomation({ id: 'never', name: 'Never' }) + rows: [ + makeCatalogRow('old', { + name: 'Old', + lastRunAt: Date.parse('2026-08-11T09:00:00Z') + }), + makeCatalogRow('never', { name: 'Never' }) ], externalEntries: [makeExternalEntry({ lastRunAt: '2026-08-12T09:00:00Z' })], - runs: [makeRun({ automationId: 'old', dispatchedAt: Date.parse('2026-08-11T09:00:00Z') })], filter: { status: 'all', lastRun: 'all', agentIds: [] }, - sort: { field: 'lastRun', direction: 'desc' } + sort: { field: 'lastRun', direction: 'desc' }, + locale: 'en' }) - expect(items.map((item) => item.id)).toEqual(['manager-1:job-1', 'old', 'never']) + expect(items.map((item) => item.id)).toEqual([ + 'manager-1:job-1', + rowKey('old'), + rowKey('never') + ]) }) }) diff --git a/src/renderer/src/components/automations/automation-list-view.ts b/src/renderer/src/components/automations/automation-list-view.ts index 459d4b0f588..cedd394ed23 100644 --- a/src/renderer/src/components/automations/automation-list-view.ts +++ b/src/renderer/src/components/automations/automation-list-view.ts @@ -1,5 +1,3 @@ -import { getIntlLocale } from '@/i18n/i18n' -import type { Automation, AutomationRun } from '../../../../shared/automations-types' import type { TuiAgent } from '../../../../shared/tui-agent' import { hostStableKey } from '../../../../shared/automation-owner-key' import type { AutomationListRow } from './automation-list-row-identity' @@ -7,8 +5,6 @@ import type { ExternalAutomationListEntry } from './external-automation-list-ent import { getAutomationRowLastRunSnapshot, getExternalAutomationLastRunSnapshot, - getLocalAutomationLastRunSnapshot, - indexLatestAutomationRuns, type AutomationLastRunSnapshot } from './automation-list-last-run' @@ -22,6 +18,13 @@ export type AutomationListSort = { direction: AutomationListSortDirection } +/** + * A row and an external job flattened to what the shared list renders and sorts. + * + * `id` is the row's own key, never the bare automation ID: under All hosts two + * authorities can return the same ID, and the sort tie-break decides render + * order, so a bare ID would collapse them. See `automation-list-row-identity`. + */ export type AutomationListViewItem = | { kind: 'local' @@ -31,7 +34,7 @@ export type AutomationListViewItem = lastRunAt: number | null lastRun: AutomationLastRunSnapshot agentId: TuiAgent - automation: Automation + row: AutomationListRow } | { kind: 'external' @@ -117,30 +120,26 @@ function matchesLastRunFilter( return snapshot.tone === filter } +/** Flattens the two rendered collections into one sortable list, preserving row identity. */ export function buildAutomationListViewItems({ - automations, - externalEntries, - runs + rows, + externalEntries }: { - automations: readonly Automation[] + rows: readonly AutomationListRow[] externalEntries: readonly ExternalAutomationListEntry[] - runs: readonly AutomationRun[] }): AutomationListViewItem[] { - const lastRunByAutomationId = indexLatestAutomationRuns(runs) - const locals: AutomationListViewItem[] = automations.map((automation) => { - const lastRun = getLocalAutomationLastRunSnapshot( - automation, - lastRunByAutomationId.get(automation.id) - ) + const locals: AutomationListViewItem[] = rows.map((row) => { + // Why: the same snapshot the row cell renders, so the sort matches the column. + const lastRun = getAutomationRowLastRunSnapshot(row) return { kind: 'local', - id: automation.id, - name: automation.name, - enabled: automation.enabled, + id: row.key, + name: row.automation.name, + enabled: row.automation.enabled, lastRunAt: lastRun.at, lastRun, - agentId: automation.agentId, - automation + agentId: row.automation.agentId, + row } }) const externals: AutomationListViewItem[] = externalEntries.map((entry) => { @@ -217,34 +216,21 @@ export function filterExternalAutomationListEntries( ) } -export function filterAutomationListViewItems( - items: readonly AutomationListViewItem[], - filter: AutomationListFilter -): AutomationListViewItem[] { - if (!isAutomationListFilterActive(filter)) { - return [...items] - } - return items.filter( - (item) => - matchesStatusFilter(item.enabled, filter.status) && - matchesLastRunFilter(item.lastRun, filter.lastRun) && - (filter.agentIds.length === 0 || - (item.agentId !== null && filter.agentIds.includes(item.agentId))) - ) -} - +/** + * `locale` is a parameter, not a `getIntlLocale()` read, so callers memoizing this + * can declare it — a hidden read is invisible to a dependency array. + */ export function sortAutomationListViewItems( items: readonly AutomationListViewItem[], - sort: AutomationListSort | null + sort: AutomationListSort | null, + locale: string ): AutomationListViewItem[] { if (!sort || items.length < 2) { return [...items] } const next = [...items] const compareNames = - sort.field === 'name' - ? new Intl.Collator(getIntlLocale(), { sensitivity: 'base' }).compare - : null + sort.field === 'name' ? new Intl.Collator(locale, { sensitivity: 'base' }).compare : null next.sort((left, right) => { const compared = compareNames ? compareNames(left.name, right.name) @@ -257,24 +243,26 @@ export function sortAutomationListViewItems( return next } +/** The rendered list: filter each collection with its own rules, then sort as one. */ export function applyAutomationListView({ - automations, + rows, externalEntries, - runs, filter, - sort + sort, + locale }: { - automations: readonly Automation[] + rows: readonly AutomationListRow[] externalEntries: readonly ExternalAutomationListEntry[] - runs: readonly AutomationRun[] filter: AutomationListFilter sort: AutomationListSort | null + locale: string }): AutomationListViewItem[] { return sortAutomationListViewItems( - filterAutomationListViewItems( - buildAutomationListViewItems({ automations, externalEntries, runs }), - filter - ), - sort + buildAutomationListViewItems({ + rows: filterAutomationListRows(rows, filter), + externalEntries: filterExternalAutomationListEntries(externalEntries, filter) + }), + sort, + locale ) } diff --git a/src/renderer/src/components/automations/automations-page-listed-items.ts b/src/renderer/src/components/automations/automations-page-listed-items.ts new file mode 100644 index 00000000000..d87ae62b4a8 --- /dev/null +++ b/src/renderer/src/components/automations/automations-page-listed-items.ts @@ -0,0 +1,32 @@ +/** + * What the page actually listed, read back from the mocked list panel. + * + * Tests act through the same authority-qualified keys and render order the + * user's click carries, rather than synthesizing either. + */ + +import type { AutomationListRow } from './automation-list-row-identity' +import type { ExternalAutomationListEntry } from './external-automation-list-entries' +import { mocks } from './automations-page-test-harness' + +function listedItems() { + return mocks.listPanel?.sortedListItems ?? [] +} + +/** Local rows the page listed, in render order. */ +export function listedRows(): readonly AutomationListRow[] { + return listedItems().flatMap((item) => (item.kind === 'local' ? [item.row] : [])) +} + +/** External entries the page listed, in render order. */ +export function listedExternalEntries(): readonly ExternalAutomationListEntry[] { + return listedItems().flatMap((item) => (item.kind === 'external' ? [item.entry] : [])) +} + +export function listedRow(automationId: string): AutomationListRow { + const row = listedRows().find((entry) => entry.automation.id === automationId) + if (!row) { + throw new Error(`no listed row for ${automationId}`) + } + return row +} diff --git a/src/renderer/src/components/automations/automations-page-test-harness.tsx b/src/renderer/src/components/automations/automations-page-test-harness.tsx index d9fd1088bf7..e34ae0640bc 100644 --- a/src/renderer/src/components/automations/automations-page-test-harness.tsx +++ b/src/renderer/src/components/automations/automations-page-test-harness.tsx @@ -27,6 +27,7 @@ import type { AutomationHostCatalogView } from './use-automation-host-catalog' import type { AutomationCreateDestinationControl } from './use-automation-create-destination' import type { ExternalAutomationListEntry } from './external-automation-list-entries' import type { AutomationListRow } from './automation-list-row-identity' +import type { AutomationListViewItem } from './automation-list-view' import { resetAutomationCapabilityProbes } from './automation-scoped-list-client' import { addRuntimeProject as addRuntimeProjectFixture, @@ -39,7 +40,7 @@ export const RUNTIME_REPO_ID = RUNTIME_REPO_ID_FIXTURE export const RUNTIME_WORKSPACE_ID = RUNTIME_WORKSPACE_ID_FIXTURE export type ListPanelProps = { - filteredExternalAutomationEntries: ExternalAutomationListEntry[] + sortedListItems: readonly AutomationListViewItem[] selectedExternal: ExternalAutomationListEntry | null openEditExternalDialog: ( manager: ExternalAutomationListEntry['manager'], @@ -55,7 +56,6 @@ export type ListPanelProps = { ) => void hasListItems: boolean hasFilteredListItems: boolean - filteredRows: readonly AutomationListRow[] selectedRowKey: string | null selectedExternalKey: string | null hostCatalog: AutomationHostCatalogView @@ -211,30 +211,31 @@ vi.mock('./AutomationsListPanel', () => ({ return (
    - ))} - {props.filteredExternalAutomationEntries.map((entry) => ( - - ))} + {props.sortedListItems.map((item) => + item.kind === 'local' ? ( + + ) : ( + + ) + )} {props.hasListItems ? null :
    }
    ) @@ -407,18 +408,6 @@ export async function refreshOnFocus(): Promise { }) } -/** - * The row the page actually listed for an ID, so tests act through the same - * authority-qualified key the user's click carries rather than a synthesized one. - */ -export function listedRow(automationId: string): AutomationListRow { - const row = mocks.listPanel?.filteredRows.find((entry) => entry.automation.id === automationId) - if (!row) { - throw new Error(`no listed row for ${automationId}`) - } - return row -} - export function rows(container: HTMLElement, testId: string): string[] { return [...container.querySelectorAll(`[data-testid="${testId}"]`)].map( (node) => node.textContent ?? '' diff --git a/src/renderer/src/components/automations/use-automations-page-list-state.ts b/src/renderer/src/components/automations/use-automations-page-list-state.ts index 65cce90ffc2..b13c8a689ec 100644 --- a/src/renderer/src/components/automations/use-automations-page-list-state.ts +++ b/src/renderer/src/components/automations/use-automations-page-list-state.ts @@ -4,9 +4,12 @@ import { buildExternalAutomationListEntries } from './external-automation-list-e import { externalAutomationScopeEntries } from './external-automation-scope-gating' import { externalAutomationUncheckedNotice } from './external-automation-unchecked-hosts' import { + buildAutomationListViewItems, filterAutomationListRows, - filterExternalAutomationListEntries + filterExternalAutomationListEntries, + sortAutomationListViewItems } from './automation-list-view' +import { getIntlLocale } from '@/i18n/i18n' import { unscopedAutomationListRows } from './automation-list-row-identity' import { useAutomationHostCatalog } from './use-automation-host-catalog' import { useAutomationListSearch } from './use-automation-list-search' @@ -28,6 +31,7 @@ export function useAutomationsPageListState({ failedAuthorityKeys, listSearchQuery, listFilter, + listSort, selectedRowKey, selectedExternalKey, selectedAutomationRuns, @@ -129,6 +133,21 @@ export function useAutomationsPageListState({ () => externalAutomationUncheckedNotice(scopedExternal.failures, hostCatalog.entries), [hostCatalog.entries, scopedExternal.failures] ) + // Why: a language switch changes collation without touching rows, so the locale + // has to reach the memo as a value. + const sortLocale = getIntlLocale() + const sortedListItems = useMemo( + () => + sortAutomationListViewItems( + buildAutomationListViewItems({ + rows: filteredRows, + externalEntries: filteredExternalAutomationEntries + }), + listSort, + sortLocale + ), + [filteredExternalAutomationEntries, filteredRows, listSort, sortLocale] + ) return { hostCatalog, @@ -146,6 +165,7 @@ export function useAutomationsPageListState({ isListSearchQueryTooLarge, filteredRows, filteredExternalAutomationEntries, + sortedListItems, hasListItems, hasFilteredListItems, searchCounts, diff --git a/src/renderer/src/components/automations/use-automations-page-local-state.ts b/src/renderer/src/components/automations/use-automations-page-local-state.ts index e92f666cb22..7a097b144c3 100644 --- a/src/renderer/src/components/automations/use-automations-page-local-state.ts +++ b/src/renderer/src/components/automations/use-automations-page-local-state.ts @@ -12,7 +12,11 @@ import type { AutomationActionNotice } from './automation-row-action-dispatch' import type { AutomationHostCatalogEntry } from './automation-host-catalog-types' import type { AutomationCreateDestination } from './automation-create-destination' import type { AutomationListRow } from './automation-list-row-identity' -import { EMPTY_AUTOMATION_LIST_FILTER, type AutomationListFilter } from './automation-list-view' +import { + EMPTY_AUTOMATION_LIST_FILTER, + type AutomationListFilter, + type AutomationListSort +} from './automation-list-view' import type { AutomationPaneTab, AutomationRunPageOrigin, @@ -54,6 +58,7 @@ export function useAutomationsPageLocalState(store: AutomationsPageStoreState) { const [isSaving, setIsSaving] = useState(false) const [listSearchQuery, setListSearchQuery] = useState('') const [listFilter, setListFilter] = useState(EMPTY_AUTOMATION_LIST_FILTER) + const [listSort, setListSort] = useState(null) const [createOpen, setCreateOpen] = useState(false) const [createTarget, setCreateTarget] = useState('orca') const [editingAutomationId, setEditingAutomationId] = useState(null) @@ -178,6 +183,8 @@ export function useAutomationsPageLocalState(store: AutomationsPageStoreState) { setListSearchQuery, listFilter, setListFilter, + listSort, + setListSort, createOpen, setCreateOpen, createTarget, diff --git a/src/shared/pane-agent-identity-inventory.test.ts b/src/shared/pane-agent-identity-inventory.test.ts index ee868bfcc16..d493dec1aef 100644 --- a/src/shared/pane-agent-identity-inventory.test.ts +++ b/src/shared/pane-agent-identity-inventory.test.ts @@ -56,7 +56,7 @@ const INVENTORY: readonly InventoryGroup[] = [ 'src/renderer/src/components/agent-session-continuation/AgentSessionContinuationDialog.tsx', 2 ], - ['src/renderer/src/components/automations/AutomationListLocalRows.tsx', 2], + ['src/renderer/src/components/automations/AutomationListLocalRow.tsx', 2], 'src/renderer/src/components/automations/automation-draft-model.ts', ['src/renderer/src/components/automations/automation-list-search-rows.ts', 2], ['src/renderer/src/components/dashboard-popout/AgentMapSnapshotWorkspaceMenu.tsx', 2], From 55dcc5ceeeac04dc515ce7d14c084fdee5b82c69 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 5 Sep 2026 16:26:36 -0700 Subject: [PATCH 20/23] test: pin terminal Codex home to an explicit managed account (#18935) --- tests/e2e/terminal-codex-home.spec.ts | 52 +++++++++++++++++++++------ 1 file changed, 41 insertions(+), 11 deletions(-) diff --git a/tests/e2e/terminal-codex-home.spec.ts b/tests/e2e/terminal-codex-home.spec.ts index 1f85a4f4c9b..3364152d38c 100644 --- a/tests/e2e/terminal-codex-home.spec.ts +++ b/tests/e2e/terminal-codex-home.spec.ts @@ -1,3 +1,5 @@ +import { mkdirSync, writeFileSync } from 'node:fs' +import path from 'node:path' import { test, expect } from './helpers/orca-app' import { execInTerminal, @@ -27,7 +29,42 @@ test.describe('Terminal Codex runtime home', () => { await ensureTerminalVisible(orcaPage) }) - test('terminal process receives the Orca-managed Codex home', async ({ orcaPage }) => { + test('terminal process receives the selected account Codex home', async ({ + electronApp, + orcaPage + }) => { + const userData = await electronApp.evaluate(({ app }) => app.getPath('userData')) + const accountId = 'e2e-terminal-home' + const managedHomePath = path.join(userData, 'codex-accounts', accountId, 'home') + mkdirSync(managedHomePath, { recursive: true }) + writeFileSync(path.join(managedHomePath, '.orca-managed-home'), `${accountId}\n`) + writeFileSync( + path.join(managedHomePath, 'auth.json'), + JSON.stringify({ OPENAI_API_KEY: 'e2e-placeholder' }) + ) + await orcaPage.evaluate( + async ({ accountId, managedHomePath }) => { + const state = window.__store!.getState() + await state.updateSettings({ + codexManagedAccounts: [ + { + id: accountId, + email: 'terminal-home@example.invalid', + managedHomePath, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountId: accountId, + activeCodexManagedAccountIdsByRuntime: { host: accountId, wsl: {} } + }) + const tab = state.createTab(state.activeWorktreeId!) + state.setActiveTab(tab.id) + state.setActiveTabType('terminal') + }, + { accountId, managedHomePath } + ) await waitForActiveTerminalManager(orcaPage) const ptyId = await waitForActivePanePtyId(orcaPage) const marker = `__ORCA_CODEX_HOME_E2E_${Date.now()}__` @@ -43,17 +80,10 @@ test.describe('Terminal Codex runtime home', () => { .poll( async () => { probe = readCodexHomeProbe(await getTerminalContent(orcaPage), marker) - return Boolean( - probe?.codexHome && - probe.orcaCodexHome && - probe.codexHome === probe.orcaCodexHome && - /[\\/]codex-runtime-home[\\/]home$/.test(probe.codexHome) - ) + return probe }, - { timeout: 15_000, message: 'Terminal did not expose Orca-managed Codex home env' } + { timeout: 15_000, message: 'Terminal did not expose the selected Codex account home' } ) - .toBe(true) - - expect(probe?.codexHome).toBe(probe?.orcaCodexHome) + .toEqual({ codexHome: managedHomePath, orcaCodexHome: managedHomePath }) }) }) From 59756b8a1cec8b266a1461056f18c768454bfcb0 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 5 Sep 2026 16:31:11 -0700 Subject: [PATCH 21/23] test: deliver real terminal input and preserve setup reports (#18939) --- .../e2e/terminal-scroll-intent-follow.spec.ts | 44 +++++++++++-------- .../terminal-send-agent-prompt-submit.spec.ts | 1 + tests/tools/repro-terminal-send-submit.mjs | 4 +- 3 files changed, 30 insertions(+), 19 deletions(-) diff --git a/tests/e2e/terminal-scroll-intent-follow.spec.ts b/tests/e2e/terminal-scroll-intent-follow.spec.ts index c4d8b171fed..ae2dfa15466 100644 --- a/tests/e2e/terminal-scroll-intent-follow.spec.ts +++ b/tests/e2e/terminal-scroll-intent-follow.spec.ts @@ -168,6 +168,7 @@ async function injectQueuedWriteThenType(page: Page, paneKey: string): Promise { const injectionTarget = window as Window & { __terminalPtyDataInjection?: { inject: (paneKey: string, data: string) => boolean } + __releaseScrollIntentTestWrite?: () => void } const state = window.__store?.getState() const worktreeId = state?.activeWorktreeId @@ -184,38 +185,44 @@ async function injectQueuedWriteThenType(page: Page, paneKey: string): Promise void } | null } = { write: null } + const heldWrites: { data: string; callback?: () => void }[] = [] terminal.write = ((data: string, callback?: () => void) => { - holder.write = { data, callback } + heldWrites.push({ data, callback }) }) as typeof terminal.write + injectionTarget.__releaseScrollIntentTestWrite = () => { + terminal.write = originalWrite + delete injectionTarget.__releaseScrollIntentTestWrite + for (const held of heldWrites) { + originalWrite.call(terminal, held.data, held.callback) + } + } try { const payload = '\x1b[?2026h\r\x1b[2KWorking in-flight\x1b[?2026l' if (!injectionTarget.__terminalPtyDataInjection?.inject(targetPaneKey, payload)) { throw new Error('PTY injector unavailable') } + if (heldWrites.length === 0) { + throw new Error('Foreground terminal write was not captured') + } const textarea = pane.container.querySelector('.xterm-helper-textarea') if (!textarea) { throw new Error('xterm helper textarea unavailable') } textarea.focus() - const event = new KeyboardEvent('keydown', { - bubbles: true, - cancelable: true, - key: 'x', - code: 'KeyX' - }) - Object.defineProperty(event, 'keyCode', { configurable: true, value: 88 }) - Object.defineProperty(event, 'which', { configurable: true, value: 88 }) - textarea.dispatchEvent(event) - } finally { - terminal.write = originalWrite + } catch (error) { + injectionTarget.__releaseScrollIntentTestWrite() + throw error } - const heldWrite = holder.write - if (!heldWrite) { - throw new Error('Foreground terminal write was not captured') - } - originalWrite.call(terminal, heldWrite.data, heldWrite.callback) }, paneKey) + try { + await page.keyboard.press('x') + } finally { + await page.evaluate(() => { + ;( + window as Window & { __releaseScrollIntentTestWrite?: () => void } + ).__releaseScrollIntentTestWrite?.() + }) + } } async function startStreamingFixturePhase1(page: Page): Promise { @@ -308,5 +315,6 @@ test.describe('terminal scroll intent keeps following output', () => { { timeout: 5_000, intervals: [25] } ) .toBe(0) + await waitForMarkerAtBottom(orcaPage, 'STREAM_PHASE2_DONE') }) }) diff --git a/tests/e2e/terminal-send-agent-prompt-submit.spec.ts b/tests/e2e/terminal-send-agent-prompt-submit.spec.ts index 3567cb1d8a2..c1a602dd9d1 100644 --- a/tests/e2e/terminal-send-agent-prompt-submit.spec.ts +++ b/tests/e2e/terminal-send-agent-prompt-submit.spec.ts @@ -58,6 +58,7 @@ async function createFakeCodexTerminal( if (!worktree) { throw new Error(`runtime did not register ${testRepoPath}`) } + rmSync(fixtureReport, { force: true }) const created = await client.call<{ terminal: { handle: string } }>('terminal.create', { worktree: `id:${worktree.id}`, command: [fakeCodexCommand, ...args].join(' '), diff --git a/tests/tools/repro-terminal-send-submit.mjs b/tests/tools/repro-terminal-send-submit.mjs index 7e1c0152012..41d2464cbbc 100644 --- a/tests/tools/repro-terminal-send-submit.mjs +++ b/tests/tools/repro-terminal-send-submit.mjs @@ -186,7 +186,9 @@ async function parentMain() { const expectBlocked = hasFlag('expect-blocked') const providedHandle = argValue('terminal') await mkdir(tempDir, { recursive: true }) - await rm(reportPath, { force: true }) + if (!providedHandle) { + await rm(reportPath, { force: true }) + } let handle = providedHandle if (!handle) { From ab8e10e298df8be5b3294553d593a1420349c662 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 5 Sep 2026 16:43:41 -0700 Subject: [PATCH 22/23] test: isolate skill cloud fixture ports across workers (#18942) --- .../e2e/helpers/remote-skill-cloud-fixture.ts | 41 ++++++++++++------- .../remote-skill-cloud-fixture.unit.test.ts | 41 +++++++++++++++++++ tests/e2e/paired-skill-installation.spec.ts | 8 ++-- tests/e2e/ssh-skill-installation.spec.ts | 21 ++++++---- 4 files changed, 85 insertions(+), 26 deletions(-) create mode 100644 tests/e2e/helpers/remote-skill-cloud-fixture.unit.test.ts diff --git a/tests/e2e/helpers/remote-skill-cloud-fixture.ts b/tests/e2e/helpers/remote-skill-cloud-fixture.ts index f1d28d926b7..8e1650947dd 100644 --- a/tests/e2e/helpers/remote-skill-cloud-fixture.ts +++ b/tests/e2e/helpers/remote-skill-cloud-fixture.ts @@ -8,13 +8,12 @@ import { } from '../../../src/main/skills/skill-package-creation' import { SKILL_PACKAGE_CONTENT_TYPE } from '../../../src/shared/skill-package-manifest' -export const REMOTE_SKILL_CLOUD_PORT = Number(process.env.ORCA_E2E_SKILL_CLOUD_PORT ?? '43961') -export const REMOTE_SKILL_CLOUD_ORIGIN = `http://127.0.0.1:${REMOTE_SKILL_CLOUD_PORT}` export const REMOTE_SKILL_PACKAGE_ID = 'package_remote_e2e' export const REMOTE_SKILL_VERSION_ID = 'version_remote_e2e' export const REMOTE_SKILL_NAME = 'remote-e2e-skill' export type RemoteSkillCloudFixture = { + origin: string archive: CreatedSkillPackage bytes: Buffer requests: { method: string; path: string; body: unknown }[] @@ -39,19 +38,30 @@ export async function startRemoteSkillCloudFixture(): Promise { - void handleRemoteSkillCloudRequest({ request, response, archive, bytes, requests }).catch( - (error) => { - response.writeHead(500, { 'content-type': 'application/json' }) - response.end(JSON.stringify({ code: 'fixture_failed', message: String(error) })) - } - ) + void handleRemoteSkillCloudRequest({ + request, + response, + archive, + bytes, + requests, + origin + }).catch((error) => { + response.writeHead(500, { 'content-type': 'application/json' }) + response.end(JSON.stringify({ code: 'fixture_failed', message: String(error) })) + }) }) await new Promise((resolve, reject) => { server.once('error', reject) - server.listen(REMOTE_SKILL_CLOUD_PORT, '127.0.0.1', resolve) + server.listen(Number(process.env.ORCA_E2E_SKILL_CLOUD_PORT ?? 0), '127.0.0.1', resolve) }) - return { archive, bytes, requests, root, server } + const address = server.address() + if (!address || typeof address === 'string') { + throw new Error('Skill fixture has no TCP address') + } + origin = `http://127.0.0.1:${address.port}` + return { archive, bytes, requests, root, server, origin } } export async function stopRemoteSkillCloudFixture(fixture: RemoteSkillCloudFixture): Promise { @@ -60,13 +70,14 @@ export async function stopRemoteSkillCloudFixture(fixture: RemoteSkillCloudFixtu } async function handleRemoteSkillCloudRequest(input: { + origin: string request: IncomingMessage response: ServerResponse archive: CreatedSkillPackage bytes: Buffer requests: RemoteSkillCloudFixture['requests'] }): Promise { - const path = new URL(input.request.url ?? '/', REMOTE_SKILL_CLOUD_ORIGIN).pathname + const path = new URL(input.request.url ?? '/', input.origin).pathname if (input.request.method === 'GET' && path === '/package.tar.gz') { input.requests.push({ method: 'GET', path, body: null }) input.response.writeHead(200, { @@ -84,17 +95,19 @@ async function handleRemoteSkillCloudRequest(input: { const body = JSON.parse(await readRequestBody(input.request)) as unknown input.requests.push({ method: 'POST', path, body }) input.response.writeHead(200, { 'content-type': 'application/json' }) - input.response.end(JSON.stringify(downloadGrant(input.archive, input.bytes.length))) + input.response.end( + JSON.stringify(downloadGrant(input.archive, input.bytes.length, input.origin)) + ) return } input.response.writeHead(404, { 'content-type': 'application/json' }) input.response.end(JSON.stringify({ code: 'not_found', message: 'Not found' })) } -function downloadGrant(archive: CreatedSkillPackage, compressedBytes: number) { +function downloadGrant(archive: CreatedSkillPackage, compressedBytes: number, origin: string) { return { grant: { - url: `${REMOTE_SKILL_CLOUD_ORIGIN}/package.tar.gz`, + url: `${origin}/package.tar.gz`, expiresAt: '2099-01-01T00:00:00.000Z' }, version: { diff --git a/tests/e2e/helpers/remote-skill-cloud-fixture.unit.test.ts b/tests/e2e/helpers/remote-skill-cloud-fixture.unit.test.ts new file mode 100644 index 00000000000..70291cf0e8f --- /dev/null +++ b/tests/e2e/helpers/remote-skill-cloud-fixture.unit.test.ts @@ -0,0 +1,41 @@ +import { expect, it, vi } from 'vitest' +import { + REMOTE_SKILL_PACKAGE_ID, + REMOTE_SKILL_VERSION_ID, + startRemoteSkillCloudFixture, + stopRemoteSkillCloudFixture +} from './remote-skill-cloud-fixture' + +it('serves concurrent skill fixtures from independent bound origins', async () => { + vi.stubEnv('ORCA_E2E_SKILL_CLOUD_PORT', undefined) + const results = await Promise.allSettled([ + startRemoteSkillCloudFixture(), + startRemoteSkillCloudFixture() + ]) + const fixtures = results.flatMap((result) => + result.status === 'fulfilled' ? [result.value] : [] + ) + try { + expect(results.every((result) => result.status === 'fulfilled')).toBe(true) + expect(new Set(fixtures.map((fixture) => fixture.origin)).size).toBe(2) + for (const fixture of fixtures) { + const response = await fetch( + `${fixture.origin}/v1/skill-packages/${REMOTE_SKILL_PACKAGE_ID}/versions/${REMOTE_SKILL_VERSION_ID}/download-grants`, + { + method: 'POST', + body: '{}', + headers: { 'content-type': 'application/json' } + } + ) + expect(response.status).toBe(200) + const result = (await response.json()) as { grant: { url: string } } + expect(result.grant.url).toBe(`${fixture.origin}/package.tar.gz`) + const archive = await fetch(result.grant.url) + expect(Buffer.from(await archive.arrayBuffer())).toEqual(fixture.bytes) + expect(fixture.requests).toHaveLength(2) + } + } finally { + await Promise.all(fixtures.map(stopRemoteSkillCloudFixture)) + vi.unstubAllEnvs() + } +}) diff --git a/tests/e2e/paired-skill-installation.spec.ts b/tests/e2e/paired-skill-installation.spec.ts index ac31a81e2f1..0268dbb84a3 100644 --- a/tests/e2e/paired-skill-installation.spec.ts +++ b/tests/e2e/paired-skill-installation.spec.ts @@ -14,7 +14,6 @@ import { type HeadlessPairedRuntimeHost } from './helpers/headless-paired-runtime-host' import { - REMOTE_SKILL_CLOUD_ORIGIN, REMOTE_SKILL_NAME, REMOTE_SKILL_PACKAGE_ID, REMOTE_SKILL_VERSION_ID, @@ -119,13 +118,14 @@ test('installs on a headless serve runtime through the same contract', async ({ }) function cloudClientEnvironment(): Record { + const { origin } = requireCloudFixture() return { - ORCA_ARTIFACTS_API_URL: REMOTE_SKILL_CLOUD_ORIGIN, - ORCA_CLOUD_API_URL: REMOTE_SKILL_CLOUD_ORIGIN, + ORCA_ARTIFACTS_API_URL: origin, + ORCA_CLOUD_API_URL: origin, ORCA_CLOUD_CLIENT_ID: 'skills-e2e-client', ORCA_CLOUD_DEV_AUTH: '1', ORCA_CLOUD_ALLOW_PLAINTEXT_SESSION: '1', - ORCA_SKILL_PACKAGE_DOWNLOAD_ORIGINS: REMOTE_SKILL_CLOUD_ORIGIN + ORCA_SKILL_PACKAGE_DOWNLOAD_ORIGINS: origin } } diff --git a/tests/e2e/ssh-skill-installation.spec.ts b/tests/e2e/ssh-skill-installation.spec.ts index a102478fb62..794883b2cd1 100644 --- a/tests/e2e/ssh-skill-installation.spec.ts +++ b/tests/e2e/ssh-skill-installation.spec.ts @@ -10,7 +10,6 @@ import { import { connectDockerSshRelayTarget } from './helpers/docker-ssh-relay-connection' import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' import { - REMOTE_SKILL_CLOUD_ORIGIN, REMOTE_SKILL_NAME, REMOTE_SKILL_PACKAGE_ID, REMOTE_SKILL_VERSION_ID, @@ -25,13 +24,19 @@ const REMOTE_FOLDER = '/tmp/orca-skill-folder-workspace' let cloud: RemoteSkillCloudFixture | null = null test.use({ - orcaAppExtraEnv: { - ORCA_ARTIFACTS_API_URL: REMOTE_SKILL_CLOUD_ORIGIN, - ORCA_CLOUD_API_URL: REMOTE_SKILL_CLOUD_ORIGIN, - ORCA_CLOUD_CLIENT_ID: 'skills-e2e-client', - ORCA_CLOUD_DEV_AUTH: '1', - ORCA_CLOUD_ALLOW_PLAINTEXT_SESSION: '1', - ORCA_SKILL_PACKAGE_DOWNLOAD_ORIGINS: REMOTE_SKILL_CLOUD_ORIGIN + // oxlint-disable-next-line no-empty-pattern -- The server starts in beforeAll before this test fixture runs. + orcaAppExtraEnv: async ({}, provideEnv) => { + if (!cloud) { + throw new Error('Skill cloud fixture unavailable') + } + await provideEnv({ + ORCA_ARTIFACTS_API_URL: cloud.origin, + ORCA_CLOUD_API_URL: cloud.origin, + ORCA_CLOUD_CLIENT_ID: 'skills-e2e-client', + ORCA_CLOUD_DEV_AUTH: '1', + ORCA_CLOUD_ALLOW_PLAINTEXT_SESSION: '1', + ORCA_SKILL_PACKAGE_DOWNLOAD_ORIGINS: cloud.origin + }) } }) From 22a7bfd3804717898e82b30ddbf880c5511b8c5f Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 5 Sep 2026 16:45:48 -0700 Subject: [PATCH 23/23] test: align Source Control AI fixtures with current settings (#18941) --- tests/e2e/helpers/source-control-ai-generation.ts | 15 +++++++++++++-- tests/e2e/helpers/source-control-ai-generators.ts | 6 +++--- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/tests/e2e/helpers/source-control-ai-generation.ts b/tests/e2e/helpers/source-control-ai-generation.ts index f6be16d7342..c93a20e37a1 100644 --- a/tests/e2e/helpers/source-control-ai-generation.ts +++ b/tests/e2e/helpers/source-control-ai-generation.ts @@ -67,7 +67,7 @@ export async function seedCreatePrComposer(page: Page): Promise<{ prWorktreePath: string primaryBranch: string }> { - return page.evaluate(async () => { + const seeded = await page.evaluate(async () => { const store = window.__store ?? (() => { @@ -101,6 +101,7 @@ export async function seedCreatePrComposer(page: Page): Promise<{ const eligibility = { provider: 'github' as const, review: null, + reviewLookupOutcome: 'not_found' as const, canCreate: true, blockedReason: null, nextAction: null, @@ -121,7 +122,7 @@ export async function seedCreatePrComposer(page: Page): Promise<{ ...current.remoteStatusesByWorktree, [prWorktree.id]: { hasUpstream: true, - upstreamName: `origin/${branch}`, + upstreamName: primaryBranch, ahead: 0, behind: 0 } @@ -130,6 +131,10 @@ export async function seedCreatePrComposer(page: Page): Promise<{ args.branch === branch ? eligibility : { ...eligibility, canCreate: false }, fetchHostedReviewForBranch: async () => null, fetchPRForBranch: async () => null, + enqueueGitHubPRRefresh: () => undefined, + // Ignore provider work queued before this generation-only fixture was installed. + getEffectiveGitHubPRRefreshState: () => undefined, + prRefreshStates: {}, fetchUpstreamStatus: async () => undefined, setUpstreamStatus: () => undefined })) @@ -141,6 +146,12 @@ export async function seedCreatePrComposer(page: Page): Promise<{ primaryBranch } }) + // Checks reads fresh Git state instead of the seeded store cache. + execFileSync('git', ['branch', '--set-upstream-to', seeded.primaryBranch], { + cwd: seeded.prWorktreePath, + stdio: 'pipe' + }) + return seeded } export async function seedCommitMessageComposer(page: Page): Promise<{ diff --git a/tests/e2e/helpers/source-control-ai-generators.ts b/tests/e2e/helpers/source-control-ai-generators.ts index be3f1b43247..8c09bb8b556 100644 --- a/tests/e2e/helpers/source-control-ai-generators.ts +++ b/tests/e2e/helpers/source-control-ai-generators.ts @@ -14,13 +14,13 @@ async function setCustomGenerator(page: Page, scriptPath: string): Promise } await store.getState().updateSettings({ activeRuntimeEnvironmentId: null, - commitMessageAi: { - ...currentSettings.commitMessageAi, + sourceControlAi: { enabled: true, agentId: 'custom' as const, selectedModelByAgent: {}, selectedThinkingByModel: {}, - customPrompt: '', + instructionsByOperation: {}, + actions: {}, customAgentCommand: `node ${JSON.stringify(scriptPath)}` } })