From 520033b0e12a752036b40dd4cf53dac47ba2fd3b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 7 Oct 2026 03:35:08 -0700 Subject: [PATCH 001/114] fix(native-chat): fold thoughts and tool calls between replies into one row (#26048) * fix(native-chat): fold thoughts and tool calls between replies into one row A model that thinks before every tool call drew a 'Thought for Ns' row between each call, and each thought also stopped the calls from merging, so a turn read as dozens of alternating rows. The transcript now draws an unbroken stretch of tool calls and thoughts within a turn as one tool run; thoughts read in order inside it when opened and are not counted in its label. * fix(native-chat): keep work runs whole while a turn edits files A live turn that had edited a file kept splitting its newest call or thought off the run (the row carrying the turn's diff rollup could not join), so a lone "Thought for Ns" row came back at the bottom. - Run boundaries are now one pure pass in src/shared (nativeChatWorkRunSpans), applied over the built transcript slots. Section heads end runs by sitting between rows, and rows inside an expanded subagent section are grouped the same way. - The row carrying the turn's diff rollup joins as the run's last member; the run takes the rollup from it and the bar from its head. - A collapsed run reserves one tool row plus a lead's words, not every thought's text. - Lone rows and runs draw through the same MessageRow, with one flat keyed line list, so a row becoming a run no longer remounts (a revealed diff card no longer re-fires its scroll). - A run opens by default while the reader holds one of its thoughts open; the run's own choice still wins. - A drawn thought with no visible text joins a run instead of splitting it. * fix(native-chat): pair each row's result with its own call; keep open thoughts out of runs - A structured journal row's call and result now share one id (the provider's call id, else the row's own id), so a diff or output can no longer pair with an earlier unanswered call in the same work run or folded message. A quiet command followed by edits now shows each diff card under its own call, and the rollup's reveal lands on it. - A thought still being thought, while its turn or subagent works, keeps its own row after the run and joins once it ends. - Removed the rule that opened a run because a thought inside it was open; a thought keeps its own open state inside its run. * fix(native-chat): an open thought the live line lets go of joins its run Only a subagent section keeps a still-running thought out of its run. At the top level the live line owns the open thought; when it lets go (a Stop in flight, a prompt waiting on the reader) the thought now joins the run at once instead of flashing as its own row until the turn ends. * test(native-chat): guard the run row memo; name the slot post-pass test after it Settled work runs must not rebuild their lines and thoughts on every stream frame of the live run. Renames the slot-level run test so it no longer shares a name with the shared span test. --- ...tiveChatMessageList.work-run-memo.test.tsx | 126 +++++++ .../NativeChatMessageList.work-runs.test.tsx | 154 +++++++++ .../native-chat/NativeChatMessageRow.tsx | 102 ++++-- .../NativeChatMessageRow.work-run.test.tsx | 91 ++++++ .../native-chat/NativeChatToolRun.tsx | 23 +- .../native-chat/NativeChatTranscriptRow.tsx | 7 +- .../NativeChatTranscriptRow.work-run.test.tsx | 99 ++++++ .../native-chat-row-height-estimate.ts | 15 + ...-chat-subagent-handoff-copy-budget.test.ts | 5 +- ...ve-chat-subagent-section-work-runs.test.ts | 157 +++++++++ .../native-chat-transcript-slots.test.ts | 14 +- .../native-chat-transcript-slots.ts | 17 +- .../native-chat-transcript-work-runs.test.ts | 309 ++++++++++++++++++ .../native-chat-transcript-work-runs.ts | 100 ++++++ .../native-chat-work-run-pairing.test.ts | 110 +++++++ .../native-chat/use-native-chat-work-run.tsx | 71 ++++ src/shared/native-chat-work-run.test.ts | 54 +++ src/shared/native-chat-work-run.ts | 148 +++++++++ .../structured-agent-session-live-turn.ts | 15 +- ...tructured-agent-session-projection.test.ts | 10 +- .../structured-agent-session-projection.ts | 9 +- ...tructured-agent-session-tool-call-block.ts | 10 +- 22 files changed, 1573 insertions(+), 73 deletions(-) create mode 100644 src/renderer/src/components/native-chat/NativeChatMessageList.work-run-memo.test.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatMessageList.work-runs.test.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatMessageRow.work-run.test.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatTranscriptRow.work-run.test.tsx create mode 100644 src/renderer/src/components/native-chat/native-chat-subagent-section-work-runs.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-transcript-work-runs.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-transcript-work-runs.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-work-run-pairing.test.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-work-run.tsx create mode 100644 src/shared/native-chat-work-run.test.ts create mode 100644 src/shared/native-chat-work-run.ts diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.work-run-memo.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.work-run-memo.test.tsx new file mode 100644 index 000000000000..f73e96dfc1a3 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.work-run-memo.test.tsx @@ -0,0 +1,126 @@ +// @vitest-environment happy-dom + +import { cleanup, render } from '@testing-library/react' +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import type * as NativeChatWorkRunModule from '../../../../shared/native-chat-work-run' +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import type { NativeChatLiveSession } from './use-native-chat-live-session' +import { installNativeChatMessageListTestViewport } from './native-chat-message-list-test-viewport' + +// Counts each run rebuilding its lines and thoughts: a rebuilt transcript hands every run a fresh +// member list, and only the row's memo keeps a settled run from redoing that work every frame. +const entriesCalls = vi.hoisted(() => ({ count: 0 })) +vi.mock('../../../../shared/native-chat-work-run', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + nativeChatWorkRunEntries: (...args: Parameters) => { + entriesCalls.count += 1 + return actual.nativeChatWorkRunEntries(...args) + } + } +}) + +const { NativeChatMessageList } = await import('./NativeChatMessageList') + +let restoreViewport = (): void => {} +beforeAll(() => { + restoreViewport = installNativeChatMessageListTestViewport() +}) +afterAll(() => restoreViewport()) +afterEach(cleanup) + +let clock = 0 +function row( + id: string, + role: NativeChatMessage['role'], + blocks: NativeChatMessage['blocks'], + extra: Partial = {} +): NativeChatMessage { + clock += 1 + return { id, role, blocks, timestamp: clock, source: 'transcript', ...extra } +} +const thought = (id: string) => + row(id, 'reasoning', [{ type: 'text', text: `thinking ${id}` }], { + state: 'completed', + completedAt: clock + 1 + }) +const command = (id: string, output: string) => + row(id, 'assistant', [ + { type: 'tool-call', name: 'Bash', input: { command: id }, state: 'completed', callId: id }, + { type: 'tool-result', output, callId: id } + ]) + +const prompt = row('user-1', 'user', [{ type: 'text', text: 'go' }]) +// Three settled runs, each closed by a reply, then the live run. +const settled = [1, 2, 3].flatMap((turn) => [ + thought(`r${turn}a`), + command(`c${turn}a`, 'ok'), + thought(`r${turn}b`), + command(`c${turn}b`, 'ok'), + row(`reply-${turn}`, 'assistant', [{ type: 'text', text: `Step ${turn} done.` }]) +]) +const liveHead = [thought('r4'), command('c4', 'ok')] + +function journal(rows: readonly NativeChatMessage[]): AgentJournalRenderItem[] { + return [ + { + itemId: prompt.id, + revision: 1, + sequence: 1, + observedAt: 1, + body: { kind: 'message', role: 'user', blocks: prompt.blocks } + }, + { + itemId: 'turn-1', + revision: 1, + sequence: 2, + observedAt: 2, + body: { kind: 'turn', turnId: 'turn-1', state: 'running', userItemId: prompt.id } + }, + ...rows.map((message, index) => ({ + itemId: message.id, + revision: 1, + sequence: index + 3, + observedAt: index + 3, + body: { kind: 'message' as const, role: message.role, blocks: message.blocks } + })) + ] +} + +function list(tail: NativeChatMessage): React.JSX.Element { + const rows = [...settled, ...liveHead, tail] + const session: NativeChatLiveSession = { + messages: [prompt, ...rows], + status: 'working', + sessionId: 'session-1', + agent: 'codex', + hasMore: false, + loadingEarlier: false, + olderHistoryGeneration: 0, + loadEarlier: vi.fn(), + readPhase: 'ready' + } + return ( + + ) +} + +describe('settled work runs while the live run streams', () => { + it('rebuilds only the live run on each frame', () => { + const tail = (frame: number) => ({ ...command('c5', `line ${frame}`), id: 'c5' }) + const { rerender, container } = render(list(tail(0))) + expect(container.querySelectorAll('[data-native-chat-tool-run-state]')).toHaveLength(4) + entriesCalls.count = 0 + for (let frame = 1; frame <= 5; frame += 1) { + rerender(list(tail(frame))) + } + expect(entriesCalls.count).toBe(5) + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.work-runs.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.work-runs.test.tsx new file mode 100644 index 000000000000..bf5cfdb8bb4f --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.work-runs.test.tsx @@ -0,0 +1,154 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' + +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import type { NativeChatLiveSession } from './use-native-chat-live-session' +import { NativeChatMessageList } from './NativeChatMessageList' +import { installNativeChatMessageListTestViewport } from './native-chat-message-list-test-viewport' +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' + +let restoreViewport = (): void => {} +beforeAll(() => { + restoreViewport = installNativeChatMessageListTestViewport() +}) +afterAll(() => restoreViewport()) +afterEach(cleanup) + +const STARTED = 1_000 + +const prompt: NativeChatMessage = { + id: 'user-1', + role: 'user', + blocks: [{ type: 'text', text: 'Start the task' }], + timestamp: STARTED - 500, + source: 'transcript' +} + +const command: NativeChatMessage = { + id: 'tool-1', + role: 'assistant', + blocks: [{ type: 'tool-call', name: 'Bash', input: { command: 'ls logs' }, state: 'completed' }], + timestamp: STARTED, + source: 'transcript' +} + +function reasoning(state: 'running' | 'completed'): NativeChatMessage { + return { + id: 'r-1', + role: 'reasoning', + blocks: [{ type: 'text', text: 'Weighing two approaches' }], + timestamp: STARTED + 50, + source: 'transcript', + state, + ...(state === 'completed' ? { completedAt: STARTED + 12_000 } : {}) + } +} + +/** The journal that says the turn runs and what its newest content is. */ +function journal(rows: readonly NativeChatMessage[]): AgentJournalRenderItem[] { + return [ + { + itemId: prompt.id, + revision: 1, + sequence: 1, + observedAt: 1, + body: { kind: 'message', role: 'user', blocks: prompt.blocks } + }, + { + itemId: 'turn-1', + revision: 1, + sequence: 2, + observedAt: 2, + body: { kind: 'turn', turnId: 'turn-1', state: 'running', userItemId: prompt.id } + }, + ...rows.map((row, index) => ({ + itemId: row.id, + revision: 1, + sequence: index + 3, + observedAt: index + 3, + body: { + kind: 'message' as const, + role: row.role, + blocks: row.blocks, + ...(row.state ? { state: row.state } : {}) + } + })) + ] +} + +function list( + rows: readonly NativeChatMessage[], + props: Partial> = {} +): React.JSX.Element { + const session: NativeChatLiveSession = { + messages: [prompt, ...rows], + status: 'working', + sessionId: 'session-1', + agent: 'claude', + hasMore: false, + loadingEarlier: false, + olderHistoryGeneration: 0, + loadEarlier: vi.fn(), + readPhase: 'ready' + } + return ( + + ) +} + +const runHeader = (): HTMLElement => + document.querySelector('[data-native-chat-tool-run-state]')! + +describe('a thought joining a work run', () => { + it('joins collapsed when the reader never opened it', () => { + render(list([command, reasoning('completed')])) + expect(runHeader()).toHaveAttribute('aria-expanded', 'false') + expect(screen.queryByText('Weighing two approaches')).toBeNull() + }) + + // The run never opens for a thought; the thought keeps its own open state inside it. + it('keeps a thought the reader opened live open inside its collapsed run', () => { + const { rerender } = render(list([command, reasoning('running')])) + fireEvent.click(screen.getByRole('button', { name: 'Thinking' })) + expect(screen.getByText('Weighing two approaches')).toBeInTheDocument() + + rerender(list([command, reasoning('completed')])) + expect(runHeader()).toHaveAttribute('aria-expanded', 'false') + expect(screen.queryByText('Weighing two approaches')).toBeNull() + + fireEvent.click(runHeader()) + expect(screen.getByRole('button', { name: /Thought for/ })).toHaveAttribute( + 'aria-expanded', + 'true' + ) + expect(screen.getByText('Weighing two approaches')).toBeInTheDocument() + }) + + // While the live line is not showing it (a Stop in flight, a prompt the reader owes), the open + // thought sits in the run rather than flashing as its own row until the turn ends. + it.each([ + ['a Stop is in flight', { stopping: true }], + ['a prompt waits on the reader', { awaitingInput: 'unshown' as const }] + ])('keeps an open thought inside the run while %s', (_, props) => { + const second: NativeChatMessage = { + ...command, + id: 'tool-2', + blocks: [{ type: 'tool-call', name: 'Bash', input: { command: 'ls' }, state: 'completed' }], + timestamp: STARTED + 100 + } + const open = { ...reasoning('running'), id: 'r-2', timestamp: STARTED + 200 } + render(list([command, reasoning('completed'), second, open], props)) + expect(screen.queryByRole('button', { name: /Reasoning|Thought/ })).toBeNull() + fireEvent.click(runHeader()) + expect(screen.getAllByRole('button', { name: /Reasoning|Thought/ })).toHaveLength(2) + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx index 7c2418cf6f79..9705cc64ceaa 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx @@ -31,6 +31,7 @@ import type { NativeChatSubagentRosterState } from './native-chat-subagent-sections' import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' +import { useNativeChatWorkRun } from './use-native-chat-work-run' /** What a user message says about its delivery: that nothing has confirmed it yet, quietly in * place of its time, or that it did not go through, with its own Retry when the surface can send @@ -78,29 +79,7 @@ function UserMessageMeta({ ) } -/** One message: its prose first, then a collapsible run folding all of the - * turn's tool activity. Monochrome per STYLEGUIDE: user prompts read as a - * lifted card, assistant prose as body copy, reasoning de-emphasized. - * Memoized: a stream frame republishes the whole transcript, but settled rows - * keep their block identity, so only the changed row re-renders. */ -export const MessageRow = memo(function MessageRow({ - message, - previousTodoWrite, - previousUpdatePlan, - revealedDiff, - expandSignal, - activeTurnIsWorking, - trailingRun, - onScrollMessageToTop, - onLinkClick, - allowFileUriLinks = false, - deliveryNotice, - subagentRoster, - subagentDisclosure, - inSubagentSection = false, - runtimeContext, - rewind -}: { +type MessageRowProps = { message: NativeChatMessage previousTodoWrite?: NativeChatToolCallBlock previousUpdatePlan?: NativeChatToolCallBlock @@ -122,7 +101,35 @@ export const MessageRow = memo(function MessageRow({ runtimeContext?: RuntimeFileOperationArgs | null /** On a user row: discards it and everything after it. */ rewind?: NativeChatRewindSurface -}): React.JSX.Element | null { + /** This row draws a run of tool calls and thoughts: every message in it, `message` first. + * The same row either way, so a row becoming a run keeps everything it has mounted. */ + workRun?: readonly NativeChatMessage[] +} + +/** One message: its prose first, then a collapsible run folding all of the + * turn's tool activity. Monochrome per STYLEGUIDE: user prompts read as a + * lifted card, assistant prose as body copy, reasoning de-emphasized. + * Memoized: a stream frame republishes the whole transcript, but settled rows + * keep their block identity, so only the changed row re-renders. */ +export const MessageRow = memo(function MessageRow({ + message, + previousTodoWrite, + previousUpdatePlan, + revealedDiff, + expandSignal, + activeTurnIsWorking, + trailingRun, + onScrollMessageToTop, + onLinkClick, + allowFileUriLinks = false, + deliveryNotice, + subagentRoster, + subagentDisclosure, + inSubagentSection = false, + runtimeContext, + rewind, + workRun +}: MessageRowProps): React.JSX.Element | null { const rowRef = useRef(null) const blocks = nativeChatBlocksInOwnWords(message.blocks) // One pass per block set, shared with the list that decides whether this row @@ -133,6 +140,12 @@ export const MessageRow = memo(function MessageRow({ const isReasoning = message.role === 'reasoning' const isSystem = message.role === 'system' const providerFrame = blocks.find((block) => block.type === 'text' && block.providerFrame) + const run = useNativeChatWorkRun(workRun, { + revealedDiff, + activeTurnIsWorking, + onLinkClick, + allowFileUriLinks + }) const scrollToTop = useCallback(() => { if (rowRef.current) { @@ -144,6 +157,7 @@ export const MessageRow = memo(function MessageRow({ // bubble. // After all hooks, so hook order stays unconditional. if ( + run === undefined && markdown.length === 0 && !hasImages && tools.length === 0 && @@ -262,7 +276,7 @@ export const MessageRow = memo(function MessageRow({ ) } - if (isReasoning) { + if (isReasoning && run === undefined) { return (
0 + const showControls = !isSystem && words.length > 0 return (
@@ -296,9 +310,9 @@ export const MessageRow = memo(function MessageRow({ runtimeContext={runtimeContext} enablePreview={runtimeContext !== undefined} /> - {markdown ? ( + {words ? ( ) : null} - {tools.length > 0 || subagentGroups.length > 0 || backgroundTasks.length > 0 ? ( + {run || tools.length > 0 || subagentGroups.length > 0 || backgroundTasks.length > 0 ? ( ) : null} {showControls ? ( ) -}) +}, sameMessageRowProps) + +/** A rebuilt transcript hands every run a fresh member list; same messages, same row. */ +function sameMessageRowProps(previous: MessageRowProps, next: MessageRowProps): boolean { + const { workRun: previousRun, ...previousRest } = previous + const { workRun: nextRun, ...nextRest } = next + const nextProps = new Map(Object.entries(nextRest)) + return ( + Object.keys(previousRest).length === nextProps.size && + Object.entries(previousRest).every( + ([key, value]) => nextProps.has(key) && Object.is(value, nextProps.get(key)) + ) && + previousRun?.length === nextRun?.length && + (previousRun ?? []).every((member, index) => member === nextRun?.[index]) + ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatMessageRow.work-run.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageRow.work-run.test.tsx new file mode 100644 index 000000000000..7452b7a9ed68 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatMessageRow.work-run.test.tsx @@ -0,0 +1,91 @@ +// @vitest-environment happy-dom +import '@testing-library/jest-dom/vitest' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { MessageRow } from './NativeChatMessageRow' + +afterEach(() => { + cleanup() +}) + +function thought(id: string, body: string): NativeChatMessage { + return { + id, + role: 'reasoning', + blocks: [{ type: 'text', text: body }], + timestamp: 1000, + completedAt: 4000, + state: 'completed', + source: 'transcript' + } +} + +function command(id: string, cmd: string): NativeChatMessage { + return { + id, + role: 'assistant', + blocks: [ + { type: 'tool-call', name: 'Bash', input: { command: cmd }, state: 'completed', callId: id }, + { type: 'tool-result', output: 'ok', callId: id } + ], + timestamp: 1, + source: 'transcript' + } +} + +describe('message row drawing a work run', () => { + it('speaks for its calls alone, and reads its thoughts in order once opened', () => { + const first = thought('r1', 'Check the log first.') + const { container } = render( + {}} + /> + ) + const header = container.querySelector('button')! + expect(header).toHaveTextContent('Ran 2 commands') + expect(screen.queryByText(/Thought/)).not.toBeInTheDocument() + + fireEvent.click(header) + const order = Array.from( + container.querySelectorAll('button'), + (button) => button.textContent ?? '' + ).slice(1) + const at = (pattern: RegExp) => order.findIndex((label) => pattern.test(label)) + expect(order.filter((label) => /Thought for 3s/.test(label))).toHaveLength(3) + expect(at(/ls logs/)).toBeGreaterThan(at(/Thought for 3s/)) + expect(at(/rg error logs/)).toBeGreaterThan(at(/ls logs/)) + expect(order.at(-1)).toMatch(/Thought for 3s/) + }) + + it("draws a run headed by the agent's words under them", () => { + const head: NativeChatMessage = { + ...command('a', 'ls logs'), + blocks: [{ type: 'text', text: 'Looking at the logs.' }, ...command('a', 'ls logs').blocks] + } + const { container } = render( + {}} + /> + ) + expect(screen.getByText('Looking at the logs.')).toBeInTheDocument() + const header = container.querySelector('[data-native-chat-tool-run-state]')! + expect(header).toHaveTextContent('Ran 2 commands') + fireEvent.click(header) + expect(screen.getByText(/Thought for 3s/)).toBeInTheDocument() + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatToolRun.tsx b/src/renderer/src/components/native-chat/NativeChatToolRun.tsx index cc5bbbe42bb7..723aa5cc6949 100644 --- a/src/renderer/src/components/native-chat/NativeChatToolRun.tsx +++ b/src/renderer/src/components/native-chat/NativeChatToolRun.tsx @@ -47,6 +47,13 @@ import type { import { NativeChatToolRunIcon } from './NativeChatToolIcon' import { NativeChatToolRunCallCounts } from './NativeChatToolRunCallCounts' +/** Rows drawn among an open run's lines, each keyed: before the block they are keyed by, or + * after the last. */ +export type NativeChatToolRunAsides = { + before: ReadonlyMap + after: readonly React.JSX.Element[] +} + /** Stable empty default: a fresh array literal per render breaks memoization. */ const NO_SUBAGENT_GROUPS: NativeChatSubagentGroupBlock[] = [] const NO_BACKGROUND_TASKS: NativeChatBackgroundTaskBlock[] = [] @@ -67,7 +74,8 @@ export function NativeChatToolRun({ trailing, expandOverride, disclosureId, - onLinkClick + onLinkClick, + asides }: { blocks: NativeChatBlock[] previousTodoWrite?: NativeChatToolCallBlock @@ -95,6 +103,7 @@ export function NativeChatToolRun({ * opened has to be remembered somewhere that outlives the row. */ disclosureId?: string onLinkClick?: CommentMarkdownLinkClickHandler + asides?: NativeChatToolRunAsides }): React.JSX.Element | null { // This row owns the language subscription for its tool, diff, and task labels. useTranslation() @@ -327,7 +336,7 @@ export function NativeChatToolRun({
{(() => { const seen = new Map() - return headerBlocks.map((block, blockIndex) => { + const lines = headerBlocks.map((block, blockIndex) => { const taskList = taskLists?.rows.get(block) if (taskList) { return @@ -396,6 +405,16 @@ export function NativeChatToolRun({ /> ) }) + // One flat keyed list with or without asides, so a row gaining its first never remounts a line. + return asides + ? [ + ...headerBlocks.flatMap((block, blockIndex) => [ + ...(asides.before.get(block) ?? []), + lines[blockIndex] + ]), + ...asides.after + ] + : lines })()}
) : null} diff --git a/src/renderer/src/components/native-chat/NativeChatTranscriptRow.tsx b/src/renderer/src/components/native-chat/NativeChatTranscriptRow.tsx index f1464b11b7d0..e4c7609bbc4b 100644 --- a/src/renderer/src/components/native-chat/NativeChatTranscriptRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatTranscriptRow.tsx @@ -76,7 +76,7 @@ export const NativeChatTranscriptRow = memo(function NativeChatTranscriptRow({ /> ) } - const { message, turnKey, status, receipt, turnDiff } = slot + const { message, turnKey, status, receipt, turnDiff, workRun } = slot const deliveryNotice = context.deliveryNotices?.get(message.id) const predecessors = context.taskListPredecessors.get(message.id) const expanded = turnKey ? context.expandedTurnIds.has(turnKey) : undefined @@ -103,7 +103,9 @@ export const NativeChatTranscriptRow = memo(function NativeChatTranscriptRow({ previousTodoWrite={predecessors?.todowrite} previousUpdatePlan={predecessors?.update_plan} revealedDiff={ - context.revealedDiff?.messageId === message.id ? context.revealedDiff : undefined + (workRun ?? [message]).some((member) => member.id === context.revealedDiff?.messageId) + ? (context.revealedDiff ?? undefined) + : undefined } expandSignal={context.expandSignal} activeTurnIsWorking={slot.activeTurnIsWorking} @@ -121,6 +123,7 @@ export const NativeChatTranscriptRow = memo(function NativeChatTranscriptRow({ ? rewind : undefined } + workRun={workRun} /> )} {slot.statusAbove ? null : statusRow} diff --git a/src/renderer/src/components/native-chat/NativeChatTranscriptRow.work-run.test.tsx b/src/renderer/src/components/native-chat/NativeChatTranscriptRow.work-run.test.tsx new file mode 100644 index 000000000000..902fec677d41 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatTranscriptRow.work-run.test.tsx @@ -0,0 +1,99 @@ +// @vitest-environment happy-dom +import '@testing-library/jest-dom/vitest' +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { + NativeChatTranscriptRow, + type NativeChatTranscriptRowContext +} from './NativeChatTranscriptRow' +import { + buildNativeChatTranscriptSlots, + type NativeChatTranscriptSlot +} from './native-chat-transcript-slots' + +afterEach(cleanup) + +const prompt: NativeChatMessage = { + id: 'u', + role: 'user', + blocks: [{ type: 'text', text: 'go' }], + timestamp: 1, + source: 'transcript' +} +const edit: NativeChatMessage = { + id: 'edit-1', + role: 'assistant', + blocks: [ + { type: 'tool-call', name: 'Diff', input: { path: 'a.ts' }, state: 'completed' }, + { type: 'tool-result', output: '@@ -1 +1 @@\n-old\n+new' } + ], + timestamp: 2, + source: 'transcript' +} +const thought: NativeChatMessage = { + id: 'r-1', + role: 'reasoning', + blocks: [{ type: 'text', text: 'Now run the tests.' }], + timestamp: 3, + completedAt: 4, + state: 'completed', + source: 'transcript' +} +const command: NativeChatMessage = { + id: 'b', + role: 'assistant', + blocks: [{ type: 'tool-call', name: 'Bash', input: { command: 'pnpm test' }, state: 'running' }], + timestamp: 5, + source: 'transcript' +} + +/** The slot drawing `edit-1`, as the live transcript builds it. */ +function editSlot(messages: NativeChatMessage[]): NativeChatTranscriptSlot { + const slots = buildNativeChatTranscriptSlots({ + messages, + turnKeys: messages.map(() => 'u'), + liveTurnKey: 'u', + receipts: new Map(), + turnStatuses: { active: null, completedByTurn: {} }, + turnDiffs: new Map(), + expandedTurnKeys: new Set(), + isWorking: true, + lifecycleWorking: false + }) + return slots.find((slot) => slot.kind === 'message' && slot.message.id === 'edit-1')! +} + +describe('a transcript row turning into a work run', () => { + // A row that remounted as a run re-ran its revealed diff card's reveal and scrolled the reader. + it('keeps a revealed diff card mounted, so its reveal does not fire again', () => { + const onScrollMessageToTop = vi.fn() + const context: NativeChatTranscriptRowContext = { + expandSignal: false, + revealedDiff: { messageId: 'edit-1', editKey: 'Diff:0', fileIndex: 0, requestId: 1 }, + taskListPredecessors: new Map(), + expandedTurnIds: new Set(), + allowFileUriLinks: false, + onToggleExpandedTurn: () => {}, + subagentDisclosure: { setSectionOpen: () => {}, setRosterOpen: () => {} }, + onScrollMessageToTop, + onRevealDiff: () => {} + } + const lone = editSlot([prompt, edit]) + expect(lone.kind === 'message' && lone.workRun).toBeFalsy() + const { rerender } = render() + expect(onScrollMessageToTop).toHaveBeenCalledTimes(1) + const card = screen.getByText('old').closest('div')! + + const run = editSlot([prompt, edit, thought, command]) + expect(run.kind === 'message' && run.workRun?.map((member) => member.id)).toEqual([ + 'edit-1', + 'r-1', + 'b' + ]) + rerender() + expect(screen.getByRole('button', { name: /Thought/ })).toBeDefined() + expect(card.isConnected).toBe(true) + expect(onScrollMessageToTop).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-row-height-estimate.ts b/src/renderer/src/components/native-chat/native-chat-row-height-estimate.ts index 54ebc92dec2f..adf02f0ab732 100644 --- a/src/renderer/src/components/native-chat/native-chat-row-height-estimate.ts +++ b/src/renderer/src/components/native-chat/native-chat-row-height-estimate.ts @@ -111,6 +111,21 @@ export function nativeChatRowContentMetrics( return metrics } +/** A collapsed work run: a lead head's own words over one tool-run header. Its thoughts draw + * only once it opens, which remeasures it. */ +export function nativeChatWorkRunContentMetrics( + head: NativeChatRowContentMetrics, + headIsLead: boolean +): NativeChatRowContentMetrics { + return { + role: 'assistant', + textLines: headIsLead ? head.textLines : 0, + imageCount: headIsLead ? head.imageCount : 0, + toolCount: 1, + subagentGroupCount: 0 + } +} + /** The trigger's headline is chat text, so it grows with the chat size above its `min-h-6` floor. */ function collapsedReasoningHeight(typography: NativeChatRowTypography): number { return Math.max( diff --git a/src/renderer/src/components/native-chat/native-chat-subagent-handoff-copy-budget.test.ts b/src/renderer/src/components/native-chat/native-chat-subagent-handoff-copy-budget.test.ts index 2916dd3a68ee..23b4c7a5f207 100644 --- a/src/renderer/src/components/native-chat/native-chat-subagent-handoff-copy-budget.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-subagent-handoff-copy-budget.test.ts @@ -136,7 +136,10 @@ describe('nested subagent handoff accumulation', () => { const result = countHandoffCopies(input, calls) expect([...result.live]).toEqual(['parent', 'child']) expect(input).toEqual(before) - const messages = slots(input).flatMap((slot) => (slot.kind === 'message' ? [slot.message] : [])) + // Consecutive calls draw as one work run; its members are the rows. + const messages = slots(input).flatMap((slot) => + slot.kind === 'message' ? (slot.workRun ?? [slot.message]) : [] + ) const expected = [input.roster, input.parent, input.child, ...calls] expect(messages).toEqual(expected) for (const [index, row] of messages.entries()) { diff --git a/src/renderer/src/components/native-chat/native-chat-subagent-section-work-runs.test.ts b/src/renderer/src/components/native-chat/native-chat-subagent-section-work-runs.test.ts new file mode 100644 index 000000000000..0b22eda87ec9 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-subagent-section-work-runs.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from 'vitest' +import type { + NativeChatMessage, + NativeChatSubagentState +} from '../../../../shared/native-chat-types' +import { projectNativeChatTranscript } from '../../../../shared/native-chat-transcript-projection' +import { nativeChatTurnMembership } from '../../../../shared/native-chat-turn-membership' +import { compareMessages } from './native-chat-session-assembler' +import { nativeChatSubagentSections } from './native-chat-subagent-sections' +import { + buildNativeChatTranscriptSlots, + type NativeChatTranscriptSlot +} from './native-chat-transcript-slots' + +let sequence = 0 + +function row( + id: string, + blocks: NativeChatMessage['blocks'], + overrides: Partial = {} +): NativeChatMessage { + sequence += 1 + return { + id, + role: 'assistant', + blocks, + timestamp: 1, + source: 'transcript', + journalPosition: { sequence, index: 0 }, + ...overrides + } +} + +const say = (value: string) => [{ type: 'text' as const, text: value }] +const call = (name: string) => [{ type: 'tool-call' as const, name, input: { name } }] +const by = (agentId: string) => ({ agentId, producerKind: 'agent' as const }) +const thought = (id: string, agentId?: string, state: 'running' | 'completed' = 'completed') => + row(id, say(`thinking ${id}`), { + role: 'reasoning', + state, + ...(agentId ? by(agentId) : {}) + }) +const roster = (id: string, agentId: string, state: NativeChatSubagentState) => + row( + id, + [ + { + type: 'subagent-group', + groupId: `group-${id}`, + agents: [{ id: agentId, label: agentId, state }] + } + ], + { role: 'system' } + ) + +function slotsOf(rows: NativeChatMessage[], open: Record) { + const { conversation, subagentRows } = projectNativeChatTranscript(rows, compareMessages) + const { turnKeys, liveTurnKey } = nativeChatTurnMembership(conversation) + return buildNativeChatTranscriptSlots({ + messages: conversation, + turnKeys, + liveTurnKey, + receipts: new Map(), + turnStatuses: { active: null, completedByTurn: {} }, + turnDiffs: new Map(), + expandedTurnKeys: new Set(), + isWorking: false, + lifecycleWorking: false, + subagentSections: nativeChatSubagentSections(conversation, subagentRows), + subagentChoices: { sections: new Map(Object.entries(open)), rosters: new Map() } + }) +} + +/** `>` per section a row sits in, then its id, its run's member ids, or `[agent]` for a head. */ +function outline(slots: readonly NativeChatTranscriptSlot[]): string[] { + return slots.map((slot) => { + const indent = '>'.repeat(slot.depth) + if (slot.kind === 'subagent') { + return `${indent}[${slot.agentId}]` + } + if (slot.kind === 'subagent-entries') { + return `${indent}${slot.agents.map((agent) => agent.id).join()}` + } + return `${indent}${slot.workRun?.map((member) => member.id).join('+') ?? slot.message.id}` + }) +} + +describe('work runs and subagent sections', () => { + it("draws a section's own thoughts and calls as one run", () => { + const rows = [ + row('ask', say('go'), { role: 'user' }), + row('a', call('Read')), + thought('s-r1', 'helper'), + row('s-a', call('Bash'), by('helper')), + thought('s-r2', 'helper'), + row('s-b', call('Grep'), by('helper')), + row('reply', say('Done.')) + ] + expect(outline(slotsOf(rows, { helper: true }))).toEqual([ + 'ask', + 'a', + '[helper]', + '>s-r1+s-a+s-r2+s-b', + 'reply' + ]) + }) + + // The head draws between the calls, open or not, so it ends the run they would have made. + it('never runs across a section head', () => { + const rows = [ + row('ask', say('go'), { role: 'user' }), + row('a', call('Read')), + row('s-a', call('Bash'), by('helper')), + thought('r1'), + row('c', call('Grep')) + ] + expect(outline(slotsOf(rows, { helper: false }))).toEqual(['ask', 'a', '[helper]', 'r1+c']) + expect(outline(slotsOf(rows, { helper: true }))).toEqual([ + 'ask', + 'a', + '[helper]', + '>s-a', + 'r1+c' + ]) + }) + + // Only a finished thought folds into the run: one still being thought stays in view. + it("keeps a working section's open thought out of its run until it ends", () => { + const rows = (state: NativeChatSubagentState, last: 'running' | 'completed') => { + sequence = 0 + return [ + row('ask', say('go'), { role: 'user' }), + roster('spawn', 'helper', state), + thought('s-r1', 'helper'), + row('s-a', call('Read'), by('helper')), + thought('s-r2', 'helper', last) + ] + } + expect(outline(slotsOf(rows('working', 'running'), { helper: true }))).toEqual([ + 'ask', + 'spawn', + '>s-r1+s-a', + '>s-r2' + ]) + expect(outline(slotsOf(rows('working', 'completed'), { helper: true }))).toEqual([ + 'ask', + 'spawn', + '>s-r1+s-a+s-r2' + ]) + // A thought its finished agent never closed is not still being thought. + expect(outline(slotsOf(rows('completed', 'running'), { helper: true }))).toEqual([ + 'ask', + 'spawn', + '>s-r1+s-a+s-r2' + ]) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts b/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts index b46a3b495542..63637a4146a5 100644 --- a/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts @@ -76,7 +76,8 @@ describe('transcript slots', () => { .map((slot) => slot.message.id) expect(trailing([text('u', 'go', 'user'), toolRun('a'), text('b', 'Done.')])).toEqual(['b']) - expect(trailing([text('u', 'go', 'user'), toolRun('a'), toolRun('b')])).toEqual(['b']) + // Two runs in a row draw as one, headed by the first. + expect(trailing([text('u', 'go', 'user'), toolRun('a'), toolRun('b')])).toEqual(['a']) expect( trailing([text('u', 'go', 'user'), toolRun('a'), text('r', 'hmm', 'reasoning')]) ).toEqual(['a']) @@ -500,15 +501,8 @@ describe('turn-owned grouping', () => { turnStatuses: { active: null, completedByTurn: { A: settled } }, expandedTurnKeys: new Set(['A']) }) - expect(slots.map((slot) => slot.message.id)).toEqual([ - 'A', - 't1', - 'B', - 't2', - 't3', - 't4', - 'answer' - ]) + expect(slots.map((slot) => slot.message.id)).toEqual(['A', 't1', 'B', 't2', 'answer']) + expect(slots[3]?.workRun?.map((message) => message.id)).toEqual(['t2', 't3', 't4']) }) it("anchors a provider-opened turn's bar above its first row", () => { diff --git a/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts b/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts index 5d2333ee1c68..287d0da12926 100644 --- a/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts +++ b/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts @@ -47,6 +47,7 @@ import { type NativeChatSubagentSectionSlot } from './native-chat-subagent-section-slots' import { nativeChatRowRendersProse, nativeChatRowSpeaksOrActs } from './native-chat-trailing-run' +import { nativeChatTranscriptWorkRuns } from './native-chat-transcript-work-runs' export type NativeChatTranscriptSlot = | NativeChatMessageSlot @@ -86,6 +87,9 @@ export type NativeChatMessageSlot = { depth: number /** Height to reserve before the row has ever been measured. */ estimatedHeight: number + /** Set when this row draws an unbroken stretch of tool calls and thoughts as one run: + * every message in it, `message` first. */ + workRun?: readonly NativeChatMessage[] } export type NativeChatTranscriptSlotsInput = { @@ -246,7 +250,7 @@ export function buildNativeChatTranscriptSlots( sectionSlots.openAnchoredAt(message, roster, turnKey) } sectionSlots.openBefore(pending, undefined, 0) - return slots + return nativeChatTranscriptWorkRuns(slots, typography) } /** Stable key for a slot: its message id, the agent whose section it heads, or its @@ -262,8 +266,8 @@ export function nativeChatSlotKey(slot: NativeChatTranscriptSlot): string { } } -/** Slot index of a message id, or -1. Reveal targets arrive as ids because the - * row that owns them may not be mounted to be pointed at. */ +/** Slot index of a message id, or -1, counting a work run's members as its row. Reveal + * targets arrive as ids because the row that owns them may not be mounted to be pointed at. */ export function nativeChatSlotIndexOf( slots: readonly NativeChatTranscriptSlot[], messageId: string | undefined @@ -271,7 +275,12 @@ export function nativeChatSlotIndexOf( if (messageId === undefined) { return -1 } - return slots.findIndex((slot) => slot.kind === 'message' && slot.message.id === messageId) + return slots.findIndex( + (slot) => + slot.kind === 'message' && + (slot.message.id === messageId || + slot.workRun?.some((member) => member.id === messageId) === true) + ) } /** Splits off the slots of messages waiting behind the live turn, and of ones shown as not sent diff --git a/src/renderer/src/components/native-chat/native-chat-transcript-work-runs.test.ts b/src/renderer/src/components/native-chat/native-chat-transcript-work-runs.test.ts new file mode 100644 index 000000000000..dbf1084b74bb --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-transcript-work-runs.test.ts @@ -0,0 +1,309 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import type { NativeChatResolvedPrompt } from './native-chat-resolution-receipt' +import type { NativeChatTurnDiff } from './native-chat-turn-diffs' +import type { NativeChatTurnStatus } from '../../../../shared/native-chat-turn-status' +import { + buildNativeChatTranscriptSlots, + nativeChatSlotIndexOf, + type NativeChatMessageSlot +} from './native-chat-transcript-slots' +import { + nativeChatWorkRunEditKey, + nativeChatWorkRunEntries +} from '../../../../shared/native-chat-work-run' + +function text(id: string, body: string, role: NativeChatMessage['role'] = 'assistant') { + return { + id, + role, + blocks: [{ type: 'text' as const, text: body }], + timestamp: 1, + source: 'transcript' as const + } +} + +function thought(id: string): NativeChatMessage { + return { ...text(id, `thinking ${id}`, 'reasoning'), state: 'completed', completedAt: 2 } +} + +function call(id: string, name = 'shell'): NativeChatMessage { + return { + id, + role: 'assistant', + blocks: [ + { type: 'tool-call', name, input: { command: id }, state: 'completed', callId: `c-${id}` } + ], + timestamp: 1, + source: 'transcript' + } +} + +/** Agent words with a call folded under them, as `foldToolMessages` hands them over. */ +function lead(id: string): NativeChatMessage { + return { ...call(id), blocks: [{ type: 'text', text: `${id} says` }, ...call(id).blocks] } +} + +function build( + messages: NativeChatMessage[], + overrides: Partial[0]> = {} +): NativeChatMessageSlot[] { + let turn: string | undefined + const turnKeys = messages.map((message) => { + if (message.role === 'user') { + turn = message.id + } + return turn + }) + return buildNativeChatTranscriptSlots({ + messages, + turnKeys, + liveTurnKey: undefined, + receipts: new Map(), + turnStatuses: { active: null, completedByTurn: {} }, + turnDiffs: new Map(), + expandedTurnKeys: new Set(), + isWorking: false, + lifecycleWorking: false, + ...overrides + }).filter((slot): slot is NativeChatMessageSlot => slot.kind === 'message') +} + +/** Each slot as its id, or its members' ids when it draws a work run. */ +function rows(slots: NativeChatMessageSlot[]): (string | string[])[] { + return slots.map((slot) => slot.workRun?.map((message) => message.id) ?? slot.message.id) +} + +describe('work runs', () => { + // A model that thinks before every call: one row, not one per thought and one per call. + it('draws thoughts and the calls between them as one row', () => { + const slots = build([ + text('u', 'go', 'user'), + thought('r1'), + call('a'), + thought('r2'), + call('b'), + thought('r3'), + lead('x'), + thought('r4'), + call('d'), + text('y', 'Done.') + ]) + expect(rows(slots)).toEqual(['u', ['r1', 'a', 'r2', 'b', 'r3'], ['x', 'r4', 'd'], 'y']) + }) + + // The words a call was folded under head the run, so their calls and the ones after a + // thought read as one run, not two. + it('lets the agent words that carry calls head the run, but never join one', () => { + expect( + rows( + build([ + text('u', 'go', 'user'), + lead('x'), + thought('r1'), + call('a'), + lead('y'), + thought('r2'), + text('z', 'Done.') + ]) + ) + ).toEqual(['u', ['x', 'r1', 'a'], ['y', 'r2'], 'z']) + }) + + it('leaves a thought with no call beside it as its own row', () => { + expect(rows(build([text('u', 'go', 'user'), thought('r'), text('x', 'Hi.')]))).toEqual([ + 'u', + 'r', + 'x' + ]) + expect( + rows(build([text('u', 'go', 'user'), thought('r1'), thought('r2'), text('x', 'Hi.')])) + ).toEqual(['u', 'r1', 'r2', 'x']) + }) + + it('never joins rows from two turns', () => { + expect( + rows(build([text('u1', 'go', 'user'), call('a'), text('u2', 'more', 'user'), call('b')])) + ).toEqual(['u1', 'a', 'u2', 'b']) + }) + + it('ends a run at a receipt, which keeps its own row', () => { + const receipts = new Map([ + [ + 'approval', + { + kind: 'approval', + title: 'Run?', + detail: 'ls', + options: [], + resolution: { + state: 'resolved', + selectedOptionId: 'y', + resolvedBy: 'desktop', + resolvedAt: 1 + } + } + ] + ]) + const slots = build( + [ + text('u', 'go', 'user'), + call('a'), + call('b'), + text('approval', 'Run?', 'system'), + call('c') + ], + { receipts } + ) + expect(rows(slots)).toEqual(['u', ['a', 'b'], 'approval', 'c']) + }) + + // The thought the live line is showing draws no row; it joins once it ends. + it('keeps the run open across the thought the live line shows', () => { + const messages = [text('u', 'go', 'user'), call('a'), thought('r1'), call('b'), thought('r2')] + expect(rows(build(messages, { liveReasoningId: 'r2' }))).toEqual(['u', ['a', 'r1', 'b']]) + expect(rows(build(messages))).toEqual(['u', ['a', 'r1', 'b', 'r2']]) + }) + + // At the top level the live line owns the open thought; once it lets go (a Stop in flight), + // the thought joins at once rather than standing alone until the turn ends. + it('folds an open thought the live line let go of straight into the run', () => { + const open: NativeChatMessage = { ...thought('r2'), state: 'running', completedAt: undefined } + const messages = [text('u', 'go', 'user'), call('a'), thought('r1'), call('b'), open] + const live = { liveTurnKey: 'u', isWorking: true } + expect(rows(build(messages, { ...live, liveReasoningId: 'r2' }))).toEqual([ + 'u', + ['a', 'r1', 'b'] + ]) + expect(rows(build(messages, live))).toEqual(['u', ['a', 'r1', 'b', 'r2']]) + expect(rows(build(messages))).toEqual(['u', ['a', 'r1', 'b', 'r2']]) + }) + + it('is live while any of its calls is the turn frontier', () => { + const slots = build([text('u', 'go', 'user'), call('a'), thought('r1'), call('b')], { + liveTurnKey: 'u', + isWorking: true + }) + expect(slots[1]?.trailingRun).toBe(true) + }) + + // The row draws (and takes a slot), so it joins; inside the run it draws nothing. + it('lets a thought with no visible text join rather than split the run', () => { + const blank: NativeChatMessage = { ...thought('w'), blocks: [{ type: 'text', text: '\n' }] } + expect(rows(build([text('u', 'go', 'user'), call('a'), blank, call('b')]))).toEqual([ + 'u', + ['a', 'w', 'b'] + ]) + }) + + it('finds a member by id, for a reveal aimed at it', () => { + const slots = build([text('u', 'go', 'user'), call('a'), thought('r1'), call('b')]) + expect(nativeChatSlotIndexOf(slots, 'b')).toBe(1) + expect(nativeChatSlotIndexOf(slots, 'r1')).toBe(1) + }) +}) + +describe('work runs with an edit in their turn', () => { + const diff: NativeChatTurnDiff = { + files: [ + { + path: 'a.ts', + added: 1, + removed: 1, + truncated: false, + target: { messageId: 'a', editKey: 'Diff:0', fileIndex: 0 } + } + ], + added: 1, + removed: 1, + truncated: false + } + const turn = [ + text('u', 'go', 'user'), + thought('r1'), + call('a', 'Diff'), + thought('r2'), + call('b'), + thought('r3'), + call('c') + ] + + // The rollup draws under the turn's newest row, which is the run's own last member while live. + it('keeps the newest row in the live run, which carries the rollup', () => { + const slots = build(turn, { + liveTurnKey: 'u', + isWorking: true, + turnDiffs: new Map([['u', diff]]) + }) + expect(rows(slots)).toEqual(['u', ['r1', 'a', 'r2', 'b', 'r3', 'c']]) + expect(slots[1]?.trailingRun).toBe(true) + expect(slots[1]?.turnDiff).toBe(diff) + const thinking = build([...turn, thought('r4')], { + liveTurnKey: 'u', + isWorking: true, + turnDiffs: new Map([['u', diff]]) + }) + expect(rows(thinking)).toEqual(['u', ['r1', 'a', 'r2', 'b', 'r3', 'c', 'r4']]) + expect(thinking[1]?.turnDiff).toBe(diff) + }) + + it('keeps a stopped turn that ended on work as one run once opened', () => { + const settled: NativeChatTurnStatus = { startedAt: 1, thinking: false, workedSeconds: 4 } + const slots = build(turn, { + turnStatuses: { active: null, completedByTurn: { u: settled } }, + expandedTurnKeys: new Set(['u']), + turnDiffs: new Map([['u', diff]]) + }) + expect(rows(slots)).toEqual(['u', ['r1', 'a', 'r2', 'b', 'r3', 'c']]) + expect(slots[1]?.turnDiff).toBe(diff) + expect(slots[0]?.turnDiff).toBeUndefined() + }) +}) + +describe('work run height', () => { + // Collapsed, a run draws one header: its thoughts' text reserves nothing until it opens. + it('reserves one tool row for a collapsed run however long its thoughts are', () => { + const long = (id: string): NativeChatMessage => ({ + ...thought(id), + blocks: [{ type: 'text', text: 'x'.repeat(400) }] + }) + const members = Array.from({ length: 12 }, (_, index) => [ + long(`r${index}`), + call(`c${index}`) + ]).flat() + const [, lone] = build([text('u', 'go', 'user'), call('c')]) + const [, run] = build([text('u', 'go', 'user'), ...members]) + expect(run?.workRun).toHaveLength(24) + expect(run?.estimatedHeight).toBe(lone?.estimatedHeight) + }) + + it("adds a lead head's words over the run", () => { + const [, alone] = build([text('u', 'go', 'user'), lead('x')]) + const [, run] = build([text('u', 'go', 'user'), lead('x'), thought('r1'), call('a')]) + expect(run?.workRun).toHaveLength(3) + expect(run?.estimatedHeight).toBe(alone?.estimatedHeight) + }) +}) + +describe('work run entries', () => { + it('places each thought before the call after it, and keeps trailing ones last', () => { + const members = [thought('r1'), call('a'), thought('r2'), call('b'), thought('r3')] + const { blocks, thoughtsBefore, thoughtsAfter } = nativeChatWorkRunEntries(members) + expect(blocks).toEqual([...members[1]!.blocks, ...members[3]!.blocks]) + expect(thoughtsBefore.get(blocks[0]!)?.map((m) => m.id)).toEqual(['r1']) + expect(thoughtsBefore.get(blocks[1]!)?.map((m) => m.id)).toEqual(['r2']) + expect(thoughtsAfter.map((m) => m.id)).toEqual(['r3']) + }) + + // The turn's diff rollup numbers an edit within its own message; the run counts across all. + it("re-keys a message's edit to the run's numbering", () => { + const members = [call('a', 'Diff'), call('b', 'Diff')] + const { blocks } = nativeChatWorkRunEntries(members) + expect(nativeChatWorkRunEditKey(members, blocks, { messageId: 'b', editKey: 'Diff:0' })).toBe( + 'Diff:1' + ) + expect(nativeChatWorkRunEditKey(members, blocks, { messageId: 'z', editKey: 'Diff:0' })).toBe( + null + ) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-transcript-work-runs.ts b/src/renderer/src/components/native-chat/native-chat-transcript-work-runs.ts new file mode 100644 index 000000000000..b53f9ef71e2d --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-transcript-work-runs.ts @@ -0,0 +1,100 @@ +// Draws each unbroken stretch of tool calls and thoughts as one slot. A pass over the slots +// already built, so anything else that takes a slot (a section head, a roster's entries, a +// row carrying only its turn's bar or rollup) ends a run just by sitting between its rows. + +import { agentJournalItemSubagentId } from '../../../../shared/agent-session-journal-producer' +import { + nativeChatWorkRunMember, + nativeChatWorkRunSpans, + type NativeChatWorkRunRow +} from '../../../../shared/native-chat-work-run' +import { + type NativeChatRowTypography, + estimateNativeChatRowHeight, + nativeChatRowContentMetrics, + nativeChatWorkRunContentMetrics +} from './native-chat-row-height-estimate' +import type { + NativeChatMessageSlot, + NativeChatTranscriptSlot +} from './native-chat-transcript-slots' + +function workRunRow(slot: NativeChatTranscriptSlot): NativeChatWorkRunRow { + // Every slot draws something: its message, or its turn's bar or rollup. + if (slot.kind !== 'message') { + return { member: null, draws: true, scope: undefined } + } + return { + member: + slot.drawsMessage && slot.subagentRoster === undefined + ? nativeChatWorkRunMember( + slot.message, + slot.receipt !== undefined, + // At the top level the live line owns the open thought; only a section has none. + slot.depth > 0 && slot.activeTurnIsWorking + ) + : null, + draws: true, + scope: `${slot.depth}:${agentJournalItemSubagentId(slot.message)}:${slot.turnKey}` + } +} + +function workRunSlot( + members: readonly NativeChatMessageSlot[], + headIsLead: boolean, + typography: NativeChatRowTypography | undefined +): NativeChatMessageSlot { + const head = members[0]! + // A turn's bar sits on its first row and its rollup on its last, so the run takes each from there. + const { turnDiff } = members.at(-1)! + return { + ...head, + workRun: members.map((member) => member.message), + trailingRun: members.some((member) => member.trailingRun), + turnDiff, + estimatedHeight: estimateNativeChatRowHeight( + nativeChatWorkRunContentMetrics( + nativeChatRowContentMetrics(head.message, typography), + headIsLead + ), + { + hasReceipt: false, + hasStatus: head.status !== undefined, + hasTurnDiff: turnDiff !== undefined, + inSubagentSection: head.depth > 0 + }, + typography + ) + } +} + +export function nativeChatTranscriptWorkRuns( + slots: readonly NativeChatTranscriptSlot[], + typography?: NativeChatRowTypography +): NativeChatTranscriptSlot[] { + const rows = slots.map(workRunRow) + const runAt = new Map() + const joined = new Set() + for (const span of nativeChatWorkRunSpans(rows)) { + runAt.set( + span[0]!, + span.flatMap((index) => { + const slot = slots[index] + return slot?.kind === 'message' ? [slot] : [] + }) + ) + for (const index of span.slice(1)) { + joined.add(index) + } + } + const result: NativeChatTranscriptSlot[] = [] + for (const [index, slot] of slots.entries()) { + const members = runAt.get(index) + if (members) { + result.push(workRunSlot(members, rows[index]?.member === 'lead', typography)) + } else if (!joined.has(index)) { + result.push(slot) + } + } + return result +} diff --git a/src/renderer/src/components/native-chat/native-chat-work-run-pairing.test.ts b/src/renderer/src/components/native-chat/native-chat-work-run-pairing.test.ts new file mode 100644 index 000000000000..ad9696941f67 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-work-run-pairing.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest' +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { foldToolMessages, pairToolBlocks } from '../../../../shared/native-chat-tool-fold' +import { + nativeChatWorkRunEditKey, + nativeChatWorkRunEntries +} from '../../../../shared/native-chat-work-run' +import { projectStructuredAgentSessionMessages } from '../../../../shared/structured-agent-session-message-projection' +import { buildEditCards } from './native-chat-edit-cards' +import { buildNativeChatTranscriptSlots } from './native-chat-transcript-slots' + +function item( + itemId: string, + sequence: number, + body: AgentJournalRenderItem['body'] +): AgentJournalRenderItem { + return { itemId, sequence, revision: 1, observedAt: sequence, body } +} + +const prompt = item('u', 1, { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'go' }] +}) +// A command that printed nothing leaves no result, so it stays unanswered. +const quiet = item('cmd', 2, { + kind: 'tool-call', + name: 'shell', + callId: 'item_1', + input: { command: 'mkdir -p src' }, + state: 'completed' +}) +const thought = (id: string, sequence: number) => + item(id, sequence, { + kind: 'message', + role: 'reasoning', + blocks: [{ type: 'text', text: `thinking ${id}` }], + state: 'completed', + completedAt: sequence + }) +const diff = (id: string, sequence: number, file: string) => + item(id, sequence, { + kind: 'diff', + path: `src/${file}`, + patch: { + head: `@@ -1 +1 @@\n-${file} old\n+${file} new`, + digest: id, + byteLength: 20, + truncated: false + } + }) + +/** As the transcript list gets them: projected, then adjacent tool rows folded. */ +function messagesOf(items: AgentJournalRenderItem[]): NativeChatMessage[] { + return foldToolMessages( + projectStructuredAgentSessionMessages(items, [], [], { rejectedInPlace: false }) + ) +} + +/** Each edit card's call input and the files it draws. */ +function cards(blocks: NativeChatMessage['blocks']): [unknown, string[]][] { + return [...buildEditCards(blocks).editCards].map(([call, card]) => [ + call.type === 'tool-call' ? call.input : undefined, + card.files.map((file) => file.path) + ]) +} + +describe('tool results inside a work run', () => { + // Diff results carry no provider call id; across rows they went to the quiet command. + it("keeps each row's result with its own call", () => { + const messages = messagesOf([ + prompt, + quiet, + thought('r1', 3), + diff('da', 4, 'a.ts'), + thought('r2', 5), + diff('db', 6, 'b.ts') + ]) + const run = buildNativeChatTranscriptSlots({ + messages, + turnKeys: messages.map(() => 'u'), + liveTurnKey: undefined, + receipts: new Map(), + turnStatuses: { active: null, completedByTurn: {} }, + turnDiffs: new Map(), + expandedTurnKeys: new Set(), + isWorking: false, + lifecycleWorking: false + }).find((slot) => slot.kind === 'message' && slot.workRun !== undefined) + const members = run?.kind === 'message' ? (run.workRun ?? []) : [] + expect(members.map((member) => member.id)).toEqual(['cmd', 'r1', 'da', 'r2', 'db']) + const { blocks } = nativeChatWorkRunEntries(members) + expect(pairToolBlocks(blocks)[0]?.result).toBeUndefined() + expect(cards(blocks)).toEqual([ + [{ path: 'src/a.ts' }, ['src/a.ts']], + [{ path: 'src/b.ts' }, ['src/b.ts']] + ]) + // The rollup's reveal for a.ts lands on a.ts's card. + const key = nativeChatWorkRunEditKey(members, blocks, { messageId: 'da', editKey: 'Diff:0' }) + const target = [...buildEditCards(blocks).editCards.values()].find((card) => card.key === key) + expect(target?.files[0]?.path).toBe('src/a.ts') + }) + + it('keeps a result with its own call when rows fold into one message', () => { + const messages = messagesOf([prompt, quiet, diff('da', 3, 'a.ts')]) + expect(messages.map((message) => message.id)).toEqual(['u', 'cmd']) + expect(cards(messages[1]!.blocks)).toEqual([[{ path: 'src/a.ts' }, ['src/a.ts']]]) + }) +}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-work-run.tsx b/src/renderer/src/components/native-chat/use-native-chat-work-run.tsx new file mode 100644 index 000000000000..45e94a7936f4 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-work-run.tsx @@ -0,0 +1,71 @@ +import { useMemo } from 'react' +import type { CommentMarkdownLinkClickHandler } from '@/components/sidebar/CommentMarkdown' +import type { NativeChatBlock, NativeChatMessage } from '../../../../shared/native-chat-types' +import { deriveNativeChatRowContent } from '../../../../shared/native-chat-row-content' +import { + nativeChatWorkRunEditKey, + nativeChatWorkRunEntries +} from '../../../../shared/native-chat-work-run' +import { NativeChatReasoningRow } from './NativeChatReasoningRow' +import type { NativeChatToolRunAsides } from './NativeChatToolRun' +import type { NativeChatDiffReveal } from './native-chat-turn-diffs' + +/** What a row draws under its head when it draws a work run. */ +export type NativeChatWorkRun = { + /** Every member's tool blocks, in order: the run's one block list. */ + blocks: NativeChatBlock[] + /** The thoughts, among the calls they came between. */ + asides: NativeChatToolRunAsides + /** The diff reveal aimed at a member, in the run's own edit numbering. */ + revealedDiff: NativeChatDiffReveal | undefined +} + +/** The run a row draws in place of its own calls, when `members` names one. */ +export function useNativeChatWorkRun( + members: readonly NativeChatMessage[] | undefined, + { + revealedDiff, + activeTurnIsWorking, + onLinkClick, + allowFileUriLinks + }: { + revealedDiff: NativeChatDiffReveal | undefined + activeTurnIsWorking: boolean | undefined + onLinkClick: CommentMarkdownLinkClickHandler | undefined + allowFileUriLinks: boolean + } +): NativeChatWorkRun | undefined { + const entries = useMemo(() => { + if (!members) { + return undefined + } + const { blocks, thoughtsBefore, thoughtsAfter } = nativeChatWorkRunEntries(members) + const thought = (message: NativeChatMessage): React.JSX.Element => ( + // Keyed by the thought, so it stays mounted as calls arrive after it. + + ) + const before = new Map() + for (const [block, thoughts] of thoughtsBefore) { + before.set(block, thoughts.map(thought)) + } + return { + blocks, + asides: { before, after: thoughtsAfter.map(thought) } + } + }, [members, activeTurnIsWorking, onLinkClick, allowFileUriLinks]) + const runReveal = useMemo(() => { + const editKey = + entries && revealedDiff + ? nativeChatWorkRunEditKey(members ?? [], entries.blocks, revealedDiff) + : null + return revealedDiff && editKey ? { ...revealedDiff, editKey } : undefined + }, [entries, members, revealedDiff]) + return entries && { ...entries, revealedDiff: runReveal } +} diff --git a/src/shared/native-chat-work-run.test.ts b/src/shared/native-chat-work-run.test.ts new file mode 100644 index 000000000000..0fdc9c473e6a --- /dev/null +++ b/src/shared/native-chat-work-run.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest' +import { + nativeChatWorkRunSpans, + type NativeChatWorkRunMember, + type NativeChatWorkRunRow +} from './native-chat-work-run' + +/** `t` thought, `c` call, `l` lead, `x` a drawn row that ends runs, `-` a row that draws nothing; + * `|` starts the next scope. */ +function rowsOf(pattern: string): NativeChatWorkRunRow[] { + const members: Record = { t: 'thought', c: 'tool', l: 'lead' } + let scope = 0 + return [...pattern].flatMap((char) => { + if (char === '|') { + scope += 1 + return [] + } + return [{ member: members[char] ?? null, draws: char !== '-', scope: String(scope) }] + }) +} + +const spans = (pattern: string): number[][] => nativeChatWorkRunSpans(rowsOf(pattern)) + +describe('work run spans', () => { + it('runs thoughts and calls together until something else draws', () => { + expect(spans('tctctxct')).toEqual([ + [0, 1, 2, 3, 4], + [6, 7] + ]) + }) + + it('needs two members and a call', () => { + expect(spans('c')).toEqual([]) + expect(spans('tt')).toEqual([]) + expect(spans('txc')).toEqual([]) + }) + + it('lets a lead head a run but never join one', () => { + expect(spans('ltclt')).toEqual([ + [0, 1, 2], + [3, 4] + ]) + expect(spans('cl')).toEqual([]) + }) + + it('stays open across a row that draws nothing', () => { + expect(spans('c-t-c')).toEqual([[0, 2, 4]]) + }) + + it('never crosses a scope, even through a row that draws nothing', () => { + expect(spans('ct|c')).toEqual([[0, 1]]) + expect(spans('c|-|c')).toEqual([]) + }) +}) diff --git a/src/shared/native-chat-work-run.ts b/src/shared/native-chat-work-run.ts new file mode 100644 index 000000000000..48988a7e00e5 --- /dev/null +++ b/src/shared/native-chat-work-run.ts @@ -0,0 +1,148 @@ +// An unbroken stretch of tool calls and thoughts between what the agent says reads as one +// collapsible row. Thoughts sit inside it rather than splitting it, so a model that thinks +// before every call still shows one "Ran 5 commands" row, not ten alternating rows. + +import { isNativeChatAskCall } from './native-chat-ask-row' +import { deriveNativeChatRowContent } from './native-chat-row-content' +import { isStoppedBeforeStartBlock } from './native-chat-stopped-before-start' +import { pairToolBlocks } from './native-chat-tool-fold' +import type { NativeChatBlock, NativeChatMessage } from './native-chat-types' + +/** `lead`: the agent's words with calls already folded under them, which may head a run but + * never join one. */ +export type NativeChatWorkRunMember = 'lead' | 'tool' | 'thought' + +/** What a drawn row contributes to a work run, or null when it ends one. Rows that carry + * anything besides words and plain tool activity (a roster, a background task, a question, + * an approval's receipt) keep their own row. `scopeIsWorking`: the row's turn or subagent is + * still running. */ +export function nativeChatWorkRunMember( + message: NativeChatMessage, + hasReceipt: boolean, + scopeIsWorking: boolean +): NativeChatWorkRunMember | null { + if (hasReceipt) { + return null + } + if (message.role === 'reasoning') { + // A thought still being thought stays in view on its own row; it joins once it ends. Any + // other drawn thought joins, and one with no visible text draws nothing inside the run. + return scopeIsWorking && message.state === 'running' ? null : 'thought' + } + const content = deriveNativeChatRowContent(message.blocks) + if ( + message.role !== 'assistant' || + content.tools.length === 0 || + content.subagentGroups.length > 0 || + content.backgroundTasks.length > 0 || + message.blocks.some( + (block) => isStoppedBeforeStartBlock(block) || (block.type === 'text' && block.providerFrame) + ) || + content.tools.some(isNativeChatAskCall) + ) { + return null + } + return content.markdown.length > 0 || content.hasImages ? 'lead' : 'tool' +} + +/** One row of a transcript as the run boundaries read it. */ +export type NativeChatWorkRunRow = { + /** What the row adds to a run; null ends one. */ + member: NativeChatWorkRunMember | null + /** False for a row that draws nothing: it neither joins nor ends a run. */ + draws: boolean + /** Runs never cross a scope (a turn, a subagent's section), drawn or not. */ + scope: string | undefined +} + +/** Each work run's member indexes, ascending. A run has at least two members and at least one + * call; a lead may head one but never join one. Derived per call, so nothing is stored. */ +export function nativeChatWorkRunSpans(rows: readonly NativeChatWorkRunRow[]): number[][] { + const spans: number[][] = [] + let open: number[] = [] + let hasCall = false + let scope: string | undefined + const close = (): void => { + if (open.length > 1 && hasCall) { + spans.push(open) + } + open = [] + hasCall = false + } + for (const [index, row] of rows.entries()) { + if (open.length > 0 && row.scope !== scope) { + close() + } + if (!row.draws) { + continue + } + if (row.member === null || row.member === 'lead') { + close() + } + if (row.member === null) { + continue + } + if (open.length === 0) { + scope = row.scope + } + open.push(index) + hasCall ||= row.member !== 'thought' + } + close() + return spans +} + +export type NativeChatWorkRunEntries = { + /** Every member's tool blocks, in order: the run's one block list. */ + blocks: NativeChatBlock[] + /** Thoughts drawn just before a block, keyed by that block. */ + thoughtsBefore: Map + /** Thoughts after the run's last block. */ + thoughtsAfter: NativeChatMessage[] +} + +export function nativeChatWorkRunEntries( + members: readonly NativeChatMessage[] +): NativeChatWorkRunEntries { + const blocks: NativeChatBlock[] = [] + const thoughtsBefore = new Map() + let waiting: NativeChatMessage[] = [] + for (const message of members) { + if (message.role === 'reasoning') { + waiting.push(message) + continue + } + const { tools } = deriveNativeChatRowContent(message.blocks) + if (tools.length === 0) { + continue + } + if (waiting.length > 0) { + thoughtsBefore.set(tools[0]!, waiting) + waiting = [] + } + blocks.push(...tools) + } + return { blocks, thoughtsBefore, thoughtsAfter: waiting } +} + +/** A diff target names an edit by its index within one message; the run counts across + * all of them. Re-keys the target's edit to the run's own numbering. */ +export function nativeChatWorkRunEditKey( + members: readonly NativeChatMessage[], + runBlocks: readonly NativeChatBlock[], + target: { messageId: string; editKey: string } +): string | null { + const member = members.find((message) => message.id === target.messageId) + if (!member) { + return null + } + // Numbered over the raw blocks, as the turn's diff rollup numbers them. + const call = pairToolBlocks(member.blocks).find( + ({ call }, index) => call !== undefined && `${call.name}:${index}` === target.editKey + )?.call + if (!call) { + return null + } + const runIndex = pairToolBlocks(runBlocks).findIndex((pair) => pair.call === call) + return runIndex === -1 ? null : `${call.name}:${runIndex}` +} diff --git a/src/shared/structured-agent-session-live-turn.ts b/src/shared/structured-agent-session-live-turn.ts index b7ce267fb427..44bc6be69bca 100644 --- a/src/shared/structured-agent-session-live-turn.ts +++ b/src/shared/structured-agent-session-live-turn.ts @@ -204,23 +204,26 @@ export function isStructuredAgentSessionThinking({ items, latestTurn }: HostTurn export function statusStructuredAgentSessionToolCall( items: readonly AgentJournalRenderItem[] ): NativeChatToolCallBlock | null { - let newest: StructuredAgentSessionToolAction | null = null - let running: StructuredAgentSessionToolAction | null = null + let newest: { action: StructuredAgentSessionToolAction; itemId: string } | null = null + let running: { action: StructuredAgentSessionToolAction; itemId: string } | null = null for (let index = items.length - 1; index >= 0; index -= 1) { const item = items[index] - const body = item?.body + if (!item) { + continue + } + const body = item.body const turn = readAgentJournalTurn(body) if (turn) { const named = turn.state === 'running' ? (running ?? newest) : null // Built only for the winner: the host re-projects this on every journal change. - return named ? structuredAgentSessionToolCallBlock(named) : null + return named ? structuredAgentSessionToolCallBlock(named.action, named.itemId) : null } if (running || !isStructuredAgentSessionToolAction(body) || !isRootAgentJournalItem(item)) { continue } - newest ??= body + newest ??= { action: body, itemId: item.itemId } if (isRunningStructuredAgentSessionToolAction(body)) { - running = body + running = { action: body, itemId: item.itemId } } } return null diff --git a/src/shared/structured-agent-session-projection.test.ts b/src/shared/structured-agent-session-projection.test.ts index 4865b6ebe3a8..0f56b920b32b 100644 --- a/src/shared/structured-agent-session-projection.test.ts +++ b/src/shared/structured-agent-session-projection.test.ts @@ -77,8 +77,8 @@ describe('structured agent session status projection', () => { blocks: [{ type: 'tool-call' }, { type: 'tool-result', output: '@@\n+second' }] }) expect(second?.blocks).toEqual([ - { type: 'tool-call', name: 'Diff', input: { path: 'a.ts' } }, - { type: 'tool-result', output: '@@\n+second' } + { type: 'tool-call', name: 'Diff', input: { path: 'a.ts' }, callId: 'diff' }, + { type: 'tool-result', output: '@@\n+second', callId: 'diff' } ]) const pending = item('approval', 2, { kind: 'approval', @@ -517,16 +517,16 @@ describe('structured agent session status projection', () => { it('preserves structured tool lifecycle state for the live renderer', () => { const projected = projectStructuredItemToNativeChat( - item('running-tool', 1, { + item('ls', 1, { kind: 'tool-call', name: 'shell', - input: { command: 'cat package.json' }, + input: { command: 'ls' }, state: 'running' }) ) expect(projected?.blocks).toEqual([ - { type: 'tool-call', name: 'shell', input: { command: 'cat package.json' }, state: 'running' } + { type: 'tool-call', name: 'shell', input: { command: 'ls' }, state: 'running', callId: 'ls' } ]) }) diff --git a/src/shared/structured-agent-session-projection.ts b/src/shared/structured-agent-session-projection.ts index 58b7a8558a65..d82ec84f6f53 100644 --- a/src/shared/structured-agent-session-projection.ts +++ b/src/shared/structured-agent-session-projection.ts @@ -77,11 +77,13 @@ function itemBlocks(item: AgentJournalRenderItem): { return { role: body.role, blocks: body.blocks } } if (isStructuredAgentSessionToolAction(body)) { - const call = structuredAgentSessionToolCallBlock(body) + const call = structuredAgentSessionToolCallBlock(body, item.itemId) + // The call and its output are one journal row, so the result names its call. + const { callId } = call if (body.kind === 'diff') { return { role: 'assistant', - blocks: [call, { type: 'tool-result', output: boundedText(body.patch) }] + blocks: [call, { type: 'tool-result', output: boundedText(body.patch), callId }] } } return { @@ -95,8 +97,7 @@ function itemBlocks(item: AgentJournalRenderItem): { output: boundedText(body.output), // Output a call left when it was cut short is not an error it reported. isError: agentJournalToolCallLifecycle(body) === 'failed', - // The call and its output are one journal row, so the result names its call. - ...(body.callId !== undefined ? { callId: body.callId } : {}) + callId } ] : []) diff --git a/src/shared/structured-agent-session-tool-call-block.ts b/src/shared/structured-agent-session-tool-call-block.ts index b78ff9579b2a..403a74a3c6a5 100644 --- a/src/shared/structured-agent-session-tool-call-block.ts +++ b/src/shared/structured-agent-session-tool-call-block.ts @@ -26,11 +26,15 @@ export function isRunningStructuredAgentSessionToolAction( return action.kind === 'tool-call' && action.state === 'running' } +/** `itemId`: the journal row's, the call's id when the provider gave none. A call and its result + * are one row, so the result names its call by it; without an id a result pairs with the oldest + * unanswered call, which in a run of several rows can be another row's. */ export function structuredAgentSessionToolCallBlock( - action: StructuredAgentSessionToolAction + action: StructuredAgentSessionToolAction, + itemId: string ): NativeChatToolCallBlock { if (action.kind === 'diff') { - return { type: 'tool-call', name: 'Diff', input: { path: action.path } } + return { type: 'tool-call', name: 'Diff', input: { path: action.path }, callId: itemId } } return { type: 'tool-call', @@ -38,7 +42,7 @@ export function structuredAgentSessionToolCallBlock( input: action.input, state: action.state, ...(action.endedAs !== undefined ? { endedAs: action.endedAs } : {}), - ...(action.callId !== undefined ? { callId: action.callId } : {}), + callId: action.callId ?? itemId, ...(action.mcpIdentity !== undefined ? { mcpIdentity: action.mcpIdentity } : {}), ...(action.exitCode !== undefined ? { exitCode: action.exitCode } : {}), ...(action.durationMs !== undefined ? { durationMs: action.durationMs } : {}), From 2584ed906e14bf91090ca8726068c3632491d5ec Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 7 Oct 2026 03:41:42 -0700 Subject: [PATCH 002/114] feat: play video and music files in mobile previews (#26148) Play workspace video and music files on mobile using bounded authenticated downloads and native media controls. Adversarial review fixes cover rewritten files and Android policy tests. Includes iOS simulator screenshots and a playback recording in PR #26148. Co-authored-by: ChangJun Park <40492343+ckdwns9121@users.noreply.github.com> Co-authored-by: Lirone Levy Co-authored-by: dupi Co-authored-by: John Cusack <5961784+John-Cusack@users.noreply.github.com> --- ...bile-web-app-media-preview-render.test.mjs | 166 ++++++++++++++++++ config/scripts/mobile-web-app-render.test.mjs | 6 + .../orcamobilewebshell/MobileWebShellCsp.kt | 3 +- .../MobileWebShellCspTest.kt | 5 +- .../ios/MobileWebShellCsp.swift | 3 +- .../ios/MobileWebShellView.swift | 2 + .../src/files/MobileFileMediaPreview.test.tsx | 114 ++++++++++++ mobile/src/files/MobileFileMediaPreview.tsx | 88 ++++++++++ mobile/src/files/MobileFilePreviewBody.tsx | 12 ++ mobile/src/files/MobileFilePreviewScreen.tsx | 3 +- mobile/src/files/MobileMediaPlayback.tsx | 23 +++ mobile/src/files/MobileMediaPlayback.web.tsx | 60 +++++++ mobile/src/files/file-tree.ts | 2 + mobile/src/files/mobile-file-explorer-row.tsx | 5 +- .../files/mobile-file-media-download.test.ts | 160 +++++++++++++++++ .../src/files/mobile-file-media-download.ts | 123 +++++++++++++ mobile/src/files/mobile-file-media.ts | 18 ++ .../files/mobile-file-preview-editability.ts | 5 +- .../mobile-file-preview-navigation.test.ts | 9 + .../files/mobile-file-preview-navigation.ts | 7 +- .../files/mobile-file-preview-request.test.ts | 10 ++ .../src/files/mobile-file-preview-request.ts | 17 +- .../src/files/mobile-file-preview-response.ts | 2 + .../files/mobile-file-preview-retry.test.tsx | 1 + mobile/src/files/mobile-file-tab-doc.test.ts | 17 ++ mobile/src/files/mobile-file-tab-doc.ts | 6 + .../files/mobile-media-preview-cache.test.ts | 65 +++++++ .../src/files/mobile-media-preview-cache.ts | 43 +++++ .../files/mobile-media-preview-cache.web.ts | 23 +++ .../files/mobile-media-preview-document.ts | 18 ++ .../session/MobileSessionActiveContent.tsx | 1 + .../src/session/MobileSessionFileReader.tsx | 7 + .../src/session/mobile-session-route-types.ts | 2 + mobile/web-entry/web-overrides.json | 8 + ...untime-files-mobile-explorer-reads.test.ts | 41 +++++ ...time-file-commands-search-runtime-files.ts | 9 +- .../runtime-rpc-mobile-method-allowlist.ts | 1 + 37 files changed, 1073 insertions(+), 12 deletions(-) create mode 100644 config/scripts/mobile-web-app-media-preview-render.test.mjs create mode 100644 mobile/src/files/MobileFileMediaPreview.test.tsx create mode 100644 mobile/src/files/MobileFileMediaPreview.tsx create mode 100644 mobile/src/files/MobileMediaPlayback.tsx create mode 100644 mobile/src/files/MobileMediaPlayback.web.tsx create mode 100644 mobile/src/files/mobile-file-media-download.test.ts create mode 100644 mobile/src/files/mobile-file-media-download.ts create mode 100644 mobile/src/files/mobile-file-media.ts create mode 100644 mobile/src/files/mobile-media-preview-cache.test.ts create mode 100644 mobile/src/files/mobile-media-preview-cache.ts create mode 100644 mobile/src/files/mobile-media-preview-cache.web.ts create mode 100644 mobile/src/files/mobile-media-preview-document.ts diff --git a/config/scripts/mobile-web-app-media-preview-render.test.mjs b/config/scripts/mobile-web-app-media-preview-render.test.mjs new file mode 100644 index 000000000000..2c2dbd432ba3 --- /dev/null +++ b/config/scripts/mobile-web-app-media-preview-render.test.mjs @@ -0,0 +1,166 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { chromium } from 'playwright-core' +import { buildMobileWebAppBundle } from './build-mobile-web-app-bundle.mjs' +import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs' +import { + createBundleServer, + installShellDouble, + readBridgeFaultGrant, + readBridgeProtocolVersion, + readShellCsp +} from './mobile-web-app-render-harness.mjs' +import { MOBILE_WEB_PAGE_ROUTES } from './mobile-web-page-routes.mjs' + +// Two gray VP9 frames generated with ffmpeg, without audio or user content. +const WEBM = + 'GkXfo59ChoEBQveBAULygQRC84EIQoKEd2VibUKHgQJChYECGFOAZwEAAAAAAAH6EU2bdLpNu4tTq4QVSalmU6yBoU27i1OrhBZUrmtTrIHWTbuMU6uEElTDZ1OsggEjTbuMU6uEHFO7a1OsggHk7AEAAAAAAABZAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAVSalmsCrXsYMPQkBNgIxMYXZmNjIuMy4xMDBXQYxMYXZmNjIuMy4xMDBEiYhAn0AAAAAAABZUrmvIrgEAAAAAAAA/14EBc8WIPYwl7RGcI1acgQAitZyDdW5kiIEAhoVWX1ZQOYOBASPjg4Q7msoA4JCwgSC6gSCagQJVsIRVuYEBElTDZ0B/c3OfY8CAZ8iZRaOHRU5DT0RFUkSHjExhdmY2Mi4zLjEwMHNz2mPAi2PFiD2MJe0RnCNWZ8ilRaOHRU5DT0RFUkSHmExhdmM2Mi4xMS4xMDAgbGlidnB4LXZwOWfIoUWjiERVUkFUSU9ORIeTMDA6MDA6MDIuMDAwMDAwMDAwAB9DtnW354EAo52BAACAgkmDQgAB8AH2ADgkHBhKAAAwYAAAItXAAKOTgQPoAIYAQJKcAFAAAANgAABDQBxTu2uRu4+zgQC3iveBAfGCAajwgQM=' +function wavFixture() { + const bytes = Buffer.alloc(44 + 8000 * 2 * 2) + bytes.write('RIFF') + bytes.writeUInt32LE(bytes.length - 8, 4) + bytes.write('WAVEfmt ', 8) + bytes.writeUInt32LE(16, 16) + bytes.writeUInt16LE(1, 20) + bytes.writeUInt16LE(1, 22) + bytes.writeUInt32LE(8000, 24) + bytes.writeUInt32LE(16000, 28) + bytes.writeUInt16LE(2, 32) + bytes.writeUInt16LE(16, 34) + bytes.write('data', 36) + bytes.writeUInt32LE(bytes.length - 44, 40) + return bytes +} +const host = { id: 'media-host', name: 'Media Host', endpoint: 'ws://media-test', lastConnected: 1 } +const pattern = '/h/[hostId]/files/preview/[worktreeId]' +const pathname = '/h/media-host/files/preview/folder-project' +const bundles = mobileWebAppDependenciesPresent() +let scratch, server, origin, browser, policy, version, faultGrant +beforeAll(async () => { + if (!bundles) { + return + } + scratch = await mkdtemp(join(tmpdir(), 'orca-mobile-media-render-')) + const built = await buildMobileWebAppBundle({ outDir: join(scratch, 'bundle') }) + policy = await readShellCsp() + version = await readBridgeProtocolVersion() + faultGrant = await readBridgeFaultGrant() + const served = await createBundleServer({ + outDir: built.outDir, + cspHeader: (request) => + request.url.includes('oldShell=1') + ? policy.replace('media-src blob:', "media-src 'none'") + : policy + }) + server = served.server + origin = served.origin + const executablePath = process.env.ORCA_MOBILE_WEB_RENDER_BROWSER + browser = await chromium.launch({ headless: true, ...(executablePath ? { executablePath } : {}) }) +}, 120_000) +afterAll(async () => { + await browser?.close() + await new Promise((resolve) => (server ? server.close(resolve) : resolve())) + if (scratch) { + await rm(scratch, { recursive: true, force: true }) + } +}) +async function openMedia(relativePath, bytes, oldShell = false) { + const page = await browser.newPage({ viewport: { width: 390, height: 844 } }) + const errors = [] + page.on('pageerror', (error) => errors.push(error.message)) + const grants = MOBILE_WEB_PAGE_ROUTES.find((route) => route.pathname === pattern).grants + await page.addInitScript(installShellDouble, { + version, + sessionId: 'media-session', + buildId: 'media-build', + route: { + pathname, + params: { relativePath, name: relativePath, worktreeName: 'Folder project' } + }, + host, + storage: {}, + faultGrant, + grants: [faultGrant, ...grants], + pageRoutes: [pattern], + replies: { + 'files.stat': { size: bytes.length, isDirectory: false, mtime: 1 }, + 'files.readChunk': { + contentBase64: bytes.toString('base64'), + bytesRead: bytes.length, + eof: true + }, + 'files.read': { + content: 'Playback stopped after switching files', + byteLength: 43, + truncated: false + } + } + }) + await page.addInitScript(() => { + globalThis.__revokedMedia = [] + const revoke = URL.revokeObjectURL.bind(URL) + URL.revokeObjectURL = (uri) => { + globalThis.__revokedMedia.push(uri) + revoke(uri) + } + }) + await page.goto(`${origin}/?${oldShell ? 'oldShell=1' : ''}`) + return { page, errors } +} +const describeRender = bundles ? describe : describe.skip +describeRender('mobile media under the shipped shell policy', () => { + it.each([ + ['music.wav', wavFixture(), 'audio'], + ['movie.webm', Buffer.from(WEBM, 'base64'), 'video'] + ])( + 'plays and seeks %s, then releases playback and its Blob on navigation', + async (name, bytes, tag) => { + const { page, errors } = await openMedia(name, bytes) + await page.waitForFunction((tag) => document.querySelector(tag)?.readyState >= 2, tag) + const playback = await page.evaluate(async (tag) => { + const media = document.querySelector(tag) + globalThis.__previousMedia = media + media.muted = true + media.currentTime = 1 + await media.play() + await new Promise((resolve) => setTimeout(resolve, 200)) + media.pause() + return { + duration: media.duration, + time: media.currentTime, + controls: media.controls, + autoplay: media.autoplay, + uri: media.currentSrc + } + }, tag) + expect(playback.duration).toBeCloseTo(2, 1) + expect(playback.time).toBeGreaterThan(1) + expect(playback.controls).toBe(true) + expect(playback.autoplay).toBe(false) + await page.evaluate((pathname) => { + history.pushState(null, '', `${pathname}?relativePath=README.md`) + dispatchEvent(new PopStateEvent('popstate')) + }, pathname) + await page.getByText('Playback stopped after switching files', { exact: true }).waitFor() + expect( + await page.evaluate(() => ({ + paused: globalThis.__previousMedia.paused, + src: globalThis.__previousMedia.getAttribute('src'), + revoked: globalThis.__revokedMedia + })) + ).toEqual({ paused: true, src: null, revoked: [playback.uri] }) + expect(errors).toEqual([]) + await page.close() + }, + 60_000 + ) + it('explains the required app update when an older shell blocks Blob playback', async () => { + const { page } = await openMedia('music.wav', wavFixture(), true) + await page + .getByText('Update Orca Mobile to play media in this workspace', { exact: true }) + .waitFor() + await page.close() + }, 60_000) +}) diff --git a/config/scripts/mobile-web-app-render.test.mjs b/config/scripts/mobile-web-app-render.test.mjs index 84d3164c7cb3..3f105a7dfedc 100644 --- a/config/scripts/mobile-web-app-render.test.mjs +++ b/config/scripts/mobile-web-app-render.test.mjs @@ -317,6 +317,12 @@ describe('the shell policy this page is tested under', () => { ]) }) + it('admits only cached Blob URLs for media', () => { + expect(cspHeader.split('; ').filter((entry) => entry.startsWith('media-src'))).toEqual([ + 'media-src blob:' + ]) + }) + it('still refuses inline script, which is the directive that matters', () => { expect(cspHeader).toContain("script-src 'self';") expect(cspHeader).not.toContain("script-src 'self' 'unsafe-inline'") diff --git a/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellCsp.kt b/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellCsp.kt index f9ff8477cf58..b39d10056a7f 100644 --- a/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellCsp.kt +++ b/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellCsp.kt @@ -43,7 +43,8 @@ internal val MOBILE_WEB_SHELL_CSP = listOf( // fence for fetch and XMLHttpRequest; the document-start script covers only the two things the // native layer cannot see. "connect-src 'self'", - "media-src 'none'", + // Media previews play bytes downloaded through authenticated RPC, never a network URL. + "media-src blob:", "object-src 'none'", "frame-src 'none'", "child-src 'none'", diff --git a/mobile/modules/orca-mobile-web-shell/android/src/test/java/expo/modules/orcamobilewebshell/MobileWebShellCspTest.kt b/mobile/modules/orca-mobile-web-shell/android/src/test/java/expo/modules/orcamobilewebshell/MobileWebShellCspTest.kt index ff46f751dff1..ed32ae95853b 100644 --- a/mobile/modules/orca-mobile-web-shell/android/src/test/java/expo/modules/orcamobilewebshell/MobileWebShellCspTest.kt +++ b/mobile/modules/orca-mobile-web-shell/android/src/test/java/expo/modules/orcamobilewebshell/MobileWebShellCspTest.kt @@ -45,7 +45,10 @@ class MobileWebShellCspTest { listOf("img-src 'self' data: https:"), directives.filter { it.contains("data:") } ) - assertFalse(MOBILE_WEB_SHELL_CSP.contains("blob:")) + assertEquals( + listOf("media-src blob:"), + directives.filter { it.contains("blob:") } + ) // Same shape for `https:`: images and nothing else. `http:` is not a substring of `https:`, so // this still refuses a cleartext source anywhere in the header. assertEquals( diff --git a/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellCsp.swift b/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellCsp.swift index 118e858193ba..49c87afa9b90 100644 --- a/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellCsp.swift +++ b/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellCsp.swift @@ -40,7 +40,8 @@ enum MobileWebShellCsp { // the fence for fetch and XMLHttpRequest; the document-start script covers only the two things // the native layer cannot see. "connect-src 'self'", - "media-src 'none'", + // Media previews play bytes downloaded through authenticated RPC, never a network URL. + "media-src blob:", "object-src 'none'", "frame-src 'none'", "child-src 'none'", diff --git a/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellView.swift b/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellView.swift index 240264bb977a..e5575b36e2a2 100644 --- a/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellView.swift +++ b/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellView.swift @@ -196,6 +196,8 @@ final class OrcaMobileWebShellView: ExpoView, WKNavigationDelegate, WKUIDelegate required init(appContext: AppContext? = nil) { super.init(appContext: appContext) let configuration = WKWebViewConfiguration() + configuration.allowsInlineMediaPlayback = true + configuration.mediaTypesRequiringUserActionForPlayback = .all // DOM storage and databases cannot be switched off on WebKit. A non-persistent store plus a // per-session origin plus destruction on unmount is the whole mitigation, and no isolation // claim here rests on them being absent. diff --git a/mobile/src/files/MobileFileMediaPreview.test.tsx b/mobile/src/files/MobileFileMediaPreview.test.tsx new file mode 100644 index 000000000000..6ed26eb6245f --- /dev/null +++ b/mobile/src/files/MobileFileMediaPreview.test.tsx @@ -0,0 +1,114 @@ +import { createElement, StrictMode } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' + +type TestSink = { + append: ReturnType + finish: ReturnType + dispose: ReturnType +} +const downloads = vi.hoisted((): { sinks: TestSink[] } => ({ sinks: [] })) +vi.mock('react-native', () => ({ + ActivityIndicator: 'Spinner', + Pressable: 'Button', + Text: 'Text', + View: 'View', + StyleSheet: { create: (styles: unknown) => styles } +})) +vi.mock('./MobileMediaPlayback', () => ({ MobileMediaPlayback: 'Player' })) +vi.mock('./mobile-media-preview-cache', () => ({ + createMobileMediaSink: () => { + const sink = { + append: vi.fn(), + finish: vi.fn(() => `file:///media-${downloads.sinks.length}.mp4`), + dispose: vi.fn() + } + downloads.sinks.push(sink) + return sink + } +})) +import { MobileFileMediaPreview } from './MobileFileMediaPreview' +import type { RpcResponse } from '../transport/types' + +let tree: ReactTestRenderer | null = null +afterEach(() => { + act(() => tree?.unmount()) + tree = null + downloads.sinks = [] +}) +const media = { worktreeId: 'wt', relativePath: 'movie.mp4', mimeType: 'video/mp4' } +function ok(result: unknown): RpcResponse { + return { id: 'x', ok: true, result, _meta: { runtimeId: 'r' } } +} +const client = { + sendRequest: async (method: string) => + ok( + method === 'files.stat' + ? { size: 3, isDirectory: false, mtime: 1 } + : { contentBase64: 'AQID', bytesRead: 3, eof: true } + ) +} + +describe('mobile media lifecycle', () => { + it('survives Strict Mode replay and releases the active file when switching media', async () => { + await act(async () => { + tree = create( + createElement( + StrictMode, + null, + createElement(MobileFileMediaPreview, { media, client, title: 'Movie' }) + ) + ) + }) + if (!tree) { + throw new Error('Player did not mount') + } + const rendered: ReactTestRenderer = tree + expect(rendered.root.findAll((node) => String(node.type) === 'Player')).toHaveLength(1) + const active = downloads.sinks.at(-1) + expect(active?.finish).toHaveBeenCalledOnce() + expect(active?.dispose).not.toHaveBeenCalled() + await act(async () => { + rendered.update( + createElement( + StrictMode, + null, + createElement(MobileFileMediaPreview, { + media: { ...media, relativePath: 'music.mp3', mimeType: 'audio/mpeg' }, + client, + title: 'Music' + }) + ) + ) + }) + expect(active?.dispose).toHaveBeenCalled() + act(() => rendered.unmount()) + tree = null + expect(downloads.sinks.every((sink) => sink.dispose.mock.calls.length > 0)).toBe(true) + }) + it('does not stage late bytes after the preview closes during download', async () => { + let deliver: ((reply: RpcResponse) => void) | undefined + const waiting = { + sendRequest: vi.fn( + async () => + new Promise((resolve) => { + deliver = resolve + }) + ) + } + await act(async () => { + tree = create( + createElement(MobileFileMediaPreview, { media, client: waiting, title: 'Movie' }) + ) + }) + act(() => tree?.unmount()) + tree = null + await act(async () => { + deliver?.(ok({ size: 3, isDirectory: false, mtime: 1 })) + }) + expect(waiting.sendRequest).toHaveBeenCalledOnce() + expect(downloads.sinks[0]?.append).not.toHaveBeenCalled() + expect(downloads.sinks[0]?.finish).not.toHaveBeenCalled() + expect(downloads.sinks[0]?.dispose).toHaveBeenCalled() + }) +}) diff --git a/mobile/src/files/MobileFileMediaPreview.tsx b/mobile/src/files/MobileFileMediaPreview.tsx new file mode 100644 index 000000000000..a648127d256c --- /dev/null +++ b/mobile/src/files/MobileFileMediaPreview.tsx @@ -0,0 +1,88 @@ +import { useEffect, useState } from 'react' +import { ActivityIndicator, Pressable, Text, View } from 'react-native' +import { colors } from '../theme/mobile-theme' +import type { MobileFilePreviewRpcSender } from './mobile-file-preview-operations' +import type { MobileFileMedia } from './mobile-file-media' +import { downloadMobileFileMedia, type MobileMediaSink } from './mobile-file-media-download' +import { createMobileMediaSink } from './mobile-media-preview-cache' +import { MobileMediaPlayback } from './MobileMediaPlayback' +import { filePreviewStyles as styles } from './mobile-file-preview-styles' +import { formatPreviewByteLength } from './mobile-file-preview-response' + +type State = { uri: string } | { error: string } | { bytes: number; total: number } +type Props = { media: MobileFileMedia; client: MobileFilePreviewRpcSender | null; title: string } + +export function MobileFileMediaPreview(props: Props) { + const { worktreeId, relativePath, mimeType } = props.media + return +} + +function MediaDownload({ media, client, title }: Props) { + const { worktreeId, relativePath, mimeType } = media + const [state, setState] = useState({ bytes: 0, total: 0 }) + const [attempt, setAttempt] = useState(0) + useEffect(() => { + const controller = new AbortController() + let sink: MobileMediaSink | null = null + setState({ bytes: 0, total: 0 }) + if (client) { + void (async () => { + try { + sink = createMobileMediaSink(relativePath, mimeType) + const uri = await downloadMobileFileMedia( + client, + { worktreeId, relativePath, mimeType }, + sink, + controller.signal, + (bytes, total) => setState({ bytes, total }) + ) + if (!controller.signal.aborted) { + setState({ uri }) + } + } catch (error) { + if (!controller.signal.aborted) { + setState({ error: error instanceof Error ? error.message : 'Unable to load media' }) + } + } + })() + } + return () => { + controller.abort() + sink?.dispose() + } + }, [client, worktreeId, relativePath, mimeType, attempt]) + if (!client) { + return ( + + Waiting for desktop... + + ) + } + if ('uri' in state) { + return + } + if ('error' in state) { + return ( + + {state.error} + setAttempt(attempt + 1)} + accessibilityRole="button" + > + Retry + + + ) + } + return ( + + + + {state.total + ? `Downloading ${formatPreviewByteLength(state.bytes)} of ${formatPreviewByteLength(state.total)}` + : 'Loading media...'} + + + ) +} diff --git a/mobile/src/files/MobileFilePreviewBody.tsx b/mobile/src/files/MobileFilePreviewBody.tsx index 3f7512fe4487..bbfc51b4c95a 100644 --- a/mobile/src/files/MobileFilePreviewBody.tsx +++ b/mobile/src/files/MobileFilePreviewBody.tsx @@ -1,3 +1,5 @@ +import { MobileFileMediaPreview } from './MobileFileMediaPreview' +import type { MobileFilePreviewRpcSender } from './mobile-file-preview-operations' import { ActivityIndicator, Image, Pressable, ScrollView, Text, View } from 'react-native' import { colors } from '../theme/mobile-theme' import type { MobileFilePreviewResult } from './mobile-file-preview-request' @@ -9,6 +11,7 @@ import { filePreviewStyles as styles } from './mobile-file-preview-styles' type Props = { preview: MobileFilePreviewResult + client?: MobileFilePreviewRpcSender | null relativePath: string title: string editable: boolean @@ -53,6 +56,15 @@ export function MobileFilePreviewBody({ preview, ...options }: Props) { ) } + if (preview.kind === 'media') { + return ( + + ) + } if (preview.kind === 'image') { return ( diff --git a/mobile/src/files/MobileFilePreviewScreen.tsx b/mobile/src/files/MobileFilePreviewScreen.tsx index 493ffffb81f3..65ca3baf6dbc 100644 --- a/mobile/src/files/MobileFilePreviewScreen.tsx +++ b/mobile/src/files/MobileFilePreviewScreen.tsx @@ -122,7 +122,7 @@ export function MobileFilePreviewScreen({ route }: Props) { return } const loadedContent = - result.status === 'ready' && result.kind !== 'image' + result.status === 'ready' && result.kind !== 'image' && result.kind !== 'media' ? result.content : result.status === 'empty' ? '' @@ -261,6 +261,7 @@ export function MobileFilePreviewScreen({ route }: Props) { url === 'about:blank' || url === uri} + /> + ) +} diff --git a/mobile/src/files/MobileMediaPlayback.web.tsx b/mobile/src/files/MobileMediaPlayback.web.tsx new file mode 100644 index 000000000000..51b77ce452f6 --- /dev/null +++ b/mobile/src/files/MobileMediaPlayback.web.tsx @@ -0,0 +1,60 @@ +import { useEffect, useRef, useState } from 'react' +import { Text, View } from 'react-native' +import type { MobileMediaPlaybackProps } from './MobileMediaPlayback' +import { filePreviewStyles as styles } from './mobile-file-preview-styles' + +export function MobileMediaPlayback({ uri, mimeType, title }: MobileMediaPlaybackProps) { + const mediaRef = useRef(null) + const [failure, setFailure] = useState('') + useEffect(() => { + const blockedMedia = (event: SecurityPolicyViolationEvent): void => { + if (event.effectiveDirective === 'media-src') { + setFailure('Update Orca Mobile to play media in this workspace') + } + } + document.addEventListener('securitypolicyviolation', blockedMedia) + const media = mediaRef.current + if (media) { + media.src = uri + } + return () => { + document.removeEventListener('securitypolicyviolation', blockedMedia) + if (media) { + media.pause() + media.removeAttribute('src') + media.load() + } + } + }, [uri]) + if (failure) { + return ( + + {failure} + + ) + } + const props = { + src: uri, + controls: true, + preload: 'metadata', + 'aria-label': title, + ref: (element: HTMLMediaElement | null): void => { + mediaRef.current = element + }, + onError: (): void => + setFailure( + (current) => + current || + 'Unable to play this media file. Its codec may not be supported on this device.' + ) + } + return ( + + {mimeType.startsWith('audio/') ? ( + + ) +} diff --git a/mobile/src/files/file-tree.ts b/mobile/src/files/file-tree.ts index 8a3e795ac2de..e2986af4bd8b 100644 --- a/mobile/src/files/file-tree.ts +++ b/mobile/src/files/file-tree.ts @@ -1,3 +1,4 @@ +import { MEDIA_FILE_MIME_TYPES } from '../../../src/shared/media-file-extensions' // Pure tree projection for the mobile file explorer. Mobile mirrors desktop // browse semantics by flattening cached files.readDir results as folders open. import { compareFileNames } from '../../../src/shared/file-name-sort' @@ -39,6 +40,7 @@ export type FileExplorerRow = TreeNode | InlineStatusNode const DESKTOP_EXCLUDED_NAMES = new Set(['.git', 'node_modules']) const BINARY_EXTENSIONS = new Set([ + ...Object.keys(MEDIA_FILE_MIME_TYPES), '.avif', '.bmp', '.gif', diff --git a/mobile/src/files/mobile-file-explorer-row.tsx b/mobile/src/files/mobile-file-explorer-row.tsx index 36f74ad3b532..fb72494bd120 100644 --- a/mobile/src/files/mobile-file-explorer-row.tsx +++ b/mobile/src/files/mobile-file-explorer-row.tsx @@ -1,3 +1,4 @@ +import { classifyMobileArtifact } from '../session/mobile-artifact-kind' import { ActivityIndicator, Pressable, Text, View } from 'react-native' import { ChevronDown, @@ -82,12 +83,10 @@ function TreeRow(props: { const { item, expanded, onPreviewFile, onToggleDirectory } = props const isDirectory = item.kind === 'directory' const isExpanded = expanded.has(item.relativePath) - // Images render in the mobile viewer (via files.readPreview), so a binary - // image is openable; only non-previewable binaries are unavailable. const previewable = item.kind !== 'directory' && canPreviewMobileFileRow({ kind: item.kind, relativePath: item.relativePath }) - const isImage = item.kind === 'binary' && previewable + const isImage = item.kind === 'binary' && classifyMobileArtifact(item.relativePath) === 'image' const disabled = item.kind === 'binary' && !previewable const markdown = item.kind === 'text' && isMarkdownPath(item.relativePath) diff --git a/mobile/src/files/mobile-file-media-download.test.ts b/mobile/src/files/mobile-file-media-download.test.ts new file mode 100644 index 000000000000..a1f23ad1a875 --- /dev/null +++ b/mobile/src/files/mobile-file-media-download.test.ts @@ -0,0 +1,160 @@ +import { Buffer } from 'buffer/index.js' +import { describe, expect, it, vi } from 'vitest' +import type { RpcResponse } from '../transport/types' +import { + downloadMobileFileMedia, + MOBILE_MEDIA_CHUNK_BYTES, + MOBILE_MEDIA_MAX_BYTES +} from './mobile-file-media-download' + +const media = { + worktreeId: 'folder-workspace', + relativePath: 'clips/demo.mp4', + mimeType: 'video/mp4' +} +const ok = (result: unknown): RpcResponse => ({ + id: '1', + ok: true, + result, + _meta: { runtimeId: 'host' } +}) +const stat = (size: number, mtime = 1, ctime?: number) => + ok({ size, mtime, ctime, isDirectory: false, futureField: true }) +const chunk = (bytes: Uint8Array, eof: boolean) => + ok({ contentBase64: Buffer.from(bytes).toString('base64'), bytesRead: bytes.length, eof }) +function rig(replies: RpcResponse[]) { + const client = { + sendRequest: vi.fn(async (_method: string, _params?: unknown, _options?: unknown) => { + const reply = replies.shift() + if (!reply) { + throw new Error('Unexpected request') + } + return reply + }) + } + const sink = { append: vi.fn(), finish: vi.fn(() => 'file:///cache/media.mp4'), dispose: vi.fn() } + const controller = new AbortController() + const progress = vi.fn() + return { + client, + sink, + controller, + progress, + run: () => downloadMobileFileMedia(client, media, sink, controller.signal, progress) + } +} + +describe('mobile media download', () => { + it.each(['forbidden', 'method_not_found'])( + 'explains when an older host refuses media stat: %s', + async (code) => { + const r = rig([ + { + id: '1', + ok: false, + error: { code, message: 'Unavailable' }, + _meta: { runtimeId: 'host' } + } + ]) + await expect(r.run()).rejects.toThrow('Update Orca on your desktop') + expect(r.client.sendRequest).toHaveBeenCalledTimes(1) + expect(r.sink.dispose).toHaveBeenCalledOnce() + } + ) + it('stages bounded chunks byte-for-byte and verifies the file after downloading', async () => { + const bytes = new Uint8Array(MOBILE_MEDIA_CHUNK_BYTES + 3).fill(123) + const r = rig([ + stat(bytes.length), + chunk(bytes.subarray(0, MOBILE_MEDIA_CHUNK_BYTES), false), + chunk(bytes.subarray(MOBILE_MEDIA_CHUNK_BYTES), true), + stat(bytes.length) + ]) + expect(await r.run()).toBe('file:///cache/media.mp4') + expect(r.client.sendRequest.mock.calls.map((call) => call[0])).toEqual([ + 'files.stat', + 'files.readChunk', + 'files.readChunk', + 'files.stat' + ]) + expect(r.sink.append).toHaveBeenNthCalledWith( + 1, + Buffer.from(bytes.subarray(0, MOBILE_MEDIA_CHUNK_BYTES)) + ) + expect(r.sink.append).toHaveBeenNthCalledWith( + 2, + Buffer.from(bytes.subarray(MOBILE_MEDIA_CHUNK_BYTES)) + ) + expect(r.progress).toHaveBeenLastCalledWith(bytes.length, bytes.length) + expect(r.sink.dispose).not.toHaveBeenCalled() + }) + it('refuses oversized files before requesting any bytes', async () => { + const r = rig([stat(MOBILE_MEDIA_MAX_BYTES + 1)]) + await expect(r.run()).rejects.toThrow('256 MB') + expect(r.client.sendRequest).toHaveBeenCalledTimes(1) + expect(r.sink.append).not.toHaveBeenCalled() + expect(r.sink.dispose).toHaveBeenCalledOnce() + }) + it.each([ + { contentBase64: 'AA==', bytesRead: 3, eof: true }, + { contentBase64: '', bytesRead: 0, eof: false }, + { contentBase64: 'AA==', bytesRead: 1, eof: true }, + { contentBase64: 'AAAAAA==', bytesRead: 4, eof: true }, + { contentBase64: '$invalid', bytesRead: 3, eof: true } + ])('discards a malformed or truncated transfer: %j', async (invalid) => { + const r = rig([stat(3), ok(invalid)]) + await expect(r.run()).rejects.toThrow() + expect(r.sink.append).not.toHaveBeenCalled() + expect(r.sink.finish).not.toHaveBeenCalled() + expect(r.sink.dispose).toHaveBeenCalledOnce() + }) + it('refuses a file modified while it was downloaded', async () => { + const r = rig([stat(3), chunk(new Uint8Array(3), true), stat(3, 2)]) + await expect(r.run()).rejects.toThrow('File changed') + expect(r.sink.finish).not.toHaveBeenCalled() + expect(r.sink.dispose).toHaveBeenCalledOnce() + }) + it('rejects a multi-chunk rewrite that restores the modification time and size', async () => { + const first = new Uint8Array(MOBILE_MEDIA_CHUNK_BYTES).fill(1) + const second = new Uint8Array(3).fill(2) + const size = first.length + second.length + const r = rig([stat(size, 1, 2), chunk(first, false), chunk(second, true), stat(size, 1, 3)]) + await expect(r.run()).rejects.toThrow('File changed') + expect(r.sink.append).toHaveBeenCalledTimes(2) + expect(r.sink.finish).not.toHaveBeenCalled() + expect(r.sink.dispose).toHaveBeenCalledOnce() + }) + it('accepts an unchanged write timestamp and rejects its disappearance', async () => { + const bytes = new Uint8Array([1, 2, 3]) + const unchanged = rig([stat(3, 1, 2), chunk(bytes, true), stat(3, 1, 2)]) + expect(await unchanged.run()).toBe('file:///cache/media.mp4') + const missing = rig([stat(3, 1, 2), chunk(bytes, true), stat(3)]) + await expect(missing.run()).rejects.toThrow('File changed') + expect(missing.sink.dispose).toHaveBeenCalledOnce() + }) + it('never writes or requests another chunk after cancellation during a pending read', async () => { + const r = rig([stat(3)]) + r.client.sendRequest + .mockImplementationOnce(async () => stat(3)) + .mockImplementationOnce(async () => { + r.controller.abort() + return chunk(new Uint8Array(3), true) + }) + await expect(r.run()).rejects.toThrow('cancelled') + expect(r.client.sendRequest).toHaveBeenCalledTimes(2) + expect(r.sink.append).not.toHaveBeenCalled() + expect(r.sink.dispose).toHaveBeenCalledOnce() + }) + it('discards cached bytes when the owning host disconnects', async () => { + const r = rig([ + stat(3), + { + id: '1', + ok: false, + error: { code: 'disconnected', message: 'SSH provider unavailable' }, + _meta: { runtimeId: 'host' } + } + ]) + await expect(r.run()).rejects.toThrow('SSH provider unavailable') + expect(r.sink.dispose).toHaveBeenCalledOnce() + }) +}) diff --git a/mobile/src/files/mobile-file-media-download.ts b/mobile/src/files/mobile-file-media-download.ts new file mode 100644 index 000000000000..56af299fb4de --- /dev/null +++ b/mobile/src/files/mobile-file-media-download.ts @@ -0,0 +1,123 @@ +import { Buffer } from 'buffer/index.js' +import { z } from 'zod' +import { bindDeferredRpcOperation, defineRpcOperation } from '../transport/rpc-operation' +import { rpcResultVariant } from '../transport/rpc-operation-result-reader' +import type { MobileFilePreviewRpcSender } from './mobile-file-preview-operations' +import type { MobileFileMedia } from './mobile-file-media' +import { isMobileMethodUnavailableError } from '../transport/mobile-method-unavailable' + +export const MOBILE_MEDIA_CHUNK_BYTES = 384 * 1024 +export const MOBILE_MEDIA_MAX_BYTES = 256 * 1024 * 1024 + +const statRead = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'files.media-preview-stat', + method: 'files.stat', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: rpcResultVariant( + 'media-file-stat', + z.looseObject({ + size: z.number().int().nonnegative(), + isDirectory: z.boolean(), + mtime: z.number().finite(), + ctime: z.number().finite().optional() + }) + ) + }) +) +const chunkRead = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'files.media-preview-chunk', + method: 'files.readChunk', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: rpcResultVariant( + 'media-file-chunk', + z.looseObject({ + contentBase64: z + .string() + .max((MOBILE_MEDIA_CHUNK_BYTES * 4) / 3) + .regex(/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/), + bytesRead: z.number().int().min(0).max(MOBILE_MEDIA_CHUNK_BYTES), + eof: z.boolean() + }) + ) + }) +) + +export type MobileMediaSink = { + append(bytes: Uint8Array): void + finish(): string + dispose(): void +} + +export async function downloadMobileFileMedia( + client: MobileFilePreviewRpcSender, + media: MobileFileMedia, + sink: MobileMediaSink, + signal: AbortSignal, + onProgress: (bytes: number, total: number) => void +): Promise { + const params = { worktree: `id:${media.worktreeId}`, relativePath: media.relativePath } + const options = { failWhenDisconnected: true, timeoutMs: 30_000 } + try { + checkDownloadActive(signal) + const statReply = await statRead.request(client, params, options) + if ( + !statReply.ok && + isMobileMethodUnavailableError(statReply.error.code, statReply.error.message) + ) { + throw new Error('Update Orca on your desktop to preview media files on mobile') + } + const stat = statRead.interpret(statReply) + checkDownloadActive(signal) + if (stat.isDirectory || stat.size === 0) { + throw new Error('This file has no playable media') + } + if (stat.size > MOBILE_MEDIA_MAX_BYTES) { + throw new Error('Files larger than 256 MB cannot be previewed on mobile') + } + let offset = 0 + while (offset < stat.size) { + checkDownloadActive(signal) + const length = Math.min(MOBILE_MEDIA_CHUNK_BYTES, stat.size - offset) + const chunk = chunkRead.interpret( + await chunkRead.request(client, { ...params, offset, length }, options) + ) + checkDownloadActive(signal) + const bytes = Buffer.from(chunk.contentBase64, 'base64') + if ( + bytes.byteLength !== chunk.bytesRead || + chunk.bytesRead === 0 || + chunk.bytesRead > length || + (chunk.eof && offset + chunk.bytesRead !== stat.size) + ) { + throw new Error('File changed during download. Retry the preview') + } + sink.append(bytes) + offset += chunk.bytesRead + onProgress(offset, stat.size) + } + const latest = statRead.interpret(await statRead.request(client, params, options)) + checkDownloadActive(signal) + if ( + latest.isDirectory || + latest.size !== stat.size || + latest.mtime !== stat.mtime || + (stat.ctime !== undefined && latest.ctime !== stat.ctime) + ) { + throw new Error('File changed during download. Retry the preview') + } + return sink.finish() + } catch (error) { + sink.dispose() + throw error + } +} + +function checkDownloadActive(signal: AbortSignal): void { + if (signal.aborted) { + throw new Error('Media download cancelled') + } +} diff --git a/mobile/src/files/mobile-file-media.ts b/mobile/src/files/mobile-file-media.ts new file mode 100644 index 000000000000..b0f525b9e69a --- /dev/null +++ b/mobile/src/files/mobile-file-media.ts @@ -0,0 +1,18 @@ +import { MEDIA_FILE_MIME_TYPES } from '../../../src/shared/media-file-extensions' + +export type MobileFileMedia = { + worktreeId: string + relativePath: string + mimeType: string +} + +export function mobileFileMedia(worktreeId: string, relativePath?: string): MobileFileMedia | null { + const mimeType = relativePath ? mobileFileMediaMime(relativePath) : null + return relativePath && mimeType ? { worktreeId, relativePath, mimeType } : null +} + +export function mobileFileMediaMime(path: string): string | null { + const name = path.split(/[/\\]/).pop() ?? '' + const dot = name.lastIndexOf('.') + return dot > 0 ? (MEDIA_FILE_MIME_TYPES[name.slice(dot).toLowerCase()] ?? null) : null +} diff --git a/mobile/src/files/mobile-file-preview-editability.ts b/mobile/src/files/mobile-file-preview-editability.ts index d8ed196e5cbe..23cec55e1c0e 100644 --- a/mobile/src/files/mobile-file-preview-editability.ts +++ b/mobile/src/files/mobile-file-preview-editability.ts @@ -11,7 +11,10 @@ export function isEditableMobileTerminalArtifactPreview( return false } return ( - (preview.status === 'ready' && preview.kind !== 'image' && !preview.truncated) || + (preview.status === 'ready' && + preview.kind !== 'image' && + preview.kind !== 'media' && + !preview.truncated) || preview.status === 'empty' ) } diff --git a/mobile/src/files/mobile-file-preview-navigation.test.ts b/mobile/src/files/mobile-file-preview-navigation.test.ts index 56d3a6247e73..2c8f96818dda 100644 --- a/mobile/src/files/mobile-file-preview-navigation.test.ts +++ b/mobile/src/files/mobile-file-preview-navigation.test.ts @@ -60,6 +60,15 @@ describe('mobile-file-preview-navigation', () => { expect(scheduleClose).toHaveBeenCalledWith(expect.any(Function), 0) }) + it.each(['mp4', 'MOV', 'm4v', 'webm', 'mp3', 'wav', 'm4a', 'aac', 'ogg', 'oga', 'flac', 'opus'])( + 'opens %s files from the explorer', + (extension) => { + expect( + canPreviewMobileFileRow({ kind: 'binary', relativePath: `media/demo.${extension}` }) + ).toBe(true) + } + ) + it('keeps non-image binary rows disabled while previewing text and raster images', () => { expect(canPreviewMobileFileRow({ kind: 'text', relativePath: 'src/app.ts' })).toBe(true) expect(canPreviewMobileFileRow({ kind: 'binary', relativePath: 'assets/logo.webp' })).toBe(true) diff --git a/mobile/src/files/mobile-file-preview-navigation.ts b/mobile/src/files/mobile-file-preview-navigation.ts index 3cd89212212e..af63d9dfe58b 100644 --- a/mobile/src/files/mobile-file-preview-navigation.ts +++ b/mobile/src/files/mobile-file-preview-navigation.ts @@ -1,3 +1,4 @@ +import { mobileFileMediaMime } from './mobile-file-media' import { classifyMobileArtifact } from '../session/mobile-artifact-kind' import { defaultScheduleTimer } from '../transport/timer-scheduler' import { @@ -34,5 +35,9 @@ export function canPreviewMobileFileRow(item: { kind: 'text' | 'binary' relativePath: string }): boolean { - return item.kind === 'text' || classifyMobileArtifact(item.relativePath) === 'image' + return ( + item.kind === 'text' || + classifyMobileArtifact(item.relativePath) === 'image' || + mobileFileMediaMime(item.relativePath) !== null + ) } diff --git a/mobile/src/files/mobile-file-preview-request.test.ts b/mobile/src/files/mobile-file-preview-request.test.ts index ff1faa168efb..aa1dc151c2fe 100644 --- a/mobile/src/files/mobile-file-preview-request.test.ts +++ b/mobile/src/files/mobile-file-preview-request.test.ts @@ -33,6 +33,16 @@ function clientWithResponses(responses: RpcResponse[]) { } describe('mobile-file-preview-request', () => { + it('prepares a media descriptor without asking the host to buffer a whole file', async () => { + const client = clientWith(fail('Unexpected whole-file read')) + await expect(loadMobileFilePreview(client, 'folder-project', 'movie.mp4')).resolves.toEqual({ + status: 'ready', + kind: 'media', + media: { worktreeId: 'folder-project', relativePath: 'movie.mp4', mimeType: 'video/mp4' } + }) + expect(client.sendRequest).not.toHaveBeenCalled() + }) + it('selects readPreview for raster images and read for text-like files', () => { expect(createMobileFilePreviewRequest('wt-1', 'assets/logo.png')).toEqual({ method: 'files.readPreview', diff --git a/mobile/src/files/mobile-file-preview-request.ts b/mobile/src/files/mobile-file-preview-request.ts index 5a76dc335608..67fbdc18b47e 100644 --- a/mobile/src/files/mobile-file-preview-request.ts +++ b/mobile/src/files/mobile-file-preview-request.ts @@ -1,3 +1,4 @@ +import { mobileFileMedia } from './mobile-file-media' import { classifyMobileArtifact } from '../session/mobile-artifact-kind' import type { RpcAcceptedResult } from '../transport/rpc-accepted-result' import type { RpcFailure, RpcResponse } from '../transport/types' @@ -143,6 +144,15 @@ export async function loadMobileFilePreview( relativePath?: string, options: TerminalArtifactRetryOptions = {} ): Promise { + const media = + typeof worktreeIdOrSource === 'string' + ? mobileFileMedia(worktreeIdOrSource, relativePath) + : worktreeIdOrSource.source === 'worktree' + ? mobileFileMedia(worktreeIdOrSource.worktreeId, worktreeIdOrSource.relativePath) + : null + if (media) { + return { status: 'ready', kind: 'media', media } + } let source = worktreeIdOrSource let read = await sendMobileFilePreviewRead( client, @@ -296,7 +306,12 @@ function terminalArtifactPreviewMatchesBase( if (preview.status === 'empty') { return baseContent.length === 0 } - return preview.status === 'ready' && preview.kind !== 'image' && preview.content === baseContent + return ( + preview.status === 'ready' && + preview.kind !== 'image' && + preview.kind !== 'media' && + preview.content === baseContent + ) } function previewPathForSource(source: MobileFilePreviewSource): string { diff --git a/mobile/src/files/mobile-file-preview-response.ts b/mobile/src/files/mobile-file-preview-response.ts index fba691dd2c4c..d124464950f2 100644 --- a/mobile/src/files/mobile-file-preview-response.ts +++ b/mobile/src/files/mobile-file-preview-response.ts @@ -1,3 +1,4 @@ +import type { MobileFileMedia } from './mobile-file-media' import { classifyMobileArtifact } from '../session/mobile-artifact-kind' import type { RpcFailure } from '../transport/types' import { isMarkdownPath } from './file-tree' @@ -6,6 +7,7 @@ import { isTerminalArtifactGrantError } from './terminal-artifact-grant-error' export type MobileFilePreviewTextKind = 'html' | 'markdown' | 'text' export type MobileFilePreviewResult = + | { status: 'ready'; kind: 'media'; media: MobileFileMedia } | { status: 'loading' message: string diff --git a/mobile/src/files/mobile-file-preview-retry.test.tsx b/mobile/src/files/mobile-file-preview-retry.test.tsx index 0321b31afac0..46ed986c1bc9 100644 --- a/mobile/src/files/mobile-file-preview-retry.test.tsx +++ b/mobile/src/files/mobile-file-preview-retry.test.tsx @@ -1,3 +1,4 @@ +vi.mock('./MobileFileMediaPreview', () => ({ MobileFileMediaPreview: () => null })) import { createElement } from 'react' import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, describe, expect, it, vi } from 'vitest' diff --git a/mobile/src/files/mobile-file-tab-doc.test.ts b/mobile/src/files/mobile-file-tab-doc.test.ts index 6bd153c67bb8..b56ea2154b5c 100644 --- a/mobile/src/files/mobile-file-tab-doc.test.ts +++ b/mobile/src/files/mobile-file-tab-doc.test.ts @@ -35,6 +35,23 @@ function clientOf(byMethod: Record): { const WT = { worktreeId: 'wt1' } describe('resolveMobileFileTabDoc', () => { + it('hands video and music tabs to the same chunked player without requesting whole-file previews', async () => { + const client = clientOf({}) + for (const [relativePath, mimeType] of [ + ['movie.MP4', 'video/mp4'], + ['music.mp3', 'audio/mpeg'] + ]) { + expect(await resolveMobileFileTabDoc(client, { ...WT, relativePath: relativePath! })).toEqual( + { + status: 'ready', + kind: 'media', + media: { ...WT, relativePath, mimeType } + } + ) + } + expect(client.calls).toEqual([]) + }) + it('renders a staged text diff', async () => { const client = clientOf({ 'git.diff': ok({ kind: 'text', originalContent: 'a\n', modifiedContent: 'a\nb\n' }) diff --git a/mobile/src/files/mobile-file-tab-doc.ts b/mobile/src/files/mobile-file-tab-doc.ts index b22ae9fe396c..5cfd8e334126 100644 --- a/mobile/src/files/mobile-file-tab-doc.ts +++ b/mobile/src/files/mobile-file-tab-doc.ts @@ -1,3 +1,4 @@ +import { mobileFileMedia, type MobileFileMedia } from './mobile-file-media' import { buildImageDataUri } from '../../../src/shared/image-data-uri' import { classifyMobileArtifact } from '../session/mobile-artifact-kind' import { buildMobileDiffLines, type MobileDiffLine } from '../session/mobile-diff-lines' @@ -12,6 +13,7 @@ import { // The ready doc a session file tab renders. Mirrors the ready arm of the route's // FileDocState; kept in src so the loader stays testable without the route. export type MobileFileTabDoc = + | { status: 'ready'; kind: 'media'; media: MobileFileMedia } | { status: 'ready'; kind: 'file'; content: string; truncated: boolean; byteLength: number } | { status: 'ready'; kind: 'diff'; lines: MobileDiffLine[]; truncated: boolean } | { status: 'ready'; kind: 'image'; dataUri: string } @@ -51,6 +53,10 @@ export async function resolveMobileFileTabDoc( return { status: 'ready', kind: 'diff', lines: diff.lines, truncated: diff.truncated } } + const media = mobileFileMedia(request.worktreeId, relativePath) + if (media) { + return { status: 'ready', kind: 'media', media } + } const artifactKind = classifyMobileArtifact(relativePath) if (artifactKind === 'image') { const preview = await fileTabImageRead.request(client, { worktree, relativePath }) diff --git a/mobile/src/files/mobile-media-preview-cache.test.ts b/mobile/src/files/mobile-media-preview-cache.test.ts new file mode 100644 index 000000000000..2b1e5355de3e --- /dev/null +++ b/mobile/src/files/mobile-media-preview-cache.test.ts @@ -0,0 +1,65 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { Buffer } from 'buffer/index.js' + +const state = vi.hoisted(() => ({ write: vi.fn(), close: vi.fn(), remove: vi.fn(), open: vi.fn() })) +vi.mock('expo-file-system', () => ({ + Paths: { cache: 'file:///cache/' }, + File: class { + exists = true + uri = 'file:///cache/media.mp4' + create() {} + open() { + state.open() + return { writeBytes: state.write, close: state.close } + } + delete() { + this.exists = false + state.remove() + } + } +})) +import { createMobileMediaSink } from './mobile-media-preview-cache' + +beforeEach(() => { + vi.clearAllMocks() + state.open.mockReset() +}) +describe('native media staging', () => { + it('writes chunks through one handle and deletes the file after playback', () => { + const sink = createMobileMediaSink('movie.mp4', 'video/mp4') + const bytes = new Uint8Array([1, 2, 3]) + sink.append(bytes) + expect(state.write).toHaveBeenCalledWith(bytes) + expect(sink.finish()).toBe('file:///cache/media.mp4') + expect(state.close).toHaveBeenCalledOnce() + sink.dispose() + sink.dispose() + expect(state.close).toHaveBeenCalledOnce() + expect(state.remove).toHaveBeenCalledOnce() + expect(() => sink.append(bytes)).toThrow('closed') + }) + it('closes and deletes a partially downloaded file', () => { + const sink = createMobileMediaSink('song.mp3', 'audio/mpeg') + sink.dispose() + expect(state.close).toHaveBeenCalledOnce() + expect(state.remove).toHaveBeenCalledOnce() + }) + it('converts decoded Buffer chunks to the native writer’s supported typed array', () => { + state.write.mockImplementation((bytes: Uint8Array) => { + if (bytes.constructor !== Uint8Array) { + throw new Error('unsupported typed array') + } + }) + const sink = createMobileMediaSink('movie.mp4', 'video/mp4') + sink.append(Buffer.from('AQID', 'base64')) + expect(state.write.mock.calls[0]?.[0]).toEqual(new Uint8Array([1, 2, 3])) + sink.dispose() + }) + it('removes the file when opening its handle fails', () => { + state.open.mockImplementation(() => { + throw new Error('disk unavailable') + }) + expect(() => createMobileMediaSink('song.mp3', 'audio/mpeg')).toThrow('disk unavailable') + expect(state.remove).toHaveBeenCalledOnce() + }) +}) diff --git a/mobile/src/files/mobile-media-preview-cache.ts b/mobile/src/files/mobile-media-preview-cache.ts new file mode 100644 index 000000000000..9cb5f2da120e --- /dev/null +++ b/mobile/src/files/mobile-media-preview-cache.ts @@ -0,0 +1,43 @@ +import { File, Paths, type FileHandle } from 'expo-file-system' +import type { MobileMediaSink } from './mobile-file-media-download' + +export function createMobileMediaSink(relativePath: string, _mimeType: string): MobileMediaSink { + const extension = relativePath.split('.').pop()?.toLowerCase() ?? 'bin' + const file = new File(Paths.cache, `orca-preview-${Date.now()}-${Math.random()}.${extension}`) + file.create() + let handle: FileHandle | null + try { + handle = file.open() + } catch (error) { + file.delete() + throw error + } + const close = (): void => { + const openHandle = handle + handle = null + openHandle?.close() + } + return { + append: (bytes) => { + if (!handle) { + throw new Error('Media preview was closed') + } + // Expo identifies typed arrays by constructor; Buffer subclasses are rejected. + handle.writeBytes(new Uint8Array(bytes)) + }, + finish: () => { + close() + return file.uri + }, + dispose: () => { + try { + close() + if (file.exists) { + file.delete() + } + } catch { + // The OS can reclaim a cache file if deletion fails. + } + } + } +} diff --git a/mobile/src/files/mobile-media-preview-cache.web.ts b/mobile/src/files/mobile-media-preview-cache.web.ts new file mode 100644 index 000000000000..131a56f37af8 --- /dev/null +++ b/mobile/src/files/mobile-media-preview-cache.web.ts @@ -0,0 +1,23 @@ +import type { MobileMediaSink } from './mobile-file-media-download' + +export function createMobileMediaSink(_relativePath: string, mimeType: string): MobileMediaSink { + let parts: Uint8Array[] = [] + let uri: string | null = null + return { + append: (bytes) => { + parts.push(new Uint8Array(bytes)) + }, + finish: () => { + uri = URL.createObjectURL(new Blob(parts, { type: mimeType })) + parts = [] + return uri + }, + dispose: () => { + if (uri) { + URL.revokeObjectURL(uri) + uri = null + } + parts = [] + } + } +} diff --git a/mobile/src/files/mobile-media-preview-document.ts b/mobile/src/files/mobile-media-preview-document.ts new file mode 100644 index 000000000000..077d85c14636 --- /dev/null +++ b/mobile/src/files/mobile-media-preview-document.ts @@ -0,0 +1,18 @@ +import { escapeHtml } from '../components/rich-markdown/markdown-escaping' +import { colors, spacing, typography } from '../theme/mobile-theme' + +export function mobileMediaPreviewDocument(uri: string, mimeType: string, title: string): string { + const tag = mimeType.startsWith('audio/') ? 'audio' : 'video' + return ` + + + +<${tag} controls playsinline preload="metadata" src="${escapeHtml(uri)}" aria-label="${escapeHtml(title)}" +onerror="this.hidden=true;document.getElementById('error').hidden=false"> + +` +} diff --git a/mobile/src/session/MobileSessionActiveContent.tsx b/mobile/src/session/MobileSessionActiveContent.tsx index b616f11c7fe0..53918879206d 100644 --- a/mobile/src/session/MobileSessionActiveContent.tsx +++ b/mobile/src/session/MobileSessionActiveContent.tsx @@ -128,6 +128,7 @@ export function MobileSessionActiveContent({ ) : activeFileTab ? ( + } if (doc.kind === 'image') { return ( diff --git a/mobile/src/session/mobile-session-route-types.ts b/mobile/src/session/mobile-session-route-types.ts index b8ef9a8764cc..7a71ee9ab40a 100644 --- a/mobile/src/session/mobile-session-route-types.ts +++ b/mobile/src/session/mobile-session-route-types.ts @@ -1,3 +1,4 @@ +import type { MobileFileMedia } from '../files/mobile-file-media' import type { AgentSessionHandleProvider } from '../../../src/shared/agent-session-provider-handle' import type { DiffComment } from '../../../src/shared/diff-comment-types' import type { TuiAgent } from '../../../src/shared/tui-agent' @@ -97,6 +98,7 @@ export type MarkdownDocState = | { status: 'error'; message: string } export type FileDocState = + | { status: 'ready'; kind: 'media'; media: MobileFileMedia } | { status: 'loading' } | { status: 'ready'; kind: 'file'; content: string; truncated: boolean; byteLength: number } | { status: 'ready'; kind: 'diff'; lines: MobileDiffLine[]; truncated: boolean } diff --git a/mobile/web-entry/web-overrides.json b/mobile/web-entry/web-overrides.json index c0c3bcb43757..c1ba2a2a92e5 100644 --- a/mobile/web-entry/web-overrides.json +++ b/mobile/web-entry/web-overrides.json @@ -208,6 +208,14 @@ { "file": "src/app-update/use-wall-app-update.web.ts", "reason": "The update checker runs in the app, not the page. The native file hands the compatibility wall the release that check found; this one offers none, so the wall inside the page keeps its store or releases link and the page bundle never carries the checker." + }, + { + "file": "src/files/MobileMediaPlayback.web.tsx", + "reason": "The browser and hybrid shell play cached Blob URLs with HTML media controls. The native sibling uses the existing WebView to play its private cache file." + }, + { + "file": "src/files/mobile-media-preview-cache.web.ts", + "reason": "The page has no native filesystem. This cache creates a temporary Blob URL from bounded chunks and revokes it when the preview closes; native builds write the chunks to a cache file." } ] } diff --git a/src/main/runtime/orca-runtime-files-mobile-explorer-reads.test.ts b/src/main/runtime/orca-runtime-files-mobile-explorer-reads.test.ts index 63f1ea7eed83..f12e89deeba4 100644 --- a/src/main/runtime/orca-runtime-files-mobile-explorer-reads.test.ts +++ b/src/main/runtime/orca-runtime-files-mobile-explorer-reads.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import type * as FileSystem from 'node:fs/promises' import { enoent, readdirMock, @@ -45,6 +46,46 @@ function dirEntry(args: { name: string; directory?: boolean; symlink?: boolean } describe('RuntimeFileCommands', () => { useRuntimeFileCommandsLifecycle() + it('returns the authorized local file change time for media consistency checks', async () => { + const { commands } = createRuntimeFileCommands() + resolveAuthorizedPathMock.mockResolvedValue('/repo/video.mp4') + statMock.mockResolvedValue({ size: 3, mtimeMs: 1, ctimeMs: 2, isDirectory: () => false }) + expect(await commands.statRuntimeFile('id:wt-1', 'video.mp4')).toEqual({ + size: 3, + isDirectory: false, + mtime: 1, + ctime: 2 + }) + expect(resolveAuthorizedPathMock).toHaveBeenCalled() + expect(statMock).toHaveBeenCalledWith('/repo/video.mp4') + }) + + it('detects a real same-size rewrite even after restoring the modification time', async () => { + vi.useRealTimers() + const files = await vi.importActual('node:fs/promises') + const { join } = await import('node:path') + const { tmpdir } = await import('node:os') + const root = await files.mkdtemp(join(tmpdir(), 'orca-media-revision-')) + const filePath = join(root, 'video.mp4') + try { + await files.writeFile(filePath, 'abc') + await files.utimes(filePath, 1, 2) + const { commands } = createRuntimeFileCommands() + resolveAuthorizedPathMock.mockResolvedValue(filePath) + statMock.mockImplementation((path: string) => files.stat(path)) + const before = await commands.statRuntimeFile('id:wt-1', 'video.mp4') + await new Promise((resolve) => setTimeout(resolve, 20)) + await files.writeFile(filePath, 'xyz') + await files.utimes(filePath, 1, 2) + const after = await commands.statRuntimeFile('id:wt-1', 'video.mp4') + expect(after.size).toBe(before.size) + expect(after.mtime).toBe(before.mtime) + expect(after.ctime).not.toBe(before.ctime) + } finally { + await files.rm(root, { recursive: true, force: true }) + } + }) + it('opens source control diffs through the renderer host (inheriting active runtime env)', async () => { const openDiff = vi.fn() const { commands } = createRuntimeFileCommands({ openDiff }) diff --git a/src/main/runtime/runtime-file-commands-search-runtime-files.ts b/src/main/runtime/runtime-file-commands-search-runtime-files.ts index 3b798be6de62..d445e4824b73 100644 --- a/src/main/runtime/runtime-file-commands-search-runtime-files.ts +++ b/src/main/runtime/runtime-file-commands-search-runtime-files.ts @@ -132,7 +132,7 @@ export class RuntimeFileCommandsWithSearchRuntimeFiles extends RuntimeFileComman async statRuntimeFile( worktreeSelector: string, relativePath: string - ): Promise<{ size: number; isDirectory: boolean; mtime: number }> { + ): Promise<{ size: number; isDirectory: boolean; mtime: number; ctime?: number }> { const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath) const provider = requireRuntimeFileProvider(target) if (provider) { @@ -145,6 +145,11 @@ export class RuntimeFileCommandsWithSearchRuntimeFiles extends RuntimeFileComman } const filePath = await resolveAuthorizedPath(target.path, this.host.requireStore()) const stats = await stat(filePath) - return { size: stats.size, isDirectory: stats.isDirectory(), mtime: stats.mtimeMs } + return { + size: stats.size, + isDirectory: stats.isDirectory(), + mtime: stats.mtimeMs, + ctime: stats.ctimeMs + } } } diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts index 0b2192751c5f..c6e43823a808 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts @@ -52,6 +52,7 @@ export const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'files.readTerminalArtifactPreview', 'files.resolveTerminalPath', 'files.searchPaths', + 'files.stat', 'files.writeTerminalArtifact', 'folderWorkspace.list', 'git.abortMerge', From e4da27e1e947b86faea8c78c67dd09ca9fa4e328 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 7 Oct 2026 04:08:28 -0700 Subject: [PATCH 003/114] perf(tests): reuse the first UI-state store constructor per case (#26185) --- src/main/persistence-ui-state.test.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/main/persistence-ui-state.test.ts b/src/main/persistence-ui-state.test.ts index 4324e61cdf56..bf6ff8e5e98a 100644 --- a/src/main/persistence-ui-state.test.ts +++ b/src/main/persistence-ui-state.test.ts @@ -8,6 +8,7 @@ import { createDefaultWorkspaceCleanupBrowseState } from '../shared/workspace-cl import { closeTestStores, createSqliteTestStore, + createStore as createFreshStore, readPersistedStateJson, testState, dataFile, @@ -38,8 +39,12 @@ vi.mock('electron', () => ({ } })) +let hasCreatedStoreInCase = false + async function createStore() { - vi.resetModules() + if (hasCreatedStoreInCase) { + vi.resetModules() + } const { setSecretStore } = await import('../shared/secret-store') setSecretStore({ isEncryptionAvailable: () => true, @@ -53,6 +58,10 @@ async function createStore() { }, describeProtectionGap: () => null }) + if (!hasCreatedStoreInCase) { + hasCreatedStoreInCase = true + return createFreshStore() + } const { Store, initDataPath } = await import('./persistence') // Why here: userData resolves through AppEnvironment, and this must point at this // file's temp dir rather than the global fake's shared one, after resetModules. @@ -71,6 +80,7 @@ vi.mock('./telemetry/cohort-classifier', () => ({ describe('Store', () => { beforeEach(() => { + hasCreatedStoreInCase = false testState.dir = mkdtempSync(join(tmpdir(), 'orca-test-')) trackMock.mockReset() getCohortAtEmitMock.mockReset() From b8c967196bdbcc0b5c93ba586b744ecd7c964349 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 7 Oct 2026 04:09:51 -0700 Subject: [PATCH 004/114] test(ssh): isolate permission probes from real PID reuse (#26190) --- .../ssh/orcad-remote-shell-commands.integration.test.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/main/ssh/orcad-remote-shell-commands.integration.test.ts b/src/main/ssh/orcad-remote-shell-commands.integration.test.ts index 3e9ff5587073..9227ccd9a00b 100644 --- a/src/main/ssh/orcad-remote-shell-commands.integration.test.ts +++ b/src/main/ssh/orcad-remote-shell-commands.integration.test.ts @@ -486,12 +486,19 @@ describe('liveness and stop commands, run for real', () => { it('reports a permission-denied liveness probe as UNKNOWN in any locale', () => { writeFileSync(join(versionDir, ORCAD_PID_FILENAME), '4242') + // Keep the permission branch independent of unrelated host PID reuse. + const matchingPs = + 'ps() { [ "$#" = 4 ] && [ "$1" = -o ] && [ "$2" = args= ] && ' + + '[ "$3" = -p ] && [ "$4" = 4242 ] || return 1; ' + + `printf 'node %s\\n' ${shellEscape(join(versionDir, 'orcad.js'))}; };` const deniedKill = 'kill() { if [ "$LC_ALL" = C ]; then echo "kill: Operation not permitted" >&2; ' + 'else echo "kill: Vorgang nicht zulässig" >&2; fi; return 1; };' expect( parseOrcadLiveness( - sh(`LC_ALL=de_DE.UTF-8; ${deniedKill} ${orcadLivenessProbeCommand(host, versionDir)}`) + sh( + `LC_ALL=de_DE.UTF-8; ${matchingPs} ${deniedKill} ${orcadLivenessProbeCommand(host, versionDir)}` + ) ) ).toBe('UNKNOWN') }) From 2dc98c93de675947958960061a335bcfbfb3f838 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 7 Oct 2026 04:43:34 -0700 Subject: [PATCH 005/114] perf(tests): reuse composer decisions without loading the hook (#26179) --- .../composer-state/composer-decisions.ts | 140 +++++++++++++++ .../source-selection-decisions.ts | 3 +- .../hooks/useComposerState-decisions.test.ts | 2 +- ...poserState-host-context-boundaries.test.ts | 2 +- .../useComposerState-host-retarget.test.ts | 2 +- .../useComposerState.integration.test.ts | 29 +++ src/renderer/src/hooks/useComposerState.ts | 170 +++--------------- 7 files changed, 199 insertions(+), 149 deletions(-) diff --git a/src/renderer/src/hooks/composer-state/composer-decisions.ts b/src/renderer/src/hooks/composer-state/composer-decisions.ts index 3538a4687f83..ce852acc7c55 100644 --- a/src/renderer/src/hooks/composer-state/composer-decisions.ts +++ b/src/renderer/src/hooks/composer-state/composer-decisions.ts @@ -1,3 +1,12 @@ +import { + getLinkedWorkItemSuggestedName, + getLinkedWorkItemWorkspaceName +} from '../../../../shared/workspace-name' +import { normalizeExecutionHostId } from '../../../../shared/execution-host' +import { isWorkItemLookupText } from '@/lib/work-item-lookup-text' +import { resolveGitHubWorkItemIdentity } from '@/lib/github-work-item-identity' +import { isWorkspaceLinkedItemSourceContextMatch } from '../../../../shared/workspace-linked-item-source-context' +import { getGitHubLinkedWorkItemIdentity } from './source-selection-decisions' import type { GitHubWorkItem } from '../../../../shared/github/work-item-types' import type { TaskSourceContext } from '../../../../shared/task-source-context' import type { ExecutionHostId } from '../../../../shared/execution-host' @@ -47,3 +56,134 @@ export type ComposerDecisions = { repoId: string ) => SmartGitHubPrStartPointSelection | null } + +export function canResolveFolderSmartGitHubSubmit({ + hasFolderSourceRepos +}: { + hasFolderSourceRepos: boolean +}): boolean { + return hasFolderSourceRepos +} + +export function isExplicitWorkspaceNameInput({ + name, + lastAutoName +}: { + name: string + lastAutoName: string +}): boolean { + // Why: a user-authored name must win over linked-item and first-message AI naming. + return Boolean(name.trim()) && name !== lastAutoName && !isWorkItemLookupText(name) +} + +export function resolveSmartGitHubCreateNames({ + resolutionKind, + smartWorkspaceName, + smartDisplayName, + fallbackWorkspaceName, + nameIsAutoManaged +}: { + resolutionKind: 'metadata-only' | 'pr-start-point' + smartWorkspaceName: string + smartDisplayName: string | undefined + fallbackWorkspaceName: string + nameIsAutoManaged: boolean +}): { workspaceName: string; displayName: string | undefined } { + if (resolutionKind === 'pr-start-point' && !nameIsAutoManaged && fallbackWorkspaceName) { + return { workspaceName: fallbackWorkspaceName, displayName: undefined } + } + return { workspaceName: smartWorkspaceName, displayName: smartDisplayName } +} + +function getLinkedWorkItemSeedName(item: LinkedWorkItemSummary | null | undefined): string { + if (!item) { + return '' + } + return getLinkedWorkItemWorkspaceName(item)?.seedName ?? getLinkedWorkItemSuggestedName(item) +} + +export function getInitialAutoManagedWorkspaceName({ + draftName, + draftLinkedWorkItem, + initialName, + initialLinkedWorkItem +}: { + draftName?: string | null + draftLinkedWorkItem?: LinkedWorkItemSummary | null + initialName: string + initialLinkedWorkItem?: LinkedWorkItemSummary | null +}): string { + // Why: a prefilled name counts as user input unless it exactly matches the linked-item seed Orca generated. + const candidateName = draftName ?? initialName + const seedName = getLinkedWorkItemSeedName(draftLinkedWorkItem ?? initialLinkedWorkItem) + return candidateName && seedName && candidateName === seedName ? candidateName : '' +} + +export type InitialWorkspaceRunSeedInput = { + draftProjectId?: string | null + draftHostId?: string | null + draftProjectHostSetupId?: string | null + initialTaskSourceContext?: Pick< + TaskSourceContext, + 'projectId' | 'hostId' | 'projectHostSetupId' + > | null +} + +export function resolveInitialWorkspaceRunSeed({ + draftProjectId, + draftHostId, + draftProjectHostSetupId, + initialTaskSourceContext +}: InitialWorkspaceRunSeedInput): { + projectId: string | null + hostId: ExecutionHostId | null + projectHostSetupId: string | null +} { + return { + projectId: draftProjectId ?? initialTaskSourceContext?.projectId ?? null, + hostId: normalizeExecutionHostId(draftHostId ?? initialTaskSourceContext?.hostId), + projectHostSetupId: + draftProjectHostSetupId ?? initialTaskSourceContext?.projectHostSetupId ?? null + } +} + +export function getInitialGitHubPrStartPointSelection({ + item, + linkedWorkItem, + repoId +}: { + item: GitHubWorkItem | null | undefined + linkedWorkItem: LinkedWorkItemSummary | null | undefined + repoId: string | null | undefined +}): SmartGitHubPrStartPointSelection | null { + if (!item || !repoId) { + return null + } + const itemIdentity = resolveGitHubWorkItemIdentity(item) + const linkedIdentity = getGitHubLinkedWorkItemIdentity(linkedWorkItem) + if ( + itemIdentity.type !== 'pr' || + linkedIdentity?.type !== 'pr' || + linkedIdentity.number !== itemIdentity.number + ) { + return null + } + return { + repoId, + item: { ...item, type: itemIdentity.type, number: itemIdentity.number } + } +} + +export function retargetGitHubPrStartPointSelection( + selection: SmartGitHubPrStartPointSelection | null, + repoId: string +): SmartGitHubPrStartPointSelection | null { + return selection ? { repoId, item: selection.item } : null +} + +export function getMatchingLinkedTaskSourceContext( + item: LinkedWorkItemSummary | null | undefined, + context: TaskSourceContext | null | undefined +): TaskSourceContext | null { + return isWorkspaceLinkedItemSourceContextMatch(item, context) ? (context ?? null) : null +} diff --git a/src/renderer/src/hooks/composer-state/source-selection-decisions.ts b/src/renderer/src/hooks/composer-state/source-selection-decisions.ts index 5977e7bf2c4b..cb5511faf4ae 100644 --- a/src/renderer/src/hooks/composer-state/source-selection-decisions.ts +++ b/src/renderer/src/hooks/composer-state/source-selection-decisions.ts @@ -1,6 +1,7 @@ import type { GitHubPrStartPoint, GitPushTarget } from '../../../../shared/worktree/types' import type { GitHubWorkItem } from '../../../../shared/github/work-item-types' -import { getLinkedWorkItemProvider, type LinkedWorkItemSummary } from '@/lib/new-workspace' +import { getLinkedWorkItemProvider } from '@/lib/linked-work-item-provider' +import type { LinkedWorkItemSummary } from '@/lib/new-workspace' import type { SmartGitHubSubmitResolution } from '@/lib/smart-github-submit' import { resolveGitHubWorkItemIdentity, diff --git a/src/renderer/src/hooks/useComposerState-decisions.test.ts b/src/renderer/src/hooks/useComposerState-decisions.test.ts index fa23b77d8145..862a1ff7589e 100644 --- a/src/renderer/src/hooks/useComposerState-decisions.test.ts +++ b/src/renderer/src/hooks/useComposerState-decisions.test.ts @@ -4,7 +4,7 @@ import { getMatchingLinkedTaskSourceContext, resolveInitialWorkspaceRunSeed, resolveSmartGitHubCreateNames -} from './useComposerState' +} from './composer-state/composer-decisions' describe('useComposerState public decisions', () => { it('preserves a user name for PR start points only when the fallback is non-empty', () => { diff --git a/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts b/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts index 9d76c9f9a232..9e51e8d82911 100644 --- a/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts +++ b/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts @@ -6,7 +6,7 @@ import { isExplicitWorkspaceNameInput, resolveSmartGitHubCreateNames, resolveInitialWorkspaceRunSeed -} from './useComposerState' +} from './composer-state/composer-decisions' describe('useComposerState host-context boundaries', () => { it('seeds TaskPage pull requests as submit-time PR start points', () => { diff --git a/src/renderer/src/hooks/useComposerState-host-retarget.test.ts b/src/renderer/src/hooks/useComposerState-host-retarget.test.ts index c40feea68814..7cf201b52df6 100644 --- a/src/renderer/src/hooks/useComposerState-host-retarget.test.ts +++ b/src/renderer/src/hooks/useComposerState-host-retarget.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { retargetGitHubPrStartPointSelection } from './useComposerState' +import { retargetGitHubPrStartPointSelection } from './composer-state/composer-decisions' describe('useComposerState host retarget', () => { it('re-resolves a seeded PR after switching its run host', () => { diff --git a/src/renderer/src/hooks/useComposerState.integration.test.ts b/src/renderer/src/hooks/useComposerState.integration.test.ts index b80d6d3bf180..318aad599cad 100644 --- a/src/renderer/src/hooks/useComposerState.integration.test.ts +++ b/src/renderer/src/hooks/useComposerState.integration.test.ts @@ -4,6 +4,8 @@ import { act, renderHook } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useAppStore } from '@/store' import { useComposerState } from './useComposerState' +import * as publicComposerDecisions from './useComposerState' +import * as composerDecisions from './composer-state/composer-decisions' let originalApiDescriptor: PropertyDescriptor | undefined @@ -31,6 +33,33 @@ afterEach(() => { }) describe('useComposerState integrated lifecycle', () => { + it('keeps the public composer decisions bound to the canonical decision module', () => { + expect(publicComposerDecisions.canResolveFolderSmartGitHubSubmit).toBe( + composerDecisions.canResolveFolderSmartGitHubSubmit + ) + expect(publicComposerDecisions.getInitialAutoManagedWorkspaceName).toBe( + composerDecisions.getInitialAutoManagedWorkspaceName + ) + expect(publicComposerDecisions.getInitialGitHubPrStartPointSelection).toBe( + composerDecisions.getInitialGitHubPrStartPointSelection + ) + expect(publicComposerDecisions.getMatchingLinkedTaskSourceContext).toBe( + composerDecisions.getMatchingLinkedTaskSourceContext + ) + expect(publicComposerDecisions.isExplicitWorkspaceNameInput).toBe( + composerDecisions.isExplicitWorkspaceNameInput + ) + expect(publicComposerDecisions.resolveInitialWorkspaceRunSeed).toBe( + composerDecisions.resolveInitialWorkspaceRunSeed + ) + expect(publicComposerDecisions.resolveSmartGitHubCreateNames).toBe( + composerDecisions.resolveSmartGitHubCreateNames + ) + expect(publicComposerDecisions.retargetGitHubPrStartPointSelection).toBe( + composerDecisions.retargetGitHubPrStartPointSelection + ) + }) + it('composes two live composers and exposes the parent-worktree control state', () => { useAppStore.setState({ repos: [], diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index 31925c86b98a..228bc63d15e1 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -1,24 +1,23 @@ import type { RefObject } from 'react' -import { - getLinkedWorkItemSuggestedName, - getLinkedWorkItemWorkspaceName, - type LinkedWorkItemSummary -} from '@/lib/new-workspace' +import '@/lib/new-workspace' +import type { LinkedWorkItemSummary } from '@/lib/new-workspace' import type { GitHubWorkItem } from '../../../shared/github/work-item-types' import type { TuiAgent } from '../../../shared/tui-agent' import type { TaskSourceContext } from '../../../shared/task-source-context' import type { WorkspaceSource as WorkspaceCreateTelemetrySource } from '../../../shared/workspace-source' import type { WorkspaceStatus } from '../../../shared/worktree/types' -import { normalizeExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' -import { isWorkItemLookupText } from '@/lib/work-item-lookup-text' -import { resolveGitHubWorkItemIdentity } from '@/lib/github-work-item-identity' -import { isWorkspaceLinkedItemSourceContextMatch } from '../../../shared/workspace-linked-item-source-context' -import { - getGitHubLinkedWorkItemIdentity, - type SmartGitHubPrStartPointSelection -} from './composer-state/source-selection-decisions' import { buildComposerCardProps } from './composer-state/composer-card-props' -import type { ComposerDecisions } from './composer-state/composer-decisions' +import { + canResolveFolderSmartGitHubSubmit, + getInitialAutoManagedWorkspaceName, + getInitialGitHubPrStartPointSelection, + getMatchingLinkedTaskSourceContext, + isExplicitWorkspaceNameInput, + resolveInitialWorkspaceRunSeed, + resolveSmartGitHubCreateNames, + retargetGitHubPrStartPointSelection, + type ComposerDecisions +} from './composer-state/composer-decisions' import { useComposerTargetState } from './composer-state/composer-target-state' import { useComposerExternalSync } from './composer-state/composer-external-sync' import { useComposerSourceState } from './composer-state/composer-source-state' @@ -29,6 +28,18 @@ import type { ComposerCardSourceProps } from './composer-state/composer-card-contract' +export { + canResolveFolderSmartGitHubSubmit, + getInitialAutoManagedWorkspaceName, + getInitialGitHubPrStartPointSelection, + getMatchingLinkedTaskSourceContext, + isExplicitWorkspaceNameInput, + resolveInitialWorkspaceRunSeed, + resolveSmartGitHubCreateNames, + retargetGitHubPrStartPointSelection +} from './composer-state/composer-decisions' +export type { InitialWorkspaceRunSeedInput } from './composer-state/composer-decisions' + export type UseComposerStateOptions = { initialRepoId?: string initialEphemeralVmRecipeId?: string @@ -62,137 +73,6 @@ export type UseComposerStateResult = { selectAddedProjectRepo: (repoId: string) => void } -export function canResolveFolderSmartGitHubSubmit({ - hasFolderSourceRepos -}: { - hasFolderSourceRepos: boolean -}): boolean { - return hasFolderSourceRepos -} - -export function isExplicitWorkspaceNameInput({ - name, - lastAutoName -}: { - name: string - lastAutoName: string -}): boolean { - // Why: a user-authored name must win over linked-item and first-message AI naming. - return Boolean(name.trim()) && name !== lastAutoName && !isWorkItemLookupText(name) -} - -export function resolveSmartGitHubCreateNames({ - resolutionKind, - smartWorkspaceName, - smartDisplayName, - fallbackWorkspaceName, - nameIsAutoManaged -}: { - resolutionKind: 'metadata-only' | 'pr-start-point' - smartWorkspaceName: string - smartDisplayName: string | undefined - fallbackWorkspaceName: string - nameIsAutoManaged: boolean -}): { workspaceName: string; displayName: string | undefined } { - if (resolutionKind === 'pr-start-point' && !nameIsAutoManaged && fallbackWorkspaceName) { - return { workspaceName: fallbackWorkspaceName, displayName: undefined } - } - return { workspaceName: smartWorkspaceName, displayName: smartDisplayName } -} - -function getLinkedWorkItemSeedName(item: LinkedWorkItemSummary | null | undefined): string { - if (!item) { - return '' - } - return getLinkedWorkItemWorkspaceName(item)?.seedName ?? getLinkedWorkItemSuggestedName(item) -} - -export function getInitialAutoManagedWorkspaceName({ - draftName, - draftLinkedWorkItem, - initialName, - initialLinkedWorkItem -}: { - draftName?: string | null - draftLinkedWorkItem?: LinkedWorkItemSummary | null - initialName: string - initialLinkedWorkItem?: LinkedWorkItemSummary | null -}): string { - // Why: a prefilled name counts as user input unless it exactly matches the linked-item seed Orca generated. - const candidateName = draftName ?? initialName - const seedName = getLinkedWorkItemSeedName(draftLinkedWorkItem ?? initialLinkedWorkItem) - return candidateName && seedName && candidateName === seedName ? candidateName : '' -} - -export type InitialWorkspaceRunSeedInput = { - draftProjectId?: string | null - draftHostId?: string | null - draftProjectHostSetupId?: string | null - initialTaskSourceContext?: Pick< - TaskSourceContext, - 'projectId' | 'hostId' | 'projectHostSetupId' - > | null -} - -export function resolveInitialWorkspaceRunSeed({ - draftProjectId, - draftHostId, - draftProjectHostSetupId, - initialTaskSourceContext -}: InitialWorkspaceRunSeedInput): { - projectId: string | null - hostId: ExecutionHostId | null - projectHostSetupId: string | null -} { - return { - projectId: draftProjectId ?? initialTaskSourceContext?.projectId ?? null, - hostId: normalizeExecutionHostId(draftHostId ?? initialTaskSourceContext?.hostId), - projectHostSetupId: - draftProjectHostSetupId ?? initialTaskSourceContext?.projectHostSetupId ?? null - } -} - -export function getInitialGitHubPrStartPointSelection({ - item, - linkedWorkItem, - repoId -}: { - item: GitHubWorkItem | null | undefined - linkedWorkItem: LinkedWorkItemSummary | null | undefined - repoId: string | null | undefined -}): SmartGitHubPrStartPointSelection | null { - if (!item || !repoId) { - return null - } - const itemIdentity = resolveGitHubWorkItemIdentity(item) - const linkedIdentity = getGitHubLinkedWorkItemIdentity(linkedWorkItem) - if ( - itemIdentity.type !== 'pr' || - linkedIdentity?.type !== 'pr' || - linkedIdentity.number !== itemIdentity.number - ) { - return null - } - return { - repoId, - item: { ...item, type: itemIdentity.type, number: itemIdentity.number } - } -} - -export function retargetGitHubPrStartPointSelection( - selection: SmartGitHubPrStartPointSelection | null, - repoId: string -): SmartGitHubPrStartPointSelection | null { - return selection ? { repoId, item: selection.item } : null -} - -export function getMatchingLinkedTaskSourceContext( - item: LinkedWorkItemSummary | null | undefined, - context: TaskSourceContext | null | undefined -): TaskSourceContext | null { - return isWorkspaceLinkedItemSourceContextMatch(item, context) ? (context ?? null) : null -} - const COMPOSER_DECISIONS: ComposerDecisions = { canResolveFolderSmartGitHubSubmit, getInitialAutoManagedWorkspaceName, From 7f8b4a8dd4c29ce37c059f54731873cdcdf0541f Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 7 Oct 2026 05:36:43 -0700 Subject: [PATCH 006/114] test: isolate remote upload cases from bootstrap writes and clock rollback (#26202) --- ...space-unplaced-upload-suppression.test.tsx | 48 ++++++++++++++++--- 1 file changed, 42 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/app-shell/remote-workspace-unplaced-upload-suppression.test.tsx b/src/renderer/src/app-shell/remote-workspace-unplaced-upload-suppression.test.tsx index 81aaf945e367..c2af06208d41 100644 --- a/src/renderer/src/app-shell/remote-workspace-unplaced-upload-suppression.test.tsx +++ b/src/renderer/src/app-shell/remote-workspace-unplaced-upload-suppression.test.tsx @@ -21,6 +21,7 @@ import type { import type { DirectSshAuthority, SshProviderEpoch } from '../../../shared/ssh-types' import type { DirectSshPreparationInput } from '../hooks/direct-ssh-reconnect-coordinator' import { createRemoteWorkspaceTargetSync } from '../hooks/remote-workspace-target-sync' +import { isDirectSshRemoteWorkspaceApplyInProgress } from '../hooks/remote-workspace-snapshot-apply' import { makeWorktree } from '../store/slices/store-test-helpers' vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) @@ -31,6 +32,7 @@ vi.mock('@/lib/agent-status', async (importOriginal) => { import { useAppStore } from '@/store' import { useAppSessionPersistence } from './use-app-session-persistence' +import * as workspaceSessionHostPersistence from '../lib/workspace-session-host-persistence' const TARGET_ID = 'target-a' const REPO_ROOT = '/srv/proj' @@ -111,6 +113,7 @@ function installWindowApi(sessionPatch = vi.fn(async () => {})): void { function seedStore(withHostCatalog: boolean): void { useAppStore.setState({ + ...useAppStore.getInitialState(), workspaceSessionReady: true, hydrationSucceeded: true, repos: [ @@ -199,6 +202,30 @@ function createSync( }) } +// The initial full write must settle before measured upload responses are installed. +async function renderSettledPersistence() { + const pendingWrites: Promise[] = [] + const persistByHost = workspaceSessionHostPersistence.patchWorkspaceSessionByHost + const observeWrite = vi.spyOn(workspaceSessionHostPersistence, 'patchWorkspaceSessionByHost') + observeWrite.mockImplementation((...args) => { + const result = persistByHost(...args) + pendingWrites.push(result.written) + return result + }) + try { + installWindowApi() + const persistence = renderHook(() => useAppSessionPersistence()) + await vi.advanceTimersByTimeAsync(DEBOUNCE_MS) + expect(pendingWrites).toHaveLength(1) + await Promise.all(pendingWrites) + expect(uploads).not.toHaveBeenCalled() + return persistence + } finally { + await Promise.allSettled(pendingWrites) + observeWrite.mockRestore() + } +} + /** A local edit the persistence subscriber must want to write, then let it settle. */ async function touchSessionAndSettle(marker: string): Promise { await vi.advanceTimersByTimeAsync(WRITE_SUPPRESSION_MS) @@ -230,16 +257,25 @@ function authorizeUploadsAtRevision(revision: number): void { }) } +let nextCaseTime = Date.now() + beforeEach(() => { - vi.useFakeTimers() + vi.useFakeTimers({ now: nextCaseTime }) uploads.mockReset() uploads.mockResolvedValue([]) installWindowApi() }) -afterEach(() => { - cleanup() - vi.useRealTimers() +afterEach(async () => { + try { + cleanup() + await vi.advanceTimersByTimeAsync(WRITE_SUPPRESSION_MS) + expect(isDirectSshRemoteWorkspaceApplyInProgress()).toBe(false) + } finally { + // The shared apply deadline must not outlive a rollback of this fixture's clock. + nextCaseTime = Date.now() + vi.useRealTimers() + } }) describe('uploads from a client that could not place the host tabs', () => { @@ -363,6 +399,7 @@ describe('uploads from a client that could not place the host tabs', () => { it('keeps a later same-lineage upload after the earlier result advances the revision', async () => { seedStore(true) + const persistence = await renderSettledPersistence() authorizeUploadsAtRevision(7) const secondLocalWrite = deferred() let localWriteCount = 0 @@ -379,7 +416,6 @@ describe('uploads from a client that could not place the host tabs', () => { } ]) installWindowApi(sessionPatch) - const persistence = renderHook(() => useAppSessionPersistence()) await vi.advanceTimersByTimeAsync(WRITE_SUPPRESSION_MS) useAppStore.setState({ activeTabId: 'first-local-write' }) @@ -423,6 +459,7 @@ describe('uploads from a client that could not place the host tabs', () => { it('retains transient upload authority so the next local edit retries', async () => { seedStore(true) + const persistence = await renderSettledPersistence() authorizeUploadsAtRevision(7) uploads.mockResolvedValueOnce([ { @@ -436,7 +473,6 @@ describe('uploads from a client that could not place the host tabs', () => { result: { ok: true, snapshot: snapshot(8, 'observation-7') } } ]) - const persistence = renderHook(() => useAppSessionPersistence()) await touchSessionAndSettle('transient-failure') await flushMicrotasks() From 0d8289997296413ed1629f7256885c27c277b436 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 12:42:41 +0000 Subject: [PATCH 007/114] Update README downloads badge --- docs/assets/readme-downloads.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index f895d9dc38a5..c00f23d26584 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 96m + + downloads: 97m @@ -15,7 +15,7 @@ downloads downloads - 96m - 96m + 97m + 97m From e381443886233fa2f19c5d8be1e9c19850437565 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 7 Oct 2026 06:43:57 -0700 Subject: [PATCH 008/114] test: advance retry policy clocks without real waits (#26220) --- .../wsl-hook-relay-recovery.test.ts | 20 +++++++++++++------ .../native-chat-composer-draft-store.test.ts | 6 ++++-- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/src/main/agent-hooks/wsl-hook-relay-recovery.test.ts b/src/main/agent-hooks/wsl-hook-relay-recovery.test.ts index 018d91019153..a603a22282aa 100644 --- a/src/main/agent-hooks/wsl-hook-relay-recovery.test.ts +++ b/src/main/agent-hooks/wsl-hook-relay-recovery.test.ts @@ -1,10 +1,14 @@ // Restart-timer policy: recovery must re-ensure a running distro, must NOT // boot a stopped one (wsl -d starts stopped distros), and must respect state // currency and disposal. -import { describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { WslRelayRecovery, type WslRelayRecoveryState } from './wsl-hook-relay-recovery' +beforeEach(() => vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })) + +afterEach(() => vi.useRealTimers()) + function makeState(): WslRelayRecoveryState { return { distro: 'Ubuntu', cooldownUntil: Date.now() - 1_000 } } @@ -26,6 +30,7 @@ describe('WslRelayRecovery', () => { }) const state = makeState() recovery.scheduleRestart(state) + await vi.advanceTimersByTimeAsync(250) await waitFor(() => restart.mock.calls.length === 1) expect(restart).toHaveBeenCalledWith('Ubuntu') }) @@ -44,6 +49,7 @@ describe('WslRelayRecovery', () => { }) const state = makeState() recovery.scheduleRestart(state) + await vi.advanceTimersByTimeAsync(250) await waitFor(() => dropState.mock.calls.length === 1) expect(restart).not.toHaveBeenCalled() expect(warn).toHaveBeenCalledWith(expect.stringContaining('distro not running')) @@ -62,7 +68,7 @@ describe('WslRelayRecovery', () => { }) const state = makeState() recovery.scheduleRestart(state) - await new Promise((resolve) => setTimeout(resolve, 500)) + await vi.advanceTimersByTimeAsync(500) expect(probe).not.toHaveBeenCalled() expect(restart).not.toHaveBeenCalled() }) @@ -83,11 +89,12 @@ describe('WslRelayRecovery', () => { }) const state = makeState() recovery.scheduleRestart(state) + await vi.advanceTimersByTimeAsync(250) await waitFor(() => probe.mock.calls.length === 1) // A fresh ensure() replaces this state while the probe is in flight. current = false resolveProbe?.(false) - await new Promise((resolve) => setTimeout(resolve, 100)) + await vi.advanceTimersByTimeAsync(100) expect(dropState).not.toHaveBeenCalled() expect(restart).not.toHaveBeenCalled() }) @@ -107,10 +114,11 @@ describe('WslRelayRecovery', () => { }) const state = makeState() recovery.scheduleRestart(state) + await vi.advanceTimersByTimeAsync(250) await waitFor(() => probe.mock.calls.length === 1) current = false resolveProbe?.(true) - await new Promise((resolve) => setTimeout(resolve, 100)) + await vi.advanceTimersByTimeAsync(100) expect(restart).not.toHaveBeenCalled() }) @@ -126,7 +134,7 @@ describe('WslRelayRecovery', () => { }) const state = makeState() recovery.scheduleOneShotReinstall(state, 10, run) - await new Promise((resolve) => setTimeout(resolve, 100)) + await vi.advanceTimersByTimeAsync(100) expect(run).not.toHaveBeenCalled() expect(state.reinstallTimer).toBeUndefined() }) @@ -146,7 +154,7 @@ describe('WslRelayRecovery', () => { recovery.scheduleRestart(state) recovery.scheduleOneShotReinstall(state, 100, reinstall) recovery.clearTimers(state) - await new Promise((resolve) => setTimeout(resolve, 500)) + await vi.advanceTimersByTimeAsync(500) expect(restart).not.toHaveBeenCalled() expect(reinstall).not.toHaveBeenCalled() expect(state.restartTimer).toBeUndefined() diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts index aac5ae37e055..93cb58ba7ba6 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts @@ -608,10 +608,11 @@ describe('native-chat composer draft store', () => { failures-- > 0 ? Promise.reject(new Error('backing store')) : storage.loadAll() } const reloaded = await reload({ using: flaky, hydrate: false }) + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) await reloaded.store.waitForNativeChatComposerDrafts(1) reloaded.drafts.appendNativeChatDraftCache('agent-session:s1', 'given back') await reloaded.store.nativeChatComposerDraftWritesSettled() - await new Promise((resolve) => setTimeout(resolve, 1_200)) + await vi.advanceTimersByTimeAsync(1_200) await reloaded.store.hydrateNativeChatComposerDrafts() await reloaded.store.nativeChatComposerDraftWritesSettled() @@ -639,10 +640,11 @@ describe('native-chat composer draft store', () => { } } const reloaded = await reload({ using: flaky, hydrate: false }) + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) await reloaded.store.waitForNativeChatComposerDrafts(1) reloaded.drafts.appendNativeChatDraftCache('agent-session:s1', 'first') await reloaded.store.nativeChatComposerDraftWritesSettled() - await new Promise((resolve) => setTimeout(resolve, 1_200)) + await vi.advanceTimersByTimeAsync(1_200) reloaded.drafts.appendNativeChatDraftCache('agent-session:s1', 'second') await reloaded.store.nativeChatComposerDraftWritesSettled() From 40d35fb7bfcee9b33ff8027bc4a9a44ee1a48f6d Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 7 Oct 2026 06:53:40 -0700 Subject: [PATCH 009/114] test: run real SSH command contracts under Node (#26226) --- config/scripts/vitest-node-runtime-files.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/config/scripts/vitest-node-runtime-files.mjs b/config/scripts/vitest-node-runtime-files.mjs index 69e473c91d22..791e38f209f7 100644 --- a/config/scripts/vitest-node-runtime-files.mjs +++ b/config/scripts/vitest-node-runtime-files.mjs @@ -6,6 +6,7 @@ export const NODE_RUNTIME_INCLUDE = [ 'config/scripts/cloud-security-history-workflow.test.mjs', 'config/scripts/skill-recipe-shell.test.mjs', 'src/main/ssh/ssh-relay-endpoint-incumbent-shell.integration.test.ts', + 'src/main/ssh/ssh-remote-commands.test.ts', 'src/shared/child-process/run-process.test.ts', 'tests/e2e/cursor-quota-transport.unit.test.ts', 'src/main/ai-vault-search/session-search-index-writer.test.ts', From 4a0418d9c178fd3e2fa71ccf6620b391ef540507 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 7 Oct 2026 07:21:16 -0700 Subject: [PATCH 010/114] test: import Activity fixture builders from their existing owners (#26229) --- .../components/activity/ActivityPrototypePage-test-fixtures.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/components/activity/ActivityPrototypePage-test-fixtures.ts b/src/renderer/src/components/activity/ActivityPrototypePage-test-fixtures.ts index 181f96869a27..1aa95c86c954 100644 --- a/src/renderer/src/components/activity/ActivityPrototypePage-test-fixtures.ts +++ b/src/renderer/src/components/activity/ActivityPrototypePage-test-fixtures.ts @@ -4,7 +4,8 @@ import { makePaneKey } from '../../../../shared/stable-pane-id' import type { TerminalTab } from '../../../../shared/terminal-tab-types' import type { Worktree } from '../../../../shared/worktree/types' import type { RetainedAgentEntry } from '@/store/slices/agent-status' -import { buildActivityEvents, buildAgentPaneThreads } from './ActivityPrototypePage' +import { buildActivityEvents } from './activity-event-builder' +import { buildAgentPaneThreads } from './activity-thread-builder' export const LEAF_ID = '11111111-1111-4111-8111-111111111111' export const LEAF_ID_2 = '22222222-2222-4222-8222-222222222222' From 39710c79b848dfa247f2cf0eac2e6cddaaadd165 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 7 Oct 2026 07:24:25 -0700 Subject: [PATCH 011/114] test: load renderer fixture builders without the full Store (#26217) --- ...space-unplaced-upload-suppression.test.tsx | 2 +- ...ity-clear-completed-state-lifetime.test.ts | 2 +- ...ed-editor-workspace-runtime-owner.test.tsx | 2 +- ...e-floating-terminal-close-actions.test.tsx | 6 +- ...cturedAgentSessionAttentionBridge.test.tsx | 8 +- ...use-checks-panel-terminal-worktree.test.ts | 2 +- .../useGitStatusPolling.rerender.test.ts | 3 +- ...WorkspaceKanbanCard.host-identity.test.tsx | 2 +- .../stale-agent-row-unverifiable.test.ts | 2 +- ...ee-card-compact-agent-row-stopping.test.ts | 2 +- ...worktree-delete-host-qualification.test.ts | 2 +- ...ab-close-commands.floating-reclaim.test.ts | 2 +- .../use-notification-dispatch.test.ts | 2 +- ...al-provider-snapshot-bound-pty-ids.test.ts | 3 +- ...scanned-host-confirmation-removal.test.tsx | 2 +- .../hooks/useAutoAckViewedAgent.away.test.ts | 2 +- .../useAutoAckViewedAgent.clock-skew.test.ts | 2 +- ...eAutoAckViewedAgent.floating-panel.test.ts | 2 +- .../useAutoAckViewedAgent.focus-group.test.ts | 2 +- .../src/hooks/useUnreadDockBadge.test.ts | 4 +- ...irror-handle-gap-snapshot-lifetime.test.ts | 2 +- ...t-session-direct-ssh-hydration-gap.test.ts | 2 +- ...agent-session-execution-host-scope.test.ts | 2 +- ...ailed-target-sleeping-agent-resume.test.ts | 2 +- .../lib/unread-badge-count-migration.test.ts | 3 +- .../src/lib/unread-badge-count.test.ts | 3 +- ...ime-session-mirror-environment-key.test.ts | 2 +- .../slices/editor-tab-host-ownership.test.ts | 2 +- .../src/store/slices/new-markdown.test.ts | 2 +- .../slices/store-session-test-harness.ts | 60 ++++++++++ .../src/store/slices/store-test-helpers.ts | 103 ++---------------- .../slices/worktrees-slice-test-fixtures.ts | 8 ++ 32 files changed, 117 insertions(+), 128 deletions(-) diff --git a/src/renderer/src/app-shell/remote-workspace-unplaced-upload-suppression.test.tsx b/src/renderer/src/app-shell/remote-workspace-unplaced-upload-suppression.test.tsx index c2af06208d41..3b65dbd380f3 100644 --- a/src/renderer/src/app-shell/remote-workspace-unplaced-upload-suppression.test.tsx +++ b/src/renderer/src/app-shell/remote-workspace-unplaced-upload-suppression.test.tsx @@ -22,7 +22,7 @@ import type { DirectSshAuthority, SshProviderEpoch } from '../../../shared/ssh-t import type { DirectSshPreparationInput } from '../hooks/direct-ssh-reconnect-coordinator' import { createRemoteWorkspaceTargetSync } from '../hooks/remote-workspace-target-sync' import { isDirectSshRemoteWorkspaceApplyInProgress } from '../hooks/remote-workspace-snapshot-apply' -import { makeWorktree } from '../store/slices/store-test-helpers' +import { makeWorktree } from '../store/slices/worktrees-slice-test-fixtures' vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) vi.mock('@/lib/agent-status', async (importOriginal) => { diff --git a/src/renderer/src/components/activity/activity-clear-completed-state-lifetime.test.ts b/src/renderer/src/components/activity/activity-clear-completed-state-lifetime.test.ts index 8bbfbe03ee2c..834f6e6e51d4 100644 --- a/src/renderer/src/components/activity/activity-clear-completed-state-lifetime.test.ts +++ b/src/renderer/src/components/activity/activity-clear-completed-state-lifetime.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useAppStore } from '@/store' -import { makeOpenFile } from '@/store/slices/store-test-helpers' +import { makeOpenFile } from '../../store/slices/store-session-test-harness' import { makeRetainedDoneEntry, makeTab, makeWorktree } from './ActivityPrototypePage-test-fixtures' import type { AgentPaneThread } from './activity-thread-types' diff --git a/src/renderer/src/components/editor/restored-editor-workspace-runtime-owner.test.tsx b/src/renderer/src/components/editor/restored-editor-workspace-runtime-owner.test.tsx index b92e8eb2de66..a410a99492ab 100644 --- a/src/renderer/src/components/editor/restored-editor-workspace-runtime-owner.test.tsx +++ b/src/renderer/src/components/editor/restored-editor-workspace-runtime-owner.test.tsx @@ -5,7 +5,7 @@ import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as RuntimeFileClient from '@/runtime/runtime-file-client' import { useAppStore } from '@/store' -import { makeWorktree } from '@/store/slices/store-test-helpers' +import { makeWorktree } from '../../store/slices/worktrees-slice-test-fixtures' import type { FileContent } from './editor-panel-content-types' import { useEditorPanelFileContentLoader, diff --git a/src/renderer/src/components/floating-terminal/use-floating-terminal-close-actions.test.tsx b/src/renderer/src/components/floating-terminal/use-floating-terminal-close-actions.test.tsx index 3907c88c0208..28988ed23f97 100644 --- a/src/renderer/src/components/floating-terminal/use-floating-terminal-close-actions.test.tsx +++ b/src/renderer/src/components/floating-terminal/use-floating-terminal-close-actions.test.tsx @@ -3,7 +3,11 @@ import { renderHook } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { FLOATING_TERMINAL_WORKTREE_ID, getDefaultSettings } from '../../../../shared/constants' import { useAppStore } from '@/store' -import { makeOpenFile, makeTabGroup, makeUnifiedTab } from '@/store/slices/store-test-helpers' +import { + makeOpenFile, + makeTabGroup, + makeUnifiedTab +} from '../../store/slices/store-session-test-harness' import { useFloatingTerminalCloseActions } from './use-floating-terminal-close-actions' const requestEditorFileClose = vi.hoisted(() => vi.fn()) diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionAttentionBridge.test.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionAttentionBridge.test.tsx index 362512bf694c..99a01c6d4379 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionAttentionBridge.test.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionAttentionBridge.test.tsx @@ -73,12 +73,8 @@ import { getStructuredAgentSessionStatusFeed, resetStructuredAgentSessionStatusFeedsForTests } from '@/runtime/structured-agent-session-status-feed' -import { - makeTabGroup, - makeUnifiedTab, - makeWorktree, - TEST_REPO -} from '@/store/slices/store-test-helpers' +import { makeTabGroup, makeUnifiedTab } from '../../store/slices/store-session-test-harness' +import { makeWorktree, TEST_REPO } from '../../store/slices/worktrees-slice-test-fixtures' // Worktree ids encode their repo (`repoId::path`); the unread reducer buckets by that prefix. const WORKSPACE = 'repo1::/tmp/wt' diff --git a/src/renderer/src/components/right-sidebar/use-checks-panel-terminal-worktree.test.ts b/src/renderer/src/components/right-sidebar/use-checks-panel-terminal-worktree.test.ts index d79f9a16fbe5..a65697bed8f7 100644 --- a/src/renderer/src/components/right-sidebar/use-checks-panel-terminal-worktree.test.ts +++ b/src/renderer/src/components/right-sidebar/use-checks-panel-terminal-worktree.test.ts @@ -7,7 +7,7 @@ import type { Repo } from '../../../../shared/repo-types' import type { Worktree } from '../../../../shared/worktree/types' import { useAppStore } from '@/store' import type { AppState } from '@/store/types' -import { makeWorktree, TEST_REPO } from '@/store/slices/store-test-helpers' +import { makeWorktree, TEST_REPO } from '../../store/slices/worktrees-slice-test-fixtures' import { useChecksPanelTerminalWorktree } from './use-checks-panel-terminal-worktree' const initialAppState = useAppStore.getInitialState() diff --git a/src/renderer/src/components/right-sidebar/useGitStatusPolling.rerender.test.ts b/src/renderer/src/components/right-sidebar/useGitStatusPolling.rerender.test.ts index 7d565090d697..561af39b624c 100644 --- a/src/renderer/src/components/right-sidebar/useGitStatusPolling.rerender.test.ts +++ b/src/renderer/src/components/right-sidebar/useGitStatusPolling.rerender.test.ts @@ -7,7 +7,8 @@ import type { Repo } from '../../../../shared/repo-types' import type { Worktree } from '../../../../shared/worktree/types' import { useAppStore } from '@/store' import type { AppState } from '@/store/types' -import { makeOpenFile, makeWorktree, TEST_REPO } from '@/store/slices/store-test-helpers' +import { makeOpenFile } from '../../store/slices/store-session-test-harness' +import { makeWorktree, TEST_REPO } from '../../store/slices/worktrees-slice-test-fixtures' import { ORCA_WORKTREE_FILE_CHANGE_EVENT } from '@/hooks/worktree-file-change-event' import { useGitStatusPolling } from './useGitStatusPolling' diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanCard.host-identity.test.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanCard.host-identity.test.tsx index 44daf3f00e60..4c94c30fc36a 100644 --- a/src/renderer/src/components/sidebar/WorkspaceKanbanCard.host-identity.test.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanCard.host-identity.test.tsx @@ -3,7 +3,7 @@ import { cleanup, fireEvent, render } from '@testing-library/react' import React from 'react' import { afterEach, describe, expect, it, vi } from 'vitest' -import { makeWorktree } from '../../store/slices/store-test-helpers' +import { makeWorktree } from '../../store/slices/worktrees-slice-test-fixtures' import WorkspaceKanbanCard from './WorkspaceKanbanCard' vi.mock('@/components/ui/badge', () => ({ diff --git a/src/renderer/src/components/sidebar/stale-agent-row-unverifiable.test.ts b/src/renderer/src/components/sidebar/stale-agent-row-unverifiable.test.ts index 052adc752b95..a2fb42aaced1 100644 --- a/src/renderer/src/components/sidebar/stale-agent-row-unverifiable.test.ts +++ b/src/renderer/src/components/sidebar/stale-agent-row-unverifiable.test.ts @@ -17,7 +17,7 @@ import { AGENT_STATUS_STALE_AFTER_MS, type AgentStatusEntry } from '../../../../shared/agent-status-types' -import { makeTab } from '../../store/slices/store-test-helpers' +import { makeTab } from '../../store/slices/store-session-test-harness' import { buildWorktreeAgentRows } from './worktree-agent-rows' import { getAgentDotState } from './worktree-card-agent-summary' import { getCompactAgentSecondary } from './worktree-card-compact-agent-row' diff --git a/src/renderer/src/components/sidebar/worktree-card-compact-agent-row-stopping.test.ts b/src/renderer/src/components/sidebar/worktree-card-compact-agent-row-stopping.test.ts index ea6227d42285..d92d8445af7a 100644 --- a/src/renderer/src/components/sidebar/worktree-card-compact-agent-row-stopping.test.ts +++ b/src/renderer/src/components/sidebar/worktree-card-compact-agent-row-stopping.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' -import { makeTab } from '../../store/slices/store-test-helpers' +import { makeTab } from '../../store/slices/store-session-test-harness' import { buildWorktreeAgentRows } from './worktree-agent-rows' import { getAgentDotState } from './worktree-card-agent-summary' import { getCompactAgentLineOrder } from './worktree-card-compact-agent-line-order' diff --git a/src/renderer/src/components/sidebar/worktree-delete-host-qualification.test.ts b/src/renderer/src/components/sidebar/worktree-delete-host-qualification.test.ts index 99ec86874076..00cfddee685d 100644 --- a/src/renderer/src/components/sidebar/worktree-delete-host-qualification.test.ts +++ b/src/renderer/src/components/sidebar/worktree-delete-host-qualification.test.ts @@ -23,7 +23,7 @@ import type { AppState } from '@/store/types' import type { Worktree } from '../../../../shared/worktree/types' import type { ExecutionHostId } from '../../../../shared/execution-host' import { makeWorktree } from '@/store/slices/worktrees-slice-test-fixtures' -import { makeTab } from '@/store/slices/store-test-helpers' +import { makeTab } from '../../store/slices/store-session-test-harness' import { createTestStore, mockApi, diff --git a/src/renderer/src/components/tab-group/workspace-tab-close-commands.floating-reclaim.test.ts b/src/renderer/src/components/tab-group/workspace-tab-close-commands.floating-reclaim.test.ts index b0aa5da13f23..00743829fb83 100644 --- a/src/renderer/src/components/tab-group/workspace-tab-close-commands.floating-reclaim.test.ts +++ b/src/renderer/src/components/tab-group/workspace-tab-close-commands.floating-reclaim.test.ts @@ -33,7 +33,7 @@ vi.mock('@/store/pinned-tab-close-guard', () => ({ import { dispatchWorkspaceTabCommand } from '@/lib/workspace-tab-commands' import { useAppStore } from '../../store' -import { makeOpenFile } from '../../store/slices/store-test-helpers' +import { makeOpenFile } from '../../store/slices/store-session-test-harness' const terminalTab: Tab = { id: 'unified-terminal', diff --git a/src/renderer/src/components/terminal-pane/use-notification-dispatch.test.ts b/src/renderer/src/components/terminal-pane/use-notification-dispatch.test.ts index a4607978b83d..9956d34df4a0 100644 --- a/src/renderer/src/components/terminal-pane/use-notification-dispatch.test.ts +++ b/src/renderer/src/components/terminal-pane/use-notification-dispatch.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { dispatchTerminalNotification } from './use-notification-dispatch' import { AGENT_STATUS_STALE_AFTER_MS } from '../../../../shared/agent-status-types' import { buildAgentNotificationId } from '../../../../shared/agent-notification-id' -import { makeTabGroup, makeUnifiedTab } from '@/store/slices/store-test-helpers' +import { makeTabGroup, makeUnifiedTab } from '../../store/slices/store-session-test-harness' import { LIVE_LEAF_ID, PANE_KEY, diff --git a/src/renderer/src/components/terminal/terminal-provider-snapshot-bound-pty-ids.test.ts b/src/renderer/src/components/terminal/terminal-provider-snapshot-bound-pty-ids.test.ts index eba9359990a7..ad6f7de8714b 100644 --- a/src/renderer/src/components/terminal/terminal-provider-snapshot-bound-pty-ids.test.ts +++ b/src/renderer/src/components/terminal/terminal-provider-snapshot-bound-pty-ids.test.ts @@ -2,7 +2,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as SnapshotCapabilityModule from './terminal-provider-snapshot-capability' import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types' -import { makeTab, makeWorktree } from '@/store/slices/store-test-helpers' +import { makeTab } from '../../store/slices/store-session-test-harness' +import { makeWorktree } from '../../store/slices/worktrees-slice-test-fixtures' const { collectPtyIds } = vi.hoisted(() => ({ collectPtyIds: vi.fn() })) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-scanned-host-confirmation-removal.test.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-scanned-host-confirmation-removal.test.tsx index eb69b692334d..7b2934b8e829 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-scanned-host-confirmation-removal.test.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-scanned-host-confirmation-removal.test.tsx @@ -43,7 +43,7 @@ vi.mock('../../../../main/providers/ssh-git-dispatch', () => ({ vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn() } })) import { useAppStore } from '@/store' -import { makeWorktree } from '@/store/slices/store-test-helpers' +import { makeWorktree } from '../../store/slices/worktrees-slice-test-fixtures' import { resetAuthoritativelyRemovedWorktreeMemoryForTests } from '@/store/slices/worktrees' import { useWorkspaceCleanupRemoval } from './use-workspace-cleanup-removal' diff --git a/src/renderer/src/hooks/useAutoAckViewedAgent.away.test.ts b/src/renderer/src/hooks/useAutoAckViewedAgent.away.test.ts index 0f5cadb1e907..7370ef6b57fc 100644 --- a/src/renderer/src/hooks/useAutoAckViewedAgent.away.test.ts +++ b/src/renderer/src/hooks/useAutoAckViewedAgent.away.test.ts @@ -3,7 +3,7 @@ import { act, cleanup, renderHook, waitFor } from '@testing-library/react' import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { useAutoAckViewedAgent } from './useAutoAckViewedAgent' import { useAppStore } from '../store' -import { makeTab, makeTabGroup, makeUnifiedTab } from '../store/slices/store-test-helpers' +import { makeTab, makeTabGroup, makeUnifiedTab } from '../store/slices/store-session-test-harness' import { makePaneKey } from '../../../shared/stable-pane-id' import { createNotificationsApi } from '../web/preload-api/web-notifications-api' diff --git a/src/renderer/src/hooks/useAutoAckViewedAgent.clock-skew.test.ts b/src/renderer/src/hooks/useAutoAckViewedAgent.clock-skew.test.ts index c90e8119f057..1640edd0b27f 100644 --- a/src/renderer/src/hooks/useAutoAckViewedAgent.clock-skew.test.ts +++ b/src/renderer/src/hooks/useAutoAckViewedAgent.clock-skew.test.ts @@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as AgentAutoAckPresence from './agent-auto-ack-presence' import { useAutoAckViewedAgent } from './useAutoAckViewedAgent' import { useAppStore } from '../store' -import { makeTab, makeTabGroup, makeUnifiedTab } from '../store/slices/store-test-helpers' +import { makeTab, makeTabGroup, makeUnifiedTab } from '../store/slices/store-session-test-harness' import { makePaneKey } from '../../../shared/stable-pane-id' import type { AgentStatusEntry } from '../../../shared/agent-status-types' diff --git a/src/renderer/src/hooks/useAutoAckViewedAgent.floating-panel.test.ts b/src/renderer/src/hooks/useAutoAckViewedAgent.floating-panel.test.ts index 47a56b338a27..4622f63ef825 100644 --- a/src/renderer/src/hooks/useAutoAckViewedAgent.floating-panel.test.ts +++ b/src/renderer/src/hooks/useAutoAckViewedAgent.floating-panel.test.ts @@ -6,7 +6,7 @@ import type * as AgentAutoAckPresence from './agent-auto-ack-presence' import { useAutoAckViewedAgent } from './useAutoAckViewedAgent' import { useAppStore } from '../store' import { selectFloatingWorkspaceHasUnread } from '../store/selectors' -import { makeTab, makeTabGroup, makeUnifiedTab } from '../store/slices/store-test-helpers' +import { makeTab, makeTabGroup, makeUnifiedTab } from '../store/slices/store-session-test-harness' import { FLOATING_TERMINAL_WORKTREE_ID, getDefaultSettings } from '../../../shared/constants' import { makePaneKey } from '../../../shared/stable-pane-id' diff --git a/src/renderer/src/hooks/useAutoAckViewedAgent.focus-group.test.ts b/src/renderer/src/hooks/useAutoAckViewedAgent.focus-group.test.ts index a2a71dd440cf..402b1e0f9dad 100644 --- a/src/renderer/src/hooks/useAutoAckViewedAgent.focus-group.test.ts +++ b/src/renderer/src/hooks/useAutoAckViewedAgent.focus-group.test.ts @@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as AgentAutoAckPresence from './agent-auto-ack-presence' import { useAutoAckViewedAgent } from './useAutoAckViewedAgent' import { useAppStore } from '../store' -import { makeTab, makeTabGroup, makeUnifiedTab } from '../store/slices/store-test-helpers' +import { makeTab, makeTabGroup, makeUnifiedTab } from '../store/slices/store-session-test-harness' import { structuredAgentSessionPaneKey } from '../../../shared/structured-agent-session-projection' vi.mock('./agent-auto-ack-presence', async (importOriginal) => ({ diff --git a/src/renderer/src/hooks/useUnreadDockBadge.test.ts b/src/renderer/src/hooks/useUnreadDockBadge.test.ts index 5dd6cb5aa8a3..6a6cb15cabb2 100644 --- a/src/renderer/src/hooks/useUnreadDockBadge.test.ts +++ b/src/renderer/src/hooks/useUnreadDockBadge.test.ts @@ -3,8 +3,8 @@ import { act, cleanup, renderHook } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as UnreadBadgeCountModule from '@/lib/unread-badge-count' -import { makeFolderWorkspace } from '@/store/slices/worktrees-slice-test-fixtures' -import { makeTab, makeWorktree } from '@/store/slices/store-test-helpers' +import { makeFolderWorkspace, makeWorktree } from '@/store/slices/worktrees-slice-test-fixtures' +import { makeTab } from '../store/slices/store-session-test-harness' import { FLOATING_TERMINAL_WORKTREE_ID, getDefaultSettings } from '../../../shared/constants' const { getUnreadBadgeCount } = vi.hoisted(() => ({ getUnreadBadgeCount: vi.fn() })) diff --git a/src/renderer/src/lib/host-mirror-handle-gap-snapshot-lifetime.test.ts b/src/renderer/src/lib/host-mirror-handle-gap-snapshot-lifetime.test.ts index a07d79121970..bad549e81af5 100644 --- a/src/renderer/src/lib/host-mirror-handle-gap-snapshot-lifetime.test.ts +++ b/src/renderer/src/lib/host-mirror-handle-gap-snapshot-lifetime.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useAppStore, type AppState } from '@/store' -import { makeTab } from '@/store/slices/store-test-helpers' +import { makeTab } from '../store/slices/store-session-test-harness' import { clearRuntimeEnvironmentConnectionGenerationsForTests, setRuntimeEnvironmentConnectionGenerationForTests diff --git a/src/renderer/src/lib/resume-sleeping-agent-session-direct-ssh-hydration-gap.test.ts b/src/renderer/src/lib/resume-sleeping-agent-session-direct-ssh-hydration-gap.test.ts index 0b9404d1da48..7919124e93c2 100644 --- a/src/renderer/src/lib/resume-sleeping-agent-session-direct-ssh-hydration-gap.test.ts +++ b/src/renderer/src/lib/resume-sleeping-agent-session-direct-ssh-hydration-gap.test.ts @@ -13,7 +13,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' import { useAppStore } from '@/store' -import { makeWorktree } from '@/store/slices/store-test-helpers' +import { makeWorktree } from '../store/slices/worktrees-slice-test-fixtures' import { resumeSleepingAgentSessionsForWorktree } from './resume-sleeping-agent-session' const initialAppStoreState = useAppStore.getState() diff --git a/src/renderer/src/lib/resume-sleeping-agent-session-execution-host-scope.test.ts b/src/renderer/src/lib/resume-sleeping-agent-session-execution-host-scope.test.ts index 54b5459a8cf5..c25dab229647 100644 --- a/src/renderer/src/lib/resume-sleeping-agent-session-execution-host-scope.test.ts +++ b/src/renderer/src/lib/resume-sleeping-agent-session-execution-host-scope.test.ts @@ -17,7 +17,7 @@ import { afterEach, describe, expect, it } from 'vitest' import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' import type { ExecutionHostId } from '../../../shared/execution-host' import { useAppStore } from '@/store' -import { makeWorktree, TEST_REPO } from '@/store/slices/store-test-helpers' +import { makeWorktree, TEST_REPO } from '../store/slices/worktrees-slice-test-fixtures' import { resumeSleepingAgentSessionsForWorktree } from './resume-sleeping-agent-session' import { agentResumeOriginNamesAnotherExecutionHost, diff --git a/src/renderer/src/lib/ssh-failed-target-sleeping-agent-resume.test.ts b/src/renderer/src/lib/ssh-failed-target-sleeping-agent-resume.test.ts index 1232681d980f..e35e46fc908c 100644 --- a/src/renderer/src/lib/ssh-failed-target-sleeping-agent-resume.test.ts +++ b/src/renderer/src/lib/ssh-failed-target-sleeping-agent-resume.test.ts @@ -11,7 +11,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' import type { TerminalTab } from '../../../shared/terminal-tab-types' import { useAppStore } from '@/store' -import { makeWorktree } from '@/store/slices/store-test-helpers' +import { makeWorktree } from '../store/slices/worktrees-slice-test-fixtures' import { resolveWorkspaceTerminalHostAuthority } from './workspace-terminal-host-authority' import { resumeSleepingAgentSessionsForWorktree } from './resume-sleeping-agent-session' diff --git a/src/renderer/src/lib/unread-badge-count-migration.test.ts b/src/renderer/src/lib/unread-badge-count-migration.test.ts index b8ebb7a3fc23..0ab73125f83e 100644 --- a/src/renderer/src/lib/unread-badge-count-migration.test.ts +++ b/src/renderer/src/lib/unread-badge-count-migration.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' -import { makeTab, makeWorktree, TEST_REPO } from '@/store/slices/store-test-helpers' +import { makeTab } from '../store/slices/store-session-test-harness' +import { makeWorktree, TEST_REPO } from '../store/slices/worktrees-slice-test-fixtures' import { makeFolderWorkspace } from '@/store/slices/worktrees-slice-test-fixtures' import type { ProjectGroup } from '../../../shared/project-group-types' import { getUnreadBadgeCount, type UnreadBadgeCountSources } from './unread-badge-count' diff --git a/src/renderer/src/lib/unread-badge-count.test.ts b/src/renderer/src/lib/unread-badge-count.test.ts index 87a9980497ef..b3e718b0f7ef 100644 --- a/src/renderer/src/lib/unread-badge-count.test.ts +++ b/src/renderer/src/lib/unread-badge-count.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' -import { makeTab, makeWorktree, TEST_REPO } from '@/store/slices/store-test-helpers' +import { makeTab } from '../store/slices/store-session-test-harness' +import { makeWorktree, TEST_REPO } from '../store/slices/worktrees-slice-test-fixtures' import { makeFolderWorkspace } from '@/store/slices/worktrees-slice-test-fixtures' import type { ProjectGroup } from '../../../shared/project-group-types' import type { Worktree } from '../../../shared/worktree/types' diff --git a/src/renderer/src/runtime/use-runtime-session-mirror-environment-key.test.ts b/src/renderer/src/runtime/use-runtime-session-mirror-environment-key.test.ts index 344016acf4d3..2069bb85bdcd 100644 --- a/src/renderer/src/runtime/use-runtime-session-mirror-environment-key.test.ts +++ b/src/renderer/src/runtime/use-runtime-session-mirror-environment-key.test.ts @@ -7,7 +7,7 @@ import { getDefaultSettings } from '../../../shared/constants' import type { ProjectGroup } from '../../../shared/project-group-types' import type { Repo } from '../../../shared/repo-types' import type * as RuntimeSessionMirrorTargetsModule from '@/lib/runtime-session-mirror-targets' -import { makeWorktree } from '@/store/slices/store-test-helpers' +import { makeWorktree } from '../store/slices/worktrees-slice-test-fixtures' const { getMirrorTargets } = vi.hoisted(() => ({ getMirrorTargets: vi.fn() })) diff --git a/src/renderer/src/store/slices/editor-tab-host-ownership.test.ts b/src/renderer/src/store/slices/editor-tab-host-ownership.test.ts index 20ef622011de..1125330db6ab 100644 --- a/src/renderer/src/store/slices/editor-tab-host-ownership.test.ts +++ b/src/renderer/src/store/slices/editor-tab-host-ownership.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { createEditorTabsStore } from './editor-slice-test-harness' -import { makeWorktree, TEST_REPO } from './store-test-helpers' +import { makeWorktree, TEST_REPO } from './worktrees-slice-test-fixtures' import { getDefaultSettings } from '../../../../shared/constants' import { assertEditorFileOperationCurrent, diff --git a/src/renderer/src/store/slices/new-markdown.test.ts b/src/renderer/src/store/slices/new-markdown.test.ts index ae859be50777..982f804b6cbf 100644 --- a/src/renderer/src/store/slices/new-markdown.test.ts +++ b/src/renderer/src/store/slices/new-markdown.test.ts @@ -4,7 +4,7 @@ import { createEditorSlice } from './editor' import type { AppState } from '../types' import type { BrowserTab } from '../../../../shared/browser-workspace-types' import type { Tab, TabContentType, TabGroup } from '../../../../shared/tab-types' -import { makeTab } from './store-test-helpers' +import { makeTab } from './store-session-test-harness' function createEditorStore(overrides?: Partial): StoreApi { // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/src/renderer/src/store/slices/store-session-test-harness.ts b/src/renderer/src/store/slices/store-session-test-harness.ts index 6ef35f6360ed..0a1f08266b72 100644 --- a/src/renderer/src/store/slices/store-session-test-harness.ts +++ b/src/renderer/src/store/slices/store-session-test-harness.ts @@ -1,5 +1,9 @@ import { vi, type Mock } from 'vitest' import type { BrowserTab } from '../../../../shared/browser-workspace-types' +import type { Tab, TabGroup } from '../../../../shared/tab-types' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types' +import type { OpenFile } from './editor' +import { translate } from '@/i18n/i18n' /** Shape shared by the Claude/Codex/OpenCode usage-scanner namespaces. */ type UsageScannerMocks = { @@ -128,3 +132,59 @@ export function makeBrowserTab( ...overrides } } + +export function makeTab( + overrides: Partial & { id: string; worktreeId: string } +): TerminalTab { + return { + ptyId: null, + title: translate('auto.store.slices.store.test.helpers.b9a8117c33', 'Terminal 1'), + customTitle: null, + color: null, + sortOrder: 0, + createdAt: Date.now(), + ...overrides + } +} + +export function makeLayout(): TerminalLayoutSnapshot { + return { root: null, activeLeafId: null, expandedLeafId: null } +} + +export function makeOpenFile( + overrides: Partial & { id: string; worktreeId: string } +): OpenFile { + return { + filePath: overrides.id, + relativePath: 'file.ts', + language: 'typescript', + isDirty: false, + mode: 'edit', + ...overrides + } +} + +export function makeUnifiedTab( + overrides: Partial & { id: string; worktreeId: string; groupId: string } +): Tab { + return { + entityId: overrides.id, + contentType: 'terminal', + label: translate('auto.store.slices.store.test.helpers.b9a8117c33', 'Terminal 1'), + customLabel: null, + color: null, + sortOrder: 0, + createdAt: Date.now(), + ...overrides + } +} + +export function makeTabGroup( + overrides: Partial & { id: string; worktreeId: string } +): TabGroup { + return { + activeTabId: null, + tabOrder: [], + ...overrides + } +} diff --git a/src/renderer/src/store/slices/store-test-helpers.ts b/src/renderer/src/store/slices/store-test-helpers.ts index 259eda1c10b6..abc8f0fdaa06 100644 --- a/src/renderer/src/store/slices/store-test-helpers.ts +++ b/src/renderer/src/store/slices/store-test-helpers.ts @@ -1,9 +1,6 @@ import { create } from 'zustand' import type { AppState } from '../types' -import type { Tab, TabGroup } from '../../../../shared/tab-types' -import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types' import type { Worktree } from '../../../../shared/worktree/types' -import type { OpenFile } from './editor' import { createRepoSlice } from './repos' import { createSparsePresetsSlice } from './sparse-presets' import { createWorktreeSlice } from './worktrees' @@ -52,15 +49,16 @@ import { createTaskCreationDraftsSlice } from './task-creation-drafts' import { createRemoteServerUpdatesSlice } from './remote-server-updates' import { createTerminalQuickCommandHostsSlice } from './terminal-quick-command-hosts' import { createStructuredSessionLaunchDirectorySlice } from './structured-session-launch-directories' -import { translate } from '@/i18n/i18n' - -export const TEST_REPO = { - id: 'repo1', - path: '/repo1', - displayName: 'Repo 1', - badgeColor: '#000', - addedAt: 0 -} +import '@/i18n/i18n' +import { makeWorktree, TEST_REPO } from './worktrees-slice-test-fixtures' +export { makeWorktree, TEST_REPO } from './worktrees-slice-test-fixtures' +export { + makeTab, + makeLayout, + makeOpenFile, + makeUnifiedTab, + makeTabGroup +} from './store-session-test-harness' export function createTestStore() { return create()((...a) => ({ @@ -126,31 +124,6 @@ export function seedStore( }) } -export function makeWorktree( - overrides: Partial & { id: string; repoId: string } -): Worktree { - return { - path: '/tmp/wt', - head: 'abc123', - branch: 'refs/heads/feature', - isBare: false, - isMainWorktree: false, - displayName: 'feature', - comment: '', - linkedIssue: null, - linkedPR: null, - linkedLinearIssue: null, - linkedGitLabMR: null, - linkedGitLabIssue: null, - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: 0, - lastActivityAt: 0, - ...overrides - } -} - export function makeRuntimeOwnedWorktree( overrides: Partial & { id: string; repoId: string }, runtimeEnvironmentId = 'runtime-1' @@ -161,59 +134,3 @@ export function makeRuntimeOwnedWorktree( runtimeOwnerEnvironmentId: runtimeEnvironmentId }) } - -export function makeTab( - overrides: Partial & { id: string; worktreeId: string } -): TerminalTab { - return { - ptyId: null, - title: translate('auto.store.slices.store.test.helpers.b9a8117c33', 'Terminal 1'), - customTitle: null, - color: null, - sortOrder: 0, - createdAt: Date.now(), - ...overrides - } -} - -export function makeLayout(): TerminalLayoutSnapshot { - return { root: null, activeLeafId: null, expandedLeafId: null } -} - -export function makeOpenFile( - overrides: Partial & { id: string; worktreeId: string } -): OpenFile { - return { - filePath: overrides.id, - relativePath: 'file.ts', - language: 'typescript', - isDirty: false, - mode: 'edit', - ...overrides - } -} - -export function makeUnifiedTab( - overrides: Partial & { id: string; worktreeId: string; groupId: string } -): Tab { - return { - entityId: overrides.id, - contentType: 'terminal', - label: translate('auto.store.slices.store.test.helpers.b9a8117c33', 'Terminal 1'), - customLabel: null, - color: null, - sortOrder: 0, - createdAt: Date.now(), - ...overrides - } -} - -export function makeTabGroup( - overrides: Partial & { id: string; worktreeId: string } -): TabGroup { - return { - activeTabId: null, - tabOrder: [], - ...overrides - } -} diff --git a/src/renderer/src/store/slices/worktrees-slice-test-fixtures.ts b/src/renderer/src/store/slices/worktrees-slice-test-fixtures.ts index 9202984481d7..748739942326 100644 --- a/src/renderer/src/store/slices/worktrees-slice-test-fixtures.ts +++ b/src/renderer/src/store/slices/worktrees-slice-test-fixtures.ts @@ -4,6 +4,14 @@ import type { TerminalTab } from '../../../../shared/terminal-tab-types' import type { WorkspaceLineage, WorktreeLineage } from '../../../../shared/worktree/lineage-types' import type { Worktree } from '../../../../shared/worktree/types' +export const TEST_REPO = { + id: 'repo1', + path: '/repo1', + displayName: 'Repo 1', + badgeColor: '#000', + addedAt: 0 +} + export function makeWorktree( overrides: Partial & { id: string; repoId: string } ): Worktree { From 76d1b7bf29932f6e1edfb6b0b34d65c5c88e5f55 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 7 Oct 2026 07:39:43 -0700 Subject: [PATCH 012/114] test: reuse first SQLite fixture construction in five suites (#26231) --- ...sistence-duplicate-repo-id-host-scope.test.ts | 16 ++++++++++++++-- ...e-ssh-readoption-automation-migration.test.ts | 16 ++++++++++++++-- ...nce-workspace-pinned-automation-fence.test.ts | 11 ++++++++++- ...ence-workspace-repin-automation-owner.test.ts | 11 ++++++++++- .../persistence-worktree-name-retirement.test.ts | 16 ++++++++++++++-- 5 files changed, 62 insertions(+), 8 deletions(-) diff --git a/src/main/persistence-duplicate-repo-id-host-scope.test.ts b/src/main/persistence-duplicate-repo-id-host-scope.test.ts index 48cb826f909d..efc5abc14766 100644 --- a/src/main/persistence-duplicate-repo-id-host-scope.test.ts +++ b/src/main/persistence-duplicate-repo-id-host-scope.test.ts @@ -1,4 +1,9 @@ -import { closeTestStores, createSqliteTestStore } from './persistence-test-harness' +import { + closeTestStores, + createSqliteTestStore, + createStore as createFreshStore, + testState +} from './persistence-test-harness' /** * The same repo id may be registered on two execution hosts (see `removeProjectForHost`). * Every deletion that resolves a *row* must therefore delete only that row: `removeProject` @@ -14,8 +19,9 @@ import type { Repo } from '../shared/repo-types' import { getDefaultPersistedState } from '../shared/constants' import { toRuntimeExecutionHostId } from '../shared/execution-host' import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup' +import { resetRetirementCollisionKeyCacheForTests } from './worktree-name-retirement' -const testState = { dir: '' } +let hasCreatedStoreInCase = false vi.mock('electron', () => ({ app: { getPath: () => testState.dir }, @@ -52,6 +58,10 @@ async function createStoreFromState(state: Record) { JSON.stringify({ ...getDefaultPersistedState(testState.dir), ...state }), 'utf-8' ) + if (!hasCreatedStoreInCase) { + hasCreatedStoreInCase = true + return createFreshStore() + } vi.resetModules() const { Store, initDataPath } = await import('./persistence') // Why here: userData resolves through AppEnvironment, and this must point at this @@ -96,6 +106,8 @@ function staleLocalSetupState() { } beforeEach(() => { + hasCreatedStoreInCase = false + resetRetirementCollisionKeyCacheForTests() testState.dir = mkdtempSync(join(tmpdir(), 'orca-dup-repo-id-')) }) diff --git a/src/main/persistence-ssh-readoption-automation-migration.test.ts b/src/main/persistence-ssh-readoption-automation-migration.test.ts index 886b5475983c..e7820b41b80c 100644 --- a/src/main/persistence-ssh-readoption-automation-migration.test.ts +++ b/src/main/persistence-ssh-readoption-automation-migration.test.ts @@ -1,4 +1,9 @@ -import { closeTestStores, createSqliteTestStore } from './persistence-test-harness' +import { + closeTestStores, + createSqliteTestStore, + createStore as createFreshStore, + testState +} from './persistence-test-harness' /** * SSH re-adoption has to repair the automation with the workspace. * @@ -22,8 +27,9 @@ import { toSshExecutionHostId } from '../shared/execution-host' import { hostStableKey } from '../shared/automation-owner-key' import { folderWorkspaceKey } from '../shared/workspace-scope' import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup' +import { resetRetirementCollisionKeyCacheForTests } from './worktree-name-retirement' -const testState = { dir: '' } +let hasCreatedStoreInCase = false vi.mock('electron', () => ({ app: { getPath: () => testState.dir }, @@ -142,6 +148,10 @@ async function createStoreFromState(state: Record) { JSON.stringify({ ...getDefaultPersistedState(testState.dir), ...state }), 'utf-8' ) + if (!hasCreatedStoreInCase) { + hasCreatedStoreInCase = true + return createFreshStore() + } vi.resetModules() installFakeAppEnvironment({ getPath: () => testState.dir }) const { Store, initDataPath } = await import('./persistence') @@ -170,6 +180,8 @@ function readdDevBox(ssh: SshConnectionStore) { } beforeEach(() => { + hasCreatedStoreInCase = false + resetRetirementCollisionKeyCacheForTests() testState.dir = mkdtempSync(join(tmpdir(), 'orca-readopt-')) }) diff --git a/src/main/persistence-workspace-pinned-automation-fence.test.ts b/src/main/persistence-workspace-pinned-automation-fence.test.ts index 1eaf04739a9c..e0ea55afd4d0 100644 --- a/src/main/persistence-workspace-pinned-automation-fence.test.ts +++ b/src/main/persistence-workspace-pinned-automation-fence.test.ts @@ -1,6 +1,8 @@ import { closeTestStores, createSqliteTestStore, + createStore as createFreshStore, + testState, readPersistedStateJson, writePersistedStateJson } from './persistence-test-harness' @@ -27,8 +29,9 @@ import { AUTOMATION_OWNER_CONFLICT_CODES } from '../shared/automation-owner-conf import { getDefaultPersistedState } from '../shared/constants' import { folderWorkspaceKey } from '../shared/workspace-scope' import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup' +import { resetRetirementCollisionKeyCacheForTests } from './worktree-name-retirement' -const testState = { dir: '' } +let hasCreatedStoreInCase = false vi.mock('electron', () => ({ app: { getPath: () => testState.dir }, @@ -118,6 +121,10 @@ async function createStoreFromState(state: Record) { JSON.stringify({ ...getDefaultPersistedState(testState.dir), ...state }), 'utf-8' ) + if (!hasCreatedStoreInCase) { + hasCreatedStoreInCase = true + return createFreshStore() + } vi.resetModules() installFakeAppEnvironment({ getPath: () => testState.dir }) const { Store, initDataPath } = await import('./persistence') @@ -161,6 +168,8 @@ function createPinnedAutomation(store: { } beforeEach(() => { + hasCreatedStoreInCase = false + resetRetirementCollisionKeyCacheForTests() testState.dir = mkdtempSync(join(tmpdir(), 'orca-pinned-fence-')) }) diff --git a/src/main/persistence-workspace-repin-automation-owner.test.ts b/src/main/persistence-workspace-repin-automation-owner.test.ts index 4f17c14ec47b..5eb0bcef44bd 100644 --- a/src/main/persistence-workspace-repin-automation-owner.test.ts +++ b/src/main/persistence-workspace-repin-automation-owner.test.ts @@ -1,6 +1,8 @@ import { closeTestStores, createSqliteTestStore, + createStore as createFreshStore, + testState, readPersistedStateJson, writePersistedStateJson } from './persistence-test-harness' @@ -27,8 +29,9 @@ import { AUTOMATION_ORPHAN_ISSUES } from '../shared/automation-list-scope' import { getDefaultPersistedState } from '../shared/constants' import { folderWorkspaceKey } from '../shared/workspace-scope' import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup' +import { resetRetirementCollisionKeyCacheForTests } from './worktree-name-retirement' -const testState = { dir: '' } +let hasCreatedStoreInCase = false vi.mock('electron', () => ({ app: { getPath: () => testState.dir }, @@ -142,6 +145,10 @@ async function loadStore(state: Record) { JSON.stringify({ ...getDefaultPersistedState(testState.dir), ...state }), 'utf-8' ) + if (!hasCreatedStoreInCase) { + hasCreatedStoreInCase = true + return createFreshStore() + } vi.resetModules() installFakeAppEnvironment({ getPath: () => testState.dir }) const { Store, initDataPath } = await import('./persistence') @@ -161,6 +168,8 @@ function repinWorkspace(connectionId: string, sshTargets?: SshTarget[]): void { } beforeEach(() => { + hasCreatedStoreInCase = false + resetRetirementCollisionKeyCacheForTests() testState.dir = mkdtempSync(join(tmpdir(), 'orca-workspace-repin-')) }) diff --git a/src/main/persistence-worktree-name-retirement.test.ts b/src/main/persistence-worktree-name-retirement.test.ts index ebe4db5feec8..3bb572e9a345 100644 --- a/src/main/persistence-worktree-name-retirement.test.ts +++ b/src/main/persistence-worktree-name-retirement.test.ts @@ -1,4 +1,9 @@ -import { closeTestStores, createSqliteTestStore } from './persistence-test-harness' +import { + closeTestStores, + createSqliteTestStore, + createStore as createFreshStore, + testState +} from './persistence-test-harness' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -10,8 +15,9 @@ import type { SshTarget } from '../shared/ssh-types' import { MAX_RETIREMENT_NAMESPACES } from './worktree-retirement-namespace' import { getRuntimeOwnedSshTargetId } from './ssh/ssh-connection-store' import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup' +import { resetRetirementCollisionKeyCacheForTests } from './worktree-name-retirement' -const testState = { dir: '' } +let hasCreatedStoreInCase = false vi.mock('electron', () => ({ app: { getPath: () => testState.dir }, @@ -61,10 +67,16 @@ async function createStore(persisted: Record = {}) { JSON.stringify({ ...getDefaultPersistedState(testState.dir), ...persisted }), 'utf-8' ) + if (!hasCreatedStoreInCase) { + hasCreatedStoreInCase = true + return createFreshStore() + } return reloadStore() } beforeEach(() => { + hasCreatedStoreInCase = false + resetRetirementCollisionKeyCacheForTests() testState.dir = mkdtempSync(join(tmpdir(), 'orca-worktree-name-retirement-')) }) From a576c0bc4ee430093b24abf0a5a1840a49c76930 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 7 Oct 2026 08:59:23 -0700 Subject: [PATCH 013/114] test(persistence): reuse first constructors in six more suites (#26250) --- .../automations/automation-owner-fencing.test.ts | 16 ++++++++++++++-- .../automation-update-host-retarget.test.ts | 16 ++++++++++++++-- ...automation-workspace-host-attribution.test.ts | 16 ++++++++++++++-- src/main/automations/service-precheck.test.ts | 13 +++++++++++-- src/main/worktree-removal-close-records.test.ts | 16 ++++++++++++++-- ...ree-removal-session-partition-fencing.test.ts | 16 ++++++++++++++-- 6 files changed, 81 insertions(+), 12 deletions(-) diff --git a/src/main/automations/automation-owner-fencing.test.ts b/src/main/automations/automation-owner-fencing.test.ts index 14852c0267d9..87c18e466674 100644 --- a/src/main/automations/automation-owner-fencing.test.ts +++ b/src/main/automations/automation-owner-fencing.test.ts @@ -1,4 +1,10 @@ -import { closeTestStores, createSqliteTestStore } from '../persistence-test-harness' +import { + closeTestStores, + createSqliteTestStore, + createStore as createFreshStore, + testState +} from '../persistence-test-harness' +import { resetRetirementCollisionKeyCacheForTests } from '../worktree-name-retirement' /** * The desktop authority's end of the scoped-list and owner-fenced contracts: * a parameterless read still answers with everything it stores, a scoped read is @@ -16,7 +22,7 @@ import { getDefaultPersistedState } from '../../shared/constants' import { AUTOMATION_OWNER_CONFLICT_CODES } from '../../shared/automation-owner-conflict' import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' -const testState = { dir: '' } +let hasCreatedStoreInCase = false vi.mock('electron', () => ({ app: { getPath: () => testState.dir }, @@ -103,6 +109,10 @@ async function createStore() { }), 'utf-8' ) + if (!hasCreatedStoreInCase) { + hasCreatedStoreInCase = true + return createFreshStore() + } vi.resetModules() installFakeAppEnvironment({ getPath: () => testState.dir }) const { Store, initDataPath } = await import('../persistence') @@ -111,6 +121,8 @@ async function createStore() { } beforeEach(() => { + hasCreatedStoreInCase = false + resetRetirementCollisionKeyCacheForTests() testState.dir = mkdtempSync(join(tmpdir(), 'automation-fencing-')) }) diff --git a/src/main/automations/automation-update-host-retarget.test.ts b/src/main/automations/automation-update-host-retarget.test.ts index 62c2b000d6fd..69fd40826429 100644 --- a/src/main/automations/automation-update-host-retarget.test.ts +++ b/src/main/automations/automation-update-host-retarget.test.ts @@ -1,4 +1,10 @@ -import { closeTestStores, createSqliteTestStore } from '../persistence-test-harness' +import { + closeTestStores, + createSqliteTestStore, + createStore as createFreshStore, + testState +} from '../persistence-test-harness' +import { resetRetirementCollisionKeyCacheForTests } from '../worktree-name-retirement' /** * An update may not move a record to another host unless it was asked to. * @@ -20,7 +26,7 @@ import { AUTOMATION_ORPHAN_ISSUES } from '../../shared/automation-list-scope' import { AUTOMATION_OWNER_CONFLICT_CODES } from '../../shared/automation-owner-conflict' import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' -const testState = { dir: '' } +let hasCreatedStoreInCase = false vi.mock('electron', () => ({ app: { getPath: () => testState.dir }, @@ -120,6 +126,10 @@ async function createStore() { }), 'utf-8' ) + if (!hasCreatedStoreInCase) { + hasCreatedStoreInCase = true + return createFreshStore() + } vi.resetModules() installFakeAppEnvironment({ getPath: () => testState.dir }) const { Store, initDataPath } = await import('../persistence') @@ -144,6 +154,8 @@ function projectedSelector(store: Store, id: string) { const ORPHAN = { selector: { kind: 'orphan' } } as const beforeEach(() => { + hasCreatedStoreInCase = false + resetRetirementCollisionKeyCacheForTests() testState.dir = mkdtempSync(join(tmpdir(), 'automation-retarget-')) }) diff --git a/src/main/automations/automation-workspace-host-attribution.test.ts b/src/main/automations/automation-workspace-host-attribution.test.ts index 326de8fad23b..6a4ab525557d 100644 --- a/src/main/automations/automation-workspace-host-attribution.test.ts +++ b/src/main/automations/automation-workspace-host-attribution.test.ts @@ -1,4 +1,10 @@ -import { closeTestStores, createSqliteTestStore } from '../persistence-test-harness' +import { + closeTestStores, + createSqliteTestStore, + createStore as createFreshStore, + testState +} from '../persistence-test-harness' +import { resetRetirementCollisionKeyCacheForTests } from '../worktree-name-retirement' /** * A folder workspace can pin its execution host while its repo stays local. * The desktop authority used to project only the repo, so those records listed @@ -19,7 +25,7 @@ import { AUTOMATION_ORPHAN_ISSUES } from '../../shared/automation-list-scope' import { AutomationService } from './service' import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' -const testState = { dir: '' } +let hasCreatedStoreInCase = false vi.mock('electron', () => ({ app: { getPath: () => testState.dir }, @@ -116,6 +122,10 @@ async function createStore( }), 'utf-8' ) + if (!hasCreatedStoreInCase) { + hasCreatedStoreInCase = true + return createFreshStore() + } vi.resetModules() installFakeAppEnvironment({ getPath: () => testState.dir }) const { Store, initDataPath } = await import('../persistence') @@ -124,6 +134,8 @@ async function createStore( } beforeEach(() => { + hasCreatedStoreInCase = false + resetRetirementCollisionKeyCacheForTests() testState.dir = mkdtempSync(join(tmpdir(), 'automation-workspace-host-')) }) diff --git a/src/main/automations/service-precheck.test.ts b/src/main/automations/service-precheck.test.ts index 2bd3b5dd969e..7e3ec5886ced 100644 --- a/src/main/automations/service-precheck.test.ts +++ b/src/main/automations/service-precheck.test.ts @@ -2,8 +2,11 @@ import { closeTestStores, createSqliteTestStore, readPersistedStateJson, - writePersistedStateJson + writePersistedStateJson, + createStore as createFreshStore, + testState } from '../persistence-test-harness' +import { resetRetirementCollisionKeyCacheForTests } from '../worktree-name-retirement' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mkdtempSync, rmSync } from 'node:fs' import { join } from 'node:path' @@ -15,7 +18,7 @@ import { createAutomationRunWriter } from './automation-run-writer' import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' const runAutomationPrecheckMock = vi.hoisted(() => vi.fn()) -const testState = { dir: '' } +let hasCreatedStoreInCase = false vi.mock('electron', () => ({ app: { @@ -33,6 +36,10 @@ vi.mock('./precheck-runner', () => ({ })) async function createStore() { + if (!hasCreatedStoreInCase) { + hasCreatedStoreInCase = true + return createFreshStore() + } vi.resetModules() installFakeAppEnvironment({ getPath: () => testState.dir }) const { Store, initDataPath } = await import('../persistence') @@ -64,6 +71,8 @@ function mutateDataFile( describe('AutomationService prechecks', () => { beforeEach(() => { + hasCreatedStoreInCase = false + resetRetirementCollisionKeyCacheForTests() testState.dir = mkdtempSync(join(tmpdir(), 'orca-automations-test-')) runAutomationPrecheckMock.mockReset() vi.useFakeTimers() diff --git a/src/main/worktree-removal-close-records.test.ts b/src/main/worktree-removal-close-records.test.ts index 9d2d2e32e71d..0b3722482c8a 100644 --- a/src/main/worktree-removal-close-records.test.ts +++ b/src/main/worktree-removal-close-records.test.ts @@ -1,4 +1,7 @@ -import { closeTestStores, createSqliteTestStore } from './persistence-test-harness' +import { closeTestStores, createSqliteTestStore, testState } from './persistence-test-harness' +import { Store as CanonicalStore } from './persistence/loading-store/store' +import { initDataPath as initCanonicalDataPath } from './persistence/loading-store/user-data-path' +import { resetRetirementCollisionKeyCacheForTests } from './worktree-name-retirement' // Why this file exists: removing a workspace's session rows must take its close records with it, // or they hold cap slots until the TTL and read as "emptied on purpose" for a new workspace at the // same path. @@ -15,7 +18,7 @@ import { type ClosedTerminalTabTombstonesByTabId } from '../shared/closed-terminal-tab-tombstones' -const testState = { dir: '' } +let hasCreatedStoreInCase = false vi.mock('./ssh/ssh-config-parser', () => ({ loadUserSshConfig: vi.fn(), @@ -37,6 +40,13 @@ vi.mock('./telemetry/client', () => ({ track: vi.fn() })) vi.mock('./telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn().mockReturnValue({}) })) async function createStore() { + if (!hasCreatedStoreInCase) { + hasCreatedStoreInCase = true + initCanonicalDataPath() + return createSqliteTestStore(CanonicalStore, { + dataFile: join(testState.dir, 'orca-data.json') + }) + } vi.resetModules() const { Store, initDataPath } = await import('./persistence') initDataPath() @@ -70,6 +80,8 @@ function sessionWithRecords(map: ClosedTerminalTabTombstonesByTabId) { describe('close records on workspace removal', () => { beforeEach(() => { + hasCreatedStoreInCase = false + resetRetirementCollisionKeyCacheForTests() testState.dir = mkdtempSync(join(tmpdir(), 'orca-test-')) }) diff --git a/src/main/worktree-removal-session-partition-fencing.test.ts b/src/main/worktree-removal-session-partition-fencing.test.ts index 03dc50a3dd54..8a5f1dad8a45 100644 --- a/src/main/worktree-removal-session-partition-fencing.test.ts +++ b/src/main/worktree-removal-session-partition-fencing.test.ts @@ -1,4 +1,7 @@ -import { closeTestStores, createSqliteTestStore } from './persistence-test-harness' +import { closeTestStores, createSqliteTestStore, testState } from './persistence-test-harness' +import { Store as CanonicalStore } from './persistence/loading-store/store' +import { initDataPath as initCanonicalDataPath } from './persistence/loading-store/user-data-path' +import { resetRetirementCollisionKeyCacheForTests } from './worktree-name-retirement' // Why this file exists: worktree removal writes to host session partitions, and the two hazards below // are only visible across a removal followed by a renderer session write — persistence.test.ts covers // removal and partitioning separately, so neither suite catches the interaction. @@ -10,7 +13,7 @@ import type { TerminalTab } from '../shared/terminal-tab-types' import { getDefaultWorkspaceSession } from '../shared/constants' import { toRuntimeExecutionHostId } from '../shared/execution-host' -const testState = { dir: '' } +let hasCreatedStoreInCase = false vi.mock('./ssh/ssh-config-parser', () => ({ loadUserSshConfig: vi.fn(), @@ -32,6 +35,13 @@ vi.mock('./telemetry/client', () => ({ track: vi.fn() })) vi.mock('./telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn().mockReturnValue({}) })) async function createStore() { + if (!hasCreatedStoreInCase) { + hasCreatedStoreInCase = true + initCanonicalDataPath() + return createSqliteTestStore(CanonicalStore, { + dataFile: join(testState.dir, 'orca-data.json') + }) + } vi.resetModules() const { Store, initDataPath } = await import('./persistence') initDataPath() @@ -56,6 +66,8 @@ const LIVE = 'repo-gone::/workspace/live' describe('worktree removal across host session partitions', () => { beforeEach(() => { + hasCreatedStoreInCase = false + resetRetirementCollisionKeyCacheForTests() testState.dir = mkdtempSync(join(tmpdir(), 'orca-test-')) }) From 7d6d7ca6f841acf686a8dd07c1dbcc7cf40073fa Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 7 Oct 2026 09:20:57 -0700 Subject: [PATCH 014/114] fix(skills): observe archive abort errors before reading (#26246) --- src/main/skills/skill-package-tar.test.ts | 33 ++++++++++++++++++++++- src/main/skills/skill-package-tar.ts | 2 ++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/main/skills/skill-package-tar.test.ts b/src/main/skills/skill-package-tar.test.ts index 4354c5d04431..5ed98a39d549 100644 --- a/src/main/skills/skill-package-tar.test.ts +++ b/src/main/skills/skill-package-tar.test.ts @@ -1,5 +1,13 @@ import { describe, expect, it } from 'vitest' -import { parseSkillTarHeader, SKILL_TAR_BLOCK_BYTES } from './skill-package-tar' +import { mkdtemp, rm } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { + openSkillTarGzip, + parseSkillTarHeader, + SKILL_TAR_BLOCK_BYTES, + writeSkillTarGzip +} from './skill-package-tar' function writeOctal(header: Buffer, offset: number, length: number, value: number): void { header.write(`${value.toString(8).padStart(length - 1, '0')}\0`, offset, length, 'ascii') @@ -62,3 +70,26 @@ describe('skill package tar envelope', () => { } }) }) + +describe('skill tar reader error ownership', () => { + it('observes an abort after the pipeline completes before the first reader request', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-skill-tar-abort-')) + try { + const archivePath = join(root, 'archive.tar.gz') + await writeSkillTarGzip(archivePath, [ + { path: 'SKILL.md', size: 1, executable: false, bytes: Buffer.from('x') } + ]) + const archive = await openSkillTarGzip(archivePath) + await archive.archiveIdentity + const failure = Object.assign(new Error('staging directory already exists'), { + code: 'EEXIST' + }) + archive.abort(failure) + // Observe destruction before lazily attaching the reader's error listener. + await new Promise((resolve) => setImmediate(resolve)) + await expect(archive.reader.readExact(SKILL_TAR_BLOCK_BYTES)).rejects.toBe(failure) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) +}) diff --git a/src/main/skills/skill-package-tar.ts b/src/main/skills/skill-package-tar.ts index f8cac20c6332..4a67f7ca5530 100644 --- a/src/main/skills/skill-package-tar.ts +++ b/src/main/skills/skill-package-tar.ts @@ -262,6 +262,8 @@ export async function openSkillTarGzip(archivePath: string): Promise<{ } }) const gunzip = createGunzip() + // Observe late aborts before the archive reader starts. + gunzip.on('error', () => undefined) const completion = pipeline(source, verifier, gunzip) const archiveIdentity = completion.then(() => ({ archiveSha256: archiveHash.digest('hex'), From 91d6ed0f3c847bd29c20c62e123a478e961b5a19 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 7 Oct 2026 10:22:29 -0700 Subject: [PATCH 015/114] fix(worktrees): a phone or CLI create puts the agent in the first orca.yaml default tab, as the desktop does (#26106) * fix(worktrees): the host's create puts its agent in the first default tab, as the window does With orca.yaml default tabs, the window's create starts the agent in the first default tab: that tab takes the template's title and color, and the template's command does not run beside the agent. The host's create started the agent in its own untitled tab and then created every default tab, the first one running its command too, so the workspace had one tab more than the window's create. Now the agent's tab takes the first template's place. * fix(worktrees): title the agent's default tab as a tab, so the phone keeps the agent's status The host's create titled the agent's tab by renaming the terminal, which writes the pane's own title stamped to outrank every title the agent sets later. On a headless host the phone reads status from those titles, so the agent tab showed the template title forever and lost its working/idle status. The window's create only sets the tab's custom title. The host now titles the tab the same way: the window gets the tab's custom title (not counted as a user rename), and a headless host saves it on the tab and shows it on the phone until the agent titles itself. The pane's title is left to the agent. The host also finds a terminal's tab itself from its handle, so the create over SSH, which never passed the tab, colors the agent's tab too. Every default tab is dressed by the same step, so the other tabs' titles are now kept on a headless host as well, and a failed title no longer skips the color. * fix(worktrees): find a default tab through the window's leaf once the window adopts its handle The host found a provisioned terminal's tab only through the runtime's own pty record. Once the window's graph sync adopts the handle, that lookup misses, and the default tab's title and color would be dropped with only a log line. No create reaches it today (the tab is dressed before the window's first graph sync), but any wait added before provisioning would. It now falls back to the window's leaf, as renaming a terminal already does. Tests: the restart case now saves the title to a real session and rebuilds the phone's tabs from it; a create through createManagedWorktree with an agent and default tabs spawns the agent and the second template only and titles the agent's tab "Dev" without counting it as a user rename. * test(worktrees): drop type assertions from the default-tab tests Use a runtime subclass for the protected launch scope and provisioning host, a typed store and notifier, and typed mocks in place of casts, so the changed-code quality gate passes. --- ...a-runtime-default-tab-agent-status.test.ts | 240 ++++++++++++++++++ ...get-worktree-terminal-provisioning-host.ts | 24 +- ...time-persist-headless-session-tab-props.ts | 8 +- ...worktree-setup-and-startup-part-03.spec.ts | 86 +++++++ src/main/runtime/runtime-notifier-contract.ts | 3 +- ...ime-worktree-terminal-provisioning.test.ts | 96 +++++++ .../runtime-worktree-terminal-provisioning.ts | 49 +++- src/main/window/runtime-window-lifecycle.ts | 3 +- .../ui-bridge-terminal-and-session-tabs.ts | 4 +- src/preload/api/ui-command-event-api.ts | 2 +- .../terminal-ui-routing-ipc-bridge.ts | 6 +- 11 files changed, 504 insertions(+), 17 deletions(-) create mode 100644 src/main/runtime/orca-runtime-default-tab-agent-status.test.ts create mode 100644 src/main/runtime/runtime-worktree-terminal-provisioning.test.ts diff --git a/src/main/runtime/orca-runtime-default-tab-agent-status.test.ts b/src/main/runtime/orca-runtime-default-tab-agent-status.test.ts new file mode 100644 index 000000000000..6af614603557 --- /dev/null +++ b/src/main/runtime/orca-runtime-default-tab-agent-status.test.ts @@ -0,0 +1,240 @@ +// A host-side create dresses its agent's tab as the first orca.yaml default tab. That title +// belongs to the tab, as the window's setTabCustomTitle makes it: on a headless host the +// phone must still read the agent's own titles for its status. +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' +import { getDefaultWorkspaceSession } from '../../shared/constants' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' +import { createMinimalPersistedTerminalTab } from '../persistence/restoring-sessions/session-owner-fields' +import { buildHeadlessMobileSessionTerminalTabs } from './mobile-session-terminal-projection' +import type { RuntimeNotifier } from './runtime-notifier-contract' +import type { RuntimeStore } from './runtime-store-contract' +import type { TerminalWorkspaceLaunchScope } from './runtime-legacy-worker-terminal-recovery-types' +import { provisionWorktreeTerminals } from './runtime-worktree-terminal-provisioning' + +vi.mock('electron', () => ({ + BrowserWindow: { fromId: vi.fn(() => null) }, + webContents: { fromId: vi.fn(() => null) }, + ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + app: { getPath: vi.fn(() => '/tmp') } +})) + +const WORKTREE_ID = 'wt-1' +const AGENT_TAB_ID = 'agent-tab' +const AGENT_LEAF_ID = '11111111-1111-4111-8111-111111111111' +const AGENT_PTY_ID = 'pty-agent' + +class DefaultTabRuntime extends OrcaRuntimeService { + constructor( + store: RuntimeStore | null, + private readonly connectionId: string | null + ) { + super(store) + } + + protected override async resolveTerminalWorkspaceLaunchScope(): Promise { + return { + id: WORKTREE_ID, + path: '/repo/app', + connectionId: this.connectionId, + repo: null, + folderWorkspace: null + } + } + + provisioningHost() { + return this.getWorktreeTerminalProvisioningHost() + } + + ptyTitle(ptyId: string): string | null | undefined { + return this.ptysById.get(ptyId)?.title + } +} + +function sessionStore(initial: WorkspaceSessionState): { + store: RuntimeStore + getSession: () => WorkspaceSessionState +} { + let session = initial + const store: RuntimeStore = { + getRepos: () => [], + getRepo: () => undefined, + addRepo: vi.fn(), + updateRepo: vi.fn(), + getAllWorktreeMeta: () => ({}), + getWorktreeMeta: () => undefined, + setWorktreeMeta: vi.fn(), + removeWorktreeMeta: vi.fn(), + getGitHubCache: vi.fn(), + getWorkspaceSession: () => session, + setWorkspaceSession: (next) => { + session = next + }, + getSettings: () => ({ + workspaceDir: '/workspaces', + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: false, + branchPrefix: 'none', + branchPrefixCustom: '' + }) + } + return { store, getSession: () => session } +} + +function notifierWithRename(renameTerminal: RuntimeNotifier['renameTerminal']): RuntimeNotifier { + return { + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + splitTerminal: vi.fn(), + renameTerminal, + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + } +} + +async function createHeadlessRuntimeWithAgent( + connectionId: string | null = null, + store: RuntimeStore | null = null +): Promise<{ runtime: DefaultTabRuntime; handle: string }> { + const runtime = new DefaultTabRuntime(store, connectionId) + let spawned = 0 + runtime.setPtyController({ + spawn: vi.fn(async () => ({ id: spawned++ === 0 ? AGENT_PTY_ID : `pty-${spawned}` })), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + const agent = await runtime.createTerminal(`id:${WORKTREE_ID}`, { + tabId: AGENT_TAB_ID, + leafId: AGENT_LEAF_ID, + launchAgent: 'claude' + }) + return { runtime, handle: agent.handle } +} + +async function dressAgentTab(runtime: DefaultTabRuntime, handle: string): Promise { + // Only the handle crosses: the host finds the tab, as it must for a create over SSH too. + await provisionWorktreeTerminals(runtime.provisioningHost(), { + worktreeSelector: `id:${WORKTREE_ID}`, + worktreeId: WORKTREE_ID, + worktreePath: '/repo/app', + defaultTabs: { + runCommands: true, + tabs: [{ title: 'Dev', command: 'pnpm dev', color: '#ff0000' }] + }, + primaryTerminalHandle: handle, + hasStartupTerminal: true, + setupCommandPlatform: 'posix' + }) +} + +async function agentTab(runtime: OrcaRuntimeService) { + const result = await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`) + const tab = result.tabs.find( + (candidate) => candidate.type === 'terminal' && candidate.parentTabId === AGENT_TAB_ID + ) + if (tab?.type !== 'terminal') { + throw new Error('expected the agent terminal tab') + } + return tab +} + +describe('the agent tab a host-side create dresses as the first default tab', () => { + it.each([ + ['a local', null], + ['an SSH', 'ssh-1'] + ])( + 'gives %s agent tab the template title and color before the agent titles itself', + async (_, connectionId) => { + const { runtime, handle } = await createHeadlessRuntimeWithAgent(connectionId) + await dressAgentTab(runtime, handle) + + expect(await agentTab(runtime)).toEqual( + expect.objectContaining({ title: 'Dev', color: '#ff0000' }) + ) + } + ) + + it("keeps the phone's status on the agent's own titles", async () => { + const { runtime, handle } = await createHeadlessRuntimeWithAgent() + await dressAgentTab(runtime, handle) + + runtime.onPtyData(AGENT_PTY_ID, '\x1b]0;⠋ Claude\x07', Date.now()) + + expect((await agentTab(runtime)).agentStatus).toEqual( + expect.objectContaining({ state: 'working' }) + ) + }) + + it('titles the tab, never the pane, and keeps the title across a restart', async () => { + // The spawn's pty binding has already saved the agent's tab when provisioning runs. + const { store, getSession } = sessionStore({ + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [WORKTREE_ID]: [ + createMinimalPersistedTerminalTab({ + worktreeId: WORKTREE_ID, + tabId: AGENT_TAB_ID, + ptyId: AGENT_PTY_ID, + existingTabCount: 0 + }) + ] + } + }) + const { runtime, handle } = await createHeadlessRuntimeWithAgent(null, store) + const renameTerminal = vi.fn() + runtime.setNotifier(notifierWithRename(renameTerminal)) + + await dressAgentTab(runtime, handle) + + expect(renameTerminal).toHaveBeenCalledWith(AGENT_TAB_ID, 'Dev', { recordInteraction: false }) + expect(runtime.ptyTitle(AGENT_PTY_ID)).toBeNull() + // A restarted headless host rebuilds the phone's tabs from the saved session. + const session = getSession() + expect( + buildHeadlessMobileSessionTerminalTabs( + WORKTREE_ID, + session.tabsByWorktree[WORKTREE_ID] ?? [], + session + ) + ).toEqual([ + expect.objectContaining({ parentTabId: AGENT_TAB_ID, title: 'Dev', color: '#ff0000' }) + ]) + }) + + it("still titles the tab once the window's graph has taken over the handle", async () => { + const { runtime, handle } = await createHeadlessRuntimeWithAgent() + const renameTerminal = vi.fn() + runtime.setNotifier(notifierWithRename(renameTerminal)) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: AGENT_TAB_ID, + worktreeId: WORKTREE_ID, + title: 'Terminal', + activeLeafId: AGENT_LEAF_ID, + layout: null + } + ], + leaves: [ + { + tabId: AGENT_TAB_ID, + worktreeId: WORKTREE_ID, + leafId: AGENT_LEAF_ID, + paneRuntimeId: 1, + ptyId: AGENT_PTY_ID + } + ] + }) + + await runtime.provisioningHost().setTabTitle(handle, 'Dev') + + expect(renameTerminal).toHaveBeenCalledWith(AGENT_TAB_ID, 'Dev', { recordInteraction: false }) + }) +}) diff --git a/src/main/runtime/orca-runtime-get-worktree-terminal-provisioning-host.ts b/src/main/runtime/orca-runtime-get-worktree-terminal-provisioning-host.ts index 3b465f643b36..0add054a67c5 100644 --- a/src/main/runtime/orca-runtime-get-worktree-terminal-provisioning-host.ts +++ b/src/main/runtime/orca-runtime-get-worktree-terminal-provisioning-host.ts @@ -27,7 +27,18 @@ export class OrcaRuntimeWithGetWorktreeTerminalProvisioningHost extends OrcaRunt createTerminal: (selector, options) => this.createTerminal(selector, options, createdWorktree), splitTerminal: (handle, options) => this.splitTerminal(handle, options, createdWorktree), - setTabColor: async (worktreeId, tabId, color) => { + setTabTitle: async (handle, title) => { + const { worktreeId, tabId } = this.getProvisionedTerminalTab(handle) + // Why not renameTerminal: it writes the pane's title, stamped to outrank every later + // agent title, which would hide the agent's own titles and status for the pane's life. + this.notifier?.renameTerminal(tabId, title, { recordInteraction: false }) + if (!this.getAvailableAuthoritativeWindow()) { + this.persistHeadlessTerminalTitle(worktreeId, tabId, title) + this.applyHeadlessSessionTabPropsToSnapshot(worktreeId, tabId, { title }) + } + }, + setTabColor: async (handle, color) => { + const { worktreeId, tabId } = this.getProvisionedTerminalTab(handle) await this.setMobileSessionTabProps(`id:${worktreeId}`, { tabId, color }) }, getSettings: () => this.requireStore().getSettings(), @@ -37,6 +48,17 @@ export class OrcaRuntimeWithGetWorktreeTerminalProvisioningHost extends OrcaRunt } } + protected getProvisionedTerminalTab(handle: string): { worktreeId: string; tabId: string } { + const pty = this.getLivePtyForHandle(handle)?.pty + if (pty?.tabId) { + return { worktreeId: pty.worktreeId, tabId: pty.tabId } + } + // Why: once the window's graph sync adopts the handle it names the window's leaf, not the + // runtime's pty, as renameTerminal also resolves it. + const { leaf } = this.getLiveLeafForHandle(handle) + return { worktreeId: leaf.worktreeId, tabId: leaf.tabId } + } + protected getWorktreeStartupReadinessHost(): WorktreeStartupReadinessHost { return { getPtyId: (handle) => this.getLivePtyForHandle(handle)?.pty.ptyId ?? null, diff --git a/src/main/runtime/orca-runtime-persist-headless-session-tab-props.ts b/src/main/runtime/orca-runtime-persist-headless-session-tab-props.ts index 7c21c0256545..78c31b578346 100644 --- a/src/main/runtime/orca-runtime-persist-headless-session-tab-props.ts +++ b/src/main/runtime/orca-runtime-persist-headless-session-tab-props.ts @@ -67,7 +67,12 @@ export class OrcaRuntimeWithPersistHeadlessSessionTabProps extends OrcaRuntimeWi protected applyHeadlessSessionTabPropsToSnapshot( worktreeId: string, tabId: string, - props: { color?: string | null; isPinned?: boolean; viewMode?: 'terminal' | 'chat' } + props: { + title?: string + color?: string | null + isPinned?: boolean + viewMode?: 'terminal' | 'chat' + } ): void { const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) if (!snapshot) { @@ -81,6 +86,7 @@ export class OrcaRuntimeWithPersistHeadlessSessionTabProps extends OrcaRuntimeWi changed = true return { ...tab, + ...(props.title !== undefined ? { title: props.title } : {}), ...(props.color !== undefined ? { color: props.color } : {}), ...(props.isPinned !== undefined ? { isPinned: props.isPinned } : {}), ...(props.viewMode !== undefined ? { viewMode: props.viewMode } : {}) diff --git a/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-03.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-03.spec.ts index 8c3724365884..f40f25da7e48 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-03.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-03.spec.ts @@ -411,6 +411,92 @@ describe('OrcaRuntimeService', () => { ) }) + it('puts the agent a create starts in the first default tab, as the window does', async () => { + const metaById: Record = {} + const runtimeStore = { + ...store, + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], + setWorktreeMeta: (worktreeId: string, meta: Partial) => { + metaById[worktreeId] = { ...(metaById[worktreeId] ?? makeWorktreeMeta()), ...meta } + return metaById[worktreeId] + } + } + const runtime = new OrcaRuntimeService(runtimeStore as never) + const spawn = vi + .fn<(options: { command?: string }) => Promise<{ id: string }>>() + .mockResolvedValueOnce({ id: 'pty-default-agent' }) + .mockResolvedValueOnce({ id: 'pty-default-test' }) + const revealTerminalSession = vi + .fn<(worktreeId: string, request: { tabId?: string }) => Promise<{ tabId: string }>>() + .mockResolvedValueOnce({ tabId: 'tab-default-agent' }) + .mockResolvedValueOnce({ tabId: 'tab-default-test' }) + const renameTerminal = vi.fn() + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + revealTerminalSession, + splitTerminal: vi.fn(), + renameTerminal, + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + + computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-default-agent') + ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-default-agent') + vi.mocked(getDefaultTabsLaunch).mockReturnValue({ + runCommands: true, + tabs: [ + { title: 'Dev', command: 'pnpm dev' }, + { title: 'Test', command: 'pnpm test' } + ] + }) + vi.mocked(listWorktrees).mockResolvedValue([ + { + path: '/tmp/workspaces/runtime-default-agent', + head: 'def', + branch: 'runtime-default-agent', + isBare: false, + isMainWorktree: false + } + ]) + + const result = await runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'runtime-default-agent', + setupDecision: 'run', + createdWithAgent: 'claude', + startup: { command: 'claude' } + }) + + await vi.waitFor(() => expect(revealTerminalSession).toHaveBeenCalledTimes(2)) + const agentTabId = revealTerminalSession.mock.calls[0]?.[1].tabId + expect(agentTabId).toEqual(expect.any(String)) + expect(renameTerminal).toHaveBeenCalledWith(agentTabId, 'Dev', { recordInteraction: false }) + // The first template's command never runs beside the agent; only the rest are created. + expect(spawn).toHaveBeenCalledTimes(2) + expect(spawn.mock.calls.map(([options]) => options.command)).toEqual([ + expect.stringContaining('claude'), + 'pnpm test' + ]) + expect(revealTerminalSession).toHaveBeenNthCalledWith( + 2, + result.worktree.id, + expect.objectContaining({ title: 'Test', activate: false }) + ) + }) + it('uses desktop task agent selection and bracketed-pastes startup drafts for local worktrees', async () => { vi.useFakeTimers() onTestFinished(() => { diff --git a/src/main/runtime/runtime-notifier-contract.ts b/src/main/runtime/runtime-notifier-contract.ts index 8a6b3d2bc467..d13da3976f1d 100644 --- a/src/main/runtime/runtime-notifier-contract.ts +++ b/src/main/runtime/runtime-notifier-contract.ts @@ -104,7 +104,8 @@ export type RuntimeNotifier = { newLeafId?: string } ): void - renameTerminal(tabId: string, title: string | null): void + /** `recordInteraction: false` marks a title the host applied, not one the user typed. */ + renameTerminal(tabId: string, title: string | null, options?: { recordInteraction: false }): void focusTerminal(tabId: string, worktreeId: string, leafId?: string | null): void focusEditorTab?(tabId: string, worktreeId: string): void closeSessionTab?(tabId: string, worktreeId: string): void | Promise diff --git a/src/main/runtime/runtime-worktree-terminal-provisioning.test.ts b/src/main/runtime/runtime-worktree-terminal-provisioning.test.ts new file mode 100644 index 000000000000..3ae0cd8e1b95 --- /dev/null +++ b/src/main/runtime/runtime-worktree-terminal-provisioning.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it, vi } from 'vitest' +import { + provisionWorktreeTerminals, + type WorktreeTerminalProvisioningHost +} from './runtime-worktree-terminal-provisioning' + +const DEFAULT_TABS = { + runCommands: true, + tabs: [ + { title: 'Dev', command: 'pnpm dev', color: '#ff0000' }, + { title: 'Tests', command: 'pnpm test', color: '#00ff00' } + ] +} + +function fakeHost() { + let created = 0 + return { + canSpawn: () => true, + createTerminal: vi.fn(async () => { + created += 1 + return { handle: `term_${created}` } + }), + splitTerminal: vi.fn(), + setTabTitle: vi.fn(async (_handle: string, _title: string) => {}), + setTabColor: vi.fn(async (_handle: string, _color: string) => {}), + getSettings: () => ({}), + getPtyId: () => undefined, + recordSetupCompletionToken: vi.fn() + } satisfies Partial> +} + +function provision(host: ReturnType, startup: boolean) { + return provisionWorktreeTerminals( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fake implements every host member provisioning reaches for default tabs with no setup. + host as unknown as WorktreeTerminalProvisioningHost, + { + worktreeSelector: 'id:wt-1', + worktreeId: 'wt-1', + worktreePath: '/worktrees/wt-1', + defaultTabs: DEFAULT_TABS, + primaryTerminalHandle: startup ? 'term_agent' : null, + hasStartupTerminal: startup, + setupCommandPlatform: 'posix' + } + ) +} + +describe('default tabs beside the agent a create started', () => { + it('makes the agent the first default tab, as the window lays it out', async () => { + const host = fakeHost() + await provision(host, true) + + expect(host.setTabTitle).toHaveBeenCalledWith('term_agent', 'Dev') + expect(host.setTabColor).toHaveBeenCalledWith('term_agent', '#ff0000') + // The first template's command never runs beside the agent; only the rest are created. + expect(host.createTerminal).toHaveBeenCalledTimes(1) + expect(host.createTerminal).toHaveBeenCalledWith('id:wt-1', { + title: 'Tests', + command: 'pnpm test' + }) + }) + + it('dresses every created default tab the same way', async () => { + const host = fakeHost() + await provision(host, false) + + expect(host.createTerminal).toHaveBeenCalledTimes(2) + expect(host.setTabTitle.mock.calls).toEqual([ + ['term_1', 'Dev'], + ['term_2', 'Tests'] + ]) + expect(host.setTabColor.mock.calls).toEqual([ + ['term_1', '#ff0000'], + ['term_2', '#00ff00'] + ]) + }) + + it('still colors the tab and creates the rest when titling fails', async () => { + const host = fakeHost() + host.setTabTitle.mockRejectedValue(new Error('terminal_tab_unresolved')) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + await expect(provision(host, true)).resolves.toEqual({ + setupSpawned: false, + setupTerminalHandle: null + }) + + expect(host.setTabColor).toHaveBeenCalledWith('term_agent', '#ff0000') + expect(host.createTerminal).toHaveBeenCalledTimes(1) + expect(warn).toHaveBeenCalledWith( + '[worktree-create] Failed to title a default tab for wt-1:', + expect.any(Error) + ) + warn.mockRestore() + }) +}) diff --git a/src/main/runtime/runtime-worktree-terminal-provisioning.ts b/src/main/runtime/runtime-worktree-terminal-provisioning.ts index 248757f863a8..3110c5228c65 100644 --- a/src/main/runtime/runtime-worktree-terminal-provisioning.ts +++ b/src/main/runtime/runtime-worktree-terminal-provisioning.ts @@ -5,7 +5,7 @@ import { buildObservedSetupCommand } from './orchestration/setup-completion-sign import { buildSetupRunnerCommand } from '../../shared/setup-runner-command' import type { RuntimeStore } from './runtime-store-contract' -type TerminalResult = { handle: string; tabId?: string | null } +type TerminalResult = { handle: string } export type WorktreeProvisionTerminalOptions = { title?: string command?: string @@ -25,7 +25,10 @@ export type WorktreeTerminalProvisioningHost = { handle: string, options: WorktreeProvisionTerminalOptions ) => Promise - setTabColor: (worktreeId: string, tabId: string, color: string) => Promise + // Why: keyed by the terminal; the host resolves its tab, so no create path can forget to pass it. + // The title is the tab's own, as the window's setTabCustomTitle sets it, never the pane's title. + setTabTitle: (handle: string, title: string) => Promise + setTabColor: (handle: string, color: string) => Promise getSettings: () => ReturnType getPtyId: (handle: string) => string | undefined recordSetupCompletionToken: (ptyId: string, token: string) => void @@ -51,13 +54,20 @@ export async function createWorktreeDefaultTabTerminals( selector: string, worktreeId: string, defaultTabs: CreateWorktreeResult['defaultTabs'] | undefined, - surfacing: { surfaceOwner?: false } = {} + surfacing: { surfaceOwner?: false } = {}, + startupHandle?: string ): Promise { if (!defaultTabs || defaultTabs.tabs.length === 0 || !host.canSpawn()) { return [] } + const [first, ...rest] = defaultTabs.tabs + // The agent the create started is the workspace's first tab, as the window lays it out: it takes + // the first template's title and color, and that template's command does not run beside it. + if (startupHandle && first) { + await dressAsDefaultTab(host, worktreeId, startupHandle, first) + } const handles: string[] = [] - for (const template of defaultTabs.tabs) { + for (const template of startupHandle ? rest : defaultTabs.tabs) { try { const command = template.command?.trim() const terminal = await host.createTerminal(selector, { @@ -66,9 +76,7 @@ export async function createWorktreeDefaultTabTerminals( ...surfacing }) handles.push(terminal.handle) - if (template.color && terminal.tabId) { - await host.setTabColor(worktreeId, terminal.tabId, template.color) - } + await dressAsDefaultTab(host, worktreeId, terminal.handle, template) } catch (error) { console.warn(`[worktree-create] Failed to create default tab for ${worktreeId}:`, error) } @@ -76,6 +84,30 @@ export async function createWorktreeDefaultTabTerminals( return handles } +// Why: a tab's look is bookkeeping; a failure is logged and never costs the tab, the other field, +// or the create. +async function dressAsDefaultTab( + host: WorktreeTerminalProvisioningHost, + worktreeId: string, + handle: string, + template: NonNullable['tabs'][number] +): Promise { + if (template.title) { + try { + await host.setTabTitle(handle, template.title) + } catch (error) { + console.warn(`[worktree-create] Failed to title a default tab for ${worktreeId}:`, error) + } + } + if (template.color) { + try { + await host.setTabColor(handle, template.color) + } catch (error) { + console.warn(`[worktree-create] Failed to color a default tab for ${worktreeId}:`, error) + } + } +} + export async function provisionWorktreeTerminals( host: WorktreeTerminalProvisioningHost, args: WorktreeTerminalProvisioningArgs @@ -92,7 +124,8 @@ export async function provisionWorktreeTerminals( args.worktreeSelector, args.worktreeId, args.defaultTabs, - surfacing + surfacing, + args.hasStartupTerminal ? (args.primaryTerminalHandle ?? undefined) : undefined ) let primaryHandle = args.primaryTerminalHandle ?? defaultHandles[0] ?? null const setupLaunchMode = diff --git a/src/main/window/runtime-window-lifecycle.ts b/src/main/window/runtime-window-lifecycle.ts index 857ed79d787e..95abe7c35158 100644 --- a/src/main/window/runtime-window-lifecycle.ts +++ b/src/main/window/runtime-window-lifecycle.ts @@ -165,7 +165,8 @@ export function registerRuntimeWindowLifecycle( newLeafId: opts.newLeafId }) }, - renameTerminal: (tabId, title) => send('ui:renameTerminal', { tabId, title }), + renameTerminal: (tabId, title, options) => + send('ui:renameTerminal', { tabId, title, ...options }), focusTerminal: (tabId, worktreeId, leafId) => send('ui:focusTerminal', { tabId, worktreeId, leafId }), focusEditorTab: (tabId, worktreeId) => send('ui:focusEditorTab', { tabId, worktreeId }), diff --git a/src/preload/api/ui-bridge-terminal-and-session-tabs.ts b/src/preload/api/ui-bridge-terminal-and-session-tabs.ts index 458a50c05841..a9555fae256d 100644 --- a/src/preload/api/ui-bridge-terminal-and-session-tabs.ts +++ b/src/preload/api/ui-bridge-terminal-and-session-tabs.ts @@ -145,11 +145,11 @@ export const uiTerminalAndSessionTabsApi = { return () => ipcRenderer.removeListener('ui:splitTerminal', listener) }, onRenameTerminal: ( - callback: (data: { tabId: string; title: string | null }) => void + callback: (data: { tabId: string; title: string | null; recordInteraction?: false }) => void ): (() => void) => { const listener = ( _event: Electron.IpcRendererEvent, - data: { tabId: string; title: string | null } + data: { tabId: string; title: string | null; recordInteraction?: false } ) => callback(data) ipcRenderer.on('ui:renameTerminal', listener) return () => ipcRenderer.removeListener('ui:renameTerminal', listener) diff --git a/src/preload/api/ui-command-event-api.ts b/src/preload/api/ui-command-event-api.ts index 9a9c8524ed3e..02dce476eeca 100644 --- a/src/preload/api/ui-command-event-api.ts +++ b/src/preload/api/ui-command-event-api.ts @@ -201,7 +201,7 @@ export type UiCommandEventApi = { }) => void ) => () => void onRenameTerminal: ( - callback: (data: { tabId: string; title: string | null }) => void + callback: (data: { tabId: string; title: string | null; recordInteraction?: false }) => void ) => () => void onFocusTerminal: ( callback: (data: { diff --git a/src/renderer/src/hooks/ipc-events/terminal-ui-routing-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/terminal-ui-routing-ipc-bridge.ts index 29ddc098726e..0d616577e364 100644 --- a/src/renderer/src/hooks/ipc-events/terminal-ui-routing-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/terminal-ui-routing-ipc-bridge.ts @@ -111,8 +111,10 @@ export function registerTerminalUiRoutingIpcBridge(unsubs: (() => void)[]): void unsubs.push(window.api.ui.onSplitTerminal(routeRuntimeTerminalSplitRequest)) unsubs.push( - window.api.ui.onRenameTerminal(({ tabId, title }) => { - useAppStore.getState().setTabCustomTitle(tabId, title) + window.api.ui.onRenameTerminal(({ tabId, title, recordInteraction }) => { + useAppStore + .getState() + .setTabCustomTitle(tabId, title, recordInteraction === false ? { recordInteraction } : {}) }) ) From 136c039896c75ff3b67b0e9e8453b49f2fbbc3f0 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 7 Oct 2026 10:22:35 -0700 Subject: [PATCH 016/114] test: import Activity functions directly in eight suites (#26237) --- ...tivityPrototypePage.filter-focus-shortcut.test.ts | 2 +- .../activity/ActivityPrototypePage.test.ts | 12 +++++++----- .../ActivityPrototypePage.thread-grouping.test.ts | 11 ++++------- .../activity/ActivityThreadOptionsMenu.test.tsx | 2 +- ...activity-portal-readiness-decay.react185.test.tsx | 2 +- .../activity-portal-readiness-loop.react185.test.tsx | 2 +- ...al-readiness-subscription-churn.react185.test.tsx | 2 +- .../activity/activity-status-group-header.test.ts | 8 +++----- 8 files changed, 19 insertions(+), 22 deletions(-) diff --git a/src/renderer/src/components/activity/ActivityPrototypePage.filter-focus-shortcut.test.ts b/src/renderer/src/components/activity/ActivityPrototypePage.filter-focus-shortcut.test.ts index da8fe2e2df24..6a4f2c64d688 100644 --- a/src/renderer/src/components/activity/ActivityPrototypePage.filter-focus-shortcut.test.ts +++ b/src/renderer/src/components/activity/ActivityPrototypePage.filter-focus-shortcut.test.ts @@ -3,7 +3,7 @@ import { handleActivityFilterFocusShortcut, isActivityFilterFocusShortcut, shouldIgnoreActivityFilterFocusShortcutTarget -} from './ActivityPrototypePage' +} from './activity-filter-focus-shortcut' describe('activity filter focus shortcut', () => { it('matches Cmd+F on Mac and Ctrl+F elsewhere without extra modifiers', () => { diff --git a/src/renderer/src/components/activity/ActivityPrototypePage.test.ts b/src/renderer/src/components/activity/ActivityPrototypePage.test.ts index 2a960cb464e5..9f6587be094f 100644 --- a/src/renderer/src/components/activity/ActivityPrototypePage.test.ts +++ b/src/renderer/src/components/activity/ActivityPrototypePage.test.ts @@ -8,13 +8,16 @@ import { makePaneKey } from '../../../../shared/stable-pane-id' import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { ACTIVITY_SEARCH_QUERY_MAX_BYTES, - activityThreadResponseRenderPreview, activityThreadMatchesSearchQuery, - buildActivityEvents, - buildAgentPaneThreads, buildActivityThreadGroups, isActivitySearchQueryTooLarge -} from './ActivityPrototypePage' +} from './activity-thread-grouping' +import { + activityThreadResponseRenderPreview, + activityThreadStatusId +} from './activity-thread-presentation' +import { buildActivityEvents } from './activity-event-builder' +import { buildAgentPaneThreads } from './activity-thread-builder' import { makeActivityResult, makeRepo, @@ -29,7 +32,6 @@ import { PANE_KEY_2, PANE_KEY_3 } from './ActivityPrototypePage-test-fixtures' -import { activityThreadStatusId } from './activity-thread-presentation' describe('buildActivityEvents', () => { it('keeps every pane visible before applying the global activity cap', () => { diff --git a/src/renderer/src/components/activity/ActivityPrototypePage.thread-grouping.test.ts b/src/renderer/src/components/activity/ActivityPrototypePage.thread-grouping.test.ts index 42f45f1683d2..9a53c4ada07c 100644 --- a/src/renderer/src/components/activity/ActivityPrototypePage.thread-grouping.test.ts +++ b/src/renderer/src/components/activity/ActivityPrototypePage.thread-grouping.test.ts @@ -4,13 +4,10 @@ import { describe, expect, it } from 'vitest' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' import { formatAgentTypeLabel } from '@/lib/agent-status' import { TooltipProvider } from '@/components/ui/tooltip' -import { - buildActivityThreadGroups, - buildActivityEvents, - buildAgentPaneThreads, - getActivityThreadGroup, - ThreadAgentStateIndicator -} from './ActivityPrototypePage' +import { buildActivityThreadGroups, getActivityThreadGroup } from './activity-thread-grouping' +import { buildActivityEvents } from './activity-event-builder' +import { buildAgentPaneThreads } from './activity-thread-builder' +import { ThreadAgentStateIndicator } from './activity-thread-controls' import { makeActivityResult, makeRepo, diff --git a/src/renderer/src/components/activity/ActivityThreadOptionsMenu.test.tsx b/src/renderer/src/components/activity/ActivityThreadOptionsMenu.test.tsx index c5cedcb2443d..544101f7ff2b 100644 --- a/src/renderer/src/components/activity/ActivityThreadOptionsMenu.test.tsx +++ b/src/renderer/src/components/activity/ActivityThreadOptionsMenu.test.tsx @@ -6,7 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { TooltipProvider } from '@/components/ui/tooltip' import { useAppStore } from '@/store' -import { ActivityThreadOptionsMenu } from './ActivityPrototypePage' +import { ActivityThreadOptionsMenu } from './activity-thread-controls' import type { ActivityGroupBy } from './activity-thread-types' import { makeRepo } from './ActivityPrototypePage-test-fixtures' diff --git a/src/renderer/src/components/activity/activity-portal-readiness-decay.react185.test.tsx b/src/renderer/src/components/activity/activity-portal-readiness-decay.react185.test.tsx index f9a6581d608a..50e2383aa63d 100644 --- a/src/renderer/src/components/activity/activity-portal-readiness-decay.react185.test.tsx +++ b/src/renderer/src/components/activity/activity-portal-readiness-decay.react185.test.tsx @@ -2,7 +2,7 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { useActivityTerminalPortalStatus } from './ActivityPrototypePage' +import { useActivityTerminalPortalStatus } from './activity-terminal-portal-status' import { ACTIVITY_PORTAL_READINESS_BURST_WINDOW_MS, ACTIVITY_PORTAL_READINESS_MAX_FLIPS diff --git a/src/renderer/src/components/activity/activity-portal-readiness-loop.react185.test.tsx b/src/renderer/src/components/activity/activity-portal-readiness-loop.react185.test.tsx index 2389e753bb3e..209dece6a78d 100644 --- a/src/renderer/src/components/activity/activity-portal-readiness-loop.react185.test.tsx +++ b/src/renderer/src/components/activity/activity-portal-readiness-loop.react185.test.tsx @@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true -import { useActivityTerminalPortalStatus } from './ActivityPrototypePage' +import { useActivityTerminalPortalStatus } from './activity-terminal-portal-status' import { findActivityTerminalPortal, type ActivityTerminalPortalTarget diff --git a/src/renderer/src/components/activity/activity-portal-readiness-subscription-churn.react185.test.tsx b/src/renderer/src/components/activity/activity-portal-readiness-subscription-churn.react185.test.tsx index d8b9c3caff41..135b784887a7 100644 --- a/src/renderer/src/components/activity/activity-portal-readiness-subscription-churn.react185.test.tsx +++ b/src/renderer/src/components/activity/activity-portal-readiness-subscription-churn.react185.test.tsx @@ -3,7 +3,7 @@ import { act, useState } from 'react' import { flushSync } from 'react-dom' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { useActivityTerminalPortalStatus } from './ActivityPrototypePage' +import { useActivityTerminalPortalStatus } from './activity-terminal-portal-status' import { ACTIVITY_PORTAL_READINESS_MAX_FLIPS } from './activity-portal-readiness-oscillation' ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true diff --git a/src/renderer/src/components/activity/activity-status-group-header.test.ts b/src/renderer/src/components/activity/activity-status-group-header.test.ts index cbcf468b900d..5c60c422fe9f 100644 --- a/src/renderer/src/components/activity/activity-status-group-header.test.ts +++ b/src/renderer/src/components/activity/activity-status-group-header.test.ts @@ -1,10 +1,8 @@ import { describe, expect, it } from 'vitest' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' -import { - buildActivityEvents, - buildActivityThreadGroups, - buildAgentPaneThreads -} from './ActivityPrototypePage' +import { buildActivityEvents } from './activity-event-builder' +import { buildActivityThreadGroups } from './activity-thread-grouping' +import { buildAgentPaneThreads } from './activity-thread-builder' import { makeRepo, makeTabWithIds, From c2f3229362124326fbcf897d3cf17f6e91313f58 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 7 Oct 2026 10:23:29 -0700 Subject: [PATCH 017/114] fix(worktrees): a phone or CLI create keeps a sparse preset only when its folders match (#26104) * fix(worktrees): the host's create keeps a sparse preset only when its directories match The window's create records a sparse preset on the new worktree only when the checked-out directories are exactly that preset's, so an edited selection is not shown as the preset it began as. The host's create wrote whatever preset id it was sent. Both now share one check (moved out of the two copies in the window's create path). * fix(worktrees): read sparse presets only when a preset was chosen, and never fail the create on them The shared preset check took the preset list up front, so every create read the presets even with no preset chosen (the window's create used to skip the read), and a read that threw failed the create - on the host after `git worktree add` had already run. The check now takes a reader, returns early when no preset was sent, and reads inside its try so unreadable presets record no preset instead of blocking the create. Repeated checked-out directories no longer match a preset with a different set of directories. --- .../ipc/sparse-preset-attribution.test.ts | 68 +++++++++++++++++++ src/main/ipc/sparse-preset-attribution.ts | 33 +++++++++ src/main/ipc/worktree-remote.ts | 50 ++++---------- .../runtime-local-worktree-create.test.ts | 43 +++++++++++- .../runtime-local-worktree-materialization.ts | 8 ++- 5 files changed, 163 insertions(+), 39 deletions(-) create mode 100644 src/main/ipc/sparse-preset-attribution.test.ts create mode 100644 src/main/ipc/sparse-preset-attribution.ts diff --git a/src/main/ipc/sparse-preset-attribution.test.ts b/src/main/ipc/sparse-preset-attribution.test.ts new file mode 100644 index 000000000000..34861c818d9f --- /dev/null +++ b/src/main/ipc/sparse-preset-attribution.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it, vi } from 'vitest' +import type { SparsePreset } from '../../shared/worktree/create-types' +import { attributedSparsePresetId } from './sparse-preset-attribution' + +const PRESET: SparsePreset = { + id: 'preset-1', + repoId: 'repo-1', + name: 'web', + directories: ['apps/web', 'packages/ui'], + createdAt: 0, + updatedAt: 0 +} + +describe('the sparse preset a create is recorded under', () => { + it('keeps a preset whose directories are exactly the ones checked out, in any order', () => { + expect( + attributedSparsePresetId(() => [PRESET], 'repo-1', 'preset-1', ['packages/ui', 'apps/web']) + ).toBe('preset-1') + }) + + it('keeps a preset saved with trailing slashes', () => { + const preset = { ...PRESET, directories: ['apps/web/', 'packages/ui/'] } + expect( + attributedSparsePresetId(() => [preset], 'repo-1', 'preset-1', ['apps/web', 'packages/ui']) + ).toBe('preset-1') + }) + + it.each([ + ['an edited selection', 'repo-1', 'preset-1', ['apps/web']], + [ + 'a selection that repeats one of its directories', + 'repo-1', + 'preset-1', + ['apps/web', 'apps/web'] + ], + ['another repo’s preset', 'repo-2', 'preset-1', ['apps/web', 'packages/ui']], + ['a preset that no longer exists', 'repo-1', 'preset-gone', ['apps/web', 'packages/ui']] + ])('records none for %s', (_case, repoId, presetId, directories) => { + expect(attributedSparsePresetId(() => [PRESET], repoId, presetId, directories)).toBeUndefined() + }) + + it.each([undefined, ''])('does not read presets when no preset was chosen (%j)', (presetId) => { + const readPresets = vi.fn(() => [PRESET]) + expect( + attributedSparsePresetId(readPresets, 'repo-1', presetId, ['apps/web', 'packages/ui']) + ).toBeUndefined() + expect(readPresets).not.toHaveBeenCalled() + }) + + it('records none, without throwing, when presets cannot be read', () => { + const readPresets = (): SparsePreset[] => { + throw new Error('corrupt presets') + } + expect( + attributedSparsePresetId(readPresets, 'repo-1', 'preset-1', ['apps/web', 'packages/ui']) + ).toBeUndefined() + }) + + it('records none for a preset whose saved directories are not repo-relative', () => { + const preset = { ...PRESET, directories: ['/abs/apps/web', 'packages/ui'] } + expect( + attributedSparsePresetId(() => [preset], 'repo-1', 'preset-1', [ + 'abs/apps/web', + 'packages/ui' + ]) + ).toBeUndefined() + }) +}) diff --git a/src/main/ipc/sparse-preset-attribution.ts b/src/main/ipc/sparse-preset-attribution.ts new file mode 100644 index 000000000000..4c186c401acd --- /dev/null +++ b/src/main/ipc/sparse-preset-attribution.ts @@ -0,0 +1,33 @@ +import type { SparsePreset } from '../../shared/worktree/create-types' +import { normalizeSparseDirectories } from './sparse-checkout-directories' + +/** + * The preset a sparse create is recorded under: only one of this repo's presets whose directories + * are exactly the ones checked out, so an edited selection is never shown as the preset it began as. + */ +export function attributedSparsePresetId( + readPresets: () => readonly SparsePreset[], + repoId: string, + presetId: string | undefined, + sparseDirectories: readonly string[] +): string | undefined { + if (!presetId) { + return undefined + } + try { + const preset = readPresets().find((entry) => entry.id === presetId) + if (preset?.repoId !== repoId) { + return undefined + } + // Set-based so directory order doesn't matter, as the renderer's `sparseDirectoriesMatch` does. + const presetSet = new Set(normalizeSparseDirectories(preset.directories)) + const checkedOutSet = new Set(sparseDirectories) + const directoriesMatch = + presetSet.size === checkedOutSet.size && + [...checkedOutSet].every((entry) => presetSet.has(entry)) + return directoriesMatch ? preset.id : undefined + } catch { + // Unreadable or corrupt preset data must not block the create or falsely label the new worktree. + return undefined + } +} diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 4e4f1d51f4aa..cdfe55d105d7 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -140,6 +140,7 @@ import { formatWorktreeIncludeCopyWarning } from './worktree-include-copy-budget import { resolveWorktreeIncludePaths } from '../git/worktree-include-file' import { resolveWorktreeSharedDirectories } from '../git/worktree-shared-directories' import { normalizeSparseDirectories } from './sparse-checkout-directories' +import { attributedSparsePresetId } from './sparse-preset-attribution' import { joinWorktreeRelativePath } from '../runtime/runtime-relative-paths' import type { IFilesystemProvider } from '../providers/types' import { @@ -1890,24 +1891,12 @@ export async function createRemoteWorktree( if (args.sparseCheckout && sparseDirectories.length === 0) { throw new Error('Sparse checkout requires at least one repo-relative directory.') } - let sparsePresetId: string | undefined - if (args.sparseCheckout?.presetId) { - const preset = store - .getSparsePresets(repo.id) - .find((entry) => entry.id === args.sparseCheckout?.presetId) - if (preset?.repoId === repo.id) { - try { - const presetDirectories = normalizeSparseDirectories(preset.directories) - const presetSet = new Set(presetDirectories) - const directoriesMatch = - presetDirectories.length === sparseDirectories.length && - sparseDirectories.every((entry) => presetSet.has(entry)) - sparsePresetId = directoriesMatch ? preset.id : undefined - } catch { - // Why: corrupt preset data should not block creation or falsely label the new worktree. - } - } - } + const sparsePresetId = attributedSparsePresetId( + () => store.getSparsePresets(repo.id), + repo.id, + args.sparseCheckout?.presetId, + sparseDirectories + ) // Why: addWorktree/setup probes run inside the new path; older relays need that root registered before accepting git/fs ops there. await registerRequiredSshWorktreeCreateRoots(repo.connectionId!, [remotePath]) @@ -2379,25 +2368,12 @@ async function performLocalWorktreeCreate( if (args.sparseCheckout && sparseDirectories.length === 0) { throw new Error('Sparse checkout requires at least one repo-relative directory.') } - let sparsePresetId: string | undefined - if (args.sparseCheckout?.presetId) { - const preset = store - .getSparsePresets(repo.id) - .find((entry) => entry.id === args.sparseCheckout?.presetId) - if (preset?.repoId === repo.id) { - try { - const presetDirectories = normalizeSparseDirectories(preset.directories) - // Why: Set-based compare so directory order doesn't affect attribution — matches renderer's sparseDirectoriesMatch. - const presetSet = new Set(presetDirectories) - const directoriesMatch = - presetDirectories.length === sparseDirectories.length && - sparseDirectories.every((entry) => presetSet.has(entry)) - sparsePresetId = directoriesMatch ? preset.id : undefined - } catch { - // Why: corrupt preset data should not block creation or falsely label the new worktree. - } - } - } + const sparsePresetId = attributedSparsePresetId( + () => store.getSparsePresets(repo.id), + repo.id, + args.sparseCheckout?.presetId, + sparseDirectories + ) let effectiveRequestedName = requestedName let effectiveSanitizedName = sanitizedName diff --git a/src/main/runtime/runtime-local-worktree-create.test.ts b/src/main/runtime/runtime-local-worktree-create.test.ts index 979b05b90907..834962fc6b14 100644 --- a/src/main/runtime/runtime-local-worktree-create.test.ts +++ b/src/main/runtime/runtime-local-worktree-create.test.ts @@ -90,9 +90,21 @@ import { createWorktreeCreateTimingRecorder, type WorktreeCreateTimingRecorder } from '../worktree-create-timing' +import type { SparsePreset } from '../../shared/worktree/create-types' const worktreePath = resolve('/worktrees', 'app') +const writtenMeta: Partial[] = [] +const WEB_PRESET: SparsePreset = { + id: 'preset-web', + repoId: 'repo-1', + name: 'web', + directories: ['apps/web', 'packages/ui'], + createdAt: 0, + updatedAt: 0 +} +const readSparsePresets = vi.fn<(repoId: string) => SparsePreset[]>() + function createWorktree( request: Partial = {}, rearm: PreparationRearmHolder = { fire: () => {} }, @@ -106,7 +118,11 @@ function createWorktree( refreshLocalBaseRefOnWorktreeCreate: false, branchPrefix: '' }), - setWorktreeMeta: (_id: string, updates: Partial) => updates + setWorktreeMeta: (_id: string, updates: Partial) => { + writtenMeta.push(updates) + return updates + }, + getSparsePresets: (repoId: string): SparsePreset[] => readSparsePresets(repoId) } return createRuntimeLocalManagedWorktree({ request: { repoSelector: 'repo-1', name: 'app', baseBranch: 'main', ...request }, @@ -162,6 +178,7 @@ beforeEach(() => { mocks.resolveInclude.mockResolvedValue(['.env']) mocks.copyPaths.mockResolvedValue([]) mocks.effectiveHooks.mockReturnValue(null) + readSparsePresets.mockReturnValue([WEB_PRESET]) }) describe('runtime prepared-worktree replenishment', () => { @@ -336,6 +353,30 @@ describe('runtime create Git priority', () => { } }) + it.each([ + ['exactly its directories', ['packages/ui', 'apps/web'], 'preset-web'], + ['an edited selection', ['apps/web'], undefined] + ])('records the sparse preset only for %s', async (_case, directories, recorded) => { + writtenMeta.length = 0 + await createWorktree({ sparseCheckout: { directories, presetId: 'preset-web' } }) + + expect(writtenMeta.find((meta) => meta.sparseDirectories)?.sparsePresetId).toBe(recorded) + }) + + it('still creates the worktree, with no sparse preset, when presets cannot be read', async () => { + writtenMeta.length = 0 + readSparsePresets.mockImplementation(() => { + throw new Error('corrupt presets') + }) + await createWorktree({ + sparseCheckout: { directories: ['apps/web', 'packages/ui'], presetId: 'preset-web' } + }) + + const sparseMeta = writtenMeta.find((meta) => meta.sparseDirectories) + expect(sparseMeta?.sparseDirectories).toEqual(['apps/web', 'packages/ui']) + expect(sparseMeta?.sparsePresetId).toBeUndefined() + }) + it('preserves priority for sparse creates and remote base refreshes', async () => { const base = { remote: 'origin', diff --git a/src/main/runtime/runtime-local-worktree-materialization.ts b/src/main/runtime/runtime-local-worktree-materialization.ts index 9347a7e7ffce..19880e75430a 100644 --- a/src/main/runtime/runtime-local-worktree-materialization.ts +++ b/src/main/runtime/runtime-local-worktree-materialization.ts @@ -22,6 +22,7 @@ import { resolveWorktreeSharedDirectories } from '../git/worktree-shared-directo import type { RuntimeManagedWorktreeCreateArgs } from './runtime-managed-worktree-create-types' import type { RemoteTrackingBase } from './runtime-remote-fetch-controller' import type { RuntimeStore } from './runtime-store-contract' +import { attributedSparsePresetId } from '../ipc/sparse-preset-attribution' import type { WorktreeCreateTimingRecorder } from '../worktree-create-timing' export async function materializeRuntimeLocalWorktree(args: { @@ -91,7 +92,12 @@ export async function materializeRuntimeLocalWorktree(args: { ? { sparseDirectories, sparseBaseRef: metadataBaseRef, - sparsePresetId: request.sparseCheckout?.presetId + sparsePresetId: attributedSparsePresetId( + () => store.getSparsePresets?.(repo.id) ?? [], + repo.id, + request.sparseCheckout?.presetId, + sparseDirectories + ) } : {}), ...(request.linkedIssue !== undefined ? { linkedIssue: request.linkedIssue } : {}), From 68c493fa72c82d89508179a45ab7205cc4bdce0c Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 7 Oct 2026 10:28:30 -0700 Subject: [PATCH 018/114] test(runtime): advance injected teardown policy clocks (#26267) --- ...ructured-session-worktree-teardown.test.ts | 132 +++++++++++------- 1 file changed, 83 insertions(+), 49 deletions(-) diff --git a/src/main/runtime/structured-session-worktree-teardown.test.ts b/src/main/runtime/structured-session-worktree-teardown.test.ts index f33d7889b31f..4c4145bab2e5 100644 --- a/src/main/runtime/structured-session-worktree-teardown.test.ts +++ b/src/main/runtime/structured-session-worktree-teardown.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionRecord } from '../../shared/agent-session-record' import type { IPtyProvider } from '../providers/types' +import { ABANDONED_SWEEP_GRACE_MS } from './forced-sweep-settlement' const hostRef: { current: unknown } = { current: null } @@ -512,74 +513,107 @@ describe('worktree teardown and structured agent sessions', () => { }) it('refuses in agent-session wording when the close outlives the sweep budget', async () => { - // A structured close that runs out of time used to reject with the PTY timeout sentinel, which - // the classifier reads FIRST — so the toast blamed terminals, and the Force Delete meant to - // clear the wedge hit the same rejection again (#11960). - installHost({ records: [record('s1', WORKTREE)], closeGate: new Promise(() => {}) }) - const error = await killAllProcessesForWorktree( - WORKTREE, - destructiveDeps({ timeoutMs: 5 }) - ).catch((thrown: Error) => thrown.message) - expect(error).toContain('could not confirm these closed: 1 agent session (claude)') - expect(isUnstoppedPtyRemovalError(error as string)).toBe(false) - expect(classifyWorktreeForceDeleteReason(error as string, true)).toBe('running-agent-session') + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }) + try { + // A structured close that runs out of time used to reject with the PTY timeout sentinel, which + // the classifier reads FIRST — so the toast blamed terminals, and the Force Delete meant to + // clear the wedge hit the same rejection again (#11960). + installHost({ records: [record('s1', WORKTREE)], closeGate: new Promise(() => {}) }) + const outcome = killAllProcessesForWorktree( + WORKTREE, + destructiveDeps({ timeoutMs: 5 }) + ).catch((thrown: Error) => thrown.message) + await vi.advanceTimersByTimeAsync(5) + const error = await outcome + expect(error).toContain('could not confirm these closed: 1 agent session (claude)') + expect(isUnstoppedPtyRemovalError(error as string)).toBe(false) + expect(classifyWorktreeForceDeleteReason(error as string, true)).toBe('running-agent-session') + } finally { + vi.useRealTimers() + } }) it('never wedges Force Delete on a close that will not settle', async () => { + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }) const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - installHost({ records: [record('s1', WORKTREE)], closeGate: new Promise(() => {}) }) - await expect( - killAllProcessesForWorktree( + try { + installHost({ records: [record('s1', WORKTREE)], closeGate: new Promise(() => {}) }) + const outcome = killAllProcessesForWorktree( WORKTREE, destructiveDeps({ allowUnverifiedStop: true, timeoutMs: 5 }) ) - ).resolves.toMatchObject({ runtimeStopped: 0 }) - const message = structuredSessionWarning(warn) - expect(message).toContain('could not confirm these closed: 1 agent session (claude)') - // The pin: a close that ran out of time was never watched stay attached. This warn is the only - // record a forced removal leaves, and the removal.ts split exists precisely so "we could not - // confirm" is never reported as "we saw it running" — including here. - expect(message).not.toContain('still attached') - warn.mockRestore() + void outcome.catch(() => undefined) + await vi.advanceTimersByTimeAsync(5 + ABANDONED_SWEEP_GRACE_MS) + await expect(outcome).resolves.toMatchObject({ runtimeStopped: 0 }) + const message = structuredSessionWarning(warn) + expect(message).toContain('could not confirm these closed: 1 agent session (claude)') + // The pin: a close that ran out of time was never watched stay attached. This warn is the only + // record a forced removal leaves, and the removal.ts split exists precisely so "we could not + // confirm" is never reported as "we saw it running" — including here. + expect(message).not.toContain('still attached') + } finally { + warn.mockRestore() + vi.useRealTimers() + } }) it('names only the sessions still open when the budget expires mid-close', async () => { - // The close loop is serial, so a deadline can land part-way through it. A fallback assembled - // at the deadline could only name the whole list — so a removal that had already closed the - // first chat still told the user both were still there, which is the exact thing this sweep - // exists to stop doing: never report state nobody observed. - installHost({ - records: [record('s1', WORKTREE), record('s2', WORKTREE, { provider: 'codex' })], - closeGates: { s2: new Promise(() => {}) } - }) - const error = await killAllProcessesForWorktree( - WORKTREE, - destructiveDeps({ timeoutMs: 40 }) - ).catch((thrown: Error) => thrown.message) - expect(error).toContain('could not confirm these closed: 1 agent session (codex)') - expect(error).not.toContain('claude') + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }) + try { + // The close loop is serial, so a deadline can land part-way through it. A fallback assembled + // at the deadline could only name the whole list — so a removal that had already closed the + // first chat still told the user both were still there, which is the exact thing this sweep + // exists to stop doing: never report state nobody observed. + installHost({ + records: [record('s1', WORKTREE), record('s2', WORKTREE, { provider: 'codex' })], + closeGates: { s2: new Promise(() => {}) } + }) + const outcome = killAllProcessesForWorktree( + WORKTREE, + destructiveDeps({ timeoutMs: 40 }) + ).catch((thrown: Error) => thrown.message) + await vi.advanceTimersByTimeAsync(40) + const error = await outcome + expect(error).toContain('could not confirm these closed: 1 agent session (codex)') + expect(error).not.toContain('claude') + } finally { + vi.useRealTimers() + } }) it('counts the closes that landed before the budget expired', async () => { + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }) // The other half of the same fallback: it reported zero closes, so the removal log said // `structured=0` for a chat it had just ended. const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) const slowClose = new Promise((resolve) => { setTimeout(resolve, 300) }) - installHost({ - records: [record('s1', WORKTREE), record('s2', WORKTREE, { provider: 'codex' })], - closeGates: { s2: slowClose } - }) - const result = await killAllProcessesForWorktree( - WORKTREE, - destructiveDeps({ allowUnverifiedStop: true, timeoutMs: 40 }) - ) - expect(result.structuredStopped).toBe(1) - expect(structuredSessionWarning(warn)).toContain( - 'could not confirm these closed: 1 agent session (codex)' - ) - warn.mockRestore() + try { + installHost({ + records: [record('s1', WORKTREE), record('s2', WORKTREE, { provider: 'codex' })], + closeGates: { s2: slowClose } + }) + const outcome = killAllProcessesForWorktree( + WORKTREE, + destructiveDeps({ allowUnverifiedStop: true, timeoutMs: 40 }) + ) + void outcome.catch(() => undefined) + await vi.advanceTimersByTimeAsync(300) + const result = await outcome + expect(result.structuredStopped).toBe(1) + expect(structuredSessionWarning(warn)).toContain( + 'could not confirm these closed: 1 agent session (codex)' + ) + } finally { + try { + await vi.advanceTimersByTimeAsync(300) + await slowClose + } finally { + warn.mockRestore() + vi.useRealTimers() + } + } }) it('stops issuing new closes once the budget is spent', async () => { From 80d45095d2ffbac477716e13711f6bfea2ed0551 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 7 Oct 2026 10:29:52 -0700 Subject: [PATCH 019/114] fix(native-chat): a message kept after a quit or close waits in order and follows the chat's next turn (#25960) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(native-chat): drop the queue-paused header and Resume button A Stop, a restart or /clear holds the queued cards. The hold stays; only the header row naming why, and its Resume button, go. A held card shows no caption, and its own Steer, or any new message, releases the queue. * test(native-chat): type the unknown hold reason a newer host may publish * fix(native-chat): a held card offers Send, not Steer, when no turn runs Steer vs Send now follows whether a turn is running, not the card's hold, so a card held after a Stop, a restart or /clear reads Send. * fix(native-chat): the queue sends past held cards instead of stalling behind them A card queued after a Stop (or written after a restart or /clear) sent only once the cards held before it were released; with no header to explain or release the hold, it sat silently. The next sendable card now skips held cards; a returned card still blocks what is behind it. * fix(native-chat): the queue's send of a card is the person's turn, so held cards follow it After a Stop, a card queued later sent past the held cards, but the queue recorded that send as Orca's own turn. It never ended the Stop's pause, so the held cards then waited forever with nothing on the card saying why. A queued card is always something the person wrote: only the client send RPC may now create one. The queue's send of it is therefore recorded as the person's turn, which ends the Stop's pause once the agent takes it, and the held cards then drain in order. * fix(native-chat): a queued card carries its author, so the queue's send of it is that author's turn Main now lets Orca's own sends ask to queue (sendAgentTurn's 'queue' delivery), so "every card is a person's" no longer holds by refusing host sends. Each card records who wrote it (the submission's client/host vocabulary) in a new nullable column; the drain records that origin, so a person's card ends a Stop's pause and Orca's does not. /clear carries the author. Rows from before the column read as a person's. The userSend-only admission gate is removed. * docs(native-chat): state why an unrecorded card author reads as a person's * fix(native-chat): a restart holds only cards written before it, and an idle held queue offers Resume A restart's pause held every waiting card, including one a person typed after the restart while Orca's own continuation ran, and nothing released it except a per-card Send. It now holds only cards another host process wrote, the same way a Stop holds only cards queued before it. The composer's primary button becomes Resume (Play) while nothing is typed, no turn runs and the host holds a card Resume would send, whatever held it (Stop, restart or /clear). It calls the existing agentSession.queuedMessagesResume, guarded against a second press in flight. A card nothing holds keeps the run going between a turn's end and the queue's send of it, so its Steer no longer flips to Send for the frame in between. * fix(native-chat): the host publishes which pause holds each queued card The host published one pause for the whole queue, so a client held every waiting card while it was set. Between a turn's end and the queue's send of a card queued after a Stop or restart, the composer could flash Resume and the cards Send, and a card queued after a Stop lost its "Waiting for your answer" caption. Each published card now carries an optional `heldBy`: the pause holding it, or null, derived from the same rule the drain reads. A client holds only those cards; against a host without the field it falls back to the queue-level pause. * test(native-chat): Resume needs the queue capability and is disabled whenever Send is * docs(native-chat): describe per-card holds in the queue contract and table comments * fix(native-chat): the composer goes from Resume straight to Stop, and Resume returns focus After Resume, the host lifts the hold in one update and sends the first card in a later one. In between nothing was running, so the composer's button flashed a disabled Send. A card nothing holds now keeps the queue's run going for the button too: an empty composer shows Stop, disabled until the turn starts. Not when the host refuses every send (a rewind whose outcome is unknown, read from its status), where nothing is coming. The same fix removes the Stop, Send, Stop flip between queued turns. Resume disables the button, which dropped keyboard focus; focus now returns to the composer. * fix(native-chat): the host names the card its queue sends next, so the chat stays working across the gap A turn's end, or a Resume, and the queue's send of the next card commit as two host updates. In between nothing was running, so the working status, timer, pickers and composer button flipped for one update. The client guessed the drain from its own copy of the host's gates, which missed a /clear-replaced source and covered only the button. The queue publication now carries `nextQueuedMessageId`: the drain's own next card through the drain's own gate (`nextStructuredQueuedMessage`, which the drain step now calls), null whenever the host would refuse the send. The client derives one fact, the queue is about to send, and every working reader follows it; Stop stays disabled until a turn can be stopped. The client-side copy of the gates and the status-feed rewind read are removed. * test(native-chat): the queue's next card survives the coalescer, the reducer and a history page * test(native-chat): build the snapshot that names the next card through its helper * feat(native-chat): a held queue keeps its header row, and a new message asks before passing it The queue's header row ("Queue paused because you interrupted", or Orca restarted, or you cleared the conversation) comes back above the cards it holds, with Resume; it names the oldest held card's pause, as the host publishes it per card, and hides over cards held only on their own or returned. The header's Resume and the composer's share one in-flight guard. A held card reads Steer again whether or not a turn runs; a card held on its own or returned keeps Send. Sending a message while the header shows (Enter or the button) first asks "Send message?": Clear queue deletes every card and then sends (a failed delete sends nothing), Send message sends and keeps the cards, which follow the new turn, and dismissing sends nothing and keeps the draft. Host commands send as they are. * fix(native-chat): the paused row goes while your own message is on its way to lift it After "Send message" over a held queue, the row kept saying "Queue paused…" until the agent accepted the new turn. The chat now reads that gap from the outbox: while this composer's direct send is recorded by the host and not yet accepted, the controller shows no paused row (and so no Resume or confirmation). A refusal settles the entry and the row comes back, since the hold did not lift. Orca's own sends never enter this outbox, and the queue's send of a card goes under a fresh id, so neither hides it. Nothing is stored. * fix(native-chat): a "Send message?" choice is taken once, and a failed Clear queue is one toast The closing dialog stays mounted and clickable through its exit animation, and a double-click or a held Enter lands twice before any re-render, so Send message (or Clear queue) could send the captured message twice. The pending send now lives in a ref that the first choice takes; a second one finds nothing. Clear queue deletes one card at a time and stops at the first failure, so a failed press shows one toast instead of one per card. The dialog keeps its compact width at desktop sizes and the primitive's narrow-window gutter (`max-w-sm sm:max-w-sm`, as the other compact confirmations). * fix(native-chat): Clear queue's message goes out once, and keeps text typed while it waits After Clear queue, the message waited in the composer while the cards were deleted one by one. A second Enter in that window sent it again, and text typed meanwhile was wiped when the chained send was accepted. From the Clear queue choice until its message has gone out, the composer's structured send does nothing. The chained send (and Send message's) now carries the composition it was taken from, and the composer is cleared on acceptance only if it still holds exactly that, as host commands already do. Also: the v1 contract comment names `nextQueuedMessageId` and its absent- means-null fallback, and the own-send check returns at once on an empty outbox. * fix(native-chat): the queue carries on after any turn, in order, and a restart sends nothing by itself - Any accepted turn ends a Stop's or a /clear's pause, whoever sent it (a person, Orca's own messages, or the queue), and so does Resume. The card and submission author fields that only fed the old person-only rule are gone. - The queue sends strictly in order: a card never overtakes a held one. - After a restart nothing sends by itself and no paused row shows: the chat's next turn (the carry-on, or the person's own message) runs first, then the cards. - Resume and "Send message?" are offered only while nothing runs and no prompt waits. * fix(native-chat): after a restart no queue pause shows, and a card written before the next turn waits for it too * fix(native-chat): a quit hands no queued card off, and the paused row goes while any turn that will lift it is on its way - The queue stops handing cards off when the host tears down. A card sent during a quit was refused at close, and that refused send withdrew the chat's restart offer, so resuming after the relaunch sent nothing. - The host publishes no pause while a turn sent after it (your message, Steer, or Orca's own) waits for the agent; a refusal shows it again. This replaces the client's own-send check. - A card written after a restart is an ordinary card again: it waits while any card from before the restart still waits. * refactor(native-chat): the host's paused-row-while-a-turn-is-on-its-way check in one expression * refactor(native-chat): the composer's queue Resume rides the structured transport beside the held queue * fix(native-chat): the "Send message?" choice ends with the pause it asked about; tests follow main's draft props - The open dialog closes when the queue's pause lifts under it (Orca's mail, another client's Resume, any accepted turn): nothing is sent, the draft stays, and the next Enter sends as usual. The pending choice records the hold it was asked under; nothing new is stored. - The composer-field Resume test passes main's dropScopeKey/draftScopeKey. - The dialog test expects main's rule: only the sent text leaves the composer. * fix(native-chat): a message kept after a quit or close waits like every other card, and follows the chat's next turn A message Orca accepted but never handed to the agent before a quit, crash or close came back as a card held on its own ("Not sent yet — press Send"): the queue skipped past it, so later cards sent first, and only the person's own Send released it. It is now an ordinary card at the head of the queue that waits, with every card the chat closed with, for the chat's next accepted turn. One rule for a chat that was not running, derived from the journal: when this host first opens a chat (after a restart or crash), or a person closes it, and cards are waiting, a reopen mark is written (a tombstone carrier key, like the Stop and Resume marks). The cards queued before it wait until a turn is accepted or Resume comes after it; nothing sends by itself, and no paused row shows, a Stop's included. The idle sweep's own eviction writes nothing and changes nothing the person sees. A mark that cannot be written leaves the open working and holds from the open itself until the next turn; the next open marks again. A rewind restates the mark. /clear's carried cards also wait unshown. This replaces the host-instance comparison and its adoption write, and the 'kept' hold (stored 'kept' and legacy 'stopped' holds now read as none). * fix(native-chat): mark the reopen in the one open path, and keep an idle chat with waiting cards open Review round 1: the startup restore opened chats past the per-host first-open mark, so their cards could send by themselves after a quit or crash; a card mid-hand-off at the open got no mark; a close that left the chat open re-marked after every new send. - Every open marks when a card waits, or is mid-hand-off with its send unanswered. - The idle sweep keeps a chat's handle while cards wait, so its eviction never reopens one and stays invisible; it drops it on the next sweep once they leave. - A person's close marks once; its delivery re-check marks only when it settled a send. - A failed mark holds from where the mark would have gone. - A Stop made after a reopen shows its row. - The rig's restart is a real quit and relaunch. * fix(native-chat): review round 2: restore the dropped Resume and failed-Stop tests; a late mark starts where the chat stopped - Restores eight tests the previous commit dropped by mistake. - A mark the delivery loop or a close's re-check writes after a later send starts where the chat stopped, so that send still lifts it; a later mark never narrows an earlier, wider one. - Only the idle sweep's own close keeps a chat with a card waiting (or mid-hand-off) open, and it still releases an ended child's lease first; a person's close drops it as before. * test(mobile): a host-kept card's test stands in a Stop's pause, as this host publishes no restart pause Main's #24660 test published queuePause 'restarted', which this branch's wire type no longer lists, so the mobile tests typecheck ratchet failed. * refactor(native-chat): settle a restart's leftovers and mark them in one host-lifetime step Keeps the delivery loop under its line limit after main's Stopping change; no behaviour change. * test(native-chat): a kept card's Resume and failed-mark tests quit through the held start's release Main's #25152 holds the start these tests send into; quitting without releasing it left the quit waiting. --- .../MobileNativeChatQueuedMessages.test.tsx | 3 +- .../MobileNativeChatQueuedMessages.tsx | 2 +- ...le-structured-queued-message-cards.test.ts | 28 +- .../mobile-structured-queued-message-cards.ts | 10 +- ...e-structured-agent-session-queued.test.tsx | 11 +- ...bile-structured-queued-message-controls.ts | 4 +- .../journal-dispatch-settlement.test.ts | 31 +- .../journal-dispatch-settlement.ts | 23 +- .../journal-epoch-replacement.test.ts | 2 +- .../journal-epoch-replacement.ts | 8 +- .../journal-queued-messages.ts | 54 ++-- .../journal-row-schema.ts | 17 +- .../journal-stop-and-resume-rows.ts | 33 +- .../journal-store-collaborators.ts | 5 +- .../journal-store-contracts.ts | 6 +- .../agent-session-journal/journal-store.ts | 25 ++ .../journal-unsent-send-hold.test.ts | 26 +- .../journal-unsent-send-hold.ts | 29 +- .../queued-message-holds.ts | 26 +- .../queued-message-pause.test.ts | 294 +++++++++++------ .../queued-message-pause.ts | 89 ++++-- .../queued-message-settlement.ts | 2 - .../queued-message-store.test.ts | 36 +-- .../queued-message-stored-row.ts | 10 +- .../queued-message-table.ts | 33 +- ...red-agent-session-conversation-lifetime.ts | 16 +- ...uctured-agent-session-conversation-open.ts | 13 +- .../structured-agent-session-delivery-loop.ts | 14 +- .../structured-agent-session-host-delivery.ts | 5 +- .../structured-agent-session-host-lifetime.ts | 65 +++- ...gent-session-provider-child-record.test.ts | 12 +- ...ed-agent-session-queued-card-holds.test.ts | 18 +- ...tured-agent-session-queued-command.test.ts | 3 +- ...session-queued-message-rig.test-fixture.ts | 9 +- ...ured-agent-session-queued-messages.test.ts | 24 +- ...tructured-agent-session-queued-messages.ts | 2 +- ...ructured-agent-session-queued-mutations.ts | 17 +- ...ed-agent-session-queued-pause-lift.test.ts | 33 +- .../structured-agent-session-queued-pause.ts | 47 ++- ...ctured-agent-session-queued-publication.ts | 35 +- ...ured-agent-session-queued-stop-row.test.ts | 10 +- .../structured-agent-session-queued-wiring.ts | 8 +- ...d-agent-session-stop-event-binding.test.ts | 12 +- ...d-agent-session-stop-event-entries.test.ts | 4 +- ...ured-agent-session-unrun-send-hold.test.ts | 25 +- ...red-agent-session-unsent-send-hold.test.ts | 302 +++++++++++++----- .../NativeChatQueuedMessageCard.tsx | 11 +- .../NativeChatQueuedMessageList.test.tsx | 23 +- .../NativeChatQueuedMessageList.tsx | 5 - ...uctured-agent-session-queued-cards.test.ts | 12 +- src/renderer/src/i18n/locales/en.json | 2 - src/renderer/src/i18n/locales/es.json | 2 - src/renderer/src/i18n/locales/fr.json | 2 - src/renderer/src/i18n/locales/ja.json | 2 - src/renderer/src/i18n/locales/ko.json | 2 - src/renderer/src/i18n/locales/zh.json | 2 - .../agent-session-queued-message-wire.ts | 31 +- ...structured-agent-session-coalescer.test.ts | 4 +- ...-session-stop-event-downgrade.unit.test.ts | 17 +- .../kept-card-downgrade.unit.test.ts | 42 ++- 60 files changed, 950 insertions(+), 688 deletions(-) diff --git a/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx b/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx index 3ea59e635e22..5eb53046128b 100644 --- a/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx +++ b/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx @@ -281,8 +281,7 @@ describe('MobileNativeChatQueuedMessages', () => { it('heads the box with why the queue is paused, for each reason', async () => { const rows: readonly [AgentSessionQueuePause['reason'], string][] = [ - ['stopped', 'Queue paused because you interrupted'], - ['cleared', 'Queue paused after you cleared the conversation'] + ['stopped', 'Queue paused because you interrupted'] ] for (const [reason, label] of rows) { const mounted = await mountPaused({ pause: { reason } }) diff --git a/mobile/src/session/MobileNativeChatQueuedMessages.tsx b/mobile/src/session/MobileNativeChatQueuedMessages.tsx index f600781dde51..fa44fdd01fda 100644 --- a/mobile/src/session/MobileNativeChatQueuedMessages.tsx +++ b/mobile/src/session/MobileNativeChatQueuedMessages.tsx @@ -26,7 +26,7 @@ const RESUME_KEY = '\u0000resume' export type MobileNativeChatQueuedMessagesProps = { cards?: MobileQueuedMessageCard[] /** Steer for a waiting card, the paused queue's included; plain Send for a card held on its own - * (its send failed, or the host kept it unsent), or a returned one. */ + * (its send failed), or a returned one. */ onSend?: (messageId: string) => Promise onDelete?: (messageId: string) => Promise /** Copy the card's text into the composer, then delete the card. */ diff --git a/mobile/src/session/mobile-structured-queued-message-cards.test.ts b/mobile/src/session/mobile-structured-queued-message-cards.test.ts index 3341b7a80d9c..8ead70b20275 100644 --- a/mobile/src/session/mobile-structured-queued-message-cards.test.ts +++ b/mobile/src/session/mobile-structured-queued-message-cards.test.ts @@ -2,10 +2,7 @@ import { describe, expect, it } from 'vitest' import { agentSessionFailureFact } from '../../../src/shared/agent-session-failure' import { agentSessionFailureWords } from '../../../src/shared/agent-session-failure-words' import { DISPATCH_REJECTED_HOST_RESTARTED } from '../../../src/shared/structured-agent-session-dispatch-rejection' -import { - QUEUED_MESSAGE_PAUSED_KEPT, - QUEUED_MESSAGE_PAUSED_SEND_FAILED -} from '../../../src/shared/agent-session-wire' +import { QUEUED_MESSAGE_PAUSED_SEND_FAILED } from '../../../src/shared/agent-session-wire' import type { AgentSessionQueuedMessage } from '../../../src/shared/agent-session-wire' import { mobileQueueHasResumableCard, @@ -103,35 +100,12 @@ describe('mobileQueuedMessageCards', () => { expect(resumable([failed])).toBe(false) expect(resumable([failed, waiting])).toBe(true) expect(resumable([waiting, { ...returned, position: 3 }])).toBe(true) - // A kept card is held on its own, like a failed one: the drain goes past it. - const kept = draft({ messageId: 'k', paused: true, pausedReason: QUEUED_MESSAGE_PAUSED_KEPT }) - expect(resumable([kept])).toBe(false) - expect(resumable([kept, waiting])).toBe(true) - }) - - // The host kept it unsent across a restart or a close; the cards behind it are not held by it. - it('captions a kept card as not sent yet, and leaves the cards behind it plainly queued', () => { - const cards = mobileQueuedMessageCards( - [ - draft({ messageId: 'k', paused: true, pausedReason: QUEUED_MESSAGE_PAUSED_KEPT }), - draft({ messageId: 'b', position: 2 }) - ], - [], - { pendingPrompt: false } - ) - expect(cards.map(({ caption, needsAttention }) => ({ caption, needsAttention }))).toEqual([ - { caption: 'Not sent yet — tap Send to send it', needsAttention: false }, - { caption: null, needsAttention: false } - ]) }) it('words the paused queue by reason, and one this build does not know as a plain pause', () => { expect(mobileQueuePauseLabel({ reason: 'stopped' })).toBe( 'Queue paused because you interrupted' ) - expect(mobileQueuePauseLabel({ reason: 'cleared' })).toBe( - 'Queue paused after you cleared the conversation' - ) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a reason newer than this build's union, as a newer host would send it. const newer = { reason: 'later_reason' } as never expect(mobileQueuePauseLabel(newer)).toBe('Queue paused') diff --git a/mobile/src/session/mobile-structured-queued-message-cards.ts b/mobile/src/session/mobile-structured-queued-message-cards.ts index 6388efbf4123..ac423e358502 100644 --- a/mobile/src/session/mobile-structured-queued-message-cards.ts +++ b/mobile/src/session/mobile-structured-queued-message-cards.ts @@ -8,7 +8,6 @@ import { agentSessionWriteNoticeEnglish } from '../../../src/shared/agent-sessio import { dispatchWasWithdrawn } from '../../../src/shared/structured-agent-session-dispatch-rejection' import { structuredAgentSessionAttemptFailureParts } from '../../../src/shared/structured-agent-session-send-disposition' import { - QUEUED_MESSAGE_PAUSED_KEPT, QUEUED_MESSAGE_PAUSED_SEND_FAILED, type AgentSessionQueuedMessage, type AgentSessionQueuePause @@ -53,22 +52,17 @@ function returnedCaption( ) } -/** One card's own hold: a failed conversion, or a send the host kept; the queue's pause is the - * list's first row. */ +/** One card's own hold: a failed conversion; the queue's pause is the list's first row. */ function pausedCaption(reason: string | undefined): string { if (reason === QUEUED_MESSAGE_PAUSED_SEND_FAILED) { return "Couldn't send — tap Send to retry" } - if (reason === QUEUED_MESSAGE_PAUSED_KEPT) { - return 'Not sent yet — tap Send to send it' - } // Absent or unknown (newer host) marker: a plain pause, promising no release rule. return 'Paused' } const QUEUE_PAUSE_LABELS: Readonly> = { - stopped: 'Queue paused because you interrupted', - cleared: 'Queue paused after you cleared the conversation' + stopped: 'Queue paused because you interrupted' } /** Whether Resume would send anything: a waiting card with no hold of its own, ahead of any diff --git a/mobile/src/session/use-mobile-structured-agent-session-queued.test.tsx b/mobile/src/session/use-mobile-structured-agent-session-queued.test.tsx index c14e823ab401..ceaee501b650 100644 --- a/mobile/src/session/use-mobile-structured-agent-session-queued.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-session-queued.test.tsx @@ -577,21 +577,20 @@ describe('mobile structured queued messages', () => { listener?.( batchEvent( [ - queuedDraft({ messageId: 'kept-1', paused: true, pausedReason: 'kept' }), + queuedDraft({ messageId: 'kept-1' }), queuedDraft({ messageId: 'behind', position: 2 }) ], [], - // This host publishes no restart pause; a Stop's stands in for any queue-wide one. - { reason: 'stopped' } + null ) ) ) + // Plain waiting cards: the host holds them until the chat's next turn, and shows no row. expect(hook!.queued.cards.map(({ messageId, caption }) => ({ messageId, caption }))).toEqual([ - { messageId: 'kept-1', caption: 'Not sent yet — tap Send to send it' }, + { messageId: 'kept-1', caption: null }, { messageId: 'behind', caption: null } ]) - // The kept card is held on its own, so Resume would send the card behind it. - expect(hook!.queued.pause).toEqual({ reason: 'stopped' }) + expect(hook!.queued.pause).toBeNull() }) }) diff --git a/mobile/src/session/use-mobile-structured-queued-message-controls.ts b/mobile/src/session/use-mobile-structured-queued-message-controls.ts index f65ecf2d46e3..a786eeacbe09 100644 --- a/mobile/src/session/use-mobile-structured-queued-message-controls.ts +++ b/mobile/src/session/use-mobile-structured-queued-message-controls.ts @@ -1,8 +1,8 @@ // The queued-draft surface the structured session exposes: cards derived from // the published list and the Send-now / Delete / Edit actions. Shown whatever // the queue capability says: a host that does not queue sends still publishes a -// message it kept across a restart or a close, and only queueing a new send is -// gated. A host older than the queue publishes no list, so shows no cards. +// message it kept as a card across a restart or a close, and only queueing a new +// send is gated. A host older than the queue publishes no list, so shows no cards. // Nothing here is durable: the host owns the queue, and the published list is // the only truth a card action ever needs. diff --git a/src/main/native-chat/agent-session-journal/journal-dispatch-settlement.test.ts b/src/main/native-chat/agent-session-journal/journal-dispatch-settlement.test.ts index 7c6deabef407..b952f7672c5a 100644 --- a/src/main/native-chat/agent-session-journal/journal-dispatch-settlement.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-dispatch-settlement.test.ts @@ -10,34 +10,29 @@ import { } from '../../../shared/structured-agent-session-dispatch-rejection' import { rejectedDraftSettlement } from './journal-dispatch-settlement' -function settle(kind: SubmissionRejectionKind, origin?: 'client' | 'host') { - return rejectedDraftSettlement({ - ...agentSessionFailureWords(agentSessionFailureFact(kind), { surface: 'rejection' }), - origin - }) +function settle(kind: SubmissionRejectionKind) { + return rejectedDraftSettlement( + agentSessionFailureWords(agentSessionFailureFact(kind), { surface: 'rejection' }) + ) } describe('what a rejection does to the draft it was consumed from', () => { it("a Stop's withdrawal sends it back to waiting, under the queue's pause rather than a hold of its own", () => { - expect(settle('cancelled')).toEqual({ state: 'waiting', kept: false }) + expect(settle('cancelled')).toEqual({ state: 'waiting' }) expect(rejectedDraftSettlement({ reason: DISPATCH_REJECTED_CANCELLED })).toEqual({ - state: 'waiting', - kept: false + state: 'waiting' }) - expect(settle('notDelivered')).toEqual({ state: 'waiting', kept: false }) + expect(settle('notDelivered')).toEqual({ state: 'waiting' }) }) - // A Send the person asked for and the host never handed over waits for them; the queue's own - // hand-off, or one from a build that recorded no origin, waits under the queue's pause. - it('a restart or close before hand-over sends it back to waiting, kept only when the person sent it', () => { + // Whoever sent it, it waits under the reopen's pause like every card the chat closed with. + it('a restart or close before hand-over sends it back to waiting, with no hold of its own', () => { for (const kind of ['hostRestarted', 'chatClosed'] as const) { - expect(settle(kind, 'client')).toEqual({ state: 'waiting', kept: true }) - expect(settle(kind, 'host')).toEqual({ state: 'waiting', kept: false }) - expect(settle(kind)).toEqual({ state: 'waiting', kept: false }) + expect(settle(kind)).toEqual({ state: 'waiting' }) } - expect( - rejectedDraftSettlement({ reason: DISPATCH_REJECTED_HOST_RESTARTED, origin: 'client' }) - ).toEqual({ state: 'waiting', kept: true }) + expect(rejectedDraftSettlement({ reason: DISPATCH_REJECTED_HOST_RESTARTED })).toEqual({ + state: 'waiting' + }) }) it('a failure returns the card for the user to act on', () => { diff --git a/src/main/native-chat/agent-session-journal/journal-dispatch-settlement.ts b/src/main/native-chat/agent-session-journal/journal-dispatch-settlement.ts index 6b3e53f36b37..c56e4f47d45d 100644 --- a/src/main/native-chat/agent-session-journal/journal-dispatch-settlement.ts +++ b/src/main/native-chat/agent-session-journal/journal-dispatch-settlement.ts @@ -30,27 +30,18 @@ export function consumedSubmissionWasRejected( } /** What a consumed draft becomes when its submission is rejected. */ -export type RejectedDraftSettlement = { state: 'returned' } | { state: 'waiting'; kept: boolean } +export type RejectedDraftSettlement = { state: 'returned' | 'waiting' } /** - * Where no one failed the user, the draft goes back to waiting at its own position, under whatever - * pauses the queue: a Stop's, or the restart's, derived from the host instance. A Send the person - * asked for (`origin` client) that a restart or a close cut short is kept (`kept`) until they send - * it again, as the host keeps every message a person sent and it never handed over; the queue's - * own hand-off is not theirs, so it waits as any queued card does: under the restart's pause after - * a restart, and plainly queued after a close in the same process. A returned card would block the drafts - * behind it on a failure that never happened. A failure returns the card with its refusal for the - * user to act on. + * Where no one failed the user, the draft goes back to waiting, under whatever pauses the queue: a + * Stop's, or, when a restart or a close cut it short, the reopen's, so it follows the chat's next + * turn like every card the chat closed with. A returned card would block the drafts behind it on a + * failure that never happened. A failure returns the card with its refusal for the user to act on. */ export function rejectedDraftSettlement( - rejected: Pick & { rejection?: unknown } + rejected: Pick & { rejection?: unknown } ): RejectedDraftSettlement { - const { verdict, kind } = classifyDispatchRejection(rejected) - if (verdict !== null) { - return { state: 'returned' } - } - const cutShort = kind === 'hostRestarted' || kind === 'chatClosed' - return { state: 'waiting', kept: cutShort && rejected.origin === 'client' } + return { state: classifyDispatchRejection(rejected).verdict !== null ? 'returned' : 'waiting' } } /** True when committing this row NEWLY settles the submission to `rejected` — diff --git a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.test.ts b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.test.ts index 6337b00417bf..36247e2571ff 100644 --- a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.test.ts @@ -68,7 +68,7 @@ function replace(input: { reason: 'legacy_import', fence: 1, items: input.items, - queuePause: { lifted: false, liveStop: null }, + queuePause: { lifted: false, liveStop: null, reopened: false }, now, mintEpoch: () => `epoch-${clock}`, onPublished: input.onPublished ?? (() => undefined) diff --git a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts index da53b2e9a11d..dd56f2ac0f75 100644 --- a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts +++ b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts @@ -16,6 +16,7 @@ import type { JournalLoad } from './journal-open' import { applyJournalRow, createJournalReducerState } from './journal-reducer' import { buildJournalItemRow, journalRowBase } from './journal-row-builders' import { + buildJournalQueueReopenRow, buildJournalQueueResumeRow, buildJournalStopEventRow } from './journal-stop-and-resume-rows' @@ -77,7 +78,7 @@ export function replaceJournalEpoch(input: { applyJournalRow(state, row) rows.push(row) } - const { lifted, liveStop } = input.queuePause + const { lifted, liveStop, reopened } = input.queuePause const place = () => ({ state, seq: state.lastSequence + 1, fence: input.fence, ts: input.now() }) if (lifted) { const row = buildJournalQueueResumeRow(place()) @@ -89,6 +90,11 @@ export function replaceJournalEpoch(input: { applyJournalRow(state, row) rows.push(row) } + if (reopened) { + const row = buildJournalQueueReopenRow(place()) + applyJournalRow(state, row) + rows.push(row) + } const { sessionId } = input.identity input.database.transaction((db) => { diff --git a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts index bb6eb2ef4d78..9614c7548ff6 100644 --- a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts +++ b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts @@ -5,7 +5,10 @@ // working status, teardown, or the idle sweep. import type Database from '../../sqlite/sync-database' -import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import type { + AgentJournalCursor, + AgentJournalMessageItem +} from '../../../shared/agent-session-journal-types' import { AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS, AGENT_SESSION_OPERATION_FUTURE_SKEW_MS @@ -15,7 +18,7 @@ import type { JournalReducerState } from './journal-reducer' import type { JournalRow } from './journal-row-schema' import type { JournalOperationReceipt, JournalRowTransactionHook } from './journal-row-writer' import type { JournalSubmissionConsume } from './journal-store-contracts' -import { adoptQueuedMessages, holdQueuedMessages } from './queued-message-holds' +import { holdQueuedMessages } from './queued-message-holds' import { deriveQueuePauses, journalUserStopInForce, @@ -55,8 +58,8 @@ export type JournalQueuedMessagesDeps = { database: () => JournalHostDatabase readOnly: () => boolean state: () => JournalReducerState - /** Whether this handle found the row at `sequence` on disk when it opened. */ - wroteBeforeOpen: (sequence: number) => boolean + /** Where the reopen's pause begins when this handle could not mark it (`reopenFloor`). */ + reopenFloor: () => AgentJournalCursor | null /** The journal's own commit notification. Every standalone draft-table * transaction that changed rows fires it after COMMIT, so a draft or hold * change publishes and wakes the drain through the same path a journal row @@ -157,8 +160,21 @@ export class JournalQueuedMessages { } /** The queue's pauses in force, derived from the fold and the cards (`queued-message-pause.ts`). */ - pauses(hostInstance: string): DerivedQueuePause[] { - return this.derivePauses(this.list(), hostInstance) + pauses(): DerivedQueuePause[] { + return this.derivePauses(this.list()) + } + + /** A card waits, or is mid-hand-off and may come back to waiting: a chat that stops running + * marks it (`AgentSessionJournal.markQueueReopen`). */ + awaitReopenMark(): boolean { + const { submissions } = this.deps.state() + return this.list().some((row) => { + if (row.state === 'waiting') { + return true + } + const handOff = row.consumedAs ? submissions.get(row.consumedAs)?.dispatchState : undefined + return row.state === 'dispatched' && (handOff === 'pending' || handOff === 'unknown') + }) } /** The person's Stop still pausing the queue, if any (`journalUserStopInForce`). */ @@ -167,37 +183,17 @@ export class JournalQueuedMessages { return journalUserStopInForce(state.queuePauseMarks, state.latestAcceptedTurnSequence) } - private derivePauses( - cards: readonly QueuedMessageRow[], - hostInstance: string - ): DerivedQueuePause[] { + private derivePauses(cards: readonly QueuedMessageRow[]): DerivedQueuePause[] { const state = this.deps.state() return deriveQueuePauses({ epoch: state.epoch, marks: state.queuePauseMarks, latestAcceptedTurnSequence: state.latestAcceptedTurnSequence, cards, - hostInstance, - restartEnded: this.restartEnded() + reopenFloor: this.deps.reopenFloor() }) } - /** A turn started since this handle opened, which ends a restart's pause. */ - restartEnded(): boolean { - const latest = this.deps.state().latestAcceptedTurnSequence - return latest > 0 && !this.deps.wroteBeforeOpen(latest) - } - - /** Adopts waiting rows another host instance wrote into this one, ending a restart's pause. - * Returns whether anything changed. */ - adopt(hostInstance: string): Promise { - const { sessionId } = this.deps - return this.transact( - (db) => adoptQueuedMessages(db, { sessionId, hostInstance }), - (changed) => changed > 0 - ).then((changed) => changed > 0) - } - /** Inside the caller's journal-row transaction (`journal-unsent-send-hold.ts`): one kept send * becomes a card, and the cards ahead of the queue take the positions given. False when a card * by that id already exists, which then stands. */ @@ -286,7 +282,7 @@ export class JournalQueuedMessages { // pick and this claim (both run on the session's serialized lane, held across the send), so // this guards any pause-relevant row written off that lane from overtaking a held card. const cards = listQueuedMessages(db, this.deps.sessionId) - const pauses = this.derivePauses(cards, input.yieldsToPause.hostInstance) + const pauses = this.derivePauses(cards) if (nextSendableQueuedCard(pauses, cards)?.messageId !== input.messageId) { throw new QueuedMessageNotConsumableError(input.messageId, input.expect) } diff --git a/src/main/native-chat/agent-session-journal/journal-row-schema.ts b/src/main/native-chat/agent-session-journal/journal-row-schema.ts index d2a95a6db724..1d226761ae25 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-schema.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-schema.ts @@ -95,6 +95,12 @@ export type JournalTombstoneRow = JournalRowBase & { stopEvent?: JournalStopEvent /** Present: not a removal but a person's Resume of the queue, on an id no item ever takes. */ queueResume?: true + /** Present: not a removal but a reopen that found waiting cards (`queued-message-pause.ts`), on + * an id no item ever takes. */ + queueReopen?: true + /** On a reopen mark written after the chat stopped: where it stopped, so a send accepted since + * lifts it. Absent: the mark's own row. */ + queueReopenSince?: number } /** One Stop that took effect. Temporary carrier: a tombstone's extra key, which every host ignores, @@ -112,16 +118,21 @@ export type JournalStopEvent = { caller?: string } -/** A tombstone that carries a Stop event or a Resume mark instead of removing an item. */ +/** A tombstone that carries a Stop event, a Resume or a reopen mark instead of removing an item. */ export type JournalStopOrResumeRow = JournalTombstoneRow & ( | { stopEvent: NonNullable } | { queueResume: NonNullable } + | { queueReopen: NonNullable } ) -/** A Stop's event or a Resume. Any value counts, so a newer build's mark never removes an item. */ +/** A Stop's event, a Resume or a reopen mark. Any value counts, so a newer build's mark never + * removes an item. */ export function isJournalStopOrResumeRow(row: JournalRow): row is JournalStopOrResumeRow { - return row.kind === 'tombstone' && (row.stopEvent !== undefined || row.queueResume !== undefined) + return ( + row.kind === 'tombstone' && + (row.stopEvent !== undefined || row.queueResume !== undefined || row.queueReopen !== undefined) + ) } /** The write-ahead row. Durable BEFORE the adapter dispatches anything; it diff --git a/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.ts b/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.ts index e763af8b2e6c..c4ddb98bc398 100644 --- a/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.ts +++ b/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.ts @@ -1,12 +1,13 @@ -// The rows a Stop's event and a person's Resume append: tombstones of ids no item ever takes, -// each carrying its record as an extra key (`journal-row-schema.ts` says why not a row kind). +// The rows a Stop's event, a person's Resume and a reopen that found waiting cards append: +// tombstones of ids no item ever takes, each carrying its record as an extra key +// (`journal-row-schema.ts` says why not a row kind). import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import type { JournalReducerState } from './journal-reducer' import { journalRowBase } from './journal-row-builders' import type { JournalStopEvent, JournalTombstoneRow } from './journal-row-schema' -/** One id per mark kind; no item ever takes either. */ +/** One id per mark kind; no item ever takes any of them. */ const JOURNAL_STOP_EVENT_ITEM_ID = agentJournalItemKey({ provider: 'orca', clientMessageId: 'stop-event' @@ -15,6 +16,10 @@ const JOURNAL_QUEUE_RESUME_ITEM_ID = agentJournalItemKey({ provider: 'orca', clientMessageId: 'queue-resume' }) +const JOURNAL_QUEUE_REOPEN_ITEM_ID = agentJournalItemKey({ + provider: 'orca', + clientMessageId: 'queue-reopen' +}) type RowPlace = { state: JournalReducerState; seq: number; fence: number; ts: number } @@ -40,6 +45,19 @@ export function buildJournalQueueResumeRow(input: RowPlace): JournalTombstoneRow } } +export function buildJournalQueueReopenRow( + input: RowPlace & { since?: number } +): JournalTombstoneRow { + return { + kind: 'tombstone', + itemId: JOURNAL_QUEUE_REOPEN_ITEM_ID, + revision: 1, + queueReopen: true, + ...(input.since !== undefined ? { queueReopenSince: input.since } : {}), + ...journalRowBase(input.state.epoch, input.seq, input.fence, input.ts) + } +} + /** A Stop taking effect now: its event's time is its row's. */ export function journalStopEventRowBuilder( state: () => JournalReducerState, @@ -56,3 +74,12 @@ export function journalQueueResumeRowBuilder( ): (seq: number, ts: number) => JournalTombstoneRow { return (seq, ts) => buildJournalQueueResumeRow({ state: state(), seq, fence, ts }) } + +export function journalQueueReopenRowBuilder( + state: () => JournalReducerState, + fence: number, + since?: number +): (seq: number, ts: number) => JournalTombstoneRow { + return (seq, ts) => + buildJournalQueueReopenRow({ state: state(), seq, fence, ts, ...(since ? { since } : {}) }) +} diff --git a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts index 600d673b6d81..506547beb10f 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts @@ -69,7 +69,8 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal queuePauseRestatement: () => journalQueuePauseRestatement( host.state().queuePauseMarks, - host.state().latestAcceptedTurnSequence + host.state().latestAcceptedTurnSequence, + host.journal().queuedMessages.pauses() ), cursor: host.cursor, adopt: host.adopt @@ -81,7 +82,7 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal database: host.database, readOnly: host.readOnly, state: host.state, - wroteBeforeOpen: (sequence) => host.journal().wroteBeforeOpen(sequence), + reopenFloor: () => host.journal().reopenFloor(), committed: host.notifyCommitted }) const rowWriter = new JournalRowWriter({ diff --git a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts index 7492ea7e461c..41b892adf348 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts @@ -112,9 +112,9 @@ export type JournalSubmissionConsume = { /** The host process handing it off, stamped on the draft so a hand-off withdrawn back to * waiting belongs to the process that sent it, not the one that first wrote the card. */ hostInstance?: string - /** The queue's own send: refused in the consume's transaction while the queue's pause, as - * this host instance derives it, holds the card. Send-now omits it. */ - yieldsToPause?: { hostInstance: string } + /** The queue's own send: refused in the consume's transaction while the queue's pause holds + * the card. Send-now omits it. */ + yieldsToPause?: true } export type JournalItemAppendInput = { diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts index 7548f6e5502e..06bdfba25fea 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -57,6 +57,7 @@ import type { import type { JournalQueuedMessages } from './journal-queued-messages' import { journalQueueResumeRowBuilder, + journalQueueReopenRowBuilder, journalStopEventRowBuilder } from './journal-stop-and-resume-rows' import type { AgentJournalEpochReason, JournalStopEvent } from './journal-row-schema' @@ -84,6 +85,7 @@ export class AgentSessionJournal { private state: JournalReducerState private openedThrough: AgentJournalCursor = { epoch: '', sequence: 0 } + private reopenUnmarked: AgentJournalCursor | null = null private onCommitted: (() => void) | null = null private readonly queue: JournalWriteQueue private readonly rowWriter: JournalRowWriter @@ -156,6 +158,12 @@ export class AgentSessionJournal { ) } + /** Where the reopen's pause begins when this handle could not write its mark: where the mark + * would have gone. Null once a mark is written. Per handle, so the next open marks again. */ + reopenFloor(): AgentJournalCursor | null { + return this.reopenUnmarked + } + async open(): Promise { await this.restore() this.openedThrough = this.cursor() @@ -292,6 +300,23 @@ export class AgentSessionJournal { return this.rowWriter.append(journalQueueResumeRowBuilder(() => this.state, fence)) } + /** This open found waiting cards an earlier handle wrote (`queued-message-pause.ts`). */ + appendQueueReopen(fence: number, since?: number): Promise { + return this.rowWriter.append(journalQueueReopenRowBuilder(() => this.state, fence, since)) + } + + /** Marks the reopen when a card waits or is mid-hand-off (it may come back to waiting), from + * `since` when the chat stopped before now; a failed write leaves where the mark would have gone + * as the pause's start (`reopenFloor`), and throws. */ + async markQueueReopen(fence: number, since?: number): Promise { + if (this.queuedMessages.awaitReopenMark()) { + const sequence = since ?? this.state.lastSequence + 1 + this.reopenUnmarked = { epoch: this.state.epoch, sequence } + await this.appendQueueReopen(fence, since) + this.reopenUnmarked = null + } + } + appendLifecycleBatch(input: JournalLifecycleBatchInput): Promise { return this.lifecycleBatchAppender.append(input) } diff --git a/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.test.ts b/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.test.ts index 293519d3680f..29fb7eb35fa2 100644 --- a/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.test.ts @@ -17,7 +17,6 @@ import { } from '../../../shared/agent-session-message-source' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' -import { QUEUED_MESSAGE_PAUSED_KEPT } from '../../../shared/agent-session-queued-message-wire' import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation' import { structuredAgentSessionCompactBody } from '../agent-session-wire/structured-agent-session-command-turn' @@ -180,7 +179,7 @@ describe('which sends an earlier host process left unsent are kept', () => { }) expect(reopened.queuedMessages.get(id)).toMatchObject({ state: 'waiting', - holdReason: QUEUED_MESSAGE_PAUSED_KEPT, + holdReason: null, hostInstance: HOST, body: message(`text of ${id}`), fingerprint: fingerprint(message(`text of ${id}`)), @@ -229,14 +228,13 @@ describe('which sends an earlier host process left unsent are kept', () => { } }) - // Only a Send the person asked for comes back kept; the queue's own hand-off waits under the - // restart's pause, where it stood. + // Whoever sent it, the card comes back with no hold of its own, to wait under the reopen's pause. it.each([ - { by: 'Send now', origin: 'client' as const, holdReason: QUEUED_MESSAGE_PAUSED_KEPT }, - { by: 'the queue', origin: 'host' as const, holdReason: null } + { by: 'Send now', origin: 'client' as const }, + { by: 'the queue', origin: 'host' as const } ])( 'a card’s own hand-off by $by returns its card, and makes no second one', - async ({ origin, holdReason }) => { + async ({ origin }) => { const journal = await afterRestart(async (earlier) => { await earlier.queuedMessages.insert({ messageId: 'card', @@ -262,7 +260,7 @@ describe('which sends an earlier host process left unsent are kept', () => { expect(journal.queuedMessages.get('card')).toMatchObject({ state: 'waiting', consumedAs: null, - holdReason + holdReason: null }) expect(journal.queuedMessages.get('handoff')).toBeNull() } @@ -383,7 +381,14 @@ describe('where kept sends go in the queue', () => { }) // That run kept A, at a stale place behind C, then died before B. await interrupted.resolveDispatch( - { clientMessageId: 'A', state: 'rejected', ...HOST_RESTARTED, fence: 0, recovered: true }, + { + clientMessageId: 'A', + state: 'rejected', + ...HOST_RESTARTED, + fence: 0, + recovered: true, + keptAsQueuedMessageId: 'A' + }, (db) => { interrupted.queuedMessages.holdInTransaction(db, { card: { @@ -391,7 +396,6 @@ describe('where kept sends go in the queue', () => { body: message('text of A'), fingerprint: fingerprint(message('text of A')), hostInstance: HOST, - holdReason: QUEUED_MESSAGE_PAUSED_KEPT, queuedAt: { epoch: 'epoch-1', sequence: interrupted.submission('A')!.acceptedSequence! @@ -419,7 +423,7 @@ describe('a close of the chat', () => { expect(cardOrder(journal)).toEqual(['person']) expect(journal.queuedMessages.get('person')).toMatchObject({ - holdReason: QUEUED_MESSAGE_PAUSED_KEPT, + holdReason: null, hostInstance: HOST }) for (const id of ['person', 'mail']) { diff --git a/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.ts b/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.ts index 9341fbce6e11..fd5a774c7bf2 100644 --- a/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.ts +++ b/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.ts @@ -1,9 +1,10 @@ // What becomes of a send the host accepted and can no longer hand over: an earlier host process // quit or crashed first (settled at the next open), or the chat is closing. Either way the agent // provably never got it. A person's message (typed, or a launch's first prompt) is kept as a card -// at the head of the queue, held until the person sends, edits or deletes it (`kept`). Everything -// else is rejected as before, because something else re-derives it or the person re-runs it. The -// submission itself is always rejected, so it is never handed over twice. +// at the head of the queue, an ordinary one: like every card the chat closed with, it waits for the +// chat's next turn (`queued-message-pause.ts`). Everything else is rejected as before, because +// something else re-derives it or the person re-runs it. The submission itself is always rejected, +// so it is never handed over twice. import type { AgentJournalItemBody, @@ -12,7 +13,6 @@ import type { } from '../../../shared/agent-session-journal-types' import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' -import { QUEUED_MESSAGE_PAUSED_KEPT } from '../../../shared/agent-session-queued-message-wire' import { USER_MESSAGE_SOURCE } from '../../../shared/agent-session-message-source' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' @@ -75,7 +75,8 @@ export function isUnansweredHandedOverSubmission( * both. This batch's cards, and the card of a Send the person asked for, go to the head of the * queue in the order they were accepted, behind the cards an earlier settlement kept; the queue's * own hand-off returns its card where it stood. Throws once every row was - * tried when one of them could not be written at all: that row stays queued. + * tried when one of them could not be written at all: that row stays queued. Resolves where the + * newest send it settled was accepted, or null when it settled none. */ export async function holdUnsentSends( journal: AgentSessionJournal, @@ -87,7 +88,7 @@ export async function holdUnsentSends( * handed and never echoed. It ran none, so each is unsent as surely as a queued one. */ unrun?: true } -): Promise { +): Promise { const { hold } = input const unsent = journal .submissions() @@ -101,7 +102,7 @@ export async function holdUnsentSends( ) .sort((a, b) => (a.acceptedSequence ?? 0) - (b.acceptedSequence ?? 0)) if (unsent.length === 0) { - return + return null } const { epoch } = journal.cursor() const kept = unsent.map((submission) => ({ @@ -144,7 +145,6 @@ export async function holdUnsentSends( card.body ), hostInstance: input.hostInstance, - holdReason: QUEUED_MESSAGE_PAUSED_KEPT, queuedAt: { epoch, sequence: submission.acceptedSequence ?? 0 }, position: card.position } @@ -185,6 +185,7 @@ export async function holdUnsentSends( if (failures.length > 0) { throw new AggregateError(failures, 'settling unsent sends failed') } + return unsent.at(-1)?.acceptedSequence ?? 0 } /** Where each card of the batch goes: right before every other card, the cards an earlier @@ -196,8 +197,18 @@ function headOfQueuePositions( batch: readonly { submission: AgentJournalSubmission; body: AgentJournalMessageItem | null }[] ): { placed: QueuedMessagePositionMove[]; earlier: QueuedMessagePositionMove[] } { const cards = journal.queuedMessages.list() + const keptIds = new Set( + journal.submissions().flatMap((entry) => entry.keptAsQueuedMessageId ?? []) + ) + const { epoch } = journal.cursor() + // Kept by an earlier settlement: its rejection names it, or, past an epoch that dropped that + // row, it was queued in an earlier epoch, before anything this one accepted. const earlier = cards - .filter((card) => card.state === 'waiting' && card.holdReason === QUEUED_MESSAGE_PAUSED_KEPT) + .filter( + (card) => + card.state === 'waiting' && + (keptIds.has(card.messageId) || (card.queuedAt !== null && card.queuedAt.epoch !== epoch)) + ) .map((card) => card.messageId) const placed: QueuedMessagePositionMove[] = [] for (const { submission, body } of batch) { diff --git a/src/main/native-chat/agent-session-journal/queued-message-holds.ts b/src/main/native-chat/agent-session-journal/queued-message-holds.ts index 05867436e12e..c3f90590c117 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-holds.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-holds.ts @@ -1,7 +1,7 @@ // Per-draft holds: what keeps one card from auto-sending, stored on its row. A -// Stop or a restart pauses the queue instead (`queued-message-pause.ts`, derived); -// a per-draft hold is a conversion that failed, or a send the host kept -// (`QueuedMessageHoldReason`), which an explicit Send releases. +// Stop, a /clear or a reopen pauses the queue instead (`queued-message-pause.ts`, +// derived); a per-draft hold is a conversion that failed (`QueuedMessageHoldReason`), +// which an explicit Send releases. import type Database from '../../sqlite/sync-database' import type { QueuedMessageHoldReason } from './queued-message-table' @@ -27,23 +27,3 @@ export function holdQueuedMessages( } return held } - -/** Ends a restart's pause: waiting rows another host instance wrote are adopted - * into this one, the same fact the pause is derived from, so no second copy - * exists. Also clears a per-row 'stopped' hold an earlier build of the queue - * wrote, which this build only ever lifts. Returns how many rows it changed. */ -export function adoptQueuedMessages( - db: Database.Database, - input: { sessionId: string; hostInstance: string } -): number { - return Number( - db - .prepare( - `UPDATE queued_messages - SET host_instance = ?, hold_reason = CASE WHEN hold_reason = 'stopped' THEN NULL ELSE hold_reason END - WHERE session_id = ? AND state = 'waiting' - AND (host_instance <> ? OR hold_reason = 'stopped')` - ) - .run(input.hostInstance, input.sessionId, input.hostInstance).changes ?? 0 - ) -} diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts index 83570726945d..dabc1d64335d 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts @@ -5,7 +5,7 @@ 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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentJournalMessageItem, AgentSessionJournalIdentity @@ -29,7 +29,6 @@ import { } from './queued-message-pause' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' -import { QUEUED_MESSAGE_PAUSED_KEPT } from '../../../shared/agent-session-queued-message-wire' import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const IDENTITY: AgentSessionJournalIdentity = { @@ -97,12 +96,12 @@ function userStop(journal: AgentSessionJournal) { } function reason(journal: AgentSessionJournal): string | null { - return journal.queuedMessages.pauses(HOST)[0]?.reason ?? null + return journal.queuedMessages.pauses()[0]?.reason ?? null } /** Each card, and whether a pause in force holds it. */ function held(journal: AgentSessionJournal): [string, boolean][] { - const pauses = journal.queuedMessages.pauses(HOST) + const pauses = journal.queuedMessages.pauses() return journal.queuedMessages .list() .filter((card) => card.state === 'waiting') @@ -308,7 +307,7 @@ describe("the queue's pause, derived from the journal", () => { expect: 'waiting', settledByOp: null, hostInstance: HOST, - ...(automatic ? { yieldsToPause: { hostInstance: HOST } } : {}) + ...(automatic ? { yieldsToPause: true as const } : {}) } ) await expect(consume('drain-1', true)).rejects.toBeInstanceOf(QueuedMessageNotConsumableError) @@ -390,7 +389,7 @@ describe("the queue's pause, derived from the journal", () => { await turn(journal, 'typed') await userStop(journal) await journal.replaceEpochItems('handle_forked', 0, []) - expect(journal.queuedMessages.pauses(HOST).map((pause) => pause.reason)).toEqual(['stopped']) + expect(journal.queuedMessages.pauses().map((pause) => pause.reason)).toEqual(['stopped']) await journal.appendQueueResume(0) expect(reason(journal)).toBeNull() }) @@ -460,7 +459,7 @@ describe('which cards a pause holds', () => { expect: 'waiting', settledByOp: null, hostInstance: HOST, - yieldsToPause: { hostInstance: HOST } + yieldsToPause: true } ) ).rejects.toBeInstanceOf(QueuedMessageNotConsumableError) @@ -480,89 +479,191 @@ describe('which cards a pause holds', () => { }) }) -describe("a restart's pause", () => { - it("adopting a restart's rows moves them into this instance and clears an older build's stored 'stopped' hold; send_failed stays", async () => { - const journal = await open() - await journal.queuedMessages.insert({ - messageId: 'draft-restart', - body: message('written before the restart'), - fingerprint: 'fp-draft-restart', - hostInstance: 'proc-0' - }) - expect(reason(journal)).toBe('restarted') - await queueDraft(journal, 'draft-legacy') - await queueDraft(journal, 'draft-failed') - await journal.queuedMessages.hold({ messageIds: ['draft-failed'], reason: 'send_failed' }) - const db = new Database(journalDatabasePath(root)) - db.prepare("UPDATE queued_messages SET hold_reason = 'stopped' WHERE message_id = ?").run( - 'draft-legacy' - ) - db.close() - journal.queuedMessages.invalidate() - expect(await journal.queuedMessages.adopt(HOST)).toBe(true) +describe("a reopen's pause", () => { + /** This handle closes and the next one opens and marks it, as a quit, a crash or a chat close + * leaves it; `failMark` makes the mark's write fail. */ + async function reopen( + journal: AgentSessionJournal, + failMark = false + ): Promise { + await journal.close() + const reopened = await open() + if (failMark) { + vi.spyOn(reopened, 'appendQueueReopen').mockRejectedValueOnce(new Error('disk full')) + await expect(reopened.markQueueReopen(0)).rejects.toThrow('disk full') + } else { + await reopened.markQueueReopen(0) + } + return reopened + } + + it('holds the cards queued before the reopen, never one queued after its mark', async () => { + let journal = await open() + await queueDraft(journal, 'before') expect(reason(journal)).toBeNull() - expect( - journal.queuedMessages.list().map((row) => [row.messageId, row.hostInstance, row.holdReason]) - ).toEqual([ - ['draft-restart', HOST, null], - ['draft-legacy', HOST, null], - ['draft-failed', HOST, 'send_failed'] + journal = await reopen(journal) + await queueDraft(journal, 'after') + expect(held(journal)).toEqual([ + ['before', true], + ['after', false] ]) + expect(reason(journal)).toBe('restarted') }) - it('adoption and Resume keep a kept card held: only its own Send, Edit or Delete releases it', async () => { - const journal = await open() - const kept = await journal.queuedMessages.insert({ - messageId: 'kept', - body: message('kept across a restart'), - fingerprint: 'fp-kept', - hostInstance: 'proc-0', - holdReason: QUEUED_MESSAGE_PAUSED_KEPT - }) - expect(kept.holdReason).toBe(QUEUED_MESSAGE_PAUSED_KEPT) - expect(await journal.queuedMessages.adopt(HOST)).toBe(true) - expect(journal.queuedMessages.get('kept')).toMatchObject({ - hostInstance: HOST, - holdReason: QUEUED_MESSAGE_PAUSED_KEPT + it.each([ + ['an accepted turn', (journal: AgentSessionJournal) => turn(journal, 'carry-on')], + ['a Resume', (journal: AgentSessionJournal) => journal.appendQueueResume(0)] + ])('%s after the reopen lifts it', async (_name, lift) => { + let journal = await open() + await queueDraft(journal, 'before') + journal = await reopen(journal) + await lift(journal) + expect(reason(journal)).toBeNull() + }) + + it('a turn the agent has not accepted yet lifts nothing', async () => { + let journal = await open() + await queueDraft(journal, 'before') + journal = await reopen(journal) + await turn(journal, 'carry-on', false) + expect(reason(journal)).toBe('restarted') + }) + + // A hand-off in flight may come back to waiting after the open: the open marks for it too. + it('marks a reopen whose only card is mid-hand-off, and holds it once it comes back', async () => { + let journal = await open() + await queueDraft(journal, 'in-flight') + await journal.appendSubmission( + { + clientMessageId: 'drain-1', + payloadFingerprint: 'fp-in-flight', + body: message('in-flight'), + fence: 0, + handoverRecorded: true + }, + { messageId: 'in-flight', expect: 'waiting', settledByOp: null, hostInstance: HOST } + ) + journal = await reopen(journal) + await journal.resolveDispatch({ + clientMessageId: 'drain-1', + state: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact('notDelivered'), { + surface: 'rejection' + }), + fence: 0 }) + expect(held(journal)).toEqual([['in-flight', true]]) }) - it('an adoption with nothing to adopt changes nothing and fires no commit notification', async () => { + // Written after a later send was accepted, the mark starts where the chat stopped, so that + // send still lifts it; it never narrows a wider mark before it. + it('a mark written late starts where the chat stopped, and narrows no earlier mark', async () => { const journal = await open() - await queueDraft(journal, 'draft-1') - const revision = journal.queuedMessages.revision() - expect(await journal.queuedMessages.adopt(HOST)).toBe(false) - expect(journal.queuedMessages.revision()).toBe(revision) + await queueDraft(journal, 'kept') + const stopped = journal.cursor().sequence + 1 + await turn(journal, 'later') + await journal.markQueueReopen(0, stopped) + expect(reason(journal)).toBeNull() + + const reopened = await reopen(journal) + expect(reason(reopened)).toBe('restarted') + await reopened.markQueueReopen(0, stopped) + expect(reason(reopened)).toBe('restarted') + }) + + it('a reopen with no waiting card writes nothing', async () => { + let journal = await open() + await turn(journal, 'earlier') + const before = journal.cursor() + journal = await reopen(journal) + expect(journal.cursor()).toEqual(before) + await queueDraft(journal, 'after') + expect(held(journal)).toEqual([['after', false]]) + }) + + it('a second reopen before any turn still holds them; one after a turn holds them again', async () => { + let journal = await open() + await queueDraft(journal, 'before') + journal = await reopen(journal) + journal = await reopen(journal) + expect(reason(journal)).toBe('restarted') + await turn(journal, 'carry-on') + expect(reason(journal)).toBeNull() + // Still waiting (here, nothing drained it): the chat closed with it again, so it waits again. + journal = await reopen(journal) + expect(reason(journal)).toBe('restarted') + }) + + // The open could not write its mark: the open itself is the boundary, so nothing sends by + // itself; the next turn or Resume lifts it, and the next open marks again. + it('without a mark the open itself is where the pause begins', async () => { + let journal = await open() + await queueDraft(journal, 'before') + journal = await reopen(journal, true) + expect(held(journal)).toEqual([['before', true]]) + await turn(journal, 'carry-on') + expect(reason(journal)).toBeNull() + }) + + it("reads an earlier build's stored 'kept' and 'stopped' holds as none; send_failed stays", async () => { + let journal = await open() + for (const id of ['was-kept', 'was-stopped', 'failed']) { + await queueDraft(journal, id) + } + await journal.queuedMessages.hold({ messageIds: ['failed'], reason: 'send_failed' }) + const db = new Database(journalDatabasePath(root)) + for (const [id, stored] of [ + ['was-kept', 'kept'], + ['was-stopped', 'stopped'] + ]) { + db.prepare('UPDATE queued_messages SET hold_reason = ? WHERE message_id = ?').run(stored, id) + } + db.close() + journal = await reopen(journal) + expect(journal.queuedMessages.list().map((row) => [row.messageId, row.holdReason])).toEqual([ + ['was-kept', null], + ['was-stopped', null], + ['failed', 'send_failed'] + ]) + expect(held(journal)).toEqual([ + ['was-kept', true], + ['was-stopped', true], + ['failed', false] + ]) }) }) describe('which cards the pauses in force hold', () => { type Card = Parameters[1] & { messageId: string } - const DEAD = 'proc-0' function card(messageId: string, queuedAfter: number, fields: Partial = {}): Card { const queuedAt = { epoch: 'epoch-1', sequence: queuedAfter + 1 } - const base = { state: 'waiting', holdReason: null, hostInstance: HOST, carriedFrom: null } + const base = { state: 'waiting', holdReason: null, carriedFrom: null } return { messageId, ...base, queuedAt, ...fields } } - /** A Stop at sequence 5 unless `stopped` is 0; no turn or Resume since. */ - function pausesOver(cards: readonly Card[], stopped = 5) { + /** A Stop at sequence 5 unless `stopped` is 0; this handle opened at `opened` and could not mark + * it (0: nothing to mark); no turn, Resume or reopen mark since. */ + function pausesOver(cards: readonly Card[], stopped = 5, opened = 0) { return deriveQueuePauses({ epoch: 'epoch-1', marks: { latestStop: stopped ? { sequence: stopped, event: { reason: 'user-stop', at: 0 } } : null, - resumedSequence: 0 + resumedSequence: 0, + reopenedSequence: 0 }, latestAcceptedTurnSequence: 0, cards, - hostInstance: HOST, - restartEnded: false + reopenFloor: opened ? { epoch: 'epoch-1', sequence: opened + 1 } : null }) } - function holding(cards: readonly Card[], stopped?: number): [string, string | null][] { - const pauses = pausesOver(cards, stopped) + function holding( + cards: readonly Card[], + stopped?: number, + opened?: number + ): [string, string | null][] { + const pauses = pausesOver(cards, stopped, opened) return cards.map((each) => [each.messageId, queuePauseHolding(pauses, each)?.reason ?? null]) } @@ -580,64 +681,51 @@ describe('which cards the pauses in force hold', () => { ]) }) - it("a Stop that holds nothing never hides a restart's: a dead process's card queued after it waits", () => { - const after = card('after', 5, { hostInstance: DEAD }) - expect(pausesOver([after]).map((pause) => pause.reason)).toEqual(['stopped', 'restarted']) - expect(holding([after])).toEqual([['after', 'restarted']]) - expect(nextSendableQueuedCard(pausesOver([after]), [after])).toBeNull() - expect(resumableQueuePause(pausesOver([after]), [after])?.reason).toBe('restarted') - // Written by this process, nothing holds it: a card queued after a Stop sends normally. - const live = card('after', 5) - expect(nextSendableQueuedCard(pausesOver([live]), [live])).toBe(live) + it("a Stop that holds nothing never hides a reopen's: a card from before the open waits", () => { + const after = card('after', 5) + expect(pausesOver([after], 5, 6).map((pause) => pause.reason)).toEqual(['stopped', 'restarted']) + expect(holding([after], 5, 6)).toEqual([['after', 'restarted']]) + expect(nextSendableQueuedCard(pausesOver([after], 5, 6), [after])).toBeNull() + // Queued since the open, nothing holds it: a card queued after a Stop sends normally. + expect(nextSendableQueuedCard(pausesOver([after]), [after])).toBe(after) }) it("a card names the first pause holding it, and the header names the first held card's", () => { - const cards = [ - card('before', 3, { hostInstance: DEAD }), - card('after', 5, { hostInstance: DEAD }) - ] - expect(holding(cards)).toEqual([ + const cards = [card('before', 3), card('after', 5)] + expect(holding(cards, 5, 6)).toEqual([ ['before', 'stopped'], ['after', 'restarted'] ]) - expect(resumableQueuePause(pausesOver(cards), cards)?.reason).toBe('stopped') + expect(resumableQueuePause(pausesOver(cards, 5, 6), cards)?.reason).toBe('stopped') }) - // Held on its own, as a card whose send failed: the queue goes past it, and Resume is offered - // over the cards a pause holds behind it. - it('a kept card is skipped like a send_failed one; the cards behind it still send', () => { - const behind = card('behind', 2) - const kept = [card('kept', 1, { holdReason: QUEUED_MESSAGE_PAUSED_KEPT }), behind] - expect(holding(kept, 0)).toEqual([ - ['kept', null], - ['behind', null] + // R13-1: a message Orca kept after a quit is an ordinary card, in order, under the reopen's pause. + it('a card from before the open waits in order, and so do the cards behind it', () => { + const cards = [card('kept', 1), card('behind', 2)] + expect(holding(cards, 0, 4)).toEqual([ + ['kept', 'restarted'], + ['behind', 'restarted'] ]) - expect(nextSendableQueuedCard(pausesOver(kept, 0), kept)).toBe(behind) - expect(resumableQueuePause(pausesOver(kept, 0), kept)).toBeNull() - const restarted = [kept[0]!, card('dead', 2, { hostInstance: DEAD })] - expect(nextSendableQueuedCard(pausesOver(restarted, 0), restarted)).toBeNull() - expect(resumableQueuePause(pausesOver(restarted, 0), restarted)?.reason).toBe('restarted') - }) - - // A dead process's card held on its own waits for its own Send, so it pauses no other card. - it('a card held on its own from a dead process starts no restart pause', () => { - for (const holdReason of [QUEUED_MESSAGE_PAUSED_KEPT, 'send_failed']) { - const cards = [card('held', 1, { hostInstance: DEAD, holdReason }), card('live', 2)] - expect(pausesOver(cards, 0)).toEqual([]) - expect(nextSendableQueuedCard(pausesOver(cards, 0), cards)?.messageId).toBe('live') - } + expect(nextSendableQueuedCard(pausesOver(cards, 0, 4), cards)).toBeNull() + }) + + // A card held on its own waits for its own Send, so it starts no reopen pause for the others. + it('a card held on its own from before the open starts no reopen pause', () => { + const cards = [card('held', 1, { holdReason: 'send_failed' }), card('live', 5)] + expect(pausesOver(cards, 0, 4)).toEqual([]) + expect(nextSendableQueuedCard(pausesOver(cards, 0, 4), cards)?.messageId).toBe('live') }) - it("a /clear's pause that holds nothing never hides a restart's", () => { + it("a /clear's pause that holds nothing never hides a reopen's", () => { const cards = [ card('carried', 1, { carriedFrom: 'source-session', holdReason: 'send_failed' }), - card('typed', 2, { hostInstance: DEAD }) + card('typed', 2) ] - expect(pausesOver(cards, 0).map((pause) => pause.reason)).toEqual(['cleared', 'restarted']) - expect(holding(cards, 0)).toEqual([ + expect(pausesOver(cards, 0, 4).map((pause) => pause.reason)).toEqual(['cleared', 'restarted']) + expect(holding(cards, 0, 4)).toEqual([ ['carried', null], ['typed', 'restarted'] ]) - expect(nextSendableQueuedCard(pausesOver(cards, 0), cards)).toBeNull() + expect(nextSendableQueuedCard(pausesOver(cards, 0, 4), cards)).toBeNull() }) }) diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.ts index 5b13856b8f0a..a7a652d81b7c 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-pause.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-pause.ts @@ -5,12 +5,14 @@ // person's pauses. // - 'cleared': a card /clear carried into this conversation waits, and no turn or Resume has // happened here since. -// - 'restarted': a waiting card with no hold of its own was written by another host process, -// and no turn has started since this conversation opened. Never published: after a restart -// nothing sends by itself, and the next turn (the carry-on or the person's own message) runs -// first. +// - 'restarted': a card queued before this conversation last opened waits — Orca quit or +// crashed, or the chat closed, while it waited — and no turn or Resume has happened since the +// open. The open marks itself with a row when it finds waiting cards (`queueReopen`), so a card +// queued after it is never mistaken for one from before. Never published: nothing sends by +// itself, and the next turn (the carry-on or the person's own message) runs first. // Any accepted turn lifts them, whoever sent it: a person, Orca's own messages, or the queue. -// A card held on its own (`hold_reason`) is outside every pause: only an action on it releases it. +// A card held on its own (`hold_reason`, a failed conversion) is outside every pause: only an +// action on it releases it. import type { AgentJournalCursor, @@ -33,29 +35,32 @@ export type JournalStopSettle = { export type JournalStopFailedOn = { turnId: string } | { openedAfter: number } -/** The latest Stop event, whatever its reason, and the latest Resume row, folded by the reducer. */ +/** The latest Stop event, whatever its reason, the latest Resume row and the latest reopen mark, + * folded by the reducer. */ export type JournalQueuePauseMarks = { latestStop: { sequence: number; event: JournalStopEvent; settle?: JournalStopSettle } | null /** 0 when none. */ resumedSequence: number + /** 0 when none. */ + reopenedSequence: number } export type DerivedQueuePause = { reason: QueuePauseReason - /** Where a Stop's pause began: a card queued at or after it is newer. Null for the others. */ + /** Where a Stop's or a reopen's pause began: a card queued at or after it is newer. Null for + * /clear's, which holds the cards it carried. */ since: AgentJournalCursor | null } type QueueCard = { state: string holdReason: string | null - hostInstance: string carriedFrom: string | null queuedAt: AgentJournalCursor | null } export function createJournalQueuePauseMarks(): JournalQueuePauseMarks { - return { latestStop: null, resumedSequence: 0 } + return { latestStop: null, resumedSequence: 0, reopenedSequence: 0 } } /** The keys are read from disk unchecked: a value no build writes (a corrupt row) is ignored. */ @@ -79,6 +84,11 @@ export function foldJournalQueuePauseMark( marks.latestStop = { sequence: row.seq, event: row.stopEvent } } else if (row.queueResume === true) { marks.resumedSequence = row.seq + } else if (row.queueReopen === true) { + // Never earlier than a mark before it: a late mark of one send narrows no wider one. + const since = row.queueReopenSince + const start = typeof since === 'number' && since > 0 && since <= row.seq ? since : row.seq + marks.reopenedSequence = Math.max(marks.reopenedSequence, start) } } @@ -103,20 +113,23 @@ export function journalUserStopInForce( } /** What a rewind's new epoch restates so its pauses read as they did: a lift of /clear's pause (a - * turn or a Resume happened), then the Stop still in force, in that order so the lift - * never ends the Stop. */ + * turn or a Resume happened), then the Stop still in force, then the reopen's pause still holding + * a card, in that order so the lift never ends either. */ export type JournalQueuePauseRestatement = { lifted: boolean liveStop: JournalStopEvent | null + reopened: boolean } export function journalQueuePauseRestatement( marks: JournalQueuePauseMarks, - latestAcceptedTurnSequence: number + latestAcceptedTurnSequence: number, + pauses: readonly DerivedQueuePause[] ): JournalQueuePauseRestatement { return { lifted: latestAcceptedTurnSequence > 0 || marks.resumedSequence > 0, - liveStop: journalUserStopInForce(marks, latestAcceptedTurnSequence)?.event ?? null + liveStop: journalUserStopInForce(marks, latestAcceptedTurnSequence)?.event ?? null, + reopened: pauses.some((pause) => pause.reason === 'restarted') } } @@ -127,9 +140,8 @@ export function deriveQueuePauses(input: { marks: JournalQueuePauseMarks latestAcceptedTurnSequence: number cards: readonly QueueCard[] - hostInstance: string - /** A turn started since this conversation opened. */ - restartEnded: boolean + /** Where the reopen's pause begins when this handle could not mark it; null otherwise. */ + reopenFloor: AgentJournalCursor | null }): DerivedQueuePause[] { const { epoch, marks, latestAcceptedTurnSequence } = input const pauses: DerivedQueuePause[] = [] @@ -142,21 +154,42 @@ export function deriveQueuePauses(input: { if (carried.length > 0 && latestAcceptedTurnSequence === 0 && marks.resumedSequence === 0) { pauses.push({ reason: 'cleared', since: null }) } - // A card held on its own waits for its own Send whoever wrote it, so it pauses nothing else. - const foreign = waiting.some( - (card) => card.holdReason === null && card.hostInstance !== input.hostInstance - ) - if (!input.restartEnded && foreign) { - // The process that wrote a card is gone: every card waits, whenever it was written. - pauses.push({ reason: 'restarted', since: null }) + const reopened = reopenPause(input) + if ( + reopened && + waiting.some((card) => card.holdReason === null && queuedBefore(reopened, card)) + ) { + pauses.push(reopened) } return pauses } -/** Queued before the pause began: for /clear, a card it carried; for a restart, every card. For a - * Stop, a card queued before its row; one from another epoch (before a rewind) or from a build - * that recorded no position counts as before. A withdrawn steer keeps its position, so is held. */ -function queuedBeforePause(pause: DerivedQueuePause, card: QueueCard): boolean { +/** Where the latest reopen began: its mark, or, when this handle could not write one, the open + * itself, so a failed write holds a little more and never sends anything by itself. A floor from + * another epoch is not this one's (a rewind restates the mark). Null once a turn or a Resume came + * after it. */ +function reopenPause(input: { + epoch: string + marks: JournalQueuePauseMarks + latestAcceptedTurnSequence: number + reopenFloor: AgentJournalCursor | null +}): DerivedQueuePause | null { + const { epoch, marks, reopenFloor } = input + const floor = reopenFloor?.epoch === epoch ? reopenFloor.sequence : 0 + const sequence = Math.max(marks.reopenedSequence, floor) + if (sequence === 0) { + return null + } + if (Math.max(input.latestAcceptedTurnSequence, marks.resumedSequence) >= sequence) { + return null + } + return { reason: 'restarted', since: { epoch, sequence } } +} + +/** Queued before the pause began: for /clear, a card it carried. For a Stop or a reopen, a card + * queued before its row; one from another epoch (before a rewind) or from a build that recorded + * no position counts as before. A withdrawn steer keeps its position, so is held. */ +function queuedBefore(pause: DerivedQueuePause, card: QueueCard): boolean { if (pause.reason === 'cleared') { return card.carriedFrom !== null } @@ -180,7 +213,7 @@ export function queuePauseHolding( return undefined } // A card queued AFTER a Stop is a new instruction and is not held; it still waits behind a held one. - return pauses.find((pause) => queuedBeforePause(pause, card)) + return pauses.find((pause) => queuedBefore(pause, card)) } /** The card the queue sends next: the oldest waiting one with no hold of its own, unless a diff --git a/src/main/native-chat/agent-session-journal/queued-message-settlement.ts b/src/main/native-chat/agent-session-journal/queued-message-settlement.ts index 029bdb74e6f3..9c30bf4311dd 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-settlement.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-settlement.ts @@ -58,7 +58,6 @@ export function settleOwedQueuedMessages( consumedRef, reason: submission?.reason ?? null, rejection: submission?.rejection, - origin: submission?.origin, now: input.now }) settled += changed ? 1 : 0 @@ -124,7 +123,6 @@ export function settleQueuedMessagesForRow( consumedRef: row.clientMessageId, reason: row.reason, rejection: row.rejection, - origin: submission?.origin, now: input.now }) return changed + (settled ? 1 : 0) diff --git a/src/main/native-chat/agent-session-journal/queued-message-store.test.ts b/src/main/native-chat/agent-session-journal/queued-message-store.test.ts index 7160718124bc..02e07bc53ee6 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-store.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-store.test.ts @@ -13,7 +13,6 @@ import type { } from '../../../shared/agent-session-journal-types' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' -import { QUEUED_MESSAGE_PAUSED_KEPT } from '../../../shared/agent-session-queued-message-wire' import Database from '../../sqlite/sync-database' import { JOURNAL_DB_SCHEMA_VERSION } from './journal-database-schema' import { journalDatabasePath } from './journal-host-database' @@ -420,11 +419,11 @@ describe('returned transition (D1/N4)', () => { }) it.each([ - { by: 'the queue', origin: 'host' as const, holdReason: null }, - { by: 'the person', origin: 'client' as const, holdReason: QUEUED_MESSAGE_PAUSED_KEPT } + { by: 'the queue', origin: 'host' as const }, + { by: 'the person', origin: 'client' as const } ])( 'a restart between $by’s consume and handover sends the draft back to waiting', - async ({ origin, holdReason }) => { + async ({ origin }) => { let journal = await open() await queueDraft(journal, 'draft-1') await consumeDraft(journal, 'draft-1', { origin }) @@ -434,11 +433,10 @@ describe('returned transition (D1/N4)', () => { await journal.rejectQueuedSubmissions(0, HOST_RESTARTED, (submission) => journal.wroteBeforeOpen(submission.acceptedSequence) ) - // The queue's own hand-off waits under the restart's pause, derived from the row's host - // instance; a Send the person asked for waits for them, kept. + // Whoever sent it, it waits with no hold of its own, under the reopen's pause. expect(journal.queuedMessages.get('draft-1')).toMatchObject({ state: 'waiting', - holdReason, + holdReason: null, hostInstance: 'proc-1', consumedAs: null, returnedReason: null @@ -595,23 +593,15 @@ describe('open-time repair and retention', () => { } }) - // The repair reaches the live hook's answer from the stored rejection and who asked for it. + // The repair reaches the live hook's answer from the stored rejection. it.each([ - { - origin: 'client' as const, - cause: 'hostRestarted' as const, - holdReason: QUEUED_MESSAGE_PAUSED_KEPT - }, - { origin: 'host' as const, cause: 'hostRestarted' as const, holdReason: null }, - { - origin: 'client' as const, - cause: 'chatClosed' as const, - holdReason: QUEUED_MESSAGE_PAUSED_KEPT - }, - { origin: 'host' as const, cause: 'chatClosed' as const, holdReason: null } + { origin: 'client' as const, cause: 'hostRestarted' as const }, + { origin: 'host' as const, cause: 'hostRestarted' as const }, + { origin: 'client' as const, cause: 'chatClosed' as const }, + { origin: 'host' as const, cause: 'chatClosed' as const } ])( - 'a skipped hook for a $origin hand-off cut short ($cause) is repaired at open, holdReason $holdReason', - async ({ origin, cause, holdReason }) => { + 'a skipped hook for a $origin hand-off cut short ($cause) is repaired at open, back to waiting', + async ({ origin, cause }) => { let journal = await open() await queueDraft(journal, 'draft-1') await consumeDraft(journal, 'draft-1', { origin }) @@ -629,7 +619,7 @@ describe('open-time repair and retention', () => { journal = await open() expect(journal.queuedMessages.get('draft-1')).toMatchObject({ state: 'waiting', - holdReason, + holdReason: null, consumedAs: null }) } diff --git a/src/main/native-chat/agent-session-journal/queued-message-stored-row.ts b/src/main/native-chat/agent-session-journal/queued-message-stored-row.ts index 1e5fdb2ac03b..51233b66abb0 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-stored-row.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-stored-row.ts @@ -55,7 +55,7 @@ export function readStoredQueuedMessageRow(row: unknown): QueuedMessageRow | nul createdAt: record.created_at, hostInstance: record.host_instance, state, - holdReason: record.hold_reason, + holdReason: storedHoldReason(record.hold_reason), returnedReason: record.returned_reason, returnedRejection: storedRejection(record.returned_rejection), settledAt: record.settled_at, @@ -69,6 +69,14 @@ export function readStoredQueuedMessageRow(row: unknown): QueuedMessageRow | nul } } +/** Holds an earlier build stored that this one derives instead: a kept send (`kept`) waits under + * the reopen's pause, a Stop's (`stopped`) under the Stop's. */ +const QUEUED_MESSAGE_RETIRED_HOLD_REASONS: ReadonlySet = new Set(['kept', 'stopped']) + +function storedHoldReason(stored: string | null): string | null { + return stored !== null && QUEUED_MESSAGE_RETIRED_HOLD_REASONS.has(stored) ? null : stored +} + function storedRejection(json: string | null): UnreadAgentSessionFailureFact | null { if (json === null) { return null diff --git a/src/main/native-chat/agent-session-journal/queued-message-table.ts b/src/main/native-chat/agent-session-journal/queued-message-table.ts index f3e82613b1f3..c9b380a25352 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-table.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-table.ts @@ -13,23 +13,18 @@ import type { AgentJournalCursor, AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' -import { - QUEUED_MESSAGE_PAUSED_KEPT, - type QUEUED_MESSAGE_PAUSED_SEND_FAILED -} from '../../../shared/agent-session-queued-message-wire' +import type { QUEUED_MESSAGE_PAUSED_SEND_FAILED } from '../../../shared/agent-session-queued-message-wire' import { rejectedDraftSettlement } from './journal-dispatch-settlement' import { readStoredQueuedMessageRow } from './queued-message-stored-row' export type QueuedMessageState = 'waiting' | 'dispatched' | 'returned' | 'withdrawn' -/** Why ONE waiting draft is held from auto-sending: its conversion failed (`send_failed`), or it - * is a send the host accepted and kept across a restart or a close (`kept`). Stored on the row, so - * it survives handle eviction and restart; a wire marker (it publishes as `pausedReason`). A Stop - * or a restart pauses the whole queue instead. A reader treats an unknown stored value as a plain - * hold. */ -export type QueuedMessageHoldReason = - | typeof QUEUED_MESSAGE_PAUSED_SEND_FAILED - | typeof QUEUED_MESSAGE_PAUSED_KEPT +/** Why ONE waiting draft is held from auto-sending: its conversion failed (`send_failed`). Stored + * on the row, so it survives handle eviction and restart; a wire marker (it publishes as + * `pausedReason`). A Stop, a /clear or a reopen pauses the queue instead. A reader treats an + * unknown stored value as a plain hold, and the holds earlier builds wrote that this one derives + * (`QUEUED_MESSAGE_RETIRED_HOLD_REASONS`) as none. */ +export type QueuedMessageHoldReason = typeof QUEUED_MESSAGE_PAUSED_SEND_FAILED /** Definitively unsettled: what Stop, /clear, Edit and the budget count, and * what the published list shows. Pending/unknown/accepted deliveries and @@ -231,8 +226,8 @@ export function withdrawQueuedMessages( * dispatched → returned, or back to waiting (`rejectedDraftSettlement`), * matched on the draft's CURRENT hand-off (`consumed_as`), so a re-send refused * again still settles while a late duplicate of an earlier refusal matches - * nothing. A draft back to waiting keeps its position and carries no refusal, held as `kept` - * when the settlement says so; its spent submissions stay findable by their `queuedMessageId` link. + * nothing. A draft back to waiting keeps its position and carries no refusal; its spent + * submissions stay findable by their `queuedMessageId` link. */ export function settleRejectedQueuedMessage( db: Database.Database, @@ -241,8 +236,6 @@ export function settleRejectedQueuedMessage( consumedRef: string reason: string | null rejection: UnreadAgentSessionFailureFact | undefined - /** Who asked for the rejected hand-off (`AgentJournalSubmission.origin`). */ - origin: 'client' | 'host' | undefined now: number } ): boolean { @@ -252,15 +245,11 @@ export function settleRejectedQueuedMessage( ? db .prepare( `UPDATE queued_messages - SET state = 'waiting', hold_reason = ?, consumed_as = NULL, + SET state = 'waiting', hold_reason = NULL, consumed_as = NULL, returned_reason = NULL, returned_rejection = NULL, settled_at = NULL, settled_by_op = NULL WHERE session_id = ? AND state = 'dispatched' AND consumed_as = ?` ) - .run( - settlement.kept ? QUEUED_MESSAGE_PAUSED_KEPT : null, - input.sessionId, - input.consumedRef - ) + .run(input.sessionId, input.consumedRef) : db .prepare( `UPDATE queued_messages diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts index f60b2f295834..1100c0abf81b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts @@ -52,7 +52,7 @@ export function createStructuredAgentSessionConversationLifetime(host: { const stopAgent = (sessionId: string, ending: StructuredAgentSessionStopEnding) => stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId, ending) - const closeConversation = async (sessionId: string): Promise => { + const closeConversation = async (sessionId: string, atRest = false): Promise => { // The handle carries the proof that releases a lease its child's wind-down could not. if (await releaseLeaseOfEndedStructuredAgentSessionChild(host.context(), sessionId)) { return false @@ -66,7 +66,8 @@ export function createStructuredAgentSessionConversationLifetime(host: { host.closeStatus(id, { listed: !tabs.present || tabs.sessionIds.includes(id) }) } }, - sessionId + sessionId, + atRest ) } @@ -91,7 +92,7 @@ export function createStructuredAgentSessionConversationLifetime(host: { stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId, { cause: 'host-stop' }), - closeConversation, + closeConversation: (sessionId) => closeConversation(sessionId, true), logger: deps().logger, ...deps().idleSweep }) @@ -140,7 +141,7 @@ export function createStructuredAgentSessionConversationLifetime(host: { }) }, /** Ends a chat's resources, not the chat: its record and journal stay on disk, and what is - * still queued will not be sent; a person's message stays as a held card. */ + * still queued waits for the chat's next turn; a person's unsent message stays as a card. */ close: (sessionId: string, cause: StructuredAgentSessionCloseCause): Promise => { // Outside the queue: a start the provider never answers must not hold the close behind it. host.context().runtimeState.acquireAborts.abort(sessionId, 'closed while starting') @@ -148,8 +149,11 @@ export function createStructuredAgentSessionConversationLifetime(host: { readRefusals.forget(sessionId) const session = sessions.get(sessionId) if (session) { - // Settled before the stop, so no start delivers it. - await holdClosedStructuredAgentSessionSends(deps(), sessionId, session.journal) + // Settled and marked before the stop, so no start delivers it, and nothing it closed with + // sends by itself, even if the handle stays open. + await holdClosedStructuredAgentSessionSends(deps(), sessionId, session.journal, { + mark: 'always' + }) } await stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId, { cause }) await closeConversation(sessionId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts index aa1a5c3d6191..ec7f478501f7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts @@ -6,8 +6,9 @@ // crash boundary. That // needs no lease: provider history decides such a row later, under a won lease, in the attach. A // row an earlier process accepted and never handed over (it quit or crashed first) is settled here -// too, before any reader, command or child sees it: a person's message is kept as a held card, the -// rest rejected (`journal-unsent-send-hold.ts`). Nothing here starts a provider child. +// too, before any reader, command or child sees it: a person's message is kept as a card, the rest +// rejected (`journal-unsent-send-hold.ts`). The cards then wait for the chat's next turn +// (`queued-message-pause.ts`). Nothing here starts a provider child. import type { JournalHostDatabase } from '../agent-session-journal/journal-host-database' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' @@ -23,7 +24,10 @@ import { import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { settleStaleStructuredAgentSessionState } from './structured-agent-session-dead-generation-settlement' import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' -import { structuredAgentSessionHostInstance } from './structured-agent-session-queued-pause' +import { + markStructuredQueueReopen, + structuredAgentSessionHostInstance +} from './structured-agent-session-queued-pause' import type { StructuredAgentSessionHostDeps, StructuredAgentSessionHostSession @@ -115,6 +119,9 @@ export async function openStructuredAgentSessionConversationJournal( error }) } + // After the leftovers became cards, so the mark follows every card this open found. Every open + // comes after the chat stopped running: the idle sweep never closes one with cards waiting. + await markStructuredQueueReopen(sessionId, journal, fence, deps.logger) // No child in this process writes to a journal nobody had open, so whatever it shows running // belongs to a generation that is gone, whatever the lease still claims. Settled before any // reader or child sees it. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts index a7989f940ef2..380742f0e235 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts @@ -17,8 +17,6 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import type { AgentSessionFailureWordsContext } from '../../../shared/agent-session-failure-words' -import { holdUnsentSends } from '../agent-session-journal/journal-unsent-send-hold' -import { structuredAgentSessionHostInstance } from './structured-agent-session-queued-pause' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import type { StructuredAgentRegistry } from './structured-agent-registry' import { @@ -41,6 +39,7 @@ import { handOverSubmission } from './structured-agent-session-turns' import { structuredAgentSessionNextHandover } from './structured-agent-session-opening-send' import { structuredAgentSessionCommandRunning } from './structured-agent-session-command-turn' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' +import { holdRestartedStructuredAgentSessionSends } from './structured-agent-session-host-lifetime' export type StructuredAgentSessionDeliveryLoopDeps = { sessions: ReadonlyMap @@ -167,11 +166,12 @@ export class StructuredAgentSessionDeliveryLoop { // The open already did, unless its write failed: a row an earlier handle wrote is never handed // over, whether it outlived a quit or a crash. A failure here throws, so none is: this run then // fails, which rejects every queued send, this process's own too. - await holdUnsentSends(session.journal, { - fence: this.deps.conversationFence(sessionId), - hostInstance: structuredAgentSessionHostInstance(), - hold: { cause: 'hostRestarted' } - }) + await holdRestartedStructuredAgentSessionSends( + this.deps.logger, + sessionId, + session.journal, + this.deps.conversationFence(sessionId) + ) if (!(await this.closeWhatTheUserClosed(sessionId, session))) { // Never start an agent for a message the user closed; the next wake re-derives and retries. return this.stop(sessionId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts index 4e4247c316fa..9abd58aa0218 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts @@ -74,7 +74,10 @@ export function createStructuredAgentSessionConversationDelivery(input: { holdClosed: async (sessionId, which) => { const session = sessions.get(sessionId) return session - ? holdClosedStructuredAgentSessionSends(deps, sessionId, session.journal, which) + ? holdClosedStructuredAgentSessionSends(deps, sessionId, session.journal, { + mark: 'settled', + which + }) : true }, failureTextContext: (sessionId) => 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 84dec78d0717..17441c67b93f 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 @@ -24,7 +24,10 @@ import type { } from './structured-agent-session-host-types' import type { StructuredAgentSessionChildExit } from './structured-agent-session-child-exit' import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' -import { structuredAgentSessionHostInstance } from './structured-agent-session-queued-pause' +import { + markStructuredQueueReopen, + structuredAgentSessionHostInstance +} from './structured-agent-session-queued-pause' import type { StructuredAgentSessionStopCause } from './structured-agent-session-adapter' export type { StructuredAgentSessionStopEnding } from './structured-agent-session-host-stop-event' import { @@ -61,31 +64,62 @@ type ConversationCloseDeps = Pick & { } /** What is still queued when the chat closes will not be handed over: a person's message is kept - * as a held card, the rest rejected (`journal-unsent-send-hold.ts`). A quit is not a close: the - * next open settles what it left. `which` narrows it to the messages a close that did not complete + * as a card that waits for the chat's next turn, the rest rejected (`journal-unsent-send-hold.ts`). + * A quit is not a close: the next open settles what it left. The chat stops running, so the + * reopen mark follows (`markStructuredQueueReopen`): on every `close`, or only once it settled a + * send, for a later re-check of the same close, which must never mark past a new send. `which` narrows it to the messages a close that did not complete * closed. Best effort, so a close never waits on it: resolves false when it failed, reported and * never thrown. */ export async function holdClosedStructuredAgentSessionSends( deps: ConversationCloseDeps, sessionId: string, journal: StructuredAgentSessionHostSession['journal'], - which?: (submission: AgentJournalSubmission) => boolean + close: { mark: 'always' | 'settled'; which?: (submission: AgentJournalSubmission) => boolean } ): Promise { - return holdUnsentSends(journal, { - fence: structuredAgentSessionConversationFence(deps.store, sessionId), + const fence = structuredAgentSessionConversationFence(deps.store, sessionId) + const { which } = close + const settled = await holdUnsentSends(journal, { + fence, hostInstance: structuredAgentSessionHostInstance(), hold: { cause: 'chatClosed', ...(which ? { which } : {}) } }).then( - () => true, + (newest) => ({ ok: true, newest }), (error: unknown) => { deps.logger.warn('settling queued messages of a closed chat failed', { scope: 'queued-abandon', sessionId, error }) - return false + return { ok: false, newest: null } } ) + if (close.mark === 'always') { + await markStructuredQueueReopen(sessionId, journal, fence, deps.logger) + } else if (settled.newest !== null) { + // A send that woke this re-check came after the ones it settled: the mark starts at them. + await markStructuredQueueReopen(sessionId, journal, fence, deps.logger, settled.newest + 1) + } + return settled.ok +} + +/** What an earlier host process left queued and the open could not settle (its write failed): + * kept or rejected now, never handed over. A failure throws. A send that woke this came after the + * ones it settled, so the reopen mark starts where they did. */ +export async function holdRestartedStructuredAgentSessionSends( + logger: StructuredAgentSessionHostDeps['logger'], + sessionId: string, + journal: StructuredAgentSessionHostSession['journal'], + fence: number +): Promise { + const hostInstance = structuredAgentSessionHostInstance() + const settled = await holdUnsentSends(journal, { + fence, + hostInstance, + hold: { cause: 'hostRestarted' } + }) + if (settled !== null) { + await markStructuredQueueReopen(sessionId, journal, fence, logger, settled + 1) + } } /** @@ -159,14 +193,18 @@ export type StructuredAgentSessionCloseCause = Extract< > /** Whether the conversation's handle is only a cache now: no child, and nothing queued or waiting - * on the provider. */ + * on the provider. `atRest`: the idle sweep's own close, which also keeps a chat with a card + * waiting, since a reopen marks it to wait for a turn (`markStructuredQueueReopen`), and an + * eviction the person never saw must not; a person's close has marked them already. */ export function structuredAgentSessionConversationClosable( - session: StructuredAgentSessionHostSession + session: StructuredAgentSessionHostSession, + atRest = false ): boolean { return ( session.child === null && !session.journal.submissions().some(isQueuedAgentJournalSubmission) && - session.journal.pendingSubmissions().length === 0 + session.journal.pendingSubmissions().length === 0 && + !(atRest && session.journal.queuedMessages.awaitReopenMark()) ) } @@ -181,10 +219,11 @@ export async function closeStructuredAgentSessionConversationUnderSerialize( /** The status row outlives the handle; see `StructuredAgentSessionClientDelivery`. */ closeStatus: (sessionId: string) => void }, - sessionId: string + sessionId: string, + atRest = false ): Promise { const session = context.sessions.get(sessionId) - if (!session || !structuredAgentSessionConversationClosable(session)) { + if (!session || !structuredAgentSessionConversationClosable(session, atRest)) { return false } context.sessions.delete(sessionId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts index 13edb107a4b4..8c98a685c7d2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts @@ -29,6 +29,7 @@ import { ensureStructuredAgentSessionAgent } from './structured-agent-session-ag import { StructuredAgentSessionHost } from './structured-agent-session-host' import { stopStructuredAgentSessionAgentUnderSerialize } from './structured-agent-session-host-lifetime' import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' +import { structuredQueuePauses } from './structured-agent-session-queued-pause' import { HOST_TEST_LOCATION, HOST_TEST_NOW as NOW, @@ -773,7 +774,7 @@ describe('how a stopped child ends the start its loop was waiting on', () => { expect(await statusRows()).toEqual([]) }) - it('keeps a person’s message the user closed as a held card, and starts no child for it', async () => { + it('keeps a person’s message the user closed as a waiting card, and starts no child for it', async () => { const start = deferred() adapterExtras = { closeSession: vi.fn(async () => true) } await restartHost() @@ -797,6 +798,15 @@ describe('how a stopped child ends the start its loop was waiting on', () => { expect(page.ok && page.page.queuedMessages?.map((card) => card.messageId)).toEqual([first]) expect(acquire).toHaveBeenCalledTimes(starts) expect(dispatch).not.toHaveBeenCalled() + // The close stopped the chat running: the card waits for its next turn, by a mark the + // re-check wrote once, when it kept the card. + const journal = conversation()!.journal + expect(structuredQueuePauses(journal).map((pause) => pause.reason)).toEqual(['restarted']) + const marks = vi.spyOn(journal, 'appendQueueReopen') + await accept('second', { person: true }) + await settleLoop() + // A later send's re-check settles nothing, so it marks nothing past that send. + expect(marks).not.toHaveBeenCalled() }) it('starts no child when closing what was queued fails, and closes it on the next wake', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-holds.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-holds.test.ts index 10b48321bc9a..d4050e8e39ad 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-holds.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-holds.test.ts @@ -21,7 +21,7 @@ import { nativeChatComposerPrimaryAction, type NativeChatComposerPrimaryAction } from '../../../renderer/src/components/native-chat/native-chat-composer-primary-action' -import { JournalQueuedMessages } from '../agent-session-journal/journal-queued-messages' +import { AgentSessionJournal } from '../agent-session-journal/journal-store' import { readQueuePublication, structuredQueueSendGate @@ -512,11 +512,17 @@ describe("the queue's next card on a history page", () => { const card = await queuedDraft('held, then released') await rig.stop() await rig.settleAccepted(working, 'stopped') - // The Resume row is written; its adoption, and so the drain behind it, waits. + // The Resume row is written; the Resume itself, and so the drain behind it, waits. let release: () => void = () => undefined - const adopt = vi - .spyOn(JournalQueuedMessages.prototype, 'adopt') - .mockImplementationOnce(() => new Promise((resolve) => (release = () => resolve(false)))) + const held = new Promise((resolve) => (release = resolve)) + const write = AgentSessionJournal.prototype.appendQueueResume + const resume = vi + .spyOn(AgentSessionJournal.prototype, 'appendQueueResume') + .mockImplementationOnce(async function (this: AgentSessionJournal, fence: number) { + const cursor = await write.call(this, fence) + await held + return cursor + }) const resumed = rig.resume() try { await eventually(async () => { @@ -525,7 +531,7 @@ describe("the queue's next card on a history page", () => { }) } finally { release() - adopt.mockRestore() + resume.mockRestore() } expect(await resumed).toMatchObject({ ok: true, value: { resumed: true } }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-command.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-command.test.ts index dda3d2112cd3..7ab0fcfc1f9f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-command.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-command.test.ts @@ -152,7 +152,8 @@ describe('/clear', () => { { messageId: firstId, state: 'waiting' }, { messageId: secondId, state: 'waiting' } ]) - expect(await rig.queuePause(replacementId)).toEqual({ reason: 'cleared' }) + // Held, unshown: nothing runs in the fresh conversation. + expect(await rig.queuePause(replacementId)).toBeNull() const body = hostTestMessage('first in the new chat') const fields = { body, delivery: 'queue-if-active' as const } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts index a0e3af88c49b..fd3294ddc7d6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts @@ -203,13 +203,10 @@ export async function createQueuedMessageTestRig( }) } - /** A host-process restart, as the queue sees it: the conversation closes, and - * opens afresh under a new instance id while its rows survive. The close is an eviction, whose - * Stop event ends a person's Stop pause if work runs; a quit writes none, so a test of that - * pause across a restart uses `crashRestartHostProcess`. */ + /** A host-process restart: the app quits (its own teardown runs) and a new host opens the same + * state. A quit writes no close's Stop event, so a person's Stop pause survives it. */ async function restartHostProcess(): Promise { - await host.close(SESSION, 'evict') - rotateStructuredAgentSessionHostInstanceForTests() + await quitRestartHostProcess() } /** A host process that dies with no close: a new host opens the same state directory. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts index 686940c4fa77..8e9344352358 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts @@ -298,7 +298,7 @@ describe('held drafts', () => { await new Promise((resolve) => setTimeout(resolve, 250)) expect(await rig.handoff(draftId)).toBeUndefined() expect(await rig.queuePause()).toBeNull() - // The user's send starting its turn lifts it, and adopts the row into this instance. + // The user's send starting its turn lifts it. const next = send('user starts a new turn') await next.result expect(await rig.handoff(draftId)).toBeUndefined() @@ -420,10 +420,12 @@ describe('Stop and Delete', () => { await stop() expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) await settleAccepted(working, 'a') - // Evict the handle and reopen (the history read opens the conversation at - // rest): the pause is derived from what the journal holds, so nothing drains. + // Close and reopen it (the history read opens it at rest): the derived pause still holds, so + // nothing drains; a close hides the row, as nothing runs there until its next turn. await host.close(SESSION, 'evict') - expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + expect(await rig.queuePause()).toBeNull() + const reopened = host.collaboratorsForTests().sessions.get(SESSION)!.journal + expect(structuredQueuePauses(reopened).map((pause) => pause.reason)).toContain('stopped') await new Promise((resolve) => setTimeout(resolve, 250)) expect(await rig.handoff(draftId)).toBeUndefined() // Send-now overrides the pause — the user acting is a release. @@ -614,15 +616,15 @@ describe('/clear', () => { if (!replacementId) { throw new Error('expected a replacement session') } - // The source's cards are spent tombstones; the replacement shows them on a - // queue paused by the clear — not "because you interrupted" — until the user - // acts: Resume, or their next send starting its turn. + // The source's cards are spent tombstones; the replacement shows them as plain waiting cards, + // held by the clear with no row (nothing runs there), until the user acts: Resume, or their + // next send starting its turn. expect(await drafts()).toHaveLength(0) expect(await drafts(replacementId)).toEqual([ { messageId: firstId, state: 'waiting' }, { messageId: secondId, state: 'waiting' } ]) - expect(await rig.queuePause(replacementId)).toEqual({ reason: 'cleared' }) + expect(await rig.queuePause(replacementId)).toBeNull() // Paused from before the first carried card lands: the idle replacement auto-sends nothing. await new Promise((resolve) => setTimeout(resolve, 250)) expect((await host.journalSnapshot(replacementId)).submissions).toHaveLength(0) @@ -663,14 +665,14 @@ describe('/clear', () => { ) }) - it("the replacement's 'cleared' pause lifts through Resume exactly like a Stop's", async () => { + it("the replacement's unshown 'cleared' pause lifts through Resume exactly like a Stop's", async () => { const [firstId] = await pausedDrafts() const cleared = await clear(hostTestOperationId()) const replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined if (!replacementId) { throw new Error('expected a replacement session') } - expect(await rig.queuePause(replacementId)).toEqual({ reason: 'cleared' }) + expect(await rig.queuePause(replacementId)).toBeNull() const resumed = await host.queuedMessagesResume(CALLER, { envelope: envelope( {}, @@ -733,7 +735,7 @@ describe('/clear', () => { // The refusal belonged to the source's submissions; on the replacement the // text is simply a waiting draft again, behind the replacement's pause. expect(await drafts(replacementId)).toEqual([{ messageId: draftId, state: 'waiting' }]) - expect(await rig.queuePause(replacementId)).toEqual({ reason: 'cleared' }) + expect(await rig.queuePause(replacementId)).toBeNull() expect(await drafts()).toHaveLength(0) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts index 3acc45552aa6..fe8b31efe901 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts @@ -367,7 +367,7 @@ export class StructuredAgentSessionQueuedMessageDrain { expect: 'waiting', settledByOp: null, hostInstance: structuredAgentSessionHostInstance(), - yieldsToPause: { hostInstance: structuredAgentSessionHostInstance() } + yieldsToPause: true } ) } catch (error) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts index b637e1fc89e4..3ba5bbb612e9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts @@ -7,13 +7,12 @@ import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import { agentSessionOperationKey } from '../../../shared/agent-session-operation-ledger' -import { - QUEUED_MESSAGE_PAUSED_KEPT, - type AgentSessionMutationEnvelope, - type AgentSessionMutationResult, - type AgentSessionQueuedMessageDeleteResult, - type AgentSessionQueuedMessagesResumeResult, - type AgentSessionSendResult +import type { + AgentSessionMutationEnvelope, + AgentSessionMutationResult, + AgentSessionQueuedMessageDeleteResult, + AgentSessionQueuedMessagesResumeResult, + AgentSessionSendResult } from '../../../shared/agent-session-wire' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { QueuedMessageNotConsumableError } from '../agent-session-journal/journal-queued-messages' @@ -117,10 +116,6 @@ export async function carryQueuedMessagesToClearReplacement( fingerprint: agentSessionSendBodyFingerprint(input.replacementSessionId, row.body), hostInstance: structuredAgentSessionHostInstance(), carriedFrom: ctx.sessionId, - // A kept send stays held there too: no later message may release it. - ...(row.holdReason === QUEUED_MESSAGE_PAUSED_KEPT - ? { holdReason: QUEUED_MESSAGE_PAUSED_KEPT } - : {}) }) } await withdrawQueuedMessagesForOperation(ctx.journal, { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts index d55f7cea3fb0..418c51f5cc74 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts @@ -20,11 +20,7 @@ import { } from './structured-agent-session-queued-message-rig.test-fixture' import { openRigTurnFor } from './structured-agent-session-queued-rig-turn.test-fixture' import { sameQueuePause } from './structured-agent-session-queued-publication' -import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' -import { - structuredAgentSessionHostInstance, - structuredQueuePauses -} from './structured-agent-session-queued-pause' +import { structuredQueuePauses } from './structured-agent-session-queued-pause' let rig: QueuedMessageTestRig @@ -365,7 +361,7 @@ describe('a pause only over cards Resume could send', () => { }) describe("a restart's pause", () => { - it('once a turn ends it, stays ended when the conversation reopens', async () => { + it('a turn ends it; closing again with a card still waiting holds that card again', async () => { const working = await rig.workingSend() const first = await queuedDraft('first') const second = await queuedDraft('second') @@ -377,16 +373,17 @@ describe("a restart's pause", () => { await next.result await rig.settleAccepted(next.id, 'b') await eventually(async () => expect(await rig.handoff(first)).toBeDefined()) - // Reopened, that turn is "before this open", yet the pause it ended stays ended: - // the lift adopted the rows into this process. + // Closed again with a card still waiting, it waits again for the next turn, unshown. await rig.host.close(HOST_TEST_SESSION, 'evict') expect(await rig.queuePause()).toBeNull() expect(await rig.drafts()).toContainEqual({ messageId: second, state: 'waiting' }) + expect(derivedPauses()).toEqual(['restarted']) }) }) describe('a card handed off after a restart', () => { - it('belongs to the process that sent it: withdrawn back to waiting, it raises no restart pause', async () => { + // Returned, not waiting, when Orca restarted: the reopen holds nothing; only the Stop does. + it('sent again and withdrawn by a Stop, it waits under that Stop alone, shown; Resume lifts it', async () => { const working = await rig.workingSend() const draftId = await queuedDraft('refused, then re-sent after a restart') await rig.settleAccepted(working, 'a') @@ -396,7 +393,7 @@ describe('a card handed off after a restart', () => { expect(await rig.drafts()).toEqual([{ messageId: draftId, state: 'returned' }]) ) await rig.restartHostProcess() - // Sent again in this process, then withdrawn by a Stop before the agent had it. + // Sent again after the restart, then withdrawn by a Stop before the agent had it. // Its delivery is held, so the Stop runs ahead of the handover. const { held, release } = holdDelivery() const handedOver = rig.dispatch.mock.calls.length @@ -410,18 +407,10 @@ describe('a card handed off after a restart', () => { await eventually(async () => expect(await rig.drafts()).toEqual([{ messageId: draftId, state: 'waiting' }]) ) - const journal = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal - if (!journal) { - throw new Error('expected the conversation open') - } - expect(journal.queuedMessages.get(draftId)?.hostInstance).toBe( - structuredAgentSessionHostInstance() - ) - // With the Stop's pause gone, nothing else holds it: no restart happened since it was sent. - await journal.appendQueueResume( - structuredAgentSessionConversationFence(rig.store, HOST_TEST_SESSION) - ) - expect(structuredQueuePauses(journal)).toEqual([]) + expect(derivedPauses()).toEqual(['stopped']) + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) + expect(derivedPauses()).toEqual([]) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause.ts index 6722313d0394..e59a56a7c795 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause.ts @@ -1,6 +1,6 @@ // Whether the queue is paused, and why — derived from the journal and the cards -// (`queued-message-pause.ts`), never stored. A Stop's event and a Resume are journal -// rows; an explicit Resume lifts any pause. +// (`queued-message-pause.ts`), never stored. A Stop's event, a Resume and a reopen that found +// waiting cards are journal rows; an explicit Resume lifts any pause. import { randomUUID } from 'node:crypto' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' @@ -8,15 +8,15 @@ import type { DerivedQueuePause } from '../agent-session-journal/queued-message- import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' /** A per-process id, minted once per host process like the runtime's own - * `runtimeId` (`orca-runtime-runtime-id.ts`); a draft written by another - * instance pauses the queue rather than auto-sending after a restart. */ + * `runtimeId` (`orca-runtime-runtime-id.ts`), stamped on the cards this process writes or hands + * off. No pause reads it: an older build holds every card another instance wrote. */ let hostInstance = randomUUID() export function structuredAgentSessionHostInstance(): string { return hostInstance } -/** Simulates a host-process restart. Tests only. */ +/** A new stamp, as a new host process mints. Tests only; no pause reads it. */ export function rotateStructuredAgentSessionHostInstanceForTests(): string { hostInstance = randomUUID() return hostInstance @@ -26,38 +26,34 @@ type PauseJournal = Pick /** The queue's pauses in force, derived; none when the queue sends on its own. */ export function structuredQueuePauses(journal: PauseJournal): DerivedQueuePause[] { - return journal.queuedMessages.pauses(hostInstance) + return journal.queuedMessages.pauses() } -/** - * Every journal publish: a restart's rows are adopted into this instance once a turn started. - * The derivation already reads them as lifted; the write keeps that answer when the handle - * reopens (its "since this conversation opened" moves). Bookkeeping: a failure is reported. - */ -export async function adoptEndedRestartPause( +/** The mark of a chat that stopped running with cards waiting — Orca quit or crashed, or the chat + * was closed — so they wait for its next turn (`queued-message-pause.ts`). Every open marks, and + * so does a person's close: the idle sweep never closes a chat with cards waiting, so its + * eviction never reopens one. `since`: where the chat stopped, for a mark written after a send + * that came later, which must still lift it. Bookkeeping, so a failure is reported and never + * thrown; the pause then starts where the mark would have gone, holding no less. */ +export async function markStructuredQueueReopen( sessionId: string, - journal: PauseJournal, - logger: StructuredAgentSessionLogger + journal: Pick, + fence: number, + logger: StructuredAgentSessionLogger, + since?: number ): Promise { try { - const { queuedMessages } = journal - const restarted = queuedMessages - .list() - .some((row) => row.state === 'waiting' && row.hostInstance !== hostInstance) - if (restarted && queuedMessages.restartEnded()) { - await queuedMessages.adopt(hostInstance) - } + await journal.markQueueReopen(fence, since) } catch (error) { - logger.warn("adopting a restart's queued cards after a started turn failed", { - scope: 'queue-pause-adoption', + logger.warn('marking a reopened queue failed', { + scope: 'queue-reopen-mark', sessionId, error }) } } -/** Resume: a journal row that ends a Stop's or a /clear's pause, and adoption of a restart's - * rows. Returns whether the queue was paused. */ +/** Resume: a journal row that ends every pause. Returns whether the queue was paused. */ export async function resumeStructuredQueue( journal: Pick, fence: number @@ -66,6 +62,5 @@ export async function resumeStructuredQueue( return false } await journal.appendQueueResume(fence) - await journal.queuedMessages.adopt(hostInstance) return true } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts index af1b7e567b3f..d48d9bf27c39 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts @@ -4,7 +4,6 @@ // streams from re-sending it. They ride together: a client never sees one without the others. import { - QUEUED_MESSAGE_PAUSED_KEPT, QUEUED_MESSAGE_PAUSED_SEND_FAILED, type AgentSessionQueuedMessage, type AgentSessionQueuedMessagePausedReason, @@ -42,9 +41,8 @@ export function structuredQueueSendGate( }) } -/** Waiting and returned rows only. `paused` is a per-card hold (a failed - * conversion, or a send the host kept); a Stop or a /clear pauses the queue, published once - * beside it. */ +/** Waiting and returned rows only. `paused` is a per-card hold (a failed conversion); a person's + * Stop pauses the queue, published once beside it. */ function computePublishedQueuedMessages(journal: AgentSessionJournal): AgentSessionQueuedMessage[] { const published: AgentSessionQueuedMessage[] = [] for (const row of journal.queuedMessages.list()) { @@ -72,7 +70,7 @@ function computePublishedQueuedMessages(journal: AgentSessionJournal): AgentSess function isPublishedPausedReason( reason: string | null ): reason is AgentSessionQueuedMessagePausedReason { - return reason === QUEUED_MESSAGE_PAUSED_SEND_FAILED || reason === QUEUED_MESSAGE_PAUSED_KEPT + return reason === QUEUED_MESSAGE_PAUSED_SEND_FAILED } type ListMemo = { key: string; serialized: string; list: AgentSessionQueuedMessage[] } @@ -114,18 +112,29 @@ export function readQueuePublication( gate: QueueSendGate ): QueuePublication { const queuedMessages = readPublishedQueuedMessages(journal) - // Read per emit: the pause also turns on submissions (a turn starting). Shown only over a card - // Resume would send, so its header never offers to send nothing; deleting a blocking returned - // card shows it again. After a restart nothing is shown, a Stop's or /clear's included: the - // chat's next turn lifts every pause, and the cards read as plain waiting cards until then. + // Read per emit: the pause also turns on submissions (a turn starting). Only a person's Stop is + // shown, and only over a card Resume would send, so its header never offers to send nothing; + // deleting a blocking returned card shows it again. After a /clear, a restart or a close nothing + // is shown, a Stop's from before it included: nothing runs in the chat, its next turn lifts every + // pause, and the cards read as plain waiting cards until then. A Stop the person makes after the + // reopen shows. const pauses = structuredQueuePauses(journal) - const restarted = pauses.some((pause) => pause.reason === 'restarted') - const resumable = restarted ? null : resumableQueuePause(pauses, journal.queuedMessages.list()) + const stop = pauses.find((pause) => pause.reason === 'stopped')?.since + const silent = pauses.some( + (pause) => + pause.reason === 'cleared' || + (pause.reason === 'restarted' && + (!stop || + !pause.since || + stop.epoch !== pause.since.epoch || + stop.sequence < pause.since.sequence)) + ) + const resumable = silent ? null : resumableQueuePause(pauses, journal.queuedMessages.list()) // The submissions are read only while a pause would show, never while the queue runs freely. const pause = resumable && !queuePauseLiftOnItsWay(resumable, journal.submissions()) ? resumable : null - // `restarted` is already excluded above; the test narrows the type. - const queuePause = pause && pause.reason !== 'restarted' ? { reason: pause.reason } : null + // The test narrows the type. + const queuePause = pause?.reason === 'stopped' ? { reason: pause.reason } : null const nextQueuedMessageId = nextStructuredQueuedMessage({ journal, ...gate() })?.messageId ?? null const previous = publications.get(journal) if ( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts index 001e0b10786b..2b0aae62f487 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts @@ -246,7 +246,11 @@ describe("a /clear's carried cards", () => { if (!replacementId) { throw new Error(`expected a replacement session: ${JSON.stringify(cleared)}`) } - expect(await rig.queuePause(replacementId)).toEqual({ reason: 'cleared' }) + // Held there, unshown: nothing runs in the fresh conversation. + expect(await rig.queuePause(replacementId)).toBeNull() + expect(structuredQueuePauses(journal(replacementId)).map((pause) => pause.reason)).toEqual([ + 'cleared' + ]) // Idle there, so the person's send goes straight out rather than queueing. const text = hostTestMessage('hi') const person = rig.host.send(QUEUED_RIG_CALLER, { @@ -299,7 +303,9 @@ describe('no stored pause', () => { ...fields }) const replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined - expect(replacementId && (await rig.queuePause(replacementId))).toEqual({ reason: 'cleared' }) + expect( + replacementId && structuredQueuePauses(journal(replacementId)).map((pause) => pause.reason) + ).toEqual(['cleared']) expect(pauseTables()).toBe(0) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-wiring.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-wiring.ts index af7aee44bc98..a5ddfeb64748 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-wiring.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-wiring.ts @@ -9,7 +9,6 @@ import type { } from './structured-agent-session-host-types' import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' import { StructuredAgentSessionQueuedMessageDrain } from './structured-agent-session-queued-messages' -import { adoptEndedRestartPause } from './structured-agent-session-queued-pause' import { deleteQueuedStructuredAgentMessage, resumeStructuredAgentQueue, @@ -39,14 +38,9 @@ export function wireStructuredAgentSessionQueuedMessages( return { drain, /** Every journal publish: turn, submission, prompt, command and Stop - * settlements are all commits, and each re-derives the drain's gates — - * and adopts a restart's cards once a turn started. */ + * settlements are all commits, and each re-derives the drain's gates. */ onJournalActivity: (sessionId: string) => { sessions.touch(sessionId) - const journal = sessions.get(sessionId)?.journal - if (journal) { - void adoptEndedRestartPause(sessionId, journal, context().deps.logger) - } drain.schedule(sessionId) }, queuedMessageSend: ( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts index b5d33be6827d..9c9722de00bb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts @@ -3,6 +3,7 @@ // does a Stop of a start that never landed; a card it held, which Resume releases, and anything // sent after it end as their own. Turn rows name the send that opened them, as Codex writes them. +import { structuredQueuePauses } from './structured-agent-session-queued-pause' import { afterEach, describe, expect, it, vi } from 'vitest' import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' import { @@ -128,6 +129,13 @@ async function mailTurn(): Promise { } /** The host evicts the chat; the Stop events as its provider close finds them. */ +/** The person's Stop still pauses the reopened chat. The close hides its row, as every close of a + * chat does: nothing runs there until its next turn. */ +async function stillStopped(): Promise { + expect(await rig.queuePause()).toBeNull() + return structuredQueuePauses(journal()).some((pause) => pause.reason === 'stopped') +} + async function evictedAt(): Promise { let atClose: JournalStopEvent[] = [] rig.closeSession.mockImplementationOnce(async () => { @@ -371,7 +379,7 @@ describe('a host stop with no turn running after a Stop that named none', () => expect(journal().stopMarks.latest()?.event).not.toHaveProperty('turnId') expect(await evictedAt()).toEqual(['user-stop']) - expect(await rig.queuePause()).toMatchObject({ reason: 'stopped' }) + expect(await stillStopped()).toBe(true) expect(await rig.handoff(card)).toBeUndefined() } ) @@ -386,7 +394,7 @@ describe('a host stop with no turn running after a Stop that named none', () => expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: false } }) expect(await evictedAt()).toEqual(['user-stop']) - expect(await rig.queuePause()).toMatchObject({ reason: 'stopped' }) + expect(await stillStopped()).toBe(true) expect(await rig.handoff(card)).toBeUndefined() }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts index 3f1afc89d6bc..17a274d6f5fc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts @@ -251,7 +251,9 @@ describe('every Stop entry writes its event, with its reason, before it ends the await rig.host.close(HOST_TEST_SESSION, 'evict') expect(atClose.events?.map((event) => event.reason)).toEqual(['user-stop']) - expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + // The close hides the row, as every close of a chat does; the Stop still pauses. + expect(await rig.queuePause()).toBeNull() + expect(structuredQueuePauses(journal()).map((pause) => pause.reason)).toContain('stopped') }, 20_000 ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unrun-send-hold.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unrun-send-hold.test.ts index 5112b6ff74de..fe6a29cdcae6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unrun-send-hold.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unrun-send-hold.test.ts @@ -1,13 +1,14 @@ // A message handed to an agent whose start never answers provably never ran: its CLI takes no // message before it answers initialize. So when the chat ends then, the message is settled as a // queued one is for the same end (`journal-unsent-send-hold.ts`): a quit or a close keeps a -// person's words as a held card, a person's Stop withdraws it. Against the real host, store and +// person's words as an ordinary card that waits for the chat's next turn, a person's Stop +// withdraws it. Against the real host, store and // journal, with an agent that stays starting. import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' -import { QUEUED_MESSAGE_PAUSED_KEPT } from '../../../shared/agent-session-queued-message-wire' +import { structuredQueuePauses } from './structured-agent-session-queued-pause' import { createQueuedMessageTestRig, eventually, @@ -17,7 +18,17 @@ import { HOST_TEST_SESSION as SESSION } from './structured-agent-session-host-te const words = (kind: 'hostRestarted' | 'chatClosed' | 'cancelled') => agentSessionFailureWords(agentSessionFailureFact(kind), { surface: 'rejection' }) -const KEPT = { state: 'waiting', paused: true } +/** A kept send: an ordinary waiting card, with no hold of its own. */ +const KEPT = { state: 'waiting' } + +/** What holds the queue, derived from the open journal. */ +function derivedPauses(): string[] { + const journal = rig.host.collaboratorsForTests().sessions.get(SESSION)?.journal + if (!journal) { + throw new Error('expected the conversation open') + } + return structuredQueuePauses(journal).map((pause) => pause.reason) +} let rig: QueuedMessageTestRig @@ -47,7 +58,7 @@ async function handedToHungStart(text: string): Promise { } describe('a message handed to a start that never answered, then the chat ends', () => { - it('is a held card after a quit, rejected as a restart, as a queued one is', async () => { + it('is a card after a quit, rejected as a restart, held by the reopen until a turn', async () => { const id = await handedToHungStart('kept through quit') await rig.quitRestartHostProcess() @@ -59,7 +70,8 @@ describe('a message handed to a start that never answered, then the chat ends', expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) const [card] = rig.host.collaboratorsForTests().sessions.get(SESSION)?.journal.queuedMessages.list() ?? [] - expect(card).toMatchObject({ holdReason: QUEUED_MESSAGE_PAUSED_KEPT }) + expect(card).toMatchObject({ holdReason: null }) + expect(derivedPauses()).toEqual(['restarted']) // Nothing ran, so there is nothing to resume: the card alone holds the words. expect(await rig.restartOffers()).toEqual([]) }) @@ -81,7 +93,7 @@ describe('a message handed to a start that never answered, then the chat ends', }) it.each(['user-close', 'evict'] as const)( - 'is a held card after a %s, rejected as closed, as a queued one is', + 'is a card after a %s, rejected as closed, held by the reopen until a turn', async (cause) => { const id = await handedToHungStart('kept at close') await rig.host.close(SESSION, cause) @@ -93,6 +105,7 @@ describe('a message handed to a start that never answered, then the chat ends', keptAsQueuedMessageId: id }) expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) + expect(derivedPauses()).toEqual(['restarted']) } ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unsent-send-hold.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unsent-send-hold.test.ts index d3a658d42657..3f9e272a80f4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unsent-send-hold.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unsent-send-hold.test.ts @@ -1,13 +1,12 @@ // A message the host accepted while the agent was starting, then Orca quit or crashed, or the chat -// closed, before handing it over: it is kept as a held card at the head of the queue, never sent on -// its own, and released only by the person's own Send, Edit or Delete on it. Against the real host, -// store and journal. +// closed, before handing it over: it is kept as an ordinary card at the head of the queue, never +// sent on its own; like every card the chat closed with, it follows the chat's next accepted turn +// (or a Resume, Send now, Edit or Delete). Against the real host, store and journal. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import { QUEUED_MESSAGE_PAUSED_KEPT } from '../../../shared/agent-session-queued-message-wire' import type { AgentMessageSource } from '../../../shared/agent-session-message-source' import { projectStructuredAgentSessionMessages } from '../../../shared/structured-agent-session-message-projection' import { createStructuredAgentSessionOutboxEntry } from '../../../shared/structured-agent-session-outbox' @@ -17,7 +16,10 @@ import { openAgentSessionJournal } from '../agent-session-journal/journal-store- import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { journalIdentityFor } from './structured-agent-session-attach' import { attachParamsForRecord } from './structured-agent-session-conversation-open' -import { structuredAgentSessionHostInstance } from './structured-agent-session-queued-pause' +import { + structuredAgentSessionHostInstance, + structuredQueuePauses +} from './structured-agent-session-queued-pause' import { createQueuedMessageTestRig, eventually, @@ -48,7 +50,8 @@ const HOST_RESTARTED = agentSessionFailureWords(agentSessionFailureFact('hostRes const CHAT_CLOSED = agentSessionFailureWords(agentSessionFailureFact('chatClosed'), { surface: 'rejection' }) -const KEPT = { state: 'waiting', paused: true } +/** A kept send: an ordinary waiting card, with no hold of its own. */ +const KEPT = { state: 'waiting' } let rig: QueuedMessageTestRig @@ -133,33 +136,36 @@ describe('a message accepted while the agent starts, then Orca stops', () => { it.each([ { how: 'quit', restart: () => quitRestart() }, { how: 'crash', restart: async () => rig.crashRestartHostProcess() } - ])('is a held card after a $how, and its submission is rejected unseen', async ({ restart }) => { - const id = await acceptWhileStarting(sendRequest('hello after restart')) - await restart() + ])( + 'is a waiting card after a $how, and its submission is rejected unseen', + async ({ restart }) => { + const id = await acceptWhileStarting(sendRequest('hello after restart')) + await restart() - expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) - // Held on its own: no queue-wide pause, so no Resume to press. - expect(await rig.queuePause()).toBeNull() - expect(await rig.submission(id)).toMatchObject({ - dispatchState: 'rejected', - ...HOST_RESTARTED, - origin: 'client', - source: { kind: 'user' } - }) - const [card] = journal().queuedMessages.list() - expect(card).toMatchObject({ - messageId: id, - holdReason: QUEUED_MESSAGE_PAUSED_KEPT, - hostInstance: structuredAgentSessionHostInstance(), - body: hostTestMessage('hello after restart'), - queuedAt: { epoch: journal().epoch, sequence: expect.any(Number) } - }) - // Nothing is sent on its own, however many times the chat reopens. - await rig.host.close(SESSION, 'evict') - rig.crashRestartHostProcess() - expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) - expect(rig.dispatch).not.toHaveBeenCalled() - }) + expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) + // Nothing runs in the chat, so no paused row: it waits for the chat's next turn. + expect(await rig.queuePause()).toBeNull() + expect(await rig.submission(id)).toMatchObject({ + dispatchState: 'rejected', + ...HOST_RESTARTED, + origin: 'client', + source: { kind: 'user' } + }) + const [card] = journal().queuedMessages.list() + expect(card).toMatchObject({ + messageId: id, + holdReason: null, + hostInstance: structuredAgentSessionHostInstance(), + body: hostTestMessage('hello after restart'), + queuedAt: { epoch: journal().epoch, sequence: expect.any(Number) } + }) + // Nothing is sent on its own, however many times the chat reopens. + await rig.host.close(SESSION, 'evict') + rig.crashRestartHostProcess() + expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) + expect(rig.dispatch).not.toHaveBeenCalled() + } + ) it('quit writes nothing for it: no closed-chat rejection, still queued until the next open', async () => { const id = await acceptWhileStarting(sendRequest('kept through quit')) @@ -182,33 +188,32 @@ describe('a message accepted while the agent starts, then Orca stops', () => { }) }) -describe('only an action on the card releases a kept card', () => { - it('a later message is delivered alone; Resume sends nothing; its own Send sends it once', async () => { +// R13-1: a kept send waits in order, like every card the chat closed with, and follows the +// chat's next accepted turn; no "press Send" step. +describe('the next accepted turn releases a kept card', () => { + it('a later message runs first, then the card follows it in order', async () => { const id = await acceptWhileStarting(sendRequest('the kept words')) await quitRestart() - // The person types the same words again, as one who cannot see cards would. - const retyped = rig.send('the kept words') - await retyped.result + const next = rig.send('a new request') + await next.result await eventually(async () => - expect((await rig.submission(retyped.id))?.handedOverAt).toBeDefined() + expect((await rig.submission(next.id))?.handedOverAt).toBeDefined() ) - await rig.settleAccepted(retyped.id, 'retyped') - await new Promise((resolve) => setTimeout(resolve, 100)) - expect(dispatchedTexts()).toEqual(['the kept words']) - expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) - - await rig.resume() - await new Promise((resolve) => setTimeout(resolve, 100)) expect(await rig.handoff(id)).toBeUndefined() - expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) + await rig.settleAccepted(next.id, 'next') + await eventually(() => expect(dispatchedTexts()).toEqual(['a new request', 'the kept words'])) + expect(await rig.handoff(id)).toMatchObject({ origin: 'host' }) + }) - expect(await rig.sendNow(id)).toMatchObject({ ok: true }) - const handoff = await rig.handoff(id) - expect(handoff?.clientMessageId).not.toBe(id) - expect(handoff).toMatchObject({ origin: 'client' }) - await eventually(() => expect(dispatchedTexts()).toEqual(['the kept words', 'the kept words'])) - expect(await rig.drafts()).toEqual([]) + it('a Resume sends it, and nothing sends it before', async () => { + const id = await acceptWhileStarting(sendRequest('resume me')) + await quitRestart() + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(rig.dispatch).not.toHaveBeenCalled() + expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) + await eventually(() => expect(dispatchedTexts()).toEqual(['resume me'])) + expect(await rig.handoff(id)).toBeDefined() }) it('Send now sends it at once, and Delete removes it', async () => { @@ -273,7 +278,7 @@ describe('only an action on the card releases a kept card', () => { expect(await rig.drafts()).toEqual([]) }) - it('a Stop on the reopened chat leaves it kept: the open settles it before the Stop runs', async () => { + it('a Stop on the reopened chat leaves its card waiting: the open settles it before the Stop runs', async () => { const id = await acceptWhileStarting(sendRequest('survives a stop')) await rig.crashRestartHostProcess() @@ -283,26 +288,33 @@ describe('only an action on the card releases a kept card', () => { expect(await rig.submission(id)).toMatchObject({ dispatchState: 'rejected', ...HOST_RESTARTED }) }) - // A Send now cut short by a quit hands the card back kept, so a retyped copy still goes alone. - // After a second restart the kept card is another process's, yet it pauses nothing else. - it('a later restart leaves the cards queued after it unpaused', async () => { + // Another restart before any turn: the card still waits, and the first turn there releases it + // ahead of the cards queued behind that turn. + it('a second restart still holds it; the next turn sends it before the cards queued after', async () => { const id = await acceptWhileStarting(sendRequest('kept twice over')) await quitRestart() expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) rig.crashRestartHostProcess() + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(rig.dispatch).not.toHaveBeenCalled() const working = await rig.workingSend() const later = rig.send('queued after the restart', 'queue-if-active') expect(await later.result).toMatchObject({ ok: true, value: { queued: { state: 'waiting' } } }) expect(await rig.queuePause()).toBeNull() await rig.settleAccepted(working, 'working') + await eventually(() => expect(dispatchedTexts()).toEqual(['work on this', 'kept twice over'])) + await rig.settleAccepted(await rig.handoffId(id), 'kept') await eventually(() => - expect(dispatchedTexts()).toEqual(['work on this', 'queued after the restart']) + expect(dispatchedTexts()).toEqual([ + 'work on this', + 'kept twice over', + 'queued after the restart' + ]) ) - expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) }) - it('stays kept when its own Send is cut short by another quit', async () => { + it('its own Send cut short by another quit brings it back at the head, waiting', async () => { const id = await acceptWhileStarting(sendRequest('the kept words')) await quitRestart() expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) @@ -321,16 +333,9 @@ describe('only an action on the card releases a kept card', () => { await quitRestart() expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) - expect(journal().queuedMessages.get(id)?.holdReason).toBe(QUEUED_MESSAGE_PAUSED_KEPT) - const retyped = rig.send('the kept words') - await retyped.result - await eventually(async () => - expect((await rig.submission(retyped.id))?.handedOverAt).toBeDefined() - ) - await rig.settleAccepted(retyped.id, 'retyped') + expect(journal().queuedMessages.get(id)?.holdReason).toBeNull() await new Promise((resolve) => setTimeout(resolve, 100)) - expect(dispatchedTexts()).toEqual(['the kept words']) - expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) + expect(rig.dispatch).not.toHaveBeenCalled() }) }) @@ -437,8 +442,8 @@ describe('the queue at a quit', () => { } ) - // The card the person pushed ahead waits for their own Send; Resume sends the rest. - it('Send now on an ordinary card, cut short by a quit, returns it kept while Resume sends the rest', async () => { + // The card the person pushed ahead comes back at the head; Resume sends it, then the rest. + it('Send now on an ordinary card, cut short by a quit, returns it to the head; Resume sends both in order', async () => { await compactRunning() const first = rig.send('first queued', 'queue-if-active') await first.result @@ -455,9 +460,9 @@ describe('the queue at a quit', () => { ]) expect(await rig.queuePause()).toBeNull() await rig.resume() - await eventually(() => expect(dispatchedTexts()).toEqual(['first queued'])) - await new Promise((resolve) => setTimeout(resolve, 100)) - expect(await rig.drafts()).toEqual([{ messageId: pushed.id, ...KEPT }]) + await eventually(() => expect(dispatchedTexts()).toEqual(['pushed ahead'])) + await rig.settleAccepted(await rig.handoffId(pushed.id), 'pushed') + await eventually(() => expect(dispatchedTexts()).toEqual(['pushed ahead', 'first queued'])) }) }) @@ -465,7 +470,7 @@ describe('the queue at a quit', () => { // chat, and the chat can be reopened from its history. describe('a message accepted while the agent starts, then the chat closes', () => { it.each(['user-close', 'evict'] as const)( - 'after a %s it is a held card the reopened chat shows, rejected as closed', + 'after a %s it is a waiting card the reopened chat shows, rejected as closed', async (cause) => { const id = await acceptWhileStarting(sendRequest('kept at close')) await closeWhileStarting(cause) @@ -488,9 +493,9 @@ describe('a message accepted while the agent starts, then the chat closes', () = .join('|') expect(texts).not.toContain('kept at close') expect(page.page.queuedMessages?.[0]).toMatchObject({ - body: hostTestMessage('kept at close'), - pausedReason: QUEUED_MESSAGE_PAUSED_KEPT + body: hostTestMessage('kept at close') }) + expect(page.page.queuedMessages?.[0]).not.toHaveProperty('paused') expect(rig.dispatch).not.toHaveBeenCalled() } ) @@ -499,7 +504,7 @@ describe('a message accepted while the agent starts, then the chat closes', () = // The desktop's outbox keeps an unconfirmed send and sends it again, under the same id, once the // app is back. That replay meets the card the open made from the same send. describe('the same send arriving again after the restart', () => { - it('a client that never asked to queue is answered from its own record: one card, still held, nothing sent', async () => { + it('a client that never asked to queue is answered from its own record: one card, still waiting, nothing sent', async () => { const request = sendRequest('sent again by the outbox') const id = await acceptWhileStarting(request) await quitRestart() @@ -521,16 +526,17 @@ describe('the same send arriving again after the restart', () => { await new Promise((resolve) => setTimeout(resolve, 100)) expect(rig.dispatch).not.toHaveBeenCalled() - // Retry on such a client sends the words under a new id: delivered once, the card still held. + // Retry on such a client sends the words under a new id, and the card follows that turn, as + // every card the chat closed with does: the same words twice, by the one rule. const retried = rig.send('sent again by the outbox') await retried.result await eventually(async () => expect((await rig.submission(retried.id))?.handedOverAt).toBeDefined() ) await rig.settleAccepted(retried.id, 'retried') - await new Promise((resolve) => setTimeout(resolve, 100)) - expect(dispatchedTexts()).toEqual(['sent again by the outbox']) - expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) + await eventually(() => + expect(dispatchedTexts()).toEqual(['sent again by the outbox', 'sent again by the outbox']) + ) }) it('a client that asked to queue is told the host holds it', async () => { @@ -624,8 +630,8 @@ describe('what is not kept', () => { }) }) -describe('/clear carries a kept card still held', () => { - it('a turn in the new conversation never releases it', async () => { +describe('/clear carries a kept card', () => { + it('the first turn in the new conversation releases it, after that turn', async () => { const id = await acceptWhileStarting(sendRequest('carried through clear')) await quitRestart() expect(await rig.drafts()).toHaveLength(1) @@ -639,8 +645,8 @@ describe('/clear carries a kept card still held', () => { throw new Error('expected a replacement session') } expect(await rig.drafts(replacementId)).toEqual([{ messageId: id, ...KEPT }]) - const carried = rig.host.collaboratorsForTests().sessions.get(replacementId)?.journal - expect(carried?.queuedMessages.list()[0]?.holdReason).toBe(QUEUED_MESSAGE_PAUSED_KEPT) + // Nothing runs in the fresh conversation, so no paused row. + expect(await rig.queuePause(replacementId)).toBeNull() const body = hostTestMessage('first turn after the clear') const turn = await rig.host.send(QUEUED_RIG_CALLER, { @@ -670,9 +676,131 @@ describe('/clear carries a kept card still held', () => { clientMessageId: turnId, providerIdentity: { provider: 'codex', threadId: 'thread-1', turnId: 'turn-x', ordinal: 0 } }) + await eventually(() => + expect(dispatchedTexts()).toEqual(['first turn after the clear', 'carried through clear']) + ) + }) +}) + +// One rule for a chat that was not running: a card queued while it ran drains as its turn ends, +// and a card it closed with waits for its next accepted turn, written down by the reopen's mark. +describe("the reopen's mark", () => { + it('a card queued mid-turn drains when that turn ends, with no reopen and no mark', async () => { + const marks = vi.spyOn(AgentSessionJournal.prototype, 'appendQueueReopen') + const working = await rig.workingSend() + const queued = rig.send('queued mid-turn', 'queue-if-active') + expect(await queued.result).toMatchObject({ ok: true, value: { queued: { state: 'waiting' } } }) + await rig.settleAccepted(working, 'working') + await eventually(() => expect(dispatchedTexts()).toEqual(['work on this', 'queued mid-turn'])) + expect(marks).not.toHaveBeenCalled() + }) + + it.each(['user-close', 'evict'] as const)( + 'a %s and a reopen in the same process: the card waits for the next turn', + async (cause) => { + const working = await rig.workingSend() + const queued = rig.send('queued before the close', 'queue-if-active') + await queued.result + await rig.host.close(SESSION, cause) + expect(await rig.drafts()).toEqual([{ messageId: queued.id, state: 'waiting' }]) + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(await rig.handoff(queued.id)).toBeUndefined() + expect(dispatchedTexts()).toEqual(['work on this']) + void working + + const next = rig.send('after the reopen') + await next.result + await eventually(async () => + expect((await rig.submission(next.id))?.handedOverAt).toBeDefined() + ) + await rig.settleAccepted(next.id, 'next') + await eventually(() => + expect(dispatchedTexts()).toEqual([ + 'work on this', + 'after the reopen', + 'queued before the close' + ]) + ) + } + ) + + // An idle eviction is Orca's own act: the person sees the same rows, and nothing new is held. + it('an idle-sweep eviction of a stopped chat writes nothing, and its Stop row stays', async () => { + await rig.dispose() + rig = await createQueuedMessageTestRig({ idleSweep: { idleMs: 0, intervalMs: 60 * 60 * 1000 } }) + const marks = vi.spyOn(AgentSessionJournal.prototype, 'appendQueueReopen') + const working = await rig.workingSend() + const held = rig.send('held by the Stop', 'queue-if-active') + await held.result + expect(await rig.stop()).toMatchObject({ ok: true }) + await rig.settleAccepted(working, 'stopped') + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + + await rig.host.collaboratorsForTests().lifetime.idleSweep.tick() + + expect(rig.closeSession).toHaveBeenCalledTimes(1) + // A chat with cards waiting keeps its handle, so no reopen can mark them. + expect(rig.host.collaboratorsForTests().sessions.has(SESSION)).toBe(true) + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + expect(await rig.handoff(held.id)).toBeUndefined() + expect(marks).not.toHaveBeenCalled() + // Kept only while a card waits: once the last one leaves, the next sweep drops it as usual. + expect(await rig.deleteQueued(held.id)).toMatchObject({ ok: true, value: { deleted: true } }) + await rig.host.collaboratorsForTests().lifetime.idleSweep.tick() + expect(rig.host.collaboratorsForTests().sessions.has(SESSION)).toBe(false) + }) + + // The startup restore opens a visible chat through the same open as any reader. + it('a chat the startup restore reopens after a crash holds its cards', async () => { + const working = await rig.workingSend() + const queued = rig.send('queued behind work', 'queue-if-active') + await queued.result + void working + const sent = rig.dispatch.mock.calls.length + rig.crashRestartHostProcess() + await rig.host.restoreReadableSessions([SESSION]) + expect(rig.host.collaboratorsForTests().sessions.has(SESSION)).toBe(true) await new Promise((resolve) => setTimeout(resolve, 100)) - expect(await rig.drafts(replacementId)).toEqual([{ messageId: id, ...KEPT }]) - expect(dispatchedTexts()).toEqual(['first turn after the clear']) + expect(await rig.handoff(queued.id)).toBeUndefined() + expect(rig.dispatch).toHaveBeenCalledTimes(sent) + expect(structuredQueuePauses(journal()).map((pause) => pause.reason)).toEqual(['restarted']) + }) + + it('an idle chat with no waiting card is closed and reopened without writing anything', async () => { + const marks = vi.spyOn(AgentSessionJournal.prototype, 'appendQueueReopen') + const working = await rig.workingSend() + await rig.settleAccepted(working, 'working') + const rows = (await rig.host.journalSnapshot(SESSION)).cursor + await rig.host.close(SESSION, 'evict') + expect(await rig.drafts()).toEqual([]) + expect((await rig.host.journalSnapshot(SESSION)).cursor).toEqual(rows) + expect(marks).not.toHaveBeenCalled() + }) + + // Bookkeeping never gates the person: the open goes on, and the open itself is where the pause + // begins, so nothing sends by itself; the next turn releases the card, and the next open marks. + it('an open whose mark cannot be written still opens, and still holds the card', async () => { + const id = await acceptWhileStarting(sendRequest('kept, mark failed')) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const marks = vi + .spyOn(AgentSessionJournal.prototype, 'appendQueueReopen') + .mockRejectedValueOnce(new Error('disk full')) + await quitRestart() + + expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) + expect(marks).toHaveBeenCalledOnce() + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(rig.dispatch).not.toHaveBeenCalled() + + const next = rig.send('a new request') + await next.result + await eventually(async () => + expect((await rig.submission(next.id))?.handedOverAt).toBeDefined() + ) + await rig.settleAccepted(next.id, 'next') + await eventually(() => + expect(dispatchedTexts()).toEqual(['a new request', 'kept, mark failed']) + ) }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx index d9dad7cb8291..d30d41e3559c 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx @@ -21,10 +21,7 @@ import { structuredAgentSessionAttemptFailureParts } from '../../../../shared/st import { classifyDispatchRejection } from '../../../../shared/structured-agent-session-dispatch-rejection' import { readWholeAgentSessionFailureFact } from '../../../../shared/agent-session-failure' import { agentSessionWriteNoticeText } from './agent-session-write-notice-text' -import { - QUEUED_MESSAGE_PAUSED_KEPT, - QUEUED_MESSAGE_PAUSED_SEND_FAILED -} from '../../../../shared/agent-session-wire' +import { QUEUED_MESSAGE_PAUSED_SEND_FAILED } from '../../../../shared/agent-session-wire' import { isMacPlatform } from './native-chat-shortcut' import { queuedMessageCardSteers, @@ -67,12 +64,6 @@ export function queuedMessageCardCaption(card: QueuedMessageCard): string | null "Couldn't send — press Send to retry." ) } - if (card.pausedReason === QUEUED_MESSAGE_PAUSED_KEPT) { - return translate( - 'components.native-chat.queuedMessages.pausedKept', - 'Not sent yet — press Send to send it.' - ) - } return translate('components.native-chat.queuedMessages.paused', 'Paused') case 'behind-returned': return translate( diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx index 65150b38c631..fc43152b8a77 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx @@ -305,7 +305,6 @@ describe('NativeChatQueuedMessageList', () => { it.each([ ['stopped', 'Queue paused because you interrupted'], - ['cleared', 'Queue paused after you cleared the conversation'], ['some-newer-reason', 'Queue paused'] ])( "a queue the host holds ('%s') shows one header row above the cards, with Resume; the held card still reads Steer", @@ -526,22 +525,6 @@ describe('NativeChatQueuedMessageList', () => { expect(screen.getAllByRole('button', { name: 'Send' }).length).toBeGreaterThan(0) }) - // A message the host kept after a restart or a close is not a mid-turn steer: on an idle chat - // its action is plainly Send, and its caption says it was never sent. - it('a kept card says it was not sent yet and offers Send, with no Resume over it', async () => { - renderList( - // The controller names no pause over a card held on its own (`queuedMessagesQueuePause`). - controller([card({ messageId: 'held', hold: 'paused', pausedReason: 'kept' })], null) - ) - const [row] = screen.getAllByRole('listitem') - expect(row?.textContent).toContain('Not sent yet — press Send to send it.') - expect(row?.textContent).not.toContain('kept') - fireEvent.focus(screen.getByRole('button', { name: 'Send' })) - expect((await screen.findAllByText('Send this message now')).length).toBeGreaterThan(0) - expect(screen.queryByRole('button', { name: 'Steer' })).toBeNull() - expect(screen.queryByRole('button', { name: 'Resume' })).toBeNull() - }) - it('an absent or unknown pause marker reads as a plain pause, never raw', () => { renderList( controller([ @@ -581,11 +564,7 @@ describe('NativeChatQueuedMessageList', () => { ])( 'offers Turn off queueing and the send chord only when the host queues sends ($queueCapable)', async ({ queueCapable, offered }) => { - const owner = controller( - [card({ messageId: 'kept', hold: 'paused', pausedReason: 'kept' })], - null, - queueCapable - ) + const owner = controller([card({ messageId: 'kept', hold: 'paused' })], null, queueCapable) renderList(owner) fireEvent.focus(screen.getByRole('button', { name: 'Send' })) const hint = await screen.findAllByText('Send this message now') diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx index 460b15c886a0..ea5b454f9974 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx @@ -88,11 +88,6 @@ function queuePauseText(pause: { reason: string }): string { 'components.native-chat.queuedMessages.queuePausedStopped', 'Queue paused because you interrupted' ) - case 'cleared': - return translate( - 'components.native-chat.queuedMessages.queuePausedCleared', - 'Queue paused after you cleared the conversation' - ) default: return translate('components.native-chat.queuedMessages.queuePaused', 'Queue paused') } diff --git a/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts index 28df16cb67b0..78fe77119989 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts @@ -176,14 +176,10 @@ describe('queued message cards', () => { const project = (messages: AgentSessionQueuedMessage[], queuePaused = true) => projectQueuedMessageCards(messages, [], { hasPendingPrompt: false, queuePaused }) expect(queuedMessagesQueuePause(project([draft('held', 1)]), stopped)).toEqual(stopped) - expect(queuedMessagesQueuePause(project([draft('held', 1)]), { reason: 'cleared' })).toEqual({ - reason: 'cleared' - }) const unsendable = [ draft('returned', 1, { state: 'returned', returnedReason: null }), draft('behind', 2), - draft('failed', 3, { paused: true, pausedReason: 'send_failed' }), - draft('kept', 4, { paused: true, pausedReason: 'kept' }) + draft('failed', 3, { paused: true, pausedReason: 'send_failed' }) ] expect(queuedMessagesQueuePause(project(unsendable), stopped)).toBeNull() // No published pause (an idle chat after a restart): plain cards, no header. @@ -192,13 +188,13 @@ describe('queued message cards', () => { expect(queuedMessagesQueuePause(unpaused, null)).toBeNull() }) - // A kept card is held on its own, like a failed one: the host sends the cards behind it. - it('a card behind a kept or a send_failed card is not held by it', () => { + // A card held on its own (a failed send, or a newer host's hold) holds nothing behind it. + it('a card behind a card held on its own is not held by it', () => { const cards = projectQueuedMessageCards( [ draft('failed', 1, { paused: true, pausedReason: 'send_failed' }), draft('after-failed', 2), - draft('kept', 3, { paused: true, pausedReason: 'kept' }), + draft('newer-hold', 3, { paused: true }), draft('behind', 4) ], [], diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 8dceef411720..fc97de96510c 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18548,10 +18548,8 @@ "editAlreadySent": "Already sent — your text is still in the composer.", "withdrawnHold": "Stopped before it was sent", "pausedSendFailed": "Couldn't send — press Send to retry.", - "pausedKept": "Not sent yet — press Send to send it.", "paused": "Paused", "queuePausedStopped": "Queue paused because you interrupted", - "queuePausedCleared": "Queue paused after you cleared the conversation", "queuePaused": "Queue paused", "resume": "Resume", "sendConfirmTitle": "Send message?", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 720249792888..d9cafabd60dd 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -17987,10 +17987,8 @@ "turnOffQueueing": "Desactivar la cola", "withdrawnHold": "Detenido antes de enviarse", "pausedSendFailed": "No se pudo enviar. Pulsa Enviar para reintentar.", - "pausedKept": "Aún no se ha enviado. Pulsa Enviar para enviarlo.", "paused": "En pausa", "queuePausedStopped": "Cola en pausa porque interrumpiste", - "queuePausedCleared": "Cola en pausa después de que borraste la conversación", "queuePaused": "Cola en pausa", "resume": "Reanudar", "sendConfirmTitle": "¿Enviar mensaje?", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 8e1c7b8e2602..9999b2f99758 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -18220,10 +18220,8 @@ "turnOffQueueing": "Désactiver la file d'attente", "withdrawnHold": "Arrêté avant l'envoi", "pausedSendFailed": "Envoi impossible. Appuyez sur Envoyer pour réessayer.", - "pausedKept": "Pas encore envoyé. Appuyez sur Envoyer pour l'envoyer.", "paused": "En pause", "queuePausedStopped": "File d'attente en pause, car vous avez interrompu", - "queuePausedCleared": "File d'attente en pause après l'effacement de la conversation", "queuePaused": "File d'attente en pause", "resume": "Reprendre", "sendConfirmTitle": "Envoyer le message ?", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index b0bf44ffd36a..ca313e7942ba 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -18154,10 +18154,8 @@ "turnOffQueueing": "キューへの追加をオフにする", "withdrawnHold": "送信前に停止されました", "pausedSendFailed": "送信できませんでした。「送信」を押して再試行してください。", - "pausedKept": "まだ送信されていません。「送信」を押すと送信されます。", "paused": "一時停止中", "queuePausedStopped": "中断したため、キューを一時停止しました", - "queuePausedCleared": "会話を消去したため、キューを一時停止しました", "queuePaused": "キューは一時停止中です", "resume": "再開", "sendConfirmTitle": "メッセージを送信しますか?", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index eb6834be3d03..49daef9ca7c8 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -18154,10 +18154,8 @@ "turnOffQueueing": "대기열 사용 안 함", "withdrawnHold": "전송 전에 중지됨", "pausedSendFailed": "보낼 수 없습니다. 보내기를 눌러 다시 시도하세요.", - "pausedKept": "아직 보내지 않았습니다. 보내기를 눌러 보내세요.", "paused": "일시 중지됨", "queuePausedStopped": "중단했기 때문에 대기열이 일시 중지되었습니다", - "queuePausedCleared": "대화를 지워서 대기열이 일시 중지되었습니다", "queuePaused": "대기열이 일시 중지되었습니다", "resume": "재개", "sendConfirmTitle": "메시지를 보낼까요?", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 5c130bd2b631..b19f3a7a1013 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -18119,10 +18119,8 @@ "turnOffQueueing": "关闭排队", "withdrawnHold": "在发送前已停止", "pausedSendFailed": "无法发送。点按发送以重试。", - "pausedKept": "尚未发送。点按发送即可发送。", "paused": "已暂停", "queuePausedStopped": "因你中断了操作,队列已暂停", - "queuePausedCleared": "清除对话后,队列已暂停", "queuePaused": "队列已暂停", "resume": "继续", "sendConfirmTitle": "发送消息?", diff --git a/src/shared/agent-session-queued-message-wire.ts b/src/shared/agent-session-queued-message-wire.ts index a1df60b85f6f..d10d263aa024 100644 --- a/src/shared/agent-session-queued-message-wire.ts +++ b/src/shared/agent-session-queued-message-wire.ts @@ -7,21 +7,15 @@ import type { AgentJournalMessageItem } from './agent-session-journal-types' /** The draft could not be converted into a send; an explicit Send retries it. */ export const QUEUED_MESSAGE_PAUSED_SEND_FAILED = 'send_failed' as const -/** A person's message the host accepted and never handed over before a restart or a close of the - * chat. It waits for the person's own Send; the cards behind it still send, as past a failed one. */ -export const QUEUED_MESSAGE_PAUSED_KEPT = 'kept' as const +export type AgentSessionQueuedMessagePausedReason = typeof QUEUED_MESSAGE_PAUSED_SEND_FAILED -export type AgentSessionQueuedMessagePausedReason = - | typeof QUEUED_MESSAGE_PAUSED_SEND_FAILED - | typeof QUEUED_MESSAGE_PAUSED_KEPT - -/** The whole queue is paused and sends nothing on its own: 'stopped' — the user - * interrupted ("Queue paused because you interrupted") — or 'cleared' — a /clear - * carried the cards into a fresh conversation. Resume (`agentSession.queuedMessagesResume`), - * or any turn starting, lifts it; Send-now on one card sends that card and leaves the - * rest paused until its turn starts. A client treats an unknown reason as a - * plain pause, so a newer host can add one. */ -export type AgentSessionQueuePause = { reason: 'stopped' | 'cleared' } +/** The whole queue is paused and sends nothing on its own: 'stopped' — the user interrupted + * ("Queue paused because you interrupted"). Resume (`agentSession.queuedMessagesResume`), or any + * turn starting, lifts it; Send-now on one card sends that card and leaves the rest paused until + * its turn starts. A /clear or a reopen also holds cards until the chat's next turn, but nothing + * runs then, so neither is published. A client treats an unknown reason as a plain pause, so a + * newer host can add one. */ +export type AgentSessionQueuePause = { reason: 'stopped' } /** What rides beside a frame's `queuedMessages`, published together with the list. */ export type AgentSessionQueuePublicationFields = { @@ -44,12 +38,11 @@ export type AgentSessionQueuedMessage = { position: number body: AgentJournalMessageItem state: 'waiting' | 'returned' - /** This one card is held, whatever the queue's pause: its conversion failed, or the host kept it. */ + /** This one card is held, whatever the queue's pause: its conversion failed. */ paused?: true - /** Why it is held, as a marker the client localizes: 'send_failed' ("couldn't - * send") or 'kept' (accepted, never sent); only an explicit Send releases either. A client - * must treat an unknown marker as a plain hold, so a newer host can add one. The queue-level - * pause is `queuePause`, published beside the list. */ + /** Why it is held, as a marker the client localizes: 'send_failed' ("couldn't send"); only an + * explicit Send releases it. A client must treat an unknown marker as a plain hold, so a newer + * host can add one. The queue-level pause is `queuePause`, published beside the list. */ pausedReason?: AgentSessionQueuedMessagePausedReason /** A returned card's refusal: the `reason` and `rejection` pair its submission settled with. * Only a failure returns a card; a draft a Stop or restart took back waits again. Clients classify it from `returnedRejection` (falling back to `returnedReason` when a host diff --git a/src/shared/structured-agent-session-coalescer.test.ts b/src/shared/structured-agent-session-coalescer.test.ts index 08dbf9ed1f98..5076e6023d14 100644 --- a/src/shared/structured-agent-session-coalescer.test.ts +++ b/src/shared/structured-agent-session-coalescer.test.ts @@ -115,10 +115,10 @@ describe('structured agent session event coalescer', () => { const events: AgentSessionSubscribeEvent[] = [] const coalescer = createStructuredAgentSessionEventCoalescer((event) => events.push(event)) - coalescer.push({ ...batch(1), queuedMessages: [], queuePause: { reason: 'cleared' } }) + coalescer.push({ ...batch(1), queuedMessages: [], queuePause: { reason: 'stopped' } }) coalescer.push(batch(2)) coalescer.flush() - expect(events[0]).toMatchObject({ queuedMessages: [], queuePause: { reason: 'cleared' } }) + expect(events[0]).toMatchObject({ queuedMessages: [], queuePause: { reason: 'stopped' } }) coalescer.push({ ...batch(3), queuedMessages: [], queuePause: null }) coalescer.push({ ...batch(4), queuedMessages: [], queuePause: { reason: 'stopped' } }) diff --git a/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts index ac18fe159869..2d440b4a6ac3 100644 --- a/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts +++ b/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts @@ -31,8 +31,8 @@ import { renderJournalState } from '../../../src/main/native-chat/agent-session-journal/journal-reducer' -// A release that knows neither the Stop event nor the Resume marker: an unknown row kind would -// make it delete the journal from that row on, so both ride a tombstone it already reads. +// A release that knows none of the Stop event, the Resume marker and the reopen mark: an unknown +// row kind would make it delete the journal from that row on, so each rides a tombstone it reads. const BASELINE_REF = 'v1.4.218' const JOURNAL = 'src/main/native-chat/agent-session-journal' // A main build that shares this one's host database and schema version, so a downgrade to it opens @@ -143,7 +143,7 @@ type OldReplay = { } // Both downgrade probes load real old builds, including cold extraction and transforms. -test("an older build keeps every row around a Stop's event and a Resume, and folds the rows after them", async () => { +test("an older build keeps every row around a Stop's event, a Resume and a reopen mark, and folds the rows after them", async () => { const directory = mkdtempSync(join(tmpdir(), 'orca-stop-event-downgrade-')) const journals = createTrackedJournalOpener() try { @@ -160,6 +160,7 @@ test("an older build keeps every row around a Stop's event and a Resume, and fol await journal.appendStopEvent({ reason: 'user-stop', turnId: 'turn-1', caller: 'client-1' }, 1) await journal.appendStopEvent({ reason: 'user-close', turnId: 'turn-1' }, 1) await journal.appendQueueResume(1) + await journal.appendQueueReopen(1) const afterMarks = journal.cursor() await append(1, 'after the Stop') const since = journal.readSince({ epoch: journal.epoch, sequence: 0 }) @@ -226,7 +227,7 @@ test("an older build keeps every row around a Stop's event and a Resume, and fol }) expect(projected.ok).toBe(true) expect(projected.batch?.items).toEqual([]) - expect(projected.batch?.removedItemIds).toHaveLength(2) + expect(projected.batch?.removedItemIds).toHaveLength(3) const liveIds = new Set(journal.snapshot().items.map((entry) => entry.itemId)) expect(projected.batch?.removedItemIds.some((id) => liveIds.has(id))).toBe(false) } finally { @@ -274,9 +275,7 @@ test("an older build opens this build's journal writable and appends to it; the await journal.appendStopEvent({ reason: 'user-stop', turnId: 'turn-1', caller: 'client-1' }, 1) await journal.appendItem(item(1), { kind: 'status', text: 'after the Stop' }, scope) const wrote = { cursor: journal.cursor(), items: itemIds(journal) } - expect(journal.queuedMessages.pauses('host-a').map((pause) => pause.reason)).toEqual([ - 'stopped' - ]) + expect(journal.queuedMessages.pauses().map((pause) => pause.reason)).toEqual(['stopped']) await journals.closeAll() const rowsBefore = storedRows(directory) @@ -303,9 +302,7 @@ test("an older build opens this build's journal writable and appends to it; the const upgraded = await journals.open({ identity: IDENTITY, stateDirectory: directory }) expect(upgraded.cursor().sequence).toBe(wrote.cursor.sequence + 1) expect(itemIds(upgraded)).toEqual([...wrote.items, 'codex:thread-1:turn-1:2']) - expect(upgraded.queuedMessages.pauses('host-a').map((pause) => pause.reason)).toEqual([ - 'stopped' - ]) + expect(upgraded.queuedMessages.pauses().map((pause) => pause.reason)).toEqual(['stopped']) } finally { await journals.closeAll() rmSync(directory, { recursive: true, force: true }) diff --git a/tests/e2e/cross-version-wire/kept-card-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/kept-card-downgrade.unit.test.ts index 24b495575fbd..f6ac7f11cb5f 100644 --- a/tests/e2e/cross-version-wire/kept-card-downgrade.unit.test.ts +++ b/tests/e2e/cross-version-wire/kept-card-downgrade.unit.test.ts @@ -11,19 +11,17 @@ import { claudeProviderHandle } from '../../../src/shared/agent-session-provider import { USER_MESSAGE_SOURCE } from '../../../src/shared/agent-session-message-source' import { agentSessionFailureFact } from '../../../src/shared/agent-session-failure' import { agentSessionFailureWords } from '../../../src/shared/agent-session-failure-words' -import { QUEUED_MESSAGE_PAUSED_KEPT } from '../../../src/shared/agent-session-queued-message-wire' import { createTrackedJournalOpener } from '../../../src/main/native-chat/agent-session-journal/journal-host-database-test-support' import { holdUnsentSends } from '../../../src/main/native-chat/agent-session-journal/journal-unsent-send-hold' import { importReleaseCheckoutModule, materializeReleaseCheckout } from './release-checkout' -// A kept send is an ordinary waiting card held by a new value in its existing `hold_reason` -// column, placed ahead of the queue: no new state or column. A build with the queue but without -// this change reads an unknown hold as a plain one: it must list the card first and never send it, -// even after a person's turn ends a restart's pause, and must still settle a send this build's quit -// left queued. One exception lives in that build's own code: its /clear carries every card over -// without its hold, so the replacement's next turn sends a carried kept card. The main commit this change branched from, which has the queue; move it to the -// newest release that has the queue and predates this change. A baseline holding this change tests -// no downgrade. +// A kept send is an ordinary waiting card placed ahead of the queue, and the reopen's mark is a +// tombstone carrier key: no new state, column or row kind. A build with the queue but without +// either holds a card another host process wrote under its own restart pause, so it must list the +// kept card first, never send it by itself, send it first once a turn there ends that pause, and +// still settle a send this build's quit left queued. The main commit the kept send branched from, +// which has the queue; move it to the newest release that has the queue and predates it. A +// baseline holding this change tests no downgrade. const BASELINE_REF = '5a56636f6679071d6ec68b851ef7932cd3222560' const JOURNAL = 'src/main/native-chat/agent-session-journal' const HOST_RESTARTED = agentSessionFailureWords(agentSessionFailureFact('hostRestarted'), { @@ -130,7 +128,7 @@ async function acceptPersonSend( }) } -test('an older build lists a kept card first and never sends it, even after a person’s turn', async () => { +test('an older build lists a kept card first, holds it, and sends it first once a turn there lifts its pause', async () => { const directory = mkdtempSync(join(tmpdir(), 'orca-kept-card-downgrade-')) const journals = createTrackedJournalOpener() try { @@ -149,6 +147,7 @@ test('an older build lists a kept card first and never sends it, even after a pe hostInstance: 'host-b', hold: { cause: 'hostRestarted' } }) + await reopened.markQueueReopen(0) expect(reopened.queuedMessages.list().map((card) => card.messageId)).toEqual([ 'kept', 'queued-card' @@ -165,25 +164,22 @@ test('an older build lists a kept card first and never sends it, even after a pe state, holdReason })) + expect(downgraded.repair).toEqual({ malformedRows: 0 }) expect(cards()).toEqual([ - { messageId: 'kept', state: 'waiting', holdReason: QUEUED_MESSAGE_PAUSED_KEPT }, + { messageId: 'kept', state: 'waiting', holdReason: null }, { messageId: 'queued-card', state: 'waiting', holdReason: null } ]) expect(downgraded.queuedMessages.list()[0]!.position).toBeLessThan(1) expect(downgraded.submission('kept')).toMatchObject({ dispatchState: 'rejected' }) - // A person's turn there adopts every card into its process, which ends the restart's - // pause; the kept card's hold survives it, so its drain never picks it. That build skips a - // held card as it skips a failed send's, so the card behind it is the one it sends. + // Written by another process, so that build's restart pause holds both: nothing sends. + const pauses = () => downgraded.queuedMessages.pauses('host-c') + expect(pauses().map((pause) => pause.reason)).toEqual(['restarted']) + expect(nextSendable(pauses(), downgraded.queuedMessages.list())).toBeNull() + // A person's turn there adopts every card into its process, which ends that pause; the + // kept card, first in the queue, is the one it sends first. expect(await downgraded.queuedMessages.adopt('host-c')).toBe(true) - expect(cards()[0]).toMatchObject({ - messageId: 'kept', - holdReason: QUEUED_MESSAGE_PAUSED_KEPT - }) - expect(downgraded.queuedMessages.pauses('host-c')).toEqual([]) - expect( - nextSendable(downgraded.queuedMessages.pauses('host-c'), downgraded.queuedMessages.list()) - ?.messageId - ).toBe('queued-card') + expect(pauses()).toEqual([]) + expect(nextSendable(pauses(), downgraded.queuedMessages.list())?.messageId).toBe('kept') } finally { await older.closeAll() } From 98f0193afb6445f46ba89312face5b93ab437f8f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 7 Oct 2026 11:11:35 -0700 Subject: [PATCH 020/114] feat(native-chat): show agent-written visuals inline in structured chats (#26103) * feat(native-chat): visual directive grammar and host read for a chat's visuals folder A shared grammar for the ::orca-visual{file="..." title="..."} reply line, the per-chat visuals folder location on the owning host, and the agentSession.readVisual runtime method that reads one visual with lexical and canonical containment, a 512 KiB bounded read and UTF-8 refusal. * feat(native-chat): render chat visuals inline and in the right sidebar Native-chat assistant replies render a ::orca-visual{...} line as the chat's HTML visual in an opaque, scripts-only sandboxed frame: CSP first, the host frame navigation guard registered before content runs, live theme without a reload, fitted height, links opened in the viewer's browser only from a real gesture, lazy mount, and one muted line when the visual cannot be shown. Open in sidebar shows the same frame in the right sidebar, widened while it is open and restored after. * fix(native-chat): visual CI fixes, shared height governor, live-turn streaming hold Registers agentSession.readVisual from the methods index so the structured method file stays under its line budget, replaces reflective reads with checked narrowing, moves the pure height governor to src/shared for mobile, and holds a half-written directive tail while the turn works (structured text rows carry no running state). * fix(native-chat): harden the visual read and link opening Re-checks after the open that the chat's visuals folder is still the real directory at Orca's path, reports unexpected filesystem faults by code without host paths, and lets one click in a visual open at most one page. * fix(native-chat): keep visual lines out of plain-text reply surfaces; review fixes One shared helper drops visual lines (outside fenced code) from reply text where it becomes plain text: the structured status summary that feeds the sidebar row, dashboard, notifications, phone rows and handoffs, and AI Vault reply previews. Review fixes: height also counts a pinned body's overflow, only the live frontier row holds a half-written visual line, the runaway-height stop needs the same step repeated, and any host refusal evicts the cached revision. * fix(native-chat): resolve the visuals folder without the removed journal-paths helper Main removed the per-chat journal paths and the journal database's state directory; the visuals folder keeps the same sha256 layout on its own and the read method uses the profile state directory the chat host is opened in. * fix(native-chat): review round 2 fixes; copy a reply without its visual lines Reply previews in Agent Session History drop visual lines per text part before lines are folded; the frame adds a body's overflow only when the body really overflows; fence tracking follows CommonMark closers and openers; the copy button copies a reply without visual lines; a coded read fault keeps its cause. * fix(native-chat): update the frame's theme ref after render; read the visuals folder pair at once * test(native-chat): declare agentSession.readVisual on the cross-version agent-session surface * fix(native-chat): copying a reply keeps its code blocks and indentation Removing visual lines now closes only the gap each removal leaves, instead of collapsing blank lines across the whole reply and trimming its indentation; the visuals folder is checked parent first again so a broken path answers the same way every time. --- .../ai-vault/session-scanner-accumulator.ts | 4 +- .../session-scanner-text-normalization.ts | 29 +- .../ai-vault/session-scanner-values.test.ts | 18 ++ .../native-chat-visual-file-read.test.ts | 204 ++++++++++++++ .../native-chat-visual-file-read.ts | 223 +++++++++++++++ .../native-chat/native-chat-visuals-folder.ts | 17 ++ src/main/runtime/rpc/methods/index.ts | 2 + ...ructured-agent-session-rpc.test-fixture.ts | 14 +- .../structured-agent-session-visual.test.ts | 149 ++++++++++ .../structured-agent-session-visual.ts | 42 +++ .../host-frame-navigation-guard.test.ts} | 25 +- .../host-frame-navigation-guard.ts} | 34 ++- .../main-window-webview-security.test.ts | 4 +- .../window/main-window-webview-security.ts | 8 +- .../native-chat/NativeChatInlineVisual.tsx | 111 ++++++++ .../native-chat/NativeChatMarkdown.tsx | 26 +- .../NativeChatMarkdown.visual.test.tsx | 155 +++++++++++ .../native-chat/NativeChatMessageRow.test.tsx | 31 +++ .../native-chat/NativeChatMessageRow.tsx | 8 + .../NativeChatTranscriptChrome.tsx | 9 +- .../components/native-chat/NativeChatView.tsx | 10 +- .../NativeChatVisualFrame.test.tsx | 176 ++++++++++++ .../native-chat/NativeChatVisualFrame.tsx | 169 ++++++++++++ .../native-chat/NativeChatVisualPanel.tsx | 80 ++++++ .../native-chat-visual-markdown-extension.tsx | 79 ++++++ ...ative-chat-visual-markdown-syntax.test.tsx | 135 +++++++++ .../native-chat-visual-markdown-syntax.ts | 160 +++++++++++ .../native-chat/native-chat-visual-owner.tsx | 53 ++++ .../native-chat-visual-read-client.test.ts | 151 ++++++++++ .../native-chat-visual-read-client.ts | 158 +++++++++++ .../use-native-chat-visual-document.ts | 99 +++++++ .../use-native-chat-visual-theme.ts | 89 ++++++ .../src/components/right-sidebar/index.tsx | 18 +- .../right-sidebar-panel-content.tsx | 19 +- .../right-sidebar/right-sidebar-width.ts | 8 + .../components/sidebar/CommentMarkdown.tsx | 45 ++- src/renderer/src/i18n/locales/en.json | 4 + .../editor/actions/right-sidebar-state.ts | 77 +++++- .../right-sidebar-visual-state.test.ts | 96 +++++++ .../native-chat-visual-directive.test.ts | 221 +++++++++++++++ src/shared/native-chat-visual-directive.ts | 218 +++++++++++++++ ...native-chat-visual-height-governor.test.ts | 65 +++++ .../native-chat-visual-height-governor.ts | 69 +++++ src/shared/native-chat-visual-shell.test.ts | 161 +++++++++++ src/shared/native-chat-visual-shell.ts | 261 ++++++++++++++++++ .../agent-session-visual-params.ts | 42 +++ .../rpc-params-catalog.generated.ts | 2 + ...tured-agent-session-latest-request.test.ts | 34 +++ ...structured-agent-session-latest-request.ts | 4 +- ...ructured-agent-session-surface-manifest.ts | 10 + 50 files changed, 3775 insertions(+), 51 deletions(-) create mode 100644 src/main/native-chat/native-chat-visual-file-read.test.ts create mode 100644 src/main/native-chat/native-chat-visual-file-read.ts create mode 100644 src/main/native-chat/native-chat-visuals-folder.ts create mode 100644 src/main/runtime/rpc/methods/structured-agent-session-visual.test.ts create mode 100644 src/main/runtime/rpc/methods/structured-agent-session-visual.ts rename src/main/{plugins/plugin-panel-navigation-guard.test.ts => window/host-frame-navigation-guard.test.ts} (51%) rename src/main/{plugins/plugin-panel-navigation-guard.ts => window/host-frame-navigation-guard.ts} (62%) create mode 100644 src/renderer/src/components/native-chat/NativeChatInlineVisual.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatMarkdown.visual.test.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatVisualFrame.test.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatVisualFrame.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatVisualPanel.tsx create mode 100644 src/renderer/src/components/native-chat/native-chat-visual-markdown-extension.tsx create mode 100644 src/renderer/src/components/native-chat/native-chat-visual-markdown-syntax.test.tsx create mode 100644 src/renderer/src/components/native-chat/native-chat-visual-markdown-syntax.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-visual-owner.tsx create mode 100644 src/renderer/src/components/native-chat/native-chat-visual-read-client.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-visual-read-client.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-visual-document.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-visual-theme.ts create mode 100644 src/renderer/src/store/slices/editor/actions/right-sidebar-visual-state.test.ts create mode 100644 src/shared/native-chat-visual-directive.test.ts create mode 100644 src/shared/native-chat-visual-directive.ts create mode 100644 src/shared/native-chat-visual-height-governor.test.ts create mode 100644 src/shared/native-chat-visual-height-governor.ts create mode 100644 src/shared/native-chat-visual-shell.test.ts create mode 100644 src/shared/native-chat-visual-shell.ts create mode 100644 src/shared/rpc-contract/agent-session-visual-params.ts diff --git a/src/main/ai-vault/session-scanner-accumulator.ts b/src/main/ai-vault/session-scanner-accumulator.ts index cb664b41581c..357816cb9b44 100644 --- a/src/main/ai-vault/session-scanner-accumulator.ts +++ b/src/main/ai-vault/session-scanner-accumulator.ts @@ -230,7 +230,7 @@ export function addPreviewMessage( () => (args.text ? normalizeFullFirstUserPromptText(args.text) : null), args.seedFirstUserPrompt ) - const text = normalizePreviewText(args.text ?? '') + const text = normalizePreviewText(args.text ?? '', args.role) if (!text) { return } @@ -267,7 +267,7 @@ export function addPreviewContent( } addPreviewMessage(accumulator, { role, - text: extractPreviewContentText(content), + text: extractPreviewContentText(content, role), timestamp, // Content path already seeded above when capture is enabled. seedFirstUserPrompt: false, diff --git a/src/main/ai-vault/session-scanner-text-normalization.ts b/src/main/ai-vault/session-scanner-text-normalization.ts index 50fc54ab7aaa..9ad459575f66 100644 --- a/src/main/ai-vault/session-scanner-text-normalization.ts +++ b/src/main/ai-vault/session-scanner-text-normalization.ts @@ -1,4 +1,5 @@ import { sliceAtCodeUnitLimit } from '../../shared/surrogate-safe-text-slice' +import { withoutNativeChatVisualDirectiveLines } from '../../shared/native-chat-visual-directive' export { sliceAtCodeUnitLimit } @@ -38,17 +39,31 @@ export function normalizeTitleText(value: string): string | null { return finalizeNormalizedText(normalizeStringText(value, SESSION_TITLE_TEXT_LIMIT)) } -export function extractPreviewContentText(value: unknown): string | null { - return normalizeContentText(value, SESSION_PREVIEW_TEXT_LIMIT) +/** + * `role` 'assistant': a reply's visual lines show only in a chat transcript, so a preview drops + * them, per text part and before lines are folded into one. + */ +export function extractPreviewContentText(value: unknown, role?: string): string | null { + return normalizeContentText(value, SESSION_PREVIEW_TEXT_LIMIT, role === 'assistant') } -export function normalizePreviewText(value: string): string | null { - return finalizeNormalizedText(normalizeStringText(value, SESSION_PREVIEW_TEXT_LIMIT)) +export function normalizePreviewText(value: string, role?: string): string | null { + return finalizeNormalizedText( + normalizeStringText(previewSource(value, role === 'assistant'), SESSION_PREVIEW_TEXT_LIMIT) + ) +} + +function previewSource(text: string, dropVisualLines: boolean): string { + return dropVisualLines ? withoutNativeChatVisualDirectiveLines(text) : text } -function normalizeContentText(value: unknown, limit: number): string | null { +function normalizeContentText( + value: unknown, + limit: number, + dropVisualLines = false +): string | null { if (typeof value === 'string') { - return finalizeNormalizedText(normalizeStringText(value, limit)) + return finalizeNormalizedText(normalizeStringText(previewSource(value, dropVisualLines), limit)) } if (!Array.isArray(value)) { return null @@ -61,7 +76,7 @@ function normalizeContentText(value: unknown, limit: number): string | null { continue } appendInterPartSpace(builder) - appendNormalizedString(builder, text) + appendNormalizedString(builder, previewSource(text, dropVisualLines)) if (builder.truncated) { break } diff --git a/src/main/ai-vault/session-scanner-values.test.ts b/src/main/ai-vault/session-scanner-values.test.ts index 10f6195b07c2..8fce0a6c49bf 100644 --- a/src/main/ai-vault/session-scanner-values.test.ts +++ b/src/main/ai-vault/session-scanner-values.test.ts @@ -24,6 +24,24 @@ describe('AI Vault session scanner text values', () => { expect(normalizeTitleText('Use this repo guidance')).toBeNull() }) + it("drops a reply's visual lines from its preview, but not a user's", () => { + const line = '::orca-visual{file="latency.html" title="p95"}' + expect(normalizePreviewText(`p95 is highest.\n\n${line}\n\nDone.`, 'assistant')).toBe( + 'p95 is highest. Done.' + ) + expect(normalizePreviewText(line, 'assistant')).toBeNull() + expect(normalizePreviewText(line, 'user')).toBe(line) + // Provider content (Claude/Codex message parts) folds lines; the visual line goes first. + expect( + extractPreviewContentText( + [{ type: 'text', text: `Here it is.\n\n${line}\n\nDone.` }], + 'assistant' + ) + ).toBe('Here it is. Done.') + expect(extractPreviewContentText(`Here it is.\n${line}`, 'assistant')).toBe('Here it is.') + expect(extractPreviewContentText([{ type: 'text', text: line }], 'user')).toBe(line) + }) + it('folds large preview text directly without full-string replacement', () => { const replaceSpy = vi.spyOn(String.prototype, 'replace') const hiddenContext = `${'SECRET\n'.repeat(10_000)}` diff --git a/src/main/native-chat/native-chat-visual-file-read.test.ts b/src/main/native-chat/native-chat-visual-file-read.test.ts new file mode 100644 index 000000000000..c28798e88b5f --- /dev/null +++ b/src/main/native-chat/native-chat-visual-file-read.test.ts @@ -0,0 +1,204 @@ +import { execFileSync } from 'node:child_process' +import { chmod, mkdir, mkdtemp, rm, symlink, writeFile, link } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { NATIVE_CHAT_VISUAL_MAX_BYTES } from '../../shared/native-chat-visual-directive' +import { nativeChatVisualsFolderFor } from './native-chat-visuals-folder' +import { nativeChatVisualRevision, readNativeChatVisualFile } from './native-chat-visual-file-read' + +const posixIt = process.platform === 'win32' ? it.skip : it + +let stateDirectory: string +let folder: string + +beforeEach(async () => { + stateDirectory = await mkdtemp(join(tmpdir(), 'orca-visual-read-')) + folder = nativeChatVisualsFolderFor(stateDirectory, 'session-alpha') + await mkdir(folder, { recursive: true }) +}) + +afterEach(async () => { + await rm(stateDirectory, { recursive: true, force: true }) +}) + +describe('nativeChatVisualsFolderFor', () => { + it('places each chat in its own hashed folder under the state directory', () => { + const alpha = nativeChatVisualsFolderFor('/state', 'session-alpha') + const beta = nativeChatVisualsFolderFor('/state', 'session-beta') + expect(alpha).toMatch(/native-chat-visuals[\\/][0-9a-f]{32}$/) + expect(alpha.startsWith(join('/state', 'native-chat-visuals'))).toBe(true) + expect(alpha).not.toBe(beta) + }) +}) + +describe('readNativeChatVisualFile', () => { + it('reads a UTF-8 visual with its revision and size', async () => { + const html = '

Grüße

' + await writeFile(join(folder, 'chart.html'), html) + const result = await readNativeChatVisualFile(folder, 'chart.html') + expect(result).toEqual({ + ok: true, + html, + revision: nativeChatVisualRevision(Buffer.from(html)), + sizeBytes: Buffer.byteLength(html) + }) + }) + + it('answers unchanged without the bytes when the client already holds the revision', async () => { + await writeFile(join(folder, 'chart.html'), '

a

') + const first = await readNativeChatVisualFile(folder, 'chart.html') + if (!first.ok) { + throw new Error('expected a read') + } + const again = await readNativeChatVisualFile(folder, 'chart.html', first.revision) + expect(again).toEqual({ ok: true, revision: first.revision, sizeBytes: 8, unchanged: true }) + + await writeFile(join(folder, 'chart.html'), '

b

') + const changed = await readNativeChatVisualFile(folder, 'chart.html', first.revision) + expect(changed).toMatchObject({ ok: true, html: '

b

' }) + }) + + it('reports a missing file or a missing folder as not_found', async () => { + expect(await readNativeChatVisualFile(folder, 'nope.html')).toEqual({ + ok: false, + error: 'not_found' + }) + const absent = nativeChatVisualsFolderFor(stateDirectory, 'session-never') + expect(await readNativeChatVisualFile(absent, 'chart.html')).toEqual({ + ok: false, + error: 'not_found' + }) + }) + + it('refuses names that are not a bare visual file name', async () => { + await mkdir(join(stateDirectory, 'other'), { recursive: true }) + await writeFile(join(stateDirectory, 'other', 'secret.html'), 'secret') + for (const name of ['../other/secret.html', '/etc/hosts', 'chart.txt', 'sub/chart.html']) { + expect(await readNativeChatVisualFile(folder, name)).toEqual({ + ok: false, + error: 'outside_folder' + }) + } + }) + + posixIt('refuses a symlink to a sibling chat folder or another workspace', async () => { + const sibling = nativeChatVisualsFolderFor(stateDirectory, 'session-beta') + await mkdir(sibling, { recursive: true }) + await writeFile(join(sibling, 'theirs.html'), '

other chat

') + const worktree = join(stateDirectory, 'worktree') + await mkdir(worktree) + await writeFile(join(worktree, 'index.html'), '

repo

') + + await symlink(join(sibling, 'theirs.html'), join(folder, 'theirs.html')) + await symlink(join(worktree, 'index.html'), join(folder, 'repo.html')) + await symlink(join(folder, 'missing-target.html'), join(folder, 'dangling.html')) + + for (const name of ['theirs.html', 'repo.html', 'dangling.html']) { + expect(await readNativeChatVisualFile(folder, name)).toEqual({ + ok: false, + error: 'outside_folder' + }) + } + }) + + posixIt('refuses a visuals folder replaced by a symlink', async () => { + const elsewhere = join(stateDirectory, 'elsewhere') + await mkdir(elsewhere) + await writeFile(join(elsewhere, 'chart.html'), '

elsewhere

') + await rm(folder, { recursive: true }) + await symlink(elsewhere, folder) + expect(await readNativeChatVisualFile(folder, 'chart.html')).toEqual({ + ok: false, + error: 'outside_folder' + }) + }) + + posixIt('refuses when the shared visuals root is a symlink', async () => { + const root = join(stateDirectory, 'native-chat-visuals') + const moved = join(stateDirectory, 'moved-root') + await rm(root, { recursive: true }) + await mkdir(join(moved, 'x'), { recursive: true }) + await symlink(moved, root) + await mkdir(folder, { recursive: true }) + await writeFile(join(folder, 'chart.html'), '

a

') + expect(await readNativeChatVisualFile(folder, 'chart.html')).toEqual({ + ok: false, + error: 'outside_folder' + }) + }) + + it('refuses a directory named like a visual', async () => { + await mkdir(join(folder, 'dir.html')) + expect(await readNativeChatVisualFile(folder, 'dir.html')).toEqual({ + ok: false, + error: 'not_a_file' + }) + }) + + posixIt('refuses a FIFO without waiting for a writer', async () => { + execFileSync('mkfifo', [join(folder, 'pipe.html')]) + expect(await readNativeChatVisualFile(folder, 'pipe.html')).toEqual({ + ok: false, + error: 'not_a_file' + }) + }) + + it('refuses a file over the byte cap and accepts one exactly at it', async () => { + await writeFile(join(folder, 'max.html'), 'a'.repeat(NATIVE_CHAT_VISUAL_MAX_BYTES)) + expect(await readNativeChatVisualFile(folder, 'max.html')).toMatchObject({ ok: true }) + await writeFile(join(folder, 'big.html'), 'a'.repeat(NATIVE_CHAT_VISUAL_MAX_BYTES + 1)) + expect(await readNativeChatVisualFile(folder, 'big.html')).toEqual({ + ok: false, + error: 'too_large' + }) + }) + + it('refuses binary and invalid UTF-8 content', async () => { + await writeFile(join(folder, 'zero-byte.html'), Buffer.from([0x3c, 0x00, 0x3e])) + await writeFile(join(folder, 'latin1.html'), Buffer.from([0x3c, 0xe9, 0x3e])) + expect(await readNativeChatVisualFile(folder, 'zero-byte.html')).toEqual({ + ok: false, + error: 'not_text' + }) + expect(await readNativeChatVisualFile(folder, 'latin1.html')).toEqual({ + ok: false, + error: 'not_text' + }) + }) + + it('reads a hard link the agent made inside its own folder', async () => { + // A hard link grants nothing a copy would not: the agent could write the same bytes itself. + await writeFile(join(folder, 'a.html'), '

a

') + await link(join(folder, 'a.html'), join(folder, 'b.html')) + expect(await readNativeChatVisualFile(folder, 'b.html')).toMatchObject({ ok: true }) + }) + + it('reads the replacement after a file is swapped for new content', async () => { + await writeFile(join(folder, 'chart.html'), '

old

') + await rm(join(folder, 'chart.html')) + await writeFile(join(folder, 'chart.html'), '

new

') + expect(await readNativeChatVisualFile(folder, 'chart.html')).toMatchObject({ + ok: true, + html: '

new

' + }) + }) + + posixIt('reports an unexpected filesystem fault without the host path', async () => { + await writeFile(join(folder, 'chart.html'), '

a

') + await chmod(join(folder, 'chart.html'), 0o000) + try { + const failure = await readNativeChatVisualFile(folder, 'chart.html').then( + () => null, + (error: unknown) => error + ) + // Root reads through the mode bits; everyone else gets the coded fault. + if (failure !== null) { + expect(String(failure)).toContain('visual_read_failed:EACCES') + expect(String(failure)).not.toContain(stateDirectory) + } + } finally { + await chmod(join(folder, 'chart.html'), 0o644) + } + }) +}) diff --git a/src/main/native-chat/native-chat-visual-file-read.ts b/src/main/native-chat/native-chat-visual-file-read.ts new file mode 100644 index 000000000000..36faa39f1ae0 --- /dev/null +++ b/src/main/native-chat/native-chat-visual-file-read.ts @@ -0,0 +1,223 @@ +// Reading one visual out of a chat's visuals folder. This is the boundary, not the renderer: the +// file must sit directly in the folder both lexically and canonically, be a regular file reached +// without a symlink, and be UTF-8 text within the byte cap. + +import { createHash } from 'node:crypto' +import { constants, type Stats } from 'node:fs' +import { lstat, open, realpath, type FileHandle } from 'node:fs/promises' +import { basename, dirname, join } from 'node:path' +import { + NATIVE_CHAT_VISUAL_MAX_BYTES, + isNativeChatVisualFileName +} from '../../shared/native-chat-visual-directive' +import type { + AgentSessionReadVisualResult, + AgentSessionVisualReadError +} from '../../shared/rpc-contract/agent-session-visual-params' +import { isENOENT } from '../ipc/filesystem-path-containment' +import { readLocalFileBounded } from '../ipc/filesystem/local-regular-file-read' + +// Why O_NOFOLLOW: refuses a final-component symlink at open, so no swap after a check can redirect +// the read. Windows has no such flag; there the lstat and post-open identity checks hold the line. +const VISUAL_OPEN_FLAGS = + constants.O_RDONLY | (constants.O_NONBLOCK ?? 0) | (constants.O_NOFOLLOW ?? 0) + +const REVISION_HEX_LENGTH = 32 + +class VisualReadRefusal extends Error { + constructor(readonly refusal: AgentSessionVisualReadError) { + super(refusal) + } +} + +function refuse(refusal: AgentSessionVisualReadError): never { + throw new VisualReadRefusal(refusal) +} + +function errorCode(error: unknown): string | undefined { + return error instanceof Error && 'code' in error && typeof error.code === 'string' + ? error.code + : undefined +} + +function sameFile(left: Stats, right: Stats): boolean { + return left.dev === right.dev && left.ino === right.ino +} + +/** + * The folder and its parent are Orca's own directories below the trusted state directory. Either + * one replaced by a symlink would aim every read somewhere else, so a link there is refused. + */ +async function canonicalFolder(folder: string): Promise { + try { + // Parent first, so the answer for a broken path does not depend on which check settles first. + const parentStats = await lstat(dirname(folder)) + const owned = [parentStats, await lstat(folder)] + if (owned.some((stats) => stats.isSymbolicLink())) { + refuse('outside_folder') + } + if (owned.some((stats) => !stats.isDirectory())) { + refuse('not_found') + } + return await realpath(folder) + } catch (error) { + if (error instanceof VisualReadRefusal) { + throw error + } + if (isENOENT(error) || errorCode(error) === 'ENOTDIR') { + refuse('not_found') + } + throw error + } +} + +async function openVisual(candidate: string): Promise { + try { + if ((await lstat(candidate)).isSymbolicLink()) { + refuse('outside_folder') + } + return await open(candidate, VISUAL_OPEN_FLAGS) + } catch (error) { + if (error instanceof VisualReadRefusal) { + throw error + } + if (isENOENT(error) || errorCode(error) === 'ENOTDIR') { + refuse('not_found') + } + // ELOOP: the path became a symlink between the lstat and the open. + if (errorCode(error) === 'ELOOP' || errorCode(error) === 'EMLINK') { + refuse('outside_folder') + } + if (errorCode(error) === 'EISDIR') { + refuse('not_a_file') + } + throw error + } +} + +/** + * After the open: the handle must still be the file directly inside the canonical folder, and the + * folder that canonical path names must still be the real directory at Orca's own path (a symlink + * swapped in and out around the first check would otherwise aim `folderReal` elsewhere). + */ +async function assertStillContained( + folder: string, + candidate: string, + folderReal: string, + file: string, + opened: Stats +): Promise { + let resolved: string + let current: Stats + try { + const [parentNow, folderNow, canonicalFolderNow] = await Promise.all([ + lstat(dirname(folder)), + lstat(folder), + lstat(folderReal) + ]) + if ( + parentNow.isSymbolicLink() || + folderNow.isSymbolicLink() || + !sameFile(folderNow, canonicalFolderNow) + ) { + refuse('outside_folder') + } + resolved = await realpath(candidate) + current = await lstat(candidate) + } catch (error) { + if (error instanceof VisualReadRefusal) { + throw error + } + if (isENOENT(error)) { + refuse('not_found') + } + throw error + } + // Why compare names case-insensitively: a case-insensitive volume may report the stored case. + if ( + dirname(resolved) !== folderReal || + basename(resolved).toLowerCase() !== file.toLowerCase() || + current.isSymbolicLink() || + !sameFile(current, opened) + ) { + refuse('outside_folder') + } +} + +function decodeVisualText(buffer: Buffer): string { + if (buffer.includes(0)) { + refuse('not_text') + } + try { + return new TextDecoder('utf-8', { fatal: true }).decode(buffer) + } catch { + return refuse('not_text') + } +} + +export function nativeChatVisualRevision(buffer: Buffer): string { + return createHash('sha256').update(buffer).digest('hex').slice(0, REVISION_HEX_LENGTH) +} + +async function readContained( + folder: string, + file: string, + knownRevision: string | undefined +): Promise { + if (!isNativeChatVisualFileName(file)) { + refuse('outside_folder') + } + const folderReal = await canonicalFolder(folder) + const candidate = join(folderReal, file) + if (dirname(candidate) !== folderReal) { + refuse('outside_folder') + } + const handle = await openVisual(candidate) + try { + const stats = await handle.stat() + if (!stats.isFile()) { + refuse('not_a_file') + } + if (stats.size > NATIVE_CHAT_VISUAL_MAX_BYTES) { + refuse('too_large') + } + await assertStillContained(folder, candidate, folderReal, file, stats) + let buffer: Buffer + try { + buffer = await readLocalFileBounded(handle, NATIVE_CHAT_VISUAL_MAX_BYTES, stats.size) + } catch (error) { + if (error instanceof Error && error.message.startsWith('File too large')) { + refuse('too_large') + } + throw error + } + const html = decodeVisualText(buffer) + const revision = nativeChatVisualRevision(buffer) + const sizeBytes = buffer.length + return knownRevision === revision + ? { ok: true, revision, sizeBytes, unchanged: true } + : { ok: true, revision, sizeBytes, html } + } finally { + await handle.close() + } +} + +/** + * The visual `file` from `folder`, or the refusal the host observed. Unexpected filesystem faults + * still throw, so the client reads them as unavailable rather than as a verdict about the file; the + * thrown error names only the error code, never a host path. + */ +export async function readNativeChatVisualFile( + folder: string, + file: string, + knownRevision?: string +): Promise { + try { + return await readContained(folder, file, knownRevision) + } catch (error) { + if (error instanceof VisualReadRefusal) { + return { ok: false, error: error.refusal } + } + throw new Error(`visual_read_failed:${errorCode(error) ?? 'unknown'}`, { cause: error }) + } +} diff --git a/src/main/native-chat/native-chat-visuals-folder.ts b/src/main/native-chat/native-chat-visuals-folder.ts new file mode 100644 index 000000000000..7af2263b5d14 --- /dev/null +++ b/src/main/native-chat/native-chat-visuals-folder.ts @@ -0,0 +1,17 @@ +// Where a structured chat's visuals live on the host that owns the chat: Orca-owned state beside the +// chat journal, never inside the user's workspace. + +import { createHash } from 'node:crypto' +import { join } from 'node:path' + +const NATIVE_CHAT_VISUALS_DIR_NAME = 'native-chat-visuals' + +/** + * `/native-chat-visuals/`. Keyed by the session id + * alone, the chat record's primary key, so the folder needs no workspace lookup to find or remove; + * hashed so any id is one safe path segment. + */ +export function nativeChatVisualsFolderFor(stateDirectory: string, sessionId: string): string { + const segment = createHash('sha256').update(sessionId, 'utf8').digest('hex').slice(0, 32) + return join(stateDirectory, NATIVE_CHAT_VISUALS_DIR_NAME, segment) +} diff --git a/src/main/runtime/rpc/methods/index.ts b/src/main/runtime/rpc/methods/index.ts index 00bd68f5746e..9f87aab0b906 100644 --- a/src/main/runtime/rpc/methods/index.ts +++ b/src/main/runtime/rpc/methods/index.ts @@ -49,6 +49,7 @@ import { PAIRING_METHODS } from './pairing' import { UPDATER_METHODS } from './updater' import { AGENT_SESSION_METHODS } from './agent-session' import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' +import { STRUCTURED_AGENT_SESSION_VISUAL_METHODS } from './structured-agent-session-visual' import { STRUCTURED_AGENT_SESSION_AGENTS_METHODS } from './structured-agent-session-agents' import { ARTIFACT_METHODS } from './artifacts' import { AGENT_HOOK_METHODS } from './agent-hooks' @@ -69,6 +70,7 @@ export const ALL_RPC_METHODS = [ ...WORKTREE_METHODS, ...AGENT_SESSION_METHODS, ...STRUCTURED_AGENT_SESSION_METHODS, + ...STRUCTURED_AGENT_SESSION_VISUAL_METHODS, ...STRUCTURED_AGENT_SESSION_AGENTS_METHODS, ...AGENT_LAUNCH_METHODS, ...TERMINAL_METHODS, diff --git a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts index 43f813b35ee6..c4b3f62700b6 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts @@ -16,7 +16,7 @@ import { AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' -import type { RpcRequest, RpcResponse } from '../core' +import type { RpcAnyMethodDeclaration, RpcRequest, RpcResponse } from '../core' import { RpcDispatcher } from '../dispatcher' import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' import { createStructuredAgentSessionLogger } from '../../../native-chat/agent-session-wire/structured-agent-session-logger' @@ -216,7 +216,10 @@ export function hostStub(): StructuredAgentSessionHost { return hostCalls as unknown as StructuredAgentSessionHost } -export function dispatcher(runtimeOverrides: Record = {}): RpcDispatcher { +export function dispatcher( + runtimeOverrides: Record = {}, + methods: readonly RpcAnyMethodDeclaration[] = STRUCTURED_AGENT_SESSION_METHODS +): RpcDispatcher { reset(runtimeCalls) Object.assign(runtimeCalls, { getStructuredAgentSessionCreateSupport: vi.fn(async () => ({ supported: true })), @@ -254,7 +257,7 @@ export function dispatcher(runtimeOverrides: Record = {}): RpcD } return new RpcDispatcher({ runtime: runtime as unknown as OrcaRuntimeService, - methods: STRUCTURED_AGENT_SESSION_METHODS + methods }) } @@ -269,10 +272,11 @@ export async function call( clientCapabilities?: string[] signal?: AbortSignal }, - runtimeOverrides: Record = {} + runtimeOverrides: Record = {}, + methods?: readonly RpcAnyMethodDeclaration[] ): Promise { const replies: RpcResponse[] = [] - await dispatcher(runtimeOverrides).dispatchStreaming( + await dispatcher(runtimeOverrides, methods).dispatchStreaming( request(method, params), (raw) => replies.push(JSON.parse(raw) as RpcResponse), client diff --git a/src/main/runtime/rpc/methods/structured-agent-session-visual.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-visual.test.ts new file mode 100644 index 000000000000..8d73e3b374fd --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-visual.test.ts @@ -0,0 +1,149 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionRecord } from '../../../../shared/agent-session-record' +import { agentSessionRecordFixture } from '../../../../shared/agent-session-record.test-fixture' +import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' +import { nativeChatVisualsFolderFor } from '../../../native-chat/native-chat-visuals-folder' +import { ALL_RPC_METHODS } from '.' +import { STRUCTURED_AGENT_SESSION_VISUAL_METHODS } from './structured-agent-session-visual' +import { + call as callStructured, + clearStructuredHostStub, + hostStub, + SESSION, + STRUCTURED_CLIENT +} from './structured-agent-session-rpc.test-fixture' + +type CallClient = Parameters[2] + +function call(method: string, params: unknown, client: CallClient) { + return callStructured(method, params, client, {}, STRUCTURED_AGENT_SESSION_VISUAL_METHODS) +} + +let stateDirectory: string +vi.mock('../../../orca-profiles/profile-storage-paths', () => ({ + getProfileUserDataPath: () => stateDirectory +})) +let record: AgentSessionRecord | null + +beforeEach(async () => { + stateDirectory = await mkdtemp(join(tmpdir(), 'orca-visual-rpc-')) + record = { ...agentSessionRecordFixture(), sessionId: SESSION } + setStructuredAgentSessionHost( + Object.assign(hostStub(), { + deps: { + store: { getRecord: (id: string) => (record?.sessionId === id ? record : null) } + } + }) + ) +}) + +afterEach(async () => { + clearStructuredHostStub() + await rm(stateDirectory, { recursive: true, force: true }) +}) + +async function writeVisual(name: string, html: string): Promise { + const folder = nativeChatVisualsFolderFor(stateDirectory, SESSION) + await mkdir(folder, { recursive: true }) + await writeFile(join(folder, name), html) +} + +describe('agentSession.readVisual', () => { + it('is registered on the runtime manifest', () => { + expect(ALL_RPC_METHODS.map((method) => method.name)).toContain('agentSession.readVisual') + }) + + it("reads a file from the chat's own folder under the host's state directory", async () => { + await writeVisual('chart.html', '

chart

') + const reply = await call( + 'agentSession.readVisual', + { sessionId: SESSION, file: 'chart.html' }, + STRUCTURED_CLIENT + ) + expect(reply).toMatchObject({ + ok: true, + result: { ok: true, html: '

chart

', sizeBytes: 12 } + }) + }) + + it('answers unchanged for the revision the client holds', async () => { + await writeVisual('chart.html', '

chart

') + const first = await call( + 'agentSession.readVisual', + { sessionId: SESSION, file: 'chart.html' }, + STRUCTURED_CLIENT + ) + const result: unknown = first.ok ? first.result : null + const revision = + typeof result === 'object' && result !== null && 'revision' in result + ? String(result.revision) + : '' + const again = await call( + 'agentSession.readVisual', + { sessionId: SESSION, file: 'chart.html', knownRevision: revision }, + STRUCTURED_CLIENT + ) + expect(again).toMatchObject({ ok: true, result: { ok: true, unchanged: true, revision } }) + const againResult: unknown = again.ok ? again.result : null + expect(typeof againResult === 'object' && againResult !== null && 'html' in againResult).toBe( + false + ) + }) + + it('reports a session this host has no record of', async () => { + record = null + const reply = await call( + 'agentSession.readVisual', + { sessionId: SESSION, file: 'chart.html' }, + STRUCTURED_CLIENT + ) + expect(reply).toMatchObject({ ok: true, result: { ok: false, error: 'session_not_found' } }) + }) + + it('refuses a chat recorded on another execution host or a WSL distro', async () => { + const base = agentSessionRecordFixture() + record = { + ...base, + sessionId: SESSION, + location: { ...base.location, executionHostId: 'ssh:box' } + } + const ssh = await call( + 'agentSession.readVisual', + { sessionId: SESSION, file: 'chart.html' }, + STRUCTURED_CLIENT + ) + expect(ssh).toMatchObject({ ok: true, result: { ok: false, error: 'unsupported_location' } }) + + record = { ...base, sessionId: SESSION, location: { ...base.location, wslDistro: 'Ubuntu' } } + const wsl = await call( + 'agentSession.readVisual', + { sessionId: SESSION, file: 'chart.html' }, + STRUCTURED_CLIENT + ) + expect(wsl).toMatchObject({ ok: true, result: { ok: false, error: 'unsupported_location' } }) + }) + + it.each([ + ['a path', { sessionId: SESSION, file: '../chart.html' }], + ['a non-html file', { sessionId: SESSION, file: 'chart.txt' }], + ['a bad session id', { sessionId: '../x', file: 'chart.html' }], + ['an unknown field', { sessionId: SESSION, file: 'chart.html', path: '/etc' }], + ['a malformed revision', { sessionId: SESSION, file: 'chart.html', knownRevision: 'zz' }] + ])('rejects %s as invalid params', async (_name, params) => { + const reply = await call('agentSession.readVisual', params, STRUCTURED_CLIENT) + expect(reply).toMatchObject({ ok: false, error: { code: 'invalid_argument' } }) + }) + + it('is refused to a client that cannot read structured sessions', async () => { + await writeVisual('chart.html', '

chart

') + const reply = await call( + 'agentSession.readVisual', + { sessionId: SESSION, file: 'chart.html' }, + { clientKind: 'runtime', clientCapabilities: [] } + ) + expect(reply.ok).toBe(false) + }) +}) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-visual.ts b/src/main/runtime/rpc/methods/structured-agent-session-visual.ts new file mode 100644 index 000000000000..ed486013a65d --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-visual.ts @@ -0,0 +1,42 @@ +// `agentSession.readVisual` — one HTML visual from a chat's own visuals folder. +// +// Additive: an older host answers `method_not_found` (a phone gets `forbidden` from the mobile +// allowlist gate on a host without the entry), and the client shows the visual as unavailable. +// The host resolves the folder from its own state directory and record; a client supplies only the +// session id and a bare file name. + +import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' +import { + ReadVisualParams, + type AgentSessionReadVisualResult +} from '../../../../shared/rpc-contract/agent-session-visual-params' +import { nativeChatVisualsFolderFor } from '../../../native-chat/native-chat-visuals-folder' +import { getProfileUserDataPath } from '../../../orca-profiles/profile-storage-paths' +import { readNativeChatVisualFile } from '../../../native-chat/native-chat-visual-file-read' +import { defineMethod } from '../core' +import { requireInstalledStructuredHost } from './structured-agent-session-gate' + +export const STRUCTURED_AGENT_SESSION_VISUAL_METHODS = [ + defineMethod({ + name: 'agentSession.readVisual', + params: ReadVisualParams, + handler: async (params, ctx): Promise => { + const host = await requireInstalledStructuredHost(ctx) + const record = host.deps.store.getRecord(params.sessionId) + if (!record) { + return { ok: false, error: 'session_not_found' } + } + // Structured chats run on their owning runtime's own filesystem; any other location has no + // visuals folder this process can read. + if ( + record.location.executionHostId !== LOCAL_EXECUTION_HOST_ID || + record.location.wslDistro + ) { + return { ok: false, error: 'unsupported_location' } + } + // The same state directory the chat host and its journal are opened in on this process. + const folder = nativeChatVisualsFolderFor(getProfileUserDataPath(), params.sessionId) + return readNativeChatVisualFile(folder, params.file, params.knownRevision) + } + }) +] diff --git a/src/main/plugins/plugin-panel-navigation-guard.test.ts b/src/main/window/host-frame-navigation-guard.test.ts similarity index 51% rename from src/main/plugins/plugin-panel-navigation-guard.test.ts rename to src/main/window/host-frame-navigation-guard.test.ts index 2889e57cf707..1a4aae3d0420 100644 --- a/src/main/plugins/plugin-panel-navigation-guard.test.ts +++ b/src/main/window/host-frame-navigation-guard.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { PLUGIN_PANEL_FRAME_NAME_PREFIX } from '../../shared/plugins/plugin-panel-bridge' -import { PluginPanelNavigationRegistry } from './plugin-panel-navigation-guard' +import { NATIVE_CHAT_VISUAL_FRAME_NAME_PREFIX } from '../../shared/native-chat-visual-shell' +import { HostFrameNavigationRegistry, hostFrameKindForName } from './host-frame-navigation-guard' function frame(input: { id: number; name?: string; url?: string }) { let destroyed = false @@ -14,9 +15,9 @@ function frame(input: { id: number; name?: string; url?: string }) { } } -describe('PluginPanelNavigationRegistry', () => { +describe('HostFrameNavigationRegistry', () => { it('blocks only host-marked plugin srcdoc frames', () => { - const registry = new PluginPanelNavigationRegistry() + const registry = new HostFrameNavigationRegistry() const plugin = frame({ id: 1, name: `${PLUGIN_PANEL_FRAME_NAME_PREFIX}demo` }) const notebook = frame({ id: 2 }) registry.register(plugin) @@ -28,7 +29,7 @@ describe('PluginPanelNavigationRegistry', () => { }) it('keeps pre-parse identity after name mutation and prunes destroyed frames', () => { - const registry = new PluginPanelNavigationRegistry() + const registry = new HostFrameNavigationRegistry() const plugin = frame({ id: 1, name: `${PLUGIN_PANEL_FRAME_NAME_PREFIX}demo` }) registry.register(plugin) plugin.name = '' @@ -38,4 +39,20 @@ describe('PluginPanelNavigationRegistry', () => { plugin.destroy() expect(registry.shouldBlock(plugin, plugin, 'https://example.com')).toBe(false) }) + + it('contains chat visual frames the same way, as their own kind', () => { + const registry = new HostFrameNavigationRegistry() + const visual = frame({ id: 3, name: `${NATIVE_CHAT_VISUAL_FRAME_NAME_PREFIX}abc` }) + registry.register(visual) + expect(hostFrameKindForName(visual.name)).toBe('chat-visual') + expect(hostFrameKindForName(`${PLUGIN_PANEL_FRAME_NAME_PREFIX}demo`)).toBe('plugin-panel') + expect(hostFrameKindForName('orca-chat-visual')).toBeNull() + + expect(registry.shouldBlock(visual, null, 'about:srcdoc')).toBe(false) + // Its own navigation (location, meta refresh, link) and any child it starts are refused. + expect(registry.shouldBlock(visual, visual, 'https://example.com')).toBe(true) + expect(registry.shouldBlock(visual, null, 'https://example.com')).toBe(true) + const child = frame({ id: 4 }) + expect(registry.shouldBlock(child, visual, 'https://example.com')).toBe(true) + }) }) diff --git a/src/main/plugins/plugin-panel-navigation-guard.ts b/src/main/window/host-frame-navigation-guard.ts similarity index 62% rename from src/main/plugins/plugin-panel-navigation-guard.ts rename to src/main/window/host-frame-navigation-guard.ts index d183eae98881..110b3705c5d4 100644 --- a/src/main/plugins/plugin-panel-navigation-guard.ts +++ b/src/main/window/host-frame-navigation-guard.ts @@ -1,22 +1,40 @@ import type { WebContents, WebFrameMain } from 'electron' +import { NATIVE_CHAT_VISUAL_FRAME_NAME_PREFIX } from '../../shared/native-chat-visual-shell' import { PLUGIN_PANEL_FRAME_NAME_PREFIX } from '../../shared/plugins/plugin-panel-bridge' type NavigationFrame = Pick +/** + * Host-built srcdoc frames that hold content Orca did not write. Each kind is only a navigation + * containment class: registering a frame grants it nothing (no plugin identity, no actions). + */ +const HOST_FRAME_KINDS = [ + { kind: 'plugin-panel', namePrefix: PLUGIN_PANEL_FRAME_NAME_PREFIX }, + { kind: 'chat-visual', namePrefix: NATIVE_CHAT_VISUAL_FRAME_NAME_PREFIX } +] as const + +export type HostFrameKind = (typeof HOST_FRAME_KINDS)[number]['kind'] + type RegisteredFrame = { frame: NavigationFrame + kind: HostFrameKind initialSrcdocPending: boolean } -/** Records host-marked panel frame identities at browsing-context creation, - * before plugin parsing can mutate window.name. */ -export class PluginPanelNavigationRegistry { +export function hostFrameKindForName(name: string): HostFrameKind | null { + return HOST_FRAME_KINDS.find((entry) => name.startsWith(entry.namePrefix))?.kind ?? null +} + +/** Records host-marked frame identities at browsing-context creation, before their content can + * mutate window.name. */ +export class HostFrameNavigationRegistry { private readonly frames = new Map() register(frame: NavigationFrame): void { this.prune() - if (frame.name.startsWith(PLUGIN_PANEL_FRAME_NAME_PREFIX)) { - this.frames.set(frame.frameTreeNodeId, { frame, initialSrcdocPending: true }) + const kind = hostFrameKindForName(frame.name) + if (kind) { + this.frames.set(frame.frameTreeNodeId, { frame, kind, initialSrcdocPending: true }) } } @@ -52,8 +70,8 @@ export class PluginPanelNavigationRegistry { } } -export function registerPluginPanelNavigationGuard(webContents: WebContents): void { - const registry = new PluginPanelNavigationRegistry() +export function registerHostFrameNavigationGuard(webContents: WebContents): void { + const registry = new HostFrameNavigationRegistry() webContents.on('frame-created', (_event, { frame }) => { if (frame) { registry.register(frame) @@ -62,7 +80,7 @@ export function registerPluginPanelNavigationGuard(webContents: WebContents): vo webContents.on('did-start-navigation', (event) => { if (!event.isMainFrame && event.url === 'about:srcdoc' && event.frame) { // Some Chromium builds populate the frame name only when navigation - // starts; this event still precedes document parsing and plugin code. + // starts; this event still precedes document parsing and frame content. registry.register(event.frame) } }) diff --git a/src/main/window/main-window-webview-security.test.ts b/src/main/window/main-window-webview-security.test.ts index e54dc6a68369..d47a2f8118ef 100644 --- a/src/main/window/main-window-webview-security.test.ts +++ b/src/main/window/main-window-webview-security.test.ts @@ -15,8 +15,8 @@ vi.mock('../browser/browser-manager', () => ({ vi.mock('../browser/browser-session-registry', () => ({ browserSessionRegistry: { isAllowedPartition: mocks.isAllowedPartition } })) -vi.mock('../plugins/plugin-panel-navigation-guard', () => ({ - registerPluginPanelNavigationGuard: mocks.registerPluginGuard +vi.mock('./host-frame-navigation-guard', () => ({ + registerHostFrameNavigationGuard: mocks.registerPluginGuard })) vi.mock('./privileged-window-navigation', () => ({ installPrivilegedWindowNavigationPolicy: mocks.installNavigationPolicy diff --git a/src/main/window/main-window-webview-security.ts b/src/main/window/main-window-webview-security.ts index a662449eb580..7c026d7cf4a3 100644 --- a/src/main/window/main-window-webview-security.ts +++ b/src/main/window/main-window-webview-security.ts @@ -20,7 +20,7 @@ import { revokeAllDocPreviewGrants } from '../browser/doc-preview-grant-registry' import { isDocPreviewSession } from '../browser/doc-preview-protocol' -import { registerPluginPanelNavigationGuard } from '../plugins/plugin-panel-navigation-guard' +import { registerHostFrameNavigationGuard } from './host-frame-navigation-guard' import { installPrivilegedWindowNavigationPolicy } from './privileged-window-navigation' /** @@ -54,9 +54,9 @@ export function installMainWindowWebviewSecurity(mainWindow: BrowserWindow): voi setDocPreviewFailureSink(null) revokeAllDocPreviewGrants() }) - // Why: containment must be listening before any plugin panel frame is created, - // so register it with the window's other navigation policy. - registerPluginPanelNavigationGuard(mainWindow.webContents) + // Why: containment must be listening before any plugin panel or chat visual frame is + // created, so register it with the window's other navigation policy. + registerHostFrameNavigationGuard(mainWindow.webContents) const browserWindowClosePreload = join(__dirname, 'browser-window-close-preload.js') // Why a preview gets a preload at all: it is our own editor surface, not a browsing guest. This diff --git a/src/renderer/src/components/native-chat/NativeChatInlineVisual.tsx b/src/renderer/src/components/native-chat/NativeChatInlineVisual.tsx new file mode 100644 index 000000000000..eda0f0e7f9ea --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatInlineVisual.tsx @@ -0,0 +1,111 @@ +import { useEffect, useRef, useState } from 'react' +import { Maximize2 } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { translate } from '@/i18n/i18n' +import { useAppStore } from '@/store' +import { NATIVE_CHAT_VISUAL_RESERVED_HEIGHT, NativeChatVisualFrame } from './NativeChatVisualFrame' +import { observeTranscriptVisibility } from './NativeChatTranscriptChrome' +import type { NativeChatVisualOwner } from './native-chat-visual-owner' +import { useNativeChatVisualDocument } from './use-native-chat-visual-document' + +export function NativeChatVisualUnavailable(): React.JSX.Element { + return ( +

+ {translate('components.native-chat.visualUnavailable', 'Visualization unavailable')} +

+ ) +} + +/** + * A visual inside an assistant reply. Its frame mounts once the reply scrolls near it and stays + * mounted, so what the reader did in it survives scrolling away and back. + */ +export function NativeChatInlineVisual({ + owner, + messageId, + file, + title +}: { + owner: NativeChatVisualOwner + messageId: string + file: string + title: string | null +}): React.JSX.Element { + const boxRef = useRef(null) + const [near, setNear] = useState(false) + const [retired, setRetired] = useState(false) + const openRightSidebarVisual = useAppStore((state) => state.openRightSidebarVisual) + const state = useNativeChatVisualDocument( + { target: owner.target, sessionId: owner.sessionId, file }, + near + ) + + useEffect(() => { + const element = boxRef.current + if (!element || near) { + return + } + return observeTranscriptVisibility(element, (visible) => { + if (visible) { + setNear(true) + } + }) + }, [near]) + + if (retired || state.status === 'unavailable') { + return + } + const label = title ?? file + const openLabel = translate('components.native-chat.visualOpenInSidebar', 'Open in sidebar') + + return ( +
+ {state.status === 'ready' && near ? ( + <> + setRetired(true)} + /> +
+ + + + + + {openLabel} + + +
+ + ) : ( +
+ )} +
+ ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatMarkdown.tsx b/src/renderer/src/components/native-chat/NativeChatMarkdown.tsx index 3f2e89fb07ea..4033b51fc620 100644 --- a/src/renderer/src/components/native-chat/NativeChatMarkdown.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMarkdown.tsx @@ -1,11 +1,33 @@ import type { ComponentProps } from 'react' import CommentMarkdown from '@/components/sidebar/CommentMarkdown' import { cn } from '@/lib/utils' +import { withoutPendingNativeChatVisualDirectiveTail } from '../../../../shared/native-chat-visual-directive' +import { useNativeChatVisualMarkdownExtension } from './native-chat-visual-markdown-extension' import './native-chat-markdown.css' +type NativeChatMarkdownProps = ComponentProps & { + /** On assistant prose in a structured chat: this message may show visuals. */ + visualMessageId?: string + /** The reply is still arriving, so an unfinished visual line at its end is held back. */ + streaming?: boolean +} + export function NativeChatMarkdown({ className, + visualMessageId, + streaming = false, + content, ...props -}: ComponentProps): React.JSX.Element { - return +}: NativeChatMarkdownProps): React.JSX.Element { + const extension = useNativeChatVisualMarkdownExtension(visualMessageId) + return ( + + ) } diff --git a/src/renderer/src/components/native-chat/NativeChatMarkdown.visual.test.tsx b/src/renderer/src/components/native-chat/NativeChatMarkdown.visual.test.tsx new file mode 100644 index 000000000000..98a08e57436e --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatMarkdown.visual.test.tsx @@ -0,0 +1,155 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' + +import { cleanup, render, waitFor } from '@testing-library/react' +import type { ReactNode } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const callRuntimeRpc = vi.fn() +vi.mock('@/runtime/runtime-rpc-client', () => ({ + callRuntimeRpc: (...args: unknown[]) => callRuntimeRpc(...args) +})) + +import { NativeChatMarkdown } from './NativeChatMarkdown' +import { MessageRow } from './NativeChatMessageRow' +import { + NativeChatVisualOwnerContext, + type NativeChatVisualOwner +} from './native-chat-visual-owner' +import { clearNativeChatVisualCacheForTests } from './native-chat-visual-read-client' +import { TooltipProvider } from '@/components/ui/tooltip' + +const owner: NativeChatVisualOwner = { + target: { kind: 'local' }, + sessionId: 'session-alpha', + tabId: 'tab-1', + worktreeId: 'wt-1' +} +const LINE = '::orca-visual{file="usage.html" title="Usage"}' + +function withOwner(children: ReactNode, value: NativeChatVisualOwner | null = owner): ReactNode { + return ( + + + {children} + + + ) +} + +// The transcript mounts a visual once it scrolls near; here every visual is in view at once. +class InViewObserver { + constructor(private readonly callback: (entries: Partial[]) => void) {} + observe(target: Element): void { + this.callback([{ target, isIntersecting: true }]) + } + unobserve(): void {} + disconnect(): void {} +} + +beforeEach(() => { + vi.stubGlobal('IntersectionObserver', InViewObserver) + clearNativeChatVisualCacheForTests() + callRuntimeRpc.mockReset() + callRuntimeRpc.mockResolvedValue({ ok: true, revision: 'r1', sizeBytes: 9, html: '

v

' }) +}) + +afterEach(() => { + cleanup() + vi.unstubAllGlobals() +}) + +describe('NativeChatMarkdown visuals', () => { + it('holds back an unfinished directive at the end of a streaming reply', () => { + const { container } = render( + withOwner( + + ) + ) + expect(container).toHaveTextContent('Here it is:') + expect(container).not.toHaveTextContent('::orca-visual') + }) + + it('shows unfinished syntax as text once the reply is no longer streaming', () => { + const { container } = render( + withOwner( + + ) + ) + expect(container).toHaveTextContent('::orca-visual{file="usa') + }) + + it('keeps a mounted visual frame while the rest of the reply streams in', async () => { + const { container, rerender } = render( + withOwner( + + ) + ) + await waitFor(() => expect(container.querySelector('iframe')).not.toBeNull()) + const frame = container.querySelector('iframe') + rerender( + withOwner( + + ) + ) + expect(container.querySelector('iframe')).toBe(frame) + expect(container).toHaveTextContent('More text arriving now') + expect(callRuntimeRpc).toHaveBeenCalledTimes(1) + }) + + it('shows one muted line when the host refuses the file', async () => { + callRuntimeRpc.mockResolvedValue({ ok: false, error: 'outside_folder' }) + const { container } = render( + withOwner() + ) + await waitFor(() => expect(container).toHaveTextContent('Visualization unavailable')) + expect(container.querySelector('iframe')).toBeNull() + }) + + it('leaves the line as text where visuals do not apply', () => { + const noMessage = render(withOwner()) + expect(noMessage.container).toHaveTextContent('::orca-visual') + cleanup() + const noOwner = render( + withOwner(, null) + ) + expect(noOwner.container).toHaveTextContent('::orca-visual') + expect(callRuntimeRpc).not.toHaveBeenCalled() + }) + + it('holds the tail of a text row while its turn works, though the row carries no state', () => { + const row = (activeTurnIsWorking: boolean, trailingRun = true) => + withOwner( + + + ) + fireEvent.click(screen.getByRole('button', { name: 'Copy message' })) + + await waitFor(() => { + expect(writeClipboardText).toHaveBeenCalledWith('Here it is.\n\nDone.') + }) + }) + it('omits the copy button on image-only user messages', () => { render( ) : null} {run || tools.length > 0 || subagentGroups.length > 0 || backgroundTasks.length > 0 ? ( diff --git a/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.tsx b/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.tsx index e2f712c742c2..ac9d54d748d0 100644 --- a/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.tsx +++ b/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.tsx @@ -7,6 +7,7 @@ import type { NativeChatBlock } from '../../../../shared/native-chat-types' import { NativeChatCopyButton } from './NativeChatCopyButton' import { NativeChatMessageTimestamp } from './NativeChatMessageTimestamp' import { nativeChatProviderFrameSummary } from '../../../../shared/native-chat-provider-frame-summary' +import { withoutNativeChatVisualDirectiveLines } from '../../../../shared/native-chat-visual-directive' import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog' import { getLocalImageCacheKey, @@ -24,7 +25,10 @@ type VisibilityListener = (isVisible: boolean) => void const visibilityListeners = new Map() let visibilityObserver: IntersectionObserver | null = null -function observeTranscriptVisibility(element: Element, listener: VisibilityListener): () => void { +export function observeTranscriptVisibility( + element: Element, + listener: VisibilityListener +): () => void { if (typeof IntersectionObserver === 'undefined') { listener(true) return () => {} @@ -289,7 +293,8 @@ export function NativeChatAgentControls({ }): React.JSX.Element { return (
- + {/* A visual line means nothing pasted outside Orca, so the copy leaves it out. */} +