From 9eb73a608fa9c5e1777df79d9576e20135ab4be5 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:25:43 -0700 Subject: [PATCH 01/67] Replace agentArgsOverride with unified removeAgentArgs approach (#25091) Consolidate override detection and removal into removeAgentArgs. Previously catalogs used agentArgsOverride to detect conflicts and removeAgentArgs to strip them; now removeAgentArgs handles both by returning stripped tokens. This eliminates redundancy and makes the intent clearer. Enhance removeAgentArgOption with optional value filtering to support selective removal for complex cases like Codex config overrides. --- src/shared/agent-session-option-agent-args.ts | 39 ++++------- ...gent-session-option-catalog-antigravity.ts | 3 - ...ent-session-option-catalog-claude-codex.ts | 64 +++++-------------- .../agent-session-option-catalog-codebuddy.ts | 3 - ...nt-session-option-catalog-gemini-cursor.ts | 9 ++- .../agent-session-option-catalog-grok.test.ts | 31 +++++---- .../agent-session-option-catalog-grok.ts | 7 +- .../agent-session-option-catalog-muse.ts | 3 - .../agent-session-option-catalog-omp.test.ts | 15 +++-- .../agent-session-option-catalog-omp.ts | 4 +- ...nt-session-option-catalog-removers.test.ts | 61 ++++++++++++++++++ .../agent-session-option-catalog-types.ts | 8 +-- src/shared/agent-session-option-launch.ts | 10 ++- src/shared/command-option-occurrence.ts | 2 +- 14 files changed, 139 insertions(+), 120 deletions(-) create mode 100644 src/shared/agent-session-option-catalog-removers.test.ts diff --git a/src/shared/agent-session-option-agent-args.ts b/src/shared/agent-session-option-agent-args.ts index d357ea57b73..eed4f1ec3ed 100644 --- a/src/shared/agent-session-option-agent-args.ts +++ b/src/shared/agent-session-option-agent-args.ts @@ -1,35 +1,24 @@ +import { findOptionOccurrence } from './command-option-occurrence' + export function agentArgOptionTokens(tokens: readonly string[]): readonly string[] { const terminator = tokens.indexOf('--') return terminator === -1 ? tokens : tokens.slice(0, terminator) } +/** Removes each occurrence before `--`, or only those whose value `matchesValue` accepts. */ export function removeAgentArgOption( tokens: readonly string[], - aliases: readonly string[] + aliases: readonly string[], + matchesValue: (value: string | undefined) => boolean = () => true ): string[] { - const result: string[] = [] - for (let index = 0; index < tokens.length; index += 1) { - const token = tokens[index] - if (token === '--') { - result.push(...tokens.slice(index)) - break - } - const exact = aliases.includes(token) - const matched = aliases.some( - (alias) => - token.startsWith(`${alias}=`) || - (alias.startsWith('-') && - !alias.startsWith('--') && - token.startsWith(alias) && - token.length > alias.length) - ) - if (!exact && !matched) { - result.push(token) - continue - } - if (exact && tokens[index + 1] && !tokens[index + 1].startsWith('-')) { - index += 1 - } + const kept: string[] = [] + let rest = tokens + let found = findOptionOccurrence(rest, aliases, true) + while (found) { + const end = found.index + found.consumed + kept.push(...rest.slice(0, matchesValue(found.value) ? found.index : end)) + rest = rest.slice(end) + found = findOptionOccurrence(rest, aliases, true) } - return result + return [...kept, ...rest] } diff --git a/src/shared/agent-session-option-catalog-antigravity.ts b/src/shared/agent-session-option-catalog-antigravity.ts index e6427a1070b..a2078c963d8 100644 --- a/src/shared/agent-session-option-catalog-antigravity.ts +++ b/src/shared/agent-session-option-catalog-antigravity.ts @@ -1,4 +1,3 @@ -import { hasFlag } from './agent-cli-flag-detection' import { removeAgentArgOption } from './agent-session-option-agent-args' import type { AgentSessionOptionCatalog, CatalogOption } from './agent-session-option-catalog-types' @@ -17,7 +16,6 @@ const ANTIGRAVITY_EFFORT: CatalogOption = { }, apply: { launchArgs: (value) => ['--effort', String(value)], - agentArgsOverride: (tokens) => hasFlag(tokens, ['--effort']), removeAgentArgs: (tokens) => removeAgentArgOption(tokens, ['--effort']), midSession: { kind: 'command', build: (value) => `/effort ${String(value)}` } } @@ -29,7 +27,6 @@ export const ANTIGRAVITY_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = { models: [], modelApply: { launchArgs: (value) => ['--model', String(value)], - agentArgsOverride: (tokens) => hasFlag(tokens, ['--model']), removeAgentArgs: (tokens) => removeAgentArgOption(tokens, ['--model']), midSession: { kind: 'agent-picker', command: '/model' } }, diff --git a/src/shared/agent-session-option-catalog-claude-codex.ts b/src/shared/agent-session-option-catalog-claude-codex.ts index b7e9d0e6bed..9fb27d3a97f 100644 --- a/src/shared/agent-session-option-catalog-claude-codex.ts +++ b/src/shared/agent-session-option-catalog-claude-codex.ts @@ -3,55 +3,24 @@ import type { CatalogModel, CatalogOption } from './agent-session-option-catalog-types' -import { agentArgOptionTokens, removeAgentArgOption } from './agent-session-option-agent-args' +import { removeAgentArgOption } from './agent-session-option-agent-args' import { CLAUDE_MODEL_LIST_ARGS, CLAUDE_MODEL_LIST_STDIN, parseClaudeModelList } from './claude-model-list-probe' -import { hasFlag } from './agent-cli-flag-detection' -function hasCodexEffortOverride(tokens: readonly string[]): boolean { - if (hasFlag(tokens, ['--reasoning-effort'])) { - return true - } - const optionTokens = agentArgOptionTokens(tokens) - return optionTokens.some((token, index) => { - const previous = optionTokens[index - 1] - return ( - (token.startsWith('model_reasoning_effort=') && - (previous === '-c' || previous === '--config')) || - token.startsWith('-cmodel_reasoning_effort=') || - token.startsWith('-c=model_reasoning_effort=') || - token.startsWith('--config=model_reasoning_effort=') - ) - }) -} - -function removeCodexEffortOverride(tokens: readonly string[]): string[] { - const withoutFlag = removeAgentArgOption(tokens, ['--reasoning-effort']) - const result: string[] = [] - for (let index = 0; index < withoutFlag.length; index += 1) { - const token = withoutFlag[index] - if (token === '--') { - result.push(...withoutFlag.slice(index)) - break - } - const next = withoutFlag[index + 1] - if ((token === '-c' || token === '--config') && next?.startsWith('model_reasoning_effort=')) { - index += 1 - continue - } - if ( - token.startsWith('-cmodel_reasoning_effort=') || - token.startsWith('-c=model_reasoning_effort=') || - token.startsWith('--config=model_reasoning_effort=') - ) { - continue - } - result.push(token) - } - return result +// Why: Codex also reads any setting from a `-c key=value` config override. +function removeCodexAgentArgs( + tokens: readonly string[], + flags: readonly string[], + configKey: string +): string[] { + return removeAgentArgOption( + removeAgentArgOption(tokens, flags), + ['-c', '--config'], + (value) => value?.startsWith(`${configKey}=`) === true + ) } const STANDARD_EFFORT_CHOICES = [ @@ -84,7 +53,6 @@ function claudeEffortWithChoices(choices: typeof EXTENDED_EFFORT_CHOICES): Catal }, apply: { launchArgs: (value) => ['--effort', String(value)], - agentArgsOverride: (tokens) => hasFlag(tokens, ['--effort']), removeAgentArgs: (tokens) => removeAgentArgOption(tokens, ['--effort']), midSession: { kind: 'command', build: (value) => `/effort ${String(value)}` } } @@ -162,7 +130,6 @@ export const CLAUDE_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = { ], modelApply: { launchArgs: (value) => ['--model', String(value)], - agentArgsOverride: (tokens) => hasFlag(tokens, ['--model']), removeAgentArgs: (tokens) => removeAgentArgOption(tokens, ['--model']), midSession: { kind: 'command', @@ -204,8 +171,8 @@ function codexEffort(ceiling: 'xhigh' | 'max' | 'ultra'): CatalogOption { }, apply: { launchArgs: (value) => ['-c', `model_reasoning_effort=${String(value)}`], - agentArgsOverride: hasCodexEffortOverride, - removeAgentArgs: removeCodexEffortOverride, + removeAgentArgs: (tokens) => + removeCodexAgentArgs(tokens, ['--reasoning-effort'], 'model_reasoning_effort'), midSession: { kind: 'agent-picker', command: '/model', delivery: 'type' } } } @@ -228,8 +195,7 @@ export const CODEX_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = { ], modelApply: { launchArgs: (value) => ['-m', String(value)], - agentArgsOverride: (tokens) => hasFlag(tokens, ['-m', '--model']), - removeAgentArgs: (tokens) => removeAgentArgOption(tokens, ['-m', '--model']), + removeAgentArgs: (tokens) => removeCodexAgentArgs(tokens, ['-m', '--model'], 'model'), // Codex classifies multi-character writes as pasted prose; type the bare // command and let its own picker apply the account-supported model. midSession: { kind: 'agent-picker', command: '/model', delivery: 'type' } diff --git a/src/shared/agent-session-option-catalog-codebuddy.ts b/src/shared/agent-session-option-catalog-codebuddy.ts index d0a8afa04c5..9c149919472 100644 --- a/src/shared/agent-session-option-catalog-codebuddy.ts +++ b/src/shared/agent-session-option-catalog-codebuddy.ts @@ -1,4 +1,3 @@ -import { hasFlag } from './agent-cli-flag-detection' import { removeAgentArgOption } from './agent-session-option-agent-args' import type { AgentSessionOptionCatalog, CatalogOption } from './agent-session-option-catalog-types' @@ -20,7 +19,6 @@ const CODEBUDDY_EFFORT: CatalogOption = { }, apply: { launchArgs: (value) => ['--effort', String(value)], - agentArgsOverride: (tokens) => hasFlag(tokens, ['--effort']), removeAgentArgs: (tokens) => removeAgentArgOption(tokens, ['--effort']) } } @@ -37,7 +35,6 @@ export const CODEBUDDY_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = { ].map((model) => ({ ...model, options: [CODEBUDDY_EFFORT] })), modelApply: { launchArgs: (value) => ['--model', String(value)], - agentArgsOverride: (tokens) => hasFlag(tokens, ['--model']), removeAgentArgs: (tokens) => removeAgentArgOption(tokens, ['--model']) }, unknownModelOptions: [CODEBUDDY_EFFORT] diff --git a/src/shared/agent-session-option-catalog-gemini-cursor.ts b/src/shared/agent-session-option-catalog-gemini-cursor.ts index d6dfe08627e..510394d2bd5 100644 --- a/src/shared/agent-session-option-catalog-gemini-cursor.ts +++ b/src/shared/agent-session-option-catalog-gemini-cursor.ts @@ -1,4 +1,3 @@ -import { hasFlag } from './agent-cli-flag-detection' import type { AgentSessionOptionCatalog, CatalogModel, @@ -6,7 +5,8 @@ import type { } from './agent-session-option-catalog-types' import { removeAgentArgOption } from './agent-session-option-agent-args' -const hasModelFlag = (tokens: readonly string[]): boolean => hasFlag(tokens, ['-m', '--model']) +const removeModelFlag = (tokens: readonly string[]): string[] => + removeAgentArgOption(tokens, ['-m', '--model']) export const GEMINI_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = { models: [ @@ -17,7 +17,7 @@ export const GEMINI_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = { ], modelApply: { launchArgs: (value) => ['-m', String(value)], - agentArgsOverride: hasModelFlag, + removeAgentArgs: removeModelFlag, midSession: { kind: 'agent-picker', command: '/model' } } } @@ -86,8 +86,7 @@ export const CURSOR_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = { ], modelApply: { launchArgs: (value) => ['--model', String(value)], - agentArgsOverride: hasModelFlag, - removeAgentArgs: (tokens) => removeAgentArgOption(tokens, ['-m', '--model']), + removeAgentArgs: removeModelFlag, midSession: { kind: 'command', build: (value) => `/model ${String(value)}` } }, composeModelValue: (modelId, values) => { diff --git a/src/shared/agent-session-option-catalog-grok.test.ts b/src/shared/agent-session-option-catalog-grok.test.ts index 0295cb46ad7..7a54621c7c6 100644 --- a/src/shared/agent-session-option-catalog-grok.test.ts +++ b/src/shared/agent-session-option-catalog-grok.test.ts @@ -179,32 +179,37 @@ describe('grok launch args', () => { }) }) -describe('grok agentArgsOverride', () => { - const modelOverride = GROK_SESSION_OPTION_CATALOG.modelApply.agentArgsOverride! - const effortOverride = grokEffortOption().apply.agentArgsOverride! +describe('grok agent-arg overrides', () => { + const removeModel = GROK_SESSION_OPTION_CATALOG.modelApply.removeAgentArgs! + const removeEffort = grokEffortOption().apply.removeAgentArgs! - it('detects a user-supplied model flag in every spelling', () => { + it('strips a user-supplied model flag in every spelling', () => { for (const tokens of [ ['-m', 'grok-build'], ['-mgrok-build'], ['--model', 'grok-build'], ['--model=grok-build'] ]) { - expect(modelOverride(tokens)).toBe(true) + expect(removeModel(tokens)).toEqual([]) } }) it('does not fire on a different flag or a positional that contains -m', () => { - expect(modelOverride(['--model-context', '8000'])).toBe(false) - expect(modelOverride(['summarize-my-diff'])).toBe(false) - expect(modelOverride(['--reasoning-effort', 'low'])).toBe(false) - expect(modelOverride([])).toBe(false) + for (const tokens of [ + ['--model-context', '8000'], + ['summarize-my-diff'], + ['--reasoning-effort', 'low'], + [] + ]) { + expect(removeModel(tokens)).toEqual(tokens) + } }) - it('detects both effort spellings', () => { - expect(effortOverride(['--effort', 'low'])).toBe(true) - expect(effortOverride(['--reasoning-effort=low'])).toBe(true) - expect(effortOverride(['--effortless'])).toBe(false) + it('strips both effort spellings', () => { + expect(removeEffort(['--effort', 'low', '--reasoning-effort=high', '--keep'])).toEqual([ + '--keep' + ]) + expect(removeEffort(['--effortless'])).toEqual(['--effortless']) }) it('drops only the overridden key from the launch record', () => { diff --git a/src/shared/agent-session-option-catalog-grok.ts b/src/shared/agent-session-option-catalog-grok.ts index 4f14fdbeade..3577ae036a0 100644 --- a/src/shared/agent-session-option-catalog-grok.ts +++ b/src/shared/agent-session-option-catalog-grok.ts @@ -1,9 +1,9 @@ -import { hasFlag } from './agent-cli-flag-detection' import type { AgentSessionOptionCatalog, CatalogModel, CatalogOption } from './agent-session-option-catalog-types' +import { removeAgentArgOption } from './agent-session-option-agent-args' import { parseGrokModelList } from './grok-model-list-probe' // The offered slice of grok's canonical ladder, low to high. Its `none` tier is @@ -35,7 +35,8 @@ function grokEffort(ceiling: 'high' | 'xhigh'): CatalogOption { }, apply: { launchArgs: (value) => ['--reasoning-effort', String(value)], - agentArgsOverride: (tokens) => hasFlag(tokens, ['--effort', '--reasoning-effort']), + // Why: grok reads both spellings. + removeAgentArgs: (tokens) => removeAgentArgOption(tokens, ['--effort', '--reasoning-effort']), midSession: { kind: 'command', build: (value) => `/effort ${String(value)}` } } } @@ -67,7 +68,7 @@ export const GROK_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = { ], modelApply: { launchArgs: (value) => ['-m', String(value)], - agentArgsOverride: (tokens) => hasFlag(tokens, ['-m', '--model']), + removeAgentArgs: (tokens) => removeAgentArgOption(tokens, ['-m', '--model']), // Why: `agent-picker` would replace the whole model list with "Choose in // agent picker…" and never persist a model, so `-m` would never be emitted. midSession: { kind: 'command', build: (value) => `/model ${String(value)}` } diff --git a/src/shared/agent-session-option-catalog-muse.ts b/src/shared/agent-session-option-catalog-muse.ts index 6b5df60fe4d..91163ca7267 100644 --- a/src/shared/agent-session-option-catalog-muse.ts +++ b/src/shared/agent-session-option-catalog-muse.ts @@ -1,4 +1,3 @@ -import { hasFlag } from './agent-cli-flag-detection' import { removeAgentArgOption } from './agent-session-option-agent-args' import type { AgentSessionOptionCatalog, CatalogOption } from './agent-session-option-catalog-types' @@ -22,7 +21,6 @@ const MUSE_EFFORT: CatalogOption = { }, apply: { launchArgs: (value) => ['--reasoning-effort', String(value)], - agentArgsOverride: (tokens) => hasFlag(tokens, ['--reasoning-effort']), removeAgentArgs: (tokens) => removeAgentArgOption(tokens, ['--reasoning-effort']) } } @@ -34,7 +32,6 @@ export const MUSE_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = { models: [], modelApply: { launchArgs: (value) => ['--model', String(value)], - agentArgsOverride: (tokens) => hasFlag(tokens, ['--model']), removeAgentArgs: (tokens) => removeAgentArgOption(tokens, ['--model']) }, unknownModelOptions: [MUSE_EFFORT] diff --git a/src/shared/agent-session-option-catalog-omp.test.ts b/src/shared/agent-session-option-catalog-omp.test.ts index fdac98154c6..b5bd44e32f3 100644 --- a/src/shared/agent-session-option-catalog-omp.test.ts +++ b/src/shared/agent-session-option-catalog-omp.test.ts @@ -116,13 +116,16 @@ describe('omp session option catalog', () => { }) it('yields to a user --model in the launch args, in either spelling', () => { - const override = OMP_SESSION_OPTION_CATALOG.modelApply.agentArgsOverride! - expect(override(['--model', 'opus'])).toBe(true) - expect(override(['--model=openai/gpt-5.5'])).toBe(true) - expect(override(['--no-extensions'])).toBe(false) + const remove = OMP_SESSION_OPTION_CATALOG.modelApply.removeAgentArgs! + expect(remove(['--model', 'opus', '--model=openai/gpt-5.5', '--no-extensions'])).toEqual([ + '--no-extensions' + ]) // `--models` scopes Ctrl+P cycling; it does not pick a model. omp has no `-m`. - expect(override(['--models=anthropic/*'])).toBe(false) - expect(override(['-m', 'opus'])).toBe(false) + expect(remove(['--models=anthropic/*', '-m', 'opus'])).toEqual([ + '--models=anthropic/*', + '-m', + 'opus' + ]) }) it('switches mid-session with /orca-model , which omp resolves exactly', () => { diff --git a/src/shared/agent-session-option-catalog-omp.ts b/src/shared/agent-session-option-catalog-omp.ts index 33b870b3d7d..312b3f6306a 100644 --- a/src/shared/agent-session-option-catalog-omp.ts +++ b/src/shared/agent-session-option-catalog-omp.ts @@ -1,4 +1,4 @@ -import { hasFlag } from './agent-cli-flag-detection' +import { removeAgentArgOption } from './agent-session-option-agent-args' import type { AgentSessionOptionCatalog, CatalogModel } from './agent-session-option-catalog-types' import { parseOmpModelList } from './omp-model-list-probe' @@ -18,7 +18,7 @@ export const OMP_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = { models: [], modelApply: { launchArgs: (value) => ['--model', String(value)], - agentArgsOverride: (tokens) => hasFlag(tokens, ['--model']), + removeAgentArgs: (tokens) => removeAgentArgOption(tokens, ['--model']), // OMP only opens its TUI picker for /model; our extension applies the exact selector. midSession: { kind: 'command', build: (value) => `/orca-model ${String(value)}` } }, diff --git a/src/shared/agent-session-option-catalog-removers.test.ts b/src/shared/agent-session-option-catalog-removers.test.ts new file mode 100644 index 00000000000..64262213d4e --- /dev/null +++ b/src/shared/agent-session-option-catalog-removers.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest' +import { getAgentSessionOptionCatalog } from './agent-session-option-catalog' +import { removeAgentArgOption } from './agent-session-option-agent-args' +import { + removeOverriddenAgentSessionArgs, + resolveAgentSessionOptionLaunch +} from './agent-session-option-launch' + +const removeCodexModel = getAgentSessionOptionCatalog('codex')!.modelApply.removeAgentArgs! + +describe('catalog removers', () => { + it.each([ + [['-c', 'model=o3']], + [['--config', 'model=o3']], + [['-c=model=o3']], + [['-cmodel=o3']], + [['--config=model=o3']] + ])('strips Codex model config %j and yields the picked model to it', (tokens) => { + expect(removeCodexModel([...tokens, '--search'])).toEqual(['--search']) + expect( + resolveAgentSessionOptionLaunch('codex', { model: 'gpt-5.5' }, tokens).appliedValues + ).toEqual({}) + }) + + it('keeps other Codex config, including effort, when stripping the model', () => { + const tokens = [ + '-c', + 'model_reasoning_effort=high', + '-cmodel_reasoning_effort=low', + '-csandbox=x' + ] + expect(removeCodexModel(tokens)).toEqual(tokens) + }) + + it('leaves everything after a terminator', () => { + const tokens = ['--', '-c', 'model=o3'] + expect(removeCodexModel(tokens)).toEqual(tokens) + }) + + it('lets a chat pick replace a configured gemini model on server launches', () => { + expect( + removeOverriddenAgentSessionArgs('gemini', { model: 'gemini-2.5-pro' }, [ + '-m', + 'a', + '--model=b', + '-mc', + '--yolo' + ]) + ).toEqual(['--yolo']) + }) +}) + +describe('removeAgentArgOption', () => { + it('consumes an empty value', () => { + expect(removeAgentArgOption(['--model', '', '--yolo'], ['--model'])).toEqual(['--yolo']) + }) + + it('keeps a following flag', () => { + expect(removeAgentArgOption(['--model', '--yolo'], ['--model'])).toEqual(['--yolo']) + }) +}) diff --git a/src/shared/agent-session-option-catalog-types.ts b/src/shared/agent-session-option-catalog-types.ts index 74e488e5549..fe15d5080e0 100644 --- a/src/shared/agent-session-option-catalog-types.ts +++ b/src/shared/agent-session-option-catalog-types.ts @@ -21,10 +21,8 @@ export type CatalogMidSessionApply = export type CatalogOptionApply = { launchArgs?: (value: SessionOptionValue) => string[] - /** Why: later free-form args win, so the launch record must discard any - * picker value that those args may have replaced. */ - agentArgsOverride?: (tokens: readonly string[]) => boolean - /** Removes conflicting defaults before a more specific launch choice is inserted. */ + /** Strips the free-form args that set this option. Why: later free-form args win, so + * anything this strips also tells the launch record to discard the picker value. */ removeAgentArgs?: (tokens: readonly string[]) => string[] composedIntoModel?: true midSession?: CatalogMidSessionApply @@ -75,7 +73,7 @@ export type AgentSessionOptionCatalog = { * * Known gap: "no model flag" is unverified. A user `-m` in `agentArgs` launches that * model while the picker, which never reads launch args, still names the CLI default. - * A real fix means threading `modelApply.agentArgsOverride` through to the surface. */ + * A real fix means threading `modelApply.removeAgentArgs` through to the surface. */ defaultModelIsCliDefault?: true listModels?: { command: string diff --git a/src/shared/agent-session-option-launch.ts b/src/shared/agent-session-option-launch.ts index 53df967a838..e9c5b90181d 100644 --- a/src/shared/agent-session-option-launch.ts +++ b/src/shared/agent-session-option-launch.ts @@ -1,5 +1,6 @@ import type { AgentType } from './agent-status-types' import { findCatalogModel, getAgentSessionOptionCatalog } from './agent-session-option-catalog' +import type { CatalogOptionApply } from './agent-session-option-catalog-types' import type { SessionOptionValue } from './native-chat-session-options' export type ResolvedSessionOptionLaunch = { @@ -7,6 +8,11 @@ export type ResolvedSessionOptionLaunch = { appliedValues: Record } +function isOverriddenByAgentArgs(apply: CatalogOptionApply, tokens: readonly string[]): boolean { + const kept = apply.removeAgentArgs?.(tokens) + return kept !== undefined && kept.length < tokens.length +} + export function removeOverriddenAgentSessionArgs( agent: AgentType, values: Record | null | undefined, @@ -63,7 +69,7 @@ export function resolveAgentSessionOptionLaunch( const composedModelId = catalog.composeModelValue ? catalog.composeModelValue(modelId, modelValues) : modelId - const modelOverridden = catalog.modelApply.agentArgsOverride?.(trailingAgentArgs) === true + const modelOverridden = isOverriddenByAgentArgs(catalog.modelApply, trailingAgentArgs) if (catalog.modelApply.launchArgs) { args.push(...catalog.modelApply.launchArgs(composedModelId)) @@ -86,7 +92,7 @@ export function resolveAgentSessionOptionLaunch( continue } args.push(...option.apply.launchArgs(value)) - if (!modelOverridden && !option.apply.agentArgsOverride?.(trailingAgentArgs)) { + if (!modelOverridden && !isOverriddenByAgentArgs(option.apply, trailingAgentArgs)) { appliedValues[option.id] = value } } diff --git a/src/shared/command-option-occurrence.ts b/src/shared/command-option-occurrence.ts index 6dee668829b..203e7252723 100644 --- a/src/shared/command-option-occurrence.ts +++ b/src/shared/command-option-occurrence.ts @@ -11,7 +11,7 @@ function matchesOption(token: string, aliases: readonly string[]): boolean { } export function findOptionOccurrence( - tokens: string[], + tokens: readonly string[], aliases: readonly string[], stopAtTerminator: boolean ): { index: number; consumed: number; value?: string } | null { From 4fdf6df25b55998a9e4f60189196cd6971058f16 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:27:54 -0700 Subject: [PATCH 02/67] Reuse the ancestor path while building mobile agent rows (#24539) Preserve traversal order and cycle guards using one call-local path Set rather than a copy at every depth. --- .../worktree/agent-row-lineage-parity.test.ts | 127 ++++++++++++++++++ mobile/src/worktree/agent-row-lineage.ts | 11 +- 2 files changed, 133 insertions(+), 5 deletions(-) create mode 100644 mobile/src/worktree/agent-row-lineage-parity.test.ts diff --git a/mobile/src/worktree/agent-row-lineage-parity.test.ts b/mobile/src/worktree/agent-row-lineage-parity.test.ts new file mode 100644 index 00000000000..10a73d47a4f --- /dev/null +++ b/mobile/src/worktree/agent-row-lineage-parity.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types' +import { + buildAgentRowLineageTree, + flattenAgentRowLineage, + type AgentRowNode +} from './agent-row-lineage' + +function row(paneKey: string, parentPaneKey: string | null): RuntimeWorktreeAgentRow { + return { + paneKey, + parentPaneKey, + state: 'working', + agentType: 'claude', + prompt: '', + taskTitle: null, + displayName: null, + lastAssistantMessage: null, + toolName: null, + toolInput: null, + interrupted: false, + stateStartedAt: 0, + updatedAt: 0 + } +} + +// Frozen pre-optimization traversal: duplicate pane keys may be emitted on distinct branches. +function legacyFlatten(rows: readonly RuntimeWorktreeAgentRow[]): AgentRowNode[] { + const { rootRows, childrenByParentPaneKey } = buildAgentRowLineageTree(rows) + const out: AgentRowNode[] = [] + const seen = new Set() + const visit = (agent: RuntimeWorktreeAgentRow, depth: number, ancestors: ReadonlySet) => { + if (ancestors.has(agent.paneKey)) { + return + } + seen.add(agent.paneKey) + out.push({ row: agent, depth, children: [] }) + const nextAncestors = new Set(ancestors) + nextAncestors.add(agent.paneKey) + for (const child of childrenByParentPaneKey.get(agent.paneKey) ?? []) { + visit(child, depth + 1, nextAncestors) + } + } + for (const root of rootRows) { + visit(root, 0, new Set()) + } + for (const agent of rows) { + if (!seen.has(agent.paneKey)) { + seen.add(agent.paneKey) + out.push({ row: agent, depth: 0, children: [] }) + } + } + return out +} + +describe('agent lineage traversal parity', () => { + it('matches the previous traversal across cycles, dangling parents, duplicates, and input order', () => { + const variants = ['a', 'b', 'c'].flatMap((paneKey) => + [null, 'a', 'b', 'c', 'missing'].map((parentPaneKey) => row(paneKey, parentPaneKey)) + ) + expect(flattenAgentRowLineage([])).toEqual(legacyFlatten([])) + for (const first of variants) { + for (const second of variants) { + for (const third of variants) { + const rows = [first, second, third] + const expected = legacyFlatten(rows) + const actual = flattenAgentRowLineage(rows) + expect(actual).toEqual(expected) + actual.forEach((node, index) => expect(node.row).toBe(expected[index]?.row)) + } + } + } + }) + + it('releases the ancestor path between duplicate roots and sibling branches', () => { + const firstRoot = row('root', null) + const secondRoot = row('root', null) + const firstChild = row('child', 'root') + const secondChild = row('child', 'root') + const grandchild = row('grandchild', 'child') + const rows = [firstRoot, firstChild, secondChild, grandchild, secondRoot] + const actual = flattenAgentRowLineage(rows) + expect(actual).toEqual(legacyFlatten(rows)) + expect(actual.map((node) => [rows.indexOf(node.row), node.depth])).toEqual([ + [0, 0], + [1, 1], + [3, 2], + [2, 1], + [3, 2], + [4, 0], + [1, 1], + [3, 2], + [2, 1], + [3, 2] + ]) + }) + + it('copies no ancestor members while traversing a long lineage', () => { + const rows = Array.from({ length: 512 }, (_, index) => + row(`pane-${index}`, index === 0 ? null : `pane-${index - 1}`) + ) + const NativeSet = globalThis.Set + let copiedMembers = 0 + class ObservedSet extends NativeSet { + constructor(values?: Iterable | null) { + super() + if (values) { + for (const value of values) { + copiedMembers += 1 + this.add(value) + } + } + } + } + vi.stubGlobal('Set', ObservedSet) + let actual: AgentRowNode[] + try { + actual = flattenAgentRowLineage(rows) + } finally { + vi.unstubAllGlobals() + } + expect(copiedMembers).toBe(0) + expect(actual.map((node) => [node.row.paneKey, node.depth])).toEqual( + rows.map((agent, index) => [agent.paneKey, index]) + ) + }) +}) diff --git a/mobile/src/worktree/agent-row-lineage.ts b/mobile/src/worktree/agent-row-lineage.ts index 4d3fe7ffe25..2c48c5b09c9 100644 --- a/mobile/src/worktree/agent-row-lineage.ts +++ b/mobile/src/worktree/agent-row-lineage.ts @@ -59,21 +59,22 @@ export function flattenAgentRowLineage(rows: readonly RuntimeWorktreeAgentRow[]) const { rootRows, childrenByParentPaneKey } = buildAgentRowLineageTree(rows) const out: AgentRowNode[] = [] const seen = new Set() - const visit = (row: RuntimeWorktreeAgentRow, depth: number, ancestors: ReadonlySet) => { + const ancestors = new Set() + const visit = (row: RuntimeWorktreeAgentRow, depth: number) => { if (ancestors.has(row.paneKey)) { return } seen.add(row.paneKey) const node: AgentRowNode = { row, depth, children: [] } out.push(node) - const nextAncestors = new Set(ancestors) - nextAncestors.add(row.paneKey) + ancestors.add(row.paneKey) for (const child of childrenByParentPaneKey.get(row.paneKey) ?? []) { - visit(child, depth + 1, nextAncestors) + visit(child, depth + 1) } + ancestors.delete(row.paneKey) } for (const root of rootRows) { - visit(root, 0, new Set()) + visit(root, 0) } // Why: a cyclic component that coexists with a normal rooted tree has no entry // in rootRows and is unreachable from any root, so it would silently vanish. From 8b1dc63459171575b3b0c8a93cb0496be3dab8d2 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:27:58 -0700 Subject: [PATCH 03/67] Release waiting terminal output when a mobile subscription fails to start (#24547) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dispose the failed subscription record’s existing terminal backlog before rethrowing its original start error. --- ...ge-host-subscription-start-cleanup.test.ts | 217 ++++++++++++++++++ .../bridge-host-subscriptions.ts | 1 + 2 files changed, 218 insertions(+) create mode 100644 mobile/src/mobile-web-shell/bridge-host-subscription-start-cleanup.test.ts diff --git a/mobile/src/mobile-web-shell/bridge-host-subscription-start-cleanup.test.ts b/mobile/src/mobile-web-shell/bridge-host-subscription-start-cleanup.test.ts new file mode 100644 index 00000000000..1f30232828f --- /dev/null +++ b/mobile/src/mobile-web-shell/bridge-host-subscription-start-cleanup.test.ts @@ -0,0 +1,217 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { BRIDGE_MAX_MESSAGE_BYTES } from './bridge/bridge-caps' +import { createFakeRpcClient } from './bridge-host-test-fakes' +import { harness, ID, subscribeFrame } from './bridge-host-test-harness' +import { BRIDGE_MAX_UNACKED_FRAMES, BridgeHostSubscriptions } from './bridge-host-subscriptions' +import { + BridgeTerminalOutputBacklog, + TERMINAL_STREAM_ACK_SILENCE_MS, + type TerminalBacklogTimers +} from './bridge-terminal-output-backlog' + +const HELD_OUTPUT = { type: 'data', streamId: 1, chunk: 'h'.repeat(48 * 1024) } + +function manualTimers(): TerminalBacklogTimers & { + pending: () => number + delays: number[] + fire: () => void +} { + const handlers = new Map void>() + const delays: number[] = [] + let nextHandle = 0 + return { + set: (handler, ms) => { + nextHandle += 1 + handlers.set(nextHandle, handler) + delays.push(ms) + return nextHandle + }, + clear: (handle) => { + handlers.delete(handle) + }, + pending: () => handlers.size, + delays, + fire: () => { + for (const [handle, handler] of handlers) { + handlers.delete(handle) + handler() + } + } + } +} + +function emitWindowAndHold(onData: (payload: unknown) => void): void { + for (let index = 0; index < BRIDGE_MAX_UNACKED_FRAMES; index += 1) { + onData({ type: 'data', streamId: 1, chunk: String(index) }) + } + onData(HELD_OUTPUT) +} + +function observeBacklogs(): BridgeTerminalOutputBacklog[] { + const backlogs: BridgeTerminalOutputBacklog[] = [] + const hold = BridgeTerminalOutputBacklog.prototype.hold + vi.spyOn(BridgeTerminalOutputBacklog.prototype, 'hold').mockImplementation(function ( + this: BridgeTerminalOutputBacklog, + payload: unknown + ) { + if (!backlogs.includes(this)) { + backlogs.push(this) + } + return hold.call(this, payload) + }) + return backlogs +} + +afterEach(() => vi.restoreAllMocks()) + +describe('bridge subscription start cleanup', () => { + it('rethrows the original immediate failure without keeping a slot or timer', () => { + const failure = new Error('subscribe failed') + const timers = manualTimers() + const subscriptions = new BridgeHostSubscriptions({ + client: { + ...createFakeRpcClient(), + subscribe: () => { + throw failure + } + }, + post: vi.fn(), + onBinaryFrameDropped: vi.fn(), + terminalTimers: timers + }) + + expect(() => subscriptions.start(ID, 'terminal.subscribe', {})).toThrow(failure) + expect(subscriptions.size).toBe(0) + expect(timers.pending()).toBe(0) + expect(timers.delays).toEqual([]) + }) + + it('releases held output and its silence timer when subscribe emits and then throws', () => { + const failure = new Error('subscribe failed after output') + const timers = manualTimers() + const backlogs = observeBacklogs() + const subscriptions = new BridgeHostSubscriptions({ + client: { + ...createFakeRpcClient(), + subscribe: (_method, _params, onData) => { + emitWindowAndHold(onData) + expect(backlogs[0]?.pendingBytes).toBe(JSON.stringify(HELD_OUTPUT).length) + expect(timers.pending()).toBe(1) + throw failure + } + }, + post: vi.fn(), + onBinaryFrameDropped: vi.fn(), + terminalTimers: timers + }) + + let thrown: unknown + try { + subscriptions.start(ID, 'terminal.subscribe', {}) + } catch (error) { + thrown = error + } + expect(thrown).toBe(failure) + expect(subscriptions.size).toBe(0) + expect(backlogs).toHaveLength(1) + expect({ + pendingBytes: backlogs[0]?.pendingBytes, + held: backlogs[0]?.held, + liveTimers: timers.pending() + }).toEqual({ pendingBytes: 0, held: false, liveTimers: 0 }) + expect(timers.delays).toEqual([TERMINAL_STREAM_ACK_SILENCE_MS]) + }) + + it('answers the original failure and leaves a same-ID replacement alive past the old deadline', () => { + const client = createFakeRpcClient() + const timers = manualTimers() + let starts = 0 + const bridge = harness({ + ready: true, + terminalTimers: timers, + client: { + ...client, + subscribe: (method, params, onData, options) => { + starts += 1 + if (starts === 1) { + emitWindowAndHold(onData) + throw new Error('failed after output') + } + return client.subscribe(method, params, onData, options) + } + } + }) + + bridge.host.receive(subscribeFrame(ID)) + expect(bridge.last()).toMatchObject({ type: 'error', id: ID }) + bridge.host.receive(subscribeFrame(ID)) + expect(client.streams).toHaveLength(1) + timers.fire() + expect(client.streams[0]?.unsubscribes).toBe(0) + client.streams[0]?.emit({ type: 'data', streamId: 2, chunk: 'replacement still live' }) + expect(bridge.last()).toEqual({ + v: 1, + type: 'event', + id: ID, + seq: 1, + payload: { type: 'data', streamId: 2, chunk: 'replacement still live' } + }) + expect(bridge.frames().filter((frame) => frame.type === 'end')).toEqual([]) + bridge.host.dispose() + expect(client.streams[0]?.unsubscribes).toBe(1) + }) + + it('keeps a successful synchronous backlog until ack and unsubscribes once on cancel', () => { + const timers = manualTimers() + const backlogs = observeBacklogs() + const unsubscribe = vi.fn() + const subscriptions = new BridgeHostSubscriptions({ + client: { + ...createFakeRpcClient(), + subscribe: (_method, _params, onData) => { + emitWindowAndHold(onData) + return unsubscribe + } + }, + post: vi.fn(), + onBinaryFrameDropped: vi.fn(), + terminalTimers: timers + }) + + subscriptions.start(ID, 'terminal.subscribe', {}) + expect(subscriptions.has(ID)).toBe(true) + expect(backlogs[0]?.pendingBytes).toBe(JSON.stringify(HELD_OUTPUT).length) + expect(timers.pending()).toBe(1) + subscriptions.ack(ID, BRIDGE_MAX_UNACKED_FRAMES) + expect(backlogs[0]?.pendingBytes).toBe(0) + expect(timers.pending()).toBe(0) + subscriptions.cancel(ID, null) + subscriptions.cancel(ID, null) + expect(unsubscribe).toHaveBeenCalledTimes(1) + }) + + it('preserves an overflow emitted before the original subscribe failure', () => { + const failure = new Error('failure after overflow') + const timers = manualTimers() + const post = vi.fn() + const subscriptions = new BridgeHostSubscriptions({ + client: { + ...createFakeRpcClient(), + subscribe: (_method, _params, onData) => { + onData('z'.repeat(BRIDGE_MAX_MESSAGE_BYTES)) + throw failure + } + }, + post, + onBinaryFrameDropped: vi.fn(), + terminalTimers: timers + }) + + expect(() => subscriptions.start(ID, 'terminal.subscribe', {})).toThrow(failure) + expect(post.mock.calls).toEqual([ + [JSON.stringify({ v: 1, type: 'end', id: ID, reason: 'overflow' })] + ]) + expect(subscriptions.size).toBe(0) + expect(timers.pending()).toBe(0) + }) +}) diff --git a/mobile/src/mobile-web-shell/bridge-host-subscriptions.ts b/mobile/src/mobile-web-shell/bridge-host-subscriptions.ts index ab5430f15f6..e6f6847e6d9 100644 --- a/mobile/src/mobile-web-shell/bridge-host-subscriptions.ts +++ b/mobile/src/mobile-web-shell/bridge-host-subscriptions.ts @@ -143,6 +143,7 @@ export class BridgeHostSubscriptions { ) } catch (error) { this.open.delete(id) + record.backlog?.dispose() throw error } // A stream that emitted and overflowed inside `subscribe` is already retired, and its From 558c10f616232b12496044c09ccdb9589448c258 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:28:01 -0700 Subject: [PATCH 04/67] Avoid cloning terminal agent owners twice in relay listings (#24713) Capture the existing host-age expression at its original time, then use one call-local fresh owner clone for the length check and unchanged published owner field. --- ...ndler-owner-inventory-clone-budget.test.ts | 398 ++++++++++++++++++ src/relay/pty-handler.ts | 8 +- 2 files changed, 402 insertions(+), 4 deletions(-) create mode 100644 src/relay/pty-handler-owner-inventory-clone-budget.test.ts diff --git a/src/relay/pty-handler-owner-inventory-clone-budget.test.ts b/src/relay/pty-handler-owner-inventory-clone-budget.test.ts new file mode 100644 index 00000000000..bb283e47992 --- /dev/null +++ b/src/relay/pty-handler-owner-inventory-clone-budget.test.ts @@ -0,0 +1,398 @@ +import './mock-descendant-sweep' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as CryptoModule from 'node:crypto' +import type * as SnapshotModule from '../shared/process-table-snapshot-reader' +import { RelayDispatcher } from './dispatcher' +import { encodeJsonRpcFrame, parseJsonRpcMessage, type JsonRpcResponse } from './protocol' +import { PtyHandler } from './pty-handler' +import { ClaimedAgentPtyOwnerRegistry } from '../shared/claimed-agent-pty-owner' +import { + isAgentSessionClaimedSpawnResult, + isAgentSessionOwnerBinding, + type AgentSessionOwnerBinding +} from '../shared/agent-session-host-authority' +import * as ptyShell from './pty-shell-utils' + +const mocks = vi.hoisted(() => ({ spawn: vi.fn(), snapshots: vi.fn(), uuid: 0 })) +vi.mock('node:crypto', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + randomUUID: () => `00000000-0000-4000-8000-${String(++mocks.uuid).padStart(12, '0')}` + } +}) +vi.mock('node-pty', () => ({ spawn: mocks.spawn })) +vi.mock('../shared/process-table-snapshot-reader', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, getStrictProcessTableSnapshotWithAge: mocks.snapshots } +}) +vi.mock('../main/pty/posix-pty-process-groups', () => ({ + forceKillPosixPtyProcessGroups: vi.fn((_pid: number, fallback: () => void) => fallback()) +})) + +type InventoryRow = { + id: string + incarnationId: string + agentSessionOwners?: AgentSessionOwnerBinding[] +} + +function inventoryRows(value: unknown): InventoryRow[] { + if (!Array.isArray(value)) { + throw new Error('Inventory result must be an array') + } + return value.map((row: unknown) => { + if ( + typeof row !== 'object' || + row === null || + !('id' in row) || + typeof row.id !== 'string' || + !('incarnationId' in row) || + typeof row.incarnationId !== 'string' + ) { + throw new Error('Inventory row identity is missing') + } + const owners = 'agentSessionOwners' in row ? row.agentSessionOwners : undefined + if ( + owners !== undefined && + (!Array.isArray(owners) || !owners.every(isAgentSessionOwnerBinding)) + ) { + throw new Error('Inventory owners are malformed') + } + return { + id: row.id, + incarnationId: row.incarnationId, + ...(owners ? { agentSessionOwners: owners } : {}) + } + }) +} + +describe('relay owner inventory clone budget', () => { + let dispatcher: RelayDispatcher + let handler: PtyHandler + let originalPlatform: PropertyDescriptor | undefined + let requestId = 0 + const frames: Buffer[] = [] + + async function response(method: string, params: Record = {}) { + const id = ++requestId + const before = frames.length + dispatcher.feed(encodeJsonRpcFrame({ jsonrpc: '2.0', id, method, params }, id, 0)) + for (let turn = 0; turn < 50 && frames.length === before; turn++) { + await Promise.resolve() + } + expect(frames).toHaveLength(before + 1) + const frame = frames[before] + const message = parseJsonRpcMessage(frame.subarray(13)) + return { message, frame } + } + + async function request(method: string, params: Record = {}) { + const { message, frame } = await response(method, params) + const id = requestId + if (!('id' in message) || message.id !== id || 'method' in message || !('result' in message)) { + throw new Error(`Unexpected RPC response: ${JSON.stringify(message)}`) + } + return { result: message.result, frame } + } + + beforeEach(() => { + originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) + vi.useFakeTimers() + vi.spyOn(Date, 'now').mockReturnValue(1_000_000) + vi.spyOn(ptyShell, 'isProcessAlive').mockReturnValue(true) + mocks.snapshots.mockReset().mockResolvedValue({ rows: [], capturedAgeMs: 0 }) + mocks.uuid = 0 + mocks.spawn.mockReset().mockImplementation(() => ({ + pid: process.pid, + process: 'zsh', + onData: vi.fn(), + onExit: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + clear: vi.fn(), + pause: vi.fn(), + resume: vi.fn() + })) + requestId = 0 + frames.length = 0 + dispatcher = new RelayDispatcher((frame) => { + frames.push(Buffer.from(frame)) + return true + }) + handler = new PtyHandler(dispatcher, undefined, 'owner-budget') + }) + + afterEach(async () => { + await handler.dispose({ waitForPhysicalExit: false }) + dispatcher.dispose() + vi.restoreAllMocks() + vi.useRealTimers() + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + }) + + it.each([false, true, undefined])( + 'samples the complete frame clock before cloning, evidence=%s', + async (includeForegroundProcessEvidence) => { + const spawned = await request('pty.spawn', { + cwd: process.cwd(), + env: { ORCA_PANE_KEY: 'clock-pane' }, + agentSessionEnsure: { + claim: { + digestVersion: 1, + keyId: 'clock-key', + identityDigest: 'a'.repeat(43), + worktreeScopeDigest: 'b'.repeat(43), + agent: 'codex' + }, + surface: { + worktreeId: 'clock-folder', + tabId: '11111111-1111-4111-8111-111111111111', + leafId: '22222222-2222-4222-8222-222222222222', + terminalHandle: 'term_clock' + } + } + }) + const identity = inventoryRows([spawned.result])[0] + if ( + typeof spawned.result !== 'object' || + spawned.result === null || + !('agentSessionEnsure' in spawned.result) || + !isAgentSessionClaimedSpawnResult(spawned.result.agentSessionEnsure) + ) { + throw new Error('Claimed spawn receipt is missing') + } + let now = 1_000_017 + vi.spyOn(Date, 'now').mockImplementation(() => now) + const originalList = ClaimedAgentPtyOwnerRegistry.prototype.listForPty + vi.spyOn(ClaimedAgentPtyOwnerRegistry.prototype, 'listForPty').mockImplementation(function ( + this: ClaimedAgentPtyOwnerRegistry, + id: string + ) { + const owners = originalList.call(this, id) + now += 20 + return owners + }) + for (let iteration = 0; iteration < 2; iteration++) { + now = 1_000_017 + iteration * 100 + const receipt = await request( + 'pty.listProcesses', + includeForegroundProcessEvidence === undefined ? {} : { includeForegroundProcessEvidence } + ) + const full = [ + { + id: identity.id, + incarnationId: identity.incarnationId, + cwd: process.cwd(), + title: 'zsh', + hostAgeMs: 17 + iteration * 100, + paneBound: true, + ...(includeForegroundProcessEvidence !== false + ? { + foregroundProcessEvidence: { + authorityGeneration: 'owner-budget', + observationEpoch: iteration + 1, + capturedAgeMs: 0, + verdict: 'unverifiable', + reason: 'root_missing' + } + } + : {}), + agentSessionOwners: [spawned.result.agentSessionEnsure.owner] + } + ] + expect(receipt.result).toEqual(full) + expect( + receipt.frame.equals( + encodeJsonRpcFrame( + { jsonrpc: '2.0', id: requestId, result: full }, + requestId, + requestId + ) + ) + ).toBe(true) + } + } + ) + + it('publishes the clock error before attempting an owner clone', async () => { + await request('pty.spawn', { cwd: process.cwd() }) + const list = vi + .spyOn(ClaimedAgentPtyOwnerRegistry.prototype, 'listForPty') + .mockImplementation(() => { + throw new Error('owner-clone-failed') + }) + // Isolate the age read after the decoder, receive and inventory clock stamps. + const clock = vi + .spyOn(Date, 'now') + .mockReturnValue(1_000_000) + .mockReturnValueOnce(1_000_000) + .mockReturnValueOnce(1_000_000) + .mockReturnValueOnce(1_000_000) + .mockImplementationOnce(() => { + throw new Error('host-clock-failed') + }) + try { + const receipt = await response('pty.listProcesses', { + includeForegroundProcessEvidence: false + }) + const expected: JsonRpcResponse = { + jsonrpc: '2.0', + id: requestId, + error: { code: -32000, message: 'host-clock-failed' } + } + expect(receipt.message).toEqual(expected) + expect(receipt.frame.equals(encodeJsonRpcFrame(expected, requestId, requestId))).toBe(true) + expect(list).not.toHaveBeenCalled() + } finally { + clock.mockReturnValue(1_000_000) + } + }) + + it.each([false, true, undefined])( + 'keeps complete RPC frames and fresh owners, evidence=%s', + async (includeForegroundProcessEvidence) => { + const withEvidence = includeForegroundProcessEvidence !== false + const expected: Record[] = [] + const ensure = vi.spyOn(ClaimedAgentPtyOwnerRegistry.prototype, 'ensure') + let firstOwner: AgentSessionOwnerBinding | null = null + const ownedCount = 8 + const plainCount = 3 + for (let index = 0; index < ownedCount + plainCount; index++) { + const claim = { + digestVersion: 1, + keyId: `key${index}`, + identityDigest: 'a'.repeat(43), + worktreeScopeDigest: 'b'.repeat(43), + agent: 'codex' + } + const surface = { + worktreeId: `folder${index}`, + tabId: '11111111-1111-4111-8111-111111111111', + leafId: '22222222-2222-4222-8222-222222222222', + terminalHandle: `term_owner${index}` + } + const spawned = await request('pty.spawn', { + cwd: process.cwd(), + env: { ORCA_PANE_KEY: `pane${index}` }, + ...(index < ownedCount ? { agentSessionEnsure: { claim, surface } } : {}) + }) + const identity = inventoryRows([spawned.result])[0] + let owner: AgentSessionOwnerBinding | null = null + if (index < ownedCount) { + if ( + typeof spawned.result !== 'object' || + spawned.result === null || + !('agentSessionEnsure' in spawned.result) || + !isAgentSessionClaimedSpawnResult(spawned.result.agentSessionEnsure) + ) { + throw new Error('Claimed spawn receipt is missing') + } + owner = spawned.result.agentSessionEnsure.owner + firstOwner ??= owner + } + expected.push({ + id: identity.id, + incarnationId: identity.incarnationId, + cwd: process.cwd(), + title: 'zsh', + hostAgeMs: 0, + paneBound: true, + ...(owner ? { agentSessionOwners: [owner] } : {}) + }) + } + const registry = ensure.mock.contexts[0] + if (!(registry instanceof ClaimedAgentPtyOwnerRegistry) || !firstOwner) { + throw new Error('Actual claimed registry was not used') + } + const secondOwner: AgentSessionOwnerBinding = { + ...firstOwner, + claim: { ...firstOwner.claim, keyId: 'second-owner' }, + surface: { ...firstOwner.surface, terminalHandle: 'term_second' }, + generation: 'second-generation' + } + registry.register(secondOwner) + expected[0].agentSessionOwners = [firstOwner, secondOwner] + const list = vi.spyOn(ClaimedAgentPtyOwnerRegistry.prototype, 'listForPty') + let previous: InventoryRow[] | null = null + let previousClone: AgentSessionOwnerBinding[] | null = null + for (let iteration = 0; iteration < 10; iteration++) { + const firstCloneIndex = list.mock.results.length + const response = await request( + 'pty.listProcesses', + includeForegroundProcessEvidence === undefined ? {} : { includeForegroundProcessEvidence } + ) + const full = expected.map(({ agentSessionOwners, ...row }) => ({ + ...row, + ...(withEvidence + ? { + foregroundProcessEvidence: { + authorityGeneration: 'owner-budget', + observationEpoch: iteration + 1, + capturedAgeMs: 0, + verdict: 'unverifiable', + reason: 'root_missing' + } + } + : {}), + ...(agentSessionOwners ? { agentSessionOwners } : {}) + })) + expect(response.result).toEqual(full) + const expectedFrame = encodeJsonRpcFrame( + { jsonrpc: '2.0', id: requestId, result: full }, + requestId, + requestId + ) + expect(response.frame.equals(expectedFrame)).toBe(true) + const rows = inventoryRows(response.result) + const firstClone: unknown = list.mock.results[firstCloneIndex].value + if (!Array.isArray(firstClone) || !firstClone.every(isAgentSessionOwnerBinding)) { + throw new Error('Actual registry clone is malformed') + } + if (previousClone) { + expect(firstClone).not.toBe(previousClone) + expect(firstClone[0]).not.toBe(previousClone[0]) + expect(firstClone[0].claim).not.toBe(previousClone[0].claim) + expect(firstClone[0].surface).not.toBe(previousClone[0].surface) + } + if (previous) { + expect(rows[0].agentSessionOwners).not.toBe(previous[0].agentSessionOwners) + expect(rows[0].agentSessionOwners?.[0]).not.toBe(previous[0].agentSessionOwners?.[0]) + expect(rows[0].agentSessionOwners?.[0].claim).not.toBe( + previous[0].agentSessionOwners?.[0].claim + ) + expect(rows[0].agentSessionOwners?.[0].surface).not.toBe( + previous[0].agentSessionOwners?.[0].surface + ) + } + const owner = rows[0].agentSessionOwners?.[0] + if (!owner) { + throw new Error('Expected first owner') + } + if (iteration === 4) { + registry.release(secondOwner.ptyId, secondOwner.generation) + expected[0].agentSessionOwners = [firstOwner] + } + owner.claim.keyId = 'mutated-client-copy' + owner.surface.terminalHandle = 'term_mutated' + firstClone[0].claim.keyId = 'mutated-published-clone' + firstClone[0].surface.terminalHandle = 'term_mutated_clone' + previousClone = firstClone + previous = rows + } + expect(mocks.snapshots).toHaveBeenCalledTimes(withEvidence ? 10 : 0) + const copiedOwners = list.mock.results.reduce((count, result) => { + const owners: unknown = result.value + if (!Array.isArray(owners) || !owners.every(isAgentSessionOwnerBinding)) { + throw new Error('Actual registry clone is malformed') + } + return count + owners.length + }, 0) + expect(copiedOwners).toBe(85) + expect(list).toHaveBeenCalledTimes((ownedCount + plainCount) * 10) + } + ) +}) diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 1311be13b67..579e920269c 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -3055,12 +3055,14 @@ export class PtyHandler { } ) : undefined + const hostAgeMs = Math.max(0, Date.now() - managed.createdAt) + const agentSessionOwners = this.agentSessionOwners.listForPty(id) results.push({ id, incarnationId: managed.incarnationId, cwd: managed.initialCwd, title, - hostAgeMs: Math.max(0, Date.now() - managed.createdAt), + hostAgeMs, paneBound: Boolean(managed.paneKey ?? managed.attachIdentity?.paneKey), ...(managed.ownerClientInstanceId ? { ownerClientInstanceId: managed.ownerClientInstanceId } @@ -3068,9 +3070,7 @@ export class PtyHandler { ...(managed.worktreeId ? { worktreeId: managed.worktreeId } : {}), ...(managed.terminalHandle ? { terminalHandle: managed.terminalHandle } : {}), ...(foregroundProcessEvidence ? { foregroundProcessEvidence } : {}), - ...(this.agentSessionOwners.listForPty(id).length - ? { agentSessionOwners: this.agentSessionOwners.listForPty(id) } - : {}) + ...(agentSessionOwners.length ? { agentSessionOwners } : {}) }) } return results From caf8078d13a5edea223a72dd89b67c04026215a3 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:31:23 -0700 Subject: [PATCH 05/67] Reuse prepared statements for internal worker checks (#24573) * Reuse prepared statements across remaining Dispatch reads Use the existing schema-pinned complete Dispatch column list in five remaining read modules, enabling the existing bounded statement cache without caching results. * Reuse prepared statements for internal worker checks Use the existing complete Dispatch projection only for named-field internal consumers; preserve original wildcard reads for every returned failure snapshot. --- .../db/decision-gates/decision-gate-store.ts | 9 +- .../dispatch-context/dispatch-completion.ts | 25 +- .../orchestration/db/dispatch-depth.ts | 5 +- .../legacy/legacy-compatibility-candidates.ts | 6 +- .../foreign-direct-mailbox-routing.ts | 17 +- ...ing-dispatch-statement-compilation.test.ts | 295 ++++++++++++++++++ 6 files changed, 335 insertions(+), 22 deletions(-) create mode 100644 src/main/runtime/orchestration/db/remaining-dispatch-statement-compilation.test.ts diff --git a/src/main/runtime/orchestration/db/decision-gates/decision-gate-store.ts b/src/main/runtime/orchestration/db/decision-gates/decision-gate-store.ts index 296bcea42bc..7884141973e 100644 --- a/src/main/runtime/orchestration/db/decision-gates/decision-gate-store.ts +++ b/src/main/runtime/orchestration/db/decision-gates/decision-gate-store.ts @@ -3,6 +3,7 @@ import { OrchestrationError } from '../../orchestration-error' import { generateId } from '../generated-id' import type { OrchestrationDb } from '../orchestration-db' import { transitionLifecycleWithDb } from '../lifecycle-transition' +import { DISPATCH_CONTEXT_COLUMN_LIST } from '../row-column-lists' // ── Decision Gates ── @@ -27,13 +28,15 @@ export function createGate( } const runId = task.run_id this.requireRun(runId) - const active = this.db + const rawActive = this.db .prepare( - `SELECT * FROM dispatch_contexts + `SELECT ${DISPATCH_CONTEXT_COLUMN_LIST} FROM dispatch_contexts WHERE task_id = ? AND status IN ('pending', 'dispatched') ORDER BY rowid DESC LIMIT 1` ) - .get(gate.taskId) as DispatchContextRow | undefined + .get(gate.taskId) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The schema-pinned complete Dispatch projection returns one Dispatch row or undefined; the adapter exposes unknown. + const active = rawActive as DispatchContextRow | undefined if ( gate.requester && (!active || diff --git a/src/main/runtime/orchestration/db/dispatch-context/dispatch-completion.ts b/src/main/runtime/orchestration/db/dispatch-context/dispatch-completion.ts index be1cf99d2b4..5821e5c5463 100644 --- a/src/main/runtime/orchestration/db/dispatch-context/dispatch-completion.ts +++ b/src/main/runtime/orchestration/db/dispatch-context/dispatch-completion.ts @@ -3,6 +3,7 @@ import { OrchestrationError } from '../../orchestration-error' import { DISPATCH_CIRCUIT_BREAK_FAILURES } from './dispatch-circuit-breaker' import type { OrchestrationDb } from '../orchestration-db' import { getActiveDispatchForTask } from './task-dispatch-reconciliation' +import { DISPATCH_CONTEXT_COLUMN_LIST } from '../row-column-lists' import { beginLifecycleWriteTransaction, commitLifecycleWriteTransaction, @@ -46,11 +47,13 @@ export function settleActiveDispatchesForTask( status: 'completed' | 'failed', failure?: string ): void { - const rows = db.db + const rawRows = db.db .prepare( - "SELECT * FROM dispatch_contexts WHERE task_id = ? AND status IN ('pending', 'dispatched')" + `SELECT ${DISPATCH_CONTEXT_COLUMN_LIST} FROM dispatch_contexts WHERE task_id = ? AND status IN ('pending', 'dispatched')` ) - .all(taskId) as DispatchContextRow[] + .all(taskId) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The existing complete Dispatch projection is pinned to this table's schema by row-column-lists.test.ts. + const rows = rawRows as DispatchContextRow[] for (const row of rows) { transitionLifecycleWithDb(db.db, { entity: 'dispatch', @@ -97,15 +100,17 @@ export function getStaleDispatches( this: OrchestrationDb, thresholdIso: string ): DispatchContextRow[] { - return this.db + const rows = this.db .prepare( - `SELECT * FROM dispatch_contexts + `SELECT ${DISPATCH_CONTEXT_COLUMN_LIST} FROM dispatch_contexts WHERE status = 'dispatched' AND dispatched_at IS NOT NULL AND julianday(dispatched_at) < julianday(?) AND (last_heartbeat_at IS NULL OR julianday(last_heartbeat_at) < julianday(?))` ) - .all(thresholdIso, thresholdIso) as DispatchContextRow[] + .all(thresholdIso, thresholdIso) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The existing complete Dispatch projection is pinned to this table's schema by row-column-lists.test.ts. + return rows as DispatchContextRow[] } export function failDispatch( @@ -164,9 +169,11 @@ export function failDispatch( capability_revoked_at: before.capability_revoked_at ?? new Date().toISOString() } }) - const ctx = this.db.prepare('SELECT * FROM dispatch_contexts WHERE id = ?').get(ctxId) as - | DispatchContextRow - | undefined + const rawContext = this.db + .prepare(`SELECT ${DISPATCH_CONTEXT_COLUMN_LIST} FROM dispatch_contexts WHERE id = ?`) + .get(ctxId) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The schema-pinned complete Dispatch projection returns one Dispatch row or undefined; the adapter exposes unknown. + const ctx = rawContext as DispatchContextRow | undefined if (!ctx) { commitLifecycleWriteTransaction(this.db, transaction) return undefined diff --git a/src/main/runtime/orchestration/db/dispatch-depth.ts b/src/main/runtime/orchestration/db/dispatch-depth.ts index 93c5bd65fb6..32020aa3b3f 100644 --- a/src/main/runtime/orchestration/db/dispatch-depth.ts +++ b/src/main/runtime/orchestration/db/dispatch-depth.ts @@ -10,6 +10,7 @@ import type { OrchestrationDb } from './orchestration-db' import type { OrcaSessionId } from '../../../../shared/orca-session-address' import type { DispatchContextRow, RemoteDispatchAttachmentRow } from '../types' import { potentiallyLiveRemoteAttachmentSql } from './federation/remote-attachment-liveness' +import { DISPATCH_CONTEXT_COLUMN_LIST } from './row-column-lists' /** * Who is creating a dispatch row, for nesting-depth purposes. @@ -147,12 +148,12 @@ function findActiveDispatchForCreator( } const row = this.db .prepare( - `SELECT * FROM dispatch_contexts + `SELECT ${DISPATCH_CONTEXT_COLUMN_LIST} FROM dispatch_contexts WHERE assignee_orca_session_id = ? AND status IN ('pending', 'dispatched') ORDER BY rowid DESC LIMIT 1` ) .get(creator.orcaSessionId) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: SELECT * over this table returns the row shape its schema and row type define, like every row cast in db/. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The schema-pinned complete Dispatch projection returns one Dispatch row or undefined; the adapter exposes unknown. return row as DispatchContextRow | undefined } diff --git a/src/main/runtime/orchestration/db/legacy/legacy-compatibility-candidates.ts b/src/main/runtime/orchestration/db/legacy/legacy-compatibility-candidates.ts index d94c89ac4b2..24cb88be578 100644 --- a/src/main/runtime/orchestration/db/legacy/legacy-compatibility-candidates.ts +++ b/src/main/runtime/orchestration/db/legacy/legacy-compatibility-candidates.ts @@ -7,6 +7,7 @@ import { OrchestrationError } from '../../orchestration-error' import { LEGACY_CONTRACT_VERSION } from '../contract-constants' import { isEquivalentPaneKey } from '../pane-key-match' import type { OrchestrationDb } from '../orchestration-db' +import { DISPATCH_CONTEXT_COLUMN_LIST } from '../row-column-lists' export function resolveLegacyCompatibilityPrincipalByIdentity( this: OrchestrationDb, @@ -60,9 +61,10 @@ export function resolveLegacyWorkerCandidate( ? [this.getDispatchContextById(params.dispatchId)].filter( (row): row is DispatchContextRow => row !== undefined ) - : (this.db + : // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The existing complete Dispatch projection is pinned to this table's schema by row-column-lists.test.ts. + (this.db .prepare( - `SELECT * FROM dispatch_contexts + `SELECT ${DISPATCH_CONTEXT_COLUMN_LIST} FROM dispatch_contexts WHERE run_id = ? AND contract_version = ? AND status IN ('pending', 'dispatched') ORDER BY rowid` diff --git a/src/main/runtime/orchestration/db/messages/foreign-direct-mailbox-routing.ts b/src/main/runtime/orchestration/db/messages/foreign-direct-mailbox-routing.ts index 1b5afd1fbcb..f8ebfb4762c 100644 --- a/src/main/runtime/orchestration/db/messages/foreign-direct-mailbox-routing.ts +++ b/src/main/runtime/orchestration/db/messages/foreign-direct-mailbox-routing.ts @@ -2,6 +2,7 @@ import { parsePaneKey } from '../../../../../shared/stable-pane-id' import type { DispatchContextRow, MessageType } from '../../types' import { DISPATCH_PANE_KEY_MATCH_SUFFIX_SQL, paneKeyMatchSuffix } from '../pane-key-match' import type { OrchestrationDb } from '../orchestration-db' +import { DISPATCH_CONTEXT_COLUMN_LIST } from '../row-column-lists' import { ORCHESTRATION_DELIVERY_BATCH_LIMIT, type ForeignDirectMailboxRoutingPage @@ -13,25 +14,29 @@ export function findActiveDispatchForDirectMessageOwner( directHandle: string, paneKey?: string ): DispatchContextRow | undefined { - const exact = this.db + const rawExact = this.db .prepare( - `SELECT * FROM dispatch_contexts + `SELECT ${DISPATCH_CONTEXT_COLUMN_LIST} FROM dispatch_contexts WHERE run_id = ? AND assignee_handle = ? AND status IN ('pending', 'dispatched') ORDER BY rowid DESC LIMIT 1` ) - .get(runId, directHandle) as DispatchContextRow | undefined + .get(runId, directHandle) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The schema-pinned complete Dispatch projection returns one Dispatch row or undefined; the adapter exposes unknown. + const exact = rawExact as DispatchContextRow | undefined if (exact || !paneKey || !parsePaneKey(paneKey)) { return exact } - return this.db + const reminted = this.db .prepare( - `SELECT * FROM dispatch_contexts + `SELECT ${DISPATCH_CONTEXT_COLUMN_LIST} FROM dispatch_contexts WHERE run_id = ? AND assignee_pane_key IS NOT NULL AND status IN ('pending', 'dispatched') AND instr(assignee_pane_key, ':') > 1 AND ${DISPATCH_PANE_KEY_MATCH_SUFFIX_SQL} = ? ORDER BY rowid DESC LIMIT 1` ) - .get(runId, paneKeyMatchSuffix(paneKey)) as DispatchContextRow | undefined + .get(runId, paneKeyMatchSuffix(paneKey)) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The schema-pinned complete Dispatch projection returns one Dispatch row or undefined; the adapter exposes unknown. + return reminted as DispatchContextRow | undefined } export function routeForeignDirectMessagesToOwnedMailboxes( diff --git a/src/main/runtime/orchestration/db/remaining-dispatch-statement-compilation.test.ts b/src/main/runtime/orchestration/db/remaining-dispatch-statement-compilation.test.ts new file mode 100644 index 00000000000..3873f628500 --- /dev/null +++ b/src/main/runtime/orchestration/db/remaining-dispatch-statement-compilation.test.ts @@ -0,0 +1,295 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { DatabaseSync } from 'node:sqlite' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { isOrcaSessionId } from '../../../../shared/orca-session-address' +import { OrchestrationDb } from './orchestration-db' +import { createRootDispatch } from './root-dispatch-test-fixture' +import { LEGACY_CONTRACT_VERSION } from './contract-constants' +import { DISPATCH_CONTEXT_COLUMNS } from './row-column-lists' +import { warnStaleDispatches } from '../coordinator-task-dispatch' + +const databases: OrchestrationDb[] = [] +const directories: string[] = [] +const NOW = '2026-10-02T12:00:00.000Z' +const PANE = 'tab_worker:11111111-1111-4111-8111-111111111111' + +afterEach(() => { + vi.restoreAllMocks() + vi.useRealTimers() + for (const db of databases.splice(0)) { + db.close() + } + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function fixture(count = 1, version: 'fresh' | 'v41' = 'fresh') { + const directory = mkdtempSync(join(tmpdir(), 'orca-remaining-dispatch-compilation-')) + directories.push(directory) + const path = join(directory, 'orchestration.db') + let writer = new OrchestrationDb(path) + databases.push(writer) + const run = writer.createRun({ + objective: 'dispatch lifecycle', + coordinatorHandle: 'coordinator', + coordinatorPaneKey: 'tab_coordinator:22222222-2222-4222-8222-222222222222' + }) + const entries = Array.from({ length: count }, (_, index) => { + const task = writer.createTask({ spec: `work ${index}`, runId: run.id }) + const pane = + index === 0 + ? PANE + : `tab_worker:${String(index).padStart(8, '0')}-1111-4111-8111-111111111111` + const dispatch = createRootDispatch(writer, task.id, `worker_${index}`, pane) + writer.db + .prepare('UPDATE dispatch_contexts SET status = ?, dispatched_at = ?, depth = ? WHERE id = ?') + .run('dispatched', '2026-10-02 10:00:00', 2, dispatch.id) + writer.db.prepare('UPDATE tasks SET status = ? WHERE id = ?').run('dispatched', task.id) + const expected = writer.db + .prepare('SELECT * FROM dispatch_contexts WHERE id = ?') + .get(dispatch.id) + if (!expected) { + throw new Error('Missing dispatch fixture') + } + return { task, dispatch, expected } + }) + if (version === 'v41') { + writer.db.exec(` + DROP INDEX idx_runs_coordinator_orca_session_id; + DROP INDEX idx_dispatch_assignee_orca_session_id; + DROP TRIGGER trg_runs_remember_coordinator_insert; + DROP TRIGGER trg_runs_remember_coordinator_update; + DROP TRIGGER trg_messages_route_coordinator_mail; + ALTER TABLE runs DROP COLUMN coordinator_orca_session_id; + ALTER TABLE runs DROP COLUMN coordinator_orca_session_id_generation; + ALTER TABLE dispatch_contexts DROP COLUMN assignee_orca_session_id; + ALTER TABLE dispatch_contexts DROP COLUMN creator_orca_session_id; + `) + writer.db.pragma('user_version = 41') + databases.pop() + writer.close() + writer = new OrchestrationDb(path) + databases.push(writer) + for (const entry of entries) { + const migrated = writer.db + .prepare('SELECT * FROM dispatch_contexts WHERE id = ?') + .get(entry.dispatch.id) + if (!migrated) { + throw new Error('Missing migrated dispatch') + } + entry.expected = migrated + } + } + expect( + writer.db + .prepare('PRAGMA table_info(dispatch_contexts)') + .all() + .map((row) => row.name) + .toSorted() + ).toEqual(DISPATCH_CONTEXT_COLUMNS.toSorted()) + const reader = new OrchestrationDb(path) + databases.push(reader) + return { writer, reader, run, entries } +} + +function fullDispatchReads(calls: [string, ...unknown[]][]): string[] { + return calls + .map(([sql]) => sql) + .filter( + (sql) => + /SELECT (?:\*|id, run_id, task_id, contract_version,)/.test(sql) && + sql.includes('FROM dispatch_contexts') + ) +} + +describe('remaining dispatch metadata statement compilation', () => { + it.each(['fresh', 'v41'] as const)( + 'reuses stale and direct-owner reads with exact rows, rebinding, plans and cross-connection freshness (%s)', + (version) => { + const { + writer, + reader, + run, + entries: [{ dispatch, expected }] + } = fixture(1, version) + vi.useFakeTimers() + vi.setSystemTime(new Date(NOW)) + const compile = vi.spyOn(DatabaseSync.prototype, 'prepare') + for (let call = 0; call < 10; call += 1) { + expect(reader.getStaleDispatches('2026-10-02T11:00:00Z')).toEqual([expected]) + expect(reader.getStaleDispatches('2026-10-02T09:00:00Z')).toEqual([]) + expect(reader.findActiveDispatchForDirectMessageOwner(run.id, 'worker_0')).toEqual(expected) + expect(reader.findActiveDispatchForDirectMessageOwner(run.id, 'missing', PANE)).toEqual( + expected + ) + expect( + reader.findActiveDispatchForDirectMessageOwner( + run.id, + 'missing', + PANE.replace('tab_worker:', 'reminted:') + ) + ).toEqual(expected) + expect( + reader.findActiveDispatchForDirectMessageOwner(run.id, 'missing', 'bad:legacy') + ).toBeUndefined() + } + const warnings: string[] = [] + warnStaleDispatches(reader, (warning) => warnings.push(warning)) + expect(warnings).toEqual([ + `Warning: worker worker_0 on task ${dispatch.task_id} has not sent a heartbeat in ~10 min (dispatch ${dispatch.id})` + ]) + writer.db + .prepare( + 'UPDATE dispatch_contexts SET last_heartbeat_at = ?, assignee_handle = ? WHERE id = ?' + ) + .run(NOW, 'updated', dispatch.id) + expect(reader.getStaleDispatches('2026-10-02T11:00:00Z')).toEqual([]) + expect(reader.findActiveDispatchForDirectMessageOwner(run.id, 'worker_0')).toBeUndefined() + expect(reader.findActiveDispatchForDirectMessageOwner(run.id, 'updated')).toEqual({ + ...expected, + last_heartbeat_at: NOW, + assignee_handle: 'updated' + }) + const reads = fullDispatchReads(compile.mock.calls) + const staleSql = reads.find((sql) => sql.includes('julianday')) + compile.mockRestore() + if (!staleSql) { + throw new Error('Missing stale query') + } + const wildcardSql = staleSql.replace(/SELECT .*? FROM/, 'SELECT * FROM') + expect(writer.db.prepare(`EXPLAIN QUERY PLAN ${staleSql}`).all(NOW, NOW)).toEqual( + writer.db.prepare(`EXPLAIN QUERY PLAN ${wildcardSql}`).all(NOW, NOW) + ) + expect(reads).toHaveLength(3) + } + ) + + it.each(['fresh', 'v41'] as const)( + 'reuses session creator reads without changing self-created parent exclusion or fresh depth (%s)', + (version) => { + const { + writer, + reader, + entries: [{ dispatch }] + } = fixture(1, version) + const sessionId = '00000000-0000-4000-8000-000000000001' + if (!isOrcaSessionId(sessionId)) { + throw new Error('Invalid session fixture') + } + writer.db + .prepare('UPDATE dispatch_contexts SET assignee_orca_session_id = ? WHERE id = ?') + .run(sessionId, dispatch.id) + const compile = vi.spyOn(DatabaseSync.prototype, 'prepare') + for (let call = 0; call < 10; call += 1) { + expect(reader.resolveCreatorDepth({ kind: 'session', orcaSessionId: sessionId })).toBe(2) + expect(reader.resolveCreatorDispatchId({ kind: 'session', orcaSessionId: sessionId })).toBe( + dispatch.id + ) + } + writer.db.prepare('UPDATE dispatch_contexts SET depth = ? WHERE id = ?').run(3, dispatch.id) + expect(reader.resolveCreatorDepth({ kind: 'session', orcaSessionId: sessionId })).toBe(3) + writer.db + .prepare('UPDATE dispatch_contexts SET creator_handle = assignee_handle WHERE id = ?') + .run(dispatch.id) + expect(reader.resolveCreatorDepth({ kind: 'session', orcaSessionId: sessionId })).toBe(0) + expect( + reader.resolveCreatorDispatchId({ kind: 'session', orcaSessionId: sessionId }) + ).toBeNull() + expect(fullDispatchReads(compile.mock.calls)).toHaveLength(1) + } + ) + + it.each(['fresh', 'v41'] as const)( + 'reuses gate ownership and settlement reads while preserving completed rows and blocked tasks (%s)', + (version) => { + vi.useFakeTimers() + vi.setSystemTime(new Date(NOW)) + const { reader, entries } = fixture(10, version) + const compile = vi.spyOn(DatabaseSync.prototype, 'prepare') + for (const { task, dispatch, expected } of entries) { + const gate = reader.createGate({ taskId: task.id, question: 'Continue?' }) + expect(gate.task_id).toBe(task.id) + expect(gate.status).toBe('pending') + expect(reader.getDispatchContextById(dispatch.id)).toEqual({ + ...expected, + status: 'completed', + completed_at: NOW, + capability_revoked_at: NOW + }) + expect(reader.getTask(task.id)?.status).toBe('blocked') + } + expect(fullDispatchReads(compile.mock.calls)).toHaveLength(12) + } + ) + + it.each(['fresh', 'v41'] as const)( + 'reuses the private failure snapshot while preserving returned committed row order (%s)', + (version) => { + vi.useFakeTimers() + vi.setSystemTime(new Date(NOW)) + const { reader, entries } = fixture(10, version) + const compile = vi.spyOn(DatabaseSync.prototype, 'prepare') + for (const { task, dispatch, expected } of entries) { + const expectedFailure = { + ...expected, + status: 'failed', + failure_count: 1, + last_failure: 'failed send', + completed_at: NOW, + capability_revoked_at: NOW + } + const failure = reader.failDispatch(dispatch.id, 'failed send') + expect(failure).toEqual(expectedFailure) + expect(JSON.stringify(failure)).toBe(JSON.stringify(expectedFailure)) + expect(reader.getTask(task.id)?.status).toBe('ready') + } + const reads = fullDispatchReads(compile.mock.calls) + compile.mockRestore() + const first = entries[0] + if (!first) { + throw new Error('Missing failure fixture') + } + const before = reader.db + .prepare('SELECT * FROM dispatch_contexts WHERE id = ?') + .get(first.dispatch.id) + const early = reader.failDispatch(first.dispatch.id, 'later') + expect(JSON.stringify(early)).toBe(JSON.stringify(before)) + expect(early).not.toBe(reader.failDispatch(first.dispatch.id, 'later')) + expect(reads).toHaveLength(21) + } + ) + + it.each(['fresh', 'v41'] as const)( + 'reuses legacy dispatch identity scans with full rows and immediately fences settled rows (%s)', + (version) => { + const { + writer, + reader, + run, + entries: [{ dispatch, expected }] + } = fixture(1, version) + writer.db + .prepare('UPDATE dispatch_contexts SET contract_version = ? WHERE id = ?') + .run(LEGACY_CONTRACT_VERSION, dispatch.id) + const compile = vi.spyOn(DatabaseSync.prototype, 'prepare') + for (let call = 0; call < 10; call += 1) { + expect( + reader.resolveLegacyWorkerCandidate({ runId: run.id, terminalHandle: 'worker_0' }) + ).toEqual({ dispatch: { ...expected, contract_version: LEGACY_CONTRACT_VERSION } }) + expect( + reader.resolveLegacyWorkerCandidate({ runId: run.id, terminalHandle: 'missing' }) + ).toBeUndefined() + } + writer.db + .prepare('UPDATE dispatch_contexts SET status = ? WHERE id = ?') + .run('completed', dispatch.id) + expect( + reader.resolveLegacyWorkerCandidate({ runId: run.id, terminalHandle: 'worker_0' }) + ).toBeUndefined() + expect(fullDispatchReads(compile.mock.calls)).toHaveLength(1) + } + ) +}) From f1e6aea7fc9be8dbac32899da6132efecfd07de9 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:31:26 -0700 Subject: [PATCH 06/67] Prepare the Activity search query once per filtered list (#24701) Lazily reuse a call-local search predicate inside the existing filter, using unchanged byte gates and thread text cache. --- ...activity-search-query-work-budget.test.tsx | 254 ++++++++++++++++++ .../activity/activity-thread-grouping.ts | 14 + .../activity/use-agent-pane-threads.ts | 6 +- 3 files changed, 272 insertions(+), 2 deletions(-) create mode 100644 src/renderer/src/components/activity/activity-search-query-work-budget.test.tsx diff --git a/src/renderer/src/components/activity/activity-search-query-work-budget.test.tsx b/src/renderer/src/components/activity/activity-search-query-work-budget.test.tsx new file mode 100644 index 00000000000..e10b55728cb --- /dev/null +++ b/src/renderer/src/components/activity/activity-search-query-work-budget.test.tsx @@ -0,0 +1,254 @@ +// @vitest-environment happy-dom +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import { toSshExecutionHostId } from '../../../../shared/execution-host' +import * as clipboard from '../../../../shared/clipboard-text' +import { useAppStore } from '@/store' +import { + LEAF_ID, + makeRepo, + makeTabWithIds, + makeWorktree +} from './ActivityPrototypePage-test-fixtures' +import { + activityThreadMatchesSearchQuery, + createActivityThreadSearchMatcher, + getThreadSearchTextComputeCount +} from './activity-thread-grouping' +import { useAgentPaneThreads } from './use-agent-pane-threads' + +vi.mock('@/store', async () => { + const { createTestStore } = await import('@/store/slices/store-test-helpers') + return { useAppStore: createTestStore() } +}) + +afterEach(() => { + cleanup() + vi.restoreAllMocks() +}) + +function installThreads(count: number, prompt = 'Task PROJECT ☃ progress'): string[] { + vi.spyOn(Date, 'now').mockReturnValue(100_000) + const repo = makeRepo() + const worktree = makeWorktree() + const tabs = Array.from({ length: count }, (_, index) => + makeTabWithIds(`tab-${index}`, worktree.id) + ) + const entries: Record = {} + for (const tab of tabs) { + const paneKey = makePaneKey(tab.id, LEAF_ID) + entries[paneKey] = { + paneKey, + state: 'working', + prompt, + agentType: 'claude', + updatedAt: 100_000, + stateStartedAt: 99_000, + stateHistory: [] + } + } + useAppStore.setState({ + agentStatusByPaneKey: entries, + retainedAgentsByPaneKey: {}, + migrationUnsupportedByPtyId: {}, + runtimeAgentOrchestrationByPaneKey: {}, + acknowledgedAgentsByPaneKey: {}, + activityClearedAtByPaneKey: {}, + repos: [repo], + worktreesByRepo: { [repo.id]: [worktree] }, + folderWorkspaces: [], + detectedWorktreesByRepo: {}, + tabsByWorktree: { [worktree.id]: tabs }, + unifiedTabsByWorktree: {}, + agentsVisibleHostIds: null, + agentsFilterRepoIds: [] + }) + return Object.keys(entries) +} + +function queryWork(query: string) { + const texts = new Set([query, query.trim(), query.trim().toLowerCase()].filter(Boolean)) + const originalBudget = clipboard.isClipboardTextByteLengthOverLimit + const originalTrim = String.prototype.trim + const originalLower = String.prototype.toLowerCase + const counts = { budget: 0, trim: 0, lower: 0 } + const budget = vi + .spyOn(clipboard, 'isClipboardTextByteLengthOverLimit') + .mockImplementation((text, maxBytes) => { + if (texts.has(text)) { + counts.budget += 1 + } + return originalBudget(text, maxBytes) + }) + const trim = vi.spyOn(String.prototype, 'trim').mockImplementation(function (this: string) { + if (texts.has(String(this))) { + counts.trim += 1 + } + return originalTrim.call(this) + }) + const lower = vi.spyOn(String.prototype, 'toLowerCase').mockImplementation(function ( + this: string + ) { + if (texts.has(String(this))) { + counts.lower += 1 + } + return originalLower.call(this) + }) + return { + counts, + restore() { + budget.mockRestore() + trim.mockRestore() + lower.mockRestore() + } + } +} + +function options(query: string): Parameters[0] { + return { query, readFilter: 'all', groupBy: 'none', selectedPaneKey: null, showChildAgents: true } +} + +describe('Activity search query work in the real hook', () => { + it.each([12, 128, 1000])('prepares one query for %i live threads', (count) => { + installThreads(count) + const work = queryWork(' PROJECT ☃ ') + const activity = renderHook(() => useAgentPaneThreads(options(' PROJECT ☃ '))) + work.restore() + expect(activity.result.current.allThreads).toHaveLength(count) + expect(activity.result.current.visibleThreads).toEqual(activity.result.current.allThreads) + for (const [index, thread] of activity.result.current.visibleThreads.entries()) { + expect(thread).toBe(activity.result.current.allThreads[index]) + } + expect(work.counts).toEqual({ budget: 2, trim: 2, lower: 2 }) + }) + + it.each(['', ' \t\n '])('keeps an empty query lazy: %j', (query) => { + installThreads(12) + const before = getThreadSearchTextComputeCount() + const activity = renderHook(() => useAgentPaneThreads(options(query))) + expect(activity.result.current.visibleThreads).toEqual(activity.result.current.allThreads) + expect(getThreadSearchTextComputeCount()).toBe(before) + }) + + it('retains both original and normalized UTF-8 byte limits', () => { + const query = 'İ'.repeat(700) + installThreads(12, query) + const before = getThreadSearchTextComputeCount() + const work = queryWork(query) + const activity = renderHook(() => useAgentPaneThreads(options(query))) + work.restore() + expect(activity.result.current.allThreads).toHaveLength(12) + expect(activity.result.current.visibleThreads).toEqual([]) + expect(getThreadSearchTextComputeCount()).toBe(before) + expect(work.counts.budget).toBe(2) + }) + + it('does not prepare a row query before read/child/scope filters admit a row', () => { + const panes = installThreads(12) + useAppStore.setState({ + acknowledgedAgentsByPaneKey: Object.fromEntries(panes.map((paneKey) => [paneKey, 100_000])) + }) + const work = queryWork(' PROJECT ☃ ') + const activity = renderHook(() => + useAgentPaneThreads({ ...options(' PROJECT ☃ '), readFilter: 'unread' }) + ) + work.restore() + expect(activity.result.current.allThreads).toHaveLength(12) + expect(activity.result.current.visibleThreads).toEqual([]) + expect(work.counts).toEqual({ budget: 1, trim: 1, lower: 1 }) + }) + + it('keeps selection, child classification and scoped row order', () => { + const panes = installThreads(8) + const parent = panes[0] + const child = panes[1] + useAppStore.setState({ + runtimeAgentOrchestrationByPaneKey: { + [child]: { parentPaneKey: parent, taskId: 'task', dispatchId: 'dispatch' } + }, + acknowledgedAgentsByPaneKey: { [child]: 100_000 } + }) + const activity = renderHook((input) => useAgentPaneThreads(input), { + initialProps: { + ...options('PROJECT'), + readFilter: 'unread' as const, + showChildAgents: false, + selectedPaneKey: child + } + }) + expect(activity.result.current.visibleThreads.map((thread) => thread.paneKey)).toEqual(panes) + act(() => useAppStore.setState({ agentsVisibleHostIds: [toSshExecutionHostId('other')] })) + expect(activity.result.current.visibleThreads.map((thread) => thread.paneKey)).toEqual([child]) + activity.rerender({ + ...options('NO MATCH'), + readFilter: 'unread', + showChildAgents: false, + selectedPaneKey: child + }) + expect(activity.result.current.visibleThreads).toEqual([]) + expect(activity.result.current.effectiveSelectedPaneKey).toBe(child) + }) + + it('uses a changed query and new thread data without a retained query cache', () => { + const panes = installThreads(12, 'first prompt') + const activity = renderHook((query) => useAgentPaneThreads(options(query)), { + initialProps: 'first' + }) + expect(activity.result.current.visibleThreads).toHaveLength(12) + activity.rerender('second') + expect(activity.result.current.visibleThreads).toEqual([]) + act(() => { + const entries = useAppStore.getState().agentStatusByPaneKey + useAppStore.setState({ + agentStatusByPaneKey: { + ...entries, + [panes[2]]: { ...entries[panes[2]], prompt: 'second prompt' } + } + }) + }) + expect(activity.result.current.visibleThreads.map((thread) => thread.paneKey)).toEqual([ + panes[2] + ]) + }) + + it('keeps original oversized query rejection ahead of normalization/search', () => { + installThreads(12) + const query = ' '.repeat(2049) + const before = getThreadSearchTextComputeCount() + const work = queryWork(query) + const activity = renderHook(() => useAgentPaneThreads(options(query))) + work.restore() + expect(activity.result.current.visibleThreads).toEqual([]) + expect(getThreadSearchTextComputeCount()).toBe(before) + expect(work.counts).toEqual({ budget: 1, trim: 0, lower: 0 }) + }) +}) + +describe('prepared Activity search preserves the public one-thread matcher', () => { + it('matches original output and lazy text for Unicode, boundary and empty queries', () => { + installThreads(8, 'Unicode İ 👩🏽‍💻 and BILLING') + const activity = renderHook(() => useAgentPaneThreads(options(''))) + const queries = [ + '', + '\t\n ', + 'BILLING', + ' Unicode İ ', + '👩🏽‍💻', + '\ud800', + 'x'.repeat(2048), + 'é'.repeat(1024), + 'é'.repeat(1025), + 'İ'.repeat(700) + ] + for (const searchQuery of queries) { + const matches = createActivityThreadSearchMatcher(searchQuery) + expect(activity.result.current.allThreads.map(matches)).toEqual( + activity.result.current.allThreads.map((thread) => + activityThreadMatchesSearchQuery({ thread, searchQuery }) + ) + ) + } + }) +}) diff --git a/src/renderer/src/components/activity/activity-thread-grouping.ts b/src/renderer/src/components/activity/activity-thread-grouping.ts index 79eef1b3688..17ad0bb447b 100644 --- a/src/renderer/src/components/activity/activity-thread-grouping.ts +++ b/src/renderer/src/components/activity/activity-thread-grouping.ts @@ -134,6 +134,20 @@ export function isActivitySearchQueryTooLarge( return isClipboardTextByteLengthOverLimit(query, maxBytes) } +export function createActivityThreadSearchMatcher( + searchQuery: string +): (thread: AgentPaneThread) => boolean { + if (isActivitySearchQueryTooLarge(searchQuery)) { + return () => false + } + const trimmedQuery = searchQuery.trim() + if (!trimmedQuery) { + return () => true + } + const normalizedQuery = trimmedQuery.toLowerCase() + return (thread) => threadSearchText(thread).includes(normalizedQuery) +} + export function activityThreadMatchesSearchQuery({ thread, searchQuery diff --git a/src/renderer/src/components/activity/use-agent-pane-threads.ts b/src/renderer/src/components/activity/use-agent-pane-threads.ts index f9575f02a76..ce1f571cdbc 100644 --- a/src/renderer/src/components/activity/use-agent-pane-threads.ts +++ b/src/renderer/src/components/activity/use-agent-pane-threads.ts @@ -15,7 +15,7 @@ import { collectChildAgentPaneKeys } from './activity-thread-child-agent' const EMPTY_PANE_KEYS: ReadonlySet = new Set() import { filterThreadsByActivityScope, resolveActivityScopeRepoIds } from './activity-scope-filter' import { - activityThreadMatchesSearchQuery, + createActivityThreadSearchMatcher, buildActivityThreadGroups, isActivitySearchQueryTooLarge } from './activity-thread-grouping' @@ -212,6 +212,7 @@ export function useAgentPaneThreads(args: { const normalizedQuery = isActivitySearchQueryTooLarge(deferredQuery) ? null : deferredQuery.trim().toLowerCase() + let matchesSearchQuery: ReturnType | undefined return scopeVisibleThreads.filter((thread) => { // Why: keep the just-selected thread visible after auto-mark-read flips it to read, else unread-only mode makes the clicked row vanish from the list. if ( @@ -232,7 +233,8 @@ export function useAgentPaneThreads(args: { if (normalizedQuery === null) { return false } - return activityThreadMatchesSearchQuery({ thread, searchQuery: normalizedQuery }) + matchesSearchQuery ??= createActivityThreadSearchMatcher(normalizedQuery) + return matchesSearchQuery(thread) }) }, [ scopeVisibleThreads, From b5814d058e563e2002d9a0f955a33400749d4cd4 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:31:29 -0700 Subject: [PATCH 07/67] Avoid rescanning shared chunks in the plain Node build guard (#24717) Reuse a successful unchanged chunk-code verdict within one synchronous writeBundle invocation, while preserving traversal and current-code reads. --- .../build-plugins/plain-node-entry-guard.ts | 25 ++++- config/scripts/plain-node-entry-guard.test.ts | 101 +++++++++++++++++- 2 files changed, 119 insertions(+), 7 deletions(-) diff --git a/config/build-plugins/plain-node-entry-guard.ts b/config/build-plugins/plain-node-entry-guard.ts index 5ea771e293c..0640a984437 100644 --- a/config/build-plugins/plain-node-entry-guard.ts +++ b/config/build-plugins/plain-node-entry-guard.ts @@ -122,10 +122,15 @@ function assertNoElectronRequire( entryName: string, entry: OutputChunk, byFileName: Map, + electronFreeChunkCode: Map, runtime: EntryRuntime = 'plain-Node process' ): void { for (const chunk of collectReachableChunks(entry, byFileName)) { - if (ELECTRON_REQUIRE_RE.test(chunk.code)) { + const code = chunk.code + if (electronFreeChunkCode.get(chunk) === code) { + continue + } + if (ELECTRON_REQUIRE_RE.test(code)) { throw new Error( `[plain-node-entry-guard] "${entryName}" reaches chunk "${chunk.fileName}" that ` + `requires electron. "${entryName}" runs as a ${runtime}, where ` + @@ -133,6 +138,7 @@ function assertNoElectronRequire( `v1.4.129-rc.1 daemon outage). Keep electron imports out of its module graph.` ) } + electronFreeChunkCode.set(chunk, code) } } @@ -272,17 +278,30 @@ export function createPlainNodeEntryGuardPlugin( } } + const electronFreeChunkCode = new Map() for (const entryName of PLAIN_NODE_ENTRY_NAMES) { const entry = entryByName.get(entryName) if (entry) { - assertNoElectronRequire(entryName, entry, byFileName, 'plain-Node process') + assertNoElectronRequire( + entryName, + entry, + byFileName, + electronFreeChunkCode, + 'plain-Node process' + ) } } for (const entryName of WORKER_THREAD_ENTRY_NAMES) { const entry = entryByName.get(entryName) if (entry) { - assertNoElectronRequire(entryName, entry, byFileName, 'worker thread') + assertNoElectronRequire( + entryName, + entry, + byFileName, + electronFreeChunkCode, + 'worker thread' + ) } } diff --git a/config/scripts/plain-node-entry-guard.test.ts b/config/scripts/plain-node-entry-guard.test.ts index 588b32871bc..15be37ebcb2 100644 --- a/config/scripts/plain-node-entry-guard.test.ts +++ b/config/scripts/plain-node-entry-guard.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import type { Plugin, Rollup } from 'vite' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { CLI_MAIN_ENTRY_NAMES, createPlainNodeEntryGuardPlugin, @@ -160,14 +160,20 @@ describe('guarded entry names', () => { // hand-written "must stay electron-free" comments, and the port-scan worker sits // one import away from a client that deliberately does require electron. describe('CLI and worker thread entry guard', () => { - function runEntryWriteBundle(plugin: Plugin, bundle: Rollup.OutputBundle): void { + function runEntryWriteBundle( + plugin: Plugin, + bundle: Rollup.OutputBundle, + watchMode = false + ): void { const hook = plugin.writeBundle if (typeof hook !== 'function') { throw new Error('Expected writeBundle hook') } hook.call( - { meta: { watchMode: false } } as never, - { dir: createOutputDir() } as Rollup.NormalizedOutputOptions, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This hook reads only meta.watchMode from its context. + { meta: { watchMode } } as never, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This hook reads only dir from the output options. + { dir: outputDir ?? createOutputDir() } as Rollup.NormalizedOutputOptions, bundle ) } @@ -184,6 +190,93 @@ describe('CLI and worker thread entry guard', () => { } as Rollup.OutputChunk } + function countElectronRequireScans(run: () => void): number { + const pattern = /require\(\s*["'`]electron(?:\/[^"'`]+)?["'`]\s*\)/ + const originalTest = RegExp.prototype.test + let scans = 0 + const spy = vi.spyOn(RegExp.prototype, 'test').mockImplementation(function ( + this: RegExp, + value: string + ) { + if (this.source === pattern.source) { + scans += 1 + } + return originalTest.call(this, value) + }) + try { + run() + } finally { + spy.mockRestore() + } + return scans + } + + it('scans shared code once across every guarded entry', () => { + const shared = entryChunk('shared', 'require("node:fs")') + shared.isEntry = false + const bundle: Rollup.OutputBundle = { [shared.fileName]: shared } + for (const name of GUARDED_ENTRY_NAMES) { + const entry = entryChunk(name, `require("./shared.js"); // ${name}`, [shared.fileName]) + bundle[entry.fileName] = entry + } + const snapshot = structuredClone(bundle) + + const scans = countElectronRequireScans(() => { + runEntryWriteBundle(createPlainNodeEntryGuardPlugin(), bundle) + }) + + expect(bundle).toEqual(snapshot) + expect(scans).toBe(GUARDED_ENTRY_NAMES.length + 1) + }) + + it('does not retain a successful scan across output bundles', () => { + const plugin = createPlainNodeEntryGuardPlugin() + const entry = entryChunk('stt-worker', 'require("node:fs")') + const bundle: Rollup.OutputBundle = { [entry.fileName]: entry } + const scans = countElectronRequireScans(() => { + runEntryWriteBundle(plugin, bundle) + runEntryWriteBundle(plugin, bundle) + }) + expect(scans).toBe(2) + + entry.code = 'require("electron/main")' + expect(() => runEntryWriteBundle(plugin, bundle)).toThrow( + '[plain-node-entry-guard] "stt-worker" reaches chunk "stt-worker.js" that requires electron. ' + ) + }) + + it('still reads changed code on the same chunk within one bundle scan', () => { + const first = entryChunk('stt-worker', 'require("node:fs")') + const second = entryChunk('warp-theme-parser-worker', 'require("node:fs")') + const shared = entryChunk('shared', '') + shared.isEntry = false + let reads = 0 + Object.defineProperty(shared, 'code', { + get: () => (++reads === 1 ? 'require("node:fs")' : 'require("electron")') + }) + first.imports = [shared.fileName] + second.dynamicImports = [shared.fileName] + const bundle: Rollup.OutputBundle = { + [first.fileName]: first, + [second.fileName]: second, + [shared.fileName]: shared + } + + expect(() => runEntryWriteBundle(createPlainNodeEntryGuardPlugin(), bundle)).toThrow( + '[plain-node-entry-guard] "warp-theme-parser-worker" reaches chunk "shared.js"' + ) + expect(reads).toBe(2) + }) + + it('keeps watch mode free of entry scanning', () => { + const entry = entryChunk('stt-worker', 'require("electron")') + const bundle: Rollup.OutputBundle = { [entry.fileName]: entry } + const scans = countElectronRequireScans(() => { + runEntryWriteBundle(createPlainNodeEntryGuardPlugin(), bundle, true) + }) + expect(scans).toBe(0) + }) + it.each(CLI_MAIN_ENTRY_NAMES)('rejects direct and transitive Electron imports in %s', (name) => { const plugin = createPlainNodeEntryGuardPlugin() const entry = entryChunk(name, 'require("electron")') From b5db0adb253e26ea520e43ee31dee1b6a9a1ad53 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:31:31 -0700 Subject: [PATCH 08/67] Select the latest stable release tag in one pass (#24786) Replace filter/sort/last selection with one traversal using the unchanged stable-tag regex and numeric comparator; update on equality to preserve the original last spelling. Both actual callers own private plain arrays from Git stdout. --- ...able-release-tag-selection-budget.test.mjs | 110 ++++++++++++++++++ config/scripts/stable-release-tags.mjs | 16 ++- 2 files changed, 120 insertions(+), 6 deletions(-) create mode 100644 config/scripts/stable-release-tag-selection-budget.test.mjs diff --git a/config/scripts/stable-release-tag-selection-budget.test.mjs b/config/scripts/stable-release-tag-selection-budget.test.mjs new file mode 100644 index 00000000000..1571b0b9a32 --- /dev/null +++ b/config/scripts/stable-release-tag-selection-budget.test.mjs @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest' +import { selectLatestStableReleaseTag } from './stable-release-tags.mjs' + +function expectedLatest(tags) { + let latest = null + let latestParts = [] + for (const tag of tags) { + const match = /^v(\d+)\.(\d+)\.(\d+)$/.exec(tag) + if (!match) { + continue + } + const parts = match.slice(1).map((part) => { + const value = Number.parseInt(part, 10) + return Number.isFinite(value) ? value : 0 + }) + let comparison = 0 + for (let index = 0; index < 3 && comparison === 0; index++) { + comparison = parts[index] - (latestParts[index] ?? 0) + } + if (latest === null || comparison >= 0) { + latest = tag + latestParts = parts + } + } + return latest +} + +function measurePartMaps(tags) { + const nativeMap = Array.prototype.map + let partMaps = 0 + Array.prototype.map = function (callback, thisArg) { + partMaps++ + return nativeMap.call(this, callback, thisArg) + } + let result + try { + result = selectLatestStableReleaseTag(tags) + } finally { + Array.prototype.map = nativeMap + } + return { result, partMaps } +} + +describe('stable release tag selection work', () => { + it.each([0, 1, 12, 128, 1000])('bounds numeric parsing for %i Git tag strings', (count) => { + const tags = Array.from( + { length: count }, + (_, index) => `v1.${(index * 37) % 17}.${(index * 101) % (count + 1)}` + ) + const input = [...tags] + const expected = expectedLatest(tags) + const measured = measurePartMaps(Object.freeze(tags)) + expect(measured.result).toBe(expected) + expect(tags).toEqual(input) + // Each unchanged comparator converts the two triples through four maps. + expect(measured.partMaps).toBeLessThanOrEqual(4 * Math.max(0, count - 1)) + }) + + it('preserves tie spelling, numeric fallback and invalid-tag admission', () => { + const cases = [ + { tags: [], expected: null }, + { tags: ['nightly', 'mobile-v1.2.3', 'v1.2.3-rc.1'], expected: null }, + { tags: ['v0001.4.003', 'v1.04.3'], expected: 'v1.04.3' }, + { tags: ['v1.04.3', 'v0001.4.003'], expected: 'v0001.4.003' }, + { tags: ['v1.2.3', 'v1.2.3\n'], expected: 'v1.2.3' }, + { tags: ['v1.2.3\r\n', 'v0.0.1'], expected: 'v0.0.1' }, + { tags: [`v${'9'.repeat(400)}.1.2`, 'v0.1.2'], expected: 'v0.1.2' }, + { + tags: ['v9007199254740993.1.0', 'v9007199254740992.1.0'], + expected: 'v9007199254740992.1.0' + } + ] + for (const { tags, expected } of cases) { + const input = [...tags] + expect(selectLatestStableReleaseTag(Object.freeze(tags))).toBe(expected) + expect(tags).toEqual(input) + } + const sparse = [] + sparse.length = 12 + sparse[3] = 'v1.2.3' + sparse[8] = 'v2.0.0' + expect(selectLatestStableReleaseTag(Object.freeze(sparse))).toBe('v2.0.0') + }) + + it('selects the same latest spelling across ordinary version namespaces and repeated calls', () => { + let randomState = 673151 + const next = () => { + randomState = (randomState * 1664525 + 1013904223) >>> 0 + return randomState + } + for (let seed = 0; seed < 256; seed++) { + const tags = [] + for (let index = 0, count = next() % 129; index < count; index++) { + const triple = [next() % 13, next() % 17, next() % 257] + const prefix = next() % 8 === 0 ? 'mobile-v' : 'v' + const suffix = next() % 9 === 0 ? '-rc.1' : '' + tags.push(`${prefix}${triple.join('.')}${suffix}`) + if (index % 11 === 0) { + tags.push(tags.at(-1)) + } + } + const input = [...tags] + const expected = expectedLatest(tags) + expect(selectLatestStableReleaseTag(tags)).toBe(expected) + expect(tags).toEqual(input) + tags.push('v99.99.99') + expect(selectLatestStableReleaseTag(tags)).toBe('v99.99.99') + } + }) +}) diff --git a/config/scripts/stable-release-tags.mjs b/config/scripts/stable-release-tags.mjs index 2650f23eaa8..09032370dc7 100644 --- a/config/scripts/stable-release-tags.mjs +++ b/config/scripts/stable-release-tags.mjs @@ -21,10 +21,14 @@ export function compareReleaseTags(a, b) { /** @param {string[]} tags @returns {string | null} */ export function selectLatestStableReleaseTag(tags) { - return ( - tags - .filter((tag) => STABLE_DESKTOP_RELEASE_TAG.test(tag)) - .sort(compareReleaseTags) - .at(-1) ?? null - ) + let latest = null + for (const tag of tags) { + if ( + STABLE_DESKTOP_RELEASE_TAG.test(tag) && + (latest === null || compareReleaseTags(latest, tag) <= 0) + ) { + latest = tag + } + } + return latest } From a436aabcb5e21434d80637792bb4777608f0c56f Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:31:34 -0700 Subject: [PATCH 09/67] Avoid Resource Manager renders when terminal removal leaves its inventory unchanged (#24806) Reuse the private React state object only when the existing single/bulk removal helper returns its identical inventory; keep every lifecycle revision, tombstone, known-ID write and helper invocation in its original order. --- ...source-manager-exit-render-budget.test.tsx | 185 +++++++++++++ ...rce-session-removal-render-budget.test.tsx | 259 ++++++++++++++++++ .../use-resource-session-inventory.ts | 20 +- 3 files changed, 456 insertions(+), 8 deletions(-) create mode 100644 src/renderer/src/components/status-bar/resource-manager-exit-render-budget.test.tsx create mode 100644 src/renderer/src/components/status-bar/resource-session-removal-render-budget.test.tsx diff --git a/src/renderer/src/components/status-bar/resource-manager-exit-render-budget.test.tsx b/src/renderer/src/components/status-bar/resource-manager-exit-render-budget.test.tsx new file mode 100644 index 00000000000..0a014eb73d6 --- /dev/null +++ b/src/renderer/src/components/status-bar/resource-manager-exit-render-budget.test.tsx @@ -0,0 +1,185 @@ +// @vitest-environment happy-dom +import { createElement, Profiler, type ReactNode } from 'react' +import { act, cleanup, render } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { DaemonSession } from '@/components/status-bar/resource-usage-merge-types' + +vi.mock('@/store', () => { + const state = { + memorySnapshot: null, + memorySnapshotError: null, + workspaceSessionReady: true, + fetchMemorySnapshot: vi.fn(async () => {}), + setActiveView: vi.fn(), + openModal: vi.fn(), + openSpacePage: vi.fn(), + recordFeatureInteraction: vi.fn(), + activeView: 'worktree', + activeWorktreeId: null, + workspaceSpaceAnalysis: null, + workspaceSpaceScanning: false, + runtimePaneTitlesByTabId: {}, + repos: [], + worktreesByRepo: {}, + folderWorkspaces: [], + projectGroups: [], + tabsByWorktree: {}, + browserTabsByWorktree: {}, + ptyIdsByTabId: {}, + terminalLayoutsByTabId: {}, + deferredSshSessionIdsByTabId: {} + } + return { + useAppStore: Object.assign((selector: (value: typeof state) => unknown) => selector(state), { + getState: () => state + }) + } +}) +vi.mock('@/components/ui/popover', () => ({ + PopoverTrigger: ({ children }: { children: ReactNode }) => children +})) +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: ReactNode }) => children, + TooltipContent: ({ children }: { children: ReactNode }) => children, + TooltipTrigger: ({ children }: { children: ReactNode }) => children +})) +vi.mock('@/i18n/i18n', () => ({ + i18n: { language: 'en' }, + translate: (_key: string, fallback: string, values?: Record) => + values + ? Object.entries(values).reduce( + (text, [key, value]) => text.replace(`{{${key}}}`, value), + fallback + ) + : fallback +})) +vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } })) +import { useAppStore } from '@/store' +import { useResourceUsageStatusController } from '@/components/status-bar/use-resource-usage-status-controller' +import { renderResourceUsageStatusTrigger } from '@/components/status-bar/resource-usage-status-trigger' + +let spawned: (data: { id: string }) => void +let exited: (data: { id: string; code: number }) => void +const rows: DaemonSession[] = [ + { id: 'native-local', title: 'Local shell', cwd: '/notes', agentOwnership: 'absent' }, + { id: 'ssh-remote', title: 'Remote shell', cwd: 'C:\\notes', agentOwnership: 'present' } +] +const list = vi.fn<() => Promise>() +beforeEach(() => { + vi.useFakeTimers() + list.mockReset().mockResolvedValue(rows) + vi.stubGlobal('api', { + pty: { + listSessions: list, + onSpawned: (callback: typeof spawned) => { + spawned = callback + return vi.fn() + }, + onExit: (callback: typeof exited) => { + exited = callback + return vi.fn() + } + } + }) +}) +afterEach(() => { + cleanup() + vi.clearAllTimers() + vi.useRealTimers() + vi.unstubAllGlobals() + vi.restoreAllMocks() +}) + +it('preserves actual badge DOM/controller outputs and no extra reads while avoiding no-op commits', async () => { + let renders = 0, + commits = 0 + let current: ReturnType | undefined + function Probe() { + renders += 1 + current = useResourceUsageStatusController() + return renderResourceUsageStatusTrigger({ ...current, iconOnly: false }) + } + const view = render( + createElement( + Profiler, + { + id: 'resource-badge', + onRender: () => { + commits += 1 + } + }, + createElement(Probe) + ) + ) + await act(async () => {}) + if (!current) { + throw new Error('actual controller not mounted') + } + expect(current.triggerSessionCount).toBe(2) + expect(current.resourceManagerAriaLabel).toContain('2') + const initialMarkup = view.container.innerHTML + const before = renders, + committed = commits, + requests = list.mock.calls.length + for (let index = 0; index < 64; index += 1) { + act(() => { + spawned({ id: `short-lived-${index}` }) + exited({ id: `short-lived-${index}`, code: 0 }) + }) + if (!current) { + throw new Error('actual controller disappeared') + } + expect(current.triggerSessionCount).toBe(2) + expect(current.daemonUnreachable).toBe(false) + expect(current.unifiedRepos).toEqual([]) + expect(view.container.innerHTML).toBe(initialMarkup) + } + const counts = { + renders: renders - before, + commits: commits - committed, + reads: list.mock.calls.length - requests, + pendingTimers: vi.getTimerCount() + } + await act(async () => { + current?.setOpen(true) + }) + if (!current) { + throw new Error('actual open controller missing') + } + expect(current.open).toBe(true) + expect(list).toHaveBeenCalledTimes(requests + 1) + const snapshotReads = useAppStore.getState().fetchMemorySnapshot + const readsAtOpen = vi.mocked(snapshotReads).mock.calls.length + await act(async () => { + await vi.advanceTimersByTimeAsync(1_999) + }) + expect(snapshotReads).toHaveBeenCalledTimes(readsAtOpen) + await act(async () => { + await vi.advanceTimersByTimeAsync(1) + }) + expect(snapshotReads).toHaveBeenCalledTimes(readsAtOpen + 1) + expect(list).toHaveBeenCalledTimes(requests + 1) + expect(current.triggerSessionCount).toBe(2) + expect( + current.unifiedRepos + .flatMap((group) => group.worktrees) + .flatMap((worktree) => worktree.sessions) + .map((session) => session.sessionId) + ).toEqual(rows.map((row) => row.id)) + const beforeExit = list.mock.calls.length + act(() => exited({ id: 'ssh-remote', code: 0 })) + expect(current.triggerSessionCount).toBe(1) + expect( + current.unifiedRepos + .flatMap((group) => group.worktrees) + .flatMap((worktree) => worktree.sessions) + .map((session) => session.sessionId) + ).toEqual(['native-local']) + expect(list.mock.calls.length).toBe(beforeExit) + act(() => current?.setOpen(false)) + expect(vi.getTimerCount()).toBe(0) + view.unmount() + expect(vi.getTimerCount()).toBe(0) + + expect(counts).toEqual({ renders: 1, commits: 1, reads: 0, pendingTimers: 0 }) +}) diff --git a/src/renderer/src/components/status-bar/resource-session-removal-render-budget.test.tsx b/src/renderer/src/components/status-bar/resource-session-removal-render-budget.test.tsx new file mode 100644 index 00000000000..bc9bb7efc9e --- /dev/null +++ b/src/renderer/src/components/status-bar/resource-session-removal-render-budget.test.tsx @@ -0,0 +1,259 @@ +// @vitest-environment happy-dom +import { act, cleanup, renderHook } from '@testing-library/react' +import { createElement, Profiler, StrictMode, type ReactNode } from 'react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { DaemonSession } from './resource-usage-merge-types' +import { notifyDaemonSessionInventoryInvalidated } from './daemon-session-inventory-invalidation' +import { useResourceSessionInventory } from './use-resource-session-inventory' + +type Inventory = ReturnType +const rows: DaemonSession[] = Array.from({ length: 12 }, (_, index) => ({ + id: index % 2 ? `ssh-${index}` : `native-${index}`, + title: `Terminal ${index}`, + cwd: index % 2 ? 'C:\\folder' : '/folder', + agentOwnership: 'absent' +})) +let spawned: (data: { id: string }) => void +let exited: (data: { id: string; code: number }) => void +const list = vi.fn<() => Promise>() +let stopped: string[] +function setup(hook: typeof useResourceSessionInventory, ready = true, strict = false) { + let renders = 0 + let commits = 0 + const view = renderHook( + (props: { ready: boolean }) => { + renders += 1 + return hook(props.ready) + }, + { + initialProps: { ready }, + wrapper: ({ children }: { children: ReactNode }) => + createElement( + Profiler, + { + id: 'inventory', + onRender: () => { + commits += 1 + } + }, + strict ? createElement(StrictMode, null, children) : children + ) + } + ) + return { ...view, renders: () => renders, commits: () => commits } +} +function output(value: Inventory, expected: readonly DaemonSession[], failed = false): void { + expect(value.sessionsError).toBe(failed) + expect(value.sessionInventory.count).toBe(expected.length) + expect(value.sessionInventory.sessions).toEqual(expected) + expected.forEach((row, index) => expect(value.sessionInventory.sessions[index]).toBe(row)) +} +function deferred() { + let resolve: (rows: DaemonSession[]) => void = () => {} + let reject: (error: Error) => void = () => {} + const promise = new Promise((yes, no) => { + resolve = yes + reject = no + }) + return { promise, resolve, reject } +} +beforeEach(() => { + vi.useFakeTimers() + stopped = [] + list.mockReset().mockResolvedValue(rows) + vi.stubGlobal('api', { + pty: { + listSessions: list, + onSpawned: (callback: typeof spawned) => { + spawned = callback + return () => stopped.push('spawn') + }, + onExit: (callback: typeof exited) => { + exited = callback + return () => stopped.push('exit') + } + } + }) +}) +afterEach(() => { + cleanup() + vi.useRealTimers() + vi.unstubAllGlobals() + vi.restoreAllMocks() +}) + +it.each([1, 2, 8, 64])( + 'counts %s ordinary closed-inventory exits with complete parity first', + async (events) => { + list.mockResolvedValue(rows) + const view = setup(useResourceSessionInventory) + await act(async () => {}) + const initial = view.result.current + const before = view.renders(), + requests = list.mock.calls.length + for (let index = 0; index < events; index += 1) { + act(() => exited({ id: `unlisted-${index}`, code: 0 })) + output(view.result.current, rows) + expect(view.result.current.sessionInventory).toBe(initial.sessionInventory) + expect(view.result.current.refreshSessions).toBe(initial.refreshSessions) + } + expect(list.mock.calls.length).toBe(requests) + expect(vi.getTimerCount()).toBe(0) + const renders = view.renders() - before + view.unmount() + + expect(renders).toBe(1) + } +) + +it('preserves complete inventory and request provenance for real short-lived spawn/exit notifications before render counts', async () => { + const view = setup(useResourceSessionInventory) + await act(async () => {}) + output(view.result.current, rows) + const initial = view.result.current + const before = view.renders(), + committed = view.commits(), + calls = list.mock.calls.length + for (let index = 0; index < 100; index += 1) { + act(() => { + spawned({ id: `short-lived-${index}` }) + exited({ id: `short-lived-${index}`, code: 0 }) + }) + output(view.result.current, rows) + expect(view.result.current.sessionInventory).toBe(initial.sessionInventory) + expect(view.result.current.refreshSessions).toBe(initial.refreshSessions) + expect(view.result.current.removeSession).toBe(initial.removeSession) + expect(view.result.current.removeSessions).toBe(initial.removeSessions) + } + await act(async () => { + await vi.advanceTimersByTimeAsync(1) + }) + output(view.result.current, rows) + const counts = { + renders: view.renders() - before, + commits: view.commits() - committed, + reads: list.mock.calls.length - calls, + callbacks: view.result.current.removeSession === initial.removeSession + } + view.unmount() + expect(stopped.slice(-2)).toEqual(['spawn', 'exit']) + + expect(counts.renders).toBeLessThanOrEqual(1) + expect(counts.reads).toBe(0) + expect(counts.commits).toBeLessThanOrEqual(1) +}) + +it('preserves unknown bulk/empty removal, real removal, errors and readiness epochs before counts', async () => { + list.mockResolvedValue(rows) + const view = setup(useResourceSessionInventory) + await act(async () => {}) + const before = view.renders() + act(() => view.result.current.removeSessions(new Set())) + output(view.result.current, rows) + act(() => view.result.current.removeSessions(new Set(['missing', '__proto__']))) + output(view.result.current, rows) + const renders = view.renders() - before + const beforeRemoval = view.result.current.sessionInventory + act(() => view.result.current.removeSession(rows[0]!.id)) + output(view.result.current, rows.slice(1)) + expect(view.result.current.sessionInventory).not.toBe(beforeRemoval) + expect(view.result.current.sessionInventory.sessions).not.toBe(beforeRemoval.sessions) + act(() => view.result.current.removeSessions(new Set([rows[2]!.id, rows[5]!.id]))) + output( + view.result.current, + rows.filter((row) => ![rows[0]!.id, rows[2]!.id, rows[5]!.id].includes(row.id)) + ) + list.mockRejectedValueOnce(new Error('original offline')) + await act(async () => { + await view.result.current.refreshSessions() + }) + expect(view.result.current.sessionsError).toBe(true) + const beforeError = view.result.current.sessionInventory + act(() => view.result.current.removeSession('absent')) + expect(view.result.current.sessionsError).toBe(true) + expect(view.result.current.sessionInventory).toBe(beforeError) + view.rerender({ ready: false }) + output(view.result.current, []) + view.rerender({ ready: true }) + await act(async () => {}) + output(view.result.current, rows) + view.unmount() + + expect(renders).toBe(1) +}) + +it.each([false, true])( + 'keeps unknown tombstones and same-ID reuse; StrictMode=%s', + async (strict) => { + const seed = deferred() + list.mockReturnValue(seed.promise) + const view = setup(useResourceSessionInventory, true, strict) + act(() => view.result.current.removeSession('missing-in-inventory')) + output(view.result.current, []) + const missing = { ...rows[0]!, id: 'missing-in-inventory' } + await act(async () => { + seed.resolve([...rows, missing]) + await seed.promise + }) + output(view.result.current, rows) + list.mockResolvedValueOnce([...rows, missing]) + await act(async () => { + await view.result.current.refreshSessions() + }) + output(view.result.current, [...rows, missing]) + const before = list.mock.calls.length + act(() => spawned({ id: missing.id })) + await act(async () => { + await vi.advanceTimersByTimeAsync(1) + }) + expect(list.mock.calls.length).toBe(before) + view.unmount() + } +) + +it.each([false, true])( + 'keeps newest refresh, invalidation and disposal; StrictMode=%s', + async (strict) => { + list.mockResolvedValue(rows) + const view = setup(useResourceSessionInventory, true, strict) + await act(async () => {}) + const older = deferred(), + newer = deferred() + list.mockReturnValueOnce(older.promise).mockReturnValueOnce(newer.promise) + let oldRead: Promise = Promise.resolve(), + newRead: Promise = Promise.resolve() + act(() => { + oldRead = view.result.current.refreshSessions() + newRead = view.result.current.refreshSessions() + }) + act(() => exited({ id: rows[0]!.id, code: 0 })) + await act(async () => { + newer.resolve(rows) + await newRead + }) + output(view.result.current, rows.slice(1)) + await act(async () => { + older.resolve([]) + await oldRead + }) + output(view.result.current, rows.slice(1)) + list.mockResolvedValueOnce(rows) + await act(async () => notifyDaemonSessionInventoryInvalidated()) + output(view.result.current, rows) + const late = deferred() + list.mockReturnValueOnce(late.promise) + act(() => spawned({ id: 'later' })) + await act(async () => { + await vi.advanceTimersByTimeAsync(1) + }) + view.unmount() + const before = list.mock.calls.length + await act(async () => { + late.resolve(rows) + await late.promise + await vi.advanceTimersByTimeAsync(1) + notifyDaemonSessionInventoryInvalidated() + }) + expect(list.mock.calls.length).toBe(before) + } +) diff --git a/src/renderer/src/components/status-bar/use-resource-session-inventory.ts b/src/renderer/src/components/status-bar/use-resource-session-inventory.ts index dd28e4b0398..cdc712e8123 100644 --- a/src/renderer/src/components/status-bar/use-resource-session-inventory.ts +++ b/src/renderer/src/components/status-bar/use-resource-session-inventory.ts @@ -96,10 +96,12 @@ export function useResourceSessionInventory(ready: boolean): ResourceSessionInve const lifecycleRevision = ++lifecycleRevisionRef.current removedAtRevisionRef.current.set(sessionId, lifecycleRevision) knownSessionIdsRef.current.delete(sessionId) - setStoredState((current) => ({ - ...current, - sessionInventory: removeSessionFromInventory(current.sessionInventory, sessionId) - })) + setStoredState((current) => { + const sessionInventory = removeSessionFromInventory(current.sessionInventory, sessionId) + return sessionInventory === current.sessionInventory + ? current + : { ...current, sessionInventory } + }) }, []) const removeSessions = useCallback((sessionIds: ReadonlySet): void => { @@ -108,10 +110,12 @@ export function useResourceSessionInventory(ready: boolean): ResourceSessionInve removedAtRevisionRef.current.set(sessionId, lifecycleRevision) knownSessionIdsRef.current.delete(sessionId) } - setStoredState((current) => ({ - ...current, - sessionInventory: removeSessionsFromInventory(current.sessionInventory, sessionIds) - })) + setStoredState((current) => { + const sessionInventory = removeSessionsFromInventory(current.sessionInventory, sessionIds) + return sessionInventory === current.sessionInventory + ? current + : { ...current, sessionInventory } + }) }, []) useEffect(() => { From cf43dd1a8883a60b417ee035b200d2c41ab2bf7a Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:35:22 -0700 Subject: [PATCH 10/67] Cancel pending cursor updates when a terminal closes (#24566) Reuse the existing pane frame tracker to cancel owned deferred focus-class updates during existing cleanup, with disposed guards against late or reentrant delivery. --- .../pane-container-listener-lifecycle.test.ts | 28 +++ ...ane-dom-focus-class-sync-lifecycle.test.ts | 210 ++++++++++++++++++ .../pane-manager/pane-dom-focus-class-sync.ts | 14 +- 3 files changed, 251 insertions(+), 1 deletion(-) create mode 100644 src/renderer/src/lib/pane-manager/pane-dom-focus-class-sync-lifecycle.test.ts diff --git a/src/renderer/src/lib/pane-manager/pane-container-listener-lifecycle.test.ts b/src/renderer/src/lib/pane-manager/pane-container-listener-lifecycle.test.ts index 99147c065f6..ba80462ac87 100644 --- a/src/renderer/src/lib/pane-manager/pane-container-listener-lifecycle.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-container-listener-lifecycle.test.ts @@ -1,6 +1,8 @@ +// @vitest-environment happy-dom import { describe, expect, it, vi } from 'vitest' import type { ManagedPaneInternal } from './pane-manager-types' import { disposePane } from './pane-lifecycle' +import { attachDomRendererFocusClassSync } from './pane-dom-focus-class-sync' function makePane(): ManagedPaneInternal { const leafId = '11111111-1111-4111-8111-111111111111' as never @@ -36,6 +38,32 @@ function makePane(): ManagedPaneInternal { } describe('disposePane container listener cleanup', () => { + it('cancels focus sync frames before disposing the terminal and removing its pane', () => { + const pending = new Map() + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + pending.set(1, callback) + return 1 + }) + vi.stubGlobal('cancelAnimationFrame', (id: number) => pending.delete(id)) + try { + const pane = makePane() + pane.focusClassSyncCleanup = attachDomRendererFocusClassSync(document.createElement('div')) + const panes = new Map([[pane.id, pane]]) + let pendingAtDispose: number | undefined + vi.mocked(pane.terminal.dispose).mockImplementation(() => { + pendingAtDispose = pending.size + }) + expect(pending.size).toBe(1) + disposePane(pane, panes) + expect(pendingAtDispose).toBe(0) + expect(pane.terminal.dispose).toHaveBeenCalledOnce() + expect(pane.focusClassSyncCleanup).toBeNull() + expect(panes.has(pane.id)).toBe(false) + } finally { + vi.unstubAllGlobals() + } + }) + it('removes pane container focus listeners', () => { const pane = makePane() const pointerDownHandler = pane.panePointerDownHandler diff --git a/src/renderer/src/lib/pane-manager/pane-dom-focus-class-sync-lifecycle.test.ts b/src/renderer/src/lib/pane-manager/pane-dom-focus-class-sync-lifecycle.test.ts new file mode 100644 index 00000000000..c52ec59fa3a --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-dom-focus-class-sync-lifecycle.test.ts @@ -0,0 +1,210 @@ +// @vitest-environment happy-dom +import { afterEach, describe, expect, it, vi } from 'vitest' +import { attachDomRendererFocusClassSync } from './pane-dom-focus-class-sync' + +function frameQueue() { + let nextId = 0 + const pending = new Map() + const cancel = vi.fn((id: number) => pending.delete(id)) + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + const id = ++nextId + pending.set(id, callback) + return id + }) + vi.stubGlobal('cancelAnimationFrame', cancel) + return { + pending, + cancel, + flush: () => { + const scheduled = Array.from(pending) + for (const [id, callback] of scheduled) { + pending.delete(id) + callback(16) + } + } + } +} + +function captureObserver(): () => void { + let notify = (): void => {} + vi.stubGlobal( + 'MutationObserver', + class { + constructor(callback: MutationCallback) { + notify = () => callback([], this) + } + observe(): void {} + disconnect(): void {} + takeRecords(): MutationRecord[] { + return [] + } + } + ) + return () => notify() +} + +function terminalElement(): HTMLDivElement { + const element = document.createElement('div') + element.innerHTML = '
' + return element +} + +function disposedTargets(count: number): WeakRef[] { + const targets: WeakRef[] = [] + for (let index = 0; index < count; index += 1) { + const element = terminalElement() + const release = attachDomRendererFocusClassSync(element) + targets.push(new WeakRef(element)) + release() + } + return targets +} + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +describe('terminal DOM focus sync lifetime', () => { + it('releases every disposed terminal DOM target while animation frames remain suspended', async () => { + const frames = frameQueue() + const targets = disposedTargets(64) + if (typeof globalThis.gc !== 'function') { + throw new Error('The test runner must enable --expose-gc') + } + await new Promise((resolve) => setImmediate(resolve)) + globalThis.gc() + globalThis.gc() + expect({ + callbacks: frames.pending.size, + targets: targets.filter((target) => target.deref() !== undefined).length + }).toEqual({ callbacks: 0, targets: 0 }) + }) + + it('keeps every immediate and deferred live sync in the same order without coalescing', () => { + const frames = frameQueue() + const notify = captureObserver() + const element = terminalElement() + const rows = element.firstElementChild + if (!rows) { + throw new Error('missing terminal rows') + } + const toggle = vi.spyOn(rows.classList, 'toggle') + const release = attachDomRendererFocusClassSync(element) + try { + element.classList.add('focus') + element.dispatchEvent(new Event('focusin')) + element.classList.remove('focus') + notify() + element.classList.add('focus') + element.dispatchEvent(new Event('focusout')) + expect(frames.pending.size).toBe(4) + frames.flush() + expect(toggle.mock.calls).toEqual( + [false, true, false, true, true, true, true, true].map((focused) => [ + 'xterm-focus', + focused + ]) + ) + expect(frames.pending.size).toBe(0) + release() + expect(frames.cancel).not.toHaveBeenCalled() + } finally { + release() + } + }) + + it('ignores callbacks and already queued listener delivery after disposal', () => { + const frames = frameQueue() + const notify = captureObserver() + const element = terminalElement() + const add = vi.spyOn(element, 'addEventListener') + const query = vi.spyOn(element, 'querySelector') + const release = attachDomRendererFocusClassSync(element) + const queued = [...frames.pending.values()] + release() + query.mockClear() + for (const callback of queued) { + callback(16) + } + for (const [, listener] of add.mock.calls) { + if (typeof listener === 'function') { + listener.call(element, new Event('focusin')) + } + } + notify() + element.dispatchEvent(new Event('focusin')) + expect(query).not.toHaveBeenCalled() + expect(frames.pending.size).toBe(0) + expect(frames.cancel).toHaveBeenCalledOnce() + }) + + it('does not queue another frame when the immediate sync disposes its own owner', () => { + const frames = frameQueue() + captureObserver() + const element = terminalElement() + const release = attachDomRendererFocusClassSync(element) + const query = element.querySelector.bind(element) + vi.spyOn(element, 'querySelector').mockImplementation((selector) => { + release() + return query(selector) + }) + element.dispatchEvent(new Event('focusin')) + expect(frames.pending.size).toBe(0) + expect(frames.cancel).toHaveBeenCalledOnce() + }) + + it('cancels only the old attachment when the same terminal element gets a successor', () => { + const frames = frameQueue() + captureObserver() + const element = terminalElement() + const oldRelease = attachDomRendererFocusClassSync(element) + const oldFrames = [...frames.pending.values()] + const release = attachDomRendererFocusClassSync(element) + try { + oldRelease() + expect(frames.pending.size).toBe(1) + for (const callback of oldFrames) { + callback(16) + } + element.classList.add('focus') + element.dispatchEvent(new Event('focusin')) + expect(frames.pending.size).toBe(2) + frames.flush() + expect(element.firstElementChild?.classList.contains('xterm-focus')).toBe(true) + release() + expect(frames.cancel).toHaveBeenCalledOnce() + } finally { + oldRelease() + release() + } + }) + + it('keeps synchronously completed frame shims out of the pending set', () => { + const cancel = vi.fn() + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + callback(16) + return 1 + }) + vi.stubGlobal('cancelAnimationFrame', cancel) + const element = terminalElement() + const query = vi.spyOn(element, 'querySelector') + const release = attachDomRendererFocusClassSync(element) + expect(query).toHaveBeenCalledTimes(2) + release() + expect(cancel).not.toHaveBeenCalled() + }) + + it('keeps missing elements and rows safe and releases repeated cleanup', () => { + const frames = frameQueue() + const missingRelease = attachDomRendererFocusClassSync(undefined) + missingRelease() + expect(frames.pending.size).toBe(0) + const release = attachDomRendererFocusClassSync(document.createElement('div')) + frames.flush() + release() + release() + expect(frames.pending.size).toBe(0) + expect(frames.cancel).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/lib/pane-manager/pane-dom-focus-class-sync.ts b/src/renderer/src/lib/pane-manager/pane-dom-focus-class-sync.ts index 137b9346ae8..c6540a9b8b6 100644 --- a/src/renderer/src/lib/pane-manager/pane-dom-focus-class-sync.ts +++ b/src/renderer/src/lib/pane-manager/pane-dom-focus-class-sync.ts @@ -1,3 +1,5 @@ +import { PaneReparentFrameTracker } from './pane-reparent-frame-tracker' + export function attachDomRendererFocusClassSync( terminalElement: HTMLElement | undefined ): () => void { @@ -5,6 +7,9 @@ export function attachDomRendererFocusClassSync( return () => undefined } + let disposed = false + const frames = new PaneReparentFrameTracker(() => disposed) + const sync = (): void => { const rows = terminalElement.querySelector('.xterm-rows') if (!rows) { @@ -16,8 +21,13 @@ export function attachDomRendererFocusClassSync( } const scheduleSync = (): void => { + if (disposed) { + return + } sync() - requestAnimationFrame(sync) + if (!disposed) { + frames.request(sync) + } } const observer = new MutationObserver(scheduleSync) @@ -27,6 +37,8 @@ export function attachDomRendererFocusClassSync( scheduleSync() return () => { + disposed = true + frames.cancelPending() observer.disconnect() terminalElement.removeEventListener('focusin', scheduleSync) terminalElement.removeEventListener('focusout', scheduleSync) From eee19ac42d772186eae6c268588d9d6b824073d8 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:35:25 -0700 Subject: [PATCH 11/67] Cancel terminal preview fit frames when the preview closes (#24712) Reuse the existing frame tracker inside the box-fit owner, suppress scheduling after disposal and cancel pending frames at the start of the existing effect cleanup. --- ...ntTerminalPreview.frame-lifecycle.test.tsx | 268 ++++++++++++++++++ ...TerminalPreview.settings-lifetime.test.tsx | 2 +- .../dashboard-popout/AgentTerminalPreview.tsx | 11 +- ...preview-terminal-box-fit-lifecycle.test.ts | 177 ++++++++++++ .../preview-terminal-box-fit.ts | 21 +- 5 files changed, 468 insertions(+), 11 deletions(-) create mode 100644 src/renderer/src/components/dashboard-popout/AgentTerminalPreview.frame-lifecycle.test.tsx create mode 100644 src/renderer/src/components/dashboard-popout/preview-terminal-box-fit-lifecycle.test.ts diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.frame-lifecycle.test.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.frame-lifecycle.test.tsx new file mode 100644 index 00000000000..3f07b0b0553 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.frame-lifecycle.test.tsx @@ -0,0 +1,268 @@ +// @vitest-environment happy-dom + +import { act, cleanup, render } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { ITerminalOptions } from '@xterm/xterm' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import { createGlobalSettingsFixture } from '../../../../shared/global-settings-test-fixture' +import { useAppStore } from '@/store' +import { AgentTerminalPreview } from './AgentTerminalPreview' + +type PreviewInstance = { + options: ITerminalOptions + container: HTMLElement | null + dispose: ReturnType + writeCallbacks: (() => void)[] +} +const harness = vi.hoisted(() => ({ + instances: new Array(), + deferWrites: false +})) + +vi.mock('@xterm/xterm', () => ({ + Terminal: class { + cols = 80 + rows = 24 + buffer = { active: { cursorY: 14 } } + container: HTMLElement | null = null + screen = document.createElement('div') + writeCallbacks: (() => void)[] = [] + write = vi.fn((_data: string, callback?: () => void) => { + if (!callback) { + return + } + if (harness.deferWrites) { + this.writeCallbacks.push(callback) + } else { + callback() + } + }) + focus = vi.fn() + resize = vi.fn() + reset = vi.fn() + onData = vi.fn(() => ({ dispose: vi.fn() })) + dispose = vi.fn(() => this.screen.remove()) + constructor(public options: ITerminalOptions) { + harness.instances.push(this) + } + open(container: HTMLElement): void { + this.container = container + this.screen.className = 'xterm-screen' + Object.defineProperties(this.screen, { + offsetWidth: { + get: () => + this.cols * + (this.options.fontSize === 18 || + this.options.fontFamily?.includes('Fira Code') || + this.options.fontWeight === 900 || + this.options.fontWeightBold === 900 + ? 12 + : 10) + }, + offsetHeight: { get: () => this.rows * 16 * (this.options.lineHeight ?? 1) } + }) + const box = container.parentElement + if (!box) { + throw new Error('Missing preview box') + } + Object.defineProperties(box, { + clientWidth: { configurable: true, value: 600 }, + clientHeight: { configurable: true, value: 240 } + }) + container.append(this.screen) + } + } +})) +vi.mock('@/components/terminal-pane/terminal-user-input-signal', () => ({ + subscribeToTerminalUserInput: () => ({ dispose: vi.fn() }) +})) +vi.mock('@/components/terminal-pane/use-system-prefers-dark', () => ({ + useSystemPrefersDark: () => false +})) +vi.mock('@/lib/keyboard-layout/use-effective-mac-option-as-alt', () => ({ + useEffectiveMacOptionAsAlt: (value: string) => value +})) +vi.mock('./preview-terminal-ligatures', () => ({ syncPreviewTerminalLigatures: vi.fn() })) +vi.mock('./preview-terminal-compatibility', () => ({ + installPreviewTerminalCompatibility: () => vi.fn() +})) +vi.mock('./preview-terminal-ime-bridge', () => ({ + installPreviewImeBridge: () => ({ claimKeyEvent: () => false, dispose: vi.fn() }) +})) +vi.mock('./preview-terminal-key-handler', () => ({ + installPreviewTerminalKeyHandler: () => vi.fn() +})) +vi.mock('@/components/terminal-pane/terminal-native-copy-gutter', () => ({ + installTerminalNativeCopyGutterTrim: () => ({ dispose: vi.fn() }) +})) +vi.mock('./preview-terminal-app-menu-clipboard', () => ({ + installPreviewTerminalAppMenuClipboard: () => vi.fn() +})) +vi.mock('./preview-terminal-right-click-paste', () => ({ + installPreviewTerminalRightClickPaste: () => vi.fn() +})) + +const initial = useAppStore.getInitialState() +const connect = vi.fn() +const fit = vi.fn(async (_ptyId: string, cols: number, rows: number) => ({ cols, rows })) +const unsubscribe = vi.fn(async () => {}) +let settings: GlobalSettings +let originalApi: PropertyDescriptor | undefined +const frames = new Map() +let nextFrame = 0 +const cancelFrame = vi.fn((id: number) => frames.delete(id)) +function flushFrames(): void { + for (const [id, callback] of frames) { + frames.delete(id) + callback(16) + } +} + +beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() + harness.instances.length = 0 + harness.deferWrites = false + frames.clear() + nextFrame = 0 + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + const id = nextFrame++ + frames.set(id, callback) + return id + }) + vi.stubGlobal('cancelAnimationFrame', cancelFrame) + // No resize notification or later output: replay must perform its own fit and grid claim. + vi.stubGlobal( + 'ResizeObserver', + class { + observe = vi.fn() + disconnect = vi.fn() + } + ) + settings = createGlobalSettingsFixture({ + theme: 'dark', + activeRuntimeEnvironmentId: null, + terminalFontSize: 14, + terminalFontFamily: 'JetBrains Mono', + terminalFontWeight: 500, + terminalFontWeightBold: 700, + terminalLineHeight: 1, + terminalLigatures: 'off' + }) + connect + .mockReset() + .mockResolvedValue({ snapshot: { data: '', cols: 80, rows: 24, seq: 1 }, replay: [] }) + originalApi = Object.getOwnPropertyDescriptor(window, 'api') + Object.defineProperty(window, 'api', { + configurable: true, + value: { + settings: { + set: async (updates: Partial) => { + settings = structuredClone({ ...settings, ...updates }) + return settings + } + }, + terminalPreview: { connect, fit, unsubscribe, onData: () => vi.fn() } + } + }) + useAppStore.setState({ ...initial, settings }, true) +}) + +afterEach(() => { + cleanup() + for (const instance of harness.instances) { + expect(instance.dispose).toHaveBeenCalledOnce() + } + useAppStore.setState(initial, true) + if (originalApi) { + Object.defineProperty(window, 'api', originalApi) + } else { + Reflect.deleteProperty(window, 'api') + } + frames.clear() + vi.unstubAllGlobals() + vi.useRealTimers() +}) + +it('releases every frame after 64 actual preview unmounts', async () => { + for (let index = 0; index < 64; index++) { + const view = render() + await act(async () => {}) + view.unmount() + } + expect(connect).toHaveBeenCalledTimes(64) + expect(unsubscribe).toHaveBeenCalledTimes(64) + expect(harness.instances).toHaveLength(64) + for (const instance of harness.instances) { + expect(instance.dispose).toHaveBeenCalledOnce() + } + expect(fit).not.toHaveBeenCalled() + expect(frames.size).toBe(0) + expect(cancelFrame).toHaveBeenCalledTimes(64) + expect(cancelFrame).toHaveBeenCalledWith(0) +}) + +it('does not schedule an old parsed write after actual unmount', async () => { + harness.deferWrites = true + const view = render() + await act(async () => {}) + const terminal = harness.instances[0] + expect(terminal).toBeDefined() + flushFrames() + view.unmount() + for (const callback of terminal?.writeCallbacks.splice(0) ?? []) { + callback() + } + expect(connect).toHaveBeenCalledOnce() + expect(unsubscribe).toHaveBeenCalledOnce() + expect(terminal?.dispose).toHaveBeenCalledOnce() + expect(frames.size).toBe(0) +}) + +it('retains the replacement frame on the same DOM and ignores retired parsed writes', async () => { + harness.deferWrites = true + render() + await act(async () => {}) + const old = harness.instances[0] + await act(async () => useAppStore.getState().updateSettings({ terminalFontSize: 18 })) + const replacement = harness.instances[1] + const pending = frames.size + for (const callback of old?.writeCallbacks.splice(0) ?? []) { + callback() + } + const afterOldWrites = frames.size + flushFrames() + await act(async () => { + await vi.advanceTimersByTimeAsync(200) + }) + expect(connect).toHaveBeenCalledTimes(2) + expect(unsubscribe).toHaveBeenCalledExactlyOnceWith('pty-1') + expect(old?.dispose).toHaveBeenCalledOnce() + expect(replacement?.container).toBe(old?.container) + expect(replacement?.container?.style.transform).toBe('scale(0.625)') + expect(replacement?.container?.style.transformOrigin).toBe('top left') + expect(fit).toHaveBeenCalledExactlyOnceWith('pty-1', 50, 15) + expect(frames.size).toBe(0) + expect([pending, afterOldWrites]).toEqual([1, 1]) +}) + +it('keeps live replay writes coalesced and fits the latest cursor and dimensions', async () => { + harness.deferWrites = true + render() + await act(async () => {}) + const terminal = harness.instances[0] + for (const callback of terminal?.writeCallbacks.splice(0) ?? []) { + callback() + } + expect(frames.size).toBe(1) + flushFrames() + await act(async () => { + await vi.advanceTimersByTimeAsync(200) + }) + expect(connect).toHaveBeenCalledOnce() + expect(unsubscribe).not.toHaveBeenCalled() + expect(terminal?.dispose).not.toHaveBeenCalled() + expect(terminal?.container?.style.transform).toBe('scale(0.75)') + expect(terminal?.container?.style.transformOrigin).toBe('top left') + expect(fit).toHaveBeenCalledExactlyOnceWith('pty-live', 60, 15) +}) diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.settings-lifetime.test.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.settings-lifetime.test.tsx index 84c0242e186..8b30cb09d70 100644 --- a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.settings-lifetime.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.settings-lifetime.test.tsx @@ -53,7 +53,7 @@ vi.mock('./preview-grid-claim', () => ({ createPreviewGridClaim: () => ({ schedule: vi.fn(), dispose: vi.fn() }) })) vi.mock('./preview-terminal-box-fit', () => ({ - createPreviewBoxFit: () => ({ schedule: vi.fn() }) + createPreviewBoxFit: () => ({ schedule: vi.fn(), dispose: vi.fn() }) })) vi.mock('./preview-terminal-ligatures', () => ({ syncPreviewTerminalLigatures: vi.fn() })) vi.mock('./preview-terminal-compatibility', () => ({ diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx index 875f13a96f4..15cdaa1285c 100644 --- a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx @@ -33,8 +33,7 @@ import { isWindowsUserAgent } from '@/components/terminal-pane/pane-helpers' import type { TerminalPreviewDataPayload } from '../../../../shared/terminal-preview' const PREVIEW_SCROLLBACK_ROWS = 24 -// Why: main only ever serializes PREVIEW_SCROLLBACK_ROWS of history into this -// terminal, so the pane's user-configured scrollback would only cost memory. +// Preview snapshots bound history; pane scrollback would only cost memory. const PREVIEW_SCROLLBACK_BUFFER_ROWS = 1000 const FALLBACK_COLS = 80 const FALLBACK_ROWS = 24 @@ -130,7 +129,6 @@ export function AgentTerminalPreview({ const pendingLivePayloads: Extract[] = [] const boxFit = createPreviewBoxFit({ container, getTerminal: () => terminal }) - const scheduleFit = boxFit.schedule const gridClaim = createPreviewGridClaim({ ptyId, @@ -142,7 +140,7 @@ export function AgentTerminalPreview({ typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(() => { - scheduleFit() + boxFit.schedule() gridClaim.schedule() }) if (container.parentElement) { @@ -162,7 +160,7 @@ export function AgentTerminalPreview({ replayDepth++ terminal?.write(chunk, () => { replayDepth-- - scheduleFit() + boxFit.schedule() onDone?.() }) } @@ -317,7 +315,7 @@ export function AgentTerminalPreview({ // Queue behind every replay write so replacement never clears a half-parsed frame. writeReplayed('', requestRefresh) } - scheduleFit() + boxFit.schedule() gridClaim.schedule() terminal.focus() } @@ -392,6 +390,7 @@ export function AgentTerminalPreview({ return () => { disposed = true + boxFit.dispose() if (retryTimer) { clearTimeout(retryTimer) } diff --git a/src/renderer/src/components/dashboard-popout/preview-terminal-box-fit-lifecycle.test.ts b/src/renderer/src/components/dashboard-popout/preview-terminal-box-fit-lifecycle.test.ts new file mode 100644 index 00000000000..e96d1eb1f29 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/preview-terminal-box-fit-lifecycle.test.ts @@ -0,0 +1,177 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { Terminal } from '@xterm/xterm' +import { createPreviewBoxFit } from './preview-terminal-box-fit' + +const frames = new Map() +const cancelFrame = vi.fn((id: number) => frames.delete(id)) +let nextFrame = 0 +const terminals: Terminal[] = [] + +beforeEach(() => { + nextFrame = 0 + frames.clear() + vi.clearAllMocks() + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + const id = nextFrame++ + frames.set(id, callback) + return id + }) + vi.stubGlobal('cancelAnimationFrame', cancelFrame) +}) + +afterEach(() => { + for (const terminal of terminals.splice(0)) { + terminal.dispose() + } + vi.unstubAllGlobals() +}) + +function harness(getTerminal?: () => null) { + const box = document.createElement('div') + const container = document.createElement('div') + const screen = document.createElement('div') + screen.className = 'xterm-screen' + box.append(container) + container.append(screen) + Object.defineProperties(box, { + clientWidth: { value: 600 }, + clientHeight: { value: 240 } + }) + Object.defineProperties(screen, { + offsetWidth: { value: 800 }, + offsetHeight: { value: 384 } + }) + const terminal = new Terminal({ rows: 24, cols: 80, allowProposedApi: true }) + terminals.push(terminal) + const read = vi.fn(getTerminal ?? (() => terminal)) + return { ...createPreviewBoxFit({ container, getTerminal: read }), box, container, read } +} + +function flush(id = 0): void { + const callback = frames.get(id) + frames.delete(id) + callback?.(16) +} + +function thrown(operation: () => void): unknown { + try { + operation() + } catch (error) { + return error + } + throw new Error('Expected an error') +} + +it('keeps live geometry and coalesces repeated requests into one frame', () => { + const owner = harness() + owner.schedule() + owner.schedule() + owner.schedule() + expect(owner.read).not.toHaveBeenCalled() + expect(frames.size).toBe(1) + flush() + expect(owner.read).toHaveBeenCalledOnce() + expect(owner.container.style.transform).toBe('scale(0.75)') + expect(owner.container.style.transformOrigin).toBe('top left') + expect(owner.box.style.alignItems).toBe('flex-start') + owner.dispose() + expect(cancelFrame).not.toHaveBeenCalled() +}) + +it('cancels frame zero once and suppresses captured or later callbacks after disposal', () => { + const owner = harness() + owner.schedule() + const captured = frames.get(0) + owner.dispose() + owner.dispose() + captured?.(16) + owner.schedule() + expect(owner.read).not.toHaveBeenCalled() + expect(owner.container.style.transform).toBe('') + expect(frames.size).toBe(0) + expect(cancelFrame).toHaveBeenCalledExactlyOnceWith(0) +}) + +it('preserves synchronous frame completion without canceling a completed handle', () => { + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + callback(16) + return 0 + }) + const owner = harness() + owner.schedule() + owner.schedule() + expect(owner.read).toHaveBeenCalledTimes(2) + owner.dispose() + expect(cancelFrame).not.toHaveBeenCalled() +}) + +it('retains a reentrant successor frame and cancels only that pending successor', () => { + const owner = harness() + owner.read.mockImplementationOnce(() => { + owner.schedule() + return null + }) + owner.schedule() + flush() + expect(owner.read).toHaveBeenCalledOnce() + expect([...frames.keys()]).toEqual([1]) + owner.dispose() + expect(cancelFrame).toHaveBeenCalledExactlyOnceWith(1) +}) + +it('marks disposal before cancellation reenters schedule or invokes the retired callback', () => { + const owner = harness() + owner.schedule() + const callback = frames.get(0) + cancelFrame.mockImplementationOnce((id) => { + owner.schedule() + owner.dispose() + callback?.(16) + return frames.delete(id) + }) + owner.dispose() + expect(owner.read).not.toHaveBeenCalled() + expect(frames.size).toBe(0) + expect(cancelFrame).toHaveBeenCalledExactlyOnceWith(0) +}) + +it('preserves the original synchronous scheduling error and already-set coalescing flag', () => { + const error = new Error('scheduler failed') + const request = vi.fn(() => { + throw error + }) + vi.stubGlobal('requestAnimationFrame', request) + const owner = harness() + expect(thrown(owner.schedule)).toBe(error) + owner.schedule() + expect(request).toHaveBeenCalledOnce() + owner.dispose() + expect(cancelFrame).not.toHaveBeenCalled() +}) + +it('preserves a fit error and allows the next live request after completion', () => { + const error = new Error('terminal read failed') + const owner = harness() + owner.read.mockImplementationOnce(() => { + throw error + }) + owner.schedule() + expect(thrown(() => flush())).toBe(error) + owner.schedule() + flush(1) + expect(owner.read).toHaveBeenCalledTimes(2) + owner.dispose() + expect(cancelFrame).not.toHaveBeenCalled() +}) + +it('does not cancel another owner when both use the same container', () => { + const old = harness() + const current = createPreviewBoxFit({ container: old.container, getTerminal: () => null }) + old.schedule() + current.schedule() + old.dispose() + expect([...frames.keys()]).toEqual([1]) + current.dispose() + expect(cancelFrame.mock.calls).toEqual([[0], [1]]) +}) diff --git a/src/renderer/src/components/dashboard-popout/preview-terminal-box-fit.ts b/src/renderer/src/components/dashboard-popout/preview-terminal-box-fit.ts index ccf1f9f0da5..966c07983bf 100644 --- a/src/renderer/src/components/dashboard-popout/preview-terminal-box-fit.ts +++ b/src/renderer/src/components/dashboard-popout/preview-terminal-box-fit.ts @@ -1,4 +1,5 @@ import type { Terminal } from '@xterm/xterm' +import { PaneReparentFrameTracker } from '@/lib/pane-manager/pane-reparent-frame-tracker' type PreviewBoxFitTerminal = Pick @@ -13,7 +14,9 @@ type PreviewBoxFitTerminal = Pick export function createPreviewBoxFit(args: { container: HTMLElement getTerminal: () => PreviewBoxFitTerminal | null -}): { fit: () => void; schedule: () => void } { +}): { fit: () => void; schedule: () => void; dispose: () => void } { + let disposed = false + const frames = new PaneReparentFrameTracker(() => disposed) const fit = (): void => { const terminal = args.getTerminal() const screen = args.container.querySelector('.xterm-screen') @@ -33,15 +36,25 @@ export function createPreviewBoxFit(args: { // Re-fit after every parsed write (cursor may move ends); rAF coalesces. let scheduled = false const schedule = (): void => { - if (scheduled) { + if (disposed || scheduled) { return } scheduled = true - requestAnimationFrame(() => { + frames.request(() => { scheduled = false fit() }) } - return { fit, schedule } + return { + fit, + schedule, + dispose: (): void => { + if (disposed) { + return + } + disposed = true + frames.cancelPending() + } + } } From 1de8396f5b6e7636489b887c188c426bdde2133c Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:35:28 -0700 Subject: [PATCH 12/67] Skip new mobile toast work after feedback owner cleanup (#24759) Reuse the existing mobile mounted-ref lifecycle pattern at toast presentation entry, preserving admitted clipboard outcomes and every live animation/sequence/timer operation. --- ...obile-session-toast-owner-cleanup.test.tsx | 434 ++++++++++++++++++ ...se-mobile-session-feedback-capabilities.ts | 13 +- 2 files changed, 446 insertions(+), 1 deletion(-) create mode 100644 mobile/src/session/mobile-session-toast-owner-cleanup.test.tsx diff --git a/mobile/src/session/mobile-session-toast-owner-cleanup.test.tsx b/mobile/src/session/mobile-session-toast-owner-cleanup.test.tsx new file mode 100644 index 00000000000..3ba4220962b --- /dev/null +++ b/mobile/src/session/mobile-session-toast-owner-cleanup.test.tsx @@ -0,0 +1,434 @@ +import { createElement, StrictMode } from 'react' +import { act, create } from 'react-test-renderer' +import { afterEach, expect, it, vi } from 'vitest' + +type AnimationStart = { + options: { toValue: number; duration: number; useNativeDriver: boolean } + complete: (result: { finished: boolean }) => void +} +const boundary = vi.hoisted(() => { + const animationStarts: AnimationStart[] = [] + return { + animationStarts, + writeText: vi.fn<(text: string) => Promise>(), + success: vi.fn(), + error: vi.fn(), + synchronous: false + } +}) +vi.mock('react-native', () => ({ + View: 'View', + Platform: { OS: 'ios' }, + Keyboard: { dismiss: vi.fn() }, + Animated: { + timing: ( + _value: unknown, + options: { toValue: number; duration: number; useNativeDriver: boolean } + ) => ({ + start: (complete: (result: { finished: boolean }) => void) => { + boundary.animationStarts.push({ options, complete }) + if (boundary.synchronous) { + complete({ finished: true }) + } + } + }) + } +})) +vi.mock('../platform/clipboard', () => ({ + useClipboardWriter: () => ({ writeText: boundary.writeText }), + useClipboardReader: () => ({ contents: async () => ({ text: false, image: false }) }) +})) +vi.mock('../platform/haptics', () => ({ + triggerSuccess: boundary.success, + triggerError: boundary.error, + triggerSelection: vi.fn(), + triggerEdgeBump: vi.fn() +})) +vi.mock('../terminal/terminal-copy-gutter-preference', () => ({ + useTerminalCopyTrimsGutter: () => ({ current: false }) +})) +vi.mock('./mobile-session-styles', () => ({ styles: { container: {}, kavInner: {} } })) +vi.mock('./MobileSessionHeader', () => ({ MobileSessionHeader: () => null })) +vi.mock('./MobileSessionContentRow', () => ({ MobileSessionContentRow: () => null })) +vi.mock('./MobileSessionSheets', () => ({ MobileSessionSheets: () => null })) +import { useMobileSessionFeedbackCapabilities } from './use-mobile-session-feedback-capabilities' +import { useMobileSessionAccessorySelection } from './use-mobile-session-accessory-selection' +import { MobileSessionSurface } from './MobileSessionSurface' + +type FeedbackScope = Parameters[0] +type AccessoryScope = Parameters[0] +type SurfaceController = Parameters[0]['controller'] + +afterEach(() => { + vi.clearAllTimers() + vi.useRealTimers() + vi.clearAllMocks() + boundary.animationStarts.length = 0 + boundary.synchronous = false + vi.restoreAllMocks() +}) +function mounted(strict = false) { + const setToastMessage = vi.fn() + const clearTerminalCache = vi.fn() + const cancelSelect = vi.fn() + const scope = { + client: null, + connState: 'connected', + initialCreateWarning: '', + sessionTabs: [], + sessionTabsRef: { current: [] }, + activeSessionTabId: null, + activeSessionTabIdRef: { current: null }, + markdownDocs: new Map(), + markdownDocsRef: { current: new Map() }, + createWarningState: { source: '', visible: '' }, + setCreateWarningState: vi.fn(), + setToastMessage, + toastOpacityRef: { current: {} }, + toastHideTimerRef: { current: null }, + toastSeqRef: { current: 0 }, + clientRef: { current: null }, + connStateRef: { current: 'connected' }, + activeSessionTabTypeRef: { current: 'terminal' }, + delayedActionTimersRef: { current: new Set() }, + activeSessionTab: { type: 'terminal' }, + worktreeId: 'folder:remote', + isFloatingWorkspaceRoute: false, + setTerminalKeyboardMetrics: vi.fn(), + setSelectModeActive: vi.fn(), + setCanPaste: vi.fn(), + ptyModesRef: { current: new Map() }, + initialModesSeenRef: { current: new Set() }, + terminalRefs: { current: new Map([['handle', { cancelSelect }]]) }, + liveInputFocusTimerRef: { current: null }, + sessionTabActionSheetRequestSeqRef: { current: 0 }, + activeHandleRef: { current: 'handle' }, + clearPendingLiveInputCommit: vi.fn(), + clearTerminalCache, + handleAccessoryKey: vi.fn(), + clearSessionTabActionSheetKeyboardListener: vi.fn() + } + const read: { + value: + | (ReturnType & + ReturnType) + | undefined + } = { value: undefined } + function Probe({ + surfaceKey = 'initial', + visible = true + }: { + surfaceKey?: string + visible?: boolean + }) { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Actual feedback hook reads only the enumerated members; its type includes unrelated composed controller hooks. + const feedbackScope = scope as unknown as FeedbackScope + const feedback = useMobileSessionFeedbackCapabilities(feedbackScope) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Actual accessory hook reads only enumerated members plus actual feedback capability callbacks. + const accessoryScope = { ...scope, ...feedback } as unknown as AccessoryScope + const accessory = useMobileSessionAccessorySelection(accessoryScope) + const result = { ...feedback, ...accessory } + read.value = result + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Actual surface reads only setMobileSessionRootRef; its child components are isolated presentation stubs. + const controller = result as unknown as SurfaceController + return visible ? createElement(MobileSessionSurface, { key: surfaceKey, controller }) : null + } + let root: ReturnType | undefined + const element = (surfaceKey = 'initial', visible = true) => + strict + ? createElement(StrictMode, null, createElement(Probe, { surfaceKey, visible })) + : createElement(Probe, { surfaceKey, visible }) + act(() => { + root = create(element(), { createNodeMock: () => ({}) }) + }) + if (!root || !read.value) { + throw new Error('real composed hooks did not mount') + } + const renderer = root + return { + root, + get api() { + if (!read.value) { + throw new Error('real composed hooks did not render') + } + return read.value + }, + scope, + setToastMessage, + clearTerminalCache, + cancelSelect, + rerender: (surfaceKey: string, visible = true) => + act(() => renderer.update(element(surfaceKey, visible))) + } +} +it.each([ + ['success', false], + ['failure', false], + ['success', true], + ['failure', true] +] as const)( + 'keeps admitted %s copy outcome after unmount without new timers, StrictMode=%s', + async (outcome, strict) => { + vi.useFakeTimers() + let resolveWrite: (() => void) | undefined + let rejectWrite: ((error: Error) => void) | undefined + const clipboard = new Promise((resolve, reject) => { + resolveWrite = resolve + rejectWrite = reject + }) + boundary.writeText.mockReturnValue(clipboard) + const view = mounted(strict) + const baselineClear = view.clearTerminalCache.mock.calls.length + const baselineSequence = view.scope.toastSeqRef.current + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + let copying: Promise | undefined + act(() => { + copying = view.api.handleSelectionCopy('handle', 'original text') + }) + expect(boundary.writeText.mock.calls).toEqual([['original text']]) + expect(boundary.animationStarts).toHaveLength(0) + act(() => view.root.unmount()) + expect(view.clearTerminalCache).toHaveBeenCalledTimes(baselineClear + 1) + expect(view.scope.toastSeqRef.current).toBe(baselineSequence + 1) + expect(vi.getTimerCount()).toBe(0) + await act(async () => { + if (outcome === 'success') { + resolveWrite?.() + } else { + rejectWrite?.(new Error('clipboard refused')) + } + await copying + }) + expect(boundary.success).toHaveBeenCalledTimes(outcome === 'success' ? 1 : 0) + expect(boundary.error).toHaveBeenCalledTimes(outcome === 'failure' ? 1 : 0) + expect(view.cancelSelect).toHaveBeenCalledTimes(outcome === 'success' ? 1 : 0) + expect(warn.mock.calls).toEqual( + outcome === 'failure' + ? [['[mobile-clip] setString failed', { name: 'Error', message: 'clipboard refused' }]] + : [] + ) + for (const animation of boundary.animationStarts) { + act(() => animation.complete({ finished: true })) + } + expect(vi.getTimerCount()).toBe(0) + expect(boundary.animationStarts).toHaveLength(0) + expect(view.setToastMessage).not.toHaveBeenCalled() + } +) +it('proves existing detach sequence already fences an admitted fade-in', () => { + vi.useFakeTimers() + const view = mounted() + act(() => view.api.showToast('already visible')) + expect(boundary.animationStarts).toHaveLength(1) + act(() => view.root.unmount()) + act(() => boundary.animationStarts[0].complete({ finished: true })) + expect(vi.getTimerCount()).toBe(0) + expect(boundary.animationStarts).toHaveLength(1) +}) +it('proves existing detach cleanup clears a live hide timer', () => { + vi.useFakeTimers() + const view = mounted() + act(() => view.api.showToast('already visible')) + act(() => boundary.animationStarts[0].complete({ finished: true })) + expect(vi.getTimerCount()).toBe(1) + act(() => view.root.unmount()) + expect(vi.getTimerCount()).toBe(0) +}) + +it.each([false, true])( + 'preserves late admitted copy after same-hook root replacement, StrictMode=%s', + async (strict) => { + vi.useFakeTimers() + let finish: (() => void) | undefined + boundary.writeText.mockReturnValue( + new Promise((resolve) => { + finish = resolve + }) + ) + const view = mounted(strict) + const originalShow = view.api.showToast + let copying: Promise | undefined + act(() => { + copying = view.api.handleSelectionCopy('handle', 'original text') + }) + const previousClear = view.clearTerminalCache.mock.calls.length + view.rerender('replacement') + expect(view.clearTerminalCache.mock.calls.length).toBeGreaterThan(previousClear) + expect(view.api.showToast).toBe(originalShow) + await act(async () => { + finish?.() + await copying + }) + expect(boundary.writeText.mock.calls).toEqual([['original text']]) + expect(boundary.success).toHaveBeenCalledTimes(1) + expect(view.cancelSelect).toHaveBeenCalledTimes(1) + expect(view.setToastMessage.mock.calls).toEqual([['Copied']]) + expect(boundary.animationStarts).toHaveLength(1) + act(() => boundary.animationStarts[0].complete({ finished: true })) + expect(vi.getTimerCount()).toBe(1) + act(() => view.root.unmount()) + expect(vi.getTimerCount()).toBe(0) + } +) +it('preserves late copy while surface is temporarily detached but hook stays mounted', async () => { + vi.useFakeTimers() + let finish: (() => void) | undefined + boundary.writeText.mockReturnValue( + new Promise((resolve) => { + finish = resolve + }) + ) + const view = mounted() + let copying: Promise | undefined + act(() => { + copying = view.api.handleSelectionCopy('handle', 'original text') + }) + view.rerender('inactive', false) + expect(view.clearTerminalCache).toHaveBeenCalledTimes(1) + await act(async () => { + finish?.() + await copying + }) + expect(boundary.success).toHaveBeenCalledTimes(1) + expect(view.cancelSelect).toHaveBeenCalledTimes(1) + expect(view.setToastMessage.mock.calls).toEqual([['Copied']]) + act(() => boundary.animationStarts[0].complete({ finished: true })) + expect(vi.getTimerCount()).toBe(1) + view.rerender('active', true) + act(() => view.root.unmount()) + expect(vi.getTimerCount()).toBe(0) +}) +it.each([1200, 1500])('keeps exact live fade-in/hide/fade-out timing at %s ms', (duration) => { + vi.useFakeTimers() + const view = mounted() + act(() => view.api.showToast('message', duration)) + expect(view.setToastMessage.mock.calls).toEqual([['message']]) + expect(boundary.animationStarts[0].options).toEqual({ + toValue: 1, + duration: 150, + useNativeDriver: true + }) + act(() => boundary.animationStarts[0].complete({ finished: true })) + expect(vi.getTimerCount()).toBe(1) + act(() => { + vi.advanceTimersByTime(duration - 1) + }) + expect(boundary.animationStarts).toHaveLength(1) + act(() => { + vi.advanceTimersByTime(1) + }) + expect(vi.getTimerCount()).toBe(0) + expect(boundary.animationStarts[1].options).toEqual({ + toValue: 0, + duration: 200, + useNativeDriver: true + }) + act(() => boundary.animationStarts[1].complete({ finished: true })) + expect(view.setToastMessage.mock.calls).toEqual([['message'], [null]]) + act(() => view.root.unmount()) +}) +it('preserves old unfinished/replaced animation fences and latest toast ownership', () => { + vi.useFakeTimers() + const view = mounted() + act(() => view.api.showToast('first')) + act(() => view.api.showToast('second', 1500)) + act(() => boundary.animationStarts[0].complete({ finished: true })) + expect(vi.getTimerCount()).toBe(0) + act(() => boundary.animationStarts[1].complete({ finished: false })) + expect(vi.getTimerCount()).toBe(0) + act(() => view.api.showToast('third')) + act(() => boundary.animationStarts[2].complete({ finished: true })) + expect(vi.getTimerCount()).toBe(1) + act(() => view.api.showToast('fourth')) + expect(vi.getTimerCount()).toBe(0) + act(() => boundary.animationStarts[3].complete({ finished: true })) + act(() => { + vi.advanceTimersByTime(1200) + }) + act(() => view.api.showToast('fifth')) + act(() => boundary.animationStarts[4].complete({ finished: true })) + expect(view.setToastMessage.mock.calls).toEqual([ + ['first'], + ['second'], + ['third'], + ['fourth'], + ['fifth'] + ]) + act(() => view.root.unmount()) +}) +it('keeps 64 admitted copies after 64 surface/hook unmounts without new timers', async () => { + vi.useFakeTimers() + const releases: (() => void)[] = [] + const copying: Promise[] = [] + const views: ReturnType[] = [] + for (let index = 0; index < 64; index++) { + boundary.writeText.mockReturnValueOnce( + new Promise((resolve) => { + releases.push(resolve) + }) + ) + const view = mounted() + views.push(view) + act(() => { + copying.push(view.api.handleSelectionCopy('handle', 'copy' + index)) + }) + act(() => view.root.unmount()) + } + expect(boundary.writeText.mock.calls).toEqual( + Array.from({ length: 64 }, (_, index) => ['copy' + index]) + ) + expect(vi.getTimerCount()).toBe(0) + await act(async () => { + for (const release of releases) { + release() + } + await Promise.all(copying) + }) + expect(boundary.success).toHaveBeenCalledTimes(64) + expect(boundary.error).not.toHaveBeenCalled() + for (const view of views) { + expect(view.cancelSelect).toHaveBeenCalledTimes(1) + expect(view.clearTerminalCache).toHaveBeenCalledTimes(1) + } + for (const animation of boundary.animationStarts) { + act(() => animation.complete({ finished: true })) + } + expect(vi.getTimerCount()).toBe(0) + expect(boundary.animationStarts).toHaveLength(0) +}) + +it('preserves synchronous native completion and natural hide cleanup', () => { + vi.useFakeTimers() + boundary.synchronous = true + const view = mounted() + act(() => view.api.showToast('sync')) + expect(vi.getTimerCount()).toBe(1) + act(() => { + vi.advanceTimersByTime(1200) + }) + expect(vi.getTimerCount()).toBe(0) + expect(view.setToastMessage.mock.calls).toEqual([['sync'], [null]]) + expect(boundary.animationStarts.map((start) => start.options)).toEqual([ + { toValue: 1, duration: 150, useNativeDriver: true }, + { toValue: 0, duration: 200, useNativeDriver: true } + ]) + act(() => view.root.unmount()) +}) +it('preserves reentrant newer-toast sequence and animation start order', () => { + vi.useFakeTimers() + const view = mounted() + view.setToastMessage.mockImplementationOnce(() => view.api.showToast('inner')) + act(() => view.api.showToast('outer')) + expect(view.setToastMessage.mock.calls).toEqual([['outer'], ['inner']]) + expect(boundary.animationStarts).toHaveLength(2) + act(() => boundary.animationStarts[0].complete({ finished: true })) + expect(vi.getTimerCount()).toBe(1) + act(() => boundary.animationStarts[1].complete({ finished: true })) + expect(vi.getTimerCount()).toBe(1) + act(() => { + vi.advanceTimersByTime(1200) + }) + act(() => boundary.animationStarts[2].complete({ finished: true })) + expect(view.setToastMessage.mock.calls).toEqual([['outer'], ['inner'], [null]]) + act(() => view.root.unmount()) +}) diff --git a/mobile/src/session/use-mobile-session-feedback-capabilities.ts b/mobile/src/session/use-mobile-session-feedback-capabilities.ts index ea8d6438bf4..ad0fb781363 100644 --- a/mobile/src/session/use-mobile-session-feedback-capabilities.ts +++ b/mobile/src/session/use-mobile-session-feedback-capabilities.ts @@ -1,4 +1,4 @@ -import { useState, useRef, useCallback } from 'react' +import { useState, useRef, useCallback, useEffect } from 'react' import { Animated } from 'react-native' import { reconcileMobileSessionCreateWarningState } from './mobile-session-create-warning-state' import type { MobileSessionTerminalRuntimeModel } from './use-mobile-session-terminal-runtime' @@ -60,6 +60,14 @@ export function useMobileSessionFeedbackCapabilities(scope: MobileSessionTermina } const createWarning = reconciledCreateWarningState.visible + const mountedRef = useRef(true) + useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + } + }, []) + const clearDelayedActionTimers = useCallback(() => { for (const timer of delayedActionTimersRef.current) { clearTimeout(timer) @@ -85,6 +93,9 @@ export function useMobileSessionFeedbackCapabilities(scope: MobileSessionTermina const showToast = useCallback( (message: string, durationMs = 1200) => { + if (!mountedRef.current) { + return + } const seq = toastSeqRef.current + 1 toastSeqRef.current = seq clearToastHideTimer() From 86b2dd6de8b8717faaf25579eeaa8410e2577261 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:47:03 -0700 Subject: [PATCH 13/67] Release terminal side effects after they have been delivered (#24548) Clear consumed queue slots after successful apply or overflow carry; preserve backing identity across reentrant callbacks and report actual retained slots. --- config/reliability-gates.jsonc | 84 ++++++++++++ ...utput-side-effect-queue-reentrancy.test.ts | 121 +++++++++++++++++ ...output-side-effect-queue-retention.test.ts | 125 ++++++++++++++++++ .../pty-output-side-effect-queue.ts | 27 +++- .../pty-side-effect-pending-census.test.ts | 6 +- .../pty-side-effect-pending-census.ts | 2 +- 6 files changed, 358 insertions(+), 7 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/pty-output-side-effect-queue-reentrancy.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-output-side-effect-queue-retention.test.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index a157e9dc79c..faf844b5137 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -21411,6 +21411,90 @@ "A tombstone that exhausts its retries stays on disk until the next startup, unchanged from before." ], "demotionRule": "Keep experimental or demote if the reused listing strands a displaced root, crosses the admission cap, rearms an exhausted retry through another root, hands one tombstone to removal twice, or touches a recreated live history path." + }, + { + "id": "terminal-performance.consumed-side-effect-retention", + "title": "Terminal side-effect queues release successfully applied and evicted effects", + "maturity": "experimental", + "protection": "partial", + "owner": "terminal-runtime", + "layer": "renderer-unit", + "surfaces": ["terminal output side effects", "renderer memory census"], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "daemon", "ssh", "remote-runtime"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["local"], + "coverageNotes": "Provider-independent queue and mocked IPC output contracts run on macOS. Remote-runtime uses this processor but has no live session run. Native mobile uses another processor and is unaffected; host ownership, ACKs, wire, paths, folder/git identity, PTY lifecycle and output bytes are unchanged. Linux, Windows, WSL and live remote execution are gaps.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/blob/main/src/renderer/src/components/terminal-pane/pty-output-side-effect-queue.ts" + ], + "invariant": "Release consumed title/payload objects after successful apply or overflow carry, preserving callback order, 64-effect drains, the 512-effect pending cap, bell and payload carry, empty-tail coalescing, reentrant clear/flush/enqueue, thrown-apply behavior and output delivery.", + "oracle": "Forced GC collects all 64 applied effects from a 100-effect bounded drain while the remaining 36 stay alive and deliver in order; it also collects the first evicted effect in a 513-effect burst while all 512 survivors remain alive. Clearing during apply immediately releases all 99 other pending effects, as the original queue did. Census reports 36 retained objects after the bounded drain. Explicit reentrant and error cases produce the same observations against the original queue.", + "commands": [ + "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/renderer/src/components/terminal-pane/pty-output-side-effect-queue-retention.test.ts src/renderer/src/components/terminal-pane/pty-output-side-effect-queue-reentrancy.test.ts src/renderer/src/components/terminal-pane/pty-side-effect-pending-census.test.ts src/renderer/src/components/terminal-pane/pty-transport-output-side-effects.test.ts src/renderer/src/components/terminal-pane/pty-transport-eager-buffer-replay.test.ts" + ], + "testFiles": [ + "src/renderer/src/components/terminal-pane/pty-output-side-effect-queue-retention.test.ts", + "src/renderer/src/components/terminal-pane/pty-output-side-effect-queue-reentrancy.test.ts", + "src/renderer/src/components/terminal-pane/pty-side-effect-pending-census.test.ts", + "src/renderer/src/components/terminal-pane/pty-transport-output-side-effects.test.ts", + "src/renderer/src/components/terminal-pane/pty-transport-eager-buffer-replay.test.ts" + ], + "assertionRefs": [ + { + "file": "src/renderer/src/components/terminal-pane/pty-output-side-effect-queue-retention.test.ts", + "assertions": [ + "releases applied effects while preserving every pending effect and its delivery order", + "releases an evicted effect before the compaction threshold", + "releases every pending effect immediately when clear is called during apply" + ] + }, + { + "file": "src/renderer/src/components/terminal-pane/pty-output-side-effect-queue-reentrancy.test.ts", + "assertions": [ + "keeps empty-tail coalescing observable during the apply callback", + "delivers the same effect requeued after clear without releasing its new slot", + "preserves nested flush order and effects enqueued after the inner compaction", + "preserves thrown apply errors and their existing empty-tail coalescing" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-10-01", + "runner": "local", + "platform": "macos", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/renderer/src/components/terminal-pane/pty-output-side-effect-queue-retention.test.ts src/renderer/src/components/terminal-pane/pty-output-side-effect-queue-reentrancy.test.ts src/renderer/src/components/terminal-pane/pty-side-effect-pending-census.test.ts src/renderer/src/components/terminal-pane/pty-transport-output-side-effects.test.ts src/renderer/src/components/terminal-pane/pty-transport-eager-buffer-replay.test.ts", + "result": "passed", + "durationSeconds": 1.66, + "summary": "46 tests across five files passed. Four reentrant/error cases also pass against an isolated original-queue copy; stock and candidate both release 99 pending effects when clear runs during apply." + } + ], + "runtimeBudget": { + "p95Seconds": 30, + "scope": "Focused renderer queue/output tests, including forced GC; p95 not established." + }, + "flakeHistory": { + "status": "not-started", + "evidence": "Local author and independent review validation; no CI soak. Reference-lifetime tests require the test runner's existing --expose-gc." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "Original queue fails both WeakRef collection assertions: all 64 applied effects and the evicted effect remain reachable. Clearing only consumed slots passes while all pending effects stay live. Baseline and candidate both pass all four reentrant/error observations." + }, + "performanceBudget": { + "required": true, + "evidence": "Retained objects fall from 100 to 36 after one bounded drain and from 513 to 512 after one overflow eviction before compaction. Release is constant work per consumed effect; no changed drain limit, timer, polling, batching, cache, transport call or subprocess. Existing compaction cadence remains; clear truncates then replaces its backing array to preserve immediate release and protect reentrant same-object requeue." + }, + "knownGaps": [ + "No real renderer heap-byte or input-latency measurement; references and complete delivery are the deterministic oracle.", + "No live Linux, Windows, WSL, SSH or paired-runtime session run; these use provider-independent queue code.", + "Thrown apply callbacks keep their consumed reference until the original compaction boundary to preserve exception/coalescing behavior." + ], + "promotionCriteria": [ + "Collect CI soak with zero unexplained GC flakes and retain callback-order, overflow-carry and reentrancy assertions." + ], + "demotionRule": "Keep experimental; investigate delivery, coalescing, error-path or pending-reference regressions without weakening the retention or fidelity oracle." } ] } diff --git a/src/renderer/src/components/terminal-pane/pty-output-side-effect-queue-reentrancy.test.ts b/src/renderer/src/components/terminal-pane/pty-output-side-effect-queue-reentrancy.test.ts new file mode 100644 index 00000000000..ced9735cea8 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-output-side-effect-queue-reentrancy.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from 'vitest' +import { + createPtyOutputSideEffectQueue, + type PendingPtySideEffect, + type PtyOutputSideEffectQueue +} from './pty-output-side-effect-queue' + +function emptyEffect(titleScanEffect: PendingPtySideEffect['titleScanEffect'] = 'none') { + return { + payloads: [], + titles: [], + titleScanEffect, + containsBell: false, + suppressAttentionEvents: false + } +} + +function titledEffect(title: string): PendingPtySideEffect { + return { ...emptyEffect(), titles: [title] } +} + +function withQueue( + run: (queue: PtyOutputSideEffectQueue) => void, + apply: (effect: PendingPtySideEffect) => void +): void { + const queue = createPtyOutputSideEffectQueue({ + countWorkingTitles: (titles) => titles.length, + apply + }) + try { + run(queue) + } finally { + queue.clear() + queue.disposeGauge() + } +} + +describe('PTY side-effect queue reentrant delivery', () => { + it('keeps empty-tail coalescing observable during the apply callback', () => { + let queue: PtyOutputSideEffectQueue + const observed: string[] = [] + withQueue( + (created) => { + queue = created + queue.enqueue(emptyEffect()) + queue.flush() + expect(observed).toEqual(['none', 'stale-probe']) + expect(queue.isDrained()).toBe(true) + }, + (effect) => { + observed.push(effect.titleScanEffect) + queue.enqueue(emptyEffect('stale-probe')) + observed.push(effect.titleScanEffect) + } + ) + }) + + it('delivers the same effect requeued after clear without releasing its new slot', () => { + let queue: PtyOutputSideEffectQueue + const delivered: string[] = [] + withQueue( + (created) => { + queue = created + queue.enqueue(titledEffect('same')) + queue.flush() + expect(delivered).toEqual(['same', 'same']) + expect(queue.isDrained()).toBe(true) + expect(queue.pendingWorkingTitleCount()).toBe(0) + }, + (effect) => { + delivered.push(...effect.titles) + if (delivered.length === 1) { + queue.clear() + queue.enqueue(effect) + } + } + ) + }) + + it('preserves nested flush order and effects enqueued after the inner compaction', () => { + let queue: PtyOutputSideEffectQueue + const delivered: string[] = [] + withQueue( + (created) => { + queue = created + queue.enqueue(titledEffect('first')) + queue.enqueue(titledEffect('second')) + queue.flush() + expect(delivered).toEqual(['first', 'second', 'third']) + expect(queue.isDrained()).toBe(true) + expect(queue.pendingWorkingTitleCount()).toBe(0) + }, + (effect) => { + delivered.push(...effect.titles) + if (effect.titles[0] === 'first') { + queue.flush() + queue.enqueue(titledEffect('third')) + } + } + ) + }) + + it('preserves thrown apply errors and their existing empty-tail coalescing', () => { + const failure = new Error('apply failed') + let applied = 0 + withQueue( + (queue) => { + queue.enqueue(emptyEffect()) + expect(() => queue.flush()).toThrow(failure) + queue.enqueue(emptyEffect('stale-probe')) + queue.flush() + expect(applied).toBe(1) + expect(queue.isDrained()).toBe(true) + }, + () => { + applied += 1 + throw failure + } + ) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-output-side-effect-queue-retention.test.ts b/src/renderer/src/components/terminal-pane/pty-output-side-effect-queue-retention.test.ts new file mode 100644 index 00000000000..d3c7e9cdb4d --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-output-side-effect-queue-retention.test.ts @@ -0,0 +1,125 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + createPtyOutputSideEffectQueue, + MAX_PENDING_PTY_SIDE_EFFECTS, + type PendingPtySideEffect, + type PtyOutputSideEffectQueue +} from './pty-output-side-effect-queue' + +function enqueueEffects( + queue: PtyOutputSideEffectQueue, + count: number +): WeakRef[] { + const references: WeakRef[] = [] + for (let index = 0; index < count; index += 1) { + const effect: PendingPtySideEffect = { + payloads: [], + titles: [`title-${index}`], + titleScanEffect: 'none', + containsBell: false, + suppressAttentionEvents: false + } + references.push(new WeakRef(effect)) + queue.enqueue(effect) + } + return references +} + +async function collectEffects(): Promise { + if (typeof globalThis.gc !== 'function') { + throw new Error('The test runner must enable --expose-gc') + } + // WeakRefs keep new targets alive until the next event-loop turn. + await new Promise((resolve) => setImmediate(resolve)) + globalThis.gc() + globalThis.gc() +} + +describe('PTY side-effect queue retention', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('releases applied effects while preserving every pending effect and its delivery order', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const delivered: string[] = [] + const queue = createPtyOutputSideEffectQueue({ + countWorkingTitles: (titles) => titles.length, + apply: (effect) => delivered.push(...effect.titles) + }) + try { + const references = enqueueEffects(queue, 100) + queue.scheduleDrain() + await vi.runOnlyPendingTimersAsync() + queue.pause() + expect(delivered).toEqual(Array.from({ length: 64 }, (_, index) => `title-${index}`)) + expect(queue.pendingWorkingTitleCount()).toBe(36) + + await collectEffects() + expect(references.slice(0, 64).every((reference) => reference.deref() === undefined)).toBe( + true + ) + expect(references.slice(64).every((reference) => reference.deref() !== undefined)).toBe(true) + + queue.flush() + expect(delivered).toEqual(Array.from({ length: 100 }, (_, index) => `title-${index}`)) + expect(queue.isDrained()).toBe(true) + expect(queue.pendingWorkingTitleCount()).toBe(0) + } finally { + queue.clear() + queue.disposeGauge() + } + }) + + it('releases an evicted effect before the compaction threshold', async () => { + const delivered: string[] = [] + const queue = createPtyOutputSideEffectQueue({ + countWorkingTitles: (titles) => titles.length, + apply: (effect) => delivered.push(...effect.titles) + }) + try { + const references = enqueueEffects(queue, MAX_PENDING_PTY_SIDE_EFFECTS + 1) + await collectEffects() + expect(references[0].deref()).toBeUndefined() + expect(references.slice(1).every((reference) => reference.deref() !== undefined)).toBe(true) + expect(queue.pendingWorkingTitleCount()).toBe(MAX_PENDING_PTY_SIDE_EFFECTS) + + queue.flush() + expect(delivered).toEqual( + Array.from({ length: MAX_PENDING_PTY_SIDE_EFFECTS }, (_, index) => `title-${index + 1}`) + ) + } finally { + queue.clear() + queue.disposeGauge() + } + }) + + it('releases every pending effect immediately when clear is called during apply', async () => { + let references: WeakRef[] = [] + let collected = 0 + const queue = createPtyOutputSideEffectQueue({ + countWorkingTitles: (titles) => titles.length, + apply: () => { + queue.clear() + if (typeof globalThis.gc !== 'function') { + throw new Error('The test runner must enable --expose-gc') + } + globalThis.gc() + globalThis.gc() + collected = references + .slice(1) + .filter((reference) => reference.deref() === undefined).length + } + }) + try { + references = enqueueEffects(queue, 100) + await new Promise((resolve) => setImmediate(resolve)) + queue.flush() + expect(collected).toBe(99) + expect(queue.isDrained()).toBe(true) + } finally { + queue.clear() + queue.disposeGauge() + } + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-output-side-effect-queue.ts b/src/renderer/src/components/terminal-pane/pty-output-side-effect-queue.ts index ca852e1b558..9de941be889 100644 --- a/src/renderer/src/components/terminal-pane/pty-output-side-effect-queue.ts +++ b/src/renderer/src/components/terminal-pane/pty-output-side-effect-queue.ts @@ -37,12 +37,13 @@ export function createPtyOutputSideEffectQueue({ apply }: PtyOutputSideEffectQueueOptions): PtyOutputSideEffectQueue { let drainTimer: ReturnType | null = null - let pendingEffects: PendingPtySideEffect[] = [] + let pendingEffects: (PendingPtySideEffect | undefined)[] = [] let pendingIndex = 0 let pendingWorkingTitles = 0 + let retainedEffects = 0 const gauge: PtySideEffectGauge = { pending: () => pendingEffects.length - pendingIndex, - retained: () => pendingEffects.length + retained: () => retainedEffects } const disposeGauge = registerPtySideEffectPendingGauge(gauge) @@ -53,16 +54,30 @@ export function createPtyOutputSideEffectQueue({ if (pendingIndex >= pendingEffects.length) { pendingEffects = [] pendingIndex = 0 + retainedEffects = 0 return } if (force || pendingIndex >= MAX_PTY_SIDE_EFFECTS_PER_DRAIN * 4) { pendingEffects = pendingEffects.slice(pendingIndex) pendingIndex = 0 + retainedEffects = pendingEffects.length + } + } + + function releaseConsumedEffect(effects: typeof pendingEffects, index: number): void { + if (effects[index] === undefined) { + return + } + effects[index] = undefined + if (effects === pendingEffects) { + retainedEffects -= 1 } } function evictOldestIfFull(): void { while (pendingEffects.length - pendingIndex >= MAX_PENDING_PTY_SIDE_EFFECTS) { + const effects = pendingEffects + const evictedIndex = pendingIndex const evicted = pendingEffects[pendingIndex] if (!evicted) { return @@ -82,6 +97,7 @@ export function createPtyOutputSideEffectQueue({ : merged } } + releaseConsumedEffect(effects, evictedIndex) compact() } } @@ -103,6 +119,7 @@ export function createPtyOutputSideEffectQueue({ } evictOldestIfFull() pendingEffects.push(effect) + retainedEffects += 1 pendingWorkingTitles += countWorkingTitles(effect.titles) } @@ -125,6 +142,8 @@ export function createPtyOutputSideEffectQueue({ const limit = options.flushAll ? Number.POSITIVE_INFINITY : MAX_PTY_SIDE_EFFECTS_PER_DRAIN let processed = 0 while (pendingIndex < pendingEffects.length && processed < limit) { + const effects = pendingEffects + const appliedIndex = pendingIndex const next = pendingEffects[pendingIndex] if (!next) { break @@ -133,6 +152,8 @@ export function createPtyOutputSideEffectQueue({ processed += 1 pendingWorkingTitles = Math.max(0, pendingWorkingTitles - countWorkingTitles(next.titles)) apply(next) + // Release after apply so reentrant enqueues retain the existing tail coalescing behavior. + releaseConsumedEffect(effects, appliedIndex) } compact(options.flushAll === true) if (pendingIndex < pendingEffects.length) { @@ -151,8 +172,10 @@ export function createPtyOutputSideEffectQueue({ clear: () => { clearDrainTimer() pendingEffects.length = 0 + pendingEffects = [] pendingIndex = 0 pendingWorkingTitles = 0 + retainedEffects = 0 }, isDrained: () => pendingIndex >= pendingEffects.length, pendingWorkingTitleCount: () => pendingWorkingTitles, diff --git a/src/renderer/src/components/terminal-pane/pty-side-effect-pending-census.test.ts b/src/renderer/src/components/terminal-pane/pty-side-effect-pending-census.test.ts index eb9a5724d52..eb32488da4c 100644 --- a/src/renderer/src/components/terminal-pane/pty-side-effect-pending-census.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-side-effect-pending-census.test.ts @@ -95,9 +95,7 @@ describe('pty side-effect pending census', () => { expect(ptySideEffectCounts()).toEqual(before) }) - // Why: a partly drained queue still holds every entry until compaction, so depth alone - // would understate the retained bytes this census exists to attribute. - it('reports drained-but-uncompacted entries as retained after a bounded drain', async () => { + it('excludes cleared consumed entries from retention after a bounded drain', async () => { vi.useFakeTimers() const { createPtyOutputProcessor } = await import('./pty-transport') const before = ptySideEffectCounts() @@ -115,7 +113,7 @@ describe('pty side-effect pending census', () => { // One drain applies MAX_PTY_SIDE_EFFECTS_PER_DRAIN entries but stays under the compaction threshold. await vi.runOnlyPendingTimersAsync() expect(ptySideEffectCounts().pending).toBe(before.pending + 36) - expect(ptySideEffectCounts().retained).toBe(before.retained + 100) + expect(ptySideEffectCounts().retained).toBe(before.retained + 36) processor.flushPendingSideEffects() expect(ptySideEffectCounts()).toEqual({ diff --git a/src/renderer/src/components/terminal-pane/pty-side-effect-pending-census.ts b/src/renderer/src/components/terminal-pane/pty-side-effect-pending-census.ts index 53f1f98c4e2..154582e4232 100644 --- a/src/renderer/src/components/terminal-pane/pty-side-effect-pending-census.ts +++ b/src/renderer/src/components/terminal-pane/pty-side-effect-pending-census.ts @@ -4,7 +4,7 @@ import { registerRendererMemoryProfileContributor } from '@/lib/renderer-memory- export type PtySideEffectGauge = { /** Entries still awaiting apply; saturates at MAX_PENDING_PTY_SIDE_EFFECTS. */ pending: () => number - /** Entries the queue array still holds — drained-but-uncompacted ones included, so this is the count that tracks retained bytes. */ + /** Effects still referenced by the queue array, excluding cleared consumed slots. */ retained: () => number } From 24580c770d4ea77505cf7e576a2cdeb57defe5e2 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:47:05 -0700 Subject: [PATCH 14/67] Release relay handshake timers when a host connection leaves (#24554) Reuse the existing first-frame finish pattern to remove stage-owned timer/message/close callbacks on receipt, timeout or close; check OPEN after successful async assignment verification. --- .../src/host-control-proof-cleanup.test.ts | 366 ++++++++++++++++++ cloud/apps/relay/src/host-session-registry.ts | 38 +- 2 files changed, 393 insertions(+), 11 deletions(-) create mode 100644 cloud/apps/relay/src/host-control-proof-cleanup.test.ts diff --git a/cloud/apps/relay/src/host-control-proof-cleanup.test.ts b/cloud/apps/relay/src/host-control-proof-cleanup.test.ts new file mode 100644 index 00000000000..ce7a25fa530 --- /dev/null +++ b/cloud/apps/relay/src/host-control-proof-cleanup.test.ts @@ -0,0 +1,366 @@ +import { createHash, createHmac } from 'node:crypto' +import { EventEmitter } from 'node:events' +import { + buildHostProofMacInput, + HostChallengeSchema, + HOST_CHALLENGE_PLAINTEXT_DOMAIN, + RELAY_CLOSE_CODE +} from '@orca-cloud/relay-contract' +import nacl from 'tweetnacl' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type WebSocket from 'ws' +import { RelayAssignmentStore } from './assignment-store.js' +import { loadRelayConfig } from './config.js' +import { RelayCredentialStore } from './credential-store.js' +import type { RelayDatabase } from './database.js' +import { HostSessionRegistry } from './host-session-registry.js' +import type { RelayTokenClaims } from './relay-token-verifier.js' +import { ProcessQueuedByteBudget } from './splice-forwarder.js' + +class ProofSocket extends EventEmitter { + readonly OPEN = 1 + readonly CLOSING = 2 + readonly CLOSED = 3 + readyState = this.OPEN + readonly send = vi.fn<(frame: string) => void>() + readonly close = vi.fn((code?: number, reason?: string) => { + this.readyState = this.CLOSED + this.emit('close', code, Buffer.from(reason ?? '')) + }) + + peerClose(): void { + this.readyState = this.CLOSED + this.emit('close', 1000, Buffer.alloc(0)) + } + + registrySocket(): WebSocket { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fake implements the registry's send, state, close and EventEmitter surface; no actual networking is invoked. + return this as unknown as WebSocket + } +} + +function fixture() { + const database: RelayDatabase = { + query: vi.fn(async () => []), + queryLocked: vi.fn(async () => []), + transaction: (operation) => operation(database), + close: async () => undefined + } + const config = loadRelayConfig({ + ORCA_RELAY_PUBLIC_URL: 'http://127.0.0.1', + ORCA_RELAY_CELL_URL: 'http://127.0.0.1', + ORCA_RELAY_AUTH_ISSUER: 'https://auth.example.test', + ORCA_RELAY_JWKS_URL: 'https://auth.example.test/jwks', + ORCA_RELAY_ASSIGNMENT_SIGNING_KEY: 'synthetic-assignment-key-for-test-only', + ORCA_RELAY_ROLE: 'cell', + ORCA_RELAY_ADMIN_AUDIENCE: 'https://auth.example.test/admin', + ORCA_RELAY_DEPLOY_SERVICE_ACCOUNT: 'deploy@example.test', + ORCA_RELAY_CELL_CONNECTION_HARD_CAP: '600', + ORCA_RELAY_CELL_CONNECTION_UNOBSERVED_BOUND: '60' + }) + const assignments = new RelayAssignmentStore(database) + const verify = vi.spyOn(assignments, 'verifyCellAssignment').mockResolvedValue(true) + const activate = vi.spyOn(assignments, 'activateControl').mockResolvedValue('control:1') + vi.spyOn(assignments, 'markMigrationTargetRegistered').mockResolvedValue(true) + const recordAuth = vi.fn() + const registry = new HostSessionRegistry( + config, + async () => null, + new RelayCredentialStore(database), + assignments, + new ProcessQueuedByteBudget(), + { + recordAuth, + recordForwardedBytes: vi.fn(), + recordHttp: vi.fn(), + recordReconnect: vi.fn(), + recordSql: vi.fn() + } + ) + const keyPair = nacl.box.keyPair() + const identity = { + sub: 'user-proof', + prof: 'profile-proof', + relayHostId: createHash('sha256').update(keyPair.publicKey).digest('base64url').slice(0, 16), + purpose: 'host-control', + exp: Math.floor(Date.now() / 1000) + 3600 + } satisfies RelayTokenClaims + const hello = JSON.stringify({ + type: 'host-hello', + v: 1, + relayHostId: identity.relayHostId, + assignmentEpoch: 1, + hostPublicKeyB64: Buffer.from(keyPair.publicKey).toString('base64'), + appVersion: 'test' + }) + return { registry, verify, activate, recordAuth, database, identity, keyPair, hello } +} + +async function openProof(h: ReturnType, socket = new ProofSocket()) { + h.registry.acceptControl(socket.registrySocket(), h.identity) + socket.emit('message', Buffer.from(h.hello), false) + await vi.advanceTimersByTimeAsync(0) + expect(h.verify).toHaveBeenCalled() + expect(socket.send).toHaveBeenCalledOnce() + return socket +} + +function answerProof(socket: ProofSocket, keyPair: nacl.BoxKeyPair): void { + const frame = socket.send.mock.calls[0]?.[0] + if (frame === undefined) { + throw new Error('missing challenge') + } + const parsed: unknown = JSON.parse(frame) + if (parsed === null || typeof parsed !== 'object' || !('type' in parsed)) { + throw new Error('invalid challenge frame') + } + const { type, ...fields } = parsed + expect(type).toBe('host-challenge') + const challenge = HostChallengeSchema.parse(fields) + const plaintext = nacl.box.open( + Buffer.from(challenge.ciphertextB64, 'base64'), + Buffer.from(challenge.nonceB64, 'base64'), + Buffer.from(challenge.relayEphemeralPublicKeyB64, 'base64'), + keyPair.secretKey + ) + if (plaintext === null) { + throw new Error('challenge did not decrypt') + } + const domain = new TextEncoder().encode(`${HOST_CHALLENGE_PLAINTEXT_DOMAIN}\0`) + expect(plaintext.subarray(0, domain.length)).toEqual(domain) + const transcriptLength = new DataView( + plaintext.buffer, + plaintext.byteOffset + domain.length, + 4 + ).getUint32(0, false) + const transcriptStart = domain.length + 4 + const transcript = plaintext.subarray(transcriptStart, transcriptStart + transcriptLength) + const secret = plaintext.subarray(transcriptStart + transcriptLength) + const proofB64 = createHmac('sha256', secret) + .update(buildHostProofMacInput(transcript)) + .digest('base64') + socket.emit( + 'message', + Buffer.from( + JSON.stringify({ type: 'host-challenge-ack', challengeId: challenge.challengeId, proofB64 }) + ), + false + ) +} + +beforeEach(() => vi.useFakeTimers()) +afterEach(() => { + vi.clearAllTimers() + vi.useRealTimers() + vi.restoreAllMocks() +}) + +describe('host control proof cleanup', () => { + it('allocates no hello stage for an already closed peer', () => { + const h = fixture() + const socket = new ProofSocket() + socket.peerClose() + h.registry.acceptControl(socket.registrySocket(), h.identity) + expect(vi.getTimerCount()).toBe(0) + expect(socket.listenerCount('message')).toBe(0) + expect(socket.listenerCount('close')).toBe(0) + expect(h.verify).not.toHaveBeenCalled() + }) + + it('releases the host hello timer and listeners when its peer closes early', () => { + const h = fixture() + const socket = new ProofSocket() + h.registry.acceptControl(socket.registrySocket(), h.identity) + expect(vi.getTimerCount()).toBe(1) + expect(socket.listenerCount('message')).toBe(1) + socket.peerClose() + expect({ + timers: vi.getTimerCount(), + message: socket.listenerCount('message'), + close: socket.listenerCount('close') + }).toEqual({ timers: 0, message: 0, close: 0 }) + vi.advanceTimersByTime(2000) + expect(socket.close).not.toHaveBeenCalled() + expect(h.verify).not.toHaveBeenCalled() + expect(h.database.query).not.toHaveBeenCalled() + }) + + it('preserves the exact hello deadline and refusal while releasing its message listener', () => { + const h = fixture() + const socket = new ProofSocket() + h.registry.acceptControl(socket.registrySocket(), h.identity) + vi.advanceTimersByTime(1999) + expect(socket.close).not.toHaveBeenCalled() + vi.advanceTimersByTime(1) + expect(socket.close).toHaveBeenCalledExactlyOnceWith( + RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL, + 'host hello timeout' + ) + expect(socket.listenerCount('message')).toBe(0) + expect(vi.getTimerCount()).toBe(0) + }) + + it('releases the challenge timer and listeners when its peer closes before proof', async () => { + const h = fixture() + const socket = await openProof(h) + expect(vi.getTimerCount()).toBe(1) + expect(socket.listenerCount('message')).toBe(1) + socket.peerClose() + expect({ + timers: vi.getTimerCount(), + message: socket.listenerCount('message'), + close: socket.listenerCount('close') + }).toEqual({ timers: 0, message: 0, close: 0 }) + await vi.advanceTimersByTimeAsync(10_000) + expect(socket.close).not.toHaveBeenCalled() + expect(h.activate).not.toHaveBeenCalled() + expect(h.database.query).not.toHaveBeenCalled() + }) + + it('preserves the exact proof deadline and refusal with no leftover listener', async () => { + const h = fixture() + const socket = await openProof(h) + await vi.advanceTimersByTimeAsync(9999) + expect(socket.close).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(1) + expect(socket.close).toHaveBeenCalledExactlyOnceWith( + RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL, + 'host proof timeout' + ) + expect(socket.listenerCount('message')).toBe(0) + expect(h.activate).not.toHaveBeenCalled() + expect(h.recordAuth).not.toHaveBeenCalled() + }) + + it('does no challenge crypto, send, timer or registration after a closed peer finishes verification', async () => { + const h = fixture() + let finish!: (valid: boolean) => void + h.verify.mockReturnValueOnce( + new Promise((resolve) => { + finish = resolve + }) + ) + const generateKey = vi.spyOn(nacl.box, 'keyPair') + const socket = new ProofSocket() + h.registry.acceptControl(socket.registrySocket(), h.identity) + socket.emit('message', Buffer.from(h.hello), false) + expect(h.verify).toHaveBeenCalledOnce() + socket.peerClose() + finish(true) + await vi.advanceTimersByTimeAsync(0) + expect(socket.send).not.toHaveBeenCalled() + expect(generateKey).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + expect(socket.listenerCount('message')).toBe(0) + expect(h.activate).not.toHaveBeenCalled() + expect(h.database.query).not.toHaveBeenCalled() + expect( + h.registry.get({ userId: h.identity.sub, relayHostId: h.identity.relayHostId }) + ).toBeNull() + }) + + it.each([false, true])('preserves invalid first-frame refusal (binary=%s)', (binary) => { + const h = fixture() + const socket = new ProofSocket() + h.registry.acceptControl(socket.registrySocket(), h.identity) + socket.emit('message', Buffer.from('{}'), binary) + expect(socket.close).toHaveBeenCalledExactlyOnceWith( + RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL, + binary ? 'host hello must be text' : 'invalid host hello' + ) + expect(vi.getTimerCount()).toBe(0) + expect(socket.listenerCount('close')).toBe(0) + expect(h.activate).not.toHaveBeenCalled() + }) + + it.each([false, true])( + 'preserves invalid proof authentication failure (binary=%s)', + async (binary) => { + const h = fixture() + const socket = await openProof(h) + socket.emit('message', Buffer.from('{}'), binary) + expect(socket.close).toHaveBeenCalledExactlyOnceWith( + RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL, + 'invalid host proof' + ) + expect(h.recordAuth).toHaveBeenCalledExactlyOnceWith(false) + expect(vi.getTimerCount()).toBe(0) + expect(socket.listenerCount('close')).toBe(0) + expect(h.activate).not.toHaveBeenCalled() + } + ) + + it('allocates no proof wait when sending the challenge closes its peer', async () => { + const h = fixture() + const socket = new ProofSocket() + socket.send.mockImplementation(() => socket.peerClose()) + await openProof(h, socket) + expect(vi.getTimerCount()).toBe(0) + expect(socket.listenerCount('message')).toBe(0) + expect(socket.listenerCount('close')).toBe(0) + expect(h.activate).not.toHaveBeenCalled() + }) + + it('keeps the existing diagnostic and refusal when challenge send throws', async () => { + const h = fixture() + const socket = new ProofSocket() + socket.send.mockImplementation(() => { + throw new Error('synthetic send failure') + }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + await openProof(h, socket) + expect(socket.close).toHaveBeenCalledExactlyOnceWith( + RELAY_CLOSE_CODE.LIMIT_EXCEEDED, + 'relay temporarily unavailable' + ) + expect(warn).toHaveBeenCalledExactlyOnceWith( + '[orca-relay] host hello proof failed: synthetic send failure' + ) + expect(vi.getTimerCount()).toBe(0) + expect(socket.listenerCount('close')).toBe(0) + }) + + it('contains assignment lookup rejection with its existing close and diagnostic', async () => { + const h = fixture() + h.verify.mockRejectedValueOnce(new Error('synthetic lookup failure')) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const socket = new ProofSocket() + h.registry.acceptControl(socket.registrySocket(), h.identity) + socket.emit('message', Buffer.from(h.hello), false) + await vi.advanceTimersByTimeAsync(0) + expect(socket.close).toHaveBeenCalledExactlyOnceWith( + RELAY_CLOSE_CODE.LIMIT_EXCEEDED, + 'relay temporarily unavailable' + ) + expect(warn).toHaveBeenCalledExactlyOnceWith( + '[orca-relay] host hello proof failed: synthetic lookup failure' + ) + expect(vi.getTimerCount()).toBe(0) + expect(socket.listenerCount('close')).toBe(0) + }) + + it('keeps a newer same-host peer live when the old proof peer closes', async () => { + const h = fixture() + const oldPeer = await openProof(h) + const replacement = await openProof(h) + oldPeer.peerClose() + expect(vi.getTimerCount()).toBe(1) + answerProof(replacement, h.keyPair) + await vi.advanceTimersByTimeAsync(0) + expect(h.activate).toHaveBeenCalledOnce() + expect(h.recordAuth).toHaveBeenCalledExactlyOnceWith(true) + expect( + h.registry.get({ userId: h.identity.sub, relayHostId: h.identity.relayHostId })?.socket + ).toBe(replacement) + expect(replacement.send).toHaveBeenCalledTimes(2) + expect(replacement.listenerCount('message')).toBe(1) + expect(replacement.listenerCount('close')).toBe(2) + expect(vi.getTimerCount()).toBe(1) + await vi.advanceTimersByTimeAsync(10_000) + expect(oldPeer.close).not.toHaveBeenCalled() + expect(replacement.close).not.toHaveBeenCalled() + h.registry.drain(0) + await vi.advanceTimersByTimeAsync(0) + expect(vi.getTimerCount()).toBe(0) + }) +}) diff --git a/cloud/apps/relay/src/host-session-registry.ts b/cloud/apps/relay/src/host-session-registry.ts index 4e3372ce692..06cfb2d57ac 100644 --- a/cloud/apps/relay/src/host-session-registry.ts +++ b/cloud/apps/relay/src/host-session-registry.ts @@ -160,6 +160,30 @@ function send(socket: WebSocket, type: string, message: object): void { socket.send(JSON.stringify({ type, ...message })) } +function readControlFrame( + socket: WebSocket, + timeoutMs: number, + timeoutReason: string, + receive: (raw: RawData, isBinary: boolean) => void +): void { + if (socket.readyState !== socket.OPEN) return + const timer = setTimeout(() => { + finish() + socket.close(RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL, timeoutReason) + }, timeoutMs) + function finish(): void { + clearTimeout(timer) + socket.off('message', onMessage) + socket.off('close', finish) + } + function onMessage(raw: RawData, isBinary: boolean): void { + finish() + receive(raw, isBinary) + } + socket.once('message', onMessage) + socket.once('close', finish) +} + // Hosts abandon connects after 15s; waiting much longer than that behind a // stalled predecessor only accumulates doomed sockets. const ACTIVATION_QUEUE_WAIT_MS = 30_000 @@ -794,12 +818,7 @@ export class HostSessionRegistry { socket.close(RELAY_CLOSE_CODE.DRAINING, 'relay draining') return } - let firstFrameTimer: ReturnType | null = setTimeout(() => { - socket.close(RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL, 'host hello timeout') - }, 2_000) - socket.once('message', (raw, isBinary) => { - if (firstFrameTimer) clearTimeout(firstFrameTimer) - firstFrameTimer = null + readControlFrame(socket, 2_000, 'host hello timeout', (raw, isBinary) => { if (isBinary) { socket.close(RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL, 'host hello must be text') return @@ -991,6 +1010,7 @@ export class HostSessionRegistry { socket.close(RELAY_CLOSE_CODE.WRONG_CELL, 'wrong assignment epoch') return } + if (socket.readyState !== socket.OPEN) return const key = this.key(identity.sub, identity.relayHostId) const existing = this.sessions.get(key) @@ -1035,11 +1055,7 @@ export class HostSessionRegistry { ciphertextB64: Buffer.from(ciphertext).toString('base64'), expiresAt }) - const proofTimer = setTimeout(() => { - socket.close(RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL, 'host proof timeout') - }, 10_000) - socket.once('message', (raw, isBinary) => { - clearTimeout(proofTimer) + readControlFrame(socket, 10_000, 'host proof timeout', (raw, isBinary) => { const ack = isBinary ? null : HostChallengeAckSchema.safeParse(payload(raw, 'host-challenge-ack')) From 4498e099f619d943f1d406ec159c6c7e1878c7c9 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:47:08 -0700 Subject: [PATCH 15/67] Avoid restarting error timers on closed mobile relay links (#24567) Check the existing irreversible closed flag before scheduling the existing missing-close fallback timer. --- .../src/transport/mobile-relay-e2ee-link.ts | 3 + .../mobile-relay-error-timer-cleanup.test.ts | 192 ++++++++++++++++++ 2 files changed, 195 insertions(+) create mode 100644 mobile/src/transport/mobile-relay-error-timer-cleanup.test.ts diff --git a/mobile/src/transport/mobile-relay-e2ee-link.ts b/mobile/src/transport/mobile-relay-e2ee-link.ts index c2f24f71823..05f66979e8b 100644 --- a/mobile/src/transport/mobile-relay-e2ee-link.ts +++ b/mobile/src/transport/mobile-relay-e2ee-link.ts @@ -130,6 +130,9 @@ export class MobileRelayE2eeLink { // `error` is often delivered just before `close`; wait for close so a // typed relay code is not replaced by a generic transport error. this.socket.onerror = () => { + if (this.closed) { + return + } this.transportErrorTimer ??= setTimeout(() => { this.transportErrorTimer = null this.fail(new RelayOuterError(1006)) diff --git a/mobile/src/transport/mobile-relay-error-timer-cleanup.test.ts b/mobile/src/transport/mobile-relay-error-timer-cleanup.test.ts new file mode 100644 index 00000000000..4727f34646e --- /dev/null +++ b/mobile/src/transport/mobile-relay-error-timer-cleanup.test.ts @@ -0,0 +1,192 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { RELAY_HOST_CLOSE_REASON } from '../../../src/shared/relay-host-close-reason' + +vi.mock('./mobile-e2ee-v2-client-session', () => ({ + MobileE2EEV2ClientSession: { create: () => ({}) } +})) + +vi.mock('./mobile-e2ee-v2-physical-channel', () => ({ + MobileE2EEV2PhysicalChannel: class { + start = vi.fn() + handleMessage = vi.fn(async () => {}) + sendText = vi.fn(() => true) + sendBinary = vi.fn(() => true) + dispose = vi.fn() + } +})) + +import { MobileRelayE2eeLink } from './mobile-relay-e2ee-link' + +class ErrorTimerSocket { + readonly OPEN = 1 + readyState = 1 + bufferedAmount = 0 + onopen: (() => void) | null = null + onmessage: ((event: { data: unknown }) => void) | null = null + onerror: (() => void) | null = null + onclose: ((event: { code: number; reason: string }) => void) | null = null + send = vi.fn((_frame: string) => {}) + close = vi.fn(() => {}) +} + +function linkFixture(): { + link: MobileRelayE2eeLink + socket: ErrorTimerSocket + onError: ReturnType + onHostCloseReason: ReturnType +} { + const socket = new ErrorTimerSocket() + const onError = vi.fn() + const onHostCloseReason = vi.fn() + const link = new MobileRelayE2eeLink({ + endpoint: { + cellUrl: 'https://relay-c1.onorca.dev', + relayHostId: 'AbCdEf0123_-xyZ9' + }, + credential: 'credential', + expectedCredentialKind: 'resume', + deviceToken: 'device-token', + desktopPublicKeyB64: 'desktop-key', + onAuthenticated: vi.fn(), + onText: vi.fn(), + onBinary: vi.fn(), + onError, + onHostCloseReason, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The observed socket implements every WebSocket member this link and mocked channel use. + createSocket: () => socket as unknown as WebSocket + }) + return { link, socket, onError, onHostCloseReason } +} + +beforeEach(() => vi.useFakeTimers()) +afterEach(() => { + vi.clearAllTimers() + vi.useRealTimers() + vi.restoreAllMocks() +}) + +describe('closed relay link transport-error timer ownership', () => { + it('starts no fallback for errors queued after explicit close', () => { + const { link, socket, onError } = linkFixture() + link.close() + socket.onerror?.() + socket.onerror?.() + expect(vi.getTimerCount()).toBe(0) + expect(onError).not.toHaveBeenCalled() + expect(socket.close).toHaveBeenCalledOnce() + }) + + it('starts no fallback after failure and preserves the original thrown error', () => { + const { socket, onError } = linkFixture() + const failure = new Error('relay auth write failed') + socket.send.mockImplementation(() => { + throw failure + }) + socket.onopen?.() + socket.onerror?.() + expect(vi.getTimerCount()).toBe(0) + expect(onError).toHaveBeenCalledExactlyOnceWith(failure) + expect(socket.close).toHaveBeenCalledOnce() + }) + + it.each(['close', 'fail', 'fallback'])( + 'starts no fallback for a reentrant socket.close error during %s', + async (mode) => { + const { link, socket, onError } = linkFixture() + socket.close.mockImplementation(() => { + socket.onerror?.() + }) + if (mode === 'close') { + link.close() + expect(onError).not.toHaveBeenCalled() + } else if (mode === 'fail') { + socket.onclose?.({ code: 4409, reason: '' }) + expect(onError).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ message: 'relay_outer_4409' }) + ) + } else { + socket.onerror?.() + await vi.advanceTimersByTimeAsync(250) + expect(onError).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ message: 'relay_outer_1006' }) + ) + } + expect(vi.getTimerCount()).toBe(0) + expect(socket.close).toHaveBeenCalledOnce() + } + ) + + it('starts no fallback after normal close and still reports its host reason', () => { + const { socket, onError, onHostCloseReason } = linkFixture() + socket.onclose?.({ code: 4409, reason: RELAY_HOST_CLOSE_REASON.SIGNED_OUT }) + socket.onerror?.() + expect(vi.getTimerCount()).toBe(0) + expect(onError).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ message: 'relay_outer_4409' }) + ) + expect(onHostCloseReason).toHaveBeenCalledExactlyOnceWith(RELAY_HOST_CLOSE_REASON.SIGNED_OUT) + }) + + it('retains one live fallback and the exact missing-close grace deadline', async () => { + const { socket, onError } = linkFixture() + socket.onerror?.() + socket.onerror?.() + expect(vi.getTimerCount()).toBe(1) + await vi.advanceTimersByTimeAsync(249) + expect(onError).not.toHaveBeenCalled() + expect(socket.close).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(1) + expect(onError).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ message: 'relay_outer_1006' }) + ) + expect(socket.close).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(0) + }) + + it('retains a typed close and host reason during the live error grace', async () => { + const { socket, onError, onHostCloseReason } = linkFixture() + socket.onerror?.() + await vi.advanceTimersByTimeAsync(249) + socket.onclose?.({ code: 4409, reason: RELAY_HOST_CLOSE_REASON.SIGNED_OUT }) + expect(vi.getTimerCount()).toBe(0) + await vi.advanceTimersByTimeAsync(1) + expect(onError).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ message: 'relay_outer_4409' }) + ) + expect(onHostCloseReason).toHaveBeenCalledExactlyOnceWith(RELAY_HOST_CLOSE_REASON.SIGNED_OUT) + }) + + it('retains late host-reason reporting after a rejected handshake', async () => { + const { socket, onError, onHostCloseReason } = linkFixture() + socket.onmessage?.({ data: JSON.stringify({ type: 'relay-hello', ok: false, code: 4409 }) }) + await vi.advanceTimersByTimeAsync(0) + expect(onError).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ message: 'relay_outer_4409' }) + ) + socket.onerror?.() + expect(vi.getTimerCount()).toBe(0) + socket.onclose?.({ code: 4409, reason: RELAY_HOST_CLOSE_REASON.SIGNED_OUT }) + expect(onHostCloseReason).toHaveBeenCalledExactlyOnceWith(RELAY_HOST_CLOSE_REASON.SIGNED_OUT) + expect(onError).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(0) + }) + + it('keeps a replacement link independent and leaves no timer after its close', async () => { + const old = linkFixture() + old.link.close() + const replacement = linkFixture() + old.socket.onerror?.() + replacement.socket.onerror?.() + expect(vi.getTimerCount()).toBe(1) + replacement.socket.onclose?.({ code: 4409, reason: RELAY_HOST_CLOSE_REASON.SIGNED_OUT }) + expect(vi.getTimerCount()).toBe(0) + await vi.advanceTimersByTimeAsync(250) + expect(old.onError).not.toHaveBeenCalled() + expect(replacement.onError).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ message: 'relay_outer_4409' }) + ) + expect(replacement.onHostCloseReason).toHaveBeenCalledExactlyOnceWith( + RELAY_HOST_CLOSE_REASON.SIGNED_OUT + ) + }) +}) From 831710c38095ba8b093ceb64213f1e8c99553f31 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:47:11 -0700 Subject: [PATCH 16/67] Avoid restarting error timers after mobile relay pairing closes (#24568) Check the existing pairing owner closed flag before allocating its missing-close fallback alarm. --- ...-relay-pairing-error-timer-cleanup.test.ts | 253 ++++++++++++++++++ .../transport/mobile-relay-physical-client.ts | 3 + 2 files changed, 256 insertions(+) create mode 100644 mobile/src/transport/mobile-relay-pairing-error-timer-cleanup.test.ts diff --git a/mobile/src/transport/mobile-relay-pairing-error-timer-cleanup.test.ts b/mobile/src/transport/mobile-relay-pairing-error-timer-cleanup.test.ts new file mode 100644 index 00000000000..13236d69cc7 --- /dev/null +++ b/mobile/src/transport/mobile-relay-pairing-error-timer-cleanup.test.ts @@ -0,0 +1,253 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ConnectionLogEntry } from './types' + +type ChannelRecord = { + options: { onAuthenticated(): void; onText(value: string): void; onError(error: Error): void } + start: ReturnType + sendText: ReturnType + dispose: ReturnType +} + +const observed = vi.hoisted(() => ({ channels: new Array() })) +vi.mock('./mobile-e2ee-v2-client-session', () => ({ + MobileE2EEV2ClientSession: { create: () => ({}) } +})) +vi.mock('./mobile-e2ee-v2-physical-channel', () => ({ + MobileE2EEV2PhysicalChannel: class { + start = vi.fn() + handleMessage = vi.fn(async () => {}) + sendText = vi.fn((_frame: string) => true) + dispose = vi.fn() + constructor(readonly options: ChannelRecord['options']) { + observed.channels.push(this) + } + } +})) + +import { connectMobileRelayForPairing, RelayOuterError } from './mobile-relay-physical-client' + +class PairingTimerSocket { + readonly OPEN = 1 + readyState = 1 + bufferedAmount = 0 + onopen: (() => void) | null = null + onmessage: ((event: { data: unknown }) => void) | null = null + onerror: (() => void) | null = null + onclose: ((event: { code: number }) => void) | null = null + send = vi.fn((_frame: string) => {}) + close = vi.fn(() => {}) +} + +function fixture() { + const socket = new PairingTimerSocket() + const logs: ConnectionLogEntry[] = [] + const client = connectMobileRelayForPairing({ + relay: { + v: 1, + directorUrl: 'https://relay.onorca.dev', + cellUrl: 'https://relay-c1.onorca.dev', + assignmentEpoch: 7, + relayHostId: 'AbCdEf0123_-xyZ9', + inviteToken: 'abcdefghijklmnopqrstuvwxyzABCDEFGH012345678', + inviteExpiresAt: Date.now() + 300_000, + e2eeFraming: 2 + }, + deviceToken: 'device-token', + desktopPublicKeyB64: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', + onLog: (entry) => logs.push(entry), + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The observed socket implements every WebSocket member this client and mocked channel use. + createSocket: () => socket as unknown as WebSocket + }) + const channel = observed.channels.at(-1) + if (!channel) { + throw new Error('missing observed pairing channel') + } + return { client, socket, logs, channel } +} + +async function authenticate(target: ReturnType): Promise { + target.socket.onmessage?.({ + data: JSON.stringify({ + type: 'relay-hello', + ok: true, + credentialKind: 'invite', + leaseExpiresAt: Date.now() + 60_000 + }) + }) + await vi.advanceTimersByTimeAsync(0) + expect(target.channel.start).toHaveBeenCalledOnce() + target.channel.options.onAuthenticated() +} + +function closedLogs(logs: ConnectionLogEntry[]): ConnectionLogEntry[] { + return logs.filter((entry) => entry.message === 'Relay: pairing socket closed') +} + +beforeEach(() => { + observed.channels.length = 0 + vi.useFakeTimers() +}) +afterEach(() => { + vi.clearAllTimers() + vi.useRealTimers() + vi.restoreAllMocks() +}) + +describe('closed relay pairing client error timer ownership', () => { + it('keeps authentication waiter rejection and intentional-close log exact without a late alarm', async () => { + const target = fixture() + const first = target.client.sendRequest('first').catch((error: unknown) => error) + const second = target.client.sendRequest('second').catch((error: unknown) => error) + target.client.close() + const failure = await first + expect(failure).toEqual(new Error('relay pairing client closed')) + expect(await second).toBe(failure) + target.socket.onerror?.() + target.socket.onerror?.() + expect(vi.getTimerCount()).toBe(0) + expect(closedLogs(target.logs)).toEqual([ + expect.objectContaining({ level: 'info', detail: 'relay-c1.onorca.dev' }) + ]) + expect(target.channel.dispose).toHaveBeenCalledOnce() + expect(target.socket.close).toHaveBeenCalledOnce() + }) + + it('keeps the original error for every pending RPC and clears their timers before late errors', async () => { + const target = fixture() + await authenticate(target) + const first = target.client.sendRequest('first').catch((error: unknown) => error) + const second = target.client.sendRequest('second').catch((error: unknown) => error) + await vi.advanceTimersByTimeAsync(0) + expect(vi.getTimerCount()).toBe(2) + const failure = new Error('channel failed') + target.channel.options.onError(failure) + expect(await first).toBe(failure) + expect(await second).toBe(failure) + target.socket.onerror?.() + expect(vi.getTimerCount()).toBe(0) + expect(closedLogs(target.logs)).toEqual([ + expect.objectContaining({ level: 'warn', detail: 'Error: channel failed' }) + ]) + expect(target.channel.dispose).toHaveBeenCalledOnce() + expect(target.socket.close).toHaveBeenCalledOnce() + }) + + it.each(['intentional', 'channel', 'fallback'])( + 'starts no timer for reentrant socket.close errors during %s', + async (mode) => { + const target = fixture() + const waiting = target.client.sendRequest('status.get').catch((error: unknown) => error) + target.socket.close.mockImplementation(() => target.socket.onerror?.()) + const failure = new Error('channel failed') + if (mode === 'intentional') { + target.client.close() + expect(await waiting).toEqual(new Error('relay pairing client closed')) + } else if (mode === 'channel') { + target.channel.options.onError(failure) + expect(await waiting).toBe(failure) + } else { + target.socket.onerror?.() + await vi.advanceTimersByTimeAsync(250) + expect(await waiting).toEqual(new RelayOuterError(1006)) + } + expect(vi.getTimerCount()).toBe(0) + expect(closedLogs(target.logs)).toHaveLength(1) + expect(target.channel.dispose).toHaveBeenCalledOnce() + expect(target.socket.close).toHaveBeenCalledOnce() + } + ) + + it('keeps a normal typed close rejection and log without a late alarm', async () => { + const target = fixture() + const waiting = target.client.sendRequest('status.get').catch((error: unknown) => error) + target.socket.onclose?.({ code: 4409 }) + expect(await waiting).toEqual(new RelayOuterError(4409)) + target.socket.onerror?.() + expect(vi.getTimerCount()).toBe(0) + expect(closedLogs(target.logs)).toEqual([ + expect.objectContaining({ level: 'warn', detail: 'relay close code 4409' }) + ]) + }) + + it('keeps a rejected hello error and log without a late alarm', async () => { + const target = fixture() + const waiting = target.client.sendRequest('status.get').catch((error: unknown) => error) + target.socket.onmessage?.({ + data: JSON.stringify({ type: 'relay-hello', ok: false, code: 4404 }) + }) + expect(await waiting).toEqual(new RelayOuterError(4404)) + target.socket.onerror?.() + expect(vi.getTimerCount()).toBe(0) + expect(closedLogs(target.logs)).toEqual([ + expect.objectContaining({ level: 'warn', detail: 'relay close code 4404' }) + ]) + expect(target.channel.start).not.toHaveBeenCalled() + }) + + it('retains one live alarm and exactly the 250 ms missing-close grace', async () => { + const target = fixture() + const waiting = target.client.sendRequest('status.get').catch((error: unknown) => error) + target.socket.onerror?.() + target.socket.onerror?.() + expect(vi.getTimerCount()).toBe(1) + await vi.advanceTimersByTimeAsync(249) + expect(closedLogs(target.logs)).toHaveLength(0) + expect(target.channel.dispose).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(1) + expect(await waiting).toEqual(new RelayOuterError(1006)) + expect(closedLogs(target.logs)).toEqual([ + expect.objectContaining({ level: 'warn', detail: 'relay close code 1006' }) + ]) + expect(vi.getTimerCount()).toBe(0) + }) + + it('retains a typed close during the live error grace', async () => { + const target = fixture() + const waiting = target.client.sendRequest('status.get').catch((error: unknown) => error) + target.socket.onerror?.() + await vi.advanceTimersByTimeAsync(249) + target.socket.onclose?.({ code: 4409 }) + expect(await waiting).toEqual(new RelayOuterError(4409)) + await vi.advanceTimersByTimeAsync(1) + expect(vi.getTimerCount()).toBe(0) + expect(closedLogs(target.logs)).toEqual([ + expect.objectContaining({ level: 'warn', detail: 'relay close code 4409' }) + ]) + }) + + it('preserves an already settled RPC through explicit close and late errors', async () => { + const target = fixture() + await authenticate(target) + const pending = target.client.sendRequest('status.get') + await vi.advanceTimersByTimeAsync(0) + target.channel.options.onText(JSON.stringify({ id: 'relay-pair-1', ok: true, result: 'done' })) + const response = await pending + expect(response).toEqual({ id: 'relay-pair-1', ok: true, result: 'done' }) + target.client.close() + target.socket.onerror?.() + expect(vi.getTimerCount()).toBe(0) + expect(await pending).toBe(response) + expect(closedLogs(target.logs)).toHaveLength(1) + expect(target.channel.dispose).toHaveBeenCalledOnce() + }) + + it('lets a replacement own its single alarm and leaves none after its close', async () => { + const old = fixture() + const oldWaiting = old.client.sendRequest('old').catch((error: unknown) => error) + old.client.close() + expect(await oldWaiting).toEqual(new Error('relay pairing client closed')) + const replacement = fixture() + const waiting = replacement.client.sendRequest('new').catch((error: unknown) => error) + old.socket.onerror?.() + replacement.socket.onerror?.() + expect(vi.getTimerCount()).toBe(1) + replacement.socket.onclose?.({ code: 4409 }) + expect(await waiting).toEqual(new RelayOuterError(4409)) + expect(vi.getTimerCount()).toBe(0) + await vi.advanceTimersByTimeAsync(250) + expect(closedLogs(old.logs)).toHaveLength(1) + expect(closedLogs(replacement.logs)).toHaveLength(1) + expect(old.channel.dispose).toHaveBeenCalledOnce() + expect(replacement.channel.dispose).toHaveBeenCalledOnce() + }) +}) diff --git a/mobile/src/transport/mobile-relay-physical-client.ts b/mobile/src/transport/mobile-relay-physical-client.ts index 3df30542ffa..0883f6bf14a 100644 --- a/mobile/src/transport/mobile-relay-physical-client.ts +++ b/mobile/src/transport/mobile-relay-physical-client.ts @@ -118,6 +118,9 @@ export function connectMobileRelayForPairing(args: { // WebSocket implementations commonly emit `error` immediately before // `close`; the bounded fallback represents an opaque 1006 close. socket.onerror = () => { + if (closed) { + return + } transportErrorTimer ??= setTimeout(() => { transportErrorTimer = null fail(new RelayOuterError(1006)) From 06194626ba45df92eae59d5b03f4e7fb2113f3bc Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:47:14 -0700 Subject: [PATCH 17/67] Delete unreturned clipboard cache files after a failed write (#24599) Reuse existing provider-copy best-effort deletion for a newly created clipboard cache file whose write fails before its URI reaches the caller. --- mobile/src/platform/native-media-device.ts | 21 +- .../native-media-stage-cleanup.test.ts | 204 ++++++++++++++++++ 2 files changed, 219 insertions(+), 6 deletions(-) create mode 100644 mobile/src/platform/native-media-stage-cleanup.test.ts diff --git a/mobile/src/platform/native-media-device.ts b/mobile/src/platform/native-media-device.ts index 23002b8901d..4d4c3552538 100644 --- a/mobile/src/platform/native-media-device.ts +++ b/mobile/src/platform/native-media-device.ts @@ -33,6 +33,14 @@ function stagedMediaFile(extension: string): FsFile { return new FsFile(Paths.cache, `orca-media-${Date.now()}-${Math.random()}.${extension}`) } +function discardUnreturnedMediaFile(file: FsFile): void { + try { + file.delete() + } catch { + // Best effort; the cache is the OS's to reclaim. + } +} + /** * Copies what a uri names into this shell's cache and answers the copy's uri. * @@ -57,11 +65,7 @@ export function copyPickedMediaIntoCache(uri: string): string { destination.write(new FsFile(uri).bytesSync()) } catch (error) { // The empty file this just created is nobody's otherwise: the caller never learns its name. - try { - destination.delete() - } catch { - // Best effort; the cache is the OS's to reclaim. - } + discardUnreturnedMediaFile(destination) throw error } return destination.uri @@ -94,7 +98,12 @@ export function nativeMediaDeviceDeps(registry: MediaHandleRegistry): NativeMedi stageBase64: (base64) => { const file = stagedMediaFile('png') file.create({ overwrite: true }) - file.write(base64, { encoding: 'base64' }) + try { + file.write(base64, { encoding: 'base64' }) + } catch (error) { + discardUnreturnedMediaFile(file) + throw error + } return file.uri }, openFile: (uri) => new FsFile(uri), diff --git a/mobile/src/platform/native-media-stage-cleanup.test.ts b/mobile/src/platform/native-media-stage-cleanup.test.ts new file mode 100644 index 00000000000..2391954c187 --- /dev/null +++ b/mobile/src/platform/native-media-stage-cleanup.test.ts @@ -0,0 +1,204 @@ +import { beforeEach, afterEach, expect, it, vi } from 'vitest' + +type FileEvent = { + operation: 'create' | 'write' | 'delete' + uri: string + content?: string | Uint8Array + options?: { overwrite?: boolean; encoding?: string } +} + +type StorageState = { + files: Map + events: FileEvent[] + writeFailure: unknown + deleteFailure: unknown + createFailure: unknown + readFailure: unknown + partialBytes: number +} + +const storage = vi.hoisted(() => ({ + files: new Map(), + events: [], + writeFailure: null, + deleteFailure: null, + createFailure: null, + readFailure: null, + partialBytes: 0 +})) + +vi.mock('expo-clipboard', () => ({ + getImageAsync: () => Promise.resolve({ data: 'data:image/png;base64,AQID' }) +})) +vi.mock('expo-document-picker', () => ({})) +vi.mock('expo-image-picker', () => ({})) +vi.mock('expo-file-system', () => ({ + File: class { + readonly uri: string + + constructor(...parts: string[]) { + this.uri = parts.join('/') + } + + get size(): number { + return storage.files.get(this.uri)?.byteLength ?? 0 + } + + create(options: { overwrite?: boolean }): void { + storage.events.push({ operation: 'create', uri: this.uri, options }) + storage.files.set(this.uri, new Uint8Array()) + if (storage.createFailure !== null) { + throw storage.createFailure + } + } + + write(content: string | Uint8Array, options?: { encoding?: string }): void { + storage.events.push({ operation: 'write', uri: this.uri, content, options }) + if (storage.writeFailure !== null) { + storage.files.set(this.uri, new Uint8Array(storage.partialBytes)) + throw storage.writeFailure + } + storage.files.set( + this.uri, + typeof content === 'string' ? Uint8Array.from(Buffer.from(content, 'base64')) : content + ) + } + + bytesSync(): Uint8Array { + if (storage.readFailure !== null) { + throw storage.readFailure + } + return Uint8Array.from([9, 8]) + } + + delete(): void { + storage.events.push({ operation: 'delete', uri: this.uri }) + if (storage.deleteFailure !== null) { + throw storage.deleteFailure + } + storage.files.delete(this.uri) + } + }, + Paths: { cache: 'file:///cache' } +})) + +import { MediaHandleRegistry } from '../mobile-web-shell/media-handle-registry' +import { createNativeMediaVerbServer } from './native-media' +import { copyPickedMediaIntoCache, nativeMediaDeviceDeps } from './native-media-device' + +beforeEach(() => { + storage.files.clear() + storage.events.length = 0 + storage.writeFailure = null + storage.deleteFailure = null + storage.createFailure = null + storage.readFailure = null + storage.partialBytes = 0 + vi.spyOn(Date, 'now').mockReturnValue(1234) + vi.spyOn(Math, 'random').mockReturnValue(0.25) +}) + +afterEach(() => vi.restoreAllMocks()) + +function device() { + return nativeMediaDeviceDeps(new MediaHandleRegistry({ now: () => 0, discard: vi.fn() })) +} + +function thrownBy(operation: () => unknown): unknown { + try { + operation() + } catch (error) { + return error + } + throw new Error('Expected staging to throw') +} + +function heldBytes(): number { + return [...storage.files.values()].reduce((total, bytes) => total + bytes.byteLength, 0) +} + +it('preserves the successful staged URI, base64 options and bytes without deleting it', () => { + const uri = device().stageBase64('AQID') + expect(uri).toBe('file:///cache/orca-media-1234-0.25.png') + expect(storage.events).toEqual([ + { operation: 'create', uri, options: { overwrite: true } }, + { operation: 'write', uri, content: 'AQID', options: { encoding: 'base64' } } + ]) + expect(storage.files.get(uri)).toEqual(Uint8Array.from([1, 2, 3])) +}) + +it.each([0, 4096])('releases an unreturned file after a write fails with %i bytes', (bytes) => { + const original = new Error('partial native write') + storage.writeFailure = original + storage.partialBytes = bytes + expect(thrownBy(() => device().stageBase64('AQID'))).toBe(original) + expect(heldBytes()).toBe(0) + expect(storage.files.size).toBe(0) + expect(storage.events.at(-1)).toEqual({ + operation: 'delete', + uri: 'file:///cache/orca-media-1234-0.25.png' + }) +}) + +it('preserves the write failure when best-effort deletion also fails', () => { + const original = new Error('native write failed') + storage.writeFailure = original + storage.deleteFailure = new Error('cache cannot be deleted') + storage.partialBytes = 4096 + expect(thrownBy(() => device().stageBase64('AQID'))).toBe(original) + expect(storage.events.map((event) => event.operation)).toEqual(['create', 'write', 'delete']) + expect(heldBytes()).toBe(4096) +}) + +it('preserves creation failure and its original operation order', () => { + const original = new Error('native create failed') + storage.createFailure = original + expect(thrownBy(() => device().stageBase64('AQID'))).toBe(original) + expect(storage.events.map((event) => event.operation)).toEqual(['create']) +}) + +it('deletes only the failed file while a prior successful file remains readable', () => { + vi.spyOn(Math, 'random').mockReturnValueOnce(0.1).mockReturnValueOnce(0.2) + const successfulUri = device().stageBase64('AQID') + const original = new Error('next write failed') + storage.writeFailure = original + storage.partialBytes = 4096 + expect(thrownBy(() => device().stageBase64('BAUG'))).toBe(original) + expect(storage.events.filter((event) => event.operation === 'delete')).toEqual([ + { operation: 'delete', uri: 'file:///cache/orca-media-1234-0.2.png' } + ]) + expect([...storage.files.keys()]).toEqual([successfulUri]) + expect(storage.files.get(successfulUri)).toEqual(Uint8Array.from([1, 2, 3])) + expect(heldBytes()).toBe(3) +}) + +it('keeps the clipboard verb rejection while leaving no unregistered cache bytes', async () => { + const original = new Error('clipboard write failed') + storage.writeFailure = original + storage.partialBytes = 4096 + const registryDiscard = vi.fn() + const registry = new MediaHandleRegistry({ now: () => 0, discard: registryDiscard }) + const serve = createNativeMediaVerbServer(nativeMediaDeviceDeps(registry)) + await expect(serve('native.media.pick', { source: 'clipboard', multiple: false })).rejects.toBe( + original + ) + expect(registry.liveCount()).toBe(0) + expect(registryDiscard).not.toHaveBeenCalled() + expect(heldBytes()).toBe(0) + expect(storage.files.size).toBe(0) +}) + +it('retains the provider-copy cleanup and original read error when deletion fails', () => { + const original = new Error('provider read failed') + storage.readFailure = original + storage.deleteFailure = new Error('cache delete failed') + expect(thrownBy(() => copyPickedMediaIntoCache('content://media/1'))).toBe(original) + expect(storage.events).toEqual([ + { + operation: 'create', + uri: 'file:///cache/orca-media-1234-0.25.bin', + options: { overwrite: true } + }, + { operation: 'delete', uri: 'file:///cache/orca-media-1234-0.25.bin' } + ]) +}) From e1b046a1bdc8c7bd31f5fff42c9465884d99f96e Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:47:18 -0700 Subject: [PATCH 18/67] Skip new legacy file inventories after mobile search cleanup (#24792) Reuse the existing mobile mounted-ref pattern only at legacy fallback entry after completed passive cleanup; preserve admitted work and all live search/authority/cache paths. Correct only the strict fully-unmounted inventory scenario and its sole golden. --- .../lifecycle-inventory-lifecycle.json | 8 +- .../mobile-file-search-owner-cleanup.test.tsx | 280 ++++++++++++++++++ .../use-mobile-native-chat-file-search.ts | 11 + .../rpc-recording/derived-goldens.ts | 20 +- .../inventory-lifecycle-derivation.test.ts | 64 ++++ 5 files changed, 378 insertions(+), 5 deletions(-) create mode 100644 mobile/src/session/mobile-file-search-owner-cleanup.test.tsx create mode 100644 mobile/src/test-support/rpc-recording/inventory-lifecycle-derivation.test.ts diff --git a/mobile/rpc-foundation/goldens/lifecycle-inventory-lifecycle.json b/mobile/rpc-foundation/goldens/lifecycle-inventory-lifecycle.json index 8784dc31168..d4adb3e24fd 100644 --- a/mobile/rpc-foundation/goldens/lifecycle-inventory-lifecycle.json +++ b/mobile/rpc-foundation/goldens/lifecycle-inventory-lifecycle.json @@ -298,8 +298,8 @@ { "id": "inventory-lifecycle.unmount-before-1:settled", "observation": { - "sender": ["32c9018052ba", "2f1953cc0315"], - "payloads": ["8db0d1234c94", "c9b9d7a9b388"], + "sender": ["32c9018052ba"], + "payloads": ["8db0d1234c94"], "settlements": { "mount": "eb79a9b3682a", "old": "eb79a9b3682a", @@ -312,8 +312,8 @@ { "id": "inventory-lifecycle.unmount-before-1:remounted", "observation": { - "sender": ["32c9018052ba", "2f1953cc0315"], - "payloads": ["8db0d1234c94", "c9b9d7a9b388"], + "sender": ["32c9018052ba"], + "payloads": ["8db0d1234c94"], "settlements": { "mount": "eb79a9b3682a", "old": "eb79a9b3682a", diff --git a/mobile/src/session/mobile-file-search-owner-cleanup.test.tsx b/mobile/src/session/mobile-file-search-owner-cleanup.test.tsx new file mode 100644 index 00000000000..3cecdb9ac0a --- /dev/null +++ b/mobile/src/session/mobile-file-search-owner-cleanup.test.tsx @@ -0,0 +1,280 @@ +import { createElement, StrictMode, type ReactNode } from 'react' +import { act, create } from 'react-test-renderer' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { MobileNativeChatComposer } from './MobileNativeChatComposer' +import { useMobileNativeChatFileSearch } from './use-mobile-native-chat-file-search' + +vi.mock('react-native', async () => { + const React = await import('react') + return { + ActivityIndicator: 'ActivityIndicator', + Image: 'Image', + Text: 'Text', + TextInput: 'TextInput', + View: 'View', + Pressable: 'Pressable', + Keyboard: { dismiss: vi.fn() }, + ScrollView: ({ children, ...props }: { children?: ReactNode }) => + React.createElement('ScrollView', props, children), + StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 } + } +}) +vi.mock('lucide-react-native', () => ({ + ArrowUp: 'ArrowUp', + Check: 'Check', + ChevronDown: 'ChevronDown', + ChevronLeft: 'ChevronLeft', + ChevronRight: 'ChevronRight', + ImagePlus: 'ImagePlus', + Mic: 'Mic', + Square: 'Square', + X: 'X' +})) +vi.mock('../components/BottomDrawer', async () => { + const React = await import('react') + return { + BottomDrawer: ({ visible, children }: { visible: boolean; children?: ReactNode }) => + visible ? React.createElement('BottomDrawer', { visible }, children) : null + } +}) + +type Reply = Awaited> +const missing: Reply = { + id: 'search', + ok: false, + error: { code: 'method_not_found', message: 'legacy host' }, + _meta: { runtimeId: 'legacy' } +} +const inventory: Reply = { + id: 'list', + ok: true, + result: { files: [{ relativePath: 'src/apple.ts' }] }, + _meta: { runtimeId: 'legacy' } +} +const searchCall = [ + 'files.searchPaths', + { + worktree: 'id:folder:remote', + query: 'apple', + limit: 16 + } +] +function pendingReply() { + let resolve: (reply: Reply) => void = () => { + throw new Error('uninitialized reply') + } + const promise = new Promise((settle) => { + resolve = settle + }) + return { promise, resolve } +} +function mount(sendRequest: RpcClient['sendRequest'], strict = false, getGeneration = () => 1) { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The actual hook only reads these two RPC members at the fake transport boundary. + const client = { sendRequest, getGeneration } as RpcClient + let search: ReturnType | undefined + function Route({ showChat }: { showChat: boolean }) { + search = useMobileNativeChatFileSearch({ client, worktreeId: 'folder:remote' }) + return showChat + ? createElement(MobileNativeChatComposer, { + value: '@apple', + onChangeText: vi.fn(), + onSend: async () => false, + sendSurfaceId: 'remote-chat', + getSendCompletionGeneration: () => 0, + getComposerEditGeneration: () => 0, + filePaths: search.nativeChatFilePaths, + onNeedFiles: search.loadNativeChatFiles + }) + : null + } + const element = (showChat: boolean) => + strict + ? createElement(StrictMode, null, createElement(Route, { showChat })) + : createElement(Route, { showChat }) + let renderer: ReturnType | undefined + act(() => { + renderer = create(element(false)) + }) + if (!renderer || !search) { + throw new Error('actual route hook did not mount') + } + const view = renderer + return { + query: () => + act(() => { + search?.loadNativeChatFiles('apple') + }), + showChat: () => + act(() => { + view.update(element(true)) + }), + triggerComposer: () => + act(() => { + view.root + .find((node) => typeof node.props.onSelectionChange === 'function') + .props.onSelectionChange({ nativeEvent: { selection: { end: 6 } } }) + }), + hideChat: () => + act(() => { + view.update(element(false)) + }), + paths: () => search?.nativeChatFilePaths, + dispose: () => + act(() => { + view.unmount() + }) + } +} +async function debounce(): Promise { + await act(async () => { + await vi.advanceTimersByTimeAsync(120) + }) +} +async function refuse(search: ReturnType): Promise { + await act(async () => { + search.resolve(missing) + await search.promise + }) +} +beforeEach(() => { + vi.useFakeTimers() +}) +afterEach(() => { + vi.clearAllTimers() + vi.useRealTimers() +}) + +it.each([false, true])( + 'a disposed actual composer owner admits no new inventory; StrictMode=%s', + async (strict) => { + const search = pendingReply() + const send = vi.fn((method) => + method === 'files.searchPaths' ? search.promise : Promise.resolve(inventory) + ) + const view = mount(send, strict) + view.showChat() + view.triggerComposer() + await debounce() + expect(send.mock.calls).toEqual([searchCall]) + view.dispose() + expect(vi.getTimerCount()).toBe(0) + await refuse(search) + expect(send.mock.calls.filter(([method]) => method === 'files.searchPaths')).toHaveLength(1) + expect(send.mock.calls.filter(([method]) => method === 'files.list')).toHaveLength(0) + } +) + +it.each([false, true])( + 'removing only the composer retains live fallback and cache; StrictMode=%s', + async (strict) => { + const search = pendingReply() + const send = vi.fn((method) => + method === 'files.searchPaths' ? search.promise : Promise.resolve(inventory) + ) + const view = mount(send, strict) + view.showChat() + view.triggerComposer() + await debounce() + expect(send.mock.calls).toEqual([searchCall]) + view.hideChat() + await refuse(search) + expect(send.mock.calls).toEqual([searchCall, ['files.list', { worktree: 'id:folder:remote' }]]) + expect(view.paths()).toEqual(['src/apple.ts']) + view.showChat() + view.triggerComposer() + await debounce() + expect(view.paths()).toEqual(['src/apple.ts']) + expect(send).toHaveBeenCalledTimes(2) + view.dispose() + } +) + +it('64 retired actual hooks do not start 64 whole-workspace reads', async () => { + const pending = Array.from({ length: 64 }, pendingReply) + let index = 0 + const send = vi.fn((method) => + method === 'files.searchPaths' ? pending[index++]!.promise : Promise.resolve(inventory) + ) + for (let owner = 0; owner < pending.length; owner++) { + const view = mount(send) + view.query() + await debounce() + view.dispose() + } + expect(send.mock.calls).toEqual(pending.map(() => searchCall)) + expect(vi.getTimerCount()).toBe(0) + await act(async () => { + pending.forEach((reply) => reply.resolve(missing)) + await Promise.all(pending.map((reply) => reply.promise)) + }) + expect(send.mock.calls.filter(([method]) => method === 'files.list')).toHaveLength(0) +}) + +it.each([false, true])( + 'a same-client successor owns its live fallback; StrictMode=%s', + async (strict) => { + const retiredSearch = pendingReply() + let searches = 0 + const send = vi.fn((method) => + method === 'files.searchPaths' + ? ++searches === 1 + ? retiredSearch.promise + : Promise.resolve(missing) + : Promise.resolve(inventory) + ) + const retired = mount(send, strict) + retired.query() + await debounce() + retired.dispose() + const successor = mount(send, strict) + successor.query() + await debounce() + expect(successor.paths()).toEqual(['src/apple.ts']) + expect(send.mock.calls).toEqual([ + searchCall, + searchCall, + ['files.list', { worktree: 'id:folder:remote' }] + ]) + await refuse(retiredSearch) + expect(successor.paths()).toEqual(['src/apple.ts']) + expect(send.mock.calls.filter(([method]) => method === 'files.list')).toHaveLength(1) + successor.dispose() + } +) + +it('reentrant disposal after fallback entry preserves the admitted inventory', async () => { + const search = pendingReply() + let disposal: (() => void) | undefined + const send = vi.fn((method) => + method === 'files.searchPaths' ? search.promise : Promise.resolve(inventory) + ) + const view = mount(send, false, () => { + disposal?.() + return 1 + }) + view.query() + await debounce() + expect(send.mock.calls).toEqual([searchCall]) + disposal = view.dispose + await refuse(search) + expect(send.mock.calls).toEqual([searchCall, ['files.list', { worktree: 'id:folder:remote' }]]) +}) + +it('already admitted inventory still settles after full owner disposal', async () => { + const list = pendingReply() + const send = vi.fn((method) => + method === 'files.searchPaths' ? Promise.resolve(missing) : list.promise + ) + const view = mount(send) + view.query() + await debounce() + expect(send.mock.calls).toEqual([searchCall, ['files.list', { worktree: 'id:folder:remote' }]]) + view.dispose() + await act(async () => { + list.resolve(inventory) + await list.promise + }) + expect(send.mock.calls).toEqual([searchCall, ['files.list', { worktree: 'id:folder:remote' }]]) + expect(vi.getTimerCount()).toBe(0) +}) diff --git a/mobile/src/session/use-mobile-native-chat-file-search.ts b/mobile/src/session/use-mobile-native-chat-file-search.ts index a71ad83829d..8fe58a8ccd6 100644 --- a/mobile/src/session/use-mobile-native-chat-file-search.ts +++ b/mobile/src/session/use-mobile-native-chat-file-search.ts @@ -34,6 +34,14 @@ export function useMobileNativeChatFileSearch(args: { new GenerationScopedRequestOwner() ).current + const mountedRef = useRef(true) + useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + } + }, []) + useEffect(() => { sequenceRef.current++ queryCacheRef.current.clear() @@ -90,6 +98,9 @@ export function useMobileNativeChatFileSearch(args: { setNativeChatFilePaths(paths) } const loadLegacyPaths = async (): Promise => { + if (!mountedRef.current) { + return + } // What retires the inventory: this host, this workspace, this logical authority. A // reconnect to the same host leaves the files on disk alone, so the physical session // epoch is deliberately not in it. Read once, so a cutover between the two calls below diff --git a/mobile/src/test-support/rpc-recording/derived-goldens.ts b/mobile/src/test-support/rpc-recording/derived-goldens.ts index 67ffa4a7e13..940b04e0645 100644 --- a/mobile/src/test-support/rpc-recording/derived-goldens.ts +++ b/mobile/src/test-support/rpc-recording/derived-goldens.ts @@ -53,6 +53,24 @@ function baseScenario(manifest: readonly RecordingScenario[], id: string): Recor return found } +function omitRetiredInventoryAdmission(scenario: RecordingScenario): RecordingScenario { + if (scenario.id !== 'inventory-lifecycle.unmount-before-1') { + return scenario + } + // A late search refusal cannot start an inventory after this owner's unmount. + return { + ...scenario, + steps: scenario.steps.filter( + (step) => + !( + ('bind' in step && + (step.bind === 'old-inventory' || step.bind === 'lifecycle-old-inventory')) || + ('complete' in step && step.complete === 'lifecycle-old-inventory') + ) + ) + } +} + /** The manifest scenario a pilot golden expands from, which its suite also mounts and mutates. */ export type PilotGolden = DerivedGolden & { scenario: RecordingScenario } @@ -144,7 +162,7 @@ export function familyGoldens(manifest: readonly RecordingScenario[]): DerivedGo actions .flatMap((action) => lifecycleSchedules(base, action)) .filter(({ scenario }) => !id.includes('hydration') || !scenario.id.endsWith('-1')) - ) + ).map(omitRetiredInventoryAdmission) }) } return goldens diff --git a/mobile/src/test-support/rpc-recording/inventory-lifecycle-derivation.test.ts b/mobile/src/test-support/rpc-recording/inventory-lifecycle-derivation.test.ts new file mode 100644 index 00000000000..9cf6c5947bc --- /dev/null +++ b/mobile/src/test-support/rpc-recording/inventory-lifecycle-derivation.test.ts @@ -0,0 +1,64 @@ +import { resolve } from 'node:path' +import { expect, it } from 'vitest' +import { familyGoldens } from './derived-goldens' +import { hoistPreludeCheckpoints } from './prelude-checkpoints' +import { readScenarios } from './scenario-input' +import { bindCompletions, lifecycleSchedules } from './schedule-driver' + +it('omits only the inventory admission following a refusal to a fully unmounted owner', () => { + const manifest = readScenarios( + resolve(import.meta.dirname, '../../../rpc-foundation/pilot-scenarios.json') + ) + const base = manifest.scenarios.find((scenario) => scenario.id === 'inventory-lifecycle') + if (!base) { + throw new Error('missing inventory base scenario') + } + const golden = familyGoldens(manifest.scenarios).find( + (item) => item.id === 'lifecycle-inventory-lifecycle' + ) + if (!golden) { + throw new Error('missing inventory lifecycle golden') + } + const original = hoistPreludeCheckpoints( + { ...base, steps: bindCompletions(base.steps) }, + (['reset', 'unmount', 'blur'] as const).flatMap((action) => lifecycleSchedules(base, action)) + ) + const derived = golden.scenarios() + expect(derived.map((scenario) => scenario.id)).toEqual(original.map((scenario) => scenario.id)) + expect(derived).toHaveLength(12) + for (const [index, scenario] of original.entries()) { + if (scenario.id !== 'inventory-lifecycle.unmount-before-1') { + expect(derived[index]).toEqual(scenario) + expect(JSON.stringify(derived[index])).toBe(JSON.stringify(scenario)) + continue + } + expect(scenario.steps).toHaveLength(13) + expect(scenario.steps.slice(7, 10)).toEqual([ + { bind: 'old-inventory', request: 'files.list#1', params: { worktree: 'id:A' } }, + { bind: 'lifecycle-old-inventory', request: 'old-inventory', params: { worktree: 'id:A' } }, + { + complete: 'lifecycle-old-inventory', + params: { worktree: 'id:A' }, + reply: { ok: true, result: { files: [{ relativePath: 'old.ts' }] } } + } + ]) + const expected = { + ...scenario, + steps: [...scenario.steps.slice(0, 7), ...scenario.steps.slice(10)] + } + expect(derived[index]).toEqual(expected) + expect(JSON.stringify(derived[index])).toBe(JSON.stringify(expected)) + expect(expected.steps.filter((step) => 'checkpoint' in step)).toEqual([ + { checkpoint: 'lifecycle-boundary' }, + { checkpoint: 'settled' }, + { checkpoint: 'remounted' } + ]) + expect(expected.steps.filter((step) => 'bind' in step)).toEqual([ + { + bind: 'lifecycle-files.searchPaths#1', + request: 'files.searchPaths#1', + params: { worktree: 'id:A', query: 'old', limit: 16 } + } + ]) + } +}) From 759c48ce92e7c34f991dfe9f165636e7eda4f0d8 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:47:20 -0700 Subject: [PATCH 19/67] Discard completed AI Vault cancellation IDs in the relay worker (#24820) Use the relay child's existing pending Set to ignore cancellation IDs after completion, matching its desktop sibling; preserve admitted active/queued cancellation and the bundled private sender's complete replies, errors and ownership. --- .../ai-vault-service-cancellation.test.ts | 348 ++++++++++++++++++ src/relay/ai-vault-service-entry.ts | 3 + 2 files changed, 351 insertions(+) create mode 100644 src/relay/ai-vault-service-cancellation.test.ts diff --git a/src/relay/ai-vault-service-cancellation.test.ts b/src/relay/ai-vault-service-cancellation.test.ts new file mode 100644 index 00000000000..8354eaabaae --- /dev/null +++ b/src/relay/ai-vault-service-cancellation.test.ts @@ -0,0 +1,348 @@ +import { setImmediate } from 'node:timers/promises' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { RelayAiVaultServiceClient } from './ai-vault-service-client' +import { AiVaultServiceTestChild } from '../main/ai-vault/session-scanner-service-test-child' +import { getRemoteHostPlatform } from '../main/ssh/ssh-remote-platform' +import type { RelayAiVaultServiceChildMessage } from './ai-vault-service-protocol' +import type { scanRemoteAiVaultSessions } from '../main/ai-vault/remote-session-scanner' + +const scan = vi.fn() +const disposeProvider = vi.fn<() => void>() + +const result = { sessions: [], issues: [], scannedAt: '2026-10-02T13:00:00.000Z' } +let outbound: RelayAiVaultServiceChildMessage[] = [] +let frames: RelayAiVaultServiceChildMessage[] = [] +let restoreProcess = (): void => undefined +let client: RelayAiVaultServiceClient | undefined + +class BridgeChild extends AiVaultServiceTestChild { + override send(message: unknown, callback?: (error: Error | null) => void): boolean { + super.send(message, callback) + process.emit('message', message) + return true + } +} + +function deliver(child: BridgeChild): void { + const message = outbound.shift() + if (!message) { + throw new Error('Expected one complete service reply in transit') + } + child.emit('message', message) +} + +async function turn(): Promise { + await setImmediate() +} + +function createClient(): BridgeChild { + const child = new BridgeChild() + client = new RelayAiVaultServiceClient({ + processFactory: () => child.asChildProcess(), + init: { remoteHome: '/home/ada', hostPlatform: getRemoteHostPlatform('linux-x64') } + }) + return child +} + +function currentClient(): RelayAiVaultServiceClient { + if (!client) { + throw new Error('Expected the owned service client') + } + return client +} + +async function close(child: BridgeChild): Promise { + const disposed = client?.dispose() + await turn() + child.emit('exit', 0) + await disposed + expect(disposeProvider).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(0) + client = undefined +} + +beforeEach(async () => { + vi.resetModules() + vi.clearAllMocks() + scan.mockReset() + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + outbound = [] + frames = [] + scan.mockResolvedValue(result) + const send = Object.getOwnPropertyDescriptor(process, 'send') + const disconnect = Object.getOwnPropertyDescriptor(process, 'disconnect') + const messages = new Set(process.listeners('message')) + const disconnects = new Set(process.listeners('disconnect')) + Object.defineProperty(process, 'send', { + configurable: true, + value: (message: RelayAiVaultServiceChildMessage) => { + outbound.push(message) + frames.push(message) + return true + } + }) + Object.defineProperty(process, 'disconnect', { configurable: true, value: vi.fn() }) + restoreProcess = () => { + for (const listener of process.listeners('message')) { + if (!messages.has(listener)) { + process.removeListener('message', listener) + } + } + for (const listener of process.listeners('disconnect')) { + if (!disconnects.has(listener)) { + process.removeListener('disconnect', listener) + } + } + if (send) { + Object.defineProperty(process, 'send', send) + } else { + delete process.send + } + if (disconnect) { + Object.defineProperty(process, 'disconnect', disconnect) + } else { + delete process.disconnect + } + } + const scanner = await import('../main/ai-vault/remote-session-scanner') + vi.spyOn(scanner, 'scanRemoteAiVaultSessions').mockImplementation(scan) + const titles = await import('../main/ai-vault/session-title-file-reader') + vi.spyOn(titles, 'readAiVaultSessionTitlesFromFiles').mockResolvedValue({ titles: [] }) + const filesystem = await import('./ai-vault-service-filesystem') + const createProvider = filesystem.createRelayAiVaultFilesystemProvider + vi.spyOn(filesystem, 'createRelayAiVaultFilesystemProvider').mockImplementation((options) => { + const provider = createProvider(options) + const dispose = provider.dispose + vi.spyOn(provider, 'dispose').mockImplementation(() => { + disposeProvider() + dispose() + }) + return provider + }) + await import('./ai-vault-service-entry') +}) + +afterEach(async () => { + process.emit('message', { type: 'shutdown' }) + await turn() + restoreProcess() + vi.clearAllTimers() + vi.restoreAllMocks() + vi.useRealTimers() +}) + +it('actual parent cancellation during completed-response transit retains zero completed IDs', async () => { + const child = createClient() + const errors: unknown[] = [] + const retained: Set[] = [] + const counts: number[] = [] + const warm = Promise.withResolvers() + scan.mockImplementationOnce(() => warm.promise) + const warmController = new AbortController() + const warmResponse = currentClient() + .listSessions({}, warmController.signal) + .catch((error: unknown) => error) + deliver(child) + await turn() + const warmAdd = vi.spyOn(Set.prototype, 'add') + try { + warmController.abort() + const callIndex = warmAdd.mock.calls.findIndex(([value]) => value === 1) + const owned = warmAdd.mock.contexts[callIndex] + if (!(owned instanceof Set)) { + throw new Error('Expected the actual native cancellation set for the admitted warm request') + } + retained.push(owned) + } finally { + warmAdd.mockRestore() + } + errors.push(await warmResponse) + warm.resolve(result) + await turn() + deliver(child) + await turn() + expect(retained[0]?.size).toBe(0) + for (let index = 0; index < 64; index++) { + const id = index + 2 + if (index % 3 !== 2) { + scan.mockImplementationOnce(async () => { + if (index % 3 === 1) { + throw new Error(`scan failure ${id}`) + } + return result + }) + } + const controller = new AbortController() + const response = ( + index % 3 === 2 + ? currentClient().resolveSessionTitles([], controller.signal) + : currentClient().listSessions({}, controller.signal) + ).catch((error: unknown) => error) + await turn() + expect(outbound).toHaveLength(1) + expect(outbound[0]).toEqual( + index % 3 === 2 + ? { type: 'result', id, operation: 'titles', value: { titles: [] } } + : index % 3 === 1 + ? { type: 'error', id, message: `scan failure ${id}` } + : { type: 'result', id, operation: 'list', value: result } + ) + const add = vi.spyOn(Set.prototype, 'add') + try { + controller.abort() + const matching = add.mock.calls.flatMap(([value], callIndex) => + value === id ? [callIndex] : [] + ) + counts.push(matching.length) + for (const callIndex of matching) { + const owned = add.mock.contexts[callIndex] + if (!(owned instanceof Set)) { + throw new Error('Expected the actual native cancellation set') + } + retained.push(owned) + } + } finally { + add.mockRestore() + } + errors.push(await response) + deliver(child) + await turn() + } + expect( + errors.every( + (error) => + error instanceof Error && + error.name === 'AbortError' && + error.message === 'The operation was aborted.' + ) + ).toBe(true) + expect(scan).toHaveBeenCalledTimes(44) + expect( + child.sent.filter( + (message) => + typeof message === 'object' && + message !== null && + 'type' in message && + message.type === 'cancel' + ) + ).toEqual(Array.from({ length: 65 }, (_, index) => ({ type: 'cancel', id: index + 1 }))) + const sizes = [...new Set(retained)].map((owned) => owned.size) + await close(child) + expect(frames).toEqual([ + { type: 'ready', protocol: 1, pid: process.pid }, + { type: 'result', id: 1, operation: 'list', value: result }, + ...Array.from({ length: 64 }, (_, index) => { + const id = index + 2 + return index % 3 === 2 + ? { type: 'result', id, operation: 'titles', value: { titles: [] } } + : index % 3 === 1 + ? { type: 'error', id, message: `scan failure ${id}` } + : { type: 'result', id, operation: 'list', value: result } + }) + ]) + expect(counts).toEqual(Array(64).fill(0)) + expect(sizes).toEqual([0]) +}) + +it('an admitted active cancellation still aborts its exact scanner signal', async () => { + const child = createClient() + const pending = Promise.withResolvers() + let signal: AbortSignal | undefined + scan.mockImplementationOnce((options) => { + signal = options.signal + return pending.promise + }) + const controller = new AbortController() + const response = currentClient() + .listSessions({}, controller.signal) + .catch((error: unknown) => error) + deliver(child) + await turn() + expect(signal?.aborted).toBe(false) + controller.abort() + await expect(response).resolves.toMatchObject({ name: 'AbortError' }) + expect(signal?.aborted).toBe(true) + pending.resolve(result) + await turn() + expect(outbound).toEqual([{ type: 'result', id: 1, operation: 'list', value: result }]) + deliver(child) + await close(child) +}) + +it('a pending queued cancellation still aborts before the queued scanner begins', async () => { + const child = createClient() + const pending = Promise.withResolvers() + const signals: AbortSignal[] = [] + scan.mockImplementation((options) => { + if (!options.signal) { + throw new Error('Expected the owned scanner signal') + } + signals.push(options.signal) + return signals.length === 1 ? pending.promise : Promise.resolve(result) + }) + const response = currentClient().listSessions({}) + deliver(child) + await turn() + process.emit('message', { type: 'request', id: 999, operation: 'list', params: {} }) + process.emit('message', { type: 'cancel', id: 999 }) + expect(signals).toHaveLength(1) + pending.resolve(result) + await turn() + expect(signals).toHaveLength(2) + expect(signals[1]?.aborted).toBe(true) + expect(outbound).toEqual([ + { type: 'result', id: 1, operation: 'list', value: result }, + { type: 'result', id: 999, operation: 'list', value: result } + ]) + deliver(child) + await expect(response).resolves.toEqual(result) + deliver(child) + await close(child) +}) + +it('unknown, pre-init and duplicate completed cancels preserve the next live request', async () => { + process.emit('message', { type: 'cancel', id: 9000 }) + const child = createClient() + const first = currentClient().listSessions({}) + deliver(child) + await turn() + deliver(child) + await expect(first).resolves.toEqual(result) + for (const id of [1, 1, 9000, 9000]) { + process.emit('message', { type: 'cancel', id }) + } + expect(outbound).toEqual([]) + const live = currentClient().listSessions({}) + await turn() + expect(scan.mock.calls[1]?.[0].signal?.aborted).toBe(false) + expect(outbound).toEqual([{ type: 'result', id: 2, operation: 'list', value: result }]) + deliver(child) + await expect(live).resolves.toEqual(result) + await close(child) +}) + +it('shutdown aborts active work and ignores later cancellations and admission', async () => { + const child = createClient() + const pending = Promise.withResolvers() + let signal: AbortSignal | undefined + scan.mockImplementationOnce((options) => { + signal = options.signal + return pending.promise + }) + const response = currentClient().listSessions({}) + const rejected = expect(response).rejects.toThrow('scan stopped') + deliver(child) + await turn() + process.emit('message', { type: 'shutdown' }) + expect(signal?.aborted).toBe(true) + process.emit('message', { type: 'cancel', id: 1 }) + process.emit('message', { type: 'cancel', id: 9000 }) + process.emit('message', { type: 'request', id: 9000, operation: 'list', params: {} }) + pending.reject(new Error('scan stopped')) + await turn() + expect(scan).toHaveBeenCalledOnce() + expect(outbound).toEqual([{ type: 'error', id: 1, message: 'scan stopped' }]) + deliver(child) + await rejected + await close(child) +}) diff --git a/src/relay/ai-vault-service-entry.ts b/src/relay/ai-vault-service-entry.ts index 9380e08d4c1..63c67885ca1 100644 --- a/src/relay/ai-vault-service-entry.ts +++ b/src/relay/ai-vault-service-entry.ts @@ -99,6 +99,9 @@ process.on('message', (raw: RelayAiVaultServiceParentMessage) => { return } if (raw?.type === 'cancel') { + if (!pending.has(raw.id)) { + return + } cancelled.add(raw.id) controllers.get(raw.id)?.abort() return From 73904fcdac27397e82f19f2144b6f1a045b13817 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:47:24 -0700 Subject: [PATCH 20/67] Release completed updater setup timers and callbacks (#24920) Cancel completed deferred updater fallbacks in finally, clear only their exact pending callback, and drop the captured timer before the original fallback guard runs; preserve destroyed admission, successor ownership and updater/quit callbacks. --- ...main-window-updater-deferred-setup.test.ts | 426 ++++++++++++++++++ src/main/window/main-window-updater.ts | 82 ++-- 2 files changed, 477 insertions(+), 31 deletions(-) create mode 100644 src/main/window/main-window-updater-deferred-setup.test.ts diff --git a/src/main/window/main-window-updater-deferred-setup.test.ts b/src/main/window/main-window-updater-deferred-setup.test.ts new file mode 100644 index 00000000000..f104b9ea138 --- /dev/null +++ b/src/main/window/main-window-updater-deferred-setup.test.ts @@ -0,0 +1,426 @@ +import { EventEmitter } from 'node:events' +import type { BrowserWindow } from 'electron' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Store } from '../persistence' +import type { UpdaterSetupOptions } from '../updater' + +const { setupMock, milestoneMock } = vi.hoisted(() => ({ + setupMock: vi.fn<(window: BrowserWindow, options?: UpdaterSetupOptions) => void>(), + milestoneMock: vi.fn<(name: string) => void>() +})) + +vi.mock('electron', () => ({ app: {}, ipcMain: {} })) +vi.mock('../updater', () => ({ setupAutoUpdater: setupMock })) +vi.mock('../ipc/ui', () => ({ isTrustedUIRenderer: () => true })) +vi.mock('../startup/startup-diagnostics', () => ({ logStartupMilestone: milestoneMock })) + +function createWindowOwner() { + const window = Object.assign(new EventEmitter(), { + id: 7, + isDestroyed: vi.fn(() => false) + }) + const ui = { + lastUpdateCheckAt: 123, + pendingUpdateNudgeId: 'pending', + dismissedUpdateNudgeId: 'dismissed', + releaseChannelOverride: 'rc' + } satisfies Partial> + const store = { + getUI: vi.fn(() => ui), + updateUI: vi.fn<(patch: Parameters[0]) => void>(), + flushPendingAsync: vi.fn().mockResolvedValue(undefined) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This owner only reads id/isDestroyed and registers once; EventEmitter supplies the original once contract. + const mainWindow = window as unknown as BrowserWindow + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The complete owner accesses only these three store methods and the checked UI fields above. + const persistence = store as unknown as Store + return { window, mainWindow, store, persistence } +} + +function captureError(action: () => void): unknown { + try { + action() + } catch (error) { + return error + } + throw new Error('Expected action to throw') +} + +function capturedSetupOptions(): UpdaterSetupOptions { + const options = setupMock.mock.calls.at(-1)?.[1] + if (!options) { + throw new Error('Missing updater options') + } + return options +} + +describe('completed deferred updater setup ownership', () => { + beforeEach(() => { + vi.resetModules() + vi.useFakeTimers() + setupMock.mockReset() + milestoneMock.mockReset() + }) + + afterEach(() => { + vi.clearAllTimers() + vi.useRealTimers() + vi.restoreAllMocks() + }) + + it('releases all 64 completed fallback handles without repeating initialization', async () => { + const updater = await import('./main-window-updater') + for (let index = 0; index < 64; index++) { + const owner = createWindowOwner() + updater.scheduleMainWindowAutoUpdaterSetup(owner.mainWindow, owner.persistence) + expect(vi.getTimerCount()).toBe(1) + updater.ensureAutoUpdaterConfigured() + expect(setupMock).toHaveBeenLastCalledWith(owner.mainWindow, expect.any(Object)) + expect(milestoneMock).toHaveBeenLastCalledWith('updater-setup-done') + expect(vi.getTimerCount()).toBe(0) + } + updater.ensureAutoUpdaterConfigured() + await vi.advanceTimersByTimeAsync(15_000) + expect(setupMock).toHaveBeenCalledTimes(64) + expect(milestoneMock).toHaveBeenCalledTimes(64) + expect(vi.getTimerCount()).toBe(0) + }) + + it('does not retain the expired native fallback of a destroyed window', async () => { + vi.useRealTimers() + const updater = await import('./main-window-updater') + const owner = createWindowOwner() + owner.window.isDestroyed.mockReturnValue(true) + function scheduleNativeFallback(): WeakRef { + const nativeSetTimeout = globalThis.setTimeout + const armed = vi + .spyOn(globalThis, 'setTimeout') + .mockImplementation((callback, delay, ...args) => + nativeSetTimeout(callback, delay === 15_000 ? 1 : delay, ...args) + ) + try { + updater.scheduleMainWindowAutoUpdaterSetup(owner.mainWindow, owner.persistence) + expect(armed).toHaveBeenCalledOnce() + expect(armed.mock.calls[0]?.[1]).toBe(15_000) + const handle = armed.mock.results[0]?.value + if (!handle) { + throw new Error('Missing native fallback timer') + } + expect(handle.hasRef()).toBe(false) + return new WeakRef(handle) + } finally { + armed.mockClear() + armed.mockRestore() + } + } + // The real deadline has separate paired proof; only this fixture's native wait is shortened. + const weakTimer = scheduleNativeFallback() + await new Promise((resolve) => setTimeout(resolve, 10)) + expect(owner.window.isDestroyed).toHaveBeenCalledOnce() + updater.ensureAutoUpdaterConfigured() + expect(owner.window.isDestroyed).toHaveBeenCalledTimes(2) + expect(setupMock).not.toHaveBeenCalled() + expect(milestoneMock).not.toHaveBeenCalled() + expect(globalThis.gc).toBeTypeOf('function') + for (let turn = 0; turn < 4; turn++) { + await new Promise((resolve) => setImmediate(resolve)) + globalThis.gc?.() + } + expect(weakTimer.deref()).toBeUndefined() + }) + + it('keeps setup after ready-to-show and releases its fallback afterward', async () => { + const updater = await import('./main-window-updater') + const owner = createWindowOwner() + updater.scheduleMainWindowAutoUpdaterSetup(owner.mainWindow, owner.persistence) + owner.window.emit('ready-to-show') + expect(setupMock).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(2) + await vi.advanceTimersByTimeAsync(0) + expect(setupMock).toHaveBeenCalledOnce() + expect(milestoneMock).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(0) + owner.window.emit('ready-to-show') + updater.ensureAutoUpdaterConfigured() + await vi.advanceTimersByTimeAsync(15_000) + expect(setupMock).toHaveBeenCalledOnce() + }) + + it('preserves the 15-second fallback when first paint never arrives', async () => { + const updater = await import('./main-window-updater') + const owner = createWindowOwner() + updater.scheduleMainWindowAutoUpdaterSetup(owner.mainWindow, owner.persistence) + await vi.advanceTimersByTimeAsync(14_999) + expect(setupMock).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(1) + await vi.advanceTimersByTimeAsync(1) + expect(setupMock).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(0) + owner.window.emit('ready-to-show') + await vi.advanceTimersByTimeAsync(0) + expect(setupMock).toHaveBeenCalledOnce() + }) + + it('preserves destroyed-window guard reads before and at the fallback deadline', async () => { + const updater = await import('./main-window-updater') + const owner = createWindowOwner() + updater.scheduleMainWindowAutoUpdaterSetup(owner.mainWindow, owner.persistence) + owner.window.isDestroyed.mockReturnValue(true) + updater.ensureAutoUpdaterConfigured() + expect(owner.window.isDestroyed).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(1) + await vi.advanceTimersByTimeAsync(14_999) + expect(owner.window.isDestroyed).toHaveBeenCalledOnce() + await vi.advanceTimersByTimeAsync(1) + expect(owner.window.isDestroyed).toHaveBeenCalledTimes(2) + updater.ensureAutoUpdaterConfigured() + expect(owner.window.isDestroyed).toHaveBeenCalledTimes(3) + expect(setupMock).not.toHaveBeenCalled() + }) + + it('keeps the fallback guard when destruction happens after ready but before immediate', async () => { + const updater = await import('./main-window-updater') + const owner = createWindowOwner() + updater.scheduleMainWindowAutoUpdaterSetup(owner.mainWindow, owner.persistence) + owner.window.emit('ready-to-show') + owner.window.isDestroyed.mockReturnValue(true) + await vi.advanceTimersByTimeAsync(0) + expect(owner.window.isDestroyed).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(1) + await vi.advanceTimersByTimeAsync(15_000) + expect(owner.window.isDestroyed).toHaveBeenCalledTimes(2) + expect(setupMock).not.toHaveBeenCalled() + }) + + it('retains original setup order and avoids reentrant initialization', async () => { + const updater = await import('./main-window-updater') + const owner = createWindowOwner() + const trace: string[] = [] + setupMock.mockImplementation(() => { + trace.push('setup') + updater.ensureAutoUpdaterConfigured() + trace.push('setup-return') + }) + milestoneMock.mockImplementation(() => { + trace.push('milestone') + updater.ensureAutoUpdaterConfigured() + }) + updater.scheduleMainWindowAutoUpdaterSetup(owner.mainWindow, owner.persistence) + updater.ensureAutoUpdaterConfigured() + expect(trace).toEqual(['setup', 'setup-return', 'milestone']) + expect(setupMock).toHaveBeenCalledOnce() + expect(owner.window.isDestroyed).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(0) + }) + + it.each(['setup', 'milestone'] as const)( + 'keeps the exact %s error and releases its completed fallback', + async (failure) => { + const updater = await import('./main-window-updater') + const owner = createWindowOwner() + const error = new Error(`${failure} failed`) + if (failure === 'setup') { + setupMock.mockImplementation(() => { + throw error + }) + } else { + milestoneMock.mockImplementation(() => { + throw error + }) + } + updater.scheduleMainWindowAutoUpdaterSetup(owner.mainWindow, owner.persistence) + expect(captureError(() => updater.ensureAutoUpdaterConfigured())).toBe(error) + expect(setupMock).toHaveBeenCalledOnce() + expect(milestoneMock).toHaveBeenCalledTimes(failure === 'setup' ? 0 : 1) + expect(vi.getTimerCount()).toBe(0) + updater.ensureAutoUpdaterConfigured() + await vi.advanceTimersByTimeAsync(15_000) + expect(setupMock).toHaveBeenCalledOnce() + } + ) + + it.each(['setup', 'milestone'] as const)( + 'keeps the exact fallback %s error without retrying initialization', + async (failure) => { + const updater = await import('./main-window-updater') + const owner = createWindowOwner() + const error = new Error(`${failure} fallback failed`) + if (failure === 'setup') { + setupMock.mockImplementation(() => { + throw error + }) + } else { + milestoneMock.mockImplementation(() => { + throw error + }) + } + updater.scheduleMainWindowAutoUpdaterSetup(owner.mainWindow, owner.persistence) + await expect(vi.advanceTimersByTimeAsync(15_000)).rejects.toBe(error) + updater.ensureAutoUpdaterConfigured() + expect(setupMock).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(0) + } + ) + + it('preserves pending retry after a failed destroyed-state read', async () => { + const updater = await import('./main-window-updater') + const owner = createWindowOwner() + const error = new Error('destroyed-state read failed') + owner.window.isDestroyed.mockImplementationOnce(() => { + throw error + }) + updater.scheduleMainWindowAutoUpdaterSetup(owner.mainWindow, owner.persistence) + expect(captureError(() => updater.ensureAutoUpdaterConfigured())).toBe(error) + expect(setupMock).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(1) + updater.ensureAutoUpdaterConfigured() + expect(setupMock).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(0) + }) + + it('preserves pending manual setup after failed ready registration', async () => { + const updater = await import('./main-window-updater') + const owner = createWindowOwner() + const error = new Error('ready registration failed') + vi.spyOn(owner.window, 'once').mockImplementationOnce(() => { + throw error + }) + expect( + captureError(() => + updater.scheduleMainWindowAutoUpdaterSetup(owner.mainWindow, owner.persistence) + ) + ).toBe(error) + expect(vi.getTimerCount()).toBe(0) + updater.ensureAutoUpdaterConfigured() + expect(setupMock).toHaveBeenCalledOnce() + }) + + it('releases the fallback created after synchronous registration reentry', async () => { + const updater = await import('./main-window-updater') + const owner = createWindowOwner() + const once = owner.window.once.bind(owner.window) + vi.spyOn(owner.window, 'once').mockImplementationOnce((event, listener) => { + updater.ensureAutoUpdaterConfigured() + return once(event, listener) + }) + updater.scheduleMainWindowAutoUpdaterSetup(owner.mainWindow, owner.persistence) + expect(setupMock).toHaveBeenCalledOnce() + expect(milestoneMock).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(0) + owner.window.emit('ready-to-show') + await vi.advanceTimersByTimeAsync(0) + expect(setupMock).toHaveBeenCalledOnce() + }) + + it('keeps a same-ID successor pending after the old window becomes ready', async () => { + const updater = await import('./main-window-updater') + const old = createWindowOwner() + const successor = createWindowOwner() + updater.scheduleMainWindowAutoUpdaterSetup(old.mainWindow, old.persistence) + updater.scheduleMainWindowAutoUpdaterSetup(successor.mainWindow, successor.persistence) + expect(vi.getTimerCount()).toBe(2) + old.window.emit('ready-to-show') + await vi.advanceTimersByTimeAsync(0) + expect(setupMock).toHaveBeenLastCalledWith(old.mainWindow, expect.any(Object)) + expect(vi.getTimerCount()).toBe(1) + updater.ensureAutoUpdaterConfigured() + expect(setupMock).toHaveBeenLastCalledWith(successor.mainWindow, expect.any(Object)) + expect(setupMock).toHaveBeenCalledTimes(2) + expect(vi.getTimerCount()).toBe(0) + }) + + it.each(['setup', 'milestone'] as const)( + 'preserves a reentrant successor when the old %s throws', + async (failure) => { + const updater = await import('./main-window-updater') + const old = createWindowOwner() + const successor = createWindowOwner() + const error = new Error('old owner failed after replacement') + const replaceAndThrow = () => { + updater.scheduleMainWindowAutoUpdaterSetup(successor.mainWindow, successor.persistence) + throw error + } + if (failure === 'setup') { + setupMock.mockImplementationOnce(replaceAndThrow) + } else { + milestoneMock.mockImplementationOnce(replaceAndThrow) + } + updater.scheduleMainWindowAutoUpdaterSetup(old.mainWindow, old.persistence) + expect(captureError(() => updater.ensureAutoUpdaterConfigured())).toBe(error) + expect(vi.getTimerCount()).toBe(1) + updater.ensureAutoUpdaterConfigured() + expect(setupMock).toHaveBeenLastCalledWith(successor.mainWindow, expect.any(Object)) + expect(setupMock).toHaveBeenCalledTimes(2) + expect(vi.getTimerCount()).toBe(0) + } + ) + + it('preserves the successor after an old destroyed fallback', async () => { + const updater = await import('./main-window-updater') + const old = createWindowOwner() + const successor = createWindowOwner() + updater.scheduleMainWindowAutoUpdaterSetup(old.mainWindow, old.persistence) + old.window.isDestroyed.mockReturnValue(true) + await vi.advanceTimersByTimeAsync(5_000) + updater.scheduleMainWindowAutoUpdaterSetup(successor.mainWindow, successor.persistence) + await vi.advanceTimersByTimeAsync(10_000) + expect(setupMock).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(1) + updater.ensureAutoUpdaterConfigured() + expect(setupMock).toHaveBeenCalledWith(successor.mainWindow, expect.any(Object)) + expect(vi.getTimerCount()).toBe(0) + }) + + it('keeps setup options, live store reads, nudge writes and required quit flush', async () => { + const updater = await import('./main-window-updater') + const owner = createWindowOwner() + const trace: string[] = [] + const error = new Error('required cleanup failed') + owner.store.flushPendingAsync.mockImplementation(async () => { + trace.push('flush') + }) + updater.scheduleMainWindowAutoUpdaterSetup(owner.mainWindow, owner.persistence, { + updateInstallMode: 'supervised-headless-serve', + onBeforeUpdateQuitFailure: 'abort', + onBeforeUpdateQuit: () => { + trace.push('cleanup') + throw error + } + }) + updater.ensureAutoUpdaterConfigured() + const options = capturedSetupOptions() + expect(Object.keys(options)).toEqual([ + 'getLastUpdateCheckAt', + 'onBeforeQuit', + 'setLastUpdateCheckAt', + 'getPendingUpdateNudgeId', + 'getDismissedUpdateNudgeId', + 'setPendingUpdateNudgeId', + 'setDismissedUpdateNudgeId', + 'getReleaseChannelOverride', + 'onBeforeQuitFailure', + 'installMode' + ]) + expect(options.getLastUpdateCheckAt?.()).toBe(123) + expect(options.getPendingUpdateNudgeId?.()).toBe('pending') + expect(options.getDismissedUpdateNudgeId?.()).toBe('dismissed') + expect(options.getReleaseChannelOverride?.()).toBe('rc') + owner.store.getUI.mockReturnValue({ ...owner.store.getUI(), lastUpdateCheckAt: 456 }) + expect(options.getLastUpdateCheckAt?.()).toBe(456) + options.setLastUpdateCheckAt?.(789) + options.setPendingUpdateNudgeId?.('next') + options.setPendingUpdateNudgeId?.(null) + options.setDismissedUpdateNudgeId?.('dismiss') + expect(owner.store.updateUI.mock.calls).toEqual([ + [{ lastUpdateCheckAt: 789 }], + [{ pendingUpdateNudgeId: 'next', dismissedUpdateVersion: null }], + [{ pendingUpdateNudgeId: null }], + [{ dismissedUpdateNudgeId: 'dismiss' }] + ]) + expect(options.installMode).toBe('supervised-headless-serve') + expect(options.onBeforeQuitFailure).toBe('abort') + await expect(options.onBeforeQuit?.()).rejects.toBe(error) + expect(trace).toEqual(['cleanup', 'flush']) + expect(vi.getTimerCount()).toBe(0) + }) +}) diff --git a/src/main/window/main-window-updater.ts b/src/main/window/main-window-updater.ts index 7cdcdd8cd73..8719bbe51ad 100644 --- a/src/main/window/main-window-updater.ts +++ b/src/main/window/main-window-updater.ts @@ -40,46 +40,66 @@ export function scheduleMainWindowAutoUpdaterSetup( ): void { // Why: setupAutoUpdater sync-require()s electron-updater (slow on cold Windows w/ Defender, #7225), so defer past first paint; timer fallback covers crash-looping renderers. let updaterSetupDone = false + let updaterSetupFallback: ReturnType | null = null + const clearDeferredSetup = (): void => { + if (updaterSetupFallback) { + clearTimeout(updaterSetupFallback) + updaterSetupFallback = null + } + if (pendingAutoUpdaterSetup === setupAutoUpdaterDeferred) { + pendingAutoUpdaterSetup = null + } + } const setupAutoUpdaterDeferred = (): void => { if (updaterSetupDone || mainWindow.isDestroyed()) { return } updaterSetupDone = true - setupAutoUpdater(mainWindow, { - getLastUpdateCheckAt: () => store.getUI().lastUpdateCheckAt, - onBeforeQuit: async () => { - try { - await options?.onBeforeUpdateQuit?.() - } finally { - await store.flushPendingAsync() - } - }, - setLastUpdateCheckAt: (timestamp) => { - store.updateUI({ lastUpdateCheckAt: timestamp }) - }, - getPendingUpdateNudgeId: () => store.getUI().pendingUpdateNudgeId ?? null, - getDismissedUpdateNudgeId: () => store.getUI().dismissedUpdateNudgeId ?? null, - setPendingUpdateNudgeId: (id) => { - // Why: only the apply branch also nulls dismissedUpdateVersion so relaunch can't resurrect the old hidden card; clearing must not, or it un-dismisses. - if (id) { - store.updateUI({ pendingUpdateNudgeId: id, dismissedUpdateVersion: null }) - } else { - store.updateUI({ pendingUpdateNudgeId: null }) - } - }, - setDismissedUpdateNudgeId: (id) => { - store.updateUI({ dismissedUpdateNudgeId: id }) - }, - getReleaseChannelOverride: () => store.getUI().releaseChannelOverride ?? null, - onBeforeQuitFailure: options?.onBeforeUpdateQuitFailure, - installMode: options?.updateInstallMode - }) - logStartupMilestone('updater-setup-done') + try { + setupAutoUpdater(mainWindow, { + getLastUpdateCheckAt: () => store.getUI().lastUpdateCheckAt, + onBeforeQuit: async () => { + try { + await options?.onBeforeUpdateQuit?.() + } finally { + await store.flushPendingAsync() + } + }, + setLastUpdateCheckAt: (timestamp) => { + store.updateUI({ lastUpdateCheckAt: timestamp }) + }, + getPendingUpdateNudgeId: () => store.getUI().pendingUpdateNudgeId ?? null, + getDismissedUpdateNudgeId: () => store.getUI().dismissedUpdateNudgeId ?? null, + setPendingUpdateNudgeId: (id) => { + // Why: only the apply branch also nulls dismissedUpdateVersion so relaunch can't resurrect the old hidden card; clearing must not, or it un-dismisses. + if (id) { + store.updateUI({ pendingUpdateNudgeId: id, dismissedUpdateVersion: null }) + } else { + store.updateUI({ pendingUpdateNudgeId: null }) + } + }, + setDismissedUpdateNudgeId: (id) => { + store.updateUI({ dismissedUpdateNudgeId: id }) + }, + getReleaseChannelOverride: () => store.getUI().releaseChannelOverride ?? null, + onBeforeQuitFailure: options?.onBeforeUpdateQuitFailure, + installMode: options?.updateInstallMode + }) + logStartupMilestone('updater-setup-done') + } finally { + clearDeferredSetup() + } } pendingAutoUpdaterSetup = setupAutoUpdaterDeferred mainWindow.once('ready-to-show', () => setImmediate(setupAutoUpdaterDeferred)) - const updaterSetupFallback = setTimeout(setupAutoUpdaterDeferred, UPDATER_SETUP_FALLBACK_MS) + updaterSetupFallback = setTimeout(() => { + updaterSetupFallback = null + setupAutoUpdaterDeferred() + }, UPDATER_SETUP_FALLBACK_MS) updaterSetupFallback.unref?.() + if (updaterSetupDone) { + clearDeferredSetup() + } } export function registerUpdaterHandlers(_store: Store): void { From 60b166468515b411ce9b9ceb20a9b4fff9804416 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:52:09 -0700 Subject: [PATCH 21/67] Check daemon idle state without building discarded terminal inventories (#24749) * Check daemon idle state without building discarded terminal inventories Replace the private idle predicate full public inventory with a host-local full pass that still reads every Session liveness getter in original order. * Remove overwritten daemon test mock assignment --- .../daemon-idle-inventory-budget.test.ts | 323 ++++++++++++++++++ src/main/daemon/daemon-server.ts | 2 +- src/main/daemon/terminal-host.ts | 29 +- 3 files changed, 339 insertions(+), 15 deletions(-) create mode 100644 src/main/daemon/daemon-idle-inventory-budget.test.ts diff --git a/src/main/daemon/daemon-idle-inventory-budget.test.ts b/src/main/daemon/daemon-idle-inventory-budget.test.ts new file mode 100644 index 00000000000..e83b6a122ce --- /dev/null +++ b/src/main/daemon/daemon-idle-inventory-budget.test.ts @@ -0,0 +1,323 @@ +import './mock-descendant-sweep' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { performance } from 'node:perf_hooks' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type * as CryptoModule from 'node:crypto' +import { DaemonServer } from './daemon-server' +import { DaemonClient } from './client' +import { DaemonClientConnections } from './daemon-client-connections' +import { TerminalHost } from './terminal-host' +import { Session } from './session' +import { ClaimedAgentPtyOwnerRegistry } from '../../shared/claimed-agent-pty-owner' +import { isAgentSessionOwnerBinding } from '../../shared/agent-session-host-authority' +import { getDaemonSocketPath } from './daemon-spawner' +import type { SubprocessHandle } from './session-subprocess-handle' + +type MockSubprocess = SubprocessHandle & { exit: () => void } +const seed = vi.hoisted(() => ({ uuid: 0 })) +vi.mock('node:crypto', async (importOriginal) => { + const original = await importOriginal() + return { + ...original, + randomUUID: () => `00000000-0000-4000-8000-${String(++seed.uuid).padStart(12, '0')}` + } +}) +const clients: DaemonClient[] = [] +const processes: MockSubprocess[] = [] +let dir = '' +let server: DaemonServer | undefined + +async function waitFor(predicate: () => boolean): Promise { + const deadline = performance.now() + 2_000 + while (!predicate()) { + if (performance.now() >= deadline) { + throw new Error('Daemon event did not settle') + } + await new Promise((resolve) => setTimeout(resolve, 5)) + } +} + +function spawn(): MockSubprocess { + let exited: ((code: number) => void) | undefined + const proc: MockSubprocess = { + pid: 90_001, + getForegroundProcess: () => null, + write: vi.fn(), + resize: vi.fn(), + kill: () => exited?.(0), + forceKill: () => exited?.(137), + terminateOwnedTree: () => 'unavailable', + signal: vi.fn(), + onData: vi.fn(), + onExit: (callback) => { + exited = callback + }, + dispose: vi.fn(), + exit: () => exited?.(0) + } + processes.push(proc) + return proc +} + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'orca-idle-inventory-')) + clients.length = 0 + processes.length = 0 + seed.uuid = 0 +}) + +afterEach(async () => { + for (const process of processes) { + process.exit() + } + for (const client of clients) { + client.disconnect() + } + await server?.shutdown() + vi.restoreAllMocks() + rmSync(dir, { recursive: true, force: true }) +}) + +it('keeps live sessions and exact inventories while visitors repeatedly disconnect', async () => { + const socketPath = getDaemonSocketPath(dir) + const tokenPath = join(dir, 'daemon.token') + const idle = vi.fn() + const getClient = vi.spyOn(DaemonClientConnections.prototype, 'get') + const create = vi.spyOn(TerminalHost.prototype, 'createOrAttach') + server = new DaemonServer({ socketPath, tokenPath, spawnSubprocess: spawn, onIdleShutdown: idle }) + await server.start() + const owner = new DaemonClient({ socketPath, tokenPath }) + clients.push(owner) + await owner.ensureConnected() + for (let index = 0; index < 16; index++) { + await owner.request('createOrAttach', { + sessionId: `session-${index}`, + cols: 80, + rows: 24, + ...(index < 8 + ? { + agentSessionEnsure: { + claim: { + digestVersion: 1, + keyId: `key${index}`, + identityDigest: 'a'.repeat(43), + worktreeScopeDigest: 'b'.repeat(43), + agent: 'codex' + }, + surface: { + worktreeId: `folder${index}`, + tabId: '11111111-1111-4111-8111-111111111111', + leafId: '22222222-2222-4222-8222-222222222222', + terminalHandle: `term_idle${index}` + } + } + } + : {}) + }) + } + const connections = getClient.mock.contexts[0] + if (!(connections instanceof DaemonClientConnections)) { + throw new Error('Actual server connections were not used') + } + const before = await owner.request('listSessions', undefined) + const expected = structuredClone(before) + if ( + typeof before !== 'object' || + before === null || + !('sessions' in before) || + !Array.isArray(before.sessions) + ) { + throw new Error('Initial inventory was malformed') + } + const first = before.sessions[0] + if ( + typeof first !== 'object' || + first === null || + !('agentSessionOwners' in first) || + !Array.isArray(first.agentSessionOwners) || + !first.agentSessionOwners.every(isAgentSessionOwnerBinding) + ) { + throw new Error('Initial owners were malformed') + } + const priorOwner = first.agentSessionOwners[0] + priorOwner.claim.keyId = 'mutated-client-copy' + priorOwner.surface.terminalHandle = 'term_mutated' + const inventory = vi.spyOn(TerminalHost.prototype, 'listSessions') + const owners = vi.spyOn(ClaimedAgentPtyOwnerRegistry.prototype, 'listForPty') + const sizes = vi.spyOn(Session.prototype, 'getAppliedSize') + const cwds = vi.spyOn(Session.prototype, 'getCwd') + const originalAlive = Object.getOwnPropertyDescriptor(Session.prototype, 'isAlive')?.get + if (!originalAlive) { + throw new Error('Session alive getter is missing') + } + const alive = vi.spyOn(Session.prototype, 'isAlive', 'get') + for (let iteration = 0; iteration < 10; iteration++) { + const visitor = new DaemonClient({ socketPath, tokenPath }) + clients.push(visitor) + await visitor.ensureConnected() + expect(await visitor.request('ping', undefined)).toEqual({ pong: true }) + visitor.disconnect() + await waitFor(() => connections.size === 1 && connections.transportCount === 2) + expect(idle).not.toHaveBeenCalled() + } + const calls = inventory.mock.calls.length + const sizeCalls = sizes.mock.calls.length + const cwdCalls = cwds.mock.calls.length + const readSessionIds = (): string[] => + alive.mock.contexts.map((session) => { + if (!(session instanceof Session)) { + throw new Error('Actual Session was not read') + } + return session.sessionId + }) + const aliveReads = readSessionIds() + const ownerCalls = owners.mock.calls.length + const copiedOwners = owners.mock.results.reduce((total, result) => { + const value: unknown = result.value + if (!Array.isArray(value)) { + throw new Error('Owner result was not an array') + } + return total + value.length + }, 0) + const after = await owner.request('listSessions', undefined) + expect(after).toEqual(expected) + expect(JSON.stringify(after)).toBe(JSON.stringify(expected)) + expect(after).not.toBe(before) + if ( + typeof after !== 'object' || + after === null || + !('sessions' in after) || + !Array.isArray(after.sessions) + ) { + throw new Error('Fresh inventory was malformed') + } + const fresh = after.sessions[0] + if ( + typeof fresh !== 'object' || + fresh === null || + !('agentSessionOwners' in fresh) || + !Array.isArray(fresh.agentSessionOwners) || + !fresh.agentSessionOwners.every(isAgentSessionOwnerBinding) + ) { + throw new Error('Fresh owners were malformed') + } + expect(fresh.agentSessionOwners).not.toBe(first.agentSessionOwners) + expect(fresh.agentSessionOwners[0]).not.toBe(priorOwner) + expect(fresh.agentSessionOwners[0].claim).not.toBe(priorOwner.claim) + expect(fresh.agentSessionOwners[0].surface).not.toBe(priorOwner.surface) + expect(idle).not.toHaveBeenCalled() + const host = create.mock.contexts[0] + if (!(host instanceof TerminalHost)) { + throw new Error('Actual host was not used') + } + const hasLive = (): boolean => { + const candidate: unknown = host + if ( + typeof candidate === 'object' && + candidate !== null && + 'hasLiveSessions' in candidate && + typeof candidate.hasLiveSessions === 'function' + ) { + const result: unknown = candidate.hasLiveSessions() + if (typeof result !== 'boolean') { + throw new Error('Live result was not boolean') + } + return result + } + return host.listSessions().length > 0 + } + const readAlive = (session: Session): boolean => { + const result: unknown = originalAlive.call(session) + if (typeof result !== 'boolean') { + throw new Error('Original alive getter was malformed') + } + return result + } + const controlReads: string[][] = [] + const failure = new Error('later-session-alive-failure') + alive.mockImplementation(function (this: Session) { + if (this.sessionId === 'session-1') { + throw failure + } + return readAlive(this) + }) + let caught: unknown + try { + hasLive() + } catch (error) { + caught = error + } + expect(caught).toBe(failure) + const sessionIds = Array.from({ length: 16 }, (_value, index) => `session-${index}`) + const deadIds = new Set(sessionIds) + alive.mockClear().mockImplementation(function (this: Session) { + return !deadIds.has(this.sessionId) && readAlive(this) + }) + expect(hasLive()).toBe(false) + controlReads.push(readSessionIds()) + deadIds.delete('session-3') + alive.mockClear() + expect(hasLive()).toBe(true) + controlReads.push(readSessionIds()) + deadIds.add('session-3') + alive.mockClear() + expect(hasLive()).toBe(false) + controlReads.push(readSessionIds()) + let entered = false + let nested: boolean | undefined + alive.mockClear().mockImplementation(function (this: Session) { + if (!entered) { + entered = true + processes[1].exit() + nested = hasLive() + } + return readAlive(this) + }) + expect(hasLive()).toBe(true) + expect(nested).toBe(true) + controlReads.push(readSessionIds()) + alive.mockRestore() + if ( + typeof expected !== 'object' || + expected === null || + !('sessions' in expected) || + !Array.isArray(expected.sessions) + ) { + throw new Error('Inventory was malformed') + } + const remaining = { + ...expected, + sessions: expected.sessions.filter( + (row: unknown) => + typeof row === 'object' && + row !== null && + 'sessionId' in row && + row.sessionId !== 'session-1' + ) + } + const afterExit = await owner.request('listSessions', undefined) + expect(afterExit).toEqual(remaining) + expect(JSON.stringify(afterExit)).toBe(JSON.stringify(remaining)) + expect(aliveReads).toEqual(Array.from({ length: 30 }, () => sessionIds).flat()) + const remainingIds = sessionIds.filter((id) => id !== 'session-1') + expect(controlReads).toEqual([ + sessionIds, + sessionIds, + sessionIds, + [ + sessionIds[0], + ...sessionIds, + sessionIds[1], + ...remainingIds, + ...remainingIds, + ...sessionIds.slice(2) + ] + ]) + expect(calls).toBe(0) + expect(ownerCalls).toBe(0) + expect(copiedOwners).toBe(0) + expect(sizeCalls).toBe(0) + expect(cwdCalls).toBe(0) +}) diff --git a/src/main/daemon/daemon-server.ts b/src/main/daemon/daemon-server.ts index ab22af24100..a917891f109 100644 --- a/src/main/daemon/daemon-server.ts +++ b/src/main/daemon/daemon-server.ts @@ -233,7 +233,7 @@ export class DaemonServer { } private isIdle(): boolean { - if (this.admission.inFlight > 0 || this.host.listSessions().length > 0) { + if (this.admission.inFlight > 0 || this.host.hasLiveSessions()) { return false } if (this.endpoint.lost) { diff --git a/src/main/daemon/terminal-host.ts b/src/main/daemon/terminal-host.ts index d5669618c7a..93b4dee6e58 100644 --- a/src/main/daemon/terminal-host.ts +++ b/src/main/daemon/terminal-host.ts @@ -1,20 +1,14 @@ import type { Session } from './session' -import { - SessionNotFoundError, - type SessionInfo, - type TakePendingOutputResult, - type TerminalSnapshot -} from './types' +import { SessionNotFoundError } from './types' +import type { SessionInfo, TakePendingOutputResult, TerminalSnapshot } from './types' import type { CreateOrAttachResult } from './terminal-host-create-contract' import type { TerminalHostOptions } from './terminal-host-options' import { disposeTerminalHostSessions } from './terminal-host-disposal' import { getAliveTerminalHostSession } from './terminal-host-session-access' import { TerminalSessionTeardown } from './terminal-session-teardown' import { ClaimedAgentPtyOwnerRegistry } from '../../shared/claimed-agent-pty-owner' -import { - createOrAttachClaimedAgentSession, - type InternalCreateOrAttachOptions -} from './terminal-host-agent-session-claim' +import { createOrAttachClaimedAgentSession } from './terminal-host-agent-session-claim' +import type { InternalCreateOrAttachOptions } from './terminal-host-agent-session-claim' import { TerminalHostAgentSessionGenerations } from './terminal-host-agent-session-generations' import { resolveTerminalHostSessionCwd } from './terminal-host-session-cwd' import { TerminalHostTombstones } from './terminal-host-tombstones' @@ -24,10 +18,8 @@ import { TerminalAttachCanceledError } from './daemon-errors' import { waitForTerminalAttachOperation } from './terminal-attach-cancellation' import { randomUUID } from 'node:crypto' import { pruneRetiredPtyIncarnations } from '../../shared/retired-pty-incarnations' -import { - inspectTerminalHostProcess, - type TerminalHostProcessInspection -} from './terminal-host-process-inspection' +import { inspectTerminalHostProcess } from './terminal-host-process-inspection' +import type { TerminalHostProcessInspection } from './terminal-host-process-inspection' import { confirmTerminalHostForegroundProcess, confirmTerminalHostShellForeground, @@ -320,6 +312,15 @@ export class TerminalHost { return listLiveTerminalHostSessions(this.sessions, this.agentSessionOwners) } + hasLiveSessions(): boolean { + let hasLive = false + // Read every Session, even after a live one, to preserve the inventory's error order. + for (const session of this.sessions.values()) { + hasLive = session.isAlive || hasLive + } + return hasLive + } + dispose(): Promise { this.creationFenced = true if (this.disposePromise) { From 8cd9751963b3046e8393f2007c9104a56a63fa84 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sat, 3 Oct 2026 16:27:02 -0700 Subject: [PATCH 22/67] fix(updater): keep macOS Orca open when background instances block updates (#24952) * fix(updater): guard macOS installs against running app instances * fix(updater): match native app blockers and preserve quit lifecycle * fix(updater): keep ordinary macOS quit on Squirrel's install-on-exit path Converting every quit with a staged update into quitAndInstall made Cmd+Q relaunch Orca, refused the quit when background instances existed, and hijacked app.relaunch()+app.quit() restart flows (profile switch, admin restart) into an update install racing the relaunched old app. Only Update & Restart runs the running-instance preflight now; the quit-without-install allowance is no longer reachable and is removed. * fix(updater): preserve quit intent through macOS staging * test(native-chat): explicitly model legacy published tab ownership --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> Co-authored-by: m4air --- .github/workflows/macos-updater-tests.yml | 49 +++ ...aude-child-work-evidence-retention.test.ts | 7 +- ...date-running-instances.integration.test.ts | 97 +++++ .../macos-update-running-instances.test.ts | 106 +++++ src/main/macos-update-running-instances.ts | 53 +++ .../startup/desktop-startup-ordering.test.ts | 4 + .../main-process-quit-update-veto.test.ts | 103 +++++ src/main/startup/main-process-quit.ts | 5 +- src/main/updater-events.test.ts | 3 +- src/main/updater-fallback.ts | 1 + ...ter-linux-package-recovery-actions.test.ts | 8 +- src/main/updater-mac-install.ts | 81 ++-- src/main/updater-mac-quit-guard.test.ts | 253 +++++++++++ src/main/updater-test-harness.ts | 40 +- src/main/updater.check-failure.test.ts | 9 + src/main/updater.check-preflight.test.ts | 88 ++++ src/main/updater.fallback.test.ts | 1 + .../updater.headless-serve-install.test.ts | 4 + .../updater.install-failure-cause.test.ts | 9 + src/main/updater.mac-install.test.ts | 396 +++++++++++++++++- src/main/updater/updater-download-install.ts | 19 +- src/main/updater/updater-install-execution.ts | 47 ++- src/main/updater/updater-menu-checks.ts | 15 + src/main/updater/updater-scheduling.ts | 15 + .../window/dashboard-popout-window.test.ts | 27 ++ src/main/window/dashboard-popout-window.ts | 7 +- .../main-window-state-lifecycle.test.ts | 64 +++ .../window/main-window-state-lifecycle.ts | 9 +- .../components/UpdateCard.error-card.test.tsx | 31 ++ .../update-card/update-card-error-model.ts | 12 + ...cturedAgentSessionAttentionBridge.test.tsx | 11 +- .../GeneralUpdateSettingsSection.test.tsx | 39 +- .../settings/GeneralUpdateSettingsSection.tsx | 7 + src/shared/update-status-types.ts | 2 + 34 files changed, 1552 insertions(+), 70 deletions(-) create mode 100644 .github/workflows/macos-updater-tests.yml create mode 100644 src/main/macos-update-running-instances.integration.test.ts create mode 100644 src/main/macos-update-running-instances.test.ts create mode 100644 src/main/macos-update-running-instances.ts create mode 100644 src/main/startup/main-process-quit-update-veto.test.ts create mode 100644 src/main/updater-mac-quit-guard.test.ts create mode 100644 src/main/window/main-window-state-lifecycle.test.ts diff --git a/.github/workflows/macos-updater-tests.yml b/.github/workflows/macos-updater-tests.yml new file mode 100644 index 00000000000..056a40204b4 --- /dev/null +++ b/.github/workflows/macos-updater-tests.yml @@ -0,0 +1,49 @@ +name: macOS updater regression tests + +on: + pull_request: + paths: + - '.github/workflows/macos-updater-tests.yml' + - 'src/main/macos-update-running-instances*' + - 'src/main/updater*' + - 'src/main/updater/**' + - 'src/main/startup/main-process-quit*' + - 'src/main/window/main-window-state-lifecycle*' + - 'src/main/window/dashboard-popout-window*' + - 'src/shared/child-process/**' + - 'src/shared/update-status-types.ts' + - 'pnpm-lock.yaml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: macos-updater-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + updater: + runs-on: macos-15 + timeout-minutes: 20 + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: ./.github/actions/install-node-dependencies + with: + native-runtime: node + - name: Exercise native application registry and update shutdown + env: + ORCA_BACKGROUND_LAUNCH: '1' + run: >- + pnpm exec vitest run --config config/vitest.config.ts + src/main/macos-update-running-instances.test.ts + src/main/macos-update-running-instances.integration.test.ts + src/main/updater.mac-install.test.ts + src/main/updater.headless-serve-install.test.ts + src/main/updater-mac-quit-guard.test.ts + src/main/startup/desktop-startup-ordering.test.ts + src/main/startup/main-process-quit-update-veto.test.ts + src/main/window/main-window-state-lifecycle.test.ts + src/main/window/dashboard-popout-window.test.ts diff --git a/src/main/claude/claude-child-work-evidence-retention.test.ts b/src/main/claude/claude-child-work-evidence-retention.test.ts index 2ba58138c24..c9c3250bcab 100644 --- a/src/main/claude/claude-child-work-evidence-retention.test.ts +++ b/src/main/claude/claude-child-work-evidence-retention.test.ts @@ -9,6 +9,7 @@ import type { StructuredAgentSessionEventSink } from '../native-chat/agent-sessi import { ClaudeChildWorkDecoder } from './claude-child-work-decoder' import { claudeChildOperation, drainClaudeChildWork } from './claude-child-work-evidence' import { createClaudeJournalTranslator } from './claude-structured-journal-translation' +import { ClaudePromptRegistry } from './claude-structured-prompt-replies' import { claudeToolResults, claudeToolUses, @@ -85,7 +86,11 @@ describe('Claude child operation output retention', () => { translator.handle({ type: 'message', sessionId: 'orca', message, observedAt: 1_000 }) const join = vi.spyOn(Array.prototype, 'join') const evidence = drainClaudeChildWork( - { childWork: new ClaudeChildWorkDecoder(), translator }, + { + childWork: new ClaudeChildWorkDecoder(), + translator, + prompts: new ClaudePromptRegistry() + }, message, 1_000 ) diff --git a/src/main/macos-update-running-instances.integration.test.ts b/src/main/macos-update-running-instances.integration.test.ts new file mode 100644 index 00000000000..afc5c3d7e66 --- /dev/null +++ b/src/main/macos-update-running-instances.integration.test.ts @@ -0,0 +1,97 @@ +import { cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { once } from 'node:events' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { expect, it, vi } from 'vitest' +import { runProcess, spawnProcess } from '../shared/child-process/run-process' +import { getMacUpdateRunningInstances } from './macos-update-running-instances' + +const APPLICATION_SOURCE = ` +#import +#import +int main(int argc, const char *argv[]) { + if (argc > 1) { sleep(30); return 0; } + @autoreleasepool { + NSApplication *app = [NSApplication sharedApplication]; + [app setActivationPolicy:NSApplicationActivationPolicyProhibited]; + [app run]; + } + return 0; +} +` + +it.runIf(process.platform === 'darwin')( + 'matches ShipIt with registered sibling apps, excluding same-executable workers and other bundle copies', + async () => { + const root = mkdtempSync(path.join(tmpdir(), 'orca-update-instances-')) + const bundle = path.join(root, 'Orca Test.app') + const executable = path.join(bundle, 'Contents', 'MacOS', 'Orca Test') + mkdirSync(path.dirname(executable), { recursive: true }) + const sourcePath = path.join(root, 'application.m') + writeFileSync(sourcePath, APPLICATION_SOURCE) + writeFileSync( + path.join(bundle, 'Contents', 'Info.plist'), + ` + CFBundleIdentifiercom.stablyai.${path.basename(root)} + CFBundleExecutableOrca Test + CFBundlePackageTypeAPPL + ` + ) + const children: ReturnType[] = [] + const closed: Promise[] = [] + try { + const compilation = await runProcess({ + program: '/usr/bin/clang', + args: ['-framework', 'AppKit', sourcePath, '-o', executable], + timeoutMs: 15_000 + }) + expect(compilation.code, compilation.stderr).toBe(0) + const otherBundle = path.join(root, 'Other Orca.app') + cpSync(bundle, otherBundle, { recursive: true }) + for (const [program, args] of [ + [executable, []], + [executable, []], + [executable, ['--worker']], + [path.join(otherBundle, 'Contents', 'MacOS', 'Orca Test'), []] + ] satisfies [string, string[]][]) { + const child = spawnProcess({ + program, + args, + env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' } + }) + children.push(child) + closed.push(once(child, 'close')) + } + const [self, sibling, worker, otherCopy] = children + await vi.waitFor( + async () => { + expect(self.pid).toBeTypeOf('number') + expect(sibling.pid).toBeTypeOf('number') + expect(await getMacUpdateRunningInstances(executable, self.pid)).toEqual([sibling.pid]) + expect( + await getMacUpdateRunningInstances( + path.join(otherBundle, 'Contents', 'MacOS', 'Orca Test'), + 0 + ) + ).toEqual([otherCopy.pid]) + const workerListing = await runProcess({ + program: '/bin/ps', + args: ['-p', String(worker.pid), '-ww', '-o', 'comm='] + }) + expect(workerListing.stdout.trim()).toBe(executable) + }, + { timeout: 5000 } + ) + sibling.kill('SIGTERM') + await closed[1] + expect(await getMacUpdateRunningInstances(executable, self.pid)).toEqual([]) + } finally { + for (const child of children) { + child.kill('SIGTERM') + } + await Promise.all(closed) + rmSync(root, { recursive: true, force: true }) + } + }, + 25_000 +) diff --git a/src/main/macos-update-running-instances.test.ts b/src/main/macos-update-running-instances.test.ts new file mode 100644 index 00000000000..f6af6f04f53 --- /dev/null +++ b/src/main/macos-update-running-instances.test.ts @@ -0,0 +1,106 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + getMacUpdateRunningInstances, + parseMacUpdateRunningInstances +} from './macos-update-running-instances' + +const { runProcessMock } = vi.hoisted(() => ({ runProcessMock: vi.fn() })) +vi.mock('../shared/child-process/run-process', () => ({ runProcess: runProcessMock })) + +const executable = '/Applications/Orca Test.app/Contents/MacOS/Orca Test' + +describe('macOS update running instances', () => { + beforeEach(() => { + runProcessMock.mockReset() + }) + + it('excludes the current process from the native registry result', () => { + expect(parseMacUpdateRunningInstances('[100,101,102]\n', 100)).toEqual([101, 102]) + expect(parseMacUpdateRunningInstances('[]\n', 100)).toEqual([]) + }) + + it.each([ + 'not JSON', + '', + '{}', + 'null', + '[0]', + '[-1]', + '[1.5]', + '["101"]', + '[null]', + '[9007199254740992]' + ])('rejects invalid registry output: %s', (listing) => { + expect(() => parseMacUpdateRunningInstances(listing, 100)).toThrow() + }) + + it.runIf(process.platform === 'darwin')( + 'passes the bundle path as an argument rather than executable script', + async () => { + const unusualExecutable = '/Applications/Orca "Test" $HOME.app/Contents/MacOS/Orca Test' + runProcessMock.mockResolvedValue({ code: 0, stdout: '[]', timedOut: false }) + await expect(getMacUpdateRunningInstances(unusualExecutable)).resolves.toEqual([]) + expect(runProcessMock).toHaveBeenCalledWith( + expect.objectContaining({ + args: [ + '-l', + 'JavaScript', + '-e', + expect.not.stringContaining('$HOME'), + '/Applications/Orca "Test" $HOME.app' + ] + }) + ) + } + ) + + it('skips development runtimes without probing the host', async () => { + expect(await getMacUpdateRunningInstances('/usr/local/bin/node')).toEqual([]) + expect(runProcessMock).not.toHaveBeenCalled() + }) + + it.each(['linux', 'win32'])('does not probe on %s', async (platform) => { + vi.stubGlobal('process', { ...process, platform }) + try { + expect(await getMacUpdateRunningInstances(executable)).toEqual([]) + expect(runProcessMock).not.toHaveBeenCalled() + } finally { + vi.unstubAllGlobals() + } + }) + + it.runIf(process.platform === 'darwin')( + 'uses the bounded native application registry query and preserves paths with spaces', + async () => { + runProcessMock.mockResolvedValue({ + code: 0, + stdout: '[100,101]\n', + timedOut: false + }) + expect(await getMacUpdateRunningInstances(executable, 100)).toEqual([101]) + expect(runProcessMock).toHaveBeenCalledWith( + expect.objectContaining({ + program: '/usr/bin/osascript', + args: [ + '-l', + 'JavaScript', + '-e', + expect.stringContaining('runningApplicationsWithBundleIdentifier'), + '/Applications/Orca Test.app' + ], + timeoutMs: 5000, + killOnOutputLimit: true + }) + ) + } + ) + + it.runIf(process.platform === 'darwin').each([ + { code: 1, timedOut: false }, + { code: null, timedOut: true }, + { code: 0, timedOut: false, outputTruncated: true } + ])('fails closed for incomplete query results: %j', async (result) => { + runProcessMock.mockResolvedValue({ stdout: '', ...result }) + await expect(getMacUpdateRunningInstances(executable)).rejects.toThrow('Could not check') + }) +}) diff --git a/src/main/macos-update-running-instances.ts b/src/main/macos-update-running-instances.ts new file mode 100644 index 00000000000..1ce757d30f5 --- /dev/null +++ b/src/main/macos-update-running-instances.ts @@ -0,0 +1,53 @@ +import path from 'node:path' +import { runProcess } from '../shared/child-process/run-process' + +const RUNNING_INSTANCES_SCRIPT = `function run(argv) { + ObjC.import('AppKit'); + const bundle = $.NSBundle.bundleWithPath(argv[0]); + const identifier = ObjC.unwrap(bundle.bundleIdentifier); + if (typeof identifier !== 'string' || !identifier) throw new Error('Missing bundle identifier'); + const target = $.NSURL.fileURLWithPath(argv[0]).URLByStandardizingPath; + const apps = $.NSRunningApplication.runningApplicationsWithBundleIdentifier(identifier); + const pids = []; + for (let i = 0; i < apps.count; i++) { + const app = apps.objectAtIndex(i); + if (app.bundleURL && app.bundleURL.URLByStandardizingPath.isEqual(target)) { + pids.push(Number(app.processIdentifier)); + } + } + return JSON.stringify(pids); +}` + +/** Squirrel waits for every main application process from the target bundle. */ +export async function getMacUpdateRunningInstances( + executable = process.execPath, + currentPid = process.pid +): Promise { + if (process.platform !== 'darwin' || !executable.includes('.app/Contents/MacOS/')) { + return [] + } + const bundlePath = path.dirname(path.dirname(path.dirname(executable))) + // Match ShipIt's registry query; run-as-node workers share the executable but do not block it. + const result = await runProcess({ + program: '/usr/bin/osascript', + args: ['-l', 'JavaScript', '-e', RUNNING_INSTANCES_SCRIPT, bundlePath], + timeoutMs: 5_000, + maxOutputBytes: 2 * 1024 * 1024, + killOnOutputLimit: true + }) + if (result.code !== 0 || result.timedOut || result.outputTruncated) { + throw new Error('Could not check running Orca instances') + } + return parseMacUpdateRunningInstances(result.stdout, currentPid) +} + +export function parseMacUpdateRunningInstances(listing: string, currentPid: number): number[] { + const pids: unknown = JSON.parse(listing) + if ( + !Array.isArray(pids) || + !pids.every((pid: unknown) => typeof pid === 'number' && Number.isSafeInteger(pid) && pid > 0) + ) { + throw new Error('Invalid macOS application listing') + } + return pids.filter((pid: number) => pid !== currentPid) +} diff --git a/src/main/startup/desktop-startup-ordering.test.ts b/src/main/startup/desktop-startup-ordering.test.ts index ce8148ea5d2..485a56c37b8 100644 --- a/src/main/startup/desktop-startup-ordering.test.ts +++ b/src/main/startup/desktop-startup-ordering.test.ts @@ -19,6 +19,10 @@ describe('startup ordering', () => { expect(beforeQuitStart).toBeGreaterThanOrEqual(0) expect(willQuitStart).toBeGreaterThan(beforeQuitStart) expect(windowAllClosedStart).toBeGreaterThan(willQuitStart) + expect(beforeQuit.indexOf('event.defaultPrevented')).toBeGreaterThanOrEqual(0) + expect(beforeQuit.indexOf('event.defaultPrevented')).toBeLessThan( + beforeQuit.indexOf('state.isQuitting = true') + ) expect(beforeQuit).not.toContain('unsubscribeSystemResumeBroadcast') expect(commitIndex).toBeGreaterThanOrEqual(0) expect(disposeIndex).toBeGreaterThan(commitIndex) diff --git a/src/main/startup/main-process-quit-update-veto.test.ts b/src/main/startup/main-process-quit-update-veto.test.ts new file mode 100644 index 00000000000..ea46417fe40 --- /dev/null +++ b/src/main/startup/main-process-quit-update-veto.test.ts @@ -0,0 +1,103 @@ +import { EventEmitter } from 'node:events' +import { afterEach, expect, it, vi } from 'vitest' + +const dependencyExports: [string, string[]][] = [ + ['../ipc/filesystem-watcher', ['closeAllWatchers']], + ['../ipc/worktree-base-directory-watcher', ['disposeWorktreeBaseDirectoryWatchers']], + ['../ipc/folder-repo-git-upgrade', ['stopFolderRepoGitUpgradeWatch']], + ['../ipc/pty', ['killAllPty']], + ['../daemon/daemon-init', ['disconnectDaemon', 'shutdownDaemon']], + ['../ipc/ssh-shutdown-drain', ['beginSshShutdown']], + ['../agent-hooks/server', ['agentHookServer']], + ['../agent-hooks/wsl-hook-relay-manager', ['wslHookRelayManager']], + ['../agent-hooks/managed-agent-hook-controls', ['removeManagedAgentHooksAsync']], + ['../runtime/structured-agent-session-runtime', ['stopStructuredAgentSessionRuntime']], + [ + '../runtime/structured-agent-session-runtime-teardown', + ['setStructuredAgentSessionTeardownTrigger'] + ], + ['../runtime/orca-runtime-files', ['awaitRuntimeFileWatcherUnsubscribes']], + ['../runtime/runtime-metadata', ['clearRuntimeMetadataIfOwned']], + [ + '../browser/paired-runtime-browser-client-host-runtime', + ['shutdownPairedRuntimeBrowserClientHosts'] + ], + ['../browser/browser-manager', ['browserManager']], + ['../codex/codex-state-db-backfill-recovery', ['stopCodexStateDbBackfillRecoveries']], + ['../codex/codex-account-session-bridge', ['stopCodexAccountSessionBridges']], + ['../git/local-repo-ref-maintenance', ['awaitPackedRefsLockRelease']], + ['../worktree-background-removal', ['stopBackgroundWorktreeRemovals']], + ['../quit-teardown-deadline', ['settleTeardownWithinDeadline', 'settleWithinMs']], + ['../quit-teardown-start-gate', ['quitTeardownStartGate']], + ['../dock/unread-badge', ['setUnreadDockBadgeCount']], + ['../tray/system-tray', ['destroySystemTray']], + ['../telemetry/client', ['shutdownTelemetry']], + ['../observability', ['shutdownObservability']], + ['../updater', ['isQuittingForUpdate']], + ['../updater-lifecycle-diagnostics', ['recordUpdaterLifecycle']], + ['../macos-tcc-prompt-notice', ['stopTccPromptNotice']], + ['../terminal-history-gc', ['cancelHistoryGc']], + ['./window-all-closed-quit-policy', ['shouldQuitWhenAllWindowsClosed']], + ['./configure-process', ['isDevParentShutdownRequested']], + ['../persistence', ['getCanonicalUserDataPath']] +] + +it('keeps startup services live when the updater has vetoed before-quit', async () => { + vi.resetModules() + const app = new EventEmitter() + const fenceAndCloseNow = vi.fn() + const setMobileRelayPairingProvider = vi.fn() + const unsubscribeAgentAwakeStatusChanges = vi.fn() + const dispose = vi.fn() + const stop = vi.fn() + const state = { + isQuitting: false, + desktopRelayService: { fenceAndCloseNow }, + runtimeRpc: { setMobileRelayPairingProvider }, + unsubscribeAgentAwakeStatusChanges, + agentAwakeService: { dispose }, + rateLimits: { stop } + } + vi.doMock('electron', () => ({ app })) + vi.doMock('./main-process-state', () => ({ mainProcessState: state })) + for (const [moduleName, exports] of dependencyExports) { + vi.doMock(moduleName, () => Object.fromEntries(exports.map((name) => [name, vi.fn()]))) + } + const exitListenersBefore = process.listeners('exit') + const { installMainProcessQuitHandlers } = await import('./main-process-quit') + installMainProcessQuitHandlers() + + app.emit('before-quit', { defaultPrevented: true }) + + expect(state.isQuitting).toBe(false) + expect(fenceAndCloseNow).not.toHaveBeenCalled() + expect(setMobileRelayPairingProvider).not.toHaveBeenCalled() + expect(unsubscribeAgentAwakeStatusChanges).not.toHaveBeenCalled() + expect(dispose).not.toHaveBeenCalled() + expect(stop).not.toHaveBeenCalled() + expect(state.agentAwakeService).toEqual({ dispose }) + expect(state.unsubscribeAgentAwakeStatusChanges).toBe(unsubscribeAgentAwakeStatusChanges) + + app.emit('before-quit', { defaultPrevented: false }) + + expect(state.isQuitting).toBe(true) + expect(fenceAndCloseNow).toHaveBeenCalledOnce() + expect(setMobileRelayPairingProvider).toHaveBeenCalledWith(null) + expect(unsubscribeAgentAwakeStatusChanges).toHaveBeenCalledOnce() + expect(dispose).toHaveBeenCalledOnce() + expect(stop).toHaveBeenCalledOnce() + for (const listener of process.listeners('exit')) { + if (!exitListenersBefore.includes(listener)) { + process.removeListener('exit', listener) + } + } +}) + +afterEach(() => { + vi.doUnmock('electron') + vi.doUnmock('./main-process-state') + for (const [moduleName] of dependencyExports) { + vi.doUnmock(moduleName) + } + vi.resetModules() +}) diff --git a/src/main/startup/main-process-quit.ts b/src/main/startup/main-process-quit.ts index 5a89c9e283f..7f218b93e6c 100644 --- a/src/main/startup/main-process-quit.ts +++ b/src/main/startup/main-process-quit.ts @@ -67,7 +67,10 @@ function shutdownWatchersOnce(): Promise { } function installBeforeQuitHandler(): void { - app.on('before-quit', () => { + app.on('before-quit', (event: Event) => { + if (event.defaultPrevented) { + return + } if (isQuittingForUpdate()) { recordUpdaterLifecycle('before_quit_allowed', undefined, { message: 'before-quit allowed for update install' diff --git a/src/main/updater-events.test.ts b/src/main/updater-events.test.ts index 6a643ae64a5..6f5ce5fbb87 100644 --- a/src/main/updater-events.test.ts +++ b/src/main/updater-events.test.ts @@ -12,7 +12,8 @@ const { appMock: { isPackaged: true, getVersion: vi.fn(() => '1.0.51'), - on: vi.fn() + on: vi.fn(), + prependListener: vi.fn() }, nativeUpdaterMock: { on: vi.fn() }, getLinuxPackageTypeMock: vi.fn<() => 'deb' | 'rpm' | 'non-root' | 'unusable'>(() => 'deb'), diff --git a/src/main/updater-fallback.ts b/src/main/updater-fallback.ts index 62ec3ff3b8c..e108469acdd 100644 --- a/src/main/updater-fallback.ts +++ b/src/main/updater-fallback.ts @@ -51,6 +51,7 @@ export function statusesEqual(left: UpdateStatus, right: UpdateStatus): boolean left.message === right.message && left.version === right.version && left.retryable === right.retryable && + left.retryAction === right.retryAction && left.userInitiated === right.userInitiated && left.activeNudgeId === right.activeNudgeId && // Recovery identity fences async actions, so same-valued recaptures must reach the renderer. diff --git a/src/main/updater-linux-package-recovery-actions.test.ts b/src/main/updater-linux-package-recovery-actions.test.ts index 9a546dd8fac..60912443180 100644 --- a/src/main/updater-linux-package-recovery-actions.test.ts +++ b/src/main/updater-linux-package-recovery-actions.test.ts @@ -38,7 +38,13 @@ const { } } return { - appMock: { isPackaged: true, getVersion: vi.fn(() => '1.0.51'), on: vi.fn(), quit: vi.fn() }, + appMock: { + isPackaged: true, + getVersion: vi.fn(() => '1.0.51'), + on: vi.fn(), + prependListener: vi.fn(), + quit: vi.fn() + }, autoUpdaterMock, clearTrackedLinuxPackageArtifactMock: vi.fn(), getTrackedLinuxPackageArtifactMock: vi.fn(), diff --git a/src/main/updater-mac-install.ts b/src/main/updater-mac-install.ts index 234cdc0b2c4..ee45db2d0f4 100644 --- a/src/main/updater-mac-install.ts +++ b/src/main/updater-mac-install.ts @@ -34,11 +34,17 @@ export function registerMacUpdaterEvents({ }) } - app.on('before-quit', (event) => { + // Why: veto before startup listeners begin shutting down services. + app.prependListener('before-quit', (event) => { if (!shouldDeferMacQuitForInstall()) { return } - if (consumeMacInstallGuardBypass()) { + // Why: an Update & Restart is checking blockers or cleaning up; a second quit must not tear down underneath it. + if (macInstallPreflightInProgress) { + event.preventDefault() + return + } + if (shouldBypassMacInstallGuard()) { recordUpdaterLifecycle('macos_before_quit_guard_bypassed') return } @@ -50,7 +56,8 @@ export function registerMacUpdaterEvents({ getCurrentStatus(), hasInstallableDownloadedVersion(), getPendingInstallVersion, - sendStatus + sendStatus, + 'quit' ) ) { recordUpdaterLifecycle('macos_before_quit_deferred', { @@ -63,16 +70,23 @@ export function registerMacUpdaterEvents({ /** Whether Squirrel.Mac has finished downloading the update from the localhost proxy. */ let squirrelReady = false +let macInstallPreflightInProgress = false + +export function setMacInstallPreflightInProgress(value: boolean): void { + macInstallPreflightInProgress = value + if (value) { + bypassMacInstallGuardUntilNextAttempt = false + } +} /** Remembers a user/app quit request that arrived before Squirrel.Mac had a * staged update ready to apply. Without this handoff, quitting during the * localhost-proxy phase exits back into the old app and the update is lost. */ -let installRequestedAfterSquirrelReady = false +let requestedActionAfterSquirrelReady: 'quit' | 'install' | null = null /** Prevents the updater-specific before-quit guard from re-blocking the * quitAndInstall-triggered shutdown that is supposed to apply the update. */ let quitAndInstallInFlight = false -/** Lets a timed-out quit attempt proceed exactly once so the app never gets - * trapped open if Squirrel.Mac stops short of the native ready signal. */ -let bypassMacInstallGuardOnce = false +/** Both quit passes must proceed when native readiness times out. */ +let bypassMacInstallGuardUntilNextAttempt = false let pendingInstallTimeout: ReturnType | null = null function clearPendingInstallTimeout(): void { @@ -83,9 +97,10 @@ function clearPendingInstallTimeout(): void { } export function resetMacInstallState(): void { - installRequestedAfterSquirrelReady = false + macInstallPreflightInProgress = false + requestedActionAfterSquirrelReady = null quitAndInstallInFlight = false - bypassMacInstallGuardOnce = false + bypassMacInstallGuardUntilNextAttempt = false clearPendingInstallTimeout() } @@ -95,18 +110,18 @@ export function beginMacUpdateDownload(): void { } export function markMacQuitAndInstallInFlight(): void { - installRequestedAfterSquirrelReady = false + requestedActionAfterSquirrelReady = null quitAndInstallInFlight = true - bypassMacInstallGuardOnce = false + bypassMacInstallGuardUntilNextAttempt = false clearPendingInstallTimeout() } -export function consumeMacInstallGuardBypass(): boolean { - if (!bypassMacInstallGuardOnce) { - return false - } - bypassMacInstallGuardOnce = false - return true +function shouldBypassMacInstallGuard(): boolean { + return bypassMacInstallGuardUntilNextAttempt +} + +export function isMacInstallRequested(): boolean { + return requestedActionAfterSquirrelReady === 'install' } export function isMacQuitAndInstallInFlight(): boolean { @@ -135,13 +150,20 @@ export function deferMacQuitUntilInstallerReady( currentStatus: UpdateStatus, hasNewerDownloadedVersion: boolean, getPendingInstallVersion: () => string, - sendStatus: (status: UpdateStatus) => void + sendStatus: (status: UpdateStatus) => void, + intent: 'quit' | 'install' = 'install' ): boolean { if (!isWaitingForMacInstallerReadiness(currentStatus, hasNewerDownloadedVersion)) { return false } - installRequestedAfterSquirrelReady = true + if (intent === 'install') { + bypassMacInstallGuardUntilNextAttempt = false + } + // Why: an ordinary retry of quit must not downgrade a pending Update & Restart. + if (intent === 'install' || requestedActionAfterSquirrelReady !== 'install') { + requestedActionAfterSquirrelReady = intent + } sendStatus({ state: 'downloading', percent: 100, version: getPendingInstallVersion() }) if (pendingInstallTimeout) { @@ -150,7 +172,7 @@ export function deferMacQuitUntilInstallerReady( pendingInstallTimeout = setTimeout(() => { pendingInstallTimeout = null - if (!installRequestedAfterSquirrelReady || quitAndInstallInFlight) { + if (!requestedActionAfterSquirrelReady || quitAndInstallInFlight) { return } @@ -162,11 +184,11 @@ export function deferMacQuitUntilInstallerReady( message: `macOS installer was not ready after ${MAC_INSTALL_READY_TIMEOUT_MS}ms; allowing quit without install` } ) - installRequestedAfterSquirrelReady = false + requestedActionAfterSquirrelReady = null // This is a safety valve. The updater path should wait for ShipIt so the // staged update can apply, but if the native ready signal never arrives we // must let the app close instead of trapping the user in a blocked quit. - bypassMacInstallGuardOnce = true + bypassMacInstallGuardUntilNextAttempt = true app.quit() }, MAC_INSTALL_READY_TIMEOUT_MS) @@ -181,14 +203,24 @@ export function handleMacInstallerReady( squirrelReady = true clearPendingInstallTimeout() recordUpdaterLifecycle('macos_installer_ready', { - deferredInstallRequested: installRequestedAfterSquirrelReady, + deferredInstallRequested: requestedActionAfterSquirrelReady === 'install', + deferredQuitRequested: requestedActionAfterSquirrelReady === 'quit', hasNewerDownloadedVersion }) - if (installRequestedAfterSquirrelReady && hasNewerDownloadedVersion) { + if (requestedActionAfterSquirrelReady === 'quit') { + requestedActionAfterSquirrelReady = null + app.quit() + return + } + + if (requestedActionAfterSquirrelReady === 'install' && hasNewerDownloadedVersion) { + setMacInstallPreflightInProgress(true) void Promise.resolve() .then(() => onReadyToInstall()) .catch((error) => { + requestedActionAfterSquirrelReady = null + setMacInstallPreflightInProgress(false) recordUpdaterLifecycle( 'macos_deferred_install_handoff_failed', { errorType: error instanceof Error ? error.name : typeof error }, @@ -198,6 +230,7 @@ export function handleMacInstallerReady( return } + requestedActionAfterSquirrelReady = null if (hasNewerDownloadedVersion) { onReadyToReportDownloaded() } diff --git a/src/main/updater-mac-quit-guard.test.ts b/src/main/updater-mac-quit-guard.test.ts new file mode 100644 index 00000000000..2819b328c8a --- /dev/null +++ b/src/main/updater-mac-quit-guard.test.ts @@ -0,0 +1,253 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { appMock, nativeUpdaterMock } = await vi.hoisted(async () => { + const { EventEmitter } = await import('node:events') + return { + appMock: Object.assign(new EventEmitter(), { quit: vi.fn() }), + nativeUpdaterMock: new EventEmitter() + } +}) + +vi.mock('electron', () => ({ app: appMock, autoUpdater: nativeUpdaterMock })) +vi.mock('./updater-lifecycle-diagnostics', () => ({ recordUpdaterLifecycle: vi.fn() })) + +import { + beginMacUpdateDownload, + deferMacQuitUntilInstallerReady, + handleMacInstallerReady, + isMacInstallRequested, + markMacQuitAndInstallInFlight, + registerMacUpdaterEvents, + resetMacInstallState, + setMacInstallPreflightInProgress +} from './updater-mac-install' + +function quitEvent(): { defaultPrevented: boolean; preventDefault: () => void } { + const event = { + defaultPrevented: false, + preventDefault: () => { + event.defaultPrevented = true + } + } + return event +} + +function registerGuard( + hasUpdate: boolean, + performQuitAndInstall = vi.fn(), + status: 'downloaded' | 'downloading' = 'downloaded' +): void { + registerMacUpdaterEvents({ + getCurrentStatus: () => + status === 'downloaded' + ? { state: 'downloaded', version: '2.0.0' } + : { state: 'downloading', percent: 100, version: '2.0.0' }, + hasInstallableDownloadedVersion: () => hasUpdate, + getPendingInstallVersion: () => '2.0.0', + getKnownReleaseUrl: () => undefined, + performQuitAndInstall, + shouldDeferMacQuitForInstall: () => true, + sendStatus: vi.fn() + }) +} + +describe.runIf(process.platform === 'darwin')('macOS quit guard ordering', () => { + beforeEach(() => { + appMock.removeAllListeners() + nativeUpdaterMock.removeAllListeners() + beginMacUpdateDownload() + appMock.quit.mockReset() + }) + + afterEach(() => { + resetMacInstallState() + vi.useRealTimers() + }) + + it('resumes an ordinary quit after native readiness without starting a relaunching install', () => { + const install = vi.fn() + registerGuard(true, install, 'downloading') + const firstQuit = quitEvent() + appMock.emit('before-quit', firstQuit) + expect(firstQuit.defaultPrevented).toBe(true) + + nativeUpdaterMock.emit('update-downloaded') + + expect(appMock.quit).toHaveBeenCalledOnce() + expect(install).not.toHaveBeenCalled() + for (let pass = 0; pass < 2; pass++) { + const resumedQuit = quitEvent() + appMock.emit('before-quit', resumedQuit) + expect(resumedQuit.defaultPrevented).toBe(false) + } + }) + + it('keeps an explicit deferred install when an ordinary quit arrives afterward', async () => { + const install = vi.fn() + registerGuard(true, install, 'downloading') + expect( + deferMacQuitUntilInstallerReady( + { state: 'downloading', percent: 100, version: '2.0.0' }, + true, + () => '2.0.0', + vi.fn() + ) + ).toBe(true) + const ordinaryQuit = quitEvent() + appMock.emit('before-quit', ordinaryQuit) + expect(ordinaryQuit.defaultPrevented).toBe(true) + nativeUpdaterMock.emit('update-downloaded') + const quitBeforeInstallCallback = quitEvent() + appMock.emit('before-quit', quitBeforeInstallCallback) + expect(quitBeforeInstallCallback.defaultPrevented).toBe(true) + await Promise.resolve() + + expect(install).toHaveBeenCalledOnce() + expect(appMock.quit).not.toHaveBeenCalled() + }) + + it('releases the requested install when readiness has no installable version', () => { + const install = vi.fn() + registerGuard(true, install, 'downloading') + deferMacQuitUntilInstallerReady( + { state: 'downloading', percent: 100, version: '2.0.0' }, + true, + () => '2.0.0', + vi.fn() + ) + expect(isMacInstallRequested()).toBe(true) + handleMacInstallerReady(false, install, vi.fn()) + expect(isMacInstallRequested()).toBe(false) + expect(install).not.toHaveBeenCalled() + }) + + it('releases the readiness handoff guard when the install callback rejects', async () => { + registerGuard(true, vi.fn(), 'downloading') + deferMacQuitUntilInstallerReady( + { state: 'downloading', percent: 100, version: '2.0.0' }, + true, + () => '2.0.0', + vi.fn() + ) + handleMacInstallerReady( + true, + () => { + throw new Error('handoff rejected') + }, + vi.fn() + ) + await Promise.resolve() + await Promise.resolve() + expect(isMacInstallRequested()).toBe(false) + const ordinaryQuit = quitEvent() + appMock.emit('before-quit', ordinaryQuit) + expect(ordinaryQuit.defaultPrevented).toBe(false) + }) + + it('allows both timeout shutdown passes and revokes that allowance for a new deferred install', async () => { + vi.useFakeTimers() + const install = vi.fn() + registerGuard(true, install, 'downloading') + appMock.emit('before-quit', quitEvent()) + await vi.advanceTimersByTimeAsync(15_000) + expect(appMock.quit).toHaveBeenCalledOnce() + for (let pass = 0; pass < 2; pass++) { + const timeoutQuit = quitEvent() + appMock.emit('before-quit', timeoutQuit) + expect(timeoutQuit.defaultPrevented).toBe(false) + } + + deferMacQuitUntilInstallerReady( + { state: 'downloading', percent: 100, version: '2.0.0' }, + true, + () => '2.0.0', + vi.fn() + ) + const retryQuit = quitEvent() + appMock.emit('before-quit', retryQuit) + expect(retryQuit.defaultPrevented).toBe(true) + nativeUpdaterMock.emit('update-downloaded') + await Promise.resolve() + expect(install).toHaveBeenCalledOnce() + }) + + it('vetoes a quit during install preflight before previously registered startup services shut down', () => { + const shutdown = vi.fn() + appMock.on('before-quit', (event) => { + if (!event.defaultPrevented) { + shutdown() + } + }) + registerGuard(true) + handleMacInstallerReady(true, vi.fn(), vi.fn()) + setMacInstallPreflightInProgress(true) + const event = quitEvent() + + appMock.emit('before-quit', event) + + expect(event.defaultPrevented).toBe(true) + expect(shutdown).not.toHaveBeenCalled() + }) + + it('lets an ordinary quit with a staged update exit instead of converting it into a relaunching install', () => { + // Why: restart flows call app.relaunch() then app.quit(); converting that quit into + // quitAndInstall would race the relaunched old app against ShipIt. + const install = vi.fn() + registerGuard(true, install) + handleMacInstallerReady(true, vi.fn(), vi.fn()) + const event = quitEvent() + + appMock.emit('before-quit', event) + + expect(event.defaultPrevented).toBe(false) + expect(install).not.toHaveBeenCalled() + }) + + it('vetoes duplicate quits through cleanup and allows the native install shutdown', () => { + registerGuard(true) + handleMacInstallerReady(true, vi.fn(), vi.fn()) + setMacInstallPreflightInProgress(true) + markMacQuitAndInstallInFlight() + for (let attempt = 0; attempt < 2; attempt++) { + const event = quitEvent() + appMock.emit('before-quit', event) + expect(event.defaultPrevented).toBe(true) + } + + setMacInstallPreflightInProgress(false) + const nativeQuit = quitEvent() + appMock.emit('before-quit', nativeQuit) + expect(nativeQuit.defaultPrevented).toBe(false) + }) + + it('allows both ordinary quit passes after refusal and vetoes a new install attempt', () => { + const install = vi.fn() + registerGuard(true, install) + handleMacInstallerReady(true, vi.fn(), vi.fn()) + resetMacInstallState() + const normalQuit = quitEvent() + appMock.emit('before-quit', normalQuit) + expect(normalQuit.defaultPrevented).toBe(false) + expect(install).not.toHaveBeenCalled() + + const teardownQuit = quitEvent() + appMock.emit('before-quit', teardownQuit) + expect(teardownQuit.defaultPrevented).toBe(false) + expect(install).not.toHaveBeenCalled() + + setMacInstallPreflightInProgress(true) + const retryQuit = quitEvent() + appMock.emit('before-quit', retryQuit) + expect(retryQuit.defaultPrevented).toBe(true) + expect(install).not.toHaveBeenCalled() + }) + + it('allows ordinary quits when no update is available', () => { + const install = vi.fn() + registerGuard(false, install) + const event = quitEvent() + appMock.emit('before-quit', event) + expect(event.defaultPrevented).toBe(false) + expect(install).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/updater-test-harness.ts b/src/main/updater-test-harness.ts index 83379b7ac9b..955ae25058a 100644 --- a/src/main/updater-test-harness.ts +++ b/src/main/updater-test-harness.ts @@ -1,3 +1,4 @@ +import { EventEmitter } from 'node:events' import { afterAll, vi } from 'vitest' import type { Mock } from 'vitest' import { clearTrackedRealTimers, trackRealTimers } from './updater-test-timer-tracking' @@ -28,6 +29,7 @@ type AppMock = { isPackaged: boolean getVersion: Mock<() => string> on: Mock<(event: string, handler: (...args: unknown[]) => void) => AppMock> + prependListener: Mock<(event: string, handler: (...args: unknown[]) => void) => AppMock> emit: (event: string, ...args: unknown[]) => void quit: UpdaterSpy } @@ -102,33 +104,25 @@ export const PRE_COMMIT_INSTALL_FAILURE = * `vi.hoisted` block so the mocks exist before the mock factories run. */ export function createUpdaterMocks(): UpdaterMocks { - const appEventHandlers = new Map void)[]>() - const eventHandlers = new Map void)[]>() - + const appEvents = new EventEmitter() + const updaterEvents = new EventEmitter() const appOn = vi.fn((event: string, handler: (...args: unknown[]) => void) => { - const handlers = appEventHandlers.get(event) ?? [] - handlers.push(handler) - appEventHandlers.set(event, handlers) + appEvents.on(event, handler) return appMock }) - - const appEmit = (event: string, ...args: unknown[]) => { - for (const handler of appEventHandlers.get(event) ?? []) { - handler(...args) - } + const appPrependListener = vi.fn((event: string, handler: (...args: unknown[]) => void) => { + appEvents.prependListener(event, handler) + return appMock + }) + const appEmit = (event: string, ...args: unknown[]): void => { + appEvents.emit(event, ...args) } - const on = vi.fn((event: string, handler: (...args: unknown[]) => void) => { - const handlers = eventHandlers.get(event) ?? [] - handlers.push(handler) - eventHandlers.set(event, handlers) + updaterEvents.on(event, handler) return autoUpdaterMock }) - - const emit = (event: string, ...args: unknown[]) => { - for (const handler of eventHandlers.get(event) ?? []) { - handler(...args) - } + const emit = (event: string, ...args: unknown[]): void => { + updaterEvents.emit(event, ...args) } // Why: `vi.resetModules()` abandons the previous test's `updater` module instance but cannot cancel @@ -161,9 +155,10 @@ export function createUpdaterMocks(): UpdaterMocks { const reset = () => { currentGeneration += 1 - appEventHandlers.clear() + appEvents.removeAllListeners() appOn.mockClear() - eventHandlers.clear() + appPrependListener.mockClear() + updaterEvents.removeAllListeners() on.mockClear() autoUpdaterMock.checkForUpdates.mockReset().mockResolvedValue(null) autoUpdaterMock.downloadUpdate.mockReset() @@ -201,6 +196,7 @@ export function createUpdaterMocks(): UpdaterMocks { isPackaged: true, getVersion: vi.fn(() => '1.0.51'), on: appOn, + prependListener: appPrependListener, emit: appEmit, quit: vi.fn() } diff --git a/src/main/updater.check-failure.test.ts b/src/main/updater.check-failure.test.ts index 62d228bf3a7..5093052290f 100644 --- a/src/main/updater.check-failure.test.ts +++ b/src/main/updater.check-failure.test.ts @@ -20,6 +20,13 @@ const { appMock, browserWindowMock, nativeUpdaterMock, autoUpdaterMock, isMock, return appMock }) + const appPrependListener = vi.fn((event: string, handler: (...args: unknown[]) => void) => { + const handlers = appEventHandlers.get(event) ?? [] + handlers.unshift(handler) + appEventHandlers.set(event, handlers) + return appMock + }) + const on = vi.fn((event: string, handler: (...args: unknown[]) => void) => { const handlers = eventHandlers.get(event) ?? [] handlers.push(handler) @@ -36,6 +43,7 @@ const { appMock, browserWindowMock, nativeUpdaterMock, autoUpdaterMock, isMock, const reset = () => { appEventHandlers.clear() appOn.mockClear() + appPrependListener.mockClear() eventHandlers.clear() on.mockClear() autoUpdaterMock.checkForUpdates.mockReset() @@ -61,6 +69,7 @@ const { appMock, browserWindowMock, nativeUpdaterMock, autoUpdaterMock, isMock, isPackaged: true, getVersion: vi.fn(() => '1.0.51'), on: appOn, + prependListener: appPrependListener, quit: vi.fn() }, browserWindowMock: { diff --git a/src/main/updater.check-preflight.test.ts b/src/main/updater.check-preflight.test.ts index 6b2618aad05..5e1476e8fb9 100644 --- a/src/main/updater.check-preflight.test.ts +++ b/src/main/updater.check-preflight.test.ts @@ -4,6 +4,7 @@ import { loadUpdaterModule, warmUpdaterModule } from './updater-test-module-load const { appMock, autoUpdaterMock, + nativeUpdaterMock, fetchChangelogMock, fetchNewerReleaseTagsMock, moduleFactories, @@ -31,6 +32,93 @@ describe('updater', () => { resetUpdaterMocks() }) + it('keeps the staged target when installation cancels a queued background feed check', async () => { + vi.useFakeTimers() + let resolveQueuedTags: (value: { tags: string[]; state: 'ready' }) => void = () => {} + fetchNewerReleaseTagsMock + .mockResolvedValueOnce({ tags: ['v1.0.61'], state: 'ready' }) + .mockImplementationOnce( + () => + new Promise<{ tags: string[]; state: 'ready' }>((resolve) => { + resolveQueuedTags = resolve + }) + ) + autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined) + autoUpdaterMock.downloadUpdate.mockResolvedValue([]) + let rejectCleanup: (error: Error) => void = () => {} + const onBeforeQuit = vi.fn( + () => + new Promise((_resolve, reject) => { + rejectCleanup = reject + }) + ) + const send = vi.fn() + const { + setupAutoUpdater, + checkForUpdatesFromMenu, + checkForUpdates, + downloadUpdate, + quitAndInstall, + getUpdateStatus + } = await loadUpdaterModule() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The updater reads only webContents.send from this window fixture. + setupAutoUpdater({ webContents: { send } } as never, { + getLastUpdateCheckAt: () => Date.now(), + onBeforeQuit, + onBeforeQuitFailure: 'abort' + }) + checkForUpdatesFromMenu() + await vi.waitFor(() => expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledOnce()) + autoUpdaterMock.emit('checking-for-update') + autoUpdaterMock.emit('update-available', { version: '1.0.61' }) + await vi.advanceTimersByTimeAsync(0) + downloadUpdate() + autoUpdaterMock.emit('update-downloaded', { version: '1.0.61' }) + const nativeReady = nativeUpdaterMock.on.mock.calls.find( + ([event]) => event === 'update-downloaded' + )?.[1] + if (typeof nativeReady === 'function') { + nativeReady() + } + expect(getUpdateStatus()).toEqual( + expect.objectContaining({ + state: 'downloaded', + version: '1.0.61' + }) + ) + const stagedFeed = autoUpdaterMock.setFeedURL.mock.calls.at(-1) + + checkForUpdates() + await vi.waitFor(() => expect(fetchNewerReleaseTagsMock).toHaveBeenCalledTimes(2)) + quitAndInstall() + await vi.advanceTimersByTimeAsync(100) + expect(onBeforeQuit).toHaveBeenCalledOnce() + resolveQueuedTags({ tags: ['v1.0.71'], state: 'ready' }) + await vi.advanceTimersByTimeAsync(1000) + + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledOnce() + expect(autoUpdaterMock.setFeedURL.mock.calls.at(-1)).toEqual(stagedFeed) + expect(getUpdateStatus()).toEqual( + expect.objectContaining({ + state: 'downloaded', + version: '1.0.61' + }) + ) + expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() + + rejectCleanup(new Error('required checkpoint failed')) + await vi.advanceTimersByTimeAsync(0) + expect(getUpdateStatus().state).toBe('error') + autoUpdaterMock.downloadUpdate.mockClear() + downloadUpdate() + expect(autoUpdaterMock.downloadUpdate).toHaveBeenCalledOnce() + expect(send).toHaveBeenCalledWith('updater:status', { + state: 'downloading', + percent: 0, + version: '1.0.61' + }) + }) + it('ignores stale updater events while a new check is still in feed preflight', async () => { vi.useFakeTimers() let resolveSecondTags: (value: { tags: string[]; state: 'no-newer' }) => void = () => {} diff --git a/src/main/updater.fallback.test.ts b/src/main/updater.fallback.test.ts index 767fef421e3..0b2912bcced 100644 --- a/src/main/updater.fallback.test.ts +++ b/src/main/updater.fallback.test.ts @@ -101,6 +101,7 @@ describe('statusesEqual', () => { expect(statusesEqual(error, { ...error, version: '1.0.62' })).toBe(false) expect(statusesEqual(error, { ...error, retryable: true })).toBe(false) + expect(statusesEqual(error, { ...error, retryAction: 'install' })).toBe(false) expect(statusesEqual(error, { ...error })).toBe(true) }) }) diff --git a/src/main/updater.headless-serve-install.test.ts b/src/main/updater.headless-serve-install.test.ts index bae6474cc66..ebf7fb6269b 100644 --- a/src/main/updater.headless-serve-install.test.ts +++ b/src/main/updater.headless-serve-install.test.ts @@ -31,6 +31,10 @@ const { appHandlers.set(event, [...(appHandlers.get(event) ?? []), handler]) return appMock }), + prependListener: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + appHandlers.set(event, [handler, ...(appHandlers.get(event) ?? [])]) + return appMock + }), emit: (event: string, ...args: unknown[]) => emit(appHandlers, event, ...args), quit: vi.fn() } diff --git a/src/main/updater.install-failure-cause.test.ts b/src/main/updater.install-failure-cause.test.ts index bef63d6912a..95b539ca00e 100644 --- a/src/main/updater.install-failure-cause.test.ts +++ b/src/main/updater.install-failure-cause.test.ts @@ -23,6 +23,13 @@ const { return appMock }) + const appPrependListener = vi.fn((event: string, handler: (...args: unknown[]) => void) => { + const handlers = appEventHandlers.get(event) ?? [] + handlers.unshift(handler) + appEventHandlers.set(event, handlers) + return appMock + }) + const on = vi.fn((event: string, handler: (...args: unknown[]) => void) => { const handlers = eventHandlers.get(event) ?? [] handlers.push(handler) @@ -39,6 +46,7 @@ const { const reset = () => { appEventHandlers.clear() appOn.mockClear() + appPrependListener.mockClear() eventHandlers.clear() on.mockClear() autoUpdaterMock.checkForUpdates.mockReset() @@ -64,6 +72,7 @@ const { isPackaged: true, getVersion: vi.fn(() => '1.4.162'), on: appOn, + prependListener: appPrependListener, quit: vi.fn(), exit: vi.fn() }, diff --git a/src/main/updater.mac-install.test.ts b/src/main/updater.mac-install.test.ts index 563b8562fd6..a3faef056ef 100644 --- a/src/main/updater.mac-install.test.ts +++ b/src/main/updater.mac-install.test.ts @@ -8,7 +8,8 @@ const { autoUpdaterMock, shellMock, isMock, - killAllPtyMock + killAllPtyMock, + getMacUpdateRunningInstancesMock } = vi.hoisted(() => { const appEventHandlers = new Map void)[]>() const eventHandlers = new Map void)[]>() @@ -66,6 +67,9 @@ const { isPackaged: true, getVersion: vi.fn(() => '1.0.51'), on: appOn, + prependListener: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + appEventHandlers.set(event, [handler, ...(appEventHandlers.get(event) ?? [])]) + }), emit: appEmit, quit: vi.fn() }, @@ -80,6 +84,7 @@ const { openExternal: vi.fn() }, isMock: { dev: false }, + getMacUpdateRunningInstancesMock: vi.fn(async (): Promise => []), killAllPtyMock: vi.fn() } }) @@ -118,8 +123,25 @@ vi.mock('./updater-nudge', () => ({ shouldApplyNudge: vi.fn().mockReturnValue(false) })) +vi.mock('./macos-update-running-instances', () => ({ + getMacUpdateRunningInstances: getMacUpdateRunningInstancesMock +})) + warmUpdaterModule() +async function prepareStagedMacUpdate(downloadUpdate: () => void): Promise { + await vi.waitFor(() => expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)) + autoUpdaterMock.emit('checking-for-update') + autoUpdaterMock.emit('update-available', { version: '1.0.61' }) + await vi.advanceTimersByTimeAsync(0) + downloadUpdate() + autoUpdaterMock.emit('update-downloaded', { version: '1.0.61' }) + const nativeReady = nativeUpdaterMock.on.mock.calls.find( + ([event]) => event === 'update-downloaded' + )?.[1] + nativeReady?.() +} + describe('updater mac install handoff', () => { beforeEach(() => { vi.resetModules() @@ -134,18 +156,21 @@ describe('updater mac install handoff', () => { appMock.isPackaged = true isMock.dev = false killAllPtyMock.mockReset() + getMacUpdateRunningInstancesMock.mockReset().mockResolvedValue([]) autoUpdaterMock.downloadUpdate.mockResolvedValue([]) vi.unstubAllGlobals() vi.useRealTimers() }) it.runIf(process.platform === 'darwin')( - 'waits for Squirrel.Mac before honoring a manual quit that should install the update', + 'resumes an ordinary quit when Squirrel becomes ready without checking blockers or relaunching', async () => { + vi.useFakeTimers() const sendMock = vi.fn() const mainWindow = { webContents: { send: sendMock } } autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined) + getMacUpdateRunningInstancesMock.mockResolvedValue([9654]) const { setupAutoUpdater, downloadUpdate } = await loadUpdaterModule() setupAutoUpdater(mainWindow as never) @@ -156,7 +181,7 @@ describe('updater mac install handoff', () => { autoUpdaterMock.emit('update-available', { version: '1.0.61' }) // Why: the update-available handler is now async (it awaits fetchChangelog). // Flush microtasks so setAvailableVersion runs before update-downloaded fires. - await new Promise((r) => setTimeout(r, 0)) + await vi.advanceTimersByTimeAsync(0) downloadUpdate() autoUpdaterMock.emit('update-downloaded', { version: '1.0.61' }) @@ -173,9 +198,14 @@ describe('updater mac install handoff', () => { nativeDownloadedHandler?.() - await vi.waitFor(() => { - expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledWith(false, true) - }) + await vi.advanceTimersByTimeAsync(0) + expect(appMock.quit).toHaveBeenCalledOnce() + expect(getMacUpdateRunningInstancesMock).not.toHaveBeenCalled() + expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() + const resumedPreventDefault = vi.fn() + appMock.emit('before-quit', { preventDefault: resumedPreventDefault }) + appMock.emit('before-quit', { preventDefault: resumedPreventDefault }) + expect(resumedPreventDefault).not.toHaveBeenCalled() expect(sendMock).toHaveBeenCalledWith('updater:status', { state: 'downloading', percent: 100, @@ -184,6 +214,60 @@ describe('updater mac install handoff', () => { } ) + it.runIf(process.platform === 'darwin')( + 'checks blockers for an explicit install requested before Squirrel becomes ready', + async () => { + vi.useFakeTimers() + const send = vi.fn() + autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined) + getMacUpdateRunningInstancesMock.mockResolvedValue([9654]) + const { + setupAutoUpdater, + downloadUpdate, + quitAndInstall, + isQuittingForUpdate, + checkForUpdates, + checkForUpdatesFromMenu, + getUpdateStatus + } = await loadUpdaterModule() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The updater reads only webContents.send from this window fixture. + setupAutoUpdater({ webContents: { send } } as never) + await vi.waitFor(() => expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledOnce()) + autoUpdaterMock.emit('checking-for-update') + autoUpdaterMock.emit('update-available', { version: '1.0.61' }) + await vi.advanceTimersByTimeAsync(0) + downloadUpdate() + autoUpdaterMock.emit('update-downloaded', { version: '1.0.61' }) + quitAndInstall() + checkForUpdatesFromMenu() + checkForUpdatesFromMenu({ localBuild: true }) + checkForUpdatesFromMenu({ channel: 'stable', targetTag: 'v1.0.70' }) + checkForUpdates() + downloadUpdate() + await vi.advanceTimersByTimeAsync(0) + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledOnce() + expect(autoUpdaterMock.downloadUpdate).toHaveBeenCalledOnce() + expect(getUpdateStatus()).toEqual( + expect.objectContaining({ state: 'downloading', percent: 100, version: '1.0.61' }) + ) + const nativeReady = nativeUpdaterMock.on.mock.calls.find( + ([event]) => event === 'update-downloaded' + )?.[1] + nativeReady?.() + await vi.advanceTimersByTimeAsync(0) + + expect(getMacUpdateRunningInstancesMock).toHaveBeenCalledOnce() + expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() + expect(appMock.quit).not.toHaveBeenCalled() + expect(isQuittingForUpdate()).toBe(false) + expect(send).toHaveBeenCalledWith('updater:quitAndInstallAborted') + expect(send).toHaveBeenCalledWith( + 'updater:status', + expect.objectContaining({ state: 'error', version: '1.0.61', retryAction: 'install' }) + ) + } + ) + it.runIf(process.platform === 'darwin')( 'ignores duplicate quit requests while deferred mac install cleanup is running', async () => { @@ -211,6 +295,7 @@ describe('updater mac install handoff', () => { downloadUpdate() autoUpdaterMock.emit('update-downloaded', { version: '1.0.61' }) + quitAndInstall() const preventDefault = vi.fn() appMock.emit('before-quit', { preventDefault }) expect(preventDefault).toHaveBeenCalledTimes(1) @@ -224,6 +309,7 @@ describe('updater mac install handoff', () => { await vi.advanceTimersByTimeAsync(0) expect(onBeforeQuit).toHaveBeenCalledTimes(1) + expect(getMacUpdateRunningInstancesMock).toHaveBeenCalledOnce() expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() quitAndInstall() @@ -309,7 +395,305 @@ describe('updater mac install handoff', () => { const secondPreventDefault = vi.fn() appMock.emit('before-quit', { preventDefault: secondPreventDefault }) expect(secondPreventDefault).not.toHaveBeenCalled() + appMock.emit('before-quit', { preventDefault: secondPreventDefault }) + expect(secondPreventDefault).not.toHaveBeenCalled() expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() } ) + + it.runIf(process.platform === 'darwin')( + 'keeps the app and terminals open when another bundle instance blocks installation, then allows retry', + async () => { + vi.useFakeTimers() + const send = vi.fn() + const onBeforeQuit = vi.fn() + autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined) + const { setupAutoUpdater, downloadUpdate, quitAndInstall, isQuittingForUpdate } = + await loadUpdaterModule() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The updater only reads webContents.send from this window fixture. + setupAutoUpdater({ webContents: { send } } as never, { onBeforeQuit }) + await prepareStagedMacUpdate(downloadUpdate) + getMacUpdateRunningInstancesMock.mockResolvedValue([9654, 10718]) + + quitAndInstall() + await vi.advanceTimersByTimeAsync(1000) + + expect(onBeforeQuit).not.toHaveBeenCalled() + expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() + expect(killAllPtyMock).not.toHaveBeenCalled() + expect(isQuittingForUpdate()).toBe(false) + expect(send).toHaveBeenCalledWith('updater:quitAndInstallAborted') + expect(send).toHaveBeenCalledWith( + 'updater:status', + expect.objectContaining({ + state: 'error', + message: expect.stringContaining('9654, 10718'), + retryAction: 'install' + }) + ) + + getMacUpdateRunningInstancesMock.mockResolvedValue([]) + quitAndInstall() + await vi.advanceTimersByTimeAsync(1000) + expect(onBeforeQuit).toHaveBeenCalledTimes(1) + expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1) + } + ) + + it.runIf(process.platform === 'darwin')( + 'keeps the app open when process enumeration fails', + async () => { + vi.useFakeTimers() + const send = vi.fn() + const onBeforeQuit = vi.fn() + autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined) + const { setupAutoUpdater, downloadUpdate, quitAndInstall, isQuittingForUpdate } = + await loadUpdaterModule() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The updater only reads webContents.send from this window fixture. + setupAutoUpdater({ webContents: { send } } as never, { onBeforeQuit }) + await prepareStagedMacUpdate(downloadUpdate) + getMacUpdateRunningInstancesMock.mockRejectedValue(new Error('probe failed')) + quitAndInstall() + await vi.advanceTimersByTimeAsync(1000) + expect(onBeforeQuit).not.toHaveBeenCalled() + expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() + expect(isQuittingForUpdate()).toBe(false) + expect(send).toHaveBeenCalledWith('updater:quitAndInstallAborted') + expect(send).toHaveBeenCalledWith( + 'updater:status', + expect.objectContaining({ + state: 'error', + message: expect.stringContaining('Could not check'), + retryAction: 'install' + }) + ) + expect(send).toHaveBeenCalledWith( + 'updater:status', + expect.objectContaining({ + message: expect.stringMatching(/close the other Orca instances.*quit Orca/i) + }) + ) + } + ) + + it.runIf(process.platform === 'darwin')( + 'leaves an ordinary quit with a staged update to Squirrel instead of starting a relaunching install', + async () => { + vi.useFakeTimers() + const onBeforeQuit = vi.fn() + autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined) + const { setupAutoUpdater, downloadUpdate } = await loadUpdaterModule() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The updater only reads webContents.send from this window fixture. + setupAutoUpdater({ webContents: { send: vi.fn() } } as never, { onBeforeQuit }) + await prepareStagedMacUpdate(downloadUpdate) + const preventDefault = vi.fn() + appMock.emit('before-quit', { preventDefault }) + appMock.emit('before-quit', { preventDefault }) + await vi.advanceTimersByTimeAsync(0) + expect(preventDefault).not.toHaveBeenCalled() + expect(getMacUpdateRunningInstancesMock).not.toHaveBeenCalled() + expect(onBeforeQuit).not.toHaveBeenCalled() + expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() + } + ) + + it.runIf(process.platform === 'darwin')( + 'blocks duplicate quits while the running-instance check is pending', + async () => { + vi.useFakeTimers() + const onBeforeQuit = vi.fn() + autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined) + const { setupAutoUpdater, downloadUpdate, quitAndInstall } = await loadUpdaterModule() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The updater only reads webContents.send from this window fixture. + setupAutoUpdater({ webContents: { send: vi.fn() } } as never, { onBeforeQuit }) + await prepareStagedMacUpdate(downloadUpdate) + + let finishProbe: (pids: number[]) => void = () => {} + getMacUpdateRunningInstancesMock.mockImplementation( + () => + new Promise((resolve) => { + finishProbe = resolve + }) + ) + quitAndInstall() + await vi.advanceTimersByTimeAsync(1000) + const preventDefault = vi.fn() + appMock.emit('before-quit', { preventDefault }) + appMock.emit('before-quit', { preventDefault }) + expect(preventDefault).toHaveBeenCalledTimes(2) + expect(getMacUpdateRunningInstancesMock).toHaveBeenCalledTimes(1) + finishProbe([9654]) + await vi.advanceTimersByTimeAsync(0) + expect(onBeforeQuit).not.toHaveBeenCalled() + expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() + } + ) + + it.runIf(process.platform === 'darwin')( + 'allows ordinary quitting after a refused install without requiring background servers to exit', + async () => { + vi.useFakeTimers() + autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined) + const { setupAutoUpdater, downloadUpdate, quitAndInstall } = await loadUpdaterModule() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The updater only reads webContents.send from this window fixture. + setupAutoUpdater({ webContents: { send: vi.fn() } } as never) + await prepareStagedMacUpdate(downloadUpdate) + getMacUpdateRunningInstancesMock.mockResolvedValue([9654]) + quitAndInstall() + await vi.advanceTimersByTimeAsync(1000) + const preventDefault = vi.fn() + appMock.emit('before-quit', { preventDefault }) + expect(preventDefault).not.toHaveBeenCalled() + // The main will-quit handler requests quit again after asynchronous teardown. + appMock.emit('before-quit', { preventDefault }) + expect(preventDefault).not.toHaveBeenCalled() + expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() + } + ) + + it.runIf(process.platform === 'darwin')( + 'prevents duplicate quits until asynchronous update cleanup finishes', + async () => { + vi.useFakeTimers() + let finishCleanup = (): void => {} + const onBeforeQuit = vi.fn( + () => + new Promise((resolve) => { + finishCleanup = resolve + }) + ) + autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined) + const { setupAutoUpdater, downloadUpdate, quitAndInstall } = await loadUpdaterModule() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The updater only reads webContents.send from this window fixture. + setupAutoUpdater({ webContents: { send: vi.fn() } } as never, { onBeforeQuit }) + await prepareStagedMacUpdate(downloadUpdate) + quitAndInstall() + await vi.advanceTimersByTimeAsync(1000) + expect(onBeforeQuit).toHaveBeenCalledTimes(1) + const preventDefault = vi.fn() + appMock.emit('before-quit', { preventDefault }) + expect(preventDefault).toHaveBeenCalledTimes(1) + expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() + finishCleanup() + await vi.advanceTimersByTimeAsync(0) + const nativePreventDefault = vi.fn() + appMock.emit('before-quit', { preventDefault: nativePreventDefault }) + expect(nativePreventDefault).not.toHaveBeenCalled() + expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1) + } + ) + + it.runIf(process.platform === 'darwin')( + 'cannot use a previous refusal to bypass a pending retry check', + async () => { + vi.useFakeTimers() + autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined) + const { setupAutoUpdater, downloadUpdate, quitAndInstall } = await loadUpdaterModule() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The updater only reads webContents.send from this window fixture. + setupAutoUpdater({ webContents: { send: vi.fn() } } as never) + await prepareStagedMacUpdate(downloadUpdate) + getMacUpdateRunningInstancesMock.mockResolvedValue([9654]) + quitAndInstall() + await vi.advanceTimersByTimeAsync(1000) + let finishProbe = (_pids: number[]): void => {} + getMacUpdateRunningInstancesMock.mockImplementation( + () => + new Promise((resolve) => { + finishProbe = resolve + }) + ) + quitAndInstall() + const preventDefault = vi.fn() + appMock.emit('before-quit', { preventDefault }) + expect(preventDefault).toHaveBeenCalledOnce() + await vi.advanceTimersByTimeAsync(1000) + appMock.emit('before-quit', { preventDefault }) + expect(preventDefault).toHaveBeenCalledTimes(2) + expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() + finishProbe([]) + await vi.advanceTimersByTimeAsync(0) + expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledOnce() + } + ) + + it.runIf(process.platform === 'darwin')( + 'permits ordinary shutdown after required update cleanup rejects', + async () => { + vi.useFakeTimers() + autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined) + const onBeforeQuit = vi.fn().mockRejectedValue(new Error('required cleanup failed')) + const { setupAutoUpdater, downloadUpdate, quitAndInstall } = await loadUpdaterModule() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The updater only reads webContents.send from this window fixture. + setupAutoUpdater({ webContents: { send: vi.fn() } } as never, { + onBeforeQuit, + onBeforeQuitFailure: 'abort' + }) + await prepareStagedMacUpdate(downloadUpdate) + quitAndInstall() + await vi.advanceTimersByTimeAsync(1000) + expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() + const preventDefault = vi.fn() + appMock.emit('before-quit', { preventDefault }) + appMock.emit('before-quit', { preventDefault }) + expect(preventDefault).not.toHaveBeenCalled() + expect(onBeforeQuit).toHaveBeenCalledOnce() + } + ) + + it.runIf(process.platform === 'darwin')( + 'keeps the install target and quit guard when checks or downloads are requested during a retry', + async () => { + vi.useFakeTimers() + autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined) + const { + setupAutoUpdater, + downloadUpdate, + quitAndInstall, + checkForUpdates, + checkForUpdatesFromMenu, + getUpdateStatus + } = await loadUpdaterModule() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The updater only reads webContents.send from this window fixture. + setupAutoUpdater({ webContents: { send: vi.fn() } } as never) + await prepareStagedMacUpdate(downloadUpdate) + getMacUpdateRunningInstancesMock.mockResolvedValue([9654]) + quitAndInstall() + await vi.advanceTimersByTimeAsync(1000) + let finishProbe = (_pids: number[]): void => {} + getMacUpdateRunningInstancesMock.mockImplementation( + () => + new Promise((resolve) => { + finishProbe = resolve + }) + ) + const failedStatus = getUpdateStatus() + const checks = autoUpdaterMock.checkForUpdates.mock.calls.length + const downloads = autoUpdaterMock.downloadUpdate.mock.calls.length + autoUpdaterMock.checkForUpdates.mockImplementation(() => { + autoUpdaterMock.emit('checking-for-update') + return Promise.resolve(undefined) + }) + const requestCompetingActions = (): void => { + checkForUpdatesFromMenu() + checkForUpdatesFromMenu({ localBuild: true }) + checkForUpdatesFromMenu({ channel: 'stable', targetTag: 'v1.0.70' }) + checkForUpdates() + downloadUpdate() + } + quitAndInstall() + requestCompetingActions() + await vi.advanceTimersByTimeAsync(1000) + requestCompetingActions() + await vi.advanceTimersByTimeAsync(0) + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(checks) + expect(autoUpdaterMock.downloadUpdate).toHaveBeenCalledTimes(downloads) + expect(getUpdateStatus()).toEqual(failedStatus) + const preventDefault = vi.fn() + appMock.emit('before-quit', { preventDefault }) + expect(preventDefault).toHaveBeenCalledOnce() + finishProbe([]) + await vi.advanceTimersByTimeAsync(0) + expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledOnce() + } + ) }) diff --git a/src/main/updater/updater-download-install.ts b/src/main/updater/updater-download-install.ts index 455e56e6efa..8b672eedbbd 100644 --- a/src/main/updater/updater-download-install.ts +++ b/src/main/updater/updater-download-install.ts @@ -1,4 +1,9 @@ -import { beginMacUpdateDownload, deferMacQuitUntilInstallerReady } from '../updater-mac-install' +import { + beginMacUpdateDownload, + deferMacQuitUntilInstallerReady, + isMacInstallRequested, + setMacInstallPreflightInProgress +} from '../updater-mac-install' import { recordUpdaterLifecycle } from '../updater-lifecycle-diagnostics' import { isExternallyManagedLinuxInstall } from '../linux-update-package-type' import { LINUX_PACKAGE_EXTERNALLY_MANAGED_MESSAGE } from '../linux-package-downloaded-status' @@ -12,7 +17,8 @@ export abstract class UpdaterDownloadInstall extends UpdaterRemoteStatus { this.localBuildSelectionInProgress || this.pinnedBuildSelectionInProgress || this.pendingQuitAndInstallTimer || - this.quitAndInstallInProgress + this.quitAndInstallInProgress || + isMacInstallRequested() ) { return } @@ -20,6 +26,9 @@ export abstract class UpdaterDownloadInstall extends UpdaterRemoteStatus { if (this.deferHeadlessServeInstall('install', this.getPendingInstallVersion())) { return } + // A queued check must not repoint the feed while native staging or installation is pending. + this.finishActiveUpdateCheckAttempt() + this.clearBackgroundCheckLaunchPending() if ( deferMacQuitUntilInstallerReady( this.currentStatus, @@ -31,6 +40,9 @@ export abstract class UpdaterDownloadInstall extends UpdaterRemoteStatus { return } + if (process.platform === 'darwin') { + setMacInstallPreflightInProgress(true) + } // Why: defer the quit a tick so the renderer can flush dismissals/state before windows start closing. this.pendingQuitAndInstallTimer = setTimeout(() => { void this.performQuitAndInstall() @@ -41,6 +53,9 @@ export abstract class UpdaterDownloadInstall extends UpdaterRemoteStatus { if ( this.localBuildSelectionInProgress || this.pinnedBuildSelectionInProgress || + this.pendingQuitAndInstallTimer || + this.quitAndInstallInProgress || + isMacInstallRequested() || this.downloadInFlight ) { return diff --git a/src/main/updater/updater-install-execution.ts b/src/main/updater/updater-install-execution.ts index 257f8e4fa93..036fab4e119 100644 --- a/src/main/updater/updater-install-execution.ts +++ b/src/main/updater/updater-install-execution.ts @@ -2,7 +2,12 @@ import { BrowserWindow } from 'electron' import { killAllPty } from '../ipc/pty' import { withUpdaterSpan } from '../observability/instrumentation' import { runWithLaunchPath } from '../startup/hydrate-shell-path' -import { markMacQuitAndInstallInFlight, isMacInstallerReady } from '../updater-mac-install' +import { + markMacQuitAndInstallInFlight, + isMacInstallerReady, + setMacInstallPreflightInProgress +} from '../updater-mac-install' +import { getMacUpdateRunningInstances } from '../macos-update-running-instances' import { armUpdateInstallExitWatchdog } from '../update-install-exit-watchdog' import { getLinuxPackageType } from '../linux-update-package-type' import { LINUX_PACKAGE_MARKER_UNUSABLE_MESSAGE } from '../linux-package-downloaded-status' @@ -51,14 +56,49 @@ export abstract class UpdaterInstallExecution extends UpdaterPackageRecovery { }) return } + this.finishActiveUpdateCheckAttempt() + this.clearBackgroundCheckLaunchPending() this.quitAndInstallInProgress = true - markMacQuitAndInstallInFlight() - // Set BEFORE anything else so the `activate` handler doesn't reopen the old version while ShipIt replaces the .app bundle. this.quittingForUpdate = true try { + if (process.platform === 'darwin') { + setMacInstallPreflightInProgress(true) + let blockers: number[] + try { + blockers = await getMacUpdateRunningInstances() + } catch { + this.resetQuitForUpdateState() + this.mainWindowRef?.webContents.send('updater:quitAndInstallAborted') + this.sendInstallFailureStatus({ + state: 'error', + version: pendingVersion, + retryAction: 'install', + message: + 'Could not check for other running Orca instances. Orca remains open. Try again. If the check keeps failing, close the other Orca instances and background orca serve servers, then quit Orca to let the update install on exit. Reopen Orca afterwards.' + }) + recordUpdaterLifecycle('macos_running_instances_check_failed') + return + } + if (blockers.length > 0) { + this.resetQuitForUpdateState() + this.mainWindowRef?.webContents.send('updater:quitAndInstallAborted') + this.sendInstallFailureStatus({ + state: 'error', + version: pendingVersion, + retryAction: 'install', + message: `Close the other Orca instances (process IDs: ${blockers.slice(0, 10).join(', ')}) before installing this update. Background orca serve instances also need to stop. Orca remains open; retry the update after closing them.` + }) + recordUpdaterLifecycle('macos_install_blocked_by_running_instances', { + pids: blockers.slice(0, 10).join(', '), + count: blockers.length + }) + return + } + } + markMacQuitAndInstallInFlight() await withUpdaterSpan({ stage: 'install' }, async (span) => { span.setAttribute('updater.version', pendingVersion || 'unknown') span.setAttribute('updater.platform', process.platform) @@ -104,6 +144,7 @@ export abstract class UpdaterInstallExecution extends UpdaterPackageRecovery { return } // Why: mark before the call so a sync 'error' during quitAndInstall can recover; pre-native errors must not look like install failure. + setMacInstallPreflightInProgress(false) this.quitAndInstallNativeInvoked = true // Why: invoke before killAllPty/removing close listeners so a sync 'error' can recover while windows and PTYs are intact. const supervisorOwnsRelaunch = this.updateInstallMode === 'supervised-headless-serve' diff --git a/src/main/updater/updater-menu-checks.ts b/src/main/updater/updater-menu-checks.ts index 5e5294f29dc..f6710d555b0 100644 --- a/src/main/updater/updater-menu-checks.ts +++ b/src/main/updater/updater-menu-checks.ts @@ -1,5 +1,6 @@ import { app } from 'electron' import { is } from '@electron-toolkit/utils' +import { isMacInstallRequested } from '../updater-mac-install' import type { UpdateCheckOptions } from '../../shared/update-status-types' import type { ReleaseChannel } from '../../shared/release-channel' import { UpdaterScheduling } from './updater-scheduling' @@ -7,6 +8,13 @@ import { UpdaterScheduling } from './updater-scheduling' /** Handles checks initiated from the desktop menu and modifier-key variants. */ export abstract class UpdaterMenuChecks extends UpdaterScheduling { protected checkForUpdatesFromMenu(options?: UpdateCheckOptions): void { + if ( + this.pendingQuitAndInstallTimer || + this.quitAndInstallInProgress || + isMacInstallRequested() + ) { + return + } if (!app.isPackaged || is.dev) { this.sendStatus({ state: 'not-available', userInitiated: true }) return @@ -60,6 +68,13 @@ export abstract class UpdaterMenuChecks extends UpdaterScheduling { const attemptId = this.beginUpdateCheckAttempt() const autoUpdater = this.getAutoUpdater() const launch = (): Promise | undefined => { + if ( + this.pendingQuitAndInstallTimer || + this.quitAndInstallInProgress || + isMacInstallRequested() + ) { + return undefined + } if (!this.isActiveUpdateCheckAttempt(attemptId)) { return undefined } diff --git a/src/main/updater/updater-scheduling.ts b/src/main/updater/updater-scheduling.ts index 954431385d0..70993c61ff2 100644 --- a/src/main/updater/updater-scheduling.ts +++ b/src/main/updater/updater-scheduling.ts @@ -1,5 +1,6 @@ import { app } from 'electron' import { is } from '@electron-toolkit/utils' +import { isMacInstallRequested } from '../updater-mac-install' import { withUpdaterSpan } from '../observability/instrumentation' import { AUTO_UPDATE_CHECK_INTERVAL_MS, @@ -45,6 +46,13 @@ export abstract class UpdaterScheduling extends UpdaterCheckFailure { protected runBackgroundUpdateCheck( nudgeId: string | null = this.getPersistedPendingUpdateNudgeId() ): boolean { + if ( + this.pendingQuitAndInstallTimer || + this.quitAndInstallInProgress || + isMacInstallRequested() + ) { + return false + } // Why: a pinned dev jump owns the feed until it settles; a background check would repoint it mid-flight and download the wrong build. if ( this.activeUpdateSource !== 'release' || @@ -69,6 +77,13 @@ export abstract class UpdaterScheduling extends UpdaterCheckFailure { const attemptId = this.beginUpdateCheckAttempt() const autoUpdater = this.getAutoUpdater() const launch = (): Promise | undefined => { + if ( + this.pendingQuitAndInstallTimer || + this.quitAndInstallInProgress || + isMacInstallRequested() + ) { + return undefined + } if (!this.isActiveUpdateCheckAttempt(attemptId)) { return undefined } diff --git a/src/main/window/dashboard-popout-window.test.ts b/src/main/window/dashboard-popout-window.test.ts index 960379a797d..791133de126 100644 --- a/src/main/window/dashboard-popout-window.test.ts +++ b/src/main/window/dashboard-popout-window.test.ts @@ -329,6 +329,33 @@ describe('createOrFocusDashboardPopout', () => { } }) + it('keeps saving bounds after a vetoed quit and freezes them only on an allowed quit', () => { + vi.useFakeTimers() + try { + const store = makeStore() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The window fixture reads getUI, updateUI, and onUIChanged supplied by this store. + createOrFocusDashboardPopout(store as never) + const win = instances[0] + const freeze = appOnMock.mock.calls.find(([event]) => event === 'before-quit')?.[1] + expect(freeze).toBeTypeOf('function') + freeze({ defaultPrevented: true }) + win.bounds = { x: 10, y: 20, width: 1200, height: 900 } + win.emit('resize') + vi.advanceTimersByTime(500) + expect(store.updateUI).toHaveBeenCalledWith({ + dashboardPopoutBounds: { x: 10, y: 20, width: 1200, height: 900 } + }) + + store.updateUI.mockClear() + freeze({ defaultPrevented: false }) + win.emit('resize') + vi.advanceTimersByTime(500) + expect(store.updateUI).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + it('closeDashboardPopout closes an open window', () => { createOrFocusDashboardPopout(makeStore() as never) const win = instances[0] diff --git a/src/main/window/dashboard-popout-window.ts b/src/main/window/dashboard-popout-window.ts index a6b082ac1ae..15e4964a46f 100644 --- a/src/main/window/dashboard-popout-window.ts +++ b/src/main/window/dashboard-popout-window.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow, nativeTheme, type WebContents } from 'electron' +import { app, BrowserWindow, nativeTheme, type WebContents, type Event } from 'electron' import { join } from 'node:path' import { is } from '@electron-toolkit/utils' import type { Store } from '../persistence' @@ -264,7 +264,10 @@ export function createOrFocusDashboardPopout( window.on('resize', saveBounds) window.on('move', saveBounds) - const freezeBounds = (): void => { + const freezeBounds = (event?: Event): void => { + if (event?.defaultPrevented) { + return + } windowClosing = true if (boundsTimer) { clearTimeout(boundsTimer) diff --git a/src/main/window/main-window-state-lifecycle.test.ts b/src/main/window/main-window-state-lifecycle.test.ts new file mode 100644 index 00000000000..3015481033f --- /dev/null +++ b/src/main/window/main-window-state-lifecycle.test.ts @@ -0,0 +1,64 @@ +import { EventEmitter } from 'node:events' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' + +const { appMock } = await vi.hoisted(async () => { + const { EventEmitter } = await import('node:events') + return { appMock: new EventEmitter() } +}) +vi.mock('electron', () => ({ app: appMock })) +vi.mock('./foreground-activation-policy', () => ({ + isWindowlessLaunch: () => true, + showWindowWithoutStealingFocus: vi.fn() +})) +vi.mock('./main-window-visual-lifecycle', () => ({ + MIN_WIDTH: 480, + MIN_HEIGHT: 360, + syncTrafficLightPosition: vi.fn() +})) + +import { installMainWindowStateLifecycle } from './main-window-state-lifecycle' + +beforeEach(() => { + vi.useFakeTimers() + appMock.removeAllListeners() +}) +afterEach(() => vi.useRealTimers()) + +it('continues saving bounds after an updater quit veto and freezes them on allowed quit', async () => { + const mainWindow = Object.assign(new EventEmitter(), { + webContents: Object.assign(new EventEmitter(), { + send: vi.fn(), + setZoomLevel: vi.fn() + }), + isDestroyed: () => false, + isFullScreen: () => false, + isMaximized: () => false, + getBounds: () => ({ x: 0, y: 0, width: 1200, height: 800 }) + }) + const updateUI = vi.fn() + const lifecycle = installMainWindowStateLifecycle({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fixture implements the window members read by the bounds lifecycle. + mainWindow: mainWindow as never, + revealOnDidFinishLoad: false, + savedMaximized: false, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Bounds persistence reads only updateUI from this store fixture. + store: { updateUI } as never + }) + appMock.emit('before-quit', { defaultPrevented: true }) + expect(lifecycle.isWindowClosing()).toBe(false) + mainWindow.emit('resize') + await vi.advanceTimersByTimeAsync(500) + expect(updateUI).toHaveBeenCalledWith({ + windowMaximized: false, + windowBounds: { x: 0, y: 0, width: 1200, height: 800 } + }) + + updateUI.mockClear() + appMock.emit('before-quit', { defaultPrevented: false }) + expect(lifecycle.isWindowClosing()).toBe(true) + mainWindow.emit('resize') + await vi.advanceTimersByTimeAsync(500) + expect(updateUI).not.toHaveBeenCalled() + lifecycle.clearInitialRevealFallbackTimer() + lifecycle.dispose() +}) diff --git a/src/main/window/main-window-state-lifecycle.ts b/src/main/window/main-window-state-lifecycle.ts index 2a5345bef50..21ce1b54bd3 100644 --- a/src/main/window/main-window-state-lifecycle.ts +++ b/src/main/window/main-window-state-lifecycle.ts @@ -1,4 +1,4 @@ -import { app, type BrowserWindow } from 'electron' +import { app, type BrowserWindow, type Event } from 'electron' import type { Store } from '../persistence' import { uiZoomFactorFromLevel } from '../../shared/ui-zoom-level' import { isWindowlessLaunch, showWindowWithoutStealingFocus } from './foreground-activation-policy' @@ -7,7 +7,7 @@ import { MIN_HEIGHT, MIN_WIDTH, syncTrafficLightPosition } from './main-window-v export type MainWindowStateLifecycle = { clearInitialRevealFallbackTimer: () => void dispose: () => void - freezeBoundsOnQuit: () => void + freezeBoundsOnQuit: (event?: Event) => void isWindowClosing: () => boolean resumeBoundsPersistence: () => void } @@ -105,7 +105,10 @@ export function installMainWindowStateLifecycle(args: { mainWindow.on('move', saveBounds) // Why: the auto-updater calls removeAllListeners('close') before quitting, so latch on app 'before-quit' too to freeze bounds during teardown. - const freezeBoundsOnQuit = (): void => { + const freezeBoundsOnQuit = (event?: Event): void => { + if (event?.defaultPrevented) { + return + } windowClosing = true if (boundsTimer) { clearTimeout(boundsTimer) diff --git a/src/renderer/src/components/UpdateCard.error-card.test.tsx b/src/renderer/src/components/UpdateCard.error-card.test.tsx index 186a285b26d..7fe9890b48d 100644 --- a/src/renderer/src/components/UpdateCard.error-card.test.tsx +++ b/src/renderer/src/components/UpdateCard.error-card.test.tsx @@ -95,6 +95,37 @@ afterEach(() => { useAppStore.setState(useAppStore.getInitialState(), true) }) +describe('UpdateCard staged install recovery', () => { + it.each([undefined, 'local'] as const)( + 'retries a blocked staged install without downloading again (source %s)', + (source) => { + renderWithInitialStatus({ + state: 'error', + version: '1.4.200', + message: + 'Close the other Orca instances (process IDs: 12345) before installing this update.', + retryAction: 'install', + ...(source ? { source } : {}) + }) + + expect(screen.getByText(/process IDs: 12345/)).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Try Again' })) + expect(quitAndInstall).toHaveBeenCalledTimes(1) + expect(download).not.toHaveBeenCalled() + expect(screen.queryByRole('button', { name: 'Retry Download' })).toBeNull() + expect(screen.queryByRole('button', { name: 'Choose Another Build' })).toBeNull() + } + ) + + it('keeps the download retry for errors from older hosts without an install action', () => { + renderWithInitialStatus({ state: 'error', version: '1.4.200', message: 'Download failed' }) + + fireEvent.click(screen.getByRole('button', { name: 'Retry Download' })) + expect(download).toHaveBeenCalledTimes(1) + expect(quitAndInstall).not.toHaveBeenCalled() + }) +}) + describe('UpdateCard Windows signature failures', () => { it('does not offer the rejected version as a manual publisher-check bypass', () => { const message = diff --git a/src/renderer/src/components/maintenance/update-card/update-card-error-model.ts b/src/renderer/src/components/maintenance/update-card/update-card-error-model.ts index 1a6ab8672a0..a911d58ad62 100644 --- a/src/renderer/src/components/maintenance/update-card/update-card-error-model.ts +++ b/src/renderer/src/components/maintenance/update-card/update-card-error-model.ts @@ -47,6 +47,18 @@ export function buildUpdateCardErrorModel({ } : null } + if (status.retryAction === 'install' && status.retryable !== false) { + return { + title: translate('auto.components.UpdateCard.4cf109845a', 'Update Error'), + summary: status.message, + detail: status.message, + releaseUrl: getReleaseNotesUrlForVersion(cachedVersion), + primaryAction: { + label: translate('auto.components.UpdateCard.2c2d3e03ca', 'Try Again'), + onClick: onInstallRetry + } + } + } if (isLocalBuild) { return { title: cachedVersion diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionAttentionBridge.test.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionAttentionBridge.test.tsx index d83cf01b19b..346b1d449b1 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionAttentionBridge.test.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionAttentionBridge.test.tsx @@ -391,7 +391,7 @@ describe('StructuredAgentSessionAttentionBridge', () => { }) }) - it('keeps a published paired chat subscribed and delivers after a workspace-id collision', async () => { + it('keeps a legacy published paired chat subscribed and delivers after a workspace-id collision', async () => { const store = mocks.store if (!store) { throw new Error('test store was not initialized') @@ -445,10 +445,15 @@ describe('StructuredAgentSessionAttentionBridge', () => { 100 ) ) - const tab = store.getState().unifiedTabsByWorktree[WORKSPACE][0] - if (!tab) { + const publishedTab = store.getState().unifiedTabsByWorktree[WORKSPACE][0] + if (!publishedTab) { throw new Error('snapshot did not publish a chat tab') } + expect(publishedTab.executionHostId).toBe('runtime:env-1') + // Why: restored tabs from before host stamping can still have ambiguous catalog ownership. + const tab = { ...publishedTab } + delete tab.executionHostId + store.setState({ unifiedTabsByWorktree: { [WORKSPACE]: [tab] } }) expect(tab.contentType).toBe('agent-session') expect(tab.executionHostId).toBeUndefined() render() diff --git a/src/renderer/src/components/settings/GeneralUpdateSettingsSection.test.tsx b/src/renderer/src/components/settings/GeneralUpdateSettingsSection.test.tsx index f569c9f5114..996b15eb88e 100644 --- a/src/renderer/src/components/settings/GeneralUpdateSettingsSection.test.tsx +++ b/src/renderer/src/components/settings/GeneralUpdateSettingsSection.test.tsx @@ -1,5 +1,5 @@ // @vitest-environment happy-dom -import { cleanup, render, screen } from '@testing-library/react' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { useAppStore } from '../../store' import { GeneralUpdateSettingsSection } from './GeneralUpdateSettingsSection' @@ -7,7 +7,10 @@ import { GeneralUpdateSettingsSection } from './GeneralUpdateSettingsSection' vi.mock('./GeneralRemoteServerUpdates', () => ({ GeneralRemoteServerUpdates: () => null })) vi.mock('./ReleaseChannelSection', () => ({ ReleaseChannelSection: () => null })) +const quitAndInstall = vi.fn() + beforeEach(() => { + quitAndInstall.mockReset().mockResolvedValue(undefined) useAppStore.setState({ updateStatus: { state: 'available', version: '1.4.200', changelog: null } }) @@ -17,6 +20,7 @@ beforeEach(() => { updater: { check: vi.fn(), download: vi.fn(), + quitAndInstall, getVersion: vi.fn().mockResolvedValue('1.4.199') } } @@ -35,3 +39,36 @@ it('describes the available action as a download', () => { expect(screen.getByText(/is available\. Click "Download Update" to download it\./)).toBeTruthy() expect(screen.queryByText(/download and install it/)).toBeNull() }) + +it('retries installation from the settings panel when a staged update is blocked', () => { + useAppStore.setState({ + updateStatus: { + state: 'error', + version: '1.4.200', + message: 'Close the other Orca instances before installing this update.', + retryAction: 'install' + } + }) + render() + + fireEvent.click(screen.getByRole('button', { name: 'Try Again' })) + expect(quitAndInstall).toHaveBeenCalledTimes(1) +}) + +it.each([undefined, false] as const)( + 'does not offer install retry without an install action or when retry is refused (%s)', + (retryable) => { + useAppStore.setState({ + updateStatus: { + state: 'error', + version: '1.4.200', + message: 'Update failed.', + ...(retryable === false ? { retryAction: 'install', retryable } : {}) + } + }) + render() + + expect(screen.queryByRole('button', { name: 'Try Again' })).toBeNull() + expect(quitAndInstall).not.toHaveBeenCalled() + } +) diff --git a/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx b/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx index 1e59c200fca..fb4d5171f1b 100644 --- a/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx +++ b/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx @@ -138,6 +138,13 @@ export function GeneralUpdateSettingsSection(): React.JSX.Element { )} {updateStatus.version}) + ) : updateStatus.state === 'error' && + updateStatus.retryAction === 'install' && + updateStatus.retryable !== false ? ( + ) : updateStatus.state === 'downloaded' ? ( ', + '````', + '**Feedback:** Make this primary action more obvious.' + ].join('\n') + ) + }) + + it('groups interleaved captured URLs in first-seen order with global numbers and captured viewports', () => { + const first = makeAnnotation() + first.payload.page.viewportWidth = 800 + const second = makeAnnotation({ id: 'note-b', comment: 'B feedback' }) + second.payload.page.sanitizedUrl = 'https://example.com/account' + second.payload.page.viewportWidth = 1024 + second.payload.target.sourceFile = 'src/Account.tsx:10:1' + const third = makeAnnotation({ id: 'note-a2', comment: 'A again' }) + third.payload.page.viewportWidth = 1440 + third.payload.target.sourceFile = 'src/PricingChanged.tsx:3:1' + + const markdown = formatBrowserAnnotationsAsMarkdown([first, second, third]) + expect(markdown.match(/^### \d+\./gm)).toEqual(['### 1.', '### 3.', '### 2.']) + const [pricing, account] = markdown.split('## Design Feedback: ').slice(1) + expect(pricing).toContain('/pricing') + expect(pricing).toContain('**URL:** `https://example.com/pricing`') + expect(pricing).toContain('**Viewport:** 800x720') + expect(pricing).toContain('**Viewport:** 1440x720') + expect(pricing).toContain('src/PricingChanged.tsx:3:1') + expect(pricing).not.toContain('B feedback') + expect(account).toContain('/account') + expect(account).toContain('**URL:** `https://example.com/account`') + expect(account).toContain('**Viewport:** 1024x720') + expect(account).toContain('src/Account.tsx:10:1') + expect(account).toContain('**Feedback:** B feedback') + }) + + it('escapes and bounds added mixed-page context with the existing inline formatting', () => { + const first = makeAnnotation() + const second = makeAnnotation({ browserPageId: 'page`id\n## injected' }) + second.payload.page.sanitizedUrl = 'invalid`url\n## injected' + const markdown = formatBrowserAnnotationsAsMarkdown([first, second]) + expect(markdown).toContain('**URL:** ``invalid`url ## injected``') + expect(markdown).toContain('**Browser tab id:** ``page`id ## injected``') + expect(markdown).not.toContain('\n## injected') + }) + it('includes agent-useful selectors, source, react tree, styles, and feedback', () => { const markdown = formatBrowserAnnotationsAsMarkdown([makeAnnotation()]) diff --git a/src/renderer/src/components/browser-pane/annotate/browser-annotation-output.ts b/src/renderer/src/components/browser-pane/annotate/browser-annotation-output.ts index 06f9bc22f38..b58896d6a64 100644 --- a/src/renderer/src/components/browser-pane/annotate/browser-annotation-output.ts +++ b/src/renderer/src/components/browser-pane/annotate/browser-annotation-output.ts @@ -159,70 +159,92 @@ export function formatBrowserAnnotationsAsMarkdown(annotations: BrowserPageAnnot const firstAnnotation = annotations[0] const first = firstAnnotation.payload - const lines: string[] = [ - `## Design Feedback: ${formatPageHeading(first)}`, - '', - `**URL:** ${first.page.sanitizedUrl}`, - `**Browser tab id:** ${firstAnnotation.browserPageId}`, - `**Viewport:** ${first.page.viewportWidth}x${first.page.viewportHeight}`, - '' - ] - + const groups = new Map() annotations.forEach((annotation, index) => { - const { payload } = annotation - const { target } = payload - const rect = target.rectViewport - const styleLines = formatStyles(target.computedStyles) - - lines.push(`### ${index + 1}. ${annotationElementLabel(payload)}`) - lines.push(`**Intent:** ${annotation.intent}`) - lines.push(`**Selector:** ${inlineCode(target.selector)}`) - if (target.elementPath) { - lines.push(`**Location:** ${inlineCode(target.elementPath)}`) - } - if (target.sourceFile) { - lines.push(`**Source:** ${inlineText(target.sourceFile)}`) - } - if (target.reactComponents) { - lines.push(`**React:** ${inlineText(target.reactComponents)}`) - } - lines.push( - `**Bounds:** x=${Math.round(rect.x)}, y=${Math.round(rect.y)}, ${Math.round(rect.width)}x${Math.round(rect.height)}` - ) - if (target.cssClasses) { - lines.push(`**Classes:** ${inlineCode(target.cssClasses)}`) - } - if (target.selectedText) { - lines.push(`**Selected text:** "${inlineText(target.selectedText)}"`) - } else if (target.textSnippet) { - lines.push(`**Text:** "${inlineText(target.textSnippet)}"`) - } - if (payload.nearbyText.length > 0) { - lines.push('**Nearby text:**') - for (const text of payload.nearbyText) { - lines.push(`- ${inlineText(text)}`) - } - } - if (target.nearbyElements?.length) { - lines.push('**Nearby elements:**') - for (const element of target.nearbyElements) { - lines.push(`- ${inlineText(element)}`) - } - } - if (styleLines.length > 0) { - lines.push('**Computed styles:**') - lines.push(...styleLines) - } - if (target.fullPath) { - lines.push(`**Full DOM path:** ${inlineCode(target.fullPath)}`) - } - if (target.htmlSnippet) { - lines.push('**HTML:**') - lines.push(...fence('html', target.htmlSnippet)) - } - lines.push(`**Feedback:** ${inlineText(annotation.comment)}`) - lines.push('') + const url = annotation.payload.page.sanitizedUrl + const entries = groups.get(url) ?? [] + entries.push({ annotation, index }) + groups.set(url, entries) }) + const mixedPages = groups.size > 1 + const lines: string[] = mixedPages + ? ['## Design Feedback across pages', ''] + : [ + `## Design Feedback: ${formatPageHeading(first)}`, + '', + `**URL:** ${first.page.sanitizedUrl}`, + `**Browser tab id:** ${firstAnnotation.browserPageId}`, + `**Viewport:** ${first.page.viewportWidth}x${first.page.viewportHeight}`, + '' + ] + + for (const [url, entries] of groups) { + if (mixedPages) { + lines.push( + `## Design Feedback: ${inlineText(formatPageHeading(entries[0].annotation.payload))}` + ) + lines.push('', `**URL:** ${inlineCode(inlineText(url))}`, '') + } + entries.forEach(({ annotation, index }) => { + const { payload } = annotation + const { target } = payload + const rect = target.rectViewport + const styleLines = formatStyles(target.computedStyles) + + lines.push(`### ${index + 1}. ${annotationElementLabel(payload)}`) + if (mixedPages) { + lines.push(`**Browser tab id:** ${inlineCode(inlineText(annotation.browserPageId))}`) + lines.push(`**Viewport:** ${payload.page.viewportWidth}x${payload.page.viewportHeight}`) + } + lines.push(`**Intent:** ${annotation.intent}`) + lines.push(`**Selector:** ${inlineCode(target.selector)}`) + if (target.elementPath) { + lines.push(`**Location:** ${inlineCode(target.elementPath)}`) + } + if (target.sourceFile) { + lines.push(`**Source:** ${inlineText(target.sourceFile)}`) + } + if (target.reactComponents) { + lines.push(`**React:** ${inlineText(target.reactComponents)}`) + } + lines.push( + `**Bounds:** x=${Math.round(rect.x)}, y=${Math.round(rect.y)}, ${Math.round(rect.width)}x${Math.round(rect.height)}` + ) + if (target.cssClasses) { + lines.push(`**Classes:** ${inlineCode(target.cssClasses)}`) + } + if (target.selectedText) { + lines.push(`**Selected text:** "${inlineText(target.selectedText)}"`) + } else if (target.textSnippet) { + lines.push(`**Text:** "${inlineText(target.textSnippet)}"`) + } + if (payload.nearbyText.length > 0) { + lines.push('**Nearby text:**') + for (const text of payload.nearbyText) { + lines.push(`- ${inlineText(text)}`) + } + } + if (target.nearbyElements?.length) { + lines.push('**Nearby elements:**') + for (const element of target.nearbyElements) { + lines.push(`- ${inlineText(element)}`) + } + } + if (styleLines.length > 0) { + lines.push('**Computed styles:**') + lines.push(...styleLines) + } + if (target.fullPath) { + lines.push(`**Full DOM path:** ${inlineCode(target.fullPath)}`) + } + if (target.htmlSnippet) { + lines.push('**HTML:**') + lines.push(...fence('html', target.htmlSnippet)) + } + lines.push(`**Feedback:** ${inlineText(annotation.comment)}`) + lines.push('') + }) + } return lines.join('\n').trimEnd() } diff --git a/src/renderer/src/components/browser-pane/annotate/browser-annotation-page-url.ts b/src/renderer/src/components/browser-pane/annotate/browser-annotation-page-url.ts new file mode 100644 index 00000000000..d1d53603c63 --- /dev/null +++ b/src/renderer/src/components/browser-pane/annotate/browser-annotation-page-url.ts @@ -0,0 +1,12 @@ +export function browserAnnotationMatchesPageUrl(capturedUrl: string, currentUrl: string): boolean { + try { + const captured = new URL(capturedUrl) + const current = new URL(currentUrl) + // Capture metadata strips query strings and fragments before leaving the guest. + current.search = '' + current.hash = '' + return captured.href === current.href + } catch { + return false + } +} diff --git a/src/renderer/src/components/browser-pane/annotate/browser-guest-annotate-overlays.tsx b/src/renderer/src/components/browser-pane/annotate/browser-guest-annotate-overlays.tsx index 86aeeac65ab..200670cd536 100644 --- a/src/renderer/src/components/browser-pane/annotate/browser-guest-annotate-overlays.tsx +++ b/src/renderer/src/components/browser-pane/annotate/browser-guest-annotate-overlays.tsx @@ -39,7 +39,8 @@ export function BrowserGuestAnnotateOverlays({ markupPortalContainer, webviewRef, browserOverlayViewport, - worktreeId + worktreeId, + currentUrl }: { markup: MarkupModeController grab: GrabModeHook @@ -50,6 +51,7 @@ export function BrowserGuestAnnotateOverlays({ webviewRef: MutableRefObject browserOverlayViewport: BrowserOverlayViewport worktreeId: string + currentUrl?: string }): React.JSX.Element { const { pendingAnnotationPayload, @@ -110,6 +112,7 @@ export function BrowserGuestAnnotateOverlays({ {browserAnnotations.length > 0 && browserAnnotationTrayOpen ? ( handleUpdateBrowserAnnotation: ReturnType } { @@ -80,6 +80,7 @@ function renderTray(): { { + it('attributes a retained note to its captured page while preserving edit and delete controls', () => { + const { handleDeleteBrowserAnnotation } = renderTray('https://example.com/next') + expect(screen.getByTitle('https://example.com')).toHaveTextContent('https://example.com') + fireEvent.click(screen.getByRole('button', { name: 'Delete annotation 1' })) + expect(handleDeleteBrowserAnnotation).toHaveBeenCalledWith('annotation-1') + }) + + it('keeps current-page and document-preview metadata compact', () => { + renderTray('https://example.com/?query=private#fragment') + expect(screen.queryByTitle('https://example.com')).not.toBeInTheDocument() + cleanup() + renderTray() + expect(screen.queryByTitle('https://example.com')).not.toBeInTheDocument() + }) + it('seeds the textarea with the current comment when entering edit mode', () => { renderTray() diff --git a/src/renderer/src/components/browser-pane/annotate/browser-page-annotation-tray.tsx b/src/renderer/src/components/browser-pane/annotate/browser-page-annotation-tray.tsx index aa847d2f4e2..a22f18b06d5 100644 --- a/src/renderer/src/components/browser-pane/annotate/browser-page-annotation-tray.tsx +++ b/src/renderer/src/components/browser-pane/annotate/browser-page-annotation-tray.tsx @@ -18,9 +18,11 @@ import { import { BROWSER_ANNOTATION_INTENT_OPTIONS } from '../describe-page/browser-annotation-geometry' import { BrowserAnnotationSendMenuContent } from './BrowserAnnotationSendMenuContent' import { preventAgentSendTargetOutsideDismiss } from './prevent-agent-send-target-outside-dismiss' +import { browserAnnotationMatchesPageUrl } from './browser-annotation-page-url' export function BrowserPageAnnotationTray({ browserAnnotations, + currentUrl, annotationTraySendOpen, handleAnnotationTraySendOpenChange, worktreeId, @@ -34,6 +36,7 @@ export function BrowserPageAnnotationTray({ handleUpdateBrowserAnnotation }: { browserAnnotations: BrowserPageAnnotation[] + currentUrl?: string annotationTraySendOpen: boolean handleAnnotationTraySendOpenChange: (open: boolean) => void worktreeId: string @@ -269,8 +272,17 @@ export function BrowserPageAnnotationTray({
{annotation.comment}
-
+
{annotation.intent} + {currentUrl !== undefined && + !browserAnnotationMatchesPageUrl( + annotation.payload.page.sanitizedUrl, + currentUrl + ) ? ( + + {annotation.payload.page.sanitizedUrl} + + ) : null}
diff --git a/src/renderer/src/components/browser-pane/annotate/guest-annotation-viewport-bridge.test.ts b/src/renderer/src/components/browser-pane/annotate/guest-annotation-viewport-bridge.test.ts new file mode 100644 index 00000000000..69625c91ab3 --- /dev/null +++ b/src/renderer/src/components/browser-pane/annotate/guest-annotation-viewport-bridge.test.ts @@ -0,0 +1,113 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { makeAnnotation } from '@/store/slices/browser-annotation-test-fixture' +import type { BrowserSetAnnotationViewportBridgeArgs } from '../../../../../shared/browser-annotation-viewport-bridge' +import { syncGuestAnnotationViewportBridge } from './guest-annotation-viewport-bridge' + +afterEach(() => vi.unstubAllGlobals()) + +function createBridge() { + const setAnnotationViewportBridge = vi + .fn<(args: BrowserSetAnnotationViewportBridgeArgs) => Promise>() + .mockResolvedValue(undefined) + vi.stubGlobal('window', { api: { browser: { setAnnotationViewportBridge } } }) + const notes = [makeAnnotation('page-1', 'old'), makeAnnotation('page-1', 'fresh')] + return { setAnnotationViewportBridge, notes } +} + +describe('current-document annotation marker projection', () => { + it('requires both eligible geometry and the captured URL while retaining global tray indices', () => { + const { setAnnotationViewportBridge, notes } = createBridge() + syncGuestAnnotationViewportBridge({ + toolTargetId: 'page-1', + annotations: notes, + currentDocument: { markerIds: ['fresh'], url: 'https://example.com/?private=query#fragment' }, + pendingPayload: null, + surfaceActive: true, + token: 'token' + }) + expect(setAnnotationViewportBridge).toHaveBeenLastCalledWith( + expect.objectContaining({ + enabled: true, + emitViewport: false, + markers: [expect.objectContaining({ id: 'fresh', index: 1 })] + }) + ) + syncGuestAnnotationViewportBridge({ + toolTargetId: 'page-1', + annotations: notes, + currentDocument: { markerIds: ['fresh'], url: 'https://example.com/next' }, + pendingPayload: null, + surfaceActive: true, + token: 'token' + }) + expect(setAnnotationViewportBridge).toHaveBeenLastCalledWith( + expect.objectContaining({ enabled: false, markers: [] }) + ) + }) + + it('does not resurrect old same-URL geometry on dom-ready or return, but permits pending viewport events', () => { + const { setAnnotationViewportBridge, notes } = createBridge() + const args = { + toolTargetId: 'page-1', + annotations: notes, + currentDocument: { markerIds: [], url: 'https://example.com' }, + pendingPayload: null, + surfaceActive: true, + token: 'token' + } + syncGuestAnnotationViewportBridge(args) + syncGuestAnnotationViewportBridge(args) + expect(setAnnotationViewportBridge).toHaveBeenLastCalledWith( + expect.objectContaining({ enabled: false, markers: [] }) + ) + syncGuestAnnotationViewportBridge({ ...args, pendingPayload: notes[1].payload }) + expect(setAnnotationViewportBridge).toHaveBeenLastCalledWith( + expect.objectContaining({ enabled: true, emitViewport: true, markers: [] }) + ) + syncGuestAnnotationViewportBridge({ + ...args, + pendingPayload: notes[1].payload, + surfaceActive: false + }) + expect(setAnnotationViewportBridge).toHaveBeenLastCalledWith( + expect.objectContaining({ enabled: false }) + ) + }) + + it('preserves the document-preview caller when no browsing projection is supplied', () => { + const { setAnnotationViewportBridge, notes } = createBridge() + syncGuestAnnotationViewportBridge({ + toolTargetId: 'reminted-preview', + annotations: notes, + pendingPayload: null, + surfaceActive: true, + token: 'token' + }) + expect(setAnnotationViewportBridge).toHaveBeenLastCalledWith( + expect.objectContaining({ + browserPageId: 'reminted-preview', + enabled: true, + markers: [ + expect.objectContaining({ id: 'old', index: 0 }), + expect.objectContaining({ id: 'fresh', index: 1 }) + ] + }) + ) + }) + + it('tolerates a destroyed guest without retaining or throwing a rejected bridge call', async () => { + const { setAnnotationViewportBridge, notes } = createBridge() + setAnnotationViewportBridge.mockRejectedValueOnce(new Error('Guest destroyed')) + expect(() => + syncGuestAnnotationViewportBridge({ + toolTargetId: 'page-1', + annotations: notes, + currentDocument: { markerIds: [], url: 'https://example.com' }, + pendingPayload: null, + surfaceActive: true, + token: 'token' + }) + ).not.toThrow() + await Promise.resolve() + }) +}) diff --git a/src/renderer/src/components/browser-pane/annotate/guest-annotation-viewport-bridge.ts b/src/renderer/src/components/browser-pane/annotate/guest-annotation-viewport-bridge.ts index 281fc67c77f..6e28c0fa9d1 100644 --- a/src/renderer/src/components/browser-pane/annotate/guest-annotation-viewport-bridge.ts +++ b/src/renderer/src/components/browser-pane/annotate/guest-annotation-viewport-bridge.ts @@ -2,33 +2,42 @@ import type { BrowserGrabPayload, BrowserPageAnnotation } from '../../../../../shared/browser-grab-types' +import { browserAnnotationMatchesPageUrl } from './browser-annotation-page-url' -/** - * Push the current annotation set into the guest, where badges render in-page so they track scroll - * without a message per frame. Shared by every surface that annotates a guest — the payload is - * derived only from the annotations themselves, so the two surfaces cannot disagree about it. - */ +// Guest-rendered badges track scrolling without a renderer message per frame. export function syncGuestAnnotationViewportBridge({ toolTargetId, annotations, + currentDocument, pendingPayload, surfaceActive, token }: { toolTargetId: string annotations: BrowserPageAnnotation[] + currentDocument?: { markerIds: readonly string[]; url: string } pendingPayload: BrowserGrabPayload | null surfaceActive: boolean token: string }): void { // Why: existing badges render in-guest for smooth scroll; only the pending dialog needs viewport messages. - const markers = annotations.map((annotation, index) => ({ - id: annotation.id, - index, - isFixed: annotation.payload.target.isFixed === true, - rectPage: annotation.payload.target.rectPage, - rectViewport: annotation.payload.target.rectViewport - })) + const eligibleIds = currentDocument ? new Set(currentDocument.markerIds) : null + // Keep tray numbering even when earlier notes belong to retired documents. + const markers = annotations.flatMap((annotation, index) => + currentDocument && + (!eligibleIds?.has(annotation.id) || + !browserAnnotationMatchesPageUrl(annotation.payload.page.sanitizedUrl, currentDocument.url)) + ? [] + : [ + { + id: annotation.id, + index, + isFixed: annotation.payload.target.isFixed === true, + rectPage: annotation.payload.target.rectPage, + rectViewport: annotation.payload.target.rectViewport + } + ] + ) void window.api.browser .setAnnotationViewportBridge({ browserPageId: toolTargetId, diff --git a/src/renderer/src/components/browser-pane/annotate/use-browser-page-grab-annotations.test.tsx b/src/renderer/src/components/browser-pane/annotate/use-browser-page-grab-annotations.test.tsx new file mode 100644 index 00000000000..6e66608992a --- /dev/null +++ b/src/renderer/src/components/browser-pane/annotate/use-browser-page-grab-annotations.test.tsx @@ -0,0 +1,191 @@ +// @vitest-environment happy-dom +import { useLayoutEffect } from 'react' +import { act, cleanup, renderHook, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createTestStore } from '@/store/slices/browser-slice-test-harness' +import { makeAnnotation } from '@/store/slices/browser-annotation-test-fixture' +import type { + BrowserGrabResult, + BrowserCaptureSelectionScreenshotResult +} from '../../../../../shared/browser-grab-types' +import { useGrabMode } from './useGrabMode' +import { useBrowserPageGrabAnnotations } from './use-browser-page-grab-annotations' + +const state = vi.hoisted((): { store?: ReturnType } => ({})) +vi.mock('@/store', () => ({ + useAppStore: ( + selector: (value: ReturnType['getState']>) => unknown + ) => { + if (!state.store) { + throw new Error('Missing test store') + } + return state.store(selector) + } +})) +vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((accept) => { + resolve = accept + }) + return { promise, resolve } +} + +beforeEach(() => { + state.store = createTestStore() +}) +afterEach(() => { + cleanup() + Reflect.deleteProperty(window, 'api') +}) + +function mount(invalidateBeforePendingEffect = false) { + const selection = deferred() + const screenshot = deferred() + const awaitGrabSelection = vi + .fn() + .mockReturnValueOnce(selection.promise) + .mockReturnValue(new Promise(() => {})) + const captureSelectionScreenshot = vi.fn().mockReturnValue(screenshot.promise) + const cancelGrab = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(window, 'api', { + configurable: true, + value: { + browser: { + setGrabMode: vi.fn().mockResolvedValue({ ok: true }), + awaitGrabSelection, + captureSelectionScreenshot, + cancelGrab + } + } + }) + const containerRef = { current: null } + const webviewRef = { current: null } + const setBrowserOverlayViewport = vi.fn() + const setBrowserAnnotationTrayOpen = vi.fn() + const hook = renderHook(() => { + const grab = useGrabMode('page-1') + const annotations = useBrowserPageGrabAnnotations({ + browserTabId: 'page-1', + isActive: false, + grab, + containerRef, + webviewRef, + setBrowserOverlayViewport, + setBrowserAnnotationTrayOpen, + browserAnnotationsLength: 0 + }) + const { cancelPendingBrowserCapture } = annotations + useLayoutEffect(() => { + if (invalidateBeforePendingEffect && grab.state === 'confirming') { + cancelPendingBrowserCapture() + } + }, [grab.state, cancelPendingBrowserCapture]) + return { grab, annotations } + }) + return { + ...hook, + selection, + screenshot, + awaitGrabSelection, + captureSelectionScreenshot, + cancelGrab + } +} + +function selected(): BrowserGrabResult { + return { opId: 'fixture-op', kind: 'selected', payload: makeAnnotation('page-1').payload } +} + +describe('capture cancellation on a document boundary', () => { + it('does not restore pending capture when loading cancels between commit and passive effects', async () => { + const h = mount(true) + act(() => h.result.current.annotations.startGrabIntent('annotate')) + await waitFor(() => expect(h.awaitGrabSelection).toHaveBeenCalledOnce()) + await act(async () => { + h.selection.resolve(selected()) + h.screenshot.resolve({ ok: false, reason: 'fixture' }) + await h.screenshot.promise + }) + expect(h.result.current.grab.state).toBe('idle') + expect(h.result.current.annotations.pendingAnnotationPayload).toBeNull() + act(() => h.result.current.annotations.handleAddBrowserAnnotation('Canceled capture', 'fix')) + expect(state.store?.getState().browserAnnotationsByPageId['page-1']).toBeUndefined() + expect(state.store?.getState().browserAnnotationMarkerIdsByPageId['page-1']).toBeUndefined() + }) + + it('ignores a same-page selection that completes after document invalidation', async () => { + const h = mount() + act(() => h.result.current.annotations.startGrabIntent('annotate')) + await waitFor(() => expect(h.awaitGrabSelection).toHaveBeenCalledOnce()) + act(() => h.result.current.annotations.cancelPendingBrowserCapture()) + await act(async () => { + h.selection.resolve(selected()) + await h.selection.promise + }) + expect(h.cancelGrab).toHaveBeenCalledWith({ browserPageId: 'page-1' }) + expect(h.captureSelectionScreenshot).not.toHaveBeenCalled() + expect(h.result.current.grab.state).toBe('idle') + expect(h.result.current.annotations.pendingAnnotationPayload).toBeNull() + expect(state.store?.getState().browserAnnotationsByPageId['page-1']).toBeUndefined() + }) + + it('ignores a same-URL screenshot that finishes after cancellation without creating eligible geometry', async () => { + const h = mount() + act(() => h.result.current.annotations.startGrabIntent('annotate')) + await waitFor(() => expect(h.awaitGrabSelection).toHaveBeenCalledOnce()) + await act(async () => { + h.selection.resolve(selected()) + await h.selection.promise + }) + expect(h.captureSelectionScreenshot).toHaveBeenCalledOnce() + act(() => h.result.current.annotations.cancelPendingBrowserCapture()) + await act(async () => { + h.screenshot.resolve({ ok: false, reason: 'fixture' }) + await h.screenshot.promise + }) + expect(h.result.current.grab.state).toBe('idle') + expect(h.result.current.annotations.pendingAnnotationPayload).toBeNull() + expect(state.store?.getState().browserAnnotationMarkerIdsByPageId['page-1']).toBeUndefined() + }) + + it('blocks an already-rendered Add callback after invalidation and permits a fresh capture', async () => { + const h = mount() + act(() => h.result.current.annotations.startGrabIntent('annotate')) + await waitFor(() => expect(h.awaitGrabSelection).toHaveBeenCalledOnce()) + await act(async () => { + h.selection.resolve(selected()) + h.screenshot.resolve({ ok: false, reason: 'fixture' }) + await h.screenshot.promise + }) + await waitFor(() => + expect(h.result.current.annotations.pendingAnnotationPayload).not.toBeNull() + ) + const oldAdd = h.result.current.annotations.handleAddBrowserAnnotation + act(() => { + h.result.current.annotations.cancelPendingBrowserCapture() + oldAdd('Stale note', 'fix') + }) + expect(state.store?.getState().browserAnnotationsByPageId['page-1']).toBeUndefined() + + const fresh = deferred() + h.awaitGrabSelection.mockReturnValueOnce(fresh.promise) + act(() => h.result.current.annotations.startGrabIntent('annotate')) + await waitFor(() => expect(h.awaitGrabSelection).toHaveBeenCalledTimes(2)) + await act(async () => { + fresh.resolve(selected()) + await fresh.promise + }) + await waitFor(() => + expect(h.result.current.annotations.pendingAnnotationPayload).not.toBeNull() + ) + act(() => h.result.current.annotations.handleAddBrowserAnnotation('Fresh note', 'change')) + const saved = state.store?.getState().browserAnnotationsByPageId['page-1'] + expect(saved).toHaveLength(1) + expect(saved?.[0].comment).toBe('Fresh note') + expect(state.store?.getState().browserAnnotationMarkerIdsByPageId['page-1']).toEqual([ + saved?.[0].id + ]) + }) +}) diff --git a/src/renderer/src/components/browser-pane/annotate/use-browser-page-grab-annotations.ts b/src/renderer/src/components/browser-pane/annotate/use-browser-page-grab-annotations.ts index 16225019b31..7852acd1b2d 100644 --- a/src/renderer/src/components/browser-pane/annotate/use-browser-page-grab-annotations.ts +++ b/src/renderer/src/components/browser-pane/annotate/use-browser-page-grab-annotations.ts @@ -16,16 +16,18 @@ import type { BrowserGrabPayload } from '../../../../../shared/browser-grab-types' import { formatGrabPayloadAsText } from './GrabConfirmationSheet' -import type { GrabModeHook } from './useGrabMode' import { createBrowserAnnotationId, createBrowserAnnotationPayload, - DEFAULT_BROWSER_ANNOTATION_PRIORITY, - type BrowserOverlayViewport + DEFAULT_BROWSER_ANNOTATION_PRIORITY } from '../describe-page/browser-annotation-geometry' import { useBrowserPageAnnotationViewportTracking } from './use-browser-page-annotation-viewport-tracking' import { runBrowserGrabActionShortcut } from './browser-page-grab-action' -import type { BrowserPageGrabToastState, GrabIntent } from '../describe-page/browser-page-types' +import type { + BrowserPageGrabAnnotationsOptions, + BrowserPageGrabToastState, + GrabIntent +} from '../describe-page/browser-page-types' const copiedGrabToastMessage = (): string => translate( @@ -55,25 +57,7 @@ export function useBrowserPageGrabAnnotations({ setBrowserOverlayViewport, browserAnnotationsLength, setBrowserAnnotationTrayOpen -}: { - /** Scopes the stored annotations. Stable for the life of the surface. */ - browserTabId: string - /** - * The id main resolves to a guest. Defaults to the annotation scope, which is the same string - * for a browser page — a preview re-mints this on recovery, and its annotations must not be - * orphaned when it does. - */ - toolTargetId?: string - isActive: boolean - grab: GrabModeHook - containerRef: MutableRefObject - trackingContainer?: HTMLDivElement | null - trackingScroller?: HTMLDivElement | null - webviewRef: MutableRefObject - setBrowserOverlayViewport: Dispatch> - browserAnnotationsLength: number - setBrowserAnnotationTrayOpen: Dispatch> -}): { +}: BrowserPageGrabAnnotationsOptions): { grabIntent: GrabIntent startGrabIntent: (nextIntent: GrabIntent) => void pendingAnnotationPayload: BrowserGrabPayload | null @@ -87,6 +71,7 @@ export function useBrowserPageGrabAnnotations({ grabMenuActionTakenRef: MutableRefObject handleAddBrowserAnnotation: (comment: string, intent: BrowserAnnotationIntent) => void handleCancelPendingBrowserAnnotation: () => void + cancelPendingBrowserCapture: () => void handleGrabActionShortcut: (key: 'c' | 's') => void } { const mountedRef = useMountedRef() @@ -114,11 +99,7 @@ export function useBrowserPageGrabAnnotations({ const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction) const addBrowserPageAnnotation = useAppStore((s) => s.addBrowserPageAnnotation) - useEffect(() => { - return () => { - clearTimeout(grabToastTimerRef.current) - } - }, []) + useEffect(() => () => clearTimeout(grabToastTimerRef.current), []) const dismissGrabToast = useCallback(() => { clearTimeout(grabToastTimerRef.current) @@ -165,13 +146,16 @@ export function useBrowserPageGrabAnnotations({ [containerRef, dismissGrabToast, mountedRef, webviewRef] ) - // Why: the same in-guest picker powers two flows — Cmd/Ctrl+C copies, the toolbar action creates a pending annotation. + // The picker supports clipboard copying and independently dismissible annotation drafts. useEffect(() => { if (grab.state !== 'confirming' || !grab.payload) { return } if (grabIntent === 'annotate') { - setPendingAnnotationPayload(grab.payload) + // Loading may cancel the selection before React accepts this pending snapshot. + setPendingAnnotationPayload(() => + grabPayloadRef.current === grab.payload ? grab.payload : null + ) return } if (!grab.contextMenu) { @@ -267,7 +251,7 @@ export function useBrowserPageGrabAnnotations({ const handleAddBrowserAnnotation = useCallback( (comment: string, intent: BrowserAnnotationIntent): void => { - const payload = pendingAnnotationPayload + const payload = pendingAnnotationPayloadRef.current if (!payload) { return } @@ -281,6 +265,7 @@ export function useBrowserPageGrabAnnotations({ payload: createBrowserAnnotationPayload(payload) }) recordFeatureInteraction('browser-annotations') + pendingAnnotationPayloadRef.current = null setPendingAnnotationPayload(null) setBrowserAnnotationTrayOpen(true) showGrabToast(annotationAddedGrabToastMessage(), 'success', payload) @@ -290,7 +275,6 @@ export function useBrowserPageGrabAnnotations({ addBrowserPageAnnotation, browserTabId, grab, - pendingAnnotationPayload, recordFeatureInteraction, setBrowserAnnotationTrayOpen, showGrabToast @@ -298,12 +282,22 @@ export function useBrowserPageGrabAnnotations({ ) const handleCancelPendingBrowserAnnotation = useCallback((): void => { + pendingAnnotationPayloadRef.current = null setPendingAnnotationPayload(null) if (grabIntent === 'annotate' && grab.state === 'confirming') { grab.rearm() } }, [grab, grabIntent]) + const cancelPendingBrowserCapture = useCallback((): void => { + grabRef.current.cancel() + pendingAnnotationPayloadRef.current = null + grabPayloadRef.current = null + setPendingAnnotationPayload(null) + clearTimeout(grabToastTimerRef.current) + setGrabToast(null) + }, []) + return { grabIntent, startGrabIntent, @@ -318,6 +312,7 @@ export function useBrowserPageGrabAnnotations({ grabMenuActionTakenRef, handleAddBrowserAnnotation, handleCancelPendingBrowserAnnotation, + cancelPendingBrowserCapture, handleGrabActionShortcut } } diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-pane.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-pane.tsx index 33dbfe52c87..eb72a5f0f45 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-pane.tsx +++ b/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-pane.tsx @@ -224,7 +224,7 @@ export function BrowserPagePane({ onUpdatePageState, onSetUrl, setAddressBarValue: nav.setAddressBarValue, - setPendingAnnotationPayload: grabAnnotations.setPendingAnnotationPayload, + cancelPendingBrowserCapture: grabAnnotations.cancelPendingBrowserCapture, setBrowserOverlayViewport, setFindOpen, focusAddressBarNow, @@ -232,7 +232,6 @@ export function BrowserPagePane({ paneZoomLevelRef: zoom.paneZoomLevelRef, setBrowserZoomPercent: zoom.setBrowserZoomPercent, pendingAnnotationPayload: grabAnnotations.pendingAnnotationPayload, - browserAnnotationsLength: annotationSend.browserAnnotations.length, inputLocked, faviconUrl: browserTab.faviconUrl, webviewRef, diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-viewport-overlays.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-viewport-overlays.tsx index 6c216a1dd09..3a9403f35fd 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-viewport-overlays.tsx +++ b/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-viewport-overlays.tsx @@ -84,6 +84,7 @@ export function BrowserPageViewportOverlays({ webviewRef={webviewRef} browserOverlayViewport={browserOverlayViewport} worktreeId={worktreeId} + currentUrl={browserTab.url} /> setFindOpen(false)} webviewRef={webviewRef} /> diff --git a/src/renderer/src/components/browser-pane/describe-page/browser-page-types.ts b/src/renderer/src/components/browser-pane/describe-page/browser-page-types.ts index bbe8949d9b2..4a378fe1701 100644 --- a/src/renderer/src/components/browser-pane/describe-page/browser-page-types.ts +++ b/src/renderer/src/components/browser-pane/describe-page/browser-page-types.ts @@ -1,5 +1,8 @@ +import type { Dispatch, MutableRefObject, SetStateAction } from 'react' import type { BrowserGrabPayload } from '../../../../../shared/browser-grab-types' import type { BrowserPage as BrowserPageState } from '../../../../../shared/browser-workspace-types' +import type { GrabModeHook } from '../annotate/useGrabMode' +import type { BrowserOverlayViewport } from './browser-annotation-geometry' export type BrowserTabPageState = Partial< Pick< @@ -35,6 +38,26 @@ export type BrowserPageGrabToastState = { payload: BrowserGrabPayload | null } +export type BrowserPageGrabAnnotationsOptions = { + /** Scopes the stored annotations. Stable for the life of the surface. */ + browserTabId: string + /** + * The id main resolves to a guest. Defaults to the annotation scope, which is the same string + * for a browser page — a preview re-mints this on recovery, and its annotations must not be + * orphaned when it does. + */ + toolTargetId?: string + isActive: boolean + grab: GrabModeHook + containerRef: MutableRefObject + trackingContainer?: HTMLDivElement | null + trackingScroller?: HTMLDivElement | null + webviewRef: MutableRefObject + setBrowserOverlayViewport: Dispatch> + browserAnnotationsLength: number + setBrowserAnnotationTrayOpen: Dispatch> +} + export type BrowserPageRecoveryNavigationValidation = { committed: boolean started: boolean diff --git a/src/renderer/src/components/browser-pane/host-guest/attach-browser-page-webview.ts b/src/renderer/src/components/browser-pane/host-guest/attach-browser-page-webview.ts index 4d690b31f03..dd880308b26 100644 --- a/src/renderer/src/components/browser-pane/host-guest/attach-browser-page-webview.ts +++ b/src/renderer/src/components/browser-pane/host-guest/attach-browser-page-webview.ts @@ -1,5 +1,4 @@ import type { Dispatch, DragEvent, MutableRefObject, RefObject, SetStateAction } from 'react' -import type { BrowserGrabPayload } from '../../../../../shared/browser-grab-types' import { normalizeBrowserNavigationUrl, redactKagiSessionToken @@ -52,9 +51,8 @@ export type AttachBrowserPageWebviewArgs = { addressBarInputRef: RefObject lastKnownWebviewUrlRef: MutableRefObject trackNextLoadingEventRef: MutableRefObject - clearBrowserPageAnnotationsRef: MutableRefObject<(pageId: string) => void> + invalidateBrowserAnnotationDocumentRef: MutableRefObject<() => void> onSetUrlRef: MutableRefObject - setPendingAnnotationPayload: Dispatch> setBrowserOverlayViewport: Dispatch> setAddressBarValue: Dispatch> addBrowserHistoryEntryRef: MutableRefObject< @@ -108,6 +106,7 @@ export function attachBrowserPageWebview( if (!container || !webviewContainer) { return } + args.invalidateBrowserAnnotationDocumentRef.current() const webview = ensuredWebview.webview const needsInitialNavigation = ensuredWebview.created seedLiveBrowserUrl(browserTabId, redactKagiSessionToken(browserTabUrlRef.current)) diff --git a/src/renderer/src/components/browser-pane/host-guest/bind-browser-page-webview-listeners.ts b/src/renderer/src/components/browser-pane/host-guest/bind-browser-page-webview-listeners.ts index f8699324889..a7d786e12d3 100644 --- a/src/renderer/src/components/browser-pane/host-guest/bind-browser-page-webview-listeners.ts +++ b/src/renderer/src/components/browser-pane/host-guest/bind-browser-page-webview-listeners.ts @@ -54,9 +54,8 @@ export function bindBrowserPageWebviewListeners({ addressBarInputRef, lastKnownWebviewUrlRef, trackNextLoadingEventRef, - clearBrowserPageAnnotationsRef, + invalidateBrowserAnnotationDocumentRef, onSetUrlRef, - setPendingAnnotationPayload, setBrowserOverlayViewport, setAddressBarValue, addBrowserHistoryEntryRef, @@ -105,10 +104,9 @@ export function bindBrowserPageWebviewListeners({ trackNextLoadingEventRef, keepAddressBarFocusRef, recoveryNavigationValidationRef, - clearBrowserPageAnnotationsRef, + invalidateBrowserAnnotationDocumentRef, onUpdatePageStateRef, onSetUrlRef, - setPendingAnnotationPayload, setBrowserOverlayViewport, setAddressBarValue, focusAddressBarNow @@ -126,6 +124,7 @@ export function bindBrowserPageWebviewListeners({ webview, browserTabId, browserTabUrl, + invalidateBrowserAnnotationDocumentRef, recoveryNavigationValidationRef, activeLoadFailureRef, lastKnownWebviewUrlRef, diff --git a/src/renderer/src/components/browser-pane/host-guest/browser-page-annotation-document.test.ts b/src/renderer/src/components/browser-pane/host-guest/browser-page-annotation-document.test.ts new file mode 100644 index 00000000000..2ff2ebbab08 --- /dev/null +++ b/src/renderer/src/components/browser-pane/host-guest/browser-page-annotation-document.test.ts @@ -0,0 +1,150 @@ +// @vitest-environment happy-dom +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createBrowserMockApi, createTestStore } from '@/store/slices/browser-slice-test-harness' +import { makeAnnotation } from '@/store/slices/browser-annotation-test-fixture' +import { syncGuestAnnotationViewportBridge } from '../annotate/guest-annotation-viewport-bridge' +import { createBrowserPageWebviewLoadingHandlers } from './browser-page-webview-loading-handlers' +import { createBrowserPageWebviewNavigationHandlers } from './browser-page-webview-navigation-handlers' + +afterEach(() => vi.unstubAllGlobals()) + +function createHarness() { + const setAnnotationViewportBridge = vi.fn().mockResolvedValue(undefined) + vi.stubGlobal('window', { + api: { + ...createBrowserMockApi(vi.fn()), + browser: { ...createBrowserMockApi(vi.fn()).browser, setAnnotationViewportBridge } + } + }) + const store = createTestStore() + const pageId = store.getState().createBrowserTab('wt-1', 'https://example.com').activePageId + if (!pageId) { + throw new Error('Expected browser page') + } + store.getState().addBrowserPageAnnotation(makeAnnotation(pageId)) + const ref = (current: T) => ({ current }) + const cancelCapture = vi.fn() + const setBrowserOverlayViewport = vi.fn() + const url = ref('https://example.com/') + const webviewFixture = { + getURL: () => url.current ?? '', + getTitle: () => 'Example', + canGoBack: () => false, + canGoForward: () => false, + src: 'https://example.com/' + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these factories only use the guest methods explicitly supplied by this fixture. + const webview = webviewFixture as unknown as Electron.WebviewTag + const invalidateBrowserAnnotationDocumentRef = ref(() => { + store.getState().invalidateBrowserPageAnnotationGeometry(pageId) + cancelCapture() + syncGuestAnnotationViewportBridge({ + toolTargetId: pageId, + annotations: store.getState().browserAnnotationsByPageId[pageId], + currentDocument: { + markerIds: store.getState().browserAnnotationMarkerIdsByPageId[pageId] ?? [], + url: url.current ?? '' + }, + pendingPayload: null, + surfaceActive: true, + token: 'token' + }) + }) + const common = { + webview, + browserTabId: pageId, + faviconUrlRef: ref(null), + addressBarInputRef: ref(null), + activeLoadFailureRef: ref(null), + lastKnownWebviewUrlRef: url, + recoveryNavigationValidationRef: ref(null), + invalidateBrowserAnnotationDocumentRef, + onUpdatePageStateRef: ref(vi.fn()), + onSetUrlRef: ref((id: string, nextUrl: string) => + store.getState().setBrowserPageUrl(id, nextUrl) + ), + setBrowserOverlayViewport, + setAddressBarValue: vi.fn() + } + const loading = createBrowserPageWebviewLoadingHandlers({ + ...common, + browserTabUrlRef: ref('https://example.com/'), + addressBarValueRef: ref('https://example.com/'), + trackNextLoadingEventRef: ref(false), + keepAddressBarFocusRef: ref(false), + focusAddressBarNow: () => false + }) + const navigation = createBrowserPageWebviewNavigationHandlers({ + ...common, + browserTabUrl: 'https://example.com/', + addBrowserHistoryEntryRef: ref(vi.fn()), + annotationViewportBridgeTokenRef: ref('token') + }) + return { + store, + pageId, + loading, + navigation, + cancelCapture, + setAnnotationViewportBridge, + setBrowserOverlayViewport + } +} + +function navigationEvent( + url: string, + isInPlace = false, + isMainFrame = true +): Electron.DidStartNavigationEvent { + return Object.assign(new Event('did-start-navigation'), { + url, + isInPlace, + isMainFrame, + frameProcessId: 1, + frameRoutingId: 1 + }) +} + +describe('browser annotation document boundaries', () => { + it('retains the exact saved notes on actual same-URL loading-start, including untracked loads', () => { + const h = createHarness() + const saved = h.store.getState().browserAnnotationsByPageId[h.pageId] + h.loading.handleDidStartLoading() + expect(h.store.getState().browserAnnotationsByPageId[h.pageId]).toBe(saved) + expect(h.store.getState().browserAnnotationMarkerIdsByPageId[h.pageId]).toBeUndefined() + expect(h.cancelCapture).toHaveBeenCalledOnce() + expect(h.setBrowserOverlayViewport).toHaveBeenCalledWith({ scrollX: 0, scrollY: 0, version: 0 }) + expect(h.setAnnotationViewportBridge).toHaveBeenLastCalledWith( + expect.objectContaining({ enabled: false, emitViewport: false, markers: [] }) + ) + }) + + it('retires geometry for full and same-document main-frame navigation but not subframes', () => { + const h = createHarness() + h.navigation.handleDidStartNavigation( + navigationEvent('https://example.com/frame', false, false) + ) + expect(h.cancelCapture).not.toHaveBeenCalled() + h.navigation.handleDidStartNavigation(navigationEvent('https://example.com/#new', true)) + expect(h.cancelCapture).toHaveBeenCalledOnce() + h.store.getState().addBrowserPageAnnotation(makeAnnotation(h.pageId, 'fresh')) + h.navigation.handleDidStartNavigation(navigationEvent('https://example.com/next')) + expect(h.cancelCapture).toHaveBeenCalledTimes(2) + expect(h.store.getState().browserAnnotationsByPageId[h.pageId]).toHaveLength(2) + expect(h.store.getState().browserAnnotationMarkerIdsByPageId[h.pageId]).toBeUndefined() + }) + + it('keeps geometry retired after redirects and away/back commits, including missed start events', () => { + const h = createHarness() + const saved = h.store.getState().browserAnnotationsByPageId[h.pageId] + h.loading.handleDidStartLoading() + h.navigation.handleDidRedirectNavigation(navigationEvent('https://example.org/redirect')) + h.navigation.handleFullDidNavigate({ url: 'https://example.org/redirect', isMainFrame: true }) + h.navigation.handleFullDidNavigate({ url: 'https://example.com/', isMainFrame: true }) + expect(h.store.getState().browserAnnotationsByPageId[h.pageId]).toBe(saved) + expect(h.store.getState().browserAnnotationMarkerIdsByPageId[h.pageId]).toBeUndefined() + h.store.getState().addBrowserPageAnnotation(makeAnnotation(h.pageId, 'fresh')) + h.navigation.handleDidNavigateInPage({ url: 'https://example.com/#another', isMainFrame: true }) + expect(h.store.getState().browserAnnotationMarkerIdsByPageId[h.pageId]).toBeUndefined() + }) +}) diff --git a/src/renderer/src/components/browser-pane/host-guest/browser-page-favicon-retention.test.ts b/src/renderer/src/components/browser-pane/host-guest/browser-page-favicon-retention.test.ts index 924e7834319..40b0e122a1f 100644 --- a/src/renderer/src/components/browser-pane/host-guest/browser-page-favicon-retention.test.ts +++ b/src/renderer/src/components/browser-pane/host-guest/browser-page-favicon-retention.test.ts @@ -27,6 +27,7 @@ function createHarness(startUrl: string) { webview, browserTabId: TAB_ID, browserTabUrl: startUrl, + invalidateBrowserAnnotationDocumentRef: ref(vi.fn()), recoveryNavigationValidationRef: ref(null), activeLoadFailureRef: ref(null), // Why the destination, not the current document: Orca-driven navigations set this ref before @@ -53,10 +54,9 @@ function createHarness(startUrl: string) { trackNextLoadingEventRef: ref(true), keepAddressBarFocusRef: ref(false), recoveryNavigationValidationRef: ref(null), - clearBrowserPageAnnotationsRef: ref(vi.fn()), + invalidateBrowserAnnotationDocumentRef: ref(vi.fn()), onUpdatePageStateRef, onSetUrlRef: ref(vi.fn()), - setPendingAnnotationPayload: vi.fn(), setBrowserOverlayViewport: vi.fn(), setAddressBarValue: vi.fn(), focusAddressBarNow: () => false diff --git a/src/renderer/src/components/browser-pane/host-guest/browser-page-webview-loading-handlers.ts b/src/renderer/src/components/browser-pane/host-guest/browser-page-webview-loading-handlers.ts index b6887f119eb..31082559244 100644 --- a/src/renderer/src/components/browser-pane/host-guest/browser-page-webview-loading-handlers.ts +++ b/src/renderer/src/components/browser-pane/host-guest/browser-page-webview-loading-handlers.ts @@ -1,5 +1,4 @@ import type { Dispatch, MutableRefObject, RefObject, SetStateAction } from 'react' -import type { BrowserGrabPayload } from '../../../../../shared/browser-grab-types' import { normalizeBrowserNavigationUrl, redactKagiSessionToken @@ -35,10 +34,9 @@ export type BrowserPageWebviewLoadingHandlersArgs = { trackNextLoadingEventRef: MutableRefObject keepAddressBarFocusRef: MutableRefObject recoveryNavigationValidationRef: MutableRefObject - clearBrowserPageAnnotationsRef: MutableRefObject<(pageId: string) => void> + invalidateBrowserAnnotationDocumentRef: MutableRefObject<() => void> onUpdatePageStateRef: MutableRefObject<(tabId: string, updates: BrowserTabPageState) => void> onSetUrlRef: MutableRefObject - setPendingAnnotationPayload: Dispatch> setBrowserOverlayViewport: Dispatch> setAddressBarValue: Dispatch> focusAddressBarNow: () => boolean @@ -62,18 +60,16 @@ export function createBrowserPageWebviewLoadingHandlers({ trackNextLoadingEventRef, keepAddressBarFocusRef, recoveryNavigationValidationRef, - clearBrowserPageAnnotationsRef, + invalidateBrowserAnnotationDocumentRef, onUpdatePageStateRef, onSetUrlRef, - setPendingAnnotationPayload, setBrowserOverlayViewport, setAddressBarValue, focusAddressBarNow }: BrowserPageWebviewLoadingHandlersArgs): BrowserPageWebviewLoadingHandlers { const handleDidStartLoading = (): void => { - // Why: a reload replaces the document without changing the URL, invalidating captured element rects like a navigation does. - clearBrowserPageAnnotationsRef.current(browserTabId) - setPendingAnnotationPayload(null) + // A reload replaces geometry even when the URL stays the same. + invalidateBrowserAnnotationDocumentRef.current() setBrowserOverlayViewport({ scrollX: 0, scrollY: 0, version: 0 }) if (!trackNextLoadingEventRef.current) { return diff --git a/src/renderer/src/components/browser-pane/host-guest/browser-page-webview-navigation-handlers.ts b/src/renderer/src/components/browser-pane/host-guest/browser-page-webview-navigation-handlers.ts index 240e763cf63..1c08005a6fc 100644 --- a/src/renderer/src/components/browser-pane/host-guest/browser-page-webview-navigation-handlers.ts +++ b/src/renderer/src/components/browser-pane/host-guest/browser-page-webview-navigation-handlers.ts @@ -28,6 +28,7 @@ export type BrowserPageWebviewNavigationHandlersArgs = { webview: Electron.WebviewTag browserTabId: string browserTabUrl: string + invalidateBrowserAnnotationDocumentRef: MutableRefObject<() => void> recoveryNavigationValidationRef: MutableRefObject activeLoadFailureRef: MutableRefObject lastKnownWebviewUrlRef: MutableRefObject @@ -57,6 +58,7 @@ export function createBrowserPageWebviewNavigationHandlers({ webview, browserTabId, browserTabUrl, + invalidateBrowserAnnotationDocumentRef, recoveryNavigationValidationRef, activeLoadFailureRef, lastKnownWebviewUrlRef, @@ -92,6 +94,9 @@ export function createBrowserPageWebviewNavigationHandlers({ } const handleDidStartNavigation = (event: Electron.DidStartNavigationEvent): void => { + if (event.isMainFrame && event.url) { + invalidateBrowserAnnotationDocumentRef.current() + } if (!event.isMainFrame || event.isInPlace || !event.url) { return } @@ -126,6 +131,9 @@ export function createBrowserPageWebviewNavigationHandlers({ const browserModelUrl = redactKagiSessionToken(currentUrl) const normalizedBrowserModelUrl = normalizeBrowserNavigationUrl(browserModelUrl) ?? browserModelUrl + if (lastKnownWebviewUrlRef.current !== normalizedBrowserModelUrl) { + invalidateBrowserAnnotationDocumentRef.current() + } lastKnownWebviewUrlRef.current = normalizedBrowserModelUrl rememberLiveBrowserUrl(browserTabId, browserModelUrl) // Why: don't overwrite in-progress typing (see above). diff --git a/src/renderer/src/components/browser-pane/host-guest/use-browser-page-webview-lifecycle.ts b/src/renderer/src/components/browser-pane/host-guest/use-browser-page-webview-lifecycle.ts index 72859a415b5..5e5a8036f80 100644 --- a/src/renderer/src/components/browser-pane/host-guest/use-browser-page-webview-lifecycle.ts +++ b/src/renderer/src/components/browser-pane/host-guest/use-browser-page-webview-lifecycle.ts @@ -54,7 +54,7 @@ export function useBrowserPageWebviewLifecycle({ onUpdatePageState, onSetUrl, setAddressBarValue, - setPendingAnnotationPayload, + cancelPendingBrowserCapture, setBrowserOverlayViewport, setFindOpen, focusAddressBarNow, @@ -62,7 +62,6 @@ export function useBrowserPageWebviewLifecycle({ paneZoomLevelRef, setBrowserZoomPercent, pendingAnnotationPayload, - browserAnnotationsLength, inputLocked, faviconUrl, webviewRef, @@ -96,7 +95,7 @@ export function useBrowserPageWebviewLifecycle({ onUpdatePageState: (tabId: string, updates: BrowserTabPageState) => void onSetUrl: BrowserPageUrlSetter setAddressBarValue: Dispatch> - setPendingAnnotationPayload: Dispatch> + cancelPendingBrowserCapture: () => void setBrowserOverlayViewport: Dispatch> setFindOpen: Dispatch> focusAddressBarNow: () => boolean @@ -104,7 +103,6 @@ export function useBrowserPageWebviewLifecycle({ paneZoomLevelRef: MutableRefObject setBrowserZoomPercent: Dispatch> pendingAnnotationPayload: BrowserGrabPayload | null - browserAnnotationsLength: number inputLocked: boolean faviconUrl: string | null webviewRef: MutableRefObject @@ -139,9 +137,10 @@ export function useBrowserPageWebviewLifecycle({ const browserAnnotations = useAppStore( (s) => s.browserAnnotationsByPageId[browserTabId] ?? EMPTY_BROWSER_ANNOTATIONS ) - const browserAnnotationsRef = useRef(browserAnnotations) - const clearBrowserPageAnnotations = useAppStore((s) => s.clearBrowserPageAnnotations) - const clearBrowserPageAnnotationsRef = useRef(clearBrowserPageAnnotations) + const browserAnnotationMarkerIds = useAppStore( + (s) => s.browserAnnotationMarkerIdsByPageId[browserTabId] + ) + const invalidatePendingCaptureRef = useRef(cancelPendingBrowserCapture) useLayoutEffect(() => { browserTabLoadingRef.current = browserTabLoading @@ -149,13 +148,11 @@ export function useBrowserPageWebviewLifecycle({ viewportPresetIdRef.current = viewportPresetId isActiveRef.current = isActive pendingAnnotationPayloadRef.current = pendingAnnotationPayload - browserAnnotationsRef.current = browserAnnotations - clearBrowserPageAnnotationsRef.current = clearBrowserPageAnnotations + invalidatePendingCaptureRef.current = cancelPendingBrowserCapture isPaintableRef.current = isPaintable }, [ - browserAnnotations, browserTabLoading, - clearBrowserPageAnnotations, + cancelPendingBrowserCapture, inputLocked, isActive, isPaintable, @@ -211,14 +208,30 @@ export function useBrowserPageWebviewLifecycle({ ) const syncBrowserAnnotationViewportBridge = useCallback((): void => { + const state = useAppStore.getState() syncGuestAnnotationViewportBridge({ toolTargetId: browserTabId, - annotations: browserAnnotationsRef.current, + annotations: state.browserAnnotationsByPageId[browserTabId] ?? EMPTY_BROWSER_ANNOTATIONS, + currentDocument: { + markerIds: state.browserAnnotationMarkerIdsByPageId[browserTabId] ?? [], + url: browserTabUrlRef.current + }, pendingPayload: pendingAnnotationPayloadRef.current, surfaceActive: isActiveRef.current, token: annotationViewportBridgeTokenRef.current }) - }, [browserTabId]) + }, [browserTabId, browserTabUrlRef]) + + const invalidateBrowserAnnotationDocument = useCallback((): void => { + useAppStore.getState().invalidateBrowserPageAnnotationGeometry(browserTabId) + invalidatePendingCaptureRef.current() + pendingAnnotationPayloadRef.current = null + syncBrowserAnnotationViewportBridge() + }, [browserTabId, syncBrowserAnnotationViewportBridge]) + const invalidateBrowserAnnotationDocumentRef = useRef(invalidateBrowserAnnotationDocument) + useLayoutEffect(() => { + invalidateBrowserAnnotationDocumentRef.current = invalidateBrowserAnnotationDocument + }, [invalidateBrowserAnnotationDocument]) // Why: browserTab.url excluded from deps (changes every navigation → would destroy/recreate the webview); URL logic reads browserTabUrlRef. useEffect(() => { @@ -255,9 +268,8 @@ export function useBrowserPageWebviewLifecycle({ addressBarInputRef, lastKnownWebviewUrlRef, trackNextLoadingEventRef, - clearBrowserPageAnnotationsRef, + invalidateBrowserAnnotationDocumentRef, onSetUrlRef, - setPendingAnnotationPayload, setBrowserOverlayViewport, setAddressBarValue, addBrowserHistoryEntryRef, @@ -295,8 +307,10 @@ export function useBrowserPageWebviewLifecycle({ useEffect(() => { syncBrowserAnnotationViewportBridge() }, [ - browserAnnotationsLength, + browserAnnotations, + browserAnnotationMarkerIds, browserTabId, + browserTabUrl, isActive, pendingAnnotationPayload, syncBrowserAnnotationViewportBridge diff --git a/src/renderer/src/store/slices/browser-annotation-retention.test.ts b/src/renderer/src/store/slices/browser-annotation-retention.test.ts new file mode 100644 index 00000000000..b39050c99db --- /dev/null +++ b/src/renderer/src/store/slices/browser-annotation-retention.test.ts @@ -0,0 +1,144 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { GRAB_BUDGET } from '../../../../shared/browser-grab-types' +import { createBrowserMockApi, createTestStore } from './browser-slice-test-harness' +import { makeAnnotation } from './browser-annotation-test-fixture' + +vi.mock('@/runtime/web-runtime-session', () => ({ createWebRuntimeSessionBrowserTab: vi.fn() })) + +function createAnnotatedPage() { + vi.stubGlobal('window', { api: createBrowserMockApi(vi.fn()) }) + const store = createTestStore() + store.setState({ repos: [], folderWorkspaces: [] }) + const workspace = store.getState().createBrowserTab('wt-1', 'https://example.com') + const pageId = workspace.activePageId + if (!pageId) { + throw new Error('Expected browser page') + } + store.getState().addBrowserPageAnnotation(makeAnnotation(pageId)) + return { store, workspace, pageId } +} + +afterEach(() => vi.unstubAllGlobals()) + +describe('saved browser note lifetime and marker eligibility', () => { + it('retires same-URL geometry without changing the saved array or object references', () => { + const { store, pageId } = createAnnotatedPage() + const saved = store.getState().browserAnnotationsByPageId[pageId] + const note = saved[0] + expect(store.getState().browserAnnotationMarkerIdsByPageId[pageId]).toEqual([note.id]) + + store.getState().setBrowserPageUrl(pageId, 'https://example.com') + expect(store.getState().browserAnnotationMarkerIdsByPageId[pageId]).toEqual([note.id]) + store.getState().invalidateBrowserPageAnnotationGeometry(pageId) + + expect(store.getState().browserAnnotationsByPageId[pageId]).toBe(saved) + expect(store.getState().browserAnnotationsByPageId[pageId][0]).toBe(note) + expect(store.getState().browserAnnotationMarkerIdsByPageId[pageId]).toBeUndefined() + const listener = vi.fn() + const unsubscribe = store.subscribe(listener) + store.getState().invalidateBrowserPageAnnotationGeometry(pageId) + expect(listener).not.toHaveBeenCalled() + unsubscribe() + + store.getState().addBrowserPageAnnotation(makeAnnotation(pageId, 'fresh')) + expect(store.getState().browserAnnotationsByPageId[pageId][0]).toBe(note) + expect(store.getState().browserAnnotationMarkerIdsByPageId[pageId]).toEqual(['fresh']) + }) + + it('shares the 20-note budget across URLs and bounds marker IDs to retained notes', () => { + const { store, pageId } = createAnnotatedPage() + for (let index = 0; index < GRAB_BUDGET.annotationsMaxPerPage + 3; index++) { + const url = `https://example.com/page-${index % 2}` + store.getState().setBrowserPageUrl(pageId, url) + const note = makeAnnotation(pageId, `note-${index}`) + note.payload.page.sanitizedUrl = url + store.getState().addBrowserPageAnnotation(note) + } + const saved = store.getState().browserAnnotationsByPageId[pageId] + const eligible = store.getState().browserAnnotationMarkerIdsByPageId[pageId] + expect(saved).toHaveLength(20) + expect(saved[0].id).toBe('note-3') + expect(new Set(saved.map((note) => note.payload.page.sanitizedUrl)).size).toBe(2) + expect(eligible).toEqual(['note-22']) + expect(eligible.every((id) => saved.some((note) => note.id === id))).toBe(true) + + store.getState().deleteBrowserPageAnnotation(pageId, 'note-22') + expect(store.getState().browserAnnotationMarkerIdsByPageId[pageId]).toBeUndefined() + store.getState().clearBrowserPageAnnotations(pageId) + expect(store.getState().browserAnnotationsByPageId[pageId]).toBeUndefined() + }) + + it('prunes eligible IDs when the oldest current-document notes are evicted', () => { + const { store, pageId } = createAnnotatedPage() + for (let index = 0; index < 22; index++) { + store.getState().addBrowserPageAnnotation(makeAnnotation(pageId, `note-${index}`)) + } + expect(store.getState().browserAnnotationMarkerIdsByPageId[pageId]).toEqual( + store.getState().browserAnnotationsByPageId[pageId].map((note) => note.id) + ) + expect(store.getState().browserAnnotationMarkerIdsByPageId[pageId]).toHaveLength(20) + }) + + it('removes an accepted delivery snapshot after navigation while preserving later edits and additions', async () => { + const { store, pageId } = createAnnotatedPage() + store.getState().addBrowserPageAnnotation(makeAnnotation(pageId, 'edited')) + const delivered = store.getState().browserAnnotationsByPageId[pageId] + let accept!: () => void + const delivery = new Promise((resolve) => { + accept = resolve + }).then(() => { + store.getState().removeDeliveredBrowserPageAnnotations(pageId, delivered) + }) + store.getState().setBrowserPageUrl(pageId, 'https://example.com/next') + store.getState().invalidateBrowserPageAnnotationGeometry(pageId) + store.getState().updateBrowserPageAnnotation(pageId, 'edited', { + comment: 'Edited while sending', + intent: 'fix' + }) + store.getState().addBrowserPageAnnotation(makeAnnotation(pageId, 'fresh')) + accept() + await delivery + + const remaining = store.getState().browserAnnotationsByPageId[pageId] + expect(remaining.map((note) => note.id)).toEqual(['edited', 'fresh']) + expect(remaining[0].comment).toBe('Edited while sending') + expect(store.getState().browserAnnotationMarkerIdsByPageId[pageId]).toEqual(['fresh']) + store.getState().removeDeliveredBrowserPageAnnotations(pageId, remaining) + expect(store.getState().browserAnnotationsByPageId[pageId]).toBeUndefined() + expect(store.getState().browserAnnotationMarkerIdsByPageId[pageId]).toBeUndefined() + }) + + it.each(['page', 'workspace', 'conversion', 'hydration'] as const)( + 'cleans up note and marker authority on %s teardown', + (mode) => { + const { store, workspace, pageId } = createAnnotatedPage() + if (mode === 'page') { + store.getState().closeBrowserPage(pageId) + } + if (mode === 'workspace') { + store.getState().closeBrowserTab(workspace.id) + } + if (mode === 'conversion') { + store.getState().convertBrowserPage(pageId, { + kind: 'workspace-doc', + docLocation: { + kind: 'workspace-doc', + worktreeId: 'wt-1', + filePath: '/workspace/index.html' + } + }) + } + if (mode === 'hydration') { + store.getState().hydrateBrowserSession({ + activeRepoId: null, + activeWorktreeId: null, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {} + }) + } + expect(store.getState().browserAnnotationsByPageId[pageId]).toBeUndefined() + expect(store.getState().browserAnnotationMarkerIdsByPageId[pageId]).toBeUndefined() + } + ) +}) diff --git a/src/renderer/src/store/slices/browser-annotation-test-fixture.ts b/src/renderer/src/store/slices/browser-annotation-test-fixture.ts new file mode 100644 index 00000000000..439f0139e8f --- /dev/null +++ b/src/renderer/src/store/slices/browser-annotation-test-fixture.ts @@ -0,0 +1,60 @@ +import type { BrowserPageAnnotation } from '../../../../shared/browser-grab-types' + +export function makeAnnotation(pageId: string, id = 'annotation-1'): BrowserPageAnnotation { + return { + id, + browserPageId: pageId, + comment: 'Fix this button', + intent: 'fix', + priority: 'important', + createdAt: '2026-05-15T00:00:00.000Z', + payload: { + page: { + sanitizedUrl: 'https://example.com', + title: 'Example', + viewportWidth: 1280, + viewportHeight: 720, + scrollX: 0, + scrollY: 0, + devicePixelRatio: 1, + capturedAt: '2026-05-15T00:00:00.000Z' + }, + target: { + tagName: 'button', + selector: 'button', + textSnippet: 'Submit', + htmlSnippet: '', + attributes: {}, + accessibility: { + role: 'button', + accessibleName: 'Submit', + ariaLabel: null, + ariaLabelledBy: null + }, + rectViewport: { x: 0, y: 0, width: 100, height: 40 }, + rectPage: { x: 0, y: 0, width: 100, height: 40 }, + computedStyles: { + display: 'inline-flex', + position: 'static', + width: '100px', + height: '40px', + margin: '0px', + padding: '0px', + color: 'rgb(0, 0, 0)', + backgroundColor: 'rgba(0, 0, 0, 0)', + border: '0px none', + borderRadius: '0px', + fontFamily: 'Geist', + fontSize: '14px', + fontWeight: '400', + lineHeight: '20px', + textAlign: 'center', + zIndex: 'auto' + } + }, + nearbyText: [], + ancestorPath: [], + screenshot: null + } + } +} diff --git a/src/renderer/src/store/slices/browser.test.ts b/src/renderer/src/store/slices/browser.test.ts index b961f54fbad..9cdf05e17ed 100644 --- a/src/renderer/src/store/slices/browser.test.ts +++ b/src/renderer/src/store/slices/browser.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import type { AppState } from '../types' import { GRAB_BUDGET, type BrowserPageAnnotation } from '../../../../shared/browser-grab-types' +import { makeAnnotation } from './browser-annotation-test-fixture' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import { createBrowserMockApi, @@ -45,65 +46,6 @@ function seedUnifiedBrowserTab( }) } -function makeAnnotation(pageId: string, id = 'annotation-1'): BrowserPageAnnotation { - return { - id, - browserPageId: pageId, - comment: 'Fix this button', - intent: 'fix', - priority: 'important', - createdAt: '2026-05-15T00:00:00.000Z', - payload: { - page: { - sanitizedUrl: 'https://example.com', - title: 'Example', - viewportWidth: 1280, - viewportHeight: 720, - scrollX: 0, - scrollY: 0, - devicePixelRatio: 1, - capturedAt: '2026-05-15T00:00:00.000Z' - }, - target: { - tagName: 'button', - selector: 'button', - textSnippet: 'Submit', - htmlSnippet: '', - attributes: {}, - accessibility: { - role: 'button', - accessibleName: 'Submit', - ariaLabel: null, - ariaLabelledBy: null - }, - rectViewport: { x: 0, y: 0, width: 100, height: 40 }, - rectPage: { x: 0, y: 0, width: 100, height: 40 }, - computedStyles: { - display: 'inline-flex', - position: 'static', - width: '100px', - height: '40px', - margin: '0px', - padding: '0px', - color: 'rgb(0, 0, 0)', - backgroundColor: 'rgba(0, 0, 0, 0)', - border: '0px none', - borderRadius: '0px', - fontFamily: 'Geist', - fontSize: '14px', - fontWeight: '400', - lineHeight: '20px', - textAlign: 'center', - zIndex: 'auto' - } - }, - nearbyText: [], - ancestorPath: [], - screenshot: null - } - } -} - describe('createBrowserSlice annotations', () => { it('announces the store-selected browser page before its guest is destroyed', () => { const store = createTestStore() @@ -164,7 +106,7 @@ describe('createBrowserSlice annotations', () => { }) }) - it('clears page annotations when the browser page URL changes', () => { + it('retains saved page annotations while retiring geometry when the URL changes', () => { const store = createTestStore() const tab = store.getState().createBrowserTab('wt-1', 'https://example.com') const pageId = tab.activePageId @@ -175,9 +117,13 @@ describe('createBrowserSlice annotations', () => { store.getState().addBrowserPageAnnotation(makeAnnotation(pageId)) expect(store.getState().browserAnnotationsByPageId[pageId]).toHaveLength(1) + const saved = store.getState().browserAnnotationsByPageId[pageId] store.getState().setBrowserPageUrl(pageId, 'https://example.com/next') + store.getState().setBrowserPageUrl(pageId, 'https://example.com') - expect(store.getState().browserAnnotationsByPageId[pageId]).toBeUndefined() + expect(store.getState().browserAnnotationsByPageId[pageId]).toBe(saved) + expect(saved?.[0]?.payload.page.sanitizedUrl).toBe('https://example.com') + expect(store.getState().browserAnnotationMarkerIdsByPageId[pageId]).toBeUndefined() }) it('can commit a navigation URL without hiding an active recovery error', () => { diff --git a/src/renderer/src/store/slices/browser/browser-close-actions.ts b/src/renderer/src/store/slices/browser/browser-close-actions.ts index 33b9c4bb681..b2e0eac483d 100644 --- a/src/renderer/src/store/slices/browser/browser-close-actions.ts +++ b/src/renderer/src/store/slices/browser/browser-close-actions.ts @@ -55,11 +55,13 @@ export function createBrowserCloseActions( const nextBrowserPagesByWorkspace = { ...s.browserPagesByWorkspace } delete nextBrowserPagesByWorkspace[tabId] const nextBrowserAnnotationsByPageId = { ...s.browserAnnotationsByPageId } + const nextBrowserAnnotationMarkerIdsByPageId = { ...s.browserAnnotationMarkerIdsByPageId } const nextBrowserCertificateFailuresByPageId = { ...s.browserCertificateFailuresByPageId } for (const page of closedPages) { delete nextBrowserAnnotationsByPageId[page.id] + delete nextBrowserAnnotationMarkerIdsByPageId[page.id] delete nextBrowserCertificateFailuresByPageId[page.id] } docPageIdsToRelease = closedPages.filter((page) => page.docLocation).map((page) => page.id) @@ -168,7 +170,8 @@ export function createBrowserCloseActions( recentlyClosedBrowserPagesByWorkspace: nextRecentlyClosedBrowserPagesByWorkspace, remoteBrowserPageHandlesByPageId: nextRemoteBrowserPageHandlesByPageId, browserCertificateFailuresByPageId: nextBrowserCertificateFailuresByPageId, - browserAnnotationsByPageId: nextBrowserAnnotationsByPageId + browserAnnotationsByPageId: nextBrowserAnnotationsByPageId, + browserAnnotationMarkerIdsByPageId: nextBrowserAnnotationMarkerIdsByPageId } }) diff --git a/src/renderer/src/store/slices/browser/browser-hydration-actions.ts b/src/renderer/src/store/slices/browser/browser-hydration-actions.ts index 13beee05872..defeee551a6 100644 --- a/src/renderer/src/store/slices/browser/browser-hydration-actions.ts +++ b/src/renderer/src/store/slices/browser/browser-hydration-actions.ts @@ -207,6 +207,7 @@ export function createBrowserHydrationActions( buildRestoredRemoteBrowserPageHandles(browserPagesByWorkspace), browserCertificateFailuresByPageId: {}, browserAnnotationsByPageId: {}, + browserAnnotationMarkerIdsByPageId: {}, browserUrlHistory: normalizeBrowserHistoryEntries(session.browserUrlHistory ?? []), workspaceDocHistory: normalizeWorkspaceDocHistoryEntries( session.workspaceDocHistory ?? [] diff --git a/src/renderer/src/store/slices/browser/browser-page-annotation.ts b/src/renderer/src/store/slices/browser/browser-page-annotation.ts index c8be505ae2d..47b83f3ce20 100644 --- a/src/renderer/src/store/slices/browser/browser-page-annotation.ts +++ b/src/renderer/src/store/slices/browser/browser-page-annotation.ts @@ -11,8 +11,31 @@ export function sanitizeBrowserPageAnnotation( : annotation.comment, payload: { ...annotation.payload, - // Why: annotations persist to disk; null the transient screenshot to avoid retaining megabytes per note. + // Saved notes must not retain transient screenshot bytes. screenshot: null } } } + +export function retainBrowserAnnotationMarkerIds( + markersByPageId: Record, + pageId: string, + annotations: readonly BrowserPageAnnotation[], + addedId?: string +): Record { + const existingIds = markersByPageId[pageId] ?? [] + const retainedIds = new Set(annotations.map((annotation) => annotation.id)) + const nextIds = [...new Set(addedId ? [...existingIds, addedId] : existingIds)].filter((id) => + retainedIds.has(id) + ) + if (nextIds.length === existingIds.length && nextIds.every((id, i) => id === existingIds[i])) { + return markersByPageId + } + const nextByPageId = { ...markersByPageId } + if (nextIds.length > 0) { + nextByPageId[pageId] = nextIds + } else { + delete nextByPageId[pageId] + } + return nextByPageId +} diff --git a/src/renderer/src/store/slices/browser/browser-page-conversion-actions.ts b/src/renderer/src/store/slices/browser/browser-page-conversion-actions.ts index 53156c04dd1..11a78f5f17b 100644 --- a/src/renderer/src/store/slices/browser/browser-page-conversion-actions.ts +++ b/src/renderer/src/store/slices/browser/browser-page-conversion-actions.ts @@ -59,7 +59,9 @@ export function createBrowserPageConversionActions( const nextRemoteBrowserPageHandlesByPageId = { ...s.remoteBrowserPageHandlesByPageId } delete nextRemoteBrowserPageHandlesByPageId[plan.oldPage.id] const nextBrowserAnnotationsByPageId = { ...s.browserAnnotationsByPageId } + const nextBrowserAnnotationMarkerIdsByPageId = { ...s.browserAnnotationMarkerIdsByPageId } delete nextBrowserAnnotationsByPageId[plan.oldPage.id] + delete nextBrowserAnnotationMarkerIdsByPageId[plan.oldPage.id] const nextBrowserCertificateFailuresByPageId = { ...s.browserCertificateFailuresByPageId } delete nextBrowserCertificateFailuresByPageId[plan.oldPage.id] return { @@ -85,7 +87,8 @@ export function createBrowserPageConversionActions( ), remoteBrowserPageHandlesByPageId: nextRemoteBrowserPageHandlesByPageId, browserCertificateFailuresByPageId: nextBrowserCertificateFailuresByPageId, - browserAnnotationsByPageId: nextBrowserAnnotationsByPageId + browserAnnotationsByPageId: nextBrowserAnnotationsByPageId, + browserAnnotationMarkerIdsByPageId: nextBrowserAnnotationMarkerIdsByPageId } }) // Why the casts: the assignments happen inside set()'s callback, which TS's flow analysis does diff --git a/src/renderer/src/store/slices/browser/browser-page-create-actions.ts b/src/renderer/src/store/slices/browser/browser-page-create-actions.ts index d5562b3e59a..bb31d191a2c 100644 --- a/src/renderer/src/store/slices/browser/browser-page-create-actions.ts +++ b/src/renderer/src/store/slices/browser/browser-page-create-actions.ts @@ -140,7 +140,9 @@ export function createBrowserPageCreateActions( } delete nextRemoteBrowserPageHandlesByPageId[pageId] const nextBrowserAnnotationsByPageId = { ...s.browserAnnotationsByPageId } + const nextBrowserAnnotationMarkerIdsByPageId = { ...s.browserAnnotationMarkerIdsByPageId } delete nextBrowserAnnotationsByPageId[pageId] + delete nextBrowserAnnotationMarkerIdsByPageId[pageId] const nextBrowserCertificateFailuresByPageId = { ...s.browserCertificateFailuresByPageId } @@ -178,7 +180,8 @@ export function createBrowserPageCreateActions( ), remoteBrowserPageHandlesByPageId: nextRemoteBrowserPageHandlesByPageId, browserCertificateFailuresByPageId: nextBrowserCertificateFailuresByPageId, - browserAnnotationsByPageId: nextBrowserAnnotationsByPageId + browserAnnotationsByPageId: nextBrowserAnnotationsByPageId, + browserAnnotationMarkerIdsByPageId: nextBrowserAnnotationMarkerIdsByPageId } }) diff --git a/src/renderer/src/store/slices/browser/browser-page-metadata-actions.ts b/src/renderer/src/store/slices/browser/browser-page-metadata-actions.ts index d5c1675621c..48ef5426846 100644 --- a/src/renderer/src/store/slices/browser/browser-page-metadata-actions.ts +++ b/src/renderer/src/store/slices/browser/browser-page-metadata-actions.ts @@ -6,7 +6,11 @@ import type { RemoteBrowserPageHandle } from './browser-slice-contract' import { findPage, findWorkspace } from '../browser-page-records' -import { sanitizeBrowserPageAnnotation } from './browser-page-annotation' +import { + retainBrowserAnnotationMarkerIds, + sanitizeBrowserPageAnnotation +} from './browser-page-annotation' +import { omitRecordKeys } from '../worktrees/teardown/record-key-omission' export function createBrowserPageMetadataActions( set: BrowserSliceSet, @@ -20,6 +24,7 @@ export function createBrowserPageMetadataActions( | 'updateBrowserPageAnnotation' | 'deleteBrowserPageAnnotation' | 'clearBrowserPageAnnotations' + | 'invalidateBrowserPageAnnotationGeometry' | 'removeDeliveredBrowserPageAnnotations' > { return { @@ -81,7 +86,13 @@ export function createBrowserPageMetadataActions( browserAnnotationsByPageId: { ...s.browserAnnotationsByPageId, [annotation.browserPageId]: next - } + }, + browserAnnotationMarkerIdsByPageId: retainBrowserAnnotationMarkerIds( + s.browserAnnotationMarkerIdsByPageId, + annotation.browserPageId, + next, + annotation.id + ) } }), @@ -116,17 +127,35 @@ export function createBrowserPageMetadataActions( } else { delete nextByPageId[pageId] } - return { browserAnnotationsByPageId: nextByPageId } + return { + browserAnnotationsByPageId: nextByPageId, + browserAnnotationMarkerIdsByPageId: retainBrowserAnnotationMarkerIds( + s.browserAnnotationMarkerIdsByPageId, + pageId, + next + ) + } }), clearBrowserPageAnnotations: (pageId) => set((s) => { - if (!s.browserAnnotationsByPageId[pageId]?.length) { + const notes = omitRecordKeys(s.browserAnnotationsByPageId, [pageId]) + const markers = omitRecordKeys(s.browserAnnotationMarkerIdsByPageId, [pageId]) + if ( + notes === s.browserAnnotationsByPageId && + markers === s.browserAnnotationMarkerIdsByPageId + ) { return s } - const nextByPageId = { ...s.browserAnnotationsByPageId } - delete nextByPageId[pageId] - return { browserAnnotationsByPageId: nextByPageId } + return { browserAnnotationsByPageId: notes, browserAnnotationMarkerIdsByPageId: markers } + }), + + invalidateBrowserPageAnnotationGeometry: (pageId) => + set((s) => { + const markers = omitRecordKeys(s.browserAnnotationMarkerIdsByPageId, [pageId]) + return markers === s.browserAnnotationMarkerIdsByPageId + ? s + : { browserAnnotationMarkerIdsByPageId: markers } }), // Identity matching preserves edits and additions made during delivery. @@ -144,7 +173,14 @@ export function createBrowserPageMetadataActions( } else { delete nextByPageId[pageId] } - return { browserAnnotationsByPageId: nextByPageId } + return { + browserAnnotationsByPageId: nextByPageId, + browserAnnotationMarkerIdsByPageId: retainBrowserAnnotationMarkerIds( + s.browserAnnotationMarkerIdsByPageId, + pageId, + remaining + ) + } }) } } diff --git a/src/renderer/src/store/slices/browser/browser-page-state-actions.ts b/src/renderer/src/store/slices/browser/browser-page-state-actions.ts index 83aa3c23673..2ad9e6c3387 100644 --- a/src/renderer/src/store/slices/browser/browser-page-state-actions.ts +++ b/src/renderer/src/store/slices/browser/browser-page-state-actions.ts @@ -187,8 +187,8 @@ export function createBrowserPageStateActions( // and a document's url is blank by construction. A grant committed here would reach // persistence, the publish boundary and the address bar, exactly as at the other two doors. const nextPageUrl = page.docLocation ? ORCA_BROWSER_BLANK_URL : nextUrl - // Why: annotations point at DOM coords of the loaded document; a real URL change invalidates those markers. - const shouldClearAnnotations = normalizeUrl(page.url) !== nextPageUrl + // Saved feedback survives navigation; its old document coordinates do not. + const shouldInvalidateGeometry = normalizeUrl(page.url) !== nextPageUrl const nextPages = (s.browserPagesByWorkspace[workspace.id] ?? []).map((entry) => entry.id === pageId ? { @@ -203,11 +203,11 @@ export function createBrowserPageStateActions( : entry ) const nextWorkspace = mirrorWorkspaceFromActivePage(workspace, nextPages) - const nextBrowserAnnotationsByPageId = shouldClearAnnotations - ? { ...s.browserAnnotationsByPageId } - : s.browserAnnotationsByPageId - if (shouldClearAnnotations) { - delete nextBrowserAnnotationsByPageId[pageId] + const nextBrowserAnnotationMarkerIdsByPageId = shouldInvalidateGeometry + ? { ...s.browserAnnotationMarkerIdsByPageId } + : s.browserAnnotationMarkerIdsByPageId + if (shouldInvalidateGeometry) { + delete nextBrowserAnnotationMarkerIdsByPageId[pageId] } return { browserPagesByWorkspace: { @@ -220,8 +220,8 @@ export function createBrowserPageStateActions( (tab) => (tab.id === workspace.id ? nextWorkspace : tab) ) }, - ...(shouldClearAnnotations - ? { browserAnnotationsByPageId: nextBrowserAnnotationsByPageId } + ...(shouldInvalidateGeometry + ? { browserAnnotationMarkerIdsByPageId: nextBrowserAnnotationMarkerIdsByPageId } : {}) } }) diff --git a/src/renderer/src/store/slices/browser/browser-slice-contract.ts b/src/renderer/src/store/slices/browser/browser-slice-contract.ts index 3cbeba14d98..9baa4c1bc40 100644 --- a/src/renderer/src/store/slices/browser/browser-slice-contract.ts +++ b/src/renderer/src/store/slices/browser/browser-slice-contract.ts @@ -112,6 +112,7 @@ export type BrowserSlice = { browserPagesByWorkspace: Record browserCertificateFailuresByPageId: Record browserAnnotationsByPageId: Record + browserAnnotationMarkerIdsByPageId: Record remoteBrowserPageHandlesByPageId: Record /** * Closes of client-hosted pages their owning runtime never heard, keyed by environment. @@ -196,6 +197,7 @@ export type BrowserSlice = { ) => void deleteBrowserPageAnnotation: (pageId: string, annotationId: string) => void clearBrowserPageAnnotations: (pageId: string) => void + invalidateBrowserPageAnnotationGeometry: (pageId: string) => void removeDeliveredBrowserPageAnnotations: ( pageId: string, deliveredAnnotations: readonly BrowserPageAnnotation[] diff --git a/src/renderer/src/store/slices/browser/create-browser-slice.ts b/src/renderer/src/store/slices/browser/create-browser-slice.ts index 13519fc5b3a..a25310694d7 100644 --- a/src/renderer/src/store/slices/browser/create-browser-slice.ts +++ b/src/renderer/src/store/slices/browser/create-browser-slice.ts @@ -21,6 +21,7 @@ export const createBrowserSlice: StateCreator = browserPagesByWorkspace: {}, browserCertificateFailuresByPageId: {}, browserAnnotationsByPageId: {}, + browserAnnotationMarkerIdsByPageId: {}, remoteBrowserPageHandlesByPageId: {}, clientHostedBrowserCloseIntentsByEnvironment: {}, activeBrowserTabId: null, diff --git a/src/renderer/src/store/slices/worktree-removal-maps-leak.test.ts b/src/renderer/src/store/slices/worktree-removal-maps-leak.test.ts index 0c553518ef0..d882e98770b 100644 --- a/src/renderer/src/store/slices/worktree-removal-maps-leak.test.ts +++ b/src/renderer/src/store/slices/worktree-removal-maps-leak.test.ts @@ -290,6 +290,7 @@ describe('worktree removal evicts the per-worktree + per-page maps it previously [WS2]: [makePage(P2, WS2, WT2)] }, browserAnnotationsByPageId: { [P1]: [], [P2]: [] }, + browserAnnotationMarkerIdsByPageId: { [P1]: ['note-1'], [P2]: ['note-2'] }, remoteBrowserPageHandlesByPageId: { [P1]: { environmentId: 'env-1', remotePageId: 'r-1' }, [P2]: { environmentId: 'env-2', remotePageId: 'r-2' } @@ -308,6 +309,7 @@ describe('worktree removal evicts the per-worktree + per-page maps it previously const s = store.getState() // Removed worktree's workspace + page entries are gone. expect(s.browserAnnotationsByPageId[P1]).toBeUndefined() + expect(s.browserAnnotationMarkerIdsByPageId[P1]).toBeUndefined() expect(s.remoteBrowserPageHandlesByPageId[P1]).toBeUndefined() expect(s.pendingAddressBarFocusByPageId[P1]).toBeUndefined() expect(s.pendingAddressBarFocusByTabId[WS1]).toBeUndefined() @@ -315,6 +317,7 @@ describe('worktree removal evicts the per-worktree + per-page maps it previously expect(s.recentlyClosedBrowserPagesByWorkspace[WS1]).toBeUndefined() // Surviving worktree's entries remain (guard over-eviction). expect(s.browserAnnotationsByPageId[P2]).toBeDefined() + expect(s.browserAnnotationMarkerIdsByPageId[P2]).toEqual(['note-2']) expect(s.remoteBrowserPageHandlesByPageId[P2]).toBeDefined() expect(s.pendingAddressBarFocusByPageId[P2]).toBe(true) expect(s.pendingAddressBarFocusByTabId[WS2]).toBe(true) diff --git a/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts b/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts index ffa3cb6076d..26f86df2810 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts @@ -195,6 +195,7 @@ export function buildWorktreePurgeState( activeBrowserTabIdByWorktree: omitByWorktree(s.activeBrowserTabIdByWorktree), // Why: keyed by page/workspace id, only cleaned by closeBrowserTab on the single-removal path; the bulk reconcile missed them, orphaning an entry per page of externally-removed worktrees. browserAnnotationsByPageId: omitByPageId(s.browserAnnotationsByPageId), + browserAnnotationMarkerIdsByPageId: omitByPageId(s.browserAnnotationMarkerIdsByPageId), remoteBrowserPageHandlesByPageId: omitByPageId(s.remoteBrowserPageHandlesByPageId), pendingAddressBarFocusByPageId: omitByPageId(s.pendingAddressBarFocusByPageId), // createBrowserTab writes both the workspace id and the page id into this map. diff --git a/src/shared/browser-annotation-viewport-bridge.ts b/src/shared/browser-annotation-viewport-bridge.ts index f30a8a5fbef..c9846eb3116 100644 --- a/src/shared/browser-annotation-viewport-bridge.ts +++ b/src/shared/browser-annotation-viewport-bridge.ts @@ -111,6 +111,9 @@ export function buildBrowserAnnotationViewportBridgeScript({ document.removeEventListener('scroll', state.requestUpdate, true); window.removeEventListener('resize', state.requestUpdate, true); } + if (state.onBeforeUnload) { + window.removeEventListener('beforeunload', state.onBeforeUnload); + } removeOverlay(state); }; @@ -234,7 +237,8 @@ export function buildBrowserAnnotationViewportBridgeScript({ markerElements: new Map(), markers: [], shadowRoot: null, - requestUpdate: null + requestUpdate: null, + onBeforeUnload: null }; state.requestUpdate = () => { @@ -246,10 +250,17 @@ export function buildBrowserAnnotationViewportBridgeScript({ }); }; + // Navigation can hold IPC cleanup until the next document finishes loading. + state.onBeforeUnload = () => { + cleanup(state); + delete globalThis[stateKey]; + }; + updateMarkers(state, markers); window.addEventListener('scroll', state.requestUpdate, true); document.addEventListener('scroll', state.requestUpdate, true); window.addEventListener('resize', state.requestUpdate, true); + window.addEventListener('beforeunload', state.onBeforeUnload); globalThis[stateKey] = state; state.requestUpdate(); return true; diff --git a/src/shared/browser-annotation-viewport-lifecycle.test.ts b/src/shared/browser-annotation-viewport-lifecycle.test.ts new file mode 100644 index 00000000000..cc7fbe8a4d7 --- /dev/null +++ b/src/shared/browser-annotation-viewport-lifecycle.test.ts @@ -0,0 +1,74 @@ +import { runInNewContext } from 'node:vm' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { buildBrowserAnnotationViewportBridgeScript } from './browser-annotation-viewport-bridge' + +const guestAborts: (() => Promise)[] = [] + +afterEach(async () => { + await Promise.all(guestAborts.splice(0).map((abort) => abort())) +}) + +async function createGuest() { + const { Window } = await import('happy-dom') + const window = new Window() + guestAborts.push(() => window.happyDOM.abort()) + const requestAnimationFrame = vi.fn(() => 17) + const cancelAnimationFrame = vi.fn() + const context = { window, document: window.document, requestAnimationFrame, cancelAnimationFrame } + const install = (enabled = true): void => { + runInNewContext( + buildBrowserAnnotationViewportBridgeScript({ + emitViewport: false, + enabled, + token: 'lifecycle-test-token', + markers: [ + { + id: 'saved-note', + index: 0, + isFixed: false, + rectPage: { x: 10, y: 20, width: 80, height: 30 }, + rectViewport: { x: 10, y: 20, width: 80, height: 30 } + } + ] + }), + context + ) + } + const markerHosts = (): number => + window.document.querySelectorAll('[data-orca-browser-annotation-overlay]').length + return { window, context, install, markerHosts, requestAnimationFrame, cancelAnimationFrame } +} + +describe('browser annotation guest document lifecycle', () => { + it('retires visible markers before a replacement document finishes loading', async () => { + const guest = await createGuest() + guest.install() + expect(guest.markerHosts()).toBe(1) + + guest.window.dispatchEvent(new guest.window.Event('beforeunload')) + + expect(guest.markerHosts()).toBe(0) + expect(Reflect.has(guest.context, '__orcaBrowserAnnotationViewportBridge')).toBe(false) + expect(guest.cancelAnimationFrame).toHaveBeenCalledWith(17) + guest.requestAnimationFrame.mockClear() + guest.window.dispatchEvent(new guest.window.Event('scroll')) + guest.window.document.dispatchEvent(new guest.window.Event('scroll')) + guest.window.dispatchEvent(new guest.window.Event('resize')) + expect(guest.requestAnimationFrame).not.toHaveBeenCalled() + }) + + it('removes the old unload listener when disabled and can install fresh markers', async () => { + const guest = await createGuest() + guest.install() + guest.install(false) + expect(guest.markerHosts()).toBe(0) + guest.install() + expect(guest.markerHosts()).toBe(1) + guest.cancelAnimationFrame.mockClear() + + guest.window.dispatchEvent(new guest.window.Event('beforeunload')) + + expect(guest.markerHosts()).toBe(0) + expect(guest.cancelAnimationFrame).toHaveBeenCalledTimes(1) + }) +}) From 136b990a053333a577012d9a4caaca0785a7a13d Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:03:11 -0700 Subject: [PATCH 33/67] fix(search): negotiate supported agents across mixed host versions (#25009) Keep older request and reply parsers usable while current peers retain all supported history. Co-authored-by: nwparker --- docs/reference/remote-wire-compatibility.md | 17 ++ src/cli/handlers/search.test.ts | 18 +- ...session-search-agent-compatibility.test.ts | 30 ++- .../session-search-service-registry.test.ts | 22 +- .../session-search-service-registry.ts | 12 +- .../rpc/methods/ai-vault-search.test.ts | 34 +++ src/shared/ai-vault-search-client.ts | 39 ++-- .../ai-vault-search-status-fallback.test.ts | 128 +++++++++++ ...oder-history-search-downgrade.unit.test.ts | 2 +- ...on-search-agent-set-downgrade.unit.test.ts | 205 ++++++++++++++++++ 10 files changed, 467 insertions(+), 40 deletions(-) create mode 100644 src/shared/ai-vault-search-status-fallback.test.ts create mode 100644 tests/e2e/cross-version-wire/session-search-agent-set-downgrade.unit.test.ts diff --git a/docs/reference/remote-wire-compatibility.md b/docs/reference/remote-wire-compatibility.md index b2bcb3c40f4..c7720c68dce 100644 --- a/docs/reference/remote-wire-compatibility.md +++ b/docs/reference/remote-wire-compatibility.md @@ -111,6 +111,23 @@ provider, it puts `unsupported` on the wire and makes that host refuse its own. reply-schema fallback must never shape a param. Gate on the token instead, where the client decides what it is willing to do with an arm it does not know. +## Session search agent negotiation + +`aiVault.searchStatus` optionally advertises `supportedAgents`; current search clients +send their own `supportedAgents` with `aiVault.searchSessions`. These are string lists, +so a future provider name does not make a peer reject the capability reply. The client +narrows explicit agent filters to the host's list before calling its request parser. +The host narrows retrieval to the client's list before publishing a page. + +A peer without this field uses the frozen v1.4.211 search vocabulary. The existing +`supportsQoderHistory` flag proves CodeBuddy, ZCode, and Qoder support; +`supportsJcodeHistory` independently proves Jcode support. An explicit list takes +precedence over both flags. If only the status method is missing, the client still +searches the conservative legacy subset; other status errors propagate. Empty host +intersections keep the requested filters and use the existing no-match scope, so +consent, readiness, and unknown-scope results retain their normal precedence. +Local IPC advertises this build's full list, and every remote leg negotiates separately. + ## Enforcement `tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts` runs the real diff --git a/src/cli/handlers/search.test.ts b/src/cli/handlers/search.test.ts index c65a96ebe5c..2db6b9785fc 100644 --- a/src/cli/handlers/search.test.ts +++ b/src/cli/handlers/search.test.ts @@ -63,7 +63,17 @@ async function runSearch( ): Promise<{ call: ReturnType; output: string }> { const call = options.error ? vi.fn().mockRejectedValue(options.error) - : vi.fn().mockResolvedValue(envelope(options.result ?? resultsResponse)) + : vi + .fn() + .mockImplementation((method: string) => + Promise.resolve( + envelope( + method === 'aiVault.searchStatus' + ? statusResponse + : (options.result ?? resultsResponse) + ) + ) + ) const lines: string[] = [] vi.spyOn(console, 'log').mockImplementation((value: unknown) => { lines.push(String(value)) @@ -161,9 +171,13 @@ describe('orca search over the runtime RPC', () => { ] ] - it.each(flagCases)('sends %s', async (_name, flags, params) => { + it.each(flagCases)('sends %s', async (name, flags, params) => { const { call } = await runSearch(flags) + expect(call).toHaveBeenCalledTimes(name === 'filters' ? 2 : 1) + if (name === 'filters') { + expect(call).toHaveBeenNthCalledWith(1, 'aiVault.searchStatus', {}) + } expect(call).toHaveBeenCalledWith('aiVault.searchSessions', { ...params, supportedAgents: [...AI_VAULT_AGENTS], diff --git a/src/main/ai-vault-search/session-search-agent-compatibility.test.ts b/src/main/ai-vault-search/session-search-agent-compatibility.test.ts index a8c1ba96e75..d04cf73f667 100644 --- a/src/main/ai-vault-search/session-search-agent-compatibility.test.ts +++ b/src/main/ai-vault-search/session-search-agent-compatibility.test.ts @@ -53,9 +53,12 @@ test.each(['runtime', 'relay'] as const)( }, transport ) - ).toEqual({ kind: 'unavailable', reason: 'unsupported-agent' }) - expect(service.search).not.toHaveBeenCalled() - expect(service.reconcile).not.toHaveBeenCalled() + ).toMatchObject({ kind: 'results', hits: [], page: { cursor: null, hasMore: false } }) + expect(service.search).toHaveBeenCalledExactlyOnceWith( + { query: 'proof', limit: 20, freshness: 'wait-until-current' }, + { kind: 'resolved', paths: [''] } + ) + expect(service.reconcile).toHaveBeenCalledTimes(1) } ) @@ -84,7 +87,7 @@ test.each(['qoder', 'jcode'] as const)( ) test.each(['runtime', 'relay'] as const)( - 'does not query or reconcile an unsupported-only filter on %s', + 'keeps an unsupported-only filter narrow through normal service checks on %s', async (transport) => { const request = { query: 'proof', @@ -105,14 +108,25 @@ test.each(['runtime', 'relay'] as const)( { ...request, filters: { agents: [...request.filters.agents] } }, transport ) - ).toEqual({ kind: 'unavailable', reason: 'unsupported-agent' }) - expect(service.search).not.toHaveBeenCalled() + ).toMatchObject({ kind: 'results', hits: [], page: { cursor: null, hasMore: false } }) + expect(service.search).toHaveBeenCalledExactlyOnceWith( + { + query: 'proof', + limit: 20, + filters: { agents: ['jcode'] }, + freshness: 'wait-until-current' + }, + { kind: 'resolved', paths: [''] } + ) expect(service.status).not.toHaveBeenCalled() - expect(service.reconcile).not.toHaveBeenCalled() + expect(service.reconcile).toHaveBeenCalledTimes(1) for (const reason of ['disabled', 'not-ready'] as const) { service.search.mockResolvedValue({ kind: 'unavailable', reason }) expect( - await searchSessionService({ query: 'proof', filters: { agents: ['codex'] } }, transport) + await searchSessionService( + { query: 'proof', supportedAgents: [], filters: { agents: ['jcode'] } }, + transport + ) ).toEqual({ kind: 'unavailable', reason }) } } diff --git a/src/main/ai-vault-search/session-search-service-registry.test.ts b/src/main/ai-vault-search/session-search-service-registry.test.ts index d8fb2c081bc..8e172e5f2c1 100644 --- a/src/main/ai-vault-search/session-search-service-registry.test.ts +++ b/src/main/ai-vault-search/session-search-service-registry.test.ts @@ -86,8 +86,8 @@ describe('session search service registry', () => { { query: 'proof', supportedAgents: ['claude'], filters: { agents: [agent] } }, transport ) - ).toEqual({ kind: 'unavailable', reason: 'unsupported-agent' }) - expect(service.search).toHaveBeenCalledTimes(1) + ).toMatchObject({ kind: 'results', hits: [], page: { cursor: null, hasMore: false } }) + expect(service.search).toHaveBeenCalledTimes(2) expect(service.reconcile).not.toHaveBeenCalled() const client = createSessionSearchClient( (method, request) => @@ -114,6 +114,24 @@ describe('session search service registry', () => { ) expect((await sessionSearchServiceStatus({}, 'ipc')).supportedAgents).toEqual(AI_VAULT_AGENTS) }) + it.each(['disabled', 'not-ready'] as const)( + 'preserves %s precedence for empty negotiated host intersections', + async (reason) => { + const service = fakeSearchService() + service.search.mockResolvedValue({ kind: 'unavailable', reason }) + setSessionSearchService(service) + expect( + await searchSessionService( + { query: 'proof', supportedAgents: [], filters: { agents: ['jcode'] } }, + 'runtime' + ) + ).toEqual({ kind: 'unavailable', reason }) + expect(service.search).toHaveBeenCalledExactlyOnceWith( + { query: 'proof', limit: 20, filters: { agents: ['jcode'] } }, + { kind: 'resolved', paths: [''] } + ) + } + ) it('waits for reconcile before search, and clears its timeout', async () => { vi.useFakeTimers() const service = fakeSearchService() diff --git a/src/main/ai-vault-search/session-search-service-registry.ts b/src/main/ai-vault-search/session-search-service-registry.ts index 2f339185e64..02c448e11da 100644 --- a/src/main/ai-vault-search/session-search-service-registry.ts +++ b/src/main/ai-vault-search/session-search-service-registry.ts @@ -52,11 +52,8 @@ export async function searchSessionService( supportsJcodeHistory } ) - if (compatibleAgents.length === 0) { - return { kind: 'unavailable', reason: 'unsupported-agent' } - } const compatibleRequest = - compatibleAgents.length === agents.length + compatibleAgents.length === 0 || compatibleAgents.length === agents.length ? request : { ...request, @@ -68,12 +65,16 @@ export async function searchSessionService( const hostScope = within ? resolveSessionSearchScope(within, sessionSearchScopeCatalog()) : undefined + const retrievalScope = + compatibleAgents.length === 0 && hostScope?.kind !== 'unknown' + ? { kind: 'resolved' as const, paths: [''] } + : hostScope const freshness = request.freshness === 'wait-until-current' ? await reconcileWithin(current, freshnessTimeoutMs) : false const result = AiVaultSearchResponseSchema.parse( - await current.search(compatibleRequest, hostScope) + await current.search(compatibleRequest, retrievalScope) ) if (result.kind !== 'results') { return result @@ -81,6 +82,7 @@ export async function searchSessionService( const { debug, ...fields } = result return { ...fields, + ...(compatibleAgents.length === 0 ? { page: { cursor: null, hasMore: false } } : {}), hits: result.hits .filter((hit) => compatibleAgents.includes(hit.agent)) .map((hit) => redactForTransport(hit, transport)), diff --git a/src/main/runtime/rpc/methods/ai-vault-search.test.ts b/src/main/runtime/rpc/methods/ai-vault-search.test.ts index 16f998e84e9..c247b0f31b1 100644 --- a/src/main/runtime/rpc/methods/ai-vault-search.test.ts +++ b/src/main/runtime/rpc/methods/ai-vault-search.test.ts @@ -116,6 +116,40 @@ describe('session search runtime RPC', () => { } ) }) + it.each(['runtime', 'relay'] as const)( + 'searches when the real dispatcher is missing only status over %s', + async (transport) => { + const service = fakeSearchService() + setSessionSearchService(service) + const rpc = new RpcDispatcher({ + runtime: new OrcaRuntimeService(), + methods: AI_VAULT_METHODS.filter((method) => method.name !== 'aiVault.searchStatus') + }) + const replies: unknown[] = [] + const client = createSessionSearchClient(async (method, params) => { + const response = await rpc.dispatch({ ...request(params), method }) + replies.push(response) + if (!response.ok) { + throw Object.assign(new Error(response.error.message), { code: response.error.code }) + } + return response.result + }, transport) + expect( + await client.searchSessions({ + query: 'needle', + filters: { agents: ['claude'] } + }) + ).toMatchObject({ kind: 'results' }) + expect(replies).toMatchObject([ + { ok: false, error: { code: 'method_not_found' } }, + { ok: true } + ]) + expect(service.search).toHaveBeenCalledExactlyOnceWith( + { query: 'needle', limit: 20, filters: { agents: ['claude'] } }, + undefined + ) + } + ) it('maps the old runtime dispatcher refusal and rejects malformed responses', async () => { const legacy = dispatcher(true) const client = createSessionSearchClient(async (method, params) => { diff --git a/src/shared/ai-vault-search-client.ts b/src/shared/ai-vault-search-client.ts index c00b27e6b2c..3ca5184ed1f 100644 --- a/src/shared/ai-vault-search-client.ts +++ b/src/shared/ai-vault-search-client.ts @@ -13,10 +13,7 @@ import { redactStatusForTransport, type SessionSearchTransport } from './ai-vault-search-transport' -import { - compatibleSearchAgents, - needsSearchAgentNegotiation -} from './ai-vault-search-agent-compatibility' +import { compatibleSearchAgents } from './ai-vault-search-agent-compatibility' import { AI_VAULT_AGENTS } from './ai-vault-types' export function unavailableSessionSearchStatus(): AiVaultSearchStatus { @@ -55,12 +52,8 @@ export function createSessionSearchClient( let raw: unknown try { // IPC and its all-hosts merge are this build; each remote leg negotiates its own host. - if ( - transport !== 'ipc' && - parsed.filters?.agents && - needsSearchAgentNegotiation(parsed.filters.agents) - ) { - const status = AiVaultSearchStatusSchema.parse(await call('aiVault.searchStatus', {})) + if (transport !== 'ipc' && parsed.filters?.agents?.length) { + const status = await readSessionSearchStatus(call) const agents = compatibleSearchAgents(parsed.filters.agents, status) if (agents.length === 0) { return { kind: 'unavailable', reason: 'unsupported-agent' } @@ -90,18 +83,20 @@ export function createSessionSearchClient( ...(parsed.debug && debug ? { debug } : {}) } }, - searchStatus: async () => { - try { - return redactStatusForTransport( - AiVaultSearchStatusSchema.parse(await call('aiVault.searchStatus', {})), - transport - ) - } catch (error) { - if (isUnknownSessionSearchMethod(error)) { - return unavailableSessionSearchStatus() - } - throw error - } + searchStatus: async () => + redactStatusForTransport(await readSessionSearchStatus(call), transport) + } +} + +async function readSessionSearchStatus( + call: (method: string, params: Record) => Promise +): Promise { + try { + return AiVaultSearchStatusSchema.parse(await call('aiVault.searchStatus', {})) + } catch (error) { + if (isUnknownSessionSearchMethod(error)) { + return unavailableSessionSearchStatus() } + throw error } } diff --git a/src/shared/ai-vault-search-status-fallback.test.ts b/src/shared/ai-vault-search-status-fallback.test.ts new file mode 100644 index 00000000000..56c9118b736 --- /dev/null +++ b/src/shared/ai-vault-search-status-fallback.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it, vi } from 'vitest' +import { createSessionSearchClient, unavailableSessionSearchStatus } from './ai-vault-search-client' +import { AiVaultSearchRequestSchema as LegacyRequestSchema } from './__fixtures__/pre-qoder-search-request' +import { searchResults } from './ai-vault-search-test-fixture' + +const query = { + query: 'needle', + scope: 'conversation' as const, + limit: 7, + cursor: 'next-page', + filters: { agents: ['claude'] as const, scopePaths: ['/execution-host/folder'] } +} + +describe.each(['runtime', 'relay'] as const)('missing search status over %s', (transport) => { + it.each([-32601, 'method_not_found'])( + 'searches legacy filters after status refusal %s', + async (code) => { + const call = vi.fn(async (method: string, params: Record) => { + if (method === 'aiVault.searchStatus') { + throw { code } + } + expect(LegacyRequestSchema.parse(params)).toEqual({ + ...query, + filters: { ...query.filters, agents: ['claude'] } + }) + return searchResults() + }) + expect( + await createSessionSearchClient(call, transport).searchSessions({ + ...query, + filters: { ...query.filters, agents: ['claude'] } + }) + ).toMatchObject({ kind: 'results' }) + expect(call.mock.calls.map(([method]) => method)).toEqual([ + 'aiVault.searchStatus', + 'aiVault.searchSessions' + ]) + } + ) + + it('keeps only the conservative legacy subset when status is absent', async () => { + const call = vi.fn(async (method: string, params: Record) => { + if (method === 'aiVault.searchStatus') { + throw { code: 'method_not_found' } + } + expect(LegacyRequestSchema.parse(params)).toEqual({ + ...query, + filters: { ...query.filters, agents: ['claude'] } + }) + return searchResults() + }) + expect( + await createSessionSearchClient(call, transport).searchSessions({ + ...query, + filters: { ...query.filters, agents: ['jcode', 'qoder', 'codebuddy', 'zcode', 'claude'] } + }) + ).toMatchObject({ kind: 'results' }) + expect(call).toHaveBeenCalledTimes(2) + }) + + it('does not dispatch an unsupported-only filter after status refusal', async () => { + const call = vi.fn(async () => { + throw { code: 'method_not_found' } + }) + expect( + await createSessionSearchClient(call, transport).searchSessions({ + query: 'needle', + filters: { agents: ['jcode'] } + }) + ).toEqual({ kind: 'unavailable', reason: 'unsupported-agent' }) + expect(call).toHaveBeenCalledExactlyOnceWith('aiVault.searchStatus', {}) + }) + + it.each([{ supportedAgents: [] }, { supportedAgents: ['future-agent'] }])( + 'honors explicit narrow host vocabulary $supportedAgents', + async ({ supportedAgents }) => { + const call = vi.fn(async () => ({ ...unavailableSessionSearchStatus(), supportedAgents })) + expect( + await createSessionSearchClient(call, transport).searchSessions({ + query: 'needle', + filters: { agents: ['claude'] } + }) + ).toEqual({ kind: 'unavailable', reason: 'unsupported-agent' }) + expect(call).toHaveBeenCalledExactlyOnceWith('aiVault.searchStatus', {}) + } + ) + + it.each([ + { code: 'unauthorized', message: 'Authentication required' }, + { code: 'connection_closed', message: 'Host disconnected' }, + { code: -32000, message: 'Execution host refused request' } + ])('propagates status failure $code without a search', async (error) => { + const call = vi.fn(async () => { + throw error + }) + await expect( + createSessionSearchClient(call, transport).searchSessions({ + query: 'needle', + filters: { agents: ['claude'] } + }) + ).rejects.toEqual(error) + expect(call).toHaveBeenCalledExactlyOnceWith('aiVault.searchStatus', {}) + }) + + it('propagates a malformed status instead of assuming a legacy host', async () => { + const call = vi.fn(async () => ({ supportedAgents: ['claude'] })) + await expect( + createSessionSearchClient(call, transport).searchSessions({ + query: 'needle', + filters: { agents: ['claude'] } + }) + ).rejects.toThrow() + expect(call).toHaveBeenCalledExactlyOnceWith('aiVault.searchStatus', {}) + }) + + it('reports no service only when the search method itself is missing', async () => { + const call = vi.fn(async () => { + throw { code: 'method_not_found' } + }) + expect( + await createSessionSearchClient(call, transport).searchSessions({ + query: 'needle', + filters: { agents: ['claude'] } + }) + ).toEqual({ kind: 'unavailable', reason: 'no-service' }) + expect(call.mock.calls.map(() => true)).toHaveLength(2) + }) +}) diff --git a/tests/e2e/cross-version-wire/qoder-history-search-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/qoder-history-search-downgrade.unit.test.ts index c9bdc70b6b6..6204a15f8b7 100644 --- a/tests/e2e/cross-version-wire/qoder-history-search-downgrade.unit.test.ts +++ b/tests/e2e/cross-version-wire/qoder-history-search-downgrade.unit.test.ts @@ -180,7 +180,7 @@ test.each(PRE_QODER_HOSTS)( expect( await client.searchSessions({ query: 'proof', filters: { agents: ['codex'] }, within }) ).toMatchObject({ hits: [{ agent: 'codex' }] }) - expect(call).toHaveBeenCalledTimes(1) + expect(call).toHaveBeenCalledTimes(2) } ) diff --git a/tests/e2e/cross-version-wire/session-search-agent-set-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/session-search-agent-set-downgrade.unit.test.ts new file mode 100644 index 00000000000..71554054d0d --- /dev/null +++ b/tests/e2e/cross-version-wire/session-search-agent-set-downgrade.unit.test.ts @@ -0,0 +1,205 @@ +import { createSessionSearchService } from '../../../src/main/ai-vault-search/session-search-service' +import { + addSyntheticSession, + openSessionSearchHarness +} from '../../../src/main/ai-vault-search/session-search-engine-test-fixture' +import { afterEach, expect, test, vi } from 'vitest' +import { + createSessionSearchClient, + unavailableSessionSearchStatus +} from '../../../src/shared/ai-vault-search-client' +import { AI_VAULT_AGENTS } from '../../../src/shared/ai-vault-types' +import { + searchHit, + searchResults, + fakeSearchService +} from '../../../src/shared/ai-vault-search-test-fixture' +import { + searchSessionService, + sessionSearchServiceStatus, + setSessionSearchService +} from '../../../src/main/ai-vault-search/session-search-service-registry' +import { importReleaseCheckoutModule, materializeReleaseCheckout } from './release-checkout' + +const LEGACY_REF = 'b49abdb1f4da6b3d62dfa9ccf3c74dc9e74d291c' +afterEach(() => setSessionSearchService(null)) + +test('the actual pre-Jcode client reads current host pages while current peers retain every provider', async () => { + const baseline = await importReleaseCheckoutModule( + await materializeReleaseCheckout(LEGACY_REF), + 'src/shared/ai-vault-search-contract.ts' + ) + const oldResponse = baseline.AiVaultSearchResponseSchema + if ( + !oldResponse || + typeof oldResponse !== 'object' || + !('safeParse' in oldResponse) || + typeof oldResponse.safeParse !== 'function' + ) { + throw new Error('Pinned release has no response parser') + } + const hits = (['codex', 'qoder', 'jcode'] as const).map((agent) => ({ ...searchHit(), agent })) + expect(oldResponse.safeParse({ ...searchResults(), hits: [hits[2]] })).toHaveProperty( + 'success', + false + ) + const service = fakeSearchService() + service.search.mockImplementation(async (request) => ({ + ...searchResults(), + hits: hits.filter( + (hit) => !request.filters?.agents?.length || request.filters.agents.includes(hit.agent) + ) + })) + setSessionSearchService(service) + const legacy = await searchSessionService({ query: 'proof' }, 'relay') + expect(oldResponse.safeParse(legacy)).toHaveProperty('success', true) + expect(legacy).toMatchObject({ hits: [{ agent: 'codex' }] }) + const client = createSessionSearchClient( + (method, params) => + method === 'aiVault.searchStatus' + ? sessionSearchServiceStatus(params, 'relay') + : searchSessionService(params, 'relay'), + 'relay' + ) + expect(await client.searchSessions({ query: 'proof' })).toMatchObject({ + hits: [{ agent: 'codex' }, { agent: 'qoder' }, { agent: 'jcode' }] + }) + expect(await sessionSearchServiceStatus({}, 'relay')).toMatchObject({ + supportedAgents: [...AI_VAULT_AGENTS] + }) +}) + +test.each(['runtime', 'relay'] as const)( + 'a new %s client refuses sole unsupported Jcode without widening the query', + async (transport) => { + const call = vi.fn(async () => unavailableSessionSearchStatus()) + expect( + await createSessionSearchClient(call, transport).searchSessions({ + query: 'proof', + filters: { agents: ['jcode'] } + }) + ).toEqual({ kind: 'unavailable', reason: 'unsupported-agent' }) + expect(call).toHaveBeenCalledExactlyOnceWith('aiVault.searchStatus', {}) + } +) + +test('agent-list negotiation takes precedence over the old flag and tolerates future advertised names', async () => { + const within = { kind: 'workspace' as const, worktreeId: 'folder:/task-owned/folder' } + const call = vi.fn(async (method: string) => + method === 'aiVault.searchStatus' + ? { + ...unavailableSessionSearchStatus(), + supportsQoderHistory: true, + supportedAgents: ['codex', 'jcode', 'future-agent'] + } + : searchResults() + ) + await createSessionSearchClient(call, 'runtime').searchSessions({ + query: 'proof', + cursor: 'page-2', + within, + filters: { agents: ['codex', 'qoder', 'jcode'], since: '2026-08-01T00:00:00Z', sort: 'newest' } + }) + expect(call).toHaveBeenLastCalledWith( + 'aiVault.searchSessions', + expect.objectContaining({ + cursor: 'page-2', + within, + filters: { agents: ['codex', 'jcode'], since: '2026-08-01T00:00:00Z', sort: 'newest' }, + supportedAgents: [...AI_VAULT_AGENTS] + }) + ) +}) + +test('a current host honors a negotiated client subset before index search and keeps scope identity', async () => { + const service = fakeSearchService() + setSessionSearchService(service) + await searchSessionService( + { + query: 'proof', + supportsQoderHistory: true, + supportedAgents: ['codex', 'future-agent'], + filters: { agents: ['codex', 'qoder', 'jcode'], scopePaths: ['/task-owned'] } + }, + 'runtime' + ) + expect(service.search).toHaveBeenLastCalledWith( + { query: 'proof', limit: 20, filters: { agents: ['codex'], scopePaths: ['/task-owned'] } }, + undefined + ) +}) + +test.each([{ supportedAgents: [] }, { supportedAgents: ['future-agent'] }])( + 'a client with no shared advertised agents returns no indexed rows for %j', + async ({ supportedAgents }) => { + const harness = await openSessionSearchHarness('ss-empty-peer-agents') + try { + addSyntheticSession(harness.db, { id: 1, agent: 'codex' }) + addSyntheticSession(harness.db, { id: 2, agent: 'jcode' }) + const retrieve = vi.spyOn(harness.engine, 'search') + const service = createSessionSearchService({ + engine: harness.engine, + indexer: { status: unavailableSessionSearchStatus, reconcile: async () => {} } + }) + setSessionSearchService(service) + const reply = await searchSessionService({ query: 'needle', supportedAgents }, 'runtime') + expect(reply).toMatchObject({ hits: [], page: { cursor: null, hasMore: false } }) + expect(retrieve).toHaveBeenCalledExactlyOnceWith({ + query: 'needle', + limit: 20, + filters: { scopePaths: [''] } + }) + expect(retrieve.mock.results[0]).toMatchObject({ + type: 'return', + value: { hits: [], page: { cursor: null, hasMore: false } } + }) + } finally { + setSessionSearchService(null) + await harness.close() + } + } +) + +test.each(['v1.4.211', LEGACY_REF])( + 'a missing status method still searches through the actual %s request parser', + async (ref) => { + const baseline = await importReleaseCheckoutModule( + await materializeReleaseCheckout(ref), + 'src/shared/ai-vault-search-contract.ts' + ) + const parser = baseline.AiVaultSearchRequestSchema + if ( + !parser || + typeof parser !== 'object' || + !('parse' in parser) || + typeof parser.parse !== 'function' + ) { + throw new Error('Pinned release has no request parser') + } + const call = vi.fn(async (method: string, request: Record) => { + if (method === 'aiVault.searchStatus') { + throw { code: 'method_not_found' } + } + parser.parse(request) + expect(request).toMatchObject({ + filters: { + agents: AI_VAULT_AGENTS.filter( + (agent) => !['codebuddy', 'zcode', 'qoder', 'jcode'].includes(agent) + ), + scopePaths: ['/execution-host/folder'] + } + }) + return searchResults() + }) + expect( + await createSessionSearchClient(call, 'relay').searchSessions({ + query: 'needle', + filters: { agents: [...AI_VAULT_AGENTS], scopePaths: ['/execution-host/folder'] } + }) + ).toMatchObject({ kind: 'results' }) + expect(call.mock.calls.map(([method]) => method)).toEqual([ + 'aiVault.searchStatus', + 'aiVault.searchSessions' + ]) + } +) From f199a20c3acd32973924a86cb342e9c27530d739 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:04:52 -0700 Subject: [PATCH 34/67] Preserve OpenCode reasoning and recorded patches in native history (#24790) * Use bounded OpenCode context for vault session continuation OpenCode database and synthetic row paths are not text transcripts. Use the vault preview or captured pane context, preserving actual transcript paths containing a hash and supporting both OpenCode lanes and Windows paths. Adapted the intent of #11859 and extended it to actual installed v2 vault rows. Co-authored-by: mrcha033 * Read real OpenCode sessions in terminal-backed native Chat Reuse the bounded AI Vault SQLite worker for v1 and v2 session pages and live updates. Keep terminal input as the real execution path and pace OpenCode Stop through its two-Escape interrupt. Co-authored-by: xodmd45-ctrl * fix(opencode): publish approval cards for permission requests * Send OpenCode native approval through its Enter selector * Resolve mobile Chat readability for folder workspaces * Bound OpenCode part batches and preserve v2 image attachments * Prefer live migrated OpenCode sessions over legacy copies * Consolidate mobile Chat eligibility test imports * Consolidate OpenCode SQLite protocol type imports * fix(native-chat): preserve OpenCode reasoning and patch parts Separate genuine reasoning from answer blocks in both native SQLite schemas and retain recorded patches as completed patch tools. Keep each database row together at page boundaries so the existing raw-row cursors cannot drop half of a mixed row. Adapted from @akhan157's OpenCode native history work in #13287 at bb661d10d716764fb472d824cd434678875b1947; retains the current bounded reader and account discovery instead of restoring the older capture and cursor implementation. Verified against genuine private installed 2.0.16 and official 1.18.30 CLI ingestion. Co-authored-by: Adnan Khan * fix(native-chat): keep split OpenCode rows intact on desktop and mobile Preserve the native reader's bounded OpenCode row groups in paired reads and snapshot/replacement frames so a second presentation-count slice cannot drop reasoning while advancing the database cursor. Sort derived reasoning before its answer under the same provider timestamp while retaining journal order. These two boundaries were reproduced with genuine installed 2.0.16 and official 1.18.30 sessions in a hidden desktop renderer and the current mobile view over an actual authenticated encrypted pairing. Completes the semantic presentation from @akhan157's #13287 without importing its older clipping or cursor implementation. Co-authored-by: Adnan Khan * fix(native-chat): keep reasoning and answers together in live windows * Bound OpenCode transcript RPC pages and present omission notices * Bound OpenCode transcript RPC pages and present omission notices * Bound OpenCode transcript RPC pages and present omission notices * Update native worker oversized-history notice contract --------- Co-authored-by: mrcha033 Co-authored-by: xodmd45-ctrl Co-authored-by: nwparker --- .../session/MobileNativeChatMessage.test.ts | 35 ++ .../src/session/MobileNativeChatMessage.tsx | 11 + .../mobile-native-chat-message-styles.ts | 5 + .../mobile-native-chat-pair-window.test.ts | 134 +++++++ ...ion-scanner-opencode-native-worker.test.ts | 7 +- .../transcript-opencode-part-blocks.ts | 68 +++- .../transcript-opencode-read-bounds.test.ts | 67 ++++ ...nscript-opencode-semantic-fidelity.test.ts | 331 +++++++++++++++++ .../transcript-opencode-sqlite-query.test.ts | 22 +- .../transcript-opencode-sqlite-query.ts | 44 ++- .../transcript-opencode2-sqlite.test.ts | 11 +- .../transcript-opencode2-sqlite.ts | 70 ++-- ...ative-chat-opencode-semantic-pages.test.ts | 144 ++++++++ ...ive-chat-opencode-wire-page-budget.test.ts | 333 ++++++++++++++++++ .../methods/native-chat-rpc-page-bounds.ts | 100 ++++++ src/main/runtime/rpc/methods/native-chat.ts | 62 ++-- src/shared/native-chat-merge-pairs.test.ts | 114 ++++++ src/shared/native-chat-merge.ts | 12 +- .../native-chat-reasoning-order.test.ts | 46 +++ .../native-chat-transcript-projection.ts | 13 +- src/shared/native-chat-types.ts | 24 ++ 21 files changed, 1554 insertions(+), 99 deletions(-) create mode 100644 mobile/src/session/mobile-native-chat-pair-window.test.ts create mode 100644 src/main/native-chat/transcript-opencode-read-bounds.test.ts create mode 100644 src/main/native-chat/transcript-opencode-semantic-fidelity.test.ts create mode 100644 src/main/runtime/rpc/methods/native-chat-opencode-semantic-pages.test.ts create mode 100644 src/main/runtime/rpc/methods/native-chat-opencode-wire-page-budget.test.ts create mode 100644 src/main/runtime/rpc/methods/native-chat-rpc-page-bounds.ts create mode 100644 src/shared/native-chat-merge-pairs.test.ts create mode 100644 src/shared/native-chat-reasoning-order.test.ts diff --git a/mobile/src/session/MobileNativeChatMessage.test.ts b/mobile/src/session/MobileNativeChatMessage.test.ts index 67e90132866..c8193641fef 100644 --- a/mobile/src/session/MobileNativeChatMessage.test.ts +++ b/mobile/src/session/MobileNativeChatMessage.test.ts @@ -3,6 +3,8 @@ import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'rea import { afterEach, describe, expect, it, vi } from 'vitest' import { MAX_TOOL_DETAIL_LENGTH } from '../../../src/shared/native-chat-tool-summary' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { AGENT_SESSION_HOST_STATUS_COPY } from '../../../src/shared/agent-session-host-status-rows' +import { colors } from '../theme/mobile-theme' vi.mock('react-native', async () => { const React = await import('react') @@ -60,6 +62,7 @@ describe('MobileNativeChatMessage', () => { function render( message: NativeChatMessage, props: { + fontScale?: number toolsExpanded?: boolean structuredActivityUi?: boolean activeTurnIsWorking?: boolean @@ -81,6 +84,38 @@ describe('MobileNativeChatMessage', () => { const textIn = (node: ReactTestInstance): string[] => node.findAllByType('Text' as never).map((text) => String(text.children.join(''))) + it.each(['system', 'user'] as const)( + 'renders a %s host notice as selectable muted text rather than a markdown answer', + (role) => { + const tree = render( + { + id: 'notice', + role, + timestamp: 1, + blocks: [ + { type: 'text', text: 'provider fallback', presentation: 'history-item-too-large' } + ] + }, + { fontScale: 1.5 } + ) + expect(tree.root.findAll((node) => String(node.type) === 'MobileMarkdown')).toHaveLength(0) + const text = tree.root.find((node) => String(node.type) === 'Text') + expect(text.props.children).toBe(AGENT_SESSION_HOST_STATUS_COPY['history-item-too-large']) + expect(text.props.selectable).toBe(true) + expect(Object.assign({}, ...text.props.style)).toMatchObject({ + color: colors.textMuted, + fontSize: 25.5 + }) + } + ) + + it('preserves an ordinary assistant answer without interpreting its text as a host notice', () => { + const tree = render(toolMessage([{ type: 'text', text: 'provider fallback' }])) + expect(tree.root.find((node) => String(node.type) === 'MobileMarkdown').props.content).toBe( + 'provider fallback' + ) + }) + it('renders a loadable preview URI as an image thumbnail', () => { const tree = render(userMessage([{ type: 'image-ref', url: 'file:///a.jpg', alt: 'a photo' }])) const image = tree.root.findByType('Image' as never) diff --git a/mobile/src/session/MobileNativeChatMessage.tsx b/mobile/src/session/MobileNativeChatMessage.tsx index 9c894e3cd7f..40635166281 100644 --- a/mobile/src/session/MobileNativeChatMessage.tsx +++ b/mobile/src/session/MobileNativeChatMessage.tsx @@ -4,6 +4,10 @@ import { Image, Text as NativeText, View } from 'react-native' import { splitNativeChatBlocks } from '../../../src/shared/native-chat-tool-fold' import { selectActiveToolCall } from '../../../src/shared/native-chat-tool-activity' import { isImageRefBlock, isTextBlock } from '../../../src/shared/native-chat-types' +import { + AGENT_SESSION_HOST_STATUS_COPY, + isAgentSessionHostStatusPresentation +} from '../../../src/shared/agent-session-host-status-rows' import type { NativeChatBlock, NativeChatMessage } from '../../../src/shared/native-chat-types' import { MobileMarkdown } from '../components/MobileMarkdown' import { MobileNativeChatTurnStatus } from './MobileNativeChatTurnStatus' @@ -24,6 +28,13 @@ function Prose({ onOpenFile?: (relativePath: string) => void }): React.JSX.Element | null { if (isTextBlock(block)) { + if (isAgentSessionHostStatusPresentation(block.presentation)) { + return ( + + {AGENT_SESSION_HOST_STATUS_COPY[block.presentation]} + + ) + } // Inverted (user) bubbles use a fixed dark-on-light text rather than the // markdown renderer's light-on-dark palette. if (invert) { diff --git a/mobile/src/session/mobile-native-chat-message-styles.ts b/mobile/src/session/mobile-native-chat-message-styles.ts index 51ff9ab1f36..225d664e65e 100644 --- a/mobile/src/session/mobile-native-chat-message-styles.ts +++ b/mobile/src/session/mobile-native-chat-message-styles.ts @@ -29,6 +29,11 @@ export const styles = StyleSheet.create({ lineHeight: TEXT_SIZE + 6, fontWeight: '500' }, + hostNotice: { + color: colors.textMuted, + fontSize: TEXT_SIZE, + lineHeight: TEXT_SIZE + 6 + }, reasoning: { opacity: 0.7 }, diff --git a/mobile/src/session/mobile-native-chat-pair-window.test.ts b/mobile/src/session/mobile-native-chat-pair-window.test.ts new file mode 100644 index 00000000000..fb2bd15605c --- /dev/null +++ b/mobile/src/session/mobile-native-chat-pair-window.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from 'vitest' +import { createNativeChatMerger, replaceList } from '../../../src/shared/native-chat-merge' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { applyMobileNativeChatStreamFrame } from './mobile-native-chat-stream-frame' + +function row(id: string, role: NativeChatMessage['role'] = 'assistant'): NativeChatMessage { + return { id, role, source: 'transcript', timestamp: 1, blocks: [{ type: 'text', text: id }] } +} + +function pair(id: string): NativeChatMessage[] { + return [row(`${id}:reasoning`, 'reasoning'), row(id)] +} + +describe('mobile complete-pair stream windows', () => { + it.each(['appended', 'snapshot'])( + 'keeps a pair at the %s cutoff without moving the cursor', + (type) => { + const merger = createNativeChatMerger() + const base = [ + ...pair('opencode:oldest'), + ...Array.from({ length: 38 }, (_, i) => row(`m${i}`)) + ] + replaceList(merger, base) + const result = applyMobileNativeChatStreamFrame({ + merger, + frame: { + type, + messages: type === 'snapshot' ? [...base.slice(-1), row('latest')] : [row('latest')], + hasMore: true, + beforeOffset: 1 + }, + limit: 40, + replaceSnapshot: false + }) + expect(result).toEqual({ kind: 'messages', messages: [...base, row('latest')] }) + expect(merger.indexById.get('opencode:oldest:reasoning')).toBe(0) + } + ) + + it.each(['appended', 'snapshot'])( + 'keeps reasoning, answer and omission from one raw row at the %s cutoff', + (type) => { + const group = [...pair('opencode:oldest'), row('opencode:oldest:omission', 'system')].map( + (message) => ({ ...message, transcriptOffset: 7 }) + ) + const base = [...group, ...Array.from({ length: 38 }, (_, i) => row(`m${i}`))] + const merger = createNativeChatMerger() + replaceList(merger, base) + const result = applyMobileNativeChatStreamFrame({ + merger, + frame: { + type, + messages: type === 'snapshot' ? [...base.slice(-1), row('latest')] : [row('latest')], + hasMore: true, + beforeOffset: 7 + }, + limit: 40, + replaceSnapshot: false + }) + expect(result).toEqual({ kind: 'messages', messages: [...base, row('latest')] }) + expect(merger.list.slice(0, 3)).toEqual(group) + expect(merger.list).toHaveLength(42) + } + ) + + it('invalidates the cursor when the oldest complete pair leaves', () => { + const merger = createNativeChatMerger() + const base = [...pair('opencode:oldest'), ...Array.from({ length: 39 }, (_, i) => row(`m${i}`))] + replaceList(merger, base) + const result = applyMobileNativeChatStreamFrame({ + merger, + frame: { type: 'appended', messages: [row('latest')] }, + limit: 40, + replaceSnapshot: false + }) + expect(result).toEqual({ + kind: 'messages', + messages: [...base.slice(2), row('latest')], + cursorInvalidated: true + }) + expect(merger.indexById.has('opencode:oldest')).toBe(false) + expect(merger.indexById.has('opencode:oldest:reasoning')).toBe(false) + }) + + it('retains a host page with an extra pair row across a user append followed by a pair append', () => { + const merger = createNativeChatMerger() + const base = [ + ...pair('opencode:first'), + ...Array.from({ length: 13 }, (_, i) => [ + row(`user-${i}`, 'user'), + ...pair(`opencode:${i}`) + ]).flat() + ] + applyMobileNativeChatStreamFrame({ + merger, + frame: { type: 'snapshot', messages: base, hasMore: true, beforeOffset: 123 }, + limit: 40, + replaceSnapshot: true + }) + applyMobileNativeChatStreamFrame({ + merger, + frame: { type: 'appended', messages: [row('latest-user', 'user')] }, + limit: 40, + replaceSnapshot: false + }) + const result = applyMobileNativeChatStreamFrame({ + merger, + frame: { type: 'appended', messages: pair('opencode:latest') }, + limit: 40, + replaceSnapshot: false + }) + expect(result).toMatchObject({ kind: 'messages', cursorInvalidated: true }) + expect(merger.list).toHaveLength(41) + expect(merger.list.slice(0, 2)).toEqual(pair('opencode:0')) + expect(merger.list.slice(-2)).toEqual(pair('opencode:latest')) + }) + + it('keeps legacy peers with independent reasoning IDs at the raw row limit', () => { + const merger = createNativeChatMerger() + replaceList(merger, [row('claude-thinking', 'reasoning'), row('claude-answer')]) + const result = applyMobileNativeChatStreamFrame({ + merger, + frame: { type: 'appended', messages: [row('latest')] }, + limit: 2, + replaceSnapshot: false + }) + expect(result).toEqual({ + kind: 'messages', + messages: [row('claude-answer'), row('latest')], + cursorInvalidated: true + }) + expect(merger.list).toHaveLength(2) + }) +}) diff --git a/src/main/ai-vault/session-scanner-opencode-native-worker.test.ts b/src/main/ai-vault/session-scanner-opencode-native-worker.test.ts index 9e8511967f2..9b41f700b24 100644 --- a/src/main/ai-vault/session-scanner-opencode-native-worker.test.ts +++ b/src/main/ai-vault/session-scanner-opencode-native-worker.test.ts @@ -73,8 +73,13 @@ describe('OpenCode reads through the production shared worker', () => { throw new Error('Expected the bounded native page') } expect(page.items.map((item) => item.rowid)).toEqual([2, 3]) + expect(page.items[0]?.message.role).toBe('system') expect(page.items[0]?.message.blocks).toEqual([ - { type: 'text', text: AGENT_SESSION_HOST_STATUS_COPY['history-item-too-large'] } + { + type: 'text', + text: AGENT_SESSION_HOST_STATUS_COPY['history-item-too-large'], + presentation: 'history-item-too-large' + } ]) const repeat = await client.readNativeChat(args) expect(repeat).toEqual(page) diff --git a/src/main/native-chat/transcript-opencode-part-blocks.ts b/src/main/native-chat/transcript-opencode-part-blocks.ts index 572851b34ae..ec8ca0cd046 100644 --- a/src/main/native-chat/transcript-opencode-part-blocks.ts +++ b/src/main/native-chat/transcript-opencode-part-blocks.ts @@ -1,6 +1,11 @@ import { fileURLToPath } from 'node:url' -import type { NativeChatBlock, NativeChatImageRefBlock } from '../../shared/native-chat-types' -import { AGENT_SESSION_HOST_STATUS_COPY } from '../../shared/agent-session-host-status-rows' +import type { + NativeChatBlock, + NativeChatImageRefBlock, + NativeChatMessage, + NativeChatTextBlock +} from '../../shared/native-chat-types' +import { agentSessionHostStatusBody } from '../../shared/agent-session-host-status-rows' import { asRecord, extractString, parseJsonObject } from '../ai-vault/session-scanner-values' // query module so each stays under the repo's file-size cap. Electron-free: // runs on the OpenCode SQLite worker thread (#8864). @@ -13,11 +18,50 @@ export type OpenCodePartRow = { export const OPENCODE_TRANSCRIPT_MAX_ROW_BYTES = 2 * 1024 * 1024 -export function opencodeMessageBlocks(partRows: OpenCodePartRow[]): NativeChatBlock[] { +export function opencodeMessages( + message: Pick, + partRows: OpenCodePartRow[], + additionalBlocks: NativeChatBlock[] = [] +): NativeChatMessage[] { + const { blocks, reasoning, notices } = opencodeMessageContent(partRows) + blocks.push(...additionalBlocks) + const messages: NativeChatMessage[] = [] + if (reasoning.length > 0) { + messages.push({ + ...message, + id: blocks.length > 0 || notices.length > 0 ? `${message.id}:reasoning` : message.id, + role: 'reasoning', + blocks: reasoning, + source: 'transcript' + }) + } + if (blocks.length > 0) { + messages.push({ ...message, blocks, source: 'transcript' }) + } + if (notices.length > 0) { + messages.push({ + ...message, + id: messages.length > 0 ? `${message.id}:omission` : message.id, + role: 'system', + blocks: notices, + source: 'transcript' + }) + } + return messages +} + +function opencodeMessageContent(partRows: OpenCodePartRow[]): { + blocks: NativeChatBlock[] + reasoning: NativeChatBlock[] + notices: NativeChatTextBlock[] +} { const blocks: NativeChatBlock[] = [] + const reasoning: NativeChatBlock[] = [] + const notices: NativeChatTextBlock[] = [] for (const partRow of partRows) { if (partRow.data === null) { - blocks.push({ type: 'text', text: AGENT_SESSION_HOST_STATUS_COPY['history-item-too-large'] }) + const { text, presentation } = agentSessionHostStatusBody('history-item-too-large') + notices.push({ type: 'text', text, presentation }) continue } const part = parseJsonObject(partRow.data) @@ -38,7 +82,7 @@ export function opencodeMessageBlocks(partRows: OpenCodePartRow[]): NativeChatBl case 'reasoning': { const text = extractString(part.text) if (text) { - blocks.push({ type: 'text', text }) + reasoning.push({ type: 'text', text }) } break } @@ -46,6 +90,18 @@ export function opencodeMessageBlocks(partRows: OpenCodePartRow[]): NativeChatBl blocks.push(...opencodeToolBlocks(part)) break } + case 'patch': { + const files = Array.isArray(part.files) + ? part.files.filter((file): file is string => typeof file === 'string') + : [] + blocks.push({ + type: 'tool-call', + name: 'patch', + state: 'completed', + input: { hash: extractString(part.hash), files } + }) + break + } case 'file': { const block = opencodeFileBlock(part) if (block) { @@ -58,7 +114,7 @@ export function opencodeMessageBlocks(partRows: OpenCodePartRow[]): NativeChatBl break } } - return blocks + return { blocks, reasoning, notices } } function opencodeFileBlock(part: Record): NativeChatImageRefBlock | null { diff --git a/src/main/native-chat/transcript-opencode-read-bounds.test.ts b/src/main/native-chat/transcript-opencode-read-bounds.test.ts new file mode 100644 index 00000000000..46f4ace57c1 --- /dev/null +++ b/src/main/native-chat/transcript-opencode-read-bounds.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it, vi } from 'vitest' +import type { NativeChatMessage } from '../../shared/native-chat-types' +import { + readOpenCodeNativeChatTranscriptFull, + readOpenCodeNativeChatTranscriptTail +} from './transcript-opencode' + +vi.mock('../managed-data-accounts/service', () => ({ getManagedDataAccountService: vi.fn() })) + +describe('OpenCode full native read bounds', () => { + it('counts reasoning, answer and image bytes cumulatively across pages', async () => { + let calls = 0 + const chunk = 'x'.repeat(1_000_000) + const messages: NativeChatMessage[] = Array.from({ length: 12 }, (_, index) => ({ + id: String(index), + role: index % 2 === 0 ? 'reasoning' : 'assistant', + timestamp: 1, + source: 'transcript', + blocks: [{ type: 'text', text: chunk }] + })) + messages.push({ + id: 'image', + role: 'user', + timestamp: 1, + source: 'transcript', + blocks: [{ type: 'image-ref', url: `data:image/png;base64,${chunk}` }] + }) + const result = await readOpenCodeNativeChatTranscriptFull('session', { + resolveDbPath: async () => 'private-db', + readPage: async () => ({ + items: messages.map((message) => ({ rowid: 100 - calls, fingerprint: '', message })), + hasMore: true, + beforeMessageRowId: 100 - ++calls + }) + }) + expect(result).toEqual({ error: 'OpenCode transcript exceeds its full read limit' }) + expect(calls).toBe(6) + }) + + it.each(['tail', 'full'] as const)( + 'keeps cancellation attached to %s discovery and reads', + async (kind) => { + const controller = new AbortController() + const resolveDbPath = vi.fn(async (_sessionId?: string, signal?: AbortSignal) => { + expect(signal).toBe(controller.signal) + return 'private-db' + }) + const readPage = vi.fn(async (_args: unknown, signal?: AbortSignal) => { + expect(signal).toBe(controller.signal) + controller.abort(new Error('Cancelled private read')) + signal?.throwIfAborted() + return null + }) + const deps = { resolveDbPath, readPage } + const result = + kind === 'tail' + ? await readOpenCodeNativeChatTranscriptTail( + { sessionId: 'session', limit: 1 }, + deps, + controller.signal + ) + : await readOpenCodeNativeChatTranscriptFull('session', deps, controller.signal) + expect(result).toEqual({ error: 'Cancelled private read' }) + expect(readPage).toHaveBeenCalledOnce() + } + ) +}) diff --git a/src/main/native-chat/transcript-opencode-semantic-fidelity.test.ts b/src/main/native-chat/transcript-opencode-semantic-fidelity.test.ts new file mode 100644 index 00000000000..aa30a4b743a --- /dev/null +++ b/src/main/native-chat/transcript-opencode-semantic-fidelity.test.ts @@ -0,0 +1,331 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { NativeChatMessage } from '../../shared/native-chat-types' +import { AGENT_SESSION_HOST_STATUS_COPY } from '../../shared/agent-session-host-status-rows' +import Database from '../sqlite/sync-database' +import { opencodeMessages } from './transcript-opencode-part-blocks' +import { + readOpenCodeTranscriptPage, + readOpenCodeTranscriptSignal +} from './transcript-opencode-sqlite-query' +import { readOpenCodeNativeChatTranscriptFull } from './transcript-opencode' +import { subscribeOpenCodeNativeChatTranscript } from './transcript-opencode-subscribe' + +vi.mock('../managed-data-accounts/service', () => ({ getManagedDataAccountService: vi.fn() })) + +const fixtures: { db: Database.Database; root: string }[] = [] +afterEach(() => { + for (const { db, root } of fixtures.splice(0)) { + db.close() + rmSync(root, { recursive: true, force: true }) + } +}) + +function fixture(version: 'v1' | 'v2') { + const root = mkdtempSync(join(tmpdir(), 'orca-opencode-semantics-')) + const path = join(root, 'opencode.db') + const db = new Database(path) + fixtures.push({ db, root }) + if (version === 'v1') { + db.exec(`CREATE TABLE session (id TEXT PRIMARY KEY); + CREATE TABLE message (id TEXT, session_id TEXT, time_created INTEGER, time_updated INTEGER, data TEXT); + CREATE TABLE part (id TEXT, message_id TEXT, session_id TEXT, time_updated INTEGER, data TEXT); + INSERT INTO session VALUES ('session');`) + } else { + db.exec(`CREATE TABLE session_v2 (id TEXT PRIMARY KEY); + CREATE TABLE session_message (id TEXT, session_id TEXT, type TEXT, seq INTEGER, + data TEXT, time_created INTEGER, time_updated INTEGER); + INSERT INTO session_v2 VALUES ('session');`) + } + let sequence = 0 + const insert = (id: string, parts: unknown[]) => { + sequence += 7 + if (version === 'v1') { + db.prepare('INSERT INTO message VALUES (?, ?, ?, ?, ?)').run( + id, + 'session', + sequence, + sequence, + JSON.stringify({ role: 'assistant' }) + ) + parts.forEach((part, index) => { + db.prepare('INSERT INTO part VALUES (?, ?, ?, ?, ?)').run( + `${id}:${index}`, + id, + 'session', + sequence, + JSON.stringify(part) + ) + }) + } else { + db.prepare('INSERT INTO session_message VALUES (?, ?, ?, ?, ?, ?, ?)').run( + id, + 'session', + 'assistant', + sequence, + JSON.stringify({ content: parts }), + sequence, + sequence + ) + } + } + const prefix = version === 'v1' ? '' : 'opencode:' + return { db, path, insert, prefix } +} + +const mixed = (answer = 'ANSWER_SENTINEL') => [ + { type: 'reasoning', text: 'REASONING_SENTINEL' }, + { type: 'text', text: answer } +] + +it('keeps reasoning apart from prose, image refs, tools, and applied patch records', () => { + // Contracts captured through isolated 2.0.16 and official 1.18.30 CLI ingestion. + const parts = [ + ...mixed(), + { type: 'text', text: 'injected context', synthetic: true }, + { type: 'step-finish' }, + { type: 'file', mime: 'image/png', url: 'data:image/png;base64,YQ==' }, + { type: 'patch', hash: 'snapshot-before-edit', files: ['sentinel.txt', 3] } + ].map((part) => ({ message_id: 'row', time_updated: 1, data: JSON.stringify(part) })) + const messages = opencodeMessages({ id: 'row', role: 'assistant', timestamp: 1 }, parts) + expect(messages).toEqual([ + { + id: 'row:reasoning', + role: 'reasoning', + timestamp: 1, + source: 'transcript', + blocks: [{ type: 'text', text: 'REASONING_SENTINEL' }] + }, + { + id: 'row', + role: 'assistant', + timestamp: 1, + source: 'transcript', + blocks: [ + { type: 'text', text: 'ANSWER_SENTINEL' }, + { type: 'image-ref', url: 'data:image/png;base64,YQ==' }, + { + type: 'tool-call', + name: 'patch', + state: 'completed', + input: { hash: 'snapshot-before-edit', files: ['sentinel.txt'] } + } + ] + } + ]) + expect( + opencodeMessages({ id: 'thinking', role: 'assistant', timestamp: null }, parts.slice(0, 1)) + ).toEqual([ + { + id: 'thinking', + role: 'reasoning', + timestamp: null, + source: 'transcript', + blocks: [{ type: 'text', text: 'REASONING_SENTINEL' }] + } + ]) +}) + +describe.each(['v1', 'v2'] as const)('%s semantic pagination', (version) => { + it.each([1, 2, 3])('reconstructs sparse mixed history once with a page limit of %i', (limit) => { + const { path, insert, prefix } = fixture(version) + const expected: string[] = [] + for (let index = 0; index < 12; index++) { + const id = String(index) + if (index % 3 === 1) { + insert(id, [{ type: 'step-start' }]) + } else if (index % 3 === 2) { + insert(id, mixed()) + expected.push(`${prefix}${id}:reasoning`, `${prefix}${id}`) + } else { + insert(id, [{ type: 'text', text: id }]) + expected.push(`${prefix}${id}`) + } + } + const pages: NativeChatMessage[][] = [] + let cursor: number | undefined + for (let pageIndex = 0; pageIndex < 30; pageIndex++) { + const page = readOpenCodeTranscriptPage({ + dbPath: path, + sessionId: 'session', + limit, + beforeMessageRowId: cursor + })! + expect(page.items.length).toBeLessThanOrEqual(limit + 1) + for (const item of page.items.filter((item) => item.message.role === 'reasoning')) { + expect( + page.items.some( + (other) => other.rowid === item.rowid && other.message.role === 'assistant' + ) + ).toBe(true) + } + pages.push(page.items.map((item) => item.message)) + if (!page.hasMore) { + break + } + expect(page.beforeMessageRowId).toBeLessThan(cursor ?? Number.MAX_SAFE_INTEGER) + cursor = page.beforeMessageRowId ?? undefined + } + expect( + pages + .toReversed() + .flat() + .map((message) => message.id) + ).toEqual(expected) + }) + + it('reconstructs a full read beyond its 500-message window without losing a split row', async () => { + const { path, insert, prefix } = fixture(version) + const expected: string[] = [] + for (let index = 0; index < 260; index++) { + insert(String(index), mixed()) + expected.push(`${prefix}${index}:reasoning`, `${prefix}${index}`) + } + const result = await readOpenCodeNativeChatTranscriptFull('session', { + resolveDbPath: async () => path, + readPage: async (args) => readOpenCodeTranscriptPage(args) + }) + expect('messages' in result && result.messages.map((message) => message.id)).toEqual(expected) + }) + + it('keeps both presentations on streaming mutation and new rows', async () => { + const { db, path, insert, prefix } = fixture(version) + insert('reply', mixed()) + const snapshots: NativeChatMessage[][] = [] + const replacements: NativeChatMessage[][] = [] + const appends: NativeChatMessage[][] = [] + let visible: NativeChatMessage[] = [] + const subscription = subscribeOpenCodeNativeChatTranscript( + { + agent: 'opencode', + sessionId: 'session', + initialLimit: 1, + resolvePollIntervalMs: 5, + onAppend: (messages) => { + appends.push(messages) + visible.push(...messages) + }, + onInitialSnapshot: (messages) => { + snapshots.push(messages) + visible = [...messages] + }, + onReplace: (messages) => { + replacements.push(messages) + visible = [...messages] + } + }, + undefined, + { + resolveDbPath: async () => path, + readSignal: async (dbPath, sessionId) => readOpenCodeTranscriptSignal(dbPath, sessionId), + readPage: async (args) => readOpenCodeTranscriptPage(args) + } + ) + try { + await expect.poll(() => snapshots.length).toBe(1) + expect(snapshots[0].map((message) => message.role)).toEqual(['reasoning', 'assistant']) + if (version === 'v1') { + db.prepare("UPDATE part SET data = ?, time_updated = 100 WHERE id = 'reply:1'").run( + JSON.stringify({ type: 'text', text: 'complete' }) + ) + } else { + db.prepare('UPDATE session_message SET data = ?, time_updated = 100').run( + JSON.stringify({ content: mixed('complete') }) + ) + } + await expect.poll(() => replacements.length).toBe(1) + expect(replacements[0].map((message) => message.role)).toEqual(['reasoning', 'assistant']) + expect(replacements[0][1].blocks).toEqual([{ type: 'text', text: 'complete' }]) + insert('next', mixed('next answer')) + await expect.poll(() => appends.length + replacements.length).toBe(2) + expect(visible.map((message) => message.id)).toEqual( + ['reply:reasoning', 'reply', 'next:reasoning', 'next'].map((id) => `${prefix}${id}`) + ) + expect(visible.map((message) => message.role)).toEqual([ + 'reasoning', + 'assistant', + 'reasoning', + 'assistant' + ]) + expect(visible[1].blocks).toEqual([{ type: 'text', text: 'complete' }]) + expect(visible[3].blocks).toEqual([{ type: 'text', text: 'next answer' }]) + } finally { + subscription.unsubscribe() + } + }) + + it('keeps complete pairs around an omitted oversized history row', () => { + const { path, insert, prefix } = fixture(version) + insert('before', mixed('BEFORE')) + insert('large', [ + ...mixed('MIDDLE'), + { type: 'file', mime: 'image/png', url: '😀'.repeat(2 * 1024 * 1024) } + ]) + insert('after', mixed('AFTER')) + const messages: NativeChatMessage[] = [] + const cursors = new Set() + let beforeMessageRowId: number | undefined + while (true) { + const page = readOpenCodeTranscriptPage({ + dbPath: path, + sessionId: 'session', + limit: 1, + beforeMessageRowId + }) + expect(page).not.toBeNull() + if (!page) { + throw new Error('History page unavailable') + } + messages.unshift(...page.items.map((item) => item.message)) + if (!page.hasMore) { + break + } + expect(page.beforeMessageRowId).not.toBeNull() + if (page.beforeMessageRowId === null) { + throw new Error('History cursor unavailable') + } + expect(cursors.has(page.beforeMessageRowId)).toBe(false) + cursors.add(page.beforeMessageRowId) + beforeMessageRowId = page.beforeMessageRowId + } + const middleIds = version === 'v1' ? ['large:reasoning', 'large', 'large:omission'] : ['large'] + expect(messages.map((message) => message.id)).toEqual( + ['before:reasoning', 'before', ...middleIds, 'after:reasoning', 'after'].map( + (id) => `${prefix}${id}` + ) + ) + const notices = messages.flatMap((message) => + message.blocks.filter( + (block) => + block.type === 'text' && + block.text === AGENT_SESSION_HOST_STATUS_COPY['history-item-too-large'] + ) + ) + expect(notices).toHaveLength(1) + expect(notices[0]).toMatchObject({ presentation: 'history-item-too-large' }) + const noticeRow = messages.find((message) => + message.blocks.some( + (block) => block.type === 'text' && block.presentation === 'history-item-too-large' + ) + ) + expect(noticeRow?.role).toBe('system') + expect(noticeRow?.transcriptOffset).toBeTypeOf('number') + expect(Buffer.byteLength(JSON.stringify(messages))).toBeLessThan(4096) + }) + + it('enforces one page byte budget across multiple mixed rows', () => { + const { path, insert } = fixture(version) + const large = 'x'.repeat(1_000_000) + for (let index = 0; index < 20; index++) { + insert(String(index), [ + { type: 'reasoning', text: large }, + { type: 'text', text: large } + ]) + } + expect(() => + readOpenCodeTranscriptPage({ dbPath: path, sessionId: 'session', limit: 40 }) + ).toThrow('read limit') + }) +}) diff --git a/src/main/native-chat/transcript-opencode-sqlite-query.test.ts b/src/main/native-chat/transcript-opencode-sqlite-query.test.ts index d8261365072..6e500b7b974 100644 --- a/src/main/native-chat/transcript-opencode-sqlite-query.test.ts +++ b/src/main/native-chat/transcript-opencode-sqlite-query.test.ts @@ -115,7 +115,11 @@ describe('readOpenCodeTranscriptPage', () => { const page = readOpenCodeTranscriptPage(args) expect(page?.items.map((item) => item.message.id)).toEqual(['msg-2', 'msg-3']) expect(page?.items[0]?.message.blocks).toEqual([ - { type: 'text', text: AGENT_SESSION_HOST_STATUS_COPY['history-item-too-large'] } + { + type: 'text', + text: AGENT_SESSION_HOST_STATUS_COPY['history-item-too-large'], + presentation: 'history-item-too-large' + } ]) expect(page?.items[0]?.rowid).toBe(messageRowid(db, 'msg-2')) expect(readOpenCodeTranscriptPage(args)?.items[0]?.fingerprint).toBe( @@ -152,13 +156,17 @@ describe('readOpenCodeTranscriptPage', () => { time: 1, data: { type: 'file', mime: 'image/png', url: '😀'.repeat(600_000) } }) - expect( - readOpenCodeTranscriptPage({ dbPath: path, sessionId: 'ses-1', limit: 1 })?.items[0]?.message - .blocks - ).toEqual([ - { type: 'text', text: 'see this' }, - { type: 'text', text: AGENT_SESSION_HOST_STATUS_COPY['history-item-too-large'] } + const page = readOpenCodeTranscriptPage({ dbPath: path, sessionId: 'ses-1', limit: 1 }) + expect(page?.items.map((item) => item.message)).toMatchObject([ + { id: 'msg', role: 'user', blocks: [{ type: 'text', text: 'see this' }] }, + { + id: 'msg:omission', + role: 'system', + blocks: [{ type: 'text', presentation: 'history-item-too-large' }] + } ]) + expect(page?.items[0]?.rowid).toBe(page?.items[1]?.rowid) + expect(page?.items[0]?.message.transcriptOffset).toBe(page?.items[1]?.message.transcriptOffset) }) it('returns null when the session row does not exist', () => { diff --git a/src/main/native-chat/transcript-opencode-sqlite-query.ts b/src/main/native-chat/transcript-opencode-sqlite-query.ts index 31729111c2e..d245020b619 100644 --- a/src/main/native-chat/transcript-opencode-sqlite-query.ts +++ b/src/main/native-chat/transcript-opencode-sqlite-query.ts @@ -11,7 +11,7 @@ import type SyncDatabase from '../sqlite/sync-database' type BindValue = SyncDatabase.BindValue type SqliteStatement = SyncDatabase.Statement import { - opencodeMessageBlocks, + opencodeMessages, OPENCODE_TRANSCRIPT_MAX_ROW_BYTES } from './transcript-opencode-part-blocks' // Cursors are opaque provider order: SQLite rowid in v1, session sequence in v2. @@ -166,8 +166,16 @@ export function readOpenCodeTranscriptPage(args: { break } } - const overshot = collected.length > limit - const trimmed = overshot ? collected.slice(0, limit) : collected + let retained = Math.min(limit, collected.length) + // A raw-row cursor cannot resume inside a reasoning/answer pair. + while ( + retained < collected.length && + collected[retained].rowid === collected[retained - 1].rowid + ) { + retained++ + } + const overshot = retained < collected.length + const trimmed = overshot ? collected.slice(0, retained) : collected const items = trimmed.toReversed() return { items, @@ -232,27 +240,25 @@ function mapMessageRows( const items: OpenCodeTranscriptItem[] = [] for (const row of rows) { const partList = partsByMessage.get(row.id) ?? [] - const blocks = opencodeMessageBlocks( + const record = row.data === null ? null : parseJsonObject(row.data) + const role = extractString(record?.role) + const messages = opencodeMessages( + { + id: row.id, + role: role === 'user' ? 'user' : role === 'assistant' ? 'assistant' : 'system', + timestamp: Number.isFinite(row.time_created) ? row.time_created : null + }, row.data === null ? [{ message_id: row.id, time_updated: row.time_updated, data: null }] : partList ) - if (blocks.length === 0) { - continue + for (const message of messages.toReversed()) { + items.push({ + rowid: row.message_rowid, + fingerprint: `${row.time_updated}:${partList.length}:${maxPartTimeUpdated(partList)}`, + message: { ...message, transcriptOffset: row.message_rowid } + }) } - const record = row.data === null ? null : parseJsonObject(row.data) - const role = extractString(record?.role) - items.push({ - rowid: row.message_rowid, - fingerprint: `${row.time_updated}:${partList.length}:${maxPartTimeUpdated(partList)}`, - message: { - id: row.id, - role: role === 'user' ? 'user' : role === 'assistant' ? 'assistant' : 'system', - blocks, - timestamp: Number.isFinite(row.time_created) ? row.time_created : null, - source: 'transcript' - } - }) } return items } diff --git a/src/main/native-chat/transcript-opencode2-sqlite.test.ts b/src/main/native-chat/transcript-opencode2-sqlite.test.ts index 333788c82bb..7ddd653071d 100644 --- a/src/main/native-chat/transcript-opencode2-sqlite.test.ts +++ b/src/main/native-chat/transcript-opencode2-sqlite.test.ts @@ -129,9 +129,16 @@ describe('OpenCode 2 native transcript', () => { expect(page?.items[0]).toMatchObject({ rowid: 2, message: { - role: 'user', + role: 'system', timestamp: 1, - blocks: [{ type: 'text', text: AGENT_SESSION_HOST_STATUS_COPY['history-item-too-large'] }] + transcriptOffset: 2, + blocks: [ + { + type: 'text', + text: AGENT_SESSION_HOST_STATUS_COPY['history-item-too-large'], + presentation: 'history-item-too-large' + } + ] } }) expect(readOpenCodeTranscriptPage(args)?.items[0]?.fingerprint).toBe( diff --git a/src/main/native-chat/transcript-opencode2-sqlite.ts b/src/main/native-chat/transcript-opencode2-sqlite.ts index 2ebfe950e10..1b4f01033e9 100644 --- a/src/main/native-chat/transcript-opencode2-sqlite.ts +++ b/src/main/native-chat/transcript-opencode2-sqlite.ts @@ -4,9 +4,10 @@ import { openCodeTranscriptPageLimit } from '../../shared/opencode-transcript-pa import { columnExists, tableExists } from '../opencode-usage/schema-helpers' import { asRecord, parseJsonObject } from '../ai-vault/session-scanner-values' import { - opencodeMessageBlocks, + opencodeMessages, OPENCODE_TRANSCRIPT_MAX_ROW_BYTES } from './transcript-opencode-part-blocks' +import type { NativeChatBlock } from '../../shared/native-chat-types' import type { OpenCodeTranscriptPage, OpenCodeTranscriptSignal, @@ -55,7 +56,7 @@ export function readOpenCode2TranscriptSignal( } } -function messageItem(value: unknown): OpenCodeTranscriptItem | null { +function messageItems(value: unknown): OpenCodeTranscriptItem[] { const row = asRecord(value) if ( typeof row?.id !== 'string' || @@ -65,30 +66,30 @@ function messageItem(value: unknown): OpenCodeTranscriptItem | null { ) { throw new Error('OpenCode transcript message is invalid') } + const messageCursor = row.cursor + const updatedAt = row.time_updated if ( row.data === null || (typeof row.data === 'string' && Buffer.byteLength(row.data) > OPENCODE_TRANSCRIPT_MAX_ROW_BYTES) ) { - return { - rowid: row.cursor, - fingerprint: `${row.time_updated}:omitted`, - message: { + return opencodeMessages( + { id: `opencode:${row.id}`, - role: row.type === 'user' ? 'user' : row.type === 'assistant' ? 'assistant' : 'system', - blocks: opencodeMessageBlocks([ - { message_id: row.id, time_updated: row.time_updated, data: null } - ]), - timestamp: row.time_created, - source: 'transcript' - } - } + role: 'system', + timestamp: row.time_created + }, + [{ message_id: row.id, time_updated: row.time_updated, data: null }] + ).map((message) => ({ + rowid: messageCursor, + fingerprint: `${updatedAt}:omitted`, + message: { ...message, transcriptOffset: messageCursor } + })) } if (typeof row.data !== 'string') { throw new Error('OpenCode transcript message is invalid') } const messageId = row.id - const updatedAt = row.time_updated const record = parseJsonObject(row.data) if (!record) { throw new Error('OpenCode transcript message contains invalid JSON') @@ -140,7 +141,7 @@ function messageItem(value: unknown): OpenCodeTranscriptItem | null { } ] }) - const blocks = opencodeMessageBlocks(parts) + const blocks: NativeChatBlock[] = [] const error = asRecord(record.error) if (typeof error?.message === 'string') { blocks.push({ type: 'text', text: error.message }) @@ -149,25 +150,28 @@ function messageItem(value: unknown): OpenCodeTranscriptItem | null { blocks.push({ type: 'text', text: 'Conversation interrupted' }) } if ( - blocks.length === 0 || - (row.type !== 'user' && - row.type !== 'assistant' && - row.type !== 'system' && - row.type !== 'idle') + row.type !== 'user' && + row.type !== 'assistant' && + row.type !== 'system' && + row.type !== 'idle' ) { - return null + return [] } - return { - rowid: row.cursor, - fingerprint: `${updatedAt}:${createHash('sha256').update(row.data).digest('hex')}`, - message: { + const messages = opencodeMessages( + { id: `opencode:${row.id}`, role: row.type === 'idle' ? 'system' : row.type, - blocks, - timestamp: row.time_created, - source: 'transcript' - } - } + timestamp: row.time_created + }, + parts, + blocks + ) + const fingerprint = `${updatedAt}:${createHash('sha256').update(row.data).digest('hex')}` + return messages.toReversed().map((message) => ({ + rowid: messageCursor, + fingerprint, + message: { ...message, transcriptOffset: messageCursor } + })) } export function readOpenCode2TranscriptPage( @@ -209,9 +213,9 @@ export function readOpenCode2TranscriptPage( if (++scannedRows > MAX_SCAN_ROWS || bytes > MAX_PAGE_BYTES) { throw new Error('OpenCode transcript page exceeds its read limit') } - const item = messageItem(value) + const mapped = messageItems(value) cursor = row.cursor - if (item) { + for (const item of mapped) { pageBytes += Buffer.byteLength(JSON.stringify(item)) if (pageBytes > MAX_PAGE_BYTES) { throw new Error('OpenCode transcript page exceeds its read limit') diff --git a/src/main/runtime/rpc/methods/native-chat-opencode-semantic-pages.test.ts b/src/main/runtime/rpc/methods/native-chat-opencode-semantic-pages.test.ts new file mode 100644 index 00000000000..358b723e1ac --- /dev/null +++ b/src/main/runtime/rpc/methods/native-chat-opencode-semantic-pages.test.ts @@ -0,0 +1,144 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import Database from '../../../sqlite/sync-database' +import { asRecord } from '../../../ai-vault/session-scanner-values' +import { readOpenCodeTranscriptPage } from '../../../native-chat/transcript-opencode-sqlite-query' +import { readOpenCodeNativeChatTranscriptTail } from '../../../native-chat/transcript-opencode' +import type { SubscribeNativeChatTranscriptArgs } from '../../../native-chat/transcript-watch-contract' +import { buildRegistry, isStreamingMethod, type RpcContext } from '../core' + +const state: { path: string; watcher: SubscribeNativeChatTranscriptArgs | null } = vi.hoisted( + () => ({ + path: '', + watcher: null + }) +) +vi.mock('../../../managed-data-accounts/service', () => ({ getManagedDataAccountService: vi.fn() })) +vi.mock('../../../native-chat/transcript-watch', () => ({ + readNativeChatTranscriptTail: (args: { + sessionId: string + limit: number + beforeOffset?: number + }) => + readOpenCodeNativeChatTranscriptTail(args, { + resolveDbPath: async () => state.path, + readPage: async (page) => readOpenCodeTranscriptPage(page) + }), + subscribeNativeChatTranscript: async (args: SubscribeNativeChatTranscriptArgs) => { + state.watcher = args + return { watching: true, unsubscribe: vi.fn() } + } +})) +import { NATIVE_CHAT_METHODS } from './native-chat' + +const roots: string[] = [] +afterEach(() => { + state.watcher = null + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +function fixture() { + const root = mkdtempSync(join(tmpdir(), 'orca-opencode-paired-pages-')) + roots.push(root) + state.path = join(root, 'opencode.db') + const db = new Database(state.path) + db.exec(`CREATE TABLE session_v2 (id TEXT PRIMARY KEY); + CREATE TABLE session_message (id TEXT, session_id TEXT, type TEXT, seq INTEGER, + data TEXT, time_created INTEGER, time_updated INTEGER); + INSERT INTO session_v2 VALUES ('session');`) + for (let index = 1; index <= 3; index++) { + db.prepare('INSERT INTO session_message VALUES (?, ?, ?, ?, ?, ?, ?)').run( + String(index), + 'session', + 'assistant', + index * 7, + JSON.stringify({ + content: [ + { type: 'reasoning', text: `thinking ${index}` }, + { type: 'text', text: `answer ${index}` } + ] + }), + 1, + 1 + ) + } + db.close() +} + +function context(): RpcContext { + const runtime = { registerSubscriptionCleanup: vi.fn(), cleanupSubscription: vi.fn() } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: These handlers only register or invoke subscription cleanup on the runtime. + return { runtime: runtime as unknown as RpcContext['runtime'], clientKind: 'mobile' } +} + +describe.each(['opencode', 'opencode2'])('%s paired semantic pages', (agent) => { + it('reconstructs reasoning and answers across raw-row cursors at limit 1', async () => { + fixture() + const method = buildRegistry(NATIVE_CHAT_METHODS).get('nativeChat.readSession')! + if (isStreamingMethod(method)) { + throw new Error('Expected a page read') + } + const pages: string[][] = [] + let beforeOffset: number | undefined + for (let index = 0; index < 4; index++) { + const params = method.params?.parse({ agent, sessionId: 'session', limit: 1, beforeOffset }) + const result = asRecord(await method.handler(params, context())) + expect(result?.hasMore).toBe(index < 2) + const messages = Array.isArray(result?.messages) ? result.messages : [] + expect(messages.map((message) => asRecord(message)?.role)).toEqual(['reasoning', 'assistant']) + pages.push(messages.map((message) => String(asRecord(message)?.id))) + if (result?.hasMore !== true) { + break + } + expect(typeof result.beforeOffset).toBe('number') + beforeOffset = typeof result.beforeOffset === 'number' ? result.beforeOffset : undefined + } + expect(pages.toReversed().flat()).toEqual([ + 'opencode:1:reasoning', + 'opencode:1', + 'opencode:2:reasoning', + 'opencode:2', + 'opencode:3:reasoning', + 'opencode:3' + ]) + }) + + it('keeps the pair in snapshot and replacement frames while sanitizing mobile text', async () => { + fixture() + const method = buildRegistry(NATIVE_CHAT_METHODS).get('nativeChat.subscribe')! + if (!isStreamingMethod(method)) { + throw new Error('Expected a subscription') + } + const frames: unknown[] = [] + await method.handler( + method.params?.parse({ agent, sessionId: 'session', limit: 1 }), + context(), + (frame) => frames.push(frame) + ) + const page = readOpenCodeTranscriptPage({ dbPath: state.path, sessionId: 'session', limit: 1 })! + const messages = page.items.map((item) => item.message) + messages[0].blocks = [{ type: 'text', text: 'x'.repeat(100_000) }] + const watcher = state.watcher + if (!watcher) { + throw new Error('Expected a native watcher') + } + watcher.onInitialSnapshot?.(messages, page.hasMore, page.beforeMessageRowId ?? 0) + watcher.onReplace?.(messages, page.hasMore, page.beforeMessageRowId ?? 0) + expect(frames).toHaveLength(2) + for (const frame of frames) { + const record = asRecord(frame) + const published = Array.isArray(record?.messages) ? record.messages : [] + expect(published.map((message) => asRecord(message)?.role)).toEqual([ + 'reasoning', + 'assistant' + ]) + expect(record?.beforeOffset).toBe(page.beforeMessageRowId) + expect(JSON.stringify(published).length).toBeLessThan(100_000) + } + expect(messages[0].blocks[0]).toEqual({ type: 'text', text: 'x'.repeat(100_000) }) + }) +}) diff --git a/src/main/runtime/rpc/methods/native-chat-opencode-wire-page-budget.test.ts b/src/main/runtime/rpc/methods/native-chat-opencode-wire-page-budget.test.ts new file mode 100644 index 00000000000..ec9d872d5d3 --- /dev/null +++ b/src/main/runtime/rpc/methods/native-chat-opencode-wire-page-budget.test.ts @@ -0,0 +1,333 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { createHash } from 'node:crypto' +import { afterEach, describe, expect, it, vi } from 'vitest' +import Database from '../../../sqlite/sync-database' +import { asRecord } from '../../../ai-vault/session-scanner-values' +import { readOpenCodeNativeChatTranscriptTail } from '../../../native-chat/transcript-opencode' +import { readOpenCodeTranscriptPage } from '../../../native-chat/transcript-opencode-sqlite-query' +import type { SubscribeNativeChatTranscriptArgs } from '../../../native-chat/transcript-watch-contract' +import { createDispatcherStreamingFeatureEmitter } from '../dispatcher-streaming-feature-emitter' +import { mobileE2EETextPayloadAdmissionBytes } from '../mobile-e2ee-outbound-admission' +import { REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES } from '../../../../shared/remote-runtime-memory-limits' +import { buildRegistry, isStreamingMethod, type RpcContext } from '../core' + +const state: { path: string; watcher: SubscribeNativeChatTranscriptArgs | null } = vi.hoisted( + () => ({ path: '', watcher: null }) +) +vi.mock('../../../managed-data-accounts/service', () => ({ getManagedDataAccountService: vi.fn() })) +vi.mock('../../../native-chat/transcript-watch', () => ({ + readNativeChatTranscriptTail: (args: { + sessionId: string + limit: number + beforeOffset?: number + }) => + readOpenCodeNativeChatTranscriptTail(args, { + resolveDbPath: async () => state.path, + readPage: async (page) => readOpenCodeTranscriptPage(page) + }), + subscribeNativeChatTranscript: async (args: SubscribeNativeChatTranscriptArgs) => { + state.watcher = args + return { watching: true, unsubscribe: vi.fn() } + } +})) +import { NATIVE_CHAT_METHODS } from './native-chat' +import { + boundNativeChatRpcPageByBytes, + nativeChatRpcAppendBatches +} from './native-chat-rpc-page-bounds' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' + +const roots: string[] = [] +afterEach(() => { + state.watcher = null + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +function denseFixture(version: 'v1' | 'v2'): string { + const root = mkdtempSync(join(tmpdir(), 'orca-opencode-wire-budget-')) + roots.push(root) + const path = join(root, 'opencode.db') + state.path = path + const db = new Database(path) + if (version === 'v2') { + db.exec(`CREATE TABLE session_v2 (id TEXT PRIMARY KEY); + CREATE TABLE session_message (id TEXT, session_id TEXT, type TEXT, seq INTEGER, + data TEXT, time_created INTEGER, time_updated INTEGER); + INSERT INTO session_v2 VALUES ('session');`) + } else { + db.exec(`CREATE TABLE session (id TEXT PRIMARY KEY); + CREATE TABLE message (id TEXT, session_id TEXT, time_created INTEGER, time_updated INTEGER, data TEXT); + CREATE TABLE part (id TEXT, message_id TEXT, session_id TEXT, time_updated INTEGER, data TEXT); + INSERT INTO session VALUES ('session');`) + } + db.exec('BEGIN') + const insert = (id: string, role: 'user' | 'assistant', cursor: number, content: unknown[]) => { + if (version === 'v2') { + db.prepare('INSERT INTO session_message VALUES (?, ?, ?, ?, ?, ?, ?)').run( + id, + 'session', + role, + cursor, + JSON.stringify({ content }), + cursor, + cursor + ) + } else { + db.prepare('INSERT INTO message VALUES (?, ?, ?, ?, ?)').run( + id, + 'session', + cursor, + cursor, + JSON.stringify({ role }) + ) + for (const [index, part] of content.entries()) { + db.prepare('INSERT INTO part VALUES (?, ?, ?, ?, ?)').run( + `${id}:${index}`, + id, + 'session', + cursor, + JSON.stringify(part) + ) + } + } + } + insert('oldest', 'user', 1, [{ type: 'text', text: 'Earlier history' }]) + for (let index = 1; index <= 1200; index++) { + insert(String(index), 'assistant', index * 7, [ + { type: 'reasoning', text: 'Я'.repeat(900) }, + { type: 'text', text: 'Я'.repeat(900) } + ]) + } + insert('latest', 'user', 1201 * 7, [{ type: 'text', text: 'Я'.repeat(900) }]) + db.exec('COMMIT') + db.close() + return path +} + +function context(clientKind: RpcContext['clientKind'] = 'mobile'): RpcContext { + const runtime = { registerSubscriptionCleanup: vi.fn(), cleanupSubscription: vi.fn() } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Native-chat only uses these subscription cleanup methods; its feature emitter records no interaction. + return { runtime: runtime as unknown as RpcContext['runtime'], clientKind } +} + +describe.each(['v1', 'v2'] as const)('%s byte-bounded semantic pages', (version) => { + it.each(['mobile', 'runtime'] as const)( + 'keeps an actual serialized %s replacement within E2EE admission and pages all groups', + async (clientKind) => { + const dbPath = denseFixture(version) + const page = readOpenCodeTranscriptPage({ dbPath, sessionId: 'session', limit: 2400 })! + expect(page.items).toHaveLength(2401) + expect(page.items.every((item) => Number.isFinite(item.message.transcriptOffset))).toBe(true) + expect(page.items.every((item) => item.message.transcriptOffset === item.rowid)).toBe(true) + const method = buildRegistry(NATIVE_CHAT_METHODS).get('nativeChat.subscribe')! + if (!isStreamingMethod(method)) { + throw new Error('Expected a subscription') + } + const ctx = context(clientKind) + const replies: string[] = [] + const request = { id: 'budget-control', authToken: 'fixture', method: 'nativeChat.subscribe' } + const emitter = createDispatcherStreamingFeatureEmitter( + ctx.runtime, + request, + { runtimeId: 'private-budget-control' }, + (reply) => replies.push(reply) + ) + await method.handler( + method.params?.parse({ agent: 'opencode2', sessionId: 'session', limit: 2000 }), + ctx, + emitter.emit + ) + const watcher = state.watcher + if (!watcher) { + throw new Error('Expected a native watcher') + } + watcher.onReplace?.( + page.items.map((item) => item.message), + page.hasMore, + page.beforeMessageRowId ?? 0 + ) + const reply = replies[0] + const admission = mobileE2EETextPayloadAdmissionBytes(reply) + const response = asRecord(JSON.parse(reply)) + const result = asRecord(response?.result) + const messages = Array.isArray(result?.messages) ? result.messages : [] + const receipt = process.env.ORCA_NATIVE_CHAT_BUDGET_RECEIPT + if (receipt && version === 'v2' && clientKind === 'mobile') { + writeFileSync(`${receipt}.payload.json`, reply) + writeFileSync( + receipt, + `${JSON.stringify( + { + source: process.env.ORCA_NATIVE_CHAT_BUDGET_SOURCE, + kind: 'Controlled synthetic SQLite stress; actual production parser/RPC serializer/E2EE admission, no genuine CLI claim', + inputMessages: page.items.length, + outputMessages: messages.length, + serializedBytes: Buffer.byteLength(reply), + sha256: createHash('sha256').update(reply).digest('hex'), + maximumBytes: REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES, + admission: Number.isFinite(admission) ? admission : 'Infinity', + hasMore: result?.hasMore, + beforeOffset: result?.beforeOffset, + first: messages[0], + last: messages.at(-1) + }, + null, + 2 + )}\n` + ) + } + expect(Number.isFinite(admission)).toBe(true) + expect(messages.length).toBeLessThan(page.items.length) + expect(result?.hasMore).toBe(true) + const first = asRecord(messages[0]) + expect(result?.beforeOffset).toBe(first?.transcriptOffset) + expect(messages.map((message) => asRecord(message)?.role).at(0)).toBe('reasoning') + expect(messages.map((message) => asRecord(message)?.role).at(1)).toBe('assistant') + expect(new Set(messages.map((message) => asRecord(message)?.id)).size).toBe(messages.length) + const read = buildRegistry(NATIVE_CHAT_METHODS).get('nativeChat.readSession')! + if (isStreamingMethod(read)) { + throw new Error('Expected a page read') + } + const pages = [messages] + let cursor = typeof result?.beforeOffset === 'number' ? result.beforeOffset : undefined + let hasMore = result?.hasMore === true + while (hasMore) { + const earlier = asRecord( + await read.handler( + read.params?.parse({ + agent: 'opencode', + sessionId: 'session', + limit: 2000, + beforeOffset: cursor + }), + ctx + ) + ) + const entries = Array.isArray(earlier?.messages) ? earlier.messages : [] + expect(entries.length).toBeGreaterThan(0) + const before = typeof earlier?.beforeOffset === 'number' ? earlier.beforeOffset : undefined + expect(before).toBeLessThan(cursor!) + cursor = before + hasMore = earlier?.hasMore === true + pages.unshift(entries) + } + const all = pages.flat() + const ids = all.map((message) => asRecord(message)?.id) + expect(all).toHaveLength(2402) + expect(new Set(ids).size).toBe(2402) + const prefix = version === 'v2' ? 'opencode:' : '' + expect(ids).toEqual([ + `${prefix}oldest`, + ...Array.from({ length: 1200 }, (_, index) => [ + `${prefix}${index + 1}:reasoning`, + `${prefix}${index + 1}` + ]).flat(), + `${prefix}latest` + ]) + } + ) +}) + +function message(id: string, offset: number, text: string): NativeChatMessage { + return { + id, + role: 'assistant', + source: 'transcript', + timestamp: 10, + transcriptOffset: offset, + blocks: [{ type: 'text', text }] + } +} + +it('replaces an unsendable newest raw group with one stable localized notice', () => { + const large = [ + message('row:reasoning', 42, 'Я'.repeat(1_000_000)), + message('row', 42, 'Я'.repeat(1_000_000)) + ] + large[0].role = 'reasoning' + const page = boundNativeChatRpcPageByBytes([message('older', 2, 'older'), ...large], false, 2) + expect(page).toEqual({ + messages: [ + { + id: 'row', + role: 'system', + source: 'transcript', + timestamp: 10, + transcriptOffset: 42, + blocks: [ + { + type: 'text', + text: 'This part of the chat was too large to show.', + presentation: 'history-item-too-large' + } + ] + } + ], + hasMore: true, + beforeOffset: 42 + }) + expect(Number.isFinite(mobileE2EETextPayloadAdmissionBytes(JSON.stringify(page)))).toBe(true) + const only = boundNativeChatRpcPageByBytes(large, false, 42) + expect(only.hasMore).toBe(false) + expect(only.beforeOffset).toBe(42) +}) + +it('keeps append batches complete and admits every serialized batch', () => { + const messages = Array.from({ length: 12 }, (_, index) => [ + message(`${index}:reasoning`, index, 'Я'.repeat(100_000)), + message(String(index), index, 'Я'.repeat(100_000)) + ]).flat() + for (let index = 0; index < messages.length; index += 2) { + messages[index].role = 'reasoning' + } + const batches = nativeChatRpcAppendBatches(messages) + expect(batches.length).toBeGreaterThan(1) + expect(batches.flat()).toEqual(messages) + for (const batch of batches) { + expect(batch[0].role).toBe('reasoning') + expect(batch.at(-1)?.role).toBe('assistant') + expect( + Number.isFinite( + mobileE2EETextPayloadAdmissionBytes(JSON.stringify({ type: 'appended', messages: batch })) + ) + ).toBe(true) + } +}) + +it('does not manufacture a paging cursor for legacy messages without optional offsets', () => { + const messages = [ + message('old', 1, 'Я'.repeat(700_000)), + message('new', 2, 'Я'.repeat(700_000)) + ].map(({ transcriptOffset: _offset, ...row }) => row) + const result = boundNativeChatRpcPageByBytes(messages, true, 90) + expect(result.hasMore).toBe(true) + expect(result.beforeOffset).toBe(90) + expect(result.messages).toHaveLength(1) + expect(result.messages[0].role).toBe('system') + expect(result.messages[0].blocks[0]).toMatchObject({ presentation: 'history-item-too-large' }) +}) + +it('preserves empty append lifecycle frames', async () => { + const method = buildRegistry(NATIVE_CHAT_METHODS).get('nativeChat.subscribe')! + if (!isStreamingMethod(method)) { + throw new Error('Expected a subscription') + } + const frames: unknown[] = [] + await method.handler( + method.params?.parse({ agent: 'opencode2', sessionId: 'session' }), + context(), + (frame) => frames.push(frame) + ) + state.watcher?.onAppend([], { state: 'completed', turnId: 'turn', timestamp: 10 }) + expect(frames).toEqual([ + { + type: 'appended', + messages: [], + lifecycle: { state: 'completed', turnId: 'turn', timestamp: 10 } + } + ]) +}) diff --git a/src/main/runtime/rpc/methods/native-chat-rpc-page-bounds.ts b/src/main/runtime/rpc/methods/native-chat-rpc-page-bounds.ts new file mode 100644 index 00000000000..1ebdf1056dc --- /dev/null +++ b/src/main/runtime/rpc/methods/native-chat-rpc-page-bounds.ts @@ -0,0 +1,100 @@ +import { + nativeChatMessagesShareTranscriptRow, + nativeChatSemanticRowId, + type NativeChatMessage +} from '../../../../shared/native-chat-types' +import { agentSessionHostStatusBody } from '../../../../shared/agent-session-host-status-rows' +import { boundJournalKeyComponent } from '../../../../shared/agent-session-journal-item-key' +import { HISTORY_PAGE_CONTENT_BUDGET_BYTES } from '../../../native-chat/agent-session-wire/agent-session-history-page-bounds' + +function oversizedRow(message: NativeChatMessage): NativeChatMessage { + const { text, presentation } = agentSessionHostStatusBody('history-item-too-large') + return { + id: boundJournalKeyComponent(nativeChatSemanticRowId(message)), + role: 'system', + blocks: [{ type: 'text', text, presentation }], + timestamp: message.timestamp, + source: message.source, + ...(message.transcriptOffset !== undefined + ? { transcriptOffset: message.transcriptOffset } + : {}) + } +} + +function groupBytes(group: readonly NativeChatMessage[]): number { + return group.reduce((bytes, message) => bytes + Buffer.byteLength(JSON.stringify(message)) + 1, 0) +} + +function rowGroups(messages: readonly NativeChatMessage[]): NativeChatMessage[][] { + const groups: NativeChatMessage[][] = [] + for (const message of messages) { + const last = groups.at(-1) + if (last && nativeChatMessagesShareTranscriptRow(last.at(-1)!, message)) { + last.push(message) + } else { + groups.push([message]) + } + } + return groups +} + +export function boundNativeChatRpcPageByBytes( + messages: readonly NativeChatMessage[], + hasMore: boolean, + beforeOffset: number +): { messages: NativeChatMessage[]; hasMore: boolean; beforeOffset: number } { + const groups = rowGroups(messages) + const kept: NativeChatMessage[][] = [] + let bytes = 2 + let retained = 0 + for (const group of groups.toReversed()) { + const size = groupBytes(group) + if (kept.length === 0 && size > HISTORY_PAGE_CONTENT_BUDGET_BYTES) { + kept.push([oversizedRow(group[0])]) + retained += group.length + break + } + if (bytes + size > HISTORY_PAGE_CONTENT_BUDGET_BYTES) { + break + } + kept.push(group) + retained += group.length + bytes += size + } + const selected = kept.toReversed().flat() + const dropped = retained < messages.length + const cursor = selected[0]?.transcriptOffset + // A legacy projection has no row cursor: explicitly omit the page rather than skip unseen history. + if (dropped && cursor === undefined) { + return { messages: messages.length ? [oversizedRow(messages[0])] : [], hasMore, beforeOffset } + } + return { + messages: selected, + hasMore: hasMore || dropped, + beforeOffset: dropped ? (cursor ?? beforeOffset) : beforeOffset + } +} + +export function nativeChatRpcAppendBatches( + messages: readonly NativeChatMessage[] +): NativeChatMessage[][] { + const batches: NativeChatMessage[][] = [] + let batch: NativeChatMessage[] = [] + let bytes = 2 + for (const group of rowGroups(messages)) { + const bounded = + groupBytes(group) > HISTORY_PAGE_CONTENT_BUDGET_BYTES ? [oversizedRow(group[0])] : group + const size = groupBytes(bounded) + if (batch.length > 0 && bytes + size > HISTORY_PAGE_CONTENT_BUDGET_BYTES) { + batches.push(batch) + batch = [] + bytes = 2 + } + batch.push(...bounded) + bytes += size + } + if (batch.length > 0) { + batches.push(batch) + } + return batches +} diff --git a/src/main/runtime/rpc/methods/native-chat.ts b/src/main/runtime/rpc/methods/native-chat.ts index 05f8f7f8726..99c0178ea0c 100644 --- a/src/main/runtime/rpc/methods/native-chat.ts +++ b/src/main/runtime/rpc/methods/native-chat.ts @@ -1,4 +1,5 @@ import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { resolveNativeChatTranscriptAgent } from '../../../../shared/native-chat-agent-support' import { readNativeChatTranscriptTail, subscribeNativeChatTranscript, @@ -7,6 +8,10 @@ import { } from '../../../native-chat/transcript-watch' import { defineMethod, defineStreamingMethod, type RpcContext } from '../core' import { sanitizeNativeChatRpcBlock } from './native-chat-rpc-block-sanitize' +import { + boundNativeChatRpcPageByBytes, + nativeChatRpcAppendBatches +} from './native-chat-rpc-page-bounds' import { MOBILE_NATIVE_CHAT_MAX_WINDOW, NativeChatSession, @@ -51,16 +56,21 @@ function windowTranscript( return messages.length > window ? messages.slice(-window) : messages.slice() } -/** Apply the windowed slice and keep inline image bytes off every RPC transport. - * Mobile clients additionally receive bounded text and tool bodies; runtime - * clients keep those bodies intact. */ -function windowForClient( +function pageForClient( messages: readonly NativeChatMessage[], + hasMore: boolean, + beforeOffset: number, clientKind: RpcContext['clientKind'], - limit = MOBILE_NATIVE_CHAT_DEFAULT_WINDOW -): NativeChatMessage[] { - const windowed = windowTranscript(messages, limit) - return windowed.map((message) => sanitizeMessage(message, clientKind)) + limit = MOBILE_NATIVE_CHAT_DEFAULT_WINDOW, + agent?: string +): { messages: NativeChatMessage[]; hasMore: boolean; beforeOffset: number } { + const isOpenCode = resolveNativeChatTranscriptAgent(agent) === 'opencode' + const sanitized = (isOpenCode ? messages : windowTranscript(messages, limit)).map((message) => + sanitizeMessage(message, clientKind) + ) + return isOpenCode + ? boundNativeChatRpcPageByBytes(sanitized, hasMore, beforeOffset) + : { messages: sanitized, hasMore, beforeOffset } } export const NATIVE_CHAT_METHODS = [ @@ -81,9 +91,14 @@ export const NATIVE_CHAT_METHODS = [ ) return 'messages' in result ? { - messages: windowForClient(result.messages, clientKind, limit), - hasMore: result.hasMore, - beforeOffset: result.beforeOffset, + ...pageForClient( + result.messages, + result.hasMore, + result.beforeOffset, + clientKind, + limit, + params.agent + ), ...(result.lifecycle ? { lifecycle: result.lifecycle } : {}) } : result @@ -144,9 +159,7 @@ export const NATIVE_CHAT_METHODS = [ // instead of stranding the view at 'loading' when the read keeps throwing. emit({ type: 'snapshot', - messages: windowForClient(messages, clientKind, limit), - hasMore, - beforeOffset, + ...pageForClient(messages, hasMore, beforeOffset, clientKind, limit, params.agent), ...(error ? { error } : {}), ...(lifecycle ? { lifecycle } : {}) }) @@ -166,9 +179,7 @@ export const NATIVE_CHAT_METHODS = [ } emit({ type: 'replacement', - messages: windowForClient(messages, clientKind, limit), - hasMore, - beforeOffset, + ...pageForClient(messages, hasMore, beforeOffset, clientKind, limit, params.agent), ...(lifecycle ? { lifecycle } : {}) }) }, @@ -176,11 +187,18 @@ export const NATIVE_CHAT_METHODS = [ if (closed) { return } - emit({ - type: 'appended', - messages: sanitizeAppendForClient(messages, clientKind), - ...(lifecycle ? { lifecycle } : {}) - }) + const sanitized = sanitizeAppendForClient(messages, clientKind) + const batches = + sanitized.length > 0 && resolveNativeChatTranscriptAgent(params.agent) === 'opencode' + ? nativeChatRpcAppendBatches(sanitized) + : [sanitized] + for (const batch of batches) { + emit({ + type: 'appended', + messages: batch, + ...(lifecycle && batch === batches.at(-1) ? { lifecycle } : {}) + }) + } } } let subscription: NativeChatTranscriptSubscription diff --git a/src/shared/native-chat-merge-pairs.test.ts b/src/shared/native-chat-merge-pairs.test.ts new file mode 100644 index 00000000000..fe59e24334a --- /dev/null +++ b/src/shared/native-chat-merge-pairs.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest' +import { + applyAppend, + boundNativeChatWindow, + createNativeChatMerger, + replaceList +} from './native-chat-merge' +import type { NativeChatMessage } from './native-chat-types' + +function row(id: string, overrides: Partial = {}): NativeChatMessage { + return { + id, + role: 'assistant', + source: 'transcript', + timestamp: 1, + blocks: [{ type: 'text', text: id }], + ...overrides + } +} + +function pair(id: string): NativeChatMessage[] { + return [row(`${id}:reasoning`, { role: 'reasoning' }), row(id)] +} + +describe('native chat complete-pair windows', () => { + it.each(['msg-provider', 'opencode:msg-provider'])('keeps the cutoff pair for %s', (id) => { + const messages = [row('older'), ...pair(id), row('latest')] + expect(boundNativeChatWindow(messages, 2)).toEqual(messages.slice(1)) + expect(messages.map((message) => message.id)).toEqual([ + 'older', + `${id}:reasoning`, + id, + 'latest' + ]) + }) + + it('keeps the newest complete pair even at limit 1', () => { + const messages = [...pair('older'), ...pair('latest')] + expect(boundNativeChatWindow(messages, 1)).toEqual(messages.slice(2)) + }) + + it('does not group unrelated reasoning, suffixes, or user rows', () => { + const unrelated = [row('thinking', { role: 'reasoning' }), row('answer'), row('latest')] + expect(boundNativeChatWindow(unrelated, 2)).toEqual(unrelated.slice(1)) + const suffixOnly = [row('answer:reasoning'), row('answer'), row('latest')] + expect(boundNativeChatWindow(suffixOnly, 2)).toEqual(suffixOnly.slice(1)) + const user = [row('prompt:reasoning', { role: 'reasoning' }), row('prompt', { role: 'user' })] + expect(boundNativeChatWindow(user, 1)).toEqual(user.slice(1)) + }) + + it('keeps ordering, source precedence and cached indexes after paired trims', () => { + const merger = createNativeChatMerger() + const [reasoning, answer] = pair('opencode:first') + if (!reasoning || !answer) { + throw new Error('Expected a pair') + } + replaceList(merger, [row('older'), reasoning, { ...answer, source: 'hook' }]) + const authoritative = { ...answer, blocks: [{ type: 'text' as const, text: 'final answer' }] } + const newest = row('newest') + expect(applyAppend(merger, [authoritative, newest], 2)).toEqual([ + reasoning, + authoritative, + newest + ]) + expect([...merger.indexById]).toEqual([ + [reasoning.id, 0], + [answer.id, 1], + ['newest', 2] + ]) + expect(applyAppend(merger, [{ ...answer, source: 'scrape' }], 2)[1]).toBe(authoritative) + expect(applyAppend(merger, pair('opencode:next'), 2).map((message) => message.id)).toEqual([ + 'opencode:next:reasoning', + 'opencode:next' + ]) + expect([...merger.indexById]).toEqual([ + ['opencode:next:reasoning', 0], + ['opencode:next', 1] + ]) + }) + + it.each([1, 40, 2000])('stays at most one row above limit %i across long appends', (limit) => { + const merger = createNativeChatMerger() + for (let index = 0; index < limit + 20; index++) { + const messages = applyAppend( + merger, + index % 2 === 0 ? pair(`opencode:${index}`) : [row(`user-${index}`, { role: 'user' })], + limit + ) + expect(messages.length).toBeLessThanOrEqual(limit + 1) + expect(messages[0]?.role).not.toBe('assistant') + expect(merger.indexById.size).toBe(messages.length) + } + for (let at = 0; at < merger.list.length; at++) { + const message = merger.list[at] + expect(merger.indexById.get(message.id)).toBe(at) + if (message.role === 'reasoning') { + expect(merger.list[at + 1]?.id).toBe(message.id.slice(0, -':reasoning'.length)) + } else if (message.role === 'assistant') { + expect(merger.list[at - 1]?.id).toBe(`${message.id}:reasoning`) + } + } + }) +}) + +it('retains reasoning, answer and omission projections sharing an optional raw-row cursor', () => { + const messages = [ + row('older'), + ...pair('mixed').map((message) => ({ ...message, transcriptOffset: 42 })), + row('mixed:omission', { role: 'system', transcriptOffset: 42 }), + row('latest', { transcriptOffset: 43 }) + ] + expect(boundNativeChatWindow(messages, 2)).toEqual(messages.slice(1)) + expect(boundNativeChatWindow(messages, 1)).toEqual(messages.slice(-1)) +}) diff --git a/src/shared/native-chat-merge.ts b/src/shared/native-chat-merge.ts index a2e12ad8302..310634af4b3 100644 --- a/src/shared/native-chat-merge.ts +++ b/src/shared/native-chat-merge.ts @@ -1,6 +1,7 @@ // Pure id-dedup and windowing for both desktop and mobile native-chat streams. import { + nativeChatMessagesShareTranscriptRow, NATIVE_CHAT_SOURCE_PRIORITY, type NativeChatMessage, type NativeChatSource @@ -35,10 +36,7 @@ export function mergeNativeChatMessages( return mergeNativeChatMessagesWith(existing, incoming, NATIVE_CHAT_SOURCE_PRIORITY) } -/** Cap a message list to its most-recent `limit` entries. The base read is - * already windowed; this keeps the live-append tail bounded to the same window - * so a long run can't grow the list without limit. A non-positive limit means - * "no cap". Returns the input reference when no trim is needed. */ +/** Keep the recent tail without splitting a provider row. A non-positive limit means no cap. */ export function boundNativeChatWindow( messages: readonly NativeChatMessage[], limit: number @@ -46,7 +44,11 @@ export function boundNativeChatWindow( if (limit <= 0 || messages.length <= limit) { return messages as NativeChatMessage[] } - return messages.slice(messages.length - limit) + let start = messages.length - limit + while (start > 0 && nativeChatMessagesShareTranscriptRow(messages[start - 1], messages[start])) { + start -= 1 + } + return messages.slice(start) } /** Stateful id-dedup merger that caches the id→index map across appends so a diff --git a/src/shared/native-chat-reasoning-order.test.ts b/src/shared/native-chat-reasoning-order.test.ts new file mode 100644 index 00000000000..ea3fd56305b --- /dev/null +++ b/src/shared/native-chat-reasoning-order.test.ts @@ -0,0 +1,46 @@ +import { expect, it } from 'vitest' +import type { NativeChatMessage } from './native-chat-types' +import { + compareNativeChatTranscriptMessages, + projectNativeChatTranscriptMessages +} from './native-chat-transcript-projection' + +function row(id: string, role: NativeChatMessage['role'], timestamp = 1): NativeChatMessage { + return { id, role, timestamp, source: 'transcript', blocks: [{ type: 'text', text: id }] } +} + +it('keeps derived reasoning immediately before its answer without changing provider timestamps', () => { + const messages = [ + row('opencode:msg_b', 'assistant'), + row('opencode:msg_a:other', 'assistant'), + row('opencode:msg_a', 'assistant'), + row('opencode:msg_b:reasoning', 'reasoning'), + row('opencode:msg_a:reasoning', 'reasoning') + ] + const sorted = [...messages].sort(compareNativeChatTranscriptMessages) + expect(sorted.map((message) => message.id)).toEqual([ + 'opencode:msg_a:reasoning', + 'opencode:msg_a', + 'opencode:msg_a:other', + 'opencode:msg_b:reasoning', + 'opencode:msg_b' + ]) + expect(projectNativeChatTranscriptMessages(messages)).toEqual(sorted) + expect(messages.every((message) => message.timestamp === 1)).toBe(true) + for (let first = 0; first < sorted.length; first++) { + for (let second = first + 1; second < sorted.length; second++) { + expect(compareNativeChatTranscriptMessages(sorted[first], sorted[second])).toBeLessThan(0) + expect(compareNativeChatTranscriptMessages(sorted[second], sorted[first])).toBeGreaterThan(0) + } + } +}) + +it('keeps timestamp and journal authority ahead of a derived reasoning key', () => { + const answer = row('row', 'assistant', 1) + const laterReasoning = row('row:reasoning', 'reasoning', 2) + expect(compareNativeChatTranscriptMessages(answer, laterReasoning)).toBeLessThan(0) + answer.journalPosition = { sequence: 1, index: 0 } + laterReasoning.journalPosition = { sequence: 2, index: 0 } + laterReasoning.timestamp = 0 + expect(compareNativeChatTranscriptMessages(answer, laterReasoning)).toBeLessThan(0) +}) diff --git a/src/shared/native-chat-transcript-projection.ts b/src/shared/native-chat-transcript-projection.ts index d826ecaf552..3ab78007edb 100644 --- a/src/shared/native-chat-transcript-projection.ts +++ b/src/shared/native-chat-transcript-projection.ts @@ -7,7 +7,7 @@ // conversation: each subagent's rows are that subagent's, kept apart and folded on // their own, for the surface that shows them beside the spawn that ran it. -import type { NativeChatMessage } from './native-chat-types' +import { nativeChatSemanticRowId, type NativeChatMessage } from './native-chat-types' import { compareAgentJournalPositions } from './agent-session-journal-position' import { agentJournalItemSubagentId } from './agent-session-journal-producer' import { stripNoiseMessages } from './native-chat-noise' @@ -25,13 +25,18 @@ export function compareNativeChatMessagesByTime( if (at !== bt) { return at - bt } - if (a.id < b.id) { + // Split reasoning shares its provider row's key, before that row's answer. + const aId = nativeChatSemanticRowId(a) + const bId = nativeChatSemanticRowId(b) + const aReasoning = aId !== a.id + const bReasoning = bId !== b.id + if (aId < bId) { return -1 } - if (a.id > b.id) { + if (aId > bId) { return 1 } - return 0 + return Number(bReasoning) - Number(aReasoning) } /** Rows the journal holds read in the journal's own order, never its clock: a diff --git a/src/shared/native-chat-types.ts b/src/shared/native-chat-types.ts index db7b7024cdc..127a9837910 100644 --- a/src/shared/native-chat-types.ts +++ b/src/shared/native-chat-types.ts @@ -205,6 +205,8 @@ export type NativeChatMessage = AgentJournalProducerLinkage & { * supply one (e.g. some scrape segments). Null sorts before any timestamp. */ timestamp: number | null source: NativeChatSource + /** Optional provider row cursor; split projections share it for whole-row paging. */ + transcriptOffset?: number /** Optional explicit turn key. When present, two messages with the same * `turnId` are treated as the same turn for dedup regardless of `id`. */ turnId?: string @@ -223,6 +225,28 @@ export type NativeChatMessage = AgentJournalProducerLinkage & { foldedJournalPosition?: AgentJournalPosition } +/** Split reasoning and its answer share the provider's row identity. */ +export function nativeChatSemanticRowId(message: NativeChatMessage): string { + return message.role === 'reasoning' && message.id.endsWith(':reasoning') + ? message.id.slice(0, -':reasoning'.length) + : message.id +} + +/** New hosts expose the cursor; older messages retain their reasoning/answer id convention. */ +export function nativeChatMessagesShareTranscriptRow( + first: NativeChatMessage, + second: NativeChatMessage +): boolean { + if (typeof first.transcriptOffset === 'number' && typeof second.transcriptOffset === 'number') { + return first.transcriptOffset === second.transcriptOffset + } + return ( + first.role === 'reasoning' && + second.role === 'assistant' && + nativeChatSemanticRowId(first) === second.id + ) +} + export const NATIVE_CHAT_TURN_LIFECYCLE_STATES = ['working', 'completed', 'interrupted'] as const export type NativeChatTurnLifecycleState = (typeof NATIVE_CHAT_TURN_LIFECYCLE_STATES)[number] From 1dc6157614c1620d6c229959ba022718c70e8973 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:23:01 -0700 Subject: [PATCH 35/67] ci: run the cross-version tests when the chat send builders or the RPC request path change (#25061) * ci: run cross-version wire suites when agent sends or orchestration change The selector skipped the cross-version wire job for #24901, which changed the shared agent-send path, the structured send envelope builders, and orchestration RPC code. Route the send payload/fingerprint builders, src/main/runtime/orchestration/, and the orchestration RPC methods to the job, and pin each rule in a test. * ci: run cross-version wire suites only for code they execute The agent-session suites now build their send through the shared outbox builder and check it against the release host's schema and fingerprint, so the send builder and outbox are selected exactly. Load-only orchestration rules are dropped; the dispatcher-path files every suite request runs through are selected instead. * ci: run the release's own send admission, cover queued sends and the RPC reply builder * test(cross-version): a wrong send fingerprint must be refused, so an agreed one is not vacuous --- ...nge-scope-cross-version-send-path.test.mjs | 39 +++++++++ config/scripts/pr-code-change-scope.mjs | 14 ++++ ...ss-version-agent-session-send.unit.test.ts | 84 +++++++++++++++++++ ...ructured-agent-session-surface-manifest.ts | 22 ++++- .../versioned-agent-session-wire.ts | 49 +++++++++++ 5 files changed, 205 insertions(+), 3 deletions(-) create mode 100644 config/scripts/pr-code-change-scope-cross-version-send-path.test.mjs create mode 100644 tests/e2e/cross-version-wire/cross-version-agent-session-send.unit.test.ts diff --git a/config/scripts/pr-code-change-scope-cross-version-send-path.test.mjs b/config/scripts/pr-code-change-scope-cross-version-send-path.test.mjs new file mode 100644 index 00000000000..b263b86f789 --- /dev/null +++ b/config/scripts/pr-code-change-scope-cross-version-send-path.test.mjs @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import { classifyPrJobs } from './pr-code-change-scope.mjs' + +// #24901 changed the send builders and orchestration code, and this job skipped. It runs only for +// code a suite executes: loading a module the dispatcher registers is not coverage. +describe('cross-version wire routing for the send path', () => { + it.each([ + 'src/shared/agent-session-wire-refusals.ts', + 'src/shared/structured-agent-session-mutation.ts', + 'src/shared/structured-agent-session-send-mutation.ts', + 'src/shared/structured-agent-session-outbox.ts', + 'src/main/runtime/rpc/core.ts', + 'src/main/runtime/rpc/errors.ts', + 'src/main/runtime/rpc/rpc-streaming-dispatcher.ts', + 'src/main/runtime/rpc/orchestration-contract-fence.ts', + 'src/main/runtime/rpc/orchestration-session-caller.ts', + 'src/main/runtime/rpc/orchestration-legacy-compatibility.ts', + 'src/main/runtime/rpc/orchestration-mutation-executor.ts', + 'src/shared/orchestration-rpc-contract.ts', + 'src/main/runtime/orchestration/db/schema/migrate.ts' + ])('runs the cross-version suites when %s changes', (file) => { + expect(classifyPrJobs([file])).toMatchObject({ should_run: true, 'cross-version-wire': true }) + }) + + it.each([ + 'src/shared/structured-agent-session-outbox-admission.ts', + 'src/shared/structured-agent-session-outbox-delivery.ts', + 'src/shared/structured-agent-session-outbox-stop-withdrawal.ts', + 'src/shared/structured-agent-session-composer.ts', + 'src/shared/structured-agent-session-reducer.ts', + 'src/main/runtime/orchestration/send-agent-turn.ts', + 'src/main/runtime/orchestration/orchestration-caller-identity.ts', + 'src/main/runtime/rpc/orchestration-legacy-mail.ts', + 'src/main/runtime/rpc/methods/orchestration.ts', + 'src/main/runtime/rpc/methods/orchestration/runs/dispatch-methods.ts' + ])('leaves them off for %s, which no cross-version suite executes', (file) => { + expect(classifyPrJobs([file])).toMatchObject({ should_run: true, 'cross-version-wire': false }) + }) +}) diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index 9f2f672dc84..9c658533e82 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -165,6 +165,11 @@ const CROSS_VERSION_WIRE_PREFIXES = [ 'src/shared/rpc-contract/agent-launch-params', 'src/shared/agent-session-wire', 'src/shared/agent-session-mutation-envelope', + // The send a client builds (the agent-session suite sends it to the release host) and the + // fingerprint the host's ledger and journal re-derive. + 'src/shared/structured-agent-session-mutation.ts', + 'src/shared/structured-agent-session-send-mutation.ts', + 'src/shared/structured-agent-session-outbox.ts', 'src/shared/agent-session-record', 'src/shared/agent-session-journal-', 'src/main/ai-vault/structured-session-ownership.ts', @@ -174,6 +179,15 @@ const CROSS_VERSION_WIRE_PREFIXES = [ 'src/main/runtime/agent-session-recovery-capsule', 'src/shared/agent-session-resume-marker', 'src/main/runtime/rpc/dispatcher', + // Run on every request the suites dispatch, whatever its method. + 'src/main/runtime/rpc/core.ts', + 'src/main/runtime/rpc/errors.ts', + 'src/main/runtime/rpc/rpc-streaming-dispatcher.ts', + 'src/main/runtime/rpc/orchestration-contract-fence.ts', + 'src/main/runtime/rpc/orchestration-session-caller.ts', + 'src/main/runtime/rpc/orchestration-legacy-compatibility.ts', + 'src/main/runtime/rpc/orchestration-mutation-executor.ts', + 'src/shared/orchestration-rpc-contract.ts', 'src/main/runtime/rpc/methods/agent-launch', 'src/main/runtime/rpc/methods/ai-vault.ts', 'src/main/runtime/rpc/methods/browser-tab-create-schema', diff --git a/tests/e2e/cross-version-wire/cross-version-agent-session-send.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-agent-session-send.unit.test.ts new file mode 100644 index 00000000000..5d79753797e --- /dev/null +++ b/tests/e2e/cross-version-wire/cross-version-agent-session-send.unit.test.ts @@ -0,0 +1,84 @@ +// A current client's `agentSession.send`, built by the outbox clients send from, against a real +// published release. The release's schema must take it and its admission must re-derive the +// fingerprint the client declared: a send it rejects, or digests differently and refuses as an +// operation conflict, loses the user's message. + +import { beforeAll, describe, expect, it } from 'vitest' +import { resolveBaselineReleaseRef } from './release-checkout' +import { installableHost, structuredHostStub } from './structured-agent-session-host-fixture' +import { + resetOperationIds, + sendParams, + SESSION, + WORKSPACE +} from './structured-agent-session-surface-manifest' +import { + loadAgentSessionWireBuild, + WORKING_TREE, + type AgentSessionWireBuild, + type RpcReply, + type SentMessage +} from './versioned-agent-session-wire' + +// Why: a cold CI run extracts the baseline checkout before the first pairing. +const SUITE_TIMEOUT_MS = 180_000 + +let current: AgentSessionWireBuild +let baseline: AgentSessionWireBuild + +beforeAll(async () => { + current = await loadAgentSessionWireBuild(WORKING_TREE) + baseline = await loadAgentSessionWireBuild(resolveBaselineReleaseRef()) +}, SUITE_TIMEOUT_MS) + +async function send(build: AgentSessionWireBuild, params: unknown): Promise { + const replies: RpcReply[] = [] + await build + // Releases before the setting gate was dropped admit structured calls only with it on. + .createDispatcher({ + getRuntimeId: () => 'runtime-1', + getClientSettings: () => ({ experimentalStructuredNativeChat: true }) + }) + .dispatchStreaming( + { id: 'request-send', authToken: 'cross-version-token', method: 'agentSession.send', params }, + (raw) => replies.push(JSON.parse(raw)), + { clientKind: 'runtime', clientCapabilities: current.capabilities } + ) + return replies +} + +describe('a current client sending a message', () => { + it.each([ + ['a plain message', undefined], + // `delivery` joins the operation fingerprint, so a builder that drops it is refused by a host + // that digests it. + ['a message held as a draft while a turn runs', 'queue-if-active' as const] + ])('is accepted for %s, under the fingerprint each build re-derives', async (_case, delivery) => { + // Anti-vacuous: the release must have the method, or there is no older host to send to. + expect(baseline.methodNames).toContain('agentSession.send') + for (const build of [current, baseline]) { + resetOperationIds() + const hostCalls = structuredHostStub(SESSION, WORKSPACE) + await build.installStructuredHost(installableHost(hostCalls)) + try { + const replies = await send(build, sendParams('hi', 1, delivery)) + expect(replies, `${build.label}: ${JSON.stringify(replies)}`).toMatchObject([{ ok: true }]) + expect(hostCalls.send, `${build.label}: the send reached the host`).toHaveBeenCalledTimes(1) + const sent: SentMessage = hostCalls.send.mock.calls[0]?.[1] + expect(sent.delivery, `${build.label}: delivery reached the host`).toBe(delivery) + // Past the fingerprint check, admission stops at the journal this harness never opens. + expect(await build.admitSend(sent), `${build.label}: send admission`).toMatchObject({ + ok: false, + refusal: { details: { reason: 'sessionNotAttached' } } + }) + // Negative control: proves the fingerprint check ran before admission reached the journal. + const wrong = { ...sent.envelope, payloadFingerprint: '0'.repeat(64) } + expect(await build.admitSend({ ...sent, envelope: wrong }), build.label).toMatchObject({ + refusal: { details: { reason: 'fingerprintMismatch' } } + }) + } finally { + await build.installStructuredHost(null) + } + } + }) +}) diff --git a/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts b/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts index 7eb4920deec..b999f63a651 100644 --- a/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts +++ b/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts @@ -11,6 +11,10 @@ import { attachFingerprintFields } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-attach' import type { AgentSessionAttachParams } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-attach' import { computeAgentSessionPayloadFingerprint } from '../../../src/shared/agent-session-mutation-envelope' +import { + createStructuredAgentSessionOutboxEntry, + structuredAgentSessionSendRequest +} from '../../../src/shared/structured-agent-session-outbox' export const SESSION = 'session-alpha' export const WORKSPACE = 'workspace-1' @@ -237,9 +241,21 @@ export function createIntentParams(): Record { return { envelope: envelope({ method: 'agentSession.create', fields, fence: null }), ...fields } } -export function sendParams(text: string, fence: number): Record { - const body = { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } - return { envelope: envelope({ method: 'agentSession.send', fields: { body }, fence }), body } +/** Built by the outbox clients send from, so an older host is handed exactly what a current + * client puts on the wire, fingerprint included. */ +export function sendParams( + text: string, + fence: number, + sentDelivery?: 'queue-if-active' +): Record { + const entry = createStructuredAgentSessionOutboxEntry({ + clientMessageId: operationId(), + sessionId: SESSION, + text, + attachments: [], + queuedAt: NOW + }) + return structuredAgentSessionSendRequest({ ...entry, sentDelivery }, fence) } /** Schema-valid params per method; values only need to survive validation. */ diff --git a/tests/e2e/cross-version-wire/versioned-agent-session-wire.ts b/tests/e2e/cross-version-wire/versioned-agent-session-wire.ts index 78efd420f83..da2f457ec8a 100644 --- a/tests/e2e/cross-version-wire/versioned-agent-session-wire.ts +++ b/tests/e2e/cross-version-wire/versioned-agent-session-wire.ts @@ -1,3 +1,4 @@ +import type { sendPlan } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans' import { importReleaseCheckoutModule, materializeReleaseCheckout, @@ -17,6 +18,10 @@ export const WORKING_TREE = 'working-tree' as const * in current source is invisible to a release checkout's dispatcher. */ const STRUCTURED_HOST_REGISTRY = '/src/main/native-chat/agent-session-wire/structured-agent-session-registry.ts' +const MUTATION_PLANS = + '/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts' +const MUTATION_ADMISSION = + '/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts' export type RpcReply = { id: string @@ -60,6 +65,10 @@ export type AgentSessionWireBuild = { * the surface stays loadable, and throws rather than no-opping so a build with * no slot cannot read as a surface that answered. */ installStructuredHost: (host: unknown) => Promise + /** This build's own admission of the `agentSession.send` params its host was handed. With no + * journal it stops after the fingerprint check: a fingerprint it derives differently refuses + * as `fingerprintMismatch`, one it agrees with as `sessionNotAttached`. */ + admitSend: (sent: SentMessage) => Promise } type DispatcherModule = { @@ -86,6 +95,32 @@ function applyStructuredHost(module: Record, label: string, hos ;(install as (next: unknown) => void)(host) } +/** The `agentSession.send` params a host is handed. */ +export type SentMessage = Parameters[0] + +type SendAdmissionModules = { + sendPlan: (sent: SentMessage) => unknown + admitAndRunAgentSessionMutation: (request: { + plan: unknown + envelope: SentMessage['envelope'] + journal: () => undefined + }) => Promise +} + +async function admitSend( + plans: Record, + admission: Record, + sent: SentMessage +): Promise { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the build's own send plan and admission; with no `prepareSession` it reads only plan, envelope and journal. A drifted export or shape fails this check. + const build = { ...plans, ...admission } as unknown as SendAdmissionModules + return build.admitAndRunAgentSessionMutation({ + plan: build.sendPlan(sent), + envelope: sent.envelope, + journal: () => undefined + }) +} + function capabilityStrings(module: Record): readonly string[] { const declared = module.RUNTIME_CAPABILITIES if (!Array.isArray(declared) || declared.length === 0) { @@ -117,6 +152,13 @@ async function loadWorkingTreeBuild(): Promise { const registry = await import('../../../src/main/native-chat/agent-session-wire/structured-agent-session-registry') applyStructuredHost(registry as unknown as Record, WORKING_TREE, host) + }, + admitSend: async (sent) => { + const [plans, admission] = await Promise.all([ + import('../../../src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans'), + import('../../../src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission') + ]) + return admitSend(plans, admission, sent) } } } @@ -146,6 +188,13 @@ async function loadReleaseBuild(checkout: ReleaseCheckout): Promise { + const [plans, admission] = await Promise.all([ + importReleaseCheckoutModule(checkout, MUTATION_PLANS), + importReleaseCheckoutModule(checkout, MUTATION_ADMISSION) + ]) + return admitSend(plans, admission, sent) } } } From 3cc5e1dac659ffd163b81b28261cd843261f4ca5 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:47:26 -0700 Subject: [PATCH 36/67] Avoid duplicate ripgrep scans for full file inventories (#23490) * Avoid duplicate ripgrep scans for unbounded file inventories * Pin the broad ripgrep pass superset contract --- src/main/ipc/filesystem-list-files.test.ts | 155 +++++++----------- src/main/ipc/filesystem-list-files.ts | 13 +- .../fs-handler-list-files-cancel.test.ts | 20 +-- .../fs-handler-list-files-ignored.test.ts | 91 +++++----- src/relay/fs-handler-list-files.ts | 27 +-- src/shared/quick-open-filter.test.ts | 13 ++ src/shared/quick-open-filter.ts | 2 +- 7 files changed, 132 insertions(+), 189 deletions(-) diff --git a/src/main/ipc/filesystem-list-files.test.ts b/src/main/ipc/filesystem-list-files.test.ts index e9a4d406d7f..84567c85419 100644 --- a/src/main/ipc/filesystem-list-files.test.ts +++ b/src/main/ipc/filesystem-list-files.test.ts @@ -136,38 +136,60 @@ describe('filesystem-list-files', () => { expect(spawnMock.mock.calls[0]?.[1]).not.toContain('--version') }) - it('merges normal files and ignored files and filters correctly', async () => { - const p1 = createMockProcess() - const p2 = createMockProcess() + it('keeps source files first when only a serialized byte budget is provided', async () => { + const source = createMockProcess() + const broad = createMockProcess() + spawnMock.mockImplementation((_command, args: string[]) => + isIgnoredRgPass(args) ? broad : source + ) + const listing = listQuickOpenFiles( + '/mock/root', + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: authorization and workspace lookup are mocked above. + {} as Store, + undefined, + undefined, + undefined, + 20 + ) + await flushMicrotasks() + expect(spawnMock).toHaveBeenCalledTimes(1) + expect(spawnMock.mock.calls[0]?.[1]).not.toContain('--no-ignore-vcs') + source.stdout?.emit('data', 'source.ts\n') + source.emit('close', 0, null) + await flushMicrotasks() + expect(spawnMock).toHaveBeenCalledTimes(2) + expect(spawnMock.mock.calls[1]?.[1]).toContain('--no-ignore-vcs') + broad.stdout?.emit('data', 'ignored-file.ts\n') + await expect(listing).resolves.toEqual(['source.ts']) + expect(broad.kill).toHaveBeenCalledOnce() + }) - spawnMock.mockImplementation((_cmd, args: string[]) => { - if (isIgnoredRgPass(args)) { - return p2 - } - return p1 - }) + it('lists normal and ignored files with one broad scan and filters correctly', async () => { + const p1 = createMockProcess() + + spawnMock.mockReturnValue(p1) const storeMock = {} as unknown as Store const promise = listQuickOpenFiles('/mock/root', storeMock) await flushMicrotasks() - expect(spawnMock).toHaveBeenCalledTimes(2) + expect(spawnMock).toHaveBeenCalledTimes(1) + expect(spawnMock.mock.calls[0]?.[1]).toContain('--no-ignore-vcs') // Simulate stdout output for normal files setTimeout(() => { - ;(p1.stdout as unknown as EventEmitter).emit('data', 'file1.ts\n') - ;(p1.stdout as unknown as EventEmitter).emit('data', 'node_modules/bad.js\n') - ;(p1.stdout as unknown as EventEmitter).emit('data', '.git/config\n') - ;(p1.stdout as unknown as EventEmitter).emit('data', '.github/workflows/ci.yml\n') - ;(p1.stdout as unknown as EventEmitter).emit('data', 'dir1/') // incomplete line - ;(p1.stdout as unknown as EventEmitter).emit('data', 'file2.js\n') - p1.emit('close', 0, null) + p1.stdout?.emit('data', 'file1.ts\n') + p1.stdout?.emit('data', 'node_modules/bad.js\n') + p1.stdout?.emit('data', '.git/config\n') + p1.stdout?.emit('data', '.github/workflows/ci.yml\n') + p1.stdout?.emit('data', 'dir1/') // incomplete line + p1.stdout?.emit('data', 'file2.js\n') - // Simulate stdout output for ignored files - ;(p2.stdout as unknown as EventEmitter).emit('data', '.env.local\n') - ;(p2.stdout as unknown as EventEmitter).emit('data', 'dist/generated.js\n') - ;(p2.stdout as unknown as EventEmitter).emit('data', 'file1.ts\n') // Duplicate - ;(p2.stdout as unknown as EventEmitter).emit('data', 'node_modules/ignored.js\n') - p2.emit('close', 0, null) + // The broad pass includes ignored files too. + p1.stdout?.emit('data', '.env.local\n') + p1.stdout?.emit('data', 'dist/generated.js\n') + p1.stdout?.emit('data', 'file1.ts\n') // Duplicate + p1.stdout?.emit('data', 'node_modules/ignored.js\n') + p1.emit('close', 0, null) }, 10) const result = await promise @@ -183,15 +205,9 @@ describe('filesystem-list-files', () => { it('spawns the bundled Linux rg inside the registered WSL runtime for Windows-path worktrees', async () => { const p1 = createMockProcess() - const p2 = createMockProcess() getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({ wslDistro: 'Ubuntu' }) - spawnMock.mockImplementation((_cmd, args: string[]) => { - if (isIgnoredRgPass(args)) { - return p2 - } - return p1 - }) + spawnMock.mockReturnValue(p1) const storeMock = {} as unknown as Store const promise = listQuickOpenFiles('C:\\repo', storeMock) @@ -199,7 +215,6 @@ describe('filesystem-list-files', () => { setTimeout(() => { ;(p1.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\n') p1.emit('close', 0, null) - p2.emit('close', 0, null) }, 10) await expect(promise).resolves.toEqual(['src/index.ts']) @@ -214,15 +229,9 @@ describe('filesystem-list-files', () => { it('normalizes absolute WSL rg output for Windows-path worktrees', async () => { const p1 = createMockProcess() - const p2 = createMockProcess() getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({ wslDistro: 'Ubuntu' }) - spawnMock.mockImplementation((_cmd, args: string[]) => { - if (isIgnoredRgPass(args)) { - return p2 - } - return p1 - }) + spawnMock.mockReturnValue(p1) const storeMock = {} as unknown as Store const promise = listQuickOpenFiles('C:\\repo', storeMock) @@ -230,7 +239,6 @@ describe('filesystem-list-files', () => { setTimeout(() => { ;(p1.stdout as unknown as EventEmitter).emit('data', '/mnt/c/repo/src/index.ts\n') p1.emit('close', 0, null) - p2.emit('close', 0, null) }, 10) await expect(promise).resolves.toEqual(['src/index.ts']) @@ -238,16 +246,13 @@ describe('filesystem-list-files', () => { it('treats a WSL launcher exit 127 as the bundled rg failing to start', async () => { const p1 = createMockProcess() - const p2 = createMockProcess() Object.defineProperty(p1, 'pid', { value: 1 }) - Object.defineProperty(p2, 'pid', { value: 2 }) getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({ wslDistro: 'Ubuntu' }) - spawnMock.mockImplementation((_cmd, args: string[]) => (isIgnoredRgPass(args) ? p2 : p1)) + spawnMock.mockReturnValue(p1) const promise = listQuickOpenFiles('C:\\repo', {} as unknown as Store) setTimeout(() => { p1.emit('close', 127, null) - p2.emit('close', 0, null) }, 0) await expect(promise).rejects.toThrow(BUNDLED_ERROR) @@ -256,48 +261,33 @@ describe('filesystem-list-files', () => { it("rejects with the bundled-ripgrep error when a native launcher exits outside ripgrep's contract", async () => { const p1 = createMockProcess() - const p2 = createMockProcess() - spawnMock.mockImplementation((_cmd, args: string[]) => (isIgnoredRgPass(args) ? p2 : p1)) + spawnMock.mockReturnValue(p1) const promise = listQuickOpenFiles('/mock/root', {} as unknown as Store) setTimeout(() => p1.emit('close', 127, null), 0) await expect(promise).rejects.toThrow(BUNDLED_ERROR) - expect(p2.kill).toHaveBeenCalled() }) it('rejects rg failures instead of resolving a false-empty list', async () => { const p1 = createMockProcess() - const p2 = createMockProcess() - spawnMock.mockImplementation((_cmd, args: string[]) => { - if (isIgnoredRgPass(args)) { - return p2 - } - return p1 - }) + spawnMock.mockReturnValue(p1) const storeMock = {} as unknown as Store const promise = listQuickOpenFiles('/mock/root', storeMock) setTimeout(() => { p1.emit('close', 2, null) - p2.emit('close', 0, null) }, 10) await expect(promise).rejects.toThrow('rg exited with code 2') }) - it('kills the sibling rg pass after one pass fails', async () => { + it('does not start another scan after the admitted pass fails', async () => { const p1 = createMockProcess() - const p2 = createMockProcess() - spawnMock.mockImplementation((_cmd, args: string[]) => { - if (isIgnoredRgPass(args)) { - return p2 - } - return p1 - }) + spawnMock.mockReturnValue(p1) const storeMock = {} as unknown as Store const promise = listQuickOpenFiles('/mock/root', storeMock) @@ -308,19 +298,13 @@ describe('filesystem-list-files', () => { }, 10) await expect(promise).rejects.toThrow('rg exited with code 2') - expect(p2.kill).toHaveBeenCalled() + expect(spawnMock).toHaveBeenCalledTimes(1) }) it('accepts rg code 2 when rg emitted parseable paths first', async () => { const p1 = createMockProcess() - const p2 = createMockProcess() - spawnMock.mockImplementation((_cmd, args: string[]) => { - if (isIgnoredRgPass(args)) { - return p2 - } - return p1 - }) + spawnMock.mockReturnValue(p1) const storeMock = {} as unknown as Store const promise = listQuickOpenFiles('/mock/root', storeMock) @@ -328,7 +312,6 @@ describe('filesystem-list-files', () => { setTimeout(() => { ;(p1.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\n') p1.emit('close', 2, null) - p2.emit('close', 0, null) }, 10) await expect(promise).resolves.toEqual(['src/index.ts']) @@ -339,14 +322,8 @@ describe('filesystem-list-files', () => { try { const p1 = createMockProcess() - const p2 = createMockProcess() - spawnMock.mockImplementation((_cmd, args: string[]) => { - if (isIgnoredRgPass(args)) { - return p2 - } - return p1 - }) + spawnMock.mockReturnValue(p1) const storeMock = {} as unknown as Store const promise = listQuickOpenFiles('/mock/root', storeMock) @@ -362,7 +339,6 @@ describe('filesystem-list-files', () => { await rejection expect(p1.kill).toHaveBeenCalled() - expect(p2.kill).toHaveBeenCalled() expect((p1.stdout as unknown as EventEmitter).listenerCount('data')).toBe(0) expect((p1.stderr as unknown as EventEmitter).listenerCount('data')).toBe(0) expect(p1.listenerCount('error')).toBe(0) @@ -374,8 +350,7 @@ describe('filesystem-list-files', () => { it('kills local rg scans when a paired listing is cancelled', async () => { const p1 = createMockProcess() - const p2 = createMockProcess() - spawnMock.mockImplementation((_cmd, args: string[]) => (isIgnoredRgPass(args) ? p2 : p1)) + spawnMock.mockReturnValue(p1) const controller = new AbortController() const cancellation = new FileListingCancelledError('superseded') const promise = listQuickOpenFiles( @@ -390,19 +365,12 @@ describe('filesystem-list-files', () => { await expect(promise).rejects.toBe(cancellation) expect(p1.kill).toHaveBeenCalledOnce() - expect(p2.kill).toHaveBeenCalledOnce() }) it('filters out .next, .cache, .stably, .vscode, .idea', async () => { const p1 = createMockProcess() - const p2 = createMockProcess() - spawnMock.mockImplementation((_cmd, args: string[]) => { - if (isIgnoredRgPass(args)) { - return p2 - } - return p1 - }) + spawnMock.mockReturnValue(p1) const storeMock = {} as unknown as Store const promise = listQuickOpenFiles('/mock/root', storeMock) @@ -415,9 +383,6 @@ describe('filesystem-list-files', () => { ;(p1.stdout as unknown as EventEmitter).emit('data', '.idea/workspace.xml\n') ;(p1.stdout as unknown as EventEmitter).emit('data', 'valid.ts\n') p1.emit('close', 0, null) - - // Empty ignored result - p2.emit('close', 0, null) }, 10) const result = await promise @@ -451,7 +416,7 @@ describe('filesystem-list-files', () => { }) describe('when the bundled rg cannot start', () => { - it('kills only the admitted pass when ignored rg fails before spawn', async () => { + it('does not kill a process that failed before receiving a pid', async () => { const primary = createMockProcess() const missingIgnored = createMockProcess() Object.defineProperty(missingIgnored, 'pid', { value: undefined }) @@ -461,11 +426,11 @@ describe('filesystem-list-files', () => { const promise = listQuickOpenFiles('/mock/root', {} as unknown as Store) await flushMicrotasks() - expect(spawnMock).toHaveBeenCalledTimes(2) + expect(spawnMock).toHaveBeenCalledTimes(1) missingIgnored.emit('close', -2, null) await expect(promise).rejects.toThrow(BUNDLED_ERROR) - expect(primary.kill).toHaveBeenCalled() + expect(primary.kill).not.toHaveBeenCalled() expect(missingIgnored.kill).not.toHaveBeenCalled() const error = Object.assign(new Error('spawn rg ENOENT'), { code: 'ENOENT' }) expect(() => missingIgnored.emit('error', error)).not.toThrow() diff --git a/src/main/ipc/filesystem-list-files.ts b/src/main/ipc/filesystem-list-files.ts index f1e8fb56334..4e3ea49dbe8 100644 --- a/src/main/ipc/filesystem-list-files.ts +++ b/src/main/ipc/filesystem-list-files.ts @@ -277,9 +277,7 @@ export async function listQuickOpenFiles( } const killSurvivors = (): void => { - // Why: if one rg pass fails, Promise.all rejects immediately while the - // sibling scan can keep walking a huge tree until timeout. Stop it so - // repeated Quick Open attempts do not accumulate local rg processes. + // Failed listings must release any process still walking the tree. for (const entry of children) { if (entry.isDone()) { continue @@ -303,16 +301,13 @@ export async function listQuickOpenFiles( } } try { - const primaryRun = runRg(primary) if (maxResults === undefined && maxSerializedBytes === undefined) { - // Why: a pid-less primary proves launch failure; avoid doubling the failed spawn. - await (children[0]?.child.pid === undefined - ? primaryRun - : Promise.all([primaryRun, runRg(ignoredPass)])) + // The broader pass already includes source files; an unbounded listing needs only one scan. + await runRg(ignoredPass) } else { // Why: ignored-file output can be much larger and faster than the primary pass; let source // files claim every bounded autocomplete budget first, including the transport byte cap. - await primaryRun + await runRg(primary) if ( (maxResults === undefined || files.size < maxResults) && (maxSerializedBytes === undefined || serializedBytes < maxSerializedBytes) diff --git a/src/relay/fs-handler-list-files-cancel.test.ts b/src/relay/fs-handler-list-files-cancel.test.ts index baf6101600c..97bea1b0813 100644 --- a/src/relay/fs-handler-list-files-cancel.test.ts +++ b/src/relay/fs-handler-list-files-cancel.test.ts @@ -44,26 +44,22 @@ describe('relay list-files cancellation', () => { vi.useRealTimers() }) - it('listFilesWithRg kills both rg passes and rejects when aborted mid-flight', async () => { - const primaryProc = createMockProcess() + it('listFilesWithRg kills the broad rg pass and rejects when aborted mid-flight', async () => { const ignoredProc = createMockProcess() - spawnMock.mockImplementation((_cmd: string, args: string[]) => - args.includes('--no-ignore-vcs') ? ignoredProc : primaryProc - ) + spawnMock.mockReturnValue(ignoredProc) const controller = new AbortController() const promise = listFilesWithRg('/remote/root', [], { signal: controller.signal }) // Partial output before the abort — must be discarded, not resolved. - ;(primaryProc.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\n') + ignoredProc.stdout?.emit('data', 'src/index.ts\n') controller.abort() await expect(promise).rejects.toSatisfy(isFileListingCancellation) - expect(primaryProc.kill).toHaveBeenCalled() + expect(spawnMock).toHaveBeenCalledTimes(1) expect(ignoredProc.kill).toHaveBeenCalled() // Late close events after cancellation must not fire anything. - primaryProc.emit('close', null, 'SIGTERM') ignoredProc.emit('close', null, 'SIGTERM') }) @@ -78,18 +74,14 @@ describe('relay list-files cancellation', () => { }) it('listFilesWithRg still resolves normally when a signal is provided but never aborted', async () => { - const primaryProc = createMockProcess() const ignoredProc = createMockProcess() - spawnMock.mockImplementation((_cmd: string, args: string[]) => - args.includes('--no-ignore-vcs') ? ignoredProc : primaryProc - ) + spawnMock.mockReturnValue(ignoredProc) const controller = new AbortController() const promise = listFilesWithRg('/remote/root', [], { signal: controller.signal }) setTimeout(() => { - ;(primaryProc.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\n') - primaryProc.emit('close', 0, null) + ignoredProc.stdout?.emit('data', 'src/index.ts\n') ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'dist/out.js\n') ignoredProc.emit('close', 0, null) }, 5) diff --git a/src/relay/fs-handler-list-files-ignored.test.ts b/src/relay/fs-handler-list-files-ignored.test.ts index 483afec1c4c..3f8b8c2b883 100644 --- a/src/relay/fs-handler-list-files-ignored.test.ts +++ b/src/relay/fs-handler-list-files-ignored.test.ts @@ -71,24 +71,16 @@ describe('relay quick open ignored file listing', () => { await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))) }) - it('rg ignored pass includes ignored non-env files and keeps blocklists/excludes', async () => { - const primaryProc = createMockProcess() + it('uses one broad rg pass for unbounded listings and keeps blocklists/excludes', async () => { const ignoredProc = createMockProcess() - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args.includes('--no-ignore-vcs')) { - return ignoredProc - } - return primaryProc - }) + spawnMock.mockReturnValue(ignoredProc) const promise = listFilesWithRg('/remote/root', ['packages/other']) - expect(spawnMock).toHaveBeenCalledTimes(2) + expect(spawnMock).toHaveBeenCalledTimes(1) setTimeout(() => { - ;(primaryProc.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\n') - primaryProc.emit('close', 0, null) - + ignoredProc.stdout?.emit('data', 'src/index.ts\n') ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'dist/generated.js\n') ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'node_modules/pkg/index.js\n') ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'packages/other/src/x.ts\n') @@ -97,9 +89,7 @@ describe('relay quick open ignored file listing', () => { await expect(promise).resolves.toEqual(['src/index.ts', 'dist/generated.js']) - const ignoredArgs = spawnMock.mock.calls.find((call) => - (call[1] as string[]).includes('--no-ignore-vcs') - )?.[1] as string[] + const ignoredArgs = spawnMock.mock.calls[0][1] expect(ignoredArgs).toBeDefined() expect(ignoredArgs).toContain('--no-ignore-vcs') expect(ignoredArgs).not.toContain('.env*') @@ -150,6 +140,24 @@ describe('relay quick open ignored file listing', () => { await expect(promise).resolves.toEqual(['scripts/check-target.ts', 'src/components/target.ts']) }) + it('fills a bounded listing from primary files before admitting ignored files', async () => { + const primary = createMockProcess() + const broad = createMockProcess() + spawnMock.mockReturnValueOnce(primary).mockReturnValueOnce(broad) + + const promise = listFilesWithRg('/remote/root', [], { maxResults: 2 }) + expect(spawnMock).toHaveBeenCalledTimes(1) + expect(spawnMock.mock.calls[0][1]).not.toContain('--no-ignore-vcs') + primary.stdout?.emit('data', 'src/index.ts\n') + primary.emit('close', 0, null) + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(2)) + expect(spawnMock.mock.calls[1][1]).toContain('--no-ignore-vcs') + broad.stdout?.emit('data', 'src/index.ts\ndist/generated.js\ndist/extra.js\n') + + await expect(promise).resolves.toEqual(['src/index.ts', 'dist/generated.js']) + expect(broad.kill).toHaveBeenCalled() + }) + it('retries a transient remote rg spawn failure without reporting ripgrep as missing', async () => { const failed = createMockProcess() const succeeded = createMockProcess() @@ -191,32 +199,27 @@ describe('relay quick open ignored file listing', () => { await expect(promise).resolves.toEqual(['src/target.ts']) }) - it('runs the ignored pass after an unbounded primary listing retry succeeds', async () => { - const failedPrimary = createMockProcess() - const succeededPrimary = createMockProcess() - const ignored = createMockProcess() - Object.defineProperty(failedPrimary, 'pid', { value: undefined }) - spawnMock - .mockReturnValueOnce(failedPrimary) - .mockReturnValueOnce(succeededPrimary) - .mockReturnValueOnce(ignored) + it('retries an unbounded broad listing once after a transient spawn failure', async () => { + const failed = createMockProcess() + const succeeded = createMockProcess() + Object.defineProperty(failed, 'pid', { value: undefined }) + spawnMock.mockReturnValueOnce(failed).mockReturnValueOnce(succeeded) const promise = listFilesWithRg('/remote/root') - failedPrimary.emit('error', Object.assign(new Error('spawn rg EAGAIN'), { code: 'EAGAIN' })) + failed.emit('error', Object.assign(new Error('spawn rg EAGAIN'), { code: 'EAGAIN' })) await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(2)) - ;(succeededPrimary.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\n') - succeededPrimary.emit('close', 0, null) - await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(3)) - ;(ignored.stdout as unknown as EventEmitter).emit('data', 'dist/generated.js\n') - ignored.emit('close', 0, null) + succeeded.stdout?.emit('data', 'src/index.ts\ndist/generated.js\n') + succeeded.emit('close', 0, null) await expect(promise).resolves.toEqual(['src/index.ts', 'dist/generated.js']) - expect(spawnMock.mock.calls[2][1]).toContain('--no-ignore-vcs') + expect(spawnMock).toHaveBeenCalledTimes(2) + expect(spawnMock.mock.calls[0][1]).toContain('--no-ignore-vcs') + expect(spawnMock.mock.calls[1][1]).toContain('--no-ignore-vcs') }) it.each(['error-first', 'close-first'] as const)( - 'tags a %s pre-spawn listing failure without starting the ignored pass', + 'tags a %s pre-spawn listing failure without starting another pass', async (order) => { const root = await makeTempRoot() const missing = createMockProcess() @@ -243,21 +246,17 @@ describe('relay quick open ignored file listing', () => { } ) - it('kills only the admitted pass when ignored rg fails before spawn', async () => { + it('does not signal the unbounded broad pass when it fails before spawn', async () => { const root = await makeTempRoot() - const primary = createMockProcess() const missingIgnored = createMockProcess() Object.defineProperty(missingIgnored, 'pid', { value: undefined }) - spawnMock.mockImplementation((_cmd: string, args: string[]) => - args.includes('--no-ignore-vcs') ? missingIgnored : primary - ) + spawnMock.mockReturnValue(missingIgnored) const promise = listFilesWithRg(root) - expect(spawnMock).toHaveBeenCalledTimes(2) + expect(spawnMock).toHaveBeenCalledTimes(1) missingIgnored.emit('close', -2, null) await expect(promise).rejects.toBeInstanceOf(RipgrepUnavailableError) - expect(primary.kill).toHaveBeenCalled() expect(missingIgnored.kill).not.toHaveBeenCalled() const error = Object.assign(new Error('spawn rg ENOENT'), { code: 'ENOENT' }) expect(() => missingIgnored.emit('error', error)).not.toThrow() @@ -548,14 +547,8 @@ describe('relay quick open ignored file listing', () => { it('rg file listing rejects and detaches when a timed-out child does not emit close', async () => { vi.useFakeTimers() try { - const primaryProc = createMockProcess() const ignoredProc = createMockProcess() - let callIndex = 0 - - spawnMock.mockImplementation(() => { - callIndex++ - return callIndex === 1 ? primaryProc : ignoredProc - }) + spawnMock.mockReturnValue(ignoredProc) const promise = listFilesWithRg('/remote/root') const outcomePromise = promise.then( @@ -567,12 +560,8 @@ describe('relay quick open ignored file listing', () => { const outcome = await Promise.race([outcomePromise, Promise.resolve('pending')]) expect(outcome).toBe('rejected:rg list timed out') - expect(primaryProc.kill).toHaveBeenCalled() + expect(spawnMock).toHaveBeenCalledTimes(1) expect(ignoredProc.kill).toHaveBeenCalled() - expect((primaryProc.stdout as unknown as EventEmitter).listenerCount('data')).toBe(0) - expect((primaryProc.stderr as unknown as EventEmitter).listenerCount('data')).toBe(0) - expect(primaryProc.listenerCount('error')).toBe(0) - expect(primaryProc.listenerCount('close')).toBe(0) expect((ignoredProc.stdout as unknown as EventEmitter).listenerCount('data')).toBe(0) expect((ignoredProc.stderr as unknown as EventEmitter).listenerCount('data')).toBe(0) expect(ignoredProc.listenerCount('error')).toBe(0) diff --git a/src/relay/fs-handler-list-files.ts b/src/relay/fs-handler-list-files.ts index d2b4732777c..506693fa0c8 100644 --- a/src/relay/fs-handler-list-files.ts +++ b/src/relay/fs-handler-list-files.ts @@ -6,7 +6,7 @@ * matching files" even though the file existed on disk. This implementation: * - streams via spawn (no maxBuffer failure mode) * - prunes traversal at rg level using the shared blocklist globs - * - runs a second --no-ignore-vcs pass for ignored files + * - includes gitignored files, preserving primary-first order for bounded listings * - honors excludePathPrefixes for nested linked worktrees * - rejects (not resolves) on timeout / spawn error / signal exit so * the UI shows a load error instead of a false-empty list @@ -284,10 +284,7 @@ export function listFilesWithRg( }) const killSurvivors = (reason: string): void => { - // Why: when one pass rejects, Promise.all surfaces the error immediately - // but the sibling rg keeps running up to LIST_FILES_TIMEOUT_MS. Kill it - // so repeated Quick Open opens don't pile up orphan rg processes on the - // remote. + // Cancellation or a reached budget must stop any admitted scan or retry. for (const entry of children) { if (entry.isDone()) { continue @@ -322,21 +319,13 @@ export function listFilesWithRg( } signal?.addEventListener('abort', onAbort, { once: true }) + // Without a result budget, the broader pass already contains every primary path. const passes = - searchQuery !== undefined + searchQuery !== undefined || maxResults === undefined ? runPass(ignoredPass) - : (() => { - const primaryPass = runPass(primary) - return maxResults === undefined - ? children[0]?.child.pid === undefined - ? primaryPass.then(() => runPass(ignoredPass)) - : Promise.all([primaryPass, runPass(ignoredPass)]) - : // Why: deterministic primary-first budgeting prevents a large ignored - // tree from starving ordinary source paths on a remote host. - primaryPass.then(() => - files.size < maxResults ? runPass(ignoredPass) : Promise.resolve() - ) - })() + : runPass(primary).then(() => + files.size < maxResults ? runPass(ignoredPass) : Promise.resolve() + ) passes .then(() => { @@ -353,7 +342,7 @@ export function listFilesWithRg( } done = true signal?.removeEventListener('abort', onAbort) - killSurvivors('rg list canceled after sibling failure') + killSurvivors('rg list canceled after failure') reject(err instanceof Error ? err : new Error(String(err))) }) }) diff --git a/src/shared/quick-open-filter.test.ts b/src/shared/quick-open-filter.test.ts index c481a1e8854..e142c8ef765 100644 --- a/src/shared/quick-open-filter.test.ts +++ b/src/shared/quick-open-filter.test.ts @@ -164,6 +164,19 @@ describe('buildHiddenDirExcludeGlobs', () => { }) describe('buildRgArgsForQuickOpen', () => { + it.each([ + { searchRoot: '.', excludePathPrefixes: [], forceSlashSeparator: false }, + { + searchRoot: '/root', + excludePathPrefixes: ['packages/app', 'feature[1]'], + forceSlashSeparator: true + } + ])('broadens only VCS ignore handling for $searchRoot', (options) => { + const { primary, ignoredPass } = buildRgArgsForQuickOpen(options) + expect(ignoredPass).toContain('--no-ignore-vcs') + expect(ignoredPass.filter((arg) => arg !== '--no-ignore-vcs')).toEqual(primary) + }) + it('primary pass includes --files, --hidden, hidden-dir excludes, no --follow', () => { const { primary } = buildRgArgsForQuickOpen({ searchRoot: '/root', diff --git a/src/shared/quick-open-filter.ts b/src/shared/quick-open-filter.ts index b7592657a11..ffb1ac346ef 100644 --- a/src/shared/quick-open-filter.ts +++ b/src/shared/quick-open-filter.ts @@ -183,7 +183,7 @@ export type RgArgsOptions = { export type RgArgs = { /** Main pass: all non-ignored files, hidden dotfiles included. */ primary: string[] - /** Second pass: ignored files, hidden dotfiles included. */ + /** Broader pass: primary files plus gitignored files, hidden dotfiles included. */ ignoredPass: string[] } From 5a2aa87f1c541653cb20814410d870d8c082cb5b Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:13:06 -0700 Subject: [PATCH 37/67] Select OpenCode plugin exports from the execution host version (#24662) * fix: wait for OpenCode worker composer before first dispatch Reuse captured composer readiness on local and paired execution hosts and revoke launching-shell paste anchors. Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> * feat(opencode): probe execution-host CLI capabilities * fix(opencode): select plugin default for execution host loader * fix(opencode): limit prompt prefill capability to verified release * feat(opencode): probe launch capabilities on the execution host * fix(opencode): select plugin loader for the launched host binary * fix(opencode): match WSL probe cwd and declared guest environment * fix(opencode): preserve launch environment deletion boundaries * STRICT launch CI contract correction * CAPS launch CI contract correction * test: initialize Claude prompt state in output retention fixture --------- Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> --- src/main/ipc/pty/ipc/spawn-options.ts | 13 ++ src/main/ipc/pty/runtime/spawn-options.ts | 30 ++-- .../opencode/hook-plugin-dispose-host.test.ts | 2 + .../hook-plugin-legacy-tui-ownership.test.ts | 51 +++++- .../hook-plugin-module-contract.test.ts | 9 + .../hook-plugin-opencode2-setup.test.ts | 2 + src/main/opencode/hook-service.test.ts | 3 + .../opencode/opencode-cli-version.test.ts | 131 ++++++++++++++ src/main/opencode/opencode-cli-version.ts | 87 ++++++++++ .../opencode-launch-capabilities.test.ts | 161 ++++++++++++++++++ .../opencode/opencode-launch-capabilities.ts | 99 +++++++++++ .../opencode-legacy-plugin-loader-fixture.ts | 36 ++++ src/main/opencode/opencode-pty-launch.test.ts | 158 +++++++++++++++++ src/main/opencode/opencode-pty-launch.ts | 47 +++++ .../opencode-wsl-probe-environment.test.ts | 72 ++++++++ .../opencode/status-plugin-factory-source.ts | 7 +- src/main/providers/provider-dispatch.test.ts | 1 + src/main/providers/pty-spawn-result.ts | 3 + src/relay/opencode-plugin-selection.ts | 39 +++++ src/relay/pty-handler-opencode-revive.test.ts | 142 +++++++++++++++ src/relay/pty-handler.ts | 40 ++++- src/shared/opencode-cli-version.test.ts | 34 ++++ src/shared/opencode-cli-version.ts | 26 +++ 23 files changed, 1173 insertions(+), 20 deletions(-) create mode 100644 src/main/opencode/opencode-cli-version.test.ts create mode 100644 src/main/opencode/opencode-cli-version.ts create mode 100644 src/main/opencode/opencode-launch-capabilities.test.ts create mode 100644 src/main/opencode/opencode-launch-capabilities.ts create mode 100644 src/main/opencode/opencode-pty-launch.test.ts create mode 100644 src/main/opencode/opencode-pty-launch.ts create mode 100644 src/main/opencode/opencode-wsl-probe-environment.test.ts create mode 100644 src/relay/opencode-plugin-selection.ts create mode 100644 src/relay/pty-handler-opencode-revive.test.ts create mode 100644 src/shared/opencode-cli-version.test.ts create mode 100644 src/shared/opencode-cli-version.ts diff --git a/src/main/ipc/pty/ipc/spawn-options.ts b/src/main/ipc/pty/ipc/spawn-options.ts index d0a3ba8cef1..cf2d1c53b6a 100644 --- a/src/main/ipc/pty/ipc/spawn-options.ts +++ b/src/main/ipc/pty/ipc/spawn-options.ts @@ -27,6 +27,7 @@ import { withCodexTerminalServerIsolationEnv } from '../../../../shared/codex-te import { planCodexNoDaemonLaunch } from '../../../pty/codex-no-daemon-launch-command' import type { PtyIpcSpawnState } from './spawn-state' import { applyAgentWorkspaceTrustToSpawn } from '../../../agent-workspace-trust-spawn' +import { prepareOpenCodePtyLaunch } from '../../../opencode/opencode-pty-launch' /** Carries deletions to provider-owned environments, including persistent older daemons. */ export async function buildPtyIpcSpawnOptions( @@ -64,6 +65,18 @@ export async function buildPtyIpcSpawnOptions( ctx.combinedEnvToDelete = removeCodexHomeDeletionRequests(ctx.combinedEnvToDelete) } deleteRequestedEnvKeys(ctx.spawnEnv, ctx.combinedEnvToDelete) + ctx.spawnEnv = await prepareOpenCodePtyLaunch({ + command: ctx.launchCommand, + agent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined, + env: ctx.spawnEnv, + envToDelete: (ctx.combinedEnvToDelete ??= []), + cwd: ctx.cwd, + connectionId: args.connectionId, + isFreshLaunch: !ctx.preAdoptedStablePane && ctx.launchCommand !== undefined, + ...(ctx.codexSelectionTarget.runtime === 'wsl' + ? { wsl: { distro: ctx.expectedWslDistro ?? undefined } } + : {}) + }) promoteAgentTeamsShimPath(ctx.spawnEnv, ctx.requestedAgentTeamsPath) ctx.spawnOptions = { cols: args.cols, diff --git a/src/main/ipc/pty/runtime/spawn-options.ts b/src/main/ipc/pty/runtime/spawn-options.ts index d413c0556f0..2dfdf2d0811 100644 --- a/src/main/ipc/pty/runtime/spawn-options.ts +++ b/src/main/ipc/pty/runtime/spawn-options.ts @@ -35,6 +35,7 @@ import { import type { RuntimePtySpawnState } from './spawn-state' import { applyAgentWorkspaceTrustToSpawn } from '../../../agent-workspace-trust-spawn' import { prepareAntigravityAccountForLaunch } from '../../../antigravity/native-account-launch' +import { prepareOpenCodePtyLaunch } from '../../../opencode/opencode-pty-launch' /** Headless spawns need the same host-side environment isolation as desktop spawns. */ export async function buildRuntimePtySpawnOptions( @@ -85,20 +86,12 @@ export async function buildRuntimePtySpawnOptions( ? getLegacyOpenCodeEnvKeysToDelete(ctx.env, getAppEnvironment().getPath('userData')) : [], // Why: ungated, unlike the agent-hook keys — the local provider and the relay host also spread their own process.env into every spawn. - getInheritedAgentSessionStampEnvKeysToDelete(ctx.env) - ) - if (ctx.skipCodexHomeEnv) { - ctx.spawnOptions.envToDelete = mergePtyEnvDeletions( - ctx.spawnOptions.envToDelete, - CODEX_HOME_ENV_KEYS - ) - } else if (ctx.stripInheritedOrcaCodexHome) { + getInheritedAgentSessionStampEnvKeysToDelete(ctx.env), + ctx.skipCodexHomeEnv ? CODEX_HOME_ENV_KEYS : [], // Why: the daemon owns a persistent inherited environment that may // differ from main. ORCA_CODEX_HOME asks it to compare/delete the pair. - ctx.spawnOptions.envToDelete = mergePtyEnvDeletions(ctx.spawnOptions.envToDelete, [ - 'ORCA_CODEX_HOME' - ]) - } + ctx.stripInheritedOrcaCodexHome ? ['ORCA_CODEX_HOME'] : [] + ) if (ctx.codexResumeHomeSelected) { ctx.spawnOptions.envToDelete = removeCodexHomeDeletionRequests(ctx.spawnOptions.envToDelete) } @@ -111,6 +104,19 @@ export async function buildRuntimePtySpawnOptions( env: ctx.env, envToDelete: ctx.spawnOptions.envToDelete }) + ctx.env = await prepareOpenCodePtyLaunch({ + command: ctx.launchCommand, + agent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined, + env: ctx.env, + envToDelete: (ctx.spawnOptions.envToDelete ??= []), + cwd: ctx.cwd, + connectionId: args.connectionId, + isFreshLaunch: !ctx.preAdoptedStablePane && ctx.launchCommand !== undefined, + ...(ctx.codexSelectionTarget.runtime === 'wsl' + ? { wsl: { distro: ctx.expectedWslDistro ?? undefined } } + : {}) + }) + ctx.spawnOptions.env = ctx.env promoteAgentTeamsShimPath(ctx.env, ctx.requestedAgentTeamsPath) const noDaemonLaunch = planCodexNoDaemonLaunch({ command: ctx.launchCommand, diff --git a/src/main/opencode/hook-plugin-dispose-host.test.ts b/src/main/opencode/hook-plugin-dispose-host.test.ts index 179714d8c83..52e152753a4 100644 --- a/src/main/opencode/hook-plugin-dispose-host.test.ts +++ b/src/main/opencode/hook-plugin-dispose-host.test.ts @@ -34,6 +34,7 @@ type PluginModule = { const ENV_KEYS = [ 'ORCA_PANE_KEY', 'ORCA_OPENCODE_AGENT', + 'ORCA_OPENCODE_PLUGIN_API', 'ORCA_AGENT_HOOK_ENDPOINT', 'ORCA_AGENT_HOOK_PORT', 'ORCA_AGENT_HOOK_TOKEN' @@ -81,6 +82,7 @@ describe.each(['opencode', 'opencode2'] as const)('%s plugin disposal by host', } process.env.ORCA_PANE_KEY = 'tab-1:leaf-1' process.env.ORCA_OPENCODE_AGENT = agent + process.env.ORCA_OPENCODE_PLUGIN_API = 'v2' delete process.env.ORCA_AGENT_HOOK_ENDPOINT process.env.ORCA_AGENT_HOOK_PORT = '59999' process.env.ORCA_AGENT_HOOK_TOKEN = 'test-token' diff --git a/src/main/opencode/hook-plugin-legacy-tui-ownership.test.ts b/src/main/opencode/hook-plugin-legacy-tui-ownership.test.ts index fb1f4010191..e9404da3821 100644 --- a/src/main/opencode/hook-plugin-legacy-tui-ownership.test.ts +++ b/src/main/opencode/hook-plugin-legacy-tui-ownership.test.ts @@ -6,7 +6,8 @@ import { pathToFileURL } from 'node:url' import { getOpenCodePluginSource } from './status-plugin-module-source' import { writeOpenCodeTuiPlugin } from '../../shared/opencode-tui-plugin-install' import { fakeLegacyTui, type LegacyTuiEvent } from './opencode-legacy-tui-fixture' -import { readV1Plugin } from './opencode-legacy-plugin-loader-fixture' +import { getLegacyPlugins, readV1Plugin } from './opencode-legacy-plugin-loader-fixture' +import { getOpenCodeCliCapabilities } from '../../shared/opencode-cli-version' type Post = { paneKey?: string @@ -23,7 +24,8 @@ const ENV_KEYS = [ 'ORCA_AGENT_HOOK_ENDPOINT', 'ORCA_AGENT_HOOK_PORT', 'ORCA_AGENT_HOOK_TOKEN', - 'ORCA_AGENT_HOOK_OPENCODE_TUI' + 'ORCA_AGENT_HOOK_OPENCODE_TUI', + 'ORCA_OPENCODE_PLUGIN_API' ] as const const created = (id: string, parentID?: string): LegacyTuiEvent => ({ type: 'session.created', @@ -48,6 +50,7 @@ describe('OpenCode 1 TUI API pane reporting', () => { savedArgv = process.argv savedEnv = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]])) process.argv = ['opencode', 'attach', 'http://127.0.0.1:4096'] + process.env.ORCA_OPENCODE_PLUGIN_API = 'v2' process.env.ORCA_OPENCODE_AGENT = 'opencode' process.env.ORCA_AGENT_HOOK_PORT = '59999' process.env.ORCA_AGENT_HOOK_TOKEN = 'fixture' @@ -129,6 +132,50 @@ describe('OpenCode 1 TUI API pane reporting', () => { expect(tui.listenerCount()).toBeGreaterThan(0) }) + it('loads the selected v1 factory through the release fallback once and retains the separate TUI', async () => { + process.env.ORCA_OPENCODE_PLUGIN_API = getOpenCodeCliCapabilities('1.18.30').pluginApi + process.argv = ['opencode', 'serve'] + process.env.ORCA_PANE_KEY = PANE_A + const path = join(dir, 'selected-v1-server.mjs') + writeFileSync(path, getOpenCodePluginSource()) + const plugin: unknown = await import(pathToFileURL(path).href) + if (typeof plugin !== 'object' || !plugin || !('default' in plugin)) { + throw new Error('No server plugin') + } + expect(readV1Plugin(plugin, path, 'server', 'detect')).toBeUndefined() + const factories = getLegacyPlugins(plugin) + expect(factories).toHaveLength(1) + expect(factories[0]).toBe(plugin.default) + const hooks: unknown = await factories[0]({ + client: { session: { get: async () => ({ data: { id: 'ses_root' } }) } } + }) + if ( + !hooks || + typeof hooks !== 'object' || + !('event' in hooks) || + typeof hooks.event !== 'function' || + !('dispose' in hooks) || + typeof hooks.dispose !== 'function' + ) { + throw new Error('No legacy event hooks') + } + const dispose = hooks.dispose + cleanups.push(async () => { + await dispose() + }) + await hooks.event({ + event: { + type: 'session.status', + properties: { sessionID: 'ses_root', status: { type: 'busy' } } + } + }) + await settle('SessionBusy:ses_root') + expect(posts.at(-1)).toMatchObject({ opencodeSharedServer: 1 }) + process.argv = ['opencode', 'attach', 'http://127.0.0.1:4096'] + const tui = await start() + expect(tui.listenerCount()).toBeGreaterThan(0) + }) + it('reproduces the release loader rejection of a combined server and TUI entry', () => { expect(() => readV1Plugin({ default: { server() {}, tui() {} } }, 'orca-opencode-status-tui/tui.js', 'tui') diff --git a/src/main/opencode/hook-plugin-module-contract.test.ts b/src/main/opencode/hook-plugin-module-contract.test.ts index 668a879f76a..f6d07361abe 100644 --- a/src/main/opencode/hook-plugin-module-contract.test.ts +++ b/src/main/opencode/hook-plugin-module-contract.test.ts @@ -35,6 +35,7 @@ describe('OpenCode status plugin module contract', () => { // env. Pin every input here so the run does not depend on the developer's Orca session // (an inherited ORCA_AGENT_HOOK_ENDPOINT would otherwise redirect the post to a live app). const ENV_KEYS = [ + 'ORCA_OPENCODE_PLUGIN_API', 'ORCA_PANE_KEY', 'ORCA_AGENT_HOOK_ENDPOINT', 'ORCA_AGENT_HOOK_PORT', @@ -55,6 +56,7 @@ describe('OpenCode status plugin module contract', () => { // Why: the generated plugin self-disables when this names a different major, // so an inherited value from the developer's own Orca pane would leave // `hooks.event` undefined and fail the contract for the wrong reason. + delete process.env.ORCA_OPENCODE_PLUGIN_API delete process.env.ORCA_OPENCODE_AGENT delete process.env.ORCA_AGENT_HOOK_ENDPOINT process.env.ORCA_AGENT_HOOK_PORT = '59999' @@ -86,6 +88,13 @@ describe('OpenCode status plugin module contract', () => { return (await import(pathToFileURL(pluginPath).href)) as PluginModule } + it('selects the same factory for a v1 execution host', async () => { + process.env.ORCA_OPENCODE_PLUGIN_API = 'v1' + const module = await loadPluginModule() + expect(module.default).toBeTypeOf('function') + expect(module.default).toBe(module.OrcaOpenCodeStatusPlugin) + }) + it('exposes a default export carrying a string id and a callable server()', async () => { const module = await loadPluginModule() diff --git a/src/main/opencode/hook-plugin-opencode2-setup.test.ts b/src/main/opencode/hook-plugin-opencode2-setup.test.ts index 1ada5ea1a53..0fa86f3d70d 100644 --- a/src/main/opencode/hook-plugin-opencode2-setup.test.ts +++ b/src/main/opencode/hook-plugin-opencode2-setup.test.ts @@ -49,6 +49,7 @@ describe.each(['opencode', 'opencode2'] as const)('%s plugin on OpenCode 2', (ag const ENV_KEYS = [ 'ORCA_PANE_KEY', 'ORCA_OPENCODE_AGENT', + 'ORCA_OPENCODE_PLUGIN_API', 'ORCA_AGENT_HOOK_ENDPOINT', 'ORCA_AGENT_HOOK_PORT', 'ORCA_AGENT_HOOK_TOKEN' @@ -66,6 +67,7 @@ describe.each(['opencode', 'opencode2'] as const)('%s plugin on OpenCode 2', (ag savedEnv[key] = process.env[key] } process.env.ORCA_OPENCODE_AGENT = agent + process.env.ORCA_OPENCODE_PLUGIN_API = 'v2' delete process.env.ORCA_AGENT_HOOK_ENDPOINT process.env.ORCA_AGENT_HOOK_PORT = '59999' process.env.ORCA_AGENT_HOOK_TOKEN = 'test-token' diff --git a/src/main/opencode/hook-service.test.ts b/src/main/opencode/hook-service.test.ts index e1400f58a57..482df3671f7 100644 --- a/src/main/opencode/hook-service.test.ts +++ b/src/main/opencode/hook-service.test.ts @@ -32,6 +32,7 @@ import { import { resolveOpenCodeConfigDirectory } from '../../shared/opencode-config-directory' beforeEach(() => { + vi.stubEnv('ORCA_OPENCODE_PLUGIN_API', 'v2') setAppEnvironment({ getPath: getPathMock, getAppPath: () => process.cwd(), @@ -43,6 +44,8 @@ beforeEach(() => { }) }) +afterEach(() => vi.unstubAllEnvs()) + const { isUsableId, toSafeDirName } = _internals describe('OpenCode id safety guard', () => { diff --git a/src/main/opencode/opencode-cli-version.test.ts b/src/main/opencode/opencode-cli-version.test.ts new file mode 100644 index 00000000000..23f6b9eea3f --- /dev/null +++ b/src/main/opencode/opencode-cli-version.test.ts @@ -0,0 +1,131 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import { runProcess } from '../../shared/child-process/run-process' +import { probeOpenCodeCliVersion } from './opencode-cli-version' + +vi.mock('../../shared/child-process/run-process', () => ({ runProcess: vi.fn() })) +beforeEach(() => vi.mocked(runProcess).mockReset()) + +it('shares concurrent probes and isolates executable, host and environment identities', async () => { + vi.mocked(runProcess).mockResolvedValue({ + code: 0, + signal: null, + stdout: '1.1.23', + stderr: '', + timedOut: false + }) + const options = { + executablePath: '/test/opencode', + env: { PATH: '/runtime', XDG_CONFIG_HOME: '/one' }, + hostIdentity: 'native' + } + const results = await Promise.all([ + probeOpenCodeCliVersion(options), + probeOpenCodeCliVersion(options) + ]) + expect(results[0].pluginApi).toBe('v1') + expect(runProcess).toHaveBeenCalledTimes(1) + await probeOpenCodeCliVersion({ ...options, executablePath: '/other/opencode' }) + await probeOpenCodeCliVersion({ ...options, hostIdentity: 'wsl:ubuntu' }) + await probeOpenCodeCliVersion({ ...options, env: { ...options.env, XDG_CONFIG_HOME: '/two' } }) + expect(runProcess).toHaveBeenCalledTimes(4) + expect(runProcess).toHaveBeenCalledWith( + expect.objectContaining({ args: ['--version'], timeoutMs: 5_000, maxOutputBytes: 4_096 }) + ) +}) + +it('degrades timeouts and missing executables to unknown', async () => { + vi.mocked(runProcess).mockResolvedValueOnce({ + code: null, + signal: 'SIGTERM', + stdout: '2.0.16', + stderr: '', + timedOut: true + }) + expect( + (await probeOpenCodeCliVersion({ executablePath: '/timeout/opencode', env: {} })).pluginApi + ).toBe('unknown') + vi.mocked(runProcess).mockRejectedValueOnce(new Error('ENOENT')) + expect( + (await probeOpenCodeCliVersion({ executablePath: '/missing/opencode', env: {} })).version + ).toBeNull() +}) + +it('shares a bounded execution-host callback without probing the native machine', async () => { + const execute = vi + .fn() + .mockResolvedValue({ code: 0, timedOut: false, stdout: 'opencode v2.0.16' }) + const options = { executablePath: 'opencode', env: {}, hostIdentity: 'wsl:private', execute } + const results = await Promise.all([ + probeOpenCodeCliVersion(options), + probeOpenCodeCliVersion(options) + ]) + expect(results[0].promptMode).toBe('prefill') + expect(execute).toHaveBeenCalledTimes(1) + expect(runProcess).not.toHaveBeenCalled() +}) + +it('shares probes across pane identities while preserving launch-affecting environment keys', async () => { + vi.mocked(runProcess).mockResolvedValue({ + code: 0, + signal: null, + stdout: '2.0.16', + stderr: '', + timedOut: false + }) + const options = { + executablePath: '/pane-cache/opencode', + cwd: '/workspace', + env: { + PATH: '/runtime', + OPENCODE_CONFIG_DIR: '/config', + ORCA_PANE_KEY: 'pane-1', + ORCA_TAB_ID: 'tab-1', + ORCA_TERMINAL_HANDLE: 'term-1', + ORCA_WORKTREE_ID: 'workspace-1', + ORCA_AGENT_LAUNCH_TOKEN: 'launch-1', + ORCA_AGENT_PANE: 'pane-1', + ORCA_AGENT_LAUNCH: 'launch-1' + }, + hostIdentity: 'pane-cache-host' + } + await Promise.all([ + probeOpenCodeCliVersion(options), + probeOpenCodeCliVersion({ + ...options, + env: { + ...options.env, + ORCA_PANE_KEY: 'pane-2', + ORCA_TAB_ID: 'tab-2', + ORCA_TERMINAL_HANDLE: 'term-2', + ORCA_WORKTREE_ID: 'workspace-2', + ORCA_AGENT_LAUNCH_TOKEN: 'launch-2', + ORCA_AGENT_PANE: 'pane-2', + ORCA_AGENT_LAUNCH: 'launch-2' + } + }) + ]) + expect(runProcess).toHaveBeenCalledTimes(1) + await probeOpenCodeCliVersion({ ...options, env: { ...options.env, PATH: '/other-runtime' } }) + await probeOpenCodeCliVersion({ + ...options, + env: { ...options.env, OPENCODE_CONFIG_DIR: '/other-config' } + }) + await probeOpenCodeCliVersion({ ...options, cwd: '/other-workspace' }) + expect(runProcess).toHaveBeenCalledTimes(4) +}) + +it('separates native and host-callback backends even with the same host identity', async () => { + vi.mocked(runProcess).mockResolvedValue({ + code: 0, + signal: null, + stdout: '1.18.32', + stderr: '', + timedOut: false + }) + const options = { executablePath: '/backend/opencode', env: {}, hostIdentity: 'backend-host' } + expect((await probeOpenCodeCliVersion(options)).pluginApi).toBe('v1') + const execute = vi.fn(async () => ({ code: 0, timedOut: false, stdout: '2.0.16' })) + expect((await probeOpenCodeCliVersion({ ...options, execute })).pluginApi).toBe('v2') + expect(execute).toHaveBeenCalledOnce() + expect(runProcess).toHaveBeenCalledOnce() +}) diff --git a/src/main/opencode/opencode-cli-version.ts b/src/main/opencode/opencode-cli-version.ts new file mode 100644 index 00000000000..06804bad2ea --- /dev/null +++ b/src/main/opencode/opencode-cli-version.ts @@ -0,0 +1,87 @@ +import { createHash } from 'node:crypto' +import path from 'node:path' +import { + ORCA_SCRUB_SAFE_LAUNCH_ENV, + ORCA_SCRUB_SAFE_PANE_ENV +} from '../../shared/agent-hook-scrub-safe-env' +import { runProcess } from '../../shared/child-process/run-process' +import { + getOpenCodeCliCapabilities, + type OpenCodeCliCapabilities +} from '../../shared/opencode-cli-version' + +export type OpenCodeCliVersionProbe = { + executablePath: string + env: NodeJS.ProcessEnv + cwd?: string + hostIdentity?: string + execute?: () => Promise<{ code: number | null; timedOut: boolean; stdout: string }> +} + +const probes = new Map }>() +const CACHE_TTL_MS = 60_000 +const MAX_CACHED_PROBES = 128 +const PANE_IDENTITY_ENV_KEYS = new Set([ + 'ORCA_PANE_KEY', + 'ORCA_TAB_ID', + 'ORCA_WORKTREE_ID', + 'ORCA_TERMINAL_HANDLE', + 'ORCA_AGENT_LAUNCH_TOKEN', + ORCA_SCRUB_SAFE_PANE_ENV, + ORCA_SCRUB_SAFE_LAUNCH_ENV +]) + +export function probeOpenCodeCliVersion( + options: OpenCodeCliVersionProbe +): Promise { + const identity = JSON.stringify([ + options.execute ? 'host-callback' : 'native-process', + options.hostIdentity ?? process.platform, + options.executablePath, + options.cwd, + Object.entries(options.env) + .filter(([key]) => !PANE_IDENTITY_ENV_KEYS.has(key)) + .sort(([left], [right]) => left.localeCompare(right)) + ]) + const key = createHash('sha256').update(identity).digest('hex') + const cached = probes.get(key) + if (cached && cached.expiresAt > Date.now()) { + return cached.result + } + const result = runVersionProbe(options) + probes.delete(key) + probes.set(key, { expiresAt: Date.now() + CACHE_TTL_MS, result }) + if (probes.size > MAX_CACHED_PROBES) { + const oldest = probes.keys().next().value + if (oldest !== undefined) { + probes.delete(oldest) + } + } + return result +} + +async function runVersionProbe(options: OpenCodeCliVersionProbe): Promise { + try { + const pathKey = process.platform === 'win32' && options.env.Path !== undefined ? 'Path' : 'PATH' + const executableDir = path.dirname(options.executablePath) + const inheritedPath = options.env[pathKey] + const result = options.execute + ? await options.execute() + : await runProcess({ + program: options.executablePath, + args: ['--version'], + cwd: options.cwd, + env: { + ...options.env, + [pathKey]: inheritedPath + ? `${executableDir}${path.delimiter}${inheritedPath}` + : executableDir + }, + timeoutMs: 5_000, + maxOutputBytes: 4_096 + }) + return getOpenCodeCliCapabilities(result.code === 0 && !result.timedOut ? result.stdout : null) + } catch { + return getOpenCodeCliCapabilities(null) + } +} diff --git a/src/main/opencode/opencode-launch-capabilities.test.ts b/src/main/opencode/opencode-launch-capabilities.test.ts new file mode 100644 index 00000000000..12f6a8fec64 --- /dev/null +++ b/src/main/opencode/opencode-launch-capabilities.test.ts @@ -0,0 +1,161 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + getOpenCodeLaunchExecutable, + probeOpenCodeLaunchCapabilities +} from './opencode-launch-capabilities' +import { getOpenCodeCliCapabilities } from '../../shared/opencode-cli-version' + +const mocks = vi.hoisted(() => ({ resolve: vi.fn(), probe: vi.fn(), wsl: vi.fn() })) +vi.mock('../ipc/command-path-resolver', () => ({ resolveCommandOnLocalPath: mocks.resolve })) +vi.mock('./opencode-cli-version', () => ({ probeOpenCodeCliVersion: mocks.probe })) +vi.mock('../wsl/wsl-runner', () => ({ runWslProcess: mocks.wsl })) + +beforeEach(() => { + vi.clearAllMocks() + mocks.probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16')) + mocks.wsl.mockResolvedValue({ + code: 0, + timedOut: false, + stdout: '1.18.30', + environmentResolved: true + }) +}) + +describe('OpenCode execution-host launch capability probe', () => { + it('refuses a successful version response from an unresolved WSL execution environment', async () => { + mocks.wsl.mockResolvedValue({ + code: 0, + timedOut: false, + stdout: '1.18.30', + environmentResolved: false + }) + await probeOpenCodeLaunchCapabilities({ + command: 'opencode', + env: {}, + wsl: { distro: 'Ubuntu' } + }) + expect(await mocks.probe.mock.calls[0]?.[0].execute()).toMatchObject({ code: null }) + }) + it('recognizes quoted executables and preserves explicit run commands', () => { + expect(getOpenCodeLaunchExecutable('"/app dir/opencode" run task')).toBe('/app dir/opencode') + expect(getOpenCodeLaunchExecutable('custom-launcher --standalone', 'opencode')).toBe( + 'custom-launcher' + ) + expect(getOpenCodeLaunchExecutable('claude')).toBeNull() + }) + + it('uses native resolution with the execution environment and cwd', async () => { + mocks.resolve.mockResolvedValue('/bin/opencode') + const env = { PATH: '/bin', OPENCODE_CONFIG_DIR: '/private/config' } + await probeOpenCodeLaunchCapabilities({ + command: 'opencode run task', + env, + cwd: '/repo', + hostIdentity: 'native-test' + }) + expect(mocks.resolve).toHaveBeenCalledWith('opencode', { env, cwd: '/repo' }) + expect(mocks.probe).toHaveBeenCalledWith({ + executablePath: '/bin/opencode', + env, + cwd: '/repo', + hostIdentity: 'native-test' + }) + expect(mocks.wsl).not.toHaveBeenCalled() + }) + + it('uses the relay resolver rather than client PATH resolution', async () => { + const resolveExecutable = vi.fn().mockResolvedValue('/host/opencode') + await probeOpenCodeLaunchCapabilities({ + command: 'opencode', + env: {}, + hostIdentity: 'relay:linux', + resolveExecutable + }) + expect(resolveExecutable).toHaveBeenCalledWith('opencode') + expect(mocks.resolve).not.toHaveBeenCalled() + expect(mocks.probe).toHaveBeenCalledWith( + expect.objectContaining({ executablePath: '/host/opencode', hostIdentity: 'relay:linux' }) + ) + }) + + it('returns unknown when the host cannot resolve the binary', async () => { + mocks.resolve.mockResolvedValue(null) + expect(await probeOpenCodeLaunchCapabilities({ command: 'opencode', env: {} })).toEqual( + getOpenCodeCliCapabilities(null) + ) + expect(mocks.probe).not.toHaveBeenCalled() + }) + + it('bounds WSL probes and matches guest cwd plus explicitly imported config roots', async () => { + const env = { + HOME: '/native', + PATH: '/native/bin', + OPENCODE_CONFIG_DIR: '/guest/config', + XDG_DATA_HOME: '/guest/data', + WSLENV: 'OPENCODE_CONFIG_DIR:XDG_DATA_HOME' + } + await probeOpenCodeLaunchCapabilities({ + command: 'opencode --standalone', + env, + cwd: '\\\\wsl.localhost\\Ubuntu\\home\\user\\repo', + wsl: { distro: 'Debian' }, + hostIdentity: 'host-a' + }) + expect(mocks.resolve).not.toHaveBeenCalled() + const options = mocks.probe.mock.calls[0]?.[0] + const guestEnv = { + OPENCODE_CONFIG_DIR: '/guest/config', + XDG_DATA_HOME: '/guest/data', + WSLENV: env.WSLENV + } + expect(options).toEqual( + expect.objectContaining({ + executablePath: 'opencode', + hostIdentity: 'host-a:wsl:Ubuntu', + cwd: '/home/user/repo', + env: guestEnv + }) + ) + await options.execute() + expect(mocks.wsl).toHaveBeenCalledWith({ + distro: 'Ubuntu', + loginPath: 'preferred', + cwd: '/home/user/repo', + program: 'opencode', + args: ['--version'], + env: guestEnv, + timeoutMs: 5000, + maxOutputBytes: 4096 + }) + }) + + it('does not import native configuration the actual WSL pane would not receive', async () => { + await probeOpenCodeLaunchCapabilities({ + command: 'opencode', + env: { OPENCODE_CONFIG_DIR: 'C:\\native' }, + cwd: 'D:\\repo', + wsl: { distro: 'Ubuntu' } + }) + expect(mocks.probe).toHaveBeenCalledWith( + expect.objectContaining({ cwd: '/mnt/d/repo', env: { WSLENV: '' } }) + ) + }) + + it('keeps WSL path translation flags and skips Windows-only values', async () => { + await probeOpenCodeLaunchCapabilities({ + command: 'opencode', + env: { + WSLENV: 'XDG_DATA_HOME/p:WINDOWS_ONLY/w:HOME', + XDG_DATA_HOME: 'D:\\data', + WINDOWS_ONLY: 'private', + HOME: 'C:\\native' + }, + wsl: {} + }) + expect(mocks.probe).toHaveBeenCalledWith( + expect.objectContaining({ + env: { WSLENV: 'XDG_DATA_HOME/p:WINDOWS_ONLY/w:HOME', XDG_DATA_HOME: 'D:\\data' } + }) + ) + }) +}) diff --git a/src/main/opencode/opencode-launch-capabilities.ts b/src/main/opencode/opencode-launch-capabilities.ts new file mode 100644 index 00000000000..ee056ee9d4b --- /dev/null +++ b/src/main/opencode/opencode-launch-capabilities.ts @@ -0,0 +1,99 @@ +import { parseWslUncPath, toLinuxPath } from '../../shared/wsl-paths' +import { + getFirstCommandToken, + getCommandTokenPathBasename +} from '../../shared/command-token-scanner' +import { + getOpenCodeCliCapabilities, + type OpenCodeCliCapabilities +} from '../../shared/opencode-cli-version' +import type { TuiAgent } from '../../shared/tui-agent' +import { resolveCommandOnLocalPath } from '../ipc/command-path-resolver' +import { runWslProcess } from '../wsl/wsl-runner' +import { probeOpenCodeCliVersion } from './opencode-cli-version' + +export function getOpenCodeLaunchExecutable( + command: string | undefined, + agent?: TuiAgent +): string | null { + const executable = getFirstCommandToken(command ?? '') + const name = getCommandTokenPathBasename(executable) + .toLowerCase() + .replace(/\.(?:exe|cmd|sh)$/, '') + return agent === 'opencode' || + agent === 'opencode2' || + (!agent && (name === 'opencode' || name === 'opencode2')) + ? executable || null + : null +} + +export async function probeOpenCodeLaunchCapabilities(options: { + command: string | undefined + agent?: TuiAgent + env: NodeJS.ProcessEnv + cwd?: string + wsl?: { distro?: string } + hostIdentity?: string + resolveExecutable?: (executable: string) => Promise +}): Promise { + const executable = getOpenCodeLaunchExecutable(options.command, options.agent) + if (!executable) { + return null + } + if (options.wsl) { + // An empty carrier prevents the runner from restoring deleted ambient imports. + const guestEnv: Record = { WSLENV: '' } + const wslEnv = options.env.WSLENV?.split(':') + .filter((token) => options.env[token.split('/')[0]] !== undefined) + .join(':') + if (wslEnv) { + guestEnv.WSLENV = wslEnv + for (const token of wslEnv.split(':')) { + const [key, flags = ''] = token.split('/') + if (!key || flags.includes('w') || ['PATH', 'HOME', 'TMP', 'TEMP'].includes(key)) { + continue + } + const value = options.env[key] + if (value !== undefined) { + guestEnv[key] = value + } + } + } + const wslPath = options.cwd ? parseWslUncPath(options.cwd) : null + const cwd = options.cwd ? (wslPath?.linuxPath ?? toLinuxPath(options.cwd)) : undefined + if (cwd !== undefined && !cwd.startsWith('/')) { + return getOpenCodeCliCapabilities(null) + } + const distro = wslPath?.distro ?? options.wsl.distro + return probeOpenCodeCliVersion({ + executablePath: executable, + env: guestEnv, + cwd, + hostIdentity: `${options.hostIdentity ?? 'local'}:wsl:${distro ?? 'default'}`, + execute: async () => { + const result = await runWslProcess({ + distro, + loginPath: 'preferred', + cwd, + program: executable, + args: ['--version'], + env: guestEnv, + timeoutMs: 5_000, + maxOutputBytes: 4_096 + }) + return result.environmentResolved ? result : { ...result, code: null } + } + }) + } + const executablePath = options.resolveExecutable + ? await options.resolveExecutable(executable) + : await resolveCommandOnLocalPath(executable, { env: options.env, cwd: options.cwd }) + return executablePath + ? probeOpenCodeCliVersion({ + executablePath, + env: options.env, + cwd: options.cwd, + hostIdentity: options.hostIdentity + }) + : getOpenCodeCliCapabilities(null) +} diff --git a/src/main/opencode/opencode-legacy-plugin-loader-fixture.ts b/src/main/opencode/opencode-legacy-plugin-loader-fixture.ts index 15c2f238a99..3a5a341a08d 100644 --- a/src/main/opencode/opencode-legacy-plugin-loader-fixture.ts +++ b/src/main/opencode/opencode-legacy-plugin-loader-fixture.ts @@ -44,3 +44,39 @@ export function readV1Plugin( return value } + +// OpenCode v1.18.30 (5cd8e68): packages/opencode/src/plugin/index.ts. +type ServerPlugin = (...args: unknown[]) => unknown +function isServerPlugin(value: unknown): value is ServerPlugin { + return typeof value === 'function' +} + +function getServerPlugin(value: unknown) { + if (isServerPlugin(value)) { + return value + } + if (!value || typeof value !== 'object' || !('server' in value)) { + return + } + if (!isServerPlugin(value.server)) { + return + } + return value.server +} + +export function getLegacyPlugins(mod: Record) { + const seen = new Set() + const result: ServerPlugin[] = [] + for (const entry of Object.values(mod)) { + if (seen.has(entry)) { + continue + } + seen.add(entry) + const plugin = getServerPlugin(entry) + if (!plugin) { + throw new TypeError('Plugin export is not a function') + } + result.push(plugin) + } + return result +} diff --git a/src/main/opencode/opencode-pty-launch.test.ts b/src/main/opencode/opencode-pty-launch.test.ts new file mode 100644 index 00000000000..f7813ddc969 --- /dev/null +++ b/src/main/opencode/opencode-pty-launch.test.ts @@ -0,0 +1,158 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getOpenCodeCliCapabilities } from '../../shared/opencode-cli-version' +import { prepareOpenCodePtyLaunch } from './opencode-pty-launch' +import { + buildLocalPtySpawnEnvironment, + enforceLocalPtySpawnEnvironmentOverrides +} from '../providers/local-pty-spawn-environment' +import type { LocalPtyLaunchPlan } from '../providers/local-pty-launch-plan' + +const plan: LocalPtyLaunchPlan = { + startupAgentRecognition: null, + defaultCwd: '', + cwd: '', + wslInfo: null, + worktreeWslContext: undefined, + preferredWslContext: undefined, + launchWslContext: undefined, + shellPath: process.platform === 'win32' ? 'cmd.exe' : '/bin/sh', + shellArgs: [], + effectiveCwd: '', + validationCwd: '', + startupCommandDeliveredInShellArgs: false, + windowsFallbackAttempts: [], + shellReadyLaunch: null, + getFallbackShellReadyConfig: undefined, + primaryPreLaunchEnv: {}, + isWslShell: false, + launchWslDistro: null +} + +const probe = vi.hoisted(() => vi.fn()) +vi.mock('./opencode-launch-capabilities', () => ({ probeOpenCodeLaunchCapabilities: probe })) + +beforeEach(() => probe.mockReset()) +afterEach(() => vi.unstubAllEnvs()) + +describe('execution-host OpenCode launch preparation', () => { + it('keeps deleted credentials and config absent from the probe and final provider environment', async () => { + vi.stubEnv('ANTHROPIC_API_KEY', 'dummy-deleted-key') + vi.stubEnv('OPENCODE_CONFIG_DIR', '/dummy/deleted-config') + vi.stubEnv('ORCA_OPENCODE_PLUGIN_API', 'v1') + probe.mockResolvedValue(getOpenCodeCliCapabilities(null)) + const envToDelete = ['ANTHROPIC_API_KEY', 'OPENCODE_CONFIG_DIR'] + const env = await prepareOpenCodePtyLaunch({ + command: 'opencode', + env: {}, + envToDelete, + isFreshLaunch: true + }) + const probeEnv = probe.mock.calls[0]?.[0].env + expect(probeEnv).not.toHaveProperty('ANTHROPIC_API_KEY') + expect(probeEnv).not.toHaveProperty('OPENCODE_CONFIG_DIR') + expect(probeEnv).not.toHaveProperty('ORCA_OPENCODE_PLUGIN_API') + const finalEnv = await buildLocalPtySpawnEnvironment({ + id: 'probe', + spawn: { cols: 80, rows: 24, env, envToDelete }, + getOptions: () => ({}), + plan + }) + enforceLocalPtySpawnEnvironmentOverrides({ cols: 80, rows: 24, env, envToDelete }, finalEnv) + expect(finalEnv).not.toHaveProperty('ANTHROPIC_API_KEY') + expect(finalEnv).not.toHaveProperty('OPENCODE_CONFIG_DIR') + expect(finalEnv).not.toHaveProperty('ORCA_OPENCODE_PLUGIN_API') + }) + + it('retains a verified selection through the final provider deletion pass', async () => { + vi.stubEnv('ORCA_OPENCODE_PLUGIN_API', 'v1') + probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16')) + const envToDelete = ['KEEP_DELETED', 'ORCA_OPENCODE_PLUGIN_API'] + const env = await prepareOpenCodePtyLaunch({ + command: 'opencode', + env: {}, + envToDelete, + isFreshLaunch: true + }) + const finalEnv = await buildLocalPtySpawnEnvironment({ + id: 'probe', + spawn: { cols: 80, rows: 24, env, envToDelete }, + getOptions: () => ({}), + plan + }) + finalEnv.KEEP_DELETED = 'dummy' + enforceLocalPtySpawnEnvironmentOverrides({ cols: 80, rows: 24, env, envToDelete }, finalEnv) + expect(finalEnv.ORCA_OPENCODE_PLUGIN_API).toBe('v2') + expect(finalEnv).not.toHaveProperty('KEEP_DELETED') + }) + + it.each(['1.1.23', '2.0.16'])( + 'selects the probed %s plugin for the execution host', + async (version) => { + const capabilities = getOpenCodeCliCapabilities(version) + probe.mockResolvedValue(capabilities) + const env = { + KEEP: '1', + ORCA_OPENCODE_PLUGIN_API: 'stale' + } + const result = await prepareOpenCodePtyLaunch({ + command: 'opencode --prompt test', + agent: 'opencode', + env, + envToDelete: [], + cwd: '/repo', + isFreshLaunch: true + }) + expect(result).toEqual({ KEEP: '1', ORCA_OPENCODE_PLUGIN_API: capabilities.pluginApi }) + expect(env).toEqual({ KEEP: '1', ORCA_OPENCODE_PLUGIN_API: 'stale' }) + expect(probe).toHaveBeenCalledWith( + expect.objectContaining({ + command: 'opencode --prompt test', + cwd: '/repo', + env: expect.objectContaining({ KEEP: '1' }) + }) + ) + } + ) + + it('creates a launch environment for a known binary without caller env', async () => { + probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16')) + expect( + await prepareOpenCodePtyLaunch({ + command: 'opencode', + env: undefined, + envToDelete: [], + isFreshLaunch: true + }) + ).toEqual({ ORCA_OPENCODE_PLUGIN_API: 'v2' }) + }) + + it('forwards WSL plugin selection through WSLENV after a guest probe', async () => { + probe.mockResolvedValue(getOpenCodeCliCapabilities('1.1.23')) + const env = { KEEP: '1' } + const result = await prepareOpenCodePtyLaunch({ + command: 'opencode', + agent: 'opencode', + env, + envToDelete: [], + isFreshLaunch: true, + wsl: { distro: 'Ubuntu' } + }) + expect(result).toMatchObject({ + ORCA_OPENCODE_PLUGIN_API: 'v1', + WSLENV: 'ORCA_OPENCODE_PLUGIN_API' + }) + expect(probe).toHaveBeenCalledWith(expect.objectContaining({ wsl: { distro: 'Ubuntu' } })) + }) + + it.each([{ connectionId: 'remote', isFreshLaunch: true }, { isFreshLaunch: false }])( + 'never probes the client for an attach or SSH launch', + async (route) => { + const env = { ORCA_OPENCODE_PLUGIN_API: 'v1' } + expect( + await prepareOpenCodePtyLaunch({ command: 'opencode', env, envToDelete: [], ...route }) + ).toEqual({}) + expect(probe).not.toHaveBeenCalled() + expect(env).toEqual({ ORCA_OPENCODE_PLUGIN_API: 'v1' }) + } + ) +}) diff --git a/src/main/opencode/opencode-pty-launch.ts b/src/main/opencode/opencode-pty-launch.ts new file mode 100644 index 00000000000..5aa5adab072 --- /dev/null +++ b/src/main/opencode/opencode-pty-launch.ts @@ -0,0 +1,47 @@ +import { addWslEnvKeys } from '../../shared/wsl-env' +import type { TuiAgent } from '../../shared/tui-agent' +import { deleteRequestedEnvKeys } from '../ipc/pty/host-env/path' +import { probeOpenCodeLaunchCapabilities } from './opencode-launch-capabilities' + +export async function prepareOpenCodePtyLaunch(options: { + command: string | undefined + agent?: TuiAgent + env: Record | undefined + envToDelete: string[] + cwd?: string + connectionId?: string | null + isFreshLaunch: boolean + wsl?: { distro?: string } +}): Promise | undefined> { + const env = options.env ? { ...options.env } : undefined + if (env) { + delete env.ORCA_OPENCODE_PLUGIN_API + } + // Providers merge their own ambient environment after this preparation. + if (!options.envToDelete.includes('ORCA_OPENCODE_PLUGIN_API')) { + options.envToDelete.push('ORCA_OPENCODE_PLUGIN_API') + } + if (options.connectionId || !options.isFreshLaunch) { + return env + } + const probeEnv: Record = {} + for (const [key, value] of Object.entries({ ...process.env, ...env })) { + if (value !== undefined) { + probeEnv[key] = value + } + } + deleteRequestedEnvKeys(probeEnv, options.envToDelete) + const capabilities = await probeOpenCodeLaunchCapabilities({ + ...options, + env: probeEnv + }) + if (!capabilities || capabilities.pluginApi === 'unknown') { + return env + } + const launchEnv = { ...env, ORCA_OPENCODE_PLUGIN_API: capabilities.pluginApi } + options.envToDelete.splice(options.envToDelete.indexOf('ORCA_OPENCODE_PLUGIN_API'), 1) + if (options.wsl) { + addWslEnvKeys(launchEnv, ['ORCA_OPENCODE_PLUGIN_API']) + } + return launchEnv +} diff --git a/src/main/opencode/opencode-wsl-probe-environment.test.ts b/src/main/opencode/opencode-wsl-probe-environment.test.ts new file mode 100644 index 00000000000..864321c6001 --- /dev/null +++ b/src/main/opencode/opencode-wsl-probe-environment.test.ts @@ -0,0 +1,72 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { prepareOpenCodePtyLaunch } from './opencode-pty-launch' +import { seedWslGuestEnvironmentForTests } from '../wsl/wsl-guest-environment' + +const run = vi.hoisted(() => vi.fn()) +vi.mock('../../shared/child-process/run-process', () => ({ runProcess: run })) +vi.mock('../wsl/wsl-executable-path', () => ({ resolveWslExecutablePath: () => 'wsl.exe' })) +vi.mock('../agent-hooks/server', () => ({ agentHookServer: { endpointFilePath: null } })) + +beforeEach(() => { + run.mockReset().mockResolvedValue({ code: 0, stdout: '2.0.16', timedOut: false }) + seedWslGuestEnvironmentForTests('probe-deletion', { + path: '/usr/bin', + home: '/private/guest', + envBinary: '/usr/bin/env' + }) + vi.stubEnv('DELETED_CREDENTIAL', 'dummy-host-value') + vi.stubEnv('OPENCODE_CONFIG_DIR', 'C:\\dummy\\config') + vi.stubEnv('KEEP', 'C:\\keep') + vi.stubEnv('EMPTY', '') + vi.stubEnv('WSLENV', 'DELETED_CREDENTIAL/u:OPENCODE_CONFIG_DIR/p:KEEP/p:EMPTY/u') +}) +afterEach(() => vi.unstubAllEnvs()) + +describe('OpenCode WSL version-probe deletion boundary', () => { + it.each([ + { deleted: ['DELETED_CREDENTIAL', 'OPENCODE_CONFIG_DIR'], kept: ['KEEP/p', 'EMPTY/u'] }, + { deleted: ['WSLENV'], kept: [] } + ])( + 'does not restore deleted keys through the inherited carrier: $deleted', + async ({ deleted, kept }) => { + await prepareOpenCodePtyLaunch({ + command: 'opencode', + env: {}, + envToDelete: [...deleted], + isFreshLaunch: true, + wsl: { distro: 'probe-deletion' }, + cwd: `/private/${deleted.join('-')}` + }) + const hostEnv = run.mock.calls[0]?.[0].env + expect(hostEnv).toBeDefined() + const tokens = String(hostEnv.WSLENV).split(':').filter(Boolean) + expect(tokens).not.toContain('DELETED_CREDENTIAL/u') + expect(tokens).not.toContain('OPENCODE_CONFIG_DIR/p') + expect(tokens).toEqual(expect.arrayContaining(kept)) + // The host value may exist; its carrier must not import it into the guest. + expect(hostEnv.DELETED_CREDENTIAL).toBe('dummy-host-value') + } + ) + + it('shares a sanitized concurrent probe without losing kept flags or empty values', async () => { + const options = { + command: 'opencode', + env: {}, + envToDelete: ['DELETED_CREDENTIAL', 'OPENCODE_CONFIG_DIR'], + isFreshLaunch: true, + wsl: { distro: 'probe-deletion' }, + cwd: '/private/concurrent' + } + await Promise.all([ + prepareOpenCodePtyLaunch({ ...options, envToDelete: [...options.envToDelete] }), + prepareOpenCodePtyLaunch({ ...options, envToDelete: [...options.envToDelete] }) + ]) + expect(run).toHaveBeenCalledTimes(1) + const hostEnv = run.mock.calls[0]?.[0].env + expect(hostEnv.WSLENV).toContain('KEEP/p') + expect(hostEnv.WSLENV).toContain('EMPTY/u') + expect(hostEnv.WSLENV).not.toContain('DELETED_CREDENTIAL') + expect(hostEnv.WSLENV).not.toContain('OPENCODE_CONFIG_DIR') + expect(hostEnv.EMPTY).toBe('') + }) +}) diff --git a/src/main/opencode/status-plugin-factory-source.ts b/src/main/opencode/status-plugin-factory-source.ts index 43f063dde81..fd6c39bf510 100644 --- a/src/main/opencode/status-plugin-factory-source.ts +++ b/src/main/opencode/status-plugin-factory-source.ts @@ -289,11 +289,8 @@ export function getStatusPluginFactorySource(options: { '};', ...(options.emitNextEvents ? getOpenCode2ModuleSource(pluginID, expectedAgent) : []), '', - '// Why: OpenCode also resolves plugins through the module default export, and that', - '// loader rejects the module unless the default exposes `server()` ("must default', - '// export an object with server()"). `setup()` does not satisfy it. Keep the named', - '// export so the factory-based loader still finds the same instance.', - 'export default {', + '// OpenCode 1 requires a callable default; OpenCode 2 validates a plugin object.', + 'export default process.env.ORCA_OPENCODE_PLUGIN_API === "v1" ? OrcaOpenCodeStatusPlugin : {', ` id: "${pluginID}",`, ' server: OrcaOpenCodeStatusPlugin,', ...(options.emitNextEvents ? [' setup: setupOpenCode2Status,'] : []), diff --git a/src/main/providers/provider-dispatch.test.ts b/src/main/providers/provider-dispatch.test.ts index 7ac956b70fd..12b259f5b9f 100644 --- a/src/main/providers/provider-dispatch.test.ts +++ b/src/main/providers/provider-dispatch.test.ts @@ -175,6 +175,7 @@ describe('PTY provider dispatch', () => { 'CLAUDE_CODE_CHILD_SESSION', 'CLAUDE_CODE_SESSION_ID', 'CLAUDE_CODE_BRIDGE_SESSION_ID', + 'ORCA_OPENCODE_PLUGIN_API', 'ORCA_PI_STATUS_OWNED', 'ORCA_PRIME_AGENT_STATUS_OWNED', 'ORCA_PI_TITLE_MARKER_OWNED', diff --git a/src/main/providers/pty-spawn-result.ts b/src/main/providers/pty-spawn-result.ts index 2886a00e8b6..4eb5dc45a60 100644 --- a/src/main/providers/pty-spawn-result.ts +++ b/src/main/providers/pty-spawn-result.ts @@ -4,8 +4,11 @@ import type { AgentSessionClaimedSpawnResult } from '../../shared/agent-session- import type { PtyIncarnationId } from '../../shared/pty-incarnation' import type { PtySourceReceivingActivation } from '../../shared/pty-source-receiving-activation' import type { TerminalOwner } from '../../shared/terminal-owner' +import type { OpenCodeCliCapabilities } from '../../shared/opencode-cli-version' export type PtySpawnResult = { + /** Execution-host probe; absent on older hosts and reattachments. */ + openCodeCapabilities?: OpenCodeCliCapabilities agentSessionEnsure?: AgentSessionClaimedSpawnResult /** App-facing PTY id. Remote providers must return globally routable ids, * not relay-local handles, because renderer/runtime IPC routes by this key. */ diff --git a/src/relay/opencode-plugin-selection.ts b/src/relay/opencode-plugin-selection.ts new file mode 100644 index 00000000000..38c7934cfd6 --- /dev/null +++ b/src/relay/opencode-plugin-selection.ts @@ -0,0 +1,39 @@ +import { + getOpenCodeCliCapabilities, + type OpenCodeCliCapabilities +} from '../shared/opencode-cli-version' +import { addWslEnvKeys } from '../shared/wsl-env' + +export function restoreOpenCodeCapabilities(value: unknown): OpenCodeCliCapabilities | undefined { + if ( + !value || + typeof value !== 'object' || + !('version' in value) || + typeof value.version !== 'string' + ) { + return undefined + } + const capabilities = getOpenCodeCliCapabilities(value.version) + return capabilities.pluginApi === 'unknown' ? undefined : capabilities +} + +export function applyOpenCodePluginSelection( + env: Record, + envToDelete: string[], + capabilities: OpenCodeCliCapabilities | null | undefined, + wsl: boolean +): void { + delete env.ORCA_OPENCODE_PLUGIN_API + if (!capabilities || capabilities.pluginApi === 'unknown') { + return + } + env.ORCA_OPENCODE_PLUGIN_API = capabilities.pluginApi + for (let index = envToDelete.length - 1; index >= 0; index -= 1) { + if (envToDelete[index] === 'ORCA_OPENCODE_PLUGIN_API') { + envToDelete.splice(index, 1) + } + } + if (wsl) { + addWslEnvKeys(env, ['ORCA_OPENCODE_PLUGIN_API']) + } +} diff --git a/src/relay/pty-handler-opencode-revive.test.ts b/src/relay/pty-handler-opencode-revive.test.ts new file mode 100644 index 00000000000..1c73f8be1fe --- /dev/null +++ b/src/relay/pty-handler-opencode-revive.test.ts @@ -0,0 +1,142 @@ +import './mock-descendant-sweep' +import { tmpdir } from 'node:os' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { getOpenCodeCliCapabilities } from '../shared/opencode-cli-version' +import { applyOpenCodePluginSelection } from './opencode-plugin-selection' +import type { PtyHandler } from './pty-handler' +import { + beginPtyHandlerTest, + createMockDispatcher, + createTestPtyHandler, + endPtyHandlerTest, + testPtyId, + type MockDispatcher +} from './pty-handler-test-harness' + +const mocks = vi.hoisted(() => ({ + mockPtySpawn: vi.fn(), + mockCreateShellPromptReadinessProbe: vi.fn(), + probe: vi.fn(), + mockPtyInstance: { + pid: process.pid, + onData: vi.fn(), + onExit: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + clear: vi.fn(), + pause: vi.fn(), + resume: vi.fn() + } +})) + +vi.mock('node-pty', () => ({ spawn: mocks.mockPtySpawn })) +vi.mock('../main/shell-prompt-readiness-probe', () => ({ + createShellPromptReadinessProbe: mocks.mockCreateShellPromptReadinessProbe +})) +vi.mock('../main/opencode/opencode-launch-capabilities', () => ({ + probeOpenCodeLaunchCapabilities: mocks.probe +})) + +let dispatcher: MockDispatcher +let handler: PtyHandler +let originalPlatform: PropertyDescriptor | undefined + +beforeEach(() => { + ;({ dispatcher, handler, originalPlatform } = beginPtyHandlerTest(mocks)) + mocks.probe.mockReset() +}) + +afterEach(async () => endPtyHandlerTest(handler, originalPlatform)) + +function spawnedEnvironment(): Record { + const options: unknown = mocks.mockPtySpawn.mock.calls.at(-1)?.[2] + if ( + !options || + typeof options !== 'object' || + !('env' in options) || + !options.env || + typeof options.env !== 'object' + ) { + throw new Error('Expected a PTY spawn environment') + } + return Object.fromEntries(Object.entries(options.env)) +} + +async function serialize(): Promise { + const state = await dispatcher.callRequest('pty.serialize', { ids: [testPtyId(1)] }) + if (typeof state !== 'string') { + throw new Error('Expected serialized PTY state') + } + return state +} + +async function restartHandler(): Promise { + await handler.dispose({ waitForPhysicalExit: false }) + dispatcher = createMockDispatcher() + handler = createTestPtyHandler(dispatcher) + mocks.mockPtySpawn.mockClear() +} + +it.each(['1.18.32', '2.0.16'])( + 'preserves host-selected plugin exports through two relay revives on %s', + async (version) => { + const capabilities = getOpenCodeCliCapabilities(version) + mocks.probe.mockResolvedValue(capabilities) + await dispatcher.callRequest('pty.spawn', { + cwd: tmpdir(), + command: 'opencode --standalone', + launchAgent: 'opencode', + env: { ORCA_PANE_KEY: 'tab-oc:leaf', ORCA_OPENCODE_PLUGIN_API: 'untrusted' }, + envToDelete: ['ORCA_OPENCODE_PLUGIN_API', 'ORCA_OPENCODE_PLUGIN_API'] + }) + expect(spawnedEnvironment().ORCA_OPENCODE_PLUGIN_API).toBe(capabilities.pluginApi) + const state = await serialize() + expect(JSON.parse(state)).toMatchObject([ + { openCodeCapabilities: capabilities, envToDelete: [] } + ]) + + await restartHandler() + await dispatcher.callRequest('pty.revive', { state }) + expect(spawnedEnvironment().ORCA_OPENCODE_PLUGIN_API).toBe(capabilities.pluginApi) + const second = await serialize() + await restartHandler() + await dispatcher.callRequest('pty.revive', { state: second }) + expect(spawnedEnvironment().ORCA_OPENCODE_PLUGIN_API).toBe(capabilities.pluginApi) + expect(mocks.probe).toHaveBeenCalledOnce() + } +) + +it.each([ + undefined, + { version: 'garbage', pluginApi: 'v1' }, + { version: '3.0.0', pluginApi: 'v1' }, + { version: 2, pluginApi: 'v1' } +])('keeps legacy or unverifiable serialized selection unset: %j', async (openCodeCapabilities) => { + handler.addEnvAugmenter(() => ({ ORCA_OPENCODE_PLUGIN_API: 'v1' })) + await dispatcher.callRequest('pty.revive', { + state: JSON.stringify([ + { + id: testPtyId(1), + pid: process.pid, + cols: 80, + rows: 24, + cwd: tmpdir(), + ...(openCodeCapabilities ? { openCodeCapabilities } : {}) + } + ]) + }) + expect(spawnedEnvironment().ORCA_OPENCODE_PLUGIN_API).toBeUndefined() + expect(mocks.probe).not.toHaveBeenCalled() +}) + +it('carries a selected API into WSL without dropping other forwarded keys', () => { + const env = { WSLENV: 'XDG_DATA_HOME/p', ORCA_OPENCODE_PLUGIN_API: 'untrusted' } + const envToDelete = ['ORCA_OPENCODE_PLUGIN_API', 'OTHER_ENV'] + applyOpenCodePluginSelection(env, envToDelete, getOpenCodeCliCapabilities('1.18.32'), true) + expect(env).toMatchObject({ + ORCA_OPENCODE_PLUGIN_API: 'v1', + WSLENV: 'XDG_DATA_HOME/p:ORCA_OPENCODE_PLUGIN_API' + }) + expect(envToDelete).toEqual(['OTHER_ENV']) +}) diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 579e920269c..4f86f8a3508 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -5,6 +5,13 @@ import { restoreManagedDataAccountEnvironment } from '../shared/managed-data-acc import { createTerminalTitleTracker } from '../shared/terminal-output-side-effects' import { getDecorativeTitleGateKey } from '../shared/agent-decorative-title-signature' import { FreebuffStatusProjection } from './freebuff-status-projection' +import { probeOpenCodeLaunchCapabilities } from '../main/opencode/opencode-launch-capabilities' +import type { OpenCodeCliCapabilities } from '../shared/opencode-cli-version' +import { + applyOpenCodePluginSelection, + restoreOpenCodeCapabilities +} from './opencode-plugin-selection' +import { resolveCommandPathForRelay } from './preflight-handler' import { applyRelayAgentWorkspaceTrust } from './agent-workspace-trust-spawn' import type { IPty } from 'node-pty' import { killWithDescendantSweep } from '../main/pty-descendant-termination' @@ -218,6 +225,7 @@ function parseSourceRecoveryRequest(value: unknown): PtySourceRecoveryRequest | } type ManagedPty = { + openCodeCapabilities?: OpenCodeCliCapabilities freebuffStatus?: FreebuffStatusProjection id: string incarnationId: string @@ -278,6 +286,7 @@ type ManagedPty = { } type RelayAgentSessionCreateResult = { + openCodeCapabilities?: OpenCodeCliCapabilities id: string incarnationId: string replay?: string @@ -449,6 +458,7 @@ type PtyProcessSummary = { } type SerializedPtyEntry = { + openCodeCapabilities?: OpenCodeCliCapabilities id: string pid: number cols: number @@ -1954,6 +1964,9 @@ export class PtyHandler { id: managed.id, incarnationId: managed.incarnationId, agentSessionEnsure: result, + ...(result.disposition === 'created' && managed.openCodeCapabilities + ? { openCodeCapabilities: managed.openCodeCapabilities } + : {}), ...(sourceActivation ? { sourceActivation } : {}), ...(adoptedReplay ? { replay: adoptedReplay } : {}), ...(managed.shellReadyArmed !== undefined @@ -1982,6 +1995,7 @@ export class PtyHandler { incarnationId: string sourceActivation?: PtySourceReceivingActivation shellReadyArmed?: boolean + openCodeCapabilities?: OpenCodeCliCapabilities }> { const pty = await this.loadPty() if (!pty) { @@ -2028,6 +2042,22 @@ export class PtyHandler { { id, paneKey, shell, command, launchAgent }, envToDelete ) + delete spawnEnv.ORCA_OPENCODE_PLUGIN_API + const openCodeCapabilities = await probeOpenCodeLaunchCapabilities({ + command, + agent: launchAgent, + env: spawnEnv, + cwd, + hostIdentity: `relay:${process.platform}`, + resolveExecutable: (executable) => resolveCommandPathForRelay(executable, { env: spawnEnv }), + ...(isRelayWslShell(shell) ? { wsl: { distro: terminalWindowsWslDistro ?? undefined } } : {}) + }) + applyOpenCodePluginSelection( + spawnEnv, + envToDelete, + openCodeCapabilities, + isRelayWslShell(shell) + ) await applyRelayAgentWorkspaceTrust(params.agentWorkspaceTrust, launchAgent, spawnEnv, { wslShell: isRelayWslShell(shell) }) @@ -2129,6 +2159,7 @@ export class PtyHandler { const ownerClientInstanceId = context === undefined ? null : (this.consumerIdentityResolver?.(context.clientId) ?? null) const managed: ManagedPty = { + ...(openCodeCapabilities ? { openCodeCapabilities } : {}), ...(launchAgent === 'freebuff' ? { freebuffStatus: new FreebuffStatusProjection(cols, rows) } : {}), @@ -2208,7 +2239,8 @@ export class PtyHandler { id, incarnationId: managed.incarnationId, ...(sourceActivation ? { sourceActivation } : {}), - shellReadyArmed: rendererShellReadySupported + shellReadyArmed: rendererShellReadySupported, + ...(openCodeCapabilities ? { openCodeCapabilities } : {}) } } @@ -3097,6 +3129,9 @@ export class PtyHandler { worktreeId: managed.worktreeId, ...(managed.explicitTerm !== undefined ? { explicitTerm: managed.explicitTerm } : {}), envToDelete: managed.envToDelete, + ...(managed.openCodeCapabilities + ? { openCodeCapabilities: managed.openCodeCapabilities } + : {}), gitCredentialPromptGuarded: managed.gitCredentialPromptGuarded, ...(managed.historyIsolationEnabled ? { historyIsolationEnabled: true } : {}), // Why serialized: revive re-spawns the shell, and without these a WSL @@ -3191,6 +3226,8 @@ export class PtyHandler { { id: entry.id, paneKey: entry.paneKey, shell }, envToDelete ) + const openCodeCapabilities = restoreOpenCodeCapabilities(entry.openCodeCapabilities) + applyOpenCodePluginSelection(spawnEnv, envToDelete, openCodeCapabilities, wslShell) if ( historyIsolationEnabled && entry.worktreeId && @@ -3268,6 +3305,7 @@ export class PtyHandler { ...(explicitTerm !== undefined ? { explicitTerm } : {}), envToDelete, gitCredentialPromptGuarded, + ...(openCodeCapabilities ? { openCodeCapabilities } : {}), ...(historyIsolationEnabled ? { historyIsolationEnabled: true } : {}), shellPath: shell, // Why re-stored: a revived pane can be serialized again, and losing the diff --git a/src/shared/opencode-cli-version.test.ts b/src/shared/opencode-cli-version.test.ts new file mode 100644 index 00000000000..cc9c5987f2e --- /dev/null +++ b/src/shared/opencode-cli-version.test.ts @@ -0,0 +1,34 @@ +import { expect, it } from 'vitest' +import { getOpenCodeCliCapabilities, parseOpenCodeCliVersion } from './opencode-cli-version' + +it.each(['1.1.23', 'opencode v2.0.16', '2.0.0-beta.1+build'])('parses CLI output %s', (output) => { + expect(parseOpenCodeCliVersion(output)).toBe(output.replace(/^opencode v/, '')) +}) +it.each([null, '', 'Error 2.0.16', '1.1', '2.0.16\nwarning'])( + 'does not mistake other output for a version', + (output) => { + expect(parseOpenCodeCliVersion(output)).toBeNull() + } +) +it('keeps future versions unknown instead of assuming a plugin loader or prompt policy', () => { + expect(getOpenCodeCliCapabilities('3.0.0')).toEqual({ + version: '3.0.0', + pluginApi: 'unknown', + promptMode: 'unknown' + }) + expect(getOpenCodeCliCapabilities('1.1.23')).toEqual({ + version: '1.1.23', + pluginApi: 'v1', + promptMode: 'submit' + }) + expect(getOpenCodeCliCapabilities('opencode v2.0.16')).toEqual({ + version: '2.0.16', + pluginApi: 'v2', + promptMode: 'prefill' + }) +}) + +it('does not opt unverified v2 builds into an extra prompt submission', () => { + expect(getOpenCodeCliCapabilities('2.0.17').promptMode).toBe('unknown') + expect(getOpenCodeCliCapabilities('2.0.16-beta').promptMode).toBe('unknown') +}) diff --git a/src/shared/opencode-cli-version.ts b/src/shared/opencode-cli-version.ts new file mode 100644 index 00000000000..2aa71b2c30f --- /dev/null +++ b/src/shared/opencode-cli-version.ts @@ -0,0 +1,26 @@ +import { isValidAppVersion } from './app-version' + +export type OpenCodeCliCapabilities = { + version: string | null + pluginApi: 'v1' | 'v2' | 'unknown' + promptMode: 'submit' | 'prefill' | 'unknown' +} + +export function parseOpenCodeCliVersion(output: string | null | undefined): string | null { + const version = output + ?.trim() + .match(/^(?:opencode\s+)?v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)$/i)?.[1] + return version && isValidAppVersion(version) ? version : null +} + +export function getOpenCodeCliCapabilities( + output: string | null | undefined +): OpenCodeCliCapabilities { + const version = parseOpenCodeCliVersion(output) + const major = version?.split('.')[0] + return { + version, + pluginApi: major === '1' ? 'v1' : major === '2' ? 'v2' : 'unknown', + promptMode: major === '1' ? 'submit' : version === '2.0.16' ? 'prefill' : 'unknown' + } +} From d5bb69d348d4c6b1455b8ed6da4bb144f77a6db8 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:13:45 -0700 Subject: [PATCH 38/67] Cut CI time in store, Git contention and readiness tests (#25147) * Check store retention boundaries with a faster independent oracle * Replace CI diagnostic sleeps with gated contention and scoped transcript clocks --- docs/reference/ci-runner-efficiency.md | 57 +++ .../git-admission-storm-measurement.test.ts | 371 +++++++----------- .../git-admission-storm-test-fixture.ts | 245 ++++++++++++ ...avity-screen-readiness-transcripts.test.ts | 18 +- ...tatus-store-copying-oracle.test-fixture.ts | 22 +- .../agent-status-store-in-place.test.ts | 59 ++- 6 files changed, 527 insertions(+), 245 deletions(-) create mode 100644 src/main/git/command-runner/git-admission-storm-test-fixture.ts diff --git a/docs/reference/ci-runner-efficiency.md b/docs/reference/ci-runner-efficiency.md index 8130b770dd9..19d928f3c82 100644 --- a/docs/reference/ci-runner-efficiency.md +++ b/docs/reference/ci-runner-efficiency.md @@ -1959,3 +1959,60 @@ same ordering assertion. The gate was released, both controls drained the captur job, and the instrumentation was removed. Changed-code quality passed. This proves the teardown ordering mechanism, not a measured avoided-retry saving. Final-head hosted qualification remains required. + +## October 4 store oracle and retention fixtures + +The randomized in-place-store test validated the copying oracle twice after +accepted mutations and compared snapshots through the same production parser. +Its 5,000-step retention fixture generated enough tombstones to hit the count +limit, but never reached the 4,096-revision age boundary. + +The test retains all four seeds and 1,500 mutations per seed, removes the duplicate +validation, and projects snapshots directly from the copying oracle's validated +maps. Separate fixtures now check the revision before, at and after expiry and +count overflow. Production code is unchanged. + +Three alternating one-worker pairs on `ubuntu-24.04-arm` in +[37180517143](https://github.com/stablyai/orca/actions/runs/37180517143) +measured baseline invocation times 33.551 / 33.304 / 33.529 seconds and candidate +13.848 / 13.816 / 13.875 seconds: median 33.529 to 13.848 seconds, saving 19.681 +seconds (58.7%). Baseline passed seven tests; candidate passed eight. This is a +focused test saving, not a measured whole-shard or queue-delay change. + +Hosted Node typecheck passed. Separate fault controls failed the intended +assertion for early, late and disabled age expiry, disabled count compaction, +and a snapshot that drops child descriptions. The description fault passes with +the original parser-sharing oracle and fails with the independent projection. + +## October 4 Git contention and remaining readiness waits + +The full Git admission benchmark compared a disabled arm with no correctness +assertions to an enabled arm with structural ledger checks. Its default CI test +now saturates the real base and headroom budgets with FIFO-gated child processes, +queues older background and newer interactive work, releases base slots, and +requires interactive priority, matching outputs and complete permit release. +The full original diagnostic remains opt-in through +`ORCA_GIT_ADMISSION_STORM_MEASUREMENT=1`; both opt-in tests passed locally. +The existing Windows real-Git parity tests remain unchanged; this fixture retains +its existing POSIX platform scope. + +Two remaining Antigravity transcript tests used real 5,000ms refusal windows. +They now use the existing scoped `waitForTranscriptIdle` timer harness after the +emulator drains. All 60 tests, original captured transcripts, deadlines and +readiness assertions remain. + +Three alternating one-worker hosted ARM pairs in +[37180614492](https://github.com/stablyai/orca/actions/runs/37180614492) +measured these complete focused invocations: + +| Suite | Baseline seconds | Candidate seconds | Median saving | +| --- | --- | --- | --- | +| Git admission storm | 26.619 / 26.635 / 26.582 | 1.017 / 1.018 / 1.016 | 25.602s (96.2%) | +| Antigravity readiness | 27.347 / 27.910 / 27.550 | 13.855 / 13.894 / 13.800 | 13.695s (49.7%) | + +Each candidate passed its original meaningful checks. Hosted Node typecheck +passed. Separate scheduler faults for bypassed admission, withheld release and +FIFO-only priority failed the queued-contention or interactive-start assertion. +Two additional local transcript faults failed the original picker-rejection and +repaint-readiness assertions. These are focused suite savings; whole-shard time +and queue delay were not measured by this experiment. diff --git a/src/main/git/command-runner/git-admission-storm-measurement.test.ts b/src/main/git/command-runner/git-admission-storm-measurement.test.ts index 2fb3e896b20..f7ebc3036b2 100644 --- a/src/main/git/command-runner/git-admission-storm-measurement.test.ts +++ b/src/main/git/command-runner/git-admission-storm-measurement.test.ts @@ -1,250 +1,183 @@ -/** - * POSIX-only measurement: a PATH-injected git fixture exercises the real spawn path. - * Timing distributions are reported for field comparison; CI assertions stay structural. - */ -import { chmod, mkdtemp, mkdir, readdir, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' +import { existsSync, watch } from 'node:fs' +import { chmod, mkdir, readdir, writeFile } from 'node:fs/promises' import path from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' +import { describe, expect, it } from 'vitest' +import { runProcess } from '../../../shared/child-process/run-process' import { gitExecFileAsync } from './git-exec-file' import { - GIT_ADMISSION_AGING_MS, + GENERAL_CAP, + GENERAL_HEADROOM, GitAdmissionScheduler, - MAX_GIT_CHILDREN, _gitAdmissionSnapshotForTests, _resetGitAdmissionForTests, type GitAdmissionEvent } from './git-subprocess-admission' +import { + assertAdmissionLedger, + createStormRoot, + formatMeasurementTable, + measureStorm, + type InteractiveQueueSnapshot +} from './git-admission-storm-test-fixture' -type StormMeasurement = { - mode: 'disabled' | 'enabled' - maxConcurrentChildren: number - eventLoopMaxDriftMs: number - eventLoopP99DriftMs: number - interactiveP50Ms: number - interactiveP95Ms: number - totalWallMs: number - admissionEvents: GitAdmissionEvent[] - interactiveQueueSnapshots: InteractiveQueueSnapshot[] +function waitForLiveChild(stateDir: string, id: string, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const watcher = watch(stateDir) + const finish = (error?: Error): void => { + clearTimeout(deadline) + watcher.close() + signal.removeEventListener('abort', aborted) + if (error) { + reject(error) + } else { + resolve() + } + } + const aborted = (): void => finish(new Error(`Aborted waiting for ${id}`)) + const deadline = setTimeout(() => finish(new Error(`Child ${id} did not start`)), 5_000) + const check = (): void => { + if (existsSync(path.join(stateDir, `${id}.live`))) { + finish() + } + } + watcher.on('change', check) + watcher.once('error', finish) + signal.addEventListener('abort', aborted, { once: true }) + if (signal.aborted) { + aborted() + } else { + check() + } + }) } -type InteractiveQueueSnapshot = { - commandLabel: string - backgroundWaiterIds: number[] -} - -const tempRoots: string[] = [] -const originalAdmissionDisabled = process.env.ORCA_GIT_ADMISSION_DISABLED - -afterEach(async () => { - if (originalAdmissionDisabled === undefined) { - delete process.env.ORCA_GIT_ADMISSION_DISABLED - } else { - process.env.ORCA_GIT_ADMISSION_DISABLED = originalAdmissionDisabled - } - _resetGitAdmissionForTests() - await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) -}) - -function percentile(values: readonly number[], percentileValue: number): number { - if (values.length === 0) { - return 0 - } - const sorted = [...values].sort((a, b) => a - b) - return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * percentileValue) - 1)] -} - -async function liveChildCount(stateDir: string): Promise { - return (await readdir(stateDir)).filter((name) => name.endsWith('.live')).length -} - -async function createStubGit(root: string): Promise { +async function verifySpawnContention(): Promise { + delete process.env.ORCA_GIT_ADMISSION_DISABLED + const admissionEvents: GitAdmissionEvent[] = [] + _resetGitAdmissionForTests( + new GitAdmissionScheduler({ + now: () => 0, + onAdmissionEvent: (event) => admissionEvents.push(event) + }) + ) + const root = await createStormRoot('contention') + const stateDir = path.join(root, 'state') + const gateDir = path.join(root, 'gates') const binDir = path.join(root, 'bin') - await mkdir(binDir) - const stubPath = path.join(binDir, 'git') + await Promise.all([stateDir, gateDir, binDir].map((directory) => mkdir(directory))) + const backgroundIds = Array.from({ length: GENERAL_CAP + 4 }, (_, index) => `background-${index}`) + const headroomIds = Array.from({ length: GENERAL_HEADROOM }, (_, index) => `interactive-${index}`) + const queuedIds = ['interactive-queued-0', 'interactive-queued-1'] + const ids = [...backgroundIds, ...headroomIds, ...queuedIds] + const gates = await runProcess({ + program: 'mkfifo', + args: ids.map((id) => path.join(gateDir, id)) + }) + expect(gates.code, gates.stderr).toBe(0) + const stub = path.join(binDir, 'git') await writeFile( - stubPath, + stub, `#!/bin/sh set -eu live="$ORCA_STUB_STATE_DIR/$ORCA_STUB_ID.live" : > "$live" trap 'rm -f "$live"' EXIT HUP INT TERM -sleep "$(awk "BEGIN { print $ORCA_STUB_SLEEP_MS / 1000 }")" +IFS= read -r release < "$ORCA_STUB_GATE_DIR/$ORCA_STUB_ID" printf 'stub:%s\\n' "$*" ` ) - await chmod(stubPath, 0o755) - return binDir -} - -function setAdmissionMode(mode: StormMeasurement['mode']): void { - if (mode === 'disabled') { - process.env.ORCA_GIT_ADMISSION_DISABLED = '1' - } else { - delete process.env.ORCA_GIT_ADMISSION_DISABLED - } -} - -async function measureStorm(mode: StormMeasurement['mode']): Promise { - setAdmissionMode(mode) - const admissionEvents: GitAdmissionEvent[] = [] - const admissionClockStartedAt = performance.now() - _resetGitAdmissionForTests( - new GitAdmissionScheduler({ - now: () => Math.min(performance.now() - admissionClockStartedAt, GIT_ADMISSION_AGING_MS - 1), - onAdmissionEvent: (event) => admissionEvents.push(event) - }) - ) - const root = await mkdtemp(path.join(tmpdir(), `orca-git-storm-${mode}-`)) - tempRoots.push(root) - const stateDir = path.join(root, 'state') - await mkdir(stateDir) - const binDir = await createStubGit(root) - const repoDirs = await Promise.all( - Array.from({ length: 6 }, async (_, index) => { - const repoDir = path.join(root, `repo-${index}`) - await mkdir(repoDir) - return repoDir - }) - ) - const baseEnv = { ...process.env, PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ''}` } - const startedAt = performance.now() - const drifts: number[] = [] - let nextJankSample = performance.now() + 50 - const jankTimer = setInterval(() => { - const now = performance.now() - drifts.push(Math.max(0, now - nextJankSample)) - nextJankSample = now + 50 - }, 50) - let maxConcurrentChildren = 0 - const censusTimer = setInterval(() => { - void liveChildCount(stateDir).then((count) => { - maxConcurrentChildren = Math.max(maxConcurrentChildren, count) - }) - }, 5) - - const background = Array.from({ length: 60 }, (_, index) => - gitExecFileAsync(['status', '--porcelain=v2', `storm-${index}`], { - cwd: repoDirs[index % repoDirs.length], - env: { - ...baseEnv, - ORCA_STUB_ID: `background-${index}`, - ORCA_STUB_SLEEP_MS: index % 10 === 0 ? '5000' : '200', - ORCA_STUB_STATE_DIR: stateDir - }, - admissionTier: 'background' - }) - ) + await chmod(stub, 0o755) + const controller = new AbortController() + const commands = new Map>() const interactiveQueueSnapshots: InteractiveQueueSnapshot[] = [] - const interactiveLatencies = await Promise.all( - Array.from( - { length: 10 }, - (_, index) => - new Promise((resolve, reject) => { - setTimeout( - () => { - void (async () => { - const concurrentAtInjection = await liveChildCount(stateDir) - const commandStartedAt = performance.now() - const commandLabel = `interactive-${index}` - interactiveQueueSnapshots.push({ - commandLabel, - backgroundWaiterIds: _gitAdmissionSnapshotForTests() - .queuedWaiters.filter((waiter) => waiter.tier === 'background') - .map((waiter) => waiter.id) - }) - await gitExecFileAsync(['rev-parse', commandLabel], { - cwd: repoDirs[index % repoDirs.length], - env: { - ...baseEnv, - ORCA_STUB_ID: `interactive-${index}`, - ORCA_STUB_SLEEP_MS: String(Math.max(10, concurrentAtInjection * 12)), - ORCA_STUB_STATE_DIR: stateDir - }, - admissionTier: 'interactive' - }) - resolve(performance.now() - commandStartedAt) - })().catch(reject) - }, - 50 * (index + 1) - ) - }) - ) - ) - await Promise.all(background) - clearInterval(censusTimer) - clearInterval(jankTimer) - maxConcurrentChildren = Math.max(maxConcurrentChildren, await liveChildCount(stateDir)) - const totalWallMs = performance.now() - startedAt - return { - mode, - maxConcurrentChildren, - eventLoopMaxDriftMs: Math.max(0, ...drifts), - eventLoopP99DriftMs: percentile(drifts, 0.99), - interactiveP50Ms: percentile(interactiveLatencies, 0.5), - interactiveP95Ms: percentile(interactiveLatencies, 0.95), - totalWallMs, - admissionEvents, - interactiveQueueSnapshots - } -} - -function formatMeasurementTable(rows: readonly StormMeasurement[]): string { - const rounded = rows.map((row) => ({ - mode: row.mode, - maxConcurrentChildren: row.maxConcurrentChildren, - eventLoopMaxDriftMs: row.eventLoopMaxDriftMs.toFixed(1), - eventLoopP99DriftMs: row.eventLoopP99DriftMs.toFixed(1), - interactiveP50Ms: row.interactiveP50Ms.toFixed(1), - interactiveP95Ms: row.interactiveP95Ms.toFixed(1), - totalWallMs: row.totalWallMs.toFixed(1) - })) - return JSON.stringify(rounded) -} - -function assertAdmissionLedger(measurement: StormMeasurement): void { - const activeWaiters = new Set() - const grantSequenceByWaiter = new Map() - const grantByLabel = new Map() - - measurement.admissionEvents.forEach((event, index) => { - expect(event.sequence).toBe(index) - if (event.phase === 'grant') { - activeWaiters.add(event.waiterId) - grantSequenceByWaiter.set(event.waiterId, event.sequence) - const label = event.args.find((arg) => arg.startsWith('interactive-')) - if (label) { - grantByLabel.set(label, event) + const start = (id: string, tier: 'background' | 'interactive'): void => { + if (tier === 'interactive') { + interactiveQueueSnapshots.push({ + commandLabel: id, + backgroundWaiterIds: _gitAdmissionSnapshotForTests() + .queuedWaiters.filter((waiter) => waiter.tier === 'background') + .map((waiter) => waiter.id) + }) + } + const command = gitExecFileAsync([tier === 'background' ? 'status' : 'rev-parse', id], { + cwd: root, + admissionTier: tier, + signal: controller.signal, + env: { + ...process.env, + PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ''}`, + ORCA_STUB_ID: id, + ORCA_STUB_STATE_DIR: stateDir, + ORCA_STUB_GATE_DIR: gateDir } - } else { - expect(activeWaiters.delete(event.waiterId)).toBe(true) + }) + void command.catch(() => {}) + commands.set(id, command) + } + const release = async (id: string): Promise => { + await waitForLiveChild(stateDir, id, controller.signal) + await writeFile(path.join(gateDir, id), 'release\n') + await expect(commands.get(id)).resolves.toEqual({ + stdout: `stub:${id.startsWith('background-') ? 'status' : 'rev-parse'} ${id}\n`, + stderr: '' + }) + } + try { + backgroundIds.forEach((id) => start(id, 'background')) + expect(_gitAdmissionSnapshotForTests().queued).toBe(4) + headroomIds.forEach((id) => start(id, 'interactive')) + queuedIds.forEach((id) => start(id, 'interactive')) + expect(_gitAdmissionSnapshotForTests()).toMatchObject({ + queued: 6, + budgets: { general: { baseUsed: GENERAL_CAP, headroomUsed: GENERAL_HEADROOM } } + }) + await Promise.all( + [...backgroundIds.slice(0, GENERAL_CAP), ...headroomIds].map((id) => + waitForLiveChild(stateDir, id, controller.signal) + ) + ) + expect(await readdir(stateDir)).toHaveLength(GENERAL_CAP + GENERAL_HEADROOM) + for (const [index, id] of queuedIds.entries()) { + await release(backgroundIds[index]) + await waitForLiveChild(stateDir, id, controller.signal) } - expect(activeWaiters.size).toBeLessThanOrEqual(MAX_GIT_CHILDREN) - for (const budget of event.budgets) { - expect(budget.baseUsed).toBeLessThanOrEqual(budget.baseCapacity) - expect(budget.headroomUsed).toBeLessThanOrEqual(budget.headroomCapacity) + expect(_gitAdmissionSnapshotForTests().queuedWaiters.map((waiter) => waiter.tier)).toEqual([ + 'background', + 'background', + 'background', + 'background' + ]) + for (const id of [...headroomIds, ...queuedIds, ...backgroundIds.slice(queuedIds.length)]) { + await release(id) } - }) - expect(activeWaiters.size).toBe(0) - - for (const snapshot of measurement.interactiveQueueSnapshots) { - const interactiveGrant = grantByLabel.get(snapshot.commandLabel) - expect(interactiveGrant).toBeDefined() - const preceded = snapshot.backgroundWaiterIds.filter( - (waiterId) => interactiveGrant!.sequence < (grantSequenceByWaiter.get(waiterId) ?? Infinity) - ).length - expect(preceded).toBeGreaterThanOrEqual(Math.ceil(snapshot.backgroundWaiterIds.length * 0.9)) + expect(admissionEvents.filter((event) => event.phase === 'grant')).toHaveLength(ids.length) + expect(admissionEvents.filter((event) => event.phase === 'release')).toHaveLength(ids.length) + assertAdmissionLedger({ admissionEvents, interactiveQueueSnapshots }) + expect(_gitAdmissionSnapshotForTests()).toMatchObject({ + queued: 0, + budgets: { general: { baseUsed: 0, headroomUsed: 0 } } + }) + expect(await readdir(stateDir)).toEqual([]) + } finally { + controller.abort() + await Promise.allSettled(commands.values()) } } describe.skipIf(process.platform === 'win32')('git admission storm measurement', () => { - it('reports bounded-concurrency before and after measurements', async () => { - const disabled = await measureStorm('disabled') - const enabled = await measureStorm('enabled') - console.info(`GIT_ADMISSION_STORM_MEASUREMENT=${formatMeasurementTable([disabled, enabled])}`) - assertAdmissionLedger(enabled) - }) + it( + 'bounds real children and drains interactive work before the background backlog', + verifySpawnContention + ) - // Output parity lives in git-admission-output-parity.test.ts: it needs real git, - // not the PATH stub, so it runs on win32 too and cannot share this file's gate. + it.runIf(process.env.ORCA_GIT_ADMISSION_STORM_MEASUREMENT === '1')( + 'reports bounded-concurrency before and after measurements', + async () => { + const disabled = await measureStorm('disabled') + const enabled = await measureStorm('enabled') + console.info(`GIT_ADMISSION_STORM_MEASUREMENT=${formatMeasurementTable([disabled, enabled])}`) + assertAdmissionLedger(enabled) + } + ) + // Real-git output parity remains in git-admission-output-parity.test.ts, including Windows. }) diff --git a/src/main/git/command-runner/git-admission-storm-test-fixture.ts b/src/main/git/command-runner/git-admission-storm-test-fixture.ts new file mode 100644 index 00000000000..4ee1219f027 --- /dev/null +++ b/src/main/git/command-runner/git-admission-storm-test-fixture.ts @@ -0,0 +1,245 @@ +/** + * POSIX-only measurement: a PATH-injected git fixture exercises the real spawn path. + * Timing distributions are reported for field comparison; CI assertions stay structural. + */ +import { chmod, mkdtemp, mkdir, readdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, expect } from 'vitest' +import { gitExecFileAsync } from './git-exec-file' +import { + GIT_ADMISSION_AGING_MS, + GitAdmissionScheduler, + MAX_GIT_CHILDREN, + _gitAdmissionSnapshotForTests, + _resetGitAdmissionForTests, + type GitAdmissionEvent +} from './git-subprocess-admission' + +type StormMeasurement = { + mode: 'disabled' | 'enabled' + maxConcurrentChildren: number + eventLoopMaxDriftMs: number + eventLoopP99DriftMs: number + interactiveP50Ms: number + interactiveP95Ms: number + totalWallMs: number + admissionEvents: GitAdmissionEvent[] + interactiveQueueSnapshots: InteractiveQueueSnapshot[] +} + +export type InteractiveQueueSnapshot = { + commandLabel: string + backgroundWaiterIds: number[] +} + +const tempRoots: string[] = [] +const originalAdmissionDisabled = process.env.ORCA_GIT_ADMISSION_DISABLED + +afterEach(async () => { + if (originalAdmissionDisabled === undefined) { + delete process.env.ORCA_GIT_ADMISSION_DISABLED + } else { + process.env.ORCA_GIT_ADMISSION_DISABLED = originalAdmissionDisabled + } + _resetGitAdmissionForTests() + await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +export async function createStormRoot(label: string): Promise { + const root = await mkdtemp(path.join(tmpdir(), `orca-git-storm-${label}-`)) + tempRoots.push(root) + return root +} + +function percentile(values: readonly number[], percentileValue: number): number { + if (values.length === 0) { + return 0 + } + const sorted = [...values].sort((a, b) => a - b) + return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * percentileValue) - 1)] +} + +async function liveChildCount(stateDir: string): Promise { + return (await readdir(stateDir)).filter((name) => name.endsWith('.live')).length +} + +async function createStubGit(root: string): Promise { + const binDir = path.join(root, 'bin') + await mkdir(binDir) + const stubPath = path.join(binDir, 'git') + await writeFile( + stubPath, + `#!/bin/sh +set -eu +live="$ORCA_STUB_STATE_DIR/$ORCA_STUB_ID.live" +: > "$live" +trap 'rm -f "$live"' EXIT HUP INT TERM +sleep "$(awk "BEGIN { print $ORCA_STUB_SLEEP_MS / 1000 }")" +printf 'stub:%s\\n' "$*" +` + ) + await chmod(stubPath, 0o755) + return binDir +} + +function setAdmissionMode(mode: StormMeasurement['mode']): void { + if (mode === 'disabled') { + process.env.ORCA_GIT_ADMISSION_DISABLED = '1' + } else { + delete process.env.ORCA_GIT_ADMISSION_DISABLED + } +} + +export async function measureStorm(mode: StormMeasurement['mode']): Promise { + setAdmissionMode(mode) + const admissionEvents: GitAdmissionEvent[] = [] + const admissionClockStartedAt = performance.now() + _resetGitAdmissionForTests( + new GitAdmissionScheduler({ + now: () => Math.min(performance.now() - admissionClockStartedAt, GIT_ADMISSION_AGING_MS - 1), + onAdmissionEvent: (event) => admissionEvents.push(event) + }) + ) + const root = await createStormRoot(mode) + const stateDir = path.join(root, 'state') + await mkdir(stateDir) + const binDir = await createStubGit(root) + const repoDirs = await Promise.all( + Array.from({ length: 6 }, async (_, index) => { + const repoDir = path.join(root, `repo-${index}`) + await mkdir(repoDir) + return repoDir + }) + ) + const baseEnv = { ...process.env, PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ''}` } + const startedAt = performance.now() + const drifts: number[] = [] + let nextJankSample = performance.now() + 50 + const jankTimer = setInterval(() => { + const now = performance.now() + drifts.push(Math.max(0, now - nextJankSample)) + nextJankSample = now + 50 + }, 50) + let maxConcurrentChildren = 0 + const censusTimer = setInterval(() => { + void liveChildCount(stateDir).then((count) => { + maxConcurrentChildren = Math.max(maxConcurrentChildren, count) + }) + }, 5) + + const background = Array.from({ length: 60 }, (_, index) => + gitExecFileAsync(['status', '--porcelain=v2', `storm-${index}`], { + cwd: repoDirs[index % repoDirs.length], + env: { + ...baseEnv, + ORCA_STUB_ID: `background-${index}`, + ORCA_STUB_SLEEP_MS: index % 10 === 0 ? '5000' : '200', + ORCA_STUB_STATE_DIR: stateDir + }, + admissionTier: 'background' + }) + ) + const interactiveQueueSnapshots: InteractiveQueueSnapshot[] = [] + const interactiveLatencies = await Promise.all( + Array.from( + { length: 10 }, + (_, index) => + new Promise((resolve, reject) => { + setTimeout( + () => { + void (async () => { + const concurrentAtInjection = await liveChildCount(stateDir) + const commandStartedAt = performance.now() + const commandLabel = `interactive-${index}` + interactiveQueueSnapshots.push({ + commandLabel, + backgroundWaiterIds: _gitAdmissionSnapshotForTests() + .queuedWaiters.filter((waiter) => waiter.tier === 'background') + .map((waiter) => waiter.id) + }) + await gitExecFileAsync(['rev-parse', commandLabel], { + cwd: repoDirs[index % repoDirs.length], + env: { + ...baseEnv, + ORCA_STUB_ID: `interactive-${index}`, + ORCA_STUB_SLEEP_MS: String(Math.max(10, concurrentAtInjection * 12)), + ORCA_STUB_STATE_DIR: stateDir + }, + admissionTier: 'interactive' + }) + resolve(performance.now() - commandStartedAt) + })().catch(reject) + }, + 50 * (index + 1) + ) + }) + ) + ) + await Promise.all(background) + clearInterval(censusTimer) + clearInterval(jankTimer) + maxConcurrentChildren = Math.max(maxConcurrentChildren, await liveChildCount(stateDir)) + const totalWallMs = performance.now() - startedAt + return { + mode, + maxConcurrentChildren, + eventLoopMaxDriftMs: Math.max(0, ...drifts), + eventLoopP99DriftMs: percentile(drifts, 0.99), + interactiveP50Ms: percentile(interactiveLatencies, 0.5), + interactiveP95Ms: percentile(interactiveLatencies, 0.95), + totalWallMs, + admissionEvents, + interactiveQueueSnapshots + } +} + +export function formatMeasurementTable(rows: readonly StormMeasurement[]): string { + const rounded = rows.map((row) => ({ + mode: row.mode, + maxConcurrentChildren: row.maxConcurrentChildren, + eventLoopMaxDriftMs: row.eventLoopMaxDriftMs.toFixed(1), + eventLoopP99DriftMs: row.eventLoopP99DriftMs.toFixed(1), + interactiveP50Ms: row.interactiveP50Ms.toFixed(1), + interactiveP95Ms: row.interactiveP95Ms.toFixed(1), + totalWallMs: row.totalWallMs.toFixed(1) + })) + return JSON.stringify(rounded) +} + +export function assertAdmissionLedger( + measurement: Pick +): void { + const activeWaiters = new Set() + const grantSequenceByWaiter = new Map() + const grantByLabel = new Map() + + measurement.admissionEvents.forEach((event, index) => { + expect(event.sequence).toBe(index) + if (event.phase === 'grant') { + activeWaiters.add(event.waiterId) + grantSequenceByWaiter.set(event.waiterId, event.sequence) + const label = event.args.find((arg) => arg.startsWith('interactive-')) + if (label) { + grantByLabel.set(label, event) + } + } else { + expect(activeWaiters.delete(event.waiterId)).toBe(true) + } + expect(activeWaiters.size).toBeLessThanOrEqual(MAX_GIT_CHILDREN) + for (const budget of event.budgets) { + expect(budget.baseUsed).toBeLessThanOrEqual(budget.baseCapacity) + expect(budget.headroomUsed).toBeLessThanOrEqual(budget.headroomCapacity) + } + }) + expect(activeWaiters.size).toBe(0) + + for (const snapshot of measurement.interactiveQueueSnapshots) { + const interactiveGrant = grantByLabel.get(snapshot.commandLabel) + expect(interactiveGrant).toBeDefined() + const preceded = snapshot.backgroundWaiterIds.filter( + (waiterId) => interactiveGrant!.sequence < (grantSequenceByWaiter.get(waiterId) ?? Infinity) + ).length + expect(preceded).toBeGreaterThanOrEqual(Math.ceil(snapshot.backgroundWaiterIds.length * 0.9)) + } +} diff --git a/src/main/runtime/antigravity-screen-readiness-transcripts.test.ts b/src/main/runtime/antigravity-screen-readiness-transcripts.test.ts index 1df9ccefdad..1735c102151 100644 --- a/src/main/runtime/antigravity-screen-readiness-transcripts.test.ts +++ b/src/main/runtime/antigravity-screen-readiness-transcripts.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it, vi } from 'vitest' -import { createTranscriptPane, TRANSCRIPT_PANE_PTY_ID } from './agent-transcript-pane-test-harness' +import { + createTranscriptPane, + TRANSCRIPT_PANE_PTY_ID, + waitForTranscriptIdle +} from './agent-transcript-pane-test-harness' import { finalReplayFrame, readRuntimeFixture, @@ -112,9 +116,8 @@ describe('Antigravity 1.2.14 readiness from captured bytes', () => { data: `${String.fromCharCode(27)}]0;agy${String.fromCharCode(7)}${readRuntimeFixture('antigravity-1-2-14-model-picker')}`, size: { cols: 120, rows: 40 } }) - await expect( - runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 5_000 }) - ).rejects.toThrow(/timeout/) + await runtime.readTerminal(handle, { screen: true }) + await expect(waitForTranscriptIdle({ runtime, handle }, 5_000)).rejects.toThrow(/timeout/) }, 15_000) // Why this recording: only the screen reads it ready, so settling proves the grid is trusted. @@ -133,11 +136,8 @@ describe('Antigravity 1.2.14 readiness from captured bytes', () => { runtime.onExternalPtyResize(TRANSCRIPT_PANE_PTY_ID, cols, rows) } const settles = async () => - ( - await runtime - .waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 5_000 }) - .catch(() => ({ satisfied: false })) - ).satisfied + (await waitForTranscriptIdle({ runtime, handle }, 5_000).catch(() => ({ satisfied: false }))) + .satisfied options.size = { cols: 120, rows: 40 } runtime.reflowHeadlessTerminalToPtyGrid(TRANSCRIPT_PANE_PTY_ID, 120, 40) await runtime.readTerminal(handle, { screen: true }) diff --git a/src/shared/agent-status-store-copying-oracle.test-fixture.ts b/src/shared/agent-status-store-copying-oracle.test-fixture.ts index 06446079c56..a71f009b40b 100644 --- a/src/shared/agent-status-store-copying-oracle.test-fixture.ts +++ b/src/shared/agent-status-store-copying-oracle.test-fixture.ts @@ -10,12 +10,15 @@ import { import { deserializeAgentChildWorkBindingKey } from './agent-status-child-work-binding' import { agentChildWorkBelongsTo, type AgentChildWorkRecord } from './agent-status-child-work' import { parseAgentStatusStoreMutation } from './agent-status-store-codec' -import type { AgentStatusStoreSnapshot } from './agent-status-store-contract' +import { + AGENT_STATUS_STORE_SNAPSHOT_VERSION, + type AgentStatusStoreSnapshot +} from './agent-status-store-contract' import { applyAgentStatusStoreMutationSteps } from './agent-status-store-mutation' import { cloneAgentStatusStoreState, createEmptyAgentStatusStoreState, - snapshotFromAgentStatusStoreState, + deepFreezeAgentStatusStoreValue, validateAgentStatusStoreState, type AgentStatusStoreState } from './agent-status-store-state' @@ -59,7 +62,7 @@ function applyByCopy(current: AgentStatusStoreState, value: unknown): AgentStatu export type CopyingAgentStatusStoreOracle = { applyMutation(mutation: unknown): boolean getSnapshot(): AgentStatusStoreSnapshot - /** The oracle's own state, for checking every invariant after each step. */ + /** The oracle's own maps, for fixture coverage and diagnostics. */ state(): AgentStatusStoreState getChildren(subject: AgentStatusSubject): AgentChildWorkRecord[] getAliasesForChild(childWorkId: string): AgentChildWorkAliasRecord[] @@ -76,7 +79,18 @@ export function createCopyingAgentStatusStoreOracle(epoch: string): CopyingAgent } return next !== null }, - getSnapshot: () => snapshotFromAgentStatusStoreState(current), + // Project the validated maps independently of the production snapshot parser. + getSnapshot: () => + deepFreezeAgentStatusStoreValue({ + version: AGENT_STATUS_STORE_SNAPSHOT_VERSION, + epoch: current.epoch, + revision: current.revision, + parents: [...current.parents.values()], + children: [...current.children.values()], + aliases: [...current.aliases.values()], + facts: [...current.facts.values()], + tombstones: [...current.tombstones.values()] + }), state: () => current, getChildren: (subject) => [...current.children.values()].filter((child) => agentChildWorkBelongsTo(child, subject)), diff --git a/src/shared/agent-status-store-in-place.test.ts b/src/shared/agent-status-store-in-place.test.ts index efc999aef56..f4676492ebb 100644 --- a/src/shared/agent-status-store-in-place.test.ts +++ b/src/shared/agent-status-store-in-place.test.ts @@ -8,16 +8,17 @@ import type { AgentChildWorkInput } from './agent-status-child-work' import { createAgentStatusStore } from './agent-status-store' import { parseAgentStatusStoreMutation } from './agent-status-store-codec' import { commitAgentStatusStoreMutation } from './agent-status-store-commit' -import type { AgentStatusStoreMutation } from './agent-status-store-contract' +import { + AGENT_STATUS_STORE_LIMITS, + AGENT_STATUS_STORE_TOMBSTONE_RETENTION_REVISIONS, + type AgentStatusStoreMutation +} from './agent-status-store-contract' import { createCopyingAgentStatusStoreOracle } from './agent-status-store-copying-oracle.test-fixture' import { indexAgentStatusStoreState, type AgentStatusStoreIndexes } from './agent-status-store-indexes' -import { - createEmptyAgentStatusStoreState, - validateAgentStatusStoreState -} from './agent-status-store-state' +import { createEmptyAgentStatusStoreState } from './agent-status-store-state' import { makeStructuredAgentStatusSubject, serializeAgentStatusSubject, @@ -163,7 +164,6 @@ describe('AgentStatusStore applied in place', () => { accepted += 1 expect(replica.applyTransportEnvelope(envelope)).toBe(true) } - expect(validateAgentStatusStoreState(oracle.state())).toBe(true) expect(JSON.stringify(store.getSnapshot())).toBe(JSON.stringify(oracle.getSnapshot())) for (const parent of parents) { expect(store.getChildren(parent)).toEqual(oracle.getChildren(parent)) @@ -206,18 +206,51 @@ describe('AgentStatusStore applied in place', () => { 60_000 ) - it('compacts tombstones exactly as the copying store does past the retention window', () => { + it('retires tombstones exactly at the retention revision boundary', () => { const store = createAgentStatusStore({ epoch: 'epoch-a', mode: 'authority' }) const oracle = createCopyingAgentStatusStoreOracle('epoch-a') const parent = parents[0]! - for (let step = 0; step < 5_000; step += 1) { - const mutation: AgentStatusStoreMutation = - step % 2 === 0 - ? { parent: { subject: parent }, facts: [{ subject: parent, key: `k${step}`, value: 1 }] } - : { removeFacts: [{ subject: parent, key: `k${step - 1}` }] } + for (const childWorkId of ['oldest', 'newer']) { + const mutation = { removeChildren: [childWorkId] } expect(store.applyMutation(mutation) !== null).toBe(oracle.applyMutation(mutation)) } - expect(oracle.getSnapshot().tombstones.length).toBeGreaterThan(1_000) + const advance = { parent: { subject: parent } } + for ( + let revision = 3; + revision <= AGENT_STATUS_STORE_TOMBSTONE_RETENTION_REVISIONS; + revision += 1 + ) { + expect(store.applyMutation(advance) !== null).toBe(oracle.applyMutation(advance)) + } + expect(store.getSnapshot().revision).toBe(AGENT_STATUS_STORE_TOMBSTONE_RETENTION_REVISIONS) + expect(store.getSnapshot().tombstones).toEqual([ + { entity: 'child', key: 'oldest', revision: 1 }, + { entity: 'child', key: 'newer', revision: 2 } + ]) + expect(JSON.stringify(store.getSnapshot())).toBe(JSON.stringify(oracle.getSnapshot())) + + expect(store.applyMutation(advance) !== null).toBe(oracle.applyMutation(advance)) + expect(store.getSnapshot().tombstones).toEqual([{ entity: 'child', key: 'newer', revision: 2 }]) + expect(JSON.stringify(store.getSnapshot())).toBe(JSON.stringify(oracle.getSnapshot())) + + expect(store.applyMutation(advance) !== null).toBe(oracle.applyMutation(advance)) + expect(store.getSnapshot().tombstones).toEqual([]) + expect(JSON.stringify(store.getSnapshot())).toBe(JSON.stringify(oracle.getSnapshot())) + }, 60_000) + + it('keeps the newest tombstones in order when their count overflows', () => { + const store = createAgentStatusStore({ epoch: 'epoch-a', mode: 'authority' }) + const oracle = createCopyingAgentStatusStoreOracle('epoch-a') + const childWorkIds = Array.from( + { length: AGENT_STATUS_STORE_LIMITS.tombstones }, + (_, index) => `removed-${index}` + ) + for (const mutation of [{ removeChildren: ['oldest'] }, { removeChildren: childWorkIds }]) { + expect(store.applyMutation(mutation) !== null).toBe(oracle.applyMutation(mutation)) + } + expect(store.getSnapshot().tombstones).toEqual( + childWorkIds.map((key) => ({ entity: 'child', key, revision: 2 })) + ) expect(JSON.stringify(store.getSnapshot())).toBe(JSON.stringify(oracle.getSnapshot())) }, 60_000) }) From 9207aef01d6f5f0486696d22e6af088c22bd559c Mon Sep 17 00:00:00 2001 From: mmarabel <166927047+mmarabel@users.noreply.github.com> Date: Sun, 4 Oct 2026 08:15:41 +0200 Subject: [PATCH 39/67] Add an optional shortcut to toggle child workspaces (#24165) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a user-assignable shortcut for the existing child-workspace chip action. It stays unassigned by default on macOS, Linux and Windows. Final-source rendered checks cover Settings recording/reset, guards, scroll preservation and restart. Fixes #24163 Continues mmarabel’s original contribution in this PR. Issue #24163 has no sibling implementation PR. Existing bot findings are fixed, withdrawn or addressed in the PR review. Co-authored-by: mmarabel <166927047+mmarabel@users.noreply.github.com> --- docs/site/content/docs/model/worktrees.mdx | 2 + docs/site/content/docs/settings.mdx | 1 + ...-command-handlers-child-workspaces.test.ts | 163 ++++++ .../src/app-shell/app-command-handlers.ts | 36 ++ .../src/app-shell/use-global-keybindings.ts | 6 + .../child-workspaces-toggle-target.test.ts | 158 ++++++ .../sidebar/child-workspaces-toggle-target.ts | 105 ++++ ...child-workspaces-toggle-visibility.test.ts | 147 ++++++ .../rendered-sidebar-worktree-order.ts | 17 +- .../components/sidebar/visible-worktrees.ts | 7 + .../sidebar/worktree-lineage-projection.ts | 28 + .../navigation/pending-reveal-inputs.ts | 28 +- .../worktree-list/navigation/use-selection.ts | 20 +- .../keybindings-unassigned-actions.test.ts | 30 ++ src/shared/keybindings/definitions-core-4.ts | 22 + src/shared/keybindings/types.ts | 1 + .../e2e/worktree-lineage-scroll-readiness.ts | 142 +++++ tests/e2e/worktree-lineage-state.ts | 8 +- .../worktree-lineage-toggle-dialog.spec.ts | 84 +++ .../worktree-lineage-toggle-scroll.spec.ts | 103 ++++ .../worktree-lineage-toggle-shortcut.spec.ts | 485 ++++++++++++++++++ 21 files changed, 1564 insertions(+), 29 deletions(-) create mode 100644 src/renderer/src/app-shell/app-command-handlers-child-workspaces.test.ts create mode 100644 src/renderer/src/components/sidebar/child-workspaces-toggle-target.test.ts create mode 100644 src/renderer/src/components/sidebar/child-workspaces-toggle-target.ts create mode 100644 src/renderer/src/components/sidebar/child-workspaces-toggle-visibility.test.ts create mode 100644 tests/e2e/worktree-lineage-scroll-readiness.ts create mode 100644 tests/e2e/worktree-lineage-toggle-dialog.spec.ts create mode 100644 tests/e2e/worktree-lineage-toggle-scroll.spec.ts create mode 100644 tests/e2e/worktree-lineage-toggle-shortcut.spec.ts diff --git a/docs/site/content/docs/model/worktrees.mdx b/docs/site/content/docs/model/worktrees.mdx index c90cf451351..52e432c4b6c 100644 --- a/docs/site/content/docs/model/worktrees.mdx +++ b/docs/site/content/docs/model/worktrees.mdx @@ -114,6 +114,8 @@ You can pin a worktree to the top of its project to keep long-running work in vi When a worktree has nested child worktrees (for example from orchestration or `worktree create` with a parent), the context menu can also offer **Sleep with Descendants (N)** and **Delete with Descendants…**. Sleep with descendants closes active panels on the selected workspace and every validated nested child in the same project, repo, and host — only workspaces with live terminals or browser tabs are targeted for sleep. Delete with descendants makes the existing cascading delete explicit. Stale lineage links, cycles, and children across host or repo boundaries are excluded. +A parent worktree shows its nested children under an **N children** chip; click the chip to show or hide them. To do the same from the keyboard, assign **Toggle Child Workspaces** under [Settings → Shortcuts](/docs/settings). It acts on the worktree under the pointer, or on the active worktree when nothing is hovered, and does exactly what that card's chip does. On a card with no chip of its own (it has no children, or a sidebar filter hides all of them), it uses the chip on the card's parent instead, and does nothing if there is none. + Double-click a worktree title in the sidebar to rename it inline. Double-clicking elsewhere on the card still opens the full edit dialog. In **Edit Worktree Details**, the issue field accepts **GitHub** or **Linear** (chip on the field; paste a URL to auto-detect). One linked issue per workspace — changing provider or clearing the field unlinks the previous one. For SSH workspaces whose host is disconnected, the card title row can show an inline reconnect control (see [SSH worktrees](/docs/ssh)). ## Resource Manager cleanup diff --git a/docs/site/content/docs/settings.mdx b/docs/site/content/docs/settings.mdx index 1bf6dc79434..4fac0564d48 100644 --- a/docs/site/content/docs/settings.mdx +++ b/docs/site/content/docs/settings.mdx @@ -133,6 +133,7 @@ Settings are grouped into panes. Everything here is searchable with `Cmd-,` then - Full keymap — every binding remappable. - Toggle Sleeping Workspaces ships unbound; assign it here if you want a direct shortcut for the sidebar sleeping-worktree filter. +- **Toggle Child Workspaces** ships unbound; assign it here to show or hide a parent worktree's nested children, the same as clicking its **N children** chip. It targets the hovered worktree, or the active one when nothing is hovered. - **Toggle Workspace Board** ships unbound; assign it here to open or close the Workspace Board with one shortcut. Existing bindings for `workspace.openBoard` continue to work. - Close all editor tabs defaults to `Cmd+Option+W` on macOS and `Ctrl+Alt+W` on Windows / Linux. - **Tab navigation defaults (new installs):** next/previous tab **across all types** is `Cmd+Shift+]` / `Cmd+Shift+[` (Ctrl on Linux/Windows). Same-type next/previous is `Cmd+Option+]` / `Cmd+Option+[`. Previous recent tab is `Ctrl+Tab`. Existing installs keep customized overrides under `~/.orca/keybindings.json`. diff --git a/src/renderer/src/app-shell/app-command-handlers-child-workspaces.test.ts b/src/renderer/src/app-shell/app-command-handlers-child-workspaces.test.ts new file mode 100644 index 00000000000..cfcfea4fb22 --- /dev/null +++ b/src/renderer/src/app-shell/app-command-handlers-child-workspaces.test.ts @@ -0,0 +1,163 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AppShortcutState, ShortcutDispatchInput } from './app-command-handlers' + +const mocks = vi.hoisted(() => { + const target: { groupKey: string | null } = { groupKey: 'lineage:parent' } + return { + target, + floatingFocused: false, + visibleModal: false, + requestScrollAnchor: vi.fn(), + notifyTerminalCapture: vi.fn(), + store: { + activeModal: 'none', + collapsedGroups: new Set(), + setSidebarOpen: vi.fn(), + toggleCollapsedGroup: vi.fn() + } + } +}) + +vi.mock('../store', () => ({ + useAppStore: Object.assign(vi.fn(), { getState: () => mocks.store }) +})) + +vi.mock('../components/sidebar/child-workspaces-toggle-target', () => ({ + getRenderedLineageChipKeys: () => new Set(), + resolveChildWorkspacesToggleGroupKey: () => mocks.target.groupKey +})) + +vi.mock('@/hooks/requestVirtualizedScrollAnchorRecord', () => ({ + requestVirtualizedScrollAnchorRecord: mocks.requestScrollAnchor +})) + +vi.mock('@/lib/floating-workspace-terminal-actions', () => ({ + isFloatingWorkspacePanelFocused: () => mocks.floatingFocused +})) + +vi.mock('@/lib/visible-overlay', () => ({ + hasVisibleOverlay: () => mocks.visibleModal +})) + +vi.mock('@/lib/terminal-shortcut-capture-notification', () => ({ + showTerminalShortcutCaptureNotification: mocks.notifyTerminalCapture +})) + +import { createAppCommandHandlers } from './app-command-handlers' + +function shortcutState(): AppShortcutState { + return { + activeView: 'terminal', + activeWorktreeId: 'parent', + actions: { + toggleSidebar: vi.fn(), + toggleRightSidebar: vi.fn(), + setRightSidebarOpen: vi.fn(), + setRightSidebarTab: vi.fn(), + showRightSidebarFiles: vi.fn(), + showRightSidebarSearch: vi.fn(), + openDiffNotesSendMenuForActiveWorktree: vi.fn() + }, + creationLayoutActive: false, + floatingTerminalEnabled: false, + floatingTerminalOpen: false, + floatingVisibleTabCount: 0, + keybindings: {}, + openFloatingWorkspaceMaximized: vi.fn(), + pluginCommands: [], + setFloatingTerminalOpen: vi.fn(), + terminalShortcutPolicy: 'orca-first', + workspaceChromeActive: true + } +} + +function shortcutInput(): ShortcutDispatchInput { + return { target: null, defaultPrevented: false, preventDefault: vi.fn() } +} + +function runToggle(input: ShortcutDispatchInput): boolean | undefined { + return createAppCommandHandlers(shortcutState(), input).get('sidebar.childWorkspaces.toggle')?.() +} + +describe('child workspaces toggle app command', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.target.groupKey = 'lineage:parent' + mocks.floatingFocused = false + mocks.visibleModal = false + mocks.store.activeModal = 'none' + mocks.store.collapsedGroups = new Set() + }) + + it('hides the target’s children without forcing the sidebar open', () => { + const input = shortcutInput() + + expect(runToggle(input)).toBe(true) + expect(input.preventDefault).toHaveBeenCalledOnce() + expect(mocks.requestScrollAnchor).toHaveBeenCalledWith('[data-worktree-sidebar]') + expect(mocks.store.toggleCollapsedGroup).toHaveBeenCalledWith('lineage:parent') + expect(mocks.store.setSidebarOpen).not.toHaveBeenCalled() + expect(mocks.requestScrollAnchor.mock.invocationCallOrder[0]).toBeLessThan( + mocks.store.toggleCollapsedGroup.mock.invocationCallOrder[0] ?? 0 + ) + }) + + it('opens the sidebar when showing hidden children', () => { + mocks.store.collapsedGroups = new Set(['lineage:parent']) + + expect(runToggle(shortcutInput())).toBe(true) + expect(mocks.store.toggleCollapsedGroup).toHaveBeenCalledWith('lineage:parent') + expect(mocks.store.setSidebarOpen).toHaveBeenCalledWith(true) + }) + + it('lets the chord through when the target is in no lineage', () => { + mocks.target.groupKey = null + const input = shortcutInput() + + expect(runToggle(input)).toBe(false) + expect(input.preventDefault).not.toHaveBeenCalled() + expect(mocks.store.toggleCollapsedGroup).not.toHaveBeenCalled() + expect(mocks.requestScrollAnchor).not.toHaveBeenCalled() + }) + + it('lets a modal keep its keyboard input', () => { + mocks.store.activeModal = 'delete-worktree' + const input = shortcutInput() + + expect(runToggle(input)).toBe(false) + expect(input.preventDefault).not.toHaveBeenCalled() + expect(mocks.store.toggleCollapsedGroup).not.toHaveBeenCalled() + }) + + it('lets the focused floating workspace keep its keyboard input', () => { + mocks.floatingFocused = true + const input = shortcutInput() + + expect(runToggle(input)).toBe(false) + expect(input.preventDefault).not.toHaveBeenCalled() + expect(mocks.store.toggleCollapsedGroup).not.toHaveBeenCalled() + }) + + it('leaves locally controlled modal input and persistence untouched', () => { + mocks.visibleModal = true + const input = shortcutInput() + + expect(runToggle(input)).toBe(false) + expect(input.preventDefault).not.toHaveBeenCalled() + expect(mocks.store.toggleCollapsedGroup).not.toHaveBeenCalled() + expect(mocks.requestScrollAnchor).not.toHaveBeenCalled() + expect(mocks.store.setSidebarOpen).not.toHaveBeenCalled() + }) + + it('reports a claimed terminal shortcut through the existing notification policy', () => { + const input = shortcutInput() + expect( + createAppCommandHandlers(shortcutState(), input, 'terminal').get( + 'sidebar.childWorkspaces.toggle' + )?.() + ).toBe(true) + expect(mocks.notifyTerminalCapture).toHaveBeenCalledWith( + expect.objectContaining({ actionId: 'sidebar.childWorkspaces.toggle' }) + ) + }) +}) diff --git a/src/renderer/src/app-shell/app-command-handlers.ts b/src/renderer/src/app-shell/app-command-handlers.ts index 1a7264efb5e..a91819bfe8b 100644 --- a/src/renderer/src/app-shell/app-command-handlers.ts +++ b/src/renderer/src/app-shell/app-command-handlers.ts @@ -1,10 +1,16 @@ import { useShallow } from 'zustand/react/shallow' import { canShowRightSidebarForView } from '@/lib/right-sidebar-visibility' import { isFloatingWorkspacePanelFocused } from '@/lib/floating-workspace-terminal-actions' +import { hasVisibleOverlay } from '@/lib/visible-overlay' import { requestScrollToCurrentWorkspaceRevealAndRename } from '@/lib/scroll-to-current-workspace-status' +import { requestVirtualizedScrollAnchorRecord } from '@/hooks/requestVirtualizedScrollAnchorRecord' import { showTerminalShortcutCaptureNotification } from '@/lib/terminal-shortcut-capture-notification' import { shouldShowWorktreeHistoryControls } from '../lib/titlebar-worktree-history-controls' import { TOGGLE_WORKSPACE_BOARD_EVENT } from '../components/sidebar/useWorkspaceBoardPanel' +import { + getRenderedLineageChipKeys, + resolveChildWorkspacesToggleGroupKey +} from '../components/sidebar/child-workspaces-toggle-target' import { requestTerminalTabRename } from '../components/tab-bar/terminal-tab-rename-request' import { deleteHoveredWorkspaceImmediately, @@ -178,6 +184,36 @@ export function createAppCommandHandlers( } }) ], + [ + 'sidebar.childWorkspaces.toggle', + () => { + const store = useAppStore.getState() + // Locally controlled dialogs do not set activeModal. + if ( + store.activeModal !== 'none' || + floatingWorkspaceFocused || + hasVisibleOverlay({ ignoreMatches: '[role="listbox"], [role="menu"]' }) + ) { + return false + } + const groupKey = resolveChildWorkspacesToggleGroupKey( + store, + getRenderedLineageChipKeys(store) + ) + if (!groupKey) { + return false + } + return claim('sidebar.childWorkspaces.toggle', () => { + const expanding = store.collapsedGroups.has(groupKey) + // Why: same scroll anchoring as the chip click, so the viewport does not jump. + requestVirtualizedScrollAnchorRecord('[data-worktree-sidebar]') + store.toggleCollapsedGroup(groupKey) + if (expanding) { + store.setSidebarOpen(true) + } + }) + } + ], [ 'floatingWorkspace.maximize', () => { diff --git a/src/renderer/src/app-shell/use-global-keybindings.ts b/src/renderer/src/app-shell/use-global-keybindings.ts index 78051a58ed5..a2c3afeef68 100644 --- a/src/renderer/src/app-shell/use-global-keybindings.ts +++ b/src/renderer/src/app-shell/use-global-keybindings.ts @@ -239,6 +239,12 @@ export function useGlobalKeybindings(args: { if (matchShortcut('workspace.delete') && handlers.get('workspace.delete')?.()) { return } + if ( + matchShortcut('sidebar.childWorkspaces.toggle') && + handlers.get('sidebar.childWorkspaces.toggle')?.() + ) { + return + } for (const actionId of PLUGIN_COMMAND_ALIAS_ACTION_IDS) { if (matchShortcut(actionId) && handlers.get(actionId)?.()) { return diff --git a/src/renderer/src/components/sidebar/child-workspaces-toggle-target.test.ts b/src/renderer/src/components/sidebar/child-workspaces-toggle-target.test.ts new file mode 100644 index 00000000000..43eec8e19f9 --- /dev/null +++ b/src/renderer/src/components/sidebar/child-workspaces-toggle-target.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it } from 'vitest' +import { getWorktreeHostIdentity } from '../../../../shared/worktree/host-qualified-identity' +import type { WorktreeLineage } from '../../../../shared/worktree/lineage-types' +import type { Worktree } from '../../../../shared/worktree/types' +import { folderWorkspaceKey } from '../../../../shared/workspace-scope' +import { resolveChildWorkspacesToggleGroupKey } from './child-workspaces-toggle-target' +import { worktree as baseWorktree } from './worktree-list-groups-test-fixtures' + +type ToggleState = Parameters[0] +type HoverDocument = NonNullable[2]> + +function worktree(id: string, overrides: Partial = {}): Worktree { + return { ...baseWorktree, id, instanceId: `${id}-instance`, path: `/tmp/${id}`, ...overrides } +} + +function lineage(child: Worktree, parent: Worktree): WorktreeLineage { + return { + worktreeId: child.id, + worktreeInstanceId: child.instanceId ?? '', + parentWorktreeId: parent.id, + parentWorktreeInstanceId: parent.instanceId ?? '', + origin: 'cli', + capture: { source: 'explicit-cli-flag', confidence: 'explicit' }, + createdAt: 1 + } +} + +function hoveredDocument(...hovered: Worktree[]): HoverDocument { + const rows = hovered.map((row) => ({ + dataset: { worktreeId: row.id, worktreeHostIdentity: getWorktreeHostIdentity(row) } + })) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resolver reads only length, item() and dataset from the hovered-row query. + return { + activeElement: null, + querySelectorAll: () => ({ length: rows.length, item: (index: number) => rows[index] ?? null }) + } as unknown as HoverDocument +} + +function toggleState(args: { + worktrees: Worktree[] + lineageById?: Record + active?: Worktree +}): ToggleState { + return { + activeWorktreeId: args.active?.id ?? null, + activeWorkspaceExecutionHostId: args.active?.hostId ?? null, + worktreeLineageById: args.lineageById ?? {}, + worktreesByRepo: { [baseWorktree.repoId]: args.worktrees } + } +} + +const parent = worktree('parent') +const child = worktree('child') +const grandchild = worktree('grandchild') +const loner = worktree('loner') +const family = { + worktrees: [parent, child, grandchild, loner], + lineageById: { child: lineage(child, parent), grandchild: lineage(grandchild, child) } +} + +describe('resolveChildWorkspacesToggleGroupKey', () => { + const bothChips = new Set(['lineage:parent', 'lineage:child']) + const resolve = ( + state: ToggleState, + doc: HoverDocument, + chips: ReadonlySet = bothChips + ): string | null => resolveChildWorkspacesToggleGroupKey(state, chips, doc) + + it('toggles the hovered parent’s own children', () => { + expect(resolve(toggleState(family), hoveredDocument(parent))).toBe('lineage:parent') + }) + + it('prefers the deepest hovered card, which may itself be a parent', () => { + expect(resolve(toggleState(family), hoveredDocument(parent, child))).toBe('lineage:child') + }) + + it('folds the parent when the target is a leaf child', () => { + expect(resolve(toggleState(family), hoveredDocument(grandchild))).toBe('lineage:child') + }) + + it('folds the parent when the target’s own children render no chip', () => { + expect(resolve(toggleState(family), hoveredDocument(child), new Set(['lineage:parent']))).toBe( + 'lineage:parent' + ) + }) + + it('does nothing when the sidebar renders no chip for the target or its parent', () => { + // e.g. every child is hidden by a sidebar filter, so the chip is gone + expect(resolve(toggleState(family), hoveredDocument(parent), new Set())).toBeNull() + expect(resolve(toggleState(family), hoveredDocument(child), new Set())).toBeNull() + }) + + it('falls back to the active workspace when no card is hovered', () => { + expect(resolve(toggleState({ ...family, active: parent }), hoveredDocument())).toBe( + 'lineage:parent' + ) + }) + + it('lets the hovered card win over the active one, even when it is in no lineage', () => { + expect(resolve(toggleState({ ...family, active: parent }), hoveredDocument(loner))).toBeNull() + }) + + it('returns null without a hovered or active workspace', () => { + expect(resolve(toggleState(family), hoveredDocument())).toBeNull() + }) + + it('lets an active or hovered folder card pass through even with an active parent', () => { + const folder = worktree(folderWorkspaceKey('folder-1'), { hostId: 'local' }) + expect(resolve(toggleState({ ...family, active: parent }), hoveredDocument(folder))).toBeNull() + expect(resolve(toggleState({ ...family, active: folder }), hoveredDocument())).toBeNull() + }) + + it('does not fall back to the active parent for a stale hovered card', () => { + expect( + resolve(toggleState({ ...family, active: parent }), hoveredDocument(worktree('removed'))) + ).toBeNull() + }) + + it('does not fold an archived parent', () => { + const archivedParent = { ...parent, isArchived: true } + expect( + resolve( + toggleState({ + worktrees: [archivedParent, child], + lineageById: { child: lineage(child, archivedParent) } + }), + hoveredDocument(child), + new Set(['lineage:parent']) + ) + ).toBeNull() + }) + + it('ignores a stale lineage record from an earlier instance', () => { + const recreatedGrandchild = { ...grandchild, instanceId: 'grandchild-recreated' } + expect( + resolve( + toggleState({ ...family, worktrees: [parent, child, recreatedGrandchild, loner] }), + hoveredDocument(recreatedGrandchild) + ) + ).toBeNull() + }) + + it('uses host-qualified keys and ignores same-id rows on another host', () => { + const remoteParent = worktree('parent', { hostId: 'ssh:box' }) + const remoteChild = worktree('child', { hostId: 'ssh:box' }) + const remoteKey = `lineage:${getWorktreeHostIdentity(remoteParent)}` + const state = toggleState({ + worktrees: [parent, remoteParent, remoteChild], + lineageById: { child: lineage(remoteChild, remoteParent) }, + active: remoteChild + }) + + expect(resolve(state, hoveredDocument(), new Set([remoteKey]))).toBe(remoteKey) + // Why: the local row sharing the parent's id has no chip of its own to fold. + expect(resolve(state, hoveredDocument(), new Set(['lineage:parent']))).toBeNull() + expect(resolve(state, hoveredDocument(remoteParent), new Set([remoteKey]))).toBe(remoteKey) + }) +}) diff --git a/src/renderer/src/components/sidebar/child-workspaces-toggle-target.ts b/src/renderer/src/components/sidebar/child-workspaces-toggle-target.ts new file mode 100644 index 00000000000..d97fe2acfff --- /dev/null +++ b/src/renderer/src/components/sidebar/child-workspaces-toggle-target.ts @@ -0,0 +1,105 @@ +import { + getAllWorktreesFromState, + getRepoMapFromState, + getWorktreeOnHostFromState +} from '@/store/selectors' +import type { AppState } from '@/store/types' +import { getWorktreeHostIdentity } from '../../../../shared/worktree/host-qualified-identity' +import type { Worktree } from '../../../../shared/worktree/types' +import { getHoveredWorkspaceIdentity } from './hovered-workspace-delete' +import { computeRenderedSidebarRows } from './rendered-sidebar-worktree-order' +import { + computeVisibleWorktrees, + getPublishedVisibleWorktreeShortcutTargets +} from './visible-worktrees' +import { buildVisibleWorktreeOptionsFromState } from './visible-worktree-options-from-state' +import { getWorktreeLineageGroupKey } from './worktree-list/grouping/group-keys' +import { + getHostScopedWorktreeLineageInputs, + getWorktreeLineageAncestors +} from './worktree-lineage-projection' + +type ChildWorkspacesToggleState = Pick< + AppState, + 'activeWorkspaceExecutionHostId' | 'activeWorktreeId' | 'worktreeLineageById' | 'worktreesByRepo' +> +type HoverDocument = NonNullable[0]> + +function resolveTargetWorktree( + state: ChildWorkspacesToggleState, + doc: HoverDocument +): Worktree | undefined { + const hovered = getHoveredWorkspaceIdentity(doc) + if (hovered) { + // Why: the card under the pointer wins over the active one, like the delete shortcut. + return getAllWorktreesFromState(state).find( + (worktree) => + worktree.id === hovered.workspaceId && + getWorktreeHostIdentity(worktree) === hovered.hostIdentity + ) + } + return state.activeWorktreeId + ? getWorktreeOnHostFromState( + state, + state.activeWorktreeId, + state.activeWorkspaceExecutionHostId ?? undefined + ) + : undefined +} + +function getParentWorktree( + state: ChildWorkspacesToggleState, + target: Worktree +): Worktree | undefined { + // Why: the sidebar nests a child only under a parent on the same host, and archived rows never render. + const worktrees = getAllWorktreesFromState(state).filter( + (worktree) => worktree.hostId === target.hostId && !worktree.isArchived + ) + const { worktreeMap, lineageById } = getHostScopedWorktreeLineageInputs( + worktrees, + state.worktreeLineageById, + target.hostId + ) + return getWorktreeLineageAncestors(target, lineageById, worktreeMap)[0] +} + +/** Resolves the target's rendered chip, falling back to its same-host parent's chip. */ +export function resolveChildWorkspacesToggleGroupKey( + state: ChildWorkspacesToggleState, + renderedChipKeys: ReadonlySet, + doc: HoverDocument = document +): string | null { + const target = resolveTargetWorktree(state, doc) + if (!target || target.isArchived) { + return null + } + const ownKey = getWorktreeLineageGroupKey(target) + if (renderedChipKeys.has(ownKey)) { + return ownKey + } + const parent = getParentWorktree(state, target) + const parentKey = parent ? getWorktreeLineageGroupKey(parent) : null + return parentKey && renderedChipKeys.has(parentKey) ? parentKey : null +} + +/** Uses mounted rows so temporary reveal and drag policies match the existing chip. */ +export function getRenderedLineageChipKeys(state: AppState): Set { + const publishedTargets = getPublishedVisibleWorktreeShortcutTargets() + if (publishedTargets !== null) { + return new Set( + publishedTargets.flatMap((target) => (target.lineageGroupKey ? [target.lineageGroupKey] : [])) + ) + } + const visibleWorktrees = computeVisibleWorktrees( + state.worktreesByRepo, + [], + buildVisibleWorktreeOptionsFromState(state, getRepoMapFromState(state)) + ) + const keys = new Set() + for (const row of computeRenderedSidebarRows(state, visibleWorktrees)) { + if (row.type === 'item' && row.lineageGroupKey && row.lineageChildCount > 0) { + keys.add(row.lineageGroupKey) + } + } + return keys +} diff --git a/src/renderer/src/components/sidebar/child-workspaces-toggle-visibility.test.ts b/src/renderer/src/components/sidebar/child-workspaces-toggle-visibility.test.ts new file mode 100644 index 00000000000..9ad15d894a8 --- /dev/null +++ b/src/renderer/src/components/sidebar/child-workspaces-toggle-visibility.test.ts @@ -0,0 +1,147 @@ +// @vitest-environment happy-dom + +import { afterEach, describe, expect, it } from 'vitest' +import { renderHook } from '@testing-library/react' +import { useAppStore } from '@/store' +import type { AppState } from '@/store/types' +import type { WorktreeLineage } from '../../../../shared/worktree/lineage-types' +import type { Worktree } from '../../../../shared/worktree/types' +import { createGlobalSettingsFixture } from '../../../../shared/global-settings-test-fixture' +import { + getRenderedLineageChipKeys, + resolveChildWorkspacesToggleGroupKey +} from './child-workspaces-toggle-target' +import { computeRenderedSidebarRows } from './rendered-sidebar-worktree-order' +import { useSidebarWorktreeSelection } from './worktree-list/navigation/use-selection' +import { getPinnedWorktreeDisplayPolicy } from './worktree-list/grouping/row-types' +import { setVisibleWorktreeIds, setVisibleWorktreeShortcutTargets } from './visible-worktrees' +import { repo, worktree } from './worktree-list-groups-test-fixtures' + +const initialState = useAppStore.getInitialState() +const parent: Worktree = { ...worktree, id: 'parent', instanceId: 'parent-instance' } +const child: Worktree = { ...worktree, id: 'child', instanceId: 'child-instance' } +const edge: WorktreeLineage = { + worktreeId: child.id, + worktreeInstanceId: 'child-instance', + parentWorktreeId: parent.id, + parentWorktreeInstanceId: 'parent-instance', + origin: 'manual', + capture: { source: 'manual-action', confidence: 'explicit' }, + createdAt: 1 +} +type HoverDocument = NonNullable[2]> +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the no-hover fixture supplies only the empty NodeList methods the identity reader uses. +const noHover = { + querySelectorAll: () => ({ length: 0, item: () => null }) +} as unknown as HoverDocument + +function seed(overrides: Partial = {}): AppState { + useAppStore.setState( + { + ...initialState, + repos: [repo], + worktreesByRepo: { [repo.id]: [parent, child] }, + worktreeLineageById: { [child.id]: edge }, + activeWorktreeId: parent.id, + showSleepingWorkspaces: true, + filterRepoIds: [], + groupBy: 'none', + ...overrides + }, + true + ) + return useAppStore.getState() +} + +function target(state: AppState): string | null { + return resolveChildWorkspacesToggleGroupKey(state, getRenderedLineageChipKeys(state), noHover) +} + +describe('child workspace shortcut follows canonical rendered chips', () => { + afterEach(() => { + useAppStore.setState(initialState, true) + setVisibleWorktreeIds(null) + setVisibleWorktreeShortcutTargets(null) + }) + + it('uses the mounted rows when a temporary reveal overrides a project filter', () => { + const state = seed({ filterRepoIds: ['another-project'] }) + const sectionRows = computeRenderedSidebarRows(state, [parent, child]) + const hook = renderHook(() => + useSidebarWorktreeSelection({ + sectionRows, + pinnedDisplayPolicy: getPinnedWorktreeDisplayPolicy(state.settings) + }) + ) + try { + expect(target(state)).toBe('lineage:parent') + } finally { + hook.unmount() + } + }) + + it('does not flip hidden state while a mounted host drag removes the cards', () => { + const state = seed() + const hook = renderHook(() => + useSidebarWorktreeSelection({ + sectionRows: [], + pinnedDisplayPolicy: getPinnedWorktreeDisplayPolicy(state.settings) + }) + ) + try { + expect(target(state)).toBeNull() + } finally { + hook.unmount() + } + }) + + it('keeps the parent chip available when its children are folded', () => { + const state = seed({ collapsedGroups: new Set(['lineage:parent']), activeWorktreeId: child.id }) + expect(target(state)).toBe('lineage:parent') + expect(getRenderedLineageChipKeys(state)).toEqual(new Set(['lineage:parent'])) + }) + + it('does not toggle a family hidden by a collapsed section', () => { + expect(target(seed({ collapsedGroups: new Set(['all']) }))).toBeNull() + }) + + it('does not toggle a family excluded by the project filter', () => { + expect(target(seed({ filterRepoIds: ['another-project'] }))).toBeNull() + }) + + it('does not invent a chip when all children are sleeping and filtered out', () => { + const state = seed({ + worktreesByRepo: { + [repo.id]: [{ ...parent, isMainWorktree: true, branch: 'refs/heads/main' }, child] + }, + showSleepingWorkspaces: false, + alwaysShowDefaultBranchWorkspace: true + }) + expect(target(state)).toBeNull() + expect(getRenderedLineageChipKeys(state)).toEqual(new Set()) + }) + + it('drops archived children and stale child or parent instances', () => { + for (const worktrees of [ + [parent, { ...child, isArchived: true }], + [parent, { ...child, instanceId: 'new-child' }], + [{ ...parent, instanceId: 'new-parent' }, child] + ]) { + expect(target(seed({ worktreesByRepo: { [repo.id]: worktrees } }))).toBeNull() + } + }) + + it('uses one persisted key when pinning duplicates the parent card', () => { + const pinnedParent = { ...parent, isPinned: true } + const state = seed({ + worktreesByRepo: { [repo.id]: [pinnedParent, child] }, + settings: createGlobalSettingsFixture({ showPinnedWorktreesInGroups: true }) + }) + const chips = computeRenderedSidebarRows(state, [pinnedParent, child]).filter( + (row) => row.type === 'item' && row.lineageGroupKey === 'lineage:parent' + ) + expect(chips).toHaveLength(2) + expect(getRenderedLineageChipKeys(state)).toEqual(new Set(['lineage:parent'])) + expect(target(state)).toBe('lineage:parent') + }) +}) diff --git a/src/renderer/src/components/sidebar/rendered-sidebar-worktree-order.ts b/src/renderer/src/components/sidebar/rendered-sidebar-worktree-order.ts index 2a5f0153092..290f44e2ef6 100644 --- a/src/renderer/src/components/sidebar/rendered-sidebar-worktree-order.ts +++ b/src/renderer/src/components/sidebar/rendered-sidebar-worktree-order.ts @@ -9,7 +9,7 @@ import { getRepoMapFromState, getWorktreeMapFromState } from '@/store/selectors' import { getProjectHostSetupProjectionFromState } from '@/store/project-host-setup-selector' import { buildRows } from './worktree-list/grouping/build-rows' import { getPinnedWorktreeDisplayPolicy } from './worktree-list/grouping/row-types' -import { addHostSectionRows } from './host-section-rows' +import { addHostSectionRows, type HostSectionRow } from './host-section-rows' import { orderHostSectionOptions } from './host-section-order' import { buildSidebarHostOptions } from './sidebar-host-options' import { getLogicalRepoOrderRankById } from './project-header-drop' @@ -39,6 +39,17 @@ export function computeRenderedSidebarWorktrees( state: AppState, visibleWorktrees: readonly Worktree[] ): Worktree[] { + return getRenderedWorktreesInSidebarOrder( + computeRenderedSidebarRows(state, visibleWorktrees), + getPinnedWorktreeDisplayPolicy(state.settings) + ) +} + +/** The sidebar's row model, replayed from the store without mounting WorktreeList. */ +export function computeRenderedSidebarRows( + state: AppState, + visibleWorktrees: readonly Worktree[] +): HostSectionRow[] { const defaultHostId = getSettingsFocusedExecutionHostId(state.settings) const pinnedDisplayPolicy = getPinnedWorktreeDisplayPolicy(state.settings) const projection = getProjectHostSetupProjectionFromState(state) @@ -89,7 +100,7 @@ export function computeRenderedSidebarWorktrees( // Deliberately a superset of its internal guards — on <=1 host it still no-ops, wasting only the registry build. const needsHostSections = state.workspaceHostScope !== ALL_EXECUTION_HOSTS_SCOPE || state.visibleWorkspaceHostIds != null - const sectionRows = needsHostSections + return needsHostSections ? addHostSectionRows({ rows, hostOptions: orderHostSectionOptions( @@ -112,8 +123,6 @@ export function computeRenderedSidebarWorktrees( preferProjectGrouping: true }) : rows - - return getRenderedWorktreesInSidebarOrder(sectionRows, pinnedDisplayPolicy) } export function computeRenderedSidebarWorktreeOrder( diff --git a/src/renderer/src/components/sidebar/visible-worktrees.ts b/src/renderer/src/components/sidebar/visible-worktrees.ts index 82b72109fb1..37fb31890fb 100644 --- a/src/renderer/src/components/sidebar/visible-worktrees.ts +++ b/src/renderer/src/components/sidebar/visible-worktrees.ts @@ -248,6 +248,7 @@ let _publishedVisibleIds: string[] | null = null export type VisibleWorktreeShortcutTarget = { id: string executionHostId?: Worktree['hostId'] + lineageGroupKey?: string } let _publishedVisibleShortcutTargets: VisibleWorktreeShortcutTarget[] | null = null @@ -261,6 +262,12 @@ export function setVisibleWorktreeShortcutTargets( _publishedVisibleShortcutTargets = targets } +export function getPublishedVisibleWorktreeShortcutTargets(): + | readonly VisibleWorktreeShortcutTarget[] + | null { + return _publishedVisibleShortcutTargets +} + export function getVisibleWorktreeIds(): string[] { // Prefer the published IDs that mirror the rendered sidebar order. if (_publishedVisibleIds) { diff --git a/src/renderer/src/components/sidebar/worktree-lineage-projection.ts b/src/renderer/src/components/sidebar/worktree-lineage-projection.ts index b8748f287c5..5c14a2fedad 100644 --- a/src/renderer/src/components/sidebar/worktree-lineage-projection.ts +++ b/src/renderer/src/components/sidebar/worktree-lineage-projection.ts @@ -2,6 +2,7 @@ import { getCyclicWorktreeLineageChildIds, isValidResolvedWorktreeLineageEdge } from '../../../../shared/resolved-worktree-lineage' +import type { ExecutionHostId } from '../../../../shared/execution-host' import type { WorktreeLineage } from '../../../../shared/worktree/lineage-types' import type { Worktree } from '../../../../shared/worktree/types' @@ -125,6 +126,33 @@ export function getProjectedWorktreeLineageChildrenByParentId( return childrenByParentId } +/** + * Lineage inputs for one execution host. Why: the id-keyed projection keeps one + * row per id, so a two-host id collision must be narrowed to the target's host. + */ +export function getHostScopedWorktreeLineageInputs( + worktrees: readonly Worktree[], + lineageById: Readonly>, + executionHostId: ExecutionHostId | undefined +): { worktreeMap: Map; lineageById: Record } { + const worktreeMap = new Map() + const hostLineageById: Record = {} + for (const worktree of worktrees) { + if (executionHostId && worktree.hostId && worktree.hostId !== executionHostId) { + continue + } + worktreeMap.set(worktree.id, worktree) + const projected = lineageById[worktree.id] + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: resolved rows may carry an inline lineage the Worktree type omits; it is only read, never trusted over a matching projection. + const inline = (worktree as WorktreeWithResolvedLineage).lineage + const lineage = projected?.worktreeInstanceId === worktree.instanceId ? projected : inline + if (lineage) { + hostLineageById[worktree.id] = lineage + } + } + return { worktreeMap, lineageById: hostLineageById } +} + export function getWorktreeLineageAncestors( worktree: Worktree, lineageById: Readonly>, diff --git a/src/renderer/src/components/sidebar/worktree-list/navigation/pending-reveal-inputs.ts b/src/renderer/src/components/sidebar/worktree-list/navigation/pending-reveal-inputs.ts index a0aa9a51520..09f4c4d333f 100644 --- a/src/renderer/src/components/sidebar/worktree-list/navigation/pending-reveal-inputs.ts +++ b/src/renderer/src/components/sidebar/worktree-list/navigation/pending-reveal-inputs.ts @@ -15,7 +15,10 @@ import type { ProjectGroupingModel } from '../grouping/project-grouping' import type { PinnedWorktreeDisplayPolicy, WorktreeGroupBy } from '../grouping/row-types' import { getGroupKeysForWorktree } from '../grouping/worktree-group-keys' import { isPinnedSectionWorktree } from '../../pinned-section-worktrees' -import { getWorktreeLineageAncestors } from '../../worktree-lineage-projection' +import { + getHostScopedWorktreeLineageInputs, + getWorktreeLineageAncestors +} from '../../worktree-lineage-projection' import { getFolderWorkspaceRevealGroupKeys } from './folder-reveal' import { getPinnedWorktreeRevealCollapsedGroupKeys } from './reveal-ancestors' @@ -89,24 +92,15 @@ export function expandGroupsForWorktreeReveal( args.toggleGroup(hostGroupKey) } - const hostWorktreeMap = new Map() - const hostLineageById: Record = {} - for (const worktree of args.worktrees) { - if (executionHostId && worktree.hostId && worktree.hostId !== executionHostId) { - continue - } - hostWorktreeMap.set(worktree.id, worktree) - const projected = args.worktreeLineageById[worktree.id] - const inline = (worktree as Worktree & { lineage?: WorktreeLineage | null }).lineage - const lineage = projected?.worktreeInstanceId === worktree.instanceId ? projected : inline - if (lineage) { - hostLineageById[worktree.id] = lineage - } - } + const hostLineage = getHostScopedWorktreeLineageInputs( + args.worktrees, + args.worktreeLineageById, + executionHostId + ) for (const parent of getWorktreeLineageAncestors( targetWorktree, - hostLineageById, - hostWorktreeMap + hostLineage.lineageById, + hostLineage.worktreeMap )) { const lineageGroupKey = getWorktreeLineageGroupKey(parent) if (args.collapsedGroups.has(lineageGroupKey)) { diff --git a/src/renderer/src/components/sidebar/worktree-list/navigation/use-selection.ts b/src/renderer/src/components/sidebar/worktree-list/navigation/use-selection.ts index ce11126d6e2..0d59c888785 100644 --- a/src/renderer/src/components/sidebar/worktree-list/navigation/use-selection.ts +++ b/src/renderer/src/components/sidebar/worktree-list/navigation/use-selection.ts @@ -131,19 +131,29 @@ export function useSidebarWorktreeSelection(args: { // Why layout effect: the Cmd/Ctrl+1–9 handler can fire right after commit; publishing after paint would leave the shortcut cache stale. useLayoutEffect(() => { + const chipKeysByIdentity = new Map() + for (const row of sectionRows) { + if (row.type === 'item' && row.lineageGroupKey && row.lineageChildCount > 0) { + chipKeysByIdentity.set(getWorktreeHostIdentity(row.worktree), row.lineageGroupKey) + } + } setVisibleWorktreeIds(renderedWorktreeIds) setVisibleWorktreeShortcutTargets( - renderedWorktrees.map((worktree) => ({ - id: worktree.id, - ...(worktree.hostId ? { executionHostId: worktree.hostId } : {}) - })) + renderedWorktrees.map((worktree) => { + const lineageGroupKey = chipKeysByIdentity.get(getWorktreeHostIdentity(worktree)) + return { + id: worktree.id, + ...(worktree.hostId ? { executionHostId: worktree.hostId } : {}), + ...(lineageGroupKey ? { lineageGroupKey } : {}) + } + }) ) // Why null, not []: [] is a real rendered order (all collapsed/filtered); null tells shortcuts the list is unmounted. return () => { setVisibleWorktreeIds(null) setVisibleWorktreeShortcutTargets(null) } - }, [renderedWorktreeIds, renderedWorktrees]) + }, [renderedWorktreeIds, renderedWorktrees, sectionRows]) return { renderedWorktreeIds, diff --git a/src/shared/keybindings-unassigned-actions.test.ts b/src/shared/keybindings-unassigned-actions.test.ts index 007f1074bca..a591e20fb76 100644 --- a/src/shared/keybindings-unassigned-actions.test.ts +++ b/src/shared/keybindings-unassigned-actions.test.ts @@ -215,6 +215,36 @@ describe('keybindings', () => { ) }) + it('keeps the child-workspaces toggle unassigned until users customize it', () => { + const binding = { + key: 'h', + code: 'KeyH', + control: true, + meta: false, + alt: true, + shift: false + } + + for (const platform of ['darwin', 'linux', 'win32'] as const) { + expect(getEffectiveKeybindingsForAction('sidebar.childWorkspaces.toggle', platform)).toEqual( + [] + ) + } + expect(keybindingMatchesAction('sidebar.childWorkspaces.toggle', binding, 'linux')).toBe(false) + expect( + keybindingMatchesAction('sidebar.childWorkspaces.toggle', binding, 'linux', { + 'sidebar.childWorkspaces.toggle': ['Mod+Alt+H'] + }) + ).toBe(true) + + const definition = getKeybindingDefinition('sidebar.childWorkspaces.toggle') + expect(definition?.title).toBe('Toggle Child Workspaces') + expect(definition?.scope).toBe('global') + expect(definition?.searchKeywords).toEqual( + expect.arrayContaining(['child', 'children', 'collapse', 'expand']) + ) + }) + it('leaves floating workspace minimize unassigned because floating terminal toggle owns show and hide', () => { const platforms: readonly KeybindingPlatform[] = ['darwin', 'linux', 'win32'] const minimizeAction = 'floatingWorkspace.minimize' as KeybindingActionId diff --git a/src/shared/keybindings/definitions-core-4.ts b/src/shared/keybindings/definitions-core-4.ts index 9ed02c02d86..acba537dd6c 100644 --- a/src/shared/keybindings/definitions-core-4.ts +++ b/src/shared/keybindings/definitions-core-4.ts @@ -2,6 +2,28 @@ import type { KeybindingDefinition } from './types' import { platformBindings } from './definitions-support' export const KEYBINDING_DEFINITION_CORE_4: readonly KeybindingDefinition[] = [ + { + id: 'sidebar.childWorkspaces.toggle', + title: 'Toggle Child Workspaces', + group: 'Global', + scope: 'global', + searchKeywords: [ + 'shortcut', + 'sidebar', + 'child', + 'children', + 'nested', + 'lineage', + 'workspaces', + 'worktree', + 'collapse', + 'expand', + 'show', + 'hide' + ], + // Why: ship unbound like the sleeping toggle, so no existing chord is claimed on any platform. + defaultBindings: platformBindings([]) + }, { id: 'terminal.clearPaneTitle', title: 'Clear Pane Title', diff --git a/src/shared/keybindings/types.ts b/src/shared/keybindings/types.ts index bcbd765d0e6..7098d14d659 100644 --- a/src/shared/keybindings/types.ts +++ b/src/shared/keybindings/types.ts @@ -47,6 +47,7 @@ export type KeybindingActionId = | 'sidebar.checks.toggle' | 'sidebar.ports.toggle' | 'sidebar.sleepingWorkspaces.toggle' + | 'sidebar.childWorkspaces.toggle' | 'sidebar.focusWorktreeList' | 'floatingTerminal.toggle' | 'floatingWorkspace.maximize' diff --git a/tests/e2e/worktree-lineage-scroll-readiness.ts b/tests/e2e/worktree-lineage-scroll-readiness.ts new file mode 100644 index 00000000000..75d38bbf161 --- /dev/null +++ b/tests/e2e/worktree-lineage-scroll-readiness.ts @@ -0,0 +1,142 @@ +import type { Page } from '@stablyai/playwright-test' + +// Freeze the oracle only after the fixture's reveal and measured rows have settled. +export async function waitForLineageScrollFixtureReady( + page: Page, + parentId: string +): Promise { + await page.evaluate( + (parentId) => + new Promise((resolve, reject) => { + const sidebar = document.querySelector('[data-worktree-sidebar]') + if (!sidebar) { + reject(new Error('Missing sidebar')) + return + } + const quietSpanMs = 500 + const started = performance.now() + let lastScrollAt = started + let unchangedSince = started + let signature = '' + let frameId: number | null = null + let frames = 0 + let intervalId: ReturnType | undefined + let timeoutId: ReturnType | undefined + const cleanup = (): void => { + sidebar.removeEventListener('scroll', onScroll) + window.removeEventListener('pagehide', onPageHide) + clearInterval(intervalId) + clearTimeout(timeoutId) + if (frameId !== null) { + cancelAnimationFrame(frameId) + } + } + const finish = (error?: Error): void => { + cleanup() + if (error) { + reject(error) + } else { + resolve() + } + } + const onScroll = (): void => { + lastScrollAt = performance.now() + } + const onPageHide = (): void => finish(new Error('Renderer closed before fixture settled')) + const readSignature = (): string => { + const parent = sidebar.querySelector( + `[role="option"][data-worktree-id=${JSON.stringify(parentId)}]` + ) + const wrapper = parent?.closest('[data-worktree-virtual-row]') + if ( + !parent?.isConnected || + !wrapper || + document.fonts.status !== 'loaded' || + sidebar.scrollTop <= 0 + ) { + return '' + } + const mounted = Array.from( + sidebar.querySelectorAll('[data-worktree-virtual-row]') + ) + if (mounted.length > 100) { + throw new Error('Unbounded lineage fixture row measurement') + } + const parentRect = parent.getBoundingClientRect() + const sidebarRect = sidebar.getBoundingClientRect() + return JSON.stringify({ + id: parent.dataset.worktreeId, + key: wrapper.dataset.worktreeVirtualRowKey, + index: wrapper.dataset.index, + start: wrapper.dataset.worktreeVirtualRowStart, + transform: wrapper.style.transform, + parentTop: parentRect.top, + parentHeight: parentRect.height, + scrollTop: sidebar.scrollTop, + scrollHeight: sidebar.scrollHeight, + sidebarTop: sidebarRect.top, + sidebarHeight: sidebarRect.height, + canvasHeight: wrapper.parentElement?.style.height, + rows: mounted.map((row) => ({ + key: row.dataset.worktreeVirtualRowKey, + index: row.dataset.index, + start: row.dataset.worktreeVirtualRowStart, + transform: row.style.transform, + height: row.getBoundingClientRect().height + })) + }) + } + const inspect = (): boolean => { + const next = readSignature() + if (!next || next !== signature) { + signature = next + unchangedSince = performance.now() + frames = 0 + if (frameId !== null) { + cancelAnimationFrame(frameId) + } + frameId = null + return false + } + const now = performance.now() + const ready = now - unchangedSince >= quietSpanMs && now - lastScrollAt >= quietSpanMs + if (!ready) { + frames = 0 + } + return ready + } + const onFrame = (): void => { + frameId = null + try { + if (!inspect()) { + return + } + frames += 1 + if (frames === 2) { + finish() + return + } + frameId = requestAnimationFrame(onFrame) + } catch (error) { + finish(error instanceof Error ? error : new Error(String(error))) + } + } + sidebar.addEventListener('scroll', onScroll, { passive: true }) + window.addEventListener('pagehide', onPageHide, { once: true }) + intervalId = setInterval(() => { + try { + if (inspect() && frameId === null) { + frameId = requestAnimationFrame(onFrame) + } + } catch (error) { + finish(error instanceof Error ? error : new Error(String(error))) + } + }, 50) + timeoutId = setTimeout( + () => finish(new Error(`Lineage fixture did not settle: ${signature}`)), + 10000 + ) + }), + parentId + ) +} diff --git a/tests/e2e/worktree-lineage-state.ts b/tests/e2e/worktree-lineage-state.ts index 58a84cfe2d8..219bb07c27e 100644 --- a/tests/e2e/worktree-lineage-state.ts +++ b/tests/e2e/worktree-lineage-state.ts @@ -7,9 +7,9 @@ export type LineageScenario = { export async function seedLineageScenario( page: Page, - options: { inlineOnly?: boolean } = {} + options: { inlineOnly?: boolean; preserveGrouping?: boolean } = {} ): Promise { - return page.evaluate(({ inlineOnly }) => { + return page.evaluate(({ inlineOnly, preserveGrouping }) => { const store = window.__store if (!store) { throw new Error('window.__store is not available') @@ -18,7 +18,9 @@ export async function seedLineageScenario( const state = store.getState() state.setActiveView('terminal') state.setSidebarOpen(true) - state.setGroupBy('none') + if (!preserveGrouping) { + state.setGroupBy('none') + } state.setSortBy('recent') // Why: these specs assert lineage structure, not the user's persisted // sidebar filters. Make the seeded child render even when it has no live PTY. diff --git a/tests/e2e/worktree-lineage-toggle-dialog.spec.ts b/tests/e2e/worktree-lineage-toggle-dialog.spec.ts new file mode 100644 index 00000000000..9edb3a7182b --- /dev/null +++ b/tests/e2e/worktree-lineage-toggle-dialog.spec.ts @@ -0,0 +1,84 @@ +import { test, expect } from './helpers/orca-app' +import { waitForSessionReady, waitForActiveWorktree } from './helpers/store' +import { seedLineageScenario } from './worktree-lineage-state' + +test.use({ launchEnv: { ORCA_BACKGROUND_LAUNCH: '1' } }) + +test('locally controlled project-group modal keeps child-toggle input', async ({ + orcaPage, + electronApp +}, testInfo) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + expect( + await electronApp.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows().every((window) => !window.isVisible()) + ) + ).toBe(true) + const family = await seedLineageScenario(orcaPage) + const groupId = await orcaPage.evaluate(async ({ parentId }) => { + const store = window.__store + if (!store) { + throw new Error('Missing store') + } + const parent = Object.values(store.getState().worktreesByRepo) + .flat() + .find((row) => row.id === parentId) + if (!parent) { + throw new Error('Missing parent') + } + const group = await store.getState().createProjectGroup('Shortcut dialog group') + if (!group) { + throw new Error('Missing project group') + } + await store.getState().moveProjectToGroup(parent.repoId, group.id) + store.getState().setGroupBy('repo') + await store.getState().setKeybindingOverride('sidebar.childWorkspaces.toggle', ['Mod+Alt+H']) + window.addEventListener('keydown', (event) => { + if (event.code === 'KeyH') { + document.body.dataset.childToggleClaimed = String(event.defaultPrevented) + } + }) + return group.id + }, family) + const group = orcaPage.locator(`[data-project-group-header-id="${groupId}"]`) + await expect( + orcaPage + .locator( + `[data-worktree-sidebar] [role="option"][data-worktree-id=${JSON.stringify(family.parentId)}]` + ) + .first() + .getByRole('button', { name: 'Hide 1 child workspace' }) + ).toBeVisible() + await group.hover() + await group.getByRole('button', { name: /Group actions/i }).click() + // The menu is not a modal input owner. + await orcaPage.keyboard.press('ControlOrMeta+Alt+KeyH') + expect(await orcaPage.evaluate(() => window.__store?.getState().collapsedGroups.size)).toBe(1) + await orcaPage.keyboard.press('ControlOrMeta+Alt+KeyH') + expect(await orcaPage.evaluate(() => window.__store?.getState().collapsedGroups.size)).toBe(0) + await orcaPage.getByRole('menuitem', { name: /Delete/i }).click() + const dialog = orcaPage.getByRole('dialog', { name: 'Delete Project Group' }) + await expect(dialog).toBeVisible() + await expect(dialog.getByRole('button', { name: 'Delete Group', exact: true })).toBeFocused() + expect(await orcaPage.evaluate(() => window.__store?.getState().activeModal)).toBe('none') + const before = await orcaPage.evaluate(() => + [...(window.__store?.getState().collapsedGroups ?? [])].sort() + ) + await orcaPage.keyboard.press('ControlOrMeta+Alt+KeyH') + const proof = testInfo.outputPath('local-modal-guard.png') + await orcaPage.screenshot({ path: proof }) + await testInfo.attach('local-modal-guard', { path: proof, contentType: 'image/png' }) + expect( + await orcaPage.evaluate(() => [...(window.__store?.getState().collapsedGroups ?? [])].sort()) + ).toEqual(before) + await expect(orcaPage.locator('body')).toHaveAttribute('data-child-toggle-claimed', 'false') + await dialog.getByRole('button', { name: 'Cancel', exact: true }).click() + await expect( + orcaPage + .locator( + `[data-worktree-sidebar] [role="option"][data-worktree-id=${JSON.stringify(family.childId)}]` + ) + .first() + ).toBeVisible() +}) diff --git a/tests/e2e/worktree-lineage-toggle-scroll.spec.ts b/tests/e2e/worktree-lineage-toggle-scroll.spec.ts new file mode 100644 index 00000000000..0e7b078f1a1 --- /dev/null +++ b/tests/e2e/worktree-lineage-toggle-scroll.spec.ts @@ -0,0 +1,103 @@ +import { test, expect } from './helpers/orca-app' +import { waitForSessionReady, waitForActiveWorktree } from './helpers/store' +import { seedLineageScenario } from './worktree-lineage-state' +import { waitForLineageScrollFixtureReady } from './worktree-lineage-scroll-readiness' + +test.use({ launchEnv: { ORCA_BACKGROUND_LAUNCH: '1' } }) + +test('keyboard and chip preserve the scrolled parent, and unfolding opens the sidebar', async ({ + orcaPage, + electronApp +}, testInfo) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + expect( + await electronApp.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows().every((window) => !window.isVisible()) + ) + ).toBe(true) + await orcaPage.emulateMedia({ reducedMotion: 'reduce' }) + await orcaPage.setViewportSize({ width: 1200, height: 800 }) + const family = await seedLineageScenario(orcaPage) + await orcaPage.evaluate(async ({ parentId, childId }) => { + const store = window.__store + if (!store) { + throw new Error('Missing store') + } + const state = store.getState() + const parent = Object.values(state.worktreesByRepo) + .flat() + .find((row) => row.id === parentId) + if (!parent) { + throw new Error('Missing parent') + } + const surrounding = Array.from({ length: 40 }, (_, index) => ({ + ...parent, + id: `scroll-card-${index}`, + instanceId: `scroll-instance-${index}`, + displayName: `Surrounding workspace ${index}`, + isMainWorktree: false, + isPinned: false, + parentWorktreeId: null, + childWorktreeIds: [], + lineage: null, + sortOrder: 40 - index + })) + store.setState({ + sortBy: 'manual', + worktreesByRepo: { + ...state.worktreesByRepo, + [parent.repoId]: [ + ...state.worktreesByRepo[parent.repoId].map((row) => ({ + ...row, + sortOrder: row.id === parentId ? 16.5 : row.id === childId ? 16.4 : row.sortOrder + })), + ...surrounding + ] + } + }) + await store.getState().setKeybindingOverride('sidebar.childWorkspaces.toggle', ['Mod+Alt+H']) + }, family) + + const sidebar = orcaPage.locator('[data-worktree-sidebar]') + const parentRow = sidebar + .locator(`[role="option"][data-worktree-id=${JSON.stringify(family.parentId)}]`) + .first() + const childRow = sidebar + .locator(`[role="option"][data-worktree-id=${JSON.stringify(family.childId)}]`) + .first() + await orcaPage.getByRole('button', { name: 'Reveal active workspace' }).click() + await expect(parentRow).toBeVisible() + await parentRow.evaluate((row) => row.scrollIntoView({ block: 'center' })) + await expect.poll(() => sidebar.evaluate((element) => element.scrollTop)).toBeGreaterThan(0) + const geometry = () => + parentRow.evaluate((row) => ({ + top: row.getBoundingClientRect().top, + scrollTop: row.closest('[data-worktree-sidebar]')?.scrollTop ?? 0 + })) + await waitForLineageScrollFixtureReady(orcaPage, family.parentId) + const before = await geometry() + await orcaPage.mouse.move(1150, 400) + await orcaPage.keyboard.press('ControlOrMeta+Alt+KeyH') + await expect(childRow).toBeHidden() + await expect.poll(async () => Math.abs((await geometry()).top - before.top)).toBeLessThan(2) + expect((await geometry()).scrollTop).toBeGreaterThan(0) + const collapsedProof = testInfo.outputPath('keyboard-collapsed-parent-anchor.png') + await sidebar.screenshot({ path: collapsedProof }) + await testInfo.attach('keyboard-collapsed-parent-anchor', { + path: collapsedProof, + contentType: 'image/png' + }) + + await parentRow.getByRole('button', { name: 'Show 1 child workspace' }).click() + await expect(childRow).toBeVisible() + await expect.poll(async () => Math.abs((await geometry()).top - before.top)).toBeLessThan(2) + await orcaPage.mouse.move(1150, 400) + await orcaPage.keyboard.press('ControlOrMeta+Alt+KeyH') + await expect(childRow).toBeHidden() + await orcaPage.evaluate(() => window.__store?.getState().setSidebarOpen(false)) + await expect(sidebar).toBeHidden() + await orcaPage.keyboard.press('ControlOrMeta+Alt+KeyH') + await expect(sidebar).toBeVisible() + await expect(childRow).toBeVisible() +}) diff --git a/tests/e2e/worktree-lineage-toggle-shortcut.spec.ts b/tests/e2e/worktree-lineage-toggle-shortcut.spec.ts new file mode 100644 index 00000000000..9d061af8518 --- /dev/null +++ b/tests/e2e/worktree-lineage-toggle-shortcut.spec.ts @@ -0,0 +1,485 @@ +import type { Page } from '@stablyai/playwright-test' +import { mkdirSync } from 'node:fs' +import { join } from 'node:path' +import { test, expect } from './helpers/orca-app' +import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca-restart' +import { seedLineageScenario, seedWorkspaceLiveTerminal } from './worktree-lineage-state' + +test.use({ launchEnv: { ORCA_BACKGROUND_LAUNCH: '1' } }) + +const ACTION_ID = 'sidebar.childWorkspaces.toggle' as const +const CHORD = 'ControlOrMeta+Alt+KeyH' + +function sidebarWorktreeRow(page: Page, worktreeId: string) { + return page + .locator( + `[data-worktree-sidebar] [role="option"][data-worktree-id=${JSON.stringify(worktreeId)}]` + ) + .first() +} + +async function waitForLineageWorktrees(page: Page): Promise { + await expect + .poll( + () => + page.evaluate(async () => { + const store = window.__store + if (!store) { + return 0 + } + for (const repo of store.getState().repos) { + await store.getState().fetchWorktrees(repo.id) + } + return Object.values(store.getState().worktreesByRepo) + .flat() + .filter((worktree) => !worktree.isArchived).length + }), + { timeout: 30_000 } + ) + .toBeGreaterThanOrEqual(2) +} + +async function captureEvidence(page: Page, name: string): Promise { + if (process.env.ORCA_CAPTURE_EVIDENCE !== '1') { + return + } + const directory = join(process.cwd(), 'pr-evidence') + mkdirSync(directory, { recursive: true }) + await page.screenshot({ path: join(directory, name) }) +} + +async function setToggleBinding(page: Page, bindings: string[] | null): Promise { + await page.evaluate( + async ({ actionId, bindings }) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + const state = store.getState() + await (bindings + ? state.setKeybindingOverride(actionId, bindings) + : state.resetKeybindingOverride(actionId)) + }, + { actionId: ACTION_ID, bindings } + ) +} + +async function movePointerOffSidebar(page: Page): Promise { + const viewport = + page.viewportSize() ?? + (await page.evaluate(() => ({ + width: window.innerWidth, + height: window.innerHeight + }))) + await page.mouse.move(viewport.width - 20, viewport.height / 2) +} + +async function observeShortcutClaim(page: Page): Promise { + await page.evaluate(() => { + window.addEventListener('keydown', (event) => { + if (event.code === 'KeyH') { + document.body.dataset.childToggleClaimed = String(event.defaultPrevented) + } + }) + }) +} + +test.describe('Toggle Child Workspaces shortcut', () => { + test.beforeEach(async ({ orcaPage, electronApp }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + expect( + await electronApp.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows().every((window) => !window.isVisible()) + ) + ).toBe(true) + }) + + test.afterEach(async ({ orcaPage }) => { + await setToggleBinding(orcaPage, null) + }) + + test('Settings exposes the unbound action and records an assigned shortcut', async ({ + orcaPage + }) => { + await orcaPage.evaluate(async () => { + await window.__store?.getState().updateSettings({ uiLanguage: 'en' }) + window.__store?.getState().openSettingsPage() + }) + await orcaPage.getByPlaceholder('Search settings').fill('shortcuts') + const search = orcaPage.getByPlaceholder('Search command or keys') + await expect(search).toBeVisible() + await search.fill('Toggle Child Workspaces') + await expect(orcaPage.getByText('Toggle Child Workspaces', { exact: true })).toBeVisible() + const add = orcaPage.getByRole('button', { name: 'Add shortcut for Toggle Child Workspaces' }) + await expect(add).toBeVisible() + await captureEvidence(orcaPage, 'child-toggle-settings-unbound.png') + + await add.click() + const recorder = orcaPage.locator('[data-shortcut-recorder-active]') + await expect(recorder).toBeVisible() + await recorder.press(CHORD) + await expect( + orcaPage.getByRole('button', { name: 'Change shortcut for Toggle Child Workspaces' }) + ).toBeVisible() + await captureEvidence(orcaPage, 'child-toggle-settings-assigned.png') + }) + + test('hides and shows children of the active or hovered workspace', async ({ orcaPage }) => { + const { parentId, childId } = await seedLineageScenario(orcaPage) + await setToggleBinding(orcaPage, ['Mod+Alt+H']) + const parentRow = sidebarWorktreeRow(orcaPage, parentId) + const childRow = sidebarWorktreeRow(orcaPage, childId) + + await parentRow.click() + await expect(parentRow).toHaveAttribute('aria-current', 'page') + await expect(childRow).toBeVisible() + await captureEvidence(orcaPage, 'child-toggle-sidebar-expanded.png') + + // No hovered card: the active parent is the target. + await movePointerOffSidebar(orcaPage) + await orcaPage.keyboard.press(CHORD) + await expect(childRow).toBeHidden() + await expect(parentRow.getByRole('button', { name: 'Show 1 child workspace' })).toBeVisible() + await captureEvidence(orcaPage, 'child-toggle-sidebar-collapsed.png') + + await orcaPage.keyboard.press(CHORD) + await expect(childRow).toBeVisible() + await expect(parentRow.getByRole('button', { name: 'Hide 1 child workspace' })).toBeVisible() + + await childRow.click() + await expect(childRow).toHaveAttribute('aria-current', 'page') + await movePointerOffSidebar(orcaPage) + await orcaPage.keyboard.press(CHORD) + await expect(childRow).toBeHidden() + await orcaPage.keyboard.press(CHORD) + await expect(childRow).toBeVisible() + await parentRow.click() + + // A hovered leaf child folds its parent. + await childRow.hover() + await orcaPage.keyboard.press(CHORD) + await expect(childRow).toBeHidden() + await expect(parentRow.getByRole('button', { name: 'Show 1 child workspace' })).toBeVisible() + + // A hovered parent unfolds its own children. + await parentRow.hover() + await orcaPage.keyboard.press(CHORD) + await expect(childRow).toBeVisible() + }) + + test('a modal keeps its shortcut input while the parent chip is visible', async ({ + orcaPage + }) => { + const { parentId, childId } = await seedLineageScenario(orcaPage) + await setToggleBinding(orcaPage, ['Mod+Alt+H']) + await orcaPage.evaluate(() => window.__store?.getState().openModal('worktree-palette')) + await expect(orcaPage.getByRole('dialog')).toBeVisible() + await orcaPage.getByRole('dialog').evaluate((dialog) => { + dialog.setAttribute('tabindex', '-1') + dialog.focus() + }) + await orcaPage.keyboard.press(CHORD) + await expect( + sidebarWorktreeRow(orcaPage, parentId).getByRole('button', { + name: 'Hide 1 child workspace', + includeHidden: true + }) + ).toBeVisible() + await expect(sidebarWorktreeRow(orcaPage, childId)).toBeVisible() + await orcaPage.keyboard.press('Escape') + }) + + test('a hovered parent takes precedence over another active family', async ({ orcaPage }) => { + const { parentId, childId } = await seedLineageScenario(orcaPage) + await setToggleBinding(orcaPage, ['Mod+Alt+H']) + const other = await orcaPage.evaluate( + ({ parentId, childId }) => { + const store = window.__store + if (!store) { + throw new Error('Missing store') + } + const state = store.getState() + const worktrees = Object.values(state.worktreesByRepo).flat() + const parent = worktrees.find((worktree) => worktree.id === parentId) + const child = worktrees.find((worktree) => worktree.id === childId) + const edge = state.worktreeLineageById[childId] + if (!parent || !child || !edge) { + throw new Error('Missing seeded family') + } + const otherParent = { + ...parent, + id: `${parentId}-other`, + instanceId: 'other-parent', + displayName: 'Another parent' + } + const otherChild = { + ...child, + id: `${childId}-other`, + instanceId: 'other-child', + displayName: 'Another child' + } + store.setState({ + worktreesByRepo: { + ...state.worktreesByRepo, + [parent.repoId]: [...state.worktreesByRepo[parent.repoId], otherParent, otherChild] + }, + worktreeLineageById: { + ...state.worktreeLineageById, + [otherChild.id]: { + ...edge, + worktreeId: otherChild.id, + worktreeInstanceId: otherChild.instanceId, + parentWorktreeId: otherParent.id, + parentWorktreeInstanceId: otherParent.instanceId + } + } + }) + return { parentId: otherParent.id, childId: otherChild.id } + }, + { parentId, childId } + ) + await sidebarWorktreeRow(orcaPage, other.parentId).hover() + await orcaPage.keyboard.press(CHORD) + await expect(sidebarWorktreeRow(orcaPage, other.childId)).toBeHidden() + await expect(sidebarWorktreeRow(orcaPage, childId)).toBeVisible() + await captureEvidence(orcaPage, 'child-toggle-hover-precedence.png') + }) + + test('a focused floating workspace keeps the shortcut input', async ({ orcaPage }) => { + const { parentId, childId } = await seedLineageScenario(orcaPage) + await setToggleBinding(orcaPage, ['Mod+Alt+H']) + await orcaPage.getByRole('button', { name: 'Show floating workspace', exact: true }).click() + const panel = orcaPage.locator('[data-floating-terminal-panel][aria-hidden="false"]') + await expect(panel).toBeVisible() + await panel.locator('[data-floating-terminal-shortcut-surface]').first().focus() + await orcaPage.keyboard.press(CHORD) + await expect( + sidebarWorktreeRow(orcaPage, parentId).getByRole('button', { name: 'Hide 1 child workspace' }) + ).toBeVisible() + await expect(sidebarWorktreeRow(orcaPage, childId)).toBeVisible() + await captureEvidence(orcaPage, 'child-toggle-floating-guard.png') + }) + + test('a stale child instance leaves no chip and does not claim the chord', async ({ + orcaPage + }) => { + const { parentId, childId } = await seedLineageScenario(orcaPage) + await setToggleBinding(orcaPage, ['Mod+Alt+H']) + await orcaPage.evaluate((childId) => { + window.__store?.setState((state) => ({ + worktreesByRepo: Object.fromEntries( + Object.entries(state.worktreesByRepo).map(([repoId, rows]) => [ + repoId, + rows.map((row) => + row.id === childId ? { ...row, instanceId: 'recreated-child' } : row + ) + ]) + ) + })) + }, childId) + const parentRow = sidebarWorktreeRow(orcaPage, parentId) + await expect(parentRow.getByRole('button', { name: /child workspace/ })).toHaveCount(0) + await parentRow.click() + await orcaPage.locator('[data-worktree-sidebar]').focus() + await movePointerOffSidebar(orcaPage) + await observeShortcutClaim(orcaPage) + await orcaPage.keyboard.press(CHORD) + await expect(orcaPage.locator('body')).toHaveAttribute('data-child-toggle-claimed', 'false') + await expect(sidebarWorktreeRow(orcaPage, childId)).toBeVisible() + await captureEvidence(orcaPage, 'child-toggle-stale-no-chip.png') + }) + + test('folder cards pass the chord through while another family has a chip', async ({ + orcaPage + }) => { + const { parentId, childId } = await seedLineageScenario(orcaPage) + await setToggleBinding(orcaPage, ['Mod+Alt+H']) + const folderKey = 'folder:child-toggle-folder' + await orcaPage.evaluate(() => { + const store = window.__store + const repo = store?.getState().repos[0] + if (!store || !repo) { + throw new Error('Missing seeded project') + } + store.setState({ + projectGroups: [ + { + id: 'child-toggle-group', + name: 'Folder project', + parentPath: repo.path, + parentGroupId: null, + createdFrom: 'manual', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 + } + ], + folderWorkspaces: [ + { + id: 'child-toggle-folder', + projectGroupId: 'child-toggle-group', + name: 'Folder without a child chip', + folderPath: repo.path, + executionHostId: 'local', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 1, + createdAt: 1, + updatedAt: 1 + } + ] + }) + }) + const folder = sidebarWorktreeRow(orcaPage, folderKey) + await expect(folder).toBeVisible() + await expect(folder.getByRole('button', { name: /child workspace/ })).toHaveCount(0) + await sidebarWorktreeRow(orcaPage, parentId).click() + await orcaPage.locator('[data-worktree-sidebar]').focus() + await folder.hover() + await observeShortcutClaim(orcaPage) + await orcaPage.keyboard.press(CHORD) + await expect(orcaPage.locator('body')).toHaveAttribute('data-child-toggle-claimed', 'false') + await expect(sidebarWorktreeRow(orcaPage, childId)).toBeVisible() + await folder.click() + await expect(folder).toHaveAttribute('aria-current', 'page') + await orcaPage.locator('[data-worktree-sidebar]').focus() + await movePointerOffSidebar(orcaPage) + await orcaPage.keyboard.press(CHORD) + await expect(orcaPage.locator('body')).toHaveAttribute('data-child-toggle-claimed', 'false') + await expect(sidebarWorktreeRow(orcaPage, childId)).toBeVisible() + await captureEvidence(orcaPage, 'child-toggle-folder-pass-through.png') + }) + + test('pinned duplicates share the chip state and record the sidebar anchor', async ({ + orcaPage + }) => { + const { parentId, childId } = await seedLineageScenario(orcaPage) + await setToggleBinding(orcaPage, ['Mod+Alt+H']) + await orcaPage.evaluate(async (parentId) => { + await window.__store?.getState().updateSettings({ showPinnedWorktreesInGroups: true }) + window.__store?.setState((state) => ({ + worktreesByRepo: Object.fromEntries( + Object.entries(state.worktreesByRepo).map(([repoId, rows]) => [ + repoId, + rows.map((row) => (row.id === parentId ? { ...row, isPinned: true } : row)) + ]) + ) + })) + const sidebar = document.querySelector('[data-worktree-sidebar]') + sidebar?.addEventListener('orca-record-virtualized-scroll-anchor', () => { + sidebar.dataset.anchorRecords = String(Number(sidebar.dataset.anchorRecords ?? 0) + 1) + }) + }, parentId) + const chips = orcaPage.getByRole('button', { name: 'Hide 1 child workspace', exact: true }) + await expect(chips).toHaveCount(2) + const natural = orcaPage + .locator('[data-worktree-section-key="all"]') + .filter({ has: chips }) + .first() + await natural.hover() + await orcaPage.keyboard.press(CHORD) + await expect( + orcaPage.getByRole('button', { name: 'Show 1 child workspace', exact: true }) + ).toHaveCount(2) + await expect(orcaPage.locator('[data-worktree-sidebar]')).toHaveAttribute( + 'data-anchor-records', + '1' + ) + await expect(sidebarWorktreeRow(orcaPage, childId)).toBeHidden() + await orcaPage + .getByRole('button', { name: 'Show 1 child workspace', exact: true }) + .first() + .click() + await expect(chips).toHaveCount(2) + await expect(orcaPage.locator('[data-worktree-sidebar]')).toHaveAttribute( + 'data-anchor-records', + '2' + ) + await captureEvidence(orcaPage, 'child-toggle-pinned-chip-parity.png') + }) + + test('does nothing while a sidebar filter hides every child', async ({ orcaPage }) => { + const { parentId, childId } = await seedLineageScenario(orcaPage) + await setToggleBinding(orcaPage, ['Mod+Alt+H']) + await seedWorkspaceLiveTerminal(orcaPage, parentId) + const parentRow = sidebarWorktreeRow(orcaPage, parentId) + const childRow = sidebarWorktreeRow(orcaPage, childId) + await parentRow.click() + await expect(parentRow).toHaveAttribute('aria-current', 'page') + + const readCollapsedGroups = (): Promise => + orcaPage.evaluate(() => [...(window.__store?.getState().collapsedGroups ?? [])].sort()) + const setShowSleeping = (show: boolean): Promise => + orcaPage.evaluate((show) => window.__store?.getState().setShowSleepingWorkspaces(show), show) + + // The child has no live terminal, so hiding sleeping workspaces removes it and its chip. + await setShowSleeping(false) + try { + await expect(childRow).toBeHidden() + await expect(parentRow.getByRole('button', { name: /child workspace/ })).toHaveCount(0) + const before = await readCollapsedGroups() + + // Checked after each press: two toggles of one key would cancel out. + await movePointerOffSidebar(orcaPage) + await orcaPage.keyboard.press(CHORD) + expect(await readCollapsedGroups()).toEqual(before) + await parentRow.hover() + await orcaPage.keyboard.press(CHORD) + expect(await readCollapsedGroups()).toEqual(before) + } finally { + await setShowSleeping(true) + } + await expect(childRow).toBeVisible() + }) +}) + +test('child-workspace shortcut and chip collapse survive an app restart', async ({ + testRepoPath +}, testInfo) => { + const session = createRestartSession(testInfo) + let app: Awaited>['app'] | undefined + try { + const first = await session.launch() + app = first.app + await attachRepoAndOpenTerminal(first.page, testRepoPath) + await waitForLineageWorktrees(first.page) + const family = await seedLineageScenario(first.page) + await setToggleBinding(first.page, ['Mod+Alt+H']) + await sidebarWorktreeRow(first.page, family.parentId).click() + await movePointerOffSidebar(first.page) + await first.page.keyboard.press(CHORD) + await expect(sidebarWorktreeRow(first.page, family.childId)).toBeHidden() + await session.close(app) + app = undefined + + const second = await session.launch() + app = second.app + await attachRepoAndOpenTerminal(second.page, testRepoPath) + await waitForLineageWorktrees(second.page) + // Resetting the grouping mode also clears the persisted collapsed groups. + await seedLineageScenario(second.page, { preserveGrouping: true }) + await expect( + sidebarWorktreeRow(second.page, family.parentId).getByRole('button', { + name: 'Show 1 child workspace' + }) + ).toBeVisible() + await expect(sidebarWorktreeRow(second.page, family.childId)).toBeHidden() + await movePointerOffSidebar(second.page) + await second.page.keyboard.press(CHORD) + await expect(sidebarWorktreeRow(second.page, family.childId)).toBeVisible() + await captureEvidence(second.page, 'child-toggle-restart-persistence.png') + } finally { + if (app) { + await session.close(app) + } + await session.dispose() + } +}) From f8081c53130a115b0a71f98e608d7b35b8b442c0 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:15:48 -0700 Subject: [PATCH 40/67] Filter Markdown filenames before sending the listing to the app (#25148) --- src/main/ipc/markdown-documents.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/ipc/markdown-documents.ts b/src/main/ipc/markdown-documents.ts index 6695c7aa89c..9bf263d56d4 100644 --- a/src/main/ipc/markdown-documents.ts +++ b/src/main/ipc/markdown-documents.ts @@ -111,9 +111,9 @@ export async function listMarkdownDocuments( '--null', '--path-separator', '/', - // Directory-only globs preserve hidden Markdown files without traversing hidden folders. + // Keep case variants in --glob: --iglob is applied after exclusions and can reopen hidden folders. '--glob', - '**', + '*.{[mM][dD],[mM][dD][xX],[mM][aA][rR][kK][dD][oO][wW][nN]}', '--glob', '!**/.*/', '--glob', From 8267b578b2891e9986b78f67e47be04fb321ec36 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:17:52 -0700 Subject: [PATCH 41/67] Fix Markdown Find editing without moving the caret or viewport (#25144) * Fix Markdown Find editing without changing the caret or viewport * Preserve Markdown selections across search focus and stale updates --- .../editor/RichMarkdownEditorSurface.tsx | 2 + ...-markdown-search-focus.adversarial.test.ts | 137 ++++++++ .../editor/rich-markdown-search-focus.test.ts | 162 +++++++++ .../editor/rich-markdown-search-focus.ts | 44 +++ ...useRichMarkdownSearch.adversarial.test.tsx | 312 ++++++++++++++++++ .../useRichMarkdownSearch.editing.test.tsx | 139 ++++++++ .../editor/useRichMarkdownSearch.ts | 102 +++--- .../editor/useRichMarkdownSearchHighlights.ts | 104 ++++++ tests/e2e/markdown-find-adversarial.spec.ts | 307 +++++++++++++++++ tests/e2e/markdown-find-editing.spec.ts | 215 ++++++++++++ 10 files changed, 1459 insertions(+), 65 deletions(-) create mode 100644 src/renderer/src/components/editor/rich-markdown-search-focus.adversarial.test.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-search-focus.test.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-search-focus.ts create mode 100644 src/renderer/src/components/editor/useRichMarkdownSearch.adversarial.test.tsx create mode 100644 src/renderer/src/components/editor/useRichMarkdownSearch.editing.test.tsx create mode 100644 src/renderer/src/components/editor/useRichMarkdownSearchHighlights.ts create mode 100644 tests/e2e/markdown-find-adversarial.spec.ts create mode 100644 tests/e2e/markdown-find-editing.spec.ts diff --git a/src/renderer/src/components/editor/RichMarkdownEditorSurface.tsx b/src/renderer/src/components/editor/RichMarkdownEditorSurface.tsx index 8e2ca1e4abb..bf7fcc06936 100644 --- a/src/renderer/src/components/editor/RichMarkdownEditorSurface.tsx +++ b/src/renderer/src/components/editor/RichMarkdownEditorSurface.tsx @@ -20,6 +20,7 @@ import type { MarkdownReviewNote } from '@/lib/markdown-review-notes' import type { RichMarkdownAnnotationTarget } from './rich-markdown-review-annotations' import type { RichMarkdownReviewNotePosition } from './rich-markdown-review-note-layout' import type { DiffComment } from '../../../../shared/diff-comment-types' +import { focusRichMarkdownEditorFromSearch } from './rich-markdown-search-focus' function shouldFocusEmptyEditorFromSurfaceClick( event: React.MouseEvent, @@ -209,6 +210,7 @@ export function RichMarkdownEditorSurface({ // Image layout must not anchor-scroll over the restored tab position. className="relative h-full overflow-auto scrollbar-editor [overflow-anchor:none]" onMouseDown={(event) => { + focusRichMarkdownEditorFromSearch(event.nativeEvent, editor?.view ?? null) if (!shouldFocusEmptyEditorFromSurfaceClick(event, editor)) { return } diff --git a/src/renderer/src/components/editor/rich-markdown-search-focus.adversarial.test.ts b/src/renderer/src/components/editor/rich-markdown-search-focus.adversarial.test.ts new file mode 100644 index 00000000000..4265cacb0fb --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-search-focus.adversarial.test.ts @@ -0,0 +1,137 @@ +// @vitest-environment happy-dom +import { Editor } from '@tiptap/core' +import StarterKit from '@tiptap/starter-kit' +import { TableKit } from '@tiptap/extension-table' +import { CellSelection } from '@tiptap/pm/tables' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { focusRichMarkdownEditorFromSearch } from './rich-markdown-search-focus' + +function findTextPosition(editor: Editor, text: string): number { + let position: number | null = null + editor.state.doc.descendants((node, pos) => { + if (node.isText && node.text === text) { + position = pos + return false + } + return true + }) + if (position === null) { + throw new Error(`Expected editor text: ${text}`) + } + return position +} + +function createSurface(content: string) { + const root = document.createElement('div') + root.className = 'rich-markdown-editor-shell' + document.body.append(root) + const editor = new Editor({ extensions: [StarterKit, TableKit], content }) + root.append(editor.view.dom) + const search = document.createElement('div') + search.className = 'rich-markdown-search' + const input = document.createElement('input') + search.append(input) + root.append(search) + root.addEventListener('mousedown', (event) => { + if (event instanceof MouseEvent) { + focusRichMarkdownEditorFromSearch(event, editor.view) + } + }) + return { editor, input } +} + +afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + document.body.replaceChildren() +}) + +describe('Find focus with editor-owned selection', () => { + it('restores the remembered caret before Shift click when Find owns the native selection', () => { + vi.useFakeTimers() + const { editor, input } = createSurface('

first

lower caret

') + const caret = findTextPosition(editor, 'lower caret') + 6 + const updates = vi.fn() + editor.on('update', updates) + try { + editor.commands.setTextSelection(caret) + input.focus() + document.getSelection()?.removeAllRanges() + const paragraph = editor.view.dom.querySelector('p:last-child') + const text = paragraph?.firstChild + if (!paragraph || !text) { + throw new Error('Expected lower paragraph text') + } + vi.spyOn(editor.view, 'posAtCoords').mockReturnValue({ pos: caret, inside: -1 }) + const originalDoc = editor.state.doc + const selection = editor.state.selection + const event = new MouseEvent('mousedown', { + button: 0, + shiftKey: true, + bubbles: true, + cancelable: true + }) + + paragraph.dispatchEvent(event) + + expect(document.activeElement).toBe(editor.view.dom) + expect(document.getSelection()?.anchorNode).toBe(text) + expect(document.getSelection()?.anchorOffset).toBe(6) + expect(document.getSelection()?.focusOffset).toBe(6) + expect(event.defaultPrevented).toBe(false) + + vi.advanceTimersByTime(20) + + expect(editor.state.selection).toBe(selection) + expect(editor.state.doc).toBe(originalDoc) + expect(updates).not.toHaveBeenCalled() + } finally { + editor.destroy() + } + }) + + it('focuses a handled cross-cell selection and preserves its cells without marking content dirty', () => { + vi.useFakeTimers() + const { editor, input } = createSurface( + '
firstsecond
' + ) + const updates = vi.fn() + try { + editor.commands.setTextSelection(findTextPosition(editor, 'first')) + editor.on('update', updates) + input.focus() + const secondPos = findTextPosition(editor, 'second') + vi.spyOn(editor.view, 'posAtCoords').mockReturnValue({ pos: secondPos, inside: secondPos }) + const second = editor.view.dom.querySelector('td:last-child p') + if (!second) { + throw new Error('Expected second table cell') + } + const originalDoc = editor.state.doc + const event = new MouseEvent('mousedown', { + button: 0, + shiftKey: true, + bubbles: true, + cancelable: true + }) + + second.dispatchEvent(event) + + const selection = editor.state.selection + expect(selection).toBeInstanceOf(CellSelection) + expect(event.defaultPrevented).toBe(true) + expect(document.activeElement).toBe(editor.view.dom) + expect(editor.view.dom.querySelectorAll('.selectedCell')).toHaveLength(2) + expect(editor.view.dom.classList.contains('ProseMirror-hideselection')).toBe(true) + expect(document.getSelection()?.rangeCount).toBe(1) + + vi.advanceTimersByTime(20) + + expect(editor.state.selection).toBe(selection) + expect(editor.state.doc).toBe(originalDoc) + expect(updates).not.toHaveBeenCalled() + expect(editor.view.dom.querySelectorAll('.selectedCell')).toHaveLength(2) + } finally { + editor.destroy() + } + }) +}) diff --git a/src/renderer/src/components/editor/rich-markdown-search-focus.test.ts b/src/renderer/src/components/editor/rich-markdown-search-focus.test.ts new file mode 100644 index 00000000000..d158f4466c9 --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-search-focus.test.ts @@ -0,0 +1,162 @@ +// @vitest-environment happy-dom +import { Schema } from '@tiptap/pm/model' +import { EditorState } from '@tiptap/pm/state' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { focusRichMarkdownEditorFromSearch } from './rich-markdown-search-focus' + +const schema = new Schema({ + nodes: { + doc: { content: 'paragraph+' }, + paragraph: { content: 'text*' }, + text: {} + } +}) + +function createSurface() { + const root = document.createElement('div') + root.className = 'rich-markdown-editor-shell' + const editorDom = document.createElement('div') + editorDom.contentEditable = 'true' + editorDom.tabIndex = -1 + const paragraph = document.createElement('p') + paragraph.textContent = 'First editable paragraph' + editorDom.append(paragraph) + const search = document.createElement('div') + search.className = 'rich-markdown-search' + const findInput = document.createElement('input') + const replaceInput = document.createElement('input') + search.append(findInput, replaceInput) + root.append(editorDom, search) + document.body.append(root) + const focus = vi.spyOn(editorDom, 'focus') + const viewFocus = vi.fn(() => editorDom.focus({ preventScroll: true })) + const view = { dom: editorDom, focus: viewFocus, state: EditorState.create({ schema }) } + root.addEventListener('mousedown', (event) => { + if (event instanceof MouseEvent) { + focusRichMarkdownEditorFromSearch(event, view) + } + }) + return { root, editorDom, paragraph, findInput, replaceInput, focus, viewFocus } +} + +afterEach(() => { + vi.restoreAllMocks() + document.body.replaceChildren() +}) + +describe('rich markdown search focus handoff', () => { + it.each(['find', 'replace'] as const)( + 'returns keyboard focus from %s without preventing native selection or requesting scroll', + (field) => { + const { paragraph, editorDom, findInput, replaceInput, focus, viewFocus } = createSurface() + const input = field === 'find' ? findInput : replaceInput + input.focus() + const event = new MouseEvent('mousedown', { bubbles: true, cancelable: true, button: 0 }) + + paragraph.dispatchEvent(event) + + expect(document.activeElement).toBe(editorDom) + expect(focus).toHaveBeenCalledExactlyOnceWith({ preventScroll: true }) + expect(viewFocus).not.toHaveBeenCalled() + expect(event.defaultPrevented).toBe(false) + } + ) + + it('restores the editor selection before native Shift click handling', () => { + const { paragraph, editorDom, findInput, viewFocus } = createSurface() + findInput.focus() + const event = new MouseEvent('mousedown', { + bubbles: true, + cancelable: true, + button: 0, + shiftKey: true + }) + + paragraph.dispatchEvent(event) + + expect(document.activeElement).toBe(editorDom) + expect(viewFocus).toHaveBeenCalledExactlyOnceWith() + expect(event.defaultPrevented).toBe(false) + }) + + it.each(['button', 'input', 'textarea', 'select', 'noneditable', 'task-label'])( + 'leaves embedded %s controls in charge of focus', + (kind) => { + const { editorDom, findInput, focus } = createSurface() + const control = document.createElement( + kind === 'noneditable' ? 'div' : kind === 'task-label' ? 'label' : kind + ) + if (kind === 'noneditable' || kind === 'task-label') { + control.setAttribute('contenteditable', 'false') + } + const child = document.createElement('span') + control.append(child) + editorDom.append(control) + findInput.focus() + + child.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0 })) + + expect(document.activeElement).toBe(findInput) + expect(focus).not.toHaveBeenCalled() + } + ) + + it('does not steal focus from a different editor find widget or unrelated field', () => { + const first = createSurface() + const second = createSurface() + const unrelated = document.createElement('input') + document.body.append(unrelated) + + for (const input of [second.findInput, unrelated]) { + input.focus() + first.paragraph.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0 })) + expect(document.activeElement).toBe(input) + } + + expect(first.focus).not.toHaveBeenCalled() + }) + + it('leaves ordinary editor clicks and blank surface clicks untouched', () => { + const { root, editorDom, paragraph, findInput, focus } = createSurface() + editorDom.focus() + focus.mockClear() + paragraph.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0 })) + findInput.focus() + root.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0 })) + + expect(focus).not.toHaveBeenCalled() + expect(document.activeElement).toBe(findInput) + }) + + it('leaves canceled and non-primary presses untouched', () => { + const { paragraph, findInput, focus } = createSurface() + findInput.focus() + const canceled = new MouseEvent('mousedown', { bubbles: true, cancelable: true, button: 0 }) + canceled.preventDefault() + + for (const event of [ + canceled, + new MouseEvent('mousedown', { bubbles: true, button: 1 }), + new MouseEvent('mousedown', { bubbles: true, button: 2 }) + ]) { + paragraph.dispatchEvent(event) + } + + expect(focus).not.toHaveBeenCalled() + expect(document.activeElement).toBe(findInput) + }) + + it('allows editable content nested inside an unrelated noneditable ancestor', () => { + const { root, paragraph, findInput, focus } = createSurface() + root.setAttribute('contenteditable', 'false') + findInput.focus() + + paragraph.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0 })) + + expect(focus).toHaveBeenCalledExactlyOnceWith({ preventScroll: true }) + }) + + it('does nothing when the editor has not mounted', () => { + focusRichMarkdownEditorFromSearch(new MouseEvent('mousedown', { button: 0 }), null) + }) +}) diff --git a/src/renderer/src/components/editor/rich-markdown-search-focus.ts b/src/renderer/src/components/editor/rich-markdown-search-focus.ts new file mode 100644 index 00000000000..8ef1e0f50c8 --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-search-focus.ts @@ -0,0 +1,44 @@ +import type { EditorView } from '@tiptap/pm/view' +import { CellSelection } from '@tiptap/pm/tables' + +export function focusRichMarkdownEditorFromSearch( + event: MouseEvent, + view: Pick | null +): void { + if ( + !view || + event.button !== 0 || + (event.defaultPrevented && !(view.state.selection instanceof CellSelection)) + ) { + return + } + + const editorDom = view.dom + const target = event.target + if (!(target instanceof Element) || !editorDom.contains(target)) { + return + } + + const root = editorDom.closest('.rich-markdown-editor-shell') + const activeElement = editorDom.ownerDocument.activeElement + if ( + !root || + !activeElement?.closest('.rich-markdown-search') || + activeElement.closest('.rich-markdown-editor-shell') !== root + ) { + return + } + + const control = target.closest('button, input, textarea, select, [contenteditable="false"]') + if (control && editorDom.contains(control)) { + return + } + + if (event.shiftKey || event.defaultPrevented) { + // Shift extends the current selection; handled cell selection has no browser default. + view.focus() + } else { + // Native focus preserves the browser's upcoming click or drag selection. + editorDom.focus({ preventScroll: true }) + } +} diff --git a/src/renderer/src/components/editor/useRichMarkdownSearch.adversarial.test.tsx b/src/renderer/src/components/editor/useRichMarkdownSearch.adversarial.test.tsx new file mode 100644 index 00000000000..8eb7f376d47 --- /dev/null +++ b/src/renderer/src/components/editor/useRichMarkdownSearch.adversarial.test.tsx @@ -0,0 +1,312 @@ +// @vitest-environment happy-dom +import { StrictMode, useLayoutEffect, type ReactNode } from 'react' +import { act, renderHook } from '@testing-library/react' +import { Editor } from '@tiptap/react' +import StarterKit from '@tiptap/starter-kit' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { useRichMarkdownSearch } from './useRichMarkdownSearch' + +vi.mock('@/store', () => ({ + useAppStore: (select: (state: unknown) => unknown) => select({ keybindings: {} }) +})) + +const cleanups: (() => void)[] = [] + +function mountSearch(content = '

beta beta

Edit here

', strict = false) { + vi.useFakeTimers() + const editor = new Editor({ extensions: [StarterKit], content }) + const root = document.createElement('div') + root.className = 'rich-markdown-editor-shell' + const searchBar = document.createElement('div') + searchBar.className = 'rich-markdown-search' + const input = document.createElement('input') + const otherControl = document.createElement('input') + searchBar.append(input) + root.append(editor.view.dom, searchBar) + document.body.append(root, otherControl) + const scrollContainer = document.createElement('div') + const rootRef = { current: root } + const scrollContainerRef = { current: scrollContainer } + const beforePassiveRef: { current: (() => void) | null } = { current: null } + const scrollTo = vi.spyOn(scrollContainer, 'scrollTo') + vi.spyOn(editor.view, 'coordsAtPos').mockReturnValue({ top: 20, bottom: 40, left: 0, right: 0 }) + const initialProps: { currentEditor: Editor | null } = { currentEditor: editor } + const hook = renderHook( + ({ currentEditor }: { currentEditor: Editor | null }) => { + const result = useRichMarkdownSearch({ editor: currentEditor, rootRef, scrollContainerRef }) + result.searchState.searchInputRef.current = input + useLayoutEffect(() => { + const callback = beforePassiveRef.current + beforePassiveRef.current = null + callback?.() + }) + return result + }, + { + initialProps, + wrapper: strict + ? ({ children }: { children: ReactNode }) => {children} + : undefined + } + ) + cleanups.push(() => { + hook.unmount() + editor.destroy() + root.remove() + otherControl.remove() + }) + act(() => hook.result.current.openSearch()) + const query = (value: string) => { + act(() => hook.result.current.searchActions.setSearchQuery(value)) + act(() => vi.advanceTimersByTime(150)) + } + const selectedText = () => + editor.state.doc.textBetween(editor.state.selection.from, editor.state.selection.to) + const documentCaret = () => { + act(() => { + editor.view.dom.focus({ preventScroll: true }) + editor.commands.setTextSelection(editor.state.doc.content.size - 1) + }) + } + return { + editor, + root, + hook, + input, + otherControl, + scrollTo, + query, + selectedText, + documentCaret, + beforePassiveRef + } +} + +afterEach(() => { + while (cleanups.length > 0) { + cleanups.pop()?.() + } + vi.useRealTimers() + vi.restoreAllMocks() +}) + +describe('rich Markdown search adversarial interactions', () => { + it('does not revive a delayed search after a document click and another control focus', () => { + const { editor, hook, otherControl, query, documentCaret, scrollTo } = mountSearch() + query('beta') + act(() => hook.result.current.searchActions.setSearchQuery('Edit')) + documentCaret() + const selection = editor.state.selection + act(() => otherControl.focus()) + scrollTo.mockClear() + act(() => vi.advanceTimersByTime(150)) + expect(editor.state.selection.eq(selection)).toBe(true) + expect(scrollTo).not.toHaveBeenCalled() + expect(document.activeElement).toBe(otherControl) + }) + + it('continues typing at the document caret through case and whole-word changes', () => { + const { editor, hook, query, documentCaret, scrollTo } = mountSearch() + query('beta') + documentCaret() + scrollTo.mockClear() + act(() => hook.result.current.searchActions.toggleMatchCase()) + act(() => hook.result.current.searchActions.toggleWholeWord()) + act(() => editor.commands.insertContent('XY')) + expect(editor.getText()).toBe('beta beta\n\nEdit hereXY') + expect(scrollTo).not.toHaveBeenCalled() + }) + + it('lets explicit search navigation select the only match after document editing', () => { + const { editor, hook, query, documentCaret, selectedText, scrollTo } = mountSearch( + '

beta

Edit here

' + ) + query('beta') + documentCaret() + act(() => editor.commands.insertContent('X')) + scrollTo.mockClear() + act(() => hook.result.current.searchActions.moveToMatch(1)) + expect(selectedText()).toBe('beta') + expect(scrollTo).toHaveBeenCalledOnce() + expect(editor.isFocused).toBe(true) + }) + + it('advances past replacement text which still contains the search query', () => { + const { editor, hook, query, selectedText } = mountSearch() + query('beta') + act(() => hook.result.current.searchActions.setReplaceQuery('betaX')) + act(() => hook.result.current.searchActions.replaceCurrentMatch()) + expect(editor.getText()).toBe('betaX beta\n\nEdit here') + expect(selectedText()).toBe('beta') + expect(editor.state.selection.from).toBe(7) + act(() => hook.result.current.searchActions.replaceCurrentMatch()) + expect(editor.getText()).toBe('betaX betaX\n\nEdit here') + }) + + it('does not move the document caret when Replace All retains matching text', () => { + const { editor, hook, query, documentCaret, scrollTo } = mountSearch() + query('beta') + act(() => hook.result.current.searchActions.setReplaceQuery('betaX')) + documentCaret() + scrollTo.mockClear() + act(() => hook.result.current.searchActions.replaceAllMatches()) + expect(editor.getText()).toBe('betaX betaX\n\nEdit here') + expect(editor.state.selection.empty).toBe(true) + expect(editor.state.selection.from).toBe(editor.state.doc.content.size - 1) + expect(scrollTo).not.toHaveBeenCalled() + }) + + it('clamps navigation after external edits remove the active match', () => { + const { editor, hook, query, selectedText } = mountSearch('

beta beta beta

') + query('beta') + act(() => hook.result.current.searchActions.moveToMatch(1)) + act(() => hook.result.current.searchActions.moveToMatch(1)) + act(() => editor.commands.setContent('

beta

')) + expect(hook.result.current.searchState.activeMatchIndex).toBe(0) + act(() => hook.result.current.searchActions.moveToMatch(1)) + expect(selectedText()).toBe('beta') + }) + + it('keeps decorations fresh without moving selection through batched document edits', () => { + const { editor, hook, query, documentCaret, scrollTo } = mountSearch() + query('beta') + documentCaret() + scrollTo.mockClear() + act(() => { + editor.commands.insertContent(' beta') + editor.commands.insertContent(' beta') + }) + expect(hook.result.current.searchState.matchCount).toBe(4) + expect(editor.state.selection.from).toBe(editor.state.doc.content.size - 1) + expect(editor.state.selection.empty).toBe(true) + expect(scrollTo).not.toHaveBeenCalled() + }) + + it('does not dispatch stale match positions when the document changes before passive effects', () => { + const { editor, hook, beforePassiveRef } = mountSearch('

beta

target

') + act(() => hook.result.current.searchActions.setSearchQuery('target')) + beforePassiveRef.current = () => editor.commands.setContent('

x

') + expect(() => act(() => vi.advanceTimersByTime(150))).not.toThrow() + expect(editor.getText()).toBe('x') + expect(hook.result.current.searchState.matchCount).toBe(0) + }) + + it('cancels a pending query when the input is cleared', () => { + const { editor, hook, query, scrollTo } = mountSearch() + query('beta') + const selection = editor.state.selection + scrollTo.mockClear() + act(() => hook.result.current.searchActions.setSearchQuery('Edit')) + act(() => hook.result.current.searchActions.setSearchQuery('')) + act(() => vi.advanceTimersByTime(150)) + expect(hook.result.current.searchState.matchCount).toBe(0) + expect(editor.state.selection.eq(selection)).toBe(true) + expect(scrollTo).not.toHaveBeenCalled() + }) + + it('uses refreshed positions when the document changes before search navigation commits', () => { + const { editor, hook, beforePassiveRef, selectedText, scrollTo } = mountSearch( + '

beta

target

' + ) + act(() => hook.result.current.searchActions.setSearchQuery('target')) + beforePassiveRef.current = () => editor.commands.setContent('

prefix prefix target

') + scrollTo.mockClear() + act(() => vi.advanceTimersByTime(150)) + expect(hook.result.current.searchState.matchCount).toBe(1) + expect(selectedText()).toBe('target') + expect(editor.state.selection.from).toBe(15) + expect(scrollTo).toHaveBeenCalledOnce() + }) + + it('moves Next relative to the clamped active match after several matches disappear', () => { + const { editor, hook, query } = mountSearch('

beta beta beta beta beta

') + query('beta') + for (let index = 0; index < 4; index++) { + act(() => hook.result.current.searchActions.moveToMatch(1)) + } + expect(hook.result.current.searchState.activeMatchIndex).toBe(4) + act(() => editor.commands.setContent('

beta beta beta

')) + expect(hook.result.current.searchState.activeMatchIndex).toBe(0) + act(() => hook.result.current.searchActions.moveToMatch(1)) + expect(hook.result.current.searchState.activeMatchIndex).toBe(1) + expect(editor.state.selection.from).toBe(6) + }) + + it('does not navigate to an old query when replacing during the debounce window', () => { + const { editor, hook, query, scrollTo } = mountSearch() + query('beta') + act(() => hook.result.current.searchActions.setSearchQuery('Edit')) + act(() => hook.result.current.searchActions.setReplaceQuery('changed')) + act(() => editor.commands.setTextSelection(editor.state.doc.content.size - 1)) + scrollTo.mockClear() + act(() => hook.result.current.searchActions.replaceCurrentMatch()) + expect(editor.getText()).toBe('beta beta\n\nchanged here') + expect(editor.state.selection.empty).toBe(true) + expect(editor.state.selection.from).toBe(editor.state.doc.content.size - 1) + expect(scrollTo).not.toHaveBeenCalled() + }) + + it('survives strict effect replay, null editor, and a fresh editor instance', () => { + const { editor, hook, input, query, documentCaret, scrollTo } = mountSearch(undefined, true) + query('beta') + documentCaret() + act(() => editor.commands.insertContent('X')) + act(() => input.focus()) + act(() => hook.rerender({ currentEditor: null })) + const replacement = new Editor({ extensions: [StarterKit], content: '

beta

' }) + vi.spyOn(replacement.view, 'coordsAtPos').mockReturnValue({ + top: 20, + bottom: 40, + left: 0, + right: 0 + }) + scrollTo.mockClear() + act(() => hook.rerender({ currentEditor: replacement })) + expect(hook.result.current.searchState.matchCount).toBe(1) + expect( + replacement.state.doc.textBetween( + replacement.state.selection.from, + replacement.state.selection.to + ) + ).toBe('beta') + expect(scrollTo).toHaveBeenCalledOnce() + act(() => hook.rerender({ currentEditor: null })) + replacement.destroy() + }) + + it('survives a debounce completing after its editor has been destroyed', () => { + const { editor, hook } = mountSearch() + act(() => hook.result.current.searchActions.setSearchQuery('beta')) + act(() => editor.destroy()) + expect(() => act(() => vi.advanceTimersByTime(150))).not.toThrow() + }) + + it('does not replay an old Next request into a replacement editor after focus leaves Find', () => { + const { editor, hook, root, query, documentCaret, scrollTo } = mountSearch() + query('beta') + act(() => hook.result.current.searchActions.moveToMatch(1)) + documentCaret() + act(() => hook.rerender({ currentEditor: null })) + editor.view.dom.remove() + const replacement = new Editor({ extensions: [StarterKit], content: '

beta

' }) + root.prepend(replacement.view.dom) + vi.spyOn(replacement.view, 'coordsAtPos').mockReturnValue({ + top: 20, + bottom: 40, + left: 0, + right: 0 + }) + scrollTo.mockClear() + act(() => hook.rerender({ currentEditor: replacement })) + expect(replacement.state.selection.empty).toBe(true) + expect(scrollTo).not.toHaveBeenCalled() + act(() => hook.rerender({ currentEditor: null })) + replacement.destroy() + }) + + it('survives the editor being destroyed by a selection-update listener', () => { + const { editor, query } = mountSearch() + editor.on('selectionUpdate', () => editor.destroy()) + expect(() => query('beta')).not.toThrow() + }) +}) diff --git a/src/renderer/src/components/editor/useRichMarkdownSearch.editing.test.tsx b/src/renderer/src/components/editor/useRichMarkdownSearch.editing.test.tsx new file mode 100644 index 00000000000..b9a8df6e89f --- /dev/null +++ b/src/renderer/src/components/editor/useRichMarkdownSearch.editing.test.tsx @@ -0,0 +1,139 @@ +// @vitest-environment happy-dom +import { act, renderHook } from '@testing-library/react' +import { Editor } from '@tiptap/react' +import StarterKit from '@tiptap/starter-kit' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { useRichMarkdownSearch } from './useRichMarkdownSearch' + +vi.mock('@/store', () => ({ + useAppStore: (select: (state: unknown) => unknown) => select({ keybindings: {} }) +})) + +function mountSearch() { + const editor = new Editor({ + extensions: [StarterKit], + content: '

beta beta

Edit here

' + }) + const scrollContainer = document.createElement('div') + const scrollTo = vi.spyOn(scrollContainer, 'scrollTo') + vi.spyOn(editor.view, 'coordsAtPos').mockReturnValue({ top: 20, bottom: 40, left: 0, right: 0 }) + const root = document.createElement('div') + root.className = 'rich-markdown-editor-shell' + const search = document.createElement('div') + search.className = 'rich-markdown-search' + const input = document.createElement('input') + search.append(input) + root.append(editor.view.dom) + root.append(search) + document.body.append(root) + const hook = renderHook(() => { + const result = useRichMarkdownSearch({ + editor, + rootRef: { current: root }, + scrollContainerRef: { current: scrollContainer } + }) + result.searchState.searchInputRef.current = input + return result + }) + act(() => hook.result.current.openSearch()) + act(() => hook.result.current.searchActions.setSearchQuery('beta')) + act(() => vi.advanceTimersByTime(150)) + const dispose = () => { + hook.unmount() + editor.destroy() + root.remove() + } + return { editor, hook, scrollTo, dispose } +} + +afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() +}) + +describe('rich markdown editing with Find open', () => { + it('updates highlights without moving the typing caret or scrolling to a match', () => { + vi.useFakeTimers() + const { editor, hook, scrollTo, dispose } = mountSearch() + try { + const insertion = editor.state.doc.content.size - 1 + act(() => editor.commands.setTextSelection(insertion)) + scrollTo.mockClear() + act(() => editor.commands.insertContent('X')) + act(() => editor.commands.insertContent('Y')) + expect(editor.getText()).toBe('beta beta\n\nEdit hereXY') + expect(editor.state.selection.from).toBe(insertion + 2) + expect(editor.state.selection.empty).toBe(true) + expect(hook.result.current.searchState.matchCount).toBe(2) + expect(hook.result.current.searchState.isSearchOpen).toBe(true) + expect(scrollTo).not.toHaveBeenCalled() + act(() => editor.commands.undo()) + expect(editor.getText()).toBe('beta beta\n\nEdit here') + expect(scrollTo).not.toHaveBeenCalled() + } finally { + dispose() + } + }) + + it('preserves selection when edits remove every match and later restore one', () => { + vi.useFakeTimers() + const { editor, hook, scrollTo, dispose } = mountSearch() + try { + scrollTo.mockClear() + act(() => editor.commands.setContent('

No results

End

')) + expect(hook.result.current.searchState.matchCount).toBe(0) + act(() => editor.commands.setTextSelection(editor.state.doc.content.size - 1)) + act(() => editor.commands.insertContent(' beta')) + expect(hook.result.current.searchState.matchCount).toBe(1) + expect(editor.state.selection.empty).toBe(true) + expect(editor.state.selection.from).toBe(editor.state.doc.content.size - 1) + expect(scrollTo).not.toHaveBeenCalled() + } finally { + dispose() + } + }) + + it('still navigates on Next with only one match, and advances after Replace', () => { + vi.useFakeTimers() + const { editor, hook, scrollTo, dispose } = mountSearch() + try { + act(() => hook.result.current.searchActions.setReplaceQuery('changed')) + act(() => hook.result.current.searchActions.replaceCurrentMatch()) + expect(editor.getText()).toBe('changed beta\n\nEdit here') + expect(hook.result.current.searchState.matchCount).toBe(1) + expect( + editor.state.doc.textBetween(editor.state.selection.from, editor.state.selection.to) + ).toBe('beta') + act(() => editor.commands.setTextSelection(editor.state.doc.content.size - 1)) + scrollTo.mockClear() + act(() => hook.result.current.searchActions.moveToMatch(1)) + expect( + editor.state.doc.textBetween(editor.state.selection.from, editor.state.selection.to) + ).toBe('beta') + expect(scrollTo).toHaveBeenCalledOnce() + } finally { + dispose() + } + }) + + it('does not apply delayed search navigation after focus returns to the document', () => { + vi.useFakeTimers() + const { editor, hook, scrollTo, dispose } = mountSearch() + try { + act(() => hook.result.current.searchActions.setSearchQuery('Edit')) + act(() => { + editor.view.dom.focus({ preventScroll: true }) + editor.commands.setTextSelection(editor.state.doc.content.size - 1) + }) + expect(editor.isFocused).toBe(true) + const selection = editor.state.selection + scrollTo.mockClear() + act(() => vi.advanceTimersByTime(150)) + expect(hook.result.current.searchState.matchCount).toBe(1) + expect(editor.state.selection.eq(selection)).toBe(true) + expect(scrollTo).not.toHaveBeenCalled() + } finally { + dispose() + } + }) +}) diff --git a/src/renderer/src/components/editor/useRichMarkdownSearch.ts b/src/renderer/src/components/editor/useRichMarkdownSearch.ts index 1a0f132e67c..bcc304c3463 100644 --- a/src/renderer/src/components/editor/useRichMarkdownSearch.ts +++ b/src/renderer/src/components/editor/useRichMarkdownSearch.ts @@ -1,6 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from 'react' import type { Editor } from '@tiptap/react' -import { TextSelection } from '@tiptap/pm/state' import { getShortcutPlatform } from '@/lib/shortcut-platform' import { useAppStore } from '@/store' import { @@ -14,6 +13,7 @@ import { richMarkdownSearchPluginKey } from './rich-markdown-search' import { createRichMarkdownSearchMatchesCache } from './rich-markdown-search-matches-cache' +import { useRichMarkdownSearchHighlights } from './useRichMarkdownSearchHighlights' export function useRichMarkdownSearch({ editor, @@ -42,7 +42,8 @@ export function useRichMarkdownSearch({ const [matchCase, setMatchCase] = useState(false) const [wholeWord, setWholeWord] = useState(false) const [rawActiveMatchIndex, setRawActiveMatchIndex] = useState(-1) - const [searchRevision, setSearchRevision] = useState(0) + const [navigationRequest, setNavigationRequest] = useState({ revision: 0, selectMatch: false }) + const [, setSearchRevision] = useState(0) // Why: debouncing the query that drives match computation prevents the // expensive full-doc walk from running on every keystroke — the old // un-debounced path froze the main thread on large documents. @@ -59,24 +60,24 @@ export function useRichMarkdownSearch({ const searchRequestQuery = isMarkdownPreviewSearchQueryTooLarge(debouncedQuery) ? '' : debouncedQuery + const searchDocument = editor && !editor.isDestroyed ? editor.state.doc : null const matches = useMemo(() => { - if (!editor || !isSearchOpen || !searchRequestQuery) { + if (!searchDocument || !isSearchOpen || !searchRequestQuery) { return [] } - return findMatches(editor.state.doc, searchRequestQuery, { + return findMatches(searchDocument, searchRequestQuery, { matchCase, wholeWord }) - // searchRevision is bumped on ProseMirror doc edits to trigger recomputation - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [editor, findMatches, isSearchOpen, searchRequestQuery, searchRevision, matchCase, wholeWord]) + }, [findMatches, isSearchOpen, searchRequestQuery, searchDocument, matchCase, wholeWord]) const matchCount = matches.length const getLiveMatches = useCallback(() => { if ( !editor || + editor.isDestroyed || !isSearchOpen || !searchQuery || isMarkdownPreviewSearchQueryTooLarge(searchQuery) @@ -175,10 +176,14 @@ export function useRichMarkdownSearch({ if (!match || liveMatches.some((candidate) => candidate.touchesReadOnlyAtom)) { return } - // Why: removing the active match shifts the next match into the same index, - // so leaving rawActiveMatchIndex untouched advances to it after recompute. replaceRange(match.from, match.to) - }, [activeMatchIndex, getLiveMatches, replaceRange]) + // Skip matches inside the replacement, including when it still contains the query. + const replacementEnd = match.from + replaceQuery.length + const nextIndex = getLiveMatches().findIndex((candidate) => candidate.from >= replacementEnd) + setRawActiveMatchIndex(Math.max(0, nextIndex)) + setDebouncedQuery(searchQuery) + setNavigationRequest((request) => ({ revision: request.revision + 1, selectMatch: true })) + }, [activeMatchIndex, getLiveMatches, replaceQuery, replaceRange, searchQuery]) const replaceAllMatches = useCallback(() => { if (!editor) { @@ -203,24 +208,25 @@ export function useRichMarkdownSearch({ } } editor.view.dispatch(tr) - }, [editor, getLiveMatches, replaceQuery]) + setDebouncedQuery(searchQuery) + setNavigationRequest((request) => ({ revision: request.revision + 1, selectMatch: false })) + }, [editor, getLiveMatches, replaceQuery, searchQuery]) const moveToMatch = useCallback( (direction: 1 | -1) => { - if (matchCount === 0) { + const liveMatchCount = getLiveMatches().length + if (liveMatchCount === 0) { return } - // Why: rawActiveMatchIndex starts at -1 before the user navigates, but the - // derived activeMatchIndex is already 0 (first match shown). Using 0 as the - // base when raw is -1 ensures the first Enter press advances to match 1 - // instead of computing (-1+1)%N = 0 and leaving the effect unchanged. setRawActiveMatchIndex((currentIndex) => { - const baseIndex = Math.max(currentIndex, 0) - return (baseIndex + direction + matchCount) % matchCount + const baseIndex = currentIndex >= 0 && currentIndex < liveMatchCount ? currentIndex : 0 + return (baseIndex + direction + liveMatchCount) % liveMatchCount }) + setDebouncedQuery(searchQuery) + setNavigationRequest((request) => ({ revision: request.revision + 1, selectMatch: true })) }, - [matchCount] + [getLiveMatches, searchQuery] ) const handleEditorUpdate = useCallback(() => { @@ -259,52 +265,18 @@ export function useRichMarkdownSearch({ searchInputRef.current?.select() }, [isSearchOpen]) - // Why: single effect to sync search state to ProseMirror. The old two-effect - // chain (compute matches → set state → dispatch) caused an extra render cycle - // and called findRichMarkdownSearchMatches twice per change. - useEffect(() => { - if (!editor) { - return - } - - const query = isSearchOpen ? searchRequestQuery : '' - - // Why: combining decoration meta and selection+scrollIntoView into one - // transaction avoids a split-dispatch where the first dispatch updates - // editor.state and the second dispatch's scrollIntoView can be lost - // when ProseMirror coalesces view updates. - // Why: passing pre-computed matches avoids the plugin re-walking the - // entire document — the old double-walk froze the UI on large files. - const tr = editor.state.tr - tr.setMeta(richMarkdownSearchPluginKey, { - activeIndex: activeMatchIndex, - matches, - query - }) - - const activeMatch = query && activeMatchIndex >= 0 ? matches[activeMatchIndex] : null - if (activeMatch) { - tr.setSelection(TextSelection.create(tr.doc, activeMatch.from, activeMatch.to)) - } - - editor.view.dispatch(tr) - - // Why: ProseMirror's tr.scrollIntoView() delegates to the view's - // scrollDOMIntoView which may fail to reach the outer flex scroll container - // (the editor element itself has min-height: 100% and no overflow). - // Reading coordsAtPos *after* the dispatch and manually scrolling the - // container mirrors the approach used by MarkdownPreview search. - if (activeMatch) { - const container = scrollContainerRef.current - if (container) { - const coords = editor.view.coordsAtPos(activeMatch.from) - const containerRect = container.getBoundingClientRect() - const relativeTop = coords.top - containerRect.top - const targetScroll = container.scrollTop + relativeTop - containerRect.height / 2 - container.scrollTo({ top: targetScroll, behavior: 'instant' }) - } - } - }, [activeMatchIndex, searchRequestQuery, editor, isSearchOpen, matches, scrollContainerRef]) + useRichMarkdownSearchHighlights({ + activeMatchIndex, + editor, + matchCase, + matches, + navigationRequest, + query: isSearchOpen ? searchRequestQuery : '', + scrollContainerRef, + wholeWord, + rootRef, + searchDocument + }) useEffect(() => { const handleKeyDown = (event: KeyboardEvent): void => { diff --git a/src/renderer/src/components/editor/useRichMarkdownSearchHighlights.ts b/src/renderer/src/components/editor/useRichMarkdownSearchHighlights.ts new file mode 100644 index 00000000000..c57c2a4e973 --- /dev/null +++ b/src/renderer/src/components/editor/useRichMarkdownSearchHighlights.ts @@ -0,0 +1,104 @@ +import { useEffect, useRef, type RefObject } from 'react' +import type { Editor } from '@tiptap/react' +import type { Node as ProseMirrorNode } from '@tiptap/pm/model' +import { TextSelection } from '@tiptap/pm/state' +import { richMarkdownSearchPluginKey, type RichMarkdownSearchMatch } from './rich-markdown-search' + +type SearchNavigation = { + editor: Editor + query: string + matchCase: boolean + wholeWord: boolean + navigationRequest: { revision: number; selectMatch: boolean } +} + +export function useRichMarkdownSearchHighlights({ + activeMatchIndex, + editor, + matchCase, + matches, + navigationRequest, + query, + rootRef, + searchDocument, + scrollContainerRef, + wholeWord +}: { + activeMatchIndex: number + editor: Editor | null + matchCase: boolean + matches: RichMarkdownSearchMatch[] + navigationRequest: SearchNavigation['navigationRequest'] + query: string + rootRef: RefObject + searchDocument: ProseMirrorNode | null + scrollContainerRef: RefObject + wholeWord: boolean +}): void { + const lastNavigationRef = useRef(null) + + useEffect(() => { + if (!editor || editor.isDestroyed) { + lastNavigationRef.current = null + return + } + // A newer editor transaction can arrive between render and this effect. + if (editor.state.doc !== searchDocument) { + return + } + const previous = lastNavigationRef.current + const searchChanged = + previous?.editor !== editor || + previous.query !== query || + previous.matchCase !== matchCase || + previous.wholeWord !== wholeWord + const navigationRequested = + previous?.editor === editor && previous.navigationRequest !== navigationRequest + lastNavigationRef.current = { editor, query, matchCase, wholeWord, navigationRequest } + + // Refreshing matches after an edit must preserve the user's caret and viewport. + const activeElement = rootRef.current?.ownerDocument.activeElement + const searchOwnsFocus = + activeElement && + rootRef.current?.contains(activeElement) && + activeElement.closest('.rich-markdown-search') + const shouldNavigate = navigationRequested + ? navigationRequest.selectMatch + : searchChanged && searchOwnsFocus + const activeMatch = + shouldNavigate && query && activeMatchIndex >= 0 ? matches[activeMatchIndex] : null + const tr = editor.state.tr.setMeta(richMarkdownSearchPluginKey, { + activeIndex: activeMatchIndex, + matches, + query + }) + if (activeMatch) { + tr.setSelection(TextSelection.create(tr.doc, activeMatch.from, activeMatch.to)) + } + editor.view.dispatch(tr) + if (editor.isDestroyed || editor.state.doc !== tr.doc) { + return + } + + // The editor's scrollIntoView does not reliably reach the outer flex viewport. + const container = scrollContainerRef.current + if (activeMatch && container) { + const coords = editor.view.coordsAtPos(activeMatch.from) + const containerRect = container.getBoundingClientRect() + const relativeTop = coords.top - containerRect.top + const targetScroll = container.scrollTop + relativeTop - containerRect.height / 2 + container.scrollTo({ top: targetScroll, behavior: 'instant' }) + } + }, [ + activeMatchIndex, + editor, + matchCase, + matches, + navigationRequest, + query, + rootRef, + searchDocument, + scrollContainerRef, + wholeWord + ]) +} diff --git a/tests/e2e/markdown-find-adversarial.spec.ts b/tests/e2e/markdown-find-adversarial.spec.ts new file mode 100644 index 00000000000..99ebe2282cc --- /dev/null +++ b/tests/e2e/markdown-find-adversarial.spec.ts @@ -0,0 +1,307 @@ +import type { Locator, Page } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { pressShortcut } from './helpers/shortcuts' +import { + cleanupMarkdownFixture, + createMarkdownFixture, + getActiveWorktreeContext, + openMarkdownFixture, + waitForRichMarkdownEditor +} from './helpers/markdown-editor-fixture' + +const FIRST = 'First alpha paragraph for pointer selection.' +const SECOND = 'Second beta paragraph for pointer selection.' +const SOURCE = [ + '# Adversarial Find interactions', + 'Needle first match.', + ...Array.from({ length: 65 }, (_, index) => `Spacer ${index} keeps the search match away.`), + FIRST, + SECOND, + '| Left | Right |\n| --- | --- |\n| Cell alpha | Cell beta |\n| Cell gamma | Cell delta |', + '- [ ] Task marker', + '
\nDetails marker\n\nDetails body marker\n\n
', + '```javascript\nconst codeMarker = "clean";\n```', + 'Late target paragraph for a pending query.', + ...Array.from({ length: 30 }, (_, index) => `Tail spacer ${index} keeps the second match away.`), + 'Needle final match.' +].join('\n\n') + +async function textPoint(target: Locator, offset: number) { + return target.evaluate((element, offset) => { + const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT) + let remaining = offset + let node = walker.nextNode() + while (node) { + if (node instanceof Text && remaining <= node.length) { + const range = document.createRange() + range.setStart(node, remaining) + range.collapse(true) + const bounds = range.getBoundingClientRect() + return { x: bounds.left, y: bounds.top + bounds.height / 2 } + } + remaining -= node.textContent?.length ?? 0 + node = walker.nextNode() + } + throw new Error('Text offset was not found') + }, offset) +} + +async function selectedText(page: Page) { + return page.evaluate(() => window.getSelection()?.toString() ?? '') +} + +async function centered(target: Locator) { + await target.evaluate((element) => element.scrollIntoView({ block: 'center' })) +} + +async function find(page: Page, query = 'Needle', status = '1/2') { + await pressShortcut(page, 'f') + const input = page.getByRole('textbox', { name: 'Find in rich markdown editor' }) + await expect(input).toBeFocused() + await input.fill(query) + await expect(page.locator('.rich-markdown-search-status')).toHaveText(status) + return input +} + +async function drag(page: Page, start: Locator, end: Locator, from: number, to: number) { + const firstPoint = await textPoint(start, from) + const lastPoint = await textPoint(end, to) + await page.mouse.move(firstPoint.x, firstPoint.y) + await page.mouse.down() + await page.mouse.move(lastPoint.x, lastPoint.y, { steps: 15 }) + await page.mouse.up() +} + +test.beforeEach(async ({ orcaPage, registerPostElectronShutdownCleanup }, testInfo) => { + const context = await getActiveWorktreeContext(orcaPage) + const filePath = await createMarkdownFixture( + context, + '.orca-e2e-markdown-adversarial', + 'find-interactions', + testInfo.workerIndex, + SOURCE + ) + registerPostElectronShutdownCleanup(() => cleanupMarkdownFixture(filePath)) + await openMarkdownFixture(orcaPage, context, filePath) + const editor = await waitForRichMarkdownEditor(orcaPage) + await editor.locator('p').first().click() +}) + +test('Find preserves Shift-click, double-click, and multi-paragraph copy selection', async ({ + orcaPage +}, testInfo) => { + const editor = orcaPage.locator('.rich-markdown-editor') + const first = editor.getByText(FIRST, { exact: true }) + const second = editor.getByText(SECOND, { exact: true }) + const search = await find(orcaPage) + await centered(first) + const viewport = orcaPage.locator('.rich-markdown-editor-shell .overflow-auto') + const scroll = await viewport.evaluate((element) => element.scrollTop) + const start = await textPoint(first, 6) + const end = await textPoint(second, 11) + await orcaPage.mouse.click(start.x, start.y) + await orcaPage.keyboard.down('Shift') + await orcaPage.mouse.click(end.x, end.y) + await orcaPage.keyboard.up('Shift') + expect(await selectedText(orcaPage)).toBe(`${FIRST.slice(6)}\n\n${SECOND.slice(0, 11)}`) + await expect(editor).toBeFocused() + await expect( + orcaPage + .locator('[data-tab-id]') + .filter({ hasText: 'find-interactions' }) + .last() + .locator('span.rounded-full') + ).toHaveCount(0) + await orcaPage.mouse.click(start.x, start.y) + await search.focus() + await orcaPage.keyboard.down('Shift') + await orcaPage.mouse.click(end.x, end.y) + await orcaPage.keyboard.up('Shift') + expect(await selectedText(orcaPage)).toBe(`${FIRST.slice(6)}\n\n${SECOND.slice(0, 11)}`) + const copied = await editor.evaluate((element) => { + const clipboard = new DataTransfer() + element.dispatchEvent( + new ClipboardEvent('copy', { bubbles: true, cancelable: true, clipboardData: clipboard }) + ) + return { plain: clipboard.getData('text/plain'), html: clipboard.getData('text/html') } + }) + expect(copied.plain).toContain(FIRST.slice(6)) + expect(copied.plain).toContain(SECOND.slice(0, 11)) + expect(copied.html).toContain('

') + await expect(editor.getByText('Needle first match.', { exact: true })).toBeVisible() + await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBeCloseTo(scroll, 0) + await orcaPage.screenshot({ path: testInfo.outputPath('shift-click-copy-selection.png') }) + await search.focus() + const word = await textPoint(first, 9) + await orcaPage.mouse.dblclick(word.x, word.y) + expect(await selectedText(orcaPage)).toBe('alpha') + await orcaPage.keyboard.type('xy', { delay: 100 }) + await expect( + editor.getByText('First xy paragraph for pointer selection.', { exact: true }) + ).toBeVisible() + await expect(search).toHaveValue('Needle') + await expect(orcaPage.locator('.rich-markdown-search-status')).toHaveText('1/2') +}) + +test('Replace input returns to a multi-paragraph drag and explicit next-match navigation', async ({ + orcaPage +}, testInfo) => { + const editor = orcaPage.locator('.rich-markdown-editor') + const search = await find(orcaPage) + await orcaPage.getByRole('button', { name: 'Toggle replace', exact: true }).click() + const replace = orcaPage.getByRole('textbox', { name: 'Replace in rich markdown editor' }) + await replace.fill('Thread') + const first = editor.getByText(FIRST, { exact: true }) + const second = editor.getByText(SECOND, { exact: true }) + await centered(first) + await drag(orcaPage, first, second, 6, 11) + expect(await selectedText(orcaPage)).toBe(`${FIRST.slice(6)}\n\n${SECOND.slice(0, 11)}`) + await orcaPage.keyboard.type('xy', { delay: 100 }) + await expect( + editor.getByText(`${FIRST.slice(0, 6)}xy${SECOND.slice(11)}`, { exact: true }) + ).toBeVisible() + await orcaPage.getByRole('button', { name: 'Next match', exact: true }).click() + await expect.poll(() => selectedText(orcaPage)).toBe('Needle') + await expect(orcaPage.locator('.rich-markdown-search-status')).toHaveText('2/2') + await replace.focus() + await orcaPage.getByRole('button', { name: 'Replace', exact: true }).click() + await expect(editor.getByText('Thread final match.', { exact: true })).toBeVisible() + await expect(orcaPage.locator('.rich-markdown-search-status')).toHaveText('1/1') + await expect(replace).toBeFocused() + await orcaPage.keyboard.press('Escape') + await expect(search).toHaveCount(0) + await pressShortcut(orcaPage, 'f') + await expect(search).toBeFocused() + await expect(search).toHaveValue('') + await orcaPage.screenshot({ path: testInfo.outputPath('replace-next-reopen.png') }) +}) + +test('Find returns focus and copies the intended cells after Shift-clicking a table', async ({ + orcaPage +}, testInfo) => { + const editor = orcaPage.locator('.rich-markdown-editor') + const search = await find(orcaPage, 'Cell alpha', '1/1') + const firstCell = editor.getByText('Cell alpha', { exact: true }) + const lastCell = editor.getByText('Cell delta', { exact: true }) + await centered(firstCell) + const viewport = orcaPage.locator('.rich-markdown-editor-shell .overflow-auto') + const scroll = await viewport.evaluate((element) => element.scrollTop) + const point = await textPoint(lastCell, 6) + await orcaPage.keyboard.down('Shift') + await orcaPage.mouse.move(point.x, point.y) + await orcaPage.mouse.down() + await orcaPage.evaluate( + () => + new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())) + }) + ) + await orcaPage.mouse.up() + await orcaPage.keyboard.up('Shift') + await expect(editor.locator('td.selectedCell')).toHaveCount(4) + await orcaPage.screenshot({ path: testInfo.outputPath('shift-cell-selection.png') }) + await expect(editor).toBeFocused() + await expect(search).toHaveValue('Cell alpha') + await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBeCloseTo(scroll, 0) + const tab = orcaPage.locator('[data-tab-id]').filter({ hasText: 'find-interactions' }).last() + await expect(tab.locator('span.rounded-full')).toHaveCount(0) + const copied = await editor.evaluate((element) => { + const clipboard = new DataTransfer() + element.dispatchEvent( + new ClipboardEvent('copy', { + bubbles: true, + cancelable: true, + clipboardData: clipboard + }) + ) + return clipboard.getData('text/plain') + }) + expect(copied).toBe('Cell alpha\n\nCell beta\n\nCell gamma\n\nCell delta') +}) + +test('Find preserves embedded task, details, and code editing', async ({ orcaPage }, testInfo) => { + const editor = orcaPage.locator('.rich-markdown-editor') + const search = await find(orcaPage) + await centered(editor.getByRole('checkbox')) + await search.focus() + const checkbox = editor.getByRole('checkbox') + await checkbox.check() + await expect(checkbox).toBeChecked() + await expect(search).toHaveValue('Needle') + await search.focus() + const details = editor.locator('[data-type="details"]') + await details.getByRole('button').click() + await expect(details.locator('[data-type="detailsContent"]')).toBeHidden() + await search.focus() + await details.getByRole('button').click() + await expect(details.locator('[data-type="detailsContent"]')).toBeVisible() + const body = editor.getByText('Details body marker', { exact: true }) + const bodyPoint = await textPoint(body, 7) + await search.focus() + await orcaPage.mouse.click(bodyPoint.x, bodyPoint.y) + await orcaPage.keyboard.type('xy', { delay: 100 }) + await expect(editor.getByText('Detailsxy body marker', { exact: true })).toBeVisible() + const code = editor.getByText('const codeMarker = "clean";', { exact: true }) + await centered(code) + const codePoint = await textPoint(code, 6) + await search.focus() + await orcaPage.mouse.click(codePoint.x, codePoint.y) + await orcaPage.keyboard.type('xy', { delay: 100 }) + await expect(editor.getByText('const xycodeMarker = "clean";', { exact: true })).toBeVisible() + await expect(orcaPage.locator('.rich-markdown-search-status')).toHaveText('1/2') + await orcaPage.screenshot({ path: testInfo.outputPath('embedded-control-editing.png') }) +}) + +test('a pending Find query cannot claim focus after document and checkbox interaction', async ({ + orcaPage +}, testInfo) => { + const editor = orcaPage.locator('.rich-markdown-editor') + const search = await find(orcaPage) + const target = editor.getByText('Late target paragraph for a pending query.', { exact: true }) + await centered(target) + const point = await textPoint(target, 5) + const viewport = orcaPage.locator('.rich-markdown-editor-shell .overflow-auto') + const scroll = await viewport.evaluate((element) => element.scrollTop) + const checkbox = editor.getByRole('checkbox') + const checkboxPoint = await checkbox.evaluate((element) => { + const bounds = element.getBoundingClientRect() + return { x: bounds.left + bounds.width / 2, y: bounds.top + bounds.height / 2 } + }) + await search.fill('Cell alpha') + await orcaPage.mouse.click(point.x, point.y) + await expect(editor).toBeFocused() + await orcaPage.mouse.click(checkboxPoint.x, checkboxPoint.y) + await expect(checkbox).toBeChecked() + await expect(orcaPage.locator('.rich-markdown-search-status')).toHaveText('1/1') + await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBeCloseTo(scroll, 0) + await expect(target).toHaveText('Late target paragraph for a pending query.') + await orcaPage.mouse.click(point.x, point.y) + await orcaPage.keyboard.type('xy', { delay: 100 }) + await expect( + editor.getByText('Late xytarget paragraph for a pending query.', { exact: true }) + ).toBeVisible() + await expect(orcaPage.locator('.rich-markdown-search-status')).toHaveText('1/1') + await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBeCloseTo(scroll, 0) + await expect(search).toHaveValue('Cell alpha') + await orcaPage.screenshot({ path: testInfo.outputPath('pending-query-editor-caret.png') }) +}) + +test('Replace advances when its replacement still contains the search query', async ({ + orcaPage +}, testInfo) => { + const editor = orcaPage.locator('.rich-markdown-editor') + await find(orcaPage) + await orcaPage.getByRole('button', { name: 'Toggle replace', exact: true }).click() + await orcaPage.getByRole('textbox', { name: 'Replace in rich markdown editor' }).fill('NeedleX') + await orcaPage.getByRole('button', { name: 'Replace', exact: true }).click() + await expect(editor.getByText('NeedleX first match.', { exact: true })).toBeVisible() + await expect(orcaPage.locator('.rich-markdown-search-status')).toHaveText('2/2') + await expect( + editor.getByText('Needle final match.', { exact: true }).locator('[data-active="true"]') + ).toHaveText('Needle') + await orcaPage.getByRole('button', { name: 'Replace', exact: true }).click() + await expect(editor.getByText('NeedleX final match.', { exact: true })).toBeVisible() + await expect(editor.getByText('NeedleX first match.', { exact: true })).toBeVisible() + await expect(orcaPage.locator('.rich-markdown-search-status')).toHaveText('1/2') + await orcaPage.screenshot({ path: testInfo.outputPath('replacement-retains-query.png') }) +}) diff --git a/tests/e2e/markdown-find-editing.spec.ts b/tests/e2e/markdown-find-editing.spec.ts new file mode 100644 index 00000000000..b227ba81390 --- /dev/null +++ b/tests/e2e/markdown-find-editing.spec.ts @@ -0,0 +1,215 @@ +import type { Locator, Page } from '@stablyai/playwright-test' +import path from 'node:path' +import { test, expect } from './helpers/orca-app' +import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { pressShortcut } from './helpers/shortcuts' +import { + cleanupMarkdownFixture, + createMarkdownFixture, + getActiveWorktreeContext, + openMarkdownFixture, + waitForRichMarkdownEditor +} from './helpers/markdown-editor-fixture' + +const TARGET_TEXT = 'Editing target paragraph keeps the pointer position and viewport.' +const MARKDOWN = [ + '# Find and pointer editing', + 'Needle remains at the beginning.', + ...Array.from({ length: 100 }, (_, index) => + index === 70 ? TARGET_TEXT : `Paragraph ${index} provides room to scroll through the document.` + ), + 'Needle remains at the end.' +].join('\n\n') +const TABLE_MARKDOWN = MARKDOWN.replace( + TARGET_TEXT, + `| Name | Value |\n| --- | --- |\n| Target | ${TARGET_TEXT} |` +) + +async function textPoint(paragraph: Locator, offset: number) { + return paragraph.evaluate((element, offset) => { + const text = element.firstChild + if (!(text instanceof Text)) { + throw new Error('Expected a plain-text paragraph') + } + const range = document.createRange() + range.setStart(text, offset) + range.collapse(true) + const bounds = range.getBoundingClientRect() + return { x: bounds.left, y: bounds.top + bounds.height / 2 } + }, offset) +} + +async function readParagraphSelection(paragraph: Locator) { + return paragraph.evaluate((element) => { + const selection = window.getSelection() + if (!selection?.anchorNode || !selection.focusNode) { + return null + } + if (!element.contains(selection.anchorNode) || !element.contains(selection.focusNode)) { + return null + } + const start = document.createRange() + start.selectNodeContents(element) + start.setEnd(selection.anchorNode, selection.anchorOffset) + const end = document.createRange() + end.selectNodeContents(element) + end.setEnd(selection.focusNode, selection.focusOffset) + return { + from: Math.min(start.toString().length, end.toString().length), + to: Math.max(start.toString().length, end.toString().length), + text: selection.toString() + } + }) +} + +async function centerParagraph(paragraph: Locator): Promise { + await paragraph.evaluate((element) => element.scrollIntoView({ block: 'center' })) +} + +async function pointAtParagraph(page: Page, paragraph: Locator, drag: boolean) { + const start = await textPoint(paragraph, 8) + await page.mouse.move(start.x, start.y) + await page.mouse.down() + if (drag) { + const end = await textPoint(paragraph, 24) + await page.mouse.move(end.x, end.y, { steps: 12 }) + } + // Tiptap focus commands can schedule a stale-selection scroll for the next frame. + await page.evaluate( + () => + new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())) + }) + ) + await page.mouse.up() + const selection = await readParagraphSelection(paragraph) + expect(selection).not.toBeNull() + if (!selection) { + throw new Error('Pointer selection left the intended paragraph') + } + if (drag) { + expect(selection.text).toBe('target paragraph') + } else { + expect(selection.from).toBe(selection.to) + } + return selection +} + +test.beforeEach(async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) +}) + +for (const interaction of ['click', 'drag'] as const) { + test(`keeps Find open while a document ${interaction} edits at the pointer position`, async ({ + orcaPage, + registerPostElectronShutdownCleanup + }, testInfo) => { + const context = await getActiveWorktreeContext(orcaPage) + const filePath = await createMarkdownFixture( + context, + '.orca-e2e-markdown-find-editing', + `find-${interaction}`, + testInfo.workerIndex, + MARKDOWN + ) + registerPostElectronShutdownCleanup(() => cleanupMarkdownFixture(filePath)) + await openMarkdownFixture(orcaPage, context, filePath) + const editor = await waitForRichMarkdownEditor(orcaPage) + const paragraph = editor.locator('p').filter({ hasText: 'Editing' }) + const viewport = orcaPage.locator('.rich-markdown-editor-shell .overflow-auto') + await editor.locator('p').first().click() + await pressShortcut(orcaPage, 'f') + const search = orcaPage.getByRole('textbox', { name: 'Find in rich markdown editor' }) + await expect(search).toBeFocused() + await search.fill('Needle') + await orcaPage.getByRole('button', { name: 'Match case', exact: true }).click() + await orcaPage.getByRole('button', { name: 'Match whole word', exact: true }).click() + await expect(orcaPage.locator('.rich-markdown-search-status')).toHaveText('1/2') + await centerParagraph(paragraph) + const originalScroll = await viewport.evaluate((element) => element.scrollTop) + const selection = await pointAtParagraph(orcaPage, paragraph, interaction === 'drag') + + // Separate key events allow document updates to expose a caret reset between characters. + await orcaPage.keyboard.type('xy', { delay: 100 }) + await orcaPage.screenshot({ path: testInfo.outputPath('find-document-edit.png') }) + await expect(paragraph).toHaveText( + `${TARGET_TEXT.slice(0, selection.from)}xy${TARGET_TEXT.slice(selection.to)}` + ) + await expect(editor.locator('p').first()).toHaveText('Needle remains at the beginning.') + await expect + .poll(() => readParagraphSelection(paragraph)) + .toEqual({ + from: selection.from + 2, + to: selection.from + 2, + text: '' + }) + await expect + .poll(() => viewport.evaluate((element) => element.scrollTop)) + .toBeCloseTo(originalScroll, 0) + await expect(search).toBeVisible() + await expect(search).toHaveValue('Needle') + await expect(orcaPage.getByRole('button', { name: 'Match case', exact: true })).toHaveAttribute( + 'aria-pressed', + 'true' + ) + await expect( + orcaPage.getByRole('button', { name: 'Match whole word', exact: true }) + ).toHaveAttribute('aria-pressed', 'true') + await expect(orcaPage.locator('.rich-markdown-search-status')).toHaveText('1/2') + }) + + test(`keeps the scrolled table and ${interaction} selection after switching Source to Rich`, async ({ + orcaPage, + registerPostElectronShutdownCleanup + }, testInfo) => { + const context = await getActiveWorktreeContext(orcaPage) + const filePath = await createMarkdownFixture( + context, + '.orca-e2e-markdown-find-editing', + `source-${interaction}`, + testInfo.workerIndex, + TABLE_MARKDOWN + ) + registerPostElectronShutdownCleanup(() => cleanupMarkdownFixture(filePath)) + await openMarkdownFixture(orcaPage, context, filePath) + await waitForRichMarkdownEditor(orcaPage) + await orcaPage.getByRole('radio', { name: 'Source', exact: true }).click() + await expect(orcaPage.locator('.monaco-editor')).toBeVisible() + await orcaPage.getByRole('radio', { name: 'Rich Editor', exact: true }).click() + const editor = await waitForRichMarkdownEditor(orcaPage) + await expect(editor).not.toBeFocused() + const paragraph = editor.locator('td p').filter({ hasText: 'Editing' }) + const viewport = orcaPage.locator('.rich-markdown-editor-shell .overflow-auto') + await centerParagraph(paragraph) + const originalScroll = await viewport.evaluate((element) => element.scrollTop) + const selection = await pointAtParagraph(orcaPage, paragraph, interaction === 'drag') + await expect(editor).toBeFocused() + await expect(paragraph).toHaveText(TARGET_TEXT) + await expect + .poll(() => viewport.evaluate((element) => element.scrollTop)) + .toBeCloseTo(originalScroll, 0) + const tab = orcaPage + .locator('[data-tab-id]') + .filter({ hasText: path.basename(filePath) }) + .last() + await expect(tab.locator('span.rounded-full')).toHaveCount(0) + await expect(tab.getByRole('button', { name: 'Close tab' })).toBeVisible() + await orcaPage.screenshot({ path: testInfo.outputPath('source-rich-table-selection.png') }) + await orcaPage.keyboard.type('xy', { delay: 100 }) + await orcaPage.screenshot({ path: testInfo.outputPath('source-rich-pointer-edit.png') }) + await expect(paragraph).toHaveText( + `${TARGET_TEXT.slice(0, selection.from)}xy${TARGET_TEXT.slice(selection.to)}` + ) + await expect + .poll(() => readParagraphSelection(paragraph)) + .toEqual({ + from: selection.from + 2, + to: selection.from + 2, + text: '' + }) + await expect + .poll(() => viewport.evaluate((element) => element.scrollTop)) + .toBeCloseTo(originalScroll, 0) + }) +} From ce0778626618bb9a5197209f5189190f2d51545c Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:19:55 -0700 Subject: [PATCH 42/67] Keep opened issue details and edits in the selected repository (#24729) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keeps a fork issue’s details, metadata and edits bound to the repository the user opened, including same-number issues in fork and upstream. Repairs selected-assignee leakage and delayed failed edits repainting another issue. Fixes #24378 Incorporates and cross-reviews contributor PR #24379, including its source-resolver correction and regression material. Covers the contributor PR’s Project-row identity and retained-dialog mutation findings. The final published head passes focused tests, hidden macOS rendering and current CI; the callback-timing bot thread has an evidence-based response. Co-authored-by: Katsuma Takehisa --- .../github-opened-issue-repository.test.ts | 440 ++++++++++++++++++ .../github-pr-assignee-repository.test.ts | 204 ++++++++ src/main/github/client.ts | 6 +- src/main/github/client/fetch/get-work-item.ts | 69 ++- .../github-api-repository-validation.test.ts | 21 + .../github-api-repository-validation.ts | 15 +- src/main/github/issue-comment.ts | 32 +- src/main/github/issue-field-options.ts | 46 +- src/main/github/issue-mutation-source.test.ts | 178 +++++++ src/main/github/issue-update.ts | 36 +- .../work-item-details-concurrency.test.ts | 6 +- .../work-item-details-config-change.test.ts | 207 ++++++++ .../work-item-details-enterprise-host.test.ts | 6 +- .../work-item-details-file-viewed.test.ts | 6 +- .../work-item-details-issue-source.test.ts | 239 ++++++++++ .../github/work-item-details-pr-files.test.ts | 6 +- .../github/work-item-details-test-lookup.ts | 17 + src/main/github/work-item-details.test.ts | 6 +- src/main/github/work-item-details.ts | 62 ++- .../ipc/github-issue-mutation-handlers.ts | 76 ++- .../github-issue-source-preference.test.ts | 120 +++++ src/main/ipc/github-work-item-handlers.ts | 23 +- .../ipc/github-wsl-runtime-routing.test.ts | 29 +- .../hooks-and-hosted-review.spec.ts | 6 +- .../rpc/methods/github-issue-methods.ts | 15 +- src/main/runtime/rpc/methods/github.test.ts | 11 +- .../runtime-github-issue-comment-commands.ts | 10 +- src/preload/api/github-work-item-api.ts | 4 + .../gh-edit-section-mutations.ts | 73 ++- ...gh-edit-section.assignee-identity.test.tsx | 364 +++++++++++++++ .../edit-item-fields/gh-edit-section.tsx | 71 +-- .../github-item-dialog-types.ts | 20 + .../use-github-item-dialog-details.ts | 31 +- .../work-item-details-cache.ts | 20 +- .../work-item-details-issue-target.test.ts | 81 ++++ .../components/github/PRAssigneesPanel.tsx | 14 +- .../github-work-item-comment-mutations.ts | 1 + .../github/github-work-item-edit-mutations.ts | 36 +- .../github-work-item-issue-target.test.ts | 172 +++++++ .../task-page-cache-selectors.test.ts | 31 +- .../components/task-page-cache-selectors.ts | 6 +- ...ask-page-github-confirmed-client-values.ts | 61 +++ ...page-github-dialog-state-authority.test.ts | 65 +++ ...task-page-github-dialog-state-authority.ts | 44 +- ...e-github-work-item-mutation-composition.ts | 9 +- ...sk-page-github-work-item-mutation-pages.ts | 6 +- ...page-github-work-item-mutation-registry.ts | 49 +- ...sk-page-github-work-item-mutation-types.ts | 3 +- .../task-page-github-work-item-quiet-adopt.ts | 30 +- .../components/use-task-page-github-detail.ts | 5 +- .../src/hooks/useGitHubRepoMetadata.ts | 95 ++++ .../src/hooks/useIssueMetadata.test.tsx | 75 ++- src/renderer/src/hooks/useIssueMetadata.ts | 77 +-- .../github-work-item-source-lookup.test.ts | 21 +- .../src/lib/github-work-item-source-lookup.ts | 5 +- src/renderer/src/store/github/cache-model.ts | 1 + .../github/work-item-mutation-actions.ts | 18 +- .../github-work-item-cache-identity.test.ts | 37 ++ 58 files changed, 3045 insertions(+), 372 deletions(-) create mode 100644 config/scripts/github-opened-issue-repository.test.ts create mode 100644 config/scripts/github-pr-assignee-repository.test.ts create mode 100644 src/main/github/issue-mutation-source.test.ts create mode 100644 src/main/github/work-item-details-config-change.test.ts create mode 100644 src/main/github/work-item-details-issue-source.test.ts create mode 100644 src/main/github/work-item-details-test-lookup.ts create mode 100644 src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section.assignee-identity.test.tsx create mode 100644 src/renderer/src/components/github-item-dialog/load-item-details/work-item-details-issue-target.test.ts create mode 100644 src/renderer/src/components/github/github-work-item-issue-target.test.ts create mode 100644 src/renderer/src/components/task-page-github-confirmed-client-values.ts create mode 100644 src/renderer/src/hooks/useGitHubRepoMetadata.ts diff --git a/config/scripts/github-opened-issue-repository.test.ts b/config/scripts/github-opened-issue-repository.test.ts new file mode 100644 index 00000000000..b2d0519c5cc --- /dev/null +++ b/config/scripts/github-opened-issue-repository.test.ts @@ -0,0 +1,440 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { createElement } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import type * as ReactModule from 'react' +import type * as GhUtils from '../../src/main/github/gh-utils' +import type * as IssueMetadata from '../../src/renderer/src/hooks/useIssueMetadata' +import type { GitHubWorkItem } from '../../src/shared/github/work-item-types' +import type { GitHubOwnerRepo } from '../../src/shared/github/pull-request-types' +import type { TaskSourceContext } from '../../src/shared/task-source-context' +import type { Repo } from '../../src/shared/repo-types' + +const fixture = vi.hoisted(() => { + const state: { + loads: { key: string | null; load: () => Promise }[] + requests: { args: string[]; host?: string }[] + gh: ReturnType + apiUpdate: ReturnType + preference: 'origin' | 'upstream' + } = { loads: [], requests: [], gh: vi.fn(), apiUpdate: vi.fn(), preference: 'upstream' } + return state +}) + +vi.mock('react', async (original) => ({ + ...(await original()), + useState: (initial: unknown) => [typeof initial === 'function' ? initial() : initial, vi.fn()], + useMemo: (value: () => T) => value(), + useCallback: (value: T) => value, + useRef: (initial: T) => ({ current: initial }), + useEffect: vi.fn() +})) +vi.mock('zustand/react/shallow', () => ({ useShallow: (value: T) => value })) +vi.mock('@/store', () => ({ + useAppStore: Object.assign( + (selector: (state: unknown) => unknown) => + selector({ patchWorkItem: vi.fn(), patchProjectRowContent: vi.fn() }), + { getState: () => ({ recordFeatureInteraction: vi.fn() }) } + ) +})) +vi.mock('@/lib/repo-runtime-owner', () => ({ + getSettingsForRepoRuntimeOwner: () => ({ activeRuntimeEnvironmentId: null }) +})) +vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) +vi.mock('@/components/github/github-duplicate-issue-candidates', () => ({ + useGitHubDuplicateIssueCandidates: () => [] +})) +vi.mock('@/components/github/github-work-item-comment-mutations', () => ({ + notifyWorkItemDetailsMutation: vi.fn() +})) +vi.mock('@/hooks/useIssueMetadata', async (original) => ({ + ...(await original()), + useImmediateMutation: () => ({ isPending: () => false, run: vi.fn() }) +})) +vi.mock('@/hooks/useMetadataListRequest', () => ({ + useMetadataListRequest: (args: { cacheKey: string | null; load: () => Promise }) => { + fixture.loads.push({ key: args.cacheKey, load: args.load }) + return { data: [], loading: false, error: null } + } +})) +vi.mock('../../src/main/github/gh-utils', async (original) => ({ + ...(await original()), + ghExecFileAsync: fixture.gh, + acquire: vi.fn(), + release: vi.fn(), + getOwnerRepoForRemote: async (_path: string, remote: string) => ({ + owner: remote === 'upstream' ? 'upstream-owner' : 'fork-owner', + repo: 'widgets' + }) +})) +vi.mock('../../src/main/git/remote-name-listing', () => ({ + shouldProbeGitRemote: async () => true +})) + +import { GHEditSection } from '../../src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section' +import { + runGHEditLabelToggle, + runGHEditStateChange +} from '../../src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section-mutations' +import { findTaskPageDialogWorkItem } from '../../src/renderer/src/components/task-page-cache-selectors' +import { getTaskPageRepoSourceContext } from '../../src/renderer/src/components/task-page-source-context' +import { workItemsCacheKey } from '../../src/renderer/src/store/github/cache-identity' +import { createTestStore } from '../../src/renderer/src/store/slices/github-slice-test-harness' +import { getTaskSourceCacheScope } from '../../src/shared/task-source-context' +import { listLabels, listAssignableUsers } from '../../src/main/github/issue-field-options' +import { useRepoLabels, useRepoAssignees } from '../../src/renderer/src/hooks/useIssueMetadata' +import { updateIssue } from '../../src/main/github/issue-update' +import { materializeTaskPageItemList } from '../../src/renderer/src/components/task-page-github-work-item-mutations' +import { + resetTaskPageGitHubMutationRegistryForTests, + setTaskPageGitHubMutationQueryKey +} from '../../src/renderer/src/components/task-page-github-work-item-mutation-registry' + +function renderEditSection(props: Parameters[0]): void { + renderToStaticMarkup(createElement(GHEditSection, props)) +} + +const registeredRepo: Repo = { + id: 'repo-1', + path: join(tmpdir(), 'orca-opened-issue-repository-fixture'), + displayName: 'widgets', + badgeColor: 'primary', + addedAt: 1, + upstream: { owner: 'upstream-owner', repo: 'widgets', host: 'github.com' } +} + +function sourceFor(preference: 'origin' | 'upstream'): TaskSourceContext { + const source = getTaskPageRepoSourceContext( + { ...registeredRepo, issueSourcePreference: preference }, + 'github' + ) + if (!source) { + throw new Error('Registered fixture must produce a source context') + } + return source +} + +const sourceContext = sourceFor('origin') +const fork: GitHubWorkItem = { + id: 'issue:5', + type: 'issue', + number: 5, + title: 'FORK title', + state: 'open', + url: 'https://github.com/fork-owner/widgets/issues/5', + labels: [], + updatedAt: '', + author: null, + repoId: 'repo-1' +} +const upstream: GitHubWorkItem = { + ...fork, + title: 'UPSTREAM title', + url: 'https://github.com/upstream-owner/widgets/issues/5' +} +const issueRepo = { owner: 'fork-owner', repo: 'widgets', host: 'github.com' } + +type MetadataArgs = { repoPath: string; ownerRepo?: GitHubOwnerRepo } + +beforeEach(() => { + fixture.preference = 'upstream' + fixture.loads = [] + fixture.requests = [] + resetTaskPageGitHubMutationRegistryForTests() + setTaskPageGitHubMutationQueryKey('current-upstream-list') + fixture.gh.mockReset() + fixture.gh.mockImplementation(async (args: string[], options: { host?: string }) => { + fixture.requests.push({ args, host: options.host }) + return { stdout: '', stderr: '' } + }) + fixture.apiUpdate = vi.fn((args: Parameters[0]) => + updateIssue( + args.repoPath, + args.number, + args.updates, + null, + {}, + fixture.preference, + args.ownerRepo + ) + ) + vi.stubGlobal('window', { + api: { + gh: { + updateIssue: fixture.apiUpdate, + listLabels: (args: MetadataArgs) => + listLabels(args.repoPath, fixture.preference, null, {}, args.ownerRepo), + listAssignableUsers: (args: MetadataArgs) => + listAssignableUsers(args.repoPath, fixture.preference, null, {}, args.ownerRepo) + } + } + }) +}) + +afterEach(() => { + resetTaskPageGitHubMutationRegistryForTests() + vi.unstubAllGlobals() +}) + +it.each([ + { openedItem: fork, listItem: upstream, preference: 'upstream' }, + { openedItem: upstream, listItem: fork, preference: 'origin' }, + { openedItem: fork, listItem: fork, preference: 'origin' } +] as const)( + 'scopes $openedItem.title labels under $preference to its canonical list row', + async ({ openedItem, listItem, preference }) => { + fixture.preference = preference + expect(sourceFor('upstream')).toEqual(sourceContext) + const store = createTestStore() + const key = workItemsCacheKey( + registeredRepo.id, + 36, + '', + getTaskSourceCacheScope(sourceFor('upstream')) + ) + store.setState({ + workItemsCache: { [key]: { data: [listItem], fetchedAt: Date.now() } } + }) + const opened = + findTaskPageDialogWorkItem(store.getState().workItemsCache, { + id: openedItem.id, + repoId: openedItem.repoId, + url: openedItem.url + }) ?? openedItem + expect(opened.url).toBe(openedItem.url) + const target = { ...issueRepo, owner: openedItem === fork ? 'fork-owner' : 'upstream-owner' } + let mutation: Promise = Promise.resolve() + runGHEditLabelToggle({ + itemId: opened.id, + itemNumber: opened.number, + itemRepoId: opened.repoId, + repoPath: registeredRepo.path, + sourceContext, + projectOrigin: undefined, + issueRepo: target, + label: 'fork-only-label', + localLabels: [], + run: async (_key, options) => { + options.onOptimistic?.() + mutation = options.mutate() + await mutation + options.onSuccess?.() + return true + }, + onLabelsChange: vi.fn(), + patchWorkItem: store.getState().patchWorkItem, + patchProjectRowIfNeeded: vi.fn(), + onMutated: vi.fn() + }) + await mutation + expect(fixture.requests[0].args).toContain(`${target.owner}/widgets`) + expect(fixture.apiUpdate).toHaveBeenCalledWith(expect.objectContaining({ ownerRepo: target })) + expect(store.getState().workItemsCache[key]?.data?.[0].labels).toEqual( + listItem.url === openedItem.url ? ['fork-only-label'] : [] + ) + } +) + +it.each([ + { openedItem: fork, owner: 'fork-owner', preference: 'origin' }, + { openedItem: fork, owner: 'fork-owner', preference: 'upstream' }, + { openedItem: upstream, owner: 'upstream-owner', preference: 'origin' }, + { openedItem: upstream, owner: 'upstream-owner', preference: 'upstream' } +] as const)( + 'loads $owner picker candidates while preference=$preference', + async ({ preference, openedItem, owner }) => { + fixture.preference = preference + renderEditSection({ + item: openedItem, + repoPath: registeredRepo.path, + repoId: fork.repoId, + sourceContext, + projectOrigin: undefined, + localState: 'open', + localLabels: [], + assignees: [], + onStateChange: vi.fn(), + onLabelsChange: vi.fn(), + onMutated: vi.fn(), + onUse: vi.fn() + }) + for (const request of fixture.loads.filter((load) => load.key !== null)) { + await request.load() + } + expect( + fixture.requests.map((request) => request.args.find((arg) => arg.startsWith('repos/'))) + ).toEqual([`repos/${owner}/widgets/labels`, `repos/${owner}/widgets/assignees?per_page=100`]) + } +) + +it.each([ + { openedItem: fork, listItem: fork }, + { openedItem: fork, listItem: upstream }, + { openedItem: upstream, listItem: fork }, + { openedItem: upstream, listItem: upstream } +])( + 'a $openedItem.title close only controls its own row while search lags (list=$listItem.title)', + async ({ openedItem, listItem }) => { + const target = { ...issueRepo, owner: openedItem === fork ? 'fork-owner' : 'upstream-owner' } + let pending = Promise.resolve() + runGHEditStateChange({ + newState: 'closed', + localState: 'open', + itemId: fork.id, + itemNumber: fork.number, + itemRepoId: fork.repoId, + repoPath: registeredRepo.path, + sourceContext, + projectOrigin: undefined, + issueRepo: target, + run: (_key, options) => { + pending = (async () => { + options.onOptimistic?.() + await options.mutate() + options.onSuccess?.() + })() + return pending + }, + onStateChange: vi.fn(), + patchWorkItem: vi.fn(), + patchProjectRowIfNeeded: vi.fn(), + onMutated: vi.fn() + }) + await pending + expect(fixture.apiUpdate).toHaveBeenCalledWith(expect.objectContaining({ ownerRepo: target })) + const displayed = materializeTaskPageItemList({ + networkItems: [listItem], + previousItems: [listItem], + queryKey: 'current-upstream-list' + }) + expect(displayed[0]?.state).toBe(listItem.url === openedItem.url ? 'closed' : 'open') + } +) + +it('keeps ordinary metadata caches distinct by canonical repository and host', () => { + const identities = [ + issueRepo, + { ...issueRepo, owner: 'upstream-owner' }, + { ...issueRepo, host: 'ghe.example:8443' } + ] + for (const ownerRepo of identities) { + useRepoLabels(registeredRepo.path, registeredRepo.id, { ownerRepo }) + useRepoAssignees(registeredRepo.path, registeredRepo.id, { ownerRepo }) + } + expect(new Set(fixture.loads.filter((_, i) => i % 2 === 0).map((load) => load.key)).size).toBe(3) + expect(new Set(fixture.loads.filter((_, i) => i % 2 === 1).map((load) => load.key)).size).toBe(3) +}) + +it('keeps metadata requests without an explicit target compatible', async () => { + useRepoLabels(registeredRepo.path, registeredRepo.id) + useRepoAssignees(registeredRepo.path, registeredRepo.id) + for (const request of fixture.loads) { + await request.load() + } + expect(fixture.loads.map((load) => load.key)).toEqual([registeredRepo.id, registeredRepo.id]) + expect( + fixture.requests.map((request) => request.args.find((arg) => arg.startsWith('repos/'))) + ).toEqual([ + 'repos/upstream-owner/widgets/labels', + 'repos/upstream-owner/widgets/assignees?per_page=100' + ]) +}) + +it('keeps Project row metadata on the existing slug route', async () => { + const labels = vi.fn().mockResolvedValue({ ok: true, labels: [] }) + const users = vi.fn().mockResolvedValue({ ok: true, users: [] }) + vi.stubGlobal('window', { + api: { gh: { listLabelsBySlug: labels, listAssignableUsersBySlug: users } } + }) + renderEditSection({ + item: fork, + repoPath: registeredRepo.path, + repoId: fork.repoId, + sourceContext, + projectOrigin: { + owner: 'project-owner', + repo: 'outside', + host: 'ghe.example', + number: fork.number, + type: 'issue', + projectId: 'project-1', + projectItemId: 'row-1', + cacheKey: 'project-key' + }, + localState: 'open', + localLabels: [], + assignees: [], + onStateChange: vi.fn(), + onLabelsChange: vi.fn(), + onMutated: vi.fn(), + onUse: vi.fn() + }) + for (const request of fixture.loads.filter((load) => load.key !== null)) { + await request.load() + } + expect(labels).toHaveBeenCalledWith({ + owner: 'project-owner', + repo: 'outside', + host: 'ghe.example' + }) + expect(users).toHaveBeenCalledWith({ + owner: 'project-owner', + repo: 'outside', + host: 'ghe.example' + }) + expect(fixture.requests).toEqual([]) +}) + +it('rolls back a rejected fork label without changing the upstream row', async () => { + const store = createTestStore() + const key = workItemsCacheKey(registeredRepo.id, 36, '', getTaskSourceCacheScope(sourceContext)) + store.setState({ workItemsCache: { [key]: { data: [upstream, fork], fetchedAt: 1 } } }) + fixture.gh.mockRejectedValueOnce(new Error('Fixture rejects label')) + let pending = Promise.resolve(false) + const observed: string[][][] = [] + runGHEditLabelToggle({ + itemId: fork.id, + itemNumber: fork.number, + itemRepoId: fork.repoId, + repoPath: registeredRepo.path, + sourceContext, + projectOrigin: undefined, + issueRepo, + label: 'fork-only-label', + localLabels: [], + run: (_key, options) => { + pending = (async () => { + options.onOptimistic?.() + observed.push(store.getState().workItemsCache[key]?.data?.map((row) => row.labels) ?? []) + try { + await options.mutate() + return true + } catch { + options.onRevert?.() + observed.push(store.getState().workItemsCache[key]?.data?.map((row) => row.labels) ?? []) + return false + } + })() + return pending + }, + onLabelsChange: vi.fn(), + patchWorkItem: store.getState().patchWorkItem, + patchProjectRowIfNeeded: vi.fn(), + onMutated: vi.fn() + }) + expect(await pending).toBe(false) + expect(observed).toEqual([ + [[], ['fork-only-label']], + [[], []] + ]) +}) + +it('does not fall back to upstream metadata for an invalid explicit repository', async () => { + const invalid = { owner: '../escape', repo: 'widgets', host: 'github.com' } + await expect(listLabels(registeredRepo.path, 'upstream', null, {}, invalid)).resolves.toEqual([]) + await expect( + listAssignableUsers(registeredRepo.path, 'upstream', null, {}, invalid) + ).resolves.toEqual([]) + expect(fixture.requests).toEqual([]) +}) diff --git a/config/scripts/github-pr-assignee-repository.test.ts b/config/scripts/github-pr-assignee-repository.test.ts new file mode 100644 index 00000000000..0265aa92c41 --- /dev/null +++ b/config/scripts/github-pr-assignee-repository.test.ts @@ -0,0 +1,204 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import type * as GhUtils from '../../src/main/github/gh-utils' +import type * as ReactModule from 'react' +import type { GitHubOwnerRepo } from '../../src/shared/github/pull-request-types' +import type { GitHubWorkItem } from '../../src/shared/github/work-item-types' +import type { GitHubIssueUpdate } from '../../src/shared/issue-mutation-types' + +const fixture = vi.hoisted(() => { + const state: { + callbacks: unknown[] + mutation: Promise | null + gh: ReturnType + apiUpdate: ReturnType + patch: ReturnType + preference: 'origin' | 'upstream' + localGitOptions: { wslDistro?: string } + requests: { args: string[]; host?: string; cwd?: string; wslDistro?: string }[] + } = { + callbacks: [], + mutation: null, + gh: vi.fn(), + apiUpdate: vi.fn(), + patch: vi.fn(), + preference: 'origin', + localGitOptions: {}, + requests: [] + } + return state +}) + +vi.mock('react', async (original) => ({ + ...(await original()), + useState: (value: unknown) => [typeof value === 'function' ? value() : value, vi.fn()], + useMemo: (getValue: () => T) => getValue(), + useCallback: (callback: T) => { + fixture.callbacks.push(callback) + return callback + } +})) +vi.mock('zustand/react/shallow', () => ({ useShallow: (selector: T) => selector })) +vi.mock('@/store', () => ({ + useAppStore: Object.assign( + (selector: (state: unknown) => unknown) => + selector({ + patchWorkItem: fixture.patch, + patchProjectRowContent: fixture.patch, + repos: [], + settings: {} + }), + { getState: () => ({ recordFeatureInteraction: vi.fn() }) } + ) +})) +vi.mock('@/lib/repo-runtime-owner', () => ({ + getSettingsForRepoRuntimeOwner: () => ({ activeRuntimeEnvironmentId: null }) +})) +vi.mock('@/components/ui/popover', () => ({ + Popover: vi.fn(), + PopoverContent: vi.fn(), + PopoverTrigger: vi.fn() +})) +vi.mock('@/hooks/useIssueMetadata', () => ({ + useRepoAssignees: () => ({ data: [], loading: false, error: null }), + useImmediateMutation: () => ({ + isPending: () => false, + run: (_key: string, spec: { mutate: () => Promise }) => { + fixture.mutation = spec.mutate() + } + }) +})) +vi.mock('@/hooks/useGitHubSlugMetadata', () => ({ + useRepoAssigneesBySlug: () => ({ + data: [{ login: 'octo', name: null, avatarUrl: '' }], + loading: false, + error: null + }) +})) +vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) +vi.mock('@/components/github/work-item-state-presentation', () => ({ ReviewerAvatar: vi.fn() })) +vi.mock('../../src/main/github/gh-utils', async (original) => ({ + ...(await original()), + ghExecFileAsync: fixture.gh, + acquire: vi.fn(), + release: vi.fn(), + getOwnerRepoForRemote: async (_path: string, remote: string) => ({ + owner: remote === 'upstream' ? 'upstream-owner' : 'fork-owner', + repo: 'widgets' + }) +})) +vi.mock('../../src/main/git/remote-name-listing', () => ({ + shouldProbeGitRemote: async () => true +})) + +import { PRAssigneesPanel } from '../../src/renderer/src/components/github/PRAssigneesPanel' +import { updateIssue } from '../../src/main/github/issue-update' +import { _resetOriginGitHubApiRepositoryCache } from '../../src/main/github/github-api-repository' + +const repoPath = join(tmpdir(), 'orca-pr-assignee-repository-fixture') + +beforeEach(() => { + fixture.callbacks = [] + fixture.mutation = null + fixture.requests = [] + fixture.localGitOptions = {} + fixture.gh.mockReset() + fixture.gh.mockImplementation( + async ( + args: string[], + options: { + host?: string + cwd?: string + wslDistro?: string + } + ) => { + fixture.requests.push({ + args, + host: options.host, + cwd: options.cwd, + wslDistro: options.wslDistro + }) + return { stdout: '', stderr: '' } + } + ) + _resetOriginGitHubApiRepositoryCache() + fixture.apiUpdate = vi.fn( + (args: { + repoPath: string + number: number + updates: GitHubIssueUpdate + ownerRepo?: GitHubOwnerRepo + }) => + updateIssue( + args.repoPath, + args.number, + args.updates, + null, + fixture.localGitOptions, + fixture.preference, + args.ownerRepo + ) + ) + vi.stubGlobal('window', { api: { gh: { updateIssue: fixture.apiUpdate } } }) +}) + +afterEach(() => vi.unstubAllGlobals()) + +it.each([ + { owner: 'upstream-owner', preference: 'origin', assigned: false, legacy: false }, + { owner: 'upstream-owner', preference: 'origin', assigned: true, legacy: false }, + { owner: 'fork-owner', preference: 'upstream', assigned: false, legacy: false }, + { owner: 'fork-owner', preference: 'upstream', assigned: true, legacy: false }, + { owner: 'upstream-owner', preference: 'origin', assigned: false, legacy: true } +] as const)( + 'keeps $owner PR assignees under $preference (remove=$assigned, legacy=$legacy)', + async ({ owner, preference, assigned, legacy }) => { + fixture.preference = preference + fixture.localGitOptions = owner === 'fork-owner' ? { wslDistro: 'Ubuntu' } : {} + const item: GitHubWorkItem = { + id: 'pr:5', + type: 'pr', + number: 5, + title: 'Opened PR', + state: 'open', + url: `https://github.com/${owner}/widgets/pull/5`, + prRepo: legacy ? undefined : { owner, repo: 'widgets', host: 'github.com' }, + labels: [], + updatedAt: '', + author: null, + repoId: 'repo-1', + assignees: assigned ? [{ login: 'octo', name: null, avatarUrl: '' }] : [] + } + PRAssigneesPanel({ item, repoPath, projectOrigin: undefined, onMutated: vi.fn() }) + const toggleAssignee = fixture.callbacks.at(-1) + if (typeof toggleAssignee !== 'function') { + throw new Error('PR panel did not create an assignee handler') + } + toggleAssignee('octo') + await fixture.mutation + expect(fixture.requests).toEqual([ + { + args: [ + 'issue', + 'edit', + '5', + '--repo', + `${owner}/widgets`, + assigned ? '--remove-assignee' : '--add-assignee', + 'octo' + ], + host: 'github.com', + cwd: repoPath, + wslDistro: fixture.localGitOptions.wslDistro + } + ]) + expect(fixture.apiUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + repoPath, + repoId: item.repoId, + ownerRepo: { owner, repo: 'widgets', host: 'github.com' } + }) + ) + } +) diff --git a/src/main/github/client.ts b/src/main/github/client.ts index fa331749c8b..ed72bc12232 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -23,7 +23,11 @@ export { type PullRequestPushTarget } from './client/lookup/pull-request-push-target' export { getRepoSlug, getRepoUpstream } from './client/fetch/repo-slug-upstream' -export { getWorkItem, getWorkItemByOwnerRepo } from './client/fetch/get-work-item' +export { + getWorkItem, + getWorkItemByOwnerRepo, + getWorkItemWithRepository +} from './client/fetch/get-work-item' export { listWorkItems } from './client/list/list-work-items' export { mergePR } from './client/merge/merge-pr' export { removePRReviewers, requestPRReviewers } from './client/update/pr-reviewers' diff --git a/src/main/github/client/fetch/get-work-item.ts b/src/main/github/client/fetch/get-work-item.ts index 3178a4c921a..5224061dfa3 100644 --- a/src/main/github/client/fetch/get-work-item.ts +++ b/src/main/github/client/fetch/get-work-item.ts @@ -21,6 +21,30 @@ export async function getWorkItem( localGitOptions: LocalGitExecOptions = {}, preference?: IssueSourcePreference ): Promise { + const { item } = await getWorkItemWithRepository( + repoPath, + number, + type, + connectionId, + localGitOptions, + preference + ) + return item +} + +export type WorkItemRepositoryLookup = { + item: MainWorkItem | null + repository: GitHubApiRepository | null +} + +export async function getWorkItemWithRepository( + repoPath: string, + number: number, + type?: 'issue' | 'pr', + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {}, + preference?: IssueSourcePreference +): Promise { await acquire() try { // Why: listWorkItems uses resolveIssueGitHubApiRepositorySource; open-by-number @@ -35,18 +59,24 @@ export async function getWorkItem( // Why: explicit origin with no origin identity must not bare-lookup ambient gh // (same fail-closed rule as origin-pinned PR candidate resolution). if (!source && preference === 'origin') { - return null + return { item: null, repository: null } + } + return { + item: await fetchIssueWorkItem(repoPath, source, number, connectionId, localGitOptions), + repository: source } - return await fetchIssueWorkItem(repoPath, source, number, connectionId, localGitOptions) } if (type === 'pr') { - return await fetchPullRequestWorkItemFromCandidates( - repoPath, - number, - connectionId, - localGitOptions, - preference - ) + return { + item: await fetchPullRequestWorkItemFromCandidates( + repoPath, + number, + connectionId, + localGitOptions, + preference + ), + repository: null + } } try { @@ -65,7 +95,7 @@ export async function getWorkItem( localGitOptions ) if (issue) { - return issue + return { item: issue, repository: source } } } } catch (err) { @@ -75,15 +105,18 @@ export async function getWorkItem( throw err } } - return await fetchPullRequestWorkItemFromCandidates( - repoPath, - number, - connectionId, - localGitOptions, - preference - ) + return { + item: await fetchPullRequestWorkItemFromCandidates( + repoPath, + number, + connectionId, + localGitOptions, + preference + ), + repository: null + } } catch { - return null + return { item: null, repository: null } } finally { release() } diff --git a/src/main/github/github-api-repository-validation.test.ts b/src/main/github/github-api-repository-validation.test.ts index 81e395a8bdb..7f8eec06602 100644 --- a/src/main/github/github-api-repository-validation.test.ts +++ b/src/main/github/github-api-repository-validation.test.ts @@ -22,4 +22,25 @@ describe('isValidGitHubApiRepository', () => { expect(isValidGitHubApiRepository({ owner: 'acme', repo: '..' })).toBe(false) expect(isValidGitHubApiRepository({ owner: 'acme', repo: 'a/b' })).toBe(false) }) + + it.each([42, false, null, {}])('rejects a non-string repository host: %j', (host) => { + expect(isValidGitHubApiRepository({ owner: 'acme', repo: 'orca', host })).toBe(false) + }) + + it.each([undefined, 'github.com', 'github.enterprise.test:8443'])( + 'accepts supported legacy or explicit repository host types: %j', + (host) => { + expect(isValidGitHubApiRepository({ owner: 'acme', repo: 'orca', host })).toBe(true) + } + ) + + it.each([ + null, + 'acme/orca', + { owner: 42, repo: 'orca' }, + { owner: 'acme', repo: 42 }, + { owner: 'acme' } + ])('rejects malformed runtime repository identities: %j', (repository) => { + expect(isValidGitHubApiRepository(repository)).toBe(false) + }) }) diff --git a/src/main/github/github-api-repository-validation.ts b/src/main/github/github-api-repository-validation.ts index b687a0ebdee..4031e64db13 100644 --- a/src/main/github/github-api-repository-validation.ts +++ b/src/main/github/github-api-repository-validation.ts @@ -11,8 +11,21 @@ export type GitHubApiRepositoryResolution = const OWNER_SLUG_RE = GITHUB_OWNER_SLUG_RE const REPOSITORY_SLUG_RE = /^[A-Za-z0-9._-]+$/ -export function isValidGitHubApiRepository(repository: GitHubOwnerRepo): boolean { +export function isValidGitHubApiRepository(repository: unknown): repository is GitHubOwnerRepo { + if ( + !repository || + typeof repository !== 'object' || + !('owner' in repository) || + !('repo' in repository) + ) { + return false + } return ( + typeof repository.owner === 'string' && + typeof repository.repo === 'string' && + (!('host' in repository) || + repository.host === undefined || + typeof repository.host === 'string') && OWNER_SLUG_RE.test(repository.owner) && REPOSITORY_SLUG_RE.test(repository.repo) && repository.repo !== '.' && diff --git a/src/main/github/issue-comment.ts b/src/main/github/issue-comment.ts index ac3f6a205de..478ac1ce68c 100644 --- a/src/main/github/issue-comment.ts +++ b/src/main/github/issue-comment.ts @@ -1,32 +1,34 @@ import type { GitHubCommentResult, PRComment } from '../../shared/github/comment-types' +import type { IssueSourcePreference } from '../../shared/repo-types' import type { LocalGitExecOptions, OwnerRepo } from './gh-utils' -import { getIssueGitHubApiRepository, resolveGitHubRepoExecution } from './github-api-repository' +import { + resolveGitHubRepoExecution, + resolveIssueGitHubApiRepositorySource +} from './github-api-repository' import { acquire, classifyGhError, ghExecFileAsync, release } from './gh-utils' -/** - * Add a comment to an existing GitHub issue. - * - * Why this path doesn't take a preference (mirrors `getIssue` / `updateIssue`): - * a comment is posted against an issue number already bound to a worktree or - * surfaced from a prior read. Routing through the live per-repo preference - * would let a user read upstream#N, toggle the selector to origin, and have - * their reply silently post on origin#N — a different issue entirely. That - * is the same silent-source-switch class of wrongness #1186 / the parent - * design doc guard against. List and create paths honor preference; - * mutations stay on the heuristic `getIssueOwnerRepo`. - */ +// An explicit target keeps replies bound to the conversation; otherwise use the selected source. export async function addIssueComment( repoPath: string, issueNumber: number, body: string, connectionId?: string | null, ownerRepoOverride?: OwnerRepo | null, - localGitOptions: LocalGitExecOptions = {} + localGitOptions: LocalGitExecOptions = {}, + preference?: IssueSourcePreference ): Promise { const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( repoPath, ownerRepoOverride ?? - (() => getIssueGitHubApiRepository(repoPath, connectionId, localGitOptions)), + (async () => + ( + await resolveIssueGitHubApiRepositorySource( + repoPath, + preference, + connectionId, + localGitOptions + ) + ).source), connectionId, localGitOptions ) diff --git a/src/main/github/issue-field-options.ts b/src/main/github/issue-field-options.ts index a4d2aae32f4..77e2337ae8a 100644 --- a/src/main/github/issue-field-options.ts +++ b/src/main/github/issue-field-options.ts @@ -1,4 +1,4 @@ -import type { GitHubAssignableUser } from '../../shared/github/pull-request-types' +import type { GitHubAssignableUser, GitHubOwnerRepo } from '../../shared/github/pull-request-types' import type { IssueSourcePreference } from '../../shared/repo-types' import type { LocalGitExecOptions } from './gh-utils' import { @@ -11,19 +11,21 @@ export async function listLabels( repoPath: string, preference?: IssueSourcePreference, connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} + localGitOptions: LocalGitExecOptions = {}, + repositoryOverride?: GitHubOwnerRepo | null ): Promise { const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( repoPath, - async () => - ( - await resolveIssueGitHubApiRepositorySource( - repoPath, - preference, - connectionId, - localGitOptions - ) - ).source, + repositoryOverride ?? + (async () => + ( + await resolveIssueGitHubApiRepositorySource( + repoPath, + preference, + connectionId, + localGitOptions + ) + ).source), connectionId, localGitOptions ) @@ -57,19 +59,21 @@ export async function listAssignableUsers( repoPath: string, preference?: IssueSourcePreference, connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} + localGitOptions: LocalGitExecOptions = {}, + repositoryOverride?: GitHubOwnerRepo | null ): Promise { const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( repoPath, - async () => - ( - await resolveIssueGitHubApiRepositorySource( - repoPath, - preference, - connectionId, - localGitOptions - ) - ).source, + repositoryOverride ?? + (async () => + ( + await resolveIssueGitHubApiRepositorySource( + repoPath, + preference, + connectionId, + localGitOptions + ) + ).source), connectionId, localGitOptions ) diff --git a/src/main/github/issue-mutation-source.test.ts b/src/main/github/issue-mutation-source.test.ts new file mode 100644 index 00000000000..48967198fd1 --- /dev/null +++ b/src/main/github/issue-mutation-source.test.ts @@ -0,0 +1,178 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as GithubApiRepositoryModule from './github-api-repository' +import type * as GhUtils from './gh-utils' + +const { ghExecFileAsyncMock, resolveIssueSourceMock, acquireMock, releaseMock } = vi.hoisted( + () => ({ + ghExecFileAsyncMock: vi.fn(), + resolveIssueSourceMock: vi.fn(), + acquireMock: vi.fn(), + releaseMock: vi.fn() + }) +) + +vi.mock('./gh-utils', async () => { + const actual = await vi.importActual('./gh-utils') + return { + ...actual, + ghExecFileAsync: ghExecFileAsyncMock, + acquire: acquireMock, + release: releaseMock + } +}) + +vi.mock('./github-api-repository', async (importOriginal) => ({ + ...(await importOriginal()), + resolveIssueGitHubApiRepositorySource: resolveIssueSourceMock +})) + +import { addIssueComment } from './issue-comment' +import { updateIssue } from './issue-update' + +const ORIGIN = { owner: 'fork-owner', repo: 'widgets', host: 'github.com' } +const UPSTREAM = { owner: 'upstream-owner', repo: 'widgets', host: 'github.com' } + +describe('issue mutations follow the issue source preference', () => { + beforeEach(() => { + ghExecFileAsyncMock.mockReset() + acquireMock.mockReset() + acquireMock.mockResolvedValue(undefined) + releaseMock.mockReset() + resolveIssueSourceMock.mockReset() + resolveIssueSourceMock.mockImplementation(async (_repoPath: string, preference: unknown) => ({ + source: preference === 'origin' ? ORIGIN : UPSTREAM, + fellBack: false + })) + }) + + it('posts issue comments to origin when origin is selected', async () => { + ghExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ id: 1, user: { login: 'octo', avatar_url: '' }, body: 'hi' }) + }) + + await expect( + addIssueComment('/repo-root', 5, 'hi', null, null, {}, 'origin') + ).resolves.toMatchObject({ ok: true }) + + expect(resolveIssueSourceMock).toHaveBeenCalledWith('/repo-root', 'origin', null, {}) + expect(ghExecFileAsyncMock.mock.calls[0][0]).toContain( + 'repos/fork-owner/widgets/issues/5/comments' + ) + }) + + it('keeps an explicit owner/repo override ahead of the preference', async () => { + ghExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ id: 2, user: { login: 'octo', avatar_url: '' }, body: 'hi' }) + }) + + await addIssueComment('/repo-root', 7, 'hi', null, UPSTREAM, {}, 'origin') + + expect(resolveIssueSourceMock).not.toHaveBeenCalled() + expect(ghExecFileAsyncMock.mock.calls[0][0]).toContain( + 'repos/upstream-owner/widgets/issues/7/comments' + ) + }) + + it('edits issues on origin when origin is selected', async () => { + ghExecFileAsyncMock.mockResolvedValue({ stdout: '' }) + + await expect( + updateIssue('/repo-root', 5, { body: 'Updated', addAssignees: ['octo'] }, null, {}, 'origin') + ).resolves.toEqual({ ok: true }) + + expect(resolveIssueSourceMock).toHaveBeenCalledWith('/repo-root', 'origin', null, {}) + expect(ghExecFileAsyncMock.mock.calls[0][0]).toContain('repos/fork-owner/widgets/issues/5') + expect(ghExecFileAsyncMock.mock.calls[1][0]).toEqual([ + 'issue', + 'edit', + '5', + '--repo', + 'fork-owner/widgets', + '--add-assignee', + 'octo' + ]) + }) + + it('keeps the upstream-first default when no preference is set', async () => { + ghExecFileAsyncMock.mockResolvedValue({ stdout: '' }) + + await updateIssue('/repo-root', 5, { body: 'Updated' }) + + expect(resolveIssueSourceMock).toHaveBeenCalledWith('/repo-root', undefined, undefined, {}) + expect(ghExecFileAsyncMock.mock.calls[0][0]).toContain('repos/upstream-owner/widgets/issues/5') + }) + + it('keeps edits on the opened issue when the saved preference changes', async () => { + ghExecFileAsyncMock.mockResolvedValue({ stdout: '' }) + + await updateIssue( + '/repo-root', + 5, + { body: 'Origin edit', state: 'closed', addLabels: ['bug'], addAssignees: ['octo'] }, + null, + {}, + 'upstream', + ORIGIN + ) + + expect(resolveIssueSourceMock).not.toHaveBeenCalled() + expect(ghExecFileAsyncMock.mock.calls.map(([args]) => args)).toEqual([ + ['issue', 'close', '5', '--repo', 'fork-owner/widgets'], + [ + 'api', + '-X', + 'PATCH', + 'repos/fork-owner/widgets/issues/5', + '--raw-field', + 'body=Origin edit' + ], + [ + 'issue', + 'edit', + '5', + '--repo', + 'fork-owner/widgets', + '--add-label', + 'bug', + '--add-assignee', + 'octo' + ] + ]) + }) + + it('preserves local WSL execution with an explicit issue repository', async () => { + ghExecFileAsyncMock.mockResolvedValue({ stdout: '' }) + + await updateIssue( + '/home/fixture/widgets', + 5, + { body: 'Origin edit' }, + null, + { wslDistro: 'Ubuntu' }, + 'upstream', + ORIGIN + ) + + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + expect.arrayContaining(['repos/fork-owner/widgets/issues/5']), + expect.objectContaining({ + cwd: '/home/fixture/widgets', + wslDistro: 'Ubuntu', + host: 'github.com' + }) + ) + }) + + it('refuses unresolved Origin mutations instead of using ambient gh', async () => { + resolveIssueSourceMock.mockResolvedValue({ source: null, fellBack: false }) + + await expect( + updateIssue('/repo-root', 5, { body: 'Origin edit' }, null, {}, 'origin') + ).resolves.toMatchObject({ ok: false }) + await expect( + addIssueComment('/repo-root', 5, 'Origin reply', null, null, {}, 'origin') + ).resolves.toMatchObject({ ok: false }) + + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/github/issue-update.ts b/src/main/github/issue-update.ts index 801c12b79c5..8b00b24a148 100644 --- a/src/main/github/issue-update.ts +++ b/src/main/github/issue-update.ts @@ -1,31 +1,35 @@ import type { GitHubIssueUpdate } from '../../shared/issue-mutation-types' +import type { IssueSourcePreference } from '../../shared/repo-types' import type { LocalGitExecOptions } from './gh-utils' -import { getIssueGitHubApiRepository, resolveGitHubRepoExecution } from './github-api-repository' +import { + resolveGitHubRepoExecution, + resolveIssueGitHubApiRepositorySource, + type GitHubApiRepository +} from './github-api-repository' import { acquire, classifyGhError, ghExecFileAsync, release } from './gh-utils' -/** - * Update an existing GitHub issue. Fans out to separate gh commands for - * state changes vs field edits since `gh issue edit` does not support state. - * - * Why this path doesn't take a preference (mirrors `getIssue`): mutations - * target an issue number already bound to a worktree / linked elsewhere in - * the UI. Routing an update through the live per-repo preference would let - * a user open upstream#N, toggle the selector to origin, save, and silently - * write to origin#N — a different issue (or 404). That is the exact - * silent-source-switch class of wrongness #1186 / the parent design doc - * guard against. List and create paths honor preference; mutations stay on - * the heuristic `getIssueOwnerRepo`. - */ +// Use the selected source for issue-number edits. export async function updateIssue( repoPath: string, issueNumber: number, updates: GitHubIssueUpdate, connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} + localGitOptions: LocalGitExecOptions = {}, + preference?: IssueSourcePreference, + ownerRepoOverride?: GitHubApiRepository | null ): Promise<{ ok: true } | { ok: false; error: string }> { const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( repoPath, - () => getIssueGitHubApiRepository(repoPath, connectionId, localGitOptions), + ownerRepoOverride ?? + (async () => + ( + await resolveIssueGitHubApiRepositorySource( + repoPath, + preference, + connectionId, + localGitOptions + ) + ).source), connectionId, localGitOptions ) diff --git a/src/main/github/work-item-details-concurrency.test.ts b/src/main/github/work-item-details-concurrency.test.ts index 3fdbacb6491..d7a0671470e 100644 --- a/src/main/github/work-item-details-concurrency.test.ts +++ b/src/main/github/work-item-details-concurrency.test.ts @@ -1,3 +1,4 @@ +import type * as WorkItemDetailsTestLookup from './work-item-details-test-lookup' import { beforeEach, describe, expect, it, vi } from 'vitest' const { @@ -27,8 +28,11 @@ vi.mock('./gh-utils', () => ({ }) })) -vi.mock('./client', () => ({ +vi.mock('./client', async () => ({ getWorkItem: getWorkItemMock, + getWorkItemWithRepository: ( + await vi.importActual('./work-item-details-test-lookup') + ).makeWorkItemDetailsLookupMock(getWorkItemMock), getPRComments: getPRCommentsMock, getPRChecks: getPRChecksMock })) diff --git a/src/main/github/work-item-details-config-change.test.ts b/src/main/github/work-item-details-config-change.test.ts new file mode 100644 index 00000000000..f10ecfe0046 --- /dev/null +++ b/src/main/github/work-item-details-config-change.test.ts @@ -0,0 +1,207 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { mkdir, mkdtemp, rm } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import type * as NodeOs from 'node:os' +import type * as GitExecError from '../git/exec-error' +import type * as WorkItemLookup from './client/fetch/get-work-item' +import { runProcess } from '../../shared/child-process/run-process' + +const { ghMock, fixture } = vi.hoisted(() => { + const fixture: { + path: string + initialHost: string + replacementUrl: string + requests: { args: string[]; host?: string }[] + } = { path: '', initialHost: '', replacementUrl: '', requests: [] } + return { ghMock: vi.fn(), fixture } +}) + +vi.mock('node:os', async (original) => ({ + ...(await original()), + homedir: () => join(fixture.path, 'isolated-home') +})) + +vi.mock('../git/runner', async () => { + const errors = await vi.importActual('../git/exec-error') + return { + ...errors, + ghExecFileAsync: ghMock, + gitExecFileAsync: async (args: string[], options: { cwd?: string }) => { + const result = await runProcess({ + program: 'git', + args, + cwd: options.cwd, + env: { + ...process.env, + GIT_CONFIG_NOSYSTEM: '1', + GIT_CONFIG_GLOBAL: join(fixture.path, 'isolated-home', '.gitconfig') + } + }) + if (result.code !== 0) { + throw new Error(result.stderr) + } + return { stdout: result.stdout, stderr: result.stderr } + } + } +}) + +vi.mock('./client', async () => ({ + ...(await vi.importActual('./client/fetch/get-work-item')), + getPRChecks: vi.fn(), + getPRComments: vi.fn() +})) + +vi.mock('./rate-limit', () => ({ + repositoryRateLimitGuard: () => ({ blocked: false }), + noteRepositoryRateLimitSpend: vi.fn() +})) + +import { getWorkItemDetails } from './work-item-details' +import { _resetOwnerRepoCache } from './github-repository-identity' +import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository' +import { _resetGitHubHostAuthCache } from './github-enterprise-repository' + +async function fixtureGit(args: string[]): Promise { + const result = await runProcess({ + program: 'git', + args, + cwd: fixture.path, + env: { + ...process.env, + GIT_CONFIG_NOSYSTEM: '1', + GIT_CONFIG_GLOBAL: join(fixture.path, 'isolated-home', '.gitconfig') + } + }) + expect(result.code, result.stderr).toBe(0) +} + +beforeEach(async () => { + fixture.path = await mkdtemp(join(tmpdir(), 'orca-issue-config-change-')) + await mkdir(join(fixture.path, 'isolated-home')) + await fixtureGit(['init', '--quiet']) + await fixtureGit(['remote', 'add', 'origin', 'https://github.com/fork-owner/widgets.git']) + _resetOwnerRepoCache() + _resetOriginGitHubApiRepositoryCache() + _resetGitHubHostAuthCache() + fixture.initialHost = 'github.com' + fixture.replacementUrl = 'https://github.com/replacement-owner/widgets.git' + fixture.requests = [] + ghMock.mockReset() + ghMock.mockImplementation(async (args: string[], options: { host?: string }) => { + fixture.requests.push({ args, host: options.host }) + if (args[0] === 'auth' && args[1] === 'status') { + return { + stdout: ['github.com', 'github.enterprise.test'] + .map((host) => `${host}\n Logged in to ${host} account fixture (keyring)`) + .join('\n'), + stderr: '' + } + } + if (args.includes('repos/fork-owner/widgets/issues/5')) { + await fixtureGit(['remote', 'set-url', 'origin', fixture.replacementUrl]) + return { + stdout: JSON.stringify({ + number: 5, + title: 'ORIGIN title', + state: 'open', + html_url: `https://${fixture.initialHost}/fork-owner/widgets/issues/5`, + labels: [], + updated_at: '2026-10-02T00:00:00Z', + user: { login: 'fork-author' } + }), + stderr: '' + } + } + if (args.includes('graphql')) { + const original = args.includes('owner=fork-owner') && options.host === fixture.initialHost + const marker = original ? 'ORIGIN' : 'REPLACEMENT' + return { + stdout: JSON.stringify({ + data: { + repository: { + issue: { + body: `${marker} body`, + assignees: { nodes: [{ login: `${marker}-assignee` }] }, + participants: { nodes: [] }, + comments: { + nodes: [ + { + databaseId: 1, + body: `${marker} comment`, + createdAt: '2026-10-02T00:00:00Z', + url: `https://${options.host}/fork-owner/widgets/issues/5#issuecomment-1`, + author: { login: `${marker}-author`, avatarUrl: '' } + } + ] + } + } + } + } + }), + stderr: '' + } + } + if (args.some((arg) => arg.includes('/timeline?'))) { + return { stdout: '[]', stderr: '' } + } + throw new Error(`Unexpected fixture gh request: ${args.join(' ')}`) + }) +}) + +afterEach(async () => { + await rm(fixture.path, { recursive: true, force: true }) +}) + +it.each(['issue', undefined] as const)( + 'keeps implicit %s conversation on the repository used for the item fetch', + async (type) => { + const details = await getWorkItemDetails(fixture.path, 5, type, null, {}, 'origin') + expect(details?.item.title).toBe('ORIGIN title') + expect(details?.body).toBe('ORIGIN body') + expect(details?.assignees).toEqual(['ORIGIN-assignee']) + expect(details?.comments.map((comment) => comment.body)).toEqual(['ORIGIN comment']) + expect(fixture.requests[1].args).toContain('owner=fork-owner') + } +) + +it('keeps an explicit local Tasks identity through the same real config change', async () => { + const details = await getWorkItemDetails(fixture.path, 5, 'issue', null, {}, 'origin', { + owner: 'fork-owner', + repo: 'widgets', + host: 'github.com' + }) + expect(details?.item.title).toBe('ORIGIN title') + expect(details?.body).toBe('ORIGIN body') +}) + +it.each([ + { from: 'github.com', to: 'github.enterprise.test', qualified: false }, + { from: 'github.enterprise.test', to: 'github.com', qualified: false }, + { from: 'github.com', to: 'github.enterprise.test', qualified: true }, + { from: 'github.enterprise.test', to: 'github.com', qualified: true } +])( + 'keeps explicit host from $from to $to (qualified=$qualified)', + async ({ from, to, qualified }) => { + fixture.initialHost = from + fixture.replacementUrl = `https://${to}/fork-owner/widgets.git` + await fixtureGit(['remote', 'set-url', 'origin', `https://${from}/fork-owner/widgets.git`]) + const details = await getWorkItemDetails(fixture.path, 5, 'issue', null, {}, 'origin', { + owner: 'fork-owner', + repo: 'widgets', + ...(qualified ? { host: from } : {}) + }) + expect(details?.item.url).toBe(`https://${from}/fork-owner/widgets/issues/5`) + expect(details?.body).toBe('ORIGIN body') + expect(details?.assignees).toEqual(['ORIGIN-assignee']) + expect(details?.comments.map((comment) => comment.body)).toEqual(['ORIGIN comment']) + const conversationRequests = fixture.requests.filter( + ({ args }) => args.includes('graphql') || args.some((arg) => arg.includes('/timeline?')) + ) + expect(conversationRequests.some(({ args }) => args.includes('graphql'))).toBe(true) + expect( + conversationRequests.some(({ args }) => args.some((arg) => arg.includes('/timeline?'))) + ).toBe(true) + expect(conversationRequests.map(({ host }) => host)).toEqual([from, from]) + } +) diff --git a/src/main/github/work-item-details-enterprise-host.test.ts b/src/main/github/work-item-details-enterprise-host.test.ts index be2576051b5..b615f4e566b 100644 --- a/src/main/github/work-item-details-enterprise-host.test.ts +++ b/src/main/github/work-item-details-enterprise-host.test.ts @@ -1,3 +1,4 @@ +import type * as WorkItemDetailsTestLookup from './work-item-details-test-lookup' import { beforeEach, describe, expect, it, vi } from 'vitest' type RateLimitGuardResult = @@ -47,8 +48,11 @@ vi.mock('./gh-utils', () => ({ release: vi.fn() })) -vi.mock('./client', () => ({ +vi.mock('./client', async () => ({ getWorkItem: getWorkItemMock, + getWorkItemWithRepository: ( + await vi.importActual('./work-item-details-test-lookup') + ).makeWorkItemDetailsLookupMock(getWorkItemMock), getWorkItemByOwnerRepo: getWorkItemByOwnerRepoMock, getPRChecks: getPRChecksMock, getPRComments: getPRCommentsMock diff --git a/src/main/github/work-item-details-file-viewed.test.ts b/src/main/github/work-item-details-file-viewed.test.ts index 6075803493c..7cbfa041608 100644 --- a/src/main/github/work-item-details-file-viewed.test.ts +++ b/src/main/github/work-item-details-file-viewed.test.ts @@ -1,3 +1,4 @@ +import type * as WorkItemDetailsTestLookup from './work-item-details-test-lookup' import { beforeEach, describe, expect, it, vi } from 'vitest' type RateLimitGuardResult = @@ -59,8 +60,11 @@ vi.mock('./gh-utils', () => ({ release: releaseMock })) -vi.mock('./client', () => ({ +vi.mock('./client', async () => ({ getWorkItem: getWorkItemMock, + getWorkItemWithRepository: ( + await vi.importActual('./work-item-details-test-lookup') + ).makeWorkItemDetailsLookupMock(getWorkItemMock), getWorkItemByOwnerRepo: vi.fn(), getPRChecks: getPRChecksMock, getPRComments: getPRCommentsMock diff --git a/src/main/github/work-item-details-issue-source.test.ts b/src/main/github/work-item-details-issue-source.test.ts new file mode 100644 index 00000000000..e52de876d18 --- /dev/null +++ b/src/main/github/work-item-details-issue-source.test.ts @@ -0,0 +1,239 @@ +import type * as WorkItemDetailsTestLookup from './work-item-details-test-lookup' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + ghExecFileAsyncMock, + getOwnerRepoMock, + getOwnerRepoForRemoteMock, + getWorkItemMock, + getWorkItemByOwnerRepoMock, + acquireMock, + releaseMock +} = vi.hoisted(() => ({ + ghExecFileAsyncMock: vi.fn(), + getOwnerRepoMock: vi.fn(), + getOwnerRepoForRemoteMock: vi.fn(), + getWorkItemMock: vi.fn(), + getWorkItemByOwnerRepoMock: vi.fn(), + acquireMock: vi.fn(), + releaseMock: vi.fn() +})) + +vi.mock('./gh-utils', () => ({ + ghExecFileAsync: ghExecFileAsyncMock, + getOwnerRepo: getOwnerRepoMock, + getOwnerRepoForRemote: getOwnerRepoForRemoteMock, + ghRepoExecOptions: vi.fn((context) => ({ cwd: context.repoPath })), + githubRepoContext: vi.fn((repoPath, connectionId, localGitOptions) => ({ + repoPath, + connectionId: connectionId ?? null, + ...localGitOptions + })), + acquire: acquireMock, + release: releaseMock +})) + +vi.mock('./client', async () => ({ + getWorkItem: getWorkItemMock, + getWorkItemWithRepository: ( + await vi.importActual('./work-item-details-test-lookup') + ).makeWorkItemDetailsLookupMock(getWorkItemMock), + getWorkItemByOwnerRepo: getWorkItemByOwnerRepoMock, + getPRChecks: vi.fn(), + getPRComments: vi.fn() +})) + +vi.mock('./github-enterprise-repository', () => ({ + getEnterpriseGitHubRepoSlug: vi.fn().mockResolvedValue(null), + getEnterpriseGitHubRepoSlugForRemote: vi.fn().mockResolvedValue(null), + isGitHubHostAuthenticated: vi.fn().mockResolvedValue(true) +})) + +vi.mock('./rate-limit', () => ({ + repositoryRateLimitGuard: vi.fn(() => ({ blocked: false })), + noteRepositoryRateLimitSpend: vi.fn() +})) + +vi.mock('../git/remote-name-listing', () => ({ + shouldProbeGitRemote: vi.fn().mockResolvedValue(true) +})) + +import { getWorkItemDetails } from './work-item-details' +import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository' + +const REMOTES: Record = { + origin: { owner: 'fork-owner', repo: 'widgets' }, + upstream: { owner: 'upstream-owner', repo: 'widgets' } +} + +function graphQLIssueResponse(assignee: string): { stdout: string } { + return { + stdout: JSON.stringify({ + data: { + repository: { + issue: { + body: `${assignee} body`, + assignees: { nodes: [{ login: assignee }] }, + participants: { nodes: [] }, + comments: { nodes: [] } + } + } + } + }) + } +} + +function graphQLOwnerArg(): string | undefined { + const args: string[] = ghExecFileAsyncMock.mock.calls[0][0] + return args.find((arg) => arg.startsWith('owner=')) +} + +describe('getWorkItemDetails issue source', () => { + beforeEach(() => { + _resetOriginGitHubApiRepositoryCache() + ghExecFileAsyncMock.mockReset() + getOwnerRepoMock.mockReset() + getOwnerRepoMock.mockResolvedValue(REMOTES.origin) + getOwnerRepoForRemoteMock.mockReset() + getOwnerRepoForRemoteMock.mockImplementation( + async (_repoPath: string, remoteName: string) => REMOTES[remoteName] ?? null + ) + getWorkItemMock.mockReset() + getWorkItemByOwnerRepoMock.mockReset() + getWorkItemMock.mockResolvedValue({ + id: 'issue:5', + type: 'issue', + number: 5, + title: 'Origin issue', + state: 'open', + url: 'https://github.com/fork-owner/widgets/issues/5', + labels: [], + updatedAt: '2026-04-01T00:00:00Z', + author: 'fork-author' + }) + acquireMock.mockReset() + acquireMock.mockResolvedValue(undefined) + releaseMock.mockReset() + }) + + it('reads body, assignees and comments from origin when origin is selected', async () => { + ghExecFileAsyncMock + .mockResolvedValueOnce(graphQLIssueResponse('fork-assignee')) + .mockResolvedValueOnce({ stdout: '' }) + + const details = await getWorkItemDetails('/repo-root', 5, 'issue', null, {}, 'origin') + + expect(getWorkItemMock).toHaveBeenCalledWith('/repo-root', 5, 'issue', null, {}, 'origin') + expect(graphQLOwnerArg()).toBe('owner=fork-owner') + expect(ghExecFileAsyncMock.mock.calls[1][0]).toContain( + 'repos/fork-owner/widgets/issues/5/timeline?per_page=100&page=1' + ) + expect(details?.assignees).toEqual(['fork-assignee']) + }) + + it('keeps the upstream-first default when no preference is set', async () => { + ghExecFileAsyncMock + .mockResolvedValueOnce(graphQLIssueResponse('upstream-assignee')) + .mockResolvedValueOnce({ stdout: '' }) + + const details = await getWorkItemDetails('/repo-root', 5, 'issue', null, {}) + + expect(graphQLOwnerArg()).toBe('owner=upstream-owner') + expect(details?.assignees).toEqual(['upstream-assignee']) + }) + + it('keeps a Project row on upstream even when Origin is selected', async () => { + const upstream = { ...REMOTES.upstream, host: 'github.com' } + getWorkItemByOwnerRepoMock.mockResolvedValue({ + id: 'issue:5', + type: 'issue', + number: 5, + title: 'Upstream project issue', + url: 'https://github.com/upstream-owner/widgets/issues/5', + state: 'open', + labels: [], + updatedAt: '', + author: 'upstream-author' + }) + ghExecFileAsyncMock + .mockResolvedValueOnce(graphQLIssueResponse('upstream-assignee')) + .mockResolvedValueOnce({ stdout: '' }) + + const details = await getWorkItemDetails('/repo-root', 5, 'issue', null, {}, 'origin', upstream) + + expect(getWorkItemMock).not.toHaveBeenCalled() + expect(getWorkItemByOwnerRepoMock).toHaveBeenCalledWith( + '/repo-root', + upstream, + 5, + 'issue', + null, + {} + ) + expect(graphQLOwnerArg()).toBe('owner=upstream-owner') + expect(details?.item.title).toBe('Upstream project issue') + expect(details?.assignees).toEqual(['upstream-assignee']) + }) + + it('does not fall back when the selected issue repository is unavailable', async () => { + getWorkItemByOwnerRepoMock.mockResolvedValue(null) + + await expect( + getWorkItemDetails('/repo-root', 5, 'issue', null, {}, 'upstream', { + ...REMOTES.origin, + host: 'github.com' + }) + ).resolves.toBeNull() + + expect(getWorkItemMock).not.toHaveBeenCalled() + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + }) + + it('rejects an invalid runtime issue host before calling the repository lookup', async () => { + await expect( + getWorkItemDetails('/repo-root', 5, 'issue', null, {}, 'upstream', { + ...REMOTES.origin, + host: 42 + }) + ).resolves.toBeNull() + + expect(getWorkItemByOwnerRepoMock).not.toHaveBeenCalled() + expect(getWorkItemMock).not.toHaveBeenCalled() + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + }) + + it('ignores an issue repository override on an untyped lookup', async () => { + ghExecFileAsyncMock + .mockResolvedValueOnce(graphQLIssueResponse('fork-assignee')) + .mockResolvedValueOnce({ stdout: '' }) + + await getWorkItemDetails('/repo-root', 5, undefined, null, {}, 'origin', { + owner: 'untrusted', + repo: 'other', + host: 'untrusted.example' + }) + + expect(getWorkItemByOwnerRepoMock).not.toHaveBeenCalled() + expect(graphQLOwnerArg()).toBe('owner=fork-owner') + expect(ghExecFileAsyncMock.mock.calls[0][1]).toMatchObject({ host: 'github.com' }) + }) + + it('normalizes host-less issue targets before loading the conversation', async () => { + getWorkItemByOwnerRepoMock.mockResolvedValue({ + type: 'issue', + number: 5, + title: 'Origin issue', + url: 'https://github.com/fork-owner/widgets/issues/5', + author: null + }) + ghExecFileAsyncMock + .mockResolvedValueOnce(graphQLIssueResponse('fork-assignee')) + .mockResolvedValueOnce({ stdout: '' }) + + await getWorkItemDetails('/repo-root', 5, 'issue', null, {}, 'upstream', REMOTES.origin) + + expect(graphQLOwnerArg()).toBe('owner=fork-owner') + expect(ghExecFileAsyncMock.mock.calls[0][1]).toMatchObject({ host: 'github.com' }) + expect(ghExecFileAsyncMock.mock.calls[1][1]).toMatchObject({ host: 'github.com' }) + }) +}) diff --git a/src/main/github/work-item-details-pr-files.test.ts b/src/main/github/work-item-details-pr-files.test.ts index d0205332bd5..c7af8abac85 100644 --- a/src/main/github/work-item-details-pr-files.test.ts +++ b/src/main/github/work-item-details-pr-files.test.ts @@ -1,3 +1,4 @@ +import type * as WorkItemDetailsTestLookup from './work-item-details-test-lookup' import { beforeEach, describe, expect, it, vi } from 'vitest' type RateLimitGuardResult = @@ -52,8 +53,11 @@ vi.mock('./gh-utils', () => ({ release: vi.fn() })) -vi.mock('./client', () => ({ +vi.mock('./client', async () => ({ getWorkItem: getWorkItemMock, + getWorkItemWithRepository: ( + await vi.importActual('./work-item-details-test-lookup') + ).makeWorkItemDetailsLookupMock(getWorkItemMock), getWorkItemByOwnerRepo: vi.fn(), getPRChecks: getPRChecksMock, getPRComments: getPRCommentsMock diff --git a/src/main/github/work-item-details-test-lookup.ts b/src/main/github/work-item-details-test-lookup.ts new file mode 100644 index 00000000000..69c1daf0777 --- /dev/null +++ b/src/main/github/work-item-details-test-lookup.ts @@ -0,0 +1,17 @@ +import { resolveIssueGitHubApiRepositorySource } from './github-api-repository' +import type { getWorkItem } from './client' +import type { WorkItemRepositoryLookup } from './client/fetch/get-work-item' + +// Why: detail tests replace item lookup; retain its selected-source metadata. +export function makeWorkItemDetailsLookupMock(lookup: typeof getWorkItem) { + return async (...args: Parameters): Promise => { + const item = await lookup(...args) + return { + item, + repository: + item?.type === 'issue' + ? (await resolveIssueGitHubApiRepositorySource(args[0], args[5], args[3], args[4])).source + : null + } + } +} diff --git a/src/main/github/work-item-details.test.ts b/src/main/github/work-item-details.test.ts index 3ec1a5a4d9f..ef2d3649818 100644 --- a/src/main/github/work-item-details.test.ts +++ b/src/main/github/work-item-details.test.ts @@ -1,3 +1,4 @@ +import type * as WorkItemDetailsTestLookup from './work-item-details-test-lookup' import { beforeEach, describe, expect, it, vi } from 'vitest' type RateLimitGuardResult = @@ -62,8 +63,11 @@ vi.mock('./gh-utils', () => ({ release: releaseMock })) -vi.mock('./client', () => ({ +vi.mock('./client', async () => ({ getWorkItem: getWorkItemMock, + getWorkItemWithRepository: ( + await vi.importActual('./work-item-details-test-lookup') + ).makeWorkItemDetailsLookupMock(getWorkItemMock), getWorkItemByOwnerRepo: getWorkItemByOwnerRepoMock, getPRChecks: getPRChecksMock, getPRComments: getPRCommentsMock diff --git a/src/main/github/work-item-details.ts b/src/main/github/work-item-details.ts index dafc4f514d6..385d66c9515 100644 --- a/src/main/github/work-item-details.ts +++ b/src/main/github/work-item-details.ts @@ -1,14 +1,16 @@ import type { PRCheckDetail } from '../../shared/github/check-types' import type { GitHubPRFile, GitHubPRFileContents } from '../../shared/github/pull-request-types' -import type { GitHubWorkItem, GitHubWorkItemDetails } from '../../shared/github/work-item-types' +import type { GitHubWorkItemDetails } from '../../shared/github/work-item-types' import type { IssueSourcePreference } from '../../shared/repo-types' -import { getPRChecks, getPRComments, getWorkItem } from './client' -import { acquire, release, type LocalGitExecOptions } from './gh-utils' import { - getIssueGitHubApiRepository, - resolveGitHubRepoExecution, - type GitHubApiRepository -} from './github-api-repository' + getPRChecks, + getPRComments, + getWorkItemWithRepository, + getWorkItemByOwnerRepo +} from './client' +import { acquire, release, type LocalGitExecOptions } from './gh-utils' +import { resolveGitHubRepoExecution, type GitHubApiRepository } from './github-api-repository' +import { isValidGitHubApiRepository } from './github-api-repository-validation' import { getIssueBodyAndComments, getIssueDetailsViaGraphQL } from './issue-work-item-details' import { getPRFiles, @@ -69,23 +71,49 @@ export async function getWorkItemDetails( type?: 'issue' | 'pr', connectionId?: string | null, localGitOptions: LocalGitExecOptions = {}, - preference?: IssueSourcePreference + preference?: IssueSourcePreference, + repositoryOverride?: unknown ): Promise { - const item: Omit | null = await getWorkItem( - repoPath, - number, - type, - connectionId, - localGitOptions, - preference - ) + let issueRepositoryOverride: GitHubApiRepository | null = null + if (type === 'issue' && repositoryOverride != null) { + if (!isValidGitHubApiRepository(repositoryOverride)) { + return null + } + issueRepositoryOverride = ( + await resolveGitHubRepoExecution(repoPath, repositoryOverride, connectionId, localGitOptions) + ).ownerRepo + if (!issueRepositoryOverride) { + return null + } + } + const { item, repository: selectedRepository } = issueRepositoryOverride + ? { + item: await getWorkItemByOwnerRepo( + repoPath, + issueRepositoryOverride, + number, + 'issue', + connectionId, + localGitOptions + ), + repository: issueRepositoryOverride + } + : await getWorkItemWithRepository( + repoPath, + number, + type, + connectionId, + localGitOptions, + preference + ) if (!item) { return null } + // Keep the conversation in the same repository as the selected issue. const resolvedRepository = item.type === 'issue' - ? await getIssueGitHubApiRepository(repoPath, connectionId, localGitOptions) + ? selectedRepository : (await resolveGitHubRepoExecution(repoPath, item.prRepo, connectionId, localGitOptions)) .ownerRepo diff --git a/src/main/ipc/github-issue-mutation-handlers.ts b/src/main/ipc/github-issue-mutation-handlers.ts index 992f0c6e11f..8936e2eb15a 100644 --- a/src/main/ipc/github-issue-mutation-handlers.ts +++ b/src/main/ipc/github-issue-mutation-handlers.ts @@ -15,7 +15,14 @@ import { broadcastGitHubWorkItemMutation } from './github-work-item-mutation-eve export function registerGitHubIssueMutationHandlers(store: Store): void { ipcMain.handle( 'gh:updateIssue', - async (event, args: GitHubRepoScopedArgs & { number: number; updates: GitHubIssueUpdate }) => { + async ( + event, + args: GitHubRepoScopedArgs & { + number: number + updates: GitHubIssueUpdate + ownerRepo?: GitHubOwnerRepo + } + ) => { const repo = assertRegisteredGitHubRepo(args, store) if (typeof args.number !== 'number' || !Number.isInteger(args.number) || args.number < 1) { return { ok: false, error: 'Invalid issue number' } @@ -28,7 +35,9 @@ export function registerGitHubIssueMutationHandlers(store: Store): void { args.number, args.updates, getGitHubRepoConnectionId(repo), - ...getGitHubLocalGitOptionArgs(store, repo) + getGitHubLocalGitOptionArgs(store, repo)[0], + repo.issueSourcePreference, + ...(args.ownerRepo ? [args.ownerRepo] : []) ) if (result.ok) { broadcastGitHubWorkItemMutation( @@ -67,7 +76,9 @@ export function registerGitHubIssueMutationHandlers(store: Store): void { args.body.trim(), getGitHubRepoConnectionId(repo), args.prRepo ?? null, - ...getGitHubLocalGitOptionArgs(store, repo) + getGitHubLocalGitOptionArgs(store, repo)[0], + // Why: the issue source selector only scopes issues; PR comments keep their resolution. + args.type === 'pr' ? undefined : repo.issueSourcePreference ) if (result.ok) { broadcastGitHubWorkItemMutation( @@ -84,23 +95,46 @@ export function registerGitHubIssueMutationHandlers(store: Store): void { } ) - ipcMain.handle('gh:listLabels', (_event, args: GitHubRepoScopedArgs) => { - const repo = assertRegisteredGitHubRepo(args, store) - return listLabels( - repo.path, - repo.issueSourcePreference, - getGitHubRepoConnectionId(repo), - ...getGitHubLocalGitOptionArgs(store, repo) - ) - }) + ipcMain.handle( + 'gh:listLabels', + (_event, args: GitHubRepoScopedArgs & { ownerRepo?: GitHubOwnerRepo }) => { + const repo = assertRegisteredGitHubRepo(args, store) + const localGitOptions = getGitHubLocalGitOptionArgs(store, repo) + const connectionId = getGitHubRepoConnectionId(repo) + if (args.ownerRepo) { + return listLabels( + repo.path, + repo.issueSourcePreference, + connectionId, + localGitOptions[0], + args.ownerRepo + ) + } + return listLabels(repo.path, repo.issueSourcePreference, connectionId, ...localGitOptions) + } + ) - ipcMain.handle('gh:listAssignableUsers', (_event, args: GitHubRepoScopedArgs) => { - const repo = assertRegisteredGitHubRepo(args, store) - return listAssignableUsers( - repo.path, - repo.issueSourcePreference, - getGitHubRepoConnectionId(repo), - ...getGitHubLocalGitOptionArgs(store, repo) - ) - }) + ipcMain.handle( + 'gh:listAssignableUsers', + (_event, args: GitHubRepoScopedArgs & { ownerRepo?: GitHubOwnerRepo }) => { + const repo = assertRegisteredGitHubRepo(args, store) + const localGitOptions = getGitHubLocalGitOptionArgs(store, repo) + const connectionId = getGitHubRepoConnectionId(repo) + if (args.ownerRepo) { + return listAssignableUsers( + repo.path, + repo.issueSourcePreference, + connectionId, + localGitOptions[0], + args.ownerRepo + ) + } + return listAssignableUsers( + repo.path, + repo.issueSourcePreference, + connectionId, + ...localGitOptions + ) + } + ) } diff --git a/src/main/ipc/github-issue-source-preference.test.ts b/src/main/ipc/github-issue-source-preference.test.ts index ffef0da244b..eb51af7773e 100644 --- a/src/main/ipc/github-issue-source-preference.test.ts +++ b/src/main/ipc/github-issue-source-preference.test.ts @@ -143,4 +143,124 @@ describe('registerGitHubHandlers', () => { 'origin' ) }) + + it('passes the displayed repository ahead of the current source preference', async () => { + harness.repos[0].issueSourcePreference = 'upstream' + const ownerRepo = { owner: 'fork-owner', repo: 'widgets', host: 'github.com' } + mocks.client.updateIssue.mockResolvedValue({ ok: false, error: 'stub' }) + getWorkItemDetailsMock.mockResolvedValue(null) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: These fixtures implement the repo methods used by the handlers; unrelated handlers are only registered. + registerGitHubHandlers(store as never, stats as never) + + await handlers['gh:workItemDetails'](null, { + repoPath: '/workspace/repo', + number: 12, + type: 'issue', + ownerRepo + }) + await handlers['gh:updateIssue'](null, { + repoPath: '/workspace/repo', + number: 12, + updates: { body: 'Origin edit' }, + ownerRepo + }) + await handlers['gh:listLabels'](null, { repoPath: '/workspace/repo', ownerRepo }) + await handlers['gh:listAssignableUsers'](null, { repoPath: '/workspace/repo', ownerRepo }) + + expect(getWorkItemDetailsMock).toHaveBeenCalledWith( + '/workspace/repo', + 12, + 'issue', + null, + undefined, + 'upstream', + ownerRepo + ) + expect(mocks.client.updateIssue).toHaveBeenCalledWith( + '/workspace/repo', + 12, + { body: 'Origin edit' }, + null, + undefined, + 'upstream', + ownerRepo + ) + expect(mocks.client.listLabels).toHaveBeenCalledWith( + '/workspace/repo', + 'upstream', + null, + undefined, + ownerRepo + ) + expect(mocks.client.listAssignableUsers).toHaveBeenCalledWith( + '/workspace/repo', + 'upstream', + null, + undefined, + ownerRepo + ) + }) + + it('pins the repo origin source preference on issue update and comment IPC', async () => { + harness.repos = [ + { + id: 'repo-1', + path: '/workspace/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + issueSourcePreference: 'origin' + } + ] + mocks.client.updateIssue.mockResolvedValue({ ok: false, error: 'stub' }) + mocks.client.addIssueComment.mockResolvedValue({ ok: false, error: 'stub' }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: These fixtures implement the repo methods used by the handlers; unrelated handlers are only registered. + registerGitHubHandlers(store as never, stats as never) + + await handlers['gh:updateIssue'](null, { + repoPath: '/workspace/repo', + number: 5, + updates: { body: 'Updated' } + }) + await handlers['gh:addIssueComment'](null, { + repoPath: '/workspace/repo', + number: 5, + body: 'Issue comment' + }) + await handlers['gh:addIssueComment'](null, { + repoPath: '/workspace/repo', + number: 6, + body: 'PR comment', + type: 'pr' + }) + + expect(mocks.client.updateIssue).toHaveBeenCalledWith( + '/workspace/repo', + 5, + { body: 'Updated' }, + null, + undefined, + 'origin' + ) + expect(mocks.client.addIssueComment).toHaveBeenNthCalledWith( + 1, + '/workspace/repo', + 5, + 'Issue comment', + null, + null, + undefined, + 'origin' + ) + expect(mocks.client.addIssueComment).toHaveBeenNthCalledWith( + 2, + '/workspace/repo', + 6, + 'PR comment', + null, + null, + undefined, + undefined + ) + }) }) diff --git a/src/main/ipc/github-work-item-handlers.ts b/src/main/ipc/github-work-item-handlers.ts index 26c21b3db81..94e982d60dc 100644 --- a/src/main/ipc/github-work-item-handlers.ts +++ b/src/main/ipc/github-work-item-handlers.ts @@ -146,15 +146,20 @@ export function registerGitHubWorkItemHandlers(store: Store): void { } ) - ipcMain.handle('gh:workItemDetails', (_event, args: WorkItemArgs) => { - const repo = assertRegisteredGitHubRepo(args, store) - return dispatchWorkItem( - args, - repo, - getWorkItemDetails, - getGitHubLocalGitOptionArgs(store, repo)[0] - ) - }) + ipcMain.handle( + 'gh:workItemDetails', + (_event, args: WorkItemArgs & { ownerRepo?: GitHubOwnerRepo }) => { + const repo = assertRegisteredGitHubRepo(args, store) + return dispatchWorkItem( + args, + repo, + args.ownerRepo + ? (...params) => getWorkItemDetails(...params, args.ownerRepo) + : getWorkItemDetails, + getGitHubLocalGitOptionArgs(store, repo)[0] + ) + } + ) ipcMain.handle( 'gh:notifyWorkItemMutated', diff --git a/src/main/ipc/github-wsl-runtime-routing.test.ts b/src/main/ipc/github-wsl-runtime-routing.test.ts index e2f30c18eba..977422cb68e 100644 --- a/src/main/ipc/github-wsl-runtime-routing.test.ts +++ b/src/main/ipc/github-wsl-runtime-routing.test.ts @@ -138,6 +138,15 @@ describe('registerGitHubHandlers', () => { ) await handlers['gh:listLabels'](null, { repoPath: '/workspace/repo' }) await handlers['gh:listAssignableUsers'](null, { repoPath: '/workspace/repo' }) + const metadataRepo = { owner: 'fork-owner', repo: 'widgets', host: 'github.com' } + await handlers['gh:listLabels'](null, { + repoPath: '/workspace/repo', + ownerRepo: metadataRepo + }) + await handlers['gh:listAssignableUsers'](null, { + repoPath: '/workspace/repo', + ownerRepo: metadataRepo + }) expect(getPRForBranchMock).toHaveBeenCalledWith( '/workspace/repo', @@ -197,7 +206,8 @@ describe('registerGitHubHandlers', () => { 7, { body: 'Updated' }, null, - localGitOptions + localGitOptions, + undefined ) expect(addIssueCommentMock).toHaveBeenCalledWith( '/workspace/repo', @@ -205,9 +215,24 @@ describe('registerGitHubHandlers', () => { 'Comment', null, null, - localGitOptions + localGitOptions, + undefined ) expect(listLabelsMock).toHaveBeenCalledWith('/workspace/repo', undefined, null, localGitOptions) + expect(listLabelsMock).toHaveBeenLastCalledWith( + '/workspace/repo', + undefined, + null, + localGitOptions, + metadataRepo + ) + expect(listAssignableUsersMock).toHaveBeenLastCalledWith( + '/workspace/repo', + undefined, + null, + localGitOptions, + metadataRepo + ) expect(listAssignableUsersMock).toHaveBeenCalledWith( '/workspace/repo', undefined, diff --git a/src/main/runtime/orca-runtime-tests/hooks-and-hosted-review.spec.ts b/src/main/runtime/orca-runtime-tests/hooks-and-hosted-review.spec.ts index 69878468ccb..139e018025a 100644 --- a/src/main/runtime/orca-runtime-tests/hooks-and-hosted-review.spec.ts +++ b/src/main/runtime/orca-runtime-tests/hooks-and-hosted-review.spec.ts @@ -319,7 +319,8 @@ describe('OrcaRuntimeService', () => { 12, issueUpdates, null, - localGitOptions + localGitOptions, + undefined ) expect(addGitHubIssueCommentMock).toHaveBeenCalledWith( TEST_REPO_PATH, @@ -327,7 +328,8 @@ describe('OrcaRuntimeService', () => { 'Comment', null, null, - localGitOptions + localGitOptions, + undefined ) expect(listGitHubLabelsMock).toHaveBeenCalledWith( TEST_REPO_PATH, diff --git a/src/main/runtime/rpc/methods/github-issue-methods.ts b/src/main/runtime/rpc/methods/github-issue-methods.ts index 86300017741..825fe573d2f 100644 --- a/src/main/runtime/rpc/methods/github-issue-methods.ts +++ b/src/main/runtime/rpc/methods/github-issue-methods.ts @@ -35,6 +35,19 @@ export const GITHUB_ISSUE_METHODS = [ name: 'github.addIssueComment', params: IssueComment, handler: async (params, { runtime }) => - runtime.addRepoIssueComment(params.repo, params.number, params.body, params.prRepo ?? null) + params.type + ? runtime.addRepoIssueComment( + params.repo, + params.number, + params.body, + params.prRepo ?? null, + params.type + ) + : runtime.addRepoIssueComment( + params.repo, + params.number, + params.body, + params.prRepo ?? null + ) }) ] diff --git a/src/main/runtime/rpc/methods/github.test.ts b/src/main/runtime/rpc/methods/github.test.ts index d1bf9946806..1ed202d2f57 100644 --- a/src/main/runtime/rpc/methods/github.test.ts +++ b/src/main/runtime/rpc/methods/github.test.ts @@ -626,10 +626,13 @@ describe('github RPC methods', () => { }) ) - expect(runtime.addRepoIssueComment).toHaveBeenCalledWith('repo-1', 3, 'Looks good', { - owner: 'acme', - repo: 'widgets' - }) + expect(runtime.addRepoIssueComment).toHaveBeenCalledWith( + 'repo-1', + 3, + 'Looks good', + { owner: 'acme', repo: 'widgets' }, + 'pr' + ) expect(response).toMatchObject({ ok: true, result: { ok: true, comment: { id: 1 } } }) }) diff --git a/src/main/runtime/runtime-github-issue-comment-commands.ts b/src/main/runtime/runtime-github-issue-comment-commands.ts index fcef9602405..e3f19c36d76 100644 --- a/src/main/runtime/runtime-github-issue-comment-commands.ts +++ b/src/main/runtime/runtime-github-issue-comment-commands.ts @@ -50,7 +50,8 @@ export class RuntimeGitHubIssueCommentCommands { number, updates, repo.connectionId ?? null, - ...this.deps.getLocalGitArgs(repo) + this.deps.getLocalGitArgs(repo)[0], + repo.issueSourcePreference ) } @@ -58,7 +59,8 @@ export class RuntimeGitHubIssueCommentCommands { repoSelector: string, number: number, body: string, - prRepo?: GitHubOwnerRepo | null + prRepo?: GitHubOwnerRepo | null, + type?: 'issue' | 'pr' ): Promise>> { const repo = await this.deps.resolveRepo(repoSelector) return addIssueComment( @@ -67,7 +69,9 @@ export class RuntimeGitHubIssueCommentCommands { body, repo.connectionId ?? null, prRepo ?? null, - ...this.deps.getLocalGitArgs(repo) + this.deps.getLocalGitArgs(repo)[0], + // Why: the issue source selector only scopes issues; PR comments keep their resolution. + type === 'pr' ? undefined : repo.issueSourcePreference ) } diff --git a/src/preload/api/github-work-item-api.ts b/src/preload/api/github-work-item-api.ts index ae95a908fb6..db5842eb2cf 100644 --- a/src/preload/api/github-work-item-api.ts +++ b/src/preload/api/github-work-item-api.ts @@ -75,6 +75,7 @@ export type GithubWorkItemApi = { args: GitHubRepoSelectorArgs & { number: number type?: 'issue' | 'pr' + ownerRepo?: GitHubOwnerRepo } ) => Promise notifyWorkItemMutated: (args: { @@ -106,6 +107,7 @@ export type GithubWorkItemApi = { args: GitHubRepoSelectorArgs & { number: number updates: GitHubIssueUpdate + ownerRepo?: GitHubOwnerRepo } ) => Promise<{ ok: true } | { ok: false; error: string }> addIssueComment: ( @@ -121,11 +123,13 @@ export type GithubWorkItemApi = { repoPath: string repoId?: string sourceContext?: TaskSourceContext | null + ownerRepo?: GitHubOwnerRepo }) => Promise listAssignableUsers: (args: { repoPath: string repoId?: string sourceContext?: TaskSourceContext | null + ownerRepo?: GitHubOwnerRepo }) => Promise /** Subscribe to local-mutation broadcasts so the work-item-drawer cache can invalidate across windows. Returns an unsubscribe. */ onWorkItemMutated: ( diff --git a/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section-mutations.ts b/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section-mutations.ts index 42fba184a2e..12d6831f18c 100644 --- a/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section-mutations.ts +++ b/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section-mutations.ts @@ -10,7 +10,9 @@ import { import { assertTaskPageGitHubDialogStateAuthority } from '@/components/task-page-github-dialog-state-authority' import { runIssueUpdate } from '@/components/github/github-work-item-edit-mutations' import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types' +import type { GitHubOwnerRepo } from '../../../../../shared/github/pull-request-types' import type { TaskSourceContext } from '../../../../../shared/task-source-context' +import type { GitHubPatchWorkItemOptions } from '@/store/github/cache-model' import { translate } from '@/i18n/i18n' import type { GitHubItemDialogProjectOrigin } from '../load-item-details/github-item-dialog-types' @@ -28,6 +30,7 @@ type GHEditMutationBase = { repoPath: string | null sourceContext?: TaskSourceContext | null projectOrigin: GitHubItemDialogProjectOrigin | undefined + issueRepo?: GitHubOwnerRepo | null run: GHEditMutationRun patchProjectRowIfNeeded: (patch: GHEditProjectRowPatch) => void onMutated: () => void @@ -43,6 +46,7 @@ export function runGHEditStateChange({ repoPath, sourceContext, projectOrigin, + issueRepo, run, onStateChange, patchWorkItem, @@ -58,7 +62,7 @@ export function runGHEditStateChange({ id: string, patch: { state: GitHubWorkItem['state'] }, repoId: string | undefined, - options: { sourceContext?: TaskSourceContext | null } + options: GitHubPatchWorkItemOptions ) => void }): void { // Why: a close reason still has to reach GitHub even when the item already reads as closed locally. @@ -76,6 +80,7 @@ export function runGHEditStateChange({ repoPath, sourceContext, projectOrigin, + issueRepo, number: itemNumber, updates: newState === 'closed' && closeAction @@ -87,22 +92,32 @@ export function runGHEditStateChange({ repoId: itemRepoId, itemId, state: newState, - sourceContext + sourceContext, + ownerRepo: projectOrigin ?? issueRepo }) onStateChange(newState) - patchWorkItem(itemId, { state: newState }, itemRepoId, { sourceContext }) + patchWorkItem(itemId, { state: newState }, itemRepoId, { + sourceContext, + ownerRepo: projectOrigin ?? issueRepo + }) patchProjectRowIfNeeded({ state: newState }) }, onRevert: () => { if (authority?.revert()) { onStateChange(prevState) - patchWorkItem(itemId, { state: prevState }, itemRepoId, { sourceContext }) + patchWorkItem(itemId, { state: prevState }, itemRepoId, { + sourceContext, + ownerRepo: projectOrigin ?? issueRepo + }) patchProjectRowIfNeeded({ state: prevState }) } }, onSuccess: () => { useAppStore.getState().recordFeatureInteraction('github-tasks') - patchWorkItem(itemId, { state: newState }, itemRepoId, { sourceContext }) + patchWorkItem(itemId, { state: newState }, itemRepoId, { + sourceContext, + ownerRepo: projectOrigin ?? issueRepo + }) patchProjectRowIfNeeded({ state: newState }) onMutated() }, @@ -145,6 +160,7 @@ export function runGHEditLabelToggle({ repoPath, sourceContext, projectOrigin, + issueRepo, run, onLabelsChange, patchWorkItem, @@ -159,42 +175,13 @@ export function runGHEditLabelToggle({ id: string, patch: { labels: string[] }, repoId: string | undefined, - options: { sourceContext?: TaskSourceContext | null } + options: GitHubPatchWorkItemOptions ) => void }): void { const isAdding = !localLabels.includes(label) const prevLabels = localLabels const newLabels = isAdding ? [...prevLabels, label] : prevLabels.filter((l) => l !== label) - if (isAdding) { - void run('labels', { - mutate: () => - runIssueUpdate({ - repoId: itemRepoId, - repoPath, - sourceContext, - projectOrigin, - number: itemNumber, - updates: { addLabels: [label] } - }), - onOptimistic: () => { - onLabelsChange(newLabels) - patchWorkItem(itemId, { labels: newLabels }, itemRepoId, { sourceContext }) - patchProjectRowIfNeeded({ labels: newLabels }) - }, - onSuccess: () => { - useAppStore.getState().recordFeatureInteraction('github-tasks') - onMutated() - }, - onRevert: () => { - onLabelsChange(prevLabels) - patchWorkItem(itemId, { labels: prevLabels }, itemRepoId, { sourceContext }) - patchProjectRowIfNeeded({ labels: prevLabels }) - }, - onError: (err) => toast.error(err) - }) - return - } void run('labels', { mutate: () => runIssueUpdate({ @@ -202,17 +189,24 @@ export function runGHEditLabelToggle({ repoPath, sourceContext, projectOrigin, + issueRepo, number: itemNumber, - updates: { removeLabels: [label] } + updates: isAdding ? { addLabels: [label] } : { removeLabels: [label] } }), onOptimistic: () => { onLabelsChange(newLabels) - patchWorkItem(itemId, { labels: newLabels }, itemRepoId, { sourceContext }) + patchWorkItem(itemId, { labels: newLabels }, itemRepoId, { + sourceContext, + ownerRepo: projectOrigin ?? issueRepo + }) patchProjectRowIfNeeded({ labels: newLabels }) }, onRevert: () => { onLabelsChange(prevLabels) - patchWorkItem(itemId, { labels: prevLabels }, itemRepoId, { sourceContext }) + patchWorkItem(itemId, { labels: prevLabels }, itemRepoId, { + sourceContext, + ownerRepo: projectOrigin ?? issueRepo + }) patchProjectRowIfNeeded({ labels: prevLabels }) }, onSuccess: () => { @@ -233,6 +227,7 @@ export function runGHEditAssigneeToggle({ repoPath, sourceContext, projectOrigin, + issueRepo, run, setLocalAssignees, patchProjectRowIfNeeded, @@ -260,6 +255,7 @@ export function runGHEditAssigneeToggle({ repoPath, sourceContext, projectOrigin, + issueRepo, number: itemNumber, updates: { removeAssignees: [login] } }), @@ -288,6 +284,7 @@ export function runGHEditAssigneeToggle({ repoPath, sourceContext, projectOrigin, + issueRepo, number: itemNumber, updates: { addAssignees: [login] } }), diff --git a/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section.assignee-identity.test.tsx b/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section.assignee-identity.test.tsx new file mode 100644 index 00000000000..be5ee88f54d --- /dev/null +++ b/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section.assignee-identity.test.tsx @@ -0,0 +1,364 @@ +// @vitest-environment happy-dom + +import { act, useState } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types' +import type { GitHubItemDialogProjectOrigin } from '../load-item-details/github-item-dialog-types' +import { GHEditSection } from './gh-edit-section' +import { resetTaskPageGitHubMutationRegistryForTests } from '@/components/task-page-github-work-item-mutation-registry' + +const mocks = vi.hoisted(() => ({ + update: vi.fn(), + patchWorkItem: vi.fn(), + patchProjectRowContent: vi.fn(), + recordFeatureInteraction: vi.fn(), + onMutated: vi.fn() +})) + +vi.mock('@/store', () => { + const state = { + patchWorkItem: mocks.patchWorkItem, + patchProjectRowContent: mocks.patchProjectRowContent, + recordFeatureInteraction: mocks.recordFeatureInteraction, + repos: [], + settings: null + } + return { + useAppStore: Object.assign((selector: (value: typeof state) => unknown) => selector(state), { + getState: () => state + }) + } +}) + +vi.mock('@/hooks/useIssueMetadata', async () => { + const { useImmediateMutation } = await import('@/hooks/useImmediateMutation') + return { + useImmediateMutation, + useRepoLabels: () => ({ data: [] }), + useRepoAssignees: () => ({ data: [] }) + } +}) +vi.mock('@/hooks/useGitHubSlugMetadata', () => ({ + useRepoLabelsBySlug: () => ({ data: [] }), + useRepoAssigneesBySlug: () => ({ data: [] }) +})) +vi.mock('@/components/github/github-duplicate-issue-candidates', () => ({ + useGitHubDuplicateIssueCandidates: () => [] +})) +vi.mock('@/components/github/github-work-item-edit-mutations', () => ({ + runIssueUpdate: mocks.update +})) +vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) +vi.mock('./gh-edit-section-horizontal', () => ({ GHEditSectionHorizontal: () => null })) +vi.mock('./gh-edit-section-top-columns', () => ({ + GHEditSectionTopColumns: ({ + localAssignees, + onAssigneeToggle, + localState, + localLabels, + onStateChange, + onLabelToggle + }: { + localAssignees: string[] + onAssigneeToggle: (login: string) => void + localState: GitHubWorkItem['state'] + localLabels: string[] + onStateChange: (state: 'open' | 'closed') => void + onLabelToggle: (label: string) => void + }) => ( + <> + {localAssignees.join(',')} + {localState} + {localLabels.join(',')} + + + + + + ) +})) + +const item: GitHubWorkItem = { + id: 'issue:5', + repoId: 'registered-repo', + type: 'issue', + number: 5, + title: 'Issue five', + state: 'open', + url: 'https://github.com/upstream/widgets/issues/5', + labels: [], + updatedAt: '', + author: null +} +let root: Root +let container: HTMLDivElement + +async function render( + nextItem = item, + assignees: string[] = [], + projectOrigin?: GitHubItemDialogProjectOrigin +): Promise { + await act(async () => { + root.render( + {}} + onLabelsChange={() => {}} + onMutated={mocks.onMutated} + assignees={assignees} + onUse={() => {}} + layout="top-columns" + /> + ) + }) +} + +async function click(label = 'Assign candidate'): Promise { + const button = [...container.querySelectorAll('button')].find( + (candidate) => candidate.textContent === label + ) + expect(button).toBeDefined() + await act(async () => button?.click()) +} + +function selected(): string | null { + return container.querySelector('[data-field="assignees"]')?.textContent ?? null +} + +let updateParentSelection: (nextItem: GitHubWorkItem) => void = () => {} + +function ParentSelection({ item }: { item: GitHubWorkItem }): React.JSX.Element { + const [localState, setLocalState] = useState(item.state) + const [localLabels, setLocalLabels] = useState(item.labels) + updateParentSelection = (nextItem) => { + setLocalState(nextItem.state) + setLocalLabels(nextItem.labels) + } + return ( + {}} + layout="top-columns" + /> + ) +} + +async function renderParent(nextItem: GitHubWorkItem): Promise { + await act(async () => { + updateParentSelection(nextItem) + root.render() + }) +} + +describe('opened issue assignee ownership', () => { + beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + vi.clearAllMocks() + resetTaskPageGitHubMutationRegistryForTests() + updateParentSelection = () => {} + mocks.update.mockResolvedValue({ ok: true }) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => root.unmount()) + container.remove() + vi.unstubAllGlobals() + resetTaskPageGitHubMutationRegistryForTests() + }) + + it('retires an optimistic selection across fork and return navigation', async () => { + await render() + await click() + expect(selected()).toBe('candidate-user') + await render(item, []) + expect(selected()).toBe('candidate-user') + + await render({ ...item, url: 'https://github.com/fork/widgets/issues/5' }, ['fork-user']) + expect(selected()).toBe('fork-user') + await render(item, ['authoritative-upstream-user']) + expect(selected()).toBe('authoritative-upstream-user') + }) + + it('does not revive an edited guard when returning from a different issue number', async () => { + await render() + await click() + await render({ ...item, id: 'issue:6', number: 6 }, ['other-issue-user']) + expect(selected()).toBe('other-issue-user') + await render(item, ['authoritative-upstream-user']) + expect(selected()).toBe('authoritative-upstream-user') + }) + + it.each(['Assign candidate', 'Toggle original'])( + 'does not roll a late failed %s edit into another opened issue', + async (button) => { + let rejectUpdate: (reason: Error) => void = () => {} + mocks.update.mockImplementation( + () => + new Promise((_resolve, reject) => { + rejectUpdate = reject + }) + ) + await render(item, ['original-user']) + await click(button) + await render({ ...item, id: 'issue:6', number: 6 }, ['other-issue-user']) + expect(selected()).toBe('other-issue-user') + await act(async () => rejectUpdate(new Error('held edit failed'))) + expect(selected()).toBe('other-issue-user') + } + ) + + it('treats another GitHub host as a distinct same-number issue', async () => { + await render() + await click() + await render({ ...item, url: 'https://github.example.test/upstream/widgets/issues/5' }, []) + expect(selected()).toBe('') + }) + + it('retires the issue edit when the same legacy id is used by a pull request', async () => { + await render() + await click() + await render({ ...item, type: 'pr' }, []) + await render(item, ['authoritative-upstream-user']) + expect(selected()).toBe('authoritative-upstream-user') + }) + + it('preserves optimistic edits across canonical URL casing and trailing paths', async () => { + await render() + await click() + await render({ ...item, url: 'https://GITHUB.COM/Upstream/Widgets/issues/5#activity' }, []) + expect(selected()).toBe('candidate-user') + }) + + it('uses the Project row repository before an unrelated item URL', async () => { + const projectOrigin: GitHubItemDialogProjectOrigin = { + owner: 'upstream', + repo: 'widgets', + number: 5, + type: 'issue', + projectId: 'project', + projectItemId: 'row', + cacheKey: 'project-cache' + } + await render(item, [], projectOrigin) + await click() + await render( + { ...item, url: 'https://github.com/unrelated/widgets/issues/5' }, + [], + projectOrigin + ) + expect(selected()).toBe('candidate-user') + await render(item, ['fork-user'], { ...projectOrigin, owner: 'fork' }) + expect(selected()).toBe('fork-user') + }) + + it('still rolls back a failed edit while its issue remains open', async () => { + mocks.update.mockRejectedValue(new Error('edit failed')) + await render(item, ['original-user']) + await click() + expect(selected()).toBe('original-user') + await render(item, ['refetched-user']) + expect(selected()).toBe('refetched-user') + }) + + it('keeps a late Project-row rollback scoped to the captured row', async () => { + const projectOrigin: GitHubItemDialogProjectOrigin = { + owner: 'upstream', + repo: 'widgets', + number: 5, + type: 'issue', + projectId: 'project', + projectItemId: 'upstream-row', + cacheKey: 'upstream-cache' + } + let rejectUpdate: (reason: Error) => void = () => {} + mocks.update.mockImplementation( + () => + new Promise((_resolve, reject) => { + rejectUpdate = reject + }) + ) + await render(item, ['original-user'], projectOrigin) + await click() + await render(item, ['fork-user'], { + ...projectOrigin, + owner: 'fork', + projectItemId: 'fork-row', + cacheKey: 'fork-cache' + }) + expect(selected()).toBe('fork-user') + await act(async () => rejectUpdate(new Error('held Project edit failed'))) + expect(selected()).toBe('fork-user') + expect(mocks.patchProjectRowContent).toHaveBeenLastCalledWith( + 'upstream-cache', + 'upstream-row', + { assignees: ['original-user'] } + ) + }) + + it.each([ + { button: 'Toggle label', field: 'labels', expected: 'fork-only-label' }, + { button: 'Close issue', field: 'state', expected: 'closed' } + ])( + 'a late failed $field edit cannot overwrite the new parent selection', + async ({ button, field, expected }) => { + let rejectUpdate: (reason: Error) => void = () => {} + mocks.update.mockImplementation( + () => + new Promise((_resolve, reject) => { + rejectUpdate = reject + }) + ) + await renderParent({ ...item, labels: ['upstream-original-label'] }) + await click(button) + await renderParent({ + ...item, + state: 'closed', + url: 'https://github.com/fork/widgets/issues/5', + labels: ['fork-only-label'] + }) + expect(container.querySelector(`[data-field="${field}"]`)?.textContent).toBe(expected) + await act(async () => rejectUpdate(new Error('held edit failed'))) + expect(container.querySelector(`[data-field="${field}"]`)?.textContent).toBe(expected) + expect(mocks.patchWorkItem).toHaveBeenLastCalledWith( + item.id, + field === 'labels' ? { labels: ['upstream-original-label'] } : { state: 'open' }, + item.repoId, + { + sourceContext: undefined, + ownerRepo: { owner: 'upstream', repo: 'widgets', host: 'github.com' } + } + ) + } + ) + + it.each([ + { button: 'Toggle label', field: 'labels', expected: 'upstream-original-label' }, + { button: 'Close issue', field: 'state', expected: 'open' } + ])( + 'a same-item failed $field edit still restores its prior parent value', + async ({ button, field, expected }) => { + mocks.update.mockRejectedValue(new Error('same issue edit failed')) + await renderParent({ ...item, labels: ['upstream-original-label'] }) + await click(button) + expect(container.querySelector(`[data-field="${field}"]`)?.textContent).toBe(expected) + } + ) +}) diff --git a/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section.tsx b/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section.tsx index f92c99b2a09..a50f41fc960 100644 --- a/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section.tsx +++ b/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section.tsx @@ -3,11 +3,8 @@ import { useShallow } from 'zustand/react/shallow' import { useAppStore } from '@/store' import { useRepoLabels, useRepoAssignees, useImmediateMutation } from '@/hooks/useIssueMetadata' import { useRepoLabelsBySlug, useRepoAssigneesBySlug } from '@/hooks/useGitHubSlugMetadata' -import { - getTaskSourceRuntimeSettings, - type TaskSourceContext -} from '../../../../../shared/task-source-context' -import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types' +import { getTaskSourceRuntimeSettings } from '../../../../../shared/task-source-context' +import { githubRepoIdentityKey } from '../../../../../shared/github/repository-identity-key' import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' import { getTaskPageGitHubDuplicateCandidates, @@ -17,7 +14,8 @@ import { } from '@/components/task-page-github-status-actions' import { parseOwnerRepoFromItemUrl } from '@/components/github/github-work-item-identity' import { translate } from '@/i18n/i18n' -import type { GitHubItemDialogProjectOrigin } from '../load-item-details/github-item-dialog-types' +import type { GitHubItemDialogEditSectionProps } from '../load-item-details/github-item-dialog-types' +import { useMountedRef } from '@/hooks/useMountedRef' import { getGitHubRepositoryLabelsUrl } from './repository-labels-url' import { closeGHEditAsDuplicate, @@ -29,7 +27,14 @@ import { GHEditSectionTopColumns } from './gh-edit-section-top-columns' import { GHEditSectionHorizontal } from './gh-edit-section-horizontal' import { useGitHubDuplicateIssueCandidates } from '@/components/github/github-duplicate-issue-candidates' -export function GHEditSection({ +export function GHEditSection(props: GitHubItemDialogEditSectionProps): React.JSX.Element | null { + const { item, projectOrigin } = props + const repository = projectOrigin ?? parseOwnerRepoFromItemUrl(item.url) + const itemKey = `${item.repoId}\0${repository ? githubRepoIdentityKey(repository) : item.url}\0${item.type}\0${item.id}` + return +} + +function GHEditSectionItem({ item, repoPath, repoId, @@ -45,25 +50,7 @@ export function GHEditSection({ onOpenOrUse, attachedWorkspaceLabel, layout = 'horizontal' -}: { - item: GitHubWorkItem - repoPath: string | null - repoId: string | null - sourceContext?: TaskSourceContext | null - projectOrigin: GitHubItemDialogProjectOrigin | undefined - localState: GitHubWorkItem['state'] - localLabels: string[] - onStateChange: (state: GitHubWorkItem['state']) => void - onLabelsChange: (labels: string[]) => void - /** Why: lets the parent invalidate its details cache after a mutation, else a reopen within FRESH_MS paints pre-mutation data. */ - onMutated: () => void - assignees: string[] - onUse: (item: GitHubWorkItem) => void - onOpenOrUse?: (item: GitHubWorkItem) => void - attachedWorkspaceLabel?: string | null - /** `horizontal`: compact pill strip for the non-issue drawer/header; `top-columns`: labeled columns above the issue page body. */ - layout?: 'horizontal' | 'top-columns' -}): React.JSX.Element | null { +}: GitHubItemDialogEditSectionProps): React.JSX.Element | null { const [labelPopoverOpen, setLabelPopoverOpen] = useState(false) const [assigneePopoverOpen, setAssigneePopoverOpen] = useState(false) const [statusPopoverOpen, setStatusPopoverOpen] = useState(false) @@ -72,6 +59,7 @@ export function GHEditSection({ const [duplicateError, setDuplicateError] = useState(null) const [localAssignees, setLocalAssignees] = useState(assignees) const editedAssigneesItemKeyRef = useRef(null) + const mountedRef = useMountedRef() const assigneesItemKey = `${item.repoId}\0${item.id}` const patchWorkItem = useAppStore((s) => s.patchWorkItem) const patchProjectRowContent = useAppStore((s) => s.patchProjectRowContent) @@ -101,13 +89,18 @@ export function GHEditSection({ [projectOrigin, patchProjectRowContent] ) - // Why: with projectOrigin set, read labels/assignees from the row's repo, not the workspace path, or popovers list a different repo than writes target. + const issueRepo = useMemo(() => parseOwnerRepoFromItemUrl(item.url), [item.url]) + const metadataOptions = useMemo( + () => ({ ...sourceSettings, ownerRepo: issueRepo }), + [sourceSettings, issueRepo] + ) + // Project metadata comes from the row repository. const slugOwner = projectOrigin?.owner ?? null const slugRepo = projectOrigin?.repo ?? null const repoLabelsByPath = useRepoLabels( projectOrigin ? null : repoPath, projectOrigin ? null : repoId, - sourceSettings + metadataOptions ) const repoLabelsBySlug = useRepoLabelsBySlug( slugOwner, @@ -120,7 +113,7 @@ export function GHEditSection({ const repoAssigneesByPath = useRepoAssignees( projectOrigin ? null : repoPath, projectOrigin ? null : repoId, - sourceSettings + metadataOptions ) const repoAssigneesBySlug = useRepoAssigneesBySlug( slugOwner, @@ -187,8 +180,13 @@ export function GHEditSection({ repoPath, sourceContext, projectOrigin, + issueRepo, run, - onStateChange, + onStateChange: (state) => { + if (mountedRef.current) { + onStateChange(state) + } + }, patchWorkItem, patchProjectRowIfNeeded, onMutated @@ -202,9 +200,11 @@ export function GHEditSection({ repoPath, sourceContext, projectOrigin, + issueRepo, patchWorkItem, patchProjectRowIfNeeded, run, + mountedRef, onStateChange, onMutated ] @@ -253,8 +253,13 @@ export function GHEditSection({ repoPath, sourceContext, projectOrigin, + issueRepo, run, - onLabelsChange, + onLabelsChange: (labels) => { + if (mountedRef.current) { + onLabelsChange(labels) + } + }, patchWorkItem, patchProjectRowIfNeeded, onMutated @@ -268,9 +273,11 @@ export function GHEditSection({ repoPath, sourceContext, projectOrigin, + issueRepo, patchWorkItem, patchProjectRowIfNeeded, run, + mountedRef, onLabelsChange, onMutated ] @@ -288,6 +295,7 @@ export function GHEditSection({ repoPath, sourceContext, projectOrigin, + issueRepo, run, setLocalAssignees, patchProjectRowIfNeeded, @@ -301,6 +309,7 @@ export function GHEditSection({ repoPath, sourceContext, projectOrigin, + issueRepo, localAssignees, patchProjectRowIfNeeded, run, diff --git a/src/renderer/src/components/github-item-dialog/load-item-details/github-item-dialog-types.ts b/src/renderer/src/components/github-item-dialog/load-item-details/github-item-dialog-types.ts index c0a6fc7a3b8..9c8e81953b2 100644 --- a/src/renderer/src/components/github-item-dialog/load-item-details/github-item-dialog-types.ts +++ b/src/renderer/src/components/github-item-dialog/load-item-details/github-item-dialog-types.ts @@ -26,3 +26,23 @@ export type GitHubItemDialogProps = { /** Optional Project-origin context; when set, edits route via slug-addressed IPCs against the row's repo (slug routing wins for writes). */ projectOrigin?: GitHubItemDialogProjectOrigin } + +export type GitHubItemDialogEditSectionProps = { + item: GitHubWorkItem + repoPath: string | null + repoId: string | null + sourceContext?: TaskSourceContext | null + projectOrigin: GitHubItemDialogProjectOrigin | undefined + localState: GitHubWorkItem['state'] + localLabels: string[] + onStateChange: (state: GitHubWorkItem['state']) => void + onLabelsChange: (labels: string[]) => void + /** Why: lets the parent invalidate its details cache after a mutation, else a reopen within FRESH_MS paints pre-mutation data. */ + onMutated: () => void + assignees: string[] + onUse: (item: GitHubWorkItem) => void + onOpenOrUse?: (item: GitHubWorkItem) => void + attachedWorkspaceLabel?: string | null + /** `horizontal`: compact pill strip for the non-issue drawer/header; `top-columns`: labeled columns above the issue page body. */ + layout?: 'horizontal' | 'top-columns' +} diff --git a/src/renderer/src/components/github-item-dialog/load-item-details/use-github-item-dialog-details.ts b/src/renderer/src/components/github-item-dialog/load-item-details/use-github-item-dialog-details.ts index 0d685da6020..c9bd69d9bb1 100644 --- a/src/renderer/src/components/github-item-dialog/load-item-details/use-github-item-dialog-details.ts +++ b/src/renderer/src/components/github-item-dialog/load-item-details/use-github-item-dialog-details.ts @@ -4,6 +4,7 @@ import { lookupGitHubWorkItemDetailsForSource } from '@/lib/github-work-item-sou import { canUseGitHubRepoContext } from '@/lib/github-source-runtime-context' import { normalizeItemDialogTab, + parseOwnerRepoFromItemUrl, type ItemDialogTab } from '@/components/github/github-work-item-identity' import type { PRComment } from '../../../../../shared/github/comment-types' @@ -61,6 +62,13 @@ export function useGitHubItemDialogDetails({ ?.issueSourcePreference }) const canUseDetailsRepoContext = canUseGitHubRepoContext(repoPath, sourceContext) + const issueRepository = useMemo( + () => + workItem?.type === 'issue' + ? (projectOrigin ?? parseOwnerRepoFromItemUrl(workItem.url)) + : null, + [projectOrigin, workItem] + ) const detailsCacheKey = useMemo(() => { if (!workItem || !effectiveRepoId || !canUseDetailsRepoContext) { return null @@ -69,10 +77,12 @@ export function useGitHubItemDialogDetails({ repoPath: repoPath ?? '', repoId: effectiveRepoId, issueSourcePreference, + sourceContext, sourceCacheScope: sourceContext?.provider === 'github' ? getTaskSourceCacheScope(sourceContext) : null, type: workItem.type, - number: workItem.number + number: workItem.number, + ownerRepo: issueRepository }) }, [ canUseDetailsRepoContext, @@ -80,7 +90,8 @@ export function useGitHubItemDialogDetails({ effectiveRepoId, sourceContext, workItem, - issueSourcePreference + issueSourcePreference, + issueRepository ]) // Why: reset during render so an item switch never paints the previous item's tab. @@ -97,7 +108,7 @@ export function useGitHubItemDialogDetails({ } // Why: hold comments added before the detail fetch resolves so they merge into the result instead of being overwritten. - const optimisticCommentsRef = useRef([]) + const optimisticCommentsRef = useRef(new Map()) // Why: distinguish "reopen same item" from "switch item" — reopen must keep optimistic comments since gh's 60s cache omits the just-posted one. const prevItemIdRef = useRef(null) @@ -116,7 +127,7 @@ export function useGitHubItemDialogDetails({ // Why: key off cachedEntry identity (stable), not the optimistic ref array (fresh each render), to avoid needless recompute. const details = useMemo(() => { const cachedDetails = cachedEntry?.details ?? null - const opt = optimisticCommentsRef.current + const opt = optimisticCommentsRef.current.get(detailsCacheKey ?? '') ?? [] if (!cachedDetails) { // Why: on cold open, details may still be loading — surface optimistic comments via a minimal shell so a pre-fetch comment isn't invisible. if (opt.length > 0 && workItem) { @@ -138,7 +149,7 @@ export function useGitHubItemDialogDetails({ } // Why: optimisticTick forces this ref-reading memo to re-run on cold-open writes; lint can't see the dependency. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [cachedEntry, workItem, optimisticTick]) + }, [cachedEntry, workItem, detailsCacheKey, optimisticTick]) const loading = !!cachedEntry?.pending && !cachedEntry?.details const error = cachedEntry?.error && !cachedEntry?.details ? cachedEntry.error : null @@ -158,7 +169,7 @@ export function useGitHubItemDialogDetails({ } // Why: clear optimistic comments only on item switch — on reopen, gh's 60s cache omits the just-posted comment, so keep the ref to re-merge. if (workItem.id !== prevItemIdRef.current) { - optimisticCommentsRef.current = [] + optimisticCommentsRef.current.clear() } prevItemIdRef.current = workItem.id @@ -178,7 +189,8 @@ export function useGitHubItemDialogDetails({ repoId: effectiveRepoId, sourceContext, number: workItem.number, - type: workItem.type + type: workItem.type, + ownerRepo: issueRepository }) // Why: snapshot the invalidation generation; if it advances before resolve, a mid-flight mutation invalidated the entry — don't write back. @@ -201,6 +213,7 @@ export function useGitHubItemDialogDetails({ sourceContext, workItem, detailsCacheKey, + issueRepository, refetchTick ]) @@ -231,7 +244,9 @@ export function useGitHubItemDialogDetails({ (comment: PRComment) => { useAppStore.getState().recordFeatureInteraction('github-tasks') // Why: skip refreshDetails() — gh's 60s cache would overwrite the optimistic comment; next open picks up the server version. - optimisticCommentsRef.current.push(comment) + const optimisticKey = detailsCacheKey ?? '' + const optimisticComments = optimisticCommentsRef.current.get(optimisticKey) ?? [] + optimisticCommentsRef.current.set(optimisticKey, [...optimisticComments, comment]) // Why: write through the module cache so concurrent drawers re-render; mark fetchedAt stale (0) so next open refetches server fields. if (detailsCacheKey) { const prev = workItemDetailsCache.get(detailsCacheKey) diff --git a/src/renderer/src/components/github-item-dialog/load-item-details/work-item-details-cache.ts b/src/renderer/src/components/github-item-dialog/load-item-details/work-item-details-cache.ts index 36087ef00b2..38811153a28 100644 --- a/src/renderer/src/components/github-item-dialog/load-item-details/work-item-details-cache.ts +++ b/src/renderer/src/components/github-item-dialog/load-item-details/work-item-details-cache.ts @@ -1,9 +1,13 @@ import type { PRCheckDetail } from '../../../../../shared/github/check-types' import type { GitHubAssignableUser, + GitHubOwnerRepo, GitHubPRFileViewedState } from '../../../../../shared/github/pull-request-types' +import { githubRepoIdentityKey } from '../../../../../shared/github/repository-identity-key' import type { GitHubWorkItemDetails } from '../../../../../shared/github/work-item-types' +import type { TaskSourceContext } from '../../../../../shared/task-source-context' +import { getGitHubSourceRuntimeHost } from '@/lib/github-source-runtime-context' import { onGitHubWorkItemDetailsCacheMutation } from '@/lib/github-work-item-details-cache-events' // Why: SWR cache for work-item details so reopening paints instantly instead of paying IPC + `gh` startup; keyed to avoid source/type collisions, LRU-bounded, FRESH_MS refetch on open. See docs/gh-work-item-drawer-cache.md. @@ -37,20 +41,20 @@ export function getWorkItemDetailsCacheKey(args: { repoId: string issueSourcePreference: string | undefined sourceCacheScope?: string | null + sourceContext?: TaskSourceContext | null type: 'issue' | 'pr' number: number + ownerRepo?: GitHubOwnerRepo | null }): string { // Why: key on every axis that changes which (repo, item) the IPC resolves to; `\0` separator avoids ambiguity with fields containing `:` or `/`. // Why: repoPath is the second part so match-based invalidation can find entries from a cross-window event that carries only the path. + const sourceKey = + args.ownerRepo && !getGitHubSourceRuntimeHost(args.sourceContext) + ? githubRepoIdentityKey(args.ownerRepo) + : (args.issueSourcePreference ?? 'auto') const keyParts = args.sourceCacheScope - ? [ - args.repoId, - args.repoPath, - args.sourceCacheScope, - args.issueSourcePreference ?? 'auto', - args.type - ] - : [args.repoId, args.repoPath, args.issueSourcePreference ?? 'auto', args.type] + ? [args.repoId, args.repoPath, args.sourceCacheScope, sourceKey, args.type] + : [args.repoId, args.repoPath, sourceKey, args.type] return [...keyParts, args.number].join('\0') } diff --git a/src/renderer/src/components/github-item-dialog/load-item-details/work-item-details-issue-target.test.ts b/src/renderer/src/components/github-item-dialog/load-item-details/work-item-details-issue-target.test.ts new file mode 100644 index 00000000000..a5c7ab456c8 --- /dev/null +++ b/src/renderer/src/components/github-item-dialog/load-item-details/work-item-details-issue-target.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it, vi } from 'vitest' +import type { TaskSourceContext } from '../../../../../shared/task-source-context' +import { + getWorkItemDetailsCacheKey, + invalidateWorkItemDetailsCacheByMatch, + touchWorkItemDetailsCache, + workItemDetailsCache +} from './work-item-details-cache' + +vi.mock('@/lib/github-work-item-details-cache-events', () => ({ + onGitHubWorkItemDetailsCacheMutation: vi.fn() +})) + +const keyArgs = { + repoId: 'repo-1', + repoPath: '/home/fixture/widgets', + type: 'issue' as const, + number: 12, + issueSourcePreference: 'origin' +} +const ORIGIN = { owner: 'fork-owner', repo: 'widgets', host: 'github.com' } +const UPSTREAM = { owner: 'upstream-owner', repo: 'widgets', host: 'github.com' } +const LOCAL_SOURCE: TaskSourceContext = { + kind: 'task-source', + provider: 'github', + projectId: 'project-1', + hostId: 'local', + repoId: 'repo-1' +} + +describe('issue detail cache repository identity', () => { + it('separates equal issue numbers across origin and upstream', () => { + expect(getWorkItemDetailsCacheKey({ ...keyArgs, ownerRepo: ORIGIN })).not.toBe( + getWorkItemDetailsCacheKey({ ...keyArgs, ownerRepo: UPSTREAM }) + ) + }) + + it.each([undefined, LOCAL_SOURCE])( + 'keeps a local opened issue key stable when another window changes the selector: %j', + (sourceContext) => { + expect(getWorkItemDetailsCacheKey({ ...keyArgs, ownerRepo: ORIGIN, sourceContext })).toBe( + getWorkItemDetailsCacheKey({ + ...keyArgs, + issueSourcePreference: 'upstream', + ownerRepo: ORIGIN, + sourceContext + }) + ) + } + ) + + it('keeps RPC detail caches scoped to the preference used by their existing lookup', () => { + const sourceContext: TaskSourceContext = { + kind: 'task-source', + provider: 'github', + projectId: 'project-1', + hostId: 'runtime:env-1', + repoId: 'runtime-repo' + } + const args = { ...keyArgs, ownerRepo: ORIGIN, sourceContext } + + expect(getWorkItemDetailsCacheKey(args)).not.toBe( + getWorkItemDetailsCacheKey({ ...args, issueSourcePreference: 'upstream' }) + ) + expect(getWorkItemDetailsCacheKey(args)).toBe( + getWorkItemDetailsCacheKey({ ...args, ownerRepo: null }) + ) + }) + + it('invalidates both repository variants after a mutation', () => { + const originKey = getWorkItemDetailsCacheKey({ ...keyArgs, ownerRepo: ORIGIN }) + const upstreamKey = getWorkItemDetailsCacheKey({ ...keyArgs, ownerRepo: UPSTREAM }) + touchWorkItemDetailsCache(originKey, { details: null, fetchedAt: 0 }) + touchWorkItemDetailsCache(upstreamKey, { details: null, fetchedAt: 0 }) + + invalidateWorkItemDetailsCacheByMatch(keyArgs) + + expect(workItemDetailsCache.has(originKey)).toBe(false) + expect(workItemDetailsCache.has(upstreamKey)).toBe(false) + }) +}) diff --git a/src/renderer/src/components/github/PRAssigneesPanel.tsx b/src/renderer/src/components/github/PRAssigneesPanel.tsx index d759d93880a..916c9f5d413 100644 --- a/src/renderer/src/components/github/PRAssigneesPanel.tsx +++ b/src/renderer/src/components/github/PRAssigneesPanel.tsx @@ -11,6 +11,7 @@ import { useRepoAssigneesBySlug } from '@/hooks/useGitHubSlugMetadata' import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' import { parseOwnerRepoFromItemUrl, + resolvePullRequestRepo, type GitHubWorkItemProjectOrigin } from '@/components/github/github-work-item-identity' import { runIssueUpdate } from '@/components/github/github-work-item-edit-mutations' @@ -82,6 +83,7 @@ export function PRAssigneesPanel({ ) const assigneeLogins = useMemo(() => localAssignees.map((user) => user.login), [localAssignees]) const assigneeSlug = useMemo(() => parseOwnerRepoFromItemUrl(item.url), [item.url]) + const prRepo = useMemo(() => resolvePullRequestRepo(item, projectOrigin), [item, projectOrigin]) const slugOwner = projectOrigin?.owner ?? assigneeSlug?.owner ?? null const slugRepo = projectOrigin?.repo ?? assigneeSlug?.repo ?? null const repoAssigneesBySlug = useRepoAssigneesBySlug( @@ -118,17 +120,24 @@ export function PRAssigneesPanel({ repoPath, sourceContext, projectOrigin, + issueRepo: prRepo, number: item.number, updates: isAssigned ? { removeAssignees: [login] } : { addAssignees: [login] } }), onOptimistic: () => { setLocalAssignees(nextAssignees) - patchWorkItem(item.id, { assignees: nextAssignees }, item.repoId, { sourceContext }) + patchWorkItem(item.id, { assignees: nextAssignees }, item.repoId, { + sourceContext, + ownerRepo: prRepo + }) patchProjectRowIfNeeded(nextLogins) }, onRevert: () => { setLocalAssignees(prevAssignees) - patchWorkItem(item.id, { assignees: prevAssignees }, item.repoId, { sourceContext }) + patchWorkItem(item.id, { assignees: prevAssignees }, item.repoId, { + sourceContext, + ownerRepo: prRepo + }) patchProjectRowIfNeeded(prevLogins) }, onSuccess: () => { @@ -147,6 +156,7 @@ export function PRAssigneesPanel({ onMutated, patchProjectRowIfNeeded, patchWorkItem, + prRepo, projectOrigin, repoPath, run, diff --git a/src/renderer/src/components/github/github-work-item-comment-mutations.ts b/src/renderer/src/components/github/github-work-item-comment-mutations.ts index f0f6fda3b82..6c7fc7e99ac 100644 --- a/src/renderer/src/components/github/github-work-item-comment-mutations.ts +++ b/src/renderer/src/components/github/github-work-item-comment-mutations.ts @@ -25,6 +25,7 @@ export function addIssueCommentForRepo(args: { repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId), number: args.number, body: args.body, + ...(args.type ? { type: args.type } : {}), prRepo: args.prRepo ?? null }, { timeoutMs: 30_000 } diff --git a/src/renderer/src/components/github/github-work-item-edit-mutations.ts b/src/renderer/src/components/github/github-work-item-edit-mutations.ts index 5c40aeb2328..543fb2395e8 100644 --- a/src/renderer/src/components/github/github-work-item-edit-mutations.ts +++ b/src/renderer/src/components/github/github-work-item-edit-mutations.ts @@ -29,19 +29,21 @@ export async function runIssueUpdate(args: { repoId?: string | null sourceContext?: TaskSourceContext | null projectOrigin: GitHubWorkItemProjectOrigin | undefined + issueRepo?: GitHubOwnerRepo | null number: number updates: Parameters[0]['updates'] }): Promise { - if (args.projectOrigin) { + const issueRepo = args.projectOrigin + if (issueRepo) { const targetSettings = args.sourceContext?.provider === 'github' ? getTaskSourceRuntimeSettings(args.sourceContext) : getGitHubMutationSettings(args.repoId) const target = getActiveRuntimeTarget(targetSettings) const updateArgs = { - owner: args.projectOrigin.owner, - repo: args.projectOrigin.repo, - host: githubProjectHost(args.projectOrigin.host), + owner: issueRepo.owner, + repo: issueRepo.repo, + host: githubProjectHost(issueRepo.host), number: args.number, updates: args.updates } @@ -59,18 +61,16 @@ export async function runIssueUpdate(args: { if (!res.ok) { throw new Error(res.error.message) } - if (target.kind === 'environment') { - notifyWorkItemDetailsMutation( - { - repoPath: args.repoPath ?? '', - repoId: args.repoId ?? undefined, - sourceContext: args.sourceContext, - type: 'issue', - number: args.number - }, - { local: false } - ) - } + notifyWorkItemDetailsMutation( + { + repoPath: args.repoPath ?? '', + repoId: args.repoId ?? undefined, + sourceContext: args.sourceContext, + type: 'issue', + number: args.number + }, + { local: target.kind !== 'environment' } + ) return } const runtimeHost = getGitHubSourceRuntimeHost(args.sourceContext) @@ -93,7 +93,8 @@ export async function runIssueUpdate(args: { repoId: args.repoId ?? undefined, sourceContext: args.sourceContext, number: args.number, - updates: args.updates + updates: args.updates, + ...(args.issueRepo ? { ownerRepo: args.issueRepo } : {}) }) if (!res.ok) { throw new Error(res.error) @@ -177,6 +178,7 @@ export async function runWorkItemBodyUpdate(args: { repoId: args.item.repoId, sourceContext: args.sourceContext, projectOrigin: args.projectOrigin, + issueRepo: args.parsedSlug, number: args.item.number, updates: { body: args.body } }) diff --git a/src/renderer/src/components/github/github-work-item-issue-target.test.ts b/src/renderer/src/components/github/github-work-item-issue-target.test.ts new file mode 100644 index 00000000000..88056a25cbb --- /dev/null +++ b/src/renderer/src/components/github/github-work-item-issue-target.test.ts @@ -0,0 +1,172 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { runIssueUpdate, runWorkItemBodyUpdate } from './github-work-item-edit-mutations' +import type { GitHubWorkItem } from '../../../../shared/github/work-item-types' +import type { TaskSourceContext } from '../../../../shared/task-source-context' +import { runGHEditLabelToggle } from '../github-item-dialog/edit-item-fields/gh-edit-section-mutations' + +vi.mock('@/store', () => ({ useAppStore: { getState: vi.fn() } })) +vi.mock('@/components/github/github-work-item-comment-mutations', () => ({ + notifyWorkItemDetailsMutation: vi.fn() +})) + +const ORIGIN = { owner: 'fork-owner', repo: 'widgets', host: 'github.com' } +const UPSTREAM = { owner: 'upstream-owner', repo: 'widgets', host: 'github.com' } +const projectOrigin = { + ...UPSTREAM, + number: 12, + type: 'issue' as const, + projectId: 'project-1', + projectItemId: 'project-item-12', + cacheKey: 'project-cache' +} +const localSource: TaskSourceContext = { + kind: 'task-source', + provider: 'github', + projectId: 'project-1', + hostId: 'local', + repoId: 'repo-1' +} +const item: GitHubWorkItem = { + id: 'issue:12', + type: 'issue', + number: 12, + title: 'Origin issue', + state: 'open', + url: 'https://github.com/fork-owner/widgets/issues/12', + labels: [], + updatedAt: '', + author: null, + repoId: 'repo-1' +} + +describe('issue edits retain the displayed repository', () => { + beforeEach(() => { + vi.stubGlobal('window', { + api: { + gh: { updateIssue: vi.fn().mockResolvedValue({ ok: true }), updateIssueBySlug: vi.fn() } + } + }) + }) + + it('keeps body edits on the registered repo path and passes the opened issue target', async () => { + await runWorkItemBodyUpdate({ + item, + repoPath: '/home/fixture/widgets', + projectOrigin: undefined, + body: 'Origin edit', + parsedSlug: ORIGIN + }) + + expect(window.api.gh.updateIssue).toHaveBeenCalledWith({ + repoId: 'repo-1', + repoPath: '/home/fixture/widgets', + sourceContext: undefined, + number: 12, + updates: { body: 'Origin edit' }, + ownerRepo: ORIGIN + }) + expect(window.api.gh.updateIssueBySlug).not.toHaveBeenCalled() + }) + + it('saves a Project row body in its upstream repository instead of the fork workspace', async () => { + vi.mocked(window.api.gh.updateIssueBySlug).mockResolvedValue({ ok: true }) + + await runWorkItemBodyUpdate({ + item: { ...item, url: 'https://github.com/upstream-owner/widgets/issues/12' }, + repoPath: '/home/fixture/fork-widgets', + sourceContext: localSource, + projectOrigin, + body: 'Project edit', + parsedSlug: UPSTREAM + }) + + expect(window.api.gh.updateIssueBySlug).toHaveBeenCalledWith({ + ...UPSTREAM, + number: 12, + updates: { body: 'Project edit' } + }) + expect(window.api.gh.updateIssue).not.toHaveBeenCalled() + }) + + it.each([ + { state: 'closed' as const }, + { addLabels: ['bug'] }, + { addAssignees: ['upstream-assignee'] } + ])('keeps Project field edits in the row repository: %j', async (updates) => { + vi.mocked(window.api.gh.updateIssueBySlug).mockResolvedValue({ ok: true }) + + await runIssueUpdate({ + repoPath: '/home/fixture/fork-widgets', + repoId: item.repoId, + sourceContext: localSource, + projectOrigin, + issueRepo: UPSTREAM, + number: 12, + updates + }) + + expect(window.api.gh.updateIssueBySlug).toHaveBeenCalledWith({ + ...UPSTREAM, + number: 12, + updates + }) + expect(window.api.gh.updateIssue).not.toHaveBeenCalled() + }) + + it.each([ + { state: 'closed' as const }, + { addLabels: ['bug'] }, + { addAssignees: ['fork-assignee'] } + ])('pins field edits to the opened issue repository: %j', async (updates) => { + await runIssueUpdate({ + repoPath: 'C:\\workspace\\widgets', + repoId: 'repo-1', + projectOrigin: undefined, + number: 12, + issueRepo: ORIGIN, + updates + }) + + expect(window.api.gh.updateIssue).toHaveBeenCalledWith( + expect.objectContaining({ + repoPath: 'C:\\workspace\\widgets', + ownerRepo: ORIGIN, + updates + }) + ) + expect(window.api.gh.updateIssueBySlug).not.toHaveBeenCalled() + }) + + it.each([ + { localLabels: [], updates: { addLabels: ['bug'] } }, + { localLabels: ['bug'], updates: { removeLabels: ['bug'] } } + ])( + 'retains the target for both label toggle directions: %j', + async ({ localLabels, updates }) => { + let mutation: Promise = Promise.resolve() + runGHEditLabelToggle({ + itemId: item.id, + itemNumber: item.number, + itemRepoId: item.repoId, + repoPath: 'C:\\workspace\\widgets', + projectOrigin: undefined, + issueRepo: ORIGIN, + label: 'bug', + localLabels, + run: async (_key, options) => { + mutation = options.mutate() + await mutation + }, + onLabelsChange: vi.fn(), + patchWorkItem: vi.fn(), + patchProjectRowIfNeeded: vi.fn(), + onMutated: vi.fn() + }) + await mutation + + expect(window.api.gh.updateIssue).toHaveBeenCalledWith( + expect.objectContaining({ ownerRepo: ORIGIN, updates }) + ) + } + ) +}) diff --git a/src/renderer/src/components/task-page-cache-selectors.test.ts b/src/renderer/src/components/task-page-cache-selectors.test.ts index 44777236862..ef1a0e20efe 100644 --- a/src/renderer/src/components/task-page-cache-selectors.test.ts +++ b/src/renderer/src/components/task-page-cache-selectors.test.ts @@ -204,8 +204,35 @@ describe('task page cache selectors', () => { } expect(findTaskPageDialogWorkItem(cache, null)).toBeNull() - expect(findTaskPageDialogWorkItem(cache, { id: 'issue-1', repoId: 'repo-1' })).toBe(item) - expect(findTaskPageDialogWorkItem(cache, { id: 'issue-1', repoId: 'repo-2' })).toBeNull() + expect( + findTaskPageDialogWorkItem(cache, { id: 'issue-1', repoId: 'repo-1', url: item.url }) + ).toBe(item) + expect( + findTaskPageDialogWorkItem(cache, { id: 'issue-1', repoId: 'repo-2', url: item.url }) + ).toBeNull() + }) + + it('keeps the clicked repository when a source change refreshes the same issue number', () => { + const origin = { + ...workItem('issue:12', 'repo-1'), + url: 'https://github.com/fork-owner/widgets/issues/12' + } + const upstream = { + ...origin, + url: 'https://github.com/upstream-owner/widgets/issues/12' + } + const clickedIssue = { id: origin.id, repoId: origin.repoId, url: origin.url } + const upstreamCache = { + upstream: entry([upstream]) + } + + expect(findTaskPageDialogWorkItem(upstreamCache, clickedIssue)).toBeNull() + expect( + findTaskPageDialogWorkItem( + { ...upstreamCache, origin: entry([origin]) }, + clickedIssue + ) + ).toBe(origin) }) it('reconciles paged table rows with patched work-item cache entries', () => { diff --git a/src/renderer/src/components/task-page-cache-selectors.ts b/src/renderer/src/components/task-page-cache-selectors.ts index a563dcae7b1..7cab1b34ece 100644 --- a/src/renderer/src/components/task-page-cache-selectors.ts +++ b/src/renderer/src/components/task-page-cache-selectors.ts @@ -28,6 +28,7 @@ export type TaskPageRepoCacheInput = { export type TaskPageDialogWorkItemKey = { id: string repoId: string + url: string } | null export type TaskPageRepoSourceState = { @@ -240,7 +241,10 @@ export function findTaskPageDialogWorkItem( for (const entry of Object.values(workItemsCache)) { const found = entry?.data?.find( - (wi) => wi.id === dialogWorkItemKey.id && wi.repoId === dialogWorkItemKey.repoId + (wi) => + wi.id === dialogWorkItemKey.id && + wi.repoId === dialogWorkItemKey.repoId && + wi.url === dialogWorkItemKey.url ) if (found) { return found diff --git a/src/renderer/src/components/task-page-github-confirmed-client-values.ts b/src/renderer/src/components/task-page-github-confirmed-client-values.ts new file mode 100644 index 00000000000..8b67b25d260 --- /dev/null +++ b/src/renderer/src/components/task-page-github-confirmed-client-values.ts @@ -0,0 +1,61 @@ +import type { GitHubOwnerRepo } from '../../../shared/github/pull-request-types' +import { githubRepoIdentityKey } from '../../../shared/github/repository-identity-key' +import { taskPageGitHubLastConfirmedKey } from './task-page-github-work-item-mutation-keys' + +const lastConfirmedClientValues = new Map< + string, + { value: unknown; ownerRepo?: GitHubOwnerRepo | null } +>() + +export function getLastConfirmedClientValue( + sourceScope: string | null, + repoId: string, + itemId: string, + family: string, + ownerRepo?: GitHubOwnerRepo | null +): unknown { + const entry = lastConfirmedClientValues.get( + taskPageGitHubLastConfirmedKey(sourceScope, repoId, itemId, family) + ) + if ( + ownerRepo !== undefined && + entry?.ownerRepo && + (!ownerRepo || githubRepoIdentityKey(ownerRepo) !== githubRepoIdentityKey(entry.ownerRepo)) + ) { + return undefined + } + return entry?.value +} +export function setLastConfirmedClientValue( + sourceScope: string | null, + repoId: string, + itemId: string, + family: string, + value: unknown, + ownerRepo?: GitHubOwnerRepo | null +): void { + lastConfirmedClientValues.set( + taskPageGitHubLastConfirmedKey(sourceScope, repoId, itemId, family), + { value, ownerRepo } + ) +} +export function deleteLastConfirmedClientValue( + sourceScope: string | null, + repoId: string, + itemId: string, + family: string, + ownerRepo?: GitHubOwnerRepo | null +): void { + if ( + ownerRepo !== undefined && + getLastConfirmedClientValue(sourceScope, repoId, itemId, family, ownerRepo) === undefined + ) { + return + } + lastConfirmedClientValues.delete( + taskPageGitHubLastConfirmedKey(sourceScope, repoId, itemId, family) + ) +} +export function clearLastConfirmedClientValues(): void { + lastConfirmedClientValues.clear() +} diff --git a/src/renderer/src/components/task-page-github-dialog-state-authority.test.ts b/src/renderer/src/components/task-page-github-dialog-state-authority.test.ts index b0bde81eb82..664b8bf7409 100644 --- a/src/renderer/src/components/task-page-github-dialog-state-authority.test.ts +++ b/src/renderer/src/components/task-page-github-dialog-state-authority.test.ts @@ -184,4 +184,69 @@ describe('dialog state authority (STA-3343)', () => { expect(superseded.revert()).toBe(false) expect(getLastConfirmedClientValue(null, 'repo-1', 'issue:1', 'state')).toBe('merged') }) + + it('holds qualified state only on its canonical repository and releases it on matching search', () => { + const ownerRepo = { owner: 'o', repo: 'r', host: 'github.com' } + setTaskPageGitHubMutationQueryKey('q') + assertTaskPageGitHubDialogStateAuthority({ + repoId: 'repo-1', + itemId: 'issue:1', + state: 'closed', + ownerRepo + }) + const other = item({ url: 'https://github.com/upstream/r/issues/1' }) + const enterprise = item({ url: 'https://ghe.example/o/r/issues/1' }) + expect( + applyPendingTaskPageGitHubMutationsToItems([item(), other, enterprise]).map( + (row) => row.state + ) + ).toEqual(['closed', 'open', 'open']) + adoptQuietSearchFieldsForItem({ + item: other, + serverItem: { ...other, state: 'closed' }, + sourceScope: null, + queryKey: 'q', + fetchStartedAtGeneration: getOrCreateQuietRevalidateState('q').dirtyGeneration, + patchWorkItem: () => {} + }) + expect(getLastConfirmedClientValue(null, 'repo-1', 'issue:1', 'state', ownerRepo)).toBe( + 'closed' + ) + adoptQuietSearchFieldsForItem({ + item: item(), + serverItem: item({ state: 'closed' }), + sourceScope: null, + queryKey: 'q', + fetchStartedAtGeneration: getOrCreateQuietRevalidateState('q').dirtyGeneration, + patchWorkItem: () => {} + }) + expect( + getLastConfirmedClientValue(null, 'repo-1', 'issue:1', 'state', ownerRepo) + ).toBeUndefined() + expect(applyPendingTaskPageGitHubMutationsToItems([item()])[0]?.state).toBe('open') + }) + + it('keeps another repository confirmation when an older qualified edit rolls back', () => { + const forkRepo = { owner: 'o', repo: 'r', host: 'github.com' } + const upstreamRepo = { ...forkRepo, owner: 'upstream' } + const forkAuthority = assertTaskPageGitHubDialogStateAuthority({ + repoId: 'repo-1', + itemId: 'issue:1', + state: 'closed', + ownerRepo: forkRepo + }) + assertTaskPageGitHubDialogStateAuthority({ + repoId: 'repo-1', + itemId: 'issue:1', + state: 'closed', + ownerRepo: upstreamRepo + }) + expect(forkAuthority.revert()).toBe(false) + expect(getLastConfirmedClientValue(null, 'repo-1', 'issue:1', 'state', upstreamRepo)).toBe( + 'closed' + ) + expect( + getLastConfirmedClientValue(null, 'repo-1', 'issue:1', 'state', forkRepo) + ).toBeUndefined() + }) }) diff --git a/src/renderer/src/components/task-page-github-dialog-state-authority.ts b/src/renderer/src/components/task-page-github-dialog-state-authority.ts index 2918e1eb4d0..55bb62aedf1 100644 --- a/src/renderer/src/components/task-page-github-dialog-state-authority.ts +++ b/src/renderer/src/components/task-page-github-dialog-state-authority.ts @@ -1,4 +1,5 @@ import type { GitHubWorkItem } from '../../../shared/github/work-item-types' +import type { GitHubOwnerRepo } from '../../../shared/github/pull-request-types' import { getTaskSourceCacheScope, type TaskSourceContext @@ -32,24 +33,57 @@ export function assertTaskPageGitHubDialogStateAuthority(args: { itemId: string state: GitHubWorkItem['state'] sourceContext?: TaskSourceContext | null + ownerRepo?: GitHubOwnerRepo | null }): { revert: () => boolean } { const sourceScope = args.sourceContext?.provider === 'github' ? getTaskSourceCacheScope(args.sourceContext) : null - const previous = getLastConfirmedClientValue(sourceScope, args.repoId, args.itemId, 'state') - setLastConfirmedClientValue(sourceScope, args.repoId, args.itemId, 'state', args.state) + const previous = getLastConfirmedClientValue( + sourceScope, + args.repoId, + args.itemId, + 'state', + args.ownerRepo + ) + setLastConfirmedClientValue( + sourceScope, + args.repoId, + args.itemId, + 'state', + args.state, + args.ownerRepo + ) markStateFamilyDirty(args.repoId, args.itemId) notifyTaskPageGitHubMutationRegistry() return { revert: () => { - const current = getLastConfirmedClientValue(sourceScope, args.repoId, args.itemId, 'state') + const current = getLastConfirmedClientValue( + sourceScope, + args.repoId, + args.itemId, + 'state', + args.ownerRepo + ) // A matching search adopt or newer mutation owns the state now. if (current !== args.state) { return false } if (previous === undefined) { - deleteLastConfirmedClientValue(sourceScope, args.repoId, args.itemId, 'state') + deleteLastConfirmedClientValue( + sourceScope, + args.repoId, + args.itemId, + 'state', + args.ownerRepo + ) } else { - setLastConfirmedClientValue(sourceScope, args.repoId, args.itemId, 'state', previous) + setLastConfirmedClientValue( + sourceScope, + args.repoId, + args.itemId, + 'state', + previous, + args.ownerRepo + ) } markStateFamilyDirty(args.repoId, args.itemId) notifyTaskPageGitHubMutationRegistry() diff --git a/src/renderer/src/components/task-page-github-work-item-mutation-composition.ts b/src/renderer/src/components/task-page-github-work-item-mutation-composition.ts index 6e98c29cb9b..d7387e6cfe4 100644 --- a/src/renderer/src/components/task-page-github-work-item-mutation-composition.ts +++ b/src/renderer/src/components/task-page-github-work-item-mutation-composition.ts @@ -1,6 +1,7 @@ import type { ParsedTaskQuery } from '../../../shared/task-query' import type { GitHubAssignableUser } from '../../../shared/github/pull-request-types' import type { GitHubWorkItem } from '../../../shared/github/work-item-types' +import { parseGitHubIssueOrPRLink } from '../../../shared/github/links' import { recomputeTaskPageGitHubItemSoftHide, shouldSoftHideTaskPageGitHubWorkItem @@ -98,7 +99,13 @@ export function getRegistryMergedTaskPageGitHubWorkItem( // Why: after confirm, pending is cleared but search may still lag — hold the // last confirmed whole-field values until a matching adopt or newer pending. - const lastState = getLastConfirmedClientValue(sourceScope, item.repoId, item.id, 'state') + const lastState = getLastConfirmedClientValue( + sourceScope, + item.repoId, + item.id, + 'state', + parseGitHubIssueOrPRLink(item.url)?.slug ?? null + ) if (typeof lastState === 'string') { merged = { ...merged, state: lastState as GitHubWorkItem['state'] } } diff --git a/src/renderer/src/components/task-page-github-work-item-mutation-pages.ts b/src/renderer/src/components/task-page-github-work-item-mutation-pages.ts index b8e0ff01c83..1c50e9c547e 100644 --- a/src/renderer/src/components/task-page-github-work-item-mutation-pages.ts +++ b/src/renderer/src/components/task-page-github-work-item-mutation-pages.ts @@ -1,5 +1,6 @@ import type { GitHubWorkItem } from '../../../shared/github/work-item-types' import type { TaskSourceContext } from '../../../shared/task-source-context' +import { parseGitHubIssueOrPRLink } from '../../../shared/github/links' import { getRegistryMergedTaskPageGitHubWorkItem } from './task-page-github-work-item-mutation-composition' import { getStickyHideEntry, @@ -74,7 +75,10 @@ export function reapplyPendingTaskPageGitHubMutationsToCache(args: { autoMergeEnabled: merged.autoMergeEnabled }, item.repoId, - { sourceContext: args.sourceContextByRepoId?.get(item.repoId) } + { + sourceContext: args.sourceContextByRepoId?.get(item.repoId), + ownerRepo: parseGitHubIssueOrPRLink(item.url)?.slug + } ) } } diff --git a/src/renderer/src/components/task-page-github-work-item-mutation-registry.ts b/src/renderer/src/components/task-page-github-work-item-mutation-registry.ts index 6988ff7f7b3..9fdf7760e19 100644 --- a/src/renderer/src/components/task-page-github-work-item-mutation-registry.ts +++ b/src/renderer/src/components/task-page-github-work-item-mutation-registry.ts @@ -1,4 +1,17 @@ -import type { GitHubAssignableUser } from '../../../shared/github/pull-request-types' +import { + clearLastConfirmedClientValues, + deleteLastConfirmedClientValue, + getLastConfirmedClientValue, + setLastConfirmedClientValue as storeLastConfirmedClientValue +} from './task-page-github-confirmed-client-values' +export { + deleteLastConfirmedClientValue, + getLastConfirmedClientValue +} from './task-page-github-confirmed-client-values' +import type { + GitHubAssignableUser, + GitHubOwnerRepo +} from '../../../shared/github/pull-request-types' import type { PendingOp, StickyHideEntry, @@ -15,7 +28,6 @@ export type { import { serializeTaskPageGitHubMutationKey, taskPageGitHubItemKey, - taskPageGitHubLastConfirmedKey, taskPageGitHubSnapshotKey } from './task-page-github-work-item-mutation-keys' import { clearTaskPageGitHubQuietStates } from './task-page-github-work-item-quiet-state' @@ -37,7 +49,6 @@ const listeners = new Set() const pendingByKey = new Map() const generations = new Map() const confirmedSnapshots = new Map() -const lastConfirmedClientValues = new Map() /** * Why: after confirm, pending ops are gone but lastConfirmed/snapshots stay keyed * by sourceScope. Overlay must still resolve the same scope or authority is lost. @@ -79,7 +90,7 @@ export function getTaskPageGitHubConfirmedAuthorityItemKeys(): ReadonlySet, repoId?: string, - options?: { sourceContext?: TaskSourceContext | null } + options?: GitHubPatchWorkItemOptions ) => void export type BeginTaskPageGitHubWorkItemMutationArgs = { diff --git a/src/renderer/src/components/task-page-github-work-item-quiet-adopt.ts b/src/renderer/src/components/task-page-github-work-item-quiet-adopt.ts index 5547986ab2e..769948311d0 100644 --- a/src/renderer/src/components/task-page-github-work-item-quiet-adopt.ts +++ b/src/renderer/src/components/task-page-github-work-item-quiet-adopt.ts @@ -1,5 +1,6 @@ import type { GitHubWorkItem } from '../../../shared/github/work-item-types' import type { TaskSourceContext } from '../../../shared/task-source-context' +import { parseGitHubIssueOrPRLink } from '../../../shared/github/links' import { loginSetOfUsers, loginSetsEqual } from './task-page-github-work-item-mutation-patches' import { familiesFromPendingOp, @@ -52,6 +53,7 @@ export function adoptQuietSearchFieldsForItem(args: { }): { needTrailing: boolean } { const state = getOrCreateQuietRevalidateState(args.queryKey) const itemKey = taskPageGitHubItemKey(args.item.repoId, args.item.id) + const ownerRepo = parseGitHubIssueOrPRLink(args.item.url)?.slug ?? null let needTrailing = false const G0 = args.fetchStartedAtGeneration const tryFamily = ( @@ -91,7 +93,8 @@ export function adoptQuietSearchFieldsForItem(args: { 'state', () => { args.patchWorkItem(args.item.id, { state: args.serverItem.state }, args.item.repoId, { - sourceContext: args.sourceContext + sourceContext: args.sourceContext, + ownerRepo }) }, () => { @@ -99,14 +102,27 @@ export function adoptQuietSearchFieldsForItem(args: { args.sourceScope, args.item.repoId, args.item.id, - 'state' + 'state', + ownerRepo ) return last === undefined || args.serverItem.state === last }, () => - getLastConfirmedClientValue(args.sourceScope, args.item.repoId, args.item.id, 'state') !== - undefined, - () => deleteLastConfirmedClientValue(args.sourceScope, args.item.repoId, args.item.id, 'state') + getLastConfirmedClientValue( + args.sourceScope, + args.item.repoId, + args.item.id, + 'state', + ownerRepo + ) !== undefined, + () => + deleteLastConfirmedClientValue( + args.sourceScope, + args.item.repoId, + args.item.id, + 'state', + ownerRepo + ) ) tryFamily( 'autoMerge', @@ -115,7 +131,7 @@ export function adoptQuietSearchFieldsForItem(args: { args.item.id, { autoMergeEnabled: args.serverItem.autoMergeEnabled }, args.item.repoId, - { sourceContext: args.sourceContext } + { sourceContext: args.sourceContext, ownerRepo } ) }, () => { @@ -147,7 +163,7 @@ export function adoptQuietSearchFieldsForItem(args: { args.item.id, family === 'assignees' ? { assignees: serverList } : { reviewRequests: serverList }, args.item.repoId, - { sourceContext: args.sourceContext } + { sourceContext: args.sourceContext, ownerRepo } ) }, () => { diff --git a/src/renderer/src/components/use-task-page-github-detail.ts b/src/renderer/src/components/use-task-page-github-detail.ts index b2c61f64bde..655accb61d7 100644 --- a/src/renderer/src/components/use-task-page-github-detail.ts +++ b/src/renderer/src/components/use-task-page-github-detail.ts @@ -49,7 +49,8 @@ export function useTaskPageGitHubDetail(model: TaskPageGitHubListStateModel) { const dialogWorkItemKey = githubTaskDrawerWorkItem ? { id: githubTaskDrawerWorkItem.id, - repoId: githubTaskDrawerWorkItem.repoId + repoId: githubTaskDrawerWorkItem.repoId, + url: githubTaskDrawerWorkItem.url } : null const appliedWorkItemsCacheQuery = useMemo( @@ -67,7 +68,7 @@ export function useTaskPageGitHubDetail(model: TaskPageGitHubListStateModel) { ) ) - // Why: derive the dialog item from the cache for optimistic patches, falling back to the click-time snapshot for new stubs; key by repoId so same-number issues across repos resolve to the clicked row. + // Keep cache patches in the clicked conversation when origin and upstream share an issue number. const cachedDialogWorkItem = useAppStore((s) => findTaskPageDialogWorkItem(s.workItemsCache, dialogWorkItemKey) ) diff --git a/src/renderer/src/hooks/useGitHubRepoMetadata.ts b/src/renderer/src/hooks/useGitHubRepoMetadata.ts new file mode 100644 index 00000000000..8cdb20376bc --- /dev/null +++ b/src/renderer/src/hooks/useGitHubRepoMetadata.ts @@ -0,0 +1,95 @@ +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import type { + GitHubAssignableUser, + GitHubOwnerRepo +} from '../../../shared/github/pull-request-types' +import { githubRepoIdentityKey } from '../../../shared/github/repository-identity-key' +import { createMetadataRequestStore } from './metadata-request-cache' +import { useMetadataListRequest, type MetadataListState } from './useMetadataListRequest' + +type GitHubMetadataOptions = { + runtimeEnvironmentId?: string | null + activeRuntimeEnvironmentId?: string | null + ownerRepo?: GitHubOwnerRepo | null +} + +const ghLabelStore = createMetadataRequestStore() +const ghAssigneeStore = createMetadataRequestStore() + +export function useRepoLabels( + repoPath: string | null, + repoId?: string | null, + options?: GitHubMetadataOptions +): MetadataListState { + const runtimeEnvironmentId = + options?.runtimeEnvironmentId?.trim() || options?.activeRuntimeEnvironmentId?.trim() || null + const repoSelector = repoId ?? repoPath ?? '' + const ownerRepo = runtimeEnvironmentId ? null : options?.ownerRepo + const repositoryKey = ownerRepo + ? `${repoSelector}::${githubRepoIdentityKey(ownerRepo)}` + : repoSelector + const cacheKey = + repoPath || repoId + ? runtimeEnvironmentId + ? `runtime:${runtimeEnvironmentId}:${repoSelector}` + : repositoryKey + : null + + return useMetadataListRequest({ + cacheKey, + store: ghLabelStore, + errorFallback: 'Failed to load labels', + load: () => + runtimeEnvironmentId + ? callRuntimeRpc( + { kind: 'environment', environmentId: runtimeEnvironmentId }, + 'github.listLabels', + { repo: repoSelector }, + { timeoutMs: 15_000 } + ) + : window.api.gh.listLabels({ + repoPath: repoPath ?? '', + repoId: repoId ?? undefined, + ...(ownerRepo ? { ownerRepo } : {}) + }) + }) +} + +export function useRepoAssignees( + repoPath: string | null, + repoId?: string | null, + options?: GitHubMetadataOptions +): MetadataListState { + const runtimeEnvironmentId = + options?.runtimeEnvironmentId?.trim() || options?.activeRuntimeEnvironmentId?.trim() || null + const repoSelector = repoId ?? repoPath ?? '' + const ownerRepo = runtimeEnvironmentId ? null : options?.ownerRepo + const repositoryKey = ownerRepo + ? `${repoSelector}::${githubRepoIdentityKey(ownerRepo)}` + : repoSelector + const cacheKey = + repoPath || repoId + ? runtimeEnvironmentId + ? `runtime:${runtimeEnvironmentId}:${repoSelector}` + : repositoryKey + : null + + return useMetadataListRequest({ + cacheKey, + store: ghAssigneeStore, + errorFallback: 'Failed to load assignees', + load: () => + runtimeEnvironmentId + ? callRuntimeRpc( + { kind: 'environment', environmentId: runtimeEnvironmentId }, + 'github.listAssignableUsers', + { repo: repoSelector }, + { timeoutMs: 15_000 } + ) + : window.api.gh.listAssignableUsers({ + repoPath: repoPath ?? '', + repoId: repoId ?? undefined, + ...(ownerRepo ? { ownerRepo } : {}) + }) + }) +} diff --git a/src/renderer/src/hooks/useIssueMetadata.test.tsx b/src/renderer/src/hooks/useIssueMetadata.test.tsx index bff14b1c091..f586e03fada 100644 --- a/src/renderer/src/hooks/useIssueMetadata.test.tsx +++ b/src/renderer/src/hooks/useIssueMetadata.test.tsx @@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { clearLinearMetadataCache, useRepoLabels, + useRepoAssignees, useTeamStates, useTeamsStates } from './useIssueMetadata' @@ -17,7 +18,7 @@ const linearMocks = vi.hoisted(() => ({ })) const runtimeMocks = vi.hoisted(() => ({ callRuntimeRpc: vi.fn() })) -const githubMocks = vi.hoisted(() => ({ listLabels: vi.fn() })) +const githubMocks = vi.hoisted(() => ({ listLabels: vi.fn(), listAssignableUsers: vi.fn() })) vi.mock('@/runtime/runtime-linear-project-client', () => ({ linearTeamStates: linearMocks.linearTeamStates, @@ -38,7 +39,12 @@ const roots: Root[] = [] function installWindowApi(): void { Object.defineProperty(window, 'api', { configurable: true, - value: { gh: { listLabels: githubMocks.listLabels } } + value: { + gh: { + listLabels: githubMocks.listLabels, + listAssignableUsers: githubMocks.listAssignableUsers + } + } }) } @@ -67,6 +73,7 @@ describe('useIssueMetadata hooks', () => { linearMocks.linearTeamMembers.mockReset() runtimeMocks.callRuntimeRpc.mockReset() githubMocks.listLabels.mockReset() + githubMocks.listAssignableUsers.mockReset() installWindowApi() }) @@ -97,6 +104,70 @@ describe('useIssueMetadata hooks', () => { expect(runtimeMocks.callRuntimeRpc).not.toHaveBeenCalled() }) + it('keeps concurrent fork and upstream label lists separate when the fork reply is late', async () => { + const fork = { owner: 'fork', repo: 'widgets', host: 'github.com' } + const upstream = { owner: 'upstream', repo: 'widgets', host: 'github.com' } + let finishFork: (labels: string[]) => void = () => {} + const forkReply = new Promise((resolve) => { + finishFork = resolve + }) + githubMocks.listLabels.mockImplementation(({ ownerRepo }) => + ownerRepo.owner === 'fork' ? forkReply : Promise.resolve(['upstream-label']) + ) + let forkLabels: string[] = [] + let upstreamLabels: string[] = [] + function Probe(): null { + forkLabels = useRepoLabels(null, 'concurrent-folder', { ownerRepo: fork }).data + upstreamLabels = useRepoLabels(null, 'concurrent-folder', { ownerRepo: upstream }).data + return null + } + renderProbe() + await flushEffects() + expect(forkLabels).toEqual([]) + expect(upstreamLabels).toEqual(['upstream-label']) + await act(async () => { + finishFork(['fork-label']) + }) + await flushEffects() + expect(forkLabels).toEqual(['fork-label']) + expect(upstreamLabels).toEqual(['upstream-label']) + expect(githubMocks.listLabels).toHaveBeenCalledTimes(2) + }) + + it('ignores a late fork assignee reply after the opened issue switches to upstream', async () => { + const fork = { owner: 'fork', repo: 'widgets', host: 'github.com' } + const upstream = { owner: 'upstream', repo: 'widgets', host: 'github.com' } + let finishFork: (users: { login: string; name: null; avatarUrl: string }[]) => void = () => {} + const forkReply = new Promise<{ login: string; name: null; avatarUrl: string }[]>((resolve) => { + finishFork = resolve + }) + githubMocks.listAssignableUsers.mockImplementation(({ ownerRepo }) => + ownerRepo.owner === 'fork' + ? forkReply + : Promise.resolve([{ login: 'upstream-user', name: null, avatarUrl: '' }]) + ) + let logins: string[] = [] + function Probe({ ownerRepo }: { ownerRepo: typeof fork }): null { + logins = useRepoAssignees(null, 'switch-folder', { ownerRepo }).data.map((user) => user.login) + return null + } + renderProbe() + await flushEffects() + const root = roots.at(-1) + if (!root) { + throw new Error('Expected the rendered probe') + } + act(() => root.render()) + await flushEffects() + expect(logins).toEqual(['upstream-user']) + await act(async () => { + finishFork([{ login: 'fork-user', name: null, avatarUrl: '' }]) + }) + await flushEffects() + expect(logins).toEqual(['upstream-user']) + expect(githubMocks.listAssignableUsers).toHaveBeenCalledTimes(2) + }) + it('prefers an explicit remote environment and repo id', async () => { let labels: string[] = [] runtimeMocks.callRuntimeRpc.mockResolvedValue(['remote']) diff --git a/src/renderer/src/hooks/useIssueMetadata.ts b/src/renderer/src/hooks/useIssueMetadata.ts index a7e55b80700..2ce65c3055f 100644 --- a/src/renderer/src/hooks/useIssueMetadata.ts +++ b/src/renderer/src/hooks/useIssueMetadata.ts @@ -1,12 +1,11 @@ import { useEffect, useMemo, useRef, useState } from 'react' -import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { linearTeamLabels, linearTeamMembers, linearTeamStates } from '@/runtime/runtime-linear-project-client' import type { RuntimeLinearSettings } from '@/runtime/runtime-linear-client' -import type { GitHubAssignableUser } from '../../../shared/github/pull-request-types' import type { LinearLabel, LinearMember, @@ -23,79 +22,7 @@ import { } from './metadata-request-cache' import { useMetadataListRequest, type MetadataListState } from './useMetadataListRequest' -type GitHubMetadataOptions = { - runtimeEnvironmentId?: string | null - activeRuntimeEnvironmentId?: string | null -} - -const ghLabelStore = createMetadataRequestStore() -const ghAssigneeStore = createMetadataRequestStore() - -export function useRepoLabels( - repoPath: string | null, - repoId?: string | null, - options?: GitHubMetadataOptions -): MetadataListState { - const runtimeEnvironmentId = - options?.runtimeEnvironmentId?.trim() || options?.activeRuntimeEnvironmentId?.trim() || null - const repoSelector = repoId ?? repoPath ?? '' - const cacheKey = - repoPath || repoId - ? runtimeEnvironmentId - ? `runtime:${runtimeEnvironmentId}:${repoSelector}` - : repoSelector - : null - - return useMetadataListRequest({ - cacheKey, - store: ghLabelStore, - errorFallback: 'Failed to load labels', - load: () => - runtimeEnvironmentId - ? callRuntimeRpc( - { kind: 'environment', environmentId: runtimeEnvironmentId }, - 'github.listLabels', - { repo: repoSelector }, - { timeoutMs: 15_000 } - ) - : window.api.gh - .listLabels({ repoPath: repoPath ?? '', repoId: repoId ?? undefined }) - .then((labels) => labels as string[]) - }) -} - -export function useRepoAssignees( - repoPath: string | null, - repoId?: string | null, - options?: GitHubMetadataOptions -): MetadataListState { - const runtimeEnvironmentId = - options?.runtimeEnvironmentId?.trim() || options?.activeRuntimeEnvironmentId?.trim() || null - const repoSelector = repoId ?? repoPath ?? '' - const cacheKey = - repoPath || repoId - ? runtimeEnvironmentId - ? `runtime:${runtimeEnvironmentId}:${repoSelector}` - : repoSelector - : null - - return useMetadataListRequest({ - cacheKey, - store: ghAssigneeStore, - errorFallback: 'Failed to load assignees', - load: () => - runtimeEnvironmentId - ? callRuntimeRpc( - { kind: 'environment', environmentId: runtimeEnvironmentId }, - 'github.listAssignableUsers', - { repo: repoSelector }, - { timeoutMs: 15_000 } - ) - : window.api.gh - .listAssignableUsers({ repoPath: repoPath ?? '', repoId: repoId ?? undefined }) - .then((users) => users as GitHubAssignableUser[]) - }) -} +export { useRepoLabels, useRepoAssignees } from './useGitHubRepoMetadata' const linearStateStore = createMetadataRequestStore() const linearLabelStore = createMetadataRequestStore() diff --git a/src/renderer/src/lib/github-work-item-source-lookup.test.ts b/src/renderer/src/lib/github-work-item-source-lookup.test.ts index 336583b1d6d..432401ce3e5 100644 --- a/src/renderer/src/lib/github-work-item-source-lookup.test.ts +++ b/src/renderer/src/lib/github-work-item-source-lookup.test.ts @@ -41,7 +41,8 @@ describe('GitHub source lookup routing', () => { repoId: 'renderer-repo', sourceContext: runtimeSourceContext, number: 42, - type: 'issue' + type: 'issue', + ownerRepo: { owner: 'fork-owner', repo: 'widgets', host: 'github.com' } }) ).resolves.toBeNull() @@ -100,4 +101,22 @@ describe('GitHub source lookup routing', () => { }) expect(callRuntimeRpc).not.toHaveBeenCalled() }) + + it('sends a local issue row repository to the repo-scoped details route', async () => { + const ownerRepo = { owner: 'fork-owner', repo: 'widgets', host: 'github.com' } + vi.mocked(window.api.gh.workItemDetails).mockResolvedValue(null) + + await lookupGitHubWorkItemDetailsForSource({ + repoPath: '/home/fixture/widgets', + repoId: 'local-repo', + number: 12, + type: 'issue', + ownerRepo + }) + + expect(window.api.gh.workItemDetails).toHaveBeenCalledWith( + expect.objectContaining({ repoPath: '/home/fixture/widgets', ownerRepo }) + ) + expect(callRuntimeRpc).not.toHaveBeenCalled() + }) }) diff --git a/src/renderer/src/lib/github-work-item-source-lookup.ts b/src/renderer/src/lib/github-work-item-source-lookup.ts index 5a49e15ba83..da9e3775767 100644 --- a/src/renderer/src/lib/github-work-item-source-lookup.ts +++ b/src/renderer/src/lib/github-work-item-source-lookup.ts @@ -1,5 +1,6 @@ import type { GitHubWorkItem, GitHubWorkItemDetails } from '../../../shared/github/work-item-types' import type { TaskSourceContext } from '../../../shared/task-source-context' +import type { GitHubOwnerRepo } from '../../../shared/github/pull-request-types' import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' import { getGitHubRuntimeRepoId, @@ -28,6 +29,7 @@ type GitHubWorkItemDetailsLookupArgs = { sourceContext?: TaskSourceContext | null number: number type: 'issue' | 'pr' + ownerRepo?: GitHubOwnerRepo | null } function runtimeRepoId(args: Pick): string { @@ -112,6 +114,7 @@ export function lookupGitHubWorkItemDetailsForSource( repoId: args.repoId, sourceContext, number: args.number, - type: args.type + type: args.type, + ...(args.ownerRepo ? { ownerRepo: args.ownerRepo } : {}) }) } diff --git a/src/renderer/src/store/github/cache-model.ts b/src/renderer/src/store/github/cache-model.ts index 27db1bf8167..d1546ff4768 100644 --- a/src/renderer/src/store/github/cache-model.ts +++ b/src/renderer/src/store/github/cache-model.ts @@ -61,4 +61,5 @@ export type ProjectRowContentPatch = { export type GitHubPatchWorkItemOptions = { sourceContext?: TaskSourceContext | null + ownerRepo?: GitHubOwnerRepo | null } diff --git a/src/renderer/src/store/github/work-item-mutation-actions.ts b/src/renderer/src/store/github/work-item-mutation-actions.ts index 97a6d3b66f3..fde46aef606 100644 --- a/src/renderer/src/store/github/work-item-mutation-actions.ts +++ b/src/renderer/src/store/github/work-item-mutation-actions.ts @@ -3,6 +3,8 @@ import type { AppState } from '../types' import type { GitHubSlice } from './slice-types' import { toast } from 'sonner' import type { GitHubWorkItem } from '../../../../shared/github/work-item-types' +import { parseGitHubIssueOrPRLink } from '../../../../shared/github/links' +import { githubRepoIdentityKey } from '../../../../shared/github/repository-identity-key' import { getTaskSourceCacheScope } from '../../../../shared/task-source-context' import { translate } from '@/i18n/i18n' import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' @@ -24,6 +26,7 @@ export const createWorkItemMutationActions = ( options?.sourceContext?.provider === 'github' ? getTaskSourceCacheScope(options.sourceContext) : null + const repositoryKey = options?.ownerRepo ? githubRepoIdentityKey(options.ownerRepo) : null for (const key of Object.keys(nextCache)) { // Why: don't patch another host/account's visually identical issue/PR cache entry. if (sourceScope && key !== sourceScope && !key.startsWith(`${sourceScope}::`)) { @@ -34,9 +37,18 @@ export const createWorkItemMutationActions = ( continue } // Why: issue/PR ids are only unique within a repo; cross-repo views can share `pr:42`. - const idx = entry.data.findIndex( - (item) => item.id === itemId && (!repoId || item.repoId === repoId) - ) + const idx = entry.data.findIndex((item) => { + if (item.id !== itemId || (repoId && item.repoId !== repoId)) { + return false + } + if (!repositoryKey) { + return true + } + const itemRepository = parseGitHubIssueOrPRLink(item.url)?.slug + return ( + itemRepository !== undefined && githubRepoIdentityKey(itemRepository) === repositoryKey + ) + }) if (idx === -1) { continue } diff --git a/src/renderer/src/store/slices/github-work-item-cache-identity.test.ts b/src/renderer/src/store/slices/github-work-item-cache-identity.test.ts index 77f997083aa..6fe2a9e068a 100644 --- a/src/renderer/src/store/slices/github-work-item-cache-identity.test.ts +++ b/src/renderer/src/store/slices/github-work-item-cache-identity.test.ts @@ -111,6 +111,43 @@ describe('createGitHubSlice.patchWorkItem', () => { }) expect(secondPatched).toBe(secondItem) }) + + it('scopes a canonical repository patch within its existing host and account scope', () => { + const store = createTestStore() + const source = githubSourceContext('local', 'repo-1') + const otherAccount = { ...source, projectHostSetupId: 'another-account-setup' } + const canonical: GitHubWorkItem = { + id: 'issue:42', + repoId: 'repo-1', + type: 'issue', + number: 42, + title: 'Fork issue', + state: 'open', + labels: [], + updatedAt: '', + author: null, + url: 'https://github.com/fork/widgets/issues/42' + } + const upstream = { ...canonical, url: 'https://github.com/upstream/widgets/issues/42' } + const enterprise = { ...canonical, url: 'https://ghe.example:8443/fork/widgets/issues/42' } + const sourceKey = workItemsCacheKey('repo-1', 20, '', getTaskSourceCacheScope(source)) + const otherKey = workItemsCacheKey('repo-1', 20, '', getTaskSourceCacheScope(otherAccount)) + store.setState({ + workItemsCache: { + [sourceKey]: { data: [upstream, enterprise, canonical], fetchedAt: 1 }, + [otherKey]: { data: [canonical], fetchedAt: 1 } + } + }) + store.getState().patchWorkItem(canonical.id, { labels: ['bug'] }, canonical.repoId, { + sourceContext: source, + ownerRepo: { owner: 'FORK', repo: 'Widgets', host: ' GitHub.com ' } + }) + const rows = store.getState().workItemsCache[sourceKey]?.data + expect(rows?.[0]).toBe(upstream) + expect(rows?.[1]).toBe(enterprise) + expect(rows?.[2].labels).toEqual(['bug']) + expect(store.getState().workItemsCache[otherKey]?.data?.[0]).toBe(canonical) + }) }) describe('createGitHubSlice.fetchWorkItems cache identity', () => { From b1b78b4d20b3664b1f3948a923664a2e7ba517f4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 07:06:01 +0000 Subject: [PATCH 43/67] 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 fdf7ecb03a0..3f3cf232ad1 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 90m + + downloads: 91m @@ -15,7 +15,7 @@ downloads downloads - 90m - 90m + 91m + 91m From ffd23fe25c7d1558aa12cd76c152a0343533381b Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:18:19 -0700 Subject: [PATCH 44/67] Avoid repeated runtime imports and recovery fixture seeding (#25155) --- docs/reference/ci-runner-efficiency.md | 43 ++++ ...le-state-recovery-crash-boundaries.test.ts | 101 ++++++++- .../orca-runtime-tail-wait-memo.test.ts | 209 +----------------- .../pty-transcript-prune-wait-cache.test.ts | 100 ++++++++- ...ned-tail-redraw-window.equivalence.test.ts | 6 +- src/main/runtime/terminal-projection.test.ts | 2 +- 6 files changed, 230 insertions(+), 231 deletions(-) diff --git a/docs/reference/ci-runner-efficiency.md b/docs/reference/ci-runner-efficiency.md index 19d928f3c82..1efd247e7bb 100644 --- a/docs/reference/ci-runner-efficiency.md +++ b/docs/reference/ci-runner-efficiency.md @@ -1941,6 +1941,49 @@ remained red. Its review recognized all five shards as a complete reference (10,608 files, 8,965,977 worker-ms). This was again a full fallback with `selectionEvaluated: false`, not evidence for enabling selected tests. +## October 4 runtime imports and recovery fixtures + +Three helper-only tests now import the existing terminal modules directly rather +than initializing the runtime service. Ten copied-loop cases never exercised +runtime memoization: they passed with its cache, timestamp update or prune +invalidation disabled. Two actual helper checks remain. The existing runtime +prune suite now exercises real leaf cache reuse, split prompt timestamps, +ordinary output, fresh prompts and detection after retained-history eviction. +Each of those three production faults fails a real runtime assertion. + +Recovery tests now seed three exact fixture variants once, after the seed child +has closed. Each crash still receives an independent byte-for-byte copy of the +entire database/WAL family and remapped paths. Buffer.equals retains exact byte +comparison without recursive matcher overhead. All 46 original crash boundaries +and retries remain. Four additional copy-isolation/WAL checks run, and teardown +requires that all seed bytes remain unchanged after the full suite. + +Three alternating one-worker hosted ARM pairs in +[37182181976](https://github.com/stablyai/orca/actions/runs/37182181976) +measured these complete invocations: + +| Cohort | Baseline seconds | Candidate seconds | Median saving | +| --- | --- | --- | --- | +| Three imports only, same 15 tests | 19.257 / 19.167 / 19.363 | 1.769 / 1.768 / 1.768 | 90.8% | +| Final four-file runtime cohort | 22.312 / 22.122 / 21.969 | 13.494 / 13.793 / 13.601 | 38.5% | +| Recovery crash boundaries | 24.082 / 24.075 / 24.814 | 8.061 / 8.105 / 9.074 | 66.3% | + +The final runtime cohort has seven real cases versus 16 including the copied +loops; its new runtime case is included in candidate timing. Recovery has 50 +passes versus the original 46. Hosted Node typecheck passed. Recovery faults for +last-byte database/WAL corruption, shared database paths, missing WAL copies and +accepted/unaccepted seed collision failed the intended assertions. These are +focused workload savings, not measured whole-shard or queue-delay gains. + +An independent local cache screen left both caches disabled. Across 14 unchanged +files and 92 cases, a warm Vitest transform cache reduced median invocation time +3.090 to 1.948 seconds, excluding archive costs; its cold arm increased time to +3.281 seconds. Node compilation caching showed no gain. Controls reproduced stale +transforms after TypeScript configuration or plugin-option changes, so persisted +reuse requires a complete transform-input stamp and hosted net-cost evidence. +A separate 130,000-pane leaf-collection optimization was restored: its complete +migration-file timing stayed within noise. The regression fixture remains. + ## October 3 removal fixture cleanup ordering [37105566358](https://github.com/stablyai/orca/actions/runs/37105566358) diff --git a/src/main/persistence/profile-state/profile-state-recovery-crash-boundaries.test.ts b/src/main/persistence/profile-state/profile-state-recovery-crash-boundaries.test.ts index 75e498b8d70..857e4f1c7ef 100644 --- a/src/main/persistence/profile-state/profile-state-recovery-crash-boundaries.test.ts +++ b/src/main/persistence/profile-state/profile-state-recovery-crash-boundaries.test.ts @@ -1,4 +1,5 @@ import { + cpSync, existsSync, mkdirSync, mkdtempSync, @@ -8,7 +9,7 @@ import { writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { basename, dirname, join } from 'node:path' +import { basename, dirname, join, relative } from 'node:path' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import { setSecretStore } from '../../../shared/secret-store' import { profileStateStorage } from '../../orca-profiles/profile-project-state-file' @@ -103,13 +104,25 @@ afterEach(() => { rmSync(root, { recursive: true, force: true }) } }) -afterAll(() => rmSync(suiteRoot, { recursive: true, force: true })) +afterAll(() => { + try { + for (const seed of seededFixtures.values()) { + for (const [suffix, bytes] of seed.originalFamily) { + expect(readFileSync(`${seed.databasePath}${suffix}`).equals(bytes)).toBe(true) + } + expect(readFileSync(seed.backupPath).equals(seed.backupBytes)).toBe(true) + expect(readFileSync(seed.exportPath, 'utf8')).toBe(selectedJson) + } + } finally { + rmSync(suiteRoot, { recursive: true, force: true }) + } +}) type Fixture = RecoveryCrashOptions & { backupBytes: Buffer; originalFamily: Map } +const seededFixtures = new Map() -async function fixture(kind: 'json' | 'sqlite', accepted: boolean): Promise { - const root = mkdtempSync(join(suiteRoot, 'profile-')) - fixtureRoots.push(root) +async function seedFixture(kind: 'json' | 'sqlite', accepted: boolean): Promise { + const root = mkdtempSync(join(suiteRoot, 'seed-')) const directory = join(root, 'profiles', profileId) mkdirSync(directory, { recursive: true }) writeFileSync( @@ -173,6 +186,39 @@ async function fixture(kind: 'json' | 'sqlite', accepted: boolean): Promise [ + suffix, + readFileSync(`${options.databasePath}${suffix}`) + ]) + ) + return { ...options, backupBytes: readFileSync(options.backupPath), originalFamily } +} + +async function fixture(kind: 'json' | 'sqlite', accepted: boolean): Promise { + const key = `${kind}/${accepted}` + let seed = seededFixtures.get(key) + if (!seed) { + // The seed child's close event has fired before its WAL family is copied. + seed = await seedFixture(kind, accepted) + seededFixtures.set(key, seed) + } + return cloneFixture(seed) +} + function readSqlite(path: string): unknown { const opened = openProfileStateDatabaseReadOnly(path, profileId) try { @@ -192,10 +238,12 @@ function assertQuarantine(profile: Fixture): void { const directory = join(dirname(profile.databasePath), quarantine) // Check exact family bytes before opening the copied WAL snapshot. for (const [suffix, bytes] of profile.originalFamily) { - expect(readFileSync(join(directory, `profile-state.db${suffix}`))).toEqual(bytes) + expect(readFileSync(join(directory, `profile-state.db${suffix}`)).equals(bytes)).toBe(true) } expect(readFileSync(join(directory, basename(profile.exportPath)), 'utf8')).toBe(selectedJson) - expect(readFileSync(join(directory, basename(profile.backupPath)))).toEqual(profile.backupBytes) + expect( + readFileSync(join(directory, basename(profile.backupPath))).equals(profile.backupBytes) + ).toBe(true) expect(readSqlite(join(directory, 'profile-state.db'))).toEqual(oldState) } @@ -346,7 +394,7 @@ describe('SQLite recovery process death', () => { const profile = await fixture('sqlite', true) await killRecoveryAt(bundle, profile, stage(profile, boundary)) assertQuarantine(profile) - expect(readFileSync(profile.backupPath)).toEqual(profile.backupBytes) + expect(readFileSync(profile.backupPath).equals(profile.backupBytes)).toBe(true) const expected = [ 'marker-invalidated', 'selected-export', @@ -365,7 +413,7 @@ describe('SQLite recovery process death', () => { : 'refused' assertRestart(profile, expected) retry(profile) - expect(readFileSync(profile.backupPath)).toEqual(profile.backupBytes) + expect(readFileSync(profile.backupPath).equals(profile.backupBytes)).toBe(true) }) it.skipIf(process.platform === 'win32')( @@ -378,3 +426,38 @@ describe('SQLite recovery process death', () => { } ) }) + +describe('seeded recovery fixture copies', () => { + it.each([ + ['json', false], + ['json', true], + ['sqlite', true] + ] as const)( + 'isolates %s/accepted=%s through corruption and WAL reopen', + async (kind, accepted) => { + const first = await fixture(kind, accepted) + const second = await fixture(kind, accepted) + const seed = seededFixtures.get(`${kind}/${accepted}`) + if (!seed) { + throw new Error('Seed fixture was not retained') + } + expect(new Set([first.root, second.root, seed.root]).size).toBe(3) + for (const [suffix, bytes] of seed.originalFamily) { + expect(readFileSync(`${first.databasePath}${suffix}`).equals(bytes)).toBe(true) + writeFileSync(`${first.databasePath}${suffix}`, 'corrupted-copy') + expect(readFileSync(`${second.databasePath}${suffix}`).equals(bytes)).toBe(true) + expect(readFileSync(`${seed.databasePath}${suffix}`).equals(bytes)).toBe(true) + } + const third = await fixture(kind, accepted) + expect(readSqlite(third.databasePath)).toEqual(oldState) + expect(readFileSync(seed.backupPath).equals(seed.backupBytes)).toBe(true) + expect(readFileSync(seed.exportPath, 'utf8')).toBe(selectedJson) + } + ) + + it('rejects an incomplete copied WAL family before a recovery child starts', async () => { + const incomplete = await fixture('json', false) + rmSync(`${incomplete.databasePath}-wal`) + expect(() => cloneFixture(incomplete)).toThrow(/ENOENT/) + }) +}) diff --git a/src/main/runtime/orca-runtime-tail-wait-memo.test.ts b/src/main/runtime/orca-runtime-tail-wait-memo.test.ts index 5ce8d9710cd..4ac28674dd8 100644 --- a/src/main/runtime/orca-runtime-tail-wait-memo.test.ts +++ b/src/main/runtime/orca-runtime-tail-wait-memo.test.ts @@ -1,124 +1,7 @@ import { describe, expect, it, vi } from 'vitest' -import { - appendNormalizedToTailBuffer, - buildPreview, - computeTerminalTailWaitState, - tailGainedNewerBlockedReason, - type TerminalTailWaitState -} from './orca-runtime' +import { computeTerminalTailWaitState } from './terminal-wait-tail-state' -// These tests pin the onPtyData wait-detection memoization: caching the -// post-append wait state and reusing it as the next chunk's pre-append state -// must produce byte-for-byte the same waitBlockedAt stamping as recomputing the -// wait-state scan on both sides of every chunk (the pre-memoization behavior), -// while doing roughly half the state computations. - -type RedrawCursor = ReturnType['redrawCursor'] - -type TailSim = { - tailBuffer: string[] - tailPartialLine: string - tailRedrawCursor: RedrawCursor - preview: string - waitBlockedAt: number | null - tailWaitState?: TerminalTailWaitState -} - -function newSim(): TailSim { - return { - tailBuffer: [], - tailPartialLine: '', - tailRedrawCursor: null, - preview: '', - waitBlockedAt: null - } -} - -type Compute = typeof computeTerminalTailWaitState - -// Mirrors the memoized onPtyData tail loop: reuse the cached tail-derived state -// as the previous state; only recompute it on a preview-fallback (empty tail). -function stepMemoized(sim: TailSim, chunk: string, at: number, compute: Compute): void { - const previousWaitState = - sim.tailWaitState?.fromTail === true - ? sim.tailWaitState - : compute(sim.tailBuffer, sim.tailPartialLine, sim.preview) - const nextTail = appendNormalizedToTailBuffer( - sim.tailBuffer, - sim.tailPartialLine, - chunk, - sim.tailRedrawCursor - ) - const nextWaitState = compute(nextTail.lines, nextTail.partialLine, sim.preview) - if (tailGainedNewerBlockedReason(previousWaitState, nextWaitState, chunk)) { - sim.waitBlockedAt = at - } - sim.tailWaitState = nextWaitState - sim.tailBuffer = nextTail.lines - sim.tailPartialLine = nextTail.partialLine - sim.tailRedrawCursor = nextTail.redrawCursor - sim.preview = buildPreview(nextTail.lines, nextTail.partialLine) -} - -// Reference: the pre-memoization behavior — recompute the previous state fresh -// from the current tail on every chunk (no cache). -function stepReference(sim: TailSim, chunk: string, at: number, compute: Compute): void { - const previousWaitState = compute(sim.tailBuffer, sim.tailPartialLine, sim.preview) - const nextTail = appendNormalizedToTailBuffer( - sim.tailBuffer, - sim.tailPartialLine, - chunk, - sim.tailRedrawCursor - ) - const nextWaitState = compute(nextTail.lines, nextTail.partialLine, sim.preview) - if (tailGainedNewerBlockedReason(previousWaitState, nextWaitState, chunk)) { - sim.waitBlockedAt = at - } - sim.tailBuffer = nextTail.lines - sim.tailPartialLine = nextTail.partialLine - sim.tailRedrawCursor = nextTail.redrawCursor - sim.preview = buildPreview(nextTail.lines, nextTail.partialLine) -} - -function runBoth(chunks: string[]): { memoized: (number | null)[]; reference: (number | null)[] } { - const memoSim = newSim() - const refSim = newSim() - const memoized: (number | null)[] = [] - const reference: (number | null)[] = [] - chunks.forEach((chunk, index) => { - const at = index + 1 - stepMemoized(memoSim, chunk, at, computeTerminalTailWaitState) - stepReference(refSim, chunk, at, computeTerminalTailWaitState) - memoized.push(memoSim.waitBlockedAt) - reference.push(refSim.waitBlockedAt) - }) - return { memoized, reference } -} - -const SCENARIOS: Record = { - 'plain output never blocks': ['building...\n', 'compiled ok\n', 'watching for changes\n'], - 'blocked prompt in one chunk': ['Update available! Press Enter to continue.\n'], - 'blocked prompt split across chunks': ['Update available!\n', 'Press Enter to continue.\n'], - 'blocked then plain output stays blocked': [ - 'Update available! Press Enter to continue.\n', - 'still here\n', - 'more logs\n' - ], - 'partial lines without newline then completion': [ - 'Update ava', - 'ilable! Press Enter ', - 'to continue.\n' - ], - 'empty and whitespace chunks': ['', ' ', '\n', 'ok\n', ''], - 'ready header after stale blocked prompt': [ - 'Update available! Press Enter to continue.\n', - 'OpenAI Codex\n', - 'model: gpt\n', - 'directory: /repo\n' - ] -} - -describe('onPtyData tail wait memoization', () => { +describe('terminal tail wait state', () => { it('computeTerminalTailWaitState reports fromTail and blocked signals', () => { const empty = computeTerminalTailWaitState([], '', '') expect(empty.fromTail).toBe(false) @@ -151,92 +34,4 @@ describe('onPtyData tail wait memoization', () => { lastIndexOf.mockRestore() } }) - - for (const [name, chunks] of Object.entries(SCENARIOS)) { - it(`memoized stamping matches recompute reference: ${name}`, () => { - const { memoized, reference } = runBoth(chunks) - expect(memoized).toEqual(reference) - }) - } - - it('stays equivalent across tail eviction beyond the retained cap', () => { - const chunks: string[] = [] - for (let i = 0; i < 2600; i += 1) { - chunks.push(`line ${i} of streaming build output that keeps the tail busy\n`) - } - // Introduce a real blocked prompt well past the eviction boundary. - chunks.push('Update available! Press Enter to continue.\n') - chunks.push('trailing log after prompt\n') - const { memoized, reference } = runBoth(chunks) - expect(memoized).toEqual(reference) - // The prompt must actually be detected (guards against a vacuous match). - expect(memoized.at(-1)).not.toBeNull() - }) - - it('recomputes correctly after a transcript prune empties the tail (prune-then-resume)', () => { - // pruneDisconnectedPtyTranscript empties the tail AND clears tailWaitState; - // model that here and assert the memoized stamping still tracks a fresh - // recompute across the reset (a stale cache would desync the first resumed - // chunk, since the pre-prune tail held a blocked prompt). - const prune = (sim: TailSim): void => { - sim.tailBuffer = [] - sim.tailPartialLine = '' - sim.tailRedrawCursor = null - sim.preview = '' - sim.waitBlockedAt = null - sim.tailWaitState = undefined - } - const memoSim = newSim() - const refSim = newSim() - const memoOut: (number | null)[] = [] - const refOut: (number | null)[] = [] - let at = 0 - const feed = (chunk: string): void => { - at += 1 - stepMemoized(memoSim, chunk, at, computeTerminalTailWaitState) - stepReference(refSim, chunk, at, computeTerminalTailWaitState) - memoOut.push(memoSim.waitBlockedAt) - refOut.push(refSim.waitBlockedAt) - } - // Pre-prune: leave a stale blocked prompt in the tail. - ;['building\n', 'Update available! Press Enter to continue.\n', 'more log\n'].forEach(feed) - prune(memoSim) - prune(refSim) - // Resume: a fresh blocked prompt must be stamped, not masked by stale cache. - ;['fresh start\n', 'Update available! Press Enter to continue.\n', 'after\n'].forEach(feed) - - expect(memoOut).toEqual(refOut) - expect(memoSim.waitBlockedAt).not.toBeNull() - }) - - it('does roughly half the wait-state computations of the recompute reference', () => { - const chunks: string[] = [] - for (let i = 0; i < 500; i += 1) { - chunks.push(`streaming line ${i}\n`) - } - - let memoCalls = 0 - const countingMemo: Compute = (lines, partial, preview) => { - memoCalls += 1 - return computeTerminalTailWaitState(lines, partial, preview) - } - let refCalls = 0 - const countingRef: Compute = (lines, partial, preview) => { - refCalls += 1 - return computeTerminalTailWaitState(lines, partial, preview) - } - - const memoSim = newSim() - const refSim = newSim() - chunks.forEach((chunk, index) => { - stepMemoized(memoSim, chunk, index + 1, countingMemo) - stepReference(refSim, chunk, index + 1, countingRef) - }) - - // Reference recomputes both sides every chunk: 2 per chunk. - expect(refCalls).toBe(chunks.length * 2) - // Memoized reuses the cached previous state on every non-empty-tail chunk: - // 1 per chunk plus a single first-chunk cold miss. - expect(memoCalls).toBe(chunks.length + 1) - }) }) diff --git a/src/main/runtime/pty-transcript-prune-wait-cache.test.ts b/src/main/runtime/pty-transcript-prune-wait-cache.test.ts index 6c9082d2552..6a99c7b25ee 100644 --- a/src/main/runtime/pty-transcript-prune-wait-cache.test.ts +++ b/src/main/runtime/pty-transcript-prune-wait-cache.test.ts @@ -1,14 +1,9 @@ -/** - * Regression: pruneDisconnectedPtyTranscript empties a disconnected PTY's - * retained tail. The onPtyData wait-scan memoization caches the tail's wait - * state on the record (tailWaitState) and reuses it as the next chunk's - * "previous" state — so the prune MUST also clear that cache, or a record that - * resumes output after adoption/reattach would reuse a stale (pre-prune, - * possibly blocked) wait state and mis-stamp waitBlockedAt on its first chunk. - */ -import { describe, expect, it } from 'vitest' +// A disconnected transcript must not keep a wait-scan cache from before its history was pruned. +import { describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService } from './orca-runtime' -import type { TerminalTailWaitState } from './orca-runtime' +import { MAX_TAIL_LINES } from './terminal-tail-limits' +import * as terminalWaitTailState from './terminal-wait-tail-state' +import type { TerminalTailWaitState } from './terminal-wait-tail-state' type PtyRecord = { connected: boolean @@ -20,9 +15,33 @@ type RuntimeInternals = { pruneDisconnectedPtyTranscript: (pty: PtyRecord) => void } +function onlyRuntimeLeaf(runtime: unknown) { + if (typeof runtime !== 'object' || runtime === null || !('leaves' in runtime)) { + throw new Error('Runtime has no leaves') + } + const leaves = runtime.leaves + if (!(leaves instanceof Map) || leaves.size !== 1) { + throw new Error('Expected exactly one runtime leaf') + } + const leaf: unknown = leaves.values().next().value + if ( + typeof leaf !== 'object' || + leaf === null || + !('tailBuffer' in leaf) || + !Array.isArray(leaf.tailBuffer) || + !('tailLinesTotal' in leaf) || + typeof leaf.tailLinesTotal !== 'number' || + !('waitBlockedAt' in leaf) + ) { + throw new Error('Runtime leaf has no terminal tail state') + } + return leaf +} + describe('pruneDisconnectedPtyTranscript clears the wait-scan cache', () => { it('empties the tail and drops tailWaitState so resume recomputes', () => { const runtime = new OrcaRuntimeService() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: both protected methods are declared on OrcaRuntimeService's inheritance chain. const internals = runtime as unknown as RuntimeInternals const pty = internals.recordPtyWorktree('pty-1', 'wt-1', { connected: true }) @@ -40,4 +59,65 @@ describe('pruneDisconnectedPtyTranscript clears the wait-scan cache', () => { expect(pty.tailBuffer).toEqual([]) expect(pty.tailWaitState).toBeUndefined() }) + + it('reuses the real leaf wait scan and stamps each newly arriving blocked prompt', async () => { + const runtime = new OrcaRuntimeService() + const ptyId = 'pty-memo' + const leafId = '11111111-1111-4111-8111-111111111111' + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [{ tabId: 'tab-1', worktreeId: 'wt-1', title: '', activeLeafId: leafId, layout: null }], + leaves: [ + { tabId: 'tab-1', worktreeId: 'wt-1', leafId, paneRuntimeId: 1, ptyId, paneTitle: null } + ] + }) + const leaf = onlyRuntimeLeaf(runtime) + // Different retained history exercises the leaf scan rather than the shared PTY-tail path. + leaf.tailBuffer = ['leaf-only history'] + leaf.tailLinesTotal = 1 + const scan = vi.spyOn(terminalWaitTailState, 'computeTerminalTailWaitState') + const leafScans = () => + scan.mock.calls.filter(([lines]) => lines.includes('leaf-only history')).length + try { + runtime.onPtyData(ptyId, 'first plain line\n', 1_000) + expect(leafScans()).toBe(2) + expect('tailWaitState' in leaf ? leaf.tailWaitState : undefined).toMatchObject({ + fromTail: true, + signal: null + }) + runtime.onPtyData(ptyId, 'second plain line\n', 2_000) + expect(leafScans()).toBe(3) + expect(leaf.waitBlockedAt).toBeNull() + runtime.onPtyData(ptyId, 'Update ava', 3_000) + expect(leafScans()).toBe(4) + expect(leaf.waitBlockedAt).toBeNull() + runtime.onPtyData(ptyId, 'ilable! Press Enter to continue.\n', 4_000) + expect(leafScans()).toBe(5) + expect(leaf.waitBlockedAt).toBe(4_000) + runtime.onPtyData(ptyId, 'ordinary log after the prompt\n', 5_000) + expect(leafScans()).toBe(6) + expect(leaf.waitBlockedAt).toBe(4_000) + runtime.onPtyData(ptyId, 'Update available! Press Enter to continue.\n', 6_000) + expect(leafScans()).toBe(7) + expect(leaf.waitBlockedAt).toBe(6_000) + runtime.onPtyData( + ptyId, + Array.from({ length: MAX_TAIL_LINES + 1 }, (_, index) => `streaming line ${index}\n`).join( + '' + ), + 7_000 + ) + expect(leaf.tailBuffer).toHaveLength(MAX_TAIL_LINES) + expect(leaf.tailBuffer).not.toContain('leaf-only history') + expect('tailWaitState' in leaf ? leaf.tailWaitState : undefined).toMatchObject({ + fromTail: true, + signal: null + }) + runtime.onPtyData(ptyId, 'Update available! Press Enter to continue.\n', 8_000) + expect(leaf.waitBlockedAt).toBe(8_000) + } finally { + scan.mockRestore() + await runtime.onPtyExit(ptyId, 0) + } + }) }) diff --git a/src/main/runtime/retained-tail-redraw-window.equivalence.test.ts b/src/main/runtime/retained-tail-redraw-window.equivalence.test.ts index 30b36483675..b11ce57dcfb 100644 --- a/src/main/runtime/retained-tail-redraw-window.equivalence.test.ts +++ b/src/main/runtime/retained-tail-redraw-window.equivalence.test.ts @@ -1,8 +1,6 @@ import { describe, expect, it } from 'vitest' -import { - appendNormalizedToTailBuffer, - appendNormalizedToMultilineTailBufferUnwindowed -} from './orca-runtime' +import { appendNormalizedToTailBuffer } from './terminal-tail-buffer' +import { appendNormalizedToMultilineTailBufferUnwindowed } from './terminal-tail-redraw-buffer' // Differential guard for the windowed redraw tail path: the public // appendNormalizedToTailBuffer routes vertical-control chunks through a diff --git a/src/main/runtime/terminal-projection.test.ts b/src/main/runtime/terminal-projection.test.ts index 7dff087e74c..c1634ddb19c 100644 --- a/src/main/runtime/terminal-projection.test.ts +++ b/src/main/runtime/terminal-projection.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import type { TerminalCursorContext } from '../../shared/terminal-composer-draft' import type { HeadlessEmulator } from '../daemon/headless-emulator' -import { projectTerminalTailLines } from './orca-runtime' +import { projectTerminalTailLines } from './orca-runtime-terminal-projection' describe('projectTerminalTailLines', () => { it('does not splice a scrolled viewport into unrelated tail rows', () => { From 2576783d4dc4c25f5c3ed004767967985b867571 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:24:45 -0700 Subject: [PATCH 45/67] fix(agents): keep ~/.copilot/config.json owner-only when Orca trusts a folder (#25087) * fix(agents): keep ~/.copilot/config.json owner-only when Orca trusts a folder Marking a folder trusted for Copilot rewrote ~/.copilot/config.json through a temp file created with the default umask mode (usually 0644), so an owner-only file that can hold copilotTokens became readable by other local users. Since the folder-trust change this write also runs on SSH hosts, where other users exist. The rewrite now always writes the file owner-only (0600). * test(agents): cover a fresh owner-only Copilot config.json under a permissive umask --------- Co-authored-by: m4air --- src/main/agent-trust-presets.test.ts | 39 ++++++++++++++++++++++++++++ src/main/agent-trust-presets.ts | 3 ++- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/main/agent-trust-presets.test.ts b/src/main/agent-trust-presets.test.ts index e956494fc76..e0052aa8b45 100644 --- a/src/main/agent-trust-presets.test.ts +++ b/src/main/agent-trust-presets.test.ts @@ -6,6 +6,7 @@ import { readFileSync, realpathSync, rmSync, + statSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -122,6 +123,44 @@ describe('markCopilotFolderTrusted', () => { } }) + it('keeps a token-bearing config.json owner-only', () => { + if (process.platform === 'win32') { + return + } + const workspace = mkdtempSync(join(tmpdir(), 'orca-copilot-ws-')) + const configPath = join(testState.fakeHomeDir, '.copilot', 'config.json') + // Why: a restrictive runner umask would mask a dropped mode. + const originalUmask = process.umask(0o022) + try { + mkdirSync(join(testState.fakeHomeDir, '.copilot'), { recursive: true }) + writeFileSync(configPath, JSON.stringify({ copilotTokens: { a: 'secret' } }), { + mode: 0o600 + }) + markCopilotFolderTrusted(workspace, testState.fakeHomeDir) + expect(statSync(configPath).mode & 0o777).toBe(0o600) + expect(JSON.parse(readFileSync(configPath, 'utf-8')).copilotTokens).toEqual({ a: 'secret' }) + } finally { + process.umask(originalUmask) + rmSync(workspace, { recursive: true, force: true }) + } + }) + + it('creates a missing config.json owner-only', () => { + if (process.platform === 'win32') { + return + } + const workspace = mkdtempSync(join(tmpdir(), 'orca-copilot-ws-')) + const originalUmask = process.umask(0o022) + try { + markCopilotFolderTrusted(workspace, testState.fakeHomeDir) + const configPath = join(testState.fakeHomeDir, '.copilot', 'config.json') + expect(statSync(configPath).mode & 0o777).toBe(0o600) + } finally { + process.umask(originalUmask) + rmSync(workspace, { recursive: true, force: true }) + } + }) + it('preserves existing config keys and dedups already-trusted folders', () => { const workspace = mkdtempSync(join(tmpdir(), 'orca-copilot-ws-')) const realpath = realpathSync(workspace) diff --git a/src/main/agent-trust-presets.ts b/src/main/agent-trust-presets.ts index 2d52ee005e4..eac0591728b 100644 --- a/src/main/agent-trust-presets.ts +++ b/src/main/agent-trust-presets.ts @@ -97,7 +97,8 @@ export function markCopilotFolderTrusted(workspacePath: string, home: string): v if (!existsSync(configDir)) { mkdirSync(configDir, { recursive: true }) } - writeFileAtomically(configPath, `${JSON.stringify(config, null, 2)}\n`) + // Why: config.json can hold copilotTokens, so it must stay owner-only (also on shared SSH hosts). + writeFileAtomically(configPath, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 }) } /** From 2b3b692d29102b87389042a5614d80b93956d5e0 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:25:47 -0700 Subject: [PATCH 46/67] Reduce terminal test overhead while preserving full parity checks (#25151) * Speed up terminal test oracles without reducing replay coverage * Call asynchronous parser through its checked test interface * Preserve evidence document final newline for concurrent merges --- docs/reference/ci-runner-efficiency.md | 41 +++ .../serialize-grid-cell-descriptors.test.ts | 282 +++++++++++++----- .../daemon/serialize-grid-cell-descriptors.ts | 164 ++++++++-- src/main/daemon/serialize-grid-roundtrip.ts | 29 +- ...al-output-frame-chunks-equivalence.test.ts | 41 +-- src/shared/terminal-restore-parity-fixture.ts | 15 +- .../terminal-restore-parity-writes.test.ts | 132 ++++++++ 7 files changed, 553 insertions(+), 151 deletions(-) create mode 100644 src/shared/terminal-restore-parity-writes.test.ts diff --git a/docs/reference/ci-runner-efficiency.md b/docs/reference/ci-runner-efficiency.md index 1efd247e7bb..600651a4457 100644 --- a/docs/reference/ci-runner-efficiency.md +++ b/docs/reference/ci-runner-efficiency.md @@ -1912,6 +1912,47 @@ The source was restored afterward. Extra collection turns therefore preserve the strong-retention oracle. Hosted qualification is still required; these observations do not prove a particular VM-retention cause or quantify avoided retries. +## October 4 terminal oracle execution + +Three measured test-support changes preserve the original seeds, payloads, +chunk boundaries and meaningful assertions. Serializer comparisons reuse cells +and format only the first mismatch instead of allocating descriptors for every +cell. The terminal parity writer submits every original chunk in FIFO order and +awaits the final parser callback. The independent legacy frame oracle memoizes +measured code-point widths. Its discarded algebra-only case never called +production and still passed when production always threw. + +Three alternating one-worker hosted ARM pairs measured complete invocations: + +| Cohort | Baseline median | Candidate median | Saving | +| --- | --- | --- | --- | +| Serializer replay/fuzz/descriptor checks | 71.675s | 46.581s | 35.0% | +| Emulator/reconciliation/color parity | 24.095s | 5.411s | 77.5% | +| Frame equivalence | 18.472s | 13.736s | 25.6% | + +[37180517143](https://github.com/stablyai/orca/actions/runs/37180517143) +retained 116 timed serializer passes and three existing/paired-control skips. +Separate captures matched all 190,796,645 raw bytes over 1,611 scenarios and +8,617 checkpoints (SHA256 `00ab219cfb31456af2ecd5e766d1b82d47abc751f6f2de0d7f795e36a936d3c7`), +including complete outputs and diagnostic payloads. Twenty candidate controls +passed; formatting/color/blank/clipping fault controls detected regressions. + +[37181073275](https://github.com/stablyai/orca/actions/runs/37181073275) +retained all 16 parity cases and default fuzz counts. Captures matched 2,325 +batches, 28,182 original chunks and 1,698,285 input bytes, with identical +terminal state and serialization per terminal/batch. Independent terminal +completion order differs, so comparison uses canonical per-terminal ordering +(SHA256 `c38ac1dbbefb9f6dc33ecfe7c495d65b707c1664614544622af93cfc1850e421`). +All 73 callback/parser/other-consumer controls passed; first-callback, reversed +chunks, missing empty boundary and early-completion faults failed. + +The frame candidate passed all 19 retained cases directly against the original +uncached legacy oracle, preserving 4,000 short and 800 near-cap seeded trials. +Sequence, surrogate width, byte width and span-transform faults failed real +assertions. A part-array alternative was rejected after adding time locally. +Hosted Node typecheck passed. These are focused workload savings, not measured +whole-shard or queue-delay improvements; application behavior is unchanged. + ## October 3 unit-selection evidence: include failed references The caller's `needs.test.result == 'success'` condition prevented the advisory diff --git a/src/main/daemon/serialize-grid-cell-descriptors.test.ts b/src/main/daemon/serialize-grid-cell-descriptors.test.ts index 490b40a1c9d..8aec878b790 100644 --- a/src/main/daemon/serialize-grid-cell-descriptors.test.ts +++ b/src/main/daemon/serialize-grid-cell-descriptors.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import type { Terminal } from '@xterm/headless' -import { bufferRows, cellDescriptor } from './serialize-grid-cell-descriptors' +import { cellDescriptor, compareBufferRows } from './serialize-grid-cell-descriptors' import { createFuzzTerminal, writeTerminal } from './serialize-grid-roundtrip' // Frozen allocating oracle from f69052e; a reused cell must preserve every descriptor. @@ -78,48 +78,6 @@ function allocatingBufferRows( } describe('serialize oracle cell reuse', () => { - it.each([false, true])( - 'matches allocating cells across SGR, wide text, buffers and resize (ConPTY=%s)', - (conpty) => { - const terminal = createFuzzTerminal({ cols: 14, rows: 4, scrollback: 30, conpty }) - try { - const writes = [ - 'plain \x1b[1;2;3;4;7;8;9mstyled\x1b[0m\r\n', - '\x1b[38;5;2;48;5;10m palette \x1b[38;2;11;22;33;48;2;44;55;66m RGB \x1b[0m\r\n', - '\x1b[4:3;9;53m \x1b[0m\x1b[7m \x1b[0m界👩‍💻é\r\n', - 'scroll1\r\nscroll2\r\nscroll3\r\n', - '\x1b[?1049h\x1b[1;2;3;4;7;8;9malt界\x1b[0m', - '\x1b[?1049l\x1b[2J\x1b[Hclear' - ] - for (const data of writes) { - writeTerminal(terminal, data) - for (const buffer of [ - terminal.buffer.normal, - terminal.buffer.alternate, - terminal.buffer.active - ]) { - expect(bufferRows(buffer, -1, buffer.length + 1, terminal.cols + 2)).toEqual( - allocatingBufferRows(buffer, -1, buffer.length + 1, terminal.cols + 2) - ) - } - terminal.resize(terminal.cols === 14 ? 9 : 14, 4) - expect( - bufferRows(terminal.buffer.active, 0, terminal.buffer.active.length, terminal.cols) - ).toEqual( - allocatingBufferRows( - terminal.buffer.active, - 0, - terminal.buffer.active.length, - terminal.cols - ) - ) - } - } finally { - terminal.dispose() - } - } - ) - it('preserves the order of every text flag combination while reloading a plain cell', () => { const terminal = createFuzzTerminal({ cols: 4, rows: 1, scrollback: 0 }) try { @@ -167,50 +125,11 @@ describe('serialize oracle cell reuse', () => { const scratch = terminal.buffer.active.getNullCell() expect(cellDescriptor(line, 3, 4, scratch)).toBe('CLIPPED') expect(cellDescriptor(line, 3, 4, scratch)).toBe(allocatingCellDescriptor(line, 3, 4)) - expect(bufferRows(terminal.buffer.active, 0, 1, 4)).toEqual( - allocatingBufferRows(terminal.buffer.active, 0, 1, 4) - ) } finally { terminal.dispose() } }) - it('loads every cell into one traversal-local scratch object and retains immutable descriptors', () => { - const terminal = createFuzzTerminal({ cols: 4, rows: 2, scrollback: 0 }) - try { - writeTerminal(terminal, '\x1b[1mA\x1b[0m B界') - const buffer = terminal.buffer.active - const scratch = buffer.getNullCell() - const allocate = vi.spyOn(buffer, 'getNullCell').mockReturnValue(scratch) - const getLine = buffer.getLine.bind(buffer) - const loaded: unknown[] = [] - vi.spyOn(buffer, 'getLine').mockImplementation((y) => { - const line = getLine(y) - if (line) { - const getCell = line.getCell.bind(line) - vi.spyOn(line, 'getCell').mockImplementation((x, cell) => { - loaded.push(cell) - return getCell(x, cell) - }) - } - return line - }) - const expected = allocatingBufferRows(buffer, 0, 2, 4) - loaded.length = 0 - const actual = bufferRows(buffer, 0, 2, 4) - expect(actual).toEqual(expected) - expect(allocate).toHaveBeenCalledTimes(1) - expect(loaded).toHaveLength(8) - expect(loaded.every((cell) => cell === scratch)).toBe(true) - writeTerminal(terminal, '\x1b[2J\x1b[Hnew') - bufferRows(buffer, 0, 2, 4) - expect(actual).toEqual(expected) - } finally { - terminal.dispose() - vi.restoreAllMocks() - } - }) - it('keeps missing lines and invalid columns blank after a styled cell occupied the scratch', () => { const terminal = createFuzzTerminal({ cols: 4, rows: 2, scrollback: 0 }) try { @@ -230,3 +149,202 @@ describe('serialize oracle cell reuse', () => { } }) }) + +function allocatingRowDiff(stage: string, expected: string[][], actual: string[][]) { + for (let y = 0; y < Math.max(expected.length, actual.length); y++) { + const expectedRow = expected[y] + const actualRow = actual[y] + if ( + !expectedRow || + !actualRow || + expectedRow.length !== actualRow.length || + !expectedRow.every( + (cell, x) => cell === actualRow[x] || (cell === CLIPPED && actualRow[x]?.startsWith('▯')) + ) + ) { + return { stage, row: y, expected: expectedRow?.join('|'), actual: actualRow?.join('|') } + } + } + return null +} + +function expectComparisonParity( + expected: Buffer, + actual: Buffer, + cols: number, + expectedStart = 0, + expectedEnd = expected.length, + actualStart = 0, + actualEnd = actual.length +): ReturnType { + const frozen = allocatingRowDiff( + 'parity', + allocatingBufferRows(expected, expectedStart, expectedEnd, cols), + allocatingBufferRows(actual, actualStart, actualEnd, cols) + ) + expect( + compareBufferRows( + 'parity', + expected, + expectedStart, + expectedEnd, + actual, + actualStart, + actualEnd, + cols + ) + ).toEqual(frozen) + return frozen +} + +describe('serialize oracle streaming comparison', () => { + it('reuses one cell per buffer and stops before rows after the first difference', () => { + const source = createFuzzTerminal({ cols: 8, rows: 4, scrollback: 0 }) + const replay = createFuzzTerminal({ cols: 8, rows: 4, scrollback: 0 }) + try { + writeTerminal(source, 'first\r\nsecond\r\nthird\r\nlast') + writeTerminal(replay, 'wrong\r\nsecond\r\nthird\r\nlast') + for (const buffer of [source.buffer.active, replay.buffer.active]) { + const scratch = buffer.getNullCell() + vi.spyOn(buffer, 'getNullCell').mockReturnValue(scratch) + const getLine = buffer.getLine.bind(buffer) + vi.spyOn(buffer, 'getLine').mockImplementation((y) => { + const line = getLine(y) + if (line) { + const getCell = line.getCell.bind(line) + vi.spyOn(line, 'getCell').mockImplementation((x, cell) => { + expect(cell).toBe(scratch) + return getCell(x, cell) + }) + } + return line + }) + } + const diff = compareBufferRows( + 'visible-grid', + source.buffer.active, + 0, + 4, + replay.buffer.active, + 0, + 4, + 8 + ) + expect(diff?.row).toBe(0) + for (const buffer of [source.buffer.active, replay.buffer.active]) { + expect(buffer.getNullCell).toHaveBeenCalledTimes(1) + expect(buffer.getLine).toHaveBeenCalledTimes(2) + expect(buffer.getLine).toHaveBeenCalledWith(0) + expect(buffer.getLine).toHaveBeenCalledWith(3) + } + writeTerminal(source, '\x1b[2J\x1b[Hchanged') + expect(diff?.expected).toContain('f·w1·f0:-1·b0:-1·0000000') + } finally { + source.dispose() + replay.dispose() + vi.restoreAllMocks() + } + }) + + it.each([false, true])( + 'preserves first-row diagnostics through buffer changes (ConPTY=%s)', + (conpty) => { + const source = createFuzzTerminal({ cols: 14, rows: 4, scrollback: 30, conpty }) + const replay = createFuzzTerminal({ cols: 14, rows: 4, scrollback: 30, conpty }) + try { + for (const [index, data] of [ + 'plain \x1b[1;2;3;4;7;8;9mstyled\x1b[0m\r\n', + '\x1b[38;5;2;48;5;10m palette \x1b[38;2;11;22;33;48;2;44;55;66m RGB \x1b[0m\r\n', + '\x1b[4:3;9;53m \x1b[0m\x1b[7m \x1b[0m界👩‍💻é\r\n', + 'scroll1\r\nscroll2\r\nscroll3\r\n', + '\x1b[?1049h\x1b[1;2;3;4;7;8;9malt界\x1b[0m', + '\x1b[?1049l\x1b[2J\x1b[Hclear' + ].entries()) { + writeTerminal(source, data) + writeTerminal(replay, data) + for (const [expected, actual] of [ + [source.buffer.active, replay.buffer.active], + [source.buffer.normal, replay.buffer.normal], + [source.buffer.alternate, replay.buffer.alternate] + ]) { + expect(expectComparisonParity(expected!, actual!, source.cols)).toBeNull() + expectComparisonParity(expected!, actual!, source.cols + 2, -1, expected!.length + 1) + } + const corruption = `\x1b[H\x1b[0mFAULT${index}` + writeTerminal(replay, corruption) + expect( + expectComparisonParity(source.buffer.active, replay.buffer.active, source.cols) + ).not.toBeNull() + writeTerminal(source, corruption) + source.resize(source.cols === 14 ? 9 : 14, 4) + replay.resize(source.cols, 4) + expectComparisonParity(source.buffer.active, replay.buffer.active, source.cols) + } + } finally { + source.dispose() + replay.dispose() + } + } + ) + + it('detects every text-flag loss and compares all combinations against the allocating oracle', () => { + const source = createFuzzTerminal({ cols: 4, rows: 1, scrollback: 0 }) + const replay = createFuzzTerminal({ cols: 4, rows: 1, scrollback: 0 }) + try { + const sgr = [1, 2, 3, 4, 7, 8, 9] + const writeFlags = (terminal: Terminal, mask: number): void => { + const codes = sgr.filter((_code, bit) => (mask & (1 << bit)) !== 0) + writeTerminal(terminal, `\x1b[H\x1b[0m\x1b[${codes.length ? codes.join(';') : 0}mA\x1b[0mB`) + } + for (let mask = 0; mask < 128; mask++) { + writeFlags(source, mask) + writeFlags(replay, mask) + expect(expectComparisonParity(source.buffer.active, replay.buffer.active, 4)).toBeNull() + for (let bit = 0; bit < sgr.length; bit++) { + if ((mask & (1 << bit)) !== 0) { + writeFlags(replay, mask & ~(1 << bit)) + expect( + expectComparisonParity(source.buffer.active, replay.buffer.active, 4) + ).not.toBeNull() + } + } + } + } finally { + source.dispose() + replay.dispose() + } + }) + + it.each([ + ['abc界', 'abc ', 4, false], + ['abc界', 'abc\x1b[48;2;1;2;3m ', 4, false], + ['abc ', 'abc界', 4, true], + ['é', 'è', 8, true], + ['\x1b[32mA', '\x1b[38;5;2mA', 8, false], + ['\x1b[42m ', '\x1b[48;5;2m ', 8, false], + ['\x1b[7;31m ', '\x1b[7;32m ', 8, true], + ['\x1b[4m\x1b[2J', '\x1b[2J', 8, false], + ['\x1b[4m ', ' ', 8, true], + ['\x1b[4m \x1b[0mB', ' B', 8, true], + ['text\r\n', 'text', 8, false], + ['text\r\n\x1b[48;2;1;2;3m\x1b[2K', 'text', 8, true] + ] as const)( + 'preserves blank, clipped and color policy for %j / %j', + (expected, actual, cols, differs) => { + const source = createFuzzTerminal({ cols: 8, rows: 4, scrollback: 10 }) + const replay = createFuzzTerminal({ cols: 8, rows: 4, scrollback: 10 }) + try { + writeTerminal(source, expected) + writeTerminal(replay, actual) + expect( + Boolean(expectComparisonParity(source.buffer.active, replay.buffer.active, cols)) + ).toBe(differs) + expectComparisonParity(source.buffer.active, replay.buffer.active, cols, -1, 6, -1, 5) + expectComparisonParity(source.buffer.active, replay.buffer.active, cols, 1, 5, 0, 4) + } finally { + source.dispose() + replay.dispose() + } + } + ) +}) diff --git a/src/main/daemon/serialize-grid-cell-descriptors.ts b/src/main/daemon/serialize-grid-cell-descriptors.ts index 8b6c331f5fc..9adbbc6c694 100644 --- a/src/main/daemon/serialize-grid-cell-descriptors.ts +++ b/src/main/daemon/serialize-grid-cell-descriptors.ts @@ -7,6 +7,7 @@ export type GridDiff = { stage: string; row?: number; expected: unknown; actual: type BufferLine = NonNullable> type Buffer = Terminal['buffer']['active'] +type Cell = ReturnType const COLOR_MODE_P16 = 16777216 const COLOR_MODE_P256 = 33554432 @@ -64,33 +65,158 @@ function cellsMatch(expected: string, actual: string): boolean { return expected === actual || (expected === CLIPPED && actual.startsWith('▯')) } -function rowsMatch(expected: string[], actual: string[]): boolean { - return expected.length === actual.length && expected.every((e, i) => cellsMatch(e, actual[i]!)) +function sameColor( + expectedMode: number, + expectedColor: number, + actualMode: number, + actualColor: number +): boolean { + return ( + expectedColor === actualColor && + canonicalColorMode(expectedMode, expectedColor) === canonicalColorMode(actualMode, actualColor) + ) } -export function bufferRows(buffer: Buffer, start: number, end: number, cols: number): string[][] { - const rows: string[][] = [] - const reusableCell = buffer.getNullCell() - for (let y = start; y < end; y++) { - rows.push(rowCells(buffer.getLine(y), cols, reusableCell)) +// Equal public fields imply identical descriptors; differing fields still use the frozen policy. +function sameCellFields(expected: Cell, actual: Cell): boolean { + if ( + expected.getWidth() !== actual.getWidth() || + !sameColor( + expected.getBgColorMode(), + expected.getBgColor(), + actual.getBgColorMode(), + actual.getBgColor() + ) + ) { + return false } - while (rows.length > 0 && rows.at(-1)!.every((c) => c === DEFAULT_BLANK)) { - rows.pop() + const expectedChars = expected.getChars() + const actualChars = actual.getChars() + const expectedBlank = expectedChars === '' || expectedChars === ' ' + const actualBlank = actualChars === '' || actualChars === ' ' + if (expectedBlank || actualBlank) { + return ( + expectedBlank && + actualBlank && + (expectedChars === ' ' && expected.isUnderline() !== 0) === + (actualChars === ' ' && actual.isUnderline() !== 0) && + (expectedChars === ' ' && expected.isStrikethrough() !== 0) === + (actualChars === ' ' && actual.isStrikethrough() !== 0) && + (expectedChars === ' ' && expected.isOverline() !== 0) === + (actualChars === ' ' && actual.isOverline() !== 0) && + (expected.isInverse() !== 0) === (actual.isInverse() !== 0) && + (expected.isInverse() === 0 || + sameColor( + expected.getFgColorMode(), + expected.getFgColor(), + actual.getFgColorMode(), + actual.getFgColor() + )) + ) } - return rows + return ( + expectedChars === actualChars && + sameColor( + expected.getFgColorMode(), + expected.getFgColor(), + actual.getFgColorMode(), + actual.getFgColor() + ) && + (expected.isBold() !== 0) === (actual.isBold() !== 0) && + (expected.isDim() !== 0) === (actual.isDim() !== 0) && + (expected.isItalic() !== 0) === (actual.isItalic() !== 0) && + (expected.isUnderline() !== 0) === (actual.isUnderline() !== 0) && + (expected.isInverse() !== 0) === (actual.isInverse() !== 0) && + (expected.isInvisible() !== 0) === (actual.isInvisible() !== 0) && + (expected.isStrikethrough() !== 0) === (actual.isStrikethrough() !== 0) + ) } -export function compareRowSets( +function trimmedEnd( + buffer: Buffer, + start: number, + end: number, + cols: number, + scratch: Cell +): number { + while (end > start) { + const line = buffer.getLine(end - 1) + let blank = true + for (let x = 0; x < cols; x++) { + if (cellDescriptor(line, x, cols, scratch) !== DEFAULT_BLANK) { + blank = false + break + } + } + if (!blank) { + break + } + end-- + } + return end +} + +function lineCellsMatch( + expected: BufferLine | undefined, + actual: BufferLine | undefined, + cols: number, + expectedScratch: Cell, + actualScratch: Cell +): boolean { + for (let x = 0; x < cols; x++) { + const expectedCell = + expected && x < expected.length ? expected.getCell(x, expectedScratch) : null + const actualCell = actual && x < actual.length ? actual.getCell(x, actualScratch) : null + const clipped = + x === cols - 1 && + ((expected && expected.length > cols && (expectedCell?.getWidth() ?? 0) > 1) || + (actual && actual.length > cols && (actualCell?.getWidth() ?? 0) > 1)) + if (expectedCell && actualCell && !clipped && sameCellFields(expectedCell, actualCell)) { + continue + } + if ( + !cellsMatch( + cellDescriptor(expected, x, cols, expectedScratch), + cellDescriptor(actual, x, cols, actualScratch) + ) + ) { + return false + } + } + return true +} + +/** Compares in place and formats only the first differing row, preserving diagnostic bytes. */ +export function compareBufferRows( stage: string, - expected: string[][], - actual: string[][] + expected: Buffer, + expectedStart: number, + expectedEnd: number, + actual: Buffer, + actualStart: number, + actualEnd: number, + cols: number ): GridDiff | null { - const length = Math.max(expected.length, actual.length) - for (let y = 0; y < length; y++) { - const e = expected[y] - const a = actual[y] - if (!e || !a || !rowsMatch(e, a)) { - return { stage, row: y, expected: e?.join('|'), actual: a?.join('|') } + const expectedScratch = expected.getNullCell() + const actualScratch = actual.getNullCell() + const expectedLength = + trimmedEnd(expected, expectedStart, expectedEnd, cols, expectedScratch) - expectedStart + const actualLength = trimmedEnd(actual, actualStart, actualEnd, cols, actualScratch) - actualStart + for (let y = 0; y < Math.max(expectedLength, actualLength); y++) { + const expectedLine = expected.getLine(expectedStart + y) + const actualLine = actual.getLine(actualStart + y) + if ( + y >= expectedLength || + y >= actualLength || + !lineCellsMatch(expectedLine, actualLine, cols, expectedScratch, actualScratch) + ) { + return { + stage, + row: y, + expected: + y < expectedLength ? rowCells(expectedLine, cols, expectedScratch).join('|') : undefined, + actual: y < actualLength ? rowCells(actualLine, cols, actualScratch).join('|') : undefined + } } } return null diff --git a/src/main/daemon/serialize-grid-roundtrip.ts b/src/main/daemon/serialize-grid-roundtrip.ts index d22bee6e511..26176c9ff5a 100644 --- a/src/main/daemon/serialize-grid-roundtrip.ts +++ b/src/main/daemon/serialize-grid-roundtrip.ts @@ -18,12 +18,7 @@ import { variantTrailingBackgroundRows } from './serialize-grid-variant-scope' import type { GridDiff } from './serialize-grid-cell-descriptors' -import { - bufferRows, - cellDescriptor, - CLIPPED, - compareRowSets -} from './serialize-grid-cell-descriptors' +import { cellDescriptor, CLIPPED, compareBufferRows } from './serialize-grid-cell-descriptors' export type NamedSerializer = { name: string; create: () => SerializeAddon } @@ -156,15 +151,25 @@ async function compareReplay( (src.type === dst.type ? null : { stage: 'active-buffer', expected: src.type, actual: dst.type }) ?? - compareRowSets( + compareBufferRows( 'visible-grid', - bufferRows(src, src.baseY, src.baseY + rows, cols), - bufferRows(dst, dst.baseY, dst.baseY + rows, cols) + src, + src.baseY, + src.baseY + rows, + dst, + dst.baseY, + dst.baseY + rows, + cols ) ?? - compareRowSets( + compareBufferRows( 'normal-buffer', - bufferRows(source.buffer.normal, normalStart, source.buffer.normal.length, cols), - bufferRows(replay.buffer.normal, 0, replay.buffer.normal.length, cols) + source.buffer.normal, + normalStart, + source.buffer.normal.length, + replay.buffer.normal, + 0, + replay.buffer.normal.length, + cols ) ?? (src.cursorX === dst.cursorX && src.cursorY === dst.cursorY ? null diff --git a/src/main/runtime/rpc/terminal-output-frame-chunks-equivalence.test.ts b/src/main/runtime/rpc/terminal-output-frame-chunks-equivalence.test.ts index 85e01116c3c..e143535d659 100644 --- a/src/main/runtime/rpc/terminal-output-frame-chunks-equivalence.test.ts +++ b/src/main/runtime/rpc/terminal-output-frame-chunks-equivalence.test.ts @@ -14,9 +14,7 @@ import { type TerminalOutputMeta } from './terminal-output-frame-chunks' -// Byte-for-byte reference: the pre-optimization implementation, copied verbatim. -// It accumulates `chunk += part` over `for (const part of data)` and measures each -// code point through the shared clipboard measurer. +// The legacy splitter, caching only its pure byte counts for repeated code points. function legacyByteLength(data: string): number { return measureClipboardTextByteLength(data).byteLength } @@ -64,6 +62,7 @@ function* legacyIterateTerminalOutputFrameChunks( let chunkStartOffset = 0 let offset = 0 let delayedChunk: { text: string; seq?: number } | null = null + const partByteLengths = new Map() const takeChunk = (): { text: string; seq?: number } | null => { if (!chunk) { @@ -78,7 +77,11 @@ function* legacyIterateTerminalOutputFrameChunks( } for (const part of data) { - const partBytes = legacyByteLength(part) + let partBytes = partByteLengths.get(part) + if (partBytes === undefined) { + partBytes = legacyByteLength(part) + partByteLengths.set(part, partBytes) + } if (chunkBytes > 0 && chunkBytes + partBytes > TERMINAL_STREAM_CHUNK_BYTES) { const nextChunk = takeChunk() if (nextChunk) { @@ -522,34 +525,4 @@ describe('iterateTerminalOutputFrameChunks equivalence with the pre-optimization expect([...iterateTerminalOutputFrameChunks(overByOne)].length).toBeGreaterThan(1) expect([...legacyIterateTerminalOutputFrameChunks(overByOne)].length).toBeGreaterThan(1) }) - - // The chunking loop is only reached when rawLength === data.length and transformed - // is falsy, which makes canPreserveChunkSeq === (typeof meta.seq === 'number') and - // therefore shouldDelayFinalSeq unconditionally false. The branch survives here - // (it also survived in the pre-optimization code) purely as defence in depth. - it('never reaches the delayed-final-seq branch for any meta shape', () => { - for (const data of ['', 'a', 'abc', 'x'.repeat(200)]) { - for (const seq of [undefined, 0, 5] as (number | undefined)[]) { - for (const rawDelta of [undefined, 0, 1, -1] as (number | undefined)[]) { - for (const transformed of [undefined, false, true] as (boolean | undefined)[]) { - const meta: TerminalOutputMeta = {} - if (seq !== undefined) { - meta.seq = seq - } - if (rawDelta !== undefined) { - meta.rawLength = data.length + rawDelta - } - if (transformed !== undefined) { - meta.transformed = transformed - } - const rawLength = meta.rawLength ?? data.length - const reachesChunkLoop = !meta.transformed && rawLength === data.length - const canPreserveChunkSeq = typeof meta.seq === 'number' && rawLength === data.length - const shouldDelayFinalSeq = !canPreserveChunkSeq && typeof meta.seq === 'number' - expect(reachesChunkLoop && shouldDelayFinalSeq, JSON.stringify(meta)).toBe(false) - } - } - } - } - }) }) diff --git a/src/shared/terminal-restore-parity-fixture.ts b/src/shared/terminal-restore-parity-fixture.ts index 10fb0cd6e58..4fab319ab5b 100644 --- a/src/shared/terminal-restore-parity-fixture.ts +++ b/src/shared/terminal-restore-parity-fixture.ts @@ -47,10 +47,17 @@ export function writeToTerminal(terminal: Terminal, data: string): Promise return new Promise((resolve) => terminal.write(data, resolve)) } -export async function writeChunksToTerminal(terminal: Terminal, chunks: string[]): Promise { - for (const chunk of chunks) { - await writeToTerminal(terminal, chunk) - } +export function writeChunksToTerminal(terminal: Terminal, chunks: string[]): Promise { + return new Promise((resolve) => { + if (chunks.length === 0) { + resolve() + return + } + // The final FIFO callback also fences async parser handlers in earlier chunks. + for (let index = 0; index < chunks.length; index++) { + terminal.write(chunks[index]!, index === chunks.length - 1 ? resolve : undefined) + } + }) } /** Bottom-anchored visible screen rows (baseY, not viewportY — scroll intent diff --git a/src/shared/terminal-restore-parity-writes.test.ts b/src/shared/terminal-restore-parity-writes.test.ts new file mode 100644 index 00000000000..342e7effd54 --- /dev/null +++ b/src/shared/terminal-restore-parity-writes.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it, vi } from 'vitest' +import { + createRendererParityTerminal, + cursorPosition, + normalBufferRowsTrimmed, + normalBufferStylesTrimmed, + visibleRowStyles, + visibleRowWraps, + visibleRows, + writeChunksToTerminal +} from './terminal-restore-parity-fixture' + +type AsyncOscParser = { + registerOscHandler( + identifier: number, + callback: (data: string) => boolean | Promise + ): { dispose(): void } +} + +describe('terminal parity fixture writes', () => { + it('queues every original chunk and resolves only after the last callback', async () => { + const { terminal } = createRendererParityTerminal({ cols: 8, rows: 2 }) + const writes: { data: string | Uint8Array; callback?: () => void }[] = [] + try { + vi.spyOn(terminal, 'write').mockImplementation((data, callback) => { + writes.push({ data, callback }) + }) + const chunks = ['A\x1b[', '', '31mB', '\x1b[0mC'] + let completed = false + const pending = writeChunksToTerminal(terminal, chunks).then(() => { + completed = true + }) + expect(writes.map(({ data }) => data)).toEqual(chunks) + expect(writes.slice(0, -1).every(({ callback }) => callback === undefined)).toBe(true) + await Promise.resolve() + expect(completed).toBe(false) + writes.at(-1)?.callback?.() + await pending + expect(completed).toBe(true) + } finally { + terminal.dispose() + vi.restoreAllMocks() + } + }) + + it('completes an empty batch without writing or scheduling work', async () => { + const { terminal } = createRendererParityTerminal({ cols: 8, rows: 2 }) + try { + const write = vi.spyOn(terminal, 'write') + await writeChunksToTerminal(terminal, []) + expect(write).not.toHaveBeenCalled() + } finally { + terminal.dispose() + vi.restoreAllMocks() + } + }) + + it('waits for an asynchronous parser barrier before exposing later chunks', async () => { + const { terminal } = createRendererParityTerminal({ cols: 8, rows: 2 }) + let signalEntered = (): void => {} + const entered = new Promise((resolve) => { + signalEntered = resolve + }) + let releaseParser = (): void => {} + const barrier = new Promise((resolve) => { + releaseParser = () => resolve(true) + }) + // xterm supports async handlers; its shipped public typings still say boolean. + const parser: AsyncOscParser = terminal.parser + const registration = parser.registerOscHandler(777, () => { + signalEntered() + return barrier + }) + try { + let completed = false + const pending = writeChunksToTerminal(terminal, [ + 'A\x1b]777;pause', + '\x07B', + '\x1b[31mC', + 'D' + ]).then(() => { + completed = true + }) + await entered + expect(visibleRows(terminal)[0]).toBe('A') + expect(completed).toBe(false) + releaseParser() + await pending + expect(visibleRows(terminal)[0]).toBe('ABCD') + expect(terminal.buffer.active.getLine(0)?.getCell(3)?.getFgColor()).toBe(1) + } finally { + releaseParser() + registration.dispose() + terminal.dispose() + } + }) + + it.each([ + ['A\x1b[', '38;2;11;22;33m界', 'é👩‍💻', '\x1b[0m\r\nnext'], + ['history\r\n'.repeat(8), '\x1b[?1049h\x1b[2J\x1b[H', '\x1b[48;5;2m ALT ', '\x1b[0m'], + ['\x1b[?1049hALT', '\x1b[?1049l', '\x1b[7;4;9;53m ', '\x1b[0mnormal'], + ['\x1b]8;;https://example.com\x07', 'link', '\x1b]8;;', '\x07 tail'], + ['0123456789ABCDEFGHIJKLMN', '\x1b[2A\x1b[2K', '\x1b[3;5H', 'end\x1b[?2004h'] + ])( + 'matches serial callbacks for split controls, styles and buffer transitions: %j', + async (...chunks) => { + const batched = createRendererParityTerminal({ cols: 12, rows: 4 }) + const serial = createRendererParityTerminal({ cols: 12, rows: 4 }) + try { + for (const chunk of chunks) { + await new Promise((resolve) => serial.terminal.write(chunk, resolve)) + } + await writeChunksToTerminal(batched.terminal, chunks) + expect(visibleRows(batched.terminal)).toEqual(visibleRows(serial.terminal)) + expect(visibleRowStyles(batched.terminal)).toEqual(visibleRowStyles(serial.terminal)) + expect(visibleRowWraps(batched.terminal)).toEqual(visibleRowWraps(serial.terminal)) + expect(normalBufferRowsTrimmed(batched.terminal)).toEqual( + normalBufferRowsTrimmed(serial.terminal) + ) + expect(normalBufferStylesTrimmed(batched.terminal)).toEqual( + normalBufferStylesTrimmed(serial.terminal) + ) + expect(cursorPosition(batched.terminal)).toEqual(cursorPosition(serial.terminal)) + expect(batched.terminal.modes).toEqual(serial.terminal.modes) + expect(batched.serializeAddon.serialize()).toBe(serial.serializeAddon.serialize()) + } finally { + batched.terminal.dispose() + serial.terminal.dispose() + } + } + ) +}) From 58bd15fa3ffa65942d94aee8137e35c27f4368c8 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 4 Oct 2026 01:27:30 -0700 Subject: [PATCH 47/67] Fix ripgrep result completeness, filename handling, and search errors (#25156) * Preserve ripgrep search results, filename identity, and failure diagnostics * Fix adversarial Unicode and Explorer filename findings * Register search failure localization fallback * Preserve host filename identity through document and watcher consumers --- .../patches/@streamparser__json@0.0.26.patch | 66 +++++++++ pnpm-lock.yaml | 5 +- pnpm-workspace.yaml | 1 + .../filesystem-list-files-name-filter.test.ts | 8 +- src/main/ipc/filesystem-list-files.test.ts | 71 ++++++--- src/main/ipc/filesystem-list-files.ts | 67 ++++----- .../ipc/filesystem-search-file-paths.test.ts | 76 ++++++++-- src/main/ipc/filesystem-search-file-paths.ts | 38 ++--- .../ipc/filesystem-search-rg-timeout.test.ts | 32 ++++- .../filesystem/filesystem-search-handlers.ts | 50 ++++--- .../markdown-documents-remote-paths.test.ts | 47 ++++++ .../ipc/markdown-documents-ripgrep.test.ts | 18 +++ src/main/ipc/markdown-documents.test.ts | 36 ++++- src/main/ipc/markdown-documents.ts | 71 +++++---- .../ripgrep-filename-identity-real.test.ts | 62 ++++++++ .../ripgrep/text-search-unicode-real.test.ts | 123 ++++++++++++++++ .../runtime/ripgrep-filename-identity.test.ts | 82 +++++++++++ ...ile-commands-search-local-runtime-files.ts | 41 ++++-- src/main/runtime/runtime-relative-paths.ts | 12 +- .../fs-handler-list-files-cancel.test.ts | 6 +- .../fs-handler-list-files-ignored.test.ts | 91 ++++++------ ...fs-handler-list-files-path-framing.test.ts | 57 ++++++++ src/relay/fs-handler-list-files.ts | 29 ++-- src/relay/fs-handler-utils.ts | 42 +++--- src/relay/fs-search-errors-real.test.ts | 39 +++++ src/relay/relay-bundled-ripgrep.test.ts | 2 +- src/relay/relay-bundled-ripgrep.ts | 34 +++-- src/relay/relay-ripgrep-cwd-env.test.ts | 2 +- .../relay-windows-path-ripgrep-cache.test.ts | 79 ++++++++++ src/relay/relay-windows-path-ripgrep.test.ts | 9 +- .../editor-external-watch-path-index.test.ts | 30 ++++ .../right-sidebar/SearchResultsPane.tsx | 16 ++- ...plorer-directory-filename-identity.test.ts | 54 +++++++ .../file-explorer-directory-listing.ts | 6 +- .../right-sidebar/file-explorer-entries.ts | 5 +- ...le-explorer-name-filter-projection.test.ts | 73 ++++++++++ .../file-explorer-name-filter-projection.ts | 17 +-- .../right-sidebar/file-explorer-paths.test.ts | 25 ++-- .../right-sidebar/file-explorer-paths.ts | 19 +-- ...e-explorer-watch-filename-identity.test.ts | 45 ++++++ .../right-sidebar/file-explorer-watch-path.ts | 11 +- .../file-explorer-watcher-reconcile.ts | 8 +- .../src/components/right-sidebar/path-tree.ts | 6 +- .../right-sidebar/status-display.ts | 9 +- .../useFileExplorerVisibleRowProjection.ts | 15 +- .../right-sidebar/useFileSearchPanel.ts | 4 +- .../useFileSearchRunner.test.tsx | 49 +++++++ .../right-sidebar/useFileSearchRunner.ts | 9 +- src/renderer/src/i18n/locales/en.json | 3 + src/renderer/src/lib/path.test.ts | 8 ++ src/renderer/src/lib/path.ts | 21 ++- .../editor/actions/file-search-actions.ts | 3 + .../slices/editor/search/file-search-state.ts | 1 + .../types/file-search-worktree-state.ts | 1 + src/shared/quick-open-filter.test.ts | 16 ++- src/shared/quick-open-filter.ts | 25 ++-- src/shared/quick-open-ripgrep-output-mode.ts | 14 ++ src/shared/ripgrep-dense-match-json.test.ts | 78 ++++++++++ src/shared/ripgrep-dense-match-json.ts | 135 ++++++++++++++++++ src/shared/ripgrep-filename-decoder.test.ts | 44 ++++++ src/shared/ripgrep-filename-decoder.ts | 51 +++++++ src/shared/ripgrep-line-decoding.test.ts | 61 ++++++++ src/shared/ripgrep-line-decoding.ts | 60 ++++++++ src/shared/ripgrep-match-offsets.test.ts | 36 +++++ src/shared/ripgrep-match-offsets.ts | 37 +++++ src/shared/ripgrep-search-diagnostics.test.ts | 42 ++++++ src/shared/ripgrep-search-diagnostics.ts | 34 +++++ src/shared/text-search-dense-matches.test.ts | 126 ++++++++++++++++ .../text-search-invalid-filename.test.ts | 19 +++ src/shared/text-search-paths.test.ts | 36 +++++ src/shared/text-search-paths.ts | 14 +- src/shared/text-search.ts | 59 ++++---- 72 files changed, 2260 insertions(+), 361 deletions(-) create mode 100644 config/patches/@streamparser__json@0.0.26.patch create mode 100644 src/main/ipc/markdown-documents-remote-paths.test.ts create mode 100644 src/main/ripgrep/ripgrep-filename-identity-real.test.ts create mode 100644 src/main/ripgrep/text-search-unicode-real.test.ts create mode 100644 src/main/runtime/ripgrep-filename-identity.test.ts create mode 100644 src/relay/fs-handler-list-files-path-framing.test.ts create mode 100644 src/relay/fs-search-errors-real.test.ts create mode 100644 src/relay/relay-windows-path-ripgrep-cache.test.ts create mode 100644 src/renderer/src/components/right-sidebar/file-explorer-directory-filename-identity.test.ts create mode 100644 src/renderer/src/components/right-sidebar/file-explorer-watch-filename-identity.test.ts create mode 100644 src/shared/quick-open-ripgrep-output-mode.ts create mode 100644 src/shared/ripgrep-dense-match-json.test.ts create mode 100644 src/shared/ripgrep-dense-match-json.ts create mode 100644 src/shared/ripgrep-filename-decoder.test.ts create mode 100644 src/shared/ripgrep-filename-decoder.ts create mode 100644 src/shared/ripgrep-line-decoding.test.ts create mode 100644 src/shared/ripgrep-line-decoding.ts create mode 100644 src/shared/ripgrep-match-offsets.test.ts create mode 100644 src/shared/ripgrep-match-offsets.ts create mode 100644 src/shared/ripgrep-search-diagnostics.test.ts create mode 100644 src/shared/ripgrep-search-diagnostics.ts create mode 100644 src/shared/text-search-dense-matches.test.ts create mode 100644 src/shared/text-search-invalid-filename.test.ts create mode 100644 src/shared/text-search-paths.test.ts diff --git a/config/patches/@streamparser__json@0.0.26.patch b/config/patches/@streamparser__json@0.0.26.patch new file mode 100644 index 00000000000..c93998f85fe --- /dev/null +++ b/config/patches/@streamparser__json@0.0.26.patch @@ -0,0 +1,66 @@ +diff --git a/dist/cjs/utils/bufferedString.js b/dist/cjs/utils/bufferedString.js +index 82f710a018f9771fe10335e2dcacd75d707d9062..f3cfa64cefa967d7a83c328e1abb9246135b067b 100644 +--- a/dist/cjs/utils/bufferedString.js ++++ b/dist/cjs/utils/bufferedString.js +@@ -16,7 +16,7 @@ class NonBufferedString { + constructor() { + // fatal: true makes invalid byte sequences (e.g. a lead byte followed by a + // non-continuation byte) throw instead of silently decoding to U+FFFD. +- this.decoder = new TextDecoder("utf-8", { fatal: true }); ++ this.decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }); + // Pieces appended since the last toString(), not yet folded into `string`. + this.pending = []; + this.string = ""; +@@ -66,7 +66,7 @@ class BufferedString { + constructor(bufferSize) { + // fatal: true makes invalid byte sequences (e.g. a lead byte followed by a + // non-continuation byte) throw instead of silently decoding to U+FFFD. +- this.decoder = new TextDecoder("utf-8", { fatal: true }); ++ this.decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }); + this.bufferOffset = 0; + this.string = ""; + this.byteLength = 0; +diff --git a/dist/mjs/utils/bufferedString.js b/dist/mjs/utils/bufferedString.js +index 0fb208d8615f5e20a086f75a37bef928b155c47c..0d8ca407d594d61798eca7f7253e1dfc1770d291 100644 +--- a/dist/mjs/utils/bufferedString.js ++++ b/dist/mjs/utils/bufferedString.js +@@ -13,7 +13,7 @@ export class NonBufferedString { + constructor() { + // fatal: true makes invalid byte sequences (e.g. a lead byte followed by a + // non-continuation byte) throw instead of silently decoding to U+FFFD. +- this.decoder = new TextDecoder("utf-8", { fatal: true }); ++ this.decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }); + // Pieces appended since the last toString(), not yet folded into `string`. + this.pending = []; + this.string = ""; +@@ -62,7 +62,7 @@ export class BufferedString { + constructor(bufferSize) { + // fatal: true makes invalid byte sequences (e.g. a lead byte followed by a + // non-continuation byte) throw instead of silently decoding to U+FFFD. +- this.decoder = new TextDecoder("utf-8", { fatal: true }); ++ this.decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }); + this.bufferOffset = 0; + this.string = ""; + this.byteLength = 0; +diff --git a/src/utils/bufferedString.ts b/src/utils/bufferedString.ts +index 482c7402899bb157249cfb7882d327b7d9477912..4e45ef5d5bd7ec66776ece54e917282441cededb 100644 +--- a/src/utils/bufferedString.ts ++++ b/src/utils/bufferedString.ts +@@ -40,7 +40,7 @@ export interface StringBuilder { + export class NonBufferedString implements StringBuilder { + // fatal: true makes invalid byte sequences (e.g. a lead byte followed by a + // non-continuation byte) throw instead of silently decoding to U+FFFD. +- private decoder = new TextDecoder("utf-8", { fatal: true }); ++ private decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }); + // Pieces appended since the last toString(), not yet folded into `string`. + private pending: string[] = []; + private string = ""; +@@ -90,7 +90,7 @@ export class NonBufferedString implements StringBuilder { + export class BufferedString implements StringBuilder { + // fatal: true makes invalid byte sequences (e.g. a lead byte followed by a + // non-continuation byte) throw instead of silently decoding to U+FFFD. +- private decoder = new TextDecoder("utf-8", { fatal: true }); ++ private decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }); + private buffer: Uint8Array; + private bufferOffset = 0; + private string = ""; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9fb1428002d..2c58ae7a17a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -166,6 +166,7 @@ overrides: monaco-editor>dompurify: 3.4.16 patchedDependencies: + '@streamparser/json@0.0.26': b2cf43861e5b4e485e97ffa7449ea65acab4d65dd983ab8c0508f1c68f7d80c9 '@vscode/windows-process-tree@0.8.0': 9da74aa3d17243aa53dcdc95c9f06e97437e7fbccf098aeb017579e2d24cbac2 '@xterm/addon-image@0.10.0-beta.300': e5254a46d6f57bef4a8a19683bfa685afa0ca0127545aea53b54a48104ca3562 '@xterm/addon-ligatures@0.11.0-beta.300': 47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920 @@ -198,7 +199,7 @@ importers: version: 2.5.6 '@streamparser/json': specifier: 0.0.26 - version: 0.0.26 + version: 0.0.26(patch_hash=b2cf43861e5b4e485e97ffa7449ea65acab4d65dd983ab8c0508f1c68f7d80c9) '@xterm/addon-serialize': specifier: 0.15.0-beta.300 version: 0.15.0-beta.300(patch_hash=b35533fe252e7e45433150170348889f4e08a6c17f7017ac34ea694d831fec7f)(@xterm/xterm@6.1.0-beta.303(patch_hash=dd0ccc59cd1ccf99f4d76e5aa2456da165fa0804dce19a833d7638bd07ffa393)) @@ -10208,7 +10209,7 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@streamparser/json@0.0.26': {} + '@streamparser/json@0.0.26(patch_hash=b2cf43861e5b4e485e97ffa7449ea65acab4d65dd983ab8c0508f1c68f7d80c9)': {} '@swc/core-darwin-arm64@1.15.46': optional: true diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e3c133b29ee..f00e23d7f2f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -63,3 +63,4 @@ patchedDependencies: lint-staged@16.4.0: config/patches/lint-staged@16.4.0.patch '@vscode/windows-process-tree@0.8.0': config/patches/@vscode__windows-process-tree@0.8.0.patch i18next-cli@1.74.2: config/patches/i18next-cli@1.74.2.patch + '@streamparser/json@0.0.26': config/patches/@streamparser__json@0.0.26.patch diff --git a/src/main/ipc/filesystem-list-files-name-filter.test.ts b/src/main/ipc/filesystem-list-files-name-filter.test.ts index b529dd34b39..e22043a4d69 100644 --- a/src/main/ipc/filesystem-list-files-name-filter.test.ts +++ b/src/main/ipc/filesystem-list-files-name-filter.test.ts @@ -62,7 +62,7 @@ describe('listQuickOpenFiles name filter', () => { it('counts only matches against the ripgrep cap', async () => { wslAwareSpawnMock - .mockImplementationOnce(() => fakeRipgrep('a.ts\nb.ts\nc.ts\nios/AppDelegate.swift\n')) + .mockImplementationOnce(() => fakeRipgrep('a.ts\0b.ts\0c.ts\0ios/AppDelegate.swift\0')) .mockImplementationOnce(() => fakeRipgrep('')) const files = await listQuickOpenFiles( @@ -80,7 +80,7 @@ describe('listQuickOpenFiles name filter', () => { it('rejects with the bundled-ripgrep error when the filtered ignored pass cannot start', async () => { wslAwareSpawnMock - .mockImplementationOnce(() => fakeRipgrep('ios/AppDelegate.swift\n')) + .mockImplementationOnce(() => fakeRipgrep('ios/AppDelegate.swift\0')) .mockImplementationOnce(() => fakeRipgrep('', null, -2)) await expect( @@ -98,7 +98,7 @@ describe('listQuickOpenFiles name filter', () => { it('keeps primary matches when the ignored-file pass fails during a filtered scan', async () => { wslAwareSpawnMock - .mockImplementationOnce(() => fakeRipgrep('ios/AppDelegate.swift\n')) + .mockImplementationOnce(() => fakeRipgrep('ios/AppDelegate.swift\0')) .mockImplementationOnce(() => fakeRipgrep('', 'SIGKILL')) await expect( @@ -116,7 +116,7 @@ describe('listQuickOpenFiles name filter', () => { it('still rejects an ignored-pass failure for unfiltered listings', async () => { wslAwareSpawnMock - .mockImplementationOnce(() => fakeRipgrep('a.ts\n')) + .mockImplementationOnce(() => fakeRipgrep('a.ts\0')) .mockImplementationOnce(() => fakeRipgrep('', 'SIGKILL')) await expect( diff --git a/src/main/ipc/filesystem-list-files.test.ts b/src/main/ipc/filesystem-list-files.test.ts index 84567c85419..b6fe3453c22 100644 --- a/src/main/ipc/filesystem-list-files.test.ts +++ b/src/main/ipc/filesystem-list-files.test.ts @@ -88,6 +88,33 @@ describe('filesystem-list-files', () => { ) }) + it.each(['invalid', 'incomplete'] as const)('rejects %s UTF-8 filename bytes', async (kind) => { + const child = createMockProcess() + spawnMock.mockReturnValue(child) + const store: Store = Object.create(null) + const promise = listQuickOpenFiles('/repo', store) + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(1)) + child.stdout?.emit('data', Buffer.from(kind === 'invalid' ? [0xff] : [0xe2, 0x82])) + if (kind === 'incomplete') { + child.emit('close', 0, null) + } + await expect(promise).rejects.toThrow('not valid UTF-8') + if (kind === 'invalid') { + expect(child.kill).toHaveBeenCalled() + } + }) + + it('counts NUL-delimited filenames containing newlines as one result each', async () => { + const child = createMockProcess() + spawnMock.mockReturnValue(child) + const store: Store = Object.create(null) + const promise = listQuickOpenFiles('/repo', store, undefined, undefined, 2) + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(1)) + child.stdout?.emit('data', 'first\nsecond.ts\0trailing\r\0third.ts\0') + await expect(promise).resolves.toEqual(['first\nsecond.ts', 'trailing\r']) + expect(child.kill).toHaveBeenCalled() + }) + it('rejects a synchronous launch failure before cleanup has been initialized', async () => { spawnMock.mockImplementationOnce(() => { throw Object.assign(new Error('spawn EMFILE'), { code: 'EMFILE' }) @@ -122,7 +149,7 @@ describe('filesystem-list-files', () => { ) setTimeout(() => { - ;(p1.stdout as unknown as EventEmitter).emit('data', 'one.ts\ntwo.ts') + p1.stdout?.emit('data', 'one.ts\0two.ts') p1.emit('close', 0, null) }, 0) const result = await promise @@ -154,12 +181,12 @@ describe('filesystem-list-files', () => { await flushMicrotasks() expect(spawnMock).toHaveBeenCalledTimes(1) expect(spawnMock.mock.calls[0]?.[1]).not.toContain('--no-ignore-vcs') - source.stdout?.emit('data', 'source.ts\n') + source.stdout?.emit('data', 'source.ts\0') source.emit('close', 0, null) await flushMicrotasks() expect(spawnMock).toHaveBeenCalledTimes(2) expect(spawnMock.mock.calls[1]?.[1]).toContain('--no-ignore-vcs') - broad.stdout?.emit('data', 'ignored-file.ts\n') + broad.stdout?.emit('data', 'ignored-file.ts\0') await expect(listing).resolves.toEqual(['source.ts']) expect(broad.kill).toHaveBeenCalledOnce() }) @@ -177,18 +204,18 @@ describe('filesystem-list-files', () => { // Simulate stdout output for normal files setTimeout(() => { - p1.stdout?.emit('data', 'file1.ts\n') - p1.stdout?.emit('data', 'node_modules/bad.js\n') - p1.stdout?.emit('data', '.git/config\n') - p1.stdout?.emit('data', '.github/workflows/ci.yml\n') + p1.stdout?.emit('data', 'file1.ts\0') + p1.stdout?.emit('data', 'node_modules/bad.js\0') + p1.stdout?.emit('data', '.git/config\0') + p1.stdout?.emit('data', '.github/workflows/ci.yml\0') p1.stdout?.emit('data', 'dir1/') // incomplete line - p1.stdout?.emit('data', 'file2.js\n') + p1.stdout?.emit('data', 'file2.js\0') // The broad pass includes ignored files too. - p1.stdout?.emit('data', '.env.local\n') - p1.stdout?.emit('data', 'dist/generated.js\n') - p1.stdout?.emit('data', 'file1.ts\n') // Duplicate - p1.stdout?.emit('data', 'node_modules/ignored.js\n') + p1.stdout?.emit('data', '.env.local\0') + p1.stdout?.emit('data', 'dist/generated.js\0') + p1.stdout?.emit('data', 'file1.ts\0') // Duplicate + p1.stdout?.emit('data', 'node_modules/ignored.js\0') p1.emit('close', 0, null) }, 10) @@ -213,7 +240,7 @@ describe('filesystem-list-files', () => { const promise = listQuickOpenFiles('C:\\repo', storeMock) setTimeout(() => { - ;(p1.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\n') + p1.stdout?.emit('data', 'src/index.ts\0') p1.emit('close', 0, null) }, 10) @@ -237,7 +264,7 @@ describe('filesystem-list-files', () => { const promise = listQuickOpenFiles('C:\\repo', storeMock) setTimeout(() => { - ;(p1.stdout as unknown as EventEmitter).emit('data', '/mnt/c/repo/src/index.ts\n') + p1.stdout?.emit('data', '/mnt/c/repo/src/index.ts\0') p1.emit('close', 0, null) }, 10) @@ -310,7 +337,7 @@ describe('filesystem-list-files', () => { const promise = listQuickOpenFiles('/mock/root', storeMock) setTimeout(() => { - ;(p1.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\n') + p1.stdout?.emit('data', 'src/index.ts\0') p1.emit('close', 2, null) }, 10) @@ -332,7 +359,7 @@ describe('filesystem-list-files', () => { await Promise.resolve() await Promise.resolve() - ;(p1.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\npartial') + p1.stdout?.emit('data', 'src/index.ts\0partial') const rejection = expect(promise).rejects.toThrow('rg list timed out') await vi.advanceTimersByTimeAsync(10000) @@ -376,12 +403,12 @@ describe('filesystem-list-files', () => { const promise = listQuickOpenFiles('/mock/root', storeMock) setTimeout(() => { - ;(p1.stdout as unknown as EventEmitter).emit('data', '.next/cache/1.js\n') - ;(p1.stdout as unknown as EventEmitter).emit('data', '.cache/data.json\n') - ;(p1.stdout as unknown as EventEmitter).emit('data', '.stably/config.json\n') - ;(p1.stdout as unknown as EventEmitter).emit('data', '.vscode/settings.json\n') - ;(p1.stdout as unknown as EventEmitter).emit('data', '.idea/workspace.xml\n') - ;(p1.stdout as unknown as EventEmitter).emit('data', 'valid.ts\n') + p1.stdout?.emit('data', '.next/cache/1.js\0') + p1.stdout?.emit('data', '.cache/data.json\0') + p1.stdout?.emit('data', '.stably/config.json\0') + p1.stdout?.emit('data', '.vscode/settings.json\0') + p1.stdout?.emit('data', '.idea/workspace.xml\0') + p1.stdout?.emit('data', 'valid.ts\0') p1.emit('close', 0, null) }, 10) diff --git a/src/main/ipc/filesystem-list-files.ts b/src/main/ipc/filesystem-list-files.ts index 4e3ea49dbe8..9d9042584d9 100644 --- a/src/main/ipc/filesystem-list-files.ts +++ b/src/main/ipc/filesystem-list-files.ts @@ -1,3 +1,5 @@ +import { getQuickOpenRgOutputMode } from '../../shared/quick-open-ripgrep-output-mode' +import { RipgrepFilenameDecoder, RipgrepFilenameError } from '../../shared/ripgrep-filename-decoder' import { sep } from 'node:path' import type { ChildProcess } from 'node:child_process' import type { Store } from '../persistence' @@ -8,7 +10,6 @@ import { buildExcludePathPrefixes, buildRgArgsForQuickOpen, normalizeQuickOpenRgLine, - type RgOutputMode, shouldExcludeQuickOpenRelPath, shouldIncludeQuickOpenPath } from '../../shared/quick-open-filter' @@ -79,6 +80,10 @@ export async function listQuickOpenFiles( const runRg = (args: string[]): Promise => { return new Promise((resolve, reject) => { + const filenameDecoder = new RipgrepFilenameDecoder((error) => { + killSpawnedRipgrepProcess(child) + finish(error) + }, Boolean(wslDistroForOutput)) let buf = '' let done = false let parseablePathCount = 0 @@ -138,18 +143,22 @@ export async function listQuickOpenFiles( return } let timer: ReturnType - const handleStdoutData = (chunk: string): void => { - buf += chunk + const handleStdoutData = (chunk: Buffer | string): void => { + const decoded = filenameDecoder.decode(chunk) + if (decoded === null) { + return + } + buf += decoded let start = 0 - let newlineIdx = buf.indexOf('\n', start) - while (newlineIdx !== -1) { - if (processLine(buf.substring(start, newlineIdx))) { + let delimiterIdx = buf.indexOf('\0', start) + while (delimiterIdx !== -1) { + if (processLine(buf.substring(start, delimiterIdx))) { buf = '' finishAtLimit() return } - start = newlineIdx + 1 - newlineIdx = buf.indexOf('\n', start) + start = delimiterIdx + 1 + delimiterIdx = buf.indexOf('\0', start) } buf = start < buf.length ? buf.substring(start) : '' } @@ -211,14 +220,15 @@ export async function listQuickOpenFiles( finish(new Error(`rg killed by ${signal}`)) return } + if (!filenameDecoder.finish()) { + return + } if (buf && processLine(buf)) { buf = '' finishAtLimit() return } - if (code === 0 || code === 1) { - finish() - } else if (code === 2 && parseablePathCount > 0) { + if (code === 0 || code === 1 || (code === 2 && parseablePathCount > 0)) { // rg can return 2 for unreadable subdirectories while still listing // usable files from the rest of the root. finish() @@ -257,7 +267,6 @@ export async function listQuickOpenFiles( children.push({ child, isDone: () => done, finish }) - child.stdout?.setEncoding('utf-8') child.stdout?.on('data', handleStdoutData) child.stderr?.on('data', handleStderrData) child.once('error', handleError) @@ -290,16 +299,9 @@ export async function listQuickOpenFiles( } function finishAtLimit(): void { - for (const entry of children) { - if (entry.isDone()) { - continue - } - entry.finish() - if (entry.child.exitCode === null && entry.child.signalCode === null) { - killSpawnedRipgrepProcess(entry.child) - } - } + killSurvivors() } + try { if (maxResults === undefined && maxSerializedBytes === undefined) { // The broader pass already includes source files; an unbounded listing needs only one scan. @@ -314,7 +316,12 @@ export async function listQuickOpenFiles( ) { // Why: a filtered scan walks the whole tree; an ignored-pass timeout keeps primary matches. await runRg(ignoredPass).catch((err: unknown) => { - if (!pathFilter || signal?.aborted || err instanceof RipgrepUnavailableError) { + if ( + !pathFilter || + signal?.aborted || + err instanceof RipgrepUnavailableError || + err instanceof RipgrepFilenameError + ) { throw err } }) @@ -329,19 +336,3 @@ export async function listQuickOpenFiles( ? result : limitQuickOpenFilesBySerializedBytes(result, maxSerializedBytes) } - -function getQuickOpenRgOutputMode( - rawLine: string, - translatedLine: string, - rootPath: string -): RgOutputMode { - if ( - translatedLine !== rawLine || - rawLine.startsWith('/') || - /^[A-Za-z]:[\\/]/.test(rawLine) || - rawLine.startsWith('\\\\') - ) { - return { kind: 'absolute', rootPath } - } - return { kind: 'cwd-relative' } -} diff --git a/src/main/ipc/filesystem-search-file-paths.test.ts b/src/main/ipc/filesystem-search-file-paths.test.ts index e9415d66431..57c817c3b40 100644 --- a/src/main/ipc/filesystem-search-file-paths.test.ts +++ b/src/main/ipc/filesystem-search-file-paths.test.ts @@ -81,6 +81,64 @@ describe('searchQuickOpenFilePaths', () => { ) }) + it('preserves cancellation while an incomplete UTF-8 scalar is buffered', async () => { + const child = createMockProcess() + wslAwareSpawnMock.mockReturnValue(child) + const controller = new AbortController() + const promise = searchQuickOpenFilePaths('/repo', UNUSED_STORE, { + query: 'file', + limit: 2, + signal: controller.signal + }) + await flushMicrotasks() + child.stdout?.emit('data', Buffer.from([0xf0, 0x9f])) + controller.abort() + await expect(promise).rejects.toSatisfy(isFileListingCancellation) + }) + + it.each(['invalid', 'incomplete'] as const)('rejects %s UTF-8 filename bytes', async (kind) => { + const child = createMockProcess() + wslAwareSpawnMock.mockReturnValue(child) + const promise = searchQuickOpenFilePaths('/repo', UNUSED_STORE, { query: 'file', limit: 2 }) + await flushMicrotasks() + child.stdout?.emit('data', Buffer.from(kind === 'invalid' ? [0xff] : [0xe2, 0x82])) + if (kind === 'incomplete') { + child.emit('close', 0, null) + } + await expect(promise).rejects.toThrow('not valid UTF-8') + if (kind === 'invalid') { + expect(child.kill).toHaveBeenCalled() + } + }) + + it('preserves control characters within ranked paths', async () => { + const child = createMockProcess() + wslAwareSpawnMock.mockReturnValue(child) + const promise = searchQuickOpenFilePaths('/repo', UNUSED_STORE, { + query: 'target', + limit: 2 + }) + await flushMicrotasks() + child.stdout?.emit('data', 'first\ntarget.ts\0target.ts\r\0') + child.emit('close', 0, null) + expect((await promise).paths.sort()).toEqual(['first\ntarget.ts', 'target.ts\r'].sort()) + }) + + it('rejects and stops an oversized path rather than ranking a truncated suffix', async () => { + const child = createMockProcess() + wslAwareSpawnMock.mockReturnValue(child) + const promise = searchQuickOpenFilePaths('/repo', UNUSED_STORE, { + query: 'target', + limit: 2 + }) + await flushMicrotasks() + child.stdout?.emit('data', 'x'.repeat(64 * 1024 + 1)) + await expect(promise).rejects.toThrow('file path exceeds the listing limit') + expect(child.kill).toHaveBeenCalledTimes(1) + child.stdout?.emit('data', 'target.ts\0') + child.emit('close', 0, null) + }) + it('finds fuzzy matches after 100k paths without returning excluded worktrees', async () => { const child = createMockProcess() wslAwareSpawnMock.mockReturnValue(child) @@ -94,14 +152,11 @@ describe('searchQuickOpenFilePaths', () => { expect(wslAwareSpawnMock).toHaveBeenCalledTimes(1) expect(wslAwareSpawnMock.mock.calls[0][0]).toBe('/bundled/rg') expect(wslAwareSpawnMock.mock.calls[0][1]).toContain('--no-ignore-vcs') - ;(child.stdout as unknown as EventEmitter).emit( + child.stdout?.emit( 'data', - `${Array.from({ length: 100_100 }, (_, index) => `data/payload-${index}.bin`).join('\n')}\n` - ) - ;(child.stdout as unknown as EventEmitter).emit( - 'data', - 'nested/src/sta-4354-target.ts\nsrc/sta-4354-target.ts\n' + `${Array.from({ length: 100_100 }, (_, index) => `data/payload-${index}.bin`).join('\0')}\0` ) + child.stdout?.emit('data', 'nested/src/sta-4354-target.ts\0src/sta-4354-target.ts\0') child.emit('close', 0, null) await expect(promise).resolves.toEqual({ @@ -138,7 +193,7 @@ describe('searchQuickOpenFilePaths', () => { limit: 32 }) await flushMicrotasks() - ;(child.stdout as unknown as EventEmitter).emit('data', 'src/target.ts\n') + child.stdout?.emit('data', 'src/target.ts\0') child.emit('close', 0, null) await expect(promise).resolves.toMatchObject({ paths: ['src/target.ts'] }) @@ -164,10 +219,7 @@ describe('searchQuickOpenFilePaths', () => { await flushMicrotasks() expect(wslAwareSpawnMock).toHaveBeenCalledTimes(2) - ;(succeeded.stdout as unknown as EventEmitter).emit( - 'data', - 'data/chunk-077568/sta-4354-gitignored-target.bin\n' - ) + succeeded.stdout?.emit('data', 'data/chunk-077568/sta-4354-gitignored-target.bin\0') succeeded.emit('close', 0, null) await expect(promise).resolves.toEqual({ @@ -189,7 +241,7 @@ describe('searchQuickOpenFilePaths', () => { limit: 32 }) await flushMicrotasks() - ;(succeeded.stdout as unknown as EventEmitter).emit('data', 'src/target.ts\n') + succeeded.stdout?.emit('data', 'src/target.ts\0') succeeded.emit('close', 0, null) await expect(promise).resolves.toMatchObject({ paths: ['src/target.ts'] }) diff --git a/src/main/ipc/filesystem-search-file-paths.ts b/src/main/ipc/filesystem-search-file-paths.ts index 4a003aa17c5..3de6f4c6342 100644 --- a/src/main/ipc/filesystem-search-file-paths.ts +++ b/src/main/ipc/filesystem-search-file-paths.ts @@ -1,3 +1,5 @@ +import { getQuickOpenRgOutputMode } from '../../shared/quick-open-ripgrep-output-mode' +import { RipgrepFilenameDecoder } from '../../shared/ripgrep-filename-decoder' import { sep } from 'node:path' import type { Store } from '../persistence' import { fileListingCancellationError } from '../../shared/file-listing-cancellation' @@ -6,8 +8,7 @@ import { buildRgArgsForQuickOpen, normalizeQuickOpenRgLine, shouldExcludeQuickOpenRelPath, - shouldIncludeQuickOpenPath, - type RgOutputMode + shouldIncludeQuickOpenPath } from '../../shared/quick-open-filter' import { isQuickOpenQueryTooLarge, QuickOpenPathRanker } from '../../shared/quick-open-path-search' import { @@ -110,7 +111,11 @@ function scanRipgrepPaths(args: { return Promise.reject(fileListingCancellationError(args.signal)) } return new Promise((resolve, reject) => { - const pathAccumulator = new QuickOpenSubprocessPathAccumulator(0x0a) + const filenameDecoder = new RipgrepFilenameDecoder((error) => { + killSpawnedRipgrepProcess(child) + finish(error) + }, Boolean(args.wslDistroForOutput)) + const pathAccumulator = new QuickOpenSubprocessPathAccumulator(0) let done = false let parseablePathCount = 0 let processErrorObserved = false @@ -141,7 +146,7 @@ function scanRipgrepPaths(args: { : rawLine const relPath = normalizeQuickOpenRgLine( translated, - getOutputMode(rawLine, translated, args.authorizedRootPath) + getQuickOpenRgOutputMode(rawLine, translated, args.authorizedRootPath) ) if (relPath === null) { return @@ -178,11 +183,19 @@ function scanRipgrepPaths(args: { resolve() } } - const handleStdoutData = (chunk: string): void => { - pathAccumulator.push(chunk, (path) => { + const handleStdoutData = (chunk: Buffer | string): void => { + const decoded = filenameDecoder.decode(chunk) + if (decoded === null) { + return + } + const result = pathAccumulator.push(decoded, (path) => { processLine(path) return true }) + if (result === 'path-too-large') { + killSpawnedRipgrepProcess(child) + finish(new Error('Quick Open file path exceeds the listing limit')) + } } const handleStderrData = (): void => { /* drain */ @@ -233,6 +246,9 @@ function scanRipgrepPaths(args: { finish(new Error(`rg killed by ${signal}`)) return } + if (!filenameDecoder.finish()) { + return + } const trailingPath = pathAccumulator.finish() if (trailingPath) { processLine(trailingPath) @@ -249,7 +265,6 @@ function scanRipgrepPaths(args: { finish(fileListingCancellationError(args.signal)) } - child.stdout?.setEncoding('utf-8') child.stdout?.on('data', handleStdoutData) child.stderr?.on('data', handleStderrData) child.once('error', handleError) @@ -265,12 +280,3 @@ function scanRipgrepPaths(args: { } }) } - -function getOutputMode(rawLine: string, translatedLine: string, rootPath: string): RgOutputMode { - return translatedLine !== rawLine || - rawLine.startsWith('/') || - /^[A-Za-z]:[\\/]/.test(rawLine) || - rawLine.startsWith('\\\\') - ? { kind: 'absolute', rootPath } - : { kind: 'cwd-relative' } -} diff --git a/src/main/ipc/filesystem-search-rg-timeout.test.ts b/src/main/ipc/filesystem-search-rg-timeout.test.ts index 9c37bacb20a..d4096500381 100644 --- a/src/main/ipc/filesystem-search-rg-timeout.test.ts +++ b/src/main/ipc/filesystem-search-rg-timeout.test.ts @@ -199,7 +199,7 @@ describe('filesystem rg search timeout', () => { } ) - it('keeps post-spawn errors on the existing empty-result path', async () => { + it('rejects post-spawn errors instead of returning an empty result', async () => { const child = createMockProcess() Object.defineProperty(child, 'pid', { value: 1 }) wslAwareSpawnMock.mockReturnValue(child) @@ -212,7 +212,7 @@ describe('filesystem rg search timeout', () => { await flushMicrotasks() child.emit('error', new Error('post-spawn failure')) - await expect(promise).resolves.toMatchObject({ files: [] }) + await expect(promise).rejects.toThrow('post-spawn failure') }) // Why close(97): the WSL wrapper's "cd failed" code. It is above rg's own 0/1/2, so a handler @@ -298,6 +298,34 @@ describe('filesystem rg search timeout', () => { expect(wslAwareSpawnMock.mock.calls[0]?.[0]).toBe('/bundled/linux/rg') }) + it('marks WSL filenames that UNC cannot represent as incomplete', async () => { + const child = createMockProcess() + wslAwareSpawnMock.mockReturnValue(child) + getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({ wslDistro: 'Ubuntu' }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: all store access is mocked for this handler. + registerFilesystemHandlers({} as never) + const promise = handlers.get('fs:search')!( + { sender: { id: 7 } }, + { rootPath: 'C:\\repo', query: 'hello' } + ) + await flushMicrotasks() + child.stdout?.emit( + 'data', + `${JSON.stringify({ + type: 'match', + data: { + path: { text: './a\\b.txt' }, + lines: { text: 'hello\n' }, + line_number: 1, + submatches: [{ start: 0, end: 5 }] + } + })}\n` + ) + child.emit('close', 0, null) + await expect(promise).resolves.toMatchObject({ files: [], truncated: true }) + expect(toWindowsWslPathMock).not.toHaveBeenCalled() + }) + it('translates WSL rg output for Windows-path project search results', async () => { const child = createMockProcess() wslAwareSpawnMock.mockReturnValue(child) diff --git a/src/main/ipc/filesystem/filesystem-search-handlers.ts b/src/main/ipc/filesystem/filesystem-search-handlers.ts index 9212200e151..94cc07a2f1f 100644 --- a/src/main/ipc/filesystem/filesystem-search-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-search-handlers.ts @@ -1,3 +1,4 @@ +import { RipgrepSearchDiagnostics } from '../../../shared/ripgrep-search-diagnostics' import { SearchSubprocessLineAccumulator } from '../../../shared/search-subprocess-lines' import { ipcMain } from 'electron' import type { ChildProcess } from 'node:child_process' @@ -65,7 +66,7 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte const wslDistroForOutput = parseWslPath(rootPath)?.distro ?? localGitOptions.wslDistro return new Promise((resolvePromise, rejectPromise) => { - const rgArgs = buildRgArgs(args.query, rootPath, args) + const rgArgs = buildRgArgs(args.query, '.', args) // Why: kill the prior rg so it stops parsing thousands of matches on the main thread (the large-repo freeze) after the UI moved on. const previousChild = activeTextSearches.get(searchKey) if (previousChild) { @@ -73,7 +74,8 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte } const acc = createAccumulator() - const lines = new SearchSubprocessLineAccumulator(Number.MAX_SAFE_INTEGER) + const lines = new SearchSubprocessLineAccumulator() + const diagnostics = new RipgrepSearchDiagnostics() let resolved = false let processErrorObserved = false let unavailableExitObserved = false @@ -81,8 +83,12 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte let killTimeout: ReturnType const transformAbsPath = wslDistroForOutput - ? (path: string): string => - path.startsWith('/') ? toWindowsWslPath(path, wslDistroForOutput) : path + ? (path: string): string | null => + path.includes('\\') + ? null + : path.startsWith('/') + ? toWindowsWslPath(path, wslDistroForOutput) + : path : undefined const finish = (result: SearchResult | PromiseLike): void => { @@ -108,7 +114,10 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte } resolvePromise(result) } - const resolveOnce = (): void => finish(finalize(acc)) + const resolveOnce = (code = 0, signal: NodeJS.Signals | null = null): void => { + const error = diagnostics.failure(code, signal, acc) + finish(error ? Promise.reject(error) : finalize(acc)) + } const rejectUnavailable = (): void => finish(Promise.reject(bundledRipgrepUnavailableError())) const processLine = (line: string): void => { @@ -138,10 +147,16 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte activeTextSearches.set(searchKey, nextChild) const handleStdoutData = (chunk: string): void => { - lines.push(chunk, processLine) + if (!lines.push(chunk, processLine)) { + acc.truncated = true + if (child) { + killSpawnedRipgrepProcess(child) + } + resolveOnce() + } } - const handleStderrData = (): void => { - // Drain stderr so rg cannot block on a full pipe. + const handleStderrData = (chunk: Buffer): void => { + diagnostics.append(chunk) } const handleError = (error: NodeJS.ErrnoException): void => { processErrorObserved = true @@ -151,18 +166,12 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte return } if (child && isRipgrepUnavailableExit(child, null, null)) { - // Why the cwd check first: spawn reports a missing cwd as ENOENT too, and blaming the - // binary for it tells the user to reinstall Orca over a workspace that simply moved. - // Why detach close first: a failed spawn emits error THEN close(code < 0), and - // close settles synchronously, so this probe would otherwise race it on a sub-ms - // margin -- two measurements disagreed on which wins. Detaching makes it deterministic. + // Distinguish a missing workspace from a missing binary before close can settle. child.off('close', handleClose) - // Why catch: a failed probe must not strand the search; fall back to the prior verdict. void isRipgrepSpawnCwdUsable(rootPath) .catch(() => true) .then((usable) => { - // Why re-check: finish() drops its argument once settled, so a rejected promise - // built after the close handler already won would go unhandled. + // A late rejected promise must not escape after close settles the search. if (resolved) { return } @@ -174,7 +183,10 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte }) return } - resolveOnce() + finish(Promise.reject(error)) + if (child) { + killSpawnedRipgrepProcess(child) + } } const handleClose = (code: number | null, signal: NodeJS.Signals | null): void => { // Why first: this code is above rg's own 0/1/2, so the unavailable check would otherwise @@ -193,11 +205,11 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte rejectUnavailable() return } - const tail = lines.finish() + const tail = !signal && (code === 0 || code === 1) ? lines.finish() : null if (tail !== null) { processLine(tail) } - resolveOnce() + resolveOnce(code ?? -1, signal) } nextChild.stdout?.setEncoding('utf-8') diff --git a/src/main/ipc/markdown-documents-remote-paths.test.ts b/src/main/ipc/markdown-documents-remote-paths.test.ts new file mode 100644 index 00000000000..0d5dc66983a --- /dev/null +++ b/src/main/ipc/markdown-documents-remote-paths.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it, vi } from 'vitest' +import type * as NodePath from 'node:path' + +vi.mock('node:path', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, extname: actual.win32.extname } +}) + +import { isMarkdownDocumentName, markdownDocumentFromRelativePath } from './markdown-documents' + +describe('remote Markdown filenames on a Windows client', () => { + it('keeps the local filename helper on Windows semantics', () => { + expect(isMarkdownDocumentName('notes\\.md')).toBe(false) + expect(isMarkdownDocumentName('notes.md\\')).toBe(true) + }) + + it.each(['notes\\.md', 'a\\b.MDX', '..\\a.markdown', 'nested/README.md'])( + 'preserves POSIX filename %s and its extension', + (relativePath) => { + const basename = relativePath.slice(relativePath.lastIndexOf('/') + 1) + expect(markdownDocumentFromRelativePath('/home/repo', relativePath)).toEqual({ + filePath: `/home/repo/${relativePath}`, + relativePath, + basename, + name: basename.slice(0, basename.lastIndexOf('.')) + }) + } + ) + + it.each(['notes.md\\', 'nested/.md', '../outside.md'])( + 'rejects non-Markdown or escaping POSIX filename %s', + (relativePath) => { + expect(markdownDocumentFromRelativePath('/home/repo', relativePath)).toBeNull() + } + ) + + it('normalizes Windows remote separators before reading the basename', () => { + expect(markdownDocumentFromRelativePath('C:\\repo', 'notes\\README.MD')).toEqual({ + filePath: 'C:\\repo/notes/README.MD', + relativePath: 'notes/README.MD', + basename: 'README.MD', + name: 'README' + }) + expect(markdownDocumentFromRelativePath('C:\\repo', 'notes\\.md')).toBeNull() + expect(markdownDocumentFromRelativePath('C:\\repo', '..\\outside.md')).toBeNull() + }) +}) diff --git a/src/main/ipc/markdown-documents-ripgrep.test.ts b/src/main/ipc/markdown-documents-ripgrep.test.ts index daee482bae1..13f48b78e17 100644 --- a/src/main/ipc/markdown-documents-ripgrep.test.ts +++ b/src/main/ipc/markdown-documents-ripgrep.test.ts @@ -47,6 +47,24 @@ describe('Markdown document ripgrep lifecycle', () => { expect(child.listenerCount('close')).toBe(0) }) + it('preserves timeout when an incomplete UTF-8 scalar is abandoned', async () => { + vi.useFakeTimers() + const result = listMarkdownDocuments(root) + const outcome = expect(result).rejects.toThrow('timed out') + child.stdout.write(Buffer.from([0xf0, 0x9f])) + await vi.advanceTimersByTimeAsync(15_000) + await outcome + }) + + it.each(['invalid', 'incomplete'] as const)('rejects %s UTF-8 filename bytes', async (kind) => { + const result = listMarkdownDocuments(root) + child.stdout.write(Buffer.from(kind === 'invalid' ? [0xff] : [0xe2, 0x82])) + if (kind === 'incomplete') { + child.emit('close', 0, null) + } + await expect(result).rejects.toThrow('not valid UTF-8') + }) + it('accepts an empty listing', async () => { const result = listMarkdownDocuments(root) child.emit('close', 1, null) diff --git a/src/main/ipc/markdown-documents.test.ts b/src/main/ipc/markdown-documents.test.ts index b23f5748cb4..698935b1f62 100644 --- a/src/main/ipc/markdown-documents.test.ts +++ b/src/main/ipc/markdown-documents.test.ts @@ -1,7 +1,41 @@ import { describe, expect, it } from 'vitest' -import { markdownDocumentFromFilePath } from './markdown-documents' +import { + markdownDocumentFromFilePath, + markdownDocumentFromRelativePath +} from './markdown-documents' describe('markdownDocumentFromFilePath', () => { + it.skipIf(process.platform === 'win32')('preserves local literal backslash names', () => { + expect(markdownDocumentFromFilePath('/repo\\', '/repo\\/a\\b.md')).toMatchObject({ + filePath: '/repo\\/a\\b.md', + relativePath: 'a\\b.md', + basename: 'a\\b.md' + }) + }) + + it('preserves POSIX remote filenames and trailing root backslashes', () => { + for (const name of ['a\\b.md', 'a/b.md', '..\\a.md']) { + expect(markdownDocumentFromRelativePath('/repo\\', name)).toMatchObject({ + filePath: `/repo\\/${name}`, + relativePath: name, + basename: name.slice(name.lastIndexOf('/') + 1) + }) + } + expect(markdownDocumentFromRelativePath('/repo\\', '../a.md')).toBeNull() + }) + + it.each(['C:\\repo\\', '\\\\server\\share\\repo\\'])( + 'preserves Windows separator handling under %s', + (root) => { + expect(markdownDocumentFromRelativePath(root, 'a\\b.md')).toMatchObject({ + filePath: `${root.slice(0, -1)}/a/b.md`, + relativePath: 'a/b.md', + basename: 'b.md' + }) + expect(markdownDocumentFromRelativePath(root, '..\\a.md')).toBeNull() + } + ) + it('keeps in-root path segments that merely start with parent traversal text', () => { expect(markdownDocumentFromFilePath('/workspace', '/workspace/..notes/file.md')).toMatchObject({ filePath: '/workspace/..notes/file.md', diff --git a/src/main/ipc/markdown-documents.ts b/src/main/ipc/markdown-documents.ts index 9bf263d56d4..1f457dba97e 100644 --- a/src/main/ipc/markdown-documents.ts +++ b/src/main/ipc/markdown-documents.ts @@ -1,4 +1,15 @@ -import { basename as pathBasename, extname, isAbsolute, join, relative, resolve } from 'node:path' +import { RipgrepFilenameDecoder } from '../../shared/ripgrep-filename-decoder' +import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' +import { normalizeRelativePath } from '../../shared/text-search-paths' +import { + basename as pathBasename, + extname, + isAbsolute, + join, + posix, + relative, + resolve +} from 'node:path' import type { FileDocument, MarkdownDocument } from '../../shared/filesystem-entry-types' import { spawnBundledRipgrep } from '../ripgrep/bundled-ripgrep-spawn' import { parseWslPath } from '../wsl' @@ -7,36 +18,34 @@ import { ripgrepMissingCwdError } from '../../shared/ripgrep-process-availability' -function normalizeRelativePath(path: string): string { - return path.replace(/[\\/]+/g, '/').replace(/^\/+/, '') +export function isMarkdownDocumentName(name: string): boolean { + return isMarkdownExtension(extname(name)) } -export function isMarkdownDocumentName(name: string): boolean { - const extension = extname(name).toLowerCase() - return extension === '.md' || extension === '.mdx' || extension === '.markdown' +function isMarkdownExtension(extension: string): boolean { + const normalized = extension.toLowerCase() + return normalized === '.md' || normalized === '.mdx' || normalized === '.markdown' } function basenameFromRelativePath(relativePath: string): string { - const normalizedPath = relativePath.replaceAll('\\', '/') - return normalizedPath.slice(normalizedPath.lastIndexOf('/') + 1) + return relativePath.slice(relativePath.lastIndexOf('/') + 1) } function isSafeRelativePath(relativePath: string): boolean { return !relativePath.split('/').includes('..') } -function hasParentTraversalSegment(relativePath: string): boolean { - return relativePath.split(/[\\/]+/).includes('..') -} - function rootRelativePath(rootPath: string, filePath: string): string | null { const resolvedRoot = resolve(rootPath) const resolvedFile = resolve(filePath) const relativePath = relative(resolvedRoot, resolvedFile) - if (hasParentTraversalSegment(relativePath) || isAbsolute(relativePath)) { + if ( + !isSafeRelativePath(normalizeRelativePath(relativePath, rootPath)) || + isAbsolute(relativePath) + ) { return null } - return normalizeRelativePath(relativePath) + return normalizeRelativePath(relativePath, rootPath) } export function fileDocumentFromFilePath( @@ -50,7 +59,7 @@ export function fileDocumentFromFilePath( rootRelativePath(rootPath, filePath) ?? (options.outsideRootRelativePath === 'basename' ? basename - : normalizeRelativePath(relative(rootPath, filePath))) + : normalizeRelativePath(relative(rootPath, filePath), rootPath)) return { filePath, relativePath, @@ -65,18 +74,22 @@ export function markdownDocumentFromRelativePath( rootPath: string, relativePath: string ): MarkdownDocument | null { - const normalizedRelativePath = normalizeRelativePath(relativePath) + const normalizedRelativePath = normalizeRelativePath(relativePath, rootPath) // Why: SSH providers should return root-relative paths; reject escape // segments before building a synthetic absolute path for renderer use. if (!isSafeRelativePath(normalizedRelativePath)) { return null } const basename = basenameFromRelativePath(normalizedRelativePath) - if (!isMarkdownDocumentName(basename)) { + // Remote separators are already normalized; a POSIX backslash stays part of the name. + const extension = posix.extname(basename) + if (!isMarkdownExtension(extension)) { return null } - const extension = extname(basename) - const normalizedRoot = rootPath.replace(/[\\/]+$/, '') + const normalizedRoot = rootPath.replace( + isWindowsAbsolutePathLike(rootPath) ? /[\\/]+$/ : /\/+$/, + '' + ) return { filePath: `${normalizedRoot}/${normalizedRelativePath}`, relativePath: normalizedRelativePath, @@ -131,6 +144,10 @@ export async function listMarkdownDocuments( ) return new Promise((resolveListing, reject) => { + const filenameDecoder = new RipgrepFilenameDecoder( + (error) => finish(error), + Boolean(parseWslPath(rootPath)?.distro ?? options.wslDistro) + ) const documents: MarkdownDocument[] = [] let carry = '' let stderr = '' @@ -172,8 +189,12 @@ export async function listMarkdownDocuments( const onStderr = (chunk: string): void => { stderr = (stderr + chunk).slice(0, 4096) } - const onData = (chunk: string): void => { - carry += chunk + const onData = (chunk: Buffer | string): void => { + const decoded = filenameDecoder.decode(chunk) + if (decoded === null) { + return + } + carry += decoded let start = 0 let end: number while ((end = carry.indexOf('\0', start)) !== -1) { @@ -201,10 +222,11 @@ export async function listMarkdownDocuments( finish(ripgrepMissingCwdError(rootPath)) } else if (signal || (code !== 0 && code !== 1)) { finish(new Error(`Markdown document listing failed (${signal ?? code}): ${stderr.trim()}`)) - } else if (carry) { - finish(new Error('Incomplete path in Markdown document listing')) } else { - finish() + if (!filenameDecoder.finish()) { + return + } + finish(carry ? new Error('Incomplete path in Markdown document listing') : undefined) } } const timer = setTimeout( @@ -212,7 +234,6 @@ export async function listMarkdownDocuments( MARKDOWN_LISTING_TIMEOUT_MS ) timer.unref?.() - child.stdout?.setEncoding('utf8') child.stderr?.setEncoding('utf8') child.stdout?.on('data', onData) child.stderr?.on('data', onStderr) diff --git a/src/main/ripgrep/ripgrep-filename-identity-real.test.ts b/src/main/ripgrep/ripgrep-filename-identity-real.test.ts new file mode 100644 index 00000000000..6fc7116bd7d --- /dev/null +++ b/src/main/ripgrep/ripgrep-filename-identity-real.test.ts @@ -0,0 +1,62 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { spawnBundledRipgrep } from './bundled-ripgrep-spawn' +import { buildRgArgsForQuickOpen, normalizeQuickOpenRgLine } from '../../shared/quick-open-filter' +import { buildRgArgs, createAccumulator, ingestRgJsonLine } from '../../shared/text-search' +import { joinWorktreeRelativePath } from '../runtime/runtime-relative-paths' + +async function capture(root: string, args: string[]): Promise { + const child = spawnBundledRipgrep(args, { cwd: root, stdio: ['ignore', 'pipe', 'pipe'] }) + let output = '' + child.stdout?.setEncoding('utf8').on('data', (chunk: string) => { + output += chunk + }) + child.stderr?.resume() + await new Promise((resolve, reject) => { + child.once('error', reject) + child.once('close', (code) => (code === 0 ? resolve() : reject(new Error(`rg exit ${code}`)))) + }) + return output +} + +describe.skipIf(process.platform === 'win32')('real ripgrep POSIX filename identities', () => { + it('lists, searches, and opens literal-backslash and nested names independently', async () => { + const parent = await mkdtemp(join(tmpdir(), 'orca-rg-identity-')) + const root = join(parent, 'repo\\root') + try { + await mkdir(join(root, 'a'), { recursive: true }) + await writeFile(join(root, 'a\\b.txt'), 'needle literal') + await writeFile(join(root, 'a/b.txt'), 'needle nested') + const args = buildRgArgsForQuickOpen({ + searchRoot: '.', + excludePathPrefixes: [], + forceSlashSeparator: false + }) + const listing = await capture(root, args.primary) + const names = listing + .split('\0') + .filter(Boolean) + .map((line) => normalizeQuickOpenRgLine(line, { kind: 'cwd-relative' })) + expect(names.sort()).toEqual(['a/b.txt', 'a\\b.txt'].sort()) + for (const name of names) { + expect(name).not.toBeNull() + if (name === null) { + throw new Error('invalid name') + } + expect(await readFile(joinWorktreeRelativePath(root, name), 'utf8')).toBe( + name === 'a\\b.txt' ? 'needle literal' : 'needle nested' + ) + } + const acc = createAccumulator() + for (const line of (await capture(root, buildRgArgs('needle', '.', {}))).split('\n')) { + ingestRgJsonLine(line, root, acc, 10) + } + expect([...acc.fileMap.values()].map((file) => file.relativePath).sort()).toEqual(names) + expect(acc.truncated).toBe(false) + } finally { + await rm(parent, { recursive: true, force: true }) + } + }) +}) diff --git a/src/main/ripgrep/text-search-unicode-real.test.ts b/src/main/ripgrep/text-search-unicode-real.test.ts new file mode 100644 index 00000000000..b56c1794a19 --- /dev/null +++ b/src/main/ripgrep/text-search-unicode-real.test.ts @@ -0,0 +1,123 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { spawnBundledRipgrep } from './bundled-ripgrep-spawn' +import { + buildRgArgs, + createAccumulator, + finalize, + ingestRgJsonLine +} from '../../shared/text-search' + +describe('ripgrep Unicode match coordinates', () => { + let root: string + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-rg-unicode-')) + }) + afterEach(async () => { + vi.unstubAllEnvs() + await rm(root, { recursive: true, force: true }) + }) + + async function search( + query: string, + useRegex = false, + includePattern?: string, + excludePattern?: string + ) { + const child = spawnBundledRipgrep( + buildRgArgs(query, '.', { useRegex, includePattern, excludePattern }), + { + cwd: root, + stdio: ['ignore', 'pipe', 'pipe'] + } + ) + let output = '' + child.stdout?.setEncoding('utf8').on('data', (chunk: string) => { + output += chunk + }) + child.stderr?.resume() + await new Promise((resolve, reject) => { + child.once('error', reject) + child.once('close', (code) => + code === 0 || code === 1 ? resolve() : reject(new Error(`rg exit ${code}`)) + ) + }) + const acc = createAccumulator() + for (const line of output.split('\n')) { + ingestRgJsonLine(line, root, acc, 2000) + } + return finalize(acc) + } + + it('uses UTF16 columns and lengths for multibyte prefixes and astral matches', async () => { + await writeFile(join(root, 'example.txt'), '😀 café 日本 needle 😀 needle\r\n') + const result = await search('needle|😀', true) + expect( + result.files[0]?.matches.map(({ column, matchLength }) => [column, matchLength]) + ).toEqual([ + [1, 2], + [12, 6], + [19, 2], + [22, 6] + ]) + }) + + it.each(['src/**', '/src/**'])('honors root-relative include glob %s', async (includePattern) => { + await mkdir(join(root, 'src')) + await mkdir(join(root, 'other')) + await writeFile(join(root, 'src', 'match.txt'), 'needle') + await writeFile(join(root, 'src', 'skip.txt'), 'needle') + await writeFile(join(root, 'other', 'match.txt'), 'needle') + const result = await search('needle', false, includePattern, '/src/skip.txt') + expect(result.files.map((file) => [file.filePath, file.relativePath])).toEqual([ + [join(root, 'src', 'match.txt'), 'src/match.txt'] + ]) + }) + + it('keeps navigation and clamped display coordinates aligned', async () => { + const prefix = '日本😀'.repeat(200) + await writeFile(join(root, 'long.txt'), `${prefix}needle`) + const match = (await search('needle')).files[0]?.matches[0] + expect(match?.column).toBe(prefix.length + 1) + expect( + match?.lineContent.slice( + (match.displayColumn ?? 1) - 1, + (match.displayColumn ?? 1) - 1 + match.matchLength + ) + ).toBe('needle') + }) + + it('decodes malformed UTF8 context and maps matches using the original bytes', async () => { + const bytes = Buffer.concat([ + Buffer.from('😀 '), + Buffer.from([0xe2, 0x82, 0xff]), + Buffer.from(' needle é needle\r\n') + ]) + await writeFile(join(root, 'malformed.txt'), bytes) + const content = bytes.toString('utf8').replace(/\n$/, '') + const result = await search('needle') + expect(result.totalMatches).toBe(2) + expect( + result.files[0]?.matches.map((match) => [match.column, match.matchLength, match.lineContent]) + ).toEqual([ + [content.indexOf('needle') + 1, 6, content], + [content.lastIndexOf('needle') + 1, 6, content] + ]) + expect(result.truncated).toBe(false) + }) + + it('ignores external rg config while retaining workspace ignore files', async () => { + const config = join(root, 'config') + await writeFile(config, '--invert-match\n') + vi.stubEnv('RIPGREP_CONFIG_PATH', config) + await writeFile(join(root, '.ignore'), 'ignored.txt\n') + await writeFile(join(root, 'ignored.txt'), 'needle\n') + await mkdir(join(root, 'docs')) + await writeFile(join(root, 'docs', 'visible.txt'), 'needle\nother\n') + const result = await search('needle') + expect(result.files.map((file) => file.relativePath)).toEqual(['docs/visible.txt']) + expect(result.files[0]?.matches[0]?.lineContent).toBe('needle') + }) +}) diff --git a/src/main/runtime/ripgrep-filename-identity.test.ts b/src/main/runtime/ripgrep-filename-identity.test.ts new file mode 100644 index 00000000000..b8ef50a76b6 --- /dev/null +++ b/src/main/runtime/ripgrep-filename-identity.test.ts @@ -0,0 +1,82 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { joinWorktreeRelativePath, normalizeRuntimeRelativePath } from './runtime-relative-paths' +import { buildExcludePathPrefixes, normalizeQuickOpenRgLine } from '../../shared/quick-open-filter' +import { createAccumulator, ingestRgJsonLine } from '../../shared/text-search' + +it.each(['/native/repo\\root', '/ssh/repo\\root'])( + 'preserves POSIX search identities under %s independently of client platform', + (root) => { + const acc = createAccumulator() + for (const name of ['a\\b.txt', 'a/b.txt']) { + const relative = normalizeQuickOpenRgLine(`./${name}`, { kind: 'cwd-relative' }) + expect(relative).toBe(name) + expect( + normalizeQuickOpenRgLine(`${root}/${name}`, { kind: 'absolute', rootPath: root }) + ).toBe(name) + expect(joinWorktreeRelativePath(root, normalizeRuntimeRelativePath(name, root))).toBe( + `${root}/${name}` + ) + ingestRgJsonLine( + JSON.stringify({ + type: 'match', + data: { + path: { text: `${root}/${name}` }, + lines: { text: 'needle' }, + line_number: 1, + submatches: [{ start: 0, end: 6 }] + } + }), + root, + acc, + 10 + ) + } + expect([...acc.fileMap.values()].map((file) => file.relativePath)).toEqual([ + 'a\\b.txt', + 'a/b.txt' + ]) + expect(buildExcludePathPrefixes(root, [`${root}/a\\b`])).toEqual(['a\\b']) + } +) + +it.each(['C:\\repo', '\\\\server\\share\\repo'])( + 'preserves Windows separator compatibility under %s', + (root) => { + expect(joinWorktreeRelativePath(root, normalizeRuntimeRelativePath('a\\b.txt', root))).toBe( + `${root}\\a\\b.txt` + ) + expect( + normalizeQuickOpenRgLine(`${root}\\a\\b.txt`, { kind: 'absolute', rootPath: root }) + ).toBe('a/b.txt') + } +) + +describe.skipIf(process.platform === 'win32')('real POSIX filename collision', () => { + it('opens both literal-backslash and nested files without changing identity', async () => { + const parent = await mkdtemp(join(tmpdir(), 'orca-rg-path-')) + const root = join(parent, 'repo\\root') + try { + await mkdir(join(root, 'a'), { recursive: true }) + await writeFile(join(root, 'a\\b.txt'), 'literal') + await writeFile(join(root, 'a/b.txt'), 'nested') + for (const [name, expected] of [ + ['a\\b.txt', 'literal'], + ['a/b.txt', 'nested'] + ]) { + const relative = normalizeQuickOpenRgLine(`./${name}`, { kind: 'cwd-relative' }) + expect(relative).toBe(name) + expect( + await readFile( + joinWorktreeRelativePath(root, normalizeRuntimeRelativePath(name, root)), + 'utf8' + ) + ).toBe(expected) + } + } finally { + await rm(parent, { recursive: true, force: true }) + } + }) +}) diff --git a/src/main/runtime/runtime-file-commands-search-local-runtime-files.ts b/src/main/runtime/runtime-file-commands-search-local-runtime-files.ts index 8ed435fddc7..83e30a15f47 100644 --- a/src/main/runtime/runtime-file-commands-search-local-runtime-files.ts +++ b/src/main/runtime/runtime-file-commands-search-local-runtime-files.ts @@ -1,4 +1,5 @@ // @ts-nocheck -- mechanically split class members. +import { RipgrepSearchDiagnostics } from '../../shared/ripgrep-search-diagnostics' import { SearchSubprocessLineAccumulator } from '../../shared/search-subprocess-lines' import { RuntimeFileCommandsWithSearchRuntimeFiles } from './runtime-file-commands-search-runtime-files' import type { SearchOptions, SearchResult } from '../../shared/code-search-types' @@ -50,20 +51,26 @@ export class RuntimeFileCommandsWithSearchLocalRuntimeFiles extends RuntimeFileC return new Promise((resolvePromise, rejectPromise) => { const searchKey = `${this.host.getRuntimeId()}:${authorizedRootPath}` - const rgArgs = buildRgArgs(options.query, authorizedRootPath, options) + const rgArgs = buildRgArgs(options.query, '.', options) const previousChild = this.activeRuntimeTextSearches.get(searchKey) if (previousChild) { killSpawnedRipgrepProcess(previousChild) } const acc = createAccumulator() - const lines = new SearchSubprocessLineAccumulator(Number.MAX_SAFE_INTEGER) + const lines = new SearchSubprocessLineAccumulator() + const diagnostics = new RipgrepSearchDiagnostics() let resolved = false let processErrorObserved = false let unavailableExitObserved = false let child: ChildProcessHandle | null = null const transformAbsPath = wslDistroForOutput - ? (p: string): string => (p.startsWith('/') ? toWindowsWslPath(p, wslDistroForOutput) : p) + ? (p: string): string | null => + p.includes('\\') + ? null + : p.startsWith('/') + ? toWindowsWslPath(p, wslDistroForOutput) + : p : undefined const finish = (result: SearchResult | PromiseLike): void => { @@ -77,7 +84,10 @@ export class RuntimeFileCommandsWithSearchLocalRuntimeFiles extends RuntimeFileC cleanupListeners() resolvePromise(result) } - const resolveOnce = (): void => finish(finalize(acc)) + const resolveOnce = (code = 0, signal: NodeJS.Signals | null = null): void => { + const error = diagnostics.failure(code, signal, acc) + finish(error ? Promise.reject(error) : finalize(acc)) + } const rejectUnavailable = (): void => finish(Promise.reject(bundledRipgrepUnavailableError())) let killTimeout: ReturnType | null = null @@ -133,10 +143,16 @@ export class RuntimeFileCommandsWithSearchLocalRuntimeFiles extends RuntimeFileC nextChild.stdout?.setEncoding('utf-8') const onStdoutData = (chunk: string): void => { - lines.push(chunk, processLine) + if (!lines.push(chunk, processLine)) { + acc.truncated = true + if (child) { + killSpawnedRipgrepProcess(child) + } + resolveOnce() + } } - const onStderrData = (): void => { - // Drain stderr so rg cannot block on a full pipe. + const onStderrData = (chunk: Buffer): void => { + diagnostics.append(chunk) } const onError = (error: NodeJS.ErrnoException): void => { processErrorObserved = true @@ -171,7 +187,10 @@ export class RuntimeFileCommandsWithSearchLocalRuntimeFiles extends RuntimeFileC }) return } - resolveOnce() + finish(Promise.reject(error)) + if (child) { + killSpawnedRipgrepProcess(child) + } } const onClose = (code: number | null, signal: NodeJS.Signals | null): void => { // Why first: this code is above rg's own 0/1/2, so the unavailable check would otherwise @@ -190,11 +209,11 @@ export class RuntimeFileCommandsWithSearchLocalRuntimeFiles extends RuntimeFileC rejectUnavailable() return } - const tail = lines.finish() + const tail = !signal && (code === 0 || code === 1) ? lines.finish() : null if (tail !== null) { processLine(tail) } - resolveOnce() + resolveOnce(code ?? -1, signal) } nextChild.stdout?.on('data', onStdoutData) @@ -229,7 +248,7 @@ export class RuntimeFileCommandsWithSearchLocalRuntimeFiles extends RuntimeFileC worktree: target.worktree, path: joinWorktreeRelativePath( target.worktree.path, - normalizeRuntimeRelativePath(relativePath) + normalizeRuntimeRelativePath(relativePath, target.worktree.path) ), executionHostId: target.executionHostId })) diff --git a/src/main/runtime/runtime-relative-paths.ts b/src/main/runtime/runtime-relative-paths.ts index 1ec252e24f2..1f5ab8e71cb 100644 --- a/src/main/runtime/runtime-relative-paths.ts +++ b/src/main/runtime/runtime-relative-paths.ts @@ -2,15 +2,21 @@ import { posix, win32 } from 'node:path' import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' export function joinWorktreeRelativePath(rootPath: string, relativePath: string): string { - const normalizedRelativePath = relativePath.replace(/\\/g, '/') + const normalizedRelativePath = isWindowsAbsolutePathLike(rootPath) + ? relativePath.replace(/\\/g, '/') + : relativePath if (isWindowsAbsolutePathLike(rootPath)) { return win32.join(rootPath.replace(/\//g, '\\'), ...normalizedRelativePath.split('/')) } return posix.join(rootPath, ...normalizedRelativePath.split('/')) } -export function normalizeRuntimeRelativePath(relativePath: string): string { - const normalized = relativePath.replace(/\\/g, '/').replace(/\/+$/, '') +export function normalizeRuntimeRelativePath(relativePath: string, rootPath?: string): string { + const path = + rootPath !== undefined && !isWindowsAbsolutePathLike(rootPath) + ? relativePath + : relativePath.replace(/\\/g, '/') + const normalized = path.replace(/\/+$/, '') if (normalized === '') { return '' } diff --git a/src/relay/fs-handler-list-files-cancel.test.ts b/src/relay/fs-handler-list-files-cancel.test.ts index 97bea1b0813..b6829921cb4 100644 --- a/src/relay/fs-handler-list-files-cancel.test.ts +++ b/src/relay/fs-handler-list-files-cancel.test.ts @@ -52,7 +52,7 @@ describe('relay list-files cancellation', () => { const promise = listFilesWithRg('/remote/root', [], { signal: controller.signal }) // Partial output before the abort — must be discarded, not resolved. - ignoredProc.stdout?.emit('data', 'src/index.ts\n') + ignoredProc.stdout?.emit('data', 'src/index.ts\0') controller.abort() await expect(promise).rejects.toSatisfy(isFileListingCancellation) @@ -81,8 +81,8 @@ describe('relay list-files cancellation', () => { const promise = listFilesWithRg('/remote/root', [], { signal: controller.signal }) setTimeout(() => { - ignoredProc.stdout?.emit('data', 'src/index.ts\n') - ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'dist/out.js\n') + ignoredProc.stdout?.emit('data', 'src/index.ts\0') + ignoredProc.stdout?.emit('data', 'dist/out.js\0') ignoredProc.emit('close', 0, null) }, 5) diff --git a/src/relay/fs-handler-list-files-ignored.test.ts b/src/relay/fs-handler-list-files-ignored.test.ts index 3f8b8c2b883..36a3914d021 100644 --- a/src/relay/fs-handler-list-files-ignored.test.ts +++ b/src/relay/fs-handler-list-files-ignored.test.ts @@ -71,6 +71,21 @@ describe('relay quick open ignored file listing', () => { await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))) }) + it.each(['invalid', 'incomplete'] as const)('rejects %s UTF-8 filename bytes', async (kind) => { + const child = createMockProcess() + spawnMock.mockReturnValue(child) + const promise = listFilesWithRg('/remote/root') + + child.stdout?.emit('data', Buffer.from(kind === 'invalid' ? [0xff] : [0xe2, 0x82])) + if (kind === 'incomplete') { + child.emit('close', 0, null) + } + await expect(promise).rejects.toThrow('not valid UTF-8') + if (kind === 'invalid') { + expect(child.kill).toHaveBeenCalled() + } + }) + it('uses one broad rg pass for unbounded listings and keeps blocklists/excludes', async () => { const ignoredProc = createMockProcess() @@ -80,10 +95,10 @@ describe('relay quick open ignored file listing', () => { expect(spawnMock).toHaveBeenCalledTimes(1) setTimeout(() => { - ignoredProc.stdout?.emit('data', 'src/index.ts\n') - ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'dist/generated.js\n') - ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'node_modules/pkg/index.js\n') - ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'packages/other/src/x.ts\n') + ignoredProc.stdout?.emit('data', 'src/index.ts\0') + ignoredProc.stdout?.emit('data', 'dist/generated.js\0') + ignoredProc.stdout?.emit('data', 'node_modules/pkg/index.js\0') + ignoredProc.stdout?.emit('data', 'packages/other/src/x.ts\0') ignoredProc.emit('close', 0, null) }, 10) @@ -106,10 +121,7 @@ describe('relay quick open ignored file listing', () => { spawnMock.mockImplementation(() => (++callIndex === 1 ? primaryProc : ignoredProc)) const promise = listFilesWithRg('/remote/root', [], { maxResults: 2 }) - ;(primaryProc.stdout as unknown as EventEmitter).emit( - 'data', - 'src/one.ts\nsrc/two.ts\nsrc/three.ts\n' - ) + primaryProc.stdout?.emit('data', 'src/one.ts\0src/two.ts\0src/three.ts\0') await expect(promise).resolves.toEqual(['src/one.ts', 'src/two.ts']) expect(primaryProc.kill).toHaveBeenCalled() @@ -127,14 +139,11 @@ describe('relay quick open ignored file listing', () => { expect(spawnMock).toHaveBeenCalledTimes(1) expect(spawnMock.mock.calls[0][1]).toContain('--no-ignore-vcs') - ;(ignoredProc.stdout as unknown as EventEmitter).emit( + ignoredProc.stdout?.emit( 'data', - `${Array.from({ length: 100_100 }, (_, index) => `data/payload-${index}.bin`).join('\n')}\n` - ) - ;(ignoredProc.stdout as unknown as EventEmitter).emit( - 'data', - 'src/components/target.ts\nscripts/check-target.ts\n' + `${Array.from({ length: 100_100 }, (_, index) => `data/payload-${index}.bin`).join('\0')}\0` ) + ignoredProc.stdout?.emit('data', 'src/components/target.ts\0scripts/check-target.ts\0') ignoredProc.emit('close', 0, null) await expect(promise).resolves.toEqual(['scripts/check-target.ts', 'src/components/target.ts']) @@ -148,11 +157,11 @@ describe('relay quick open ignored file listing', () => { const promise = listFilesWithRg('/remote/root', [], { maxResults: 2 }) expect(spawnMock).toHaveBeenCalledTimes(1) expect(spawnMock.mock.calls[0][1]).not.toContain('--no-ignore-vcs') - primary.stdout?.emit('data', 'src/index.ts\n') + primary.stdout?.emit('data', 'src/index.ts\0') primary.emit('close', 0, null) await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(2)) expect(spawnMock.mock.calls[1][1]).toContain('--no-ignore-vcs') - broad.stdout?.emit('data', 'src/index.ts\ndist/generated.js\ndist/extra.js\n') + broad.stdout?.emit('data', 'src/index.ts\0dist/generated.js\0dist/extra.js\0') await expect(promise).resolves.toEqual(['src/index.ts', 'dist/generated.js']) expect(broad.kill).toHaveBeenCalled() @@ -168,14 +177,11 @@ describe('relay quick open ignored file listing', () => { maxResults: 32, searchQuery: 'target' }) - ;(failed.stdout as unknown as EventEmitter).emit('data', 'src/target.ts\n') + failed.stdout?.emit('data', 'src/target.ts\0') failed.emit('error', Object.assign(new Error('spawn rg EAGAIN'), { code: 'EAGAIN' })) await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(2)) - ;(succeeded.stdout as unknown as EventEmitter).emit( - 'data', - 'src/target.ts\nsrc/another-target.ts\n' - ) + succeeded.stdout?.emit('data', 'src/target.ts\0src/another-target.ts\0') succeeded.emit('close', 0, null) await expect(promise).resolves.toEqual(['src/target.ts', 'src/another-target.ts']) @@ -193,7 +199,7 @@ describe('relay quick open ignored file listing', () => { searchQuery: 'target' }) await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(2)) - ;(succeeded.stdout as unknown as EventEmitter).emit('data', 'src/target.ts\n') + succeeded.stdout?.emit('data', 'src/target.ts\0') succeeded.emit('close', 0, null) await expect(promise).resolves.toEqual(['src/target.ts']) @@ -209,7 +215,7 @@ describe('relay quick open ignored file listing', () => { failed.emit('error', Object.assign(new Error('spawn rg EAGAIN'), { code: 'EAGAIN' })) await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(2)) - succeeded.stdout?.emit('data', 'src/index.ts\ndist/generated.js\n') + succeeded.stdout?.emit('data', 'src/index.ts\0dist/generated.js\0') succeeded.emit('close', 0, null) await expect(promise).resolves.toEqual(['src/index.ts', 'dist/generated.js']) @@ -305,18 +311,12 @@ describe('relay quick open ignored file listing', () => { const promise = listFilesWithGit(root, ['packages/other']) setTimeout(() => { - ;(primaryProc.stdout as unknown as EventEmitter).emit( - 'data', - `${staged('100644', 'src/index.ts')}\0` - ) - ;(primaryProc.stdout as unknown as EventEmitter).emit( - 'data', - `${staged('100644', 'tab\tfile.txt')}\0` - ) + primaryProc.stdout?.emit('data', `${staged('100644', 'src/index.ts')}\0`) + primaryProc.stdout?.emit('data', `${staged('100644', 'tab\tfile.txt')}\0`) primaryProc.emit('close', 0, null) - ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'dist/\0') - ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'packages/other/src/x.ts\0') + ignoredProc.stdout?.emit('data', 'dist/\0') + ignoredProc.stdout?.emit('data', 'packages/other/src/x.ts\0') ignoredProc.emit('close', 0, null) }, 10) @@ -346,7 +346,7 @@ describe('relay quick open ignored file listing', () => { spawnMock.mockImplementation(() => (++callIndex === 1 ? primaryProc : ignoredProc)) const promise = listFilesWithGit('/remote/root', [], { maxResults: 2 }) - ;(primaryProc.stdout as unknown as EventEmitter).emit('data', 'src/one.ts\0src/two.ts') + primaryProc.stdout?.emit('data', 'src/one.ts\0src/two.ts') primaryProc.emit('close', 0, null) await expect(promise).resolves.toEqual(['src/one.ts', 'src/two.ts']) expect(primaryProc.kill).toHaveBeenCalled() @@ -359,10 +359,7 @@ describe('relay quick open ignored file listing', () => { spawnMock.mockReturnValue(primaryProc) const promise = listFilesWithGit('/remote/root', [], { maxResults: 1 }) - ;(primaryProc.stdout as unknown as EventEmitter).emit( - 'data', - `discarded/\0${staged('100644', 'src/kept.ts')}\0` - ) + primaryProc.stdout?.emit('data', `discarded/\0${staged('100644', 'src/kept.ts')}\0`) await expect(promise).resolves.toEqual(['src/kept.ts']) expect(primaryProc.kill).toHaveBeenCalled() @@ -389,7 +386,7 @@ describe('relay quick open ignored file listing', () => { const promise = listFilesWithGit(root) setTimeout(() => { - ;(primaryProc.stdout as unknown as EventEmitter).emit( + primaryProc.stdout?.emit( 'data', `${staged('100644', 'README.md')}\0${staged('160000', 'packages/app')}\0packages/lib/\0` ) @@ -419,11 +416,11 @@ describe('relay quick open ignored file listing', () => { const promise = listFilesWithGit('/remote/root') setTimeout(() => { - ;(primaryProc.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\0') + primaryProc.stdout?.emit('data', 'src/index.ts\0') primaryProc.emit('close', 0, null) // Entries streamed before the kill are kept alongside the primary pass. - ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'dist/generated.js\0') + ignoredProc.stdout?.emit('data', 'dist/generated.js\0') ignoredProc.emit('close', null, 'SIGTERM') }, 10) @@ -449,7 +446,7 @@ describe('relay quick open ignored file listing', () => { const promise = listFilesWithGit('/remote/root') setTimeout(() => { - ;(primaryProc.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\0') + primaryProc.stdout?.emit('data', 'src/index.ts\0') primaryProc.emit('close', 0, null) ignoredProc.emit('close', 128, null) @@ -475,10 +472,10 @@ describe('relay quick open ignored file listing', () => { const promise = listFilesWithGit('/remote/root') setTimeout(() => { - ;(primaryProc.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\0') + primaryProc.stdout?.emit('data', 'src/index.ts\0') primaryProc.emit('close', null, 'SIGTERM') - ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'dist/generated.js\0') + ignoredProc.stdout?.emit('data', 'dist/generated.js\0') ignoredProc.emit('close', 0, null) }, 10) @@ -500,7 +497,7 @@ describe('relay quick open ignored file listing', () => { setTimeout(() => { primaryProc.emit('close', 128, null) - ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'dist/generated.js\0') + ignoredProc.stdout?.emit('data', 'dist/generated.js\0') ignoredProc.emit('close', 0, null) }, 10) @@ -733,12 +730,12 @@ describe('relay quick open ignored file listing', () => { expect(() => probe.emit('error', error)).not.toThrow() }) - it('keeps post-spawn rg search errors on the existing empty-result path', async () => { + it('rejects post-spawn rg search errors instead of returning an empty result', async () => { const started = createMockProcess() Object.defineProperty(started, 'pid', { value: 1 }) spawnMock.mockReturnValueOnce(started) const ordinaryFailure = searchWithRg('/remote/root', 'ok', { maxResults: 100 }) started.emit('error', new Error('post-spawn failure')) - await expect(ordinaryFailure).resolves.toMatchObject({ files: [], totalMatches: 0 }) + await expect(ordinaryFailure).rejects.toThrow('post-spawn failure') }) }) diff --git a/src/relay/fs-handler-list-files-path-framing.test.ts b/src/relay/fs-handler-list-files-path-framing.test.ts new file mode 100644 index 00000000000..037bbc97a41 --- /dev/null +++ b/src/relay/fs-handler-list-files-path-framing.test.ts @@ -0,0 +1,57 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { rgPath } from '@vscode/ripgrep-universal' +import { configureRelayBundledRipgrep } from './relay-bundled-ripgrep' +import { listFilesWithRg } from './fs-handler-list-files' + +let root: string | undefined + +afterEach(async () => { + configureRelayBundledRipgrep(undefined) + vi.unstubAllEnvs() + if (root) { + await rm(root, { recursive: true, force: true }) + } +}) + +it('ignores user rg configuration when listing files', async () => { + root = await mkdtemp(join(tmpdir(), 'orca-rg-config-')) + configureRelayBundledRipgrep(rgPath) + const config = join(root, 'config') + await writeFile(config, '--glob\n!*.ts\n') + await writeFile(join(root, 'visible.ts'), '') + vi.stubEnv('RIPGREP_CONFIG_PATH', config) + + expect(await listFilesWithRg(root)).toContain('visible.ts') + expect(await listFilesWithRg(root, [], { maxResults: 10 })).toContain('visible.ts') +}) + +it.skipIf(process.platform === 'win32')( + 'preserves newline, carriage return and Unicode filenames', + async () => { + root = await mkdtemp(join(tmpdir(), 'orca-rg-filenames-')) + configureRelayBundledRipgrep(rgPath) + const names = ['first\nsecond.ts', 'trailing\r', 'résumé-😀.ts', 'spaces .ts '] + const fixtureRoot = root + await Promise.all(names.map((name) => writeFile(join(fixtureRoot, name), ''))) + + expect((await listFilesWithRg(root)).sort()).toEqual([...names].sort()) + expect((await listFilesWithRg(root, [], { maxResults: 10 })).sort()).toEqual([...names].sort()) + expect(await listFilesWithRg(root, [], { searchQuery: 'second', maxResults: 1 })).toEqual([ + 'first\nsecond.ts' + ]) + } +) + +it.skipIf(process.platform !== 'linux')( + 'rejects real non-UTF8 filenames instead of fabricating paths', + async () => { + root = await mkdtemp(join(tmpdir(), 'orca-rg-invalid-name-')) + configureRelayBundledRipgrep(rgPath) + const invalidPath = Buffer.concat([Buffer.from(join(root, 'bad-')), Buffer.from([0xff])]) + await writeFile(invalidPath, '') + await expect(listFilesWithRg(root)).rejects.toThrow('not valid UTF-8') + } +) diff --git a/src/relay/fs-handler-list-files.ts b/src/relay/fs-handler-list-files.ts index 506693fa0c8..ca25cd85e9e 100644 --- a/src/relay/fs-handler-list-files.ts +++ b/src/relay/fs-handler-list-files.ts @@ -1,3 +1,4 @@ +import { RipgrepFilenameDecoder } from '../shared/ripgrep-filename-decoder' /** * Ripgrep-based file listing for Quick Open. * Why a full rewrite vs. the older execFile+maxBuffer version: on a home-dir @@ -97,6 +98,10 @@ export function listFilesWithRg( new Promise((passResolve, passReject) => { const attemptRanker = searchQuery === undefined ? null : new QuickOpenPathRanker(searchQuery, maxResults ?? 16) + const filenameDecoder = new RipgrepFilenameDecoder((error) => { + killSpawnedRipgrepProcess(child) + rejectPass(error) + }) let passBuf = '' let passDone = false let passFileCount = 0 @@ -126,11 +131,9 @@ export function listFilesWithRg( ) : error } - let timer: ReturnType | null = null const cleanup = (): void => { if (timer) { clearTimeout(timer) - timer = null } child.stdout?.off('data', handleStdoutData) child.stderr?.off('data', handleStderrData) @@ -188,17 +191,21 @@ export function listFilesWithRg( } children.push({ child, isDone: () => passDone, reject: rejectPass }) - timer = setTimeout(() => { + const timer = setTimeout(() => { // Discard residual buffer on abnormal exit — a truncated byte // sequence could look like a valid path. killSpawnedRipgrepProcess(child) rejectPass(new Error('rg list timed out')) }, LIST_FILES_TIMEOUT_MS) - function handleStdoutData(chunk: string): void { - passBuf += chunk + function handleStdoutData(chunk: Buffer | string): void { + const decoded = filenameDecoder.decode(chunk) + if (decoded === null) { + return + } + passBuf += decoded let start = 0 - let idx = passBuf.indexOf('\n', start) + let idx = passBuf.indexOf('\0', start) while (idx !== -1) { if (processLine(passBuf.substring(start, idx), attemptRanker)) { passFileCount++ @@ -207,7 +214,7 @@ export function listFilesWithRg( return } start = idx + 1 - idx = passBuf.indexOf('\n', start) + idx = passBuf.indexOf('\0', start) } passBuf = start < passBuf.length ? passBuf.substring(start) : '' } @@ -248,6 +255,9 @@ export function listFilesWithRg( rejectPass(new Error(`rg killed by ${signal}`)) return } + if (!filenameDecoder.finish()) { + return + } // Flush residual line only on clean exit. if (passBuf) { if (processLine(passBuf, attemptRanker)) { @@ -259,16 +269,13 @@ export function listFilesWithRg( // (e.g. EACCES on .ssh), but rg also returns 2 for fatal errors // (bad flag, invalid glob). Only trust exit 2 when rg emitted at // least one parseable path — otherwise treat it as a real failure. - if (code === 0 || code === 1) { - resolvePass() - } else if (code === 2 && passFileCount > 0) { + if (code === 0 || code === 1 || (code === 2 && passFileCount > 0)) { resolvePass() } else { rejectPass(new Error(`rg exited with code ${code}`)) } } - child.stdout?.setEncoding('utf-8') child.stdout?.on('data', handleStdoutData) child.stderr?.on('data', handleStderrData) child.once('error', handleError) diff --git a/src/relay/fs-handler-utils.ts b/src/relay/fs-handler-utils.ts index a3b2234ced0..ec2715c9b1a 100644 --- a/src/relay/fs-handler-utils.ts +++ b/src/relay/fs-handler-utils.ts @@ -1,3 +1,4 @@ +import { RipgrepSearchDiagnostics } from '../shared/ripgrep-search-diagnostics' /** * Pure helpers and child-process search utilities extracted from fs-handler.ts. * @@ -111,18 +112,15 @@ export function searchWithRg( return Promise.reject(abortSignalReason(signal)) } return new Promise((resolve, reject) => { - const rgArgs = buildRgArgs(query, rootPath, opts) + const rgArgs = buildRgArgs(query, '.', opts) const acc = createAccumulator() - const lines = new SearchSubprocessLineAccumulator(Number.MAX_SAFE_INTEGER) + const lines = new SearchSubprocessLineAccumulator() + const diagnostics = new RipgrepSearchDiagnostics() let resolved = false let processErrorObserved = false let unavailableExitObserved = false let launchFailureCheck: Promise | null = null - // Why: spawn can throw synchronously on invalid options (e.g. bad cwd), - // which would leak out of the `new Promise` executor and leave the - // promise forever pending. Treat a synchronous throw as a clean - // "no results" fallback, the same way an async 'error' event is handled. const resolvedRgCommand = resolveRelayRipgrepCommand() // Why not spawn a bare name when this is null: on Windows CreateProcessW searches the spawn // cwd -- the user's repo -- before PATH, so a planted rg.exe would run instead. @@ -142,8 +140,8 @@ export function searchWithRg( env, stdio: ['ignore', 'pipe', 'pipe'] }) - } catch { - resolve(finalize(acc)) + } catch (error) { + reject(error) return } @@ -181,9 +179,14 @@ export function searchWithRg( } } - function resolveOnce(): void { + function resolveOnce(code = 0, signal: NodeJS.Signals | null = null): void { if (settle()) { - resolve(finalize(acc)) + const error = diagnostics.failure(code, signal, acc) + if (error) { + reject(error) + } else { + resolve(finalize(acc)) + } } } @@ -235,11 +238,15 @@ export function searchWithRg( } function handleStdoutData(chunk: string): void { - lines.push(chunk, processLine) + if (!lines.push(chunk, processLine)) { + acc.truncated = true + killSpawnedRipgrepProcess(child) + resolveOnce() + } } - function handleStderrData(): void { - /* drain */ + function handleStderrData(chunk: Buffer): void { + diagnostics.append(chunk) } function handleError(error: Error): void { @@ -248,7 +255,10 @@ export function searchWithRg( settleLaunchFailure(error) return } - resolveOnce() + if (settle()) { + reject(error) + } + killSpawnedRipgrepProcess(child) } function handleClose(code: number | null, signal: NodeJS.Signals | null): void { @@ -261,11 +271,11 @@ export function searchWithRg( settleLaunchFailure() return } - const tail = lines.finish() + const tail = !signal && (code === 0 || code === 1) ? lines.finish() : null if (tail !== null) { processLine(tail) } - resolveOnce() + resolveOnce(code ?? -1, signal) } child.stdout?.setEncoding('utf-8') diff --git a/src/relay/fs-search-errors-real.test.ts b/src/relay/fs-search-errors-real.test.ts new file mode 100644 index 00000000000..eaf7fb9633c --- /dev/null +++ b/src/relay/fs-search-errors-real.test.ts @@ -0,0 +1,39 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it } from 'vitest' +import { rgPath } from '@vscode/ripgrep-universal' +import { configureRelayBundledRipgrep } from './relay-bundled-ripgrep' +import { searchWithRg } from './fs-handler-utils' + +let root: string +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-search-errors-')) + configureRelayBundledRipgrep(rgPath) + await writeFile(join(root, 'source.txt'), 'needle\n') +}) +afterEach(async () => { + configureRelayBundledRipgrep(undefined) + await rm(root, { recursive: true, force: true }) +}) + +it('reports an invalid regular expression as an error', async () => { + await expect(searchWithRg(root, '[', { useRegex: true, maxResults: 100 })).rejects.toThrow( + /regex parse error|unclosed character class/ + ) +}) + +it('distinguishes no matches from invalid syntax', async () => { + await expect(searchWithRg(root, 'absent', { maxResults: 100 })).resolves.toEqual({ + files: [], + totalMatches: 0, + truncated: false + }) +}) + +it('retains intentional capped results after stopping the process', async () => { + await writeFile(join(root, 'source.txt'), 'needle\n'.repeat(1000)) + const result = await searchWithRg(root, 'needle', { maxResults: 2 }) + expect(result.totalMatches).toBe(2) + expect(result.truncated).toBe(true) +}) diff --git a/src/relay/relay-bundled-ripgrep.test.ts b/src/relay/relay-bundled-ripgrep.test.ts index 5f667ad1429..93c4e56490f 100644 --- a/src/relay/relay-bundled-ripgrep.test.ts +++ b/src/relay/relay-bundled-ripgrep.test.ts @@ -104,7 +104,7 @@ describe('relay bundled ripgrep', () => { return child } const child = createProcess(42) - succeedWith(child, 'src/index.ts\n') + succeedWith(child, 'src/index.ts\0') return child }) diff --git a/src/relay/relay-bundled-ripgrep.ts b/src/relay/relay-bundled-ripgrep.ts index 787176e3f42..5c2dd89f3be 100644 --- a/src/relay/relay-bundled-ripgrep.ts +++ b/src/relay/relay-bundled-ripgrep.ts @@ -4,12 +4,13 @@ * the answer whenever that binary is absent or cannot launch on this host. */ import { existsSync, statSync } from 'node:fs' -import { delimiter, join, win32 } from 'node:path' +import { win32 } from 'node:path' import { isRipgrepSpawnCwdUsable, isTransientRipgrepSpawnError } from '../shared/ripgrep-process-availability' import { relayLogLine } from './relay-diagnostic-log' +import { buildRelayCommandEnv } from './relay-command-env' export const PATH_RIPGREP_COMMAND = 'rg' @@ -31,13 +32,13 @@ export function isDriveRootedWindowsPath(dir: string): boolean { // Why only rg.exe, though libuv honours %PATHEXT%: `.bat`/`.cmd` shims are not spawnable without // `shell: true`, and every ripgrep installer (winget, choco, scoop, cargo) lays down rg.exe. -function resolveWindowsPathRipgrep(): string | null { - for (const entry of (process.env.PATH ?? '').split(delimiter)) { +function resolveWindowsPathRipgrep(path: string): string | null { + for (const entry of path.split(';')) { const dir = entry.replace(/^"|"$/g, '').trim() if (!dir || !isDriveRootedWindowsPath(dir)) { continue } - const candidate = join(dir, 'rg.exe') + const candidate = win32.join(dir, 'rg.exe') try { if (statSync(candidate).isFile()) { return candidate @@ -49,7 +50,8 @@ function resolveWindowsPathRipgrep(): string | null { return null } -let windowsPathRipgrep: string | null | undefined +let windowsPathRipgrep: { path: string; command: string | null; expiresAt: number } | undefined +const WINDOWS_PATH_MISS_RETRY_MS = 60_000 let bundledRipgrepPath: string | null = null // Why a back-off, not forever: Windows AV often locks a just-installed rg.exe for its first spawns. @@ -69,13 +71,23 @@ export function pathRipgrepCommand(): string | null { if (process.platform !== 'win32') { return PATH_RIPGREP_COMMAND } - // Why the explicit undefined check and not `??=`: a miss resolves to null, which is nullish, so - // `??=` would re-walk every PATH entry on each call -- and a miss is the expensive case, since - // it stats every directory instead of stopping at the first hit. - if (windowsPathRipgrep === undefined) { - windowsPathRipgrep = resolveWindowsPathRipgrep() + const env = buildRelayCommandEnv() + const path = env.PATH ?? env.Path ?? '' + const now = Date.now() + // Cache the effective PATH; retry misses so an install can become visible without restarting. + if ( + !windowsPathRipgrep || + windowsPathRipgrep.path !== path || + now >= windowsPathRipgrep.expiresAt + ) { + const command = resolveWindowsPathRipgrep(path) + windowsPathRipgrep = { + path, + command, + expiresAt: command === null ? now + WINDOWS_PATH_MISS_RETRY_MS : Infinity + } } - return windowsPathRipgrep + return windowsPathRipgrep.command } /** Null means this host has no usable rg, so the caller falls back to git/readdir. */ diff --git a/src/relay/relay-ripgrep-cwd-env.test.ts b/src/relay/relay-ripgrep-cwd-env.test.ts index 38734ca63e6..c30590b4ad1 100644 --- a/src/relay/relay-ripgrep-cwd-env.test.ts +++ b/src/relay/relay-ripgrep-cwd-env.test.ts @@ -16,7 +16,7 @@ describe.runIf(process.platform !== 'win32')( writeFileSync(join(dir, 'found.txt'), 'needle') const cargo = join(dir, 'cargo') mkdirSync(join(cargo, 'bin'), { recursive: true }) - writeFileSync(join(cargo, 'bin', 'rg'), '#!/bin/sh\necho found.txt\n', { mode: 0o755 }) + writeFileSync(join(cargo, 'bin', 'rg'), "#!/bin/sh\nprintf 'found.txt\\0'\n", { mode: 0o755 }) vi.stubEnv('PATH', join(dir, 'empty-path')) vi.stubEnv('CARGO_HOME', cargo) configureRelayBundledRipgrep(undefined) diff --git a/src/relay/relay-windows-path-ripgrep-cache.test.ts b/src/relay/relay-windows-path-ripgrep-cache.test.ts new file mode 100644 index 00000000000..bea305bd741 --- /dev/null +++ b/src/relay/relay-windows-path-ripgrep-cache.test.ts @@ -0,0 +1,79 @@ +import type * as fs from 'node:fs' +import { statSync } from 'node:fs' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { pathRipgrepCommand, resetRelayRipgrepPathCacheForTests } from './relay-bundled-ripgrep' + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, statSync: vi.fn(actual.statSync) } +}) + +const originalPlatform = process.platform +const present = new Set() +const statMock = vi.mocked(statSync) +const fileStats = statSync(new URL(import.meta.url)) + +describe('Windows relay ripgrep effective PATH cache', () => { + beforeEach(() => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + vi.stubEnv('Path', undefined) + vi.stubEnv('PATH', 'C:\\tools') + vi.stubEnv('CARGO_HOME', 'C:\\cargo') + vi.useFakeTimers() + vi.setSystemTime(1000) + present.clear() + resetRelayRipgrepPathCacheForTests() + statMock.mockImplementation((path) => { + if (!present.has(String(path))) { + throw Object.assign(new Error('missing'), { code: 'ENOENT' }) + } + return fileStats + }) + statMock.mockClear() + }) + + afterEach(() => { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + vi.unstubAllEnvs() + vi.useRealTimers() + statMock.mockReset() + resetRelayRipgrepPathCacheForTests() + }) + + it('finds rg in the Cargo fallback appended to the command environment', () => { + present.add('C:\\cargo\\bin\\rg.exe') + expect(pathRipgrepCommand()).toBe('C:\\cargo\\bin\\rg.exe') + const calls = statMock.mock.calls.length + expect(pathRipgrepCommand()).toBe('C:\\cargo\\bin\\rg.exe') + expect(statMock).toHaveBeenCalledTimes(calls) + }) + + it('supports mixed-case Path and never probes cwd-relative entries', () => { + vi.stubEnv('PATH', undefined) + vi.stubEnv('Path', '.;node_modules\\.bin;\\tools;C:tools;C:\\safe') + present.add('C:\\safe\\rg.exe') + expect(pathRipgrepCommand()).toBe('C:\\safe\\rg.exe') + expect(statMock.mock.calls.map(([path]) => path)).toEqual(['C:\\safe\\rg.exe']) + }) + + it('invalidates both successful and missing resolutions when effective PATH changes', () => { + expect(pathRipgrepCommand()).toBeNull() + vi.stubEnv('CARGO_HOME', 'D:\\cargo') + present.add('D:\\cargo\\bin\\rg.exe') + expect(pathRipgrepCommand()).toBe('D:\\cargo\\bin\\rg.exe') + vi.stubEnv('PATH', 'E:\\tools') + present.add('E:\\tools\\rg.exe') + expect(pathRipgrepCommand()).toBe('E:\\tools\\rg.exe') + }) + + it('retries cached misses after a bounded delay without rescanning on each call', () => { + expect(pathRipgrepCommand()).toBeNull() + const calls = statMock.mock.calls.length + present.add('C:\\tools\\rg.exe') + vi.advanceTimersByTime(59_999) + expect(pathRipgrepCommand()).toBeNull() + expect(statMock).toHaveBeenCalledTimes(calls) + vi.advanceTimersByTime(1) + expect(pathRipgrepCommand()).toBe('C:\\tools\\rg.exe') + }) +}) diff --git a/src/relay/relay-windows-path-ripgrep.test.ts b/src/relay/relay-windows-path-ripgrep.test.ts index a6dee0e14e8..aa8601fdab5 100644 --- a/src/relay/relay-windows-path-ripgrep.test.ts +++ b/src/relay/relay-windows-path-ripgrep.test.ts @@ -1,7 +1,7 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { delimiter, join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { configureRelayBundledRipgrep, isDriveRootedWindowsPath, @@ -9,6 +9,9 @@ import { resetRelayRipgrepPathCacheForTests } from './relay-bundled-ripgrep' +// These tests isolate PATH safety; effective-environment coverage lives in the cache suite. +vi.mock('./relay-command-env', () => ({ buildRelayCommandEnv: () => ({ ...process.env }) })) + const originalPlatform = process.platform const originalPath = process.env.PATH @@ -68,7 +71,7 @@ describe('relay PATH ripgrep resolution', () => { // while the spawn -- running with the user's repo as cwd -- resolves them against the repo's. it('skips rooted PATH entries that carry no drive', () => { setPlatform('win32') - process.env.PATH = `\\tools${delimiter}/tools${delimiter}C:tools` + process.env.PATH = '\\tools;/tools;C:tools' resetRelayRipgrepPathCacheForTests() expect(pathRipgrepCommand()).toBeNull() @@ -77,7 +80,7 @@ describe('relay PATH ripgrep resolution', () => { // Why a relative PATH entry is skipped: it resolves against the cwd, the hazard being avoided. it('ignores relative PATH entries on Windows', () => { setPlatform('win32') - process.env.PATH = `.${delimiter}node_modules/.bin` + process.env.PATH = '.;node_modules/.bin' resetRelayRipgrepPathCacheForTests() expect(pathRipgrepCommand()).toBeNull() diff --git a/src/renderer/src/components/editor/editor-external-watch-path-index.test.ts b/src/renderer/src/components/editor/editor-external-watch-path-index.test.ts index 68e33bc6baf..18bc3b6ce2c 100644 --- a/src/renderer/src/components/editor/editor-external-watch-path-index.test.ts +++ b/src/renderer/src/components/editor/editor-external-watch-path-index.test.ts @@ -22,6 +22,36 @@ function file(overrides: Partial & Pick): } describe('editor external watch path batch index', () => { + it('routes POSIX literal-backslash updates only to the matching tab', () => { + const scope = { worktreeId: 'wt-posix', worktreePath: '/repo', runtimeEnvironmentId: null } + const literal = file({ + id: 'literal', + worktreeId: scope.worktreeId, + filePath: '/repo/a\\b.txt', + relativePath: 'a\\b.txt' + }) + const nested = file({ + id: 'nested', + worktreeId: scope.worktreeId, + filePath: '/repo/a/b.txt', + relativePath: 'a/b.txt' + }) + const index = indexEditorExternalWatchBatchPaths( + { + worktreePath: scope.worktreePath, + events: [ + { kind: 'update', absolutePath: literal.filePath }, + { kind: 'update', absolutePath: nested.filePath } + ] + }, + [literal, nested], + scope + ) + expect(index.changes.map((change) => change.relativePath)).toEqual(['a\\b.txt', 'a/b.txt']) + expect(index.matchingOpenFiles(index.changes[0])).toEqual([literal]) + expect(index.matchingOpenFiles(index.changes[1])).toEqual([nested]) + }) + it('matches UNC aliases for updates, deletes, and restored tombstones', () => { const restored = file({ id: 'restored', diff --git a/src/renderer/src/components/right-sidebar/SearchResultsPane.tsx b/src/renderer/src/components/right-sidebar/SearchResultsPane.tsx index b1fc60b7d5e..f0b5b27d486 100644 --- a/src/renderer/src/components/right-sidebar/SearchResultsPane.tsx +++ b/src/renderer/src/components/right-sidebar/SearchResultsPane.tsx @@ -13,6 +13,7 @@ const SEARCH_VIRTUAL_OVERSCAN = 12 type SearchResultsPaneProps = { results: SearchResult | null + error?: string | null hasCommittedResults: boolean query: string loading: boolean @@ -25,6 +26,7 @@ type SearchResultsPaneProps = { export function SearchResultsPane({ results, hasCommittedResults, + error, query, loading, rows, @@ -68,7 +70,7 @@ export function SearchResultsPane({ <> {/* Why: the summary is rendered outside the virtualizer so it stays pinned at the top while the user scrolls through results. */} - {results && rows.length > 0 && ( + {!error && results && (rows.length > 0 || results.truncated) && (

{results.totalMatches}{' '} {translate('auto.components.right.sidebar.Search.6aeda362ed', 'result')} @@ -83,7 +85,15 @@ export function SearchResultsPane({ )}
- {rows.length > 0 && ( + {error && ( +
+ {error} +
+ )} + {!error && rows.length > 0 && (
{virtualizer.getVirtualItems().map((virtualRow) => { const row = rows[virtualRow.index] @@ -117,7 +127,7 @@ export function SearchResultsPane({
)} - {!hasCommittedResults && query && !loading && ( + {!error && !hasCommittedResults && query && !loading && (
{translate('auto.components.right.sidebar.Search.d56d140747', 'Press Enter to search')}
diff --git a/src/renderer/src/components/right-sidebar/file-explorer-directory-filename-identity.test.ts b/src/renderer/src/components/right-sidebar/file-explorer-directory-filename-identity.test.ts new file mode 100644 index 00000000000..3dbc25998d2 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/file-explorer-directory-filename-identity.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest' +import { fileExplorerEntriesToTreeNodes } from './file-explorer-directory-listing' + +describe('Explorer directory filename identity', () => { + it.each(['/', '/repo/', 'C:\\', 'C:\\repo\\', '\\\\server\\share\\'])( + 'preserves the first filename character when the root ends in a separator: %s', + (root) => { + const [node] = fileExplorerEntriesToTreeNodes( + [{ name: 'abc.txt', isDirectory: false, isSymlink: false }], + root, + -1, + root, + { kind: 'local' } + ) + expect(node.relativePath).toBe('abc.txt') + } + ) + + it.each(['/repo', '/ssh/repo\\root'])( + 'keeps literal and nested POSIX paths distinct under %s', + (root) => { + const literal = fileExplorerEntriesToTreeNodes( + [{ name: 'a\\b.txt', isDirectory: false, isSymlink: false }], + root, + -1, + root, + { kind: 'local' } + )[0] + const nested = fileExplorerEntriesToTreeNodes( + [{ name: 'b.txt', isDirectory: false, isSymlink: false }], + `${root}/a`, + 0, + root, + { kind: 'local' } + )[0] + expect(literal).toMatchObject({ path: `${root}/a\\b.txt`, relativePath: 'a\\b.txt' }) + expect(nested).toMatchObject({ path: `${root}/a/b.txt`, relativePath: 'a/b.txt' }) + } + ) + + it.each(['C:\\repo', '\\\\server\\share\\repo'])( + 'keeps Windows relative paths canonical under %s', + (root) => { + const [node] = fileExplorerEntriesToTreeNodes( + [{ name: 'b.txt', isDirectory: false, isSymlink: false }], + `${root}\\a`, + 0, + root, + { kind: 'local' } + ) + expect(node).toMatchObject({ path: `${root}\\a\\b.txt`, relativePath: 'a/b.txt' }) + } + ) +}) diff --git a/src/renderer/src/components/right-sidebar/file-explorer-directory-listing.ts b/src/renderer/src/components/right-sidebar/file-explorer-directory-listing.ts index d904c910e0b..e94a32e3fa5 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-directory-listing.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-directory-listing.ts @@ -1,4 +1,4 @@ -import { joinPath, normalizeRelativePath } from '@/lib/path' +import { getRelativePathInsideRoot, joinPath } from '@/lib/path' import type { DirEntry } from '../../../../shared/filesystem-entry-types' import { sortDirEntries } from '../../../../shared/file-name-sort' import { readRuntimeDirectory } from '@/runtime/runtime-file-client' @@ -27,9 +27,7 @@ export function fileExplorerEntriesToTreeNodes( return { name: entry.name, path, - relativePath: worktreePath - ? normalizeRelativePath(path.slice(worktreePath.length + 1)) - : entry.name, + relativePath: getRelativePathInsideRoot(path, worktreePath) ?? entry.name, isDirectory: entry.isDirectory, isSymlink: entry.isSymlink, depth: depth + 1, diff --git a/src/renderer/src/components/right-sidebar/file-explorer-entries.ts b/src/renderer/src/components/right-sidebar/file-explorer-entries.ts index f21116c7822..d909224675b 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-entries.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-entries.ts @@ -1,4 +1,5 @@ import type { DirEntry } from '../../../../shared/filesystem-entry-types' +import { splitPathSegments } from './path-tree' export function shouldIncludeFileExplorerEntry(entry: DirEntry): boolean { return entry.name !== '.git' && entry.name !== 'node_modules' @@ -8,6 +9,6 @@ function isDotfileSegment(segment: string): boolean { return segment.length > 1 && segment !== '..' && segment.startsWith('.') } -export function isDotfileRelativePath(relativePath: string): boolean { - return relativePath.split(/[\\/]+/).some(isDotfileSegment) +export function isDotfileRelativePath(relativePath: string, rootPath?: string | null): boolean { + return splitPathSegments(relativePath, rootPath).some(isDotfileSegment) } diff --git a/src/renderer/src/components/right-sidebar/file-explorer-name-filter-projection.test.ts b/src/renderer/src/components/right-sidebar/file-explorer-name-filter-projection.test.ts index 6075b8c36a5..f883ec8544a 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-name-filter-projection.test.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-name-filter-projection.test.ts @@ -1,8 +1,11 @@ import { describe, expect, it } from 'vitest' import { + createNameFilteredFileExplorerProjection, + getFileExplorerNameFilterIgnoredQueryRelativePaths, getNameFilterCollapsedPathsAfterExpand, getNextNameFilterCollapsedPaths } from './file-explorer-name-filter-projection' +import { buildIgnoredSet } from './status-display' describe('getNextNameFilterCollapsedPaths', () => { it('collapses expanded filtered folders and expands collapsed filtered folders', () => { @@ -22,3 +25,73 @@ describe('getNextNameFilterCollapsedPaths', () => { expect([...expanded]).toEqual(['/repo/docs']) }) }) + +describe('name-filter filename identity', () => { + function project( + worktreePath: string, + relativePaths: string[], + options: { displayRootPath?: string; ignoredPaths?: string[]; showDotfiles?: boolean } = {} + ) { + return createNameFilteredFileExplorerProjection({ + worktreePath, + displayRootPath: options.displayRootPath, + nameFilter: { query: 'txt', relativePaths }, + ignoredSet: buildIgnoredSet(options.ignoredPaths, worktreePath), + showDotfiles: options.showDotfiles ?? true, + showGitIgnoredFiles: false + }).getVisibleSlice(0, 100) + } + + it.each(['/native/repo', '/ssh/repo\\root'])( + 'keeps literal backslashes separate from directories under %s', + (root) => { + const paths = ['a\\b.txt', 'a/b.txt', 'C:\\foo/a\\b.txt', '\\\\server/a\\b.txt'] + const files = project(root, paths).filter((row) => !row.isDirectory) + expect(files.map((row) => row.relativePath).sort()).toEqual([...paths].sort()) + expect(files.map((row) => row.path).sort()).toEqual( + paths.map((path) => `${root}/${path}`).sort() + ) + } + ) + + it('scopes a literal-backslash directory without selecting its nested counterpart', () => { + const rows = project('/repo', ['a\\b/file.txt', 'a/b/other.txt'], { + displayRootPath: '/repo/a\\b' + }) + expect(rows.map((row) => row.relativePath)).toEqual(['a\\b/file.txt']) + }) + + it('keeps ignored and dotfile identities distinct on POSIX', () => { + const paths = ['a\\b.txt', 'a/b.txt', 'a\\.hidden.txt', 'a/.hidden.txt'] + const rows = project('/repo', paths, { ignoredPaths: ['a/b.txt'], showDotfiles: false }) + expect(rows.map((row) => row.relativePath).sort()).toEqual(['a\\.hidden.txt', 'a\\b.txt']) + expect( + getFileExplorerNameFilterIgnoredQueryRelativePaths( + { query: 'txt', relativePaths: paths }, + false, + '/repo' + ) + ).toEqual(['a\\b.txt', 'a/b.txt', 'a\\.hidden.txt']) + expect( + project('/repo', paths, { ignoredPaths: ['a\\b.txt'] }).map((row) => row.relativePath) + ).not.toContain('a\\b.txt') + }) + + it.each(['C:\\repo', '\\\\server\\share\\repo'])( + 'keeps Windows separator semantics under %s', + (root) => { + const paths = ['a\\b.txt', 'a/b.txt', 'a\\.hidden.txt'] + const files = project(root, paths, { showDotfiles: false }).filter((row) => !row.isDirectory) + expect(files.map((row) => row.relativePath)).toEqual(['a/b.txt']) + expect(files.map((row) => row.path)).toEqual([`${root}\\a\\b.txt`]) + expect(project(root, paths, { ignoredPaths: ['a\\'], showDotfiles: false })).toEqual([]) + expect( + getFileExplorerNameFilterIgnoredQueryRelativePaths( + { query: 'txt', relativePaths: paths }, + false, + root + ) + ).toEqual(['a/b.txt', 'a/b.txt']) + } + ) +}) diff --git a/src/renderer/src/components/right-sidebar/file-explorer-name-filter-projection.ts b/src/renderer/src/components/right-sidebar/file-explorer-name-filter-projection.ts index b8d9256219e..5aa8b3c73e9 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-name-filter-projection.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-name-filter-projection.ts @@ -65,7 +65,8 @@ export function getFileExplorerNameFilterTokens(query: string | undefined): stri export function getFileExplorerNameFilterIgnoredQueryRelativePaths( source: FileExplorerNameFilterProjectionSource, - showDotfiles: boolean + showDotfiles: boolean, + worktreePath?: string | null ): string[] { if (isFileExplorerNameFilterQueryTooLarge(source.query)) { return [] @@ -75,11 +76,11 @@ export function getFileExplorerNameFilterIgnoredQueryRelativePaths( } const tokens = getFileExplorerNameFilterTokens(source.query) return source.relativePaths - .map((relativePath) => normalizeRelativePath(relativePath)) + .map((relativePath) => normalizeRelativePath(relativePath, worktreePath)) .filter( (relativePath) => Boolean(relativePath) && - (showDotfiles || !isDotfileRelativePath(relativePath)) && + (showDotfiles || !isDotfileRelativePath(relativePath, worktreePath)) && pathMatchesFileNameFilterTokens(relativePath, tokens) ) } @@ -139,14 +140,14 @@ export function createNameFilteredFileExplorerProjection({ const rootChildren = new Map() for (const rawRelativePath of nameFilter.relativePaths) { - const relativePath = normalizeRelativePath(rawRelativePath) + const relativePath = normalizeRelativePath(rawRelativePath, worktreePath) if ( !relativePath || getRelativePathInsideRoot(joinPath(worktreePath, relativePath), displayRootPath) === null ) { continue } - if (!showDotfiles && isDotfileRelativePath(relativePath)) { + if (!showDotfiles && isDotfileRelativePath(relativePath, worktreePath)) { continue } if (!showGitIgnoredFiles && isPathIgnored(ignoredSet, relativePath)) { @@ -156,12 +157,12 @@ export function createNameFilteredFileExplorerProjection({ continue } - const segments = splitPathSegments(relativePath) + const segments = splitPathSegments(relativePath, worktreePath) let currentChildren = rootChildren let currentRelativePath = '' for (let index = 0; index < segments.length; index += 1) { const name = segments[index] - currentRelativePath = currentRelativePath ? joinPath(currentRelativePath, name) : name + currentRelativePath = currentRelativePath ? `${currentRelativePath}/${name}` : name const isDirectory = index < segments.length - 1 let entry = currentChildren.get(name) if (!entry) { @@ -186,7 +187,7 @@ export function createNameFilteredFileExplorerProjection({ let displayChildren = rootChildren const scope = getRelativePathInsideRoot(displayRootPath, worktreePath) - for (const segment of scope ? splitPathSegments(scope) : []) { + for (const segment of scope ? splitPathSegments(scope, worktreePath) : []) { const entry = displayChildren.get(segment) if (!entry) { return createFileExplorerRowProjectionFromParts(visibleFlatRows, rowsByPath) diff --git a/src/renderer/src/components/right-sidebar/file-explorer-paths.test.ts b/src/renderer/src/components/right-sidebar/file-explorer-paths.test.ts index bbc373d6ad9..fa96214d0a4 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-paths.test.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-paths.test.ts @@ -1,15 +1,7 @@ import { describe, expect, it } from 'vitest' -import { - getRevealAncestorDirs, - isPathEqualOrDescendant, - normalizeAbsolutePath -} from './file-explorer-paths' +import { getRevealAncestorDirs, isPathEqualOrDescendant } from './file-explorer-paths' describe('file explorer path helpers', () => { - it('preserves UNC roots while normalizing separators', () => { - expect(normalizeAbsolutePath('\\\\Server\\Share\\Repo\\')).toBe('//Server/Share/Repo') - }) - it('matches Windows drive paths case-insensitively with segment boundaries', () => { expect(isPathEqualOrDescendant('c:\\repo\\src\\a.ts', 'C:\\Repo')).toBe(true) expect(isPathEqualOrDescendant('C:\\Repository\\src\\a.ts', 'C:\\Repo')).toBe(false) @@ -42,4 +34,19 @@ describe('file explorer path helpers', () => { 'C:\\repo\\src' ]) }) + + it.each(['/repo', '/ssh/repo\\root'])( + 'reveals POSIX literal-backslash names without inventing directories under %s', + (root) => { + expect(getRevealAncestorDirs(root, `${root}/a\\b.txt`)).toEqual([]) + expect(getRevealAncestorDirs(root, `${root}/a/b.txt`)).toEqual([`${root}/a`]) + expect(getRevealAncestorDirs(root, `${root}/a\\b/c\\d.txt`)).toEqual([`${root}/a\\b`]) + } + ) + + it('reveals Windows UNC paths with native separators', () => { + expect( + getRevealAncestorDirs('\\\\server\\share\\repo', '\\\\server\\share\\repo\\a\\b.txt') + ).toEqual(['\\\\server\\share\\repo\\a']) + }) }) diff --git a/src/renderer/src/components/right-sidebar/file-explorer-paths.ts b/src/renderer/src/components/right-sidebar/file-explorer-paths.ts index 1c2fa044117..ce2f64d8fc3 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-paths.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-paths.ts @@ -1,26 +1,11 @@ -import { joinPath, normalizeRelativePath } from '@/lib/path' +import { joinPath } from '@/lib/path' import { isPathInsideOrEqual, normalizeRuntimePathForComparison, - normalizeRuntimePathSeparators, relativePathInsideRoot } from '../../../../shared/cross-platform-path' import { splitPathSegments } from './path-tree' -export function normalizeAbsolutePath(path: string): string { - const normalizedPath = normalizeRuntimePathSeparators(path) - - if (normalizedPath === '/') { - return normalizedPath - } - - if (/^[A-Za-z]:\/$/.test(normalizedPath)) { - return normalizedPath - } - - return normalizedPath.replace(/\/+$/, '') -} - export function normalizeAbsolutePathForComparison(path: string): string { return normalizeRuntimePathForComparison(path) } @@ -35,7 +20,7 @@ export function getRevealAncestorDirs(worktreePath: string, filePath: string): s return null } - const segments = splitPathSegments(normalizeRelativePath(relativePath)) + const segments = splitPathSegments(relativePath, worktreePath) const ancestorDirs: string[] = [] let currentPath = worktreePath diff --git a/src/renderer/src/components/right-sidebar/file-explorer-watch-filename-identity.test.ts b/src/renderer/src/components/right-sidebar/file-explorer-watch-filename-identity.test.ts new file mode 100644 index 00000000000..f28474f5913 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/file-explorer-watch-filename-identity.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import { useAppStore } from '@/store' +import { clearStalePendingReveal } from './file-explorer-watcher-reconcile' +import { + canonicalizeFileExplorerWatchPath, + getExternalFileChangeRelativePath, + normalizeExplorerAbsolutePath, + parentDirForWatchPath +} from './file-explorer-watch-path' + +describe('Explorer watcher filename identity', () => { + it('does not clear a nested pending reveal when a distinct literal-backslash path is deleted', () => { + const previous = useAppStore.getState().pendingExplorerReveal + const pending = { worktreeId: 'wt-posix', filePath: '/repo/a/b/file.txt', requestId: 1 } + try { + useAppStore.setState({ pendingExplorerReveal: pending }) + clearStalePendingReveal('/repo/a\\b') + expect(useAppStore.getState().pendingExplorerReveal).toEqual(pending) + clearStalePendingReveal('/repo/a/b') + expect(useAppStore.getState().pendingExplorerReveal).toBeNull() + } finally { + useAppStore.setState({ pendingExplorerReveal: previous }) + } + }) + + it.each(['/repo', '/ssh/repo\\'])( + 'preserves POSIX backslashes in watched roots, filenames and parents under %s', + (root) => { + expect(normalizeExplorerAbsolutePath(`${root}/`)).toBe(root) + expect(canonicalizeFileExplorerWatchPath(root, `${root}/a\\b.txt`)).toBe(`${root}/a\\b.txt`) + expect(getExternalFileChangeRelativePath(root, `${root}/a\\b.txt`, false)).toBe('a\\b.txt') + expect(parentDirForWatchPath(`${root}/a\\b.txt`)).toBe(root) + expect(parentDirForWatchPath(`${root}/a\\/b.txt`)).toBe(`${root}/a\\`) + } + ) + + it.each(['C:\\repo', '\\\\server\\share\\repo'])( + 'retains Windows watcher separator semantics under %s', + (root) => { + expect(normalizeExplorerAbsolutePath(`${root}\\`)).toBe(root) + expect(getExternalFileChangeRelativePath(root, `${root}\\a\\b.txt`, false)).toBe('a/b.txt') + expect(parentDirForWatchPath(`${root}\\a\\b.txt`)).toBe(`${root}\\a`) + } + ) +}) diff --git a/src/renderer/src/components/right-sidebar/file-explorer-watch-path.ts b/src/renderer/src/components/right-sidebar/file-explorer-watch-path.ts index 4066bca2d81..9988d9a9387 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-watch-path.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-watch-path.ts @@ -1,11 +1,14 @@ import { joinPath, dirname, normalizeRelativePath } from '@/lib/path' import { + isWindowsAbsolutePathLike, normalizeRuntimePathForComparison, relativePathInsideRoot } from '../../../../shared/cross-platform-path' export function normalizeExplorerAbsolutePath(path: string): string { - return path === '/' || /^[A-Za-z]:[\\/]$/.test(path) ? path : path.replace(/[\\/]+$/, '') + return path === '/' || /^[A-Za-z]:[\\/]$/.test(path) + ? path + : path.replace(isWindowsAbsolutePathLike(path) ? /[\\/]+$/ : /\/+$/, '') } export function getExternalFileChangeRelativePath( @@ -23,7 +26,7 @@ export function getExternalFileChangeRelativePath( } // Why: EditorPanel reloads tabs only from a worktree-relative path, not the watcher's absolute one; normalize or contents go stale. - return normalizeRelativePath(relativePath) + return normalizeRelativePath(relativePath, worktreePath) } export function canonicalizeFileExplorerWatchPath( @@ -87,6 +90,10 @@ export function resolveCachedDirPath( } export function parentDirForWatchPath(normalizedPath: string): string { + if (!isWindowsAbsolutePathLike(normalizedPath)) { + const separator = normalizedPath.lastIndexOf('/') + return separator === -1 ? '.' : normalizedPath.slice(0, separator) || '/' + } const parentPath = dirname(normalizedPath) if (/^[A-Za-z]:$/.test(parentPath)) { return `${parentPath}${normalizedPath.includes('\\') ? '\\' : '/'}` diff --git a/src/renderer/src/components/right-sidebar/file-explorer-watcher-reconcile.ts b/src/renderer/src/components/right-sidebar/file-explorer-watcher-reconcile.ts index 0f151c54295..6072cdb201f 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-watcher-reconcile.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-watcher-reconcile.ts @@ -1,6 +1,6 @@ import type { Dispatch, SetStateAction } from 'react' import type { DirCache } from './file-explorer-types' -import { normalizeAbsolutePath, isPathEqualOrDescendant } from './file-explorer-paths' +import { isPathEqualOrDescendant } from './file-explorer-paths' import { useAppStore } from '@/store' import { normalizeRuntimePathForComparison } from '../../../../shared/cross-platform-path' @@ -103,14 +103,10 @@ export function purgeExpandedDirsSubtrees( // would cause the reveal logic to expand stale ancestor directories. export function clearStalePendingReveal(deletedPath: string): void { - const normalized = normalizeAbsolutePath(deletedPath) useAppStore.setState((state) => { if ( state.pendingExplorerReveal && - isPathEqualOrDescendant( - normalizeAbsolutePath(state.pendingExplorerReveal.filePath), - normalized - ) + isPathEqualOrDescendant(state.pendingExplorerReveal.filePath, deletedPath) ) { return { pendingExplorerReveal: null } } diff --git a/src/renderer/src/components/right-sidebar/path-tree.ts b/src/renderer/src/components/right-sidebar/path-tree.ts index 133d2ec50a7..c983d4d4859 100644 --- a/src/renderer/src/components/right-sidebar/path-tree.ts +++ b/src/renderer/src/components/right-sidebar/path-tree.ts @@ -1,3 +1,5 @@ -export function splitPathSegments(path: string): string[] { - return path.split(/[\\/]+/).filter(Boolean) +import { normalizeRelativePath } from '@/lib/path' + +export function splitPathSegments(path: string, rootPath?: string | null): string[] { + return normalizeRelativePath(path, rootPath).split('/').filter(Boolean) } diff --git a/src/renderer/src/components/right-sidebar/status-display.ts b/src/renderer/src/components/right-sidebar/status-display.ts index 2af97edd531..978d4cc98aa 100644 --- a/src/renderer/src/components/right-sidebar/status-display.ts +++ b/src/renderer/src/components/right-sidebar/status-display.ts @@ -124,14 +124,17 @@ export function shouldShowIgnoredDecoration( return !nodeStatus && isPathIgnored(ignored, relativePath) } -export function buildIgnoredSet(ignoredPaths: readonly string[] | undefined): Set { +export function buildIgnoredSet( + ignoredPaths: readonly string[] | undefined, + rootPath?: string | null +): Set { const set = new Set() if (!ignoredPaths) { return set } for (const rawPath of ignoredPaths) { - const trimmed = rawPath.endsWith('/') ? rawPath.slice(0, -1) : rawPath - set.add(normalizeRelativePath(trimmed)) + const path = normalizeRelativePath(rawPath, rootPath) + set.add(path.endsWith('/') ? path.slice(0, -1) : path) } return set } diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.ts b/src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.ts index afefa839b41..8c0e039b187 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.ts @@ -49,7 +49,7 @@ export function getFileExplorerIgnoredQueryRelativePaths( return } for (const row of cached.children) { - if (!showDotfiles && isDotfileRelativePath(row.relativePath)) { + if (!showDotfiles && isDotfileRelativePath(row.relativePath, worktreePath)) { continue } relativePaths.push(row.relativePath) @@ -88,7 +88,7 @@ export function createVisibleFileExplorerRowProjection( } const shouldHideRow = (row: TreeNode): boolean => { - if (!options.showDotfiles && isDotfileRelativePath(row.relativePath)) { + if (!options.showDotfiles && isDotfileRelativePath(row.relativePath, worktreePath)) { return true } return !options.showGitIgnoredFiles && isPathIgnored(options.ignoredSet, row.relativePath) @@ -179,7 +179,11 @@ export function useFileExplorerVisibleRowProjection( () => activeRepoSupportsGit ? nameFilter - ? getFileExplorerNameFilterIgnoredQueryRelativePaths(nameFilter, showDotfiles) + ? getFileExplorerNameFilterIgnoredQueryRelativePaths( + nameFilter, + showDotfiles, + worktreePath + ) : getFileExplorerIgnoredQueryRelativePaths( { dirCache, expanded, worktreePath, displayRootPath }, showDotfiles @@ -211,7 +215,10 @@ export function useFileExplorerVisibleRowProjection( shouldDebounceIgnoredQuery, worktreePath }) - const ignoredSet = useMemo(() => buildIgnoredSet(effectiveIgnoredPaths), [effectiveIgnoredPaths]) + const ignoredSet = useMemo( + () => buildIgnoredSet(effectiveIgnoredPaths, worktreePath), + [effectiveIgnoredPaths, worktreePath] + ) const rowProjection = useMemo( () => createVisibleFileExplorerRowProjection( diff --git a/src/renderer/src/components/right-sidebar/useFileSearchPanel.ts b/src/renderer/src/components/right-sidebar/useFileSearchPanel.ts index e66e647c26f..a1ff15bf55b 100644 --- a/src/renderer/src/components/right-sidebar/useFileSearchPanel.ts +++ b/src/renderer/src/components/right-sidebar/useFileSearchPanel.ts @@ -21,6 +21,7 @@ export type FileSearchPanelModel = { filtersProps: SearchFiltersProps resultsProps: { results: SearchResult | null + error?: string | null hasCommittedResults: boolean query: string loading: boolean @@ -132,7 +133,7 @@ export function useFileSearchPanel(explorerView: 'files' | 'search'): FileSearch useEffect(() => { if (!worktreePath) { cancelPendingSearch() - updateActiveSearchState({ results: null, resultOwner: null }) + updateActiveSearchState({ results: null, resultOwner: null, error: null }) } }, [worktreePath, cancelPendingSearch, updateActiveSearchState]) @@ -281,6 +282,7 @@ export function useFileSearchPanel(explorerView: 'files' | 'search'): FileSearch }, resultsProps: { results: deferredSearchResults.results, + error: searchState?.error, hasCommittedResults: fileSearchResults !== null, query: fileSearchQuery, loading: fileSearchLoading, diff --git a/src/renderer/src/components/right-sidebar/useFileSearchRunner.test.tsx b/src/renderer/src/components/right-sidebar/useFileSearchRunner.test.tsx index c68f5effa4e..5615a0e4ab8 100644 --- a/src/renderer/src/components/right-sidebar/useFileSearchRunner.test.tsx +++ b/src/renderer/src/components/right-sidebar/useFileSearchRunner.test.tsx @@ -163,4 +163,53 @@ describe('useFileSearchRunner result ownership', () => { resultOwner: { worktreeId, runtimeEnvironmentId: null } }) }) + + it('shows an active failure and clears it when the next search succeeds or is empty', async () => { + const worktreeId = 'missing-repo::/repo' + const { hook, updates } = renderSearchRunner( + { settings: {}, repos: [], worktreesByRepo: {}, fileSearchStateByWorktree: {} }, + worktreeId + ) + const log = vi.spyOn(console, 'error').mockImplementation(() => {}) + mocks.searchRuntimeFiles.mockRejectedValueOnce( + new Error("Error invoking remote method 'search': Error: regex parse error\nUnclosed group") + ) + await finishSearch(hook.result.current.executeSearch) + expect(Object.assign({}, ...updates)).toMatchObject({ + error: 'regex parse error\nUnclosed group', + results: null, + loading: false + }) + + await finishSearch(hook.result.current.executeSearch) + expect(Object.assign({}, ...updates)).toMatchObject({ error: null, results: RESULTS }) + act(() => hook.result.current.executeSearch('')) + expect(Object.assign({}, ...updates)).toMatchObject({ error: null, results: null }) + log.mockRestore() + }) + + it('ignores an older rejection after a newer search has succeeded', async () => { + const { hook, updates } = renderSearchRunner( + { settings: {}, repos: [], worktreesByRepo: {}, fileSearchStateByWorktree: {} }, + 'missing-repo::/repo' + ) + const log = vi.spyOn(console, 'error').mockImplementation(() => {}) + let rejectOldSearch: (reason: Error) => void = () => {} + mocks.searchRuntimeFiles.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectOldSearch = reject + }) + ) + await finishSearch(hook.result.current.executeSearch) + await finishSearch(hook.result.current.executeSearch) + await act(async () => rejectOldSearch(new Error('old failure'))) + expect(Object.assign({}, ...updates)).toMatchObject({ + error: null, + results: RESULTS, + loading: false + }) + expect(updates.some((update) => update.error === 'old failure')).toBe(false) + log.mockRestore() + }) }) diff --git a/src/renderer/src/components/right-sidebar/useFileSearchRunner.ts b/src/renderer/src/components/right-sidebar/useFileSearchRunner.ts index 7950a86114f..24d44d7a3aa 100644 --- a/src/renderer/src/components/right-sidebar/useFileSearchRunner.ts +++ b/src/renderer/src/components/right-sidebar/useFileSearchRunner.ts @@ -1,3 +1,5 @@ +import { readIpcErrorDetail } from '@/lib/ipc-error' +import { translate } from '@/i18n/i18n' import { useCallback, useEffect, useRef } from 'react' import { getConnectionId } from '@/lib/connection-context' import { @@ -17,6 +19,7 @@ const SEARCH_DEBOUNCE_MS = 300 const SEARCH_MAX_RESULTS = 2000 type UpdateSearchState = (updates: { + error?: string | null loading?: boolean results?: SearchResult | null resultOwner?: FileSearchResultOwner | null @@ -54,6 +57,7 @@ export function useFileSearchRunner({ (query: string) => { latestSearchIdRef.current += 1 const searchId = latestSearchIdRef.current + updateActiveSearchState({ error: null }) if (searchTimerRef.current) { clearTimeout(searchTimerRef.current) @@ -138,7 +142,10 @@ export function useFileSearchRunner({ console.error('Search failed:', err) if (latestSearchIdRef.current === searchId) { updateActiveSearchState({ - results: { files: [], totalMatches: 0, truncated: false }, + results: null, + error: + readIpcErrorDetail(err) ?? + translate('fileSearch.failed', 'Search failed. Try again.'), resultOwner }) } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 6c160e408dc..9bc69d7501d 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18888,5 +18888,8 @@ "removeDescription": "Stop agents using this profile first. Removal deletes its saved credentials and conversation data. Your system login stays unchanged.", "cancel": "Cancel" } + }, + "fileSearch": { + "failed": "Search failed. Try again." } } diff --git a/src/renderer/src/lib/path.test.ts b/src/renderer/src/lib/path.test.ts index 42f4d59581e..080427be04e 100644 --- a/src/renderer/src/lib/path.test.ts +++ b/src/renderer/src/lib/path.test.ts @@ -49,3 +49,11 @@ describe('getRelativePathInsideRoot', () => { ) }) }) + +it.each(['/native/repo\\root', '/ssh/repo\\root'])( + 'joins POSIX names without merging literal-backslash identities under %s', + (root) => { + expect(joinPath(root, 'a\\b.txt')).toBe(`${root}/a\\b.txt`) + expect(joinPath(root, 'a/b.txt')).toBe(`${root}/a/b.txt`) + } +) diff --git a/src/renderer/src/lib/path.ts b/src/renderer/src/lib/path.ts index 284ee247ad2..6e03992cd6e 100644 --- a/src/renderer/src/lib/path.ts +++ b/src/renderer/src/lib/path.ts @@ -1,4 +1,7 @@ -import { relativePathInsideRoot } from '../../../shared/cross-platform-path' +import { + isWindowsAbsolutePathLike, + relativePathInsideRoot +} from '../../../shared/cross-platform-path' function stripTrailingSeparators(path: string): string { return path.replace(/[\\/]+$/, '') @@ -12,7 +15,10 @@ function getSeparator(path: string): '/' | '\\' { return path.includes('\\') ? '\\' : '/' } -export function normalizeRelativePath(path: string): string { +export function normalizeRelativePath(path: string, rootPath?: string | null): string { + if (rootPath && !isWindowsAbsolutePathLike(rootPath)) { + return path.replace(/^\/+/, '').replace(/\/+/g, '/') + } return stripLeadingSeparators(path).replace(/[\\/]+/g, '/') } @@ -74,9 +80,14 @@ export function joinPath(basePath: string, relativePath: string): string { return basePath } - const separator = getSeparator(basePath) - const normalizedBasePath = stripTrailingSeparators(basePath) - const normalizedRelativePath = stripLeadingSeparators(relativePath).replace(/[\\/]+/g, separator) + const windowsPath = isWindowsAbsolutePathLike(basePath) + const separator = windowsPath ? getSeparator(basePath) : '/' + const normalizedBasePath = windowsPath + ? stripTrailingSeparators(basePath) + : basePath.replace(/\/+$/, '') + const normalizedRelativePath = windowsPath + ? stripLeadingSeparators(relativePath).replace(/[\\/]+/g, separator) + : relativePath.replace(/^\/+/, '').replace(/\/+/g, '/') return `${normalizedBasePath}${separator}${normalizedRelativePath}` } diff --git a/src/renderer/src/store/slices/editor/actions/file-search-actions.ts b/src/renderer/src/store/slices/editor/actions/file-search-actions.ts index d68a7618298..4e8ef519ceb 100644 --- a/src/renderer/src/store/slices/editor/actions/file-search-actions.ts +++ b/src/renderer/src/store/slices/editor/actions/file-search-actions.ts @@ -39,6 +39,7 @@ export function createFileSearchActions( results: null, resultOwner: null, loading: false, + error: null, collapsedFiles: new Set(), seedRequestId: (current.seedRequestId ?? 0) + 1 } @@ -57,6 +58,7 @@ export function createFileSearchActions( results: null, resultOwner: null, loading: false, + error: null, collapsedFiles: new Set(), seedRequestId: (current.seedRequestId ?? 0) + 1 } @@ -112,6 +114,7 @@ export function createFileSearchActions( results: null, resultOwner: null, loading: false, + error: null, collapsedFiles: new Set() } } diff --git a/src/renderer/src/store/slices/editor/search/file-search-state.ts b/src/renderer/src/store/slices/editor/search/file-search-state.ts index 063bb379fad..dba481e1f09 100644 --- a/src/renderer/src/store/slices/editor/search/file-search-state.ts +++ b/src/renderer/src/store/slices/editor/search/file-search-state.ts @@ -10,6 +10,7 @@ const DEFAULT_FILE_SEARCH_STATE = { results: null, resultOwner: null, loading: false, + error: null, collapsedFiles: new Set() } satisfies Omit diff --git a/src/renderer/src/store/slices/editor/types/file-search-worktree-state.ts b/src/renderer/src/store/slices/editor/types/file-search-worktree-state.ts index a59d9c1e5c6..d70c0399ce4 100644 --- a/src/renderer/src/store/slices/editor/types/file-search-worktree-state.ts +++ b/src/renderer/src/store/slices/editor/types/file-search-worktree-state.ts @@ -10,6 +10,7 @@ export type FileSearchWorktreeState = { excludePattern: string results: SearchResult | null resultOwner: FileSearchResultOwner | null + error?: string | null loading: boolean collapsedFiles: Set seedRequestId?: number diff --git a/src/shared/quick-open-filter.test.ts b/src/shared/quick-open-filter.test.ts index e142c8ef765..e6b65c8d2bb 100644 --- a/src/shared/quick-open-filter.test.ts +++ b/src/shared/quick-open-filter.test.ts @@ -225,6 +225,18 @@ describe('buildRgArgsForQuickOpen', () => { }) }) +it('pins both Quick Open passes to config-independent NUL output', () => { + const { primary, ignoredPass } = buildRgArgsForQuickOpen({ + searchRoot: '.', + excludePathPrefixes: [], + forceSlashSeparator: true + }) + for (const args of [primary, ignoredPass]) { + expect(args).toContain('--no-config') + expect(args).toContain('--null') + } +}) + describe('normalizeQuickOpenRgLine', () => { it('strips absolute root prefix', () => { expect( @@ -266,9 +278,9 @@ describe('normalizeQuickOpenRgLine', () => { expect(normalizeQuickOpenRgLine('..', { kind: 'cwd-relative' })).toBeNull() }) - it('strips CRLF', () => { + it('preserves a trailing carriage return in a NUL-delimited filename', () => { expect(normalizeQuickOpenRgLine('/root/a.ts\r', { kind: 'absolute', rootPath: '/root' })).toBe( - 'a.ts' + 'a.ts\r' ) }) diff --git a/src/shared/quick-open-filter.ts b/src/shared/quick-open-filter.ts index ffb1ac346ef..7c4da971a01 100644 --- a/src/shared/quick-open-filter.ts +++ b/src/shared/quick-open-filter.ts @@ -6,7 +6,7 @@ * Centralized to stop local/relay listFiles from drifting on blocklist, ignores, exclusions, * timeouts, and buffering. See docs/design/share-quick-open-file-listing.md. */ -import { relativePathInsideRoot } from './cross-platform-path' +import { isWindowsAbsolutePathLike, relativePathInsideRoot } from './cross-platform-path' // ─── Hidden-dir blocklist ──────────────────────────────────────────── @@ -93,7 +93,7 @@ export function buildExcludePathPrefixes(rootPath: string, excludePaths?: unknow if (relativePath === null) { continue } - let rel = relativePath.replace(/\\/g, '/') + let rel = isWindowsAbsolutePathLike(rootPath) ? relativePath.replace(/\\/g, '/') : relativePath if (!rel || isParentRelativePath(rel) || rel.startsWith('/')) { continue } @@ -204,6 +204,8 @@ export function buildRgArgsForQuickOpen(opts: RgArgsOptions): RgArgs { const primary = [ '--files', + '--no-config', + '--null', '--hidden', ...sepArgs, ...hiddenDirGlobs, @@ -214,6 +216,8 @@ export function buildRgArgsForQuickOpen(opts: RgArgsOptions): RgArgs { // Ignored pass: --no-ignore-vcs broadens to gitignored/parent/global ignored files; blocklist globs still guard. const ignoredPass = [ '--files', + '--no-config', + '--null', '--hidden', '--no-ignore-vcs', ...sepArgs, @@ -234,20 +238,18 @@ export type RgOutputMode = | { kind: 'cwd-relative' } /** - * Convert one rg --files stdout line into a root-relative, `/`-separated path. - * Returns `null` for lines that escape the root (symlink edge cases) or can't be normalized. + * Convert one NUL-delimited rg --files record into a root-relative, `/`-separated path. + * Returns `null` for records that escape the root (symlink edge cases) or can't be normalized. * Callers do any WSL translation first, keeping WSL out of the shared module. */ export function normalizeQuickOpenRgLine(rawLine: string, outputMode: RgOutputMode): string | null { - let line = rawLine - // Strip CR so CRLF from rg on Windows doesn't leak into results. - if (line.length > 0 && line.charCodeAt(line.length - 1) === 13) { - line = line.substring(0, line.length - 1) - } + const line = rawLine if (!line) { return null } - const normalized = line.replace(/\\/g, '/') + const windowsPath = + outputMode.kind === 'absolute' && isWindowsAbsolutePathLike(outputMode.rootPath) + const normalized = windowsPath ? line.replace(/\\/g, '/') : line if (outputMode.kind === 'cwd-relative') { let rel = normalized if (rel.startsWith('./')) { @@ -262,7 +264,8 @@ export function normalizeQuickOpenRgLine(rawLine: string, outputMode: RgOutputMo } // Absolute mode: strip the root prefix. // Why: only replace backslashes; collapsing repeated slashes would break Windows UNC roots (`\\server\share`). - const normalizedRoot = `${outputMode.rootPath.replace(/\\/g, '/').replace(/\/+$/, '')}/` + const root = windowsPath ? outputMode.rootPath.replace(/\\/g, '/') : outputMode.rootPath + const normalizedRoot = `${root.replace(/\/+$/, '')}/` if (normalized.startsWith(normalizedRoot)) { const rel = normalized.substring(normalizedRoot.length) if (!rel || isParentRelativePath(rel) || rel.startsWith('/')) { diff --git a/src/shared/quick-open-ripgrep-output-mode.ts b/src/shared/quick-open-ripgrep-output-mode.ts new file mode 100644 index 00000000000..e3a8f1541a1 --- /dev/null +++ b/src/shared/quick-open-ripgrep-output-mode.ts @@ -0,0 +1,14 @@ +import type { RgOutputMode } from './quick-open-filter' + +export function getQuickOpenRgOutputMode( + rawLine: string, + translatedLine: string, + rootPath: string +): RgOutputMode { + return translatedLine !== rawLine || + rawLine.startsWith('/') || + /^[A-Za-z]:[\\/]/.test(rawLine) || + rawLine.startsWith('\\\\') + ? { kind: 'absolute', rootPath } + : { kind: 'cwd-relative' } +} diff --git a/src/shared/ripgrep-dense-match-json.test.ts b/src/shared/ripgrep-dense-match-json.test.ts new file mode 100644 index 00000000000..5a4cdd41e29 --- /dev/null +++ b/src/shared/ripgrep-dense-match-json.test.ts @@ -0,0 +1,78 @@ +import { expect, it } from 'vitest' +import { JSONParser } from '@streamparser/json' +import { parseDenseRipgrepMatchJson } from './ripgrep-dense-match-json' + +it.each([0, 64 * 1024])('preserves literal and escaped BOMs with buffer size %i', (size) => { + const source = { '\ufeffkey': `\ufeffstart${'\n'.repeat(64 * 1024)}\ufeffend` } + const literal = JSON.stringify(source) + for (const record of [literal, literal.replaceAll('\ufeff', '\\uFEFF')]) { + const parser = new JSONParser({ stringBufferSize: size }) + let parsed: unknown + parser.onValue = ({ value, stack }) => { + if (stack.length === 0) { + parsed = value + } + } + parser.write(record) + expect(parsed).toEqual(JSON.parse(record)) + } +}) + +it.each(['', '\\', '\n', '\n'.repeat(64 * 1024), `${'x'.repeat(64 * 1024)}\n`])( + 'preserves U+FEFF in text and filenames across string-buffer boundaries (%#)', + (prefix) => { + const text = `${prefix}\ufeff😀x` + const source = { + type: 'match', + data: { + path: { text }, + lines: { text }, + line_number: 1, + submatches: [{ start: 0, end: 1 }] + } + } + const record = JSON.stringify(source) + expect(parseDenseRipgrepMatchJson(record, 1, 16)).toEqual(JSON.parse(record)) + } +) + +it('retains only exact match fields and the remaining range budget', () => { + const ranges = [ + { start: 0, end: 1 }, + { start: 2, end: 3 } + ] + const source = { + type: 'match', + submatches: [{ start: 99, end: 100 }], + data: { + nested: { submatches: [{ start: 88, end: 89 }] }, + lines: { text: 'a "submatches" b', bytes: 'YQ==' }, + path: { text: '/root/a.ts' }, + line_number: 12, + submatches: ranges + } + } + expect(parseDenseRipgrepMatchJson(JSON.stringify(source), 1, 16)).toEqual({ + type: 'match', + data: { + lines: source.data.lines, + path: source.data.path, + line_number: 12, + submatches: ranges.slice(0, 1) + } + }) +}) + +it('rejects depth overflow and invalid submatch shapes', () => { + expect(() => parseDenseRipgrepMatchJson('['.repeat(17), 2, 16)).toThrow() + expect(() => parseDenseRipgrepMatchJson('{"data":{"submatches":[null]}}', 2, 16)).toThrow() +}) + +it.each(['{}', '{"type":"begin","data":{}}', '{"data":null}', '{"data":[]}'])( + 'does not fabricate a match from %s', + (source) => { + const projected = parseDenseRipgrepMatchJson(source, 2, 16) + expect(projected.data?.path).toBeUndefined() + expect(projected.data?.submatches).toEqual([]) + } +) diff --git a/src/shared/ripgrep-dense-match-json.ts b/src/shared/ripgrep-dense-match-json.ts new file mode 100644 index 00000000000..821415a0748 --- /dev/null +++ b/src/shared/ripgrep-dense-match-json.ts @@ -0,0 +1,135 @@ +import { + assertJsonTextStructureWithinLimits, + JsonTextStructureCapacityError, + type JsonTextStructureLimits +} from './json-text-structure-limit' +import { JSONParser, TokenType } from '@streamparser/json' + +export type RipgrepMatchMessage = { + type?: string + data?: { + path?: { text?: string } + lines?: { text?: string; bytes?: string } + line_number?: number + submatches?: { start: number; end: number }[] + } +} + +export function parseRipgrepMatchJson( + line: string, + maxMatches: number, + limits: JsonTextStructureLimits +): RipgrepMatchMessage { + try { + assertJsonTextStructureWithinLimits(line, limits) + return JSON.parse(line) + } catch (error) { + if ( + !(error instanceof JsonTextStructureCapacityError) || + error.resource !== 'structuralTokens' + ) { + throw error + } + return parseDenseRipgrepMatchJson(line, maxMatches, limits.nestingDepth) + } +} + +/** Dense rg records retain only the requested ranges while validating the entire record. */ +export function parseDenseRipgrepMatchJson( + line: string, + maxMatches: number, + nestingDepth: number +): RipgrepMatchMessage { + const parser = new JSONParser({ + paths: [ + '$.type', + '$.data.path.text', + '$.data.lines.text', + '$.data.lines.bytes', + '$.data.line_number', + '$.data.submatches.*' + ], + keepStack: false, + stringBufferSize: 64 * 1024 + }) + const data: NonNullable = { submatches: [] } + const result: RipgrepMatchMessage = { data } + const containers: { object: boolean; expectingKey: boolean; keys?: Set }[] = [] + let elementTokens = 0 + parser.onToken = ({ token, value }) => { + const current = containers.at(-1) + if (current?.object && current.expectingKey && token === TokenType.STRING) { + if (typeof value !== 'string') { + throw new SyntaxError('Invalid rg object key') + } + if (current.keys?.has(value)) { + throw new SyntaxError('Duplicate rg object key') + } + current.keys?.add(value) + if ((current.keys?.size ?? 0) > 128) { + throw new Error('Too many rg record fields') + } + current.expectingKey = false + } + if (token === TokenType.LEFT_BRACE || token === TokenType.LEFT_BRACKET) { + containers.push({ + object: token === TokenType.LEFT_BRACE, + expectingKey: token === TokenType.LEFT_BRACE, + // rg's envelope keys are unique; reject duplicates instead of mixing projections. + keys: containers.length < 2 ? new Set() : undefined + }) + if (containers.length > nestingDepth) { + throw new Error('rg record nesting exceeds limit') + } + if (containers.length === 4) { + elementTokens = 0 + } + } else if (token === TokenType.RIGHT_BRACE || token === TokenType.RIGHT_BRACKET) { + containers.pop() + } else if (token === TokenType.COMMA && current?.object) { + current.expectingKey = true + } + if (containers.length >= 4 && ++elementTokens > 32 * 1024) { + throw new Error('rg submatch structure exceeds limit') + } + } + parser.onValue = ({ key, value, parent, stack }) => { + if (stack.length === 1 && key === 'type' && typeof value === 'string') { + result.type = value + } else if (stack.length === 2 && stack[1].key === 'data' && key === 'line_number') { + if (typeof value === 'number') { + data.line_number = value + } + } else if (stack.length === 3 && stack[1].key === 'data') { + if (stack[2].key === 'submatches' && Array.isArray(parent)) { + if ( + !value || + typeof value !== 'object' || + Array.isArray(value) || + typeof value.start !== 'number' || + typeof value.end !== 'number' + ) { + throw new SyntaxError('Invalid rg submatch') + } + if (data.submatches && data.submatches.length < maxMatches) { + data.submatches.push({ start: value.start, end: value.end }) + } + parent.pop() + } else if (stack[2].key === 'path' && key === 'text' && typeof value === 'string') { + data.path = { text: value } + } else if (stack[2].key === 'lines' && typeof value === 'string') { + if (key === 'text') { + data.lines = { ...data.lines, text: value } + } + if (key === 'bytes') { + data.lines = { ...data.lines, bytes: value } + } + } + } + } + parser.write(line) + if (!parser.isEnded) { + parser.end() + } + return result +} diff --git a/src/shared/ripgrep-filename-decoder.test.ts b/src/shared/ripgrep-filename-decoder.test.ts new file mode 100644 index 00000000000..57af67e1abe --- /dev/null +++ b/src/shared/ripgrep-filename-decoder.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import { RipgrepFilenameDecoder, RipgrepFilenameEncodingError } from './ripgrep-filename-decoder' + +describe('ripgrep filename decoding', () => { + it('preserves split scalars, BOMs and literal replacement characters', () => { + const decoder = new RipgrepFilenameDecoder() + const text = '\uFEFF日本語😀\uFFFD\0' + let decoded = '' + for (const byte of Buffer.from(text)) { + decoded += decoder.decode(Buffer.from([byte])) + } + decoder.finish() + expect(decoded).toBe(text) + }) + + it.each([[0xff], [0x80], [0xc0, 0xaf], [0xed, 0xa0, 0x80]])( + 'refuses invalid filename bytes %j', + (...bytes) => { + expect(() => new RipgrepFilenameDecoder().decode(Buffer.from(bytes))).toThrow( + RipgrepFilenameEncodingError + ) + } + ) + + it('rejects an incomplete scalar at EOF', () => { + const decoder = new RipgrepFilenameDecoder() + expect(decoder.decode(Buffer.from([0xf0, 0x9f]))).toBe('') + expect(() => decoder.finish()).toThrow('not valid UTF-8') + }) + + it('accepts string fixtures without losing decoder state', () => { + const decoder = new RipgrepFilenameDecoder() + expect(decoder.decode('literal-�\0')).toBe('literal-�\0') + decoder.finish() + }) +}) + +it('refuses literal WSL backslashes only when Windows translation is required', () => { + const errors: Error[] = [] + const decoder = new RipgrepFilenameDecoder((error) => errors.push(error), true) + expect(decoder.decode(Buffer.from('./a\\b.txt\0'))).toBeNull() + expect(errors[0]?.message).toContain('WSL filenames containing a backslash') + expect(new RipgrepFilenameDecoder().decode(Buffer.from('./a\\b.txt\0'))).toBe('./a\\b.txt\0') +}) diff --git a/src/shared/ripgrep-filename-decoder.ts b/src/shared/ripgrep-filename-decoder.ts new file mode 100644 index 00000000000..7f903b3832d --- /dev/null +++ b/src/shared/ripgrep-filename-decoder.ts @@ -0,0 +1,51 @@ +export class RipgrepFilenameError extends Error {} + +export class RipgrepFilenameEncodingError extends RipgrepFilenameError { + constructor() { + super('File listing contains a filename that is not valid UTF-8') + this.name = 'RipgrepFilenameEncodingError' + } +} + +/** Replacement decoding would return a different, potentially existing filename. */ +export class RipgrepFilenameDecoder { + private readonly decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }) + + constructor( + private readonly onError: (error: Error) => void = throwFilenameError, + private readonly rejectBackslash = false + ) {} + + decode(chunk: Buffer | string): string | null { + try { + const decoded = this.decoder.decode(typeof chunk === 'string' ? Buffer.from(chunk) : chunk, { + stream: true + }) + if (this.rejectBackslash && decoded.includes('\\')) { + throw new RipgrepFilenameError( + 'WSL filenames containing a backslash cannot be opened through Windows paths' + ) + } + return decoded + } catch (error) { + this.onError( + error instanceof RipgrepFilenameError ? error : new RipgrepFilenameEncodingError() + ) + return null + } + } + + finish(): boolean { + try { + this.decoder.decode() + return true + } catch { + this.onError(new RipgrepFilenameEncodingError()) + return false + } + } +} + +function throwFilenameError(error: Error): never { + throw error +} diff --git a/src/shared/ripgrep-line-decoding.test.ts b/src/shared/ripgrep-line-decoding.test.ts new file mode 100644 index 00000000000..3c28d76a37c --- /dev/null +++ b/src/shared/ripgrep-line-decoding.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest' +import { decodeRipgrepLine } from './ripgrep-line-decoding' +import { ripgrepMatchRanges } from './ripgrep-match-offsets' + +describe('ripgrep byte line decoding', () => { + it.each([ + [0xff], + [0xc0, 0xaf], + [0xe2, 0x82], + [0xf0, 0x90, 0x80], + [0xed, 0xa0, 0x80], + [0xf4, 0x90, 0x80, 0x80], + [0x80, 0xbf], + [0xe0, 0x80, 0xaf] + ])('maps text after malformed sequence %j like Node UTF8 decoding', (...prefix) => { + const bytes = Buffer.concat([ + Buffer.from('😀é'), + Buffer.from(prefix), + Buffer.from('needle\r\n') + ]) + const decoded = decodeRipgrepLine({ bytes: bytes.toString('base64') }) + expect(decoded.text).toBe(bytes.toString('utf8').replace(/\n$/, '')) + expect(decoded.readOffset(bytes.length - 8)).toBe(decoded.text.indexOf('needle')) + expect(decoded.readOffset(bytes.length - 2)).toBe(decoded.text.indexOf('needle') + 6) + }) + + it('rejects partial replacement boundaries and overlapping ranges without inventing coordinates', () => { + const decoded = decodeRipgrepLine({ + bytes: Buffer.from([0xe2, 0x82, 0x20, 0x78]).toString('base64') + }) + let invalid = 0 + const ranges = [ + ...ripgrepMatchRanges( + decoded.text, + [ + { start: 0, end: 1 }, + { start: 2, end: 3 }, + { start: 2, end: 4 }, + { start: 4, end: 4 } + ], + decoded.readOffset, + () => invalid++ + ) + ] + expect(ranges).toEqual([ + { start: 1, end: 2 }, + { start: 3, end: 3 } + ]) + expect(invalid).toBe(2) + }) + + it('agrees with Node on all two-byte prefixes followed by ASCII', () => { + for (let first = 0; first < 256; first++) { + for (let second = 0; second < 256; second++) { + const bytes = Buffer.from([first, second, 0x78]) + const decoded = decodeRipgrepLine({ bytes: bytes.toString('base64') }) + expect(decoded.readOffset(2)).toBe(bytes.subarray(0, 2).toString('utf8').length) + } + } + }) +}) diff --git a/src/shared/ripgrep-line-decoding.ts b/src/shared/ripgrep-line-decoding.ts new file mode 100644 index 00000000000..c94091855f4 --- /dev/null +++ b/src/shared/ripgrep-line-decoding.ts @@ -0,0 +1,60 @@ +import { createRipgrepOffsetReader } from './ripgrep-match-offsets' + +function utf8SequenceWidth(bytes: Buffer, start: number): number { + const lead = bytes[start]! + const expected = + lead >= 0xc2 && lead <= 0xdf + ? 2 + : lead >= 0xe0 && lead <= 0xef + ? 3 + : lead >= 0xf0 && lead <= 0xf4 + ? 4 + : 1 + for (let offset = 1; offset < expected; offset++) { + const next = bytes[start + offset] + if ( + next === undefined || + next < 0x80 || + next > 0xbf || + (offset === 1 && + ((lead === 0xe0 && next < 0xa0) || + (lead === 0xed && next >= 0xa0) || + (lead === 0xf0 && next < 0x90) || + (lead === 0xf4 && next >= 0x90))) + ) { + return offset + } + } + return expected +} + +function createDecodedByteOffsetReader(bytes: Buffer): (offset: number) => number | null { + let position = 0 + let column = 0 + return (offset) => { + if (!Number.isSafeInteger(offset) || offset < position) { + return null + } + while (position < offset && position < bytes.length) { + const width = utf8SequenceWidth(bytes, position) + position += width + column += width === 4 ? 2 : 1 + } + return offset === position ? column : null + } +} + +/** Decode once; malformed sequences retain their original byte widths for match coordinates. */ +export function decodeRipgrepLine(data: { text?: string; bytes?: string } | undefined): { + text: string + readOffset: (offset: number) => number | null +} { + if (typeof data?.text === 'string') { + return { text: data.text.replace(/\n$/, ''), readOffset: createRipgrepOffsetReader(data.text) } + } + const bytes = Buffer.from(data?.bytes ?? '', 'base64') + return { + text: bytes.toString('utf8').replace(/\n$/, ''), + readOffset: createDecodedByteOffsetReader(bytes) + } +} diff --git a/src/shared/ripgrep-match-offsets.test.ts b/src/shared/ripgrep-match-offsets.test.ts new file mode 100644 index 00000000000..198af0b9ab8 --- /dev/null +++ b/src/shared/ripgrep-match-offsets.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import { createRipgrepOffsetReader, ripgrepMatchRanges } from './ripgrep-match-offsets' + +describe('ripgrep match offsets', () => { + it('converts ordered ASCII, accented, CJK and astral boundaries to UTF16', () => { + const text = 'aé日😀z' + const read = createRipgrepOffsetReader(text) + expect([0, 1, 3, 6, 10, 11].map(read)).toEqual([0, 1, 2, 3, 5, 6]) + expect(read(11)).toBe(6) + }) + + it('rejects invalid, backwards and partial codepoint offsets', () => { + const read = createRipgrepOffsetReader('é😀') + expect(read(-1)).toBeNull() + expect(read(0.5)).toBeNull() + expect(read(1)).toBeNull() + expect(read(2)).toBe(1) + expect(read(0)).toBeNull() + expect(read(6)).toBe(3) + expect(read(7)).toBeNull() + }) + + it('handles many adjacent matches in one pass', () => { + const read = createRipgrepOffsetReader('😀x'.repeat(10_000)) + for (let index = 0; index < 10_000; index++) { + expect(read(index * 5)).toBe(index * 3) + expect(read(index * 5 + 4)).toBe(index * 3 + 2) + } + }) + + it('preserves zero-length matches and whole-codepoint line fallbacks', () => { + expect([...ripgrepMatchRanges('😀é', [{ start: 4, end: 4 }])]).toEqual([{ start: 2, end: 2 }]) + expect([...ripgrepMatchRanges('😀é', [])]).toEqual([{ start: 0, end: 2 }]) + expect([...ripgrepMatchRanges('', [])]).toEqual([{ start: 0, end: 0 }]) + }) +}) diff --git a/src/shared/ripgrep-match-offsets.ts b/src/shared/ripgrep-match-offsets.ts new file mode 100644 index 00000000000..fe667349701 --- /dev/null +++ b/src/shared/ripgrep-match-offsets.ts @@ -0,0 +1,37 @@ +/** Converts ordered ripgrep byte offsets without rescanning each match's prefix. */ +export function createRipgrepOffsetReader(text: string): (byteOffset: number) => number | null { + let bytePosition = 0 + let column = 0 + return (byteOffset) => { + if (!Number.isSafeInteger(byteOffset) || byteOffset < bytePosition) { + return null + } + while (bytePosition < byteOffset && column < text.length) { + const point = text.codePointAt(column)! + bytePosition += point <= 0x7f ? 1 : point <= 0x7ff ? 2 : point <= 0xffff ? 3 : 4 + column += point > 0xffff ? 2 : 1 + } + return bytePosition === byteOffset ? column : null + } +} + +export function* ripgrepMatchRanges( + text: string, + submatches: readonly { start: number; end: number }[], + readOffset = createRipgrepOffsetReader(text), + onInvalidRange?: () => void +): Generator<{ start: number; end: number }> { + if (submatches.length === 0) { + yield { start: 0, end: text.length > 0 ? (text.codePointAt(0)! > 0xffff ? 2 : 1) : 0 } + return + } + for (const submatch of submatches) { + const start = readOffset(submatch.start) + const end = readOffset(submatch.end) + if (start !== null && end !== null) { + yield { start, end } + } else { + onInvalidRange?.() + } + } +} diff --git a/src/shared/ripgrep-search-diagnostics.test.ts b/src/shared/ripgrep-search-diagnostics.test.ts new file mode 100644 index 00000000000..3bacf4678b8 --- /dev/null +++ b/src/shared/ripgrep-search-diagnostics.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' +import { createAccumulator } from './text-search' +import { RipgrepSearchDiagnostics } from './ripgrep-search-diagnostics' + +describe('ripgrep search diagnostics', () => { + it('reports invalid regex diagnostics instead of an empty successful search', () => { + const diagnostics = new RipgrepSearchDiagnostics() + diagnostics.append(Buffer.from('regex parse error: unclosed character class')) + expect(diagnostics.failure(2, null, createAccumulator())?.message).toContain( + 'unclosed character class' + ) + }) + + it('limits retained diagnostics even for large stderr chunks', () => { + const diagnostics = new RipgrepSearchDiagnostics() + diagnostics.append('x'.repeat(100_000)) + diagnostics.append('must not be retained') + const error = diagnostics.failure(2, null, createAccumulator()) + expect(error?.message).toBe(`Search failed (2): ${'x'.repeat(4096)}`) + }) + + it('marks permission-error results as incomplete', () => { + const acc = createAccumulator() + acc.totalMatches = 1 + expect(new RipgrepSearchDiagnostics().failure(2, null, acc)).toBeNull() + expect(acc.truncated).toBe(true) + }) + + it('distinguishes a killed search from intentional truncation', () => { + const diagnostics = new RipgrepSearchDiagnostics() + const acc = createAccumulator() + expect(diagnostics.failure(null, 'SIGTERM', acc)).toBeInstanceOf(Error) + acc.truncated = true + expect(diagnostics.failure(null, 'SIGTERM', acc)).toBeNull() + }) + + it.each([0, 1])('accepts normal exit %i without inventing truncation', (code) => { + const acc = createAccumulator() + expect(new RipgrepSearchDiagnostics().failure(code, null, acc)).toBeNull() + expect(acc.truncated).toBe(false) + }) +}) diff --git a/src/shared/ripgrep-search-diagnostics.ts b/src/shared/ripgrep-search-diagnostics.ts new file mode 100644 index 00000000000..4b5445a79eb --- /dev/null +++ b/src/shared/ripgrep-search-diagnostics.ts @@ -0,0 +1,34 @@ +import type { SearchAccumulator } from './text-search' + +const MAX_SEARCH_ERROR_BYTES = 4096 + +export class RipgrepSearchDiagnostics { + private readonly bytes = Buffer.alloc(MAX_SEARCH_ERROR_BYTES) + private length = 0 + + append(chunk: Buffer | string): void { + if (this.length >= this.bytes.length) { + return + } + this.length += + typeof chunk === 'string' + ? this.bytes.write(chunk, this.length, this.bytes.length - this.length, 'utf8') + : chunk.copy(this.bytes, this.length, 0, this.bytes.length - this.length) + } + + failure( + code: number | null, + signal: NodeJS.Signals | null, + acc: SearchAccumulator + ): Error | null { + if (code === 0 || code === 1 || (signal && acc.truncated)) { + return null + } + if (acc.totalMatches > 0) { + acc.truncated = true + return null + } + const detail = this.bytes.toString('utf8', 0, this.length).trim() + return new Error(`Search failed (${signal ?? code})${detail ? `: ${detail}` : ''}`) + } +} diff --git a/src/shared/text-search-dense-matches.test.ts b/src/shared/text-search-dense-matches.test.ts new file mode 100644 index 00000000000..e32cd6b35e4 --- /dev/null +++ b/src/shared/text-search-dense-matches.test.ts @@ -0,0 +1,126 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { runProcess } from './child-process/run-process' +import { buildRgArgs, createAccumulator, ingestRgJsonLine } from './text-search' + +describe('text search match budgets', () => { + it('keeps dense-line columns after a leading U+FEFF from real rg', async () => { + const { rgPath } = await import('@vscode/ripgrep-universal') + const root = await mkdtemp(join(tmpdir(), 'orca-rg-dense-bom-')) + const filename = join(root, '\ufeffdense.txt') + try { + await writeFile(filename, `header\n\ufeff${'x '.repeat(10_000)}`) + const result = await runProcess({ + program: rgPath, + args: buildRgArgs('x', '.', {}), + cwd: root + }) + expect(result.code).toBe(0) + const accumulator = createAccumulator() + for (const line of result.stdout.split('\n')) { + if (ingestRgJsonLine(line, root, accumulator, 2000) === 'stop') { + break + } + } + expect(accumulator.totalMatches).toBe(2000) + expect(accumulator.fileMap.get(filename)?.matches[0]).toMatchObject({ + line: 2, + column: 2, + matchLength: 1 + }) + expect(accumulator.fileMap.get(filename)?.matches[1999]?.column).toBe(4000) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + it('preserves the requested budget from real rg dense-line output', async () => { + const { rgPath } = await import('@vscode/ripgrep-universal') + const root = await mkdtemp(join(tmpdir(), 'orca-rg-dense-')) + try { + await writeFile(join(root, 'dense.txt'), 'x '.repeat(10_000)) + const result = await runProcess({ + program: rgPath, + args: buildRgArgs('x', root, {}), + cwd: root + }) + expect(result.code).toBe(0) + const accumulator = createAccumulator() + for (const line of result.stdout.split('\n')) { + if (ingestRgJsonLine(line, root, accumulator, 2000) === 'stop') { + break + } + } + expect(accumulator.totalMatches).toBe(2000) + expect(accumulator.truncated).toBe(true) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + it('allows more than 100 matching lines in one file under the global budget', async () => { + const { rgPath } = await import('@vscode/ripgrep-universal') + const root = await mkdtemp(join(tmpdir(), 'orca-rg-lines-')) + try { + await writeFile(join(root, 'many.txt'), 'needle\n'.repeat(150)) + const result = await runProcess({ + program: rgPath, + args: buildRgArgs('needle', root, {}), + cwd: root + }) + expect(result.code).toBe(0) + const accumulator = createAccumulator() + for (const line of result.stdout.split('\n')) { + ingestRgJsonLine(line, root, accumulator, 2000) + } + expect(accumulator.totalMatches).toBe(150) + expect(accumulator.truncated).toBe(false) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) +}) + +function denseRecord(count: number): string { + return JSON.stringify({ + type: 'match', + data: { + path: { text: '/root/dense.txt' }, + lines: { text: 'x '.repeat(count) }, + line_number: 1, + submatches: Array.from({ length: count }, (_, index) => ({ + match: { text: 'x' }, + start: index * 2, + end: index * 2 + 1 + })) + } + }) +} + +it('retains the first 2000 matches from a dense line beyond the normal JSON budget', () => { + const accumulator = createAccumulator() + expect(ingestRgJsonLine(denseRecord(10_000), '/root', accumulator, 2000)).toBe('stop') + expect(accumulator.totalMatches).toBe(2000) + expect(accumulator.truncated).toBe(true) + const matches = accumulator.fileMap.get('/root/dense.txt')?.matches + expect(matches?.map((match) => match.column)).toEqual( + Array.from({ length: 2000 }, (_, index) => index * 2 + 1) + ) +}) + +it.each([ + (record: string) => record.slice(0, -1), + (record: string) => `${record.slice(0, -2)},invalid}`, + (record: string) => `${record.slice(0, -1)},"data":{}}`, + (record: string) => `${record.slice(0, -2)},"submatches":[]}}` +])( + 'rejects invalid tails or duplicate envelope keys without retaining early matches', + (corrupt) => { + const accumulator = createAccumulator() + ingestRgJsonLine(corrupt(denseRecord(10_000)), '/root', accumulator, 2000) + expect(accumulator.totalMatches).toBe(0) + expect(accumulator.truncated).toBe(true) + } +) diff --git a/src/shared/text-search-invalid-filename.test.ts b/src/shared/text-search-invalid-filename.test.ts new file mode 100644 index 00000000000..d9faa51efde --- /dev/null +++ b/src/shared/text-search-invalid-filename.test.ts @@ -0,0 +1,19 @@ +import { expect, it } from 'vitest' +import { createAccumulator, ingestRgJsonLine } from './text-search' + +it('reports byte-only filenames as incomplete without inventing a replacement-character path', () => { + const acc = createAccumulator() + const line = JSON.stringify({ + type: 'match', + data: { + path: { bytes: Buffer.from([0xff, 0x2e, 0x74, 0x78, 0x74]).toString('base64') }, + lines: { text: 'needle\n' }, + line_number: 1, + submatches: [{ start: 0, end: 6 }] + } + }) + expect(ingestRgJsonLine(line, '/root', acc, 20)).toBe('continue') + expect(acc.truncated).toBe(true) + expect(acc.totalMatches).toBe(0) + expect(acc.fileMap.size).toBe(0) +}) diff --git a/src/shared/text-search-paths.test.ts b/src/shared/text-search-paths.test.ts new file mode 100644 index 00000000000..b6fdc3bb1c2 --- /dev/null +++ b/src/shared/text-search-paths.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import { createAccumulator, ingestRgJsonLine } from './text-search' +import { resolveSearchResultPath } from './text-search-paths' + +describe('text search result paths', () => { + it.each([ + ['/root/repo', './src/a.ts', '/root/repo/src/a.ts'], + ['/root/repo', '/root/repo/src/a.ts', '/root/repo/src/a.ts'], + ['C:\\repo', './src/a.ts', 'C:\\repo\\src\\a.ts'], + ['C:\\repo', 'C:/repo/src/a.ts', 'C:/repo/src/a.ts'], + ['\\\\wsl.localhost\\Ubuntu\\repo', './src/a.ts', '\\\\wsl.localhost\\Ubuntu\\repo\\src\\a.ts'] + ])('resolves %s and %s on the owning host', (root, reported, expected) => { + expect(resolveSearchResultPath(root, reported)).toBe(expected) + }) + + it('translates an absolute WSL path before resolving its host path', () => { + const acc = createAccumulator() + ingestRgJsonLine( + JSON.stringify({ + type: 'match', + data: { + path: { text: '/repo/src/a.ts' }, + lines: { text: 'match\n' }, + line_number: 1, + submatches: [{ start: 0, end: 5 }] + } + }), + '\\\\wsl.localhost\\Ubuntu\\repo', + acc, + 20, + (path) => `\\\\wsl.localhost\\Ubuntu${path.replaceAll('/', '\\')}` + ) + expect([...acc.fileMap.values()][0]?.relativePath).toBe('src/a.ts') + expect([...acc.fileMap.keys()]).toEqual(['\\\\wsl.localhost\\Ubuntu\\repo\\src\\a.ts']) + }) +}) diff --git a/src/shared/text-search-paths.ts b/src/shared/text-search-paths.ts index c556a10dedf..4e7cd154158 100644 --- a/src/shared/text-search-paths.ts +++ b/src/shared/text-search-paths.ts @@ -1,20 +1,28 @@ import { posix, win32 } from 'node:path' +import { isWindowsAbsolutePathLike } from './cross-platform-path' function pathFlavor(rootPath: string): typeof posix | typeof win32 { - if (/^[a-zA-Z]:[\\/]/.test(rootPath) || rootPath.startsWith('\\\\')) { + if (isWindowsAbsolutePathLike(rootPath)) { return win32 } return posix } -export function normalizeRelativePath(path: string): string { - return path.replace(/[\\/]+/g, '/').replace(/^\/+/, '') +export function normalizeRelativePath(path: string, rootPath?: string): string { + const separators = + rootPath !== undefined && !isWindowsAbsolutePathLike(rootPath) ? /\/+/g : /[\\/]+/g + return path.replace(separators, '/').replace(/^\/+/, '') } export function relativeToSearchRoot(rootPath: string, absolutePath: string): string { return pathFlavor(rootPath).relative(rootPath, absolutePath) } +export function resolveSearchResultPath(rootPath: string, reportedPath: string): string { + const paths = pathFlavor(rootPath) + return paths.isAbsolute(reportedPath) ? reportedPath : paths.resolve(rootPath, reportedPath) +} + export function joinSearchRoot(rootPath: string, relativePath: string): string { return pathFlavor(rootPath).join(rootPath, relativePath) } diff --git a/src/shared/text-search.ts b/src/shared/text-search.ts index b6f9e9e6b23..c811425a3d0 100644 --- a/src/shared/text-search.ts +++ b/src/shared/text-search.ts @@ -7,13 +7,20 @@ * can't re-diverge (notably the relay's old execFile maxBuffer that dropped matches). * Design doc: docs/design/share-text-search.md. */ -import { assertJsonTextStructureWithinLimits } from './json-text-structure-limit' +import { parseRipgrepMatchJson, type RipgrepMatchMessage } from './ripgrep-dense-match-json' import { normalizeSearchResult } from './search-match-count' import { escapeRegex } from './string-utils' import type { SearchFileResult, SearchOptions, SearchResult } from './code-search-types' import { pushSearchMatch } from './text-search-match-accumulator' import { splitSearchGlobPatterns, toGitGlobPathspecs } from './text-search-glob-patterns' -import { joinSearchRoot, normalizeRelativePath, relativeToSearchRoot } from './text-search-paths' +import { + joinSearchRoot, + normalizeRelativePath, + relativeToSearchRoot, + resolveSearchResultPath +} from './text-search-paths' +import { ripgrepMatchRanges } from './ripgrep-match-offsets' +import { decodeRipgrepLine } from './ripgrep-line-decoding' export type SearchAccumulator = { fileMap: Map @@ -27,7 +34,6 @@ export function createAccumulator(): SearchAccumulator { // ─── Constants shared by both callers ──────────────────────────────── -export const MAX_MATCHES_PER_FILE = 100 export const DEFAULT_SEARCH_MAX_RESULTS = 2000 export const SEARCH_TIMEOUT_MS = 15_000 export const SEARCH_JSON_STRUCTURE_LIMITS = { @@ -51,17 +57,15 @@ export type SearchOptionsLike = Pick< /** * Build the complete rg argv (flags + `--` + query + target) for both callers to spawn as-is. * - * Constraint: pass `rootPath` unchanged as `target` — do NOT WSL-translate it; only the rg - * invocation is routed through `wslAwareSpawn`, and output paths are translated back in `ingestRgJsonLine`. + * Use target `.` with cwd set to the search root so anchored globs match root-relative paths. */ export function buildRgArgs(query: string, target: string, opts: SearchOptionsLike): string[] { const args: string[] = [ + '--no-config', '--json', '--hidden', '--glob', '!.git', - '--max-count', - String(MAX_MATCHES_PER_FILE), '--max-filesize', `${Math.floor(SEARCH_MAX_FILE_SIZE / 1024 / 1024)}M` ] @@ -101,7 +105,7 @@ export function ingestRgJsonLine( rootPath: string, acc: SearchAccumulator, maxResults: number, - transformAbsPath?: (p: string) => string + transformAbsPath?: (p: string) => string | null ): 'continue' | 'stop' { if (acc.totalMatches >= maxResults) { return 'stop' @@ -109,19 +113,11 @@ export function ingestRgJsonLine( if (!line) { return 'continue' } - let msg: { - type?: string - data?: { - path?: { text?: string } - submatches?: { start: number; end: number }[] - line_number?: number - lines?: { text?: string } - } - } + let msg: RipgrepMatchMessage try { - assertJsonTextStructureWithinLimits(line, SEARCH_JSON_STRUCTURE_LIMITS) - msg = JSON.parse(line) + msg = parseRipgrepMatchJson(line, maxResults - acc.totalMatches, SEARCH_JSON_STRUCTURE_LIMITS) } catch { + acc.truncated = true return 'continue' } if (msg.type !== 'match' || !msg.data) { @@ -130,19 +126,22 @@ export function ingestRgJsonLine( const data = msg.data const rawPath = data.path?.text if (typeof rawPath !== 'string') { + // File APIs accept strings, so byte-only filenames cannot be opened losslessly. + acc.truncated = true return 'continue' } - const absPath = transformAbsPath ? transformAbsPath(rawPath) : rawPath - const relPath = normalizeRelativePath(relativeToSearchRoot(rootPath, absPath)) - const lineContent = (data.lines?.text ?? '').replace(/\n$/, '') - const lineNumber = data.line_number ?? 0 - let submatches = data.submatches ?? [] - if (submatches.length === 0) { - // Why: some rg matches report a line but no submatch ranges; surface a navigable line-level result instead of a count-0 row. - submatches = [{ start: 0, end: lineContent.length > 0 ? 1 : 0 }] + const mappedPath = transformAbsPath ? transformAbsPath(rawPath) : rawPath + if (mappedPath === null) { + acc.truncated = true + return 'continue' } - - for (const sub of submatches) { + const absPath = resolveSearchResultPath(rootPath, mappedPath) + const relPath = normalizeRelativePath(relativeToSearchRoot(rootPath, absPath), rootPath) + const { text: lineContent, readOffset } = decodeRipgrepLine(data.lines) + const lineNumber = data.line_number ?? 0 + for (const sub of ripgrepMatchRanges(lineContent, data.submatches ?? [], readOffset, () => { + acc.truncated = true + })) { let fileResult = acc.fileMap.get(absPath) if (!fileResult) { fileResult = { filePath: absPath, relativePath: relPath, matches: [], matchCount: 0 } @@ -263,7 +262,7 @@ export function ingestGitGrepLine( if (nullIdx === -1) { return 'continue' } - const relPath = normalizeRelativePath(line.substring(0, nullIdx)) + const relPath = normalizeRelativePath(line.substring(0, nullIdx), rootPath) const rest = line.substring(nullIdx + 1) const secondNullIdx = rest.indexOf('\0') let lineNumberText: string From e8310d5a4f4df2e49329d4934ff89ce982979c72 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 4 Oct 2026 01:41:01 -0700 Subject: [PATCH 48/67] fix(opencode): submit admitted native startup briefs without overwriting input (#24762) * fix: wait for OpenCode worker composer before first dispatch Reuse captured composer readiness on local and paired execution hosts and revoke launching-shell paste anchors. Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> * feat(opencode): probe execution-host CLI capabilities * fix(opencode): select plugin default for execution host loader * fix(opencode): limit prompt prefill capability to verified release * feat(opencode): probe launch capabilities on the execution host * fix(opencode): select plugin loader for the launched host binary * fix(opencode): match WSL probe cwd and declared guest environment * fix(opencode): preserve launch environment deletion boundaries * wip(opencode): authorize native startup prompt intent at execution owner * fix(opencode): atomically replace status plugin entrypoints * fix(opencode): retain plugin permissions across restrictive umasks * test(opencode): resolve permission fixture from primary cwd * feat(opencode): install startup prompt plugin independently of status hooks * fix(opencode): wait for admitted startup intent and preserve failed-launch briefs * fix(opencode): unsubscribe hook settings during async host shutdown * STRICT launch CI contract correction * CAPS launch CI contract correction * INTENT launch CI contract correction * test: initialize Claude prompt state in output retention fixture * Wait for OpenCode location hydration in intent startup * Bind OpenCode startup readiness to the current location in intent startup * Retry interrupted OpenCode startup prompt claims --------- Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Co-authored-by: Ahmed Nagy Co-authored-by: Orca startup hydration review Co-authored-by: Orca --- .../server-startup-prompt-control.test.ts | 146 ++++ .../server-transport-interference.test.ts | 45 +- .../agent-hooks/server/server-lifecycle.ts | 76 +- .../agent-hooks/server/server-listeners.ts | 8 + .../agent-hooks/server/server-runtime-env.ts | 2 +- src/main/agent-hooks/server/server-state.ts | 3 + .../server/server-status-hook-lifecycle.ts | 52 ++ src/main/global-fetch-call-site-audit.test.ts | 2 + .../pty-controller-process-inventory.test.ts | 14 +- src/main/ipc/pty-ipc-mock-registry.ts | 3 + src/main/ipc/pty/host-env/assembly.ts | 71 +- src/main/ipc/pty/host-env/opencode-config.ts | 84 ++ .../opencode-hook-installation.test.ts | 22 + src/main/ipc/pty/ipc/spawn-commit.ts | 6 + src/main/ipc/pty/ipc/spawn-options.ts | 4 +- src/main/ipc/pty/runtime/spawn-commit.ts | 6 + src/main/ipc/pty/runtime/spawn-options.ts | 4 +- src/main/opencode/hook-service.ts | 104 +-- .../opencode/opencode-overlay-manifest.ts | 29 + .../opencode/opencode-plugin-config-writer.ts | 45 ++ src/main/opencode/opencode-pty-launch.test.ts | 197 ++++- src/main/opencode/opencode-pty-launch.ts | 102 ++- .../opencode-startup-prompt-claims.test.ts | 171 ++++ .../opencode-startup-prompt-claims.ts | 120 +++ .../opencode-startup-prompt-installer.test.ts | 253 ++++++ .../opencode-startup-prompt-installer.ts | 82 ++ .../opencode-startup-prompt-owner.test.ts | 172 ++++ .../opencode/opencode-startup-prompt-owner.ts | 111 +++ ...de-startup-prompt-runtime-identity.test.ts | 80 ++ .../opencode-startup-prompt-source.test.ts | 761 ++++++++++++++++++ .../opencode-startup-prompt-source.ts | 152 ++++ src/main/orcad/orcad-entry.ts | 16 +- src/main/orcad/orcad-push-startup.test.ts | 29 +- src/main/providers/provider-dispatch.test.ts | 19 +- ...-authoritative-terminal-wait-permission.ts | 19 + src/main/runtime/terminal-run-facts.test.ts | 15 + src/main/runtime/terminal-run-facts.ts | 41 +- .../headless-pty-hydration-ordering.test.ts | 18 +- src/main/startup/main-process-pty-startup.ts | 4 +- .../startup/main-process-ready-foundation.ts | 2 + src/relay/opencode-overlay-mirror.ts | 40 + .../opencode-startup-prompt-install.test.ts | 86 ++ src/relay/plugin-overlay.ts | 96 +-- src/relay/pty-handler.ts | 17 + src/relay/relay-agent-hook-runtime.ts | 7 + src/relay/wsl-install-plugins-handler.ts | 24 + src/shared/opencode-headless-command.test.ts | 64 ++ src/shared/opencode-headless-command.ts | 105 ++- src/shared/opencode-startup-prompt-install.ts | 17 + src/shared/opencode-startup-prompt.test.ts | 52 ++ src/shared/opencode-startup-prompt.ts | 39 + src/shared/opencode-tui-plugin-install.ts | 18 +- src/shared/tui-agent-startup.ts | 3 +- 53 files changed, 3406 insertions(+), 252 deletions(-) create mode 100644 src/main/agent-hooks/server-startup-prompt-control.test.ts create mode 100644 src/main/agent-hooks/server/server-status-hook-lifecycle.ts create mode 100644 src/main/ipc/pty/host-env/opencode-config.ts create mode 100644 src/main/opencode/opencode-overlay-manifest.ts create mode 100644 src/main/opencode/opencode-plugin-config-writer.ts create mode 100644 src/main/opencode/opencode-startup-prompt-claims.test.ts create mode 100644 src/main/opencode/opencode-startup-prompt-claims.ts create mode 100644 src/main/opencode/opencode-startup-prompt-installer.test.ts create mode 100644 src/main/opencode/opencode-startup-prompt-installer.ts create mode 100644 src/main/opencode/opencode-startup-prompt-owner.test.ts create mode 100644 src/main/opencode/opencode-startup-prompt-owner.ts create mode 100644 src/main/opencode/opencode-startup-prompt-runtime-identity.test.ts create mode 100644 src/main/opencode/opencode-startup-prompt-source.test.ts create mode 100644 src/main/opencode/opencode-startup-prompt-source.ts create mode 100644 src/relay/opencode-overlay-mirror.ts create mode 100644 src/relay/opencode-startup-prompt-install.test.ts create mode 100644 src/shared/opencode-startup-prompt-install.ts create mode 100644 src/shared/opencode-startup-prompt.test.ts create mode 100644 src/shared/opencode-startup-prompt.ts diff --git a/src/main/agent-hooks/server-startup-prompt-control.test.ts b/src/main/agent-hooks/server-startup-prompt-control.test.ts new file mode 100644 index 00000000000..19a6efd5a89 --- /dev/null +++ b/src/main/agent-hooks/server-startup-prompt-control.test.ts @@ -0,0 +1,146 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { AgentHookServer } from './server' +import { parseAgentHookEndpointFile } from '../../shared/agent-hook-endpoint-file' +import { OPENCODE_STARTUP_PROMPT_CLAIM_PATH } from '../../shared/opencode-startup-prompt' +import { PANE } from './server.test-fixtures' + +describe('startup prompt control with status hooks disabled', () => { + it.each([false, true])( + 'persists a pending terminal status at shutdown after starting with hooks %s', + async (statusHooksEnabled) => { + const dir = mkdtempSync(join(tmpdir(), 'orca-prompt-terminal-persist-')) + const server = new AgentHookServer() + try { + await server.start({ userDataPath: dir, statusHooksEnabled }) + server.setStatusHooksEnabled(false) + server.ingestTerminalStatus({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'folder-1', + payload: { state: 'done', prompt: 'terminal status survives quit', agentType: 'opencode' } + }) + expect(server.getStatusSnapshotForPane(PANE)[0]?.state).toBe('done') + const statusPath = server.lastStatusPath + if (!statusPath) { + throw new Error('missing status persistence path') + } + server.stop() + expect(existsSync(statusPath)).toBe(true) + expect(JSON.parse(readFileSync(statusPath, 'utf8')).entries[PANE]).toMatchObject({ + paneKey: PANE, + worktreeId: 'folder-1', + payload: { state: 'done', prompt: 'terminal status survives quit', agentType: 'opencode' } + }) + } finally { + server.stop() + rmSync(dir, { recursive: true, force: true }) + } + } + ) + + it('enables and disables status without restarting the control listener', async () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-prompt-toggle-')) + class IsolatedHookServer extends AgentHookServer { + constructor() { + super() + this._setOpenCodeBinderDepsForTests({ + dbPath: () => join(dir, 'no-user-db'), + listSessions: async () => [], + listPanes: () => [], + sweep: async () => [] + }) + } + } + const server = new IsolatedHookServer() + try { + await server.start({ userDataPath: dir, statusHooksEnabled: false }) + const endpoint = server.endpointFilePath + if (!endpoint) { + throw new Error('missing control endpoint') + } + const coords = parseAgentHookEndpointFile(readFileSync(endpoint, 'utf8')) + const post = (path: string) => + fetch(`http://127.0.0.1:${coords.port}${path}`, { + method: 'POST', + headers: { 'x-orca-agent-hook-token': coords.token }, + body: '{}' + }) + expect((await post('/hook/opencode')).status).toBe(404) + await server.start({ statusHooksEnabled: true }) + expect(server.buildPtyEnv()).toHaveProperty('ORCA_AGENT_HOOK_PORT', coords.port) + expect((await post('/hook/opencode')).status).toBe(204) + server.setStatusHooksEnabled(false) + expect(server.buildPtyEnv()).toEqual({}) + expect((await post('/hook/opencode')).status).toBe(404) + await server.start() + expect((await post('/hook/opencode')).status).toBe(404) + server.setStartupPromptClaimListener( + () => 'pending', + () => {} + ) + expect(await (await post(OPENCODE_STARTUP_PROMPT_CLAIM_PATH)).json()).toEqual({ + allowed: false, + pending: true + }) + server.setStatusHooksEnabled(true) + expect((await post('/hook/opencode')).status).toBe(204) + expect(server.endpointFilePath).toBe(endpoint) + expect(parseAgentHookEndpointFile(readFileSync(endpoint, 'utf8'))).toEqual(coords) + } finally { + server.stop() + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('authenticates claims, denies malformed or missing handlers, and refuses status posts', async () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-prompt-control-')) + const server = new AgentHookServer() + try { + await server.start({ userDataPath: dir, statusHooksEnabled: false }) + expect(server.buildPtyEnv()).toEqual({}) + const statusPath = server.lastStatusPath + if (!statusPath) { + throw new Error('missing status persistence path') + } + writeFileSync(statusPath, 'existing status must survive control-only shutdown') + const endpoint = server.endpointFilePath + if (!endpoint) { + throw new Error('missing control endpoint') + } + const coords = parseAgentHookEndpointFile(readFileSync(endpoint, 'utf8')) + const post = (path: string, body: string, token = coords.token) => + fetch(`http://127.0.0.1:${coords.port}${path}`, { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-orca-agent-hook-token': token }, + body + }) + expect((await post(OPENCODE_STARTUP_PROMPT_CLAIM_PATH, '{}', 'wrong')).status).toBe(403) + expect(await (await post(OPENCODE_STARTUP_PROMPT_CLAIM_PATH, '{}')).json()).toEqual({ + allowed: false + }) + const clear = vi.fn() + const claim = vi.fn(() => true) + server.setStartupPromptClaimListener(claim, clear) + expect(await (await post(OPENCODE_STARTUP_PROMPT_CLAIM_PATH, '{}')).json()).toEqual({ + allowed: true + }) + expect(await (await post(OPENCODE_STARTUP_PROMPT_CLAIM_PATH, '{')).json()).toEqual({ + allowed: false + }) + expect(claim).toHaveBeenCalledTimes(1) + expect((await post('/hook/opencode', '{}')).status).toBe(404) + expect((await post('/statusline/claude', '{}')).status).toBe(404) + server.stop() + expect(clear).toHaveBeenCalledTimes(1) + expect(readFileSync(statusPath, 'utf8')).toBe( + 'existing status must survive control-only shutdown' + ) + } finally { + server.stop() + rmSync(dir, { recursive: true, force: true }) + } + }) +}) diff --git a/src/main/agent-hooks/server-transport-interference.test.ts b/src/main/agent-hooks/server-transport-interference.test.ts index 37c80087bdb..e8d5a8e357b 100644 --- a/src/main/agent-hooks/server-transport-interference.test.ts +++ b/src/main/agent-hooks/server-transport-interference.test.ts @@ -2,9 +2,11 @@ // short of its own Content-Length. The listener fails open on every request error, so the only // way this stays diagnosable is if the truncation is classified before it is swallowed. import { connect } from 'node:net' +import { ServerResponse } from 'node:http' import { afterEach, describe, expect, it, vi } from 'vitest' import type { HookTransportInterferenceReport } from '../../shared/agent-hook-transport-interference' import { AgentHookServer } from './server' +import { OPENCODE_STARTUP_PROMPT_CLAIM_PATH } from '../../shared/opencode-startup-prompt' async function postTruncatedHook( port: number, @@ -40,11 +42,15 @@ async function postTruncatedHook( } /** Opens a POST that announces a body and then never sends it, so Orca's own slowloris cap ends it. */ -async function postStalledHook(port: number, token: string): Promise { +async function postStalledHook( + port: number, + token: string, + pathname = '/hook/claude' +): Promise { const socket = connect({ port, host: '127.0.0.1' }) await new Promise((resolve) => socket.on('connect', () => resolve())) socket.write( - `POST /hook/claude HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/x-www-form-urlencoded\r\nX-Orca-Agent-Hook-Token: ${token}\r\nContent-Length: 100000\r\n\r\n` + `POST ${pathname} HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/x-www-form-urlencoded\r\nX-Orca-Agent-Hook-Token: ${token}\r\nContent-Length: 100000\r\n\r\n` ) await new Promise((resolve) => { socket.on('close', () => resolve()) @@ -118,6 +124,41 @@ describe('AgentHookServer transport interference', () => { expect(reports).toHaveLength(1) }, 20_000) + it('classifies an interrupted startup claim as retryable without consuming it', async () => { + const { server, port, token, reports } = await startServer() + const claim = vi.fn(() => true) + server.setStartupPromptClaimListener(claim, () => {}) + const writeHead = vi.spyOn(ServerResponse.prototype, 'writeHead') + try { + await postTruncatedHook(port, token, { + pathname: OPENCODE_STARTUP_PROMPT_CLAIM_PATH, + sentBytes: '{"nonce":', + announcedLength: 1000 + }) + // The reset peer cannot receive this response; observe the real handler's classification. + expect(writeHead.mock.calls).toEqual([[503]]) + expect(claim).not.toHaveBeenCalled() + expect(reports).toEqual([]) + } finally { + writeHead.mockRestore() + } + }) + + it('keeps a startup claim stopped by its own slowloris cap as a denial', async () => { + const { server, port, token, reports } = await startServer() + const claim = vi.fn(() => true) + server.setStartupPromptClaimListener(claim, () => {}) + const writeHead = vi.spyOn(ServerResponse.prototype, 'writeHead') + try { + await postStalledHook(port, token, OPENCODE_STARTUP_PROMPT_CLAIM_PATH) + expect(writeHead.mock.calls).toEqual([[200, { 'content-type': 'application/json' }]]) + expect(claim).not.toHaveBeenCalled() + expect(reports).toEqual([]) + } finally { + writeHead.mockRestore() + } + }, 30_000) + it('never reports for POSTs that deliver their whole body', async () => { const { port, token, reports } = await startServer() diff --git a/src/main/agent-hooks/server/server-lifecycle.ts b/src/main/agent-hooks/server/server-lifecycle.ts index 80c81f09142..825b51b8a63 100644 --- a/src/main/agent-hooks/server/server-lifecycle.ts +++ b/src/main/agent-hooks/server/server-lifecycle.ts @@ -11,21 +11,26 @@ import { readRequestBody } from '../../../shared/agent-hook-listener/request-bod import { resolveHookSource } from '../../../shared/agent-hook-listener/source-routing' import { HOOK_REQUEST_SLOWLORIS_MS } from '../../../shared/agent-hook-listener/listener-limits' import { isHookRequestTruncatedError } from '../../../shared/agent-hook-transport-interference' -import { drainAgentHookSpool, type SpoolRecord } from '../../../shared/agent-hook-spool' import { clearAllListenerCaches } from '../../../shared/agent-hook-listener/listener-state' import { trackEmptyPaneKeyHook } from './server-transport-rules' -import { AgentHookServerRuntimeEnv } from './server-runtime-env' +import { AgentHookServerStatusHookLifecycle } from './server-status-hook-lifecycle' +import { OPENCODE_STARTUP_PROMPT_CLAIM_PATH } from '../../../shared/opencode-startup-prompt' -export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv { +export abstract class AgentHookServerLifecycle extends AgentHookServerStatusHookLifecycle { /** Start the loopback listener after hydration and spool replay have settled. */ async start(options?: { env?: string userDataPath?: string endpointNamespace?: string + statusHooksEnabled?: boolean }): Promise { if (this.server) { + if (options?.statusHooksEnabled !== undefined) { + this.setStatusHooksEnabled(options.statusHooksEnabled) + } return } + this.statusHooksEnabled = options?.statusHooksEnabled !== false if (options?.env) { this.env = options.env @@ -37,30 +42,8 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv this.token = randomUUID() this.endpointFileWritten = false this.lastWrittenJson = null - if (!this.ownerStateInitialized) { - // Why: hydrate before binding the listener so an early hook POST runs against a populated map. - if (this.lastStatusFilePath) { - this.hydrateLastStatusFromDisk() - } - this.captureHydratedAuthorityCommitments() - // Drain before binding the listener so replay cannot race a live hook during startup. - if (this.endpointDir) { - const replayedPaneKeys = new Set() - drainAgentHookSpool({ - endpointDir: this.endpointDir, - getPersistedLaunchTokenHash: (paneKey) => - this.hydratedLaunchTokenHashByPaneKey.get(this.resolvePaneKeyAlias(paneKey)), - ingest: (record: SpoolRecord) => { - this.ingestSpoolRecord(record) - replayedPaneKeys.add(this.resolvePaneKeyAlias(record.paneKey)) - } - }) - // Why: the owner may have died while Orca was down; check each replayed pane once. - for (const paneKey of replayedPaneKeys) { - void this.checkAgentPresence(paneKey) - } - } - this.ownerStateInitialized = true + if (this.statusHooksEnabled) { + this.initializeStatusHookOwner() } const handleRequest = async (req: IncomingMessage, res: ServerResponse): Promise => { if (req.method !== 'POST') { @@ -84,6 +67,22 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv const pathname = new URL(req.url ?? '/', 'http://127.0.0.1').pathname try { const body = await readRequestBody(req) + if (pathname === OPENCODE_STARTUP_PROMPT_CLAIM_PATH) { + res.writeHead(200, { 'content-type': 'application/json' }) + const claim = this.onStartupPromptClaim?.(body) + res.end( + JSON.stringify({ + allowed: claim === true, + ...(claim === 'pending' ? { pending: true } : {}) + }) + ) + return + } + if (!this.statusHooksEnabled) { + res.writeHead(404) + res.end() + return + } if (pathname === CLAUDE_STATUSLINE_PATHNAME) { const statusLineEvent = parseClaudeStatusLineBody(body) if (statusLineEvent) { @@ -152,6 +151,16 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv res.writeHead(204) res.end() } catch (error) { + if (pathname === OPENCODE_STARTUP_PROMPT_CLAIM_PATH) { + if (isHookRequestTruncatedError(error) && !destroyedBySlowlorisCap) { + res.writeHead(503) + res.end() + return + } + res.writeHead(200, { 'content-type': 'application/json' }) + res.end('{"allowed":false}') + return + } // Why (#11217): an authenticated POST whose body dies short of its own Content-Length was cut // by something on the loopback path, not by a bad payload. Fail open as before, but count it — // this is the one failure mode that silently stops status for every runtime at once. @@ -192,7 +201,9 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv this.rollbackTransportStart() throw error } - this.startOpenCodeBinderLoop() + if (this.statusHooksEnabled) { + this.startOpenCodeBinderLoop() + } } private rollbackTransportStart(): void { @@ -204,14 +215,19 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv } stop(): void { - // Why: flush the pending debounced write before clearing the map, else a hook <250ms before quit is lost on relaunch. - this.flushStatusPersistSync() + // Terminal status may still have a pending write while hook ingress is disabled. + if (this.statusHooksEnabled || this.statusPersistTimer) { + this.flushStatusPersistSync() + } this.stopOpenCodeBinderLoop() this.stopTmuxStatus() this.rollbackTransportStart() this.env = 'production' this.onAgentStatus = null this.onClaudeStatusLine = null + this.clearStartupPromptClaims?.() + this.clearStartupPromptClaims = null + this.onStartupPromptClaim = null this.onPaneStatusCleared = null this.onTransportInterference = null this.transportInterference.reset() diff --git a/src/main/agent-hooks/server/server-listeners.ts b/src/main/agent-hooks/server/server-listeners.ts index 1de09c3ebdf..14b78262734 100644 --- a/src/main/agent-hooks/server/server-listeners.ts +++ b/src/main/agent-hooks/server/server-listeners.ts @@ -26,6 +26,14 @@ import { structuredStatusLegacyEvent } from './server-structured-status-row' const UNORDERED_STATUS_ROW = Number.MAX_SAFE_INTEGER export abstract class AgentHookServerListeners extends AgentHookServerState { + setStartupPromptClaimListener( + listener: (body: unknown) => boolean | 'pending', + clear: () => void + ): void { + this.onStartupPromptClaim = listener + this.clearStartupPromptClaims = clear + } + protected emitEnrichedStatus(enriched: EnrichedAgentHookEventPayload): void { this.onAgentStatus?.(enriched) for (const listener of this.enrichedStatusListeners) { diff --git a/src/main/agent-hooks/server/server-runtime-env.ts b/src/main/agent-hooks/server/server-runtime-env.ts index 7bf7eaaee30..2f94bdd4b94 100644 --- a/src/main/agent-hooks/server/server-runtime-env.ts +++ b/src/main/agent-hooks/server/server-runtime-env.ts @@ -11,7 +11,7 @@ import { AgentHookServerIngestRemote } from './server-ingest-remote' export abstract class AgentHookServerRuntimeEnv extends AgentHookServerIngestRemote { buildPtyEnv(): Record { - if (this.port <= 0 || !this.token) { + if (!this.statusHooksEnabled || this.port <= 0 || !this.token) { return {} } const env: Record = { diff --git a/src/main/agent-hooks/server/server-state.ts b/src/main/agent-hooks/server/server-state.ts index f429c0e6350..172eaeaf1ef 100644 --- a/src/main/agent-hooks/server/server-state.ts +++ b/src/main/agent-hooks/server/server-state.ts @@ -89,6 +89,9 @@ export abstract class AgentHookServerState { protected env = 'production' protected onAgentStatus: ServerAgentStatusListener = null protected onClaudeStatusLine: ServerStatusLineListener = null + protected onStartupPromptClaim: ((body: unknown) => boolean | 'pending') | null = null + protected clearStartupPromptClaims: (() => void) | null = null + protected statusHooksEnabled = true protected onPaneStatusCleared: PaneStatusClearListener | null = null protected paneStatusClearListeners = new Set() protected statusDropListeners = new Set() diff --git a/src/main/agent-hooks/server/server-status-hook-lifecycle.ts b/src/main/agent-hooks/server/server-status-hook-lifecycle.ts new file mode 100644 index 00000000000..15a43182ab2 --- /dev/null +++ b/src/main/agent-hooks/server/server-status-hook-lifecycle.ts @@ -0,0 +1,52 @@ +import { drainAgentHookSpool, type SpoolRecord } from '../../../shared/agent-hook-spool' +import { AgentHookServerRuntimeEnv } from './server-runtime-env' + +export abstract class AgentHookServerStatusHookLifecycle extends AgentHookServerRuntimeEnv { + setStatusHooksEnabled(enabled: boolean): void { + if (enabled === this.statusHooksEnabled) { + return + } + if (!enabled) { + this.flushStatusPersistSync() + this.stopOpenCodeBinderLoop() + for (const timer of this.assistantMessageRetryTimers.values()) { + clearTimeout(timer) + } + this.assistantMessageRetryTimers.clear() + this.clearAllTranscriptPolls() + } + this.statusHooksEnabled = enabled + if (enabled && this.server) { + this.initializeStatusHookOwner() + this.startOpenCodeBinderLoop() + } + } + + protected initializeStatusHookOwner(): void { + if (!this.ownerStateInitialized) { + // Why: hydrate before binding the listener so an early hook POST runs against a populated map. + if (this.lastStatusFilePath) { + this.hydrateLastStatusFromDisk() + } + this.captureHydratedAuthorityCommitments() + // Drain before binding the listener so replay cannot race a live hook during startup. + if (this.endpointDir) { + const replayedPaneKeys = new Set() + drainAgentHookSpool({ + endpointDir: this.endpointDir, + getPersistedLaunchTokenHash: (paneKey) => + this.hydratedLaunchTokenHashByPaneKey.get(this.resolvePaneKeyAlias(paneKey)), + ingest: (record: SpoolRecord) => { + this.ingestSpoolRecord(record) + replayedPaneKeys.add(this.resolvePaneKeyAlias(record.paneKey)) + } + }) + // Why: the owner may have died while Orca was down; check each replayed pane once. + for (const paneKey of replayedPaneKeys) { + void this.checkAgentPresence(paneKey) + } + } + this.ownerStateInitialized = true + } + } +} diff --git a/src/main/global-fetch-call-site-audit.test.ts b/src/main/global-fetch-call-site-audit.test.ts index 907092515f5..664bbaf1aa3 100644 --- a/src/main/global-fetch-call-site-audit.test.ts +++ b/src/main/global-fetch-call-site-audit.test.ts @@ -20,6 +20,8 @@ const AUDITED_GLOBAL_FETCH_LINES = new Map([ ['main/bitbucket/client.ts', 1], ['main/bitbucket/user-request.ts', 1], ['main/gitea/client.ts', 1], + // Generated OpenCode claim source consumes JSON or cancels its body in finally. + ['main/opencode/opencode-startup-prompt-source.ts', 1], ['main/orca-profiles/profile-cloud-client.ts', 1], ['main/orca-profiles/profile-cloud-org-members-client.ts', 1], ['main/rate-limits/codex-fetcher.ts', 3], diff --git a/src/main/ipc/pty-controller-process-inventory.test.ts b/src/main/ipc/pty-controller-process-inventory.test.ts index 004735c61cb..50b67963ab9 100644 --- a/src/main/ipc/pty-controller-process-inventory.test.ts +++ b/src/main/ipc/pty-controller-process-inventory.test.ts @@ -1,3 +1,4 @@ +import { openCodeHookServiceModuleMock } from './pty-ipc-mock-registry' import { afterEach, describe, expect, it, vi } from 'vitest' const { handleMock, onMock, removeHandlerMock, removeAllListenersMock } = vi.hoisted(() => ({ @@ -46,18 +47,7 @@ vi.mock('node-pty', () => ({ }) })) -vi.mock('../opencode/hook-service', () => ({ - openCodeHookService: { - buildPtyEnv: () => ({}), - refreshLegacySharedPlugin: vi.fn(), - clearPty: vi.fn() - }, - openCode2HookService: { - buildPtyEnv: () => ({}), - refreshLegacySharedPlugin: vi.fn(), - clearPty: vi.fn() - } -})) +vi.mock('../opencode/hook-service', () => openCodeHookServiceModuleMock()) vi.mock('../pi/titlebar-extension-service', () => ({ piTitlebarExtensionService: { buildPtyEnv: () => ({}), clearPty: vi.fn() } diff --git a/src/main/ipc/pty-ipc-mock-registry.ts b/src/main/ipc/pty-ipc-mock-registry.ts index f49978843d5..4eba8d0b164 100644 --- a/src/main/ipc/pty-ipc-mock-registry.ts +++ b/src/main/ipc/pty-ipc-mock-registry.ts @@ -106,6 +106,9 @@ export const childProcessModuleMock = (original: Record) => ({ }) export const openCodeHookServiceModuleMock = () => ({ + OpenCodeHookService: class { + buildPtyEnv = vi.fn(() => ({})) + }, openCodeHookService: { buildPtyEnv: openCodeBuildPtyEnvMock, refreshLegacySharedPlugin: vi.fn<() => void>(), diff --git a/src/main/ipc/pty/host-env/assembly.ts b/src/main/ipc/pty/host-env/assembly.ts index 1cae20bfcbd..8b921b4ebde 100644 --- a/src/main/ipc/pty/host-env/assembly.ts +++ b/src/main/ipc/pty/host-env/assembly.ts @@ -1,15 +1,10 @@ import { resolveSetupAgentSequenceLaunchCommand } from '../../../../shared/setup-agent-sequencing' -import { selectOpenCodeHookAgent } from '../../../../shared/opencode-launch-command' import { detectExplicitPiAgentKindFromCommand, isPiCompatibleAgentType } from '../../../../shared/pi-agent-kind' import { applyTerminalGitCredentialPromptGuard } from '../../terminal-git-credential-guard' -import { openCode2HookService, openCodeHookService } from '../../../opencode/hook-service' -import { - OPENCODE_CONFIG_DIR_ENV_KEYS, - isOpenCodeLegacySharedConfigDir -} from '../../../opencode/legacy-shared-config-dir' +import { ensureOpenCodeStartupPromptForLaunch } from '../../../opencode/opencode-startup-prompt-installer' import { mimoCodeHookService } from '../../../mimo/hook-service' import { agentHookServer } from '../../../agent-hooks/server' import { wslHookRelayManager } from '../../../agent-hooks/wsl-hook-relay-manager' @@ -28,13 +23,13 @@ import { exposePiManagedExtensionEnv, isMimoLaunchCommand, resolveMimocodeSourceHome, - resolveOpenCodeSourceConfigDir, resolvePiAgentSourceDir, resolveScopedPiAgentSourceDir, restoreOrStripOverlayEnv } from './pi-agent' import { AGENT_HOOK_RUNTIME_ENV_KEYS } from './spawn-env-keys' import { applyManagedDataAccountEnvironment } from '../../../managed-data-accounts/launch-environment' +import { applyOpenCodeStatusPluginEnv, captureOpenCodeSourceConfig } from './opencode-config' /** * Mutates `baseEnv` in place with all host-local PTY env vars and returns it. @@ -50,32 +45,9 @@ export function buildPtyHostEnv( mergePersistedWindowsPath(baseEnv) Object.assign(baseEnv, buildConfiguredProxyEnv(opts.networkProxySettings)) - // Why: pre-1.4.209 panes exported Orca's retired shared hooks dir; inheriting it hides the user's global OpenCode config. - const isLegacyOpenCodeHooksDir = (dir: string | undefined): boolean => - isOpenCodeLegacySharedConfigDir(dir, opts.userDataPath) - const inheritedOpenCodeEnv: NodeJS.ProcessEnv = {} - for (const key of OPENCODE_CONFIG_DIR_ENV_KEYS) { - if (isLegacyOpenCodeHooksDir(baseEnv[key])) { - delete baseEnv[key] - } - if (!isLegacyOpenCodeHooksDir(process.env[key])) { - inheritedOpenCodeEnv[key] = process.env[key] - } - } - // A daemon or sibling shell can retain a retired path that main no longer sees. - openCodeHookService.refreshLegacySharedPlugin() - openCode2HookService.refreshLegacySharedPlugin() - const resolvedOpenCodeConfigDir = resolveOpenCodeSourceConfigDir(baseEnv, inheritedOpenCodeEnv) - const preexistingOpenCodeConfigDir = isLegacyOpenCodeHooksDir(resolvedOpenCodeConfigDir) - ? undefined - : resolvedOpenCodeConfigDir + const openCodeConfig = captureOpenCodeSourceConfig(baseEnv, opts.userDataPath) const launchCommandHint = resolveSetupAgentSequenceLaunchCommand(baseEnv, opts.launchCommand) applyManagedDataAccountEnvironment(baseEnv, { ...opts, launchCommand: launchCommandHint }) - const openCodeAgent = selectOpenCodeHookAgent( - opts.launchAgent, - launchCommandHint, - (agent) => opts.agentStatusHooksEnabled && isTuiAgentEnabled(agent, opts.disabledTuiAgents) - ) const explicitPiAgentKind = isPiCompatibleAgentType(opts.launchAgent) ? opts.launchAgent : opts.launchAgent === undefined @@ -110,37 +82,13 @@ export function buildPtyHostEnv( ? resolvePiAgentSourceDir(baseEnv, 'prime-agent') : resolveScopedPiAgentSourceDir(baseEnv, 'prime-agent') - restoreOrStripOverlayEnv( + const openCodeAgent = applyOpenCodeStatusPluginEnv( + id, baseEnv, - { - primary: 'OPENCODE_CONFIG_DIR', - overlay: 'ORCA_OPENCODE_CONFIG_DIR', - source: 'ORCA_OPENCODE_SOURCE_CONFIG_DIR', - preserveExplicitPrimary: true - }, - inheritedOpenCodeEnv + openCodeConfig, + opts, + launchCommandHint ) - delete baseEnv.ORCA_OPENCODE_AGENT - if (openCodeAgent) { - // Why: OPENCODE_CONFIG_DIR is a single path, not a colon-list; mirror the user's value into an overlay so their plugins and Orca's status plugin coexist. See docs/opencode-config-dir-collision.md. - const openCodeStatusService = - openCodeAgent === 'opencode2' ? openCode2HookService : openCodeHookService - baseEnv.ORCA_OPENCODE_AGENT = openCodeAgent - // WSL owns its config writes; only the guest overlay may enter a WSL pane. - if (!opts.isWsl) { - Object.assign(baseEnv, openCodeStatusService.buildPtyEnv(id, preexistingOpenCodeConfigDir)) - } - if (baseEnv.OPENCODE_CONFIG_DIR) { - // Why: ~/.zshrc can re-export the user's default after spawn; shell-ready wrappers restore this PTY-scoped value. - baseEnv.ORCA_OPENCODE_CONFIG_DIR = baseEnv.OPENCODE_CONFIG_DIR - if (preexistingOpenCodeConfigDir) { - // Why: nested Orca terminals inherit the overlay as OPENCODE_CONFIG_DIR; keep the real source so overlays don't mirror overlays. - baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR = preexistingOpenCodeConfigDir - } else { - delete baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR - } - } - } if (opts.agentStatusHooksEnabled) { if (isMimoLaunchCommand(launchCommandHint)) { const preexistingMimocodeHome = resolveMimocodeSourceHome(baseEnv) @@ -343,5 +291,8 @@ export function buildPtyHostEnv( // process.env when baseEnv carries none, which is the daemon path's normal shape. stripLegacyTerminalShimEnv(baseEnv, process.platform) + if (!opts.isWsl) { + ensureOpenCodeStartupPromptForLaunch(baseEnv) + } return baseEnv } diff --git a/src/main/ipc/pty/host-env/opencode-config.ts b/src/main/ipc/pty/host-env/opencode-config.ts new file mode 100644 index 00000000000..88dafabc88e --- /dev/null +++ b/src/main/ipc/pty/host-env/opencode-config.ts @@ -0,0 +1,84 @@ +import { + OPENCODE_CONFIG_DIR_ENV_KEYS, + isOpenCodeLegacySharedConfigDir +} from '../../../opencode/legacy-shared-config-dir' +import { openCode2HookService, openCodeHookService } from '../../../opencode/hook-service' +import { resolveOpenCodeSourceConfigDir, restoreOrStripOverlayEnv } from './pi-agent' +import { selectOpenCodeHookAgent } from '../../../../shared/opencode-launch-command' +import { isTuiAgentEnabled } from '../../../../shared/tui-agent-selection' +import type { BuildPtyHostEnvOptions } from './types' + +type OpenCodeSourceConfig = { + inheritedEnv: NodeJS.ProcessEnv + directory: string | undefined +} + +export function captureOpenCodeSourceConfig( + env: Record, + userDataPath: string +): OpenCodeSourceConfig { + const isLegacyDirectory = (dir: string | undefined): boolean => + isOpenCodeLegacySharedConfigDir(dir, userDataPath) + const inheritedEnv: NodeJS.ProcessEnv = {} + for (const key of OPENCODE_CONFIG_DIR_ENV_KEYS) { + if (isLegacyDirectory(env[key])) { + delete env[key] + } + if (!isLegacyDirectory(process.env[key])) { + inheritedEnv[key] = process.env[key] + } + } + // A daemon or sibling shell can retain a retired path that main no longer sees. + openCodeHookService.refreshLegacySharedPlugin() + openCode2HookService.refreshLegacySharedPlugin() + const directory = resolveOpenCodeSourceConfigDir(env, inheritedEnv) + return { inheritedEnv, directory: isLegacyDirectory(directory) ? undefined : directory } +} + +export function applyOpenCodeStatusPluginEnv( + id: string, + env: Record, + config: OpenCodeSourceConfig, + options: Pick< + BuildPtyHostEnvOptions, + 'launchAgent' | 'agentStatusHooksEnabled' | 'disabledTuiAgents' | 'isWsl' + >, + command: string | undefined +): 'opencode' | 'opencode2' | null { + const agent = selectOpenCodeHookAgent( + options.launchAgent, + command, + (candidate) => + options.agentStatusHooksEnabled && isTuiAgentEnabled(candidate, options.disabledTuiAgents) + ) + restoreOrStripOverlayEnv( + env, + { + primary: 'OPENCODE_CONFIG_DIR', + overlay: 'ORCA_OPENCODE_CONFIG_DIR', + source: 'ORCA_OPENCODE_SOURCE_CONFIG_DIR', + preserveExplicitPrimary: true + }, + config.inheritedEnv + ) + delete env.ORCA_OPENCODE_AGENT + if (!agent) { + return null + } + const service = agent === 'opencode2' ? openCode2HookService : openCodeHookService + env.ORCA_OPENCODE_AGENT = agent + // WSL owns its config writes; only the guest overlay may enter a WSL pane. + if (!options.isWsl) { + Object.assign(env, service.buildPtyEnv(id, config.directory)) + } + if (env.OPENCODE_CONFIG_DIR) { + // Shell startup can re-export the default; preserve this pane's overlay and original source. + env.ORCA_OPENCODE_CONFIG_DIR = env.OPENCODE_CONFIG_DIR + if (config.directory) { + env.ORCA_OPENCODE_SOURCE_CONFIG_DIR = config.directory + } else { + delete env.ORCA_OPENCODE_SOURCE_CONFIG_DIR + } + } + return agent +} diff --git a/src/main/ipc/pty/host-env/opencode-hook-installation.test.ts b/src/main/ipc/pty/host-env/opencode-hook-installation.test.ts index a52a9d70360..ddae2771aba 100644 --- a/src/main/ipc/pty/host-env/opencode-hook-installation.test.ts +++ b/src/main/ipc/pty/host-env/opencode-hook-installation.test.ts @@ -77,6 +77,28 @@ afterEach(() => { }) describe('OpenCode installation uses the current enabled agents', () => { + it('refuses spawn if a prepared startup intent loses its owned installer', () => { + mkdirSync(fixture.userData, { recursive: true }) + writeFileSync( + join(fixture.userData, 'opencode-startup-prompt-overlays'), + 'blocked fixture root' + ) + expect(() => + buildPtyHostEnv( + 'owned-launch', + { + OPENCODE_CONFIG_DIR: custom, + ORCA_OPENCODE_PLUGIN_API: 'v2', + ORCA_OPENCODE_STARTUP_PROMPT_NONCE: 'fixture-nonce', + ORCA_OPENCODE_STARTUP_PROMPT_BODY: 'original caller brief' + }, + { ...options, agentStatusHooksEnabled: false } + ) + ).toThrow('launch was canceled') + expect(readFileSync(join(custom, 'opencode.json'), 'utf8')).toBe('{"model":"fixture"}') + expect(readFileSync(join(custom, 'plugins', 'user.js'), 'utf8')).toBe('// user plugin') + }) + const combinations = [ { disabled: [], fallback: 'opencode' }, { disabled: ['opencode'], fallback: 'opencode2' }, diff --git a/src/main/ipc/pty/ipc/spawn-commit.ts b/src/main/ipc/pty/ipc/spawn-commit.ts index 77491c9fb11..24df59c1201 100644 --- a/src/main/ipc/pty/ipc/spawn-commit.ts +++ b/src/main/ipc/pty/ipc/spawn-commit.ts @@ -22,8 +22,13 @@ import { admitPtyReattachOwnership, registerPersistedPtySpawn } from '../pane/sp import { reflowHeadlessTerminalToCommittedGrid } from '../delivery/attached-pty-size' import { seedHeadlessTerminalFromSpawnResult } from '../pane/terminal-spawn-restore' import { markNativeWindowsConptyPty } from '../../../runtime/terminal-model-query-authority' +import { commitPtyWithOpenCodePromptIntent } from '../../../opencode/opencode-startup-prompt-owner' export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise { + return commitPtyWithOpenCodePromptIntent(ctx, () => commitReservedPtyIpcSpawn(ctx)) +} + +async function commitReservedPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise { const args = ctx.args admitPtyReattachOwnership(ctx.deps.runtime, ctx.result, args.connectionId) if (ctx.nativeWindowsConptySpawn) { @@ -100,6 +105,7 @@ export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise commitReservedRuntimePtySpawn(ctx)) +} + +async function commitReservedRuntimePtySpawn(ctx: RuntimePtySpawnState) { const args = ctx.args admitPtyReattachOwnership(ctx.deps.runtime, ctx.result, args.connectionId) const providerReattachLaunchIdentity = admitProviderReattachLaunchIdentity(ctx.result) @@ -205,6 +210,7 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) { if (ctx.result.incarnationId) { ptyIncarnationById.set(ctx.result.id, ctx.result.incarnationId) } + claimSshPaneLease({ store: ctx.deps.store, connectionId: args.connectionId, diff --git a/src/main/ipc/pty/runtime/spawn-options.ts b/src/main/ipc/pty/runtime/spawn-options.ts index 2dfdf2d0811..26c3856b45e 100644 --- a/src/main/ipc/pty/runtime/spawn-options.ts +++ b/src/main/ipc/pty/runtime/spawn-options.ts @@ -104,7 +104,7 @@ export async function buildRuntimePtySpawnOptions( env: ctx.env, envToDelete: ctx.spawnOptions.envToDelete }) - ctx.env = await prepareOpenCodePtyLaunch({ + const openCodeLaunch = await prepareOpenCodePtyLaunch({ command: ctx.launchCommand, agent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined, env: ctx.env, @@ -116,6 +116,8 @@ export async function buildRuntimePtySpawnOptions( ? { wsl: { distro: ctx.expectedWslDistro ?? undefined } } : {}) }) + ctx.env = openCodeLaunch.env + ctx.launchCommand = openCodeLaunch.command ctx.spawnOptions.env = ctx.env promoteAgentTeamsShimPath(ctx.env, ctx.requestedAgentTeamsPath) const noDaemonLaunch = planCodexNoDaemonLaunch({ diff --git a/src/main/opencode/hook-service.ts b/src/main/opencode/hook-service.ts index 41f65243ab9..9162499472f 100644 --- a/src/main/opencode/hook-service.ts +++ b/src/main/opencode/hook-service.ts @@ -2,6 +2,7 @@ import { getAppEnvironment } from '../../shared/app-environment' import { join } from 'node:path' import { existsSync, + lstatSync, mkdirSync, readFileSync, readdirSync, @@ -10,22 +11,23 @@ import { writeFileSync } from 'node:fs' import { createHash } from 'node:crypto' -import { mirrorEntry, safeRemoveTree } from '../pty/overlay-mirror' +import { isSafeDescendCandidate, mirrorEntry, safeRemoveTree } from '../pty/overlay-mirror' import { getOpenCode2PluginSource, getOpenCodeFamilyPluginSource, getOpenCodePluginSource } from './status-plugin-module-source' +import { + readOpenCodeOverlayManifest, + OPENCODE_OVERLAY_MANIFEST_FILE, + type OpenCodeOverlayManifest +} from './opencode-overlay-manifest' import { resolveOpenCodeConfigDirectory } from '../../shared/opencode-config-directory' import { getOpenCodeLegacySharedConfigDir, OPENCODE2_LEGACY_HOOKS_DIR, OPENCODE_LEGACY_HOOKS_DIR } from './legacy-shared-config-dir' -import { - isInstalledOpenCodePluginCurrent, - isOverlayOpenCodePluginCurrent -} from '../../shared/opencode-installed-plugin' import { openCodeTuiPluginDirName, writeOpenCodeTuiPlugin @@ -34,19 +36,11 @@ import { writeLegacyOpenCodePluginWithAclRetry } from './legacy-plugin-acl-retry export { getOpenCode2PluginSource, getOpenCodeFamilyPluginSource, getOpenCodePluginSource } -import { - writeCanonicalOpenCodePluginAtomically, - writeOverlayOpenCodePluginAtomically -} from '../../shared/opencode-plugin-atomic-write' +import { writeCanonicalOpenCodePluginAtomically } from '../../shared/opencode-plugin-atomic-write' +import { writeOpenCodePluginConfig } from './opencode-plugin-config-writer' const ORCA_OPENCODE_PLUGIN_FILE = 'orca-opencode-status.js' const OPENCODE_OVERLAY_DIR = 'opencode-config-overlays' -const OPENCODE_OVERLAY_MANIFEST_FILE = '.orca-opencode-overlay-manifest.json' - -type OpenCodeOverlayManifest = { - topLevelEntries: string[] - pluginEntries: string[] -} type OpenCodeHookVariant = { pluginFileName: string @@ -55,6 +49,7 @@ type OpenCodeHookVariant = { pluginSource: () => string /** Also install the module as an OpenCode 2 TUI plugin (never for forks without one). */ installsTuiPlugin?: boolean + tuiOnlyDirectory?: string } // Why: session IDs may contain path separators and are hashed downstream; cap pathological input. @@ -74,6 +69,7 @@ export class OpenCodeHookService { private readonly legacyHooksDir: string private readonly overlayDir: string private readonly installsTuiPlugin: boolean + private readonly tuiOnlyDirectory: string | undefined constructor(variant?: OpenCodeHookVariant | (() => string)) { const config: OpenCodeHookVariant = @@ -93,6 +89,7 @@ export class OpenCodeHookService { }) this.pluginSource = config.pluginSource this.installsTuiPlugin = config.installsTuiPlugin === true + this.tuiOnlyDirectory = config.tuiOnlyDirectory this.pluginFileName = config.pluginFileName this.legacyHooksDir = config.legacyHooksDir this.overlayDir = config.overlayDir @@ -102,6 +99,27 @@ export class OpenCodeHookService { // Why: no-op — config dirs are app/source-scoped now, and recursive delete on the main-process hot path could freeze on Windows. } + installIntoSourceOverlay( + directory: string, + sourceConfigDir: string, + owner: OpenCodeHookService + ): 'unmatched' | 'installed' | 'failed' { + if (directory !== owner.getSourceOverlayDir(sourceConfigDir)) { + return 'unmatched' + } + try { + for (const path of [owner.getOverlayRoot(), directory, join(directory, 'plugins')]) { + if (!isSafeDescendCandidate(lstatSync(path))) { + return 'failed' + } + } + this.writePluginIntoOverlay(directory) + return 'installed' + } catch { + return 'failed' + } + } + buildPtyEnv(ptyId: string, existingConfigDir?: string | undefined): Record { if (!isUsableId(ptyId)) { // Why: on a bad id, still preserve a user-set OPENCODE_CONFIG_DIR; only the Orca status plugin is forfeited. @@ -118,13 +136,15 @@ export class OpenCodeHookService { return {} } } - if (!existsSync(existingConfigDir)) { + if (!existsSync(existingConfigDir) && !this.tuiOnlyDirectory) { return { OPENCODE_CONFIG_DIR: existingConfigDir } } const overlayDir = this.getSourceOverlayDir(existingConfigDir) try { mkdirSync(overlayDir, { recursive: true }) - this.mirrorUserConfig(existingConfigDir, overlayDir) + if (existsSync(existingConfigDir)) { + this.mirrorUserConfig(existingConfigDir, overlayDir) + } this.writePluginIntoOverlay(overlayDir) return { OPENCODE_CONFIG_DIR: overlayDir } } catch { @@ -135,6 +155,9 @@ export class OpenCodeHookService { // Why: pre-1.4.209 Orca left a server()-only plugin here that OpenCode 2 rejects. Only helps // processes that load it later; a running OpenCode 2 service keeps its cached module until restarted. refreshLegacySharedPlugin(): void { + if (this.tuiOnlyDirectory) { + return + } const pluginsDir = join(this.getSharedConfigDir(), 'plugins') const pluginPath = join(pluginsDir, this.pluginFileName) try { @@ -198,20 +221,6 @@ export class OpenCodeHookService { ) } - private readOverlayManifest(overlayDir: string): OpenCodeOverlayManifest { - try { - const parsed = JSON.parse( - readFileSync(join(overlayDir, OPENCODE_OVERLAY_MANIFEST_FILE), 'utf8') - ) as Partial - return { - topLevelEntries: Array.isArray(parsed.topLevelEntries) ? parsed.topLevelEntries : [], - pluginEntries: Array.isArray(parsed.pluginEntries) ? parsed.pluginEntries : [] - } - } catch { - return { topLevelEntries: [], pluginEntries: [] } - } - } - private writeOverlayManifest(overlayDir: string, manifest: OpenCodeOverlayManifest): void { writeFileSync( join(overlayDir, OPENCODE_OVERLAY_MANIFEST_FILE), @@ -235,7 +244,7 @@ export class OpenCodeHookService { // Why: mirror user config entries as symlinks so edits propagate live; only plugins/ becomes a real overlay dir so Orca can drop a sibling plugin file. private mirrorUserConfig(sourceDir: string, overlayDir: string): void { - const previousManifest = this.readOverlayManifest(overlayDir) + const previousManifest = readOpenCodeOverlayManifest(overlayDir) // Why: overlays persist across terminals; remove only Orca-mirrored paths so stale user config clears but OpenCode runtime dirs (node_modules) survive. this.clearManifestEntries(overlayDir, previousManifest) @@ -266,6 +275,7 @@ export class OpenCodeHookService { // Why: skip a user plugin sharing Orca's filename; mirroring it would let writePluginIntoOverlay clobber the user's file. if ( pluginEntry.name === this.pluginFileName || + pluginEntry.name === this.tuiOnlyDirectory || (this.installsTuiPlugin && pluginEntry.name === openCodeTuiPluginDirName(this.pluginFileName)) ) { @@ -288,27 +298,23 @@ export class OpenCodeHookService { this.writeOverlayManifest(overlayDir, nextManifest) } - // Atomic replacement detaches mirrored links without touching user plugins. private writePluginIntoOverlay(overlayDir: string): void { - const pluginsDir = join(overlayDir, 'plugins') - mkdirSync(pluginsDir, { recursive: true }) - const pluginPath = join(pluginsDir, this.pluginFileName) - const source = this.pluginSource() - this.writeTuiPlugin(pluginsDir, source, 'overlay') - if (!isOverlayOpenCodePluginCurrent(pluginPath, source)) { - writeOverlayOpenCodePluginAtomically(pluginPath, source) - } + this.writePluginToDirectory(overlayDir, 'overlay') } private writePluginToConfigDir(configDir: string): void { - const pluginsDir = join(configDir, 'plugins') - mkdirSync(pluginsDir, { recursive: true }) - const pluginPath = join(pluginsDir, this.pluginFileName) - const source = this.pluginSource() - this.writeTuiPlugin(pluginsDir, source) - if (!isInstalledOpenCodePluginCurrent(pluginPath, source)) { - writeCanonicalOpenCodePluginAtomically(pluginPath, source) - } + this.writePluginToDirectory(configDir, 'canonical') + } + + private writePluginToDirectory(configDir: string, ownership: 'canonical' | 'overlay'): void { + writeOpenCodePluginConfig({ + configDir, + ownership, + pluginFileName: this.pluginFileName, + getSource: () => this.pluginSource(), + installsTuiPlugin: this.installsTuiPlugin, + tuiOnlyDirectory: this.tuiOnlyDirectory + }) } private writeTuiPlugin( diff --git a/src/main/opencode/opencode-overlay-manifest.ts b/src/main/opencode/opencode-overlay-manifest.ts new file mode 100644 index 00000000000..5abd0328f25 --- /dev/null +++ b/src/main/opencode/opencode-overlay-manifest.ts @@ -0,0 +1,29 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' + +export const OPENCODE_OVERLAY_MANIFEST_FILE = '.orca-opencode-overlay-manifest.json' +export type OpenCodeOverlayManifest = { topLevelEntries: string[]; pluginEntries: string[] } + +export function readOpenCodeOverlayManifest(overlayDir: string): OpenCodeOverlayManifest { + const empty = { topLevelEntries: [], pluginEntries: [] } + try { + const parsed: unknown = JSON.parse( + readFileSync(join(overlayDir, OPENCODE_OVERLAY_MANIFEST_FILE), 'utf8') + ) + if (!parsed || typeof parsed !== 'object') { + return empty + } + return { + topLevelEntries: + 'topLevelEntries' in parsed && Array.isArray(parsed.topLevelEntries) + ? parsed.topLevelEntries.filter((entry): entry is string => typeof entry === 'string') + : [], + pluginEntries: + 'pluginEntries' in parsed && Array.isArray(parsed.pluginEntries) + ? parsed.pluginEntries.filter((entry): entry is string => typeof entry === 'string') + : [] + } + } catch { + return empty + } +} diff --git a/src/main/opencode/opencode-plugin-config-writer.ts b/src/main/opencode/opencode-plugin-config-writer.ts new file mode 100644 index 00000000000..98cfe1e7088 --- /dev/null +++ b/src/main/opencode/opencode-plugin-config-writer.ts @@ -0,0 +1,45 @@ +import { mkdirSync } from 'node:fs' +import { join } from 'node:path' +import { + isInstalledOpenCodePluginCurrent, + isOverlayOpenCodePluginCurrent +} from '../../shared/opencode-installed-plugin' +import { + writeCanonicalOpenCodePluginAtomically, + writeOverlayOpenCodePluginAtomically +} from '../../shared/opencode-plugin-atomic-write' +import { + writeOpenCodeTuiPlugin, + writeOpenCodeTuiPluginDirectory +} from '../../shared/opencode-tui-plugin-install' + +export function writeOpenCodePluginConfig(options: { + configDir: string + pluginFileName: string + getSource: () => string + installsTuiPlugin: boolean + tuiOnlyDirectory: string | undefined + ownership: 'canonical' | 'overlay' +}): void { + const pluginsDir = join(options.configDir, 'plugins') + mkdirSync(pluginsDir, { recursive: true }) + const pluginPath = join(pluginsDir, options.pluginFileName) + const source = options.getSource() + if (options.tuiOnlyDirectory) { + writeOpenCodeTuiPluginDirectory(pluginsDir, options.tuiOnlyDirectory, source, options.ownership) + return + } + if (options.installsTuiPlugin) { + writeOpenCodeTuiPlugin(pluginsDir, options.pluginFileName, source, options.ownership) + } + const overlay = options.ownership === 'overlay' + const current = overlay + ? isOverlayOpenCodePluginCurrent(pluginPath, source) + : isInstalledOpenCodePluginCurrent(pluginPath, source) + if (!current) { + const write = overlay + ? writeOverlayOpenCodePluginAtomically + : writeCanonicalOpenCodePluginAtomically + write(pluginPath, source) + } +} diff --git a/src/main/opencode/opencode-pty-launch.test.ts b/src/main/opencode/opencode-pty-launch.test.ts index f7813ddc969..854882d287f 100644 --- a/src/main/opencode/opencode-pty-launch.test.ts +++ b/src/main/opencode/opencode-pty-launch.test.ts @@ -1,6 +1,12 @@ +import { createHash } from 'node:crypto' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getOpenCodeCliCapabilities } from '../../shared/opencode-cli-version' import { prepareOpenCodePtyLaunch } from './opencode-pty-launch' +import { + OPENCODE_STARTUP_PROMPT_SHA256_ENV, + OPENCODE_STARTUP_PROMPT_BODY_ENV, + OPENCODE_STARTUP_PROMPT_SHELL_ENV +} from '../../shared/opencode-startup-prompt' import { buildLocalPtySpawnEnvironment, enforceLocalPtySpawnEnvironmentOverrides @@ -28,20 +34,195 @@ const plan: LocalPtyLaunchPlan = { launchWslDistro: null } +const hookServer = vi.hoisted(() => { + const server: { endpointFilePath: string | null } = { endpointFilePath: '/private/endpoint.env' } + return server +}) +vi.mock('../agent-hooks/server', () => ({ agentHookServer: hookServer })) +const reserve = vi.hoisted(() => vi.fn(() => true)) +vi.mock('./opencode-startup-prompt-owner', () => ({ reserveOpenCodeStartupPrompt: reserve })) + +async function prepare(options: Parameters[0]) { + return (await prepareOpenCodePtyLaunch(options)).env +} + const probe = vi.hoisted(() => vi.fn()) +const install = vi.hoisted(() => vi.fn()) +vi.mock('./opencode-startup-prompt-installer', () => ({ + installOpenCodeStartupPromptForLaunch: install +})) vi.mock('./opencode-launch-capabilities', () => ({ probeOpenCodeLaunchCapabilities: probe })) -beforeEach(() => probe.mockReset()) +beforeEach(() => { + probe.mockReset() + reserve.mockReset().mockReturnValue(true) + install.mockReset() + hookServer.endpointFilePath = '/private/endpoint.env' +}) afterEach(() => vi.unstubAllEnvs()) describe('execution-host OpenCode launch preparation', () => { + it('retains fresh owned source provenance through the final provider deletion pass', async () => { + probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16')) + install.mockImplementation((env) => { + env.OPENCODE_CONFIG_DIR = '/private/owned-overlay' + env.ORCA_OPENCODE_SOURCE_CONFIG_DIR = '/private/real-source' + return true + }) + const envToDelete = ['OPENCODE_CONFIG_DIR', 'ORCA_OPENCODE_SOURCE_CONFIG_DIR'] + const env = await prepare({ + command: 'opencode --prompt task', + isFreshLaunch: true, + envToDelete, + env: { + ORCA_AGENT_LAUNCH_TOKEN: 'admitted-launch', + ORCA_OPENCODE_STARTUP_PROMPT_SHA256: createHash('sha256').update('task').digest('hex'), + ORCA_OPENCODE_STARTUP_PROMPT_BODY: 'task', + ORCA_OPENCODE_STARTUP_PROMPT_SHELL: 'posix' + } + }) + const finalEnv = await buildLocalPtySpawnEnvironment({ + id: 'source-proof', + spawn: { cols: 80, rows: 24, env, envToDelete }, + getOptions: () => ({}), + plan + }) + enforceLocalPtySpawnEnvironmentOverrides({ cols: 80, rows: 24, env, envToDelete }, finalEnv) + expect(finalEnv.OPENCODE_CONFIG_DIR).toBe('/private/owned-overlay') + expect(finalEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBe('/private/real-source') + }) + it('removes only the automatic verified v2 prompt argument, retaining explicit run and manual flags', async () => { + probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16')) + const env = { + ORCA_AGENT_LAUNCH_TOKEN: 'admitted-launch', + [OPENCODE_STARTUP_PROMPT_SHA256_ENV]: createHash('sha256').update('task').digest('hex'), + [OPENCODE_STARTUP_PROMPT_BODY_ENV]: 'task', + [OPENCODE_STARTUP_PROMPT_SHELL_ENV]: 'posix' + } + const options = { env, envToDelete: [], isFreshLaunch: true } + expect( + ( + await prepareOpenCodePtyLaunch({ + ...options, + command: "opencode --standalone --prompt 'task'" + }) + ).command + ).toBe('opencode --standalone') + expect( + (await prepareOpenCodePtyLaunch({ ...options, command: "opencode run --prompt 'task'" })) + .command + ).toBe("opencode run --prompt 'task'") + expect( + (await prepareOpenCodePtyLaunch({ ...options, env: {}, command: "opencode --prompt 'task'" })) + .command + ).toBe("opencode --prompt 'task'") + }) + it.each(['inherited', 'explicit', 'deleted'] as const)( + 'passes the %s config environment used by the execution-host version probe to the prompt installer', + async (selection) => { + vi.stubEnv('XDG_CONFIG_HOME', '/ambient/config') + probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16')) + await prepareOpenCodePtyLaunch({ + command: 'opencode --prompt task', + envToDelete: selection === 'deleted' ? ['XDG_CONFIG_HOME'] : [], + isFreshLaunch: true, + env: { + ORCA_AGENT_LAUNCH_TOKEN: 'admitted-launch', + [OPENCODE_STARTUP_PROMPT_SHA256_ENV]: createHash('sha256').update('task').digest('hex'), + [OPENCODE_STARTUP_PROMPT_BODY_ENV]: 'task', + [OPENCODE_STARTUP_PROMPT_SHELL_ENV]: 'posix', + ...(selection === 'explicit' ? { XDG_CONFIG_HOME: '/selected/config' } : {}) + } + }) + expect(install).toHaveBeenCalledTimes(1) + const resolved = install.mock.calls[0][2] + expect(resolved).toEqual(probe.mock.calls[0][0].env) + expect(resolved.XDG_CONFIG_HOME).toBe( + selection === 'deleted' + ? undefined + : selection === 'explicit' + ? '/selected/config' + : '/ambient/config' + ) + } + ) + + it.each(['endpoint', 'installer', 'capacity', 'identity'])( + 'keeps the editable brief when automatic preparation lacks %s', + async (failure) => { + probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16')) + if (failure === 'endpoint') { + hookServer.endpointFilePath = null + } + if (failure === 'installer') { + install.mockImplementation((env) => { + delete env.ORCA_OPENCODE_STARTUP_PROMPT_NONCE + }) + } + if (failure === 'capacity') { + reserve.mockReturnValue(false) + } + const original = "opencode --standalone --prompt 'task'" + const result = await prepareOpenCodePtyLaunch({ + command: original, + envToDelete: [], + isFreshLaunch: true, + env: { + ...(failure === 'identity' ? {} : { ORCA_AGENT_LAUNCH_TOKEN: 'admitted-launch' }), + [OPENCODE_STARTUP_PROMPT_SHA256_ENV]: createHash('sha256').update('task').digest('hex'), + [OPENCODE_STARTUP_PROMPT_BODY_ENV]: 'task', + [OPENCODE_STARTUP_PROMPT_SHELL_ENV]: 'posix' + } + }) + expect(result.command).toBe(original) + expect(result.env).not.toHaveProperty('ORCA_OPENCODE_STARTUP_PROMPT_NONCE') + } + ) + it.each(['1.1.23', '2.0.16', '2.0.17', 'unknown'])( + 'gates native intent against the executing %s capability', + async (version) => { + probe.mockResolvedValue(getOpenCodeCliCapabilities(version)) + const envToDelete: string[] = [] + const fingerprint = createHash('sha256').update('task').digest('hex') + const env = await prepare({ + command: 'opencode --prompt task', + env: { + ORCA_AGENT_LAUNCH_TOKEN: 'admitted-launch', + [OPENCODE_STARTUP_PROMPT_SHA256_ENV]: fingerprint, + [OPENCODE_STARTUP_PROMPT_BODY_ENV]: 'task', + [OPENCODE_STARTUP_PROMPT_SHELL_ENV]: 'posix' + }, + envToDelete, + isFreshLaunch: true + }) + expect(env?.[OPENCODE_STARTUP_PROMPT_SHA256_ENV]).toBe( + version === '2.0.16' ? fingerprint : undefined + ) + expect(envToDelete.includes(OPENCODE_STARTUP_PROMPT_SHA256_ENV)).toBe(version !== '2.0.16') + } + ) + + it('refuses remote automatic intent without execution-owned driving input', async () => { + const fingerprint = 'b'.repeat(64) + const envToDelete: string[] = [] + const env = await prepare({ + command: 'opencode --prompt task', + connectionId: 'remote', + isFreshLaunch: true, + env: { [OPENCODE_STARTUP_PROMPT_SHA256_ENV]: fingerprint }, + envToDelete + }) + expect(env?.[OPENCODE_STARTUP_PROMPT_SHA256_ENV]).toBeUndefined() + expect(envToDelete).toContain(OPENCODE_STARTUP_PROMPT_SHA256_ENV) + expect(probe).not.toHaveBeenCalled() + }) it('keeps deleted credentials and config absent from the probe and final provider environment', async () => { vi.stubEnv('ANTHROPIC_API_KEY', 'dummy-deleted-key') vi.stubEnv('OPENCODE_CONFIG_DIR', '/dummy/deleted-config') vi.stubEnv('ORCA_OPENCODE_PLUGIN_API', 'v1') probe.mockResolvedValue(getOpenCodeCliCapabilities(null)) const envToDelete = ['ANTHROPIC_API_KEY', 'OPENCODE_CONFIG_DIR'] - const env = await prepareOpenCodePtyLaunch({ + const env = await prepare({ command: 'opencode', env: {}, envToDelete, @@ -67,7 +248,7 @@ describe('execution-host OpenCode launch preparation', () => { vi.stubEnv('ORCA_OPENCODE_PLUGIN_API', 'v1') probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16')) const envToDelete = ['KEEP_DELETED', 'ORCA_OPENCODE_PLUGIN_API'] - const env = await prepareOpenCodePtyLaunch({ + const env = await prepare({ command: 'opencode', env: {}, envToDelete, @@ -94,7 +275,7 @@ describe('execution-host OpenCode launch preparation', () => { KEEP: '1', ORCA_OPENCODE_PLUGIN_API: 'stale' } - const result = await prepareOpenCodePtyLaunch({ + const result = await prepare({ command: 'opencode --prompt test', agent: 'opencode', env, @@ -117,7 +298,7 @@ describe('execution-host OpenCode launch preparation', () => { it('creates a launch environment for a known binary without caller env', async () => { probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16')) expect( - await prepareOpenCodePtyLaunch({ + await prepare({ command: 'opencode', env: undefined, envToDelete: [], @@ -129,7 +310,7 @@ describe('execution-host OpenCode launch preparation', () => { it('forwards WSL plugin selection through WSLENV after a guest probe', async () => { probe.mockResolvedValue(getOpenCodeCliCapabilities('1.1.23')) const env = { KEEP: '1' } - const result = await prepareOpenCodePtyLaunch({ + const result = await prepare({ command: 'opencode', agent: 'opencode', env, @@ -148,9 +329,7 @@ describe('execution-host OpenCode launch preparation', () => { 'never probes the client for an attach or SSH launch', async (route) => { const env = { ORCA_OPENCODE_PLUGIN_API: 'v1' } - expect( - await prepareOpenCodePtyLaunch({ command: 'opencode', env, envToDelete: [], ...route }) - ).toEqual({}) + expect(await prepare({ command: 'opencode', env, envToDelete: [], ...route })).toEqual({}) expect(probe).not.toHaveBeenCalled() expect(env).toEqual({ ORCA_OPENCODE_PLUGIN_API: 'v1' }) } diff --git a/src/main/opencode/opencode-pty-launch.ts b/src/main/opencode/opencode-pty-launch.ts index 5aa5adab072..bf7d8bced1f 100644 --- a/src/main/opencode/opencode-pty-launch.ts +++ b/src/main/opencode/opencode-pty-launch.ts @@ -1,7 +1,29 @@ import { addWslEnvKeys } from '../../shared/wsl-env' import type { TuiAgent } from '../../shared/tui-agent' +import { randomUUID, createHash } from 'node:crypto' +import { agentHookServer } from '../agent-hooks/server' +import { tokenizeStartupCommand } from '../../shared/tui-agent-startup-shell' +import { isOpenCodeRunCommand } from '../../shared/opencode-headless-command' +import { + OPENCODE_STARTUP_PROMPT_SHA256_ENV, + OPENCODE_STARTUP_PROMPT_NONCE_ENV, + OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV, + OPENCODE_STARTUP_PROMPT_BODY_ENV, + OPENCODE_STARTUP_PROMPT_SHELL_ENV +} from '../../shared/opencode-startup-prompt' + +const intentKeys = [ + OPENCODE_STARTUP_PROMPT_SHA256_ENV, + OPENCODE_STARTUP_PROMPT_NONCE_ENV, + OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV, + OPENCODE_STARTUP_PROMPT_BODY_ENV, + OPENCODE_STARTUP_PROMPT_SHELL_ENV +] + import { deleteRequestedEnvKeys } from '../ipc/pty/host-env/path' import { probeOpenCodeLaunchCapabilities } from './opencode-launch-capabilities' +import { reserveOpenCodeStartupPrompt } from './opencode-startup-prompt-owner' +import { installOpenCodeStartupPromptForLaunch } from './opencode-startup-prompt-installer' export async function prepareOpenCodePtyLaunch(options: { command: string | undefined @@ -12,17 +34,29 @@ export async function prepareOpenCodePtyLaunch(options: { connectionId?: string | null isFreshLaunch: boolean wsl?: { distro?: string } -}): Promise | undefined> { +}): Promise<{ env: Record | undefined; command: string | undefined }> { + let command = options.command const env = options.env ? { ...options.env } : undefined + const requestedPrompt = env?.[OPENCODE_STARTUP_PROMPT_SHA256_ENV] + const body = env?.[OPENCODE_STARTUP_PROMPT_BODY_ENV] + const shell = env?.[OPENCODE_STARTUP_PROMPT_SHELL_ENV] if (env) { delete env.ORCA_OPENCODE_PLUGIN_API + for (const key of intentKeys) { + delete env[key] + } } // Providers merge their own ambient environment after this preparation. if (!options.envToDelete.includes('ORCA_OPENCODE_PLUGIN_API')) { options.envToDelete.push('ORCA_OPENCODE_PLUGIN_API') } + for (const key of intentKeys) { + if (!options.envToDelete.includes(key)) { + options.envToDelete.push(key) + } + } if (options.connectionId || !options.isFreshLaunch) { - return env + return { env, command } } const probeEnv: Record = {} for (const [key, value] of Object.entries({ ...process.env, ...env })) { @@ -36,12 +70,70 @@ export async function prepareOpenCodePtyLaunch(options: { env: probeEnv }) if (!capabilities || capabilities.pluginApi === 'unknown') { - return env + return { env, command } + } + const launchEnv: Record = { + ...env, + ORCA_OPENCODE_PLUGIN_API: capabilities.pluginApi } - const launchEnv = { ...env, ORCA_OPENCODE_PLUGIN_API: capabilities.pluginApi } options.envToDelete.splice(options.envToDelete.indexOf('ORCA_OPENCODE_PLUGIN_API'), 1) + if ( + capabilities.promptMode === 'prefill' && + !options.wsl && + launchEnv.ORCA_AGENT_LAUNCH_TOKEN && + requestedPrompt && + body && + command && + (shell === 'posix' || shell === 'powershell' || shell === 'cmd') && + createHash('sha256').update(body).digest('hex') === requestedPrompt + ) { + const parsed = tokenizeStartupCommand(command, shell) + const last = parsed.ok ? parsed.tokens.length - 1 : -1 + if ( + parsed.ok && + !isOpenCodeRunCommand(parsed.tokens, shell) && + parsed.tokens.filter((token) => token === '--prompt').length === 1 && + parsed.tokens[last - 1] === '--prompt' && + parsed.tokens[last] === body && + !parsed.spans[last].divergesFromShell && + !parsed.spans[last - 1].divergesFromShell + ) { + const endpoint = agentHookServer.endpointFilePath + if (endpoint) { + launchEnv[OPENCODE_STARTUP_PROMPT_SHA256_ENV] = requestedPrompt + launchEnv[OPENCODE_STARTUP_PROMPT_BODY_ENV] = body + launchEnv[OPENCODE_STARTUP_PROMPT_NONCE_ENV] = randomUUID() + launchEnv[OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV] = endpoint + if (installOpenCodeStartupPromptForLaunch(launchEnv, false, probeEnv)) { + for (const key of [ + 'OPENCODE_CONFIG_DIR', + 'ORCA_OPENCODE_CONFIG_DIR', + 'ORCA_OPENCODE_SOURCE_CONFIG_DIR' + ]) { + const deletion = options.envToDelete.indexOf(key) + if (launchEnv[key] && deletion !== -1) { + options.envToDelete.splice(deletion, 1) + } + } + } + const nonce = launchEnv[OPENCODE_STARTUP_PROMPT_NONCE_ENV] + if (nonce && reserveOpenCodeStartupPrompt(nonce, requestedPrompt)) { + command = command.slice(0, parsed.spans[last - 1].start).trimEnd() + } else { + for (const key of intentKeys) { + delete launchEnv[key] + } + } + for (const key of intentKeys) { + if (launchEnv[key]) { + options.envToDelete.splice(options.envToDelete.indexOf(key), 1) + } + } + } + } + } if (options.wsl) { addWslEnvKeys(launchEnv, ['ORCA_OPENCODE_PLUGIN_API']) } - return launchEnv + return { env: launchEnv, command } } diff --git a/src/main/opencode/opencode-startup-prompt-claims.test.ts b/src/main/opencode/opencode-startup-prompt-claims.test.ts new file mode 100644 index 00000000000..95f2f51fcbd --- /dev/null +++ b/src/main/opencode/opencode-startup-prompt-claims.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it, vi } from 'vitest' +import type { TerminalRunFacts } from '../runtime/terminal-run-facts' +import { OpenCodeStartupPromptClaims } from './opencode-startup-prompt-claims' + +describe('execution-owned startup prompt claims', () => { + it('consumes each nonce once and checks owner facts at claim time', () => { + const claims = new OpenCodeStartupPromptClaims() + let facts: TerminalRunFacts | null = { freshSpawn: true, firstUserInputAt: null } + claims.register('first', 'hash', () => facts) + facts.firstUserInputAt = 1 + expect(claims.claim({ nonce: 'first', digest: 'hash' })).toBe(false) + facts.firstUserInputAt = null + expect(claims.claim({ nonce: 'first', digest: 'hash' })).toBe(false) + claims.register('second', 'hash', () => facts) + expect(claims.claim({ nonce: 'second', digest: 'hash' })).toBe(true) + expect(claims.claim({ nonce: 'second', digest: 'hash' })).toBe(false) + claims.register('missing', 'hash', () => facts) + facts = null + expect(claims.claim({ nonce: 'missing', digest: 'hash' })).toBe(false) + }) + + it('refuses reattachment, mismatched hashes, expired claims and malformed bodies', () => { + let now = 0 + const claims = new OpenCodeStartupPromptClaims(() => now) + claims.register('reattach', 'hash', () => ({ freshSpawn: false, firstUserInputAt: null })) + expect(claims.claim({ nonce: 'reattach', digest: 'hash' })).toBe(false) + claims.register('mismatch', 'hash', () => ({ freshSpawn: true, firstUserInputAt: null })) + expect(claims.claim({ nonce: 'mismatch', digest: 'other' })).toBe(false) + expect(claims.claim({ nonce: 'mismatch', digest: 'hash' })).toBe(false) + claims.register('expired', 'hash', () => ({ freshSpawn: true, firstUserInputAt: null })) + now = 20000 + expect(claims.claim({ nonce: 'expired', digest: 'hash' })).toBe(false) + for (const body of [null, 1, {}, { nonce: 1 }, { nonce: 'absent' }]) { + expect(claims.claim(body)).toBe(false) + } + }) + + it('keeps pending admission bounded and never recreates canceled or consumed claims', () => { + let now = 0 + const claims = new OpenCodeStartupPromptClaims(() => now) + claims.register('waiting', 'hash', () => 'pending') + expect(claims.claim({ nonce: 'waiting', digest: 'hash' })).toBe('pending') + expect(claims.claim({ nonce: 'waiting', digest: 'hash' })).toBe('pending') + expect(claims.admit('waiting', () => ({ freshSpawn: true, firstUserInputAt: null }))).toBe(true) + expect(claims.claim({ nonce: 'waiting', digest: 'hash' })).toBe(true) + expect(claims.admit('waiting', () => ({ freshSpawn: true, firstUserInputAt: null }))).toBe( + false + ) + claims.register('canceled', 'hash', () => 'pending') + claims.cancel('canceled') + expect(claims.admit('canceled', () => ({ freshSpawn: true, firstUserInputAt: null }))).toBe( + false + ) + claims.register('expired', 'hash', () => 'pending') + now = 20000 + expect(claims.claim({ nonce: 'expired', digest: 'hash' })).toBe(false) + expect(claims.admit('expired', () => ({ freshSpawn: true, firstUserInputAt: null }))).toBe( + false + ) + claims.clear() + }) + + it('bounds pending claims and reclaims expired capacity without timers', () => { + let now = 0 + const claims = new OpenCodeStartupPromptClaims(() => now) + for (let index = 0; index < 129; index++) { + claims.register(String(index), 'hash', () => ({ freshSpawn: true, firstUserInputAt: null })) + } + expect(claims.claim({ nonce: '128', digest: 'hash' })).toBe(false) + now = 20000 + claims.register('new', 'hash', () => ({ freshSpawn: true, firstUserInputAt: null })) + expect(claims.claim({ nonce: 'new', digest: 'hash' })).toBe(true) + }) + it('starts a fresh bounded claim window after delayed spawn admission', () => { + vi.useFakeTimers() + try { + const claims = new OpenCodeStartupPromptClaims() + claims.register('slow-spawn', 'hash', () => 'pending') + vi.advanceTimersByTime(18000) + const cleanup = vi.fn() + expect( + claims.admit('slow-spawn', () => ({ freshSpawn: true, firstUserInputAt: null }), cleanup) + ).toBe(true) + vi.advanceTimersByTime(8000) + expect(claims.claim({ nonce: 'slow-spawn', digest: 'hash' })).toBe(true) + expect(cleanup).toHaveBeenCalledTimes(1) + claims.clear() + } finally { + vi.useRealTimers() + } + }) + + it('replays only the same operation while execution facts remain authorized', () => { + let now = 0 + const claims = new OpenCodeStartupPromptClaims(() => now) + let facts: TerminalRunFacts | null = { freshSpawn: true, firstUserInputAt: null } + const cleanup = vi.fn() + claims.register('retry', 'hash', () => facts, cleanup) + const body = { nonce: 'retry', digest: 'hash', requestId: 'stable-operation' } + expect(claims.claim(body)).toBe(true) + expect(claims.claim(body)).toBe(true) + expect(claims.claim({ ...body, requestId: 'another-operation' })).toBe(false) + expect(claims.claim({ nonce: 'retry', digest: 'hash' })).toBe(false) + expect(cleanup).not.toHaveBeenCalled() + expect(claims.claim(body)).toBe(true) + facts.firstUserInputAt = 1 + expect(claims.claim(body)).toBe(false) + expect(cleanup).toHaveBeenCalledTimes(1) + facts = { freshSpawn: true, firstUserInputAt: null } + expect(claims.claim(body)).toBe(false) + claims.register('expires', 'hash', () => facts, cleanup) + expect(claims.claim({ ...body, nonce: 'expires' })).toBe(true) + now = 20000 + expect(claims.claim({ ...body, nonce: 'expires' })).toBe(false) + expect(cleanup).toHaveBeenCalledTimes(2) + }) + + it('does not renew admission, retain unbounded IDs or replay retired owners', () => { + let now = 0 + const claims = new OpenCodeStartupPromptClaims(() => now) + let facts: TerminalRunFacts | null = { freshSpawn: true, firstUserInputAt: null } + claims.register('bound', 'hash', () => 'pending') + now = 18000 + expect(claims.admit('bound', () => facts)).toBe(true) + now = 37000 + expect(claims.admit('bound', () => facts)).toBe(false) + expect(claims.claim({ nonce: 'bound', digest: 'hash', requestId: 'x'.repeat(129) })).toBe(false) + expect(claims.claim({ nonce: 'bound', digest: 'hash', requestId: 'operation' })).toBe(true) + facts = null + expect(claims.claim({ nonce: 'bound', digest: 'hash', requestId: 'operation' })).toBe(false) + claims.register('clear', 'hash', () => ({ freshSpawn: true, firstUserInputAt: null })) + expect(claims.claim({ nonce: 'clear', digest: 'hash', requestId: 'operation' })).toBe(true) + claims.clear() + expect(claims.claim({ nonce: 'clear', digest: 'hash', requestId: 'operation' })).toBe(false) + }) + + it.each([null, 1, '', 'with spaces', 'x'.repeat(129)])( + 'rejects malformed operation ID %j without consuming valid authorization', + (requestId) => { + const claims = new OpenCodeStartupPromptClaims() + claims.register('valid', 'hash', () => ({ freshSpawn: true, firstUserInputAt: null })) + expect(claims.claim({ nonce: 'valid', digest: 'hash', requestId })).toBe(false) + expect(claims.claim({ nonce: 'valid', digest: 'hash', requestId: 'valid-operation' })).toBe( + true + ) + claims.clear() + } + ) + + it('expires the renewed window without permitting repeated admission to extend it', () => { + vi.useFakeTimers() + try { + const claims = new OpenCodeStartupPromptClaims() + claims.register('bounded', 'hash', () => 'pending') + vi.advanceTimersByTime(18000) + const owner = () => ({ freshSpawn: true, firstUserInputAt: null }) + const cleanup = vi.fn() + expect(claims.admit('bounded', owner, cleanup)).toBe(true) + expect(claims.claim({ nonce: 'bounded', digest: 'hash', requestId: 'operation' })).toBe(true) + vi.advanceTimersByTime(19999) + expect(claims.admit('bounded', owner)).toBe(false) + expect(claims.claim({ nonce: 'bounded', digest: 'hash', requestId: 'operation' })).toBe(true) + vi.advanceTimersByTime(1) + expect(cleanup).toHaveBeenCalledTimes(1) + expect(claims.claim({ nonce: 'bounded', digest: 'hash', requestId: 'operation' })).toBe(false) + claims.clear() + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/main/opencode/opencode-startup-prompt-claims.ts b/src/main/opencode/opencode-startup-prompt-claims.ts new file mode 100644 index 00000000000..3a8dca6053f --- /dev/null +++ b/src/main/opencode/opencode-startup-prompt-claims.ts @@ -0,0 +1,120 @@ +import type { TerminalRunFacts } from '../runtime/terminal-run-facts' + +type PromptClaim = { + digest: string + expiresAt: number + admitted: boolean + grantedRequestId?: string + readOwner: () => TerminalRunFacts | 'pending' | null + cleanup: () => void + expiry: ReturnType +} + +/** Authorizes one startup operation against current execution-owner facts. */ +export class OpenCodeStartupPromptClaims { + private readonly pending = new Map() + + constructor(private readonly now: () => number = Date.now) {} + + register( + nonce: string, + digest: string, + readOwner: PromptClaim['readOwner'], + cleanup = () => {} + ): boolean { + const now = this.now() + for (const [key, claim] of this.pending) { + if (claim.expiresAt <= now) { + this.cancel(key) + } + } + if (this.pending.size >= 128 || this.pending.has(nonce)) { + return false + } + const expiry = setTimeout(() => this.cancel(nonce), 20000) + expiry.unref?.() + this.pending.set(nonce, { + digest, + readOwner, + expiresAt: now + 20000, + admitted: false, + cleanup, + expiry + }) + return true + } + + admit(nonce: string, readOwner: PromptClaim['readOwner'], cleanup = () => {}): boolean { + const pending = this.pending.get(nonce) + if (!pending || pending.expiresAt <= this.now()) { + this.cancel(nonce) + return false + } + if (pending.admitted || pending.grantedRequestId !== undefined) { + return false + } + clearTimeout(pending.expiry) + pending.expiresAt = this.now() + 20000 + pending.expiry = setTimeout(() => this.cancel(nonce), 20000) + pending.expiry.unref?.() + pending.admitted = true + pending.readOwner = readOwner + pending.cleanup = cleanup + return true + } + + cancel(nonce: string): void { + const pending = this.pending.get(nonce) + this.pending.delete(nonce) + if (pending) { + clearTimeout(pending.expiry) + } + pending?.cleanup() + } + + clear(): void { + for (const nonce of this.pending.keys()) { + this.cancel(nonce) + } + } + + claim(body: unknown): boolean | 'pending' { + if (!body || typeof body !== 'object' || !('nonce' in body) || typeof body.nonce !== 'string') { + return false + } + const pending = this.pending.get(body.nonce) + if ( + !pending || + pending.expiresAt <= this.now() || + !('digest' in body) || + body.digest !== pending.digest + ) { + this.cancel(body.nonce) + return false + } + const requestId = 'requestId' in body ? body.requestId : undefined + if ( + requestId !== undefined && + (typeof requestId !== 'string' || !/^[a-zA-Z0-9_-]{1,128}$/.test(requestId)) + ) { + return false + } + if (pending.grantedRequestId !== undefined && pending.grantedRequestId !== requestId) { + return false + } + const owner = pending.readOwner() + if (owner === 'pending') { + return 'pending' + } + if (owner?.freshSpawn !== true || owner.firstUserInputAt !== null) { + this.cancel(body.nonce) + return false + } + if (requestId === undefined) { + this.cancel(body.nonce) + } else { + pending.grantedRequestId = requestId + } + return true + } +} diff --git a/src/main/opencode/opencode-startup-prompt-installer.test.ts b/src/main/opencode/opencode-startup-prompt-installer.test.ts new file mode 100644 index 00000000000..69ff1aa14af --- /dev/null +++ b/src/main/opencode/opencode-startup-prompt-installer.test.ts @@ -0,0 +1,253 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { setAppEnvironment } from '../../shared/app-environment' +import { + createOpenCodeStartupPromptInstaller, + installOpenCodeStartupPromptForLaunch +} from './opencode-startup-prompt-installer' +import { + captureOpenCodeSourceConfig, + applyOpenCodeStatusPluginEnv +} from '../ipc/pty/host-env/opencode-config' + +let root: string +let originalXdg: string | undefined +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'opencode-prompt-install-')) + originalXdg = process.env.XDG_CONFIG_HOME + process.env.XDG_CONFIG_HOME = join(root, 'config') + setAppEnvironment({ + getPath: () => join(root, 'profile'), + getAppPath: () => root, + getVersion: () => 'test', + isPackaged: () => false, + onWillQuit: () => {}, + exit: () => {}, + getAppMetrics: () => [] + }) +}) +afterEach(() => { + vi.unstubAllEnvs() + if (originalXdg === undefined) { + delete process.env.XDG_CONFIG_HOME + } else { + process.env.XDG_CONFIG_HOME = originalXdg + } + rmSync(root, { recursive: true, force: true }) +}) +describe('OpenCode startup prompt installer', () => { + it('refuses a replaced status overlay instead of writing through its symlink or losing status hooks', () => { + const source = join(root, 'safe-source') + const foreign = join(root, 'foreign') + mkdirSync(source) + mkdirSync(foreign) + writeFileSync(join(foreign, 'untouched.txt'), 'foreign bytes') + const env: Record = { + OPENCODE_CONFIG_DIR: source, + ORCA_OPENCODE_PLUGIN_API: 'v2', + ORCA_OPENCODE_STARTUP_PROMPT_NONCE: 'replaced-overlay' + } + const config = captureOpenCodeSourceConfig(env, join(root, 'profile')) + applyOpenCodeStatusPluginEnv( + 'pane', + env, + config, + { + launchAgent: 'opencode', + agentStatusHooksEnabled: true + }, + 'opencode' + ) + rmSync(env.OPENCODE_CONFIG_DIR, { recursive: true }) + symlinkSync(foreign, env.OPENCODE_CONFIG_DIR, 'junction') + expect(installOpenCodeStartupPromptForLaunch(env)).toBe(false) + expect(env.ORCA_OPENCODE_STARTUP_PROMPT_NONCE).toBeUndefined() + expect(readFileSync(join(foreign, 'untouched.txt'), 'utf8')).toBe('foreign bytes') + expect(existsSync(join(foreign, 'plugins'))).toBe(false) + }) + it.each(['opencode', 'opencode2'] as const)( + 'keeps real source provenance and composes the %s status and prompt in one final overlay', + (agent) => { + const source = join(root, 'real-source') + mkdirSync(join(source, 'plugins'), { recursive: true }) + writeFileSync(join(source, 'plugins', 'user.js'), 'user bytes') + writeFileSync(join(source, 'opencode.json'), '{"model":"selected/model"}') + const env: Record = { + OPENCODE_CONFIG_DIR: source, + ORCA_OPENCODE_PLUGIN_API: 'v2', + ORCA_OPENCODE_STARTUP_PROMPT_NONCE: 'source-provenance' + } + expect(installOpenCodeStartupPromptForLaunch(env, false)).toBe(true) + expect(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBe(source) + const captured = captureOpenCodeSourceConfig(env, join(root, 'profile')) + expect(captured.directory).toBe(source) + applyOpenCodeStatusPluginEnv( + 'pane', + env, + captured, + { + launchAgent: agent, + agentStatusHooksEnabled: true + }, + `${agent} --standalone` + ) + const statusOverlay = env.OPENCODE_CONFIG_DIR + expect(installOpenCodeStartupPromptForLaunch(env)).toBe(true) + expect(env.OPENCODE_CONFIG_DIR).toBe(statusOverlay) + expect(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBe(source) + expect( + readFileSync(join(statusOverlay, 'plugins', `orca-${agent}-status.js`), 'utf8') + ).toContain('ORCA_STATUS_AGENT') + expect( + existsSync(join(statusOverlay, 'plugins', 'orca-opencode-startup-prompt', 'tui.js')) + ).toBe(true) + expect(readFileSync(join(statusOverlay, 'plugins', 'user.js'), 'utf8')).toBe('user bytes') + const nested: Record = { + ...env, + ORCA_OPENCODE_STARTUP_PROMPT_NONCE: 'nested-source-provenance' + } + expect(installOpenCodeStartupPromptForLaunch(nested)).toBe(true) + expect(nested.OPENCODE_CONFIG_DIR).toBe(statusOverlay) + expect(nested.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBe(source) + expect(readFileSync(join(source, 'plugins', 'user.js'), 'utf8')).toBe('user bytes') + expect(existsSync(join(source, 'plugins', `orca-${agent}-status.js`))).toBe(false) + } + ) + it.each(['inherited', 'explicit'] as const)( + 'preserves status and user plugins from the %s XDG config when launch env is sparse', + (selection) => { + const configHome = join(root, selection === 'explicit' ? 'selected-config' : 'config') + const config = join(configHome, 'opencode') + mkdirSync(join(config, 'plugins'), { recursive: true }) + writeFileSync(join(config, 'plugins', 'orca-opencode-status.js'), 'status entry') + writeFileSync(join(config, 'plugins', 'user.js'), 'user entry') + writeFileSync(join(config, 'opencode.json'), '{"model":"selected/model"}') + const env: Record = { + ORCA_OPENCODE_PLUGIN_API: 'v2', + ORCA_OPENCODE_STARTUP_PROMPT_NONCE: 'sparse-launch', + ...(selection === 'explicit' ? { XDG_CONFIG_HOME: configHome } : {}) + } + expect(installOpenCodeStartupPromptForLaunch(env)).toBe(true) + expect( + readFileSync(join(env.OPENCODE_CONFIG_DIR, 'plugins', 'orca-opencode-status.js'), 'utf8') + ).toBe('status entry') + expect(readFileSync(join(env.OPENCODE_CONFIG_DIR, 'plugins', 'user.js'), 'utf8')).toBe( + 'user entry' + ) + expect(readFileSync(join(env.OPENCODE_CONFIG_DIR, 'opencode.json'), 'utf8')).toContain( + 'selected/model' + ) + } + ) + + it("uses the execution owner's resolved environment instead of reintroducing a deleted ambient XDG home", () => { + const selected = join(root, 'resolved-config') + mkdirSync(join(selected, 'opencode', 'plugins'), { recursive: true }) + writeFileSync(join(selected, 'opencode', 'plugins', 'user.js'), 'resolved entry') + const env: Record = { + ORCA_OPENCODE_PLUGIN_API: 'v2', + ORCA_OPENCODE_STARTUP_PROMPT_NONCE: 'resolved-launch' + } + expect(installOpenCodeStartupPromptForLaunch(env, false, { XDG_CONFIG_HOME: selected })).toBe( + true + ) + expect(readFileSync(join(env.OPENCODE_CONFIG_DIR, 'plugins', 'user.js'), 'utf8')).toBe( + 'resolved entry' + ) + expect(env.ORCA_OPENCODE_CONFIG_DIR).toBeUndefined() + }) + + it.each(['inherited', 'explicit'] as const)( + 'preserves the %s OPENCODE_CONFIG_DIR ahead of the XDG default', + (selection) => { + const ambient = join(root, 'ambient-source') + const selected = join(root, 'selected-source') + for (const config of [ambient, selected]) { + mkdirSync(join(config, 'plugins'), { recursive: true }) + writeFileSync(join(config, 'plugins', 'user.js'), config) + } + vi.stubEnv('OPENCODE_CONFIG_DIR', ambient) + const env: Record = { + ORCA_OPENCODE_PLUGIN_API: 'v2', + ORCA_OPENCODE_STARTUP_PROMPT_NONCE: 'custom-config-launch', + ...(selection === 'explicit' ? { OPENCODE_CONFIG_DIR: selected } : {}) + } + expect(installOpenCodeStartupPromptForLaunch(env)).toBe(true) + expect(readFileSync(join(env.OPENCODE_CONFIG_DIR, 'plugins', 'user.js'), 'utf8')).toBe( + selection === 'explicit' ? selected : ambient + ) + } + ) + + it('keeps a missing user config untouched and installs the launch into an owned overlay', () => { + const source = join(root, 'missing-user-config') + const env: Record = { + ORCA_OPENCODE_PLUGIN_API: 'v2', + ORCA_OPENCODE_STARTUP_PROMPT_NONCE: 'private-launch', + OPENCODE_CONFIG_DIR: source, + OPENCODE_CONFIG_CONTENT: '{"model":"opencode/model"}' + } + installOpenCodeStartupPromptForLaunch(env) + expect(existsSync(source)).toBe(false) + expect(env.OPENCODE_CONFIG_DIR).toContain( + join(root, 'profile', 'opencode-startup-prompt-overlays') + ) + expect(env.ORCA_OPENCODE_CONFIG_DIR).toBe(env.OPENCODE_CONFIG_DIR) + expect(env.OPENCODE_CONFIG_CONTENT).toBe('{"model":"opencode/model"}') + expect( + existsSync(join(env.OPENCODE_CONFIG_DIR, 'plugins', 'orca-opencode-startup-prompt', 'tui.js')) + ).toBe(true) + expect(existsSync(join(env.OPENCODE_CONFIG_DIR, 'plugins', 'orca-opencode-status.js'))).toBe( + false + ) + }) + it('installs only a TUI entry without installing status hooks or a v1/server entry', () => { + const service = createOpenCodeStartupPromptInstaller(() => 'prompt source') + expect(service.buildPtyEnv('pane')).toEqual({}) + const plugins = join(root, 'config', 'opencode', 'plugins') + expect(readFileSync(join(plugins, 'orca-opencode-startup-prompt', 'tui.js'), 'utf8')).toBe( + 'prompt source' + ) + expect(existsSync(join(plugins, 'orca-opencode-startup-prompt.js'))).toBe(false) + expect(existsSync(join(plugins, 'orca-opencode-status.js'))).toBe(false) + expect(existsSync(join(root, 'profile', 'opencode-startup-prompt-hooks'))).toBe(false) + }) + it('preserves user config and plugins across source-scoped overlay refreshes', () => { + const config = join(root, 'custom') + mkdirSync(join(config, 'plugins'), { recursive: true }) + writeFileSync(join(config, 'opencode.json'), '{"model":"user/model"}') + writeFileSync(join(config, 'plugins', 'user.js'), 'user source') + mkdirSync(join(config, 'plugins', 'orca-opencode-startup-prompt')) + writeFileSync( + join(config, 'plugins', 'orca-opencode-startup-prompt', 'tui.js'), + 'user collision' + ) + let source = 'first prompt source' + const service = createOpenCodeStartupPromptInstaller(() => source) + const first = service.buildPtyEnv('pane-a', config).OPENCODE_CONFIG_DIR + expect(first).toBeDefined() + source = 'next prompt source' + expect(service.buildPtyEnv('pane-b', config).OPENCODE_CONFIG_DIR).toBe(first) + if (!first) { + throw new Error('Missing overlay') + } + expect( + readFileSync(join(first, 'plugins', 'orca-opencode-startup-prompt', 'tui.js'), 'utf8') + ).toBe(source) + expect(readFileSync(join(first, 'opencode.json'), 'utf8')).toContain('user/model') + expect(readFileSync(join(first, 'plugins', 'user.js'), 'utf8')).toBe('user source') + expect( + readFileSync(join(config, 'plugins', 'orca-opencode-startup-prompt', 'tui.js'), 'utf8') + ).toBe('user collision') + }) +}) diff --git a/src/main/opencode/opencode-startup-prompt-installer.ts b/src/main/opencode/opencode-startup-prompt-installer.ts new file mode 100644 index 00000000000..6ae79cc58d2 --- /dev/null +++ b/src/main/opencode/opencode-startup-prompt-installer.ts @@ -0,0 +1,82 @@ +import { OpenCodeHookService, openCodeHookService, openCode2HookService } from './hook-service' +import { OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY } from '../../shared/opencode-startup-prompt-install' +import { join } from 'node:path' +import { resolveOpenCodeConfigDirectory } from '../../shared/opencode-config-directory' +import { isOverlayOpenCodePluginCurrent } from '../../shared/opencode-installed-plugin' +import { getOpenCodeStartupPromptSource } from './opencode-startup-prompt-source' +import { resolveOpenCodeSourceConfigDir } from '../ipc/pty/host-env/pi-agent' +import { + OPENCODE_STARTUP_PROMPT_NONCE_ENV, + OPENCODE_STARTUP_PROMPT_SHA256_ENV, + OPENCODE_STARTUP_PROMPT_BODY_ENV, + OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV +} from '../../shared/opencode-startup-prompt' + +export function createOpenCodeStartupPromptInstaller(source: () => string): OpenCodeHookService { + return new OpenCodeHookService({ + pluginFileName: `${OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY}.js`, + legacyHooksDir: 'opencode-startup-prompt-hooks', + overlayDir: 'opencode-startup-prompt-overlays', + pluginSource: source, + tuiOnlyDirectory: OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY + }) +} + +const installer = createOpenCodeStartupPromptInstaller(getOpenCodeStartupPromptSource) + +export function installOpenCodeStartupPromptForLaunch( + env: Record, + restoreAfterShellStartup = true, + sourceEnvironment: NodeJS.ProcessEnv = { ...process.env, ...env } +): boolean { + const nonce = env[OPENCODE_STARTUP_PROMPT_NONCE_ENV] + if (!nonce || env.ORCA_OPENCODE_PLUGIN_API !== 'v2') { + return false + } + const source = + resolveOpenCodeSourceConfigDir(env, sourceEnvironment) || + resolveOpenCodeConfigDirectory(sourceEnvironment) + const statusOwner = + env.ORCA_OPENCODE_AGENT === 'opencode2' ? openCode2HookService : openCodeHookService + const existing = + env.OPENCODE_CONFIG_DIR && env.OPENCODE_CONFIG_DIR === env.ORCA_OPENCODE_CONFIG_DIR + ? installer.installIntoSourceOverlay(env.OPENCODE_CONFIG_DIR, source, statusOwner) + : 'unmatched' + const overlay = + existing === 'installed' + ? env.OPENCODE_CONFIG_DIR + : existing === 'failed' + ? undefined + : installer.buildPtyEnv(nonce, source).OPENCODE_CONFIG_DIR + if ( + !overlay || + !isOverlayOpenCodePluginCurrent( + join(overlay, 'plugins', OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY, 'tui.js'), + getOpenCodeStartupPromptSource() + ) + ) { + for (const key of [ + OPENCODE_STARTUP_PROMPT_NONCE_ENV, + OPENCODE_STARTUP_PROMPT_SHA256_ENV, + OPENCODE_STARTUP_PROMPT_BODY_ENV, + OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV + ]) { + delete env[key] + } + return false + } + env.OPENCODE_CONFIG_DIR = overlay + env.ORCA_OPENCODE_SOURCE_CONFIG_DIR = source + if (restoreAfterShellStartup || existing === 'installed') { + env.ORCA_OPENCODE_CONFIG_DIR = overlay + } else { + delete env.ORCA_OPENCODE_CONFIG_DIR + } + return true +} + +export function ensureOpenCodeStartupPromptForLaunch(env: Record): void { + if (env[OPENCODE_STARTUP_PROMPT_NONCE_ENV] && !installOpenCodeStartupPromptForLaunch(env)) { + throw new Error('Cannot prepare OpenCode startup prompt; launch was canceled.') + } +} diff --git a/src/main/opencode/opencode-startup-prompt-owner.test.ts b/src/main/opencode/opencode-startup-prompt-owner.test.ts new file mode 100644 index 00000000000..40aa8eabe93 --- /dev/null +++ b/src/main/opencode/opencode-startup-prompt-owner.test.ts @@ -0,0 +1,172 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { TerminalRunFactsRegister } from '../runtime/terminal-run-facts' +import { + reserveOpenCodeStartupPrompt, + commitPtyWithOpenCodePromptIntent +} from './opencode-startup-prompt-owner' + +const control = vi.hoisted(() => ({ + claim: (_body: unknown): boolean | 'pending' => false, + clear: () => {} +})) +const ownership = vi.hoisted(() => ({ + ptyOwnership: new Map(), + ptyIncarnationById: new Map() +})) +vi.mock('../agent-hooks/server', () => ({ + agentHookServer: { + setStartupPromptClaimListener: (claim: typeof control.claim, clear: () => void) => { + control.claim = claim + control.clear = clear + } + } +})) +vi.mock('../ipc/pty/provider/ownership-state', () => ownership) +beforeEach(() => { + control.clear() + ownership.ptyOwnership.clear() + ownership.ptyIncarnationById.clear() +}) + +function fixture() { + const facts = new TerminalRunFactsRegister() + const result = { id: 'owned', incarnationId: 'incarnation' } + let identityReady = false + let release = () => {} + const waiting = new Promise((resolve) => { + release = resolve + }) + const runtime = { + terminalRunFacts: facts, + readOpenCodeStartupPromptOwner: () => + identityReady ? facts.read(result.id, result.incarnationId) : ('pending' as const), + isPtyStopRequested: () => false, + subscribeToPtyExit: (_ptyId: string, _listener: () => void) => () => {} + } + ownership.ptyOwnership.set(result.id, null) + ownership.ptyIncarnationById.set(result.id, result.incarnationId) + const context = { + env: { + ORCA_OPENCODE_STARTUP_PROMPT_NONCE: 'nonce', + ORCA_OPENCODE_STARTUP_PROMPT_SHA256: 'digest', + ORCA_AGENT_LAUNCH_TOKEN: 'launch' + }, + deps: { runtime }, + result, + provider: { hasPty: () => true }, + args: {} + } + reserveOpenCodeStartupPrompt('nonce', 'digest') + const body = { nonce: 'nonce', digest: 'digest' } + return { + facts, + result, + context, + waiting, + release, + body, + admit: () => { + identityReady = true + } + } +} + +describe('native prompt admission after spawn commit', () => { + it('waits through delayed persistence and later runtime identity admission', async () => { + const f = fixture() + const committed = commitPtyWithOpenCodePromptIntent(f.context, async () => { + await f.waiting + f.facts.recordSpawnCommit(f.result) + return f.result + }) + expect(control.claim(f.body)).toBe('pending') + f.release() + await committed + expect(control.claim(f.body)).toBe('pending') + f.admit() + expect(control.claim(f.body)).toBe(true) + expect(control.claim(f.body)).toBe(false) + }) + + it('keeps pre-commit driving input sticky even if the draft is erased', async () => { + const f = fixture() + const committed = commitPtyWithOpenCodePromptIntent(f.context, async () => { + await f.waiting + f.facts.recordSpawnCommit(f.result) + return f.result + }) + f.facts.recordInput(f.result.id, 'driving', 'x') + f.facts.recordInput(f.result.id, 'driving', '\u007f') + expect(control.claim(f.body)).toBe('pending') + f.release() + await committed + f.admit() + expect(control.claim(f.body)).toBe(false) + }) + + it('cancels a failed commit without allowing later admission', async () => { + const f = fixture() + await expect( + commitPtyWithOpenCodePromptIntent(f.context, async () => { + throw new Error('persistence rejected') + }) + ).rejects.toThrow('persistence rejected') + f.admit() + expect(control.claim(f.body)).toBe(false) + }) + it.each(['incarnation', 'provider', 'stop', 'exit', 'clear'])( + 'invalidates granted replay after %s', + async (reason) => { + const f = fixture() + let exited = () => {} + const unsubscribe = vi.fn() + vi.spyOn(f.context.deps.runtime, 'subscribeToPtyExit').mockImplementation( + (_id, listener: () => void) => { + exited = listener + return unsubscribe + } + ) + await commitPtyWithOpenCodePromptIntent(f.context, async () => { + f.facts.recordSpawnCommit(f.result) + return f.result + }) + f.admit() + const body = { ...f.body, requestId: 'stable-operation' } + expect(control.claim(body)).toBe(true) + expect(control.claim(body)).toBe(true) + if (reason === 'incarnation') { + ownership.ptyIncarnationById.set(f.result.id, 'replacement') + } + if (reason === 'provider') { + vi.spyOn(f.context.provider, 'hasPty').mockReturnValue(false) + } + if (reason === 'stop') { + vi.spyOn(f.context.deps.runtime, 'isPtyStopRequested').mockReturnValue(true) + } + if (reason === 'exit') { + exited() + } + if (reason === 'clear') { + control.clear() + } + expect(control.claim(body)).toBe(false) + expect(unsubscribe).toHaveBeenCalledTimes(1) + f.facts.recordSpawnCommit(f.result) + expect(control.claim(body)).toBe(false) + } + ) + + it('retains sticky input cancellation after a lost grant response', async () => { + const f = fixture() + await commitPtyWithOpenCodePromptIntent(f.context, async () => { + f.facts.recordSpawnCommit(f.result) + return f.result + }) + f.admit() + const body = { ...f.body, requestId: 'stable-operation' } + expect(control.claim(body)).toBe(true) + f.facts.recordInput(f.result.id, 'driving', 'x') + f.facts.recordInput(f.result.id, 'driving', '\u007f') + expect(control.claim(body)).toBe(false) + }) +}) diff --git a/src/main/opencode/opencode-startup-prompt-owner.ts b/src/main/opencode/opencode-startup-prompt-owner.ts new file mode 100644 index 00000000000..be1dacba25d --- /dev/null +++ b/src/main/opencode/opencode-startup-prompt-owner.ts @@ -0,0 +1,111 @@ +import { agentHookServer } from '../agent-hooks/server' +import type { OrcaRuntimeService } from '../runtime/orca-runtime' +import type { IPtyProvider, PtySpawnResult } from '../providers/types' +import { ptyIncarnationById, ptyOwnership } from '../ipc/pty/provider/ownership-state' +import { isPtyIncarnationId } from '../../shared/pty-incarnation' +import { + OPENCODE_STARTUP_PROMPT_NONCE_ENV, + OPENCODE_STARTUP_PROMPT_SHA256_ENV +} from '../../shared/opencode-startup-prompt' +import { OpenCodeStartupPromptClaims } from './opencode-startup-prompt-claims' + +type OpenCodePromptRuntime = Pick< + OrcaRuntimeService, + | 'terminalRunFacts' + | 'readOpenCodeStartupPromptOwner' + | 'isPtyStopRequested' + | 'subscribeToPtyExit' +> +const claims = new OpenCodeStartupPromptClaims() + +export function reserveOpenCodeStartupPrompt(nonce: string, digest: string): boolean { + agentHookServer.setStartupPromptClaimListener( + (body) => claims.claim(body), + () => claims.clear() + ) + return claims.register(nonce, digest, () => 'pending') +} + +export async function commitPtyWithOpenCodePromptIntent( + context: { + env?: Record + spawnEnv?: Record + deps: { runtime?: OpenCodePromptRuntime } + result: PtySpawnResult + provider: Pick + args: { connectionId?: string | null } + }, + commit: () => Promise +): Promise { + const options = { + env: context.spawnEnv ?? context.env, + runtime: context.deps.runtime, + result: context.result, + provider: context.provider, + connectionId: context.args.connectionId + } + const facts = options.runtime?.terminalRunFacts + facts?.reserveSpawnCommit(options.result) + try { + const result = await commit() + bindOpenCodeStartupPromptOwner({ ...options, result }) + return result + } catch (error) { + const nonce = options.env?.[OPENCODE_STARTUP_PROMPT_NONCE_ENV] + if (nonce) { + claims.cancel(nonce) + } + throw error + } finally { + facts?.discardSpawnCommit(options.result) + } +} + +export function bindOpenCodeStartupPromptOwner(options: { + env: Record | undefined + result: PtySpawnResult + runtime: OpenCodePromptRuntime | undefined + provider: Pick + connectionId?: string | null +}): void { + const { env, result, runtime, provider } = options + const nonce = env?.[OPENCODE_STARTUP_PROMPT_NONCE_ENV] + const digest = env?.[OPENCODE_STARTUP_PROMPT_SHA256_ENV] + const launchToken = env?.ORCA_AGENT_LAUNCH_TOKEN + const incarnation = result.incarnationId + if ( + options.connectionId || + !runtime || + result.isReattach || + result.agentSessionEnsure?.disposition === 'adopted' || + !isPtyIncarnationId(incarnation) || + !nonce || + !digest || + !launchToken + ) { + if (nonce) { + claims.cancel(nonce) + } + return + } + let unsubscribe = () => {} + if ( + claims.admit( + nonce, + () => { + if ( + ptyOwnership.get(result.id) !== null || + ptyIncarnationById.get(result.id) !== incarnation || + provider.hasPty?.(result.id) !== true || + runtime.isPtyStopRequested(result.id) + ) { + return null + } + return runtime.readOpenCodeStartupPromptOwner(result.id, incarnation, launchToken) + }, + () => unsubscribe() + ) + ) { + unsubscribe = runtime.subscribeToPtyExit(result.id, () => claims.cancel(nonce)) + } +} diff --git a/src/main/opencode/opencode-startup-prompt-runtime-identity.test.ts b/src/main/opencode/opencode-startup-prompt-runtime-identity.test.ts new file mode 100644 index 00000000000..8f4759957a1 --- /dev/null +++ b/src/main/opencode/opencode-startup-prompt-runtime-identity.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it, vi } from 'vitest' +import { OpenCodeStartupPromptClaims } from './opencode-startup-prompt-claims' +import { + createTranscriptPane, + TRANSCRIPT_PANE_PTY_ID +} from '../runtime/agent-transcript-pane-test-harness' + +vi.mock('electron', () => ({ + BrowserWindow: { fromId: vi.fn(() => null) }, + webContents: { fromId: vi.fn(() => null) }, + ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + app: { getPath: vi.fn(() => '/tmp') } +})) + +async function fixture(launchAgent?: 'opencode' | 'opencode2' | 'zcode') { + const { runtime } = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: null, + data: '', + ...(launchAgent ? { launchAgent } : {}) + }) + runtime.terminalRunFacts.recordSpawnCommit({ id: TRANSCRIPT_PANE_PTY_ID, incarnationId: 'inc-1' }) + const read = ( + ptyId = TRANSCRIPT_PANE_PTY_ID, + incarnation = 'inc-1', + token = 'transcript-launch' + ) => runtime.readOpenCodeStartupPromptOwner(ptyId, incarnation, token) + return { runtime, read } +} + +describe('runtime-owned startup prompt identity', () => { + it('keeps launch admission pending before runtime identity is assigned', async () => { + const f = await fixture() + expect(f.read()).toBe('pending') + expect(f.read('missing')).toBeNull() + expect(f.read(TRANSCRIPT_PANE_PTY_ID, 'previous-incarnation')).toBeNull() + }) + + it.each(['opencode', 'opencode2'] as const)( + 'reads only the admitted %s launch', + async (agent) => { + const f = await fixture(agent) + expect(f.read()).toEqual({ freshSpawn: true, firstUserInputAt: null }) + expect(f.read(TRANSCRIPT_PANE_PTY_ID, 'inc-1', 'another-launch')).toBeNull() + expect(f.read(TRANSCRIPT_PANE_PTY_ID, 'previous-incarnation')).toBeNull() + f.runtime.terminalRunFacts.recordInput(TRANSCRIPT_PANE_PTY_ID, 'driving', 'x', 123) + expect(f.read()).toEqual({ freshSpawn: true, firstUserInputAt: 123 }) + } + ) + + it('denies another agent even with the exact incarnation and launch token', async () => { + expect((await fixture('zcode')).read()).toBeNull() + }) + it('refuses same-ID replay after the execution owner or launch token changes', async () => { + const f = await fixture('opencode2') + const claims = new OpenCodeStartupPromptClaims() + claims.register('owned-operation', 'digest', () => f.read()) + const body = { nonce: 'owned-operation', digest: 'digest', requestId: 'stable-operation' } + expect(claims.claim(body)).toBe(true) + expect(claims.claim(body)).toBe(true) + await f.runtime.onPtyExit(TRANSCRIPT_PANE_PTY_ID, 0, 'inc-1') + f.runtime.registerPty(TRANSCRIPT_PANE_PTY_ID, 'wt-1', null, { + tabId: 'tab-1', + leafId: '11111111-1111-4111-8111-111111111111', + incarnationId: 'inc-2', + agentLaunchAuthority: { launchToken: 'replacement-launch', launchAgent: 'opencode2' } + }) + f.runtime.terminalRunFacts.recordSpawnCommit({ + id: TRANSCRIPT_PANE_PTY_ID, + incarnationId: 'inc-2' + }) + expect(f.read(TRANSCRIPT_PANE_PTY_ID, 'inc-2', 'replacement-launch')).toEqual({ + freshSpawn: true, + firstUserInputAt: null + }) + expect(f.read(TRANSCRIPT_PANE_PTY_ID, 'inc-2', 'transcript-launch')).toBeNull() + expect(claims.claim(body)).toBe(false) + claims.clear() + }) +}) diff --git a/src/main/opencode/opencode-startup-prompt-source.test.ts b/src/main/opencode/opencode-startup-prompt-source.test.ts new file mode 100644 index 00000000000..2e505ee2a8f --- /dev/null +++ b/src/main/opencode/opencode-startup-prompt-source.test.ts @@ -0,0 +1,761 @@ +import { EventEmitter } from 'node:events' +import { createServer } from 'node:http' +import { createHash } from 'node:crypto' +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + OPENCODE_STARTUP_PROMPT_SHA256_ENV, + OPENCODE_STARTUP_PROMPT_NONCE_ENV, + OPENCODE_STARTUP_PROMPT_BODY_ENV, + OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV +} from '../../shared/opencode-startup-prompt' +import { getOpenCodeStartupPromptSource } from './opencode-startup-prompt-source' +import { OpenCodeStartupPromptClaims } from './opencode-startup-prompt-claims' +import { cancelTrackingResponse } from '../lib/unread-response-body.test-fixtures' + +type PluginModule = { default: { setup: (ctx: unknown) => Promise<() => Promise> } } +const prompt = 'exact startup brief\nwith unicode é' +const digest = createHash('sha256').update(prompt).digest('hex') +let dir: string +let setup: PluginModule['default']['setup'] +let claim: ReturnType + +class Editor extends EventEmitter { + traits: { owner: string; role: string; capture: string[]; status?: string } = { + owner: 'opencode', + role: 'prompt', + capture: ['tab'] + } + plainText = '' + focused = true + insertText(text: string) { + this.replace(this.plainText + text) + } + replace(text: string) { + this.plainText = text + this.emit('line-info-change') + } +} + +type FixtureLocation = { directory: string; workspaceID?: string } + +function fixture() { + const editor = new Editor() + const input = new EventEmitter() + const memory = { settled: false, expiresAt: Date.now() + 20000 } + const route = { type: 'home' } + const agent = vi.fn((): unknown[] | undefined => [{}]) + const model = vi.fn((): unknown[] | undefined => [{}]) + const sync = vi.fn(async (_location: FixtureLocation) => {}) + const dispatch = vi.fn(() => editor.replace('')) + const ctx = { + app: { version: '2.0.16' }, + renderer: { keyInput: input, currentFocusedEditor: editor }, + storage: { memory: () => [memory, (mutate: (draft: typeof memory) => void) => mutate(memory)] }, + keymap: { dispatch }, + ui: { router: { current: () => route } }, + location: { directory: '/private' }, + data: { location: { sync, agent: { list: agent }, model: { list: model } } } + } + return { ctx, editor, input, memory, route, agent, model, sync, dispatch } +} + +beforeEach(async () => { + dir = mkdtempSync(join(tmpdir(), 'orca-opencode-prompt-')) + const path = join(dir, 'prompt.mjs') + writeFileSync(path, getOpenCodeStartupPromptSource()) + const module: PluginModule = await import(pathToFileURL(path).href) + setup = module.default.setup + vi.useFakeTimers() + vi.stubEnv(OPENCODE_STARTUP_PROMPT_SHA256_ENV, digest) + vi.stubEnv(OPENCODE_STARTUP_PROMPT_BODY_ENV, prompt) + vi.stubEnv(OPENCODE_STARTUP_PROMPT_NONCE_ENV, 'single-use-nonce') + const endpoint = join(dir, 'endpoint.cmd') + writeFileSync( + endpoint, + 'set ORCA_AGENT_HOOK_PORT=12345\nset ORCA_AGENT_HOOK_TOKEN=private-token\nset ORCA_AGENT_HOOK_ENV=test\nset ORCA_AGENT_HOOK_VERSION=1\n' + ) + vi.stubEnv(OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV, endpoint) + claim = vi.fn(async () => ({ ok: true, json: async () => ({ allowed: true }) })) + vi.stubGlobal('fetch', claim) +}) +afterEach(() => { + vi.useRealTimers() + vi.unstubAllEnvs() + vi.unstubAllGlobals() + rmSync(dir, { recursive: true, force: true }) +}) + +describe('installed-version native prompt intent plugin', () => { + it('waits for the current home location after the startup directory changes', async () => { + const f = fixture() + let location: FixtureLocation = { directory: '/private/home/private-folder' } + Object.defineProperty(f.ctx, 'location', { get: () => location }) + let releaseStartup = () => {} + let releaseHome = () => {} + let selectedModel = 'opencode/mimo-v2.6-flash-free' + const selections: string[] = [] + f.sync.mockImplementation( + (target) => + new Promise((resolve) => { + if (target.directory.endsWith('/private-folder')) { + releaseStartup = resolve + } else { + releaseHome = () => { + selectedModel = 'private-proof/model-a' + resolve() + } + } + }) + ) + f.dispatch.mockImplementation(() => { + selections.push(selectedModel) + f.editor.replace('') + }) + const dispose = await setup(f.ctx) + try { + await vi.advanceTimersByTimeAsync(100) + location = { directory: '/private/home' } + releaseStartup() + await vi.advanceTimersByTimeAsync(300) + expect(claim).not.toHaveBeenCalled() + expect(selections).toEqual([]) + expect(f.sync).toHaveBeenCalledTimes(2) + expect(f.sync).toHaveBeenLastCalledWith(location) + releaseHome() + await vi.advanceTimersByTimeAsync(500) + await vi.waitFor(() => expect(f.dispatch).toHaveBeenCalledTimes(1)) + expect(selections).toEqual(['private-proof/model-a']) + expect(claim).toHaveBeenCalledTimes(1) + } finally { + await dispose() + } + }) + + it('waits for a concrete location before starting authoritative hydration', async () => { + const f = fixture() + let location: FixtureLocation | undefined + Object.defineProperty(f.ctx, 'location', { get: () => location }) + const dispose = await setup(f.ctx) + try { + await vi.advanceTimersByTimeAsync(300) + expect(f.sync).not.toHaveBeenCalled() + expect(claim).not.toHaveBeenCalled() + location = { directory: '/private/home' } + await vi.advanceTimersByTimeAsync(500) + await vi.waitFor(() => expect(f.dispatch).toHaveBeenCalledTimes(1)) + expect(f.sync).toHaveBeenCalledExactlyOnceWith(location) + } finally { + await dispose() + } + }) + + it('keeps readiness scoped to the workspace as well as the directory', async () => { + const f = fixture() + let location: FixtureLocation = { directory: '/private', workspaceID: 'first' } + Object.defineProperty(f.ctx, 'location', { get: () => location }) + let release = () => {} + f.sync.mockImplementationOnce( + () => + new Promise((resolve) => { + release = resolve + }) + ) + const dispose = await setup(f.ctx) + try { + await vi.advanceTimersByTimeAsync(100) + location = { directory: '/private', workspaceID: 'second' } + release() + await vi.advanceTimersByTimeAsync(500) + await vi.waitFor(() => expect(f.dispatch).toHaveBeenCalledTimes(1)) + expect(f.sync).toHaveBeenCalledTimes(2) + expect(f.sync).toHaveBeenLastCalledWith(location) + expect(claim).toHaveBeenCalledTimes(1) + } finally { + await dispose() + } + }) + + it('refuses a granted claim after the composer location changes', async () => { + const f = fixture() + let location: FixtureLocation = { directory: '/private' } + Object.defineProperty(f.ctx, 'location', { get: () => location }) + let grant = () => {} + claim.mockImplementation( + () => + new Promise((resolve) => { + grant = () => resolve({ ok: true, json: async () => ({ allowed: true }) }) + }) + ) + const insert = vi.spyOn(f.editor, 'insertText') + const dispose = await setup(f.ctx) + try { + await vi.advanceTimersByTimeAsync(500) + await vi.waitFor(() => expect(claim).toHaveBeenCalledTimes(1)) + location = { directory: '/private/other' } + grant() + await vi.advanceTimersByTimeAsync(500) + expect(insert).not.toHaveBeenCalled() + expect(f.dispatch).not.toHaveBeenCalled() + expect(f.memory.settled).toBe(true) + } finally { + await dispose() + } + }) + + it('waits for authoritative location config before claiming or selecting a model', async () => { + const f = fixture() + let configuredModel = 'unavailable-fallback' + let release = () => {} + f.sync.mockImplementation( + () => + new Promise((resolve) => { + release = () => { + configuredModel = 'configured-model' + resolve() + } + }) + ) + const selections: string[] = [] + f.dispatch.mockImplementation(() => { + selections.push(configuredModel) + f.editor.replace('') + }) + const insert = vi.spyOn(f.editor, 'insertText') + const dispose = await setup(f.ctx) + try { + await vi.advanceTimersByTimeAsync(500) + expect(claim).not.toHaveBeenCalled() + expect(insert).not.toHaveBeenCalled() + expect(selections).toEqual([]) + expect(f.sync).toHaveBeenCalledExactlyOnceWith(f.ctx.location) + release() + await vi.advanceTimersByTimeAsync(500) + await vi.waitFor(() => expect(f.dispatch).toHaveBeenCalledExactlyOnceWith('prompt.submit')) + expect(selections).toEqual(['configured-model']) + expect(insert).toHaveBeenCalledExactlyOnceWith(prompt) + expect(claim).toHaveBeenCalledTimes(1) + expect(f.sync).toHaveBeenCalledTimes(1) + } finally { + await dispose() + } + }) + + it.each(['keypress', 'paste', 'edit', 'route', 'expiry', 'dispose', 'editor', 'focus'])( + 'cancels delayed location hydration on %s before any claim', + async (reason) => { + const f = fixture() + let release = () => {} + f.sync.mockImplementation( + () => + new Promise((resolve) => { + release = resolve + }) + ) + const dispose = await setup(f.ctx) + try { + await vi.advanceTimersByTimeAsync(100) + expect(claim).not.toHaveBeenCalled() + if (reason === 'keypress' || reason === 'paste') { + f.input.emit(reason) + } + if (reason === 'edit') { + f.editor.replace('typed') + f.editor.replace('') + } + if (reason === 'route') { + f.route.type = 'session' + } + if (reason === 'expiry') { + f.memory.expiresAt = Date.now() + } + if (reason === 'dispose') { + await dispose() + } + if (reason === 'editor') { + f.ctx.renderer.currentFocusedEditor = new Editor() + } + if (reason === 'focus') { + f.editor.focused = false + } + await vi.advanceTimersByTimeAsync(100) + release() + await vi.advanceTimersByTimeAsync(500) + expect(claim).not.toHaveBeenCalled() + expect(f.dispatch).not.toHaveBeenCalled() + expect(f.editor.plainText).toBe('') + if (reason !== 'focus') { + expect(f.memory.settled).toBe(true) + } + } finally { + await dispose() + } + expect(f.input.listenerCount('keypress')).toBe(0) + expect(f.editor.listenerCount('line-info-change')).toBe(0) + } + ) + + it('fails closed when authoritative location sync rejects', async () => { + const f = fixture() + f.sync.mockRejectedValue(new Error('location unavailable')) + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + expect(f.memory.settled).toBe(true) + expect(claim).not.toHaveBeenCalled() + expect(f.dispatch).not.toHaveBeenCalled() + expect(f.input.listenerCount('keypress')).toBe(0) + await dispose() + }) + + it('fails closed when authoritative location sync is missing', async () => { + const f = fixture() + const { sync: _sync, ...location } = f.ctx.data.location + const dispose = await setup({ ...f.ctx, data: { location } }) + await vi.advanceTimersByTimeAsync(500) + expect(claim).not.toHaveBeenCalled() + expect(f.dispatch).not.toHaveBeenCalled() + expect(f.input.listenerCount('keypress')).toBe(0) + await dispose() + }) + + it.each(['non-ok', 'json-rejected'])('cancels unread %s claim bodies', async (reason) => { + const cancelled = vi.fn() + const response = cancelTrackingResponse(reason === 'non-ok' ? 503 : 200, cancelled) + if (reason === 'json-rejected') { + vi.spyOn(response, 'json').mockRejectedValue(new Error('decoder rejected')) + } + claim.mockResolvedValue(response) + const f = fixture() + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + await vi.waitFor(() => expect(cancelled).toHaveBeenCalled()) + expect(f.memory.settled).toBe(false) + expect(f.dispatch).not.toHaveBeenCalled() + await dispose() + expect(f.memory.settled).toBe(true) + }) + + it('consumes an allowed claim body before dispatching the prompt', async () => { + const response = Response.json({ allowed: true }) + claim.mockResolvedValue(response) + const f = fixture() + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + await vi.waitFor(() => expect(f.dispatch).toHaveBeenCalledExactlyOnceWith('prompt.submit')) + expect(response.bodyUsed).toBe(true) + await dispose() + }) + + it('consumes malformed JSON and retries without delivering until expiry', async () => { + const response = new Response('{invalid', { status: 200 }) + claim.mockResolvedValue(response) + const f = fixture() + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + await vi.waitFor(() => { + expect(response.bodyUsed).toBe(true) + expect(claim.mock.calls.length).toBeGreaterThanOrEqual(2) + }) + expect(f.memory.settled).toBe(false) + f.memory.expiresAt = Date.now() + await vi.advanceTimersByTimeAsync(100) + expect(response.bodyUsed).toBe(true) + expect(f.dispatch).not.toHaveBeenCalled() + expect(f.memory.settled).toBe(true) + await dispose() + }) + + it.each(['dialog', 'shell', 'autocomplete'])('does not populate a %s editor', async (kind) => { + const f = fixture() + if (kind === 'dialog') { + f.editor.traits.role = 'dialog' + } + if (kind === 'shell') { + f.editor.traits.status = 'SHELL' + } + if (kind === 'autocomplete') { + f.editor.traits.capture = ['escape', 'navigate', 'submit', 'tab'] + } + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + expect(f.editor.plainText).toBe('') + expect(claim).not.toHaveBeenCalled() + expect(f.dispatch).not.toHaveBeenCalled() + await dispose() + }) + it('retries pending admission, then submits once', async () => { + claim.mockResolvedValueOnce({ ok: true, json: async () => ({ allowed: false, pending: true }) }) + const f = fixture() + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(100) + await vi.waitFor(() => expect(claim).toHaveBeenCalledTimes(1)) + expect(f.dispatch).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(300) + await vi.waitFor(() => expect(f.dispatch).toHaveBeenCalledTimes(1)) + expect(claim).toHaveBeenCalledTimes(2) + await dispose() + }) + + it('cancels pending admission on input without retrying a consumed denial', async () => { + claim.mockResolvedValue({ ok: true, json: async () => ({ allowed: false, pending: true }) }) + const f = fixture() + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(100) + await vi.waitFor(() => expect(claim).toHaveBeenCalledTimes(1)) + f.input.emit('keypress', { name: 'x' }) + await vi.advanceTimersByTimeAsync(1000) + expect(claim).toHaveBeenCalledTimes(1) + expect(f.dispatch).not.toHaveBeenCalled() + await dispose() + }) + + it('preserves typing that predates plugin setup without requesting owner permission', async () => { + const f = fixture() + f.editor.replace('early typing') + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + expect(f.editor.plainText).toBe('early typing') + expect(claim).not.toHaveBeenCalled() + expect(f.dispatch).not.toHaveBeenCalled() + await dispose() + }) + it('waits for catalogs and settles before a single dispatch', async () => { + const f = fixture() + f.model.mockReturnValue(undefined) + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + expect(f.dispatch).not.toHaveBeenCalled() + f.model.mockReturnValue([{}]) + await vi.advanceTimersByTimeAsync(500) + await vi.waitFor(() => expect(f.dispatch).toHaveBeenCalledExactlyOnceWith('prompt.submit')) + expect(claim).toHaveBeenCalledTimes(1) + expect(f.memory.settled).toBe(true) + f.editor.replace(prompt) + await vi.advanceTimersByTimeAsync(500) + expect(f.dispatch).toHaveBeenCalledTimes(1) + await dispose() + const reloadDispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + expect(f.dispatch).toHaveBeenCalledTimes(1) + await reloadDispose() + }) + + it.each(['keypress', 'paste'])('cancels on physical %s before catalogs finish', async (event) => { + const f = fixture() + f.agent.mockReturnValue(undefined) + const dispose = await setup(f.ctx) + f.input.emit(event) + f.agent.mockReturnValue([{}]) + await vi.advanceTimersByTimeAsync(500) + expect(f.dispatch).not.toHaveBeenCalled() + expect(f.memory.settled).toBe(true) + await dispose() + }) + + it('cancels a changed draft even when it is restored before the next tick', async () => { + const f = fixture() + f.model.mockReturnValue(undefined) + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(100) + f.editor.replace('edited') + f.editor.replace('') + f.model.mockReturnValue([{}]) + await vi.advanceTimersByTimeAsync(500) + expect(f.dispatch).not.toHaveBeenCalled() + await dispose() + }) + + it('cancels pending intent on route changes, expiration and disposal', async () => { + for (const reason of ['route', 'expiration', 'dispose']) { + const f = fixture() + f.model.mockReturnValue(undefined) + const dispose = await setup(f.ctx) + if (reason === 'route') { + f.route.type = 'session' + } + if (reason === 'expiration') { + f.memory.expiresAt = Date.now() + } + if (reason === 'dispose') { + await dispose() + } + await vi.advanceTimersByTimeAsync(100) + f.model.mockReturnValue([{}]) + await vi.advanceTimersByTimeAsync(500) + expect(f.dispatch).not.toHaveBeenCalled() + expect(f.memory.settled).toBe(true) + await dispose() + expect(f.input.listenerCount('keypress')).toBe(0) + } + }) + + it('leaves unverified versions and mismatched drafts unsubmitted', async () => { + const f = fixture() + f.ctx.app.version = '2.0.17' + await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + expect(f.dispatch).not.toHaveBeenCalled() + f.ctx.app.version = '2.0.16' + f.editor.replace('another brief') + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + expect(f.dispatch).not.toHaveBeenCalled() + await dispose() + }) + + it.each(['canceled', 'unavailable'])( + 'fails closed when the execution owner is %s', + async (reason) => { + claim.mockImplementation(async () => { + if (reason === 'unavailable') { + throw new Error('contact lost') + } + return { ok: true, json: async () => ({ allowed: false }) } + }) + const f = fixture() + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + expect(f.dispatch).not.toHaveBeenCalled() + if (reason === 'unavailable') { + await vi.waitFor(() => expect(claim).toHaveBeenCalled()) + expect(f.memory.settled).toBe(false) + f.memory.expiresAt = Date.now() + await vi.advanceTimersByTimeAsync(100) + } + await vi.waitFor(() => expect(f.memory.settled).toBe(true)) + await dispose() + } + ) + + it('rechecks physical cancellation after the owner response', async () => { + const f = fixture() + claim.mockImplementation(async () => { + f.input.emit('keypress') + return { ok: true, json: async () => ({ allowed: true }) } + }) + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + await vi.waitFor(() => expect(claim).toHaveBeenCalledTimes(1)) + await vi.waitFor(() => expect(f.memory.settled).toBe(true)) + expect(f.dispatch).not.toHaveBeenCalled() + await dispose() + }) + it('settles before insertion and never repeats dispatch when the draft is retained', async () => { + const f = fixture() + f.dispatch.mockImplementation(() => {}) + const insert = vi.spyOn(f.editor, 'insertText') + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(1500) + await vi.waitFor(() => expect(f.dispatch).toHaveBeenCalledTimes(1)) + expect(insert).toHaveBeenCalledExactlyOnceWith(prompt) + expect(f.memory.settled).toBe(true) + expect(f.editor.plainText).toBe(prompt) + await dispose() + const reloadDispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + expect(f.dispatch).toHaveBeenCalledTimes(1) + await reloadDispose() + }) + + it.each(['before-grant', 'after-grant', 'timeout-after-grant'])( + 'recovers actual HTTP response loss %s exactly once', + async (phase) => { + vi.useRealTimers() + vi.unstubAllGlobals() + const claims = new OpenCodeStartupPromptClaims() + claims.register('single-use-nonce', digest, () => ({ + freshSpawn: true, + firstUserInputAt: null + })) + let requests = 0 + const bodies: unknown[] = [] + const grants: (boolean | 'pending')[] = [] + let heldResponse: ReturnType | undefined + const server = createServer(async (request, response) => { + let text = '' + for await (const chunk of request) { + text += chunk.toString() + } + const body: unknown = JSON.parse(text) + bodies.push(body) + requests++ + if (requests === 1 && phase === 'before-grant') { + response.writeHead(503).end('temporarily unavailable') + return + } + const allowed = claims.claim(body) + grants.push(allowed) + if (requests === 1 && phase === 'after-grant') { + response.destroy() + return + } + response.writeHead(200, { 'content-type': 'application/json' }) + if (requests === 1 && phase === 'timeout-after-grant') { + response.write('{"allowed":') + heldResponse = setTimeout(() => response.end('true}'), 1300) + return + } + response.end(JSON.stringify({ allowed: allowed === true, pending: allowed === 'pending' })) + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (!address || typeof address === 'string') { + throw new Error('missing loopback address') + } + writeFileSync( + join(dir, 'endpoint.cmd'), + `set ORCA_AGENT_HOOK_PORT=${address.port}\nset ORCA_AGENT_HOOK_TOKEN=private-token\nset ORCA_AGENT_HOOK_ENV=test\nset ORCA_AGENT_HOOK_VERSION=1\n` + ) + const f = fixture() + const dispose = await setup(f.ctx) + try { + await vi.waitFor( + () => + expect( + f.dispatch, + JSON.stringify({ phase, requests, bodies, grants }) + ).toHaveBeenCalledTimes(1), + { timeout: 3000 } + ) + await new Promise((resolve) => setTimeout(resolve, 300)) + expect(requests).toBe(2) + expect(grants).toEqual(phase === 'before-grant' ? [true] : [true, true]) + expect(bodies[1]).toEqual(bodies[0]) + expect(bodies[0]).toHaveProperty('requestId', expect.any(String)) + expect(f.memory.settled).toBe(true) + expect(f.editor.plainText).toBe('') + } finally { + await dispose() + claims.clear() + clearTimeout(heldResponse) + server.closeAllConnections() + await new Promise((resolve) => server.close(() => resolve())) + } + } + ) + + it.each([408, 429, 503])( + 'retries transient HTTP %s with one stable operation ID', + async (status) => { + claim.mockResolvedValueOnce({ ok: false, status }) + const f = fixture() + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + await vi.waitFor(() => expect(f.dispatch).toHaveBeenCalledTimes(1)) + const firstBody = JSON.parse(claim.mock.calls[0][1].body) + expect(firstBody.requestId).toMatch(/^[a-f0-9-]{36}$/) + expect(JSON.parse(claim.mock.calls[1][1].body)).toEqual(firstBody) + expect(claim).toHaveBeenCalledTimes(2) + await dispose() + } + ) + + it.each([401, 403, 404])('settles HTTP %s denial without retrying', async (status) => { + claim.mockResolvedValue({ ok: false, status }) + const f = fixture() + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + await vi.waitFor(() => expect(f.memory.settled).toBe(true)) + expect(claim).toHaveBeenCalledTimes(1) + expect(f.dispatch).not.toHaveBeenCalled() + await dispose() + }) + + it.each(['input', 'route', 'dispose', 'expiry', 'editor'])( + 'rejects late grants after %s changes', + async (reason) => { + const f = fixture() + let release = (_response: unknown) => {} + claim.mockImplementation( + () => + new Promise((resolve) => { + release = resolve + }) + ) + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(100) + await vi.waitFor(() => expect(claim).toHaveBeenCalledTimes(1)) + if (reason === 'input') { + f.input.emit('paste') + } + if (reason === 'route') { + f.route.type = 'session' + } + if (reason === 'dispose') { + await dispose() + } + if (reason === 'expiry') { + f.memory.expiresAt = Date.now() + } + if (reason === 'editor') { + f.ctx.renderer.currentFocusedEditor = new Editor() + } + release({ ok: true, json: async () => ({ allowed: true }) }) + await vi.advanceTimersByTimeAsync(500) + expect(f.dispatch).not.toHaveBeenCalled() + expect(f.editor.plainText).toBe('') + await dispose() + } + ) + + it.each(['input', 'route', 'dispose'])( + 'ends delivery when %s changes during insertion', + async (reason) => { + const f = fixture() + let dispose = async () => {} + const insert = f.editor.insertText.bind(f.editor) + vi.spyOn(f.editor, 'insertText').mockImplementation((text) => { + expect(f.memory.settled).toBe(true) + insert(text) + if (reason === 'input') { + f.input.emit('keypress') + } + if (reason === 'route') { + f.route.type = 'session' + } + if (reason === 'dispose') { + void dispose() + } + }) + dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + await vi.waitFor(() => expect(claim).toHaveBeenCalledTimes(1)) + expect(f.dispatch).not.toHaveBeenCalled() + expect(f.memory.settled).toBe(true) + await dispose() + } + ) + + it.each(['allow', 'lost-response'])( + 'degrades safely against a legacy host with %s', + async (reason) => { + const claims = new OpenCodeStartupPromptClaims() + claims.register('single-use-nonce', digest, () => ({ + freshSpawn: true, + firstUserInputAt: null + })) + let lost = false + claim.mockImplementation(async (_url, init) => { + const body = JSON.parse(init.body) + const allowed = claims.claim({ nonce: body.nonce, digest: body.digest }) + if (reason === 'lost-response' && !lost) { + lost = true + throw new Error('legacy grant response lost') + } + return { ok: true, json: async () => ({ allowed }) } + }) + const f = fixture() + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + await vi.waitFor(() => expect(f.memory.settled).toBe(true)) + expect(f.dispatch).toHaveBeenCalledTimes(reason === 'allow' ? 1 : 0) + expect(claim).toHaveBeenCalledTimes(reason === 'allow' ? 1 : 2) + expect(f.editor.plainText).toBe('') + claims.clear() + await dispose() + } + ) +}) diff --git a/src/main/opencode/opencode-startup-prompt-source.ts b/src/main/opencode/opencode-startup-prompt-source.ts new file mode 100644 index 00000000000..4bb37861102 --- /dev/null +++ b/src/main/opencode/opencode-startup-prompt-source.ts @@ -0,0 +1,152 @@ +import { cancelUnreadResponseBody } from '../lib/unread-response-body' +import { parseAgentHookEndpointFile } from '../../shared/agent-hook-endpoint-file' +import { + OPENCODE_STARTUP_PROMPT_SHA256_ENV, + OPENCODE_STARTUP_PROMPT_NONCE_ENV, + OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV, + OPENCODE_STARTUP_PROMPT_CLAIM_PATH, + OPENCODE_STARTUP_PROMPT_BODY_ENV +} from '../../shared/opencode-startup-prompt' + +export function getOpenCodeStartupPromptSource(): string { + return String.raw` +const parseEndpoint = ${parseAgentHookEndpointFile.toString()}; +const cancelUnreadResponseBody = ${cancelUnreadResponseBody.toString()}; +async function claimStartupPrompt(nonce, digest, endpoint, requestId) { + let response; + try { + const { readFile, stat } = await import("node:fs/promises"); + if ((await stat(endpoint)).size > 4096) return false; + const coords = parseEndpoint(await readFile(endpoint, "utf8")); + const port = Number(coords.port); + if (!Number.isInteger(port) || port < 1 || port > 65535) return false; + response = await fetch("http://127.0.0.1:" + port + "${OPENCODE_STARTUP_PROMPT_CLAIM_PATH}", { + method: "POST", headers: { "content-type": "application/json", "x-orca-agent-hook-token": coords.token }, + body: JSON.stringify({ nonce, digest, requestId }), signal: AbortSignal.timeout(1000) + }); + if (!response.ok) return response.status === 408 || response.status === 429 || response.status >= 500 ? "pending" : false; + const result = await response.json(); + return result.allowed === true ? true : result.pending === true ? "pending" : false; + } catch { + // A lost grant response can be replayed with the same operation ID until expiry. + return "pending"; + } finally { + if (response) await cancelUnreadResponseBody(response); + } +} +async function submitStartupPrompt(ctx) { + const noop = async () => {}; + const digest = process.env.${OPENCODE_STARTUP_PROMPT_SHA256_ENV}; + const nonce = process.env.${OPENCODE_STARTUP_PROMPT_NONCE_ENV}; + const endpoint = process.env.${OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV}; + const prompt = process.env.${OPENCODE_STARTUP_PROMPT_BODY_ENV}; + if (ctx?.app?.version !== "2.0.16" || !/^[a-f0-9]{64}$/.test(digest || "") || !nonce || !endpoint || !prompt) return noop; + const input = ctx.renderer?.keyInput; + if (typeof ctx.storage?.memory !== "function" || typeof input?.on !== "function" || + typeof input?.off !== "function" || typeof ctx.keymap?.dispatch !== "function" || + typeof ctx.ui?.router?.current !== "function" || + typeof ctx.data?.location?.sync !== "function" || + typeof ctx.data?.location?.agent?.list !== "function" || + typeof ctx.data?.location?.model?.list !== "function") return noop; + const [memory, setMemory] = ctx.storage.memory("startup-prompt", { + initial: { settled: false, expiresAt: Date.now() + 20000 } + }); + if (memory.settled) return noop; + let timer, editor, seen = false, disposed = false, canceled = false, createHash, requestId, claiming = false, hydrating = false, readyLocation; + // Home can change location after plugin setup. + const locationKey = (location) => typeof location?.directory === "string" && location.directory ? + JSON.stringify([location.directory, location.workspaceID]) : undefined; + const isComposer = (candidate) => candidate?.traits?.owner === "opencode" && + candidate.traits.role === "prompt" && !candidate.traits.status && + candidate.traits.capture?.length === 1 && candidate.traits.capture[0] === "tab"; + const matches = (candidate) => typeof candidate?.plainText === "string" && + createHash("sha256").update(candidate.plainText).digest("hex") === digest; + const cleanup = () => { + clearInterval(timer); + input.off("keypress", cancel); + input.off("paste", cancel); + editor?.off("line-info-change", changed); + }; + const settle = () => { + setMemory((draft) => { draft.settled = true; }); + cleanup(); + }; + const cancel = () => { canceled = true; settle(); }; + // Any edit before delivery ends this startup operation. + const changed = () => { if (seen && editor.plainText !== "") settle(); }; + input.on("keypress", cancel); + input.on("paste", cancel); + const dispose = async () => { disposed = true; settle(); }; + try { + const crypto = await import("node:crypto"); + createHash = crypto.createHash; + setMemory((draft) => { draft.requestId ??= crypto.randomUUID(); }); + requestId = memory.requestId; + if (createHash("sha256").update(prompt).digest("hex") !== digest) { await dispose(); return noop; } + if (memory.settled) return dispose; + timer = setInterval(async () => { + if (disposed || memory.settled) return; + try { + if (Date.now() >= memory.expiresAt || ctx.ui.router.current()?.type !== "home") return settle(); + const current = ctx.renderer.currentFocusedEditor; + if (!isComposer(current)) { if (seen) settle(); return; } + if (editor !== current) { + if (seen) return settle(); + editor?.off("line-info-change", changed); + editor = current; + editor?.on("line-info-change", changed); + } + if (typeof editor?.plainText !== "string" || typeof editor?.insertText !== "function") return; + if (editor.plainText !== "") return settle(); + seen = true; + const location = ctx.location; + const key = locationKey(location); + if (!key) return; + if (readyLocation !== key) { + readyLocation = undefined; + if (!hydrating) { + hydrating = true; + const ref = { directory: location.directory, workspaceID: location.workspaceID }; + void ctx.data.location.sync(ref).then(() => { + hydrating = false; + if (!disposed && !memory.settled && locationKey(ctx.location) === key) readyLocation = key; + }, settle); + } + return; + } + const agents = ctx.data.location.agent.list(location); + const models = ctx.data.location.model.list(location); + if (!editor.focused || !agents?.length || !models?.length) return; + if (claiming) return; + claiming = true; + const allowed = await claimStartupPrompt(nonce, digest, endpoint, requestId); + claiming = false; + if (allowed === "pending") return; + if (!allowed) return settle(); + if (disposed || memory.settled || Date.now() >= memory.expiresAt || + locationKey(ctx.location) !== key || + ctx.ui.router.current()?.type !== "home" || ctx.renderer.currentFocusedEditor !== editor || + !editor.focused || !isComposer(editor) || editor.plainText !== "") return settle(); + setMemory((draft) => { draft.settled = true; }); + clearInterval(timer); + editor.off("line-info-change", changed); + try { + editor.insertText(prompt); + if (disposed || canceled || Date.now() >= memory.expiresAt || + locationKey(ctx.location) !== key || + ctx.ui.router.current()?.type !== "home" || ctx.renderer.currentFocusedEditor !== editor || + !editor.focused || !isComposer(editor) || !matches(editor)) return; + ctx.keymap.dispatch("prompt.submit"); + } finally { cleanup(); } + } catch { settle(); } + }, 100); + timer.unref?.(); + return dispose; + } catch { + settle(); + return noop; + } +} +export default { id: "orca-opencode-startup-prompt", setup: submitStartupPrompt }; +`.trimStart() +} diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index ad9f51e7ad6..a71307a8df5 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -151,6 +151,7 @@ async function startOrcadRuntime( | undefined let uninstallHookStatusRepublish = (): void => {} let uninstallObservedStatusIdentity = (): void => {} + let removeStatusHookSettingsListener = (): void => {} registerCleanup(async () => { try { await rpc?.stop() @@ -167,6 +168,7 @@ async function startOrcadRuntime( // orcad restart goes back to killing every running terminal. await stopOrcadDaemon() } finally { + removeStatusHookSettingsListener() uninstallObservedStatusIdentity() uninstallHookStatusRepublish() agentHookServer.stop() @@ -194,9 +196,17 @@ async function startOrcadRuntime( uninstallObservedStatusIdentity = agentHookServer.subscribeEnrichedStatus((enriched) => observedStatusCapture.observe(enriched) ) - if (isAgentStatusHooksEnabled(profileStore.getSettings())) { - await agentHookServer.start({ env: 'production', userDataPath: runtimeUserDataPath }) - } + await agentHookServer.start({ + env: 'production', + userDataPath: runtimeUserDataPath, + statusHooksEnabled: isAgentStatusHooksEnabled(profileStore.getSettings()) + }) + + removeStatusHookSettingsListener = profileStore.onSettingsChanged((updates, settings) => { + if ('agentStatusHooksEnabled' in updates) { + agentHookServer.setStatusHooksEnabled(isAgentStatusHooksEnabled(settings)) + } + }) // Why before the runtime and the PTY handlers: `setLocalPtyProvider` installs the daemon // adapter as THE local provider, and the registry's contract is that it lands before diff --git a/src/main/orcad/orcad-push-startup.test.ts b/src/main/orcad/orcad-push-startup.test.ts index 530d06f4ee8..41bba43d4cf 100644 --- a/src/main/orcad/orcad-push-startup.test.ts +++ b/src/main/orcad/orcad-push-startup.test.ts @@ -1,7 +1,8 @@ import { mkdtempSync, readdirSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { ProfilePreferences } from '../persistence/loading-store/profile-preferences' import { DeviceRegistry } from '../runtime/device-registry' import { RuntimeMobileNotificationController } from '../runtime/runtime-mobile-notification-controller' import { PushUnregisterOutbox } from '../runtime/push/push-unregister-outbox' @@ -14,6 +15,9 @@ const state = vi.hoisted(() => ({ controller: null as RuntimeMobileNotificationController | null, registry: null as DeviceRegistry | null, rpcStarted: false, + onSettingsChanged: vi.fn(), + removeSettingsListener: vi.fn(), + startDaemon: vi.fn(async () => {}), browserProvider: vi.fn(async () => null), register: vi.fn(async () => ({ ok: true, registrationId: 'headless-registration' })), send: vi.fn(async () => ({ ok: true, results: [] })) @@ -26,7 +30,7 @@ vi.mock('./orcad-app-paths', () => ({ vi.mock('./orcad-browser-provider', () => ({ resolveOrcadBrowserProvider: state.browserProvider })) vi.mock('./orcad-instance-lock', () => ({ acquireOrcadInstanceLock: () => ({ release() {} }) })) vi.mock('./orcad-daemon-supervision', () => ({ - startOrcadDaemon: async () => {}, + startOrcadDaemon: state.startDaemon, stopOrcadDaemon: async () => {} })) vi.mock('./orcad-health', () => ({ collectOrcadHealth: async () => ({}) })) @@ -44,6 +48,7 @@ vi.mock('./orcad-profile-state-startup', () => ({ createOrcadProfileStateStartup: async () => ({ store: { getSettings: () => ({}), + onSettingsChanged: state.onSettingsChanged, flushFinalOrThrowAsync: async () => {}, freezeWritesAsync: async () => {} }, @@ -124,6 +129,10 @@ vi.mock('../runtime/push/push-gateway-client', () => ({ } })) +beforeEach(() => { + state.onSettingsChanged.mockReturnValue(state.removeSettingsListener) +}) + afterEach(() => { rmSync(state.root, { recursive: true, force: true }) vi.clearAllMocks() @@ -176,6 +185,11 @@ it('starts push after RPC identity is available and stops dispatch on shutdown', expect(readdirSync(profileStateAccessPaths(state.root).participants)).toEqual([]) acquireProfileStateMaintenance(state.root).release() expect(state.controller.getListenerCount()).toBe(0) + expect(state.rpcStarted).toBe(false) + expect(state.onSettingsChanged).toHaveBeenCalledOnce() + expect(state.removeSettingsListener).toHaveBeenCalledOnce() + await host.stop() + expect(state.removeSettingsListener).toHaveBeenCalledOnce() expect(await state.controller.registerPushDevice({} as never)).toMatchObject({ registered: false }) @@ -189,3 +203,14 @@ it('releases admission when host setup fails before a runtime exists', async () expect(readdirSync(profileStateAccessPaths(state.root).participants)).toEqual([]) acquireProfileStateMaintenance(state.root).release() }) + +it('unsubscribes settings when daemon startup fails after hook setup', async () => { + state.root = mkdtempSync(join(tmpdir(), 'orca-headless-daemon-failure-')) + state.startDaemon.mockRejectedValueOnce(new Error('daemon setup failed')) + const { startOrcad } = await import('./orcad-entry') + await expect(startOrcad()).rejects.toThrow('daemon setup failed') + expect(state.onSettingsChanged).toHaveBeenCalledOnce() + expect(state.removeSettingsListener).toHaveBeenCalledOnce() + expect(readdirSync(profileStateAccessPaths(state.root).participants)).toEqual([]) + acquireProfileStateMaintenance(state.root).release() +}) diff --git a/src/main/providers/provider-dispatch.test.ts b/src/main/providers/provider-dispatch.test.ts index 12b259f5b9f..18ac3b4683e 100644 --- a/src/main/providers/provider-dispatch.test.ts +++ b/src/main/providers/provider-dispatch.test.ts @@ -1,3 +1,4 @@ +import { openCodeHookServiceModuleMock } from '../ipc/pty-ipc-mock-registry' import { settledWriteStub } from './settled-pty-write-stub' import { describe, expect, it, vi } from 'vitest' import { setPtyHostBindings } from '../ipc/pty-host-bindings' @@ -48,18 +49,7 @@ vi.mock('node-pty', () => ({ }) })) -vi.mock('../opencode/hook-service', () => ({ - openCodeHookService: { - buildPtyEnv: () => ({}), - refreshLegacySharedPlugin: vi.fn(), - clearPty: vi.fn() - }, - openCode2HookService: { - buildPtyEnv: () => ({}), - refreshLegacySharedPlugin: vi.fn(), - clearPty: vi.fn() - } -})) +vi.mock('../opencode/hook-service', () => openCodeHookServiceModuleMock()) vi.mock('../pi/titlebar-extension-service', () => ({ piTitlebarExtensionService: { buildPtyEnv: () => ({}), clearPty: vi.fn() } @@ -176,6 +166,11 @@ describe('PTY provider dispatch', () => { 'CLAUDE_CODE_SESSION_ID', 'CLAUDE_CODE_BRIDGE_SESSION_ID', 'ORCA_OPENCODE_PLUGIN_API', + 'ORCA_OPENCODE_STARTUP_PROMPT_BODY', + 'ORCA_OPENCODE_STARTUP_PROMPT_ENDPOINT', + 'ORCA_OPENCODE_STARTUP_PROMPT_NONCE', + 'ORCA_OPENCODE_STARTUP_PROMPT_SHA256', + 'ORCA_OPENCODE_STARTUP_PROMPT_SHELL', 'ORCA_PI_STATUS_OWNED', 'ORCA_PRIME_AGENT_STATUS_OWNED', 'ORCA_PI_TITLE_MARKER_OWNED', diff --git a/src/main/runtime/orca-runtime-resolve-authoritative-terminal-wait-permission.ts b/src/main/runtime/orca-runtime-resolve-authoritative-terminal-wait-permission.ts index 3265d8ac166..29c1a636ffa 100644 --- a/src/main/runtime/orca-runtime-resolve-authoritative-terminal-wait-permission.ts +++ b/src/main/runtime/orca-runtime-resolve-authoritative-terminal-wait-permission.ts @@ -19,6 +19,25 @@ import type { AgentPromptActivity } from './agent-prompt-submission-verification import { readTuiIdleHookTurn, type TuiIdleHookTurn } from './tui-idle-hook-lane' export class OrcaRuntimeWithResolveAuthoritativeTerminalWaitPermission extends OrcaRuntimeWithAgentPromptRequestCorrelation { + readOpenCodeStartupPromptOwner(ptyId: string, incarnationId: string, launchToken: string) { + const pty = this.ptysById.get(ptyId) + if (!pty || pty.incarnationId !== incarnationId) { + return null + } + // The runtime launch route admits identity immediately after low-level spawn returns. + if (pty.launchToken === null && pty.launchAgent === null && pty.launchIncarnationId === null) { + return 'pending' as const + } + if ( + pty.launchIncarnationId !== incarnationId || + pty.launchToken !== launchToken || + (pty.launchAgent !== 'opencode' && pty.launchAgent !== 'opencode2') + ) { + return null + } + return this.terminalRunFacts.read(ptyId, incarnationId) + } + protected resolveAuthoritativeTerminalWaitPermission( terminal: RuntimeTerminalAgentStatusSnapshot, explicitStatus: { status: AgentStatus; updatedAt: number } | null, diff --git a/src/main/runtime/terminal-run-facts.test.ts b/src/main/runtime/terminal-run-facts.test.ts index 85460b9fc27..1966a405949 100644 --- a/src/main/runtime/terminal-run-facts.test.ts +++ b/src/main/runtime/terminal-run-facts.test.ts @@ -2,6 +2,21 @@ import { describe, expect, it } from 'vitest' import { TerminalRunFactsRegister } from './terminal-run-facts' describe('terminal run facts', () => { + it('keeps driving input before publication across the exact reserved commit', () => { + const facts = new TerminalRunFactsRegister() + facts.recordInput('pending', 'driving', 'x', 100) + facts.reserveSpawnCommit({ id: 'pending', incarnationId: 'inc-1' }) + facts.recordSpawnCommit({ id: 'pending', incarnationId: 'inc-1' }) + expect(facts.read('pending', 'inc-1').firstUserInputAt).toBe(100) + facts.reserveSpawnCommit({ id: 'pending', incarnationId: 'inc-2' }) + facts.recordInput('pending', 'driving', 'x', 200) + facts.recordSpawnCommit({ id: 'pending', incarnationId: 'inc-2' }) + expect(facts.read('pending', 'inc-2').firstUserInputAt).toBe(200) + facts.delete('pending') + facts.reserveSpawnCommit({ id: 'pending', incarnationId: 'inc-3' }) + facts.recordSpawnCommit({ id: 'pending', incarnationId: 'inc-3' }) + expect(facts.read('pending', 'inc-3').firstUserInputAt).toBeNull() + }) it('reads a run main never saw committed as not fresh', () => { expect(new TerminalRunFactsRegister().read('pty-1', 'inc-1')).toEqual({ freshSpawn: false, diff --git a/src/main/runtime/terminal-run-facts.ts b/src/main/runtime/terminal-run-facts.ts index b20f64985e4..b1c029a767c 100644 --- a/src/main/runtime/terminal-run-facts.ts +++ b/src/main/runtime/terminal-run-facts.ts @@ -42,12 +42,38 @@ export class TerminalRunFactsRegister { private readonly runsByPtyId = new Map() // Why apart from the run record: input must count on a PTY main adopted without a commit. private readonly lastInputAtByPtyId = new Map() + private readonly pendingByPtyId = new Map< + string, + { incarnationId: string | null; firstInputAt: number | null } + >() + + reserveSpawnCommit(commit: TerminalSpawnCommit): void { + if (!commit.incarnationId) { + return + } + const prior = this.pendingByPtyId.get(commit.id) + this.pendingByPtyId.set(commit.id, { + incarnationId: commit.incarnationId, + firstInputAt: + prior?.incarnationId === null || prior?.incarnationId === commit.incarnationId + ? prior.firstInputAt + : null + }) + } + + discardSpawnCommit(commit: TerminalSpawnCommit): void { + if (this.pendingByPtyId.get(commit.id)?.incarnationId === (commit.incarnationId ?? null)) { + this.pendingByPtyId.delete(commit.id) + } + } /** Once per process: a re-registration of the same incarnation keeps its facts, and so does a * reattach or adoption of the running process unless its incarnation shows another process. */ recordSpawnCommit(commit: TerminalSpawnCommit, expectedSourceBinding?: unknown): void { const incarnationId = commit.incarnationId ?? null const previous = this.runsByPtyId.get(commit.id) + const pending = this.pendingByPtyId.get(commit.id) + this.pendingByPtyId.delete(commit.id) if (incarnationId !== null && previous?.incarnationId === incarnationId) { return } @@ -60,7 +86,11 @@ export class TerminalRunFactsRegister { this.runsByPtyId.set(commit.id, { incarnationId, spawnOrigin: origin === 'spawn' && commit.coldRestore !== undefined ? 'cold-restore' : origin, - firstUserInputAt: sameProcess ? (previous?.firstUserInputAt ?? null) : null + firstUserInputAt: sameProcess + ? (previous?.firstUserInputAt ?? null) + : pending?.incarnationId === incarnationId + ? pending.firstInputAt + : null }) } @@ -73,6 +103,14 @@ export class TerminalRunFactsRegister { } this.lastInputAtByPtyId.set(ptyId, now) const run = this.runsByPtyId.get(ptyId) + if (inputKind === 'driving') { + const pending = this.pendingByPtyId.get(ptyId) + if (pending) { + pending.firstInputAt ??= now + } else if (!run) { + this.pendingByPtyId.set(ptyId, { incarnationId: null, firstInputAt: now }) + } + } if (run && inputKind === 'driving') { run.firstUserInputAt ??= now } @@ -99,5 +137,6 @@ export class TerminalRunFactsRegister { delete(ptyId: string): void { this.runsByPtyId.delete(ptyId) this.lastInputAtByPtyId.delete(ptyId) + this.pendingByPtyId.delete(ptyId) } } diff --git a/src/main/startup/headless-pty-hydration-ordering.test.ts b/src/main/startup/headless-pty-hydration-ordering.test.ts index c761ccf0409..be618a8605d 100644 --- a/src/main/startup/headless-pty-hydration-ordering.test.ts +++ b/src/main/startup/headless-pty-hydration-ordering.test.ts @@ -78,14 +78,28 @@ describe('headless PTY registry hydration ordering', () => { const runtime = source.indexOf('const runtime = new OrcaRuntimeService(') const identityReader = source.indexOf('readObservedAgentStatusPaneIdentity:', runtime) const identitySubscription = source.indexOf('agentHookServer.subscribeEnrichedStatus(') - const hooksEnabled = source.indexOf('if (isAgentStatusHooksEnabled(', identitySubscription) + const hookStart = source.indexOf('await agentHookServer.start(', identitySubscription) + const settingsListener = source.indexOf('profileStore.onSettingsChanged(', hookStart) + const daemon = source.indexOf('await startOrcadDaemon()', hookStart) const identityFlush = source.indexOf('observedStatusCapture.attach(runtime)', runtime) expect(runtime).toBeGreaterThanOrEqual(0) expect(identityReader).toBeGreaterThan(runtime) expect(identitySubscription).toBeGreaterThanOrEqual(0) expect(identitySubscription).toBeLessThan(runtime) - expect(hooksEnabled).toBeGreaterThan(identitySubscription) + expect(hookStart).toBeGreaterThan(identitySubscription) + expect(settingsListener).toBeGreaterThan(hookStart) + expect(daemon).toBeGreaterThan(settingsListener) + expect(runtime).toBeGreaterThan(daemon) + expect(source.slice(identitySubscription, hookStart)).not.toContain( + 'if (isAgentStatusHooksEnabled(' + ) + expect(source.slice(hookStart, settingsListener)).toContain( + 'statusHooksEnabled: isAgentStatusHooksEnabled(profileStore.getSettings())' + ) + expect(source.slice(settingsListener, daemon)).toContain( + 'agentHookServer.setStatusHooksEnabled(isAgentStatusHooksEnabled(settings))' + ) expect(identityFlush).toBeGreaterThan(runtime) expect(source.slice(identitySubscription, runtime)).toContain( 'observedStatusCapture.observe(enriched)' diff --git a/src/main/startup/main-process-pty-startup.ts b/src/main/startup/main-process-pty-startup.ts index 03cef473299..b5cb284db08 100644 --- a/src/main/startup/main-process-pty-startup.ts +++ b/src/main/startup/main-process-pty-startup.ts @@ -171,9 +171,6 @@ export function startTerminalRuntimeStartupServices(): WindowsDesktopStartupServ // Why: PTY spawn env reads ORCA_AGENT_HOOK_* from live server state, so the renderer awaits this before restored terminals reconnect. startAgentHookServer: async () => { const settings = state.store?.getSettings() - if (!isAgentStatusHooksEnabled(settings)) { - return - } logStartupMilestone('startup-service-start', { service: 'agent-hook-server' }) // Why (#11217): the hook listener fails open on every request error, so an IDS resetting // loopback POSTs mid-body stops agent status for every runtime with no symptom but staleness. @@ -182,6 +179,7 @@ export function startTerminalRuntimeStartupServices(): WindowsDesktopStartupServ track('agent_hook_transport_blocked', { count: report.count }) }) await agentHookServer.start({ + statusHooksEnabled: isAgentStatusHooksEnabled(settings), env: app.isPackaged ? 'production' : 'development', // Why: hooks source this endpoint file at invocation time so old PTY env reaches the current process after restart; dev namespaces it (worktrees share `orca-dev`). userDataPath: app.getPath('userData'), diff --git a/src/main/startup/main-process-ready-foundation.ts b/src/main/startup/main-process-ready-foundation.ts index 238998bac49..c9ec3b30368 100644 --- a/src/main/startup/main-process-ready-foundation.ts +++ b/src/main/startup/main-process-ready-foundation.ts @@ -49,6 +49,7 @@ import { syncMacMenuBarIcon } from './main-window-actions' import { updateGpuAccelerationAboutPanel } from './gpu-lifecycle' import { reconcileManagedWslCliRegistrations } from '../cli/wsl-cli-registration-reconciliation' import { createWslCliReconciliationStartupBarrier } from './wsl-cli-reconciliation-startup-barrier' +import { agentHookServer } from '../agent-hooks/server' import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' import { reportProfileStateWriteFailure } from './profile-state-write-failure' @@ -248,6 +249,7 @@ export async function initializeReadyFoundation(): Promise { syncMacMenuBarIcon(settings.showMenuBarIcon !== false) } if ('agentStatusHooksEnabled' in updates) { + agentHookServer.setStatusHooksEnabled(isAgentStatusHooksEnabled(settings)) // Why both directions: the ensure gate only blocks NEW relays, so off must stop the running // guest process and timers, and on must restart them — otherwise open WSL panes report no // status until their next spawn. diff --git a/src/relay/opencode-overlay-mirror.ts b/src/relay/opencode-overlay-mirror.ts new file mode 100644 index 00000000000..0bd5e0b0f6c --- /dev/null +++ b/src/relay/opencode-overlay-mirror.ts @@ -0,0 +1,40 @@ +import { mkdirSync, readdirSync, realpathSync, statSync } from 'node:fs' +import { join } from 'node:path' +import { mirrorEntry } from '../main/pty/overlay-mirror' + +export function mirrorOpenCodeConfig( + sourceDir: string, + overlayDir: string, + excludedPluginEntries: ReadonlySet +): void { + for (const entry of readdirSync(sourceDir, { withFileTypes: true })) { + const sourcePath = join(sourceDir, entry.name) + if (entry.name === 'plugins') { + const isSymlink = entry.isSymbolicLink() + let isLinkPointingToDir = false + if (isSymlink) { + try { + isLinkPointingToDir = statSync(sourcePath).isDirectory() + } catch { + isLinkPointingToDir = false + } + } + if ((!isSymlink && entry.isDirectory()) || isLinkPointingToDir) { + const resolvedSource = isLinkPointingToDir ? realpathSync(sourcePath) : sourcePath + const overlayPluginsDir = join(overlayDir, 'plugins') + mkdirSync(overlayPluginsDir, { recursive: true }) + for (const pluginEntry of readdirSync(resolvedSource, { withFileTypes: true })) { + if (excludedPluginEntries.has(pluginEntry.name)) { + continue + } + mirrorEntry( + join(resolvedSource, pluginEntry.name), + join(overlayPluginsDir, pluginEntry.name) + ) + } + continue + } + } + mirrorEntry(sourcePath, join(overlayDir, entry.name)) + } +} diff --git a/src/relay/opencode-startup-prompt-install.test.ts b/src/relay/opencode-startup-prompt-install.test.ts new file mode 100644 index 00000000000..71cb373511e --- /dev/null +++ b/src/relay/opencode-startup-prompt-install.test.ts @@ -0,0 +1,86 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { PluginOverlayManager } from './plugin-overlay' +import { createInstallPluginsHandler } from './wsl-install-plugins-handler' + +let root: string +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'relay-prompt-install-')) +}) +afterEach(() => { + rmSync(root, { recursive: true, force: true }) +}) +const promptPath = (config: string) => + join(config, 'plugins', 'orca-opencode-startup-prompt', 'tui.js') +describe('execution-host startup prompt installation', () => { + it('caches prompt source independently and revokes future installs with an empty source', () => { + const manager = new PluginOverlayManager({ homeDir: root }) + const config = join(root, 'custom') + manager.setSources({ opencodeStartupPromptSource: 'prompt source' }) + expect(manager.hasOpenCodeSource()).toBe(false) + expect(manager.installOpenCodeStartupPromptPlugin({}, config)).toBe(true) + manager.setSources({ opencodePluginSource: '' }) + expect(manager.installOpenCodeStartupPromptPlugin({}, config)).toBe(true) + expect(readFileSync(promptPath(config), 'utf8')).toBe('prompt source') + manager.setSources({ opencodeStartupPromptSource: '' }) + expect(manager.installOpenCodeStartupPromptPlugin({}, join(root, 'not-installed'))).toBe(false) + expect(existsSync(join(root, 'not-installed'))).toBe(false) + }) + it('materializes a prompt-only overlay without overwriting a same-named user plugin', () => { + const manager = new PluginOverlayManager({ homeDir: root }) + const config = join(root, 'custom') + mkdirSync(join(config, 'plugins', 'orca-opencode-startup-prompt'), { recursive: true }) + writeFileSync(promptPath(config), 'user collision') + writeFileSync(join(config, 'opencode.json'), '{"model":"user/model"}') + manager.setSources({ opencodeStartupPromptSource: 'prompt source' }) + const overlay = manager.materializeOpenCode('pane', config) + if (!overlay) { + throw new Error('Missing prompt overlay') + } + expect(readFileSync(promptPath(overlay), 'utf8')).toBe('prompt source') + expect(readFileSync(promptPath(config), 'utf8')).toBe('user collision') + expect(readFileSync(join(overlay, 'opencode.json'), 'utf8')).toContain('user/model') + expect(existsSync(join(overlay, 'plugins', 'orca-opencode-status.js'))).toBe(false) + }) + it('installs through WSL with both status sources disabled and preserves explicit config', () => { + const config = join(root, 'custom') + const manager = new PluginOverlayManager({ homeDir: root }) + const install = createInstallPluginsHandler(manager, { + HOME: root, + OPENCODE_CONFIG_DIR: config + }) + const result = install({ + opencodeStartupPromptSource: 'first', + opencodePluginSource: '', + opencode2PluginSource: '' + }) + expect(result.installed).toMatchObject({ + opencodeStartupPrompt: true, + opencode: false, + opencode2: false + }) + expect(result.overlayDirs).toEqual({}) + expect(readFileSync(promptPath(config), 'utf8')).toBe('first') + install({ opencodeStartupPromptSource: 'second' }) + expect(readFileSync(promptPath(config), 'utf8')).toBe('second') + }) + it('refreshes prompt source in both cached status overlays without rebuilding them', () => { + const manager = new PluginOverlayManager({ homeDir: root }) + const install = createInstallPluginsHandler(manager, { HOME: root }) + const first = install({ + opencodeStartupPromptSource: 'first', + opencodePluginSource: 'status v1', + opencode2PluginSource: 'status v2' + }) + const second = install({ opencodeStartupPromptSource: 'second' }) + expect(second.overlayDirs).toEqual(first.overlayDirs) + for (const config of [second.overlayDirs.opencode, second.overlayDirs.opencode2]) { + if (!config) { + throw new Error('Missing status overlay') + } + expect(readFileSync(promptPath(config), 'utf8')).toBe('second') + } + }) +}) diff --git a/src/relay/plugin-overlay.ts b/src/relay/plugin-overlay.ts index 6d78918c46e..08c9f772bed 100644 --- a/src/relay/plugin-overlay.ts +++ b/src/relay/plugin-overlay.ts @@ -1,3 +1,9 @@ +import { mirrorOpenCodeConfig } from './opencode-overlay-mirror' +import { + writeOpenCodeStartupPromptPlugin, + OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY +} from '../shared/opencode-startup-prompt-install' +import { resolveOpenCodeConfigDirectory } from '../shared/opencode-config-directory' import { materializeOmpFreshConfig } from '../shared/omp-fresh-config' // Why: relay-side equivalent of Orca's local agent integration installers. // OpenCode still needs a config overlay, while Pi/OMP now get Orca-managed @@ -17,19 +23,11 @@ import { materializeOmpFreshConfig } from '../shared/omp-fresh-config' // implementation rooted at $HOME/.orca-relay/ for OpenCode and at the remote // Pi/OMP homes for those agents. import { createHash } from 'node:crypto' -import { - existsSync, - mkdirSync, - readFileSync, - readdirSync, - realpathSync, - statSync, - writeFileSync -} from 'node:fs' +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { writeOverlayOpenCodePluginAtomically } from '../shared/opencode-plugin-atomic-write' import { homedir } from 'node:os' import { join } from 'node:path' -import { mirrorEntry, safeRemoveOverlay } from '../main/pty/overlay-mirror' +import { safeRemoveOverlay } from '../main/pty/overlay-mirror' import type { PiAgentKind } from '../shared/pi-agent-kind' import { installOpenCodePluginInCanonicalConfig, @@ -52,9 +50,10 @@ const PI_OVERLAY_SUBDIR_BY_KIND: Record = { const OPENCODE_PLUGIN_FILE = 'orca-opencode-status.js' const OPENCODE2_PLUGIN_FILE = 'orca-opencode2-status.js' // Orca's own entries (either major, file and TUI copy) are never mirrored from user config. -const ORCA_OPENCODE_PLUGIN_ENTRIES = new Set( - [OPENCODE_PLUGIN_FILE, OPENCODE2_PLUGIN_FILE].flatMap((f) => [f, openCodeTuiPluginDirName(f)]) -) +const ORCA_OPENCODE_PLUGIN_ENTRIES = new Set([ + OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY, + ...[OPENCODE_PLUGIN_FILE, OPENCODE2_PLUGIN_FILE].flatMap((f) => [f, openCodeTuiPluginDirName(f)]) +]) const PI_EXTENSION_FILE = 'orca-agent-status.ts' const PI_AGENT_SUBDIR = 'agent' const OMP_MANAGED_STATUS_EXTENSION_DIR = 'omp-managed-status-extension' @@ -87,6 +86,7 @@ function isUsableId(id: string): boolean { return typeof id === 'string' && id.length > 0 && id.length <= 1024 } export type PluginSources = { + opencodeStartupPromptSource?: string /** Empty string revokes future installs; omission preserves the cached source. */ opencodePluginSource?: string /** Source body of OpenCode 2's status plugin. */ @@ -118,6 +118,7 @@ export function getRelayOpenCodePluginPath( ) } export class PluginOverlayManager { + private opencodeStartupPromptSource: string | null = null private opencodePluginSource: string | null = null private opencode2PluginSource: string | null = null private piExtensionSources: Record = { @@ -147,6 +148,9 @@ export class PluginOverlayManager { * process start. Future PTYs pick up the refreshed source when the relay * writes plugin/extension files before spawn. */ setSources(sources: PluginSources): void { + if (typeof sources.opencodeStartupPromptSource === 'string') { + this.opencodeStartupPromptSource = sources.opencodeStartupPromptSource + } if (typeof sources.opencodePluginSource === 'string') { this.opencodePluginSource = sources.opencodePluginSource } @@ -178,38 +182,6 @@ export class PluginOverlayManager { const source = this.piExtensionSources[kind] return source ?? (kind === 'omp' ? this.piExtensionSources.pi : null) } - private mirrorOpenCodeConfig(sourceDir: string, overlayDir: string): void { - for (const entry of readdirSync(sourceDir, { withFileTypes: true })) { - const sourcePath = join(sourceDir, entry.name) - if (entry.name === 'plugins') { - const isSymlink = entry.isSymbolicLink() - let isLinkPointingToDir = false - if (isSymlink) { - try { - isLinkPointingToDir = statSync(sourcePath).isDirectory() - } catch { - isLinkPointingToDir = false - } - } - if ((!isSymlink && entry.isDirectory()) || isLinkPointingToDir) { - const resolvedSource = isLinkPointingToDir ? realpathSync(sourcePath) : sourcePath - const overlayPluginsDir = join(overlayDir, 'plugins') - mkdirSync(overlayPluginsDir, { recursive: true }) - for (const pluginEntry of readdirSync(resolvedSource, { withFileTypes: true })) { - if (ORCA_OPENCODE_PLUGIN_ENTRIES.has(pluginEntry.name)) { - continue - } - mirrorEntry( - join(resolvedSource, pluginEntry.name), - join(overlayPluginsDir, pluginEntry.name) - ) - } - continue - } - } - mirrorEntry(sourcePath, join(overlayDir, entry.name)) - } - } private writeOpenCodePlugin(overlayDir: string, pluginFileName: string, source: string): void { const pluginsDir = join(overlayDir, 'plugins') mkdirSync(pluginsDir, { recursive: true }) @@ -230,7 +202,7 @@ export class PluginOverlayManager { agent: 'opencode' | 'opencode2' = 'opencode' ): string | null { const source = agent === 'opencode2' ? this.opencode2PluginSource : this.opencodePluginSource - if (!source || !isUsableId(id)) { + if ((!source && !this.opencodeStartupPromptSource) || !isUsableId(id)) { return null } const pluginFileName = agent === 'opencode2' ? OPENCODE2_PLUGIN_FILE : OPENCODE_PLUGIN_FILE @@ -246,9 +218,14 @@ export class PluginOverlayManager { // Why: OPENCODE_CONFIG_DIR is a single config root. Mirror the user's // remote root into the overlay before adding Orca's plugin so status // reporting does not hide their auth, models, keybinds, or plugins. - this.mirrorOpenCodeConfig(existingConfigDir, dir) + mirrorOpenCodeConfig(existingConfigDir, dir, ORCA_OPENCODE_PLUGIN_ENTRIES) + } + if (source) { + this.writeOpenCodePlugin(dir, pluginFileName, source) + } + if (this.opencodeStartupPromptSource) { + writeOpenCodeStartupPromptPlugin(dir, this.opencodeStartupPromptSource, 'overlay') } - this.writeOpenCodePlugin(dir, pluginFileName, source) return dir } catch (err) { process.stderr.write( @@ -258,6 +235,29 @@ export class PluginOverlayManager { } } + installOpenCodeStartupPromptPlugin( + environment: NodeJS.ProcessEnv | Record, + configDir?: string + ): boolean { + if (!this.opencodeStartupPromptSource) { + return false + } + try { + writeOpenCodeStartupPromptPlugin( + configDir ?? + environment.OPENCODE_CONFIG_DIR ?? + resolveOpenCodeConfigDirectory(environment, this.homeDir), + this.opencodeStartupPromptSource + ) + return true + } catch (error) { + process.stderr.write( + `[plugin-overlay] failed to install OpenCode startup prompt: ${error instanceof Error ? error.message : String(error)}\n` + ) + return false + } + } + hasOpenCode2Source(): boolean { return this.hasOpenCodeSource('opencode2') } diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 4f86f8a3508..b96781818e7 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -34,6 +34,13 @@ import { getRelayShellLaunchConfig, isRelayWslShell } from './pty-shell-launch' import { RetiredPaneSurfaceRegistry } from './retired-pane-surfaces' import { applyScrubSafeAgentEnvAliases } from '../shared/agent-hook-scrub-safe-env' import { addWslEnvKeys } from '../shared/wsl-env' +import { + OPENCODE_STARTUP_PROMPT_SHA256_ENV, + OPENCODE_STARTUP_PROMPT_NONCE_ENV, + OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV, + OPENCODE_STARTUP_PROMPT_BODY_ENV, + OPENCODE_STARTUP_PROMPT_SHELL_ENV +} from '../shared/opencode-startup-prompt' import { ORCA_IMAGE_PROTOCOL_ENV, ORCA_IMAGE_PROTOCOL_VALUE @@ -2043,6 +2050,16 @@ export class PtyHandler { envToDelete ) delete spawnEnv.ORCA_OPENCODE_PLUGIN_API + // Relay input streams lack driving-input provenance, so native intent is unavailable. + for (const key of [ + OPENCODE_STARTUP_PROMPT_SHA256_ENV, + OPENCODE_STARTUP_PROMPT_NONCE_ENV, + OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV, + OPENCODE_STARTUP_PROMPT_BODY_ENV, + OPENCODE_STARTUP_PROMPT_SHELL_ENV + ]) { + delete spawnEnv[key] + } const openCodeCapabilities = await probeOpenCodeLaunchCapabilities({ command, agent: launchAgent, diff --git a/src/relay/relay-agent-hook-runtime.ts b/src/relay/relay-agent-hook-runtime.ts index e0d9a95ae68..4ee9113919a 100644 --- a/src/relay/relay-agent-hook-runtime.ts +++ b/src/relay/relay-agent-hook-runtime.ts @@ -186,17 +186,20 @@ export class RelayAgentHookRuntime { })) registerManagedHookInstaller(this.dispatcher) this.dispatcher.onRequest(AGENT_HOOK_INSTALL_PLUGINS_METHOD, async (params) => { + const startupPrompt = params.opencodeStartupPromptSource const opencode = params.opencodePluginSource const opencode2 = params.opencode2PluginSource const pi = params.piExtensionSource const omp = params.ompExtensionSource const primeAgent = params.primeAgentExtensionSource + assertPluginSourceUnderByteCap('opencodeStartupPromptSource', startupPrompt) assertPluginSourceUnderByteCap('opencodePluginSource', opencode) assertPluginSourceUnderByteCap('opencode2PluginSource', opencode2) assertPluginSourceUnderByteCap('piExtensionSource', pi) assertPluginSourceUnderByteCap('ompExtensionSource', omp) assertPluginSourceUnderByteCap('primeAgentExtensionSource', primeAgent) this.pluginOverlay.setSources({ + opencodeStartupPromptSource: typeof startupPrompt === 'string' ? startupPrompt : undefined, opencodePluginSource: typeof opencode === 'string' ? opencode : undefined, opencode2PluginSource: typeof opencode2 === 'string' ? opencode2 : undefined, piExtensionSource: typeof pi === 'string' ? pi : undefined, @@ -213,8 +216,12 @@ export class RelayAgentHookRuntime { installOpenCodePluginInCanonicalConfig(source, agent, process.env, homedir(), true) } } + const startupPromptInstalled = this.pluginOverlay.installOpenCodeStartupPromptPlugin( + process.env + ) return { installed: { + opencodeStartupPrompt: startupPromptInstalled, opencode: this.pluginOverlay.hasOpenCodeSource(), opencode2: this.pluginOverlay.hasOpenCode2Source(), pi: this.pluginOverlay.hasPiSource('pi'), diff --git a/src/relay/wsl-install-plugins-handler.ts b/src/relay/wsl-install-plugins-handler.ts index b602d353ea8..e66ff7d20a5 100644 --- a/src/relay/wsl-install-plugins-handler.ts +++ b/src/relay/wsl-install-plugins-handler.ts @@ -16,6 +16,7 @@ import { export type InstallPluginsResult = { installed: { + opencodeStartupPrompt?: boolean opencode: boolean opencode2?: boolean pi: boolean @@ -43,18 +44,21 @@ export function createInstallPluginsHandler( let materialized2: { source: string; sourceDir: string | undefined; dir: string } | null = null return (params) => { + const startupPrompt = params.opencodeStartupPromptSource const opencode = params.opencodePluginSource const opencode2 = params.opencode2PluginSource const pi = params.piExtensionSource const omp = params.ompExtensionSource const primeAgent = params.primeAgentExtensionSource // Why: bound per-source bytes so a buggy/hostile host can't OOM the guest relay. + assertPluginSourceUnderByteCap('opencodeStartupPromptSource', startupPrompt) assertPluginSourceUnderByteCap('opencodePluginSource', opencode) assertPluginSourceUnderByteCap('opencode2PluginSource', opencode2) assertPluginSourceUnderByteCap('piExtensionSource', pi) assertPluginSourceUnderByteCap('ompExtensionSource', omp) assertPluginSourceUnderByteCap('primeAgentExtensionSource', primeAgent) pluginOverlay.setSources({ + opencodeStartupPromptSource: typeof startupPrompt === 'string' ? startupPrompt : undefined, opencodePluginSource: typeof opencode === 'string' ? opencode : undefined, opencode2PluginSource: typeof opencode2 === 'string' ? opencode2 : undefined, piExtensionSource: typeof pi === 'string' ? pi : undefined, @@ -135,8 +139,28 @@ export function createInstallPluginsHandler( } } } + const promptConfigDirs = [opencodeDir, opencode2Dir].filter( + (dir): dir is string => typeof dir === 'string' + ) + if (promptConfigDirs.length === 0) { + promptConfigDirs.push( + resolveOpenCodeSourceConfigDir( + Object.fromEntries( + Object.entries(env).flatMap(([key, value]) => + typeof value === 'string' ? [[key, value]] : [] + ) + ), + env.SHELL + ) ?? resolveOpenCodeConfigDirectory(env, env.HOME) + ) + } + const promptInstallResults = promptConfigDirs.map((dir) => + pluginOverlay.installOpenCodeStartupPromptPlugin(env, dir) + ) + const startupPromptInstalled = promptInstallResults.every(Boolean) return { installed: { + opencodeStartupPrompt: startupPromptInstalled, opencode: pluginOverlay.hasOpenCodeSource(), opencode2: pluginOverlay.hasOpenCode2Source(), pi: pluginOverlay.hasPiSource('pi'), diff --git a/src/shared/opencode-headless-command.test.ts b/src/shared/opencode-headless-command.test.ts index 408895a1dbb..09d243a4bcf 100644 --- a/src/shared/opencode-headless-command.test.ts +++ b/src/shared/opencode-headless-command.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { tokenizeStartupCommand } from './tui-agent-startup-shell' import { tokenizeCommandLine } from './agent-command-line-entrypoint' import { isOpenCodeRunCommand } from './opencode-headless-command' @@ -24,3 +25,66 @@ describe('isOpenCodeRunCommand', () => { expect(matches('opencode --log-level run')).toBe(false) }) }) + +describe('wrapped OpenCode run command position', () => { + it.each([ + 'CUSTOM_CONFIG=private opencode --log-level debug run --standalone', + 'env CUSTOM_CONFIG=private opencode run --standalone', + 'CUSTOM_CONFIG=private /usr/bin/env -- EXTRA_CONFIG=kept opencode run --standalone' + ])('recognizes only the executable behind supported POSIX prefixes: %s', (command) => { + expect(matches(command)).toBe(true) + }) + + it('recognizes a PowerShell call operator before a quoted executable', () => { + const parsed = tokenizeStartupCommand( + '& "C:\\Program Files\\opencode\\opencode.exe" run --standalone', + 'powershell' + ) + expect(parsed.ok).toBe(true) + if (!parsed.ok) { + throw new Error(parsed.error) + } + expect(isOpenCodeRunCommand(parsed.tokens, 'powershell')).toBe(true) + expect(isOpenCodeRunCommand(parsed.tokens, 'cmd')).toBe(false) + }) + + it.each([ + 'env -u FOO opencode run', + 'env -uFOO opencode run', + 'env --unset FOO opencode run', + 'env --unset=FOO opencode run', + 'env -i PRIVATE_CONFIG=kept opencode run', + 'env --ignore-environment PRIVATE_CONFIG=kept opencode run', + 'env - PRIVATE_CONFIG=kept opencode run', + 'env -C /workspace opencode run', + 'env -C/workspace opencode run', + 'env --chdir /workspace opencode run', + 'env --chdir=/workspace opencode run', + 'env -P /private/bin opencode run', + 'env -P/private/bin opencode run', + 'CONFIG=kept /usr/bin/env -i -u FOO -C /workspace -- OTHER=kept opencode run' + ])('recognizes an executable after env options: %s', (command) => { + expect(matches(command)).toBe(true) + }) + + it.each([ + 'env -u', + 'env -C', + 'env -u opencode run', + 'env -C opencode run', + 'env -u FOO echo opencode run', + 'env --unset=FOO echo run', + 'env -S "opencode run"', + 'env --unknown opencode run' + ])('does not mistake env option arguments for the executable: %s', (command) => { + expect(matches(command)).toBe(false) + }) + + it('does not find executable names inside other commands or prompt arguments', () => { + expect(matches('env CUSTOM_CONFIG=private echo opencode run')).toBe(false) + expect(matches('env CUSTOM_CONFIG=private echo run')).toBe(false) + expect(matches('CUSTOM_CONFIG=private opencode --prompt "opencode run"')).toBe(false) + expect(matches('env -- opencode serve --title run')).toBe(false) + expect(matches('opencode attach http://host/run')).toBe(false) + }) +}) diff --git a/src/shared/opencode-headless-command.ts b/src/shared/opencode-headless-command.ts index 5328f7931fc..766d70642fa 100644 --- a/src/shared/opencode-headless-command.ts +++ b/src/shared/opencode-headless-command.ts @@ -1,16 +1,105 @@ -// Why: `opencode run` answers one prompt and exits, so its process lifetime is its turn. -// Not in agent-headless-command's table: that would also drop OpenCode 1 `run`'s identity, -// whose in-process plugin reports it. Only `--log-level` takes a separate value before the -// subcommand; any other valued option makes the value the first positional, which fails safe. -export function isOpenCodeRunCommand(tokens: readonly string[]): boolean { - for (let index = 1; index < tokens.length; index += 1) { +import { extractLeadingEnvAssignments } from './command-environment' +import { getCommandTokenPathBasename } from './command-token-scanner' +import type { AgentStartupShell } from './tui-agent-startup-shell' + +const RUN_VALUE_FLAGS = new Set([ + '--log-level', + '--completions', + '--server', + '--session', + '-s', + '--model', + '-m', + '--agent', + '--format', + '--file', + '-f', + '--title' +]) + +const ENV_VALUE_FLAGS = new Set(['-u', '--unset', '-C', '--chdir', '-P']) +const ENV_EMPTY_FLAGS = new Set(['-i', '--ignore-environment', '-']) + +function envCommandPosition(tokens: readonly string[], offset: number): number { + let index = offset + while (index < tokens.length) { + const token = tokens[index] + if (token === '--') { + index += 1 + break + } + if (ENV_EMPTY_FLAGS.has(token)) { + index += 1 + } else if (ENV_VALUE_FLAGS.has(token)) { + index += 2 + } else if (/^(?:-[uCP].+|--(?:unset|chdir)=)/.test(token)) { + index += 1 + } else if (token.startsWith('-')) { + // Split-string options need another parse before their executable is known. + return tokens.length + } else { + break + } + } + return tokens.length - extractLeadingEnvAssignments(tokens.slice(index)).rest.length +} + +function openCodeCommandPosition(tokens: readonly string[], shell: AgentStartupShell): number { + if (shell === 'powershell' && tokens[0] === '&') { + return 1 + } + if (shell !== 'posix') { + return 0 + } + let index = tokens.length - extractLeadingEnvAssignments(tokens.slice()).rest.length + if (getCommandTokenPathBasename(tokens[index] ?? '') === 'env') { + index = envCommandPosition(tokens, index + 1) + } + return index +} + +export function findOpenCodeRunCommand( + tokens: readonly string[], + shell: AgentStartupShell = 'posix' +): { runIndex: number; messageSeparatorIndex: number | null } | null { + const commandPosition = openCodeCommandPosition(tokens, shell) + if (commandPosition > 0) { + const binary = getCommandTokenPathBasename(tokens[commandPosition] ?? '') + .toLowerCase() + .replace(/\.(?:exe|cmd)$/, '') + if (binary !== 'opencode' && binary !== 'opencode2') { + return null + } + } + for (let index = commandPosition + 1; index < tokens.length; index += 1) { const token = tokens[index] if (!token.startsWith('-')) { - return token === 'run' + if (token !== 'run') { + return null + } + for (let argumentIndex = index + 1; argumentIndex < tokens.length; argumentIndex += 1) { + const argument = tokens[argumentIndex] + if (argument === '--') { + return { runIndex: index, messageSeparatorIndex: argumentIndex } + } + if (RUN_VALUE_FLAGS.has(argument)) { + argumentIndex += 1 + } + } + return { runIndex: index, messageSeparatorIndex: null } } + // Other valued global options fail closed at their first positional value. if (token === '--log-level') { index += 1 } } - return false + return null +} + +// OpenCode run's process lifetime is its turn; v1 still reports through its plugin. +export function isOpenCodeRunCommand( + tokens: readonly string[], + shell: AgentStartupShell = 'posix' +): boolean { + return findOpenCodeRunCommand(tokens, shell) !== null } diff --git a/src/shared/opencode-startup-prompt-install.ts b/src/shared/opencode-startup-prompt-install.ts new file mode 100644 index 00000000000..6427a9e9886 --- /dev/null +++ b/src/shared/opencode-startup-prompt-install.ts @@ -0,0 +1,17 @@ +import { join } from 'node:path' +import { writeOpenCodeTuiPluginDirectory } from './opencode-tui-plugin-install' + +export const OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY = 'orca-opencode-startup-prompt' + +export function writeOpenCodeStartupPromptPlugin( + configDir: string, + source: string, + ownership: 'canonical' | 'overlay' = 'canonical' +): void { + writeOpenCodeTuiPluginDirectory( + join(configDir, 'plugins'), + OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY, + source, + ownership + ) +} diff --git a/src/shared/opencode-startup-prompt.test.ts b/src/shared/opencode-startup-prompt.test.ts new file mode 100644 index 00000000000..e4aab26d065 --- /dev/null +++ b/src/shared/opencode-startup-prompt.test.ts @@ -0,0 +1,52 @@ +import { createHash } from 'node:crypto' +import { describe, expect, it } from 'vitest' +import { buildAgentStartupPlan, buildAgentDraftLaunchPlan } from './tui-agent-startup' +import { + OPENCODE_STARTUP_PROMPT_SHA256_ENV, + OPENCODE_STARTUP_PROMPT_BODY_ENV, + OPENCODE_STARTUP_PROMPT_SHELL_ENV +} from './opencode-startup-prompt' + +describe('native OpenCode startup submission intent', () => { + it('binds the exact trimmed native prompt to host-selectable transport', () => { + const plan = buildAgentStartupPlan({ + agent: 'opencode', + prompt: ' task\nwith unicode é ', + cmdOverrides: {}, + platform: 'linux' + }) + expect(plan?.env).toEqual({ + [OPENCODE_STARTUP_PROMPT_SHA256_ENV]: createHash('sha256') + .update('task\nwith unicode é') + .digest('hex'), + [OPENCODE_STARTUP_PROMPT_BODY_ENV]: 'task\nwith unicode é', + [OPENCODE_STARTUP_PROMPT_SHELL_ENV]: 'posix' + }) + expect(plan?.launchCommand).toContain('--prompt') + }) + + it('preserves explicit run commands without a submission intent', () => { + const plan = buildAgentStartupPlan({ + agent: 'opencode', + prompt: 'task', + cmdOverrides: { opencode: 'opencode --log-level debug run' }, + platform: 'linux' + }) + expect(plan?.env).toBeUndefined() + expect(plan?.launchCommand).toContain('run --prompt') + }) + + it('never gives an editable draft or empty launch an automatic submission intent', () => { + const args = { agent: 'opencode' as const, cmdOverrides: {}, platform: 'linux' as const } + expect( + buildAgentDraftLaunchPlan({ ...args, draft: 'draft' })?.env?.[ + OPENCODE_STARTUP_PROMPT_SHA256_ENV + ] + ).toBeUndefined() + expect( + buildAgentStartupPlan({ ...args, prompt: '', allowEmptyPromptLaunch: true })?.env?.[ + OPENCODE_STARTUP_PROMPT_SHA256_ENV + ] + ).toBeUndefined() + }) +}) diff --git a/src/shared/opencode-startup-prompt.ts b/src/shared/opencode-startup-prompt.ts new file mode 100644 index 00000000000..1dbe9c2719d --- /dev/null +++ b/src/shared/opencode-startup-prompt.ts @@ -0,0 +1,39 @@ +import { isOpenCodeRunCommand } from './opencode-headless-command' +import { sha256 } from './sha256' +import { tokenizeStartupCommand, type AgentStartupShell } from './tui-agent-startup-shell' +import type { TuiAgent } from './tui-agent' + +export const OPENCODE_STARTUP_PROMPT_SHA256_ENV = 'ORCA_OPENCODE_STARTUP_PROMPT_SHA256' +export const OPENCODE_STARTUP_PROMPT_NONCE_ENV = 'ORCA_OPENCODE_STARTUP_PROMPT_NONCE' +export const OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV = 'ORCA_OPENCODE_STARTUP_PROMPT_ENDPOINT' +export const OPENCODE_STARTUP_PROMPT_CLAIM_PATH = '/opencode/startup-prompt/claim' +export const OPENCODE_STARTUP_PROMPT_BODY_ENV = 'ORCA_OPENCODE_STARTUP_PROMPT_BODY' +export const OPENCODE_STARTUP_PROMPT_SHELL_ENV = 'ORCA_OPENCODE_STARTUP_PROMPT_SHELL' + +export function openCodeStartupPromptEnv( + agent: TuiAgent, + command: string, + shell: AgentStartupShell, + prompt: string, + env: Record | null | undefined +): { env?: Record } { + const parsed = tokenizeStartupCommand(command, shell) + if ( + (agent !== 'opencode' && agent !== 'opencode2') || + !parsed.ok || + isOpenCodeRunCommand(parsed.tokens) + ) { + return env ? { env: { ...env } } : {} + } + const digest = Array.from(sha256(new TextEncoder().encode(prompt)), (byte) => + byte.toString(16).padStart(2, '0') + ).join('') + return { + env: { + ...env, + [OPENCODE_STARTUP_PROMPT_SHA256_ENV]: digest, + [OPENCODE_STARTUP_PROMPT_BODY_ENV]: prompt, + [OPENCODE_STARTUP_PROMPT_SHELL_ENV]: shell + } + } +} diff --git a/src/shared/opencode-tui-plugin-install.ts b/src/shared/opencode-tui-plugin-install.ts index 3a8306473c8..54e06df8883 100644 --- a/src/shared/opencode-tui-plugin-install.ts +++ b/src/shared/opencode-tui-plugin-install.ts @@ -32,13 +32,27 @@ export function writeOpenCodeTuiPlugin( source: string, ownership: 'canonical' | 'overlay' = 'canonical' ): void { - const dir = join(pluginsDir, openCodeTuiPluginDirName(pluginFileName)) + writeOpenCodeTuiPluginDirectory( + pluginsDir, + openCodeTuiPluginDirName(pluginFileName), + source, + ownership + ) +} + +export function writeOpenCodeTuiPluginDirectory( + pluginsDir: string, + directoryName: string, + source: string, + ownership: 'canonical' | 'overlay' = 'canonical' +): void { + const dir = join(pluginsDir, directoryName) const entry = join(dir, 'tui.js') // The 1.x TUI loader rejects a default object that also exposes server(). const tuiSource = source.includes('const ORCA_STATUS_AGENT = "opencode";') && source.includes('async function setupLegacyOpenCodeTui(') - ? `${source.replace(/^export default /m, 'const orcaServerPlugin = ')}\nconst { server: _orcaServerOnly, ...orcaTuiPlugin } = orcaServerPlugin;\nexport default { id: ${JSON.stringify(pluginFileName.replace(/\.js$/, ''))}, setup: setupOpenCode2Status, ...orcaTuiPlugin, tui: setupLegacyOpenCodeTui };\n` + ? `${source.replace(/^export default /m, 'const orcaServerPlugin = ')}\nconst { server: _orcaServerOnly, ...orcaTuiPlugin } = orcaServerPlugin;\nexport default { id: ${JSON.stringify(directoryName.replace(/-tui$/, ''))}, setup: setupOpenCode2Status, ...orcaTuiPlugin, tui: setupLegacyOpenCodeTui };\n` : source const isCurrent = ownership === 'canonical' ? isInstalledOpenCodePluginCurrent : isOverlayOpenCodePluginCurrent diff --git a/src/shared/tui-agent-startup.ts b/src/shared/tui-agent-startup.ts index c381454e9c4..ce8974a8cc3 100644 --- a/src/shared/tui-agent-startup.ts +++ b/src/shared/tui-agent-startup.ts @@ -17,6 +17,7 @@ import { inlineAgentDraftFitsPlatform } from './agent-draft-platform-limit' import type { TuiAgent } from './tui-agent' import type { SessionOptionValue } from './native-chat-session-options' import { resolveAgentLaunchCommand } from './tui-agent-launch-command' +import { openCodeStartupPromptEnv } from './opencode-startup-prompt' export { buildAgentResumeStartupPlan } from './tui-agent-resume-startup' @@ -123,7 +124,7 @@ export function buildAgentStartupPlan(args: { followupPrompt: null, launchConfig, ...appliedSessionOptionProps(baseCommand.appliedSessionOptions), - ...(args.agentEnv ? { env: { ...args.agentEnv } } : {}) + ...openCodeStartupPromptEnv(agent, launchCommand, shell, trimmedPrompt, args.agentEnv) } } From f62bd7dc209822345b70d46f9b4eb095224a1cab Mon Sep 17 00:00:00 2001 From: Kevin Saliou Date: Sun, 4 Oct 2026 10:41:51 +0200 Subject: [PATCH 49/67] feat(csv-viewer): detect semicolon-separated CSVs (#19894) Co-authored-by: Claude Opus 5 Co-authored-by: Neil --- .../editor/CsvViewer.delimiter.test.tsx | 89 +++++++++++++ .../src/components/editor/CsvViewer.tsx | 28 +++- .../editor/csv-delimiter-picker.tsx | 68 ++++++++++ .../src/components/editor/csv-parse.test.ts | 124 ++++++++++++++++++ .../src/components/editor/csv-parse.ts | 82 +++++++++++- src/renderer/src/i18n/locales/en.json | 7 +- 6 files changed, 387 insertions(+), 11 deletions(-) create mode 100644 src/renderer/src/components/editor/CsvViewer.delimiter.test.tsx create mode 100644 src/renderer/src/components/editor/csv-delimiter-picker.tsx diff --git a/src/renderer/src/components/editor/CsvViewer.delimiter.test.tsx b/src/renderer/src/components/editor/CsvViewer.delimiter.test.tsx new file mode 100644 index 00000000000..73f4091df0a --- /dev/null +++ b/src/renderer/src/components/editor/CsvViewer.delimiter.test.tsx @@ -0,0 +1,89 @@ +// @vitest-environment happy-dom +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import CsvViewer from './CsvViewer' + +vi.mock('@tanstack/react-virtual', () => ({ + useVirtualizer: ({ count }: { count: number }) => ({ + getVirtualItems: () => + Array.from({ length: Math.min(count, 3) }, (_, index) => ({ + index, + key: index, + start: index * 28 + })), + getTotalSize: () => count * 28 + }) +})) + +afterEach(cleanup) + +async function chooseDelimiter(label: string): Promise { + fireEvent.keyDown(screen.getByRole('combobox', { name: 'Delimiter' }), { key: 'ArrowDown' }) + fireEvent.click(await screen.findByRole('option', { name: label })) +} + +describe('CSV delimiter selection', () => { + it('lets the reader resolve ambiguous headerless decimal data', async () => { + render() + expect(screen.getByRole('combobox', { name: 'Delimiter' }).textContent).toBe('Auto (Comma)') + expect(screen.getByRole('columnheader', { name: '50;coffee' })).toBeTruthy() + + await chooseDelimiter('Semicolon (;)') + + expect(screen.getByRole('columnheader', { name: '1,50' })).toBeTruthy() + expect(screen.getByRole('cell', { name: '2,75' })).toBeTruthy() + expect(screen.getByRole('cell', { name: 'tea' })).toBeTruthy() + }) + + it('applies the selected separator to quoted multiline records', async () => { + render( + + ) + await chooseDelimiter('Semicolon (;)') + + expect(screen.getAllByRole('columnheader').map((cell) => cell.textContent)).toEqual([ + '#', + 'multi\nline', + 'value' + ]) + expect(screen.getAllByRole('cell').map((cell) => cell.textContent)).toEqual([ + 'first\nsecond', + 'a;b' + ]) + }) + + it('allows an explicit comma separator even when the extension is tsv', async () => { + render() + expect(screen.getByRole('combobox', { name: 'Delimiter' }).textContent).toBe('Auto (Tab)') + + await chooseDelimiter('Comma (,)') + + expect(screen.getByRole('columnheader', { name: 'amount' })).toBeTruthy() + expect(screen.getByRole('cell', { name: '1,50' })).toBeTruthy() + }) + + it('keeps an explicit choice on refresh and can return to the current auto choice', async () => { + const { rerender } = render() + await chooseDelimiter('Semicolon (;)') + + rerender() + + expect(screen.getByRole('combobox', { name: 'Delimiter' }).textContent).toBe('Semicolon (;)') + expect(screen.getAllByRole('columnheader')).toHaveLength(2) + await chooseDelimiter('Auto (Tab)') + expect(screen.getByRole('columnheader', { name: 'value' })).toBeTruthy() + expect(screen.getByRole('cell', { name: '2' })).toBeTruthy() + }) + + it('resets to Auto when the editor mounts a different file identity', async () => { + const { rerender } = render( + + ) + await chooseDelimiter('Comma (,)') + + rerender() + + expect(screen.getByRole('combobox', { name: 'Delimiter' }).textContent).toBe('Auto (Tab)') + expect(screen.getByRole('columnheader', { name: 'value' })).toBeTruthy() + }) +}) diff --git a/src/renderer/src/components/editor/CsvViewer.tsx b/src/renderer/src/components/editor/CsvViewer.tsx index e424a98c938..49d3a2ba51e 100644 --- a/src/renderer/src/components/editor/CsvViewer.tsx +++ b/src/renderer/src/components/editor/CsvViewer.tsx @@ -1,6 +1,7 @@ -import React, { useMemo, useRef } from 'react' +import React, { useMemo, useRef, useState } from 'react' import { useVirtualizer } from '@tanstack/react-virtual' import { detectCsvDelimiter, parseCsv } from './csv-parse' +import { CsvDelimiterPicker, type CsvDelimiterChoice } from './csv-delimiter-picker' import { translate } from '@/i18n/i18n' type CsvViewerProps = { @@ -23,11 +24,21 @@ const CHAR_PX = 7 // independently of the body, leaving values squashed together. export default function CsvViewer({ content, filePath }: CsvViewerProps): React.JSX.Element { const scrollRef = useRef(null) + const [delimiterChoice, setDelimiterChoice] = useState('auto') + const detectedDelimiter = useMemo( + () => detectCsvDelimiter(filePath, content), + [filePath, content] + ) + const delimiter = + delimiterChoice === 'auto' + ? detectedDelimiter + : delimiterChoice === 'comma' + ? ',' + : delimiterChoice === 'semicolon' + ? ';' + : '\t' - const parsed = useMemo(() => { - const delimiter = detectCsvDelimiter(filePath, content) - return parseCsv(content, delimiter) - }, [content, filePath]) + const parsed = useMemo(() => parseCsv(content, delimiter), [content, delimiter]) // Why: memoize header/body split so their references stay stable across // renders that don't change content. A top-level rest-destructure would @@ -178,7 +189,7 @@ export default function CsvViewer({ content, filePath }: CsvViewerProps): React.
-
+
{bodyRows.length.toLocaleString()}{' '} {translate('auto.components.editor.CsvViewer.ac31d2cd60', 'rows')} @@ -186,6 +197,11 @@ export default function CsvViewer({ content, filePath }: CsvViewerProps): React. {columnCount} {translate('auto.components.editor.CsvViewer.eedd0d37a7', 'columns')} +
) diff --git a/src/renderer/src/components/editor/csv-delimiter-picker.tsx b/src/renderer/src/components/editor/csv-delimiter-picker.tsx new file mode 100644 index 00000000000..d345283fd39 --- /dev/null +++ b/src/renderer/src/components/editor/csv-delimiter-picker.tsx @@ -0,0 +1,68 @@ +import { useId } from 'react' +import { Label } from '@/components/ui/label' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from '@/components/ui/select' +import { translate } from '@/i18n/i18n' + +export type CsvDelimiterChoice = 'auto' | 'comma' | 'semicolon' | 'tab' + +export function CsvDelimiterPicker({ + value, + detectedDelimiter, + onChange +}: { + value: CsvDelimiterChoice + detectedDelimiter: string + onChange: (choice: CsvDelimiterChoice) => void +}): React.JSX.Element { + const id = useId() + const comma = translate('auto.components.editor.CsvViewer.delimiterComma', 'Comma') + const semicolon = translate('auto.components.editor.CsvViewer.delimiterSemicolon', 'Semicolon') + const tab = translate('auto.components.editor.CsvViewer.delimiterTab', 'Tab') + const detectedName = + detectedDelimiter === ',' ? comma : detectedDelimiter === ';' ? semicolon : tab + const choices = [ + { + value: 'auto', + label: translate('auto.components.editor.CsvViewer.delimiterAuto', 'Auto ({{delimiter}})', { + delimiter: detectedName + }) + }, + { value: 'comma', label: `${comma} (,)` }, + { value: 'semicolon', label: `${semicolon} (;)` }, + { value: 'tab', label: tab } + ] as const + + return ( +
+ + +
+ ) +} diff --git a/src/renderer/src/components/editor/csv-parse.test.ts b/src/renderer/src/components/editor/csv-parse.test.ts index 267be8b1028..f2e935e4937 100644 --- a/src/renderer/src/components/editor/csv-parse.test.ts +++ b/src/renderer/src/components/editor/csv-parse.test.ts @@ -80,6 +80,130 @@ describe('detectCsvDelimiter', () => { expect(detectCsvDelimiter('data.csv', 'a,b,c\n1,2,3')).toBe(',') }) + it('sniffs semicolon exports that use commas as the decimal mark', () => { + const content = 'amount;label\n1,50;"Roe, John"\n2,75;lunch\n' + + expect(detectCsvDelimiter('expenses.csv', content)).toBe(';') + expect(parseCsv(content, detectCsvDelimiter('expenses.csv', content)).rows).toEqual([ + ['amount', 'label'], + ['1,50', 'Roe, John'], + ['2,75', 'lunch'] + ]) + }) + + it('keeps comma when semicolons only appear inside quoted fields', () => { + expect(detectCsvDelimiter('x.csv', '"a"";b;c",d,e\n1,2,3\n')).toBe(',') + }) + + it('prefers tab over semicolon when tabs dominate the first line', () => { + expect(detectCsvDelimiter('x.csv', 'a\tb;c\td\n')).toBe('\t') + }) + + it('uses tab for .tsv files that contain semicolons', () => { + expect(detectCsvDelimiter('data.TSV', 'a;b;c')).toBe('\t') + }) + + it.each([ + ['a;b,c', ','], + ['a;b,c\td', ','], + ['a;b\tc', '\t'], + ['a,b\tc', ','], + ['', ','], + ['single', ','] + ])('preserves existing delimiter precedence for %j', (content, delimiter) => { + expect(detectCsvDelimiter('x.csv', content)).toBe(delimiter) + }) + + it('sniffs semicolons after a BOM and leading whitespace-only lines', () => { + expect(detectCsvDelimiter('x.csv', '\uFEFF\n \t \r\na;b\n1;2')).toBe(';') + }) + + it('parses semicolon fields with quoted separators, escaped quotes and newlines', () => { + const content = '\uFEFFnote;amount\r\n"she said ""hi"";\nnext";1,50\r\n' + + expect(parseCsv(content, detectCsvDelimiter('x.csv', content))).toEqual({ + rows: [ + ['note', 'amount'], + ['she said "hi";\nnext', '1,50'] + ], + maxColumns: 2 + }) + }) + + it('keeps consistent comma columns when only the header has extra semicolons', () => { + const content = 'notes;one;two,value\nplain,1\nother,2' + + expect(detectCsvDelimiter('x.csv', content)).toBe(',') + expect(parseCsv(content, detectCsvDelimiter('x.csv', content)).rows).toEqual([ + ['notes;one;two', 'value'], + ['plain', '1'], + ['other', '2'] + ]) + }) + + it.each([',', '\t'])('keeps literal quotes inside unquoted %j fields', (delimiter) => { + const content = `notes;one;two${delimiter}value\n6" bolts${delimiter}1\nplain${delimiter}2` + + expect(detectCsvDelimiter('x.csv', content)).toBe(delimiter) + expect(parseCsv(content, detectCsvDelimiter('x.csv', content)).rows).toEqual([ + ['notes;one;two', 'value'], + ['6" bolts', '1'], + ['plain', '2'] + ]) + }) + + it('keeps consistent tab columns when a header contains extra semicolons', () => { + expect(detectCsvDelimiter('x.csv', 'notes;one;two\tvalue\nplain\t1')).toBe('\t') + }) + + it('keeps a semicolon export whose unquoted comma counts vary between rows', () => { + expect(detectCsvDelimiter('x.csv', 'name;amount;note\nAda;1,50;lunch\nBo;2;plain')).toBe(';') + }) + + it('ignores separator-like punctuation in a multiline quoted field', () => { + const content = 'notes;one;two,value\n"line one\nline;two",1' + expect(detectCsvDelimiter('x.csv', content)).toBe(',') + }) + + it('keeps first-line inference when the rows are ambiguous or ragged', () => { + expect(detectCsvDelimiter('x.csv', 'a;b;c,value\nx;y;z,1')).toBe(';') + expect(detectCsvDelimiter('x.csv', 'a;b;c\nx;y\nz')).toBe(';') + expect(detectCsvDelimiter('x.csv', '1,50;coffee\n2,75;lunch')).toBe(',') + }) + + it('uses at most eight logical records to corroborate the existing delimiter', () => { + const firstEight = ['notes;one;two,value', ...Array.from({ length: 7 }, () => 'plain,1')] + const content = [...firstEight, 'ragged,one,two,three'].join('\n') + expect(detectCsvDelimiter('x.csv', content)).toBe(',') + }) + + it('does not use an unfinished quoted record at the scan boundary as corroboration', () => { + const prefix = 'notes;one;two,value\nplain,"' + const content = `${prefix}${'x'.repeat(CSV_DELIMITER_SNIFF_SCAN_CODE_UNITS)}",1` + expect(detectCsvDelimiter('x.csv', content)).toBe(';') + }) + + it('does not use a partial unquoted record at the scan boundary as corroboration', () => { + const prefix = 'notes;one;two,value\nplain,' + const content = `${prefix}${'x'.repeat(CSV_DELIMITER_SNIFF_SCAN_CODE_UNITS)}\nother,2` + expect(detectCsvDelimiter('x.csv', content)).toBe(';') + }) + + it('does not corroborate an unfinished quoted record at EOF', () => { + expect(detectCsvDelimiter('x.csv', 'notes;one;two,value\nplain,"unfinished')).toBe(';') + }) + + it('corroborates complete records with CRLF, escaped quotes and a quoted newline', () => { + const content = 'notes;one;two,value\r\n"say ""hi"";\r\nnext",1\r\nplain,2\r\n' + expect(detectCsvDelimiter('x.csv', content)).toBe(',') + }) + + it('does not sniff semicolons beyond the first-line scan limit', () => { + const content = `${'a'.repeat(CSV_DELIMITER_SNIFF_SCAN_CODE_UNITS)};b;c` + + expect(detectCsvDelimiter('x.csv', content)).toBe(',') + }) + it('skips leading blank lines when sniffing', () => { expect(detectCsvDelimiter('x.csv', '\n\na\tb\tc')).toBe('\t') }) diff --git a/src/renderer/src/components/editor/csv-parse.ts b/src/renderer/src/components/editor/csv-parse.ts index 009e9646320..6f52edbf5b7 100644 --- a/src/renderer/src/components/editor/csv-parse.ts +++ b/src/renderer/src/components/editor/csv-parse.ts @@ -107,9 +107,7 @@ export function detectCsvDelimiter(filePath: string, content: string): string { if (filePath.toLowerCase().endsWith('.tsv')) { return '\t' } - // Why: sniff the first non-empty line for tab vs comma to handle CSVs that - // were saved with a different extension. Semicolons/pipes are out of scope; - // this tool is a viewer, not a general data importer. + // Include semicolon spreadsheet exports without adding general importer heuristics. // Why: strip a leading UTF-8 BOM so it doesn't get counted as part of the // first cell's characters (and so BOM-prefixed TSVs still sniff correctly). let text = content @@ -121,8 +119,84 @@ export function detectCsvDelimiter(filePath: string, content: string): string { // (0 tabs vs 0 commas, tie goes to comma), misdetecting blank-leading TSVs. const firstLine = findFirstNonEmptyCsvSniffLine(text) const tabs = countDelimiterOutsideQuotes(firstLine, '\t') + const semicolons = countDelimiterOutsideQuotes(firstLine, ';') const commas = countDelimiterOutsideQuotes(firstLine, ',') - return tabs > commas ? '\t' : ',' + // Keep the existing comma/tab choice unless semicolon strictly wins. + const existingDelimiter = tabs > commas ? '\t' : ',' + if (semicolons > commas && semicolons > tabs) { + return hasConsistentExistingCsvColumns(text, existingDelimiter) ? existingDelimiter : ';' + } + return existingDelimiter +} + +// Header punctuation should not replace an otherwise consistent comma/tab table. +function hasConsistentExistingCsvColumns(text: string, delimiter: string): boolean { + const scanLength = Math.min(text.length, CSV_DELIMITER_SNIFF_SCAN_CODE_UNITS) + const records: { delimiters: number; semicolons: number }[] = [] + let delimiters = 0 + let semicolons = 0 + let inQuotes = false + let fieldIsEmpty = true + let hasContent = false + const pushRecord = (): void => { + if (hasContent) { + records.push({ delimiters, semicolons }) + } + delimiters = 0 + semicolons = 0 + fieldIsEmpty = true + hasContent = false + } + + for (let index = 0; index < scanLength && records.length < 8; index += 1) { + const ch = text[index] + if (inQuotes) { + if (ch === '"') { + if (text[index + 1] === '"' && index + 1 < scanLength) { + fieldIsEmpty = false + index += 1 + } else { + inQuotes = false + } + } else { + fieldIsEmpty = false + } + continue + } + if (ch === '"' && fieldIsEmpty) { + inQuotes = true + hasContent = true + continue + } + if (ch === '\r' || ch === '\n') { + pushRecord() + if (ch === '\r' && text[index + 1] === '\n') { + index += 1 + } + continue + } + if (ch === delimiter) { + delimiters += 1 + fieldIsEmpty = true + } else { + fieldIsEmpty = false + } + if (ch === ';') { + semicolons += 1 + } + hasContent ||= !isCsvSniffWhitespace(text.charCodeAt(index)) + } + if (scanLength === text.length && records.length < 8 && !inQuotes) { + pushRecord() + } + const first = records[0] + return Boolean( + first && + first.delimiters > 0 && + records.length > 1 && + records.every((record) => record.delimiters === first.delimiters) && + records.some((record) => record.semicolons !== first.semicolons) + ) } function findFirstNonEmptyCsvSniffLine(text: string): string { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 9bc69d7501d..7b497327dac 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -15126,7 +15126,12 @@ "CsvViewer": { "eedd0d37a7": "columns", "ac31d2cd60": "rows", - "a233d55b77": "Empty file" + "a233d55b77": "Empty file", + "delimiterComma": "Comma", + "delimiterSemicolon": "Semicolon", + "delimiterTab": "Tab", + "delimiterAuto": "Auto ({{delimiter}})", + "delimiter": "Delimiter" }, "DiffNotesSendMenu": { "f1aa04b5cf": "This file", From b407d06c1ecd9d237010984ac02d9cd3abe37df0 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 4 Oct 2026 01:58:47 -0700 Subject: [PATCH 50/67] Reuse buffer cells during terminal cursor context scans (#25161) --- docs/reference/ci-runner-efficiency.md | 26 ++++++ .../terminal-cursor-line-context.test.ts | 89 +++++++++++++++++++ src/shared/terminal-cursor-line-context.ts | 50 +++++++---- 3 files changed, 148 insertions(+), 17 deletions(-) diff --git a/docs/reference/ci-runner-efficiency.md b/docs/reference/ci-runner-efficiency.md index 600651a4457..d714947325a 100644 --- a/docs/reference/ci-runner-efficiency.md +++ b/docs/reference/ci-runner-efficiency.md @@ -1858,6 +1858,32 @@ the imported helper has no top-level side effects, and the Linux rebuild branch is unchanged. Focused tests verify its Linux/macOS no-op behavior. Final-head PR checks qualify separately. +## October 4 reusable cells for terminal context scans + +Terminal cursor-context scans now request one reusable cell per invocation when +the adapter offers getNullCell, and pass it through all unchanged text/style +scans. Adapters without that optional method keep the existing allocating path. +The scratch cell is local and no cell reference escapes into returned context. +Browser composer/readiness text, colors, bold flags and wrapping are unchanged. + +Three alternating one-worker ARM pairs in +[37182789677](https://github.com/stablyai/orca/actions/runs/37182789677) +ran all 19 original cases from readiness census suite 2. Baseline complete +invocations were 37.141 / 37.879 / 37.090 seconds; candidate invocations were +33.887 / 33.387 / 32.916 seconds. Median 37.141 to 33.387 seconds saves 10.1%. +This is a focused workload measurement, not a whole-shard or queue-delay claim. + +Separate baseline/candidate captures retained all 192 cases across six census +suites. Every context and visible projection matched: 643,926 of each, with +7,465,308,324 complete length-prefixed payload bytes hashed per test/type/order. +The canonical capture digest was +`f7440c0f1b5bbb57127cd29245530029415c8e9e243c1744359f330b3c7ace19`. +These captures run outside the timing samples. All 41 cursor/composer/browser +consumer checks passed. Seven faults for lost dim filtering, wide continuation, +bold prompt, custom foreground, wrap preservation, adapter fallback and scratch +reuse failed their intended assertions. Node and web typecheck, lint and format +passed. Two added controls prove per-call scratch lifetime and adapter parity. + ## October 3 producer follow-up: automatic selection for the measured profile The first producer rollout in [#24927](https://github.com/stablyai/orca/pull/24927) diff --git a/src/main/daemon/terminal-cursor-line-context.test.ts b/src/main/daemon/terminal-cursor-line-context.test.ts index 86066b95b8c..29afe00618b 100644 --- a/src/main/daemon/terminal-cursor-line-context.test.ts +++ b/src/main/daemon/terminal-cursor-line-context.test.ts @@ -9,7 +9,96 @@ function writeSync(terminal: Terminal, data: string): void { core.writeSync(data) } +type Cell = ReturnType + +function observeCellReads(terminal: Terminal) { + const allocated: Cell[] = [] + const targets: (Cell | undefined)[] = [] + const active = terminal.buffer.active + const source = { + rows: terminal.rows, + modes: terminal.modes, + buffer: { + active: { + baseY: active.baseY, + cursorX: active.cursorX, + cursorY: active.cursorY, + viewportY: active.viewportY, + getNullCell: () => { + const cell = active.getNullCell() + allocated.push(cell) + return cell + }, + getLine: (row: number) => { + const line = active.getLine(row) + if (!line) { + return undefined + } + return { + isWrapped: line.isWrapped, + length: line.length, + translateToString: line.translateToString.bind(line), + getCell: (column: number, reusableCell?: Cell) => { + targets.push(reusableCell) + return line.getCell(column, reusableCell) + } + } + } + } + } + } + return { source, allocated, targets } +} + describe('readTerminalCursorLineContext', () => { + it('reuses one cell across every scan without retaining it between reads', () => { + const terminal = new Terminal({ cols: 12, rows: 5, allowProposedApi: true }) + try { + writeSync(terminal, '\x1b[1m❯\x1b[22m 界e\u0301\x1b7\r\n\x1b[31mfooter\x1b8') + const rig = observeCellReads(terminal) + const first = readTerminalCursorLineContext(rig.source, terminal.rows) + expect(rig.allocated).toHaveLength(1) + expect(rig.targets.length).toBeGreaterThan(terminal.cols * 2) + expect(rig.targets.every((cell) => cell === rig.allocated[0])).toBe(true) + rig.targets.length = 0 + expect(readTerminalCursorLineContext(rig.source, terminal.rows)).toEqual(first) + expect(rig.allocated).toHaveLength(2) + expect(rig.allocated[1]).not.toBe(rig.allocated[0]) + expect(rig.targets.every((cell) => cell === rig.allocated[1])).toBe(true) + } finally { + terminal.dispose() + } + }) + + it('preserves full context for an adapter without reusable cells', () => { + const terminal = new Terminal({ cols: 12, rows: 5, allowProposedApi: true }) + try { + writeSync( + terminal, + '\x1b[1m❯\x1b[22m typed\x1b7\x1b[2m hint\x1b[22m\r\n\x1b[38;2;1;2;3m界e\u0301\x1b[0m\x1b8' + ) + const rig = observeCellReads(terminal) + const source = { + ...rig.source, + buffer: { active: { ...rig.source.buffer.active, getNullCell: undefined } } + } + const context = readTerminalCursorLineContext(source, terminal.rows) + expect(context).toEqual(readTerminalCursorLineContext(terminal, terminal.rows)) + expect(context?.typedRows).toEqual(['❯ typed']) + expect(context?.typedRowsBelow[0]).toBe('界e\u0301') + expect(context?.beforeCursor).toBe('❯ typed') + expect(context?.afterCursor).toBe('') + expect(context?.rawAfterCursor).toBe(' hint') + expect(context?.promptGlyphBoldRows).toEqual([true]) + expect(context?.rowsBelowCustomForeground?.[0]).toBe(true) + expect(rig.allocated).toEqual([]) + expect(rig.targets.length).toBeGreaterThan(terminal.cols * 2) + expect(rig.targets.every((cell) => cell === undefined)).toBe(true) + } finally { + terminal.dispose() + } + }) + it.each([ { cols: 19, cursorRowTail: 'proceed with the ', continuation: 'release' }, { cols: 18, cursorRowTail: 'proceed with the', continuation: ' release' } diff --git a/src/shared/terminal-cursor-line-context.ts b/src/shared/terminal-cursor-line-context.ts index 230c85c7895..28fc2402fe2 100644 --- a/src/shared/terminal-cursor-line-context.ts +++ b/src/shared/terminal-cursor-line-context.ts @@ -8,14 +8,14 @@ type TerminalCursorCell = { isFgDefault(): boolean | number } -type TerminalCursorLine = { +type TerminalCursorLine = { readonly isWrapped: boolean readonly length: number - getCell(column: number): TerminalCursorCell | undefined + getCell(column: number, reusableCell?: Cell): Cell | undefined translateToString(trimRight?: boolean, startColumn?: number, endColumn?: number): string } -export type TerminalCursorContextSource = { +export type TerminalCursorContextSource = { readonly rows: number readonly modes: { readonly showCursor: boolean } readonly buffer: { @@ -24,15 +24,21 @@ export type TerminalCursorContextSource = { readonly cursorX: number readonly cursorY: number readonly viewportY: number - getLine(row: number): TerminalCursorLine | undefined + getLine(row: number): TerminalCursorLine | undefined + getNullCell?(): Cell } } } -function undimmedText(line: TerminalCursorLine, fromX = 0, trimRight = true): string { +function undimmedText( + line: TerminalCursorLine, + fromX = 0, + trimRight = true, + reusableCell?: Cell +): string { let text = '' for (let x = fromX; x < line.length; x += 1) { - const cell = line.getCell(x) + const cell = line.getCell(x, reusableCell) if (!cell || cell.isDim() || cell.getWidth() === 0) { continue } @@ -41,9 +47,12 @@ function undimmedText(line: TerminalCursorLine, fromX = 0, trimRight = true): st return trimRight ? text.trimEnd() : text } -function firstVisibleCellIsBold(line: TerminalCursorLine): boolean { +function firstVisibleCellIsBold( + line: TerminalCursorLine, + reusableCell?: Cell +): boolean { for (let x = 0; x < line.length; x += 1) { - const cell = line.getCell(x) + const cell = line.getCell(x, reusableCell) if (!cell || cell.getWidth() === 0 || !cell.getChars().trim()) { continue } @@ -52,9 +61,12 @@ function firstVisibleCellIsBold(line: TerminalCursorLine): boolean { return false } -function firstVisibleCellHasCustomForeground(line: TerminalCursorLine): boolean { +function firstVisibleCellHasCustomForeground( + line: TerminalCursorLine, + reusableCell?: Cell +): boolean { for (let x = 0; x < line.length; x += 1) { - const cell = line.getCell(x) + const cell = line.getCell(x, reusableCell) if (!cell || cell.getWidth() === 0 || !cell.getChars().trim()) { continue } @@ -63,8 +75,8 @@ function firstVisibleCellHasCustomForeground(line: TerminalCursorLine): boolean return false } -export function readTerminalCursorLineContext( - terminal: TerminalCursorContextSource, +export function readTerminalCursorLineContext( + terminal: TerminalCursorContextSource, rowsAroundCursor: number ): TerminalCursorContext | null { const buffer = terminal.buffer.active @@ -73,6 +85,7 @@ export function readTerminalCursorLineContext( if (!cursorLine) { return null } + const reusableCell = buffer.getNullCell?.() const rows: string[] = [] const typedRows: string[] = [] const promptGlyphBoldRows: boolean[] = [] @@ -83,8 +96,8 @@ export function readTerminalCursorLineContext( const line = buffer.getLine(row) const nextLineIsWrapped = buffer.getLine(row + 1)?.isWrapped ?? false rows.push(line?.translateToString(!nextLineIsWrapped) ?? '') - typedRows.push(line ? undimmedText(line, 0, !nextLineIsWrapped) : '') - promptGlyphBoldRows.push(line ? firstVisibleCellIsBold(line) : false) + typedRows.push(line ? undimmedText(line, 0, !nextLineIsWrapped, reusableCell) : '') + promptGlyphBoldRows.push(line ? firstVisibleCellIsBold(line, reusableCell) : false) rowsWrapped.push(line?.isWrapped ?? false) } const rowsBelow: string[] = [] @@ -96,9 +109,11 @@ export function readTerminalCursorLineContext( const line = buffer.getLine(row) const nextLineIsWrapped = buffer.getLine(row + 1)?.isWrapped ?? false rowsBelow.push(line?.translateToString(!nextLineIsWrapped) ?? '') - typedRowsBelow.push(line ? undimmedText(line, 0, !nextLineIsWrapped) : '') + typedRowsBelow.push(line ? undimmedText(line, 0, !nextLineIsWrapped, reusableCell) : '') rowsBelowWrapped.push(line?.isWrapped ?? false) - rowsBelowCustomForeground.push(line ? firstVisibleCellHasCustomForeground(line) : false) + rowsBelowCustomForeground.push( + line ? firstVisibleCellHasCustomForeground(line, reusableCell) : false + ) } return { rows, @@ -113,7 +128,8 @@ export function readTerminalCursorLineContext( afterCursor: undimmedText( cursorLine, buffer.cursorX, - !(buffer.getLine(cursorRow + 1)?.isWrapped ?? false) + !(buffer.getLine(cursorRow + 1)?.isWrapped ?? false), + reusableCell ), rawAfterCursor: cursorLine.translateToString( !(buffer.getLine(cursorRow + 1)?.isWrapped ?? false), From c4e8735f455393254016be1e7104366d4c398926 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 4 Oct 2026 02:05:05 -0700 Subject: [PATCH 51/67] Share PR preflight setup to reduce runner demand (#25150) * Share PR static analysis and compiler runner * Preserve evidence document final newline for concurrent merges * Keep readiness reuse contracts aligned with the physical preflight gate --- .github/workflows/pr.yml | 117 ++++++++++++------ .../scripts/check-node-runtime-pin.test.mjs | 2 +- .../ci-background-step-barriers.test.mjs | 7 +- .../scripts/ci-cache-warmup-workflow.test.mjs | 4 +- ...alization-extraction-change-scope.test.mjs | 4 +- ...orcad-terminal-smoke-change-scope.test.mjs | 2 +- config/scripts/pr-code-change-scope.test.mjs | 20 +-- config/scripts/pr-e2e-gate-contract.test.mjs | 4 +- config/scripts/pr-preflight-gates.test.mjs | 86 ++++++++----- config/scripts/pr-ready-check-gate.test.mjs | 10 +- config/scripts/pr-ready-check-reuse.test.mjs | 6 +- .../scripts/pr-workflow-parallelism.test.mjs | 17 ++- docs/reference/ci-runner-efficiency.md | 30 +++++ 13 files changed, 205 insertions(+), 104 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 9558255e482..f9f945b3bf3 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -154,15 +154,17 @@ jobs: echo "No specs requiring the reusable E2E workflow" fi - static_analysis: - name: static analysis + preflight: + name: static analysis and typecheck needs: [code_paths] - if: needs.code_paths.outputs.static_analysis == 'true' + if: needs.code_paths.outputs.static_analysis == 'true' || needs.code_paths.outputs.typecheck == 'true' # Why ARM: measured 128s against 172s on ubuntu-latest, with every compute step faster -- # type-aware 24s->15s, anti-slop 28->19s, localization extraction 67->46s, the orcad smoke # 39->14s. Both lint engines ship linux-arm64 and the Bun target follows process.arch, so # the whole toolchain resolves. Free for public repositories, same as the typecheck job. runs-on: ubuntu-24.04-arm + outputs: + shards: ${{ steps.unit-plan.outputs.shards }} steps: - name: Checkout @@ -197,21 +199,35 @@ jobs: # Keep each check in its own log while sharing this runner. - name: Lint + if: '!cancelled()' id: root-lint background: true - run: pnpm exec oxlint --format github + env: + PREFLIGHT_PHASE_SELECTED: ${{ needs.code_paths.outputs.static_analysis == 'true' }} + PREFLIGHT_PRIOR_SUCCESS: ${{ job.status == 'success' }} + run: | + if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi + pnpm exec oxlint --format github - name: Reject low-evidence patterns + if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm run audit:anti-slop - wait: root-lint - name: Enforce focused code-quality plugins + if: '!cancelled()' id: native-code-quality background: true - run: pnpm run audit:code-quality:native + env: + PREFLIGHT_PHASE_SELECTED: ${{ needs.code_paths.outputs.static_analysis == 'true' }} + PREFLIGHT_PRIOR_SUCCESS: ${{ job.status == 'success' }} + run: | + if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi + pnpm run audit:code-quality:native - name: Enforce type-aware code-quality baseline + if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm run audit:code-quality:type-aware # Mobile installation changes import resolution for the native cycle check. @@ -221,28 +237,39 @@ jobs: # resolves types from mobile/node_modules. Without the install every mobile type # degrades to an `error` type — reported as phantom findings against the changed lines. - uses: ./.github/actions/install-mobile-dependencies - if: needs.code_paths.outputs.mobile_dependencies == 'true' + if: needs.code_paths.outputs.static_analysis == 'true' && needs.code_paths.outputs.mobile_dependencies == 'true' - name: Enforce changed-code quality + if: '!cancelled()' id: changed-code-quality background: true - run: pnpm run check:code-quality:changed -- "${{ github.event.pull_request.base.sha }}" + env: + PREFLIGHT_PHASE_SELECTED: ${{ needs.code_paths.outputs.static_analysis == 'true' }} + PREFLIGHT_PRIOR_SUCCESS: ${{ job.status == 'success' }} + run: | + if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi + pnpm run check:code-quality:changed -- "${{ github.event.pull_request.base.sha }}" - name: Enforce React Doctor on changed lines + if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm run check:react-doctor:changed -- "${{ github.event.pull_request.base.sha }}" - wait: changed-code-quality - name: Check Zustand selector fan-out budget + if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm run check:zustand-selector-fanout - name: Check reliability gate manifest + if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm run check:reliability-gates - name: Enforce dead design-system classes + if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm run check:dead-classes - name: Check VM runtime rollback compatibility + if: needs.code_paths.outputs.static_analysis == 'true' env: BASE_SHA: ${{ github.event.pull_request.base.sha }} run: | @@ -264,25 +291,33 @@ jobs: config/scripts/ephemeral-vm-runtime-store-cross-version.test.ts - name: Enforce max-lines ratchet + if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm run check:max-lines-ratchet - name: Enforce ts-nocheck ratchet + if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm run check:ts-nocheck-ratchet - name: Enforce runtime Electron-import ratchet + if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm run check:runtime-electron-ratchet - name: Check Node runtime pin + if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm run check:node-runtime-pin # Why: extraction writes sorted evidence to an isolated temporary path, # so feature PRs need one normalized AST pass rather than a three-OS matrix. - name: Verify localization extraction + if: '!cancelled()' id: localization-extraction background: true env: + PREFLIGHT_PHASE_SELECTED: ${{ needs.code_paths.outputs.static_analysis == 'true' }} + PREFLIGHT_PRIOR_SUCCESS: ${{ job.status == 'success' }} BASE_SHA: ${{ github.event.pull_request.base.sha }} run: | + if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi # Detection failures run the full check; renames retain the removed input path. DIFF_BASE="$(node config/scripts/git-pull-request-diff-base.mjs "$BASE_SHA")" if git diff --name-only --no-renames -z "$DIFF_BASE" HEAD > "$RUNNER_TEMP/localization-changes" && @@ -296,6 +331,7 @@ jobs: # which is a property of the import graph. This proves the Node artifact it enables # actually boots, pairs, creates a worktree and round-trips a real PTY. - name: Boot orcad and round-trip a terminal + if: needs.code_paths.outputs.static_analysis == 'true' env: BASE_SHA: ${{ github.event.pull_request.base.sha }} ORCA_BACKGROUND_LAUNCH: '1' @@ -310,20 +346,30 @@ jobs: fi - name: Verify the generated RPC params catalog + if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm run verify:rpc-params-catalog - name: Verify bundled skill guides + if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm run verify:bundled-skill-guides - name: Verify skill freshness manifest + if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm run verify:skill-bundle-manifest - name: Verify localization catalogs + if: '!cancelled()' id: localization-catalogs background: true - run: pnpm run verify:localization-catalogs + env: + PREFLIGHT_PHASE_SELECTED: ${{ needs.code_paths.outputs.static_analysis == 'true' }} + PREFLIGHT_PRIOR_SUCCESS: ${{ job.status == 'success' }} + run: | + if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi + pnpm run verify:localization-catalogs - name: Verify localization coverage + if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm run verify:localization-coverage - wait: [localization-catalogs, localization-extraction] @@ -334,6 +380,7 @@ jobs: # in .d.ts to `any`, which is how #1186 shipped a broken IPC signature # past typecheck. See .github/CONTRIBUTING.md#type-declarations-prefer-ts-over-dts. - name: Guard against project-owned .d.ts in preload/shared + if: needs.code_paths.outputs.static_analysis == 'true' run: | matches=$(find src/preload src/shared -name '*.d.ts' 2>/dev/null || true) if [ -n "$matches" ]; then @@ -346,33 +393,19 @@ jobs: fi - name: Check feature wall asset budget + if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm check:feature-wall-assets - name: Verify macOS entitlements + if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm verify:macos-entitlements - typecheck: - needs: [code_paths] - if: needs.code_paths.outputs.typecheck == 'true' - # Typechecking uses no native runtime, so it can use the free public ARM runner. - runs-on: ubuntu-24.04-arm - outputs: - shards: ${{ steps.unit-plan.outputs.shards }} - - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - fetch-depth: 2 - persist-credentials: false - - - uses: ./.github/actions/install-node-dependencies - # Why: every project is `composite`, so tsc already writes a .tsbuildinfo that lets # the next run skip unchanged files. Share one cache entry across commits while the # PR base stays stable; actions/cache keeps the first successful graph and the # compiler still invalidates stale files from its content hashes. - name: Cache TypeScript incremental state + if: needs.code_paths.outputs.typecheck == 'true' uses: actions/cache@v5 with: path: config/*.tsbuildinfo @@ -382,17 +415,24 @@ jobs: # Planning shares setup and stays off the compiler's critical path. - name: Plan unit selection + if: '!cancelled()' id: unit-plan background: true env: + PREFLIGHT_PHASE_SELECTED: ${{ needs.code_paths.outputs.typecheck == 'true' }} + PREFLIGHT_PRIOR_SUCCESS: ${{ job.status == 'success' }} ORCA_UNIT_SELECTION_MODE: ${{ vars.ORCA_UNIT_SELECTION_MODE || 'shadow' }} - run: node config/scripts/ci-unit-plan.mjs + run: | + if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi + node config/scripts/ci-unit-plan.mjs - run: pnpm run typecheck + if: needs.code_paths.outputs.typecheck == 'true' - wait: unit-plan - uses: actions/upload-artifact@v7 + if: needs.code_paths.outputs.typecheck == 'true' with: name: unit-selection-attempt-${{ github.run_attempt }} path: ci-shards/unit-selection.json @@ -691,20 +731,19 @@ jobs: src/shared/startup-shell-portability.live-shell.test.ts \ src/shared/posix-command-path-lookup.test.ts - # Static analysis saves the Node cache; typecheck publishes the plan before tests fan out. + # Preflight saves the Node cache and publishes the plan before tests fan out. test: - needs: [code_paths, static_analysis, typecheck] + needs: [code_paths, preflight] # Cancellation and failed prerequisites stop the expensive matrix. if: >- !cancelled() && needs.code_paths.outputs.test == 'true' && - needs.static_analysis.result == 'success' && - needs.typecheck.result == 'success' + needs.preflight.result == 'success' uses: ./.github/workflows/unit-tests.yml with: node_versions: '["24"]' runner: ubuntu-24.04-arm - shards: ${{ needs.typecheck.outputs.shards }} + shards: ${{ needs.preflight.outputs.shards }} # Why a sibling and not part of the test workflow: it is advisory, so it must not delay the # gate. Inside unit-tests.yml a caller's `needs: test` waited for it, holding verify ~36s @@ -902,7 +941,7 @@ jobs: package: name: package - needs: [code_paths, static_analysis, typecheck] + needs: [code_paths, preflight] if: needs.code_paths.outputs.package == 'true' runs-on: ubuntu-latest # Let the serial Docker gates reach their own deadlines and report cleanup failures. @@ -1059,7 +1098,7 @@ jobs: package_windows: name: package (windows) - needs: [code_paths, static_analysis, typecheck] + needs: [code_paths, preflight] if: needs.code_paths.outputs.package_windows == 'true' runs-on: windows-2022 timeout-minutes: 30 @@ -1267,8 +1306,7 @@ jobs: if: ${{ !cancelled() }} needs: - code_paths - - static_analysis - - typecheck + - preflight - git_compatibility - codex_index_heal_contract - xterm_patch_sync @@ -1297,10 +1335,8 @@ jobs: env: CODE_PATHS: ${{ needs.code_paths.result }} SHOULD_RUN: ${{ needs.code_paths.outputs.should_run }} - STATIC_ANALYSIS: ${{ needs.static_analysis.result }} - STATIC_ANALYSIS_SHOULD_RUN: ${{ needs.code_paths.outputs.static_analysis }} - TYPECHECK: ${{ needs.typecheck.result }} - TYPECHECK_SHOULD_RUN: ${{ needs.code_paths.outputs.typecheck }} + PREFLIGHT: ${{ needs.preflight.result }} + PREFLIGHT_SHOULD_RUN: ${{ needs.code_paths.outputs.static_analysis == 'true' || needs.code_paths.outputs.typecheck == 'true' }} GIT_COMPATIBILITY: ${{ needs.git_compatibility.result }} GIT_COMPATIBILITY_SHOULD_RUN: ${{ needs.code_paths.outputs.git_compatibility }} CODEX_INDEX_HEAL_CONTRACT: ${{ needs.codex_index_heal_contract.result }} @@ -1346,8 +1382,7 @@ jobs: fi } # Require success when the PR has code-relevant changes - check_job static_analysis "$STATIC_ANALYSIS" "$STATIC_ANALYSIS_SHOULD_RUN" - check_job typecheck "$TYPECHECK" "$TYPECHECK_SHOULD_RUN" + check_job preflight "$PREFLIGHT" "$PREFLIGHT_SHOULD_RUN" check_job git_compatibility "$GIT_COMPATIBILITY" "$GIT_COMPATIBILITY_SHOULD_RUN" check_job codex_index_heal_contract "$CODEX_INDEX_HEAL_CONTRACT" "$CODEX_INDEX_HEAL_CONTRACT_SHOULD_RUN" check_job xterm_patch_sync "$XTERM_PATCH_SYNC" "$XTERM_PATCH_SYNC_SHOULD_RUN" diff --git a/config/scripts/check-node-runtime-pin.test.mjs b/config/scripts/check-node-runtime-pin.test.mjs index e1be15f0f7f..a805985d966 100644 --- a/config/scripts/check-node-runtime-pin.test.mjs +++ b/config/scripts/check-node-runtime-pin.test.mjs @@ -189,7 +189,7 @@ describe('committed pin', () => { it('runs in the static analysis job', () => { const workflow = parse(readFileSync(path.join(projectDir, '.github/workflows/pr.yml'), 'utf8')) - const commands = workflow.jobs.static_analysis.steps.map((step) => step.run ?? '') + const commands = workflow.jobs.preflight.steps.map((step) => step.run ?? '') expect(commands).toContain('pnpm run check:node-runtime-pin') }) }) diff --git a/config/scripts/ci-background-step-barriers.test.mjs b/config/scripts/ci-background-step-barriers.test.mjs index 329e6c0bbce..e005c60b989 100644 --- a/config/scripts/ci-background-step-barriers.test.mjs +++ b/config/scripts/ci-background-step-barriers.test.mjs @@ -20,8 +20,7 @@ function assertJoinedBefore(steps, id, consumer) { describe('CI background step barriers', () => { it('joins every background check without suppressing failures', () => { for (const job of [ - pr.jobs.static_analysis, - pr.jobs.typecheck, + pr.jobs.preflight, pr.jobs.mobile_web_app, pr.jobs.package, pr.jobs.shell_contracts, @@ -52,7 +51,7 @@ describe('CI background step barriers', () => { it('joins planning before publishing the unit artifact', () => { assertJoinedBefore( - pr.jobs.typecheck.steps, + pr.jobs.preflight.steps, 'unit-plan', (step) => step.uses === 'actions/upload-artifact@v7' ) @@ -88,7 +87,7 @@ describe('CI background step barriers', () => { }) it('finishes native import-cycle analysis before mobile installation changes resolution', () => { - const steps = pr.jobs.static_analysis.steps + const steps = pr.jobs.preflight.steps assertJoinedBefore(steps, 'native-code-quality', (step) => step.uses?.endsWith('/install-mobile-dependencies') ) diff --git a/config/scripts/ci-cache-warmup-workflow.test.mjs b/config/scripts/ci-cache-warmup-workflow.test.mjs index 469800edfac..01b6c214e5d 100644 --- a/config/scripts/ci-cache-warmup-workflow.test.mjs +++ b/config/scripts/ci-cache-warmup-workflow.test.mjs @@ -28,7 +28,7 @@ it('warms the same Linux Node runtime the PR shards restore', () => { const install = arm.steps.find( (step) => step.uses === './.github/actions/install-node-dependencies' ) - const primer = readWorkflow('pr').jobs.static_analysis + const primer = readWorkflow('pr').jobs.preflight expect(arm['runs-on']).toBe(primer['runs-on']) expect(arm.steps.at(-1).run).toBe('node config/scripts/ensure-native-runtime.mjs --check-only') expect(install.with).toMatchObject(primer.steps.find((step) => step.uses === install.uses).with) @@ -51,7 +51,7 @@ it('populates shared Electron archives on both Linux architectures without chang it('publishes incremental state under a key and prefix that new PRs restore', () => { const cache = steps.find((step) => step.id === 'typecheck-cache') - const prCache = readWorkflow('pr').jobs.typecheck.steps.find((step) => step.name === cache.name) + const prCache = readWorkflow('pr').jobs.preflight.steps.find((step) => step.name === cache.name) expect(cache.with.path).toBe(prCache.with.path) expect(cache.with['restore-keys']).toBe(prCache.with['restore-keys']) expect(cache.with.key).toBe( diff --git a/config/scripts/localization-extraction-change-scope.test.mjs b/config/scripts/localization-extraction-change-scope.test.mjs index 8852965ad30..b1c142b8cd0 100644 --- a/config/scripts/localization-extraction-change-scope.test.mjs +++ b/config/scripts/localization-extraction-change-scope.test.mjs @@ -39,10 +39,10 @@ it('preserves deleted and renamed inputs and falls back to extraction on detecti const workflow = parse( readFileSync(new URL('../../.github/workflows/pr.yml', import.meta.url), 'utf8') ) - const step = workflow.jobs.static_analysis.steps.find( + const step = workflow.jobs.preflight.steps.find( (candidate) => candidate.name === 'Verify localization extraction' ) - expect(step.env).toEqual({ + expect(step.env).toMatchObject({ BASE_SHA: '${{ github.event.pull_request.base.sha }}' }) // The base side comes from the merge ref's first parent, so the gate needs no merge base and diff --git a/config/scripts/orcad-terminal-smoke-change-scope.test.mjs b/config/scripts/orcad-terminal-smoke-change-scope.test.mjs index 5ee88ff0492..e3108d782be 100644 --- a/config/scripts/orcad-terminal-smoke-change-scope.test.mjs +++ b/config/scripts/orcad-terminal-smoke-change-scope.test.mjs @@ -92,7 +92,7 @@ it('only skips the unchanged smoke after a successful diff and dependency analys const workflow = parse( readFileSync(new URL('../../.github/workflows/pr.yml', import.meta.url), 'utf8') ) - const step = workflow.jobs.static_analysis.steps.find( + const step = workflow.jobs.preflight.steps.find( (candidate) => candidate.name === 'Boot orcad and round-trip a terminal' ) expect(step.env).toEqual({ diff --git a/config/scripts/pr-code-change-scope.test.mjs b/config/scripts/pr-code-change-scope.test.mjs index 297d1f8e671..9bcc7974513 100644 --- a/config/scripts/pr-code-change-scope.test.mjs +++ b/config/scripts/pr-code-change-scope.test.mjs @@ -587,14 +587,16 @@ describe('PR Checks skip wiring', () => { expect(prWorkflow.jobs.code_paths.outputs.mobile_dependencies).toBe( '${{ steps.filter.outputs.mobile_dependencies }}' ) - const steps = prWorkflow.jobs.static_analysis.steps + const steps = prWorkflow.jobs.preflight.steps const install = steps.findIndex( (step) => step.uses === './.github/actions/install-mobile-dependencies' ) const gate = steps.findIndex((step) => step.name === 'Enforce changed-code quality') expect(install).toBeGreaterThan(-1) expect(install).toBeLessThan(gate) - expect(steps[install].if).toBe("needs.code_paths.outputs.mobile_dependencies == 'true'") + expect(steps[install].if).toBe( + "needs.code_paths.outputs.static_analysis == 'true' && needs.code_paths.outputs.mobile_dependencies == 'true'" + ) // The install itself moved into the action the packaging jobs share; assert it there so // this job cannot keep the step while the action stops installing anything. const action = parse( @@ -621,21 +623,21 @@ describe('PR Checks skip wiring', () => { }) it('gates each expensive job on its classifier and cache prerequisite', () => { - for (const jobName of expensiveJobs.filter((jobName) => jobName !== 'test')) { + for (const jobName of expensiveJobs.filter( + (jobName) => !['test', 'static_analysis', 'typecheck'].includes(jobName) + )) { expect(prWorkflow.jobs[jobName].needs, jobName).toEqual( ['package', 'package_windows'].includes(jobName) - ? ['code_paths', 'static_analysis', 'typecheck'] + ? ['code_paths', 'preflight'] : ['code_paths'] ) expect(prWorkflow.jobs[jobName].if, jobName).toBe( `needs.code_paths.outputs.${jobName} == 'true'` ) } - expect(prWorkflow.jobs.test.needs).toEqual(['code_paths', 'static_analysis', 'typecheck']) - expect(prWorkflow.jobs.test.if).toContain("needs.static_analysis.result == 'success'") - expect(prWorkflow.jobs.test.if).toContain("needs.typecheck.result == 'success'") + expect(prWorkflow.jobs.test.if).toContain("needs.preflight.result == 'success'") expect(prWorkflow.jobs.test.if).toContain("needs.code_paths.outputs.test == 'true'") - expect(prWorkflow.jobs.test.with.shards).toBe('${{ needs.typecheck.outputs.shards }}') + expect(prWorkflow.jobs.test.with.shards).toBe('${{ needs.preflight.outputs.shards }}') expect(prWorkflow.jobs.unit_plan).toBeUndefined() expect(prWorkflow.jobs.test_native_cache).toBeUndefined() }) @@ -659,7 +661,7 @@ describe('PR Checks skip wiring', () => { expect(verifyStep.run).toContain('expected skipped') expect(verifyStep.run).toContain('expected success') for (const job of prWorkflow.jobs.verify.needs) { - if (job === 'code_paths') { + if (job === 'code_paths' || job === 'preflight') { continue } const envVar = `${job.replaceAll('-', '_').toUpperCase()}_SHOULD_RUN` diff --git a/config/scripts/pr-e2e-gate-contract.test.mjs b/config/scripts/pr-e2e-gate-contract.test.mjs index 4c1268cd7a2..c661327a40a 100644 --- a/config/scripts/pr-e2e-gate-contract.test.mjs +++ b/config/scripts/pr-e2e-gate-contract.test.mjs @@ -45,7 +45,7 @@ const nativeImeSpec = readFileSync( const filterStep = prWorkflow.jobs.code_paths.steps.find( (step) => step.name === 'Filter changed E2E specs' ) -const rollbackStep = prWorkflow.jobs.static_analysis.steps.find( +const rollbackStep = prWorkflow.jobs.preflight.steps.find( (step) => step.name === 'Check VM runtime rollback compatibility' ) const verifyStep = prWorkflow.jobs.verify.steps.find( @@ -133,7 +133,7 @@ describe('PR E2E gate contract', () => { for (const job of prWorkflow.jobs.verify.needs) { const envVar = job.replaceAll('-', '_').toUpperCase() expect(verifyStep.env[envVar]).toBe(`\${{ needs.${job}.result }}`) - if (job === 'code_paths') { + if (job === 'code_paths' || job === 'preflight') { continue } expect(successLoop).toContain(`"$${envVar}"`) diff --git a/config/scripts/pr-preflight-gates.test.mjs b/config/scripts/pr-preflight-gates.test.mjs index 84345ee9360..152610f561b 100644 --- a/config/scripts/pr-preflight-gates.test.mjs +++ b/config/scripts/pr-preflight-gates.test.mjs @@ -1,29 +1,21 @@ import { readFileSync } from 'node:fs' +import { runInNewContext } from 'node:vm' import { expect, it } from 'vitest' import { parse } from 'yaml' import { classifyPrJobs } from './pr-code-change-scope.mjs' const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8')) -const typecheck = workflow.jobs.typecheck -const steps = typecheck.steps +const preflight = workflow.jobs.preflight +const steps = preflight.steps const compiler = steps.find((step) => step.run === 'pnpm run typecheck') const plan = steps.find((step) => step.id === 'unit-plan') -it('shares planning setup while keeping the heavy checks on separate runners', () => { - expect(workflow.jobs.unit_plan).toBeUndefined() - expect(workflow.jobs.test_native_cache).toBeUndefined() - expect(typecheck.needs).toEqual(['code_paths']) - expect(workflow.jobs.static_analysis.needs).toEqual(['code_paths']) - expect( - steps.filter((step) => step.uses === './.github/actions/install-node-dependencies') - ).toHaveLength(1) - expect(steps[0].with['fetch-depth']).toBeGreaterThanOrEqual(2) - expect(compiler.background).toBeUndefined() - expect(plan.background).toBe(true) - expect(plan.run).toBe('node config/scripts/ci-unit-plan.mjs') - expect(plan.env.ORCA_UNIT_SELECTION_MODE).toContain('vars.ORCA_UNIT_SELECTION_MODE') - expect(steps.indexOf(plan)).toBeLessThan(steps.indexOf(compiler)) - const installs = workflow.jobs.static_analysis.steps.filter( +it('shares one setup and runs the unchanged compiler after static checks finish', () => { + expect(workflow.jobs.static_analysis).toBeUndefined() + expect(workflow.jobs.typecheck).toBeUndefined() + expect(preflight.needs).toEqual(['code_paths']) + expect(preflight['runs-on']).toBe('ubuntu-24.04-arm') + const installs = steps.filter( (step) => step.uses === './.github/actions/install-node-dependencies' ) expect(installs).toHaveLength(2) @@ -32,35 +24,71 @@ it('shares planning setup while keeping the heavy checks on separate runners', ( expect(install.with['node-version']).toBe('24') expect(install.with['persist-native-cache']).not.toBe('false') } + expect(steps[0].with['fetch-depth']).toBeGreaterThanOrEqual(2) + expect(compiler.background).toBeUndefined() + expect(plan.background).toBe(true) + expect(plan.run).toContain('node config/scripts/ci-unit-plan.mjs') + expect(plan.env.ORCA_UNIT_SELECTION_MODE).toContain('vars.ORCA_UNIT_SELECTION_MODE') + expect(steps.indexOf(plan)).toBeLessThan(steps.indexOf(compiler)) + expect(steps.indexOf(compiler)).toBeGreaterThan( + steps.findIndex((step) => step.wait?.includes('localization-extraction')) + ) }) -it('requires compiler success and joined planning before publishing shards and admitting tests', () => { +it('requires physical preflight success before publishing shards and admitting consumers', () => { const join = steps.findIndex((step) => step.wait === 'unit-plan') const upload = steps.findIndex((step) => step.uses === 'actions/upload-artifact@v7') expect(join).toBeGreaterThan(steps.indexOf(compiler)) expect(upload).toBeGreaterThan(join) expect(steps[upload]['continue-on-error']).toBeUndefined() expect(steps[upload].with.name).toBe('unit-selection-attempt-${{ github.run_attempt }}') - expect(typecheck.outputs.shards).toBe('${{ steps.unit-plan.outputs.shards }}') - expect(workflow.jobs.test.with.shards).toBe('${{ needs.typecheck.outputs.shards }}') + expect(preflight.outputs.shards).toBe('${{ steps.unit-plan.outputs.shards }}') + expect(workflow.jobs.test.with.shards).toBe('${{ needs.preflight.outputs.shards }}') for (const job of ['test', 'package', 'package_windows']) { - expect(workflow.jobs[job].needs).toEqual(['code_paths', 'static_analysis', 'typecheck']) + expect(workflow.jobs[job].needs).toEqual(['code_paths', 'preflight']) } - expect(workflow.jobs.test.if).toContain("needs.static_analysis.result == 'success'") - expect(workflow.jobs.test.if).toContain("needs.typecheck.result == 'success'") - expect(workflow.jobs.verify.needs).toContain('static_analysis') - expect(workflow.jobs.verify.needs).toContain('typecheck') + for (const result of ['success', 'failure', 'cancelled', 'skipped']) { + const admitted = runInNewContext(workflow.jobs.test.if, { + cancelled: () => false, + needs: { code_paths: { outputs: { test: 'true' } }, preflight: { result } } + }) + expect(admitted, result).toBe(result === 'success') + } + const verify = workflow.jobs.verify.steps.find( + (step) => step.name === 'Require successful checks' + ) + expect(verify.env.PREFLIGHT).toBe('${{ needs.preflight.result }}') + expect(verify.env.PREFLIGHT_SHOULD_RUN).toBe( + "${{ needs.code_paths.outputs.static_analysis == 'true' || needs.code_paths.outputs.typecheck == 'true' }}" + ) + expect(verify.run).toContain('check_job preflight "$PREFLIGHT" "$PREFLIGHT_SHOULD_RUN"') + expect(workflow.jobs.verify.needs).toContain('preflight') + expect(workflow.jobs.verify.needs).not.toContain('typecheck') }) it.each( [['README.md'], ['mobile/src/App.tsx'], ['cloud/package.json'], ['src/main/index.ts'], []].map( (changed) => ({ changed }) ) -)('keeps desktop typechecking and planning off unrelated paths: $changed', ({ changed }) => { +)('preserves phase selection for unrelated paths: $changed', ({ changed }) => { const scope = classifyPrJobs(changed) - expect(typecheck.if).toBe("needs.code_paths.outputs.typecheck == 'true'") + const needs = { + code_paths: { + outputs: Object.fromEntries(Object.entries(scope).map(([key, value]) => [key, String(value)])) + } + } + expect(runInNewContext(preflight.if, { needs })).toBe(scope.static_analysis || scope.typecheck) + expect(runInNewContext(compiler.if, { needs })).toBe(scope.typecheck) expect(scope.test).toBe(scope.typecheck) - if (scope.test) { - expect(scope.static_analysis).toBe(true) +}) + +it('registers successful no-op background work when a phase is unselected or already failed', () => { + for (const step of steps.filter((step) => step.background)) { + expect(step.if).toBe('!cancelled()') + expect(step.env.PREFLIGHT_PHASE_SELECTED).toContain('needs.code_paths.outputs.') + expect(step.env.PREFLIGHT_PRIOR_SUCCESS).toBe("${{ job.status == 'success' }}") + expect(step.run.split('\n')[0]).toBe( + 'if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi' + ) } }) diff --git a/config/scripts/pr-ready-check-gate.test.mjs b/config/scripts/pr-ready-check-gate.test.mjs index 27e5cf99103..ade7fada96a 100644 --- a/config/scripts/pr-ready-check-gate.test.mjs +++ b/config/scripts/pr-ready-check-gate.test.mjs @@ -7,10 +7,14 @@ import { PR_CHECK_JOBS } from './pr-code-change-scope.mjs' const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8')) const gate = workflow.jobs.verify.steps.find((step) => step.name === 'Require successful checks') const variable = (job) => job.replaceAll('-', '_').toUpperCase() +const requiredJobs = [ + 'preflight', + ...PR_CHECK_JOBS.filter((job) => job !== 'static_analysis' && job !== 'typecheck') +] function requiredResults(shouldRun) { return Object.fromEntries( - PR_CHECK_JOBS.flatMap((job) => [ + requiredJobs.flatMap((job) => [ [variable(job), shouldRun ? 'success' : 'skipped'], [`${variable(job)}_SHOULD_RUN`, String(shouldRun)] ]) @@ -42,7 +46,7 @@ describe.skipIf(process.platform === 'win32')( }) it('rejects every missing, failed or cancelled required result when reuse is unavailable', async () => { - for (const job of PR_CHECK_JOBS) { + for (const job of requiredJobs) { for (const result of ['', 'skipped', 'failure', 'cancelled']) { const verdict = await verify({ ...requiredResults(true), [variable(job)]: result }) expect(verdict.code, `${job}: ${result}`).toBe(1) @@ -57,7 +61,7 @@ describe.skipIf(process.platform === 'win32')( }) it('rejects unexpected downstream execution when the proven plan requires skips', async () => { - for (const job of PR_CHECK_JOBS) { + for (const job of requiredJobs) { const verdict = await verify({ ...requiredResults(false), [variable(job)]: 'success' }) expect(verdict.code, job).toBe(1) } diff --git a/config/scripts/pr-ready-check-reuse.test.mjs b/config/scripts/pr-ready-check-reuse.test.mjs index 19fb2407233..11f171a2b25 100644 --- a/config/scripts/pr-ready-check-reuse.test.mjs +++ b/config/scripts/pr-ready-check-reuse.test.mjs @@ -157,7 +157,11 @@ describe('ready-for-review required check reuse', () => { "github.event.pull_request.draft != true && steps.filter.outputs.should_run == 'true'" ) expect(workflow.jobs.verify.if).toBe('${{ !cancelled() }}') - expect(workflow.jobs.verify.needs).toEqual(['code_paths', ...PR_CHECK_JOBS]) + expect(workflow.jobs.verify.needs).toEqual([ + 'code_paths', + 'preflight', + ...PR_CHECK_JOBS.filter((job) => job !== 'static_analysis' && job !== 'typecheck') + ]) }) it.each(['pr-test-loc.yml', 'mobile.yml'])( diff --git a/config/scripts/pr-workflow-parallelism.test.mjs b/config/scripts/pr-workflow-parallelism.test.mjs index 91156e59bf0..cb9545ae7e3 100644 --- a/config/scripts/pr-workflow-parallelism.test.mjs +++ b/config/scripts/pr-workflow-parallelism.test.mjs @@ -54,7 +54,7 @@ const realZshUsage = describe('PR workflow parallelism', () => { it('keeps lightweight orchestration jobs on the free slim runner', () => { expect(workflow.jobs.code_paths['runs-on']).toBe('ubuntu-slim') - expect(workflow.jobs.typecheck['runs-on']).toBe('ubuntu-24.04-arm') + expect(workflow.jobs.preflight['runs-on']).toBe('ubuntu-24.04-arm') expect(workflow.jobs.verify['runs-on']).toBe('ubuntu-slim') expect(prTestLocWorkflow.jobs.loc['runs-on']).toBe('ubuntu-slim') expect(releasePolicyWorkflow.jobs.enforce['runs-on']).toBe('ubuntu-slim') @@ -79,7 +79,7 @@ describe('PR workflow parallelism', () => { const installStep = sharedTest.steps.find( (step) => step.uses === './.github/actions/install-node-dependencies' ) - const staticInstall = workflow.jobs.static_analysis.steps.find( + const staticInstall = workflow.jobs.preflight.steps.find( (step) => step.uses === './.github/actions/install-node-dependencies' ) const nodeNextPrimerInstall = nodeNextWorkflow.jobs.test_native_cache.steps.find( @@ -91,7 +91,7 @@ describe('PR workflow parallelism', () => { expect(nodeNextWorkflow.jobs.test.uses).toBe('./.github/workflows/unit-tests.yml') expect(JSON.parse(nodeNextWorkflow.jobs.test.with.node_versions)).toEqual(['24', '26']) expect(workflow.jobs.test.with.runner).toBe('ubuntu-24.04-arm') - expect(workflow.jobs.static_analysis['runs-on']).toBe('ubuntu-24.04-arm') + expect(workflow.jobs.preflight['runs-on']).toBe('ubuntu-24.04-arm') expect(sharedTest['runs-on']).toBe('${{ inputs.runner }}') expect(unitTestWorkflow.on.workflow_call.inputs.runner.default).toBe('ubuntu-latest') expect(nodeNextWorkflow.jobs.test.with.runner).toBeUndefined() @@ -121,7 +121,7 @@ describe('PR workflow parallelism', () => { } expect(staticInstall.with['native-runtime']).toBe('node') expect(staticInstall.with['node-version']).toBe('24') - expect(workflow.jobs.test.needs).toContain('static_analysis') + expect(workflow.jobs.test.needs).toContain('preflight') expect(workflow.jobs.test_native_cache).toBeUndefined() expect(nodeNextPrimerInstall.with['native-runtime']).toBe('node') expect(nodeNextPrimerInstall.with['node-version']).toBe('${{ matrix.node }}') @@ -348,11 +348,11 @@ describe('PR workflow parallelism', () => { (step) => step.uses === './.github/actions/install-node-dependencies' ) - for (const jobName of ['typecheck', 'git_compatibility']) { + for (const jobName of ['git_compatibility']) { expect(installFor(jobName).with, jobName).toBeUndefined() } expect(installFor('xterm_patch_sync')).toBeUndefined() - expect(installFor('static_analysis').with['native-runtime']).toBe('node') + expect(installFor('preflight').with['native-runtime']).toBe('node') expect(installFor('shell_contracts').with['native-runtime']).toBe('node') expect(sharedTestInstall.with['native-runtime']).toBe('node') expect(installFor('package').with['native-runtime']).toBe('electron') @@ -478,7 +478,7 @@ describe('PR workflow parallelism', () => { }) it('reuses TypeScript incremental state across typecheck runs', () => { - const steps = workflow.jobs.typecheck.steps + const steps = workflow.jobs.preflight.steps const cacheIndex = steps.findIndex((step) => step.name === 'Cache TypeScript incremental state') const checkIndex = steps.findIndex((step) => step.run === 'pnpm run typecheck') @@ -543,8 +543,7 @@ describe('PR workflow parallelism', () => { it('keeps verify as the aggregate required check', () => { expect(workflow.jobs.verify.needs).toEqual([ 'code_paths', - 'static_analysis', - 'typecheck', + 'preflight', 'git_compatibility', 'codex_index_heal_contract', 'xterm_patch_sync', diff --git a/docs/reference/ci-runner-efficiency.md b/docs/reference/ci-runner-efficiency.md index d714947325a..f2dc21aa66c 100644 --- a/docs/reference/ci-runner-efficiency.md +++ b/docs/reference/ci-runner-efficiency.md @@ -1921,6 +1921,36 @@ automatic selection and cold publication, not a new timing result. Local verification passed eight suites / 184 tests, the changed-code quality gate and compiled-composite actionlint. +## October 4 shared PR preflight capacity + +Static analysis and the unchanged compiler now share one ARM runner and guarded +Node 24 install. Static checks finish and all background work joins before the +compiler starts; unit planning still overlaps compilation. Each phase keeps its +classifier output. Successful no-op background bodies register every required +join when a phase is unselected or an earlier step failed. Unit and package +consumers depend on physical job success, including action cleanup. + +Three counterbalanced pairs in +[37180613601](https://github.com/stablyai/orca/actions/runs/37180613601) +used the same frozen checkout `f199a20c3acd`, Node 24.21.0, pnpm 12.8.1, +policy hashes, native cache hits, warm TypeScript cache and 10,787-file unit plan. +Both arms used the PR root-only download-store policy. Total active job time was +152 / 153 / 151 seconds separately and 138 / 133 / 129 combined. Excluding the +extra measurement-only evidence steps gives 151 / 151 / 149 versus +136 / 132 / 128 seconds: median 151 to 132, saving 19 seconds (12.6%). +Two heavy runner admissions become one. This saves capacity; it does not prove a +whole-PR latency or queue gain. The median active dependency barrier increases +from 116 to 132 seconds because compilation follows static checks. + +The separate physical-failure run +[37180755694](https://github.com/stablyai/orca/actions/runs/37180755694) +proved that an included TypeScript error failed the actual compiler, its planner +still joined, and unit/package admissions skipped. A registered late action post +failure also blocked both consumers after successful foreground checks and +published shards. All 12 unselected/prior-failure no-op backgrounds joined, and +the downstream audit passed. Local workflow contracts passed 239 tests across +12 suites; lint and formatting passed. + ## October 3 retired-cache collection observation The same owner-collection assertion failed in unit shard 3 of From d9173ffbdbfa10ed7a24af893d15f44f5baf7c35 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 4 Oct 2026 02:55:59 -0700 Subject: [PATCH 52/67] Keep Orca CLI first after shell startup (#25130) * Restore the owning Orca CLI path after shell profiles * Use a literal marker for the Bash lookup regression * Preserve plain panes and initialize zsh after prompt hook replacement * Preserve user line-editor dispatchers during deferred startup * fix: retain CLI startup when global Zsh replaces prompt hooks * test: replay global Zsh hook replacement after host startup * test: isolate controlled Zsh widgets from distro keyboard setup * fix(shell): preserve user hooks during deferred zsh initialization * Keep completed Zsh startup hooks retired when the wrapper is sourced again --------- Co-authored-by: Codex Co-authored-by: Orca maintenance Co-authored-by: Orca campaign --- .github/workflows/pr.yml | 1 + config/scripts/ci-unit-files.mjs | 1 + .../scripts/pr-workflow-parallelism.test.mjs | 1 + .../daemon-bash-rcfile.txt | 6 + .../daemon-zsh-zshenv.txt | 67 ++++- .../local-bash-rcfile.txt | 6 + .../local-zsh-zshenv.txt | 67 ++++- .../relay-bash-rcfile.txt | 6 + .../relay-zsh-zshenv.txt | 66 +++- src/main/cli/orca-cli-child-path.test.ts | 16 +- src/main/cli/orca-cli-child-path.ts | 8 + ...codex-structured-child-environment.test.ts | 15 +- .../codex-structured-session-adapter.test.ts | 3 + .../daemon/daemon-bash-shell-ready-rcfile.ts | 2 + .../daemon/shell-ready-bash-wrapper.test.ts | 44 ++- .../ipc/pty-spawn-env-terminal-basics.test.ts | 36 +++ .../local-pty-shell-ready-bash-rcfile.ts | 2 + .../providers/ssh-pty-provider-spawn.test.ts | 2 + src/main/providers/ssh-pty-spawn-env.test.ts | 43 +++ src/main/providers/ssh-pty-spawn-env.ts | 5 + ...uctured-session-child-identity-env.test.ts | 2 + .../shell-startup-feature-channel.test.ts | 41 +++ src/main/shell-templates.ts | 7 +- ...erred-startup-line-init.live-shell.test.ts | 281 ++++++++++++++++++ src/main/zsh-deferred-startup-line-init.ts | 56 ++++ .../zsh-scoped-histfile.live-shell.test.ts | 15 +- src/main/zsh-startup-wrapper-builder.ts | 16 +- src/relay/pty-shell-overlay-wrappers.ts | 2 + src/shared/orca-cli-shell-path.ts | 6 + 29 files changed, 793 insertions(+), 30 deletions(-) create mode 100644 src/main/providers/ssh-pty-spawn-env.test.ts create mode 100644 src/main/zsh-deferred-startup-line-init.live-shell.test.ts create mode 100644 src/main/zsh-deferred-startup-line-init.ts create mode 100644 src/shared/orca-cli-shell-path.ts diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index f9f945b3bf3..d5e4a710442 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -720,6 +720,7 @@ jobs: src/main/pty/omp-shell-wrapper.node-pty.test.ts \ src/main/fish-xdg-data-dirs-handoff.test.ts \ src/main/shell-startup-feature-channel.test.ts \ + src/main/zsh-deferred-startup-line-init.live-shell.test.ts \ src/main/terminal-history-fish-session.node-pty.test.ts \ src/main/zsh-scoped-histfile.live-shell.test.ts \ src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts \ diff --git a/config/scripts/ci-unit-files.mjs b/config/scripts/ci-unit-files.mjs index 80252973e22..3e0cdb67cd8 100644 --- a/config/scripts/ci-unit-files.mjs +++ b/config/scripts/ci-unit-files.mjs @@ -23,6 +23,7 @@ export const UNIT_EXCLUDE = [ 'src/main/pty/omp-shell-wrapper-alias-safety.test.ts', 'src/main/pty/omp-shell-wrapper.node-pty.test.ts', 'src/main/shell-startup-feature-channel.test.ts', + 'src/main/zsh-deferred-startup-line-init.live-shell.test.ts', 'src/main/terminal-history-fish-session.node-pty.test.ts', 'src/main/zsh-scoped-histfile.live-shell.test.ts', 'src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts', diff --git a/config/scripts/pr-workflow-parallelism.test.mjs b/config/scripts/pr-workflow-parallelism.test.mjs index cb9545ae7e3..7bc063b1d42 100644 --- a/config/scripts/pr-workflow-parallelism.test.mjs +++ b/config/scripts/pr-workflow-parallelism.test.mjs @@ -27,6 +27,7 @@ const shellContractFiles = [ 'src/main/pty/omp-shell-wrapper-alias-safety.test.ts', 'src/main/pty/omp-shell-wrapper.node-pty.test.ts', 'src/main/shell-startup-feature-channel.test.ts', + 'src/main/zsh-deferred-startup-line-init.live-shell.test.ts', 'src/main/zsh-scoped-histfile.live-shell.test.ts', 'src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts', 'src/main/zsh-wrapper-version-mismatch.live-shell.test.ts', diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt index 2d161a01289..c8de61a9fda 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt @@ -30,6 +30,12 @@ __orca_restore_agent_teams_path() { export PATH="${ORCA_AGENT_TEAMS_SHIM_DIR}:$PATH" } __orca_restore_agent_teams_path +if [ -n "${ORCA_CLI_BIN_DIR:-}" ]; then + case "${PATH:-}" in + "$ORCA_CLI_BIN_DIR"|"$ORCA_CLI_BIN_DIR":*) ;; + *) export PATH="$ORCA_CLI_BIN_DIR${PATH:+:$PATH}" ;; + esac +fi # Why: user startup files may set the default OpenCode config after Orca's # spawn env; restore the Orca-managed config dir before the first prompt. [[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}" diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt index 2187aaca0f0..18850b4a0ef 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt @@ -45,6 +45,40 @@ __orca_osc133_preexec() { # which prints a warning above every command under warn_create_global. builtin typeset -g __orca_in_command=1 } +__orca_deferred_line_init() { + builtin emulate -L zsh + (( ${+functions[__orca_deferred_init]} )) || return 0 + local __orca_direct_line_init=0 + [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]] && __orca_direct_line_init=1 + __orca_deferred_init + if (( __orca_direct_line_init && ${+widgets[zle-line-init]} )); then + zle zle-line-init "$@" + elif [[ "${widgets[zle-line-init]:-}" == user:__orca_prompt_mark ]]; then + local __orca_prev_line_init_fn="" + __orca_prompt_mark "$@" + fi +} +# Why: scheduled callbacks run after user prompt hooks without copying their function metadata. +__orca_deferred_sched_init() { + local __orca_prompt_status=$? + builtin emulate -L zsh + (( ${+functions[__orca_deferred_init]} )) && __orca_deferred_init + builtin unset __orca_deferred_sched_armed + builtin unfunction __orca_deferred_sched_init + return $__orca_prompt_status +} +__orca_arm_deferred_line_init() { + builtin emulate -L zsh + if [[ "${widgets[zle-line-init]:-}" != user:__orca_deferred_line_init ]]; then + if (( ${+widgets[zle-line-init]} )); then + zle -A zle-line-init __orca_saved_line_init + fi + zle -N zle-line-init __orca_deferred_line_init + fi + if (( ! $+__orca_deferred_sched_armed )) && builtin zmodload -F zsh/sched b:sched 2>/dev/null; then + builtin sched +0 __orca_deferred_sched_init && builtin typeset -g __orca_deferred_sched_armed=1 + fi +} __orca_deferred_init() { # Why first: this body runs after the user's own config, so it would otherwise # inherit whatever options that config left set. Under NO_UNSET an unset @@ -54,12 +88,27 @@ __orca_deferred_init() { (( $+_orca_deferred_init_done )) && return 0 builtin typeset -g _orca_deferred_init_done=1 builtin typeset -g precmd_functions + if (( ${+widgets[__orca_saved_line_init]} )); then + if [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then + zle -A __orca_saved_line_init zle-line-init + fi + zle -D __orca_saved_line_init + elif [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then + zle -D zle-line-init + fi if __orca_has_feature markers; then precmd_functions=(${precmd_functions:/__orca_deferred_init/__orca_osc133_precmd}) + (( ${precmd_functions[(Ie)__orca_osc133_precmd]} )) || precmd_functions+=(__orca_osc133_precmd) preexec_functions=(__orca_osc133_preexec ${preexec_functions[@]}) else precmd_functions=(${precmd_functions:#__orca_deferred_init}) fi + if [ -n "${ORCA_CLI_BIN_DIR:-}" ]; then + case "${PATH:-}" in + "$ORCA_CLI_BIN_DIR"|"$ORCA_CLI_BIN_DIR":*) ;; + *) export PATH="$ORCA_CLI_BIN_DIR${PATH:+:$PATH}" ;; + esac + fi if __orca_has_feature overlay; then # Why: ~/.zshrc can export the user's default OpenCode config after spawn. __orca_restore_agent_teams_path() { @@ -202,13 +251,25 @@ __orca_deferred_init() { # the permanent hook has not run yet and the first prompt would lose its mark. __orca_has_feature markers && __orca_osc133_precmd builtin unset _orca_shell_features _orca_histfile - builtin unfunction __orca_deferred_init __orca_has_feature + (( $+__orca_deferred_sched_armed )) || builtin unfunction __orca_deferred_sched_init + local __orca_widget __orca_line_init_bound=0 + for __orca_widget in "${(v)widgets[@]}"; do + if [[ "$__orca_widget" == user:__orca_deferred_line_init ]]; then + __orca_line_init_bound=1 + break + fi + done + (( __orca_line_init_bound )) || builtin unfunction __orca_deferred_line_init + builtin unfunction __orca_deferred_init __orca_has_feature __orca_arm_deferred_line_init } { builtin typeset _orca_user_zshenv="${ZDOTDIR-$HOME}/.zshenv" [[ ! -r "$_orca_user_zshenv" ]] || builtin source -- "$_orca_user_zshenv" } always { builtin unset _orca_user_zshenv - builtin typeset -ag precmd_functions - (( ${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init) + if (( ! $+_orca_deferred_init_done )); then + builtin typeset -ag precmd_functions + (( ${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init) + __orca_arm_deferred_line_init + fi } diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt index fd151e03b12..05f3da594e0 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt @@ -33,6 +33,12 @@ __orca_restore_agent_teams_path() { export PATH="${ORCA_AGENT_TEAMS_SHIM_DIR}:$PATH" } __orca_restore_agent_teams_path +if [ -n "${ORCA_CLI_BIN_DIR:-}" ]; then + case "${PATH:-}" in + "$ORCA_CLI_BIN_DIR"|"$ORCA_CLI_BIN_DIR":*) ;; + *) export PATH="$ORCA_CLI_BIN_DIR${PATH:+:$PATH}" ;; + esac +fi if [ -n "${ORCA_WSL_CLI_DIR:-}" ]; then if [ -x "$ORCA_WSL_CLI_DIR/${ORCA_CLI_COMMAND:-}" ]; then export PATH="$ORCA_WSL_CLI_DIR${PATH:+:$PATH}" diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt index 7172b28d2dc..b3c8a92869c 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt @@ -45,6 +45,40 @@ __orca_osc133_preexec() { # which prints a warning above every command under warn_create_global. builtin typeset -g __orca_in_command=1 } +__orca_deferred_line_init() { + builtin emulate -L zsh + (( ${+functions[__orca_deferred_init]} )) || return 0 + local __orca_direct_line_init=0 + [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]] && __orca_direct_line_init=1 + __orca_deferred_init + if (( __orca_direct_line_init && ${+widgets[zle-line-init]} )); then + zle zle-line-init "$@" + elif [[ "${widgets[zle-line-init]:-}" == user:__orca_prompt_mark ]]; then + local __orca_prev_line_init_fn="" + __orca_prompt_mark "$@" + fi +} +# Why: scheduled callbacks run after user prompt hooks without copying their function metadata. +__orca_deferred_sched_init() { + local __orca_prompt_status=$? + builtin emulate -L zsh + (( ${+functions[__orca_deferred_init]} )) && __orca_deferred_init + builtin unset __orca_deferred_sched_armed + builtin unfunction __orca_deferred_sched_init + return $__orca_prompt_status +} +__orca_arm_deferred_line_init() { + builtin emulate -L zsh + if [[ "${widgets[zle-line-init]:-}" != user:__orca_deferred_line_init ]]; then + if (( ${+widgets[zle-line-init]} )); then + zle -A zle-line-init __orca_saved_line_init + fi + zle -N zle-line-init __orca_deferred_line_init + fi + if (( ! $+__orca_deferred_sched_armed )) && builtin zmodload -F zsh/sched b:sched 2>/dev/null; then + builtin sched +0 __orca_deferred_sched_init && builtin typeset -g __orca_deferred_sched_armed=1 + fi +} __orca_deferred_init() { # Why first: this body runs after the user's own config, so it would otherwise # inherit whatever options that config left set. Under NO_UNSET an unset @@ -54,8 +88,17 @@ __orca_deferred_init() { (( $+_orca_deferred_init_done )) && return 0 builtin typeset -g _orca_deferred_init_done=1 builtin typeset -g precmd_functions + if (( ${+widgets[__orca_saved_line_init]} )); then + if [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then + zle -A __orca_saved_line_init zle-line-init + fi + zle -D __orca_saved_line_init + elif [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then + zle -D zle-line-init + fi if __orca_has_feature markers; then precmd_functions=(${precmd_functions:/__orca_deferred_init/__orca_osc133_precmd}) + (( ${precmd_functions[(Ie)__orca_osc133_precmd]} )) || precmd_functions+=(__orca_osc133_precmd) preexec_functions=(__orca_osc133_preexec ${preexec_functions[@]}) else precmd_functions=(${precmd_functions:#__orca_deferred_init}) @@ -67,6 +110,12 @@ __orca_deferred_init() { printf 'Orca CLI unavailable: cannot run %s. Check WSL Windows-drive mount options.\n' "$ORCA_WSL_CLI_DIR/${ORCA_CLI_COMMAND:-}" >&2 fi fi + if [ -n "${ORCA_CLI_BIN_DIR:-}" ]; then + case "${PATH:-}" in + "$ORCA_CLI_BIN_DIR"|"$ORCA_CLI_BIN_DIR":*) ;; + *) export PATH="$ORCA_CLI_BIN_DIR${PATH:+:$PATH}" ;; + esac + fi if __orca_has_feature overlay; then # Why: ~/.zshrc can export the user's default OpenCode config after spawn. __orca_restore_agent_teams_path() { @@ -219,13 +268,25 @@ __orca_deferred_init() { # the permanent hook has not run yet and the first prompt would lose its mark. __orca_has_feature markers && __orca_osc133_precmd builtin unset _orca_shell_features _orca_histfile - builtin unfunction __orca_deferred_init __orca_has_feature + (( $+__orca_deferred_sched_armed )) || builtin unfunction __orca_deferred_sched_init + local __orca_widget __orca_line_init_bound=0 + for __orca_widget in "${(v)widgets[@]}"; do + if [[ "$__orca_widget" == user:__orca_deferred_line_init ]]; then + __orca_line_init_bound=1 + break + fi + done + (( __orca_line_init_bound )) || builtin unfunction __orca_deferred_line_init + builtin unfunction __orca_deferred_init __orca_has_feature __orca_arm_deferred_line_init } { builtin typeset _orca_user_zshenv="${ZDOTDIR-$HOME}/.zshenv" [[ ! -r "$_orca_user_zshenv" ]] || builtin source -- "$_orca_user_zshenv" } always { builtin unset _orca_user_zshenv - builtin typeset -ag precmd_functions - (( ${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init) + if (( ! $+_orca_deferred_init_done )); then + builtin typeset -ag precmd_functions + (( ${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init) + __orca_arm_deferred_line_init + fi } diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/relay-bash-rcfile.txt b/src/main/__fixtures__/shell-wrapper-snapshots/relay-bash-rcfile.txt index 3042bb11df4..e9cc47d8361 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/relay-bash-rcfile.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/relay-bash-rcfile.txt @@ -26,6 +26,12 @@ fi [[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}" [[ -n "${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="${ORCA_MIMOCODE_HOME}" [[ -n "${ORCA_REMOTE_CLI_BIN_DIR:-}" ]] && case ":$PATH:" in *:"${ORCA_REMOTE_CLI_BIN_DIR}":*) ;; *) export PATH="${ORCA_REMOTE_CLI_BIN_DIR}:$PATH" ;; esac +if [ -n "${ORCA_CLI_BIN_DIR:-}" ]; then + case "${PATH:-}" in + "$ORCA_CLI_BIN_DIR"|"$ORCA_CLI_BIN_DIR":*) ;; + *) export PATH="$ORCA_CLI_BIN_DIR${PATH:+:$PATH}" ;; + esac +fi # Why: OMP does not auto-load Orca's managed status extension; wrap only # interactive launch invocations so subcommands such as `omp config` keep # their normal argv shape. diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt index 102534564b7..21dce8480e6 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt @@ -31,6 +31,40 @@ builtin typeset -g _orca_histfile="${ORCA_HISTFILE:-}" builtin unset ORCA_HISTFILE __orca_has_feature() { (( ${_orca_shell_features[(Ie)$1]} )) } __orca_has_feature identity && printf "\033]777;orca-shell-start:%s\007" "$$" +__orca_deferred_line_init() { + builtin emulate -L zsh + (( ${+functions[__orca_deferred_init]} )) || return 0 + local __orca_direct_line_init=0 + [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]] && __orca_direct_line_init=1 + __orca_deferred_init + if (( __orca_direct_line_init && ${+widgets[zle-line-init]} )); then + zle zle-line-init "$@" + elif [[ "${widgets[zle-line-init]:-}" == user:__orca_prompt_mark ]]; then + local __orca_prev_line_init_fn="" + __orca_prompt_mark "$@" + fi +} +# Why: scheduled callbacks run after user prompt hooks without copying their function metadata. +__orca_deferred_sched_init() { + local __orca_prompt_status=$? + builtin emulate -L zsh + (( ${+functions[__orca_deferred_init]} )) && __orca_deferred_init + builtin unset __orca_deferred_sched_armed + builtin unfunction __orca_deferred_sched_init + return $__orca_prompt_status +} +__orca_arm_deferred_line_init() { + builtin emulate -L zsh + if [[ "${widgets[zle-line-init]:-}" != user:__orca_deferred_line_init ]]; then + if (( ${+widgets[zle-line-init]} )); then + zle -A zle-line-init __orca_saved_line_init + fi + zle -N zle-line-init __orca_deferred_line_init + fi + if (( ! $+__orca_deferred_sched_armed )) && builtin zmodload -F zsh/sched b:sched 2>/dev/null; then + builtin sched +0 __orca_deferred_sched_init && builtin typeset -g __orca_deferred_sched_armed=1 + fi +} __orca_deferred_init() { # Why first: this body runs after the user's own config, so it would otherwise # inherit whatever options that config left set. Under NO_UNSET an unset @@ -40,7 +74,21 @@ __orca_deferred_init() { (( $+_orca_deferred_init_done )) && return 0 builtin typeset -g _orca_deferred_init_done=1 builtin typeset -g precmd_functions + if (( ${+widgets[__orca_saved_line_init]} )); then + if [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then + zle -A __orca_saved_line_init zle-line-init + fi + zle -D __orca_saved_line_init + elif [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then + zle -D zle-line-init + fi precmd_functions=(${precmd_functions:#__orca_deferred_init}) + if [ -n "${ORCA_CLI_BIN_DIR:-}" ]; then + case "${PATH:-}" in + "$ORCA_CLI_BIN_DIR"|"$ORCA_CLI_BIN_DIR":*) ;; + *) export PATH="$ORCA_CLI_BIN_DIR${PATH:+:$PATH}" ;; + esac + fi if __orca_has_feature overlay; then # Why: remote startup files can re-export user defaults after relay spawn. [[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}" @@ -170,13 +218,25 @@ __orca_deferred_init() { zle -N zle-line-init __orca_prompt_mark fi builtin unset _orca_shell_features _orca_histfile - builtin unfunction __orca_deferred_init __orca_has_feature + (( $+__orca_deferred_sched_armed )) || builtin unfunction __orca_deferred_sched_init + local __orca_widget __orca_line_init_bound=0 + for __orca_widget in "${(v)widgets[@]}"; do + if [[ "$__orca_widget" == user:__orca_deferred_line_init ]]; then + __orca_line_init_bound=1 + break + fi + done + (( __orca_line_init_bound )) || builtin unfunction __orca_deferred_line_init + builtin unfunction __orca_deferred_init __orca_has_feature __orca_arm_deferred_line_init } { builtin typeset _orca_user_zshenv="${ZDOTDIR-$HOME}/.zshenv" [[ ! -r "$_orca_user_zshenv" ]] || builtin source -- "$_orca_user_zshenv" } always { builtin unset _orca_user_zshenv - builtin typeset -ag precmd_functions - (( ${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init) + if (( ! $+_orca_deferred_init_done )); then + builtin typeset -ag precmd_functions + (( ${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init) + __orca_arm_deferred_line_init + fi } diff --git a/src/main/cli/orca-cli-child-path.test.ts b/src/main/cli/orca-cli-child-path.test.ts index 727732dd64c..7440b1a9ad8 100644 --- a/src/main/cli/orca-cli-child-path.test.ts +++ b/src/main/cli/orca-cli-child-path.test.ts @@ -27,6 +27,7 @@ describe('prependOrcaCliDirToChildPath', () => { platform: 'linux' }) expect(env.PATH).toBe(`${SHIM_DIR}:/usr/local/bin:/usr/bin`) + expect(env.ORCA_CLI_BIN_DIR).toBe(SHIM_DIR) expect(shim.ensureLinuxTerminalOrcaCliShimDir).toHaveBeenCalledWith({ userDataPath: USER_DATA }) @@ -44,13 +45,14 @@ describe('prependOrcaCliDirToChildPath', () => { it('leaves packaged Linux PATH untouched when no shim could be written', () => { shim.ensureLinuxTerminalOrcaCliShimDir.mockReturnValue(null) - const env: Record = { PATH: '/usr/bin' } + const env: Record = { PATH: '/usr/bin', ORCA_CLI_BIN_DIR: '/old-host/cli' } prependOrcaCliDirToChildPath(env, { isPackaged: true, userDataPath: USER_DATA, platform: 'linux' }) expect(env.PATH).toBe('/usr/bin') + expect(env.ORCA_CLI_BIN_DIR).toBeUndefined() }) it('leads packaged macOS PATH with the bundled CLI dir', () => { @@ -62,11 +64,16 @@ describe('prependOrcaCliDirToChildPath', () => { platform: 'darwin' }) expect(env.PATH).toBe(`${join(RESOURCES, 'bin')}:/usr/bin`) + expect(env.ORCA_CLI_BIN_DIR).toBe(join(RESOURCES, 'bin')) expect(shim.ensureLinuxTerminalOrcaCliShimDir).not.toHaveBeenCalled() }) it('leads packaged Windows PATH with the bundled CLI dir under the env block spelling', () => { - const env: Record = { Path: 'C:\\Windows\\System32' } + const env: Record = { + Path: 'C:\\Windows\\System32', + ORCA_CLI_BIN_DIR: '/parent-host/cli', + ORCA_WSL_CLI_DIR: '/guest/orca/bin' + } prependOrcaCliDirToChildPath(env, { isPackaged: true, userDataPath: USER_DATA, @@ -75,6 +82,8 @@ describe('prependOrcaCliDirToChildPath', () => { }) expect(env.Path).toBe(`${join(RESOURCES, 'bin')};C:\\Windows\\System32`) expect(env.PATH).toBeUndefined() + expect(env.ORCA_CLI_BIN_DIR).toBeUndefined() + expect(env.ORCA_WSL_CLI_DIR).toBe('/guest/orca/bin') }) it('leaves a packaged darwin/win32 PATH alone with no resources root', () => { @@ -101,6 +110,9 @@ describe('prependOrcaCliDirToChildPath', () => { platform }) expect(env.PATH).toBe(`${join(USER_DATA, 'cli', 'bin')}${pathDelimiter}/usr/bin`) + expect(env.ORCA_CLI_BIN_DIR).toBe( + platform === 'win32' ? undefined : join(USER_DATA, 'cli', 'bin') + ) expect(shim.ensureLinuxTerminalOrcaCliShimDir).not.toHaveBeenCalled() }) diff --git a/src/main/cli/orca-cli-child-path.ts b/src/main/cli/orca-cli-child-path.ts index 2a8136c367f..a15943827cc 100644 --- a/src/main/cli/orca-cli-child-path.ts +++ b/src/main/cli/orca-cli-child-path.ts @@ -39,12 +39,16 @@ export function prependOrcaCliDirToChildPath( opts: OrcaCliChildPathOptions ): string | null { const platform = opts.platform ?? process.platform + delete env.ORCA_CLI_BIN_DIR // Why: matches node:path's `delimiter` for the running platform, but stays correct when a test // drives a foreign platform through the seam. const pathDelimiter = platform === 'win32' ? ';' : delimiter // Why: dev mode needs the launcher PATH override so `orca` resolves to the dev build instead of the production binary at /usr/local/bin/orca. if (!opts.isPackaged) { const devCliBin = join(opts.userDataPath, 'cli', 'bin') + if (platform !== 'win32') { + env.ORCA_CLI_BIN_DIR = devCliBin + } const inheritedPath = readInheritedPath(env, platform) // Why: an empty PATH segment resolves as `.` in some shells (commands run from cwd); avoid a trailing delimiter. env[resolvePathEnvKey(env, platform)] = inheritedPath @@ -55,6 +59,7 @@ export function prependOrcaCliDirToChildPath( // Why: bare-`orca` shim scoped to Orca PTYs — Linux CLI installs as `orca-ide` to avoid shadowing GNOME's /usr/bin/orca screen reader (stablyai/orca#7904). const shimDir = ensureLinuxTerminalOrcaCliShimDir({ userDataPath: opts.userDataPath }) if (shimDir) { + env.ORCA_CLI_BIN_DIR = shimDir const inheritedEntries = readInheritedPath(env, platform) .split(pathDelimiter) .filter((entry) => entry.length > 0 && entry !== shimDir) @@ -64,6 +69,9 @@ export function prependOrcaCliDirToChildPath( } else if (opts.resourcesPath && (platform === 'darwin' || platform === 'win32')) { // Why: global CLI registration is optional, but agents in Orca-managed PTYs must always reach this app's bundled CLI. const bundledCliBin = join(opts.resourcesPath, 'bin') + if (platform === 'darwin') { + env.ORCA_CLI_BIN_DIR = bundledCliBin + } const inheritedPath = readInheritedPath(env, platform) env[resolvePathEnvKey(env, platform)] = inheritedPath ? `${bundledCliBin}${pathDelimiter}${inheritedPath}` diff --git a/src/main/codex/codex-structured-child-environment.test.ts b/src/main/codex/codex-structured-child-environment.test.ts index 4dcd49e9645..517f9effb90 100644 --- a/src/main/codex/codex-structured-child-environment.test.ts +++ b/src/main/codex/codex-structured-child-environment.test.ts @@ -10,6 +10,7 @@ import { } from '../runtime/structured-worker-identity' const DEV_CLI_BIN_FIRST = /^[^:;]*[\\/]cli[\\/]bin[:;]/ +const DEV_CLI_BIN_DIR = /^[^:;]*[\\/]cli[\\/]bin$/ // The dev launcher by absolute path: a login shell's profile cannot reorder it behind a global. const DEV_CLI_LAUNCHER = /^[^:;]*[\\/]cli[\\/]bin[\\/]orca-dev$/ @@ -23,7 +24,11 @@ describe('buildCodexStructuredChildEnvironment', () => { cwd: '/worktree', codexHome: '/pinned/home', resumeThreadId: null, - env: { EXAMPLE_GATEWAY_TOKEN: 'shell-exported', CODEX_HOME: '/shell/home' } + env: { + EXAMPLE_GATEWAY_TOKEN: 'shell-exported', + CODEX_HOME: '/shell/home', + ORCA_CLI_BIN_DIR: '/inherited/unowned-cli' + } }, 'spawn-token', 'session-not-a-worker' @@ -35,6 +40,9 @@ describe('buildCodexStructuredChildEnvironment', () => { ORCA_AGENT_SESSION_ID: 'session-not-a-worker', ORCA_STRUCTURED_SESSION: '1', ORCA_CLI_COMMAND: expect.stringMatching(DEV_CLI_LAUNCHER), + ...(process.platform !== 'win32' + ? { ORCA_CLI_BIN_DIR: expect.stringMatching(DEV_CLI_BIN_DIR) } + : {}), ORCA_USER_DATA_PATH: expect.any(String), // The test host is unpackaged, so this app's CLI is the dev launcher dir, first on PATH. PATH: expect.stringMatching(DEV_CLI_BIN_FIRST) @@ -57,6 +65,9 @@ describe('buildCodexStructuredChildEnvironment', () => { ORCA_AGENT_SESSION_ID: sessionId, ORCA_STRUCTURED_SESSION: '1', ORCA_CLI_COMMAND: expect.stringMatching(DEV_CLI_LAUNCHER), + ...(process.platform !== 'win32' + ? { ORCA_CLI_BIN_DIR: expect.stringMatching(DEV_CLI_BIN_DIR) } + : {}), ORCA_USER_DATA_PATH: expect.any(String), PATH: expect.stringMatching(DEV_CLI_BIN_FIRST) }) @@ -97,6 +108,7 @@ const ENV_REPORTING_APP_SERVER = String.raw` result: { sessionId: process.env.ORCA_AGENT_SESSION_ID ?? null, cliCommand: process.env.ORCA_CLI_COMMAND ?? null, + cliBinDir: process.env.ORCA_CLI_BIN_DIR ?? null, path: process.env.PATH ?? process.env.Path ?? null } }) @@ -135,6 +147,7 @@ describe('the spawned Codex child', () => { await expect(connection.request('test/env')).resolves.toEqual({ sessionId, cliCommand: expect.stringMatching(DEV_CLI_LAUNCHER), + cliBinDir: process.platform === 'win32' ? null : expect.stringMatching(DEV_CLI_BIN_DIR), path: expect.stringMatching(DEV_CLI_BIN_FIRST) }) } finally { diff --git a/src/main/codex/codex-structured-session-adapter.test.ts b/src/main/codex/codex-structured-session-adapter.test.ts index 70b2ecd7147..9276d670b4c 100644 --- a/src/main/codex/codex-structured-session-adapter.test.ts +++ b/src/main/codex/codex-structured-session-adapter.test.ts @@ -38,6 +38,9 @@ describe('CodexStructuredSessionAdapter.acquire', () => { ORCA_AGENT_SESSION_ID: 'session-1', ORCA_STRUCTURED_SESSION: '1', ORCA_CLI_COMMAND: expect.stringMatching(/^[^:;]*[\\/]cli[\\/]bin[\\/]orca-dev$/), + ...(process.platform !== 'win32' + ? { ORCA_CLI_BIN_DIR: expect.stringMatching(/^[^:;]*[\\/]cli[\\/]bin$/) } + : {}), ORCA_USER_DATA_PATH: expect.any(String), // The test host is unpackaged, so this app's CLI is the dev launcher dir, first on PATH. PATH: expect.stringMatching(/^[^:;]*[\\/]cli[\\/]bin[:;]/) diff --git a/src/main/daemon/daemon-bash-shell-ready-rcfile.ts b/src/main/daemon/daemon-bash-shell-ready-rcfile.ts index 6ae5c7f4c63..bf2e2545102 100644 --- a/src/main/daemon/daemon-bash-shell-ready-rcfile.ts +++ b/src/main/daemon/daemon-bash-shell-ready-rcfile.ts @@ -1,4 +1,5 @@ import { getPosixOmpShellWrapper } from '../pty/omp-shell-wrapper' +import { ORCA_CLI_POSIX_PATH_RESTORE } from '../../shared/orca-cli-shell-path' import { MANAGED_DATA_ACCOUNT_POSIX_RESTORE } from '../../shared/managed-data-account-shell' import { getPosixCodexShellLaunchPreflight } from '../../shared/codex-shell-function' import { BASH_PROMPT_COMMAND_COMPOSITION_BLOCK } from '../bash-prompt-command-composition' @@ -35,6 +36,7 @@ __orca_restore_agent_teams_path() { export PATH="\${ORCA_AGENT_TEAMS_SHIM_DIR}:$PATH" } __orca_restore_agent_teams_path +${ORCA_CLI_POSIX_PATH_RESTORE} # Why: user startup files may set the default OpenCode config after Orca's # spawn env; restore the Orca-managed config dir before the first prompt. [[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" diff --git a/src/main/daemon/shell-ready-bash-wrapper.test.ts b/src/main/daemon/shell-ready-bash-wrapper.test.ts index 0fbdce87029..059612f5a4b 100644 --- a/src/main/daemon/shell-ready-bash-wrapper.test.ts +++ b/src/main/daemon/shell-ready-bash-wrapper.test.ts @@ -2,8 +2,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { spawnSync } from 'node:child_process' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import type * as DaemonBashRcfileModule from './daemon-bash-shell-ready-rcfile' +import { getBashShellReadyRcfileContent } from '../providers/local-pty-shell-ready-bash-rcfile' +import { getDaemonBashShellReadyRcfileContent } from './daemon-bash-shell-ready-rcfile' +import { prependOrcaCliDirToChildPath } from '../cli/orca-cli-child-path' import { OVERLAY_ONLY_FEATURES, STARTUP_COMMAND_FEATURES @@ -57,6 +60,45 @@ describePosix('daemon shell-ready bash wrapper', () => { vi.restoreAllMocks() }) + itWithBash.each([ + ['daemon', getDaemonBashShellReadyRcfileContent], + ['local', getBashShellReadyRcfileContent] + ] as const)('keeps this app CLI first after %s Bash profiles reset PATH', (_lane, content) => { + const cliBin = join(userDataPath, 'cli', 'bin') + const ambientBin = join(userDataPath, 'ambient-bin') + mkdirSync(cliBin, { recursive: true }) + mkdirSync(ambientBin) + for (const bin of [cliBin, ambientBin]) { + const launcher = join(bin, 'orca-dev') + writeFileSync(launcher, '#!/bin/sh\nexit 0\n') + chmodSync(launcher, 0o755) + } + const env: Record = { + HOME: userDataPath, + USERPROFILE: userDataPath, + PATH: `${ambientBin}:/usr/bin:/bin`, + ORCA_BACKGROUND_LAUNCH: '1' + } + const expectedLauncher = prependOrcaCliDirToChildPath(env, { + isPackaged: false, + userDataPath + }) + writeFileSync( + join(userDataPath, '.bash_profile'), + 'export PATH="$HOME/ambient-bin:/usr/bin:/bin:$HOME/cli/bin"\n' + ) + const rcfile = join(userDataPath, 'cli-path-rcfile') + writeFileSync(rcfile, content()) + const result = spawnSync('bash', ['-c', '. "$1"; command -v orca-dev', 'bash', rcfile], { + env, + encoding: 'utf8', + timeout: 5000 + }) + expect(result.error).toBeUndefined() + expect(result.status).toBe(0) + expect(result.stdout.split('\x1b]133;C\x07').join('').trim()).toBe(expectedLauncher) + }) + // Why: regression guard for issue #2422 — bash wrapper must emit OSC 133 C/D so SSH sessions clear stale 'working' agent rows. it('emits OSC 133 C/D markers in the daemon bash wrapper', async () => { const { getShellReadyLaunchConfig } = await importFreshShellReady() diff --git a/src/main/ipc/pty-spawn-env-terminal-basics.test.ts b/src/main/ipc/pty-spawn-env-terminal-basics.test.ts index 224238bff9a..d530a9cd194 100644 --- a/src/main/ipc/pty-spawn-env-terminal-basics.test.ts +++ b/src/main/ipc/pty-spawn-env-terminal-basics.test.ts @@ -11,6 +11,7 @@ import { wslHookRelayManager } from '../agent-hooks/wsl-hook-relay-manager' import { registerPtyHandlers, buildPtyHostEnv, clearProviderPtyState } from './pty' import { buildJcodeRuntimeDir, shouldInjectJcodeRuntimeDir } from '../../shared/jcode-runtime-dir' import { makePaneKey } from '../../shared/stable-pane-id' +import { selectShellStartupFeatures } from '../shell-startup-features' vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock())) @@ -60,6 +61,41 @@ describe('registerPtyHandlers', () => { const { handlers, mainWindow, spawnAndGetEnv, withBundledCli } = setupPtyIpcSuite() describe('spawn environment', () => { + it.each(['/bin/bash', '/bin/zsh'])( + 'does not wrap a bare %s pane merely to expose this app CLI', + (shellPath) => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) + try { + const env = buildPtyHostEnv( + 'bare-cli-pane', + {}, + { + isPackaged: false, + userDataPath: '/tmp/orca-user-data', + selectedCodexHomePath: null, + agentStatusHooksEnabled: false + } + ) + expect(env.ORCA_CLI_BIN_DIR).toBe('/tmp/orca-user-data/cli/bin') + expect( + selectShellStartupFeatures({ + shellPath, + env, + hasStartupCommand: false, + waitsForShellReady: false, + emitsStartupIdentity: false + }) + ).toEqual([]) + } finally { + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + } + } + ) + it('does not install managed Pi extensions when Pi is disabled', () => { piBuildPtyEnvMock.mockClear() diff --git a/src/main/providers/local-pty-shell-ready-bash-rcfile.ts b/src/main/providers/local-pty-shell-ready-bash-rcfile.ts index d16715ed496..169773edbd5 100644 --- a/src/main/providers/local-pty-shell-ready-bash-rcfile.ts +++ b/src/main/providers/local-pty-shell-ready-bash-rcfile.ts @@ -5,6 +5,7 @@ * startup-file chain, OSC 133 hooks, and the shell-ready marker all live here. */ import { BASH_PROMPT_COMMAND_COMPOSITION_BLOCK } from '../bash-prompt-command-composition' +import { ORCA_CLI_POSIX_PATH_RESTORE } from '../../shared/orca-cli-shell-path' import { MANAGED_DATA_ACCOUNT_POSIX_RESTORE } from '../../shared/managed-data-account-shell' import { WSL_MANAGED_CLI_PATH_RESTORE } from '../wsl-managed-cli-path-restore' import { getPosixOmpShellWrapper } from '../pty/omp-shell-wrapper' @@ -47,6 +48,7 @@ __orca_restore_agent_teams_path() { export PATH="\${ORCA_AGENT_TEAMS_SHIM_DIR}:$PATH" } __orca_restore_agent_teams_path +${ORCA_CLI_POSIX_PATH_RESTORE} ${WSL_MANAGED_CLI_PATH_RESTORE} # Why: user startup files may set the default OpenCode config after Orca's # spawn env; restore the Orca-managed config dir before the first prompt. diff --git a/src/main/providers/ssh-pty-provider-spawn.test.ts b/src/main/providers/ssh-pty-provider-spawn.test.ts index 06eb32d586f..6626a238ce0 100644 --- a/src/main/providers/ssh-pty-provider-spawn.test.ts +++ b/src/main/providers/ssh-pty-provider-spawn.test.ts @@ -460,6 +460,7 @@ describe('spawn', () => { PATH: '/home/user/.orca-relay/bin:/usr/bin', ORCA_TERMINAL_HANDLE: 'term_ssh', [POWERLEVEL10K_WIZARD_DISABLE_ENV]: 'true', + ORCA_CLI_BIN_DIR: '/home/user/.orca-relay/bin', ORCA_REMOTE_CLI_BIN_DIR: '/home/user/.orca-relay/bin', ORCA_RELAY_DIR: '/home/user/.orca-relay/relay-v1', ORCA_RELAY_NODE_PATH: '/usr/bin/node', @@ -490,6 +491,7 @@ describe('spawn', () => { env: { ORCA_TERMINAL_HANDLE: 'term_ssh', [POWERLEVEL10K_WIZARD_DISABLE_ENV]: 'true', + ORCA_CLI_BIN_DIR: '/home/user/.orca-relay/bin', ORCA_REMOTE_CLI_BIN_DIR: '/home/user/.orca-relay/bin', ORCA_RELAY_DIR: '/home/user/.orca-relay/relay-v1', ORCA_RELAY_NODE_PATH: '/usr/bin/node', diff --git a/src/main/providers/ssh-pty-spawn-env.test.ts b/src/main/providers/ssh-pty-spawn-env.test.ts new file mode 100644 index 00000000000..b1fba08b0ca --- /dev/null +++ b/src/main/providers/ssh-pty-spawn-env.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import { buildSshPtySpawnEnv } from './ssh-pty-spawn-env' +import type { RemoteCliBridgeEnv } from './ssh-pty-provider-contract' + +const bridge: RemoteCliBridgeEnv = { + binDir: '/remote/orca/bin', + relayDir: '/remote/orca', + nodePath: '/remote/node', + sockPath: '/remote/orca/socket' +} + +describe('SSH CLI path ownership', () => { + it('replaces the client restore directory with the remote bridge', () => { + const env = { PATH: '/usr/bin', ORCA_CLI_BIN_DIR: '/client/orca/bin' } + const result = buildSshPtySpawnEnv({ env, remoteCliBridgeEnv: bridge }) + expect(result.ORCA_CLI_BIN_DIR).toBe(bridge.binDir) + expect(result.PATH).toBe(`${bridge.binDir}:/usr/bin`) + expect(env.ORCA_CLI_BIN_DIR).toBe('/client/orca/bin') + }) + + it('clears a client path when no remote bridge is available', () => { + const result = buildSshPtySpawnEnv({ env: { ORCA_CLI_BIN_DIR: '/client/orca/bin' } }) + expect(result.ORCA_CLI_BIN_DIR).toBeUndefined() + }) + + it('does not give a POSIX wrapper a Windows bridge directory', () => { + const result = buildSshPtySpawnEnv({ + env: { Path: 'C:\\Windows', ORCA_CLI_BIN_DIR: '/client/orca/bin' }, + remoteCliBridgeEnv: { ...bridge, binDir: 'C:\\Orca\\bin', pathDelimiter: ';' } + }) + expect(result.ORCA_CLI_BIN_DIR).toBeUndefined() + expect(result.Path).toBe('C:\\Orca\\bin;C:\\Windows') + }) + + it('preserves an explicitly deleted restore key', () => { + const result = buildSshPtySpawnEnv({ + env: { PATH: '/usr/bin' }, + remoteCliBridgeEnv: bridge, + envToDelete: ['ORCA_CLI_BIN_DIR'] + }) + expect(result.ORCA_CLI_BIN_DIR).toBeUndefined() + }) +}) diff --git a/src/main/providers/ssh-pty-spawn-env.ts b/src/main/providers/ssh-pty-spawn-env.ts index 595e750c90d..45e2abca8bd 100644 --- a/src/main/providers/ssh-pty-spawn-env.ts +++ b/src/main/providers/ssh-pty-spawn-env.ts @@ -7,8 +7,13 @@ export function buildSshPtySpawnEnv(args: { remoteCliBridgeEnv?: RemoteCliBridgeEnv }): Record { const merged = { ...args.env } + // The client CLI path cannot be restored on the execution host. + delete merged.ORCA_CLI_BIN_DIR if (args.remoteCliBridgeEnv) { const pathDelimiter = args.remoteCliBridgeEnv.pathDelimiter ?? ':' + if (pathDelimiter === ':') { + merged.ORCA_CLI_BIN_DIR = args.remoteCliBridgeEnv.binDir + } const pathKey = merged.PATH !== undefined ? 'PATH' : merged.Path !== undefined ? 'Path' : null if (pathKey) { const pathValue = merged[pathKey] ?? '' diff --git a/src/main/runtime/structured-session-child-identity-env.test.ts b/src/main/runtime/structured-session-child-identity-env.test.ts index 1289e979ad6..095aad2a8a5 100644 --- a/src/main/runtime/structured-session-child-identity-env.test.ts +++ b/src/main/runtime/structured-session-child-identity-env.test.ts @@ -70,6 +70,7 @@ describe('structuredSessionChildIdentityEnv', () => { // For a CLI that predates the id, which refuses on it instead of guessing a sibling. ORCA_STRUCTURED_SESSION: '1', ORCA_CLI_COMMAND: join(SHIM_DIR, 'orca'), + ORCA_CLI_BIN_DIR: SHIM_DIR, // The instance that minted the id, so any current CLI dials it rather than the default. ORCA_USER_DATA_PATH: USER_DATA }) @@ -131,6 +132,7 @@ describe('structuredSessionChildIdentityEnv', () => { expect(env.PATH).toBeUndefined() // The native launcher: `orca.cmd` refuses message bodies cmd.exe would mangle. expect(env.ORCA_CLI_COMMAND).toBe(join(RESOURCES, 'bin', 'orca.exe')) + expect(env.ORCA_CLI_BIN_DIR).toBeUndefined() }) it('unpackaged, through the dev launcher dir', () => { diff --git a/src/main/shell-startup-feature-channel.test.ts b/src/main/shell-startup-feature-channel.test.ts index e21269bfa91..0001a98641e 100644 --- a/src/main/shell-startup-feature-channel.test.ts +++ b/src/main/shell-startup-feature-channel.test.ts @@ -17,6 +17,7 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { POSIX_SHELL_STARTUP_COMMAND_ENV } from './pty/posix-shell-startup-command' import { selectShellStartupFeatures } from './shell-startup-features' +import { prependOrcaCliDirToChildPath } from './cli/orca-cli-child-path' import { runZshPty } from './zsh-startup-hook-pty-harness' import { ZSH_WRAPPER_DIR_MARKER_FILE } from './shell-templates' import { @@ -119,6 +120,46 @@ describePosix('zsh launch config', () => { rmSync(userDataPath, { recursive: true, force: true }) }) + itWithZsh.each([false, true])( + 'restores this app CLI after zsh startup resets PATH and replaces prompt hooks %s', + async (replacePromptHooks) => { + const cliBin = join(userDataPath, 'cli', 'bin') + const ambientBin = join(userDataPath, 'ambient-bin') + mkdirSync(cliBin, { recursive: true }) + mkdirSync(ambientBin) + for (const bin of [cliBin, ambientBin]) { + const launcher = join(bin, 'orca-dev') + writeFileSync(launcher, '#!/bin/sh\nexit 0\n') + chmodSync(launcher, 0o755) + } + writeFileSync( + join(userDataPath, '.zshrc'), + `export PATH="$HOME/ambient-bin:/usr/bin:/bin:$HOME/cli/bin"\n${replacePromptHooks ? 'precmd_functions=()\n' : ''}` + ) + const env: Record = { + ...process.env, + HOME: userDataPath, + USERPROFILE: userDataPath, + PATH: `${ambientBin}:/usr/bin:/bin` + } + const launcher = prependOrcaCliDirToChildPath(env, { isPackaged: false, userDataPath }) + const features = selectShellStartupFeatures({ + shellPath: ZSH_PATH, + env, + ...PLAIN_PANE, + hasStartupCommand: true + }) + const { getShellLaunchConfig } = await importFreshLocalPtyShellReady() + const config = getShellLaunchConfig(ZSH_PATH, features) + const result = await runZshPty({ + env: { ...env, ...config.env }, + commands: ['ORCA_LOOKUP=$(command -v orca-dev)'], + report: ['ORCA_LOOKUP'] + }) + expect(result.values.ORCA_LOOKUP).toBe(launcher) + } + ) + it('publishes exactly the selected features, whatever process.env holds', async () => { process.env.ORCA_SHELL_FEATURES = 'overlay,markers,ready,identity' const { getShellLaunchConfig } = await importFreshLocalPtyShellReady() diff --git a/src/main/shell-templates.ts b/src/main/shell-templates.ts index cb7c2fca2e9..7b5f922e89f 100644 --- a/src/main/shell-templates.ts +++ b/src/main/shell-templates.ts @@ -102,8 +102,11 @@ export const ZSH_USER_ZSHENV_SOURCE_BLOCK = `{ [[ ! -r "$_orca_user_zshenv" ]] || builtin source -- "$_orca_user_zshenv" } always { builtin unset _orca_user_zshenv - builtin typeset -ag precmd_functions - (( \${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init) + if (( ! $+_orca_deferred_init_done )); then + builtin typeset -ag precmd_functions + (( \${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init) + __orca_arm_deferred_line_init + fi }` // Why: daemon, local, and relay wrappers must preserve one Bash prompt-hook contract. diff --git a/src/main/zsh-deferred-startup-line-init.live-shell.test.ts b/src/main/zsh-deferred-startup-line-init.live-shell.test.ts new file mode 100644 index 00000000000..48246e10573 --- /dev/null +++ b/src/main/zsh-deferred-startup-line-init.live-shell.test.ts @@ -0,0 +1,281 @@ +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { prependOrcaCliDirToChildPath } from './cli/orca-cli-child-path' +import { POSIX_SHELL_STARTUP_COMMAND_ENV } from './pty/posix-shell-startup-command' +import { getZshShellReadyWrapperFile } from './providers/local-pty-shell-ready-wrapper-generation' +import { encodeShellStartupFeatures, selectShellStartupFeatures } from './shell-startup-features' +import { ZSH_WRAPPER_DIR_MARKER_FILE } from './shell-templates' +import { hasZsh, MARKERS, runZshPty, ZSH_PATH } from './zsh-startup-hook-pty-harness' + +const itWithZsh = hasZsh ? it : it.skip +const USER_WIDGET = `orca_test_line_init() { + O_UC=$((\${O_UC:-0}+1)) + O_UN="$WIDGET" + builtin printf 'ORCA_TEST_USER_WIDGET_CALL\\n' + return 1 +} +zle -N zle-line-init orca_test_line_init +` +const USER_REDRAW = `orca_test_redraw() { + O_RC=$((\${O_RC:-0}+1)) + O_RN="$WIDGET" + return 1 +} +zle -N zle-line-pre-redraw orca_test_redraw +` +const USER_PRECMD = `precmd() { + O_PCALLS=$((\${O_PCALLS:-0}+1)) + O_PN="$0" + O_PO="\${O_PO:-\${options[ksharrays]}:\${options[nounset]}}" + return 1 +} +` + +describe('zsh deferred startup after prompt-hook replacement', () => { + const roots: string[] = [] + + afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } + }) + + itWithZsh.each([ + 'history', + 'startup', + 'chained-startup', + 'stock-history', + 'stock-startup', + 'stock-chained-redraw', + 'stock-nonzero-precmd', + 'ordered-startup', + 'replaced-precmd-startup', + 'status-startup', + 'sticky-startup', + 'scheduled-startup', + 'unavailable-startup', + 'repeat-history', + 'repeat-startup' + ] as const)( + 'restores CLI precedence and preserves the user widget in a %s pane', + async (intent) => { + const historyOnly = intent.endsWith('history') + const repeatSource = intent.startsWith('repeat-') + const stockBinding = intent.startsWith('stock-') + const chainedLineInit = intent === 'chained-startup' + const chainedRedraw = intent === 'stock-chained-redraw' + const nonzeroPrecmd = intent === 'stock-nonzero-precmd' + const orderedPrecmd = intent === 'ordered-startup' + const replacedPrecmd = intent === 'replaced-precmd-startup' + const statusPrecmd = intent === 'status-startup' + const stickyPrecmd = intent === 'sticky-startup' + const scheduledPrecmd = intent === 'scheduled-startup' + const unavailableSched = intent === 'unavailable-startup' + const scheduleCleanup = scheduledPrecmd || unavailableSched || orderedPrecmd + const home = mkdtempSync(join(tmpdir(), 'orca-deferred-line-init-')) + roots.push(home) + const cliBin = join(home, 'cli', 'bin') + const ambientBin = join(home, 'ambient-bin') + const wrapperDir = join(home, 'wrapper') + for (const bin of [cliBin, ambientBin, wrapperDir]) { + mkdirSync(bin, { recursive: true }) + } + for (const bin of [cliBin, ambientBin]) { + writeFileSync(join(bin, 'orca-dev'), '#!/bin/sh\nexit 0\n') + chmodSync(join(bin, 'orca-dev'), 0o755) + } + writeFileSync( + join(home, '.zshenv'), + (chainedLineInit || chainedRedraw || repeatSource + ? '' + : stockBinding + ? USER_REDRAW + : USER_WIDGET) + + (statusPrecmd + ? 'precmd() { O_FIRST_IN=${O_FIRST_IN:-$?}; }\n' + : stickyPrecmd + ? `emulate sh -c 'precmd() { O_FIRST_IN="\${O_FIRST_IN:-$?:\${options[shwordsplit]}:\${options[ksharrays]}}"; return 1; }'\n` + : scheduledPrecmd + ? 'zmodload zsh/sched\nO_EVENT() { O_EO=${_orca_deferred_init_done:-0}; }\nsched +0 O_EVENT\nsched +3600 O_EVENT\n' + : unavailableSched + ? 'zmodload zsh/zleparameter zsh/terminfo\nO_MP=("${module_path[@]}"); module_path=()\n' + : nonzeroPrecmd || replacedPrecmd + ? USER_PRECMD + : orderedPrecmd + ? USER_PRECMD.replace('return 1', 'return 0') + : '') + ) + if (statusPrecmd || stickyPrecmd) { + writeFileSync( + join(home, '.zlogin'), + 'orca_test_status() { return 42; }; orca_test_status\n' + ) + } + // Replay Ubuntu's later widget binding before the user's own startup changes. + const stockWidget = + stockBinding || stickyPrecmd || scheduledPrecmd + ? USER_WIDGET.replaceAll('orca_test_line_init', 'zle-line-init') + : '' + writeFileSync( + join(home, '.zshrc'), + `${statusPrecmd || stickyPrecmd ? 'PS1="ORCA_FIRST_PROMPT:%? "\n' : ''}${repeatSource ? USER_WIDGET : ''}${unavailableSched ? 'module_path=("${O_MP[@]}")\n' : ''}${stockWidget}export PATH="$HOME/ambient-bin:/usr/bin:/bin:$HOME/cli/bin"\n${orderedPrecmd ? '' : 'precmd_functions=()\n'}${ + chainedLineInit + ? `${USER_WIDGET.replace('zle -N zle-line-init orca_test_line_init', 'zle -N orca_test_line_init')}autoload -Uz add-zle-hook-widget\nadd-zle-hook-widget line-init orca_test_line_init\n` + : chainedRedraw + ? `${USER_REDRAW.replace('zle -N zle-line-pre-redraw orca_test_redraw', 'zle -N orca_test_redraw')}autoload -Uz add-zle-hook-widget\nadd-zle-hook-widget line-pre-redraw orca_test_redraw\n` + : '' + }${nonzeroPrecmd ? 'orca_test_array() { O_AC=called; }\nprecmd_functions=(orca_test_array)\nsetopt KSH_ARRAYS NO_UNSET\n' : orderedPrecmd ? 'orca_test_array() { O_AO=${O_AO:-${_orca_deferred_init_done:-0}}; }\nprecmd_functions=(orca_test_array "${precmd_functions[@]}")\n' : replacedPrecmd ? 'precmd() { O_NPC=$((${O_NPC:-0}+1)); O_NPN="$0"; }\n' : ''}` + ) + writeFileSync(join(wrapperDir, '.zshenv'), getZshShellReadyWrapperFile()) + writeFileSync(join(wrapperDir, ZSH_WRAPPER_DIR_MARKER_FILE), '') + const env: Record = { + ...process.env, + HOME: home, + USERPROFILE: home, + PATH: `${ambientBin}:/usr/bin:/bin`, + // Stock cases replay this replacement after the fixture installs its own widgets. + DEBIAN_PREVENT_KEYBOARD_CHANGES: '1', + ZDOTDIR: wrapperDir, + ORCA_HISTFILE: join(home, 'scoped-history') + } + const launcher = prependOrcaCliDirToChildPath(env, { isPackaged: false, userDataPath: home }) + const features = selectShellStartupFeatures({ + shellPath: ZSH_PATH, + env, + hasStartupCommand: !historyOnly, + waitsForShellReady: !historyOnly, + emitsStartupIdentity: false + }) + env.ORCA_SHELL_FEATURES = encodeShellStartupFeatures(features) + if (!historyOnly) { + env[POSIX_SHELL_STARTUP_COMMAND_ENV] = 'O_SU=$((${O_SU:-0}+1))' + } + + if (statusPrecmd || stickyPrecmd) { + const baseline = await runZshPty({ env: { ...env, ZDOTDIR: home }, report: ['O_FIRST_IN'] }) + expect(baseline.values.O_FIRST_IN).toBe(stickyPrecmd ? '42:on:on' : '42') + } + + const result = await runZshPty({ + env, + commands: [ + ...(repeatSource + ? ['source -- "$HOME/wrapper/.zshenv"', 'source -- "$HOME/wrapper/.zshenv"'] + : []), + 'O_LK=$(command -v orca-dev)', + 'O_IR=${+functions[__orca_deferred_line_init]}', + 'O_SR=${+widgets[__orca_saved_line_init]}', + ...(scheduleCleanup + ? [ + 'O_SC=${+functions[__orca_deferred_sched_init]}', + 'O_SE=${zsh_scheduled_events[*]:-UNSET}' + ] + : []), + ...(scheduledPrecmd ? ['O_EV=${#zsh_scheduled_events}'] : []), + ...(stockBinding ? ['O_RW=${widgets[zle-line-pre-redraw]:-none}'] : []), + 'O_LI=${widgets[zle-line-init]:-none}', + 'O_PC="${precmd_functions[*]}"', + ...(nonzeroPrecmd ? ['precmd; O_PS=$?'] : []) + ], + report: [ + 'O_LK', + 'O_UC', + 'O_UN', + 'O_IR', + 'O_SR', + ...(scheduleCleanup ? ['O_SC', 'O_SE'] : []), + ...(scheduledPrecmd ? ['O_EV', 'O_EO'] : []), + ...(stockBinding ? ['O_RW', 'O_RC', 'O_RN'] : []), + ...(nonzeroPrecmd ? ['O_PCALLS', 'O_PN', 'O_PO', 'O_AC', 'O_PS'] : []), + ...(orderedPrecmd ? ['O_PCALLS', 'O_PN', 'O_AO'] : []), + ...(replacedPrecmd ? ['O_PCALLS', 'O_NPC', 'O_NPN'] : []), + ...(statusPrecmd || stickyPrecmd ? ['O_FIRST_IN'] : []), + 'O_LI', + 'O_PC', + 'O_SU', + 'HISTFILE' + ] + }) + + expect(result.values.O_LK).toBe(launcher) + expect(Number(result.values.O_UC)).toBeGreaterThan(0) + if (!chainedLineInit) { + expect(result.values.O_UN).toBe('zle-line-init') + } + if (!chainedLineInit && !chainedRedraw && !repeatSource) { + expect(result.values.O_IR).toBe('0') + } + expect(result.values.O_SR).toBe('0') + if (repeatSource) { + expect(result.output).not.toContain('job table full or recursion limit exceeded') + expect(result.values.O_PC).not.toContain('__orca_deferred_init') + } + if (scheduleCleanup) { + expect(result.values.O_SC).toBe('0') + expect(result.values.O_SE).not.toContain('orca') + } + if (unavailableSched) { + // Stock completion modules can fail before the fixture restores module_path. + expect(result.output).not.toContain('zsh/sched') + expect(result.output).not.toContain('__orca_arm_deferred_line_init:') + expect(result.values.O_SE).toBe('UNSET') + } + if (scheduledPrecmd) { + expect(result.values.O_EV).toBe('1') + expect(result.values.O_EO).toBe('0') + expect(result.values.O_SE).toContain('O_EVENT') + } + if (stockBinding) { + expect(Number(result.values.O_RC)).toBeGreaterThan(0) + expect(result.values.O_RN).toBe(chainedRedraw ? 'orca_test_redraw' : 'zle-line-pre-redraw') + if (!chainedRedraw) { + expect(result.values.O_RW).toBe('user:orca_test_redraw') + } + } + expect(result.values.HISTFILE).toBe(join(home, 'scoped-history')) + if (nonzeroPrecmd) { + expect(Number(result.values.O_PCALLS)).toBeGreaterThan(0) + expect(result.values.O_PN).toBe('precmd') + expect(result.values.O_PO).toBe('on:on') + expect(result.values.O_AC).toBe('called') + expect(result.values.O_PS).toBe('1') + } + if (orderedPrecmd) { + expect(Number(result.values.O_PCALLS)).toBeGreaterThan(0) + expect(result.values.O_PN).toBe('precmd') + expect(result.values.O_AO).toBe('0') + } + if (replacedPrecmd) { + expect(result.values.O_PCALLS).toBe('UNSET') + expect(Number(result.values.O_NPC)).toBeGreaterThan(0) + expect(result.values.O_NPN).toBe('precmd') + } + if (statusPrecmd || stickyPrecmd) { + expect(result.values.O_FIRST_IN).toBe(stickyPrecmd ? '42:on:on' : '42') + expect(result.output).toContain('ORCA_FIRST_PROMPT:42 ') + } + if (historyOnly) { + expect(result.output).not.toContain('\x1b]133;') + expect(result.values.O_LI).toBe( + stockBinding ? 'user:zle-line-init' : 'user:orca_test_line_init' + ) + expect(result.values.O_PC).not.toContain('orca') + expect(result.values.O_SU).toBe('UNSET') + } else { + expect(result.output).toContain(MARKERS.ready) + expect(result.values.O_LI).toBe('user:__orca_prompt_mark') + expect(result.values.O_PC).toBe( + nonzeroPrecmd || orderedPrecmd + ? 'orca_test_array __orca_osc133_precmd' + : '__orca_osc133_precmd' + ) + expect(result.values.O_SU).toBe('1') + expect(result.output.split('ORCA_TEST_USER_WIDGET_CALL\r\n').length - 1).toBe( + result.output.split(MARKERS.ready).length - 1 + ) + } + } + ) +}) diff --git a/src/main/zsh-deferred-startup-line-init.ts b/src/main/zsh-deferred-startup-line-init.ts new file mode 100644 index 00000000000..95b9da94df6 --- /dev/null +++ b/src/main/zsh-deferred-startup-line-init.ts @@ -0,0 +1,56 @@ +// Why: stock zshrc can replace line-init after the user clears the prompt-hook array. +export const ZSH_DEFERRED_LINE_INIT_BLOCK = `__orca_deferred_line_init() { + builtin emulate -L zsh + (( \${+functions[__orca_deferred_init]} )) || return 0 + local __orca_direct_line_init=0 + [[ "\${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]] && __orca_direct_line_init=1 + __orca_deferred_init + if (( __orca_direct_line_init && \${+widgets[zle-line-init]} )); then + zle zle-line-init "$@" + elif [[ "\${widgets[zle-line-init]:-}" == user:__orca_prompt_mark ]]; then + local __orca_prev_line_init_fn="" + __orca_prompt_mark "$@" + fi +} +# Why: scheduled callbacks run after user prompt hooks without copying their function metadata. +__orca_deferred_sched_init() { + local __orca_prompt_status=$? + builtin emulate -L zsh + (( \${+functions[__orca_deferred_init]} )) && __orca_deferred_init + builtin unset __orca_deferred_sched_armed + builtin unfunction __orca_deferred_sched_init + return $__orca_prompt_status +} +__orca_arm_deferred_line_init() { + builtin emulate -L zsh + if [[ "\${widgets[zle-line-init]:-}" != user:__orca_deferred_line_init ]]; then + if (( \${+widgets[zle-line-init]} )); then + zle -A zle-line-init __orca_saved_line_init + fi + zle -N zle-line-init __orca_deferred_line_init + fi + if (( ! $+__orca_deferred_sched_armed )) && builtin zmodload -F zsh/sched b:sched 2>/dev/null; then + builtin sched +0 __orca_deferred_sched_init && builtin typeset -g __orca_deferred_sched_armed=1 + fi +}` + +// Why: restore the exact prior widget before the existing readiness hook captures it. +export const ZSH_DEFERRED_LINE_INIT_RETIRE_BLOCK = ` if (( \${+widgets[__orca_saved_line_init]} )); then + if [[ "\${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then + zle -A __orca_saved_line_init zle-line-init + fi + zle -D __orca_saved_line_init + elif [[ "\${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then + zle -D zle-line-init + fi` + +// Why: add-zle-hook-widget can keep an alias of the bootstrap in its own chain. +export const ZSH_DEFERRED_LINE_INIT_CLEANUP_BLOCK = ` (( $+__orca_deferred_sched_armed )) || builtin unfunction __orca_deferred_sched_init + local __orca_widget __orca_line_init_bound=0 + for __orca_widget in "\${(v)widgets[@]}"; do + if [[ "$__orca_widget" == user:__orca_deferred_line_init ]]; then + __orca_line_init_bound=1 + break + fi + done + (( __orca_line_init_bound )) || builtin unfunction __orca_deferred_line_init` diff --git a/src/main/zsh-scoped-histfile.live-shell.test.ts b/src/main/zsh-scoped-histfile.live-shell.test.ts index 68ee27d24e8..5c5020c96bc 100644 --- a/src/main/zsh-scoped-histfile.live-shell.test.ts +++ b/src/main/zsh-scoped-histfile.live-shell.test.ts @@ -333,25 +333,14 @@ describe.skipIf(process.platform === 'win32')( }) itWithZsh( - 'degrades to an unwrapped pane, leaking nothing, when a config drops precmd_functions', + 'still scopes history without leaking it when a config drops precmd_functions', withHome({ ...USER_FILES, '.zshrc': 'precmd_functions=()\n' }, async (home) => { const scoped = join(home, 'orca-history', 'zsh_history') const { env, launch } = launchPane(home, scoped) const report = ['HISTFILE', 'ORCA_HISTFILE', 'ZDOTDIR'] const { values } = await runZshPty({ env, report }) - // Why compared against an unwrapped run rather than asserted to differ - // from the scoped path: whether the scoped value survives at all is the - // host's call, not Orca's. macOS /etc/zshrc overwrites HISTFILE, so it - // does not; a host with no such assignment keeps whatever the spawn env - // set. The contract on both is the same — this pane is the pane the user - // would have had unwrapped. - const unwrapped = await runZshPty({ - env: { PATH: '/usr/bin:/bin', HOME: home, HISTFILE: scoped }, - report - }) - - expect(values.HISTFILE).toBe(unwrapped.values.HISTFILE) + expect(values.HISTFILE).toBe(scoped) expect(values.HISTFILE).not.toContain(launch.env.ZDOTDIR) // ORCA_HISTFILE was consumed in .zshenv precisely so a dropped hook // leaks nothing to the pane's children. diff --git a/src/main/zsh-startup-wrapper-builder.ts b/src/main/zsh-startup-wrapper-builder.ts index d6d6ef39a30..6ec28b647c2 100644 --- a/src/main/zsh-startup-wrapper-builder.ts +++ b/src/main/zsh-startup-wrapper-builder.ts @@ -1,3 +1,4 @@ +import { ORCA_CLI_POSIX_PATH_RESTORE } from '../shared/orca-cli-shell-path' import { MANAGED_DATA_ACCOUNT_POSIX_RESTORE } from '../shared/managed-data-account-shell' /** * The single `.zshenv` Orca writes for every transport: local PTY, daemon/SSH, @@ -14,7 +15,8 @@ import { MANAGED_DATA_ACCOUNT_POSIX_RESTORE } from '../shared/managed-data-accou * dir shared by two installed builds could mix files from both. * * This shape gives ZDOTDIR back before anything else can observe it, then defers - * Orca's work to a `precmd` hook that runs at the first prompt — after + * Orca's work to a `precmd` hook, with a one-shot line-editor fallback if the + * user's config replaces its hook array. Both run at the first prompt — after * `.zprofile`, `/etc/zshrc`, `.zshrc` and `.zlogin`, all of which zsh now reads * from the user's own directory exactly as in an unwrapped shell. #11044 becomes * unreachable rather than repaired, and the emulation and mixed-build classes @@ -30,6 +32,11 @@ import { MANAGED_DATA_ACCOUNT_POSIX_RESTORE } from '../shared/managed-data-accou import { getPosixOmpShellWrapper } from './pty/omp-shell-wrapper' import { WSL_MANAGED_CLI_PATH_RESTORE } from './wsl-managed-cli-path-restore' import { getPosixCodexShellLaunchPreflight } from '../shared/codex-shell-function' +import { + ZSH_DEFERRED_LINE_INIT_BLOCK, + ZSH_DEFERRED_LINE_INIT_CLEANUP_BLOCK, + ZSH_DEFERRED_LINE_INIT_RETIRE_BLOCK +} from './zsh-deferred-startup-line-init' import { getZshShellReadyMarkerRegistrationBlock, SHELL_STARTUP_IDENTITY_MARKER_BLOCK, @@ -146,6 +153,7 @@ function buildDeferredInit(spec: ZshStartupHookSpec): string { const permanentPrecmd = spec.osc133CommandMarkers ? ` if __orca_has_feature markers; then precmd_functions=(\${precmd_functions:/__orca_deferred_init/__orca_osc133_precmd}) + (( \${precmd_functions[(Ie)__orca_osc133_precmd]} )) || precmd_functions+=(__orca_osc133_precmd) preexec_functions=(__orca_osc133_preexec \${preexec_functions[@]}) else precmd_functions=(\${precmd_functions:#__orca_deferred_init}) @@ -170,9 +178,11 @@ ${indentBlock(getZshShellReadyMarkerRegistrationBlock(spec.readyMarkerEscaped, t (( $+_orca_deferred_init_done )) && return 0 builtin typeset -g _orca_deferred_init_done=1 builtin typeset -g precmd_functions +${ZSH_DEFERRED_LINE_INIT_RETIRE_BLOCK} ${permanentPrecmd} ${joinBlocks([ spec.restores.managedWslCli ? indentBlock(WSL_MANAGED_CLI_PATH_RESTORE, ' ') : null, + indentBlock(ORCA_CLI_POSIX_PATH_RESTORE, ' ').replace(/\n$/, ''), featureGuard('overlay', getOverlayRestoreBlocks(spec)), // Why outside the overlay guard: a system-default Codex home carries no overlay key. indentBlock(getPosixCodexShellLaunchPreflight(), ' ').replace(/\n$/, ''), @@ -190,7 +200,8 @@ ${ __orca_has_feature markers && __orca_osc133_precmd\n` : '' } builtin unset _orca_shell_features _orca_histfile - builtin unfunction __orca_deferred_init __orca_has_feature +${ZSH_DEFERRED_LINE_INIT_CLEANUP_BLOCK} + builtin unfunction __orca_deferred_init __orca_has_feature __orca_arm_deferred_line_init }` } @@ -201,6 +212,7 @@ export function buildZshStartupHook(spec: ZshStartupHookSpec): string { ZSH_FEATURE_CHANNEL_BLOCK, SHELL_STARTUP_IDENTITY_MARKER_BLOCK, spec.osc133CommandMarkers ? ZSH_OSC133_FUNCTION_BLOCK : null, + ZSH_DEFERRED_LINE_INIT_BLOCK, buildDeferredInit(spec), ZSH_USER_ZSHENV_SOURCE_BLOCK ])}\n` diff --git a/src/relay/pty-shell-overlay-wrappers.ts b/src/relay/pty-shell-overlay-wrappers.ts index c8d699f36be..82ad3a7fb85 100644 --- a/src/relay/pty-shell-overlay-wrappers.ts +++ b/src/relay/pty-shell-overlay-wrappers.ts @@ -1,6 +1,7 @@ import { readFileSync, statSync } from 'node:fs' import { join } from 'node:path' import { getPosixOmpShellWrapper } from '../main/pty/omp-shell-wrapper' +import { ORCA_CLI_POSIX_PATH_RESTORE } from '../shared/orca-cli-shell-path' import { getPosixCodexShellLaunchPreflight } from '../shared/codex-shell-function' import { BASH_FEATURE_CHANNEL_BLOCK, @@ -73,6 +74,7 @@ fi [[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" [[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}" [[ -n "\${ORCA_REMOTE_CLI_BIN_DIR:-}" ]] && case ":$PATH:" in *:"\${ORCA_REMOTE_CLI_BIN_DIR}":*) ;; *) export PATH="\${ORCA_REMOTE_CLI_BIN_DIR}:$PATH" ;; esac +${ORCA_CLI_POSIX_PATH_RESTORE} ${getPosixOmpShellWrapper()} ${getPosixCodexShellLaunchPreflight()}${BASH_HISTFILE_RESTORE_BLOCK} # Why: SSH bash sessions need the same command lifecycle markers as local diff --git a/src/shared/orca-cli-shell-path.ts b/src/shared/orca-cli-shell-path.ts new file mode 100644 index 00000000000..6bbe825a45e --- /dev/null +++ b/src/shared/orca-cli-shell-path.ts @@ -0,0 +1,6 @@ +export const ORCA_CLI_POSIX_PATH_RESTORE = `if [ -n "\${ORCA_CLI_BIN_DIR:-}" ]; then + case "\${PATH:-}" in + "$ORCA_CLI_BIN_DIR"|"$ORCA_CLI_BIN_DIR":*) ;; + *) export PATH="$ORCA_CLI_BIN_DIR\${PATH:+:$PATH}" ;; + esac +fi` From 8c617301f71b42e133f9e5fa44451d63e651dfc6 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 4 Oct 2026 03:15:12 -0700 Subject: [PATCH 53/67] fix(opencode): keep overlay manifest cleanup inside owned directories (#24763) * fix: wait for OpenCode worker composer before first dispatch Reuse captured composer readiness on local and paired execution hosts and revoke launching-shell paste anchors. Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> * feat(opencode): probe execution-host CLI capabilities * fix(opencode): select plugin default for execution host loader * fix(opencode): limit prompt prefill capability to verified release * feat(opencode): probe launch capabilities on the execution host * fix(opencode): select plugin loader for the launched host binary * fix(opencode): match WSL probe cwd and declared guest environment * fix(opencode): preserve launch environment deletion boundaries * wip(opencode): authorize native startup prompt intent at execution owner * fix(opencode): atomically replace status plugin entrypoints * fix(opencode): retain plugin permissions across restrictive umasks * test(opencode): resolve permission fixture from primary cwd * feat(opencode): install startup prompt plugin independently of status hooks * fix(opencode): wait for admitted startup intent and preserve failed-launch briefs * fix(opencode): confine overlay manifest cleanup to owned directories Co-authored-by: Adnan Khan * fix: wait for OpenCode worker composer before first dispatch Reuse captured composer readiness on local and paired execution hosts and revoke launching-shell paste anchors. Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> * feat(opencode): probe execution-host CLI capabilities * fix(opencode): select plugin default for execution host loader * fix(opencode): limit prompt prefill capability to verified release * feat(opencode): probe launch capabilities on the execution host * fix(opencode): select plugin loader for the launched host binary * fix(opencode): match WSL probe cwd and declared guest environment * fix(opencode): preserve launch environment deletion boundaries * wip(opencode): authorize native startup prompt intent at execution owner * fix(opencode): atomically replace status plugin entrypoints * fix(opencode): retain plugin permissions across restrictive umasks * test(opencode): resolve permission fixture from primary cwd * feat(opencode): install startup prompt plugin independently of status hooks * fix(opencode): wait for admitted startup intent and preserve failed-launch briefs * fix(opencode): unsubscribe hook settings during async host shutdown * STRICT launch CI contract correction * CAPS launch CI contract correction * INTENT launch CI contract correction * test: initialize Claude prompt state in output retention fixture * Wait for OpenCode location hydration in intent startup * Bind OpenCode startup readiness to the current location in intent startup --------- Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Co-authored-by: Ahmed Nagy Co-authored-by: Adnan Khan Co-authored-by: Orca startup hydration review --- .../hook-service-overlay-confinement.test.ts | 98 +++++++++++++++++++ src/main/opencode/hook-service.ts | 14 ++- .../opencode-overlay-manifest.test.ts | 32 ++++++ src/main/pty/overlay-mirror.test.ts | 29 +++++- src/main/pty/overlay-mirror.ts | 35 ++++++- 5 files changed, 202 insertions(+), 6 deletions(-) create mode 100644 src/main/opencode/hook-service-overlay-confinement.test.ts create mode 100644 src/main/opencode/opencode-overlay-manifest.test.ts diff --git a/src/main/opencode/hook-service-overlay-confinement.test.ts b/src/main/opencode/hook-service-overlay-confinement.test.ts new file mode 100644 index 00000000000..46a5bee5884 --- /dev/null +++ b/src/main/opencode/hook-service-overlay-confinement.test.ts @@ -0,0 +1,98 @@ +import { + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + symlinkSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, dirname, join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { setAppEnvironment } from '../../shared/app-environment' +import { safeRemoveTree } from '../pty/overlay-mirror' +import { OpenCodeHookService } from './hook-service' +import { OPENCODE_OVERLAY_MANIFEST_FILE } from './opencode-overlay-manifest' + +let root: string +let source: string +let service: OpenCodeHookService + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orca-overlay-confinement-')) + source = join(root, 'source') + mkdirSync(join(source, 'plugins'), { recursive: true }) + writeFileSync(join(source, 'plugins', 'user.js'), 'export default {}') + vi.stubEnv('XDG_CONFIG_HOME', join(root, 'xdg')) + setAppEnvironment({ + getPath: () => join(root, 'profile'), + getAppPath: () => process.cwd(), + getVersion: () => '0.0.0-test', + isPackaged: () => false, + onWillQuit: () => {}, + exit: () => {}, + getAppMetrics: () => [] + }) + service = new OpenCodeHookService({ + pluginFileName: 'orca-test.js', + legacyHooksDir: 'legacy-test', + overlayDir: 'overlay-test', + pluginSource: () => 'export default {}' + }) +}) + +afterEach(() => { + vi.unstubAllEnvs() + rmSync(root, { recursive: true, force: true }) +}) + +it.each(['overlay-root', 'source-overlay', 'plugins'] as const)( + 'preserves outside files when the owned %s is replaced by a directory link', + (boundary) => { + const overlay = service.buildPtyEnv('pane-1', source).OPENCODE_CONFIG_DIR + if (!overlay) { + throw new Error('Expected an isolated overlay') + } + const outside = join(root, 'outside') + const externalOverlay = boundary === 'overlay-root' ? join(outside, basename(overlay)) : outside + mkdirSync(join(externalOverlay, 'plugins'), { recursive: true }) + const sentinel = join(externalOverlay, 'plugins', 'keep.js') + writeFileSync(sentinel, 'export const keep = true') + writeFileSync( + join(externalOverlay, OPENCODE_OVERLAY_MANIFEST_FILE), + JSON.stringify({ topLevelEntries: [], pluginEntries: ['keep.js'] }) + ) + const replaced = + boundary === 'overlay-root' + ? dirname(overlay) + : boundary === 'source-overlay' + ? overlay + : join(overlay, 'plugins') + const target = boundary === 'plugins' ? join(outside, 'plugins') : outside + if (boundary === 'plugins') { + writeFileSync( + join(overlay, OPENCODE_OVERLAY_MANIFEST_FILE), + JSON.stringify({ topLevelEntries: [], pluginEntries: ['keep.js'] }) + ) + } + safeRemoveTree(replaced) + symlinkSync(target, replaced, process.platform === 'win32' ? 'junction' : 'dir') + const before = readdirSync(outside, { recursive: true }).toSorted() + + const result = service.buildPtyEnv('pane-2', source) + expect(readFileSync(sentinel, 'utf8')).toBe('export const keep = true') + expect(readdirSync(outside, { recursive: true }).toSorted()).toEqual(before) + expect(result).toEqual({ OPENCODE_CONFIG_DIR: source }) + } +) + +it('still removes a stale mirrored entry from a real owned overlay', () => { + const overlay = service.buildPtyEnv('pane-1', source).OPENCODE_CONFIG_DIR + if (!overlay) { + throw new Error('Expected an isolated overlay') + } + rmSync(join(source, 'plugins', 'user.js')) + expect(service.buildPtyEnv('pane-2', source)).toEqual({ OPENCODE_CONFIG_DIR: overlay }) + expect(readdirSync(join(overlay, 'plugins'))).toEqual(['orca-test.js']) +}) diff --git a/src/main/opencode/hook-service.ts b/src/main/opencode/hook-service.ts index 9162499472f..1461b8773dc 100644 --- a/src/main/opencode/hook-service.ts +++ b/src/main/opencode/hook-service.ts @@ -11,7 +11,7 @@ import { writeFileSync } from 'node:fs' import { createHash } from 'node:crypto' -import { isSafeDescendCandidate, mirrorEntry, safeRemoveTree } from '../pty/overlay-mirror' +import { isSafeDescendCandidate, mirrorEntry, safeRemoveOverlay } from '../pty/overlay-mirror' import { getOpenCode2PluginSource, getOpenCodeFamilyPluginSource, @@ -141,7 +141,13 @@ export class OpenCodeHookService { } const overlayDir = this.getSourceOverlayDir(existingConfigDir) try { - mkdirSync(overlayDir, { recursive: true }) + // Owned directories must stay real; a replaced parent redirects both cleanup and writes. + for (const directory of [this.getOverlayRoot(), overlayDir, join(overlayDir, 'plugins')]) { + mkdirSync(directory, { recursive: true }) + if (!isSafeDescendCandidate(lstatSync(directory))) { + return { OPENCODE_CONFIG_DIR: existingConfigDir } + } + } if (existsSync(existingConfigDir)) { this.mirrorUserConfig(existingConfigDir, overlayDir) } @@ -230,7 +236,7 @@ export class OpenCodeHookService { private clearManifestEntries(overlayDir: string, manifest: OpenCodeOverlayManifest): void { for (const entryName of manifest.topLevelEntries) { - safeRemoveTree(join(overlayDir, entryName)) + safeRemoveOverlay(join(overlayDir, entryName), overlayDir) } const overlayPluginsDir = join(overlayDir, 'plugins') @@ -238,7 +244,7 @@ export class OpenCodeHookService { if (entryName === this.pluginFileName) { continue } - safeRemoveTree(join(overlayPluginsDir, entryName)) + safeRemoveOverlay(join(overlayPluginsDir, entryName), overlayPluginsDir) } } diff --git a/src/main/opencode/opencode-overlay-manifest.test.ts b/src/main/opencode/opencode-overlay-manifest.test.ts new file mode 100644 index 00000000000..7dc0404a53e --- /dev/null +++ b/src/main/opencode/opencode-overlay-manifest.test.ts @@ -0,0 +1,32 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, it } from 'vitest' +import { + readOpenCodeOverlayManifest, + OPENCODE_OVERLAY_MANIFEST_FILE +} from './opencode-overlay-manifest' + +it('accepts only string manifest entries and tolerates invalid persisted shapes', () => { + const root = mkdtempSync(join(tmpdir(), 'orca-overlay-manifest-')) + try { + const path = join(root, OPENCODE_OVERLAY_MANIFEST_FILE) + for (const text of ['null', '1', '{']) { + writeFileSync(path, text) + expect(readOpenCodeOverlayManifest(root)).toEqual({ topLevelEntries: [], pluginEntries: [] }) + } + writeFileSync( + path, + JSON.stringify({ + topLevelEntries: ['valid', 1, null], + pluginEntries: [{ bad: true }, 'plugin.js'] + }) + ) + expect(readOpenCodeOverlayManifest(root)).toEqual({ + topLevelEntries: ['valid'], + pluginEntries: ['plugin.js'] + }) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) diff --git a/src/main/pty/overlay-mirror.test.ts b/src/main/pty/overlay-mirror.test.ts index 5499d226422..1a741552719 100644 --- a/src/main/pty/overlay-mirror.test.ts +++ b/src/main/pty/overlay-mirror.test.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, rmSync, writeFileSync, symlinkSync } from 'node:fs' import { mkdtemp } from 'node:fs/promises' import { tmpdir } from 'node:os' import type * as NodePath from 'node:path' @@ -19,6 +19,33 @@ afterEach(() => { }) describe('safeRemoveOverlay', () => { + it('keeps missing owned paths a silent no-op', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-overlay-missing-')) + tempRoots.push(root) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + safeRemoveOverlay(join(root, 'missing', 'leaf'), join(root, 'missing')) + expect(warn).not.toHaveBeenCalled() + }) + + it('refuses cleanup through an intermediate symlink and unlinks only a leaf symlink', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-overlay-symlink-')) + tempRoots.push(root) + const overlay = join(root, 'overlay') + const outside = join(root, 'outside') + mkdirSync(overlay) + mkdirSync(outside) + const sentinel = join(outside, 'keep.txt') + writeFileSync(sentinel, 'private sentinel') + const linked = join(overlay, 'linked') + symlinkSync(outside, linked, process.platform === 'win32' ? 'junction' : 'dir') + vi.spyOn(console, 'warn').mockImplementation(() => {}) + safeRemoveOverlay(join(linked, 'keep.txt'), overlay) + expect(existsSync(sentinel)).toBe(true) + safeRemoveOverlay(linked, overlay) + expect(existsSync(linked)).toBe(false) + expect(existsSync(sentinel)).toBe(true) + }) + it('removes valid overlay children whose names start with dot-dot', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-overlay-root-')) tempRoots.push(root) diff --git a/src/main/pty/overlay-mirror.ts b/src/main/pty/overlay-mirror.ts index ec1b99d2b92..6a35844f871 100644 --- a/src/main/pty/overlay-mirror.ts +++ b/src/main/pty/overlay-mirror.ts @@ -138,6 +138,33 @@ export function safeRemoveTree(path: string): void { } } +// Why: cleanup must not traverse a symlink parent inside the owned overlay. +function hasSafeOverlayAncestors(root: string, relativeTarget: string): boolean { + let current = root + try { + if (lstatSync(current).isSymbolicLink()) { + return false + } + } catch (error) { + return !!error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT' + } + const segments = relativeTarget.split(sep).filter(Boolean) + for (const [index, segment] of segments.entries()) { + current = join(current, segment) + if (index === segments.length - 1) { + break + } + try { + if (lstatSync(current).isSymbolicLink()) { + return false + } + } catch (error) { + return !!error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT' + } + } + return true +} + // Why: last-line guard against an overlay-root constant ever being // mis-resolved. Any caller that points safeRemoveTree at a path outside its // designated overlay root is refused so a misconfiguration cannot turn into @@ -147,7 +174,13 @@ export function safeRemoveOverlay(overlayDir: string, overlayRoot: string): void const resolvedRoot = resolve(overlayRoot) const resolvedTarget = resolve(overlayDir) const rel = relative(resolvedRoot, resolvedTarget) - if (rel === '' || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) { + if ( + rel === '' || + rel === '..' || + rel.startsWith(`..${sep}`) || + isAbsolute(rel) || + !hasSafeOverlayAncestors(resolvedRoot, rel) + ) { console.warn( `[overlay-mirror] refusing to remove overlay outside root: target=${resolvedTarget} root=${resolvedRoot}` ) From 87bc51d3710332ea8b0f7f0e5610a31413a79582 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 4 Oct 2026 04:11:03 -0700 Subject: [PATCH 54/67] Reduce test deadline waits and exact byte comparison costs (#25187) --- docs/reference/ci-runner-efficiency.md | 105 ++++++++ .../managed-hook-script-refresh.test.ts | 7 + .../antigravity/native-account-store.test.ts | 6 +- ...t-session-claude-stop-ends-session.test.ts | 44 +++- ...gent-session-stop-deadline.test-fixture.ts | 89 +++++++ .../transcript-opencode-subscribe.test.ts | 100 +++++--- ...ook-plugin-opencode2-tui-ownership.test.ts | 232 +++++++++++++----- .../profile-state-sqlite-authority.test.ts | 8 +- .../profile-state-store-backups.test.ts | 6 +- .../profile-state-authority-bootstrap.test.ts | 8 +- .../profile-state-backup-worker.test.ts | 4 +- .../profile-state-database-snapshot.test.ts | 8 +- .../profile-state-database.test.ts | 14 +- ...er-circle-title-send-authorization.test.ts | 82 ++++++- ...-session-codex-turn-end-settlement.test.ts | 75 +++++- .../ssh/ssh-relay-gc-listing.test-fixture.ts | 148 +++++++++++ src/main/ssh/ssh-remote-commands.test.ts | 43 +++- .../attach-main-window-services.test.ts | 132 +++++----- .../main-window-service-stubs.test-fixture.ts | 80 ++++++ ...fCommentDecorator.range-selection.test.tsx | 16 +- ...tiveChatStructuredSessionDelivery.test.tsx | 120 +++++---- 21 files changed, 1050 insertions(+), 277 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-stop-deadline.test-fixture.ts create mode 100644 src/main/ssh/ssh-relay-gc-listing.test-fixture.ts create mode 100644 src/main/window/main-window-service-stubs.test-fixture.ts diff --git a/docs/reference/ci-runner-efficiency.md b/docs/reference/ci-runner-efficiency.md index f2dc21aa66c..00d1d0cf39f 100644 --- a/docs/reference/ci-runner-efficiency.md +++ b/docs/reference/ci-runner-efficiency.md @@ -46,6 +46,111 @@ used 42 aggregate runner-minutes across 11 test jobs. The estimates 34.9 headless runner-hours, including 23.4 in cancelled runs. These are baseline observations; post-merge savings have not yet been measured. +## October 4 clock, byte and import test fixtures + +These changes retain production behavior, original case names and platform +outcomes. The paired pilots use three alternating one-worker Node 24 invocations +on Ubuntu 24 ARM. Every median below is a complete focused test invocation; +they do not establish whole-shard savings or queue-delay improvements. + +| Workload | Baseline median | Candidate median | Reduction | Hosted evidence | +| ---------------------------------------------------- | --------------- | ---------------- | --------- | ------------------------------------------------------------------------ | +| Codex settlement and Claude stop deadlines, 35 cases | 35.914s | 10.142s | 71.8% | [37186232658](https://github.com/stablyai/orca/actions/runs/37186232658) | +| Native-chat delivery, 15 cases | 22.321s | 7.376s | 67.0% | [37183731823](https://github.com/stablyai/orca/actions/runs/37183731823) | +| Six profile-storage byte suites, 118 cases | 12.629s | 9.978s | 21.0% | [37183141654](https://github.com/stablyai/orca/actions/runs/37183141654) | +| Encrypted account storage, six cases | 18.277s | 0.958s | 94.8% | [37184007241](https://github.com/stablyai/orca/actions/runs/37184007241) | +| SSH remote commands, 27 cases | 6.840s | 6.078s | 11.1% | [37184454532](https://github.com/stablyai/orca/actions/runs/37184454532) | +| OpenCode subscription, 28 cases | 47.347s | 6.284s | 86.7% | [37184858823](https://github.com/stablyai/orca/actions/runs/37184858823) | +| Window-service attachment, 31 cases | 11.910s | 1.619s | 86.4% | [37186340840](https://github.com/stablyai/orca/actions/runs/37186340840) | +| OpenCode 2 TUI ownership, 41 cases | 16.811s | 2.429s | 85.6% | [37187699312](https://github.com/stablyai/orca/actions/runs/37187699312) | +| Title-send authorization, nine cases | 29.545s | 12.995s | 56.0% | [37188423318](https://github.com/stablyai/orca/actions/runs/37188423318) | +| Range selection, 15 cases | 9.737s | 7.130s | 26.8% | [37188996198](https://github.com/stablyai/orca/actions/runs/37188996198) | + +Provider tests wait for the real fake-child write/ready barrier and drain the +host stream before installing a scoped parent clock. The original 2.5/5-second +Codex and 3-second Claude deadlines remain; before/at assertions check their +boundaries. Early rejection and refusal resolve without waiting on an unreachable +barrier; finally and suite teardown restore clocks and spies even after a native +Vitest timeout. Four healthy failure-path controls pass; old-helper hangs and +removed teardown are caught. Missing-child failure retains a real ten-second observation window. +Six faults for early/late stop deadlines, late queued resend and missing child +output fail the intended assertions. Native-chat tests still use the real React +outbox hooks. Their scoped clock retains probe, retry, churn and target-switch +windows, drains async act work, and unmounts before clock restoration. All eight +hook faults fail; two boundary faults pass the old coarse tests and fail the new +before/at assertions. Node/web typecheck, lint and formatting pass. + +Native Buffer.equals replaces deep per-byte assertion traversal. It checks the +complete original bytes and length; fixtures, SQLite operations, encryption and +processes are unchanged. The six profile suites retain 114 passes and four +existing Linux case-sensitivity skips; all 118 pass locally on macOS. Seven +profile last-byte/length faults and two encrypted-vault last-byte/length faults +fail their exact byte assertions. All six encrypted-vault cases still exercise +52 accounts near the 4 MiB encrypted cap, refused growth, restart/readback and +private permissions. + +The old SSH fixture created 15,197 short stage paths but never exceeded the real +1,048,576 UTF-16-character transport tail cap; its two valid entries also left +the 64-result assertion vacuous. The replacement uses about 1,300 real excluded +stage directories under long Unicode path components, a real shell channel and +the production execCommand limiter. Actual find output exceeds that cap, while +the generated filter retains both original valid entries. A separate population +of 65 valid directories proves the first-64 limit and native find ordering. File, +symlink, nested-install and failed-enumeration checks remain. All 27 case outcomes +match across treatments (23 passes and four unavailable PowerShell 5.1 skips on +the hosted image). Eight cap, ordering, filtering and failure faults are caught. +Paths use platform utilities; local PowerShell availability retains its original +skip policy. The deadline and SSH fixtures reuse existing process/stream code. +OpenCode subscription tests batch only uninterrupted fixture writes between the +original observation barriers. Both SQLite schema versions keep every row, rowid, +read cap, poll, clock position and case. No durability pragma or production code +changes. All 28 cases pass in every pair. Separate captures compare ordered +schema and rows, transaction state, pragmas, signals, page requests/results and +subscriber callbacks: 124,754,101 payload bytes match, canonical digest +`ba0eb6f09281746071d73fae88e2e8eb45332f36892b90998b374b1b8c59b3e3`. +Missing rows, collapsed frontier rowids, read-cap overruns, missing commit and +missing rollback each fail the intended case in both schemas. Positive rollback +controls pass. The unmeasured full-suite timing report ranked this fixture at +134.229 seconds; the paired 47.347-second figure above is the relevant focused +baseline, and the two figures must not be mixed into a claimed saving. + +Window attachment tests mock five unrelated registrar modules using their actual +types. The existing window ownership, reload, media permission, native file drop, +hydration barrier and updater scheduling cases remain real. Exact store/runtime/ +window arguments and daemon registration after the PTY handler are now asserted; +nine actual production wiring/order faults fail. The registrar implementations +retain separate handler tests. Concrete existing stubs moved to one fixture to +stay within the line limit. All 31 cases and statuses match across all pairs. The +final source differs from the timed source only by a required type-assertion +safety comment. +OpenCode 2 TUI tests use a scoped async clock only after the first real native +module import. The unchanged generated plugin runs against the existing fake TUI +and fetch. Original poll, permission, retry, preview, slow POST and endpoint +windows remain. Before/at assertions pin the 100 ms poll, 500 ms permission, +120 ms slow POST and 5-second endpoint boundaries. All 41 original case outcomes +match. Separate ordered captures preserve 678 TUI/event rows and 362 complete +POST start/completion rows; wall timestamps and cross-stream interleaving are +excluded from equivalence. Eleven generated-source faults fail their intended +identity, reload, order, deadline or timer-cleanup assertions. Four import/setup/ +disposer rejection and timeout controls confirm restoration of clocks, fetch, +argv and environment. Teardown restores real clocks before its bounded wait. +Title-send authorization tests retain real terminal creation, graph binding and +positive evidence paths. Negative process-evidence probes use scoped clocks +after setup: both 150 ms polling loops retain their 6,500 ms budget, crossed at +6,600 ms, and the send guard retains its 1,050 ms deadline. Before/at assertions +check 6,599/6,600 and 1,049/1,050 ms. All nine original cases remain. Actual +early/late wrapper and guard deadlines, false spinner identity and unknown-agent +authorization faults fail their intended assertions; native clocks and runtime +instance spies are restored after each failure. +Range-selection tests stub only the saved-note send menu, which their empty +comment populations never render. A facade typed from the actual menu props +throws if invoked, and an afterEach assertion verifies no call with unconditional +mock clearing in finally. The real hook, Monaco constants/model, line and range +drag behavior, draft-card lifecycle and open-inline-card chord remain. All 15 +original test bodies are byte-unchanged. Three actual range/hunk/draft faults +fail their original assertions; a real draft-render menu call hits the facade +and sentinel, and an outer cleanup check proves mock state cleared after failure. +The actual saved-note menu retains its independent component tests. ## October 2 headless detector compiler cache The deferred detector already avoids dependency setup for known build inputs. diff --git a/src/main/agent-hooks/managed-hook-script-refresh.test.ts b/src/main/agent-hooks/managed-hook-script-refresh.test.ts index 4cd1905d8aa..30fe4a658a9 100644 --- a/src/main/agent-hooks/managed-hook-script-refresh.test.ts +++ b/src/main/agent-hooks/managed-hook-script-refresh.test.ts @@ -147,8 +147,10 @@ describe('managed hook script refresh', () => { homedirMock.mockReturnValue(home) const previousGrokHome = process.env.GROK_HOME const previousKimiHome = process.env.KIMI_CODE_HOME + const previousXdgConfigHome = process.env.XDG_CONFIG_HOME delete process.env.GROK_HOME delete process.env.KIMI_CODE_HOME + delete process.env.XDG_CONFIG_HOME try { await withPlatform('win32', () => { for (const [, install] of MANAGED_AGENT_HOOK_INSTALLERS) { @@ -187,6 +189,11 @@ describe('managed hook script refresh', () => { } else { process.env.KIMI_CODE_HOME = previousKimiHome } + if (previousXdgConfigHome === undefined) { + delete process.env.XDG_CONFIG_HOME + } else { + process.env.XDG_CONFIG_HOME = previousXdgConfigHome + } rmSync(home, { recursive: true, force: true }) } }) diff --git a/src/main/antigravity/native-account-store.test.ts b/src/main/antigravity/native-account-store.test.ts index f6d3398c679..9fbd8ab183f 100644 --- a/src/main/antigravity/native-account-store.test.ts +++ b/src/main/antigravity/native-account-store.test.ts @@ -67,11 +67,11 @@ describe('protected Antigravity account snapshots', () => { const service = new AntigravityAccountService(store, h.backend) h.setNative(paddedCredential('new-account', 60000)) await expect(service.addCurrentAccount()).rejects.toThrow('could not be saved') - expect(readFileSync(path)).toEqual(before) + expect(readFileSync(path).equals(before)).toBe(true) expect(store.read().accounts).toHaveLength(52) h.setNative(paddedCredential('large-51', 65000)) await expect(service.listAccounts()).rejects.toThrow('could not be saved') - expect(readFileSync(path)).toEqual(before) + expect(readFileSync(path).equals(before)).toBe(true) expect(store.read().accounts).toHaveLength(52) }) @@ -115,7 +115,7 @@ describe('protected Antigravity account snapshots', () => { 'Protected secret storage' ) expect(() => store.read()).toThrow('Protected secret storage') - expect(readFileSync(path)).toEqual(before) + expect(readFileSync(path).equals(before)).toBe(true) } ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts index ddef0ed0bb6..9c920b45eb3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts @@ -32,6 +32,7 @@ import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-rec import { structuredClaudeLifecycleEvent } from '../../runtime/structured-claude-runtime-adapter' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { createStoppedClaudeDeadline } from './structured-agent-session-stop-deadline.test-fixture' import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' import { HOST_TEST_NOW as NOW, @@ -133,6 +134,8 @@ beforeEach(async () => { }) afterEach(async () => { + vi.restoreAllMocks() + vi.useRealTimers() await adapter.closeAll() await host.flushAllStreamedEvents() await rm(root, { recursive: true, force: true }) @@ -214,6 +217,18 @@ function stop(turnId?: string) { return host.cancel(CALLER, { envelope: envelope('agentSession.cancel', fields), ...fields }) } +/** Resolves once everything queued on the session's lane so far has run: a Stop's second step. */ +const laneDrained = (): Promise => host['tasks'].serialize(SESSION, async () => {}) + +const stopAcrossGrace = createStoppedClaudeDeadline({ + host: () => host, + adapter: () => adapter, + claude: () => claude, + sessionId: SESSION, + stop, + laneDrained +}) + /** How many person's Stop events the journal holds when the child's close begins. */ function stopEventsAtClose(connection: FakeConnection): () => number | undefined { let atClose: number | undefined @@ -231,11 +246,6 @@ function stopEventsAtClose(connection: FakeConnection): () => number | undefined return () => atClose } -/** Resolves once everything queued on the session's lane so far has run: a Stop's second step. */ -function laneDrained(): Promise { - return host['tasks'].serialize(SESSION, async () => {}) -} - function wrote(connection: FakeConnection, text: string): boolean { return connection.sent.some((message) => JSON.stringify(message).includes(text)) } @@ -369,14 +379,15 @@ it('ends the child once the grace runs out when Claude says nothing after a Stop claude.routes.interrupt = () => ({ still_queued: [], cancelled: [] }) const clientMessageId = await sendUnechoed(connection) - const asked = Date.now() - await expect(stop()).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) + await expect(stopAcrossGrace(connection, 'request-end')).resolves.toMatchObject({ + ok: true, + value: { cancelled: true } + }) await laneDrained() // As before the wait: the send Claude never answered is doubt once its child ends. expect(connection.closed).toBe(true) expect(eventsAtClose()).toBe(1) - expect(Date.now() - asked).toBeLessThan(CLAUDE_STOP_GRACE_MS + 1_500) expect(await dispatch(clientMessageId)).toMatchObject({ state: 'unknown' }) }, 15_000) @@ -447,13 +458,14 @@ it('ends the child within the grace when Claude never answers the interrupt', as const eventsAtClose = stopEventsAtClose(connection) await openTurn(connection) - const asked = Date.now() - await expect(stop()).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) + await expect(stopAcrossGrace(connection, 'interrupt')).resolves.toMatchObject({ + ok: true, + value: { cancelled: true } + }) await laneDrained() expect(connection.closed).toBe(true) expect(eventsAtClose()).toBe(1) - expect(Date.now() - asked).toBeLessThan(CLAUDE_STOP_GRACE_MS + 1_500) expect(await turnOutcome()).toBe('cancellation') expect(await statusTexts()).toEqual(['Cancellation requested.']) }, 15_000) @@ -672,7 +684,15 @@ it.each([ await eventually(() => expect(wrote(connection, 'Follow-up.')).toBe(true)) // As the phone sends it: the turn it last saw working. - await expect(stop(ended)).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) + await expect( + _answer === 'fails' + ? stop(ended) + : stopAcrossGrace( + connection, + interrupt === NEVER_ANSWERS ? 'interrupt' : 'request-end', + ended + ) + ).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) await laneDrained() expect(connection.calls.some((call) => call.subtype === 'interrupt')).toBe(true) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-deadline.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-deadline.test-fixture.ts new file mode 100644 index 00000000000..4d33a998a59 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-deadline.test-fixture.ts @@ -0,0 +1,89 @@ +import { expect, vi } from 'vitest' +import { CLAUDE_STOP_GRACE_MS } from '../../claude/claude-request-end-wait' +import type { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' +import type { + fakeClaude, + FakeConnection +} from '../../claude/claude-structured-session-test-support' +import type { StructuredAgentSessionHost } from './structured-agent-session-host' + +export function createStoppedClaudeDeadline(deps: { + host: () => Pick + adapter: () => Pick + claude: () => ReturnType + sessionId: string + stop: (turnId?: string) => ReturnType + laneDrained: () => Promise +}) { + return async ( + connection: FakeConnection, + deadline: 'interrupt' | 'request-end', + turnId?: string + ) => { + await deps.host().flushStreamedEvents(deps.sessionId) + const reachedDeadline = Promise.withResolvers< + Awaited> | undefined + >() + let deadlineSettled = false + const claude = deps.claude() + const adapter = deps.adapter() + const interrupt = claude.routes.interrupt + const requestEnd = adapter.awaitStoppedRequestEnd + const waiting = vi + .spyOn(adapter, 'awaitStoppedRequestEnd') + .mockImplementation((sessionId, at) => { + const pending = requestEnd(sessionId, at) + reachedDeadline.resolve(undefined) + return pending.then(() => { + deadlineSettled = true + }) + }) + if (deadline === 'interrupt') { + claude.routes.interrupt = (params) => { + const pending = interrupt?.(params) + if (!(pending instanceof Promise)) { + throw new Error('Expected an unanswered interrupt promise') + } + void pending.then( + () => { + deadlineSettled = true + }, + () => { + deadlineSettled = true + } + ) + reachedDeadline.resolve(undefined) + return pending + } + } + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }) + try { + const asked = Date.now() + const stopping = deps.stop(turnId) + void stopping.then((result) => { + if (!result.ok || !result.value.cancelled) { + reachedDeadline.resolve(result) + } + }, reachedDeadline.reject) + const early = await reachedDeadline.promise + if (early !== undefined) { + return early + } + await vi.advanceTimersByTimeAsync(CLAUDE_STOP_GRACE_MS - 1) + expect(deadlineSettled).toBe(false) + expect(connection.closed).toBe(false) + await vi.advanceTimersByTimeAsync(1) + expect(deadlineSettled).toBe(true) + const result = await stopping + await deps.laneDrained() + expect(Date.now() - asked).toBeLessThan(CLAUDE_STOP_GRACE_MS + 1_500) + return result + } finally { + waiting.mockRestore() + if (interrupt) { + claude.routes.interrupt = interrupt + } + vi.useRealTimers() + } + } +} diff --git a/src/main/native-chat/transcript-opencode-subscribe.test.ts b/src/main/native-chat/transcript-opencode-subscribe.test.ts index b8e0fb01e78..cce4b243cf8 100644 --- a/src/main/native-chat/transcript-opencode-subscribe.test.ts +++ b/src/main/native-chat/transcript-opencode-subscribe.test.ts @@ -82,9 +82,21 @@ function watchFixture( db.prepare('DELETE FROM session_message WHERE id = ?').run(String(index)) } } - for (let index = 1; index <= messageCount; index++) { - insert(index, hiddenFirst && index === 1 ? '' : undefined) + const batch = (mutate: () => void) => { + db.exec('BEGIN') + try { + mutate() + db.exec('COMMIT') + } catch (error) { + db.exec('ROLLBACK') + throw error + } } + batch(() => { + for (let index = 1; index <= messageCount; index++) { + insert(index, hiddenFirst && index === 1 ? '' : undefined) + } + }) let displayed: NativeChatMessage[] = [] const onReplace = vi.fn((messages: NativeChatMessage[]) => { displayed = messages @@ -115,7 +127,17 @@ function watchFixture( } ) fixtures.push({ db, root, stop: subscription.unsubscribe }) - return { db, insert, update, remove, displayed: () => displayed, onReplace, onAppend, readPage } + return { + db, + insert, + update, + remove, + batch, + displayed: () => displayed, + onReplace, + onAppend, + readPage + } } describe.each(['v1', 'v2'] as const)('OpenCode %s watch reconciliation', (version) => { @@ -123,8 +145,10 @@ describe.each(['v1', 'v2'] as const)('OpenCode %s watch reconciliation', (versio const f = watchFixture(version, false, 1000) await vi.advanceTimersByTimeAsync(0) expect(f.displayed()).toHaveLength(300) - f.remove(701) - f.insert(1001) + f.batch(() => { + f.remove(701) + f.insert(1001) + }) await vi.advanceTimersByTimeAsync(10) expect(f.displayed()).toHaveLength(300) expect(f.displayed()[0]?.blocks).toEqual([{ type: 'text', text: 'message 702' }]) @@ -170,9 +194,11 @@ describe.each(['v1', 'v2'] as const)('OpenCode %s watch reconciliation', (versio it('replaces an empty session and permits a reused provider cursor to append', async () => { const f = watchFixture(version) await vi.advanceTimersByTimeAsync(0) - for (let index = 1; index <= 300; index++) { - f.remove(index) - } + f.batch(() => { + for (let index = 1; index <= 300; index++) { + f.remove(index) + } + }) await vi.advanceTimersByTimeAsync(10) expect(f.displayed()).toEqual([]) f.insert(1, 'after revert') @@ -194,9 +220,11 @@ describe.each(['v1', 'v2'] as const)('OpenCode %s watch reconciliation', (versio it('continues checking previously appended history and bounds every read', async () => { const f = watchFixture(version) await vi.advanceTimersByTimeAsync(0) - for (let index = 301; index <= 450; index++) { - f.insert(index) - } + f.batch(() => { + for (let index = 301; index <= 450; index++) { + f.insert(index) + } + }) await vi.advanceTimersByTimeAsync(10) f.update(1, 'edited after append') await vi.advanceTimersByTimeAsync(10) @@ -210,9 +238,11 @@ describe.each(['v1', 'v2'] as const)('OpenCode %s watch reconciliation', (versio it('holds the frontier and retries when a burst cannot bridge within the read cap', async () => { const f = watchFixture(version) await vi.advanceTimersByTimeAsync(0) - for (let index = 301; index <= 2800; index++) { - f.insert(index) - } + f.batch(() => { + for (let index = 301; index <= 2800; index++) { + f.insert(index) + } + }) await vi.advanceTimersByTimeAsync(10) expect(f.displayed()).toHaveLength(300) expect(f.onReplace).not.toHaveBeenCalled() @@ -220,10 +250,12 @@ describe.each(['v1', 'v2'] as const)('OpenCode %s watch reconciliation', (versio await vi.advanceTimersByTimeAsync(10) expect(f.readPage.mock.calls.length).toBeGreaterThan(reads) expect(f.readPage.mock.calls.every(([args]) => args.limit <= 2400)).toBe(true) - for (let index = 301; index <= 2800; index++) { - f.remove(index) - } - f.insert(301, 'after discarded burst') + f.batch(() => { + for (let index = 301; index <= 2800; index++) { + f.remove(index) + } + f.insert(301, 'after discarded burst') + }) await vi.advanceTimersByTimeAsync(10) expect(f.displayed()).toHaveLength(301) expect(f.displayed().at(-1)?.blocks).toEqual([{ type: 'text', text: 'after discarded burst' }]) @@ -233,9 +265,11 @@ describe.each(['v1', 'v2'] as const)('OpenCode %s watch reconciliation', (versio const f = watchFixture(version) await vi.advanceTimersByTimeAsync(0) for (let start = 301; start <= 2400; start += 300) { - for (let index = start; index < start + 300; index++) { - f.insert(index) - } + f.batch(() => { + for (let index = start; index < start + 300; index++) { + f.insert(index) + } + }) await vi.advanceTimersByTimeAsync(10) } f.onReplace.mockClear() @@ -253,9 +287,11 @@ describe.each(['v1', 'v2'] as const)('OpenCode %s watch reconciliation', (versio it('keeps appending after a bridged burst fills the cap', async () => { const f = watchFixture(version) await vi.advanceTimersByTimeAsync(0) - for (let index = 301; index <= 2600; index++) { - f.insert(index) - } + f.batch(() => { + for (let index = 301; index <= 2600; index++) { + f.insert(index) + } + }) await vi.advanceTimersByTimeAsync(10) expect(f.onReplace).toHaveBeenCalledOnce() expect(f.displayed()).toHaveLength(2400) @@ -272,13 +308,15 @@ describe.each(['v1', 'v2'] as const)('OpenCode %s watch reconciliation', (versio async (operation) => { const f = watchFixture(version, false, 2500, 2400) await vi.advanceTimersByTimeAsync(0) - if (operation === 'delete') { - f.remove(101) - } else { - f.update(101, operation === 'hide' ? '' : 'edited first tail row') - } - f.insert(2501) - f.insert(2502) + f.batch(() => { + if (operation === 'delete') { + f.remove(101) + } else { + f.update(101, operation === 'hide' ? '' : 'edited first tail row') + } + f.insert(2501) + f.insert(2502) + }) await vi.advanceTimersByTimeAsync(10) expect(f.onReplace).toHaveBeenCalledOnce() expect(f.onAppend).not.toHaveBeenCalled() diff --git a/src/main/opencode/hook-plugin-opencode2-tui-ownership.test.ts b/src/main/opencode/hook-plugin-opencode2-tui-ownership.test.ts index cca4712e971..b99ee05ff97 100644 --- a/src/main/opencode/hook-plugin-opencode2-tui-ownership.test.ts +++ b/src/main/opencode/hook-plugin-opencode2-tui-ownership.test.ts @@ -31,6 +31,8 @@ type PluginModule = { default?: { setup?: (ctx: unknown) => Promise<(() => Promise) | undefined> } } +const { setTimeout: realSetTimeout, clearTimeout: realClearTimeout } = globalThis + const PANE_A = 'tabA:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' const PANE_B = 'tabB:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' const SES_A = 'ses_f161fd85fffeieT0zYt80ZKorS' @@ -85,6 +87,8 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { let allPosts: Post[] let failPosts: boolean let postDelayMs: number + const cleanups = new Set<() => Promise>() + const delayedPosts = new Set<() => void>() beforeEach(() => { tempDir = mkdtempSync(join(tmpdir(), 'orca-opencode-tui-adapter-')) @@ -105,7 +109,15 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { globalThis.fetch = vi.fn(async (_input, init) => { const body = JSON.parse(String(init?.body)) if (postDelayMs > 0) { - await new Promise((resolve) => setTimeout(resolve, postDelayMs)) + await new Promise((resolve) => { + const finish = (): void => { + clearTimeout(timer) + delayedPosts.delete(finish) + resolve() + } + const timer = setTimeout(finish, postDelayMs) + delayedPosts.add(finish) + }) } if (failPosts) { return new Response('{}', { status: 500 }) @@ -122,17 +134,47 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { }) }) - afterEach(() => { - globalThis.fetch = savedFetch - process.argv = savedArgv - for (const key of ENV_KEYS) { - if (savedEnv[key] === undefined) { - delete process.env[key] - } else { - process.env[key] = savedEnv[key] + afterEach(async () => { + let deadline: ReturnType | undefined + try { + postDelayMs = 0 + for (const finish of delayedPosts) { + finish() + } + const closing = Promise.allSettled([...cleanups].map((cleanup) => cleanup())) + expect( + await Promise.race([ + closing.then((results) => results.every((result) => result.status === 'fulfilled')), + new Promise((resolve) => { + deadline = realSetTimeout(() => resolve(false), 2000) + }) + ]) + ).toBe(true) + } finally { + realClearTimeout(deadline) + try { + if (vi.isFakeTimers()) { + expect(vi.getTimerCount()).toBe(0) + } + } finally { + if (vi.isFakeTimers()) { + vi.clearAllTimers() + } + vi.useRealTimers() + cleanups.clear() + delayedPosts.clear() + globalThis.fetch = savedFetch + process.argv = savedArgv + for (const key of ENV_KEYS) { + if (savedEnv[key] === undefined) { + delete process.env[key] + } else { + process.env[key] = savedEnv[key] + } + } + rmSync(tempDir, { recursive: true, force: true }) } } - rmSync(tempDir, { recursive: true, force: true }) }) async function loadPlugin(dir = tempDir): Promise { @@ -140,7 +182,35 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { const pluginPath = join(dir, `orca-opencode-status-${Math.random().toString(36).slice(2)}.mjs`) writeFileSync(pluginPath, _internals.getOpenCodePluginSource()) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the generated module's default export is exercised below and fails the test if absent. - return (await import(pathToFileURL(pluginPath).href)) as PluginModule + const plugin = (await import(pathToFileURL(pluginPath).href)) as PluginModule + if (!vi.isFakeTimers()) { + vi.useFakeTimers({ + toFake: ['Date', 'setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] + }) + } + const setup = plugin.default?.setup + return { + default: { + setup: async (ctx) => { + const cleanup = await setup?.(ctx) + if (!cleanup) { + return + } + const close = async (): Promise => { + try { + await cleanup() + if (vi.isFakeTimers()) { + expect(vi.getTimerCount()).toBe(0) + } + } finally { + cleanups.delete(close) + } + } + cleanups.add(close) + return close + } + } + } } const summary = (list: Post[]): string[] => @@ -156,14 +226,40 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { const tui = fakeTui() const cleanup = await (await loadPlugin()).default?.setup?.(tui.ctx) await script(tui) - await vi.waitFor(() => { + await waitFor(() => { expect(summary(posts.slice(start)).at(-1)).toBe(`SessionIdle:${ownSession}`) }) await cleanup?.() return posts.slice(start) } - const tick = (ms = 20): Promise => new Promise((resolve) => setTimeout(resolve, ms)) + const tick = async (ms = 20): Promise => { + await (vi.isFakeTimers() + ? vi.advanceTimersByTimeAsync(ms) + : new Promise((resolve) => setTimeout(resolve, ms))) + } + const waitFor = async ( + check: () => void, + { timeout = 1000 }: { timeout?: number } = {} + ): Promise => { + if (!vi.isFakeTimers()) { + await vi.waitFor(check, { timeout }) + return + } + const deadline = Date.now() + timeout + await tick(0) + for (;;) { + try { + check() + return + } catch (error) { + if (Date.now() >= deadline) { + throw error + } + await tick(Math.min(50, deadline - Date.now())) + } + } + } const pump = async (tui: ReturnType, events: BusEvent[]): Promise => { for (const event of events) { tui.emit(event) @@ -237,7 +333,7 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { await pump(tui, b.start) expect(posts).toHaveLength(0) tui.navigate(SES_B) - await vi.waitFor(() => { + await waitFor(() => { expect(summary(posts)).toContain(`SessionBusy:${SES_B}`) }) await pump(tui, b.finish) @@ -284,9 +380,10 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { tui.navigate(SES_A) await pump(tui, turn(SES_A, 'A').start) tui.loseEnd(SES_A) - await vi.waitFor(() => { - expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`) - }) + await tick(99) + expect(summary(posts).at(-1)).toBe(`SessionBusy:${SES_A}`) + await tick(1) + expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`) await cleanup?.() }) @@ -301,7 +398,7 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { for (const event of [...a.start, ...a.finish]) { tui.emit(event) } - await vi.waitFor(() => { + await waitFor(() => { expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`) }) await tick(250) @@ -319,15 +416,15 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { tui.navigate(SES_A) const first = turn(SES_A, 'A') await pump(tui, [...first.start, ...first.finish]) - await vi.waitFor(() => { + await waitFor(() => { expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`) }) tui.loseStart(SES_A) - await vi.waitFor(() => { + await waitFor(() => { expect(summary(posts).at(-1)).toBe(`SessionBusy:${SES_A}`) }) tui.loseEnd(SES_A) - await vi.waitFor(() => { + await waitFor(() => { expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`) }) await cleanup?.() @@ -360,7 +457,7 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { tui.navigate(SES_A) await pump(tui, [...turn(SES_A, 'root').start, ...childStart(SES_A)]) await pump(tui, [{ type: 'session.execution.failed', data: { sessionID: SES_A } }]) - await vi.waitFor(() => { + await waitFor(() => { expect(posts.at(-1)?.payload).toMatchObject({ hook_event_name: 'SessionBusy', root_state: 'done', @@ -388,9 +485,7 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { tui.navigate(SES_A) await pump(tui, turn(SES_A, 'root').start) await pump(tui, [{ type: 'session.execution.failed', data: { sessionID: SES_A } }]) - await vi.waitFor(() => - expect(posts.at(-1)?.payload?.root_turn_error_name).toBe('UnknownError') - ) + await waitFor(() => expect(posts.at(-1)?.payload?.root_turn_error_name).toBe('UnknownError')) await first?.() const before = posts.length const second = await start(tui) @@ -414,7 +509,7 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { { type: finishType, data: { sessionID: SES_A, reason: 'user' } } ]) const second = await start(tui) - await vi.waitFor(() => expect(posts.at(-1)?.payload?.root_turn_error_name).toBeUndefined()) + await waitFor(() => expect(posts.at(-1)?.payload?.root_turn_error_name).toBeUndefined()) expect(posts.at(-1)?.payload).toMatchObject({ hook_event_name: 'SessionIdle', root_state: 'done' @@ -430,13 +525,13 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { tui.navigate(SES_A) const a = turn(SES_A, 'A') await pump(tui, a.start) - await vi.waitFor(() => expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`])) + await waitFor(() => expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`])) const beforeReload = posts.length await firstGeneration?.() expect(posts).toHaveLength(beforeReload) await pump(tui, a.finish) const secondGeneration = await start(tui) - await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`)) + await waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`)) await secondGeneration?.() expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`, `SessionIdle:${SES_A}`]) }) @@ -454,7 +549,7 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { await tick(150) expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`]) await pump(tui, a.finish) - await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`)) + await waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`)) await secondGeneration?.() }) @@ -468,7 +563,7 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { await firstGeneration?.() failPosts = false const secondGeneration = await start(tui) - await vi.waitFor(() => expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`])) + await waitFor(() => expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`])) await secondGeneration?.() }) @@ -483,7 +578,11 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { for (const event of [...a.start, ...b.start, ...b.finish, ...a.finish]) { tui.emit(event) } - await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`), { + await tick(119) + expect(allPosts).toEqual([]) + await tick(1) + expect(summary(allPosts)).toEqual(['SessionStart:undefined']) + await waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`), { timeout: 3000 }) await tick(300) @@ -505,11 +604,11 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { it('lands one start boundary in a freshly started TUI, and none on a reload after Done', async () => { const tui = fakeTui() const firstGeneration = await start(tui) - await vi.waitFor(() => expect(summary(allPosts)).toEqual(['SessionStart:undefined'])) + await waitFor(() => expect(summary(allPosts)).toEqual(['SessionStart:undefined'])) tui.navigate(SES_A) const a = turn(SES_A, 'A') await pump(tui, [...a.start, ...a.finish]) - await vi.waitFor(() => expect(summary(allPosts).at(-1)).toBe(`SessionIdle:${SES_A}`)) + await waitFor(() => expect(summary(allPosts).at(-1)).toBe(`SessionIdle:${SES_A}`)) await firstGeneration?.() const secondGeneration = await start(tui) await tick(250) @@ -524,7 +623,7 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { it('lands the start boundary only once across reloads of an idle pane', async () => { const tui = fakeTui() const firstGeneration = await start(tui) - await vi.waitFor(() => expect(summary(allPosts)).toEqual(['SessionStart:undefined'])) + await waitFor(() => expect(summary(allPosts)).toEqual(['SessionStart:undefined'])) await firstGeneration?.() const secondGeneration = await start(tui) await tick(150) @@ -536,7 +635,7 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { const tui = fakeTui() tui.navigate(SES_B) const cleanup = await start(tui) - await vi.waitFor(() => expect(summary(allPosts)).toEqual([`SessionStart:${SES_B}`])) + await waitFor(() => expect(summary(allPosts)).toEqual([`SessionStart:${SES_B}`])) await cleanup?.() }) @@ -545,7 +644,7 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { tui.navigate(SES_A) tui.loseStart(SES_A) const cleanup = await start(tui) - await vi.waitFor(() => expect(summary(allPosts)).toEqual([`SessionBusy:${SES_A}`])) + await waitFor(() => expect(summary(allPosts)).toEqual([`SessionBusy:${SES_A}`])) await tick(150) await cleanup?.() expect(summary(allPosts)).toEqual([`SessionBusy:${SES_A}`]) @@ -571,7 +670,7 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`, `SessionBusy:${SES_A}`]) expect(posts.at(-1)?.payload).toMatchObject({ root_state: 'done' }) tui.loseEnd(SES_CHILD) - await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`)) + await waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`)) await cleanup?.() expect(posts.every((post) => post.payload?.sessionID === SES_A)).toBe(true) }) @@ -603,14 +702,19 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { const cleanup = await start(tui) tui.navigate(SES_A) const a = turn(SES_A, 'A') - await pump(tui, [...a.start, ...childStart(SES_A), permission(SES_CHILD)]) - await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`PermissionRequest:${SES_A}`)) + await pump(tui, [...a.start, ...childStart(SES_A)]) + await tick(60) + await pump(tui, [permission(SES_CHILD)]) + await tick(479) + expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`]) + await tick(1) + expect(summary(posts).at(-1)).toBe(`PermissionRequest:${SES_A}`) await pump(tui, [ { type: 'permission.replied', data: { sessionID: SES_CHILD, requestID: 'per_1' } }, { type: 'session.execution.succeeded', data: { sessionID: SES_CHILD } }, ...a.finish ]) - await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`)) + await waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`)) await cleanup?.() expect(statuses(posts)).toEqual([ `SessionBusy:${SES_A}`, @@ -627,7 +731,7 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { tui.navigate(SES_A) const a = turn(SES_A, 'A spawns a background task') await pump(tui, [...a.start, ...childStart(SES_A), permission(SES_CHILD)]) - await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`PermissionRequest:${SES_A}`)) + await waitFor(() => expect(summary(posts).at(-1)).toBe(`PermissionRequest:${SES_A}`)) await pump(tui, a.finish) await tick(300) expect(summary(posts).at(-1)).toBe(`PermissionRequest:${SES_A}`) @@ -639,11 +743,11 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { const cleanup = await start(tui) tui.navigate(SES_A) await pump(tui, [...turn(SES_A, 'A').start, permission(SES_A)]) - await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`PermissionRequest:${SES_A}`)) + await waitFor(() => expect(summary(posts).at(-1)).toBe(`PermissionRequest:${SES_A}`)) // The user browses away; the reply lands while this TUI is disconnected. tui.navigate(SES_B) await pump(tui, [{ type: 'server.connected', data: {} }]) - await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionBusy:${SES_A}`)) + await waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionBusy:${SES_A}`)) await cleanup?.() }) @@ -652,7 +756,7 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { const cleanup = await start(tui) tui.navigate(SES_A) await pump(tui, [...turn(SES_A, 'A').start, permission(SES_A)]) - await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`PermissionRequest:${SES_A}`)) + await waitFor(() => expect(summary(posts).at(-1)).toBe(`PermissionRequest:${SES_A}`)) const release = tui.holdPermissionFetch() tui.serverPermissions.set(SES_A, [...(tui.permissions.get(SES_A) ?? [])]) await pump(tui, [ @@ -668,26 +772,22 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { // Why: an Orca restart moves the hook endpoint while the pane's level stays the same. it('re-posts the current level once the hook endpoint moves', async () => { - vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] }) - try { - const tui = fakeTui() - const cleanup = await start(tui) - tui.navigate(SES_A) - await pump(tui, turn(SES_A, 'A').start) - vi.advanceTimersByTime(200) - await vi.waitFor(() => expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`])) - process.env.ORCA_AGENT_HOOK_PORT = '59998' - vi.advanceTimersByTime(5000) - await vi.waitFor(() => - expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`, `SessionBusy:${SES_A}`]) - ) - vi.advanceTimersByTime(5000) - await tick(50) - expect(statuses(posts)).toHaveLength(2) - await cleanup?.() - } finally { - vi.useRealTimers() - } + const tui = fakeTui() + const cleanup = await start(tui) + tui.navigate(SES_A) + await pump(tui, turn(SES_A, 'A').start) + await tick(200) + await waitFor(() => expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`])) + process.env.ORCA_AGENT_HOOK_PORT = '59998' + await tick(4699) + expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`]) + await tick(1) + expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`, `SessionBusy:${SES_A}`]) + await tick(300) + await tick(5000) + await tick(50) + expect(statuses(posts)).toHaveLength(2) + await cleanup?.() }) it('does not pin Needs input on a request the data kept after its turn ended', async () => { @@ -696,9 +796,9 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { tui.navigate(SES_A) const a = turn(SES_A, 'A') await pump(tui, [...a.start, permission(SES_A)]) - await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`PermissionRequest:${SES_A}`)) + await waitFor(() => expect(summary(posts).at(-1)).toBe(`PermissionRequest:${SES_A}`)) await pump(tui, a.finish) - await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`)) + await waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`)) await tick(250) expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`) await cleanup?.() diff --git a/src/main/persistence/loading-store/profile-state-sqlite-authority.test.ts b/src/main/persistence/loading-store/profile-state-sqlite-authority.test.ts index 5f679d3128d..eff2cb3e6a5 100644 --- a/src/main/persistence/loading-store/profile-state-sqlite-authority.test.ts +++ b/src/main/persistence/loading-store/profile-state-sqlite-authority.test.ts @@ -127,14 +127,14 @@ describe('Store with an injected SQLite profile-state authority', () => { store.updateSettings({ terminalFontSize: store.getSettings().terminalFontSize + 1 }) await store.flushPendingOrThrowAsync() - expect(readFileSync(dataFile)).toEqual(legacyBytes) + expect(readFileSync(dataFile).equals(legacyBytes)).toBe(true) expect(existsSync(databaseFile)).toBe(true) const reloaded = new Store({ dataFile, profileStateAuthority: authority }) expect(reloaded.getSettings().theme).toBe('dark') expect(reloaded.getSettings().terminalFontSize).toBe(store.getSettings().terminalFontSize) expect(reloaded.getSettings().opencodeSessionCookie).toBe('authority-secret') - expect(readFileSync(dataFile)).toEqual(legacyBytes) + expect(readFileSync(dataFile).equals(legacyBytes)).toBe(true) reloaded.freezeWrites() }) @@ -848,7 +848,7 @@ describe('Store with an injected SQLite profile-state authority', () => { 'store-recovery-test' ) - expect(readFileSync(join(result.directory, 'profile-state.db'))).toEqual(sourceBytes) + expect(readFileSync(join(result.directory, 'profile-state.db')).equals(sourceBytes)).toBe(true) expect(readFileSync(join(result.directory, 'profile-state.db-wal'), 'utf8')).toBe( 'wal-preservation-sentinel' ) @@ -856,7 +856,7 @@ describe('Store with an injected SQLite profile-state authority', () => { profileId: 'profile-authority-test', reason: 'store-recovery-test' }) - expect(readFileSync(databasePath)).toEqual(sourceBytes) + expect(readFileSync(databasePath).equals(sourceBytes)).toBe(true) }) it('prepares frozen JSON imports without permitting file publication', () => { diff --git a/src/main/persistence/loading-store/profile-state-store-backups.test.ts b/src/main/persistence/loading-store/profile-state-store-backups.test.ts index aa60618cc42..f712bc691ce 100644 --- a/src/main/persistence/loading-store/profile-state-store-backups.test.ts +++ b/src/main/persistence/loading-store/profile-state-store-backups.test.ts @@ -129,7 +129,7 @@ describe('Store automatic SQLite recovery snapshots', () => { expect(readSnapshot(backups[0].path).state.workspaceSession.activeWorktreeId).toBe( 'backup-worktree' ) - expect(readFileSync(state.retained[0].path)).toEqual(state.retainedBytes) + expect(readFileSync(state.retained[0].path).equals(state.retainedBytes)).toBe(true) expect(readFileSync(state.dataFile, 'utf8')).toBe(state.legacyBytes) expect( readdirSync(state.directory) @@ -208,7 +208,7 @@ describe('Store automatic SQLite recovery snapshots', () => { const backups = profileStateDatabaseBackups(state.databasePath) expect(backups).toHaveLength(2) expect(readSnapshot(backups[0].path).state.settings.theme).toBe('dark') - expect(readFileSync(state.retained[0].path)).toEqual(state.retainedBytes) + expect(readFileSync(state.retained[0].path).equals(state.retainedBytes)).toBe(true) expect(JSON.parse(readFileSync(state.dataFile, 'utf8'))).toEqual( readSnapshot(state.databasePath).state ) @@ -241,7 +241,7 @@ describe('Store automatic SQLite recovery snapshots', () => { ) expect(readSnapshot(state.databasePath).state.settings.theme).toBe('dark') expect(profileStateDatabaseBackups(state.databasePath)).toEqual(state.retained) - expect(readFileSync(state.retained[0].path)).toEqual(state.retainedBytes) + expect(readFileSync(state.retained[0].path).equals(state.retainedBytes)).toBe(true) expect(readSnapshot(state.retained[0].path).state.settings.theme).toBe('light') expect(readFileSync(state.dataFile, 'utf8')).toBe(state.legacyBytes) expect(existsSync(`${state.dataFile}.bak.0`)).toBe(false) diff --git a/src/main/persistence/profile-state/profile-state-authority-bootstrap.test.ts b/src/main/persistence/profile-state/profile-state-authority-bootstrap.test.ts index 8d7b7f72674..21c13c3808f 100644 --- a/src/main/persistence/profile-state/profile-state-authority-bootstrap.test.ts +++ b/src/main/persistence/profile-state/profile-state-authority-bootstrap.test.ts @@ -197,7 +197,7 @@ describe('profile state authority bootstrap', () => { expect(() => bootstrapProfileStateAuthority(options)).toThrow( ProfileStateRecoveryRequiredError ) - expect(readFileSync(options.databaseFile)).toEqual(before) + expect(readFileSync(options.databaseFile).equals(before)).toBe(true) } const exportPath = profileStateJsonExportPath(options.dataFile, 1) writeFileSync(exportPath, raw) @@ -502,7 +502,7 @@ describe('profile state authority bootstrap', () => { expect.arrayContaining([options.databaseFile, `${options.databaseFile}-wal`]) ) expect(existsSync(options.databaseFile)).toBe(false) - expect(readFileSync(options.dataFile)).toEqual(restoredJson) + expect(readFileSync(options.dataFile).equals(restoredJson)).toBe(true) expect(existsSync(exportPath)).toBe(false) expect(readFileSync(join(result.quarantine.directory, 'profile-state.db'), 'utf8')).toBe( 'corrupt sqlite primary' @@ -538,7 +538,7 @@ describe('profile state authority bootstrap', () => { exportPath }) ).toThrow('Profile state JSON is invalid') - expect(readFileSync(options.databaseFile)).toEqual(databaseBytes) - expect(readFileSync(options.dataFile)).toEqual(dataBytes) + expect(readFileSync(options.databaseFile).equals(databaseBytes)).toBe(true) + expect(readFileSync(options.dataFile).equals(dataBytes)).toBe(true) }) }) diff --git a/src/main/persistence/profile-state/profile-state-backup-worker.test.ts b/src/main/persistence/profile-state/profile-state-backup-worker.test.ts index 9e1d09b761f..a2d40b8328c 100644 --- a/src/main/persistence/profile-state/profile-state-backup-worker.test.ts +++ b/src/main/persistence/profile-state/profile-state-backup-worker.test.ts @@ -161,7 +161,7 @@ describe('profile state backup worker', () => { await expect(runProfileStateBackupWorker(job, { workerPath })).rejects.toThrow(expectedError) expect(existsSync(job.targetPath)).toBe(false) expect(readFileSync(retained, 'utf8')).toBe('previous recovery point') - expect(readFileSync(job.databasePath)).toEqual(before) + expect(readFileSync(job.databasePath).equals(before)).toBe(true) }) it.each(['true', 'false'])('waits for actual exit after an ok=%s response', async (ok) => { @@ -251,7 +251,7 @@ describe('profile state backup worker', () => { await expect( runProfileStateBackupWorker(job, { workerPath: join(directory, 'missing.js') }) ).rejects.toThrow() - expect(readFileSync(job.databasePath)).toEqual(before) + expect(readFileSync(job.databasePath).equals(before)).toBe(true) }) it('coalesces desktop work and drains a started backup before allowing quarantine', async () => { diff --git a/src/main/persistence/profile-state/profile-state-database-snapshot.test.ts b/src/main/persistence/profile-state/profile-state-database-snapshot.test.ts index da0ebcf7ac5..238e067e9a3 100644 --- a/src/main/persistence/profile-state/profile-state-database-snapshot.test.ts +++ b/src/main/persistence/profile-state/profile-state-database-snapshot.test.ts @@ -80,7 +80,7 @@ describe('asynchronous profile-state database snapshots', () => { } expect(exportProfileStateJson(db)).toBe(originalJson) expect(db.pragma('journal_mode', { simple: true })).toBe('wal') - expect(readFileSync(databasePath)).toEqual(sourceFile) + expect(readFileSync(databasePath).equals(sourceFile)).toBe(true) importProfileStateJson(db, JSON.stringify({ settings: { theme: 'dark' } })) expect(readSnapshot(targetPath)).toBe(originalJson) expectNoTemporaryFiles(directory) @@ -150,7 +150,7 @@ describe('asynchronous profile-state database snapshots', () => { 'injected native backup failure' ) - expect(readFileSync(targetPath)).toEqual(previous) + expect(readFileSync(targetPath).equals(previous)).toBe(true) expect(exportProfileStateJson(db)).toBe(originalJson) expectNoTemporaryFiles(directory) }) @@ -184,7 +184,7 @@ describe('asynchronous profile-state database snapshots', () => { 'injected rename failure' ) - expect(readFileSync(targetPath)).toEqual(previous) + expect(readFileSync(targetPath).equals(previous)).toBe(true) expectNoTemporaryFiles(directory) }) @@ -202,7 +202,7 @@ describe('asynchronous profile-state database snapshots', () => { db.exec('ROLLBACK') } - expect(readFileSync(targetPath)).toEqual(previous) + expect(readFileSync(targetPath).equals(previous)).toBe(true) expect(exportProfileStateJson(db)).toBe(originalJson) expectNoTemporaryFiles(directory) }) diff --git a/src/main/persistence/profile-state/profile-state-database.test.ts b/src/main/persistence/profile-state/profile-state-database.test.ts index 245806b8ad0..b96858346d5 100644 --- a/src/main/persistence/profile-state/profile-state-database.test.ts +++ b/src/main/persistence/profile-state/profile-state-database.test.ts @@ -162,7 +162,7 @@ describe('profile state database', () => { } const after = statSync(dbPath) expect(after.size).toBe(before.size) - expect(readFileSync(dbPath)).toEqual(beforeBytes) + expect(readFileSync(dbPath).equals(beforeBytes)).toBe(true) }) it('opens the current schema read-only without changing its bytes', () => { @@ -179,7 +179,7 @@ describe('profile state database', () => { } finally { opened.db.close() } - expect(readFileSync(dbPath)).toEqual(before) + expect(readFileSync(dbPath).equals(before)).toBe(true) }) it('rejects a database whose profile identity does not match', () => { @@ -192,7 +192,7 @@ describe('profile state database', () => { expect(() => openProfileStateDatabase(dbPath, 'profile-b')).toThrowError( expect.objectContaining({ code: 'identity-mismatch' }) ) - expect(readFileSync(dbPath)).toEqual(before) + expect(readFileSync(dbPath).equals(before)).toBe(true) }) it('rejects malformed database bytes without replacing them', () => { @@ -204,7 +204,7 @@ describe('profile state database', () => { expect(() => openProfileStateDatabase(dbPath, 'profile-a')).toThrowError( expect.objectContaining({ code: 'unreadable' }) ) - expect(readFileSync(dbPath)).toEqual(bytes) + expect(readFileSync(dbPath).equals(bytes)).toBe(true) }) it('quarantines the database family without touching live recovery sources', () => { @@ -254,7 +254,7 @@ describe('profile state database', () => { expect(() => openProfileStateDatabase(dbPath, 'profile-a')).toThrowError( expect.objectContaining({ code: 'unreadable' }) ) - expect(readFileSync(dbPath)).toEqual(before) + expect(readFileSync(dbPath).equals(before)).toBe(true) }) it('rejects an incomplete current schema without mutating it', () => { @@ -268,7 +268,7 @@ describe('profile state database', () => { expect(() => openProfileStateDatabase(dbPath, 'profile-a')).toThrowError( expect.objectContaining({ code: 'unreadable' }) ) - expect(readFileSync(dbPath)).toEqual(before) + expect(readFileSync(dbPath).equals(before)).toBe(true) }) it('rejects current-version tables with incompatible columns without mutating them', () => { @@ -290,7 +290,7 @@ describe('profile state database', () => { expect(() => openProfileStateDatabase(dbPath, 'profile-a')).toThrowError( expect.objectContaining({ code: 'unreadable' }) ) - expect(readFileSync(dbPath)).toEqual(before) + expect(readFileSync(dbPath).equals(before)).toBe(true) }) it('does not create an empty database when the parent directory is absent', () => { diff --git a/src/main/runtime/quarter-circle-title-send-authorization.test.ts b/src/main/runtime/quarter-circle-title-send-authorization.test.ts index 8def14f0d99..afaa8362409 100644 --- a/src/main/runtime/quarter-circle-title-send-authorization.test.ts +++ b/src/main/runtime/quarter-circle-title-send-authorization.test.ts @@ -1,7 +1,7 @@ // STA-4028 (regression from #13925): quarter circles are ordinary progress glyphs — // ora, installers, any TUI animates them — so a title carrying nothing else must not // authorize a guarded send, which auto-submits with Enter into whatever owns the pane. -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService } from './orca-runtime' import { assertTerminalAgentSendable } from './rpc/terminal-agent-send-guard' import { detectAgentStatusFromTitle } from '../../shared/agent-detection' @@ -18,6 +18,11 @@ const TAB_ID = 'tab-1' const WORKTREE_ID = 'wt-1' const PTY_ID = 'pty-1' +afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() +}) + // Captured from Claude Code 2.1.228 while it read this repository's package.json. const SPINNER_ONLY_TITLE = '◑ Check package version in package.json' const SPINNER_WITH_IDENTITY_TITLE = '◐ Claude Code' @@ -91,6 +96,42 @@ async function createRuntimeWithTitle( const AUTHORIZED = 'authorized' +// The 150ms foreground tick first crosses its unchanged 6,500ms budget at 6,600ms. +const WRAPPER_REJECTION_TICK_MS = 6_600 +const NO_AGENT_GUARD_DEADLINE_MS = 1_050 + +async function readAgentEvidenceAtDeadline( + read: () => Promise, + deadlineMs: number +): Promise { + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }) + try { + let settled = false + const result = read() + void result.then( + () => { + settled = true + }, + () => { + settled = true + } + ) + await vi.advanceTimersByTimeAsync(deadlineMs - 1) + expect(settled, 'agent evidence settled before its deadline').toBe(false) + await vi.advanceTimersByTimeAsync(1) + expect(settled, 'agent evidence did not settle at its deadline').toBe(true) + expect(vi.getTimerCount()).toBe(0) + return await result + } finally { + try { + await vi.runOnlyPendingTimersAsync() + } finally { + vi.clearAllTimers() + vi.useRealTimers() + } + } +} + async function guardedSendResult(runtime: OrcaRuntimeService, handle: string): Promise { try { await assertTerminalAgentSendable({ runtime, handle, assertWritable: () => {} }) @@ -104,10 +145,18 @@ describe('quarter-circle title send authorization (STA-4028)', () => { it('refuses a guarded send when a quarter-circle spinner is the only agent evidence', async () => { const { runtime, handle } = await createRuntimeWithTitle(SPINNER_ONLY_TITLE, 'node') - await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ - isRunningAgent: false - }) - await expect(guardedSendResult(runtime, handle)).resolves.toBe('terminal_guard_no_agent') + expect( + await readAgentEvidenceAtDeadline( + () => runtime.getTerminalAgentStatus(handle), + WRAPPER_REJECTION_TICK_MS + ) + ).toMatchObject({ isRunningAgent: false }) + expect( + await readAgentEvidenceAtDeadline( + () => guardedSendResult(runtime, handle), + WRAPPER_REJECTION_TICK_MS + ) + ).toBe('terminal_guard_no_agent') }) it('refuses a guarded send when the foreground process cannot be read at all', async () => { @@ -115,7 +164,12 @@ describe('quarter-circle title send authorization (STA-4028)', () => { // stays a refusal rather than falling back to the glyph. const { runtime, handle } = await createRuntimeWithTitle(SPINNER_ONLY_TITLE, null) - await expect(guardedSendResult(runtime, handle)).resolves.toBe('terminal_guard_no_agent') + expect( + await readAgentEvidenceAtDeadline( + () => guardedSendResult(runtime, handle), + NO_AGENT_GUARD_DEADLINE_MS + ) + ).toBe('terminal_guard_no_agent') }) it('authorizes a guarded send when the foreground process is a recognized agent', async () => { @@ -158,7 +212,12 @@ describe('quarter-circle title send authorization (STA-4028)', () => { false ) - await expect(guardedSendResult(runtime, handle)).resolves.toBe('terminal_guard_no_agent') + expect( + await readAgentEvidenceAtDeadline( + () => guardedSendResult(runtime, handle), + NO_AGENT_GUARD_DEADLINE_MS + ) + ).toBe('terminal_guard_no_agent') }) it('does not carry managed Claude identity into a replacement PTY incarnation', async () => { @@ -198,9 +257,12 @@ describe('quarter-circle title send authorization (STA-4028)', () => { await expect(runtime.getTerminalAgentStatus(replacementHandle)).resolves.toMatchObject({ isRunningAgent: false }) - await expect(guardedSendResult(runtime, replacementHandle)).resolves.toBe( - 'terminal_guard_no_agent' - ) + expect( + await readAgentEvidenceAtDeadline( + () => guardedSendResult(runtime, replacementHandle), + NO_AGENT_GUARD_DEADLINE_MS + ) + ).toBe('terminal_guard_no_agent') }) it('authorizes a guarded send when the busy title itself names the agent', async () => { diff --git a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts index 36ab233a915..57e632a6c85 100644 --- a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts +++ b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts @@ -35,11 +35,17 @@ import { stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import { createCoordinatorMailObservationClock } from './structured-chat-coordinator-observation-clock.test-fixture' // The turns a send or Stop is waiting on to open, so a test knows the wait began. const openWaits = vi.hoisted(() => { - const turnIds: string[] = [] - return { turnIds } + const state: { + turnIds: string[] + ended: string[] + began: (() => void) | null + releaseAll: (() => void) | null + } = { turnIds: [], ended: [], began: null, releaseAll: null } + return state }) vi.mock('../codex/codex-structured-turn-open-wait', async (importOriginal) => { const actual = await importOriginal() @@ -47,11 +53,16 @@ vi.mock('../codex/codex-structured-turn-open-wait', async (importOriginal) => { ...actual, createCodexTurnOpenWaits: () => { const waits = actual.createCodexTurnOpenWaits() + openWaits.releaseAll = waits.releaseAll return { ...waits, wait: (turnId: string, withinMs: number) => { openWaits.turnIds.push(turnId) - return waits.wait(turnId, withinMs) + const pending = waits.wait(turnId, withinMs) + openWaits.began?.() + return pending.then(() => { + openWaits.ended.push(turnId) + }) } } } @@ -170,6 +181,8 @@ function verdictOf(submissions: readonly AgentJournalSubmission[], clientMessage beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-codex-turn-end-')) openWaits.turnIds.length = 0 + openWaits.ended.length = 0 + openWaits.began = null answers = 0 steers = 0 interrupts = 0 @@ -239,6 +252,8 @@ beforeEach(async () => { }) afterEach(async () => { + openWaits.releaseAll?.() + vi.useRealTimers() await stopStructuredAgentSessionRuntime() await rm(root, { recursive: true, force: true }) }) @@ -341,7 +356,14 @@ describe('a queued card sent now into the turn a Stop ends', () => { ) // A drain ignoring the pause re-sends only after the stopped turn ends: watch past that. await vi.waitFor(() => expect(turns.turnId).toBeNull()) - await new Promise((resolve) => setTimeout(resolve, 2_500)) + const clock = createCoordinatorMailObservationClock(() => host, SESSION) + clock.start() + try { + await clock.observe(2_500) + await host.collaboratorsForTests().serialize(SESSION, async () => {}) + } finally { + clock.restore() + } expect(steers + answers).toBe(2) expect(await sends(cardId)).toEqual([{ origin: 'client', verdict: 'withdrawn' }]) }, 20_000) @@ -454,9 +476,17 @@ describe('a Stop sent after Codex answered a cold send, before it opened the tur const sent = await send('look around') await vi.waitFor(() => expect(answers).toBe(1)) + await host.flushStreamedEvents(SESSION) + const began = Promise.withResolvers() + openWaits.began = began.resolve + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }) const stopping = stop() + await began.promise // Without the wait, it would reach Codex now, which would refuse it, and be done. - expect(await settledWithin(stopping, 1_000)).toBe('held') + const held = settledWithin(stopping, 1_000) + await vi.advanceTimersByTimeAsync(1_000) + expect(openWaits.ended).toEqual([]) + expect(await held).toBe('held') expect(interrupts).toBe(0) turns.start() await stopping @@ -480,8 +510,15 @@ describe('a Stop in that window that the turn never opens for', () => { async function waitingStop(): Promise<{ stopping: Promise }> { await send('look around') await vi.waitFor(() => expect(answers).toBe(1)) + await host.flushStreamedEvents(SESSION) + const began = Promise.withResolvers() + openWaits.began = began.resolve + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }) const stopping = stop() - expect(await settledWithin(stopping, 200)).toBe('held') + await began.promise + const held = settledWithin(stopping, 200) + await vi.advanceTimersByTimeAsync(200) + expect(await held).toBe('held') return { stopping } } @@ -499,10 +536,17 @@ describe('a Stop in that window that the turn never opens for', () => { const { stopping } = await waitingStop() const closing = host.close(SESSION, 'evict') + const closedWithinBound = settledWithin( + closing, + CODEX_TURN_OPEN_WAIT_MS + CHILD_EVICTION_TIMEOUT_MS + ) + await vi.advanceTimersByTimeAsync(CODEX_TURN_OPEN_WAIT_MS - 201) + expect(openWaits.ended).toEqual([]) + expect(childCloses).toBe(0) + await vi.advanceTimersByTimeAsync(1) + expect(openWaits.ended).toEqual(['turn-1']) - expect( - await settledWithin(closing, CODEX_TURN_OPEN_WAIT_MS + CHILD_EVICTION_TIMEOUT_MS) - ).not.toBe('held') + expect(await closedWithinBound).not.toBe('held') expect(await settledWithin(stopping, 0)).not.toBe('held') expect(childCloses).toBe(1) expect(interrupts).toBe(0) @@ -512,9 +556,16 @@ describe('a Stop in that window that the turn never opens for', () => { it('lets the app quit behind it within the eviction budget, and still close the child', async () => { await waitingStop() - expect( - await settledWithin(stopStructuredAgentSessionRuntime(), CHILD_EVICTION_TIMEOUT_MS) - ).not.toBe('held') + const quitWithinBound = settledWithin( + stopStructuredAgentSessionRuntime(), + CHILD_EVICTION_TIMEOUT_MS + ) + await vi.advanceTimersByTimeAsync(CODEX_TURN_OPEN_WAIT_MS - 201) + expect(openWaits.ended).toEqual([]) + expect(childCloses).toBe(0) + await vi.advanceTimersByTimeAsync(1) + expect(openWaits.ended).toEqual(['turn-1']) + expect(await quitWithinBound).not.toBe('held') expect(childCloses).toBe(1) }) }) diff --git a/src/main/ssh/ssh-relay-gc-listing.test-fixture.ts b/src/main/ssh/ssh-relay-gc-listing.test-fixture.ts new file mode 100644 index 00000000000..d2e5da79387 --- /dev/null +++ b/src/main/ssh/ssh-relay-gc-listing.test-fixture.ts @@ -0,0 +1,148 @@ +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { PassThrough } from 'node:stream' +import type { ClientChannel } from 'ssh2' +import { runProcess, spawnProcess } from '../../shared/child-process/run-process' +import type { SshConnection } from './ssh-connection' +import { shellEscape } from './ssh-connection-utils' +import { execCommand } from './ssh-relay-exec-command' + +export const SSH_EXEC_OUTPUT_CAP_CHARS = 1024 * 1024 + +class RelayGcShellChannel extends PassThrough implements ClientChannel { + stdin: this = this + stdout: this = this + stderr: ReturnType['stderr'] + server = false as const + type = 'session' as const + subtype = 'exec' as const + incoming: unknown = null + outgoing: unknown = null + private readonly exited = Promise.withResolvers() + + constructor(private readonly child: ReturnType) { + super({ autoDestroy: false, emitClose: false }) + this.stderr = child.stderr + child.stdout.pipe(this) + child.stdout.on('error', (error) => this.emit('error', error)) + child.on('error', (error) => { + this.exited.resolve() + this.emit('error', error) + }) + child.on('close', (code) => { + this.exited.resolve() + this.emit('close', code) + }) + child.stdin.end() + } + + async dispose(): Promise { + this.close() + await this.exited.promise + this.destroy() + } + + close(): void { + this.child.kill() + } + + eof(): void { + this.end() + } + + setWindow(): void { + throw new Error('GC listing does not resize a terminal') + } + + signal(): void { + throw new Error('GC listing does not signal a terminal') + } + + exit(): void { + throw new Error('GC listing does not set a remote exit status') + } +} + +export async function runCappedRelayGcShellCommand(command: string): Promise { + let channel: RelayGcShellChannel | undefined + const connection: Pick = { + exec: async (shellCommand) => { + channel = new RelayGcShellChannel( + spawnProcess({ program: '/bin/sh', args: ['-c', shellCommand] }) + ) + return channel + }, + usesSystemSshTransport: () => false + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: execCommand only reads exec and usesSystemSshTransport; both methods are checked above. + const sshConnection = connection as SshConnection + try { + return await execCommand(sshConnection, command) + } finally { + await channel?.dispose() + } +} + +export function createRelayGcListingFixture(): { + root: string + findCommand: string + validNames: string[] + fillValidEntryBoundary: () => void + dispose: () => void +} { + const workspace = mkdtempSync(join(tmpdir(), 'orca-relay-gc-scale-')) + let root = workspace + try { + // Long Unicode paths cross the SSH character cap with fewer real directories. + for (let index = 0; index < 4; index += 1) { + root = join(root, `gc-context-${index}-ü-${'x'.repeat(150)}`) + mkdirSync(root) + } + const validNames = ['relay-0.1.0+aaa', 'relay-0.1.0+bbb'] + const stageName = (index: number): string => + `relay-9.9.9+abc.upload-${String(index).padStart(12, '0')}` + const stageCount = + Math.ceil(SSH_EXEC_OUTPUT_CAP_CHARS / (join(root, stageName(0)).length + 1)) + 1 + for (let index = 0; index < stageCount; index += 1) { + mkdirSync(join(root, stageName(index))) + } + for (const name of validNames) { + mkdirSync(join(root, name)) + } + mkdirSync(join(root, 'relay-0.1.0+abc.upload-000000000000')) + mkdirSync(join(root, 'relay-0.1.0+ggg')) + mkdirSync(join(root, 'orcad-0.1.0+aaa')) + mkdirSync(join(root, stageName(0), 'relay-0.1.0+ccc')) + writeFileSync(join(root, 'relay-0.1.0+ddd'), '') + symlinkSync(join(root, validNames[0]), join(root, 'relay-0.1.0+eee'), 'dir') + return { + root, + findCommand: `find ${shellEscape(root)} -mindepth 1 -maxdepth 1 -type d -name 'relay-*' -print`, + validNames, + fillValidEntryBoundary: () => { + for (let index = 0; index < 63; index += 1) { + const name = `relay-0.1.0+${index.toString(16)}` + validNames.push(name) + mkdirSync(join(root, name)) + } + }, + dispose: () => rmSync(workspace, { recursive: true, force: true }) + } + } catch (error) { + rmSync(workspace, { recursive: true, force: true }) + throw error + } +} + +export async function readUncappedRelayGcPaths(command: string): Promise { + const result = await runProcess({ + program: '/bin/sh', + args: ['-c', command], + maxOutputBytes: 4 * SSH_EXEC_OUTPUT_CAP_CHARS + }) + if (result.code !== 0 || result.timedOut || result.outputTruncated) { + throw new Error(`GC fixture enumeration failed: ${result.code}: ${result.stderr}`) + } + return result.stdout.trim().split('\n') +} diff --git a/src/main/ssh/ssh-remote-commands.test.ts b/src/main/ssh/ssh-remote-commands.test.ts index 758d4739b0f..76073d24d89 100644 --- a/src/main/ssh/ssh-remote-commands.test.ts +++ b/src/main/ssh/ssh-remote-commands.test.ts @@ -11,7 +11,7 @@ import { utimesSync } from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { basename, join } from 'node:path' import { describe, expect, it } from 'vitest' import { decodeRemotePowerShellScript } from './ssh-remote-powershell' import { @@ -31,6 +31,12 @@ import { relayLivenessProbeCommand } from './ssh-remote-commands' import { getRemoteHostPlatform } from './ssh-remote-platform' +import { + createRelayGcListingFixture, + readUncappedRelayGcPaths, + runCappedRelayGcShellCommand, + SSH_EXEC_OUTPUT_CAP_CHARS +} from './ssh-relay-gc-listing.test-fixture' import { RELAY_INSTALL_COMPLETE_FILENAME, relayArtifactFilenames @@ -256,22 +262,43 @@ describe('ssh remote command builders', () => { it.runIf(process.platform !== 'win32')( 'bounds real POSIX GC output with more than the exec-cap stage population', async () => { - const root = mkdtempSync(join(tmpdir(), 'orca-relay-gc-scale-')) + const fixture = createRelayGcListingFixture() try { - for (let index = 0; index < 15_197; index += 1) { - mkdirSync(join(root, `relay-0.1.0+abc.upload-${String(index).padStart(12, '0')}`)) + const paths = await readUncappedRelayGcPaths(fixture.findCommand) + expect(paths.join('\n').length).toBeGreaterThan(SSH_EXEC_OUTPUT_CAP_CHARS) + const cappedRaw = await runCappedRelayGcShellCommand( + `${fixture.findCommand} | LC_ALL=C sort` + ) + expect(cappedRaw.length).toBe(SSH_EXEC_OUTPUT_CAP_CHARS) + for (const name of fixture.validNames) { + expect(cappedRaw).not.toContain(join(fixture.root, name)) } - mkdirSync(join(root, 'relay-0.1.0+aaa')) - mkdirSync(join(root, 'relay-0.1.0+bbb')) - const output = await runShellCommand(listRelayBaseDirsCommand(posix, root)) + const output = await runCappedRelayGcShellCommand( + listRelayBaseDirsCommand(posix, fixture.root) + ) const entries = output.trim().split('\n') expect(entries).toEqual(['relay-0.1.0+aaa', 'relay-0.1.0+bbb']) expect(Buffer.byteLength(output)).toBeLessThan(1_024) expect(entries.length).toBeLessThanOrEqual(MAX_RELAY_GC_LISTING_ENTRIES) + + fixture.fillValidEntryBoundary() + const validNames = new Set(fixture.validNames) + const unboundedEntries = (await readUncappedRelayGcPaths(fixture.findCommand)) + .map((path) => basename(path)) + .filter((name) => validNames.has(name)) + expect(unboundedEntries).toHaveLength(65) + const boundedOutput = await runCappedRelayGcShellCommand( + listRelayBaseDirsCommand(posix, fixture.root) + ) + const boundedEntries = boundedOutput.trim().split('\n') + expect(boundedEntries).toEqual(unboundedEntries.slice(0, 64)) + expect(boundedEntries).toHaveLength(64) + expect(new Set(boundedEntries).size).toBe(64) + expect(Buffer.byteLength(boundedOutput)).toBeLessThan(1_024) } finally { - rmSync(root, { recursive: true, force: true }) + fixture.dispose() } }, 30_000 diff --git a/src/main/window/attach-main-window-services.test.ts b/src/main/window/attach-main-window-services.test.ts index 9e5c5493f09..6a685f2f982 100644 --- a/src/main/window/attach-main-window-services.test.ts +++ b/src/main/window/attach-main-window-services.test.ts @@ -1,6 +1,16 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { Store } from '../persistence' -import type { RuntimeNotifier } from '../runtime/runtime-notifier-contract' +import type { registerSshHandlers } from '../ipc/ssh' +import type { registerRemoteWorkspaceHandlers } from '../ipc/remote-workspace' +import type { registerDaemonManagementHandlers } from '../ipc/pty-management' +import type { registerWorkspaceCleanupHandlers } from '../ipc/workspace-cleanup' +import type { startFolderRepoGitUpgradeWatch } from '../ipc/folder-repo-git-upgrade' +import { + createMainWindowServiceStub, + createRuntime, + deferred, + type MainWindowStub +} from './main-window-service-stubs.test-fixture' const { onMock, @@ -17,6 +27,11 @@ const { setWorktreeCatalogRemoteClientNotifierMock, registerWorktreeHandlersMock, registerPtyHandlersMock, + registerSshHandlersMock, + registerRemoteWorkspaceHandlersMock, + registerDaemonManagementHandlersMock, + registerWorkspaceCleanupHandlersMock, + startFolderRepoGitUpgradeWatchMock, hydrateLocalPtyRegistryAtBootMock, setWorktreeBaseDirectoryWatcherSyncContextMock, scheduleWorktreeBaseDirectoryWatcherSyncMock, @@ -42,6 +57,12 @@ const { setWorktreeCatalogRemoteClientNotifierMock: vi.fn(), registerWorktreeHandlersMock: vi.fn(), registerPtyHandlersMock: vi.fn(), + registerSshHandlersMock: vi.fn<(...args: Parameters) => void>(), + registerRemoteWorkspaceHandlersMock: + vi.fn<(...args: Parameters) => void>(), + registerDaemonManagementHandlersMock: vi.fn(), + registerWorkspaceCleanupHandlersMock: vi.fn(), + startFolderRepoGitUpgradeWatchMock: vi.fn(), hydrateLocalPtyRegistryAtBootMock: vi.fn(), setWorktreeBaseDirectoryWatcherSyncContextMock: vi.fn(), scheduleWorktreeBaseDirectoryWatcherSyncMock: vi.fn(), @@ -99,6 +120,20 @@ vi.mock('../ipc/pty', () => ({ registerPtyHandlers: registerPtyHandlersMock })) +vi.mock('../ipc/ssh', () => ({ registerSshHandlers: registerSshHandlersMock })) +vi.mock('../ipc/remote-workspace', () => ({ + registerRemoteWorkspaceHandlers: registerRemoteWorkspaceHandlersMock +})) +vi.mock('../ipc/pty-management', () => ({ + registerDaemonManagementHandlers: registerDaemonManagementHandlersMock +})) +vi.mock('../ipc/workspace-cleanup', () => ({ + registerWorkspaceCleanupHandlers: registerWorkspaceCleanupHandlersMock +})) +vi.mock('../ipc/folder-repo-git-upgrade', () => ({ + startFolderRepoGitUpgradeWatch: startFolderRepoGitUpgradeWatchMock +})) + vi.mock('../memory/hydrate-local-pty-registry', () => ({ hydrateLocalPtyRegistryAtBoot: hydrateLocalPtyRegistryAtBootMock })) @@ -133,57 +168,16 @@ import { attachMainWindowServices } from './attach-main-window-services' type MockFn = ReturnType -type MainWindowStub = { - id?: number - isDestroyed?: MockFn - on: MockFn - once: MockFn - webContents: { - id?: number - getURL: MockFn - isDestroyed?: MockFn - isLoadingMainFrame: MockFn - on: MockFn - send?: MockFn - reload?: MockFn - session: { - setPermissionRequestHandler: MockFn - setPermissionCheckHandler: MockFn - } - } -} - -type RuntimeStub = { - attachWindow: MockFn - setNotifier: ReturnType void>> - markRendererReloading: MockFn - markRendererReloadCancelled: MockFn - markGraphReloadFailed: MockFn - markGraphUnavailable: MockFn -} - function createMainWindow( extraWebContents: { isLoadingMainFrame?: MockFn; on?: MockFn; send?: MockFn } = {} ): MainWindowStub { - return { - id: 1, - isDestroyed: vi.fn(() => false), - on: vi.fn(), - once: vi.fn(), - webContents: { - id: 1, - getURL: vi.fn(() => 'file:///opt/orca/renderer/index.html'), - isDestroyed: vi.fn(() => false), - isLoadingMainFrame: vi.fn(() => true), - on: vi.fn(), - reload: vi.fn(), - session: { - setPermissionRequestHandler: setPermissionRequestHandlerMock, - setPermissionCheckHandler: setPermissionCheckHandlerMock - }, - ...extraWebContents - } - } + return createMainWindowServiceStub( + { + setPermissionRequestHandler: setPermissionRequestHandlerMock, + setPermissionCheckHandler: setPermissionCheckHandlerMock + }, + extraWebContents + ) } function createStore(): Store & { flushPendingAsync: MockFn } { @@ -193,25 +187,6 @@ function createStore(): Store & { flushPendingAsync: MockFn } { } as unknown as Store & { flushPendingAsync: MockFn } } -function createRuntime(): RuntimeStub { - return { - attachWindow: vi.fn(), - setNotifier: vi.fn<(notifier: RuntimeNotifier | null) => void>(), - markRendererReloading: vi.fn(), - markRendererReloadCancelled: vi.fn(), - markGraphReloadFailed: vi.fn(), - markGraphUnavailable: vi.fn() - } -} - -function deferred(): { promise: Promise; resolve: () => void } { - let resolve!: () => void - const promise = new Promise((next) => { - resolve = next - }) - return { promise, resolve } -} - function getClosedHandlers(mainWindowOnMock: MockFn): (() => void)[] { return mainWindowOnMock.mock.calls .filter(([event]) => event === 'closed') @@ -239,11 +214,30 @@ describe('attachMainWindowServices', () => { it('gives host-local catalog notifiers the runtime', () => { const runtime = createRuntime() + const mainWindow = createMainWindow() + const store = createStore() - attachMainWindowServices(createMainWindow() as never, createStore(), runtime as never) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Stubs provide the window/runtime methods exercised by attachment. + attachMainWindowServices(mainWindow as never, store, runtime as never) expect(setRepoRemoteClientNotifierMock).toHaveBeenCalledWith(runtime) expect(setWorktreeCatalogRemoteClientNotifierMock).toHaveBeenCalledWith(runtime) + expect(registerSshHandlersMock).toHaveBeenCalledExactlyOnceWith( + store, + expect.any(Function), + runtime + ) + expect(registerSshHandlersMock.mock.calls[0]?.[1]()).toBe(mainWindow) + expect(registerRemoteWorkspaceHandlersMock).toHaveBeenCalledExactlyOnceWith( + store, + expect.any(Function), + runtime + ) + expect(registerRemoteWorkspaceHandlersMock.mock.calls[0]?.[1]()).toBe(mainWindow) + expect(registerDaemonManagementHandlersMock).toHaveBeenCalledExactlyOnceWith() + expect(registerDaemonManagementHandlersMock).toHaveBeenCalledAfter(registerPtyHandlersMock) + expect(registerWorkspaceCleanupHandlersMock).toHaveBeenCalledExactlyOnceWith(store) + expect(startFolderRepoGitUpgradeWatchMock).toHaveBeenCalledExactlyOnceWith(store, mainWindow) }) it('reloads the app renderer through main and marks expected renderer teardown', async () => { diff --git a/src/main/window/main-window-service-stubs.test-fixture.ts b/src/main/window/main-window-service-stubs.test-fixture.ts new file mode 100644 index 00000000000..2db0cbcc762 --- /dev/null +++ b/src/main/window/main-window-service-stubs.test-fixture.ts @@ -0,0 +1,80 @@ +import { vi } from 'vitest' +import type { RuntimeNotifier } from '../runtime/runtime-notifier-contract' + +type MockFn = ReturnType + +export type MainWindowStub = { + id?: number + isDestroyed?: MockFn + on: MockFn + once: MockFn + webContents: { + id?: number + getURL: MockFn + isDestroyed?: MockFn + isLoadingMainFrame: MockFn + on: MockFn + send?: MockFn + reload?: MockFn + session: { + setPermissionRequestHandler: MockFn + setPermissionCheckHandler: MockFn + } + } +} + +type RuntimeStub = { + attachWindow: MockFn + setNotifier: ReturnType void>> + markRendererReloading: MockFn + markRendererReloadCancelled: MockFn + markGraphReloadFailed: MockFn + markGraphUnavailable: MockFn +} + +export function createMainWindowServiceStub( + permissionHandlers: { + setPermissionRequestHandler: MockFn + setPermissionCheckHandler: MockFn + }, + extraWebContents: { isLoadingMainFrame?: MockFn; on?: MockFn; send?: MockFn } = {} +): MainWindowStub { + return { + id: 1, + isDestroyed: vi.fn(() => false), + on: vi.fn(), + once: vi.fn(), + webContents: { + id: 1, + getURL: vi.fn(() => 'file:///opt/orca/renderer/index.html'), + isDestroyed: vi.fn(() => false), + isLoadingMainFrame: vi.fn(() => true), + on: vi.fn(), + reload: vi.fn(), + session: { + setPermissionRequestHandler: permissionHandlers.setPermissionRequestHandler, + setPermissionCheckHandler: permissionHandlers.setPermissionCheckHandler + }, + ...extraWebContents + } + } +} + +export function createRuntime(): RuntimeStub { + return { + attachWindow: vi.fn(), + setNotifier: vi.fn<(notifier: RuntimeNotifier | null) => void>(), + markRendererReloading: vi.fn(), + markRendererReloadCancelled: vi.fn(), + markGraphReloadFailed: vi.fn(), + markGraphUnavailable: vi.fn() + } +} + +export function deferred(): { promise: Promise; resolve: () => void } { + let resolve!: () => void + const promise = new Promise((next) => { + resolve = next + }) + return { promise, resolve } +} diff --git a/src/renderer/src/components/diff-comments/useDiffCommentDecorator.range-selection.test.tsx b/src/renderer/src/components/diff-comments/useDiffCommentDecorator.range-selection.test.tsx index 2708e63a137..1c1e802e650 100644 --- a/src/renderer/src/components/diff-comments/useDiffCommentDecorator.range-selection.test.tsx +++ b/src/renderer/src/components/diff-comments/useDiffCommentDecorator.range-selection.test.tsx @@ -3,6 +3,16 @@ import { act, renderHook } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { Selection } from 'monaco-editor' import type * as DiffCommentZoneCardModule from './diff-comment-zone-card' +import type { NotesSendMenu } from '../editor/NotesSendMenu' + +const notesMenuFixture = vi.hoisted(() => ({ + NotesSendMenu: vi.fn(() => { + throw new Error('Range selection must not render the agent notes menu') + }) +})) + +// Saved-note delivery is outside the selection and inline-draft paths exercised here. +vi.mock('../editor/NotesSendMenu', () => ({ NotesSendMenu: notesMenuFixture.NotesSendMenu })) const storeFixture = vi.hoisted(() => ({ activeGroupIdByWorktree: {}, @@ -146,7 +156,11 @@ beforeEach(() => { afterEach(() => { vi.unstubAllGlobals() document.body.replaceChildren() - vi.clearAllMocks() + try { + expect(notesMenuFixture.NotesSendMenu).not.toHaveBeenCalled() + } finally { + vi.clearAllMocks() + } }) describe('useDiffCommentDecorator range highlight', () => { diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx index 460df23ef8b..c5553f00edb 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx @@ -185,9 +185,20 @@ vi.mock('./NativeChatQuestionCard', () => ({ import { NativeChatStructuredSession } from './NativeChatStructuredSession' import { appendStructuredAgentSessionOutboxMessage } from './structured-agent-session-outbox-storage' +function useProbeClock(): void { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) +} + +async function advanceProbeClock(milliseconds: number): Promise { + await act(async () => { + await vi.advanceTimersByTimeAsync(milliseconds) + }) +} + describe('NativeChatStructuredSession delivery', () => { afterEach(() => { cleanup() + vi.useRealTimers() mocks.call.mockReset() mocks.mode = 'static' mocks.messageListProps = null @@ -461,6 +472,7 @@ describe('NativeChatStructuredSession delivery', () => { }) it('resends a transport-unconfirmed head so later messages are not wedged', async () => { + useProbeClock() mocks.mode = 'outbox' mocks.submissions = [] mocks.call.mockRejectedValueOnce(new Error('socket closed')).mockResolvedValue({ @@ -482,17 +494,25 @@ describe('NativeChatStructuredSession delivery', () => { const send = mocks.composerProps?.structuredTransport?.send as | ((text: string, attachments: readonly { id: string; path: string }[]) => boolean) | undefined - expect(send?.('first', [])).toBe(true) - await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) - await waitFor(() => expect(screen.getByText('Message delivery is unconfirmed.')).toBeTruthy()) + await act(async () => { + expect(send?.('first', [])).toBe(true) + }) + expect(mocks.call).toHaveBeenCalledOnce() + expect(screen.getByText('Message delivery is unconfirmed.')).toBeTruthy() - expect(send?.('second', [])).toBe(true) + await act(async () => { + expect(send?.('second', [])).toBe(true) + }) + await advanceProbeClock(999) + expect(mocks.call).toHaveBeenCalledOnce() + await advanceProbeClock(1) // The head is probed automatically, clears, and the queue drains. - await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(3), { timeout: 10000 }) - await waitFor(() => expect(screen.queryByText('Message delivery is unconfirmed.')).toBeNull()) + expect(mocks.call).toHaveBeenCalledTimes(3) + expect(screen.queryByText('Message delivery is unconfirmed.')).toBeNull() }, 20000) it('probes the same operation without marking an explicit user retry', async () => { + useProbeClock() mocks.mode = 'outbox' mocks.submissions = [] mocks.call.mockRejectedValueOnce(new Error('socket closed')).mockResolvedValue({ @@ -514,8 +534,13 @@ describe('NativeChatStructuredSession delivery', () => { const send = mocks.composerProps?.structuredTransport?.send as | ((text: string, attachments: readonly { id: string; path: string }[]) => boolean) | undefined - expect(send?.('first', [])).toBe(true) - await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2), { timeout: 10000 }) + await act(async () => { + expect(send?.('first', [])).toBe(true) + }) + await advanceProbeClock(999) + expect(mocks.call).toHaveBeenCalledOnce() + await advanceProbeClock(1) + expect(mocks.call).toHaveBeenCalledTimes(2) const first = mocks.call.mock.calls[0]?.[2] as Record const probe = mocks.call.mock.calls[1]?.[2] as Record @@ -527,6 +552,7 @@ describe('NativeChatStructuredSession delivery', () => { }, 20000) it('parks a host-confirmed unknown instead of probing it', async () => { + useProbeClock() mocks.mode = 'outbox' mocks.call.mockRejectedValueOnce(new Error('socket closed')).mockResolvedValue({ ok: true, @@ -547,8 +573,10 @@ describe('NativeChatStructuredSession delivery', () => { const send = mocks.composerProps?.structuredTransport?.send as | ((text: string, attachments: readonly { id: string; path: string }[]) => boolean) | undefined - expect(send?.('first', [])).toBe(true) - await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) + await act(async () => { + expect(send?.('first', [])).toBe(true) + }) + expect(mocks.call).toHaveBeenCalledOnce() const sent = mocks.call.mock.calls[0]?.[2] as { envelope: { clientOperationId: string } } // The host now reports an unresolved unknown: another replay is the user's call. @@ -569,13 +597,12 @@ describe('NativeChatStructuredSession delivery', () => { await act(async () => { send?.('second', []) }) - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 3000)) - }) + await advanceProbeClock(3000) expect(mocks.call).toHaveBeenCalledOnce() }, 20000) it('still probes while streaming batches rebuild the submissions array', async () => { + useProbeClock() mocks.mode = 'outbox' mocks.submissions = [] mocks.call.mockRejectedValueOnce(new Error('socket closed')).mockResolvedValue({ @@ -598,8 +625,10 @@ describe('NativeChatStructuredSession delivery', () => { const send = mocks.composerProps?.structuredTransport?.send as | ((text: string, attachments: readonly { id: string; path: string }[]) => boolean) | undefined - expect(send?.('first', [])).toBe(true) - await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) + await act(async () => { + expect(send?.('first', [])).toBe(true) + }) + expect(mocks.call).toHaveBeenCalledOnce() // Each batch mints a fresh submissions array for an unrelated message. An // array-identity dependency restarts the backoff on every one of these, so a @@ -619,7 +648,7 @@ describe('NativeChatStructuredSession delivery', () => { ] await act(async () => { rerender(makeView()) - await new Promise((resolve) => setTimeout(resolve, 250)) + await vi.advanceTimersByTimeAsync(250) }) } @@ -629,6 +658,7 @@ describe('NativeChatStructuredSession delivery', () => { }, 20000) it('restarts probe delay when the runtime target changes', async () => { + useProbeClock() mocks.mode = 'outbox' mocks.call.mockRejectedValueOnce(new Error('socket closed')).mockResolvedValue({ ok: true, @@ -651,22 +681,24 @@ describe('NativeChatStructuredSession delivery', () => { const send = mocks.composerProps?.structuredTransport?.send as | ((text: string, attachments: readonly { id: string; path: string }[]) => boolean) | undefined - expect(send?.('first', [])).toBe(true) - await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) - await waitFor(() => expect(screen.getByText('Message delivery is unconfirmed.')).toBeTruthy()) - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 300)) - }) - rerender(makeView({ kind: 'environment', environmentId: 'env-1' })) - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 600)) + expect(send?.('first', [])).toBe(true) }) expect(mocks.call).toHaveBeenCalledOnce() - await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2), { timeout: 1500 }) + expect(screen.getByText('Message delivery is unconfirmed.')).toBeTruthy() + + await advanceProbeClock(300) + rerender(makeView({ kind: 'environment', environmentId: 'env-1' })) + await advanceProbeClock(600) + expect(mocks.call).toHaveBeenCalledOnce() + await advanceProbeClock(399) + expect(mocks.call).toHaveBeenCalledOnce() + await advanceProbeClock(1) + expect(mocks.call).toHaveBeenCalledTimes(2) }, 10000) it('never auto-probes an entry the user already force-retried', async () => { + useProbeClock() mocks.mode = 'outbox' mocks.submissions = [] // Both the original send and the user's explicit Retry fail at the transport. @@ -686,25 +718,28 @@ describe('NativeChatStructuredSession delivery', () => { const send = mocks.composerProps?.structuredTransport?.send as | ((text: string, attachments: readonly { id: string; path: string }[]) => boolean) | undefined - expect(send?.('first', [])).toBe(true) - await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) - await waitFor(() => expect(screen.getByText('Message delivery is unconfirmed.')).toBeTruthy()) + await act(async () => { + expect(send?.('first', [])).toBe(true) + }) + expect(mocks.call).toHaveBeenCalledOnce() + expect(screen.getByText('Message delivery is unconfirmed.')).toBeTruthy() // User retries with the same envelope and no legacy redelivery signal. - fireEvent.click(screen.getByRole('button', { name: /Retry/ })) - await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2)) + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /Retry/ })) + }) + expect(mocks.call).toHaveBeenCalledTimes(2) const forcedRequest = mocks.call.mock.calls[1]?.[2] as Record | undefined expect(forcedRequest?.retryUnknown).toBeUndefined() // That retry also failed at the transport. The probe must not repeat an // explicit retry automatically. - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 3000)) - }) + await advanceProbeClock(3000) expect(mocks.call).toHaveBeenCalledTimes(2) }, 20000) it('does not hot-loop when the host answers pending', async () => { + useProbeClock() mocks.mode = 'outbox' mocks.call.mockResolvedValue({ ok: true, @@ -725,15 +760,18 @@ describe('NativeChatStructuredSession delivery', () => { const send = mocks.composerProps?.structuredTransport?.send as | ((text: string, attachments: readonly { id: string; path: string }[]) => boolean) | undefined - expect(send?.('first', [])).toBe(true) - await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) - - // A pending row parks the entry under the backoff instead of re-dispatching - // immediately. Without that, this window is an unbounded back-to-back RPC flood. await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 2500)) + expect(send?.('first', [])).toBe(true) }) - expect(mocks.call.mock.calls.length).toBeLessThanOrEqual(3) + expect(mocks.call).toHaveBeenCalledOnce() + + // A host-pending entry stays parked until the journal answers it. + await advanceProbeClock(999) + expect(mocks.call).toHaveBeenCalledOnce() + await advanceProbeClock(1) + expect(mocks.call).toHaveBeenCalledOnce() + await advanceProbeClock(1500) + expect(mocks.call).toHaveBeenCalledOnce() }, 20000) it('keeps probing past the old five-attempt budget', async () => { From 70cf91299b2a30b16eaef3fa3ec4d3265fe82265 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 4 Oct 2026 05:40:05 -0700 Subject: [PATCH 55/67] Clean up retired OpenCode configuration copies safely (#25222) * fix: wait for OpenCode worker composer before first dispatch Reuse captured composer readiness on local and paired execution hosts and revoke launching-shell paste anchors. Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> * feat(opencode): probe execution-host CLI capabilities * fix(opencode): select plugin default for execution host loader * fix(opencode): limit prompt prefill capability to verified release * feat(opencode): probe launch capabilities on the execution host * fix(opencode): select plugin loader for the launched host binary * fix(opencode): match WSL probe cwd and declared guest environment * fix(opencode): preserve launch environment deletion boundaries * wip(opencode): authorize native startup prompt intent at execution owner * fix(opencode): atomically replace status plugin entrypoints * fix(opencode): retain plugin permissions across restrictive umasks * test(opencode): resolve permission fixture from primary cwd * feat(opencode): install startup prompt plugin independently of status hooks * fix(opencode): wait for admitted startup intent and preserve failed-launch briefs * fix(opencode): confine overlay manifest cleanup to owned directories Co-authored-by: Adnan Khan * fix: wait for OpenCode worker composer before first dispatch Reuse captured composer readiness on local and paired execution hosts and revoke launching-shell paste anchors. Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> * feat(opencode): probe execution-host CLI capabilities * fix(opencode): select plugin default for execution host loader * fix(opencode): limit prompt prefill capability to verified release * feat(opencode): probe launch capabilities on the execution host * fix(opencode): select plugin loader for the launched host binary * fix(opencode): match WSL probe cwd and declared guest environment * fix(opencode): preserve launch environment deletion boundaries * wip(opencode): authorize native startup prompt intent at execution owner * fix(opencode): atomically replace status plugin entrypoints * fix(opencode): retain plugin permissions across restrictive umasks * test(opencode): resolve permission fixture from primary cwd * feat(opencode): install startup prompt plugin independently of status hooks * fix(opencode): wait for admitted startup intent and preserve failed-launch briefs * fix(opencode): unsubscribe hook settings during async host shutdown * STRICT launch CI contract correction * CAPS launch CI contract correction * INTENT launch CI contract correction * test: initialize Claude prompt state in output retention fixture * Wait for OpenCode location hydration in intent startup * Bind OpenCode startup readiness to the current location in intent startup * Collect retired source-scoped OpenCode configuration overlays conservatively Credit brennanb2025 for the original bounded, delayed overlay garbage-collection contribution in PR #7627. Preserve ambiguous legacy and shared-service state. * Correct inaccessible-source fixture without spying on native ESM exports * Keep delayed OpenCode cleanup within existing file limits * Reuse the overlay manifest module for existing owned-entry operations * Use the existing filesystem import in the ownership mock * test(opencode): keep overlay GC link tests portable --------- Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Co-authored-by: Ahmed Nagy Co-authored-by: Adnan Khan Co-authored-by: Orca startup hydration review Co-authored-by: OpenCode Campaign --- src/main/opencode/hook-service.ts | 61 ++--- .../opencode-overlay-manifest.test.ts | 29 +++ .../opencode/opencode-overlay-manifest.ts | 39 ++- .../opencode/overlay-dir-gc-lifecycle.test.ts | 30 +++ src/main/opencode/overlay-dir-gc-lifecycle.ts | 46 ++++ src/main/opencode/overlay-dir-gc.test.ts | 227 ++++++++++++++++++ src/main/opencode/overlay-dir-gc.ts | 206 ++++++++++++++++ src/main/opencode/overlay-dir-names.ts | 12 + .../opencode/overlay-source-ownership.test.ts | 90 +++++++ src/main/opencode/overlay-source-ownership.ts | 63 +++++ .../window/attach-main-window-services.ts | 4 + 11 files changed, 767 insertions(+), 40 deletions(-) create mode 100644 src/main/opencode/overlay-dir-gc-lifecycle.test.ts create mode 100644 src/main/opencode/overlay-dir-gc-lifecycle.ts create mode 100644 src/main/opencode/overlay-dir-gc.test.ts create mode 100644 src/main/opencode/overlay-dir-gc.ts create mode 100644 src/main/opencode/overlay-dir-names.ts create mode 100644 src/main/opencode/overlay-source-ownership.test.ts create mode 100644 src/main/opencode/overlay-source-ownership.ts diff --git a/src/main/opencode/hook-service.ts b/src/main/opencode/hook-service.ts index 1461b8773dc..7a6d137ebfa 100644 --- a/src/main/opencode/hook-service.ts +++ b/src/main/opencode/hook-service.ts @@ -7,11 +7,9 @@ import { readFileSync, readdirSync, realpathSync, - statSync, - writeFileSync + statSync } from 'node:fs' -import { createHash } from 'node:crypto' -import { isSafeDescendCandidate, mirrorEntry, safeRemoveOverlay } from '../pty/overlay-mirror' +import { isSafeDescendCandidate, mirrorEntry } from '../pty/overlay-mirror' import { getOpenCode2PluginSource, getOpenCodeFamilyPluginSource, @@ -19,7 +17,8 @@ import { } from './status-plugin-module-source' import { readOpenCodeOverlayManifest, - OPENCODE_OVERLAY_MANIFEST_FILE, + clearOpenCodeOverlayManifestEntries, + writeOpenCodeOverlayManifest, type OpenCodeOverlayManifest } from './opencode-overlay-manifest' import { resolveOpenCodeConfigDirectory } from '../../shared/opencode-config-directory' @@ -33,15 +32,19 @@ import { writeOpenCodeTuiPlugin } from '../../shared/opencode-tui-plugin-install' import { writeLegacyOpenCodePluginWithAclRetry } from './legacy-plugin-acl-retry' +import { + OPENCODE_OVERLAY_DIR, + ORCA_OPENCODE_PLUGIN_FILE, + sourceOverlayDirName, + toSafeDirName +} from './overlay-dir-names' +import { OpenCodeDirGcLifecycle } from './overlay-dir-gc-lifecycle' export { getOpenCode2PluginSource, getOpenCodeFamilyPluginSource, getOpenCodePluginSource } import { writeCanonicalOpenCodePluginAtomically } from '../../shared/opencode-plugin-atomic-write' import { writeOpenCodePluginConfig } from './opencode-plugin-config-writer' -const ORCA_OPENCODE_PLUGIN_FILE = 'orca-opencode-status.js' -const OPENCODE_OVERLAY_DIR = 'opencode-config-overlays' - type OpenCodeHookVariant = { pluginFileName: string legacyHooksDir: string @@ -57,11 +60,6 @@ function isUsableId(id: string): boolean { return typeof id === 'string' && id.length > 0 && id.length <= 1024 } -function toSafeDirName(id: string): string { - // Why: 32 hex chars (128 bits) makes collisions negligible and stays filesystem-portable (no base64 padding or `/`). - return createHash('sha256').update(id).digest('hex').slice(0, 32) -} - // Why: installs the plugin into OpenCode's config discovery path so it POSTs to the shared agent-hooks server, unifying OpenCode status with Claude/Codex/Gemini. export class OpenCodeHookService { private readonly pluginSource: () => string @@ -70,6 +68,7 @@ export class OpenCodeHookService { private readonly overlayDir: string private readonly installsTuiPlugin: boolean private readonly tuiOnlyDirectory: string | undefined + readonly configDirGc: OpenCodeDirGcLifecycle constructor(variant?: OpenCodeHookVariant | (() => string)) { const config: OpenCodeHookVariant = @@ -93,6 +92,7 @@ export class OpenCodeHookService { this.pluginFileName = config.pluginFileName this.legacyHooksDir = config.legacyHooksDir this.overlayDir = config.overlayDir + this.configDirGc = new OpenCodeDirGcLifecycle(() => this.getOverlayRoot(), this.pluginFileName) } clearPty(_ptyId: string): void { @@ -114,6 +114,7 @@ export class OpenCodeHookService { } } this.writePluginIntoOverlay(directory) + owner.configDirGc.reference(directory) return 'installed' } catch { return 'failed' @@ -152,6 +153,7 @@ export class OpenCodeHookService { this.mirrorUserConfig(existingConfigDir, overlayDir) } this.writePluginIntoOverlay(overlayDir) + this.configDirGc.reference(overlayDir) return { OPENCODE_CONFIG_DIR: overlayDir } } catch { return { OPENCODE_CONFIG_DIR: existingConfigDir } @@ -217,7 +219,7 @@ export class OpenCodeHookService { } private getSourceOverlayDir(sourceConfigDir: string): string { - return join(this.getOverlayRoot(), toSafeDirName(`source:${sourceConfigDir}`)) + return join(this.getOverlayRoot(), sourceOverlayDirName(sourceConfigDir)) } private getSharedConfigDir(): string { @@ -227,34 +229,17 @@ export class OpenCodeHookService { ) } - private writeOverlayManifest(overlayDir: string, manifest: OpenCodeOverlayManifest): void { - writeFileSync( - join(overlayDir, OPENCODE_OVERLAY_MANIFEST_FILE), - `${JSON.stringify(manifest, null, 2)}\n` - ) - } - - private clearManifestEntries(overlayDir: string, manifest: OpenCodeOverlayManifest): void { - for (const entryName of manifest.topLevelEntries) { - safeRemoveOverlay(join(overlayDir, entryName), overlayDir) - } - - const overlayPluginsDir = join(overlayDir, 'plugins') - for (const entryName of manifest.pluginEntries) { - if (entryName === this.pluginFileName) { - continue - } - safeRemoveOverlay(join(overlayPluginsDir, entryName), overlayPluginsDir) - } - } - // Why: mirror user config entries as symlinks so edits propagate live; only plugins/ becomes a real overlay dir so Orca can drop a sibling plugin file. private mirrorUserConfig(sourceDir: string, overlayDir: string): void { const previousManifest = readOpenCodeOverlayManifest(overlayDir) // Why: overlays persist across terminals; remove only Orca-mirrored paths so stale user config clears but OpenCode runtime dirs (node_modules) survive. - this.clearManifestEntries(overlayDir, previousManifest) + clearOpenCodeOverlayManifestEntries(overlayDir, previousManifest, this.pluginFileName) - const nextManifest: OpenCodeOverlayManifest = { topLevelEntries: [], pluginEntries: [] } + const nextManifest: OpenCodeOverlayManifest = { + topLevelEntries: [], + pluginEntries: [], + sourceConfigDir: sourceDir + } for (const entry of readdirSync(sourceDir, { withFileTypes: true })) { const sourcePath = join(sourceDir, entry.name) @@ -301,7 +286,7 @@ export class OpenCodeHookService { nextManifest.topLevelEntries.push(entry.name) } - this.writeOverlayManifest(overlayDir, nextManifest) + writeOpenCodeOverlayManifest(overlayDir, nextManifest) } private writePluginIntoOverlay(overlayDir: string): void { diff --git a/src/main/opencode/opencode-overlay-manifest.test.ts b/src/main/opencode/opencode-overlay-manifest.test.ts index 7dc0404a53e..65c2c765bbb 100644 --- a/src/main/opencode/opencode-overlay-manifest.test.ts +++ b/src/main/opencode/opencode-overlay-manifest.test.ts @@ -30,3 +30,32 @@ it('accepts only string manifest entries and tolerates invalid persisted shapes' rmSync(root, { recursive: true, force: true }) } }) + +it('keeps source ownership optional for older manifests and rejects non-string metadata', () => { + const root = mkdtempSync(join(tmpdir(), 'orca-overlay-source-manifest-')) + try { + const file = join(root, OPENCODE_OVERLAY_MANIFEST_FILE) + for (const source of [undefined, null, 42, {}]) { + writeFileSync( + file, + JSON.stringify({ topLevelEntries: [], pluginEntries: [], sourceConfigDir: source }) + ) + expect(readOpenCodeOverlayManifest(root)).toEqual({ topLevelEntries: [], pluginEntries: [] }) + } + writeFileSync( + file, + JSON.stringify({ + topLevelEntries: ['opencode.json'], + pluginEntries: [], + sourceConfigDir: '/owned/source' + }) + ) + expect(readOpenCodeOverlayManifest(root)).toEqual({ + topLevelEntries: ['opencode.json'], + pluginEntries: [], + sourceConfigDir: '/owned/source' + }) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) diff --git a/src/main/opencode/opencode-overlay-manifest.ts b/src/main/opencode/opencode-overlay-manifest.ts index 5abd0328f25..a256c14f8ca 100644 --- a/src/main/opencode/opencode-overlay-manifest.ts +++ b/src/main/opencode/opencode-overlay-manifest.ts @@ -1,8 +1,13 @@ -import { readFileSync } from 'node:fs' +import { readFileSync, writeFileSync } from 'node:fs' import { join } from 'node:path' +import { safeRemoveOverlay } from '../pty/overlay-mirror' export const OPENCODE_OVERLAY_MANIFEST_FILE = '.orca-opencode-overlay-manifest.json' -export type OpenCodeOverlayManifest = { topLevelEntries: string[]; pluginEntries: string[] } +export type OpenCodeOverlayManifest = { + topLevelEntries: string[] + pluginEntries: string[] + sourceConfigDir?: string +} export function readOpenCodeOverlayManifest(overlayDir: string): OpenCodeOverlayManifest { const empty = { topLevelEntries: [], pluginEntries: [] } @@ -14,6 +19,9 @@ export function readOpenCodeOverlayManifest(overlayDir: string): OpenCodeOverlay return empty } return { + ...('sourceConfigDir' in parsed && typeof parsed.sourceConfigDir === 'string' + ? { sourceConfigDir: parsed.sourceConfigDir } + : {}), topLevelEntries: 'topLevelEntries' in parsed && Array.isArray(parsed.topLevelEntries) ? parsed.topLevelEntries.filter((entry): entry is string => typeof entry === 'string') @@ -27,3 +35,30 @@ export function readOpenCodeOverlayManifest(overlayDir: string): OpenCodeOverlay return empty } } + +export function writeOpenCodeOverlayManifest( + overlayDir: string, + manifest: OpenCodeOverlayManifest +): void { + writeFileSync( + join(overlayDir, OPENCODE_OVERLAY_MANIFEST_FILE), + `${JSON.stringify(manifest, null, 2)}\n` + ) +} + +export function clearOpenCodeOverlayManifestEntries( + overlayDir: string, + manifest: OpenCodeOverlayManifest, + pluginFileName: string +): void { + for (const entryName of manifest.topLevelEntries) { + safeRemoveOverlay(join(overlayDir, entryName), overlayDir) + } + const overlayPluginsDir = join(overlayDir, 'plugins') + for (const entryName of manifest.pluginEntries) { + if (entryName === pluginFileName) { + continue + } + safeRemoveOverlay(join(overlayPluginsDir, entryName), overlayPluginsDir) + } +} diff --git a/src/main/opencode/overlay-dir-gc-lifecycle.test.ts b/src/main/opencode/overlay-dir-gc-lifecycle.test.ts new file mode 100644 index 00000000000..6149c1094fe --- /dev/null +++ b/src/main/opencode/overlay-dir-gc-lifecycle.test.ts @@ -0,0 +1,30 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { OpenCodeDirGcLifecycle } from './overlay-dir-gc-lifecycle' + +afterEach(() => { + vi.restoreAllMocks() + vi.useRealTimers() +}) + +it('schedules one delayed sweep without holding the app open', async () => { + vi.useFakeTimers() + const timeout = vi.spyOn(globalThis, 'setTimeout') + const lifecycle = new OpenCodeDirGcLifecycle(() => '/unused-test-root', 'unused-plugin.js') + const run = vi.spyOn(lifecycle, 'run').mockResolvedValue({ + scanned: 0, + removed: 0, + failed: 0, + keptReferenced: 0, + keptYoung: 0, + keptSourcePresent: 0, + keptUnverifiable: 0 + }) + const inventory = vi.fn(async () => []) + lifecycle.schedule(inventory) + lifecycle.schedule(inventory) + expect(timeout.mock.results[0]?.value.hasRef()).toBe(false) + await vi.advanceTimersByTimeAsync(179999) + expect(run).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(1) + expect(run).toHaveBeenCalledExactlyOnceWith(inventory) +}) diff --git a/src/main/opencode/overlay-dir-gc-lifecycle.ts b/src/main/opencode/overlay-dir-gc-lifecycle.ts new file mode 100644 index 00000000000..df84b28bf18 --- /dev/null +++ b/src/main/opencode/overlay-dir-gc-lifecycle.ts @@ -0,0 +1,46 @@ +import { OPENCODE_CONFIG_DIR_ENV_KEYS } from './legacy-shared-config-dir' +import { sweepOrphanedOpenCodeDirs, type OpenCodeDirGcResult } from './overlay-dir-gc' + +type ReadLivePtyIds = () => Promise + +export class OpenCodeDirGcLifecycle { + private readonly references = new Set() + private scheduled = false + + constructor( + private readonly getRoot: () => string, + private readonly pluginFileName: string + ) {} + + reference(directory: string): void { + this.references.add(directory) + } + + schedule(readLivePtyIds: ReadLivePtyIds, delayMs = 3 * 60_000): void { + if (this.scheduled) { + return + } + this.scheduled = true + const timer = setTimeout(() => { + void this.run(readLivePtyIds).catch((error) => { + console.warn('[OpenCode] Overlay cleanup skipped:', error) + }) + }, delayMs) + timer.unref() + } + + async run(readLivePtyIds: ReadLivePtyIds): Promise { + for (const key of OPENCODE_CONFIG_DIR_ENV_KEYS) { + const value = process.env[key] + if (value) { + this.references.add(value) + } + } + return sweepOrphanedOpenCodeDirs({ + overlayRoot: this.getRoot(), + pluginFileName: this.pluginFileName, + referencedConfigDirs: this.references, + readLivePtyIds + }) + } +} diff --git a/src/main/opencode/overlay-dir-gc.test.ts b/src/main/opencode/overlay-dir-gc.test.ts new file mode 100644 index 00000000000..e2472477154 --- /dev/null +++ b/src/main/opencode/overlay-dir-gc.test.ts @@ -0,0 +1,227 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + symlinkSync, + utimesSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { setAppEnvironment } from '../../shared/app-environment' +import { OpenCodeHookService } from './hook-service' +import { OPENCODE_OVERLAY_MANIFEST_FILE } from './opencode-overlay-manifest' +import { OPENCODE_DIR_GC_MIN_AGE_MS, sweepOrphanedOpenCodeDirs } from './overlay-dir-gc' +import { ORCA_OPENCODE_PLUGIN_FILE, sourceOverlayDirName } from './overlay-dir-names' + +let root: string +let overlays: string +const now = Date.now() + +beforeEach(() => { + root = realpathSync(mkdtempSync(join(tmpdir(), 'orca-overlay-gc-'))) + overlays = join(root, 'opencode-config-overlays') + mkdirSync(overlays) + vi.stubEnv('XDG_CONFIG_HOME', join(root, 'xdg')) + for (const key of [ + 'OPENCODE_CONFIG_DIR', + 'ORCA_OPENCODE_CONFIG_DIR', + 'ORCA_OPENCODE_SOURCE_CONFIG_DIR' + ]) { + vi.stubEnv(key, '') + } + setAppEnvironment({ + getPath: () => root, + getAppPath: () => root, + getVersion: () => 'test', + isPackaged: () => false, + onWillQuit: () => {}, + exit: () => {}, + getAppMetrics: () => [] + }) +}) +afterEach(() => { + vi.useRealTimers() + vi.unstubAllEnvs() + vi.restoreAllMocks() + rmSync(root, { recursive: true, force: true }) +}) + +function age(directory: string): void { + const old = new Date(now - OPENCODE_DIR_GC_MIN_AGE_MS * 2) + for (const suffix of [ + '', + OPENCODE_OVERLAY_MANIFEST_FILE, + 'plugins', + join('plugins', ORCA_OPENCODE_PLUGIN_FILE) + ]) { + utimesSync(join(directory, suffix), old, old) + } +} + +function createOverlay(name: string): { source: string; directory: string } { + const source = join(root, name) + const directory = join(overlays, sourceOverlayDirName(source)) + mkdirSync(join(directory, 'plugins'), { recursive: true }) + writeFileSync(join(directory, 'plugins', ORCA_OPENCODE_PLUGIN_FILE), 'export default {}') + writeFileSync( + join(directory, OPENCODE_OVERLAY_MANIFEST_FILE), + JSON.stringify({ + topLevelEntries: [], + pluginEntries: [], + sourceConfigDir: source + }) + ) + age(directory) + return { source, directory } +} + +function sweep(extra: Partial[0]> = {}) { + return sweepOrphanedOpenCodeDirs({ + overlayRoot: overlays, + pluginFileName: ORCA_OPENCODE_PLUGIN_FILE, + referencedConfigDirs: new Set(), + readLivePtyIds: async () => [], + now, + yieldBetweenRemovals: async () => {}, + ...extra + }) +} + +it('collects only an old, owned missing source while retaining active and reusable sources', async () => { + const retired = createOverlay('retired') + const active = createOverlay('active') + const reusable = createOverlay('reusable') + mkdirSync(active.source) + mkdirSync(reusable.source) + const result = await sweep() + expect(result.removed).toBe(1) + expect(result.keptSourcePresent).toBe(2) + expect(existsSync(retired.directory)).toBe(false) + expect(existsSync(active.directory)).toBe(true) + expect(existsSync(reusable.directory)).toBe(true) +}) + +it.each([null, ['surviving-daemon-pty']] as const)( + 'keeps a retired source with an empty fresh-process reference cache when inventory is %s', + async (ids) => { + const retired = createOverlay('retired') + expect((await sweep({ readLivePtyIds: async () => ids })).removed).toBe(0) + expect(existsSync(retired.directory)).toBe(true) + } +) + +it('preserves a candidate when owning-host inventory fails', async () => { + const retired = createOverlay('retired') + expect( + ( + await sweep({ + readLivePtyIds: async () => { + throw new Error('host unavailable') + } + }) + ).keptUnverifiable + ).toBe(1) + expect(existsSync(retired.directory)).toBe(true) +}) + +it.each(['service.json', 'service-local.json'])( + 'preserves %s without guessing service process death', + async (file) => { + const retired = createOverlay('retired') + writeFileSync(join(retired.directory, file), '{}') + age(retired.directory) + const inventory = vi.fn(async () => []) + expect((await sweep({ readLivePtyIds: inventory })).keptUnverifiable).toBe(1) + expect(inventory).not.toHaveBeenCalled() + expect(existsSync(retired.directory)).toBe(true) + } +) + +it('keeps a young candidate and refreshes reference protection after an asynchronous inventory', async () => { + const young = createOverlay('young') + utimesSync( + join(young.directory, 'plugins', ORCA_OPENCODE_PLUGIN_FILE), + new Date(now), + new Date(now) + ) + const retired = createOverlay('retired') + const references = new Set() + const result = await sweep({ + referencedConfigDirs: references, + readLivePtyIds: async () => { + references.add(join(retired.directory, 'plugins')) + return [] + } + }) + expect(result.keptYoung).toBe(1) + expect(result.keptReferenced).toBe(1) + expect(existsSync(retired.directory)).toBe(true) +}) + +it('does not follow a replaced overlay root or a source ancestor link', async () => { + const retired = createOverlay('retired') + const target = join(root, 'elsewhere') + mkdirSync(target) + symlinkSync(target, retired.source, process.platform === 'win32' ? 'junction' : 'dir') + expect((await sweep()).removed).toBe(0) + rmSync(overlays, { recursive: true }) + symlinkSync(target, overlays, process.platform === 'win32' ? 'junction' : 'dir') + expect((await sweep()).scanned).toBe(0) + expect(existsSync(target)).toBe(true) +}) + +it('retains unowned names, ambiguous old manifests and mismatched source hashes', async () => { + for (const name of ['shared', '123', 'human']) { + mkdirSync(join(overlays, name)) + } + const ambiguous = createOverlay('ambiguous') + writeFileSync( + join(ambiguous.directory, OPENCODE_OVERLAY_MANIFEST_FILE), + JSON.stringify({ topLevelEntries: [], pluginEntries: [] }) + ) + const mismatch = createOverlay('mismatch') + writeFileSync( + join(mismatch.directory, OPENCODE_OVERLAY_MANIFEST_FILE), + JSON.stringify({ topLevelEntries: [], pluginEntries: [], sourceConfigDir: root }) + ) + expect((await sweep()).keptUnverifiable).toBe(5) +}) + +it('counts failed deletion attempts toward the bound and yields between them', async () => { + for (let i = 0; i < 4; i += 1) { + createOverlay(`retired-${i}`) + } + const remove = vi.fn(() => { + throw new Error('busy') + }) + const yieldBetween = vi.fn(async () => {}) + const result = await sweep({ + maxRemovals: 2, + removeTree: remove, + yieldBetweenRemovals: yieldBetween + }) + expect(result.failed).toBe(2) + expect(remove).toHaveBeenCalledTimes(2) + expect(yieldBetween).toHaveBeenCalledTimes(2) +}) + +it('retains handed-out and inherited source references through clearPty', async () => { + const source = join(root, 'source') + mkdirSync(source) + writeFileSync(join(source, 'opencode.json'), '{}') + const service = new OpenCodeHookService(() => 'export default {}') + const directory = service.buildPtyEnv('pane', source).OPENCODE_CONFIG_DIR + if (!directory) { + throw new Error('Expected overlay') + } + rmSync(source, { recursive: true }) + age(directory) + service.clearPty('pane') + expect((await service.configDirGc.run(async () => [])).keptReferenced).toBe(1) + vi.stubEnv('ORCA_OPENCODE_SOURCE_CONFIG_DIR', source) + expect((await new OpenCodeHookService().configDirGc.run(async () => [])).keptReferenced).toBe(1) +}) diff --git a/src/main/opencode/overlay-dir-gc.ts b/src/main/opencode/overlay-dir-gc.ts new file mode 100644 index 00000000000..6961c0c3c1a --- /dev/null +++ b/src/main/opencode/overlay-dir-gc.ts @@ -0,0 +1,206 @@ +import { lstat, readdir } from 'node:fs/promises' +import { join, resolve, sep } from 'node:path' +import { yieldToEventLoop } from '../../shared/event-loop-yield' +import { isSafeDescendCandidate, safeRemoveOverlay } from '../pty/overlay-mirror' +import { + OPENCODE_OVERLAY_MANIFEST_FILE, + readOpenCodeOverlayManifest +} from './opencode-overlay-manifest' +import { inspectSourceDirectory, resolveOwnedOverlaySource } from './overlay-source-ownership' + +export const OPENCODE_DIR_GC_MIN_AGE_MS = 30 * 24 * 60 * 60 * 1000 +export const OPENCODE_DIR_GC_MAX_REMOVALS_PER_SWEEP = 500 + +export type OpenCodeDirGcOptions = { + overlayRoot: string + pluginFileName: string + referencedConfigDirs: ReadonlySet + readLivePtyIds: () => Promise + now?: number + minAgeMs?: number + maxRemovals?: number + removeTree?: (directory: string, root: string) => void + yieldBetweenRemovals?: () => Promise +} + +export type OpenCodeDirGcResult = { + scanned: number + removed: number + failed: number + keptReferenced: number + keptYoung: number + keptSourcePresent: number + keptUnverifiable: number +} + +function normalized(path: string): string { + const absolute = resolve(path) + return process.platform === 'win32' ? absolute.toLowerCase() : absolute +} + +function isReferenced(directory: string, references: ReadonlySet): boolean { + const candidate = normalized(directory) + for (const reference of references) { + const value = normalized(reference) + if (value === candidate || value.startsWith(candidate + sep)) { + return true + } + } + return false +} + +async function oldEnough(directory: string, plugin: string, now: number, minAge: number) { + let newest = 0 + for (const path of [ + directory, + join(directory, OPENCODE_OVERLAY_MANIFEST_FILE), + join(directory, 'plugins'), + join(directory, 'plugins', plugin) + ]) { + try { + const stats = await lstat(path) + if (stats.isSymbolicLink()) { + return false + } + newest = Math.max(newest, stats.mtimeMs) + } catch { + return false + } + } + return newest > 0 && now - newest >= minAge +} + +async function hasNoServiceConfig(directory: string): Promise { + try { + const names = await readdir(directory) + // Service registration lives in a different profile; config absence proves no PID verdict. + return !names.some((name) => /^service(?:-[\w.-]+)?\.json$/i.test(name)) + } catch { + return false + } +} + +export async function sweepOrphanedOpenCodeDirs( + options: OpenCodeDirGcOptions +): Promise { + const result: OpenCodeDirGcResult = { + scanned: 0, + removed: 0, + failed: 0, + keptReferenced: 0, + keptYoung: 0, + keptSourcePresent: 0, + keptUnverifiable: 0 + } + if ((await inspectSourceDirectory(options.overlayRoot)) !== 'present') { + return result + } + const now = options.now ?? Date.now() + const minAge = options.minAgeMs ?? OPENCODE_DIR_GC_MIN_AGE_MS + const requestedLimit = options.maxRemovals ?? OPENCODE_DIR_GC_MAX_REMOVALS_PER_SWEEP + const limit = Number.isFinite(requestedLimit) + ? Math.min(OPENCODE_DIR_GC_MAX_REMOVALS_PER_SWEEP, Math.max(0, Math.floor(requestedLimit))) + : OPENCODE_DIR_GC_MAX_REMOVALS_PER_SWEEP + const removeTree = options.removeTree ?? safeRemoveOverlay + const yieldBetween = options.yieldBetweenRemovals ?? yieldToEventLoop + let entries + try { + entries = await readdir(options.overlayRoot, { withFileTypes: true }) + } catch { + return result + } + for (const entry of entries) { + if (result.removed + result.failed >= limit) { + break + } + result.scanned += 1 + const directory = join(options.overlayRoot, entry.name) + if (!/^[0-9a-f]{32}$/.test(entry.name) || !isSafeDescendCandidate(entry)) { + result.keptUnverifiable += 1 + continue + } + if (isReferenced(directory, options.referencedConfigDirs)) { + result.keptReferenced += 1 + continue + } + try { + const manifestStats = await lstat(join(directory, OPENCODE_OVERLAY_MANIFEST_FILE)) + if ( + !manifestStats.isFile() || + manifestStats.isSymbolicLink() || + manifestStats.size > 64 * 1024 + ) { + result.keptUnverifiable += 1 + continue + } + const source = await resolveOwnedOverlaySource( + directory, + readOpenCodeOverlayManifest(directory) + ) + if (!source) { + result.keptUnverifiable += 1 + continue + } + const presence = await inspectSourceDirectory(source) + if (presence === 'present') { + result.keptSourcePresent += 1 + continue + } + if (presence !== 'absent' || !(await hasNoServiceConfig(directory))) { + result.keptUnverifiable += 1 + continue + } + if (!(await oldEnough(directory, options.pluginFileName, now, minAge))) { + result.keptYoung += 1 + continue + } + // A restarted app's empty cache cannot establish surviving daemon terminals are gone. + const liveIds = await options.readLivePtyIds().catch(() => null) + if (liveIds === null || liveIds.length !== 0) { + result.keptUnverifiable += 1 + continue + } + if ( + isReferenced(directory, options.referencedConfigDirs) || + isReferenced(source, options.referencedConfigDirs) + ) { + result.keptReferenced += 1 + continue + } + if ( + (await inspectSourceDirectory(source)) !== 'absent' || + (await inspectSourceDirectory(options.overlayRoot)) !== 'present' || + !(await hasNoServiceConfig(directory)) + ) { + result.keptUnverifiable += 1 + continue + } + if ( + isReferenced(directory, options.referencedConfigDirs) || + isReferenced(source, options.referencedConfigDirs) + ) { + result.keptReferenced += 1 + continue + } + try { + removeTree(directory, options.overlayRoot) + try { + await lstat(directory) + result.failed += 1 + } catch (error) { + if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') { + result.removed += 1 + } else { + result.failed += 1 + } + } + } catch { + result.failed += 1 + } + await yieldBetween() + } catch { + result.keptUnverifiable += 1 + } + } + return result +} diff --git a/src/main/opencode/overlay-dir-names.ts b/src/main/opencode/overlay-dir-names.ts new file mode 100644 index 00000000000..1e3a3a9a9f0 --- /dev/null +++ b/src/main/opencode/overlay-dir-names.ts @@ -0,0 +1,12 @@ +import { createHash } from 'node:crypto' + +export const OPENCODE_OVERLAY_DIR = 'opencode-config-overlays' +export const ORCA_OPENCODE_PLUGIN_FILE = 'orca-opencode-status.js' + +export function toSafeDirName(id: string): string { + return createHash('sha256').update(id).digest('hex').slice(0, 32) +} + +export function sourceOverlayDirName(sourceConfigDir: string): string { + return toSafeDirName(`source:${sourceConfigDir}`) +} diff --git a/src/main/opencode/overlay-source-ownership.test.ts b/src/main/opencode/overlay-source-ownership.test.ts new file mode 100644 index 00000000000..78c5d1c02ce --- /dev/null +++ b/src/main/opencode/overlay-source-ownership.test.ts @@ -0,0 +1,90 @@ +import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import * as filesystem from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { inspectSourceDirectory, resolveOwnedOverlaySource } from './overlay-source-ownership' +import { sourceOverlayDirName } from './overlay-dir-names' + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, lstat: vi.fn(actual.lstat) } +}) + +let root: string +beforeEach(() => { + root = realpathSync(mkdtempSync(join(tmpdir(), 'orca-overlay-source-'))) +}) +afterEach(() => { + vi.restoreAllMocks() + rmSync(root, { recursive: true, force: true }) +}) + +it('distinguishes a missing source from an inaccessible source and a linked ancestor', async () => { + const source = join(root, 'source') + mkdirSync(source) + expect(await inspectSourceDirectory(source)).toBe('present') + expect(await inspectSourceDirectory(join(root, 'missing', 'config'))).toBe('absent') + expect(await inspectSourceDirectory('relative-config')).toBe('unverifiable') + writeFileSync(join(root, 'file'), '') + expect(await inspectSourceDirectory(join(root, 'file'))).toBe('unverifiable') + symlinkSync(source, join(root, 'link'), process.platform === 'win32' ? 'junction' : 'dir') + expect(await inspectSourceDirectory(join(root, 'link', 'missing'))).toBe('unverifiable') + const error = Object.assign(new Error('Denied'), { code: 'EACCES' }) + vi.mocked(filesystem.lstat).mockRejectedValueOnce(error) + expect(await inspectSourceDirectory(source)).toBe('unverifiable') +}) + +it('requires an absolute source whose hash matches the owned overlay', async () => { + const source = join(root, 'source') + const overlay = join(root, sourceOverlayDirName(source)) + const manifest = { topLevelEntries: [], pluginEntries: [], sourceConfigDir: source } + expect(await resolveOwnedOverlaySource(overlay, manifest)).toBe(source) + expect( + await resolveOwnedOverlaySource(overlay, { ...manifest, sourceConfigDir: join(root, 'other') }) + ).toBeUndefined() + expect( + await resolveOwnedOverlaySource(overlay, { ...manifest, sourceConfigDir: 'relative' }) + ).toBeUndefined() + expect( + await resolveOwnedOverlaySource(join(root, '123'), { + topLevelEntries: [], + pluginEntries: [], + sourceConfigDir: source + }) + ).toBeUndefined() +}) + +it.skipIf(process.platform === 'win32')( + 'recognizes an older source-scoped manifest only through its named mirrored link', + async () => { + const source = join(root, 'retired-source') + const overlay = join(root, sourceOverlayDirName(source)) + mkdirSync(overlay) + symlinkSync(join(source, 'opencode.json'), join(overlay, 'opencode.json')) + const manifest = { topLevelEntries: ['opencode.json'], pluginEntries: [] } + expect(await resolveOwnedOverlaySource(overlay, manifest)).toBe(source) + expect( + await resolveOwnedOverlaySource(overlay, { ...manifest, topLevelEntries: ['../other'] }) + ).toBeUndefined() + expect( + await resolveOwnedOverlaySource(overlay, { ...manifest, topLevelEntries: [] }) + ).toBeUndefined() + rmSync(join(overlay, 'opencode.json')) + writeFileSync(join(overlay, 'opencode.json'), '{}') + expect(await resolveOwnedOverlaySource(overlay, manifest)).toBeUndefined() + } +) + +it.skipIf(process.platform === 'win32')('rejects arbitrary legacy source links', async () => { + const source = join(root, 'source') + const overlay = join(root, sourceOverlayDirName(source)) + mkdirSync(overlay) + symlinkSync(join(root, 'other', 'opencode.json'), join(overlay, 'opencode.json')) + expect( + await resolveOwnedOverlaySource(overlay, { + topLevelEntries: ['opencode.json'], + pluginEntries: [] + }) + ).toBeUndefined() +}) diff --git a/src/main/opencode/overlay-source-ownership.ts b/src/main/opencode/overlay-source-ownership.ts new file mode 100644 index 00000000000..4f71cd191da --- /dev/null +++ b/src/main/opencode/overlay-source-ownership.ts @@ -0,0 +1,63 @@ +import { lstat, readlink } from 'node:fs/promises' +import { basename, dirname, isAbsolute, join, parse, relative, resolve, sep } from 'node:path' +import { isSafeDescendCandidate } from '../pty/overlay-mirror' +import type { OpenCodeOverlayManifest } from './opencode-overlay-manifest' +import { sourceOverlayDirName } from './overlay-dir-names' + +export type SourceDirectoryPresence = 'present' | 'absent' | 'unverifiable' + +function isAbsent(error: unknown): boolean { + return !!error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT' +} + +export async function inspectSourceDirectory(path: string): Promise { + if (!isAbsolute(path)) { + return 'unverifiable' + } + const absolute = resolve(path) + let current = parse(absolute).root + const segments = relative(current, absolute).split(sep).filter(Boolean) + for (const segment of ['', ...segments]) { + current = join(current, segment) + try { + if (!isSafeDescendCandidate(await lstat(current))) { + return 'unverifiable' + } + } catch (error) { + return isAbsent(error) ? 'absent' : 'unverifiable' + } + } + return 'present' +} + +export async function resolveOwnedOverlaySource( + directory: string, + manifest: OpenCodeOverlayManifest +): Promise { + const matches = (source: string): boolean => + isAbsolute(source) && sourceOverlayDirName(source) === basename(directory) + if (manifest.sourceConfigDir !== undefined) { + return matches(manifest.sourceConfigDir) ? manifest.sourceConfigDir : undefined + } + // Older manifests prove a source only through a named, source-hash-matching mirror. + let source: string | undefined + for (const entry of manifest.topLevelEntries) { + if (!entry || basename(entry) !== entry || entry === '.' || entry === '..') { + return undefined + } + try { + const target = await readlink(join(directory, entry)) + const candidate = dirname(target) + if (!isAbsolute(target) || basename(target) !== entry || !matches(candidate)) { + return undefined + } + if (source !== undefined && source !== candidate) { + return undefined + } + source = candidate + } catch { + return undefined + } + } + return source +} diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index 7bd3e41b378..b448681857e 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -26,6 +26,8 @@ import { hasSystemMediaAccess, requestSystemMediaAccess } from '../browser/brows import type { OrcaRuntimeService, RuntimeWorktreeLifecycleEvent } from '../runtime/orca-runtime' import type { PreQuitCleanupFailureMode, UpdateInstallMode } from '../updater' import { scheduleHistoryGc } from '../terminal-history-gc' +import { openCodeHookService, openCode2HookService } from '../opencode/hook-service' +import { listLiveDaemonPtyIds } from '../daemon/daemon-provider-state' import { hydrateLocalPtyRegistryAtBoot } from '../memory/hydrate-local-pty-registry' import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' import { getKnownWorktreeIdsForHistoryGc } from './history-gc-worktree-ids' @@ -107,6 +109,8 @@ export function attachMainWindowServices( scheduleHistoryGc(async () => { return getKnownWorktreeIdsForHistoryGc(store) }) + openCodeHookService.configDirGc.schedule(listLiveDaemonPtyIds) + openCode2HookService.configDirGc.schedule(listLiveDaemonPtyIds) const localPtyProviderStartupReady = options?.awaitLocalPtyProviderStartup?.() if (localPtyProviderStartupReady) { void localPtyProviderStartupReady From 53899251dbcbf5a5c756f17fe1c62b51c7f12c3b Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 4 Oct 2026 05:42:26 -0700 Subject: [PATCH 56/67] Preserve Windows SSH upload failures unless pwsh is missing (#25185) * test(ssh): reproduce missing-pwsh text in staging paths * fix(ssh): classify missing PowerShell from command exit evidence * test: expose generic Windows upload error misclassification * test: await armed SSH upload before advancing fake clock * test: retain real immediate delivery around upload timeout control * fix: classify PowerShell absence from command exits only * test: expose missing-command text inside SSH stderr paths * fix: require missing pwsh diagnostic command identity * test: keep mixed write errors out of missing-command fallback * fix: require complete missing PowerShell diagnostic * test: capture complete native missing pwsh diagnostics * fix: recognize complete missing pwsh native diagnostics * test(ssh): cover source-derived NormalView localization and wrapping * fix(ssh): identify complete missing-pwsh records across NormalView layouts * test(ssh): cover raw-wrap separators and repeated error headers * fix(ssh): preserve wrapped separators and reject repeated error headers --------- Co-authored-by: Orca Campaign --- .../ssh/system-ssh-operation-lifecycle.ts | 15 +- .../ssh/system-ssh-windows-upload.test.ts | 244 +++++++++++++++++- .../ssh/system-ssh-windows-write-strategy.ts | 42 ++- 3 files changed, 290 insertions(+), 11 deletions(-) diff --git a/src/main/ssh/system-ssh-operation-lifecycle.ts b/src/main/ssh/system-ssh-operation-lifecycle.ts index 5ebfb17ced1..148ccad8ff6 100644 --- a/src/main/ssh/system-ssh-operation-lifecycle.ts +++ b/src/main/ssh/system-ssh-operation-lifecycle.ts @@ -3,6 +3,18 @@ import type { SystemSshCommandChannel } from './system-ssh-command' export type ProcessResult = { label: string; stderr: string } +export class SystemSshCommandExitError extends Error { + constructor( + label: string, + readonly exitCode: number | null, + readonly stderr: string, + signal?: NodeJS.Signals | null + ) { + const detail = exitCode === null ? `signal ${signal ?? 'unknown'}` : `exit ${exitCode}` + super(`${label} failed (${detail}): ${stderr.trim()}`) + } +} + /** * `timeoutMs` bounds a remote consumer that never returns. Windows PowerShell 5.1 cannot drain a * large redirected stdin over a non-pty ssh exec (#16432): the remote process stays alive at idle @@ -52,8 +64,7 @@ export function waitForChannelClose( } const onClose = (code: number | null, signal?: NodeJS.Signals | null): void => { if (code !== 0) { - const detail = code === null ? `signal ${signal ?? 'unknown'}` : `exit ${code}` - settle(reject, new Error(`${label} failed (${detail}): ${stderr.trim()}`)) + settle(reject, new SystemSshCommandExitError(label, code, stderr, signal)) return } settle(resolve) diff --git a/src/main/ssh/system-ssh-windows-upload.test.ts b/src/main/ssh/system-ssh-windows-upload.test.ts index c3a5ff80276..bc339f24785 100644 --- a/src/main/ssh/system-ssh-windows-upload.test.ts +++ b/src/main/ssh/system-ssh-windows-upload.test.ts @@ -51,12 +51,16 @@ import { writeBufferViaSystemSsh } from './system-ssh-file-binary-transfer' import { waitForChannelClose } from './system-ssh-operation-lifecycle' +import * as windowsFileWrite from './system-ssh-windows-file-write' import { getRemoteHostPlatform } from './ssh-remote-platform' import { clearWindowsRemoteWriteCapabilitiesForTests, getWindowsRemoteWriteCapabilities } from './system-ssh-windows-write-capabilities' -import { explainWindowsPowerShellStdinFailure } from './system-ssh-windows-write-strategy' +import { + explainWindowsPowerShellStdinFailure, + writeWindowsRemoteFile +} from './system-ssh-windows-write-strategy' import type { SshTarget } from '../../shared/ssh-types' type FakeChannel = EventEmitter & { @@ -106,6 +110,8 @@ const sftpBatches: RecordedSftpBatch[] = [] const commands: RecordedCommand[] = [] /** Index of the exec that should report a non-zero exit, to model a chunk failing mid-file. */ let failAtSpawn = -1 +let failedWriteExit = 1 +let failedWriteStderr = '' let localDir: string const fileWrites = (): RecordedCommand[] => @@ -177,6 +183,8 @@ beforeEach(() => { commands.length = 0 sftpBatches.length = 0 failAtSpawn = -1 + failedWriteExit = 1 + failedWriteStderr = '' clearWindowsRemoteWriteCapabilitiesForTests() waitForChannelCloseSpy.mockClear() localDir = mkdtempSync(join(tmpdir(), 'orca-win-upload-')) @@ -192,9 +200,14 @@ beforeEach(() => { executable: command.split(' ')[0] ?? '', stdin: channel.written }) - setImmediate(() => - spawnIndex === failAtSpawn ? channel.emit('close', 1, null) : channel.emit('close', 0, null) - ) + setImmediate(() => { + if (spawnIndex === failAtSpawn) { + channel.stderr.write(failedWriteStderr) + channel.emit('close', failedWriteExit, null) + } else { + channel.emit('close', 0, null) + } + }) }) }) }) @@ -624,6 +637,229 @@ describe('Windows upload on a host with no sftp subsystem', () => { expect(fileWrites().map(writtenPath)).not.toContain(`${remoteRoot}/relay.js`) expect(commands.some((command) => command.script.includes('::Move('))).toBe(false) }) + + it.each([ + ['relay.js', 'aabb9009eeff'], + ['relay-9009.js', 'aabbccddeeff'], + ['relay-CommandNotFoundException.js', 'aabbccddeeff'], + ['is not recognized as an internal or external command.js', 'aabbccddeeff'] + ])('propagates a failed write whose path contains absence-like text: %s', async (file, nonce) => { + const remotePath = `${remoteRoot}/${file}` + const staging = vi + .spyOn(windowsFileWrite, 'makeWindowsStagingPath') + .mockImplementation((path) => `${path}${WINDOWS_STAGED_WRITE_SUFFIX}-${nonce}`) + writeFileSync(join(localDir, file), Buffer.alloc(WINDOWS_STDIN_WRITE_CHUNK_BYTES * 3)) + failAtSpawn = 0 + try { + await expect( + uploadFileViaSystemSsh(target, join(localDir, file), remotePath, { hostPlatform }) + ).rejects.toThrow('failed (exit 1)') + + expect(fileWrites().map((write) => write.executable)).toEqual(['pwsh.exe']) + expect(getWindowsRemoteWriteCapabilities(target).shouldTry('pwsh')).toBe(true) + expect(commands.some((command) => command.script.includes('::Move('))).toBe(false) + } finally { + staging.mockRestore() + } + }) + + it.each([ + 'relay-CommandNotFoundException.js', + 'is not recognized as an internal or external command.js' + ])('propagates a write-denied stderr naming an absence-like filename: %s', async (file) => { + const remotePath = `${remoteRoot}/${file}` + writeFileSync(join(localDir, file), 'x') + failAtSpawn = 0 + failedWriteStderr = `Access to the path '${remotePath}' is denied.` + + await expect( + uploadFileViaSystemSsh(target, join(localDir, file), remotePath, { hostPlatform }) + ).rejects.toThrow('Access to the path') + + expect(fileWrites().map((write) => write.executable)).toEqual(['pwsh.exe']) + expect(getWindowsRemoteWriteCapabilities(target).shouldTry('pwsh')).toBe(true) + expect(commands.some((command) => command.script.includes('::Move('))).toBe(false) + }) + + const capturedPowerShellMissingPwsh = + "pwsh.exe : The term 'pwsh.exe' is not recognized as the name of a cmdlet, function, script file, or operable program. \r\nCheck the spelling of the name, or if a path was included, verify that the path is correct and try again.\r\nAt line:1 char:1\r\n+ pwsh.exe -NoProfile -NonInteractive -Command exit\r\n+ ~~~~~~~~\r\n + CategoryInfo : ObjectNotFound: (pwsh.exe:String) [], CommandNotFoundException\r\n + FullyQualifiedErrorId : CommandNotFoundException" + + // Source-derived resource/layout controls; these are not captured localized Windows errors. + const sourceDerivedLocalizedMissingPwsh = [ + 'pwsh.exe : La commande est introuvable.', + 'Vérifiez le nom de la commande.', + 'À la ligne:1 caractère:1', + '+ pwsh.exe -NoProfile -NonInteractive -EncodedCommand JABwAGEAdABoAA== ...', + '+ ~~~~~~~~', + ' + CategoryInfo : CommandNotFoundException — cible pwsh.exe, type String', + ' + FullyQualifiedErrorId : CommandNotFoundException' + ].join('\r\n') + const sourceDerivedWrappedMissingPwsh = [ + 'pwsh.exe : The term', + "'pwsh.exe' is not recognized.", + 'At line:1 char:1', + '+ pwsh.exe -NoProfile ', + '-NonInteractive -EncodedCommand ', + 'JABwAGEAdABoAA== ...', + '+ ~~~~~~~~', + ' + CategoryInfo : ObjectNotFound: ', + '(pwsh.exe:String) [], CommandNotFoundExcept', + 'ion', + ' + FullyQualifiedErrorId : CommandNotFoundExcept', + 'ion' + ].join('\r\n') + + it.each([ + "'missing-tool.exe' is not recognized as an internal or external command", + `Access to the path 'relay.js' is denied.\n${capturedPowerShellMissingPwsh}`, + `${capturedPowerShellMissingPwsh}\nAccess to the path 'relay.js' is denied.`, + capturedPowerShellMissingPwsh.replaceAll('pwsh.exe', 'missing-tool.exe'), + capturedPowerShellMissingPwsh.replace(/.*CategoryInfo.*\r?\n/, ''), + capturedPowerShellMissingPwsh.replace('At line:', 'pwsh.exe : Another failure.\r\nAt line:'), + capturedPowerShellMissingPwsh.replace('At line:', 'another.exe : Another failure.\r\nAt line:'), + sourceDerivedLocalizedMissingPwsh.replace('cible pwsh.exe', 'cible relay-pwsh.exe.js'), + sourceDerivedLocalizedMissingPwsh.replace('cible pwsh.exe', 'cible C:\\bin\\pwsh.exe'), + sourceDerivedLocalizedMissingPwsh.replace('cible pwsh.exe', 'cible pwsh.exe-backup'), + sourceDerivedLocalizedMissingPwsh.replace('type String', 'type FileInfo'), + sourceDerivedWrappedMissingPwsh.replace('+ pwsh.exe', '+ missing-tool.exe'), + `${sourceDerivedWrappedMissingPwsh}\n${capturedPowerShellMissingPwsh}`, + `${sourceDerivedLocalizedMissingPwsh}\nAccess to the path 'relay.js' is denied.`, + `Access to the path 'relay.js' is denied.\n${sourceDerivedLocalizedMissingPwsh}`, + 'CommandNotFoundException: missing-tool.exe', + "Access to the path 'relay.js' is denied.\nCommandNotFoundException: pwsh.exe", + "Access to the path 'relay.js' is denied.\n'pwsh.exe' is not recognized as an internal or external command" + ])( + 'does not mark PowerShell 7 absent when its script reports another missing command: %s', + async (stderr) => { + writeFileSync(join(localDir, 'relay.js'), 'x') + failAtSpawn = 0 + failedWriteStderr = stderr + + await expect( + uploadFileViaSystemSsh(target, join(localDir, 'relay.js'), `${remoteRoot}/relay.js`, { + hostPlatform + }) + ).rejects.toThrow('failed (exit 1)') + + expect(fileWrites().map((write) => write.executable)).toEqual(['pwsh.exe']) + expect(getWindowsRemoteWriteCapabilities(target).shouldTry('pwsh')).toBe(true) + expect(commands.some((command) => command.script.includes('::Move('))).toBe(false) + } + ) + + it.each([ + [9009, ''], + [1, "'pwsh.exe' is not recognized as an internal or external command"], + [1, 'CommandNotFoundException: pwsh.exe'], + [ + 1, + "'pwsh.exe' is not recognized as an internal or external command,\r\noperable program or batch file." + ], + [1, capturedPowerShellMissingPwsh] + ])('falls back on an actual missing PowerShell 7 signal: %s %s', async (exit, stderr) => { + writeFileSync(join(localDir, 'relay.js'), Buffer.alloc(WINDOWS_STDIN_WRITE_CHUNK_BYTES * 3)) + failAtSpawn = 0 + failedWriteExit = exit + failedWriteStderr = stderr + + await uploadFileViaSystemSsh(target, join(localDir, 'relay.js'), `${remoteRoot}/relay.js`, { + hostPlatform + }) + + expect(fileWrites().map((write) => write.executable)).toEqual([ + 'pwsh.exe', + 'powershell.exe', + 'powershell.exe', + 'powershell.exe' + ]) + expect(getWindowsRemoteWriteCapabilities(target).shouldTry('pwsh')).toBe(false) + expect(commands.some((command) => command.script.includes('::Move('))).toBe(true) + }) + + it.each([ + sourceDerivedLocalizedMissingPwsh, + sourceDerivedWrappedMissingPwsh, + sourceDerivedLocalizedMissingPwsh.replace(' -NonInteractive', '\r\n -NonInteractive') + ])( + 'falls back on a source-derived localized or wrapped missing-pwsh record: %s', + async (stderr) => { + writeFileSync(join(localDir, 'relay.js'), 'x') + failAtSpawn = 0 + failedWriteStderr = stderr + + await uploadFileViaSystemSsh(target, join(localDir, 'relay.js'), `${remoteRoot}/relay.js`, { + hostPlatform + }) + + expect(fileWrites().map((write) => write.executable)).toEqual(['pwsh.exe', 'powershell.exe']) + expect(getWindowsRemoteWriteCapabilities(target).shouldTry('pwsh')).toBe(false) + expect(commands.some((command) => command.script.includes('::Move('))).toBe(true) + } + ) + + it.each([ + 'relay-CommandNotFoundException.js', + 'is not recognized as an internal or external command.js' + ])('propagates a timeout whose filename resembles a missing command: %s', async (file) => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const remotePath = `${remoteRoot}/${file}` + writeFileSync(join(localDir, file), 'x') + let noteWriteStarted: () => void = () => {} + const writeStarted = new Promise((resolve) => { + noteWriteStarted = resolve + }) + spawnSystemSshCommandMock.mockImplementation((_target: SshTarget, command: string) => { + const executable = command.split(' ')[0] ?? '' + return createFakeChannel((channel) => { + commands.push({ + script: decodePowerShellCommand(command), + executable, + stdin: channel.written + }) + if (executable !== 'pwsh.exe') { + setImmediate(() => channel.emit('close', 0, null)) + } else { + noteWriteStarted() + } + }) + }) + try { + const result = expect( + uploadFileViaSystemSsh(target, join(localDir, file), remotePath, { hostPlatform }) + ).rejects.toThrow('timed out') + await writeStarted + await vi.advanceTimersByTimeAsync(WINDOWS_STDIN_WRITE_TIMEOUT_MS + 1) + await result + + expect(fileWrites().map((write) => write.executable)).toEqual(['pwsh.exe']) + expect(getWindowsRemoteWriteCapabilities(target).shouldTry('pwsh')).toBe(true) + expect(commands.some((command) => command.script.includes('::Move('))).toBe(false) + } finally { + vi.useRealTimers() + } + }) + + it.each([ + 'relay-CommandNotFoundException.js', + 'is not recognized as an internal or external command.js' + ])('propagates a short source whose filename resembles a missing command: %s', async (file) => { + await expect( + writeWindowsRemoteFile( + target, + `${remoteRoot}/${file}`, + { + totalBytes: 1, + readChunk: async () => Buffer.alloc(0), + withLocalFile: async (send) => send(join(localDir, file)) + }, + {} + ) + ).rejects.toThrow('Source ran short') + + expect(fileWrites()).toHaveLength(0) + expect(getWindowsRemoteWriteCapabilities(target).shouldTry('pwsh')).toBe(true) + expect(commands.some((command) => command.script.includes('::Move('))).toBe(false) + }) }) describe('last-resort Windows PowerShell failure reporting', () => { diff --git a/src/main/ssh/system-ssh-windows-write-strategy.ts b/src/main/ssh/system-ssh-windows-write-strategy.ts index f2cdca12516..e8016db313c 100644 --- a/src/main/ssh/system-ssh-windows-write-strategy.ts +++ b/src/main/ssh/system-ssh-windows-write-strategy.ts @@ -3,6 +3,7 @@ import { getSystemSshBuildArgsFromOperationOptions } from './system-ssh-args' import { spawnSystemSshCommand } from './system-ssh-command' import { awaitWithSystemSshAbort, + SystemSshCommandExitError, throwIfAborted, waitForChannelClose } from './system-ssh-operation-lifecycle' @@ -267,11 +268,42 @@ export function explainWindowsPowerShellStdinFailure(error: unknown): unknown { } function isPwshUnavailableError(error: unknown): boolean { - const message = error instanceof Error ? error.message : String(error) - // cmd.exe's "not recognized" and sshd's exit 9009 both mean "no pwsh here". A timeout does not: - // that is the stdin defect, and PowerShell 7 does not have it, so it must not be cached as absent. - return /is not recognized as an internal or external command|9009|CommandNotFoundException/i.test( - message + if (!(error instanceof SystemSshCommandExitError)) { + return false + } + // A complete missing-pwsh diagnostic establishes absence; paths and mixed errors do not. + const stderr = error.stderr.trim() + return ( + error.exitCode === 9009 || + /^'pwsh\.exe' is not recognized as an internal or external command(?:,\r?\noperable program or batch file\.)?$/i.test( + stderr + ) || + /^CommandNotFoundException:[ \t]*pwsh\.exe$/i.test(stderr) || + isPowerShellMissingPwshDiagnostic(stderr) + ) +} + +function isPowerShellMissingPwshDiagnostic(stderr: string): boolean { + // NormalView wraps physical lines; its localized prose and category template are not identifiers. + const record = stderr.replace(/\r?\n/g, '') + const sections = + /^pwsh\.exe[ \t]*:[ \t]*([^+]+)\+[ \t]*pwsh\.exe([ \t]+-[^~]*?)\+[ \t]*~{8}[ \t]*\+[ \t]*CategoryInfo[ \t]*:[ \t]*([^+]+)\+[ \t]*FullyQualifiedErrorId[ \t]*:[ \t]*CommandNotFoundException[ \t]*$/.exec( + record + ) + if (!sections) { + return false + } + const prelude = sections[1] ?? '' + const source = sections[2] ?? '' + const category = sections[3] ?? '' + return ( + !/\b[\w.-]+\.exe[ \t]*:/.test(prelude) && + /^[ \t]+-NoProfile[ \t]+-NonInteractive[ \t]+-(?:Command[ \t]+exit|EncodedCommand[ \t]+[A-Za-z0-9+/=]+(?:[ \t]*\.{3})?)[ \t]*$/.test( + source + ) && + category.match(/(? Date: Sun, 4 Oct 2026 06:24:30 -0700 Subject: [PATCH 57/67] Verify shared preflight selection and record full unit timings (#25239) * Strengthen shared preflight contracts and record unit timing results * Record rejected shard-weight holdouts --- .github/workflows/pr.yml | 2 +- .../scripts/check-readme-local-links.test.mjs | 3 +- config/scripts/pr-preflight-gates.test.mjs | 97 ++++++++++- docs/reference/ci-runner-efficiency.md | 150 ++++++++++++++++-- 4 files changed, 227 insertions(+), 25 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index d5e4a710442..b370dff9929 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -161,7 +161,7 @@ jobs: # Why ARM: measured 128s against 172s on ubuntu-latest, with every compute step faster -- # type-aware 24s->15s, anti-slop 28->19s, localization extraction 67->46s, the orcad smoke # 39->14s. Both lint engines ship linux-arm64 and the Bun target follows process.arch, so - # the whole toolchain resolves. Free for public repositories, same as the typecheck job. + # the whole toolchain resolves. Free for public repositories. runs-on: ubuntu-24.04-arm outputs: shards: ${{ steps.unit-plan.outputs.shards }} diff --git a/config/scripts/check-readme-local-links.test.mjs b/config/scripts/check-readme-local-links.test.mjs index e1d69723ee5..c9128630f19 100644 --- a/config/scripts/check-readme-local-links.test.mjs +++ b/config/scripts/check-readme-local-links.test.mjs @@ -156,8 +156,7 @@ describe('README local link check', () => { ]) }) - // Why the ungated job: static_analysis is skipped for docs-only diffs, which is - // exactly the kind of PR that deletes a docs-site GIF the README embeds. + // Docs-only diffs skip preflight, so the detector must check README links. it('runs on every PR through the ungated detector and in the lint script', () => { const { scripts } = JSON.parse(readFileSync(path.join(projectDir, 'package.json'), 'utf8')) const workflow = parse(readFileSync(path.join(projectDir, '.github/workflows/pr.yml'), 'utf8')) diff --git a/config/scripts/pr-preflight-gates.test.mjs b/config/scripts/pr-preflight-gates.test.mjs index 152610f561b..250ea94ffe8 100644 --- a/config/scripts/pr-preflight-gates.test.mjs +++ b/config/scripts/pr-preflight-gates.test.mjs @@ -27,7 +27,10 @@ it('shares one setup and runs the unchanged compiler after static checks finish' expect(steps[0].with['fetch-depth']).toBeGreaterThanOrEqual(2) expect(compiler.background).toBeUndefined() expect(plan.background).toBe(true) - expect(plan.run).toContain('node config/scripts/ci-unit-plan.mjs') + expect(plan.run.trim().split('\n')).toEqual([ + 'if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi', + 'node config/scripts/ci-unit-plan.mjs' + ]) expect(plan.env.ORCA_UNIT_SELECTION_MODE).toContain('vars.ORCA_UNIT_SELECTION_MODE') expect(steps.indexOf(plan)).toBeLessThan(steps.indexOf(compiler)) expect(steps.indexOf(compiler)).toBeGreaterThan( @@ -66,20 +69,98 @@ it('requires physical preflight success before publishing shards and admitting c expect(workflow.jobs.verify.needs).not.toContain('typecheck') }) -it.each( - [['README.md'], ['mobile/src/App.tsx'], ['cloud/package.json'], ['src/main/index.ts'], []].map( - (changed) => ({ changed }) +it('pins every foreground and background step to its selected phase', () => { + const staticPhase = "needs.code_paths.outputs.static_analysis == 'true'" + const typePhase = "needs.code_paths.outputs.typecheck == 'true'" + const foreground = steps.filter( + (step) => !step.background && /outputs\.(static_analysis|typecheck)/.test(step.if ?? '') ) -)('preserves phase selection for unrelated paths: $changed', ({ changed }) => { + expect(foreground.map((step) => [step.name ?? step.run ?? step.uses, step.if])).toEqual([ + ['Reject low-evidence patterns', staticPhase], + ['Enforce type-aware code-quality baseline', staticPhase], + [ + './.github/actions/install-mobile-dependencies', + `${staticPhase} && needs.code_paths.outputs.mobile_dependencies == 'true'` + ], + ['Enforce React Doctor on changed lines', staticPhase], + ['Check Zustand selector fan-out budget', staticPhase], + ['Check reliability gate manifest', staticPhase], + ['Enforce dead design-system classes', staticPhase], + ['Check VM runtime rollback compatibility', staticPhase], + ['Enforce max-lines ratchet', staticPhase], + ['Enforce ts-nocheck ratchet', staticPhase], + ['Enforce runtime Electron-import ratchet', staticPhase], + ['Check Node runtime pin', staticPhase], + ['Boot orcad and round-trip a terminal', staticPhase], + ['Verify the generated RPC params catalog', staticPhase], + ['Verify bundled skill guides', staticPhase], + ['Verify skill freshness manifest', staticPhase], + ['Verify localization coverage', staticPhase], + ['Guard against project-owned .d.ts in preload/shared', staticPhase], + ['Check feature wall asset budget', staticPhase], + ['Verify macOS entitlements', staticPhase], + ['Cache TypeScript incremental state', typePhase], + ['pnpm run typecheck', typePhase], + ['actions/upload-artifact@v7', typePhase] + ]) + expect( + steps + .filter((step) => step.background) + .map((step) => [step.id, step.env.PREFLIGHT_PHASE_SELECTED]) + ).toEqual([ + ['root-lint', `\${{ ${staticPhase} }}`], + ['native-code-quality', `\${{ ${staticPhase} }}`], + ['changed-code-quality', `\${{ ${staticPhase} }}`], + ['localization-extraction', `\${{ ${staticPhase} }}`], + ['localization-catalogs', `\${{ ${staticPhase} }}`], + ['unit-plan', `\${{ ${typePhase} }}`] + ]) +}) + +it.each([ + { changed: ['README.md'], static_analysis: false, typecheck: false, mobile_dependencies: false }, + { + changed: ['mobile/src/App.tsx'], + static_analysis: true, + typecheck: false, + mobile_dependencies: true + }, + { + changed: ['cloud/package.json'], + static_analysis: false, + typecheck: false, + mobile_dependencies: false + }, + { + changed: ['src/main/index.ts'], + static_analysis: true, + typecheck: true, + mobile_dependencies: false + }, + { + changed: ['mobile/src/App.tsx', 'src/main/index.ts'], + static_analysis: true, + typecheck: true, + mobile_dependencies: true + }, + { changed: [], static_analysis: true, typecheck: true, mobile_dependencies: true } +])('preserves exact phase selection for unrelated paths: $changed', ({ changed, ...expected }) => { const scope = classifyPrJobs(changed) + expect({ + static_analysis: scope.static_analysis, + typecheck: scope.typecheck, + mobile_dependencies: scope.mobile_dependencies + }).toEqual(expected) const needs = { code_paths: { outputs: Object.fromEntries(Object.entries(scope).map(([key, value]) => [key, String(value)])) } } - expect(runInNewContext(preflight.if, { needs })).toBe(scope.static_analysis || scope.typecheck) - expect(runInNewContext(compiler.if, { needs })).toBe(scope.typecheck) - expect(scope.test).toBe(scope.typecheck) + expect(runInNewContext(preflight.if, { needs })).toBe( + expected.static_analysis || expected.typecheck + ) + expect(runInNewContext(compiler.if, { needs })).toBe(expected.typecheck) + expect(scope.test).toBe(expected.typecheck) }) it('registers successful no-op background work when a phase is unselected or already failed', () => { diff --git a/docs/reference/ci-runner-efficiency.md b/docs/reference/ci-runner-efficiency.md index 00d1d0cf39f..58cef59dde5 100644 --- a/docs/reference/ci-runner-efficiency.md +++ b/docs/reference/ci-runner-efficiency.md @@ -133,7 +133,7 @@ POST start/completion rows; wall timestamps and cross-stream interleaving are excluded from equivalence. Eleven generated-source faults fail their intended identity, reload, order, deadline or timer-cleanup assertions. Four import/setup/ disposer rejection and timeout controls confirm restoration of clocks, fetch, -argv and environment. Teardown restores real clocks before its bounded wait. +argv and environment. Teardown holds its fake clock through bounded native cleanup and pending-timer checks, then restores real clocks. The [teardown qualification](https://github.com/stablyai/orca/actions/runs/37195736834) passes all 41 cases and 12 failure and restoration control invocations, including interval and retry leaks missed by the earlier teardown. Title-send authorization tests retain real terminal creation, graph binding and positive evidence paths. Negative process-evidence probes use scoped clocks after setup: both 150 ms polling loops retain their 6,500 ms budget, crossed at @@ -151,6 +151,7 @@ original test bodies are byte-unchanged. Three actual range/hunk/draft faults fail their original assertions; a real draft-render menu call hits the facade and sentinel, and an outer cleanup check proves mock state cleared after failure. The actual saved-note menu retains its independent component tests. + ## October 2 headless detector compiler cache The deferred detector already avoids dependency setup for known build inputs. @@ -706,6 +707,8 @@ All commands passed and plans matched within each comparison. These command timings exclude setup and queues; compiler variation contributes to the cold difference. The retained arrangement showed no cold compiler penalty. +The sequential gate in [October 4 shared PR preflight capacity](#october-4-shared-pr-preflight-capacity) supersedes the earlier rejection below. + Combining static analysis too was rejected. An [alternating same-runner comparison](https://github.com/stablyai/orca/actions/runs/36835091650) saved runner occupancy, but cold compilation slowed from 61–64 to 83–89 seconds @@ -1844,6 +1847,8 @@ these local body measurements do not establish hosted or whole-PR time savings. ## Sequential static analysis and typecheck: retain separate jobs +The earlier recommendation below is superseded by [October 4 shared PR preflight capacity](#october-4-shared-pr-preflight-capacity). + A four-trial hosted screen kept the slim router unchanged and compared the two independent ARM jobs with one ARM job running their unchanged checks sequentially. The [compiler/planner census](https://github.com/stablyai/orca/actions/runs/37069472888) @@ -2085,11 +2090,11 @@ production and still passed when production always threw. Three alternating one-worker hosted ARM pairs measured complete invocations: -| Cohort | Baseline median | Candidate median | Saving | -| --- | --- | --- | --- | -| Serializer replay/fuzz/descriptor checks | 71.675s | 46.581s | 35.0% | -| Emulator/reconciliation/color parity | 24.095s | 5.411s | 77.5% | -| Frame equivalence | 18.472s | 13.736s | 25.6% | +| Cohort | Baseline median | Candidate median | Saving | +| ---------------------------------------- | --------------- | ---------------- | ------ | +| Serializer replay/fuzz/descriptor checks | 71.675s | 46.581s | 35.0% | +| Emulator/reconciliation/color parity | 24.095s | 5.411s | 77.5% | +| Frame equivalence | 18.472s | 13.736s | 25.6% | [37180517143](https://github.com/stablyai/orca/actions/runs/37180517143) retained 116 timed serializer passes and three existing/paired-control skips. @@ -2164,11 +2169,11 @@ Three alternating one-worker hosted ARM pairs in [37182181976](https://github.com/stablyai/orca/actions/runs/37182181976) measured these complete invocations: -| Cohort | Baseline seconds | Candidate seconds | Median saving | -| --- | --- | --- | --- | -| Three imports only, same 15 tests | 19.257 / 19.167 / 19.363 | 1.769 / 1.768 / 1.768 | 90.8% | -| Final four-file runtime cohort | 22.312 / 22.122 / 21.969 | 13.494 / 13.793 / 13.601 | 38.5% | -| Recovery crash boundaries | 24.082 / 24.075 / 24.814 | 8.061 / 8.105 / 9.074 | 66.3% | +| Cohort | Baseline seconds | Candidate seconds | Median saving | +| --------------------------------- | ------------------------ | ------------------------ | ------------- | +| Three imports only, same 15 tests | 19.257 / 19.167 / 19.363 | 1.769 / 1.768 / 1.768 | 90.8% | +| Final four-file runtime cohort | 22.312 / 22.122 / 21.969 | 13.494 / 13.793 / 13.601 | 38.5% | +| Recovery crash boundaries | 24.082 / 24.075 / 24.814 | 8.061 / 8.105 / 9.074 | 66.3% | The final runtime cohort has seven real cases versus 16 including the copied loops; its new runtime case is included in candidate timing. Recovery has 50 @@ -2250,9 +2255,9 @@ Three alternating one-worker hosted ARM pairs in [37180614492](https://github.com/stablyai/orca/actions/runs/37180614492) measured these complete focused invocations: -| Suite | Baseline seconds | Candidate seconds | Median saving | -| --- | --- | --- | --- | -| Git admission storm | 26.619 / 26.635 / 26.582 | 1.017 / 1.018 / 1.016 | 25.602s (96.2%) | +| Suite | Baseline seconds | Candidate seconds | Median saving | +| --------------------- | ------------------------ | ------------------------ | --------------- | +| Git admission storm | 26.619 / 26.635 / 26.582 | 1.017 / 1.018 / 1.016 | 25.602s (96.2%) | | Antigravity readiness | 27.347 / 27.910 / 27.550 | 13.855 / 13.894 / 13.800 | 13.695s (49.7%) | Each candidate passed its original meaningful checks. Hosted Node typecheck @@ -2261,3 +2266,120 @@ FIFO-only priority failed the queued-contention or interactive-start assertion. Two additional local transcript faults failed the original picker-rejection and repaint-readiness assertions. These are focused suite savings; whole-shard time and queue delay were not measured by this experiment. + +## October 4 aggregate unit-test comparison + +A [counterbalanced hosted comparison](https://github.com/stablyai/orca/actions/runs/37197643399) +measured 128.605 seconds less summed test-process time (4.36%) and a 37.694-second +reduction in the slowest shard (5.98%). It compares the accepted optimizations +with their original file snapshots on the same source, five fixed shard +assignments, Node 24, Ubuntu ARM and four workers per process. This is one paired +trial, not a population estimate or a measurement of PR queue delay. + +| Test process | Original snapshots | Accepted optimizations | +| ----------------------------- | ------------------ | ---------------------- | +| Shard 1 | 574.221s | 533.116s | +| Shard 2 | 572.553s | 569.593s | +| Shard 3 | 630.629s | 592.935s | +| Shard 4 | 565.412s | 546.759s | +| Shard 5 | 609.410s | 581.218s | +| Sum: runner time during tests | 2952.225s | 2823.621s | +| Maximum: test critical path | 630.629s | 592.935s | + +Both arms cover exactly 10,838 timed modules on source `9574c8adb253` and tree +`4d5487e8b827`. One added eight-case batching qualification passes in a separate +0.770-second invocation outside the table, completing the 10,839-module ordinary +census. The complete timed case and outcome +comparison accounts for eight approved coverage changes: 105,231 original cases +versus 105,242 candidate cases. Removed copied simulations and an algebra-only +case are accompanied by real runtime, retention, recovery and reusable-cell +regressions. No unexpected case or outcome difference is accepted. + +Each arm starts with distinct empty transform and result caches; Node compilation +caching is disabled. Cold-cache execution ordering remains Vitest's default and +can change with source size. Source snapshots, assignments, raw reports and case outcomes +are checked. Only three case-title fields containing random temporary paths or +a UUID use stable identities, bound to the exact two test-source hashes; raw +titles remain in the artifacts. + +The five dependency setups total 84.284 seconds and are shared by both arms. +The actual paired jobs consumed 5,969 seconds and spanned 2,031 seconds from the +first start to the last completion. Those job figures include both treatments, +setup, uploads and staggered starts; they cannot be assigned to either arm or +used as a workflow saving. The table measures test-process wall time, not CPU +time or the complete CI workflow. Focused-suite percentages elsewhere in this +report are separate measurements and must not be summed into these results. + +The [earlier aggregate trial](https://github.com/stablyai/orca/actions/runs/37193799646) +is rejected because a real test failed; its timings do not qualify a gain. Two +local invocations sharing one XDG directory reproduced the Muse refresher failure. +The fixture now isolates and restores that setting in both arms. The historical +serializer case ledger was also independently corrected from the original source +before this fresh trial; its seven original cases and twenty candidate cases are +an explicit coverage change rather than an assumed equal census. + +## October 4 shard-weight holdouts + +Fresh shard weights were generated with the production importer from a complete +successful run of the accepted source. Two subsequent hosted holdouts used those +same weights and assignments without retraining. The +[first pair](https://github.com/stablyai/orca/actions/runs/37199891967) alternated +existing and fresh assignments across the five jobs; the +[second pair](https://github.com/stablyai/orca/actions/runs/37201939057) reversed +each job's treatment order. + +| Test-process measurement | First: existing | First: fresh | Reversed: existing | Reversed: fresh | +| ------------------------ | --------------- | ------------ | ------------------ | --------------- | +| Sum across five shards | 2813.595s | 2776.421s | 2924.689s | 2878.481s | +| Maximum shard wall | 578.735s | 584.709s | 603.995s | 614.408s | + +Fresh weights reduced summed test-process time by 1.32% and 1.58%, but increased +the slowest shard's time by 1.03% and 1.72%. The small capacity saving comes with +a repeated critical-path regression, so the existing weights remain. The 3.01% +improvement projected from training module durations is not a measured speed +gain. + +Every arm covers the same 10,839 modules and 105,250 case outcomes on source +`9574c8adb253`, tree `4d5487e8b827`, Node 24.21.0, Ubuntu ARM and four workers. +Both trials use cold caches and the same training data, weights, plans and case +identity rules. Complete raw reports, assignments, hashes and opposite treatment +orders are checked before combining the results. Two pairs supply no statistical +confidence or account-wide queue measurement. The second run's jobs started 119 +seconds apart; that stagger and the paired jobs' setup and upload costs are +separate from the treatment timings above. + +## October 4 remaining unit-test opportunities + +The audit retained real child-process, PTY, SSH and crash-boundary tests. It +removed copied simulations or algebra-only cases after fault controls showed +that they could pass with production behavior broken. The retained or replacement +tests exercise production behavior directly. The focused timings in this report +use the final qualified checks. They must not be added together to estimate a +whole-workflow saving. + +Several further changes did not justify promotion: + +- A synchronous readiness-clock screen retained all 49 shard cases and their + outcomes, but complete invocation time changed only from 34.210 to 33.518 + seconds in one local pair. That 2% result was too small to ship without a + stronger result; the original implementation remains. +- A larger local transform-cache screen reduced warm test execution. Separately + measured medians for warm tests (17.251 seconds), extraction (3.539 seconds) and + archive creation (3.092 seconds) sum to 23.882 seconds, versus 23.473 seconds + with caching disabled. This component estimate excludes transfer costs; it is not an + end-to-end measurement. Unkeyed plugin options, inherited configuration and + import priority also produced stale reuse. Persisted test transforms remain + disabled. +- Two successful full-run shadow references identified about 1.9% of + recorded worker time as omittable. That is advisory worker time, not measured + runner occupancy. It does not supply the failed-reference evidence or a + complete selected-run comparison needed to enable test selection. +- Six sampled failed PR runs contained no failed unit job that could trigger + unit-matrix fail-fast. Successful unit siblings of failures in other jobs + cannot be counted as savings from that policy. The sample is too small to + establish a population-wide rate, and the policy remains unchanged. + +These screens reject the examined changes; they do not establish that every +future optimization is exhausted. Shorter admitted jobs and one shared preflight +reduce demand on the existing runner allowance. They do not increase that +allowance or prove lower queue delay under different account traffic. From ea6a6d60774ac2b74bb6692d1798e3ab13b99ae0 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 4 Oct 2026 06:39:39 -0700 Subject: [PATCH 58/67] Pass wrapped OpenCode run prompts as positional messages (#25001) * fix: wait for OpenCode worker composer before first dispatch Reuse captured composer readiness on local and paired execution hosts and revoke launching-shell paste anchors. Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> * feat(opencode): probe execution-host CLI capabilities * fix(opencode): select plugin default for execution host loader * fix(opencode): limit prompt prefill capability to verified release * feat(opencode): probe launch capabilities on the execution host * fix(opencode): select plugin loader for the launched host binary * fix(opencode): match WSL probe cwd and declared guest environment * fix(opencode): preserve launch environment deletion boundaries * wip(opencode): authorize native startup prompt intent at execution owner * fix(opencode): atomically replace status plugin entrypoints * fix(opencode): retain plugin permissions across restrictive umasks * test(opencode): resolve permission fixture from primary cwd * feat(opencode): install startup prompt plugin independently of status hooks * fix(opencode): wait for admitted startup intent and preserve failed-launch briefs * fix(opencode): confine overlay manifest cleanup to owned directories Co-authored-by: Adnan Khan * fix: wait for OpenCode worker composer before first dispatch Reuse captured composer readiness on local and paired execution hosts and revoke launching-shell paste anchors. Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> * feat(opencode): probe execution-host CLI capabilities * fix(opencode): select plugin default for execution host loader * fix(opencode): limit prompt prefill capability to verified release * feat(opencode): probe launch capabilities on the execution host * fix(opencode): select plugin loader for the launched host binary * fix(opencode): match WSL probe cwd and declared guest environment * fix(opencode): preserve launch environment deletion boundaries * wip(opencode): authorize native startup prompt intent at execution owner * fix(opencode): atomically replace status plugin entrypoints * fix(opencode): retain plugin permissions across restrictive umasks * test(opencode): resolve permission fixture from primary cwd * feat(opencode): install startup prompt plugin independently of status hooks * fix(opencode): wait for admitted startup intent and preserve failed-launch briefs * fix(opencode): unsubscribe hook settings during async host shutdown * fix(opencode): confine overlay manifest cleanup to owned directories Co-authored-by: Adnan Khan * test(readiness): census recorded OpenCode composer boots * fix(opencode): reject redirected overlay parents before cleanup * fix(orcad): retain runtime cleanup when subscribing to hook settings * refactor(launch): extract OpenCode config and attachment authority * fix(opencode): retain host version selection across relay restarts * fix(opencode): pass run prompts as positional messages Preserve run flags and use the existing shell quoting and run-command detector to append the initial message after --, reusing an existing separator. TUI launches retain their version-selected prompt transport and draft behavior. Original run-order work: @coelho-doti (#13065, tracked in #17551). * fix(opencode): keep wrapped run tasks positional Recognize supported environment prefixes and PowerShell call operators without mistaking prompt arguments for executables. Keep environment and run separators separate, preserve the task text and exclude run commands from native submission. Source-parent: 23fc08b4e939169f0f6bc80d6e2a92e88699258b Related-to: stablya/orca#17551 Credits: @coelho-doti (stablya/orca#13065) * Prepare complete private OpenCode launch validation source Integrate the complete reviewed readiness, capability, native prompt, overlay and positional-run source onto frozen main. Preserve canonical atomic ACL retry, status generator/disposal, restrictive-umask fixtures and unowned source. Keep supported wrapped run commands positional. Private-validation-source: a44345ce496482c2d9d5bcaf7bb890faa3d4d52e Original-full-source: 23fc08b4e939169f0f6bc80d6e2a92e88699258b Original-core-base: 8186ded0bd5549c7bcca6f69d83e18565e1c8648 Frozen-main: 08ee7ba9efa2f3842b7a057a1eb101c824ea0087 Owned-source-paths: 111 Publication-policy: private validation only; preserve the six separate PR boundaries and held model/provider drafts Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Co-authored-by: Adnan Khan Credits: juli-gonzalez readiness contribution; Ahmed Nagy atomic plugin writer; coelho-doti positional run contribution * Prepare private complete 111-path launch validation on current main Private validation only. Preserve main credential additions and original launch ownership. Held model and provider topics remain excluded. * Recognize env options before positional OpenCode run messages * STRICT launch CI contract correction * CAPS launch CI contract correction * INTENT launch CI contract correction * test(opencode): wait for malformed claim retries before expiring intent Observe real endpoint I/O completion under fake timers before forcing expiry. * test: initialize Claude prompt state in output retention fixture * Wait for OpenCode location hydration in intent startup * fix(opencode): bind startup readiness to the composer location * Bind OpenCode startup readiness to the current location in intent startup * Retry interrupted OpenCode startup prompt claims --------- Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Co-authored-by: Ahmed Nagy Co-authored-by: Adnan Khan Co-authored-by: Orca startup hydration review Co-authored-by: Orca --- src/shared/flag-prompt-startup.ts | 50 ++++++++++ src/shared/opencode-startup-prompt.test.ts | 107 ++++++++++++++++++++- src/shared/opencode-startup-prompt.ts | 2 +- src/shared/tui-agent-startup.ts | 21 ++-- 4 files changed, 166 insertions(+), 14 deletions(-) create mode 100644 src/shared/flag-prompt-startup.ts diff --git a/src/shared/flag-prompt-startup.ts b/src/shared/flag-prompt-startup.ts new file mode 100644 index 00000000000..50e1be306fa --- /dev/null +++ b/src/shared/flag-prompt-startup.ts @@ -0,0 +1,50 @@ +import type { AgentStartupPlan } from './tui-agent-startup' +import type { SleepingAgentLaunchConfig } from './agent-session-resume' +import type { SessionOptionValue } from './native-chat-session-options' +import type { TuiAgent } from './tui-agent' +import { TUI_AGENT_CONFIG } from './tui-agent-config' +import { tokenizeStartupCommand, type AgentStartupShell } from './tui-agent-startup-shell' +import { findOpenCodeRunCommand } from './opencode-headless-command' +import { openCodeStartupPromptEnv } from './opencode-startup-prompt' + +export function appliedSessionOptionProps(values: Record) { + return Object.keys(values).length > 0 ? { sessionOptions: { ...values } } : {} +} + +export function buildFlagPromptStartupPlan(args: { + agent: TuiAgent + launchCommand: string + quotedPrompt: string + prompt: string + shell: AgentStartupShell + launchConfig: SleepingAgentLaunchConfig + sessionOptions: Record + agentEnv: Record | null | undefined +}): AgentStartupPlan { + const parsed = tokenizeStartupCommand(args.launchCommand, args.shell) + const openCodeRun = + (args.agent === 'opencode' || args.agent === 'opencode2') && parsed.ok + ? findOpenCodeRunCommand(parsed.tokens, args.shell) + : null + // OpenCode run takes a positional message; --prompt belongs to its TUI. + const promptSuffix = openCodeRun + ? openCodeRun.messageSeparatorIndex !== null + ? ` ${args.quotedPrompt}` + : ` -- ${args.quotedPrompt}` + : ` --prompt ${args.quotedPrompt}` + return { + agent: args.agent, + launchCommand: `${args.launchCommand}${promptSuffix}`, + expectedProcess: TUI_AGENT_CONFIG[args.agent].expectedProcess, + followupPrompt: null, + launchConfig: args.launchConfig, + ...appliedSessionOptionProps(args.sessionOptions), + ...openCodeStartupPromptEnv( + args.agent, + args.launchCommand, + args.shell, + args.prompt, + args.agentEnv + ) + } +} diff --git a/src/shared/opencode-startup-prompt.test.ts b/src/shared/opencode-startup-prompt.test.ts index e4aab26d065..286f7e3ad2f 100644 --- a/src/shared/opencode-startup-prompt.test.ts +++ b/src/shared/opencode-startup-prompt.test.ts @@ -6,6 +6,7 @@ import { OPENCODE_STARTUP_PROMPT_BODY_ENV, OPENCODE_STARTUP_PROMPT_SHELL_ENV } from './opencode-startup-prompt' +import { tokenizeStartupCommand } from './tui-agent-startup-shell' describe('native OpenCode startup submission intent', () => { it('binds the exact trimmed native prompt to host-selectable transport', () => { @@ -33,9 +34,63 @@ describe('native OpenCode startup submission intent', () => { platform: 'linux' }) expect(plan?.env).toBeUndefined() - expect(plan?.launchCommand).toContain('run --prompt') + expect(plan?.launchCommand).toContain("run -- 'task'") }) + it.each(['posix', 'powershell', 'cmd'] as const)( + 'keeps run and its flags before a positional message in %s', + (shell) => { + for (const agent of ['opencode', 'opencode2'] as const) { + const prompt = '--literal task with unicode é' + const plan = buildAgentStartupPlan({ + agent, + prompt, + cmdOverrides: { [agent]: 'opencode --log-level debug run --standalone' }, + platform: shell === 'posix' ? 'linux' : 'win32', + shell, + agentEnv: { CUSTOM_CONFIG: 'kept' } + }) + expect(plan).not.toBeNull() + const parsed = tokenizeStartupCommand(plan?.launchCommand ?? '', shell) + expect(parsed.ok).toBe(true) + if (!parsed.ok) { + throw new Error(parsed.error) + } + expect(parsed.tokens).toEqual([ + 'opencode', + '--log-level', + 'debug', + 'run', + '--standalone', + '--', + prompt + ]) + expect(plan?.env).toEqual({ CUSTOM_CONFIG: 'kept' }) + expect(plan?.followupPrompt).toBeNull() + } + } + ) + + it.each(['posix', 'powershell', 'cmd'] as const)( + 'reuses an existing run message separator in %s', + (shell) => { + const plan = buildAgentStartupPlan({ + agent: 'opencode', + prompt: '--literal task', + cmdOverrides: { opencode: 'opencode run --standalone --' }, + platform: shell === 'posix' ? 'linux' : 'win32', + shell + }) + const parsed = tokenizeStartupCommand(plan?.launchCommand ?? '', shell) + expect(parsed.ok).toBe(true) + if (!parsed.ok) { + throw new Error(parsed.error) + } + expect(parsed.tokens).toEqual(['opencode', 'run', '--standalone', '--', '--literal task']) + expect(plan?.env).toBeUndefined() + } + ) + it('never gives an editable draft or empty launch an automatic submission intent', () => { const args = { agent: 'opencode' as const, cmdOverrides: {}, platform: 'linux' as const } expect( @@ -50,3 +105,53 @@ describe('native OpenCode startup submission intent', () => { ).toBeUndefined() }) }) + +describe('wrapped OpenCode run startup', () => { + it.each(['opencode', 'opencode2'] as const)( + 'keeps %s run flags and positional task behind POSIX prefixes', + (agent) => { + for (const command of [ + 'CUSTOM_CONFIG=private opencode run --standalone', + 'env CUSTOM_CONFIG=private opencode run --standalone', + 'env -- CUSTOM_CONFIG=private opencode run --standalone', + 'env -- CUSTOM_CONFIG=private opencode run --standalone --', + 'env CUSTOM_CONFIG=private opencode run --title "--"' + ]) { + const plan = buildAgentStartupPlan({ + agent, + prompt: '--literal task', + cmdOverrides: { [agent]: command }, + platform: 'linux', + shell: 'posix', + isRemote: true, + agentEnv: { CUSTOM_CONFIG: 'kept' } + }) + expect(plan?.launchCommand).toBe( + command.endsWith(' --') ? `${command} '--literal task'` : `${command} -- '--literal task'` + ) + expect(plan?.env).toEqual({ CUSTOM_CONFIG: 'kept' }) + expect(plan?.followupPrompt).toBeNull() + } + } + ) + + it.each(['opencode', 'opencode2'] as const)( + 'preserves %s PowerShell call syntax and its run separator', + (agent) => { + for (const separator of ['', ' --']) { + const command = `& "C:\\Program Files\\opencode\\opencode.exe" --log-level debug run --standalone${separator}` + const plan = buildAgentStartupPlan({ + agent, + prompt: "--task's é", + cmdOverrides: { [agent]: command }, + platform: 'win32', + shell: 'powershell', + agentEnv: { CUSTOM_CONFIG: 'kept' } + }) + expect(plan?.launchCommand).toBe(`${command}${separator ? ' ' : ' -- '}'--task''s é'`) + expect(plan?.env).toEqual({ CUSTOM_CONFIG: 'kept' }) + expect(plan?.followupPrompt).toBeNull() + } + } + ) +}) diff --git a/src/shared/opencode-startup-prompt.ts b/src/shared/opencode-startup-prompt.ts index 1dbe9c2719d..e3b1c462ffa 100644 --- a/src/shared/opencode-startup-prompt.ts +++ b/src/shared/opencode-startup-prompt.ts @@ -21,7 +21,7 @@ export function openCodeStartupPromptEnv( if ( (agent !== 'opencode' && agent !== 'opencode2') || !parsed.ok || - isOpenCodeRunCommand(parsed.tokens) + isOpenCodeRunCommand(parsed.tokens, shell) ) { return env ? { env: { ...env } } : {} } diff --git a/src/shared/tui-agent-startup.ts b/src/shared/tui-agent-startup.ts index ce8974a8cc3..8c56c1d9d95 100644 --- a/src/shared/tui-agent-startup.ts +++ b/src/shared/tui-agent-startup.ts @@ -17,7 +17,7 @@ import { inlineAgentDraftFitsPlatform } from './agent-draft-platform-limit' import type { TuiAgent } from './tui-agent' import type { SessionOptionValue } from './native-chat-session-options' import { resolveAgentLaunchCommand } from './tui-agent-launch-command' -import { openCodeStartupPromptEnv } from './opencode-startup-prompt' +import { appliedSessionOptionProps, buildFlagPromptStartupPlan } from './flag-prompt-startup' export { buildAgentResumeStartupPlan } from './tui-agent-resume-startup' @@ -36,10 +36,6 @@ export type AgentStartupPlan = { sessionOptions?: Record } -function appliedSessionOptionProps(values: Record) { - return Object.keys(values).length > 0 ? { sessionOptions: { ...values } } : {} -} - export function buildAgentStartupPlan(args: { agent: TuiAgent prompt: string @@ -117,15 +113,16 @@ export function buildAgentStartupPlan(args: { } if (config.promptInjectionMode === 'flag-prompt') { - return { + return buildFlagPromptStartupPlan({ agent, - launchCommand: `${launchCommand} --prompt ${quotedPrompt}`, - expectedProcess: config.expectedProcess, - followupPrompt: null, + launchCommand, + quotedPrompt, + prompt: trimmedPrompt, + shell, launchConfig, - ...appliedSessionOptionProps(baseCommand.appliedSessionOptions), - ...openCodeStartupPromptEnv(agent, launchCommand, shell, trimmedPrompt, args.agentEnv) - } + sessionOptions: baseCommand.appliedSessionOptions, + agentEnv: args.agentEnv + }) } if (config.promptInjectionMode === 'hermes-query') { From 8d87d2cf67d545d61bd28166f06716655647753f Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sun, 4 Oct 2026 11:19:02 -0700 Subject: [PATCH 59/67] feat(codex): tell Windows users once that Codex in Orca now shares ~/.codex (#24916) * feat(codex): tell Windows users once what stays behind when Codex moves onto ~/.codex When Windows' system-default Codex first runs on ~/.codex (launch prep or the usage poll), main decides once whether Orca's managed home was ever used and which MCP servers lived only there, and persists that in UI state. The renderer shows one dismissible toast when a Codex terminal exists, after the server-isolation notice rather than on top of it, and clears the notice when shown. The "kept only in the managed home" MCP rule is extracted into isRuntimeOnlyMcpServer, which the config mirror merge now uses too, so the notice names exactly the servers the mirror would have kept. * fix(codex): stop counting Orca's own config.toml as use of the old Codex home Orca's hook install writes that home's config.toml on every startup, so its presence was true for nearly every Windows user with Codex. The home now counts as used only with recorded sessions or an MCP server of its own. Resolver tests keep one case per input source. * refactor(codex): ask main for the shared-settings notice instead of persisting it The persisted missing/object/null field, written from launch prep and the usage poll, becomes a plain codexSharedSettingsNoticeSeen flag mirroring codexTerminalServerIsolationNoticeSeen. When a Codex terminal first appears and the flag is unset, the renderer asks codexConfigSync:sharedSettingsNotice once; main answers read-only (Windows, system default on ~/.codex, managed home path without mkdir) and maps any read error to null. Runtime-home routing, launch and the test harness return to main's code. The notice no longer waits for the server-isolation toast; they may stack. The Codex-terminal watch moves to codex-terminal-presence.ts. * refactor(codex): watch for the first Codex terminal in one place for both notices The server-isolation notice now passes its due check to whenCodexTerminalAppears instead of keeping its own copy of the presence scan, input filter and subscription loop. Its behaviour and tests are unchanged. * docs(codex): trim isRuntimeOnlyMcpServer's comment to why it is shared * refactor(codex): keep McpServerTomlOwnership private to its module * test(codex): cover the shared-settings notice channel without type assertions Handlers are looked up by channel now that two are registered, so the status tests no longer depend on registration order. * refactor(codex): show the Windows shared-settings notice without asking main Every way of detecting who relied on Orca's old Codex folder had false positives, so the renderer now shows one static toast on Windows the first time a Codex terminal exists. This drops the main-process resolver, its IPC channel, preload line, web stub and shared type, and the MCP-names variant of the description. * refactor(codex): restore the MCP server ownership helpers to main's shape The static notice no longer reads MCP servers, so the shared isRuntimeOnlyMcpServer extraction has no second caller. * refactor(codex): let each notice decide when it is due, so the Codex watcher only watches The isolation notice now selects its due predicate and starts the watcher only while due, so whenCodexTerminalAppears no longer takes an isDue or re-checks hydration and settings. The shared-settings notice uses isLocalWindowsDesktopClient, its test stubs the user agent instead of mocking pane-helpers, and the hydration safeguard it relies on is now tested on the UI slice itself. * test(codex): drive the Codex notices through a reactive store, and drop a redundant hydration gate The server-isolation notice now reads "is it due" through a store selector, but its test mocked the store without re-rendering, so a due change after mount (persisted UI loading, the setting turning off) was never exercised. The notice tests now share one harness backed by a real zustand store, the shared watcher gets its own test, and both notices cover the seen flag loading after mount. persistedUIReady is dropped from isNoticeDue: the seen flag defaults to true and only hydration clears it, in the same update that sets persistedUIReady. Both notices now gate the same way. * fix(codex): keep the shared-settings toast until dismissed, and shorten it It is marked seen before it shows, so a 15s auto-close could lose it for good while the user is typing in the Codex terminal that triggered it. Every other one-shot notice that marks itself seen on show stays until dismissed; this now does too. The text drops the sentence that repeated the title and keeps only what to expect and do. --------- Co-authored-by: Orca Worker --- .../src/app-shell/use-app-shell-services.ts | 2 + .../codex-notice-test-harness.ts | 37 ++++++ .../codex-shared-settings-notice.test.ts | 89 +++++++++++++ .../codex-shared-settings-notice.ts | 37 ++++++ .../codex-terminal-presence.test.ts | 56 ++++++++ .../terminal-pane/codex-terminal-presence.ts | 37 ++++++ ...x-terminal-server-isolation-notice.test.ts | 124 ++++++------------ .../codex-terminal-server-isolation-notice.ts | 61 ++------- src/renderer/src/i18n/locales/en.json | 4 + .../store/slices/ui-notice-dismissals.test.ts | 13 ++ .../slices/ui/ui-slice-contract-contextual.ts | 2 + .../slices/ui/ui-slice-hydration-actions.ts | 1 + .../store/slices/ui/ui-slice-trust-actions.ts | 9 ++ src/shared/persisted-ui-state-types.ts | 2 + src/shared/rpc-contract/client-ui-params.ts | 1 + 15 files changed, 335 insertions(+), 140 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/codex-notice-test-harness.ts create mode 100644 src/renderer/src/components/terminal-pane/codex-shared-settings-notice.test.ts create mode 100644 src/renderer/src/components/terminal-pane/codex-shared-settings-notice.ts create mode 100644 src/renderer/src/components/terminal-pane/codex-terminal-presence.test.ts create mode 100644 src/renderer/src/components/terminal-pane/codex-terminal-presence.ts diff --git a/src/renderer/src/app-shell/use-app-shell-services.ts b/src/renderer/src/app-shell/use-app-shell-services.ts index 6c52abb6518..879fcea5984 100644 --- a/src/renderer/src/app-shell/use-app-shell-services.ts +++ b/src/renderer/src/app-shell/use-app-shell-services.ts @@ -21,6 +21,7 @@ import { useRemoteRuntimeRecoveryTriggers } from '../runtime/use-remote-runtime- import { useTerminalViewerColorPublication } from './use-terminal-viewer-color-publication' import { useBrowserIdentityMigrationNotice } from '../components/browser-pane/browser-user-agent-migration-notice' import { useCodexTerminalServerIsolationNotice } from '../components/terminal-pane/codex-terminal-server-isolation-notice' +import { useCodexSharedSettingsNotice } from '../components/terminal-pane/codex-shared-settings-notice' /** * App-level subscriptions that must outlive any individual surface. Each one is here because @@ -56,4 +57,5 @@ export function useAppShellServices(options: { floatingPanelVisible: boolean }): useOsc52ClipboardDefaultOnNotice(persistedUIReady) useBrowserIdentityMigrationNotice() useCodexTerminalServerIsolationNotice() + useCodexSharedSettingsNotice() } diff --git a/src/renderer/src/components/terminal-pane/codex-notice-test-harness.ts b/src/renderer/src/components/terminal-pane/codex-notice-test-harness.ts new file mode 100644 index 00000000000..152f09d67f8 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/codex-notice-test-harness.ts @@ -0,0 +1,37 @@ +import { act, createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { useStore } from 'zustand' +import { createStore } from 'zustand/vanilla' + +type NoticeTestState = Record + +/** Stands in for `@/store`: a real zustand store, so selectors re-render and subscribe fires. */ +export const noticeTestStore = createStore()(() => ({})) + +export const useAppStore = Object.assign( + (selector: (state: NoticeTestState) => T): T => useStore(noticeTestStore, selector), + noticeTestStore +) + +const mountedRoots: Root[] = [] + +export async function mountHook(useHook: () => void): Promise { + function HookProbe(): null { + useHook() + return null + } + const root = createRoot(document.createElement('div')) + mountedRoots.push(root) + await act(async () => root.render(createElement(HookProbe))) +} + +export function unmountHooks(): void { + for (const root of mountedRoots.splice(0)) { + act(() => root.unmount()) + } +} + +/** Applies a store write and lets the hook re-render before returning. */ +export async function setNoticeState(patch: NoticeTestState): Promise { + await act(async () => noticeTestStore.setState(patch)) +} diff --git a/src/renderer/src/components/terminal-pane/codex-shared-settings-notice.test.ts b/src/renderer/src/components/terminal-pane/codex-shared-settings-notice.test.ts new file mode 100644 index 00000000000..a9de1beb870 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/codex-shared-settings-notice.test.ts @@ -0,0 +1,89 @@ +// @vitest-environment happy-dom + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + mountHook, + noticeTestStore, + setNoticeState, + unmountHooks +} from './codex-notice-test-harness' +import { useCodexSharedSettingsNotice } from './codex-shared-settings-notice' + +const { toastInfoMock } = vi.hoisted(() => ({ toastInfoMock: vi.fn() })) + +vi.mock('sonner', () => ({ toast: { info: toastInfoMock } })) +vi.mock('@/store', () => import('./codex-notice-test-harness')) + +const codexTab = { 'wt-1': [{ id: 'tab-1', launchAgent: 'codex' }] } + +function resetStore(overrides: Record = {}): void { + noticeTestStore.setState( + { + codexSharedSettingsNoticeSeen: false, + tabsByWorktree: {}, + agentStatusByPaneKey: {}, + paneForegroundAgentByPaneKey: {}, + markCodexSharedSettingsNoticeSeen: () => + noticeTestStore.setState({ codexSharedSettingsNoticeSeen: true }), + ...overrides + }, + true + ) +} + +const isSeen = (): unknown => noticeTestStore.getState().codexSharedSettingsNoticeSeen + +describe('useCodexSharedSettingsNotice', () => { + beforeEach(() => { + toastInfoMock.mockReset() + vi.stubGlobal('navigator', { userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' }) + resetStore() + }) + + afterEach(() => { + unmountHooks() + vi.unstubAllGlobals() + }) + + it('shows once on Windows when a Codex terminal appears, and marks it seen', async () => { + await mountHook(useCodexSharedSettingsNotice) + expect(toastInfoMock).not.toHaveBeenCalled() + + await setNoticeState({ tabsByWorktree: codexTab }) + await setNoticeState({ agentStatusByPaneKey: { 'tab-1:leaf': { agentType: 'codex' } } }) + + expect(toastInfoMock).toHaveBeenCalledTimes(1) + expect(toastInfoMock).toHaveBeenCalledWith('Codex in Orca now uses ~/.codex', { + id: 'codex-shared-settings-notice', + description: + 'Codex may ask again to trust folders or approve commands. Re-add any MCP servers you added only in Orca.', + duration: Infinity + }) + expect(isSeen()).toBe(true) + }) + + it('stays quiet off Windows', async () => { + vi.stubGlobal('navigator', { userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)' }) + resetStore({ tabsByWorktree: codexTab }) + await mountHook(useCodexSharedSettingsNotice) + expect(toastInfoMock).not.toHaveBeenCalled() + expect(isSeen()).toBe(false) + }) + + it('stays quiet in a paired web client window', async () => { + vi.stubGlobal('__ORCA_WEB_CLIENT__', true) + resetStore({ tabsByWorktree: codexTab }) + await mountHook(useCodexSharedSettingsNotice) + expect(toastInfoMock).not.toHaveBeenCalled() + }) + + it('shows once the persisted seen flag loads after mount', async () => { + // Why seen: true: the store's default until persisted UI arrives. + resetStore({ codexSharedSettingsNoticeSeen: true, tabsByWorktree: codexTab }) + await mountHook(useCodexSharedSettingsNotice) + expect(toastInfoMock).not.toHaveBeenCalled() + + await setNoticeState({ codexSharedSettingsNoticeSeen: false }) + expect(toastInfoMock).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/codex-shared-settings-notice.ts b/src/renderer/src/components/terminal-pane/codex-shared-settings-notice.ts new file mode 100644 index 00000000000..db05a38d4e7 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/codex-shared-settings-notice.ts @@ -0,0 +1,37 @@ +import { useEffect } from 'react' +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' +import { useAppStore } from '@/store' +import { isLocalWindowsDesktopClient } from '@/lib/desktop-window-chrome' +import { whenCodexTerminalAppears } from './codex-terminal-presence' + +function showCodexSharedSettingsNotice(): void { + // Why mark before showing: seen means shown, so a quit or reload never repeats it. + useAppStore.getState().markCodexSharedSettingsNoticeSeen() + toast.info( + translate('terminal.codexSharedSettingsNotice.title', 'Codex in Orca now uses ~/.codex'), + { + // Why a stable id: a late sync that resets the flag can't stack a second toast. + id: 'codex-shared-settings-notice', + description: translate( + 'terminal.codexSharedSettingsNotice.description', + 'Codex may ask again to trust folders or approve commands. Re-add any MCP servers you added only in Orca.' + ), + // Why no timeout: it is marked seen before showing, so an auto-close would lose it for good. + duration: Infinity + } + ) +} + +export function useCodexSharedSettingsNotice(): void { + // Why no hydration check: the flag defaults to true until the persisted value arrives. + const seen = useAppStore((s) => s.codexSharedSettingsNoticeSeen) + + useEffect(() => { + // Why skip paired web clients: the change is on the host, whose own window shows this. + if (seen || !isLocalWindowsDesktopClient()) { + return + } + return whenCodexTerminalAppears(showCodexSharedSettingsNotice) + }, [seen]) +} diff --git a/src/renderer/src/components/terminal-pane/codex-terminal-presence.test.ts b/src/renderer/src/components/terminal-pane/codex-terminal-presence.test.ts new file mode 100644 index 00000000000..51428c9d3f9 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/codex-terminal-presence.test.ts @@ -0,0 +1,56 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { noticeTestStore } from './codex-notice-test-harness' +import { whenCodexTerminalAppears } from './codex-terminal-presence' + +vi.mock('@/store', () => import('./codex-notice-test-harness')) + +describe('whenCodexTerminalAppears', () => { + beforeEach(() => { + noticeTestStore.setState( + { tabsByWorktree: {}, agentStatusByPaneKey: {}, paneForegroundAgentByPaneKey: {} }, + true + ) + }) + + it.each([ + ['an Orca-launched Codex tab', { tabsByWorktree: { 'wt-1': [{ launchAgent: 'codex' }] } }], + ['a hook-reported Codex', { agentStatusByPaneKey: { 'tab-1:leaf': { agentType: 'codex' } } }], + [ + 'a typed codex in the foreground', + { paneForegroundAgentByPaneKey: { 'tab-1:leaf': { agent: 'codex' } } } + ] + ])('calls back once for %s', (_name, patch) => { + const onAppear = vi.fn() + whenCodexTerminalAppears(onAppear) + + noticeTestStore.setState(patch) + noticeTestStore.setState({ tabsByWorktree: { 'wt-2': [{ launchAgent: 'codex' }] } }) + + expect(onAppear).toHaveBeenCalledTimes(1) + }) + + it('calls back at once when a Codex terminal already exists', () => { + noticeTestStore.setState({ tabsByWorktree: { 'wt-1': [{ launchAgent: 'codex' }] } }) + const onAppear = vi.fn() + whenCodexTerminalAppears(onAppear) + expect(onAppear).toHaveBeenCalledTimes(1) + }) + + it('ignores other agents', () => { + const onAppear = vi.fn() + whenCodexTerminalAppears(onAppear) + noticeTestStore.setState({ + tabsByWorktree: { 'wt-1': [{ launchAgent: 'claude' }] }, + agentStatusByPaneKey: { 'tab-1:leaf': { agentType: 'claude' } }, + paneForegroundAgentByPaneKey: { 'tab-1:leaf': { agent: 'opencode' } } + }) + expect(onAppear).not.toHaveBeenCalled() + }) + + it('stops watching once unsubscribed', () => { + const onAppear = vi.fn() + whenCodexTerminalAppears(onAppear)() + noticeTestStore.setState({ tabsByWorktree: { 'wt-1': [{ launchAgent: 'codex' }] } }) + expect(onAppear).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/codex-terminal-presence.ts b/src/renderer/src/components/terminal-pane/codex-terminal-presence.ts new file mode 100644 index 00000000000..6afcd315642 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/codex-terminal-presence.ts @@ -0,0 +1,37 @@ +import { useAppStore } from '@/store' +import type { AppState } from '@/store/types' + +// Why three sources: Orca-launched tabs, hook-reported agents (SSH too), and a typed `codex` seen locally. +function hasCodexTerminal(state: AppState): boolean { + return ( + Object.values(state.tabsByWorktree).some((tabs) => + tabs.some((tab) => tab.launchAgent === 'codex') + ) || + Object.values(state.agentStatusByPaneKey).some((entry) => entry.agentType === 'codex') || + Object.values(state.paneForegroundAgentByPaneKey).some((entry) => entry.agent === 'codex') + ) +} + +function didSourcesChange(state: AppState, previous: AppState): boolean { + return ( + state.tabsByWorktree !== previous.tabsByWorktree || + state.agentStatusByPaneKey !== previous.agentStatusByPaneKey || + state.paneForegroundAgentByPaneKey !== previous.paneForegroundAgentByPaneKey + ) +} + +/** Calls `onAppear` once, as soon as a Codex terminal exists. Returns the unsubscribe. */ +export function whenCodexTerminalAppears(onAppear: () => void): () => void { + if (hasCodexTerminal(useAppStore.getState())) { + onAppear() + return () => {} + } + // Why a filtered subscription: a selector would rescan every tab on each store write. + const unsubscribe = useAppStore.subscribe((state, previous) => { + if (didSourcesChange(state, previous) && hasCodexTerminal(state)) { + unsubscribe() + onAppear() + } + }) + return unsubscribe +} diff --git a/src/renderer/src/components/terminal-pane/codex-terminal-server-isolation-notice.test.ts b/src/renderer/src/components/terminal-pane/codex-terminal-server-isolation-notice.test.ts index ce7ee221898..87878d6fe4d 100644 --- a/src/renderer/src/components/terminal-pane/codex-terminal-server-isolation-notice.test.ts +++ b/src/renderer/src/components/terminal-pane/codex-terminal-server-isolation-notice.test.ts @@ -1,43 +1,27 @@ // @vitest-environment happy-dom -import { act, createElement } from 'react' -import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { CODEX_TERMINAL_SERVER_ISOLATION_SETTINGS_TARGET_ID } from '@/lib/settings-navigation-types' +import { + mountHook, + noticeTestStore, + setNoticeState, + unmountHooks +} from './codex-notice-test-harness' import { useCodexTerminalServerIsolationNotice } from './codex-terminal-server-isolation-notice' -// Why a real zustand store double: the hook relies on subscribe/setState semantics. -const { toastInfoMock, harness } = vi.hoisted(() => ({ - toastInfoMock: vi.fn(), - harness: { setState: (_patch: Record, _replace?: true): void => {} } -})) +const { toastInfoMock } = vi.hoisted(() => ({ toastInfoMock: vi.fn() })) vi.mock('sonner', () => ({ toast: { info: toastInfoMock } })) - -vi.mock('@/store', async () => { - const { createStore } = await import('zustand/vanilla') - const backing = createStore>()(() => ({})) - harness.setState = (patch, replace) => - replace ? backing.setState(patch, true) : backing.setState(patch) - const useAppStore = (selector: (state: Record) => T): T => - selector(backing.getState()) - return { useAppStore: Object.assign(useAppStore, backing) } -}) - -const store = { - setState: (patch: Record, replace?: true) => harness.setState(patch, replace) -} -let seen = false +vi.mock('@/store', () => import('./codex-notice-test-harness')) const openSettingsPage = vi.fn() const openSettingsTarget = vi.fn() -const mountedRoots: Root[] = [] +const codexTab = { 'wt-1': [{ id: 'tab-1', launchAgent: 'codex' }] } function resetStore(overrides: Record = {}): void { - seen = false - store.setState( + noticeTestStore.setState( { - persistedUIReady: true, codexTerminalServerIsolationNoticeSeen: false, settings: { codexTerminalServerIsolation: true }, tabsByWorktree: {}, @@ -45,33 +29,14 @@ function resetStore(overrides: Record = {}): void { paneForegroundAgentByPaneKey: {}, openSettingsPage, openSettingsTarget, - markCodexTerminalServerIsolationNoticeSeen: () => { - seen = true - store.setState({ codexTerminalServerIsolationNoticeSeen: true }) - }, + markCodexTerminalServerIsolationNoticeSeen: () => + noticeTestStore.setState({ codexTerminalServerIsolationNoticeSeen: true }), ...overrides }, true ) } -function HookProbe(): null { - useCodexTerminalServerIsolationNotice() - return null -} - -async function mountProbe(): Promise { - const container = document.createElement('div') - document.body.appendChild(container) - const root = createRoot(container) - mountedRoots.push(root) - await act(async () => { - root.render(createElement(HookProbe)) - }) -} - -const codexTab = { 'wt-1': [{ id: 'tab-1', launchAgent: 'codex' }] } - describe('useCodexTerminalServerIsolationNotice', () => { beforeEach(() => { toastInfoMock.mockReset() @@ -80,65 +45,50 @@ describe('useCodexTerminalServerIsolationNotice', () => { resetStore() }) - afterEach(() => { - for (const root of mountedRoots.splice(0)) { - act(() => root.unmount()) - } - document.body.innerHTML = '' - }) + afterEach(unmountHooks) it('shows once when the first Codex terminal starts, and marks it seen', async () => { - await mountProbe() + await mountHook(useCodexTerminalServerIsolationNotice) expect(toastInfoMock).not.toHaveBeenCalled() - act(() => store.setState({ tabsByWorktree: codexTab })) - act(() => store.setState({ agentStatusByPaneKey: { 'tab-2:leaf': { agentType: 'codex' } } })) + await setNoticeState({ tabsByWorktree: codexTab }) + await setNoticeState({ agentStatusByPaneKey: { 'tab-2:leaf': { agentType: 'codex' } } }) expect(toastInfoMock).toHaveBeenCalledTimes(1) expect(toastInfoMock.mock.calls[0]?.[1]).toMatchObject({ duration: Infinity }) - expect(seen).toBe(true) - }) - - it.each([ - [ - 'a typed codex seen by hooks', - { agentStatusByPaneKey: { 'tab-1:leaf': { agentType: 'codex' } } } - ], - [ - 'a typed codex in the foreground', - { paneForegroundAgentByPaneKey: { 'tab-1:leaf': { agent: 'codex' } } } - ] - ])('also triggers on %s', async (_name, patch) => { - await mountProbe() - act(() => store.setState(patch)) - expect(toastInfoMock).toHaveBeenCalledTimes(1) - }) - - it('never shows for other agents', async () => { - await mountProbe() - act(() => - store.setState({ - tabsByWorktree: { 'wt-1': [{ id: 'tab-1', launchAgent: 'claude' }] }, - agentStatusByPaneKey: { 'tab-1:leaf': { agentType: 'claude' } }, - paneForegroundAgentByPaneKey: { 'tab-1:leaf': { agent: 'opencode' } } - }) - ) - expect(toastInfoMock).not.toHaveBeenCalled() + expect(noticeTestStore.getState().codexTerminalServerIsolationNoticeSeen).toBe(true) }) it.each([ ['it was already seen', { codexTerminalServerIsolationNoticeSeen: true }], ['the user turned the setting off', { settings: { codexTerminalServerIsolation: false } }], - ['persisted UI has not hydrated', { persistedUIReady: false }] + ['settings have not loaded', { settings: null }] ])('stays quiet when %s', async (_name, overrides) => { resetStore({ ...overrides, tabsByWorktree: codexTab }) - await mountProbe() + await mountHook(useCodexTerminalServerIsolationNotice) + expect(toastInfoMock).not.toHaveBeenCalled() + }) + + it('shows once the persisted seen flag loads after mount', async () => { + // Why seen: true: the store's default until persisted UI arrives. + resetStore({ codexTerminalServerIsolationNoticeSeen: true, tabsByWorktree: codexTab }) + await mountHook(useCodexTerminalServerIsolationNotice) + expect(toastInfoMock).not.toHaveBeenCalled() + + await setNoticeState({ codexTerminalServerIsolationNoticeSeen: false }) + expect(toastInfoMock).toHaveBeenCalledTimes(1) + }) + + it('stops waiting when the user turns the setting off', async () => { + await mountHook(useCodexTerminalServerIsolationNotice) + await setNoticeState({ settings: { codexTerminalServerIsolation: false } }) + await setNoticeState({ tabsByWorktree: codexTab }) expect(toastInfoMock).not.toHaveBeenCalled() }) it('opens Settings at the Codex server setting', async () => { resetStore({ tabsByWorktree: codexTab }) - await mountProbe() + await mountHook(useCodexTerminalServerIsolationNotice) toastInfoMock.mock.calls[0]?.[1]?.action.onClick() diff --git a/src/renderer/src/components/terminal-pane/codex-terminal-server-isolation-notice.ts b/src/renderer/src/components/terminal-pane/codex-terminal-server-isolation-notice.ts index 4fe87294bdf..b04e63d8f00 100644 --- a/src/renderer/src/components/terminal-pane/codex-terminal-server-isolation-notice.ts +++ b/src/renderer/src/components/terminal-pane/codex-terminal-server-isolation-notice.ts @@ -6,46 +6,15 @@ import type { AppState } from '@/store/types' import { isPairedWebClientWindow } from '@/lib/desktop-window-chrome' import { CODEX_TERMINAL_SERVER_ISOLATION_SETTINGS_TARGET_ID } from '@/lib/settings-navigation-types' import { isCodexTerminalServerIsolationEnabled } from '../../../../shared/codex-terminal-server-isolation' +import { whenCodexTerminalAppears } from './codex-terminal-presence' -type CodexNoticeState = Pick< - AppState, - | 'persistedUIReady' - | 'codexTerminalServerIsolationNoticeSeen' - | 'settings' - | 'tabsByWorktree' - | 'agentStatusByPaneKey' - | 'paneForegroundAgentByPaneKey' -> - -// Why three sources: Orca-launched tabs, hook-reported agents (SSH too), and a typed `codex` seen locally. -function hasCodexTerminal(state: CodexNoticeState): boolean { +// Why no hydration check: the seen flag defaults to true until the persisted value arrives. +function isNoticeDue(state: AppState): boolean { return ( - Object.values(state.tabsByWorktree).some((tabs) => - tabs.some((tab) => tab.launchAgent === 'codex') - ) || - Object.values(state.agentStatusByPaneKey).some((entry) => entry.agentType === 'codex') || - Object.values(state.paneForegroundAgentByPaneKey).some((entry) => entry.agent === 'codex') - ) -} - -export function shouldShowCodexTerminalServerIsolationNotice(state: CodexNoticeState): boolean { - return ( - state.persistedUIReady && !state.codexTerminalServerIsolationNoticeSeen && state.settings !== null && // Why: a user who already opted out needs no announcement of the default. - isCodexTerminalServerIsolationEnabled(state.settings) && - hasCodexTerminal(state) - ) -} - -function didNoticeInputsChange(state: CodexNoticeState, previous: CodexNoticeState): boolean { - return ( - state.persistedUIReady !== previous.persistedUIReady || - state.settings !== previous.settings || - state.tabsByWorktree !== previous.tabsByWorktree || - state.agentStatusByPaneKey !== previous.agentStatusByPaneKey || - state.paneForegroundAgentByPaneKey !== previous.paneForegroundAgentByPaneKey + isCodexTerminalServerIsolationEnabled(state.settings) ) } @@ -93,27 +62,13 @@ function showCodexTerminalServerIsolationNotice(): void { } export function useCodexTerminalServerIsolationNotice(): void { - const seen = useAppStore((s) => s.codexTerminalServerIsolationNoticeSeen) + const due = useAppStore(isNoticeDue) useEffect(() => { // Why: a paired web client's terminals follow the host's setting, not this window's. - if (seen || isPairedWebClientWindow()) { + if (!due || isPairedWebClientWindow()) { return } - if (shouldShowCodexTerminalServerIsolationNotice(useAppStore.getState())) { - showCodexTerminalServerIsolationNotice() - return - } - // Why a filtered subscription: a selector would rescan every tab on each store write. - const unsubscribe = useAppStore.subscribe((state, previous) => { - if ( - didNoticeInputsChange(state, previous) && - shouldShowCodexTerminalServerIsolationNotice(state) - ) { - unsubscribe() - showCodexTerminalServerIsolationNotice() - } - }) - return unsubscribe - }, [seen]) + return whenCodexTerminalAppears(showCodexTerminalServerIsolationNotice) + }, [due]) } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 7b497327dac..cb7b6f4607f 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18755,6 +18755,10 @@ "description": "This makes agent status more reliable. You can turn it back on in Settings.", "openSettings": "Open Settings" }, + "codexSharedSettingsNotice": { + "title": "Codex in Orca now uses ~/.codex", + "description": "Codex may ask again to trust folders or approve commands. Re-add any MCP servers you added only in Orca." + }, "codexSharedServerBanner": { "title": "This Codex is sharing a server with your other Codex tabs", "body": "Sessions may end unexpectedly, and agent status may be wrong.", diff --git a/src/renderer/src/store/slices/ui-notice-dismissals.test.ts b/src/renderer/src/store/slices/ui-notice-dismissals.test.ts index 180f96e427a..a69a18999c5 100644 --- a/src/renderer/src/store/slices/ui-notice-dismissals.test.ts +++ b/src/renderer/src/store/slices/ui-notice-dismissals.test.ts @@ -251,6 +251,19 @@ describe('createUISlice browser import hint dismissal', () => { }) }) +describe('createUISlice Codex shared-settings notice', () => { + it('counts as seen until hydration, then follows the persisted flag', () => { + const store = createUIStore() + expect(store.getState().codexSharedSettingsNoticeSeen).toBe(true) + + store.getState().hydratePersistedUI(makePersistedUI({})) + expect(store.getState().codexSharedSettingsNoticeSeen).toBe(false) + + store.getState().hydratePersistedUI(makePersistedUI({ codexSharedSettingsNoticeSeen: true })) + expect(store.getState().codexSharedSettingsNoticeSeen).toBe(true) + }) +}) + describe('createUISlice clearOsc52ClipboardDefaultOnNotice', () => { it('restores the armed notice from persisted UI', () => { const store = createUIStore() diff --git a/src/renderer/src/store/slices/ui/ui-slice-contract-contextual.ts b/src/renderer/src/store/slices/ui/ui-slice-contract-contextual.ts index f964d6e048a..6ec88e1e4f3 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-contract-contextual.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-contract-contextual.ts @@ -116,4 +116,6 @@ export type UISliceContextual = { dismissUsageEmptyState: () => void codexTerminalServerIsolationNoticeSeen: boolean markCodexTerminalServerIsolationNoticeSeen: () => void + codexSharedSettingsNoticeSeen: boolean + markCodexSharedSettingsNoticeSeen: () => void } diff --git a/src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts index 74f45118b09..dea5b0c2e80 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts @@ -244,6 +244,7 @@ export function createUiHydrationActions(set: UISliceSet, _get: UISliceGet): Par usageEmptyStateDismissed: ui.usageEmptyStateDismissed === true, codexTerminalServerIsolationNoticeSeen: ui.codexTerminalServerIsolationNoticeSeen === true, + codexSharedSettingsNoticeSeen: ui.codexSharedSettingsNoticeSeen === true, ...hydrateAgentReadState(ui), workspaceCleanupDismissals: sanitizeWorkspaceCleanupDismissals( ui.workspaceCleanup?.dismissals diff --git a/src/renderer/src/store/slices/ui/ui-slice-trust-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-trust-actions.ts index dabf69bb29d..1e1c25573a2 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-trust-actions.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-trust-actions.ts @@ -148,6 +148,15 @@ export function createUiTrustActions(set: UISliceSet, _get: UISliceGet): Partial } window.api.ui.set({ codexTerminalServerIsolationNoticeSeen: true }).catch(console.error) return { codexTerminalServerIsolationNoticeSeen: true } + }), + codexSharedSettingsNoticeSeen: true, + markCodexSharedSettingsNoticeSeen: () => + set((s) => { + if (s.codexSharedSettingsNoticeSeen) { + return s + } + window.api.ui.set({ codexSharedSettingsNoticeSeen: true }).catch(console.error) + return { codexSharedSettingsNoticeSeen: true } }) } } diff --git a/src/shared/persisted-ui-state-types.ts b/src/shared/persisted-ui-state-types.ts index feb9a29d213..6eaa295eed4 100644 --- a/src/shared/persisted-ui-state-types.ts +++ b/src/shared/persisted-ui-state-types.ts @@ -172,6 +172,8 @@ export type PersistedUIState = { usageEmptyStateDismissed?: boolean /** One-shot toast announcing per-terminal Codex servers; set when shown, so absent means not yet seen. */ codexTerminalServerIsolationNoticeSeen?: boolean + /** Windows one-shot toast for Codex moving onto ~/.codex; set when shown, so absent means not yet seen. */ + codexSharedSettingsNoticeSeen?: boolean /** URL for new browser tabs; null = blank tab. */ browserDefaultUrl?: string | null browserDefaultSearchEngine?: 'google' | 'duckduckgo' | 'bing' | 'kagi' | null diff --git a/src/shared/rpc-contract/client-ui-params.ts b/src/shared/rpc-contract/client-ui-params.ts index a039594604f..0295247a2e8 100644 --- a/src/shared/rpc-contract/client-ui-params.ts +++ b/src/shared/rpc-contract/client-ui-params.ts @@ -239,6 +239,7 @@ export const UiUpdateFields = z usagePercentageDisplayChangeNoticeDismissed: z.boolean().optional(), usageEmptyStateDismissed: z.boolean().optional(), codexTerminalServerIsolationNoticeSeen: z.boolean().optional(), + codexSharedSettingsNoticeSeen: z.boolean().optional(), petVisible: z.boolean().optional(), petId: z.string().optional(), customPets: UnknownRecordArray.optional(), From 0f9bc5aaadde4c8ee831a5d17446b1b08b5ac750 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sun, 4 Oct 2026 11:47:49 -0700 Subject: [PATCH 60/67] fix(codex): keep Orca-only MCP servers when refreshing the retained shared home (#24983) * fix(codex): keep Orca-only MCP servers when refreshing the retained shared home The refresh for panes that outlive an update treated the old shared home's whole MCP root as owned by ~/.codex, so it deleted servers the user had added from an Orca terminal, which existed only there. Read the home's settings baseline instead, as the normal mirror does: drop only servers the last mirror copied from ~/.codex. No baseline keeps the old behaviour; an unreadable one skips the refresh. The baseline is not advanced, keeping the refresh one-way. STA-9109 * test(codex): type the MCP ownership baseline fixture --------- Co-authored-by: Orca Worker --- ...legacy-shared-config-compatibility.test.ts | 43 ++++++++++++ .../codex-config-mirror-mcp-ownership.test.ts | 68 +++++++++++++++++++ src/main/codex/codex-config-mirror.ts | 24 +++++-- 3 files changed, 131 insertions(+), 4 deletions(-) diff --git a/src/main/codex-accounts/legacy-shared-config-compatibility.test.ts b/src/main/codex-accounts/legacy-shared-config-compatibility.test.ts index 085777fdf8c..fc8f7b815a7 100644 --- a/src/main/codex-accounts/legacy-shared-config-compatibility.test.ts +++ b/src/main/codex-accounts/legacy-shared-config-compatibility.test.ts @@ -88,6 +88,49 @@ describe('legacy shared Codex config compatibility', () => { expect(readFileSync(join(sharedRuntimeHome, 'auth.json'), 'utf-8')).toBe(staleSharedAuth) }) + it('keeps an Orca-added MCP server while settings and trust still refresh', () => { + const baselinePath = join(sharedRuntimeHome, '.orca-config-settings-baseline.json') + const baseline = JSON.stringify({ version: 3, settings: {}, mcpServers: [] }) + writeFileSync(baselinePath, baseline) + writeFileSync( + join(systemCodexHome, 'config.toml'), + ['model = "canonical"', '', '[projects."/revoked"]', 'trust_level = "untrusted"', ''].join( + '\n' + ) + ) + writeFileSync( + join(sharedRuntimeHome, 'config.toml'), + [ + 'model = "stale"', + '', + '[projects."/trusted-in-orca"]', + 'trust_level = "trusted"', + '', + '[projects."/revoked"]', + 'trust_level = "trusted"', + '', + '[hooks.state."orca:stop:0:0"]', + 'enabled = true', + '', + '[mcp_servers.demo-mcp]', + 'command = "demo"', + '' + ].join('\n') + ) + + syncLegacySharedCodexConfigForRetainedPanes({ sharedRuntimeHome, systemCodexHome }) + + const sharedConfig = readFileSync(join(sharedRuntimeHome, 'config.toml'), 'utf-8') + expect(sharedConfig).toContain('model = "canonical"') + expect(sharedConfig).not.toContain('model = "stale"') + expect(sharedConfig).toContain('[projects."/trusted-in-orca"]\ntrust_level = "trusted"') + expect(sharedConfig).toContain('[projects."/revoked"]\ntrust_level = "untrusted"') + expect(sharedConfig).not.toContain('[projects."/revoked"]\ntrust_level = "trusted"') + expect(sharedConfig).toContain('[hooks.state."orca:stop:0:0"]') + expect(sharedConfig).toContain('[mcp_servers.demo-mcp]\ncommand = "demo"') + expect(readFileSync(baselinePath, 'utf-8')).toBe(baseline) + }) + it('does not delete config when the canonical source is transiently missing', () => { const staleConfig = 'model_provider = "stale-provider"\n' writeFileSync(join(sharedRuntimeHome, 'config.toml'), staleConfig, 'utf-8') diff --git a/src/main/codex/codex-config-mirror-mcp-ownership.test.ts b/src/main/codex/codex-config-mirror-mcp-ownership.test.ts index 2c8a86fd28b..44172582e21 100644 --- a/src/main/codex/codex-config-mirror-mcp-ownership.test.ts +++ b/src/main/codex/codex-config-mirror-mcp-ownership.test.ts @@ -21,6 +21,20 @@ beforeEach(() => { afterEach(() => rmSync(root, { recursive: true, force: true })) +const BASELINE_FILE = '.orca-config-settings-baseline.json' + +type StoredBaselineFixture = { + version: 1 | 3 + settings: Record + mcpServers?: string[] +} + +function writeBaseline(baseline: StoredBaselineFixture): string { + const serialized = JSON.stringify(baseline) + writeFileSync(join(runtimeHomePath, BASELINE_FILE), serialized) + return serialized +} + describe('canonical MCP ownership during config mirroring', () => { it('does not duplicate a server defined inline in the canonical MCP table', () => { writeFileSync( @@ -130,6 +144,60 @@ describe('MCP ownership migration', () => { ) }) + it('keeps the retained shared home canonical under a pre-ownership baseline', () => { + writeFileSync(join(runtimeHomePath, 'config.toml'), '[mcp_servers.removed]\ncommand = "old"\n') + writeFileSync(join(systemHomePath, 'config.toml'), 'model = "system"\n') + writeBaseline({ version: 1, settings: {} }) + + syncSystemConfigIntoLegacySharedCodexHome({ runtimeHomePath, systemHomePath }) + + expect(readFileSync(join(runtimeHomePath, 'config.toml'), 'utf-8')).not.toContain( + '[mcp_servers.' + ) + }) + + it('keeps Orca-only servers in the retained shared home and drops ones removed from ~/.codex', () => { + writeFileSync( + join(runtimeHomePath, 'config.toml'), + [ + '[mcp_servers.demo-mcp]', + 'command = "orca-only"', + '[mcp_servers.removed]', + 'command = "mirrored"', + '[mcp_servers.kept]', + 'command = "stale"', + '' + ].join('\n') + ) + writeFileSync( + join(systemHomePath, 'config.toml'), + 'model = "system"\n[mcp_servers.kept]\ncommand = "system"\n' + ) + const baseline = writeBaseline({ version: 3, settings: {}, mcpServers: ['removed', 'kept'] }) + + syncSystemConfigIntoLegacySharedCodexHome({ runtimeHomePath, systemHomePath }) + + const runtimeConfig = readFileSync(join(runtimeHomePath, 'config.toml'), 'utf-8') + expect(runtimeConfig).toContain('[mcp_servers.demo-mcp]\ncommand = "orca-only"') + expect(runtimeConfig).not.toContain('[mcp_servers.removed]') + expect(runtimeConfig).toContain('[mcp_servers.kept]\ncommand = "system"') + expect(runtimeConfig).not.toContain('"stale"') + expect(readFileSync(join(runtimeHomePath, BASELINE_FILE), 'utf-8')).toBe(baseline) + }) + + it('leaves the retained shared home untouched when its baseline cannot be read', () => { + const runtimeConfig = 'model = "retained"\n[mcp_servers.demo-mcp]\ncommand = "orca-only"\n' + writeFileSync(join(runtimeHomePath, 'config.toml'), runtimeConfig) + writeFileSync(join(systemHomePath, 'config.toml'), 'model = "system"\n') + mkdirSync(join(runtimeHomePath, BASELINE_FILE)) + + expect(() => + syncSystemConfigIntoLegacySharedCodexHome({ runtimeHomePath, systemHomePath }) + ).toThrow() + + expect(readFileSync(join(runtimeHomePath, 'config.toml'), 'utf-8')).toBe(runtimeConfig) + }) + it('tracks commented CRLF names so their later removal remains authoritative', () => { writeFileSync( join(runtimeHomePath, 'config.toml'), diff --git a/src/main/codex/codex-config-mirror.ts b/src/main/codex/codex-config-mirror.ts index a97a8b9644c..74f8b2f0b53 100644 --- a/src/main/codex/codex-config-mirror.ts +++ b/src/main/codex/codex-config-mirror.ts @@ -16,7 +16,7 @@ import { type CodexSettingsPromotionHomes, type CodexSettingsPromotionPlan } from './config-settings-promotion' -import { readCodexSettingsBaseline } from './config-settings-baseline' +import { observeCodexSettingsBaseline, readCodexSettingsBaseline } from './config-settings-baseline' import { getCodexConfigSyncStatus, reportCodexConfigSyncOutcome } from './config-sync-stall' import { preserveRuntimeConflictValues } from './codex-config-settings-preservation' import { applyCodexDaemonSocketGuard } from './codex-daemon-socket-path-guard' @@ -161,15 +161,13 @@ export function syncSystemConfigIntoLegacySharedCodexHome( let mirroredRuntimeConfig = runtimeConfigBeforeMirror ?? '' if (rawSystemConfig.trim() !== '') { const sourceConfigDir = resolveCodexConfigMirrorSourceDirectory(homes.systemHomePath) - // The retired home has no ownership baseline; its entire MCP root stays canonical. mirroredRuntimeConfig = runtimeConfigBeforeMirror !== null ? mergeSystemCodexConfigIntoRuntime( runtimeConfigBeforeMirror, prepareSystemConfigForRuntimeMirror(rawSystemConfig, sourceConfigDir), sourceConfigDir, - new Set(), - true + ...readLegacySharedHomeMcpOwnership(homes.runtimeHomePath) ) : prepareSystemConfigForFreshRuntimeMirror(rawSystemConfig, sourceConfigDir) } @@ -186,6 +184,24 @@ export function syncSystemConfigIntoLegacySharedCodexHome( writeFileAtomicallyIfUnchanged(runtimeConfigPath, runtimeConfigBeforeMirror, nextRuntimeConfig) } +/** + * Why: MCP servers added from an Orca terminal exist only in the retired home, + * so its last mirror's baseline decides which ones ~/.codex owns. With no + * baseline the whole root stays canonical, as before; an unreadable one throws + * rather than guess. Read-only: this one-way refresh never advances it. + */ +function readLegacySharedHomeMcpOwnership( + runtimeHomePath: string +): [mirroredMcpServerNames: ReadonlySet, mirroredMcpServerRoot: boolean] { + const observation = observeCodexSettingsBaseline(runtimeHomePath) + if (observation.kind === 'indeterminate') { + throw new Error('Codex settings baseline could not be read') + } + return observation.kind === 'present' + ? [observation.baseline.mcpServers, observation.baseline.mcpServerRoot] + : [new Set(), true] +} + type CodexConfigMirrorResult = | { status: 'skipped-missing-source' } | { status: 'refused-indeterminate'; error: unknown } From 95753a10c61642c0eb3911fa1531036c608b61a3 Mon Sep 17 00:00:00 2001 From: Nicholas Ting Date: Mon, 5 Oct 2026 06:18:43 +1100 Subject: [PATCH 61/67] fix(jcode): report missing and outdated managed hooks (#25135) --- docs/reference/jcode-hook-events.md | 36 +++++--- src/main/jcode/hook-config.test.ts | 3 - src/main/jcode/hook-service.test.ts | 122 +++++++++++++++++++++++++++- src/main/jcode/hook-service.ts | 22 +++-- 4 files changed, 160 insertions(+), 23 deletions(-) diff --git a/docs/reference/jcode-hook-events.md b/docs/reference/jcode-hook-events.md index 5eaf0d52463..6e5f81be35e 100644 --- a/docs/reference/jcode-hook-events.md +++ b/docs/reference/jcode-hook-events.md @@ -13,14 +13,14 @@ points. Five are **observers** — detached, fire-and-forget, they can never slo the agent. One, `pre_tool`, is a **gate**: jcode spawns it, writes the tool input to its stdin, and waits for it to exit before the tool runs. -| Event | When | Orca state | Notable payload fields | -| --------------- | -------------------------------------------- | ---------- | --------------------------------------------------------- | -| `session_start` | TUI open, attach, or `--resume` | none | `source` = `create`/`attach`/`resume`, `model` | -| `turn_start` | prompt submitted, before the model generates | `working` | `source`, `model` | -| `pre_tool` | before each tool call (gate) | `working` | `tool_name`, `tool_input` (argument JSON as a string) | -| `post_tool` | after each tool call | `working` | `tool_name`, `status`, `duration_ms`, `output_bytes`/`error` | -| `turn_end` | turn finished | `done` | `status`, `duration_ms`, `model`, `last_assistant_text`, `error` | -| `session_end` | session closed | `done` | `source` = `close` | +| Event | When | Orca state | Notable payload fields | +| --------------- | -------------------------------------------- | ---------- | ---------------------------------------------------------------- | +| `session_start` | TUI open, attach, or `--resume` | none | `source` = `create`/`attach`/`resume`, `model` | +| `turn_start` | prompt submitted, before the model generates | `working` | `source`, `model` | +| `pre_tool` | before each tool call (gate) | `working` | `tool_name`, `tool_input` (argument JSON as a string) | +| `post_tool` | after each tool call | `working` | `tool_name`, `status`, `duration_ms`, `output_bytes`/`error` | +| `turn_end` | turn finished | `done` | `status`, `duration_ms`, `model`, `last_assistant_text`, `error` | +| `session_end` | session closed | `done` | `source` = `close` | `session_start` is identity-only. jcode fires it on an idle TUI open, so mapping it to `working` would spin before the user has typed anything (same reason Devin @@ -59,12 +59,12 @@ Three consequences the mapping depends on: ## Why Orca subscribes to the gate -`pre_tool` is the only event that can report a tool *while it runs*. Without it a +`pre_tool` is the only event that can report a tool _while it runs_. Without it a three-minute `bash` shows no tool at all until it finishes. Two rules keep the gate from ever costing the agent anything: 1. **The POST is detached.** jcode calls `child.wait_with_output()`, which waits - for the process *and* reads its stderr to EOF — a backgrounded child that + for the process _and_ reads its stderr to EOF — a backgrounded child that inherited stderr would hold the gate open for as long as it ran. The managed script runs the POST as `orca_post_jcode_event >/dev/null 2>&1 &`, so the inherited pipes are closed and the script exits immediately. @@ -83,10 +83,10 @@ command outright or asks the model to justify it — both inside the tool, with human in the loop. There is therefore no hook, and no terminal-title state, for "jcode is waiting on you" during ordinary tool use. -The one tool a *human* answers is ambient mode's `request_permission` +The one tool a _human_ answers is ambient mode's `request_permission` (`crates/jcode-app-core/src/tool/ambient.rs`), resolved out of band with `jcode permissions`. Orca maps a `pre_tool` for it to `waiting` and publishes the -tool input as the question card. `post_tool` for the same tool is *not* mapped — +tool input as the question card. `post_tool` for the same tool is _not_ mapped — by then the human has already answered. Matching is by exact tool name. jcode's live tool set is `agentgrep, apply_patch, @@ -114,7 +114,7 @@ the parser. ## Per-pane daemons jcode runs one server/client daemon per runtime dir, and lifecycle hooks fire -*inside the daemon*. Every TUI client connects the daemon the first pane started, +_inside the daemon_. Every TUI client connects the daemon the first pane started, so without isolation a second jcode pane's events carry the first pane's `ORCA_PANE_KEY` and its status lands on the wrong tab. @@ -142,6 +142,16 @@ are refreshed on Orca startup without changing the user's hook configuration. Report: https://github.com/stablyai/orca/pull/22539#issuecomment-5809618574 Launcher fix: https://github.com/1jehuang/jcode/pull/1490 +## Hook installation health + +Orca's local hook status reports `installed` only when all six events use the +current shell-quoted managed command and the managed script exists. Missing scripts, +old home paths, platform-switched commands, and legacy unquoted commands report +`partial` with a repair reason, not a user-owned hook warning. Reinstalling hooks +recreates the script and updates only Orca-owned commands. User-owned commands +remain untouched. Removing the managed entries still reports `not_installed`, +even if the shared script remains on disk. + ## Config shape `[hooks]` values accept a string or an array of strings (`HookCommands` in diff --git a/src/main/jcode/hook-config.test.ts b/src/main/jcode/hook-config.test.ts index b82e6a417b9..17dbcb379c2 100644 --- a/src/main/jcode/hook-config.test.ts +++ b/src/main/jcode/hook-config.test.ts @@ -132,9 +132,6 @@ turn_end = "~/bin/mine" # replaces agent-hooks/jcode-hook.sh }) it('repoints a managed entry left behind by a copied home or a platform switch', () => { - // Why: isManaged matches any agent-hooks/jcode-hook path, but getStatus demands - // the exact script path — a stale entry stuck the install on `partial` forever - // with no Orca action able to repair it. const stale = '/Users/old/.orca/agent-hooks/jcode-hook.sh' const source = `[hooks]\nturn_end = ${tomlQuoteString(stale)}\n` const result = applyJcodeManagedHooks(source, EVENTS, MANAGED_COMMAND, 'jcode-hook.sh') diff --git a/src/main/jcode/hook-service.test.ts b/src/main/jcode/hook-service.test.ts index 3a87aa4ef6a..f86fe52444c 100644 --- a/src/main/jcode/hook-service.test.ts +++ b/src/main/jcode/hook-service.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' @@ -17,6 +17,7 @@ import { JcodeHookService } from './hook-service' import { getJcodeConfigPath, getJcodeManagedCommand, + getJcodeManagedScriptFileName, getJcodeManagedScriptPath, JCODE_HOOK_EVENTS } from './hook-settings' @@ -72,6 +73,123 @@ describe('JcodeHookService', () => { expect(script).toContain('payload="$JCODE_HOOK_PAYLOAD"') }) + it('reports a missing managed script and repairs it without changing config', () => { + const service = new JcodeHookService() + service.install() + const config = readFileSync(getJcodeConfigPath(), 'utf8') + rmSync(getJcodeManagedScriptPath()) + + expect(service.getStatus()).toMatchObject({ + state: 'partial', + managedHooksPresent: true, + detail: 'Managed hook script missing' + }) + expect(readFileSync(getJcodeConfigPath(), 'utf8')).toBe(config) + expect(service.install().state).toBe('installed') + expect(readFileSync(getJcodeManagedScriptPath(), 'utf8')).toContain('/hook/jcode') + expect(readFileSync(getJcodeConfigPath(), 'utf8')).toBe(config) + }) + + it('reports missing scripts alongside incomplete event coverage and user hooks', () => { + const service = new JcodeHookService() + service.install() + writeFileSync( + getJcodeConfigPath(), + `[hooks]\nsession_start = ${tomlQuoteString(getJcodeManagedCommand(getJcodeManagedScriptPath()))}\nturn_end = "~/bin/my-turn-notify"\n`, + 'utf8' + ) + rmSync(getJcodeManagedScriptPath()) + + const status = service.getStatus() + expect(status.state).toBe('partial') + expect(status.detail).toContain('Managed hook script missing') + expect(status.detail).toContain( + 'Managed hook missing for events: turn_start, pre_tool, post_tool, session_end' + ) + expect(status.detail).toContain('User-owned hooks kept for events: turn_end') + expect(service.install().state).toBe('partial') + expect(service.getStatus().detail).not.toContain('script missing') + expect(readFileSync(getJcodeConfigPath(), 'utf8')).toContain( + 'turn_end = "~/bin/my-turn-notify"' + ) + }) + + it.each([ + '/Users/previous/.orca/agent-hooks/jcode-hook.sh', + 'C:\\Users\\previous\\.orca\\agent-hooks\\jcode-hook.cmd' + ])('recognizes a stale managed command %s without a local script', (stalePath) => { + const service = new JcodeHookService() + const configPath = getJcodeConfigPath() + mkdirSync(dirname(configPath), { recursive: true }) + writeFileSync( + configPath, + `[hooks]\nturn_end = ${tomlQuoteString(getJcodeManagedCommand(stalePath))}\n`, + 'utf8' + ) + + const status = service.getStatus() + expect(status.state).toBe('partial') + expect(status.managedHooksPresent).toBe(true) + expect(status.detail).toContain('Managed hook command outdated for events: turn_end') + expect(status.detail).toContain('Managed hook script missing') + expect(status.detail).not.toContain('User-owned') + expect(service.install().state).toBe('installed') + expect(readFileSync(configPath, 'utf8')).not.toContain('previous') + }) + + it('does not report complete stale event coverage as installed when the current script exists', () => { + const service = new JcodeHookService() + service.install() + const staleCommand = getJcodeManagedCommand('/Users/previous/.orca/agent-hooks/jcode-hook.sh') + writeFileSync( + getJcodeConfigPath(), + `[hooks]\n${JCODE_HOOK_EVENTS.map((event) => `${event} = ${tomlQuoteString(staleCommand)}`).join('\n')}\n`, + 'utf8' + ) + + const status = service.getStatus() + expect(status.state).toBe('partial') + expect(status.managedHooksPresent).toBe(true) + expect(status.detail).toBe( + `Managed hook command outdated for events: ${JCODE_HOOK_EVENTS.join(', ')}` + ) + expect(service.install().state).toBe('installed') + }) + + it('reports legacy unquoted commands as outdated even when the script exists', () => { + const service = new JcodeHookService() + service.install() + writeFileSync( + getJcodeConfigPath(), + `[hooks]\n${JCODE_HOOK_EVENTS.map((event) => `${event} = ${tomlQuoteString(getJcodeManagedScriptPath())}`).join('\n')}\n`, + 'utf8' + ) + + const status = service.getStatus() + expect(status.state).toBe('partial') + expect(status.detail).toContain('Managed hook command outdated for events:') + expect(status.detail).not.toContain('script missing') + expect(status.detail).not.toContain('User-owned') + expect(service.install().state).toBe('installed') + }) + + it('does not mistake a user command mentioning the managed script in a comment for Orca ownership', () => { + const configPath = getJcodeConfigPath() + mkdirSync(dirname(configPath), { recursive: true }) + writeFileSync( + configPath, + `[hooks]\nturn_end = "~/bin/my-turn-notify" # replaces agent-hooks/${getJcodeManagedScriptFileName()}\n`, + 'utf8' + ) + + const status = new JcodeHookService().getStatus() + expect(status.state).toBe('partial') + expect(status.managedHooksPresent).toBe(false) + expect(status.detail).toContain('User-owned hooks kept for events: turn_end') + expect(status.detail).not.toContain('command outdated') + expect(status.detail).not.toContain('script missing') + }) + it('preserves unrelated config tables when installing hooks', () => { const configPath = getJcodeConfigPath() mkdirSync(dirname(configPath), { recursive: true }) @@ -107,6 +225,8 @@ describe('JcodeHookService', () => { expect(before).toContain('turn_end') const status = new JcodeHookService().remove() expect(status.state).toBe('not_installed') + expect(status.detail).toBeNull() + expect(existsSync(getJcodeManagedScriptPath())).toBe(true) const after = readFileSync(getJcodeConfigPath(), 'utf8') expect(after).not.toContain(getJcodeManagedScriptPath()) }) diff --git a/src/main/jcode/hook-service.ts b/src/main/jcode/hook-service.ts index ebe41a2dc36..eff119f91d5 100644 --- a/src/main/jcode/hook-service.ts +++ b/src/main/jcode/hook-service.ts @@ -4,6 +4,7 @@ import type { SFTPWrapper } from 'ssh2' import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types' import { buildWindowsAgentHookPostCommand, + createManagedCommandMatcher, writeManagedScript } from '../agent-hooks/installer-utils' import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh' @@ -128,33 +129,42 @@ export class JcodeHookService { } const scriptPresent = existsSync(scriptPath) const managedCommand = getJcodeManagedCommand(scriptPath) + const isManaged = createManagedCommandMatcher(getJcodeManagedScriptFileName()) const missing: string[] = [] + const outdated: string[] = [] const userOwned: string[] = [] let managedCount = 0 for (const event of JCODE_HOOK_EVENTS) { const value = table[event] - // Why both forms: installs before the quoting fix stored the bare path, and - // install() repoints those — reporting them user-owned would hide the repair. - if (value === managedCommand || value === scriptPath) { + if (value === managedCommand) { managedCount += 1 + } else if (isManaged(value)) { + outdated.push(event) } else if (value === undefined) { missing.push(event) } else { userOwned.push(event) } } - const managedHooksPresent = managedCount > 0 || scriptPresent + const hasManagedEntries = managedCount > 0 || outdated.length > 0 + const managedHooksPresent = hasManagedEntries || scriptPresent let state: AgentHookInstallState let detail: string | null - if (missing.length === 0 && userOwned.length === 0) { + if (managedCount === JCODE_HOOK_EVENTS.length && scriptPresent) { state = 'installed' detail = null - } else if (managedCount === 0 && missing.length === JCODE_HOOK_EVENTS.length) { + } else if (!hasManagedEntries && missing.length === JCODE_HOOK_EVENTS.length) { state = 'not_installed' detail = null } else { state = 'partial' const parts: string[] = [] + if (hasManagedEntries && !scriptPresent) { + parts.push('Managed hook script missing') + } + if (outdated.length > 0) { + parts.push(`Managed hook command outdated for events: ${outdated.join(', ')}`) + } if (missing.length > 0) { parts.push(`Managed hook missing for events: ${missing.join(', ')}`) } From a5b8b7e2bb77ee96c9039769920e9202ea146881 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 4 Oct 2026 12:19:32 -0700 Subject: [PATCH 62/67] Delete docs/reference/jcode-hook-events.md (#25288) --- docs/reference/jcode-hook-events.md | 173 ---------------------------- 1 file changed, 173 deletions(-) delete mode 100644 docs/reference/jcode-hook-events.md diff --git a/docs/reference/jcode-hook-events.md b/docs/reference/jcode-hook-events.md deleted file mode 100644 index 6e5f81be35e..00000000000 --- a/docs/reference/jcode-hook-events.md +++ /dev/null @@ -1,173 +0,0 @@ -# jcode hook events - -What jcode actually emits, and why Orca's status mapping is shaped the way it is. -Everything below was captured from jcode **v0.87.1 (944f747e9)** by pointing every -`[hooks]` entry in `config.toml` at a script that appends `$JCODE_HOOK_PAYLOAD` to -a log, then running real turns. Re-capture before changing the mapping; do not -edit it from memory. - -## The six events - -jcode's `[hooks]` table (`crates/jcode-base/src/hooks.rs`) has six lifecycle -points. Five are **observers** — detached, fire-and-forget, they can never slow -the agent. One, `pre_tool`, is a **gate**: jcode spawns it, writes the tool input -to its stdin, and waits for it to exit before the tool runs. - -| Event | When | Orca state | Notable payload fields | -| --------------- | -------------------------------------------- | ---------- | ---------------------------------------------------------------- | -| `session_start` | TUI open, attach, or `--resume` | none | `source` = `create`/`attach`/`resume`, `model` | -| `turn_start` | prompt submitted, before the model generates | `working` | `source`, `model` | -| `pre_tool` | before each tool call (gate) | `working` | `tool_name`, `tool_input` (argument JSON as a string) | -| `post_tool` | after each tool call | `working` | `tool_name`, `status`, `duration_ms`, `output_bytes`/`error` | -| `turn_end` | turn finished | `done` | `status`, `duration_ms`, `model`, `last_assistant_text`, `error` | -| `session_end` | session closed | `done` | `source` = `close` | - -`session_start` is identity-only. jcode fires it on an idle TUI open, so mapping -it to `working` would spin before the user has typed anything (same reason Devin -does not map its `SessionStart`). - -## Captured payloads - -A `jcode run` turn that read one file and wrote another: - -```json -{"cwd":"/private/tmp/jcode-work","event":"session_start","model":"claude-haiku-4-5","session_id":"session_pawprint_1790149117838_071c2a106812396c","source":"create"} -{"cwd":"/private/tmp/jcode-work","event":"pre_tool","session_id":"session_pawprint_…","tool_input":"{\"file_path\":\"sample.txt\",\"intent\":\"Read sample.txt to get its contents\"}","tool_name":"read"} -{"cwd":"/private/tmp/jcode-work","duration_ms":"0","event":"post_tool","output_bytes":"12","session_id":"session_pawprint_…","status":"ok","tool_name":"read"} -{"cwd":"/private/tmp/jcode-work","event":"pre_tool","session_id":"session_pawprint_…","tool_input":"{\"content\":\"HELLO\",\"file_path\":\"out.txt\",\"intent\":\"Write uppercased contents of sample.txt to out.txt\"}","tool_name":"write"} -{"cwd":"/private/tmp/jcode-work","duration_ms":"9","event":"post_tool","output_bytes":"137","session_id":"session_pawprint_…","status":"ok","tool_name":"write"} -``` - -A TUI turn that failed upstream (note `turn_start`, which the `run` path does not emit): - -```json -{"cwd":"/private/tmp/jcode-work","event":"session_start","model":"claude-opus-5","session_id":"session_snail_…","source":"create"} -{"cwd":"/private/tmp/jcode-work","event":"turn_start","model":"claude-opus-5","session_id":"session_snail_…","source":"chat"} -{"cwd":"/private/tmp/jcode-work","duration_ms":"6868","error":"Anthropic API error (503 Service Unavailable): …","event":"turn_end","model":"claude-opus-5","session_id":"session_snail_…","status":"error"} -``` - -Three consequences the mapping depends on: - -- **`turn_start` only fires on the streaming turn path** (TUI, desktop, swarm - workers, headless sessions), not `jcode run`. It is what fills the otherwise - blank window between a submitted prompt and the first tool call. -- **Only `pre_tool` carries `tool_input`.** `post_tool` reports the name and the - outcome, so the tool preview has to be held from the matching `pre_tool`. -- **Every jcode tool schema has an `intent` string** the model fills in. It is - the preview fallback when no tool-specific key (`file_path`, `command`, …) - matches. - -## Why Orca subscribes to the gate - -`pre_tool` is the only event that can report a tool _while it runs_. Without it a -three-minute `bash` shows no tool at all until it finishes. Two rules keep the -gate from ever costing the agent anything: - -1. **The POST is detached.** jcode calls `child.wait_with_output()`, which waits - for the process _and_ reads its stderr to EOF — a backgrounded child that - inherited stderr would hold the gate open for as long as it ran. The managed - script runs the POST as `orca_post_jcode_event >/dev/null 2>&1 &`, so the - inherited pipes are closed and the script exits immediately. -2. **stdin is drained first.** jcode `write_all`s the full tool input to the - hook's stdin. A tool input larger than the pipe buffer (a big `write`) would - block that write until the gate timed out if nobody read it, so the script - drains stdin before any exit path. - -Orca never blocks a jcode tool call: the script always exits 0. - -## Questions and permissions - -jcode has **no interactive per-tool approval prompt**. Its safety model -(`crates/jcode-app-core/src/tool/bash_destructive_gate.rs`) either denies a -command outright or asks the model to justify it — both inside the tool, with no -human in the loop. There is therefore no hook, and no terminal-title state, for -"jcode is waiting on you" during ordinary tool use. - -The one tool a _human_ answers is ambient mode's `request_permission` -(`crates/jcode-app-core/src/tool/ambient.rs`), resolved out of band with -`jcode permissions`. Orca maps a `pre_tool` for it to `waiting` and publishes the -tool input as the question card. `post_tool` for the same tool is _not_ mapped — -by then the human has already answered. - -Matching is by exact tool name. jcode's live tool set is `agentgrep, apply_patch, -bash, batch, bg, browser, compile_remote, conversation_search, edit, gmail, -integration_tools, ls, macos_computer_use, maintainer_feedback, mcp, memory, -multiedit, open, panel, patch, read, schedule, session_search, side_panel, -skill_manage, swarm, todo, webfetch, websearch, write` plus the ambient tools; -a substring rule over that set would be matching on coincidence. - -## Terminal titles - -jcode paints OSC 0 titles roughly once a second. Captured sequence from one TUI -session: - -``` -jcode → 🐍 jcode Snake → 🐍 jcode/creek Snake → 🌐 jcode Snake · work ~0s → … → 🌐 jcode Snake · last ~6s -``` - -The format is ` jcode [ · +N -M][ · work|last ~]` -(`crates/jcode-tui/src/tui/app/terminal_title.rs`). Orca uses it for tab-bar -identity only — status comes from hooks, never from a parsed title. Note there is -no "needs input" title state; that is the same gap as above, not an omission in -the parser. - -## Per-pane daemons - -jcode runs one server/client daemon per runtime dir, and lifecycle hooks fire -_inside the daemon_. Every TUI client connects the daemon the first pane started, -so without isolation a second jcode pane's events carry the first pane's -`ORCA_PANE_KEY` and its status lands on the wrong tab. - -jcode does forward a client's terminal identity to hooks -(`CLIENT_TERMINAL_ENV_VARS` in `crates/jcode-terminal-launch/src/lib.rs`), but -that allowlist covers tmux/zellij/herdr and the terminal emulators — not -`ORCA_PANE_KEY`. Until it does, Orca stamps a per-pane `JCODE_RUNTIME_DIR` so -each pane gets its own daemon, socket, and lock. The value is a 16-hex hash of -the pane key because the socket path is capped at `SUN_LEN` (104 bytes) and a -full pane key never fits. - -## Windows hook launcher - -Use Jcode **v0.89.0 or newer** on Windows. Earlier observer hooks launch with -`DETACHED_PROCESS`, leaving their children without a console to inherit. A -console program such as the managed hook's `curl.exe` can then open a Windows -Terminal tab on every event. Jcode's launcher fix uses `CREATE_NO_WINDOW` for -observer hooks and the `pre_tool` gate, keeping their descendants invisible. -Changing the managed script alone cannot repair an older Jcode launcher. - -The managed Windows hook redirects its payload file into curl directly, avoiding -the extra shells that a `type ... | curl` pipeline starts. Existing managed scripts -are refreshed on Orca startup without changing the user's hook configuration. - -Report: https://github.com/stablyai/orca/pull/22539#issuecomment-5809618574 -Launcher fix: https://github.com/1jehuang/jcode/pull/1490 - -## Hook installation health - -Orca's local hook status reports `installed` only when all six events use the -current shell-quoted managed command and the managed script exists. Missing scripts, -old home paths, platform-switched commands, and legacy unquoted commands report -`partial` with a repair reason, not a user-owned hook warning. Reinstalling hooks -recreates the script and updates only Orca-owned commands. User-owned commands -remain untouched. Removing the managed entries still reports `not_installed`, -even if the shared script remains on disk. - -## Config shape - -`[hooks]` values accept a string or an array of strings (`HookCommands` in -`crates/jcode-config-types/src/lib.rs`), and jcode re-reads the config on reload, -so hooks can be added without restarting. jcode parses a hook command line -shell-style but **executes it directly, not through a shell** — the managed value -must be the script path, never an `if [ -f … ]` wrapper. - -That shell-style parse is `parse_hook_command` -(`crates/jcode-terminal-launch/src/lib.rs`), and it is why Orca stores the path -**shell-quoted**. The tokenizer splits on unquoted whitespace and consumes every -unquoted backslash as an escape, so a bare Windows path reaches `exec` as -`C:Usersme.orcaagent-hooksjcode-hook.cmd` and no hook fires at all; a POSIX home -with a space splits into two arguments. Single quotes pass a path through -verbatim — backslashes are literal inside them — so Orca single-quotes by -default and falls back to double quotes (escaping `\` and `"`) only for a path -that itself contains a single quote. The value is then TOML-quoted on the way -into the file, so neither the raw path nor the shell-quoted string appears -alone. From 41cc77509f483230dd1243d5357886e43d20cf94 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 4 Oct 2026 12:44:26 -0700 Subject: [PATCH 63/67] Keep active notebook cells current after external reloads (#25172) --- .../IpynbCellEditor.external-sync.test.tsx | 308 ++++++++++++++++++ .../src/components/editor/IpynbCellEditor.tsx | 49 ++- tests/e2e/notebook-cell-external-sync.spec.ts | 106 ++++++ 3 files changed, 455 insertions(+), 8 deletions(-) create mode 100644 src/renderer/src/components/editor/IpynbCellEditor.external-sync.test.tsx create mode 100644 tests/e2e/notebook-cell-external-sync.spec.ts diff --git a/src/renderer/src/components/editor/IpynbCellEditor.external-sync.test.tsx b/src/renderer/src/components/editor/IpynbCellEditor.external-sync.test.tsx new file mode 100644 index 00000000000..d53b24baf9b --- /dev/null +++ b/src/renderer/src/components/editor/IpynbCellEditor.external-sync.test.tsx @@ -0,0 +1,308 @@ +// @vitest-environment happy-dom +import { useMemo, useState } from 'react' +import type { editor } from 'monaco-editor' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { IpynbCellSource } from './IpynbCellEditor' +import { parseIpynb } from './ipynb-parse' +import { + getIpynbCellKey, + hasIpynbSourceDraft, + useIpynbDocumentEditing +} from './useIpynbDocumentEditing' + +const liveModels = vi.hoisted(() => { + const models: editor.ITextModel[] = [] + return models +}) +const widgetCalls = vi.hoisted(() => ({ + focus: vi.fn(), + layout: vi.fn(), + updateOptions: vi.fn<(options: editor.IEditorOptions) => void>() +})) + +vi.mock('@/i18n/i18n', () => ({ + i18n: { language: 'en' }, + translate: (_key: string, fallback: string) => fallback +})) +vi.mock('@/store', () => ({ + useAppStore: ( + selector: (state: { settings: undefined; editorFontZoomLevel: number }) => unknown + ) => selector({ settings: undefined, editorFontZoomLevel: 0 }) +})) +vi.mock('@/hooks/use-document-dark-theme', () => ({ useDocumentDarkTheme: () => true })) +vi.mock('./MonacoCodeExcerpt', () => ({ useMonacoColorizedLines: () => [] })) +vi.mock('./editor-shortcuts', () => ({ installMonacoEditorFindShortcut: () => () => {} })) +vi.mock('@/lib/monaco-setup', async () => { + const actual = await import('monaco-editor/esm/vs/editor/editor.api.js') + for (const id of ['python', 'javascript', 'markdown']) { + actual.languages.register({ id }) + } + return { + monaco: { + ...actual, + editor: { + ...actual.editor, + setTheme: vi.fn(), + createModel: (...args: Parameters) => { + const model = actual.editor.createModel(...args) + liveModels.push(model) + return model + }, + create: (_container: HTMLElement, { model }: { model: editor.ITextModel }) => ({ + getModel: () => model, + getContentHeight: () => 28, + layout: widgetCalls.layout, + onDidContentSizeChange: () => ({ dispose: vi.fn() }), + restoreViewState: vi.fn(), + saveViewState: () => null, + getTargetAtClientPoint: () => null, + setPosition: vi.fn(), + focus: widgetCalls.focus, + onDidBlurEditorWidget: () => ({ dispose: vi.fn() }), + addCommand: vi.fn(), + updateOptions: widgetCalls.updateOptions, + pushUndoStop: () => { + model.pushStackElement() + return true + }, + dispose: vi.fn() + }) + } + } + } +}) + +function notebookContent( + cells: { id: string; source: string; kind?: 'code' | 'markdown' }[], + language = 'python' +): string { + return JSON.stringify({ + nbformat: 4, + nbformat_minor: 5, + metadata: { language_info: { name: language } }, + cells: cells.map(({ id, source, kind }) => ({ + id, + cell_type: kind ?? 'code', + metadata: {}, + execution_count: null, + outputs: [], + source: [source] + })) + }) +} + +function NotebookCells({ + content, + onContentChange, + onDirtyStateHint +}: { + content: string + onContentChange: (content: string) => void + onDirtyStateHint: (dirty: boolean) => void +}): React.JSX.Element { + const notebook = useMemo(() => parseIpynb(content), [content]) + const [editingCellKey, setEditingCellKey] = useState(null) + const editing = useIpynbDocumentEditing({ + content, + fileId: 'external-notebook', + notebook, + onContentChange, + onDirtyStateHint, + onDeactivateEditor: () => setEditingCellKey(null) + }) + return ( +
+ {notebook.cells.map((cell, index) => { + const key = getIpynbCellKey(cell, index) + return ( + setEditingCellKey(key)} + onDeactivate={() => setEditingCellKey(null)} + onChange={(source) => editing.updateCellSource(index, source)} + /> + ) + })} +
+ ) +} + +function activeModel(): editor.ITextModel { + const model = liveModels.at(-1) + if (!model) { + throw new Error('No notebook cell editor was created') + } + return model +} + +function appendText(model: editor.ITextModel, text: string): void { + const end = model.getFullModelRange() + act(() => { + model.pushEditOperations( + [], + [{ range: { ...end, startLineNumber: end.endLineNumber, startColumn: end.endColumn }, text }], + () => null + ) + vi.advanceTimersByTime(400) + }) +} + +beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() +}) +afterEach(() => { + cleanup() + for (const model of liveModels.splice(0)) { + if (!model.isDisposed()) { + model.dispose() + } + } + vi.useRealTimers() +}) + +describe('active notebook cell external reload', () => { + it('shows the new source without dirtying the clean notebook or recreating the model', () => { + const onContentChange = vi.fn<(content: string) => void>() + const onDirtyStateHint = vi.fn<(dirty: boolean) => void>() + const initialContent = notebookContent([{ id: 'a', source: 'original source' }]) + const { rerender } = render( + + ) + fireEvent.mouseDown(screen.getByRole('button', { name: 'original source' }), { button: 0 }) + const model = activeModel() + expect(model.getValue()).toBe('original source') + expect(onDirtyStateHint).not.toHaveBeenCalled() + + rerender( + + ) + + expect(liveModels).toEqual([model]) + expect(model.getValue()).toBe('updated from disk') + expect(onContentChange).not.toHaveBeenCalled() + expect(onDirtyStateHint).not.toHaveBeenCalled() + expect(widgetCalls.focus).toHaveBeenCalledOnce() + appendText(model, '!') + const committedContent = onContentChange.mock.calls.at(-1)?.[0] + expect(committedContent).toBeDefined() + expect(parseIpynb(committedContent ?? '').cells[0]?.source).toBe('updated from disk!') + }) + + it('writes edits to the same cell after a clean external reload reorders it', () => { + const onContentChange = vi.fn<(content: string) => void>() + const onDirtyStateHint = vi.fn<(dirty: boolean) => void>() + const first = { id: 'a', source: 'active source' } + const second = { id: 'b', source: 'other source' } + const { rerender } = render( + + ) + fireEvent.mouseDown(screen.getByRole('button', { name: first.source }), { button: 0 }) + const model = activeModel() + expect(onDirtyStateHint).not.toHaveBeenCalled() + + rerender( + + ) + expect(liveModels).toEqual([model]) + expect(onDirtyStateHint).not.toHaveBeenCalled() + appendText(model, '!') + + const committedContent = onContentChange.mock.calls.at(-1)?.[0] + expect(committedContent).toBeDefined() + const cells = parseIpynb(committedContent ?? '').cells + expect(cells.map(({ id, source }) => ({ id, source }))).toEqual([ + second, + { ...first, source: 'active source!' } + ]) + }) + + it('retains undo for external source changes and treats undo as a user edit', async () => { + const onContentChange = vi.fn<(content: string) => void>() + const onDirtyStateHint = vi.fn<(dirty: boolean) => void>() + const { rerender } = render( + + ) + fireEvent.mouseDown(screen.getByRole('button', { name: 'original source' }), { button: 0 }) + const model = activeModel() + rerender( + + ) + + expect(model.canUndo()).toBe(true) + expect(onDirtyStateHint).not.toHaveBeenCalled() + await act(async () => { + await model.undo() + vi.advanceTimersByTime(400) + }) + expect(model.getValue()).toBe('original source') + expect(onDirtyStateHint).toHaveBeenCalledWith(true) + const committedContent = onContentChange.mock.calls.at(-1)?.[0] + expect(parseIpynb(committedContent ?? '').cells[0]?.source).toBe('original source') + }) + + it('updates language, wrapping and height when a clean reload changes the active cell', () => { + const onContentChange = vi.fn<(content: string) => void>() + const onDirtyStateHint = vi.fn<(dirty: boolean) => void>() + const { rerender } = render( + + ) + fireEvent.mouseDown(screen.getByRole('button', { name: 'original source' }), { button: 0 }) + const model = activeModel() + rerender( + + ) + expect(model.getLanguageId()).toBe('javascript') + expect(widgetCalls.updateOptions).toHaveBeenLastCalledWith( + expect.objectContaining({ wordWrap: 'off' }) + ) + widgetCalls.layout.mockClear() + + rerender( + + ) + expect(liveModels).toEqual([model]) + expect(model.getLanguageId()).toBe('markdown') + expect(widgetCalls.updateOptions).toHaveBeenLastCalledWith( + expect.objectContaining({ wordWrap: 'on' }) + ) + expect(widgetCalls.layout).toHaveBeenCalled() + expect(model.getValue()).toBe('**Updated**\n\nParagraph') + expect(onDirtyStateHint).not.toHaveBeenCalled() + expect(onContentChange).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/editor/IpynbCellEditor.tsx b/src/renderer/src/components/editor/IpynbCellEditor.tsx index 228bf57f6e3..e9b29768e44 100644 --- a/src/renderer/src/components/editor/IpynbCellEditor.tsx +++ b/src/renderer/src/components/editor/IpynbCellEditor.tsx @@ -5,6 +5,7 @@ import { monaco } from '@/lib/monaco-setup' import { computeEditorFontSize, resolveEditorFontStack } from '@/lib/editor-font-zoom' import { useAppStore } from '@/store' import { installMonacoEditorFindShortcut } from './editor-shortcuts' +import { syncContentUpdate } from './monaco-content-sync' import { IPYNB_CODE_CELL_PREVIEW_MAX_LINES, getIpynbCodeCellPreviewLines @@ -155,18 +156,21 @@ function IpynbSourceEditor({ const fontSize = computeEditorFontSize(settings?.terminalFontSize ?? 13, editorFontZoomLevel) const containerRef = useRef(null) const editorRef = useRef(null) + const callbacksRef = useRef({ onChange, onDeactivate }) + const syncingSourceRef = useRef(false) + const fitHeightRef = useRef<(() => void) | null>(null) + + useLayoutEffect(() => { + callbacksRef.current = { onChange, onDeactivate } + }, [onChange, onDeactivate]) useLayoutEffect(() => { monaco.editor.setTheme(isDark ? 'vs-dark' : 'vs') }, [isDark]) - useLayoutEffect(() => { - editorRef.current?.updateOptions({ fontFamily, fontSize }) - }, [fontFamily, fontSize]) - // Why: created synchronously before paint (not via @monaco-editor/react's async loader), so the // swap from the preview never shows a placeholder, an unlaid-out editor or a guessed caret. - // Mount-once: the props it reads cannot change while the cell is being edited. + // Keep the model and undo history while external reloads update the cell below. useLayoutEffect(() => { const container = containerRef.current if (!container) { @@ -206,6 +210,7 @@ function IpynbSourceEditor({ container.style.height = `${Math.min(editorInstance.getContentHeight(), maxHeight)}px` editorInstance.layout() } + fitHeightRef.current = fitHeight fitHeight() editorInstance.onDidContentSizeChange(fitHeight) // Why: restoring a view state marks the visible lines stable, so Monaco tokenizes them now @@ -217,12 +222,16 @@ function IpynbSourceEditor({ editorInstance.setPosition(target.position) } editorInstance.focus() - model.onDidChangeContent(() => onChange(model.getValue())) - editorInstance.onDidBlurEditorWidget(onDeactivate) + model.onDidChangeContent(() => { + if (!syncingSourceRef.current) { + callbacksRef.current.onChange(model.getValue()) + } + }) + editorInstance.onDidBlurEditorWidget(() => callbacksRef.current.onDeactivate()) // Escape closes an open widget first; only a bare Escape leaves the cell. editorInstance.addCommand( monaco.KeyCode.Escape, - onDeactivate, + () => callbacksRef.current.onDeactivate(), '!suggestWidgetVisible && !findWidgetVisible && !parameterHintsVisible' ) const cleanupFindShortcut = installMonacoEditorFindShortcut(editorInstance) @@ -231,9 +240,33 @@ function IpynbSourceEditor({ editorInstance.dispose() model.dispose() editorRef.current = null + fitHeightRef.current = null } // oxlint-disable-next-line react-hooks/exhaustive-deps -- mount-once; see the Why above. }, []) + useLayoutEffect(() => { + const editorInstance = editorRef.current + const model = editorInstance?.getModel() + if (!editorInstance || !model) { + return + } + if (model.getLanguageId() !== cell.language) { + monaco.editor.setModelLanguage(model, cell.language) + } + editorInstance.updateOptions({ + fontFamily, + fontSize, + wordWrap: cell.kind === 'code' ? 'off' : 'on' + }) + syncingSourceRef.current = true + try { + syncContentUpdate(editorInstance, source) + } finally { + syncingSourceRef.current = false + } + fitHeightRef.current?.() + }, [source, cell.language, cell.kind, fontFamily, fontSize]) + return
} diff --git a/tests/e2e/notebook-cell-external-sync.spec.ts b/tests/e2e/notebook-cell-external-sync.spec.ts new file mode 100644 index 00000000000..7eb788e82ad --- /dev/null +++ b/tests/e2e/notebook-cell-external-sync.spec.ts @@ -0,0 +1,106 @@ +import { randomUUID } from 'node:crypto' +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import path from 'node:path' +import type { Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { getActiveWorktreeContext } from './helpers/markdown-editor-fixture' +import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { parseIpynb } from '../../src/renderer/src/components/editor/ipynb-parse' + +const FIRST = { id: 'first', source: 'print("original")' } +const SECOND = { id: 'second', source: 'print("other")' } + +function notebookContent(cells: { id: string; source: string }[]): string { + return JSON.stringify({ + nbformat: 4, + nbformat_minor: 5, + metadata: { language_info: { name: 'python' } }, + cells: cells.map(({ id, source }) => ({ + id, + cell_type: 'code', + metadata: {}, + execution_count: null, + outputs: [], + source: [source] + })) + }) +} + +async function openNotebook(page: Page, filePath: string): Promise { + const context = await getActiveWorktreeContext(page) + await page.evaluate( + ({ filePath, worktreeId, relativePath }) => { + window.__store?.getState().openFile({ + filePath, + relativePath, + worktreeId, + language: 'json', + mode: 'edit' + }) + }, + { + filePath, + worktreeId: context.worktreeId, + relativePath: path.relative(context.rootPath, filePath) + } + ) + await expect(page.locator('.ipynb-code-surface')).toHaveCount(2) +} + +async function fileIsDirty(page: Page): Promise { + return page.evaluate(() => { + const state = window.__store?.getState() + return state?.openFiles.find((file) => file.id === state.activeFileId)?.isDirty ?? null + }) +} + +test.beforeEach(async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) +}) + +for (const reload of ['source', 'reorder'] as const) { + test(`keeps the active cell current after an external notebook ${reload} reload`, async ({ + orcaPage, + registerPostElectronShutdownCleanup + }, testInfo) => { + const context = await getActiveWorktreeContext(orcaPage) + const directory = path.join(context.rootPath, '.orca-e2e-notebook-external-sync') + await mkdir(directory, { recursive: true }) + const filePath = path.join(directory, `${reload}-${testInfo.workerIndex}-${randomUUID()}.ipynb`) + registerPostElectronShutdownCleanup(() => rm(filePath, { force: true })) + await writeFile(filePath, notebookContent([FIRST, SECOND]), 'utf8') + await openNotebook(orcaPage, filePath) + const surfaces = orcaPage.locator('.ipynb-code-surface') + await surfaces.first().getByRole('button').click() + await expect(orcaPage.locator('.ipynb-code-surface .monaco-editor')).toBeVisible() + await expect.poll(() => fileIsDirty(orcaPage)).toBe(false) + + const updatedFirst = reload === 'source' ? { ...FIRST, source: 'print("updated")' } : FIRST + const updatedSecond = { ...SECOND, source: 'print("reload marker")' } + const externalCells = + reload === 'source' ? [updatedFirst, updatedSecond] : [updatedSecond, updatedFirst] + await writeFile(filePath, notebookContent(externalCells), 'utf8') + const preview = surfaces.nth(reload === 'source' ? 1 : 0) + await expect(preview).toHaveText(updatedSecond.source, { timeout: 25_000 }) + await expect.poll(() => fileIsDirty(orcaPage)).toBe(false) + await orcaPage.screenshot({ path: testInfo.outputPath('external-reload-clean.png') }) + const activeCell = surfaces.nth(reload === 'source' ? 0 : 1) + await expect.soft(activeCell.locator('.view-lines')).toHaveText(updatedFirst.source) + + await orcaPage.keyboard.press('End') + await orcaPage.keyboard.type('!', { delay: 100 }) + await expect.poll(() => fileIsDirty(orcaPage)).toBe(true) + await orcaPage.screenshot({ path: testInfo.outputPath('external-reload-cell-edit.png') }) + await orcaPage.getByRole('button', { name: 'Save notebook', exact: true }).click() + const editedFirst = { ...updatedFirst, source: `${updatedFirst.source}!` } + await expect + .poll(async () => { + const saved = parseIpynb(await readFile(filePath, 'utf8')) + return saved.cells.map(({ id, source }) => ({ id, source })) + }) + .toEqual(reload === 'source' ? [editedFirst, updatedSecond] : [updatedSecond, editedFirst]) + await expect.poll(() => fileIsDirty(orcaPage)).toBe(false) + await orcaPage.screenshot({ path: testInfo.outputPath('external-reload-cell-saved.png') }) + }) +} From b32462f2465c58490af26f49a314d7602578ca8c Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 4 Oct 2026 13:05:30 -0700 Subject: [PATCH 64/67] Replace patched JSON parser with stream-json (#25202) * Replace patched JSON parser with stream-json * Isolate dependencies for historical server compatibility builds --- .github/workflows/node-server-tests.yml | 4 +- .../patches/@streamparser__json@0.0.26.patch | 66 ------ .../json-parser-benchmark-fixtures.mjs | 65 ++++++ .../json-parser-migration-benchmark.mjs | 166 ++++++++++++++ .../scripts/node-server-change-scope.test.mjs | 4 + config/tsconfig.cli.json | 4 +- config/tsconfig.tc.cli.json | 2 +- electron.vite.config.ts | 3 +- package.json | 3 +- pnpm-lock.yaml | 27 ++- pnpm-workspace.yaml | 1 - .../session-document-stream-contract.test.ts | 166 ++++++++++++++ src/main/ai-vault/session-document-stream.ts | 205 +++++++++--------- src/shared/json-token-reader.test.ts | 47 ++++ src/shared/json-token-reader.ts | 48 ++++ src/shared/ripgrep-dense-match-json.test.ts | 51 +++-- src/shared/ripgrep-dense-match-json.ts | 186 +++++++++------- 17 files changed, 765 insertions(+), 283 deletions(-) delete mode 100644 config/patches/@streamparser__json@0.0.26.patch create mode 100644 config/scripts/json-parser-benchmark-fixtures.mjs create mode 100644 config/scripts/json-parser-migration-benchmark.mjs create mode 100644 src/main/ai-vault/session-document-stream-contract.test.ts create mode 100644 src/shared/json-token-reader.test.ts create mode 100644 src/shared/json-token-reader.ts diff --git a/.github/workflows/node-server-tests.yml b/.github/workflows/node-server-tests.yml index 0b121c5bb2e..90c3fc68473 100644 --- a/.github/workflows/node-server-tests.yml +++ b/.github/workflows/node-server-tests.yml @@ -164,7 +164,7 @@ jobs: cache-pnpm-store-lookup-only: 'true' # Design D7 upgrade and rollback: the last Bun orcad, built from a main commit that shipped # it, beside this checkout's Node slot; the live-terminal hand-over skips once PROTOCOL_VERSION - # moves past the Bun daemon's. Its build uses this checkout's installed dependencies. + # moves past the Bun daemon's. Install its pinned dependencies independently of this checkout. - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 if: runner.os == 'Linux' with: @@ -179,7 +179,7 @@ jobs: if [ "$RUNNER_OS" != Linux ]; then exit 0; fi git fetch --no-tags --depth=1 origin "$BUN_ORCAD_COMMIT" git worktree add --detach "$RUNNER_TEMP/bun-orcad-source" "$BUN_ORCAD_COMMIT" - ln -s "$GITHUB_WORKSPACE/node_modules" "$RUNNER_TEMP/bun-orcad-source/node_modules" + pnpm --dir "$RUNNER_TEMP/bun-orcad-source" install --frozen-lockfile --ignore-scripts node "$RUNNER_TEMP/bun-orcad-source/config/scripts/build-orcad-bun.mjs" --out-dir "$RUNNER_TEMP/bun-orcad" echo "slot=$RUNNER_TEMP/bun-orcad" >> "$GITHUB_OUTPUT" echo "executable=$(command -v bun)" >> "$GITHUB_OUTPUT" diff --git a/config/patches/@streamparser__json@0.0.26.patch b/config/patches/@streamparser__json@0.0.26.patch deleted file mode 100644 index c93998f85fe..00000000000 --- a/config/patches/@streamparser__json@0.0.26.patch +++ /dev/null @@ -1,66 +0,0 @@ -diff --git a/dist/cjs/utils/bufferedString.js b/dist/cjs/utils/bufferedString.js -index 82f710a018f9771fe10335e2dcacd75d707d9062..f3cfa64cefa967d7a83c328e1abb9246135b067b 100644 ---- a/dist/cjs/utils/bufferedString.js -+++ b/dist/cjs/utils/bufferedString.js -@@ -16,7 +16,7 @@ class NonBufferedString { - constructor() { - // fatal: true makes invalid byte sequences (e.g. a lead byte followed by a - // non-continuation byte) throw instead of silently decoding to U+FFFD. -- this.decoder = new TextDecoder("utf-8", { fatal: true }); -+ this.decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }); - // Pieces appended since the last toString(), not yet folded into `string`. - this.pending = []; - this.string = ""; -@@ -66,7 +66,7 @@ class BufferedString { - constructor(bufferSize) { - // fatal: true makes invalid byte sequences (e.g. a lead byte followed by a - // non-continuation byte) throw instead of silently decoding to U+FFFD. -- this.decoder = new TextDecoder("utf-8", { fatal: true }); -+ this.decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }); - this.bufferOffset = 0; - this.string = ""; - this.byteLength = 0; -diff --git a/dist/mjs/utils/bufferedString.js b/dist/mjs/utils/bufferedString.js -index 0fb208d8615f5e20a086f75a37bef928b155c47c..0d8ca407d594d61798eca7f7253e1dfc1770d291 100644 ---- a/dist/mjs/utils/bufferedString.js -+++ b/dist/mjs/utils/bufferedString.js -@@ -13,7 +13,7 @@ export class NonBufferedString { - constructor() { - // fatal: true makes invalid byte sequences (e.g. a lead byte followed by a - // non-continuation byte) throw instead of silently decoding to U+FFFD. -- this.decoder = new TextDecoder("utf-8", { fatal: true }); -+ this.decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }); - // Pieces appended since the last toString(), not yet folded into `string`. - this.pending = []; - this.string = ""; -@@ -62,7 +62,7 @@ export class BufferedString { - constructor(bufferSize) { - // fatal: true makes invalid byte sequences (e.g. a lead byte followed by a - // non-continuation byte) throw instead of silently decoding to U+FFFD. -- this.decoder = new TextDecoder("utf-8", { fatal: true }); -+ this.decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }); - this.bufferOffset = 0; - this.string = ""; - this.byteLength = 0; -diff --git a/src/utils/bufferedString.ts b/src/utils/bufferedString.ts -index 482c7402899bb157249cfb7882d327b7d9477912..4e45ef5d5bd7ec66776ece54e917282441cededb 100644 ---- a/src/utils/bufferedString.ts -+++ b/src/utils/bufferedString.ts -@@ -40,7 +40,7 @@ export interface StringBuilder { - export class NonBufferedString implements StringBuilder { - // fatal: true makes invalid byte sequences (e.g. a lead byte followed by a - // non-continuation byte) throw instead of silently decoding to U+FFFD. -- private decoder = new TextDecoder("utf-8", { fatal: true }); -+ private decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }); - // Pieces appended since the last toString(), not yet folded into `string`. - private pending: string[] = []; - private string = ""; -@@ -90,7 +90,7 @@ export class NonBufferedString implements StringBuilder { - export class BufferedString implements StringBuilder { - // fatal: true makes invalid byte sequences (e.g. a lead byte followed by a - // non-continuation byte) throw instead of silently decoding to U+FFFD. -- private decoder = new TextDecoder("utf-8", { fatal: true }); -+ private decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }); - private buffer: Uint8Array; - private bufferOffset = 0; - private string = ""; diff --git a/config/scripts/json-parser-benchmark-fixtures.mjs b/config/scripts/json-parser-benchmark-fixtures.mjs new file mode 100644 index 00000000000..385b4010895 --- /dev/null +++ b/config/scripts/json-parser-benchmark-fixtures.mjs @@ -0,0 +1,65 @@ +import { writeFileSync } from 'node:fs' +import { join } from 'node:path' + +export const JSON_PARSER_CASES = [ + { name: 'rg-10k', kind: 'rg', count: 10_000, cap: 2000 }, + { name: 'rg-100k', kind: 'rg', count: 100_000, cap: 2000 }, + { name: 'rg-100k-cap1', kind: 'rg', count: 100_000, cap: 1 }, + { name: 'rg-unicode', kind: 'rg', count: 10_000, cap: 2000, unicode: true }, + { name: 'rg-large-dense', kind: 'rg', count: 900_000, cap: 2000, large: true }, + { name: 'rg-fast-path', kind: 'rg', count: 1, cap: 2000, large: true }, + { name: 'session-messages', kind: 'session' }, + { name: 'session-skipped-objects', kind: 'session' }, + { name: 'session-skipped-string', kind: 'session' }, + { name: 'session-selected-string', kind: 'session' } +] + +export function writeJsonParserFixtures(directory) { + for (const fixture of JSON_PARSER_CASES) { + let value + if (fixture.kind === 'rg') { + const text = fixture.unicode + ? '\ufeff日本語😀x' + : fixture.large && fixture.count > 1 + ? 'xxxx' + : 'x' + const matchBytes = Buffer.byteLength(text) + value = { + type: 'match', + data: { + path: { text: `${text}.ts` }, + lines: { + text: text.repeat(fixture.count === 1 ? 4 * 1024 * 1024 : fixture.count) + }, + line_number: 1, + submatches: Array.from({ length: fixture.count }, (_, index) => ({ + match: { text }, + start: index * matchBytes, + end: (index + 1) * matchBytes + })) + } + } + } else { + value = { id: 'synthetic', messages: [{ text: 'one' }] } + if (fixture.name === 'session-messages') { + value.agent = { model: 'model', other: 'ignored' } + value.messages = Array.from({ length: 20_000 }, (_, index) => ({ + role: index % 2 ? 'assistant' : 'user', + text: '\ufeff日本語😀 hello world '.repeat(16), + timestamp: index, + metadata: { model: 'synthetic', tokens: 512 } + })) + } else if (fixture.name === 'session-skipped-objects') { + value.ignored = Array.from({ length: 200_000 }, (_, index) => ({ + id: index, + data: { text: 'x'.repeat(64), values: [1, 2, 3] } + })) + } else if (fixture.name === 'session-skipped-string') { + value.ignored = '日本語😀x'.repeat(1_500_000) + } else { + value.messages = [{ text: '日本語😀x'.repeat(1_500_000) }] + } + } + writeFileSync(join(directory, `${fixture.name}.json`), JSON.stringify(value)) + } +} diff --git a/config/scripts/json-parser-migration-benchmark.mjs b/config/scripts/json-parser-migration-benchmark.mjs new file mode 100644 index 00000000000..0aca1734e1b --- /dev/null +++ b/config/scripts/json-parser-migration-benchmark.mjs @@ -0,0 +1,166 @@ +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { spawnSync } from 'node:child_process' +import { createReadStream, readFileSync, statSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { buildCounterbalancedSchedule } from './counterbalanced-benchmark-schedule.mjs' +import { summarizeBenchmarkSamples } from './benchmark-sample-summary.mjs' +import { JSON_PARSER_CASES, writeJsonParserFixtures } from './json-parser-benchmark-fixtures.mjs' + +// Bundle each revision's two consumers as {baseline,candidate}-{rg,session}.mjs first. +const [bundleDirectory, workerFixture, workerArm] = process.argv.slice(2) +if (!bundleDirectory || !global.gc) { + throw new Error('Usage: node --expose-gc json-parser-migration-benchmark.mjs BUNDLE_DIRECTORY') +} + +async function load(arm, kind) { + return import(pathToFileURL(resolve(bundleDirectory, `${arm}-${kind}.mjs`)).href) +} + +function prepareRun(module, fixture, file) { + if (fixture.kind === 'rg') { + const text = readFileSync(file, 'utf8') + return () => + module.parseRipgrepMatchJson(text, fixture.cap, { + structuralTokens: 32 * 1024, + nestingDepth: 16 + }) + } + return () => + module.readStreamedSessionDocument({ + bytes: createReadStream(file, { highWaterMark: 64 * 1024 }), + arrayKey: 'messages', + fields: ['id'], + objectFields: { agent: ['model'] }, + create: () => ({ count: 0, textLength: 0 }), + consume(state, value) { + state.count++ + state.textLength += typeof value?.text === 'string' ? value.text.length : 0 + } + }) +} + +function digest(value) { + return createHash('sha256').update(JSON.stringify(value)).digest('hex') +} + +async function consumedContentDigest(module, file) { + const result = await module.readStreamedSessionDocument({ + bytes: createReadStream(file, { highWaterMark: 64 * 1024 }), + arrayKey: 'messages', + fields: ['id'], + objectFields: { agent: ['model'] }, + create: () => createHash('sha256'), + consume(hash, value) { + hash.update(JSON.stringify(value)).update('\n') + } + }) + return { record: result.record, consumedSha256: result.state.digest('hex') } +} + +if (workerFixture) { + const fixture = JSON_PARSER_CASES.find((item) => workerFixture.endsWith(`${item.name}.json`)) + assert(fixture) + const module = await load(workerArm, fixture.kind) + const run = prepareRun(module, fixture, workerFixture) + global.gc() + const before = process.memoryUsage() + let running = true + let maxLoopGapMs = 0 + let previous = performance.now() + const observe = () => { + const now = performance.now() + maxLoopGapMs = Math.max(maxLoopGapMs, now - previous) + previous = now + if (running) { + setImmediate(observe) + } + } + setImmediate(observe) + const started = performance.now() + const result = await run() + const elapsedMs = performance.now() - started + await new Promise((done) => setImmediate(done)) + running = false + const peakRssMiB = process.resourceUsage().maxRSS / 1024 + global.gc() + const retainedHeapDeltaMiB = (process.memoryUsage().heapUsed - before.heapUsed) / 1024 ** 2 + console.log( + JSON.stringify({ + elapsedMs, + peakRssMiB, + retainedHeapDeltaMiB, + maxLoopGapMs, + digest: digest(result) + }) + ) +} else { + const directory = mkdtempSync(join(tmpdir(), 'orca-json-parser-benchmark-')) + try { + writeJsonParserFixtures(directory) + global.gc() + const results = [] + for (const fixture of JSON_PARSER_CASES) { + const file = join(directory, `${fixture.name}.json`) + const runs = {} + const contents = {} + for (const arm of ['baseline', 'candidate']) { + const module = await load(arm, fixture.kind) + runs[arm] = prepareRun(module, fixture, file) + if (fixture.kind === 'session') { + contents[arm] = await consumedContentDigest(module, file) + } + } + assert.deepEqual(contents.candidate, contents.baseline) + for (let warmup = 0; warmup < 3; warmup++) { + assert.deepEqual(await runs.candidate(), await runs.baseline()) + } + const samples = { baseline: [], candidate: [] } + for (const pair of buildCounterbalancedSchedule(12, 'baseline', 'candidate')) { + for (const arm of pair) { + const started = performance.now() + await runs[arm]() + samples[arm].push(performance.now() - started) + } + } + const memory = { baseline: [], candidate: [] } + for (const pair of buildCounterbalancedSchedule(2, 'baseline', 'candidate')) { + for (const arm of pair) { + const child = spawnSync( + process.execPath, + ['--expose-gc', import.meta.filename, bundleDirectory, file, arm], + { + encoding: 'utf8', + env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' }, + windowsHide: true + } + ) + assert.equal(child.status, 0, child.stderr) + memory[arm].push(JSON.parse(child.stdout)) + } + } + for (const sample of [...memory.baseline, ...memory.candidate]) { + assert.equal(sample.digest, memory.baseline[0].digest) + } + results.push({ + name: fixture.name, + bytes: statSync(file).size, + baseline: summarizeBenchmarkSamples(samples.baseline), + candidate: summarizeBenchmarkSamples(samples.candidate), + samples, + memory + }) + } + console.log( + JSON.stringify( + { node: process.version, platform: process.platform, arch: process.arch, results }, + null, + 2 + ) + ) + } finally { + rmSync(directory, { recursive: true, force: true }) + } +} diff --git a/config/scripts/node-server-change-scope.test.mjs b/config/scripts/node-server-change-scope.test.mjs index 4216beb2556..38b56f0d600 100644 --- a/config/scripts/node-server-change-scope.test.mjs +++ b/config/scripts/node-server-change-scope.test.mjs @@ -310,6 +310,10 @@ it('runs the Bun and Node cross-runtime tests on Linux against pinned inputs', ( expect(build.if).toBeUndefined() expect(build['continue-on-error']).toBeUndefined() expect(build.run).toMatch(/^if \[ "\$RUNNER_OS" != Linux \]; then exit 0; fi\n/) + expect(build.run).toContain( + 'pnpm --dir "$RUNNER_TEMP/bun-orcad-source" install --frozen-lockfile --ignore-scripts' + ) + expect(build.run).not.toContain('"$GITHUB_WORKSPACE/node_modules"') expect(build.run).toContain('echo "slot=$RUNNER_TEMP/bun-orcad" >> "$GITHUB_OUTPUT"') expect(build.run).toContain('echo "executable=$(command -v bun)" >> "$GITHUB_OUTPUT"') expect(build.run).not.toContain('GITHUB_ENV') diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 44edcfd90b7..6afcf4f142f 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -258,8 +258,8 @@ ], "compilerOptions": { "composite": true, - // TypeScript 7 removed node10 resolution; Node16 preserves CommonJS emit for this package. - "module": "Node16", + // The CLI runs on Node 24; Node20 models synchronous ESM imports from CommonJS. + "module": "Node20", "moduleResolution": "Node16", "rootDir": "../src", "outDir": "../out" diff --git a/config/tsconfig.tc.cli.json b/config/tsconfig.tc.cli.json index bd59f47e7be..c613dd688d9 100644 --- a/config/tsconfig.tc.cli.json +++ b/config/tsconfig.tc.cli.json @@ -1,7 +1,7 @@ { "extends": "./tsconfig.cli.json", "compilerOptions": { - "module": "node16", + "module": "Node20", "moduleResolution": "node16" } } diff --git a/electron.vite.config.ts b/electron.vite.config.ts index a899fd97f9f..ac23e12afba 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -13,7 +13,8 @@ import { import packageJson from './package.json' with { type: 'json' } const BUNDLED_MAIN_DEPENDENCIES = new Set([ - '@streamparser/json', + 'stream-json', + 'stream-chain', '@xterm/headless', '@xterm/addon-serialize', 'tldts', diff --git a/package.json b/package.json index 3e67dfb4813..3c1979d5ff0 100644 --- a/package.json +++ b/package.json @@ -182,7 +182,6 @@ "@floating-ui/dom": "1.8.0", "@linear/sdk": "^97.0.0", "@parcel/watcher": "^2.5.6", - "@streamparser/json": "0.0.26", "@xterm/addon-serialize": "0.15.0-beta.300", "@xterm/headless": "6.1.0-beta.302", "agent-browser": "~0.27.0", @@ -198,6 +197,8 @@ "sherpa-onnx": "1.12.37", "smol-toml": "1.8.0", "ssh2": "^1.17.0", + "stream-chain": "4.2.6", + "stream-json": "3.7.0", "tldts": "7.4.16", "tweetnacl": "^1.0.3", "ws": "^8.22.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2c58ae7a17a..fbf12ea1ae1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -166,7 +166,6 @@ overrides: monaco-editor>dompurify: 3.4.16 patchedDependencies: - '@streamparser/json@0.0.26': b2cf43861e5b4e485e97ffa7449ea65acab4d65dd983ab8c0508f1c68f7d80c9 '@vscode/windows-process-tree@0.8.0': 9da74aa3d17243aa53dcdc95c9f06e97437e7fbccf098aeb017579e2d24cbac2 '@xterm/addon-image@0.10.0-beta.300': e5254a46d6f57bef4a8a19683bfa685afa0ca0127545aea53b54a48104ca3562 '@xterm/addon-ligatures@0.11.0-beta.300': 47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920 @@ -197,9 +196,6 @@ importers: '@parcel/watcher': specifier: ^2.5.6 version: 2.5.6 - '@streamparser/json': - specifier: 0.0.26 - version: 0.0.26(patch_hash=b2cf43861e5b4e485e97ffa7449ea65acab4d65dd983ab8c0508f1c68f7d80c9) '@xterm/addon-serialize': specifier: 0.15.0-beta.300 version: 0.15.0-beta.300(patch_hash=b35533fe252e7e45433150170348889f4e08a6c17f7017ac34ea694d831fec7f)(@xterm/xterm@6.1.0-beta.303(patch_hash=dd0ccc59cd1ccf99f4d76e5aa2456da165fa0804dce19a833d7638bd07ffa393)) @@ -245,6 +241,12 @@ importers: ssh2: specifier: ^1.17.0 version: 1.17.0 + stream-chain: + specifier: 4.2.6 + version: 4.2.6 + stream-json: + specifier: 3.7.0 + version: 3.7.0 tldts: specifier: 7.4.16 version: 7.4.16 @@ -3186,9 +3188,6 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@streamparser/json@0.0.26': - resolution: {integrity: sha512-46597LNFI+MFdUnzX2QJWwmdTRdq0XVD+vVNJTtGVzIrnCuhG9pFo1OAzbNBqci8UJgk/X5KJZ6LcV+y7PTuDQ==} - '@swc/core-darwin-arm64@1.15.46': resolution: {integrity: sha512-IsISIT22EfktVJrlvIpnAxG2u/A9aob9l99HMlx80x72WlFmFPk1V3UhkEzx86eJP8hw049KTFv/RISho2cq2Q==} engines: {node: '>=10'} @@ -7355,6 +7354,12 @@ packages: resolution: {integrity: sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A==} engines: {node: '>=18'} + stream-chain@4.2.6: + resolution: {integrity: sha512-1zeJ8CrtJfmiba26ui8jXkq/xLRFvhzkdH02D5QLO9Cnovgeb28IJxg88DdraWnjnkbOol/++uIAybJbJhk7ig==} + + stream-json@3.7.0: + resolution: {integrity: sha512-rCSBdcBP/bPk6T8QFcxAj1MSzAuc5i49cYW6IE7sYObNEPccBJIUiL6fU9c9BWt40aJK0siVynLX7lWaW8alXw==} + strict-event-emitter@0.5.1: resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==} @@ -10209,8 +10214,6 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@streamparser/json@0.0.26(patch_hash=b2cf43861e5b4e485e97ffa7449ea65acab4d65dd983ab8c0508f1c68f7d80c9)': {} - '@swc/core-darwin-arm64@1.15.46': optional: true @@ -14966,6 +14969,12 @@ snapshots: stdin-discarder@0.3.2: {} + stream-chain@4.2.6: {} + + stream-json@3.7.0: + dependencies: + stream-chain: 4.2.6 + strict-event-emitter@0.5.1: optional: true diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f00e23d7f2f..e3c133b29ee 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -63,4 +63,3 @@ patchedDependencies: lint-staged@16.4.0: config/patches/lint-staged@16.4.0.patch '@vscode/windows-process-tree@0.8.0': config/patches/@vscode__windows-process-tree@0.8.0.patch i18next-cli@1.74.2: config/patches/i18next-cli@1.74.2.patch - '@streamparser/json@0.0.26': config/patches/@streamparser__json@0.0.26.patch diff --git a/src/main/ai-vault/session-document-stream-contract.test.ts b/src/main/ai-vault/session-document-stream-contract.test.ts new file mode 100644 index 00000000000..3ab4053b303 --- /dev/null +++ b/src/main/ai-vault/session-document-stream-contract.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from 'vitest' +import { readStreamedSessionDocument } from './session-document-stream' + +async function* bytes(content: string, chunkSize = 7): AsyncGenerator { + const buffer = Buffer.from(content) + for (let offset = 0; offset < buffer.length; offset += chunkSize) { + yield buffer.subarray(offset, offset + chunkSize) + } +} + +function read( + content: string, + overrides: Partial>[0]> = {} +) { + return readStreamedSessionDocument({ + bytes: bytes(content), + arrayKey: 'messages', + fields: ['id'], + objectFields: { agent: ['model'] }, + create: (): unknown[] => [], + consume: (state, value) => { + state.push(value) + }, + ...overrides + }) +} + +describe('streamed session document contracts', () => { + it.each(['[]', 'null', 'false', '0', '"not an array"', '{}'])( + 'a later messages value %s clears an earlier fold', + async (last) => { + expect(await read(`{"messages":[1,2],"messages":${last}}`)).toEqual({ record: {}, state: [] }) + } + ) + + it('replaces a failed earlier array and continues with its successor', async () => { + const consume = (state: unknown[], value: unknown): void => { + if (value === 'bad') { + throw new Error('discarded failure') + } + state.push(value) + } + expect(await read('{"messages":["bad",1],"messages":[2,3]}', { consume })).toEqual({ + record: {}, + state: [2, 3] + }) + expect(await read('{"messages":["bad"],"messages":[]}', { consume })).toEqual({ + record: {}, + state: [] + }) + }) + + it('keeps the first consumer failure unless a later array replaces it', async () => { + const failure = new Error('consumer failed') + let calls = 0 + await expect( + read('{"messages":[1,2]}', { + consume: () => { + calls++ + throw failure + } + }) + ).rejects.toBe(failure) + expect(calls).toBe(1) + }) + + it('gives malformed trailing JSON precedence over a consumer failure', async () => { + await expect( + read('{"messages":[1]} trailing', { + consume: () => { + throw new Error('consumer') + } + }) + ).rejects.toBeInstanceOf(SyntaxError) + }) + + it('keeps projected object resets and full-field overlap precedence', async () => { + expect(await read('{"agent":{"model":"old"},"agent":null}')).toEqual({ + record: { agent: {} }, + state: [] + }) + expect(await read('{"agent":{"model":"m","extra":[1,2]}}', { fields: ['agent'] })).toEqual({ + record: { agent: { model: 'm', extra: [1, 2] } }, + state: [] + }) + expect(await read('{"messages":[1,2]}', { objectFields: { messages: ['model'] } })).toEqual({ + record: { messages: {} }, + state: [1, 2] + }) + expect( + await read('{"messages":{"model":"m","ignored":1}}', { + objectFields: { messages: ['model'] } + }) + ).toEqual({ record: { messages: { model: 'm' } }, state: [] }) + }) + + it('preserves selected prototype keys as own properties', async () => { + const content = + '{"id":{"__proto__":{"polluted":true}},"agent":{"model":{"constructor":"value","__proto__":{"x":1}}}}' + const parsed = await read(content) + expect(parsed?.record).toEqual(JSON.parse(content)) + expect(Object.getPrototypeOf(parsed?.record)).toBeNull() + expect(Object.prototype).not.toHaveProperty('polluted') + }) + + it.each(['', ' ', '{', '{"messages":[1,]}', '{"messages":[]}{"messages":[]}'])( + 'rejects malformed input %j', + async (content) => { + await expect(read(content)).rejects.toBeInstanceOf(SyntaxError) + } + ) + + it.each(['null', '[]', '123', '"text"'])( + 'does not publish a nonobject document %s', + async (content) => { + expect(await read(content)).toBeNull() + } + ) + + it('validates discarded subtrees and closes their source on malformed input', async () => { + let closed = false + async function* malformed() { + try { + yield Buffer.from('{"ignored":[{"deep":1},]}') + throw new Error('must not request another chunk') + } finally { + closed = true + } + } + await expect(read('', { bytes: malformed() })).rejects.toBeInstanceOf(SyntaxError) + expect(closed).toBe(true) + }) + + it('preserves source failure and closes the source even after a consumer failure', async () => { + const failure = new Error('disk failure') + let closed = false + async function* failing() { + try { + yield Buffer.from('{"messages":[1]') + throw failure + } finally { + closed = true + } + } + await expect( + read('', { + bytes: failing(), + consume: () => { + throw new Error('consumer') + } + }) + ).rejects.toBe(failure) + expect(closed).toBe(true) + }) + + it('preserves escaped astral text across large and byte-sized chunks', async () => { + const value = `${'x'.repeat(65530)}日本語😀\\literal` + const content = JSON.stringify({ messages: [value, '\ud800', '\udc00'] }) + for (const size of [1, 65536, content.length * 4]) { + expect(await read(content, { bytes: bytes(content, size) })).toEqual({ + record: {}, + state: [value, '\ud800', '\udc00'] + }) + } + }) +}) diff --git a/src/main/ai-vault/session-document-stream.ts b/src/main/ai-vault/session-document-stream.ts index 4d12ab0fcc2..4fcf24f7a55 100644 --- a/src/main/ai-vault/session-document-stream.ts +++ b/src/main/ai-vault/session-document-stream.ts @@ -1,6 +1,7 @@ import { StringDecoder } from 'node:string_decoder' -import { JSONParser, TokenizerError, TokenParserError, TokenType } from '@streamparser/json' +import { FlexAssembler, arrayRule, objectRule } from 'stream-json/core/utils/flex-assembler.js' import { setImmediate as yieldToEventLoop } from 'node:timers/promises' +import { createJsonTokenReader } from '../../shared/json-token-reader' import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation' /** Fold one root array while retaining only the root fields the agent parser uses. */ @@ -13,92 +14,108 @@ export async function readStreamedSessionDocument(args: { consume: (state: T, value: unknown) => void signal?: AbortSignal }): Promise<{ record: Record; state: T } | null> { - const parser = new JSONParser({ - paths: [ - ...args.fields.map((field) => `$.${field}`), - ...Object.entries(args.objectFields ?? {}).flatMap(([root, fields]) => - fields.map((field) => `$.${root}.${field}`) - ), - ...(args.arrayKey ? [`$.${args.arrayKey}`, `$.${args.arrayKey}.*`] : []) - ], - keepStack: false, - stringBufferSize: 64 * 1024 - }) const record: Record = Object.create(null) const fields = new Set(args.fields) - let depth = 0 - let expectingRootKey = false - parser.onToken = ({ token, value }) => { - if (depth === 1 && expectingRootKey && token === TokenType.STRING) { - if (typeof value === 'string' && Object.hasOwn(args.objectFields ?? {}, value)) { - record[value] = Object.create(null) - } - expectingRootKey = false - } - if (token === TokenType.LEFT_BRACE || token === TokenType.LEFT_BRACKET) { - if (depth === 0 && token === TokenType.LEFT_BRACE) { - expectingRootKey = true - } - depth++ - } else if (token === TokenType.RIGHT_BRACE || token === TokenType.RIGHT_BRACKET) { - depth-- - } else if (token === TokenType.COMMA && depth === 1) { - expectingRootKey = true - } - } + const objectFields = new Map( + Object.entries(args.objectFields ?? {}).map(([root, keys]) => [root, new Set(keys)]) + ) + const foldedArray = Symbol('folded session array') let state = args.create() - let currentArray: unknown = null let consumeFailure: { error: unknown } | undefined + let inFoldedArray = false + const reset = (): void => { + state = args.create() + consumeFailure = undefined + } + const retain = (path: (string | number)[]): boolean => { + const [root, child] = path + return ( + typeof root === 'string' && + ((root !== args.arrayKey && fields.has(root)) || + (inFoldedArray && root === args.arrayKey && path.length >= 2) || + (typeof child === 'string' && objectFields.get(root)?.has(child) === true)) + ) + } + // Every discarded container needs a rule; dropping only its parent still builds large children. + const discard = { + filter: (path: (string | number)[]) => !retain(path), + create: () => null, + add: () => {} + } + const assembler = new FlexAssembler({ + maxDepth: Infinity, + objectRules: [ + objectRule>({ + filter: (path) => path.length === 0, + create: () => record, + add: (target, key, value) => { + if (key === args.arrayKey) { + if (value !== foldedArray) { + reset() + } + } else if (fields.has(key)) { + target[key] = value + } + } + }), + objectRule>({ + filter: (path) => + path.length === 1 && + typeof path[0] === 'string' && + objectFields.has(path[0]) && + (!fields.has(path[0]) || path[0] === args.arrayKey), + create: (path) => { + const projected: Record = Object.create(null) + record[String(path[0])] = projected + return projected + }, + add: (target, key, value) => { + const root = assembler.path[0] + if (typeof root === 'string' && objectFields.get(root)?.has(key)) { + target[key] = value + } + } + }), + discard + ], + arrayRules: [ + arrayRule({ + filter: (path) => Boolean(args.arrayKey) && path.length === 1 && path[0] === args.arrayKey, + create: () => { + reset() + inFoldedArray = true + return null + }, + add: (_target, value) => { + if (!consumeFailure) { + try { + args.consume(state, value) + } catch (error) { + consumeFailure = { error } + } + } + }, + finalize: () => { + inFoldedArray = false + return foldedArray + } + }), + discard + ] + }) + const parser = createJsonTokenReader((token) => { + if ( + token.name === 'keyValue' && + assembler.depth === 1 && + !assembler.isArray && + objectFields.has(token.value) + ) { + record[token.value] = Object.create(null) + } + assembler.consume(token) + }) const decoder = new StringDecoder('utf8') let objectRoot: boolean | undefined - parser.onValue = ({ key, value, parent, stack }) => { - if (stack.length === 2 && stack[1].key === args.arrayKey && Array.isArray(parent)) { - if (parent !== currentArray) { - state = args.create() - consumeFailure = undefined - currentArray = parent - } - if (!consumeFailure) { - try { - args.consume(state, value) - } catch (error) { - consumeFailure = { error } - } - } - // The parser's array cursor is independent of retained array slots. - parent.pop() - } else if ( - stack.length === 2 && - typeof stack[1].key === 'string' && - typeof key === 'string' && - parent && - !Array.isArray(parent) - ) { - const root = stack[1].key - const projected = record[root] - if ( - Object.hasOwn(args.objectFields ?? {}, root) && - args.objectFields?.[root]?.includes(key) && - projected && - typeof projected === 'object' - ) { - Reflect.set(projected, key, value) - } - } else if (stack.length === 1 && typeof key === 'string') { - if (key === args.arrayKey) { - if (value !== currentArray || !Array.isArray(value)) { - state = args.create() - consumeFailure = undefined - } - currentArray = null - } else if (fields.has(key)) { - record[key] = value - } - if (parent && typeof parent === 'object') { - Reflect.deleteProperty(parent, key) - } - } - } for await (const chunk of args.bytes) { throwIfAiVaultScanCancelled(args.signal) if (objectRoot === undefined) { @@ -107,37 +124,17 @@ export async function readStreamedSessionDocument(args: { objectRoot = first === 123 } } - parseJson(() => parser.write(decoder.write(chunk))) + parser.write(decoder.write(chunk)) await yieldToEventLoop() } const tail = decoder.end() if (tail) { - parseJson(() => parser.write(tail)) - } - if (objectRoot === undefined) { - throw new SyntaxError('Unexpected end of JSON input') - } - if (!parser.isEnded) { - parseJson(() => parser.end(), true) + parser.write(tail) } + parser.end() throwIfAiVaultScanCancelled(args.signal) if (consumeFailure) { throw consumeFailure.error } return objectRoot ? { record, state } : null } - -function parseJson(run: () => void, ending = false): void { - try { - run() - } catch (error) { - if ( - error instanceof TokenizerError || - error instanceof TokenParserError || - (ending && error instanceof Error) - ) { - throw new SyntaxError(error.message) - } - throw error - } -} diff --git a/src/shared/json-token-reader.test.ts b/src/shared/json-token-reader.test.ts new file mode 100644 index 00000000000..9cba3e8a869 --- /dev/null +++ b/src/shared/json-token-reader.test.ts @@ -0,0 +1,47 @@ +import { expect, it } from 'vitest' +import { FlexAssembler } from 'stream-json/core/utils/flex-assembler.js' +import { createJsonTokenReader } from './json-token-reader' + +it.each([1, 7, 64 * 1024, Infinity])('preserves Unicode at chunk size %s', (size) => { + const value = { '\ufeffkey': `\ufeffstart${'\n'.repeat(64 * 1024)}\ufeff😀end` } + const literal = JSON.stringify(value) + for (const text of [literal, literal.replaceAll('\ufeff', '\\uFEFF')]) { + const assembler = new FlexAssembler() + const reader = createJsonTokenReader((token) => { + assembler.consume(token) + }) + for (let offset = 0; offset < text.length; offset += size) { + reader.write(text.slice(offset, offset + size)) + } + reader.end() + expect(assembler.current).toEqual(value) + } +}) + +it.each(['', ' ', '{', '{"a":1,}', '{}{}', '[01]', '\ufeff{}'])( + 'rejects malformed JSON %j as a syntax error', + (text) => { + const reader = createJsonTokenReader(() => {}) + expect(() => { + reader.write(text) + reader.end() + }).toThrow(SyntaxError) + } +) + +it('preserves consumer errors and completes numbers at EOF synchronously', () => { + const values: string[] = [] + const reader = createJsonTokenReader((token) => { + if (token.name === 'numberValue') { + values.push(token.value) + } + }) + reader.write('12') + reader.end() + expect(values).toEqual(['12']) + const failure = new RangeError('consumer failure') + const throwing = createJsonTokenReader(() => { + throw failure + }) + expect(() => throwing.write('{}')).toThrow(failure) +}) diff --git a/src/shared/json-token-reader.ts b/src/shared/json-token-reader.ts new file mode 100644 index 00000000000..c0081ee016f --- /dev/null +++ b/src/shared/json-token-reader.ts @@ -0,0 +1,48 @@ +import parser, { type Token } from 'stream-json/core/parser.js' +import exec from 'stream-chain/exec.js' +import { none } from 'stream-chain/defs.js' + +/** Bounds token batches while preserving caller-owned UTF-8 decoding. */ +export function createJsonTokenReader( + consume: (token: Token) => void, + batchCodeUnits = 64 * 1024 +): { + write: (text: string) => void + end: () => void +} { + if (!Number.isSafeInteger(batchCodeUnits) || batchCodeUnits < 1) { + throw new RangeError('JSON token batch size must be a positive safe integer') + } + const parse = exec(parser({ streamValues: false })) + function run(input: string | typeof none): void { + let consumerFailed = false + try { + const pending = parse(input, (token: Token) => { + try { + consume(token) + } catch (error) { + consumerFailed = true + throw error + } + }) + if (pending) { + throw new Error('JSON token reader requires a synchronous parser') + } + } catch (error) { + if (!consumerFailed && error instanceof Error) { + throw new SyntaxError(error.message) + } + throw error + } + } + return { + write(text) { + for (let offset = 0; offset < text.length; offset += batchCodeUnits) { + run(text.slice(offset, offset + batchCodeUnits)) + } + }, + end() { + run(none) + } + } +} diff --git a/src/shared/ripgrep-dense-match-json.test.ts b/src/shared/ripgrep-dense-match-json.test.ts index 5a4cdd41e29..ec62caddb2f 100644 --- a/src/shared/ripgrep-dense-match-json.test.ts +++ b/src/shared/ripgrep-dense-match-json.test.ts @@ -1,23 +1,6 @@ import { expect, it } from 'vitest' -import { JSONParser } from '@streamparser/json' import { parseDenseRipgrepMatchJson } from './ripgrep-dense-match-json' -it.each([0, 64 * 1024])('preserves literal and escaped BOMs with buffer size %i', (size) => { - const source = { '\ufeffkey': `\ufeffstart${'\n'.repeat(64 * 1024)}\ufeffend` } - const literal = JSON.stringify(source) - for (const record of [literal, literal.replaceAll('\ufeff', '\\uFEFF')]) { - const parser = new JSONParser({ stringBufferSize: size }) - let parsed: unknown - parser.onValue = ({ value, stack }) => { - if (stack.length === 0) { - parsed = value - } - } - parser.write(record) - expect(parsed).toEqual(JSON.parse(record)) - } -}) - it.each(['', '\\', '\n', '\n'.repeat(64 * 1024), `${'x'.repeat(64 * 1024)}\n`])( 'preserves U+FEFF in text and filenames across string-buffer boundaries (%#)', (prefix) => { @@ -32,7 +15,9 @@ it.each(['', '\\', '\n', '\n'.repeat(64 * 1024), `${'x'.repeat(64 * 1024)}\n`])( } } const record = JSON.stringify(source) - expect(parseDenseRipgrepMatchJson(record, 1, 16)).toEqual(JSON.parse(record)) + for (const encoded of [record, record.replaceAll('\ufeff', '\\uFEFF')]) { + expect(parseDenseRipgrepMatchJson(encoded, 1, 16)).toEqual(JSON.parse(record)) + } } ) @@ -76,3 +61,33 @@ it.each(['{}', '{"type":"begin","data":{}}', '{"data":null}', '{"data":[]}'])( expect(projected.data?.submatches).toEqual([]) } ) + +it('validates submatches after reaching the requested result cap', () => { + const source = JSON.stringify({ + type: 'match', + data: { + submatches: [ + { start: 0, end: 1 }, + { start: null, end: 2 } + ] + } + }) + expect(() => parseDenseRipgrepMatchJson(source, 1, 16)).toThrow('Invalid rg submatch') +}) + +it('rejects duplicate coordinate fields whose final value is not numeric', () => { + const source = '{"data":{"submatches":[{"start":0,"start":{},"end":1}]}}' + expect(() => parseDenseRipgrepMatchJson(source, 1, 16)).toThrow('Invalid rg submatch') +}) + +it.each([16_378, 16_379])('retains the existing per-element token budget at %i values', (count) => { + const source = JSON.stringify({ + data: { submatches: [{ start: 0, end: 1, other: Array(count).fill(0) }] } + }) + const parse = (): unknown => parseDenseRipgrepMatchJson(source, 1, 16) + if (count === 16_378) { + expect(parse()).toMatchObject({ data: { submatches: [{ start: 0, end: 1 }] } }) + } else { + expect(parse).toThrow('rg submatch structure exceeds limit') + } +}) diff --git a/src/shared/ripgrep-dense-match-json.ts b/src/shared/ripgrep-dense-match-json.ts index 821415a0748..5b54069fb10 100644 --- a/src/shared/ripgrep-dense-match-json.ts +++ b/src/shared/ripgrep-dense-match-json.ts @@ -3,7 +3,7 @@ import { JsonTextStructureCapacityError, type JsonTextStructureLimits } from './json-text-structure-limit' -import { JSONParser, TokenType } from '@streamparser/json' +import { createJsonTokenReader } from './json-token-reader' export type RipgrepMatchMessage = { type?: string @@ -34,102 +34,132 @@ export function parseRipgrepMatchJson( } } +type RipgrepJsonFrame = { + kind: 'object' | 'array' + context: 'root' | 'data' | 'path' | 'lines' | 'matches' | 'match' | null + key?: string + values: number + keys?: Set + start?: number + end?: number +} + /** Dense rg records retain only the requested ranges while validating the entire record. */ export function parseDenseRipgrepMatchJson( line: string, maxMatches: number, nestingDepth: number ): RipgrepMatchMessage { - const parser = new JSONParser({ - paths: [ - '$.type', - '$.data.path.text', - '$.data.lines.text', - '$.data.lines.bytes', - '$.data.line_number', - '$.data.submatches.*' - ], - keepStack: false, - stringBufferSize: 64 * 1024 - }) const data: NonNullable = { submatches: [] } const result: RipgrepMatchMessage = { data } - const containers: { object: boolean; expectingKey: boolean; keys?: Set }[] = [] + const frames: RipgrepJsonFrame[] = [] let elementTokens = 0 - parser.onToken = ({ token, value }) => { - const current = containers.at(-1) - if (current?.object && current.expectingKey && token === TokenType.STRING) { - if (typeof value !== 'string') { - throw new SyntaxError('Invalid rg object key') - } - if (current.keys?.has(value)) { - throw new SyntaxError('Duplicate rg object key') - } - current.keys?.add(value) - if ((current.keys?.size ?? 0) > 128) { - throw new Error('Too many rg record fields') - } - current.expectingKey = false - } - if (token === TokenType.LEFT_BRACE || token === TokenType.LEFT_BRACKET) { - containers.push({ - object: token === TokenType.LEFT_BRACE, - expectingKey: token === TokenType.LEFT_BRACE, - // rg's envelope keys are unique; reject duplicates instead of mixing projections. - keys: containers.length < 2 ? new Set() : undefined - }) - if (containers.length > nestingDepth) { - throw new Error('rg record nesting exceeds limit') - } - if (containers.length === 4) { - elementTokens = 0 - } - } else if (token === TokenType.RIGHT_BRACE || token === TokenType.RIGHT_BRACKET) { - containers.pop() - } else if (token === TokenType.COMMA && current?.object) { - current.expectingKey = true - } - if (containers.length >= 4 && ++elementTokens > 32 * 1024) { + const countTokens = (amount = 1): void => { + if (frames.length >= 4 && (elementTokens += amount) > 32 * 1024) { throw new Error('rg submatch structure exceeds limit') } } - parser.onValue = ({ key, value, parent, stack }) => { - if (stack.length === 1 && key === 'type' && typeof value === 'string') { - result.type = value - } else if (stack.length === 2 && stack[1].key === 'data' && key === 'line_number') { - if (typeof value === 'number') { - data.line_number = value + const parser = createJsonTokenReader((token) => { + const current = frames.at(-1) + if (token.name === 'keyValue') { + if (!current || current.kind !== 'object') { + throw new SyntaxError('Unexpected rg object key') } - } else if (stack.length === 3 && stack[1].key === 'data') { - if (stack[2].key === 'submatches' && Array.isArray(parent)) { - if ( - !value || - typeof value !== 'object' || - Array.isArray(value) || - typeof value.start !== 'number' || - typeof value.end !== 'number' - ) { + if (current.values++ > 0) { + countTokens() + } + // Packed tokens omit commas and colons; include them in the existing structure budget. + countTokens(2) + current.key = token.value + if (current.keys?.has(token.value)) { + throw new SyntaxError('Duplicate rg object key') + } + current.keys?.add(token.value) + if ((current.keys?.size ?? 0) > 128) { + throw new Error('Too many rg record fields') + } + return + } + if (token.name === 'endObject' || token.name === 'endArray') { + const frame = frames.pop() + countTokens() + if (frame?.context === 'match') { + if (typeof frame.start !== 'number' || typeof frame.end !== 'number') { throw new SyntaxError('Invalid rg submatch') } if (data.submatches && data.submatches.length < maxMatches) { - data.submatches.push({ start: value.start, end: value.end }) - } - parent.pop() - } else if (stack[2].key === 'path' && key === 'text' && typeof value === 'string') { - data.path = { text: value } - } else if (stack[2].key === 'lines' && typeof value === 'string') { - if (key === 'text') { - data.lines = { ...data.lines, text: value } - } - if (key === 'bytes') { - data.lines = { ...data.lines, bytes: value } + data.submatches.push({ start: frame.start, end: frame.end }) } } + return } - } + if (current?.kind === 'array' && current.values++ > 0) { + countTokens() + } + if (current?.context === 'match' && (current.key === 'start' || current.key === 'end')) { + current[current.key] = undefined + } + if (token.name === 'startObject' || token.name === 'startArray') { + const kind = token.name === 'startObject' ? 'object' : 'array' + let context: RipgrepJsonFrame['context'] = null + if (!current && kind === 'object') { + context = 'root' + } else if (current?.context === 'root' && current.key === 'data' && kind === 'object') { + context = 'data' + } else if (current?.context === 'data') { + if ((current.key === 'lines' || current.key === 'path') && kind === 'object') { + context = current.key + } + if (current.key === 'submatches' && kind === 'array') { + context = 'matches' + } + } else if (current?.context === 'matches') { + if (kind !== 'object') { + throw new SyntaxError('Invalid rg submatch') + } + context = 'match' + } + frames.push({ + kind, + context, + values: 0, + keys: kind === 'object' && frames.length < 2 ? new Set() : undefined + }) + if (frames.length > nestingDepth) { + throw new Error('rg record nesting exceeds limit') + } + if (frames.length === 4) { + elementTokens = 0 + } + countTokens() + return + } + countTokens() + if (current?.context === 'matches') { + throw new SyntaxError('Invalid rg submatch') + } + if (token.name === 'numberValue') { + const value = Number(token.value) + if (current?.context === 'match' && (current.key === 'start' || current.key === 'end')) { + current[current.key] = value + } + if (current?.context === 'data' && current.key === 'line_number') { + data.line_number = value + } + } else if (token.name === 'stringValue') { + if (current?.context === 'root' && current.key === 'type') { + result.type = token.value + } + if (current?.context === 'path' && current.key === 'text') { + data.path = { text: token.value } + } + if (current?.context === 'lines' && (current.key === 'text' || current.key === 'bytes')) { + data.lines ??= {} + data.lines[current.key] = token.value + } + } + }, 8 * 1024) parser.write(line) - if (!parser.isEnded) { - parser.end() - } + parser.end() return result } From cbe64383dc3dfce6090cd4740b3ecb9450517cf4 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 4 Oct 2026 13:19:34 -0700 Subject: [PATCH 65/67] Reuse source-line calculations for Markdown review selections (#25173) * Reuse source-line calculations for Markdown review selections * Use typed editor probes in review selection performance coverage --- .../rich-markdown-comment-blocks.test.ts | 216 ++++++++++++++++++ .../editor/rich-markdown-comment-blocks.ts | 82 +++++++ .../rich-markdown-review-annotations.ts | 76 +----- .../rich-markdown-review-note-positioning.ts | 4 +- ...ich-markdown-review-rail-benchmark.test.ts | 3 +- .../rich-markdown-review-rail-blocks.ts | 32 +-- ...kdown-review-selection-performance.spec.ts | 209 +++++++++++++++++ 7 files changed, 524 insertions(+), 98 deletions(-) create mode 100644 src/renderer/src/components/editor/rich-markdown-comment-blocks.test.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-comment-blocks.ts create mode 100644 tests/e2e/markdown-review-selection-performance.spec.ts diff --git a/src/renderer/src/components/editor/rich-markdown-comment-blocks.test.ts b/src/renderer/src/components/editor/rich-markdown-comment-blocks.test.ts new file mode 100644 index 00000000000..a416d6719f0 --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-comment-blocks.test.ts @@ -0,0 +1,216 @@ +// @vitest-environment happy-dom +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Editor } from '@tiptap/core' +import type { DiffComment } from '../../../../shared/diff-comment-types' +import { createRichMarkdownExtensions } from './rich-markdown-extensions' +import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport' +import { + buildRichMarkdownCommentBlocks, + getRichMarkdownCommentBlocks +} from './rich-markdown-comment-blocks' +import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html' +import { + createRichMarkdownHtmlSuperscriptLinkContext, + type RichMarkdownHtmlSuperscriptLinkContext +} from './rich-markdown-html-superscript-link-context' +import { + getRichMarkdownAnnotationHighlightRanges, + getRichMarkdownAnnotationHighlightRangesForComment, + getRichMarkdownAnnotationTarget, + getRichMarkdownCommentAtPos +} from './rich-markdown-review-annotations' +import { getRichMarkdownReviewRailBlocks } from './rich-markdown-review-rail-blocks' + +const editors: Editor[] = [] +const SOURCE = 'First paragraph\n\n```ts\none\n\ntwo\n```\n\nLast paragraph' + +function createEditor(source = SOURCE, context?: RichMarkdownHtmlSuperscriptLinkContext) { + const root = document.createElement('div') + document.body.append(root) + const codec = createRichMarkdownEditorCodec() + const editor = new Editor({ + element: root, + extensions: createRichMarkdownExtensions({ + codec, + htmlSuperscriptLinks: Boolean(context), + htmlSuperscriptLinkContext: context + }), + content: encodeRawMarkdownHtmlForRichEditor(source, codec, { + htmlSuperscriptLinks: Boolean(context) + }), + contentType: 'markdown' + }) + editors.push(editor) + editor.view.dispatch(editor.state.tr.setMeta('addToHistory', false)) + vi.spyOn(root, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 0, 600, 400)) + return { editor, root } +} + +function selectLastParagraph(editor: Editor, characters = 4) { + let from: number | undefined + editor.state.doc.forEach((node, offset) => { + if (node.textContent === 'Last paragraph') { + from = offset + 1 + } + }) + if (from === undefined) { + throw new Error('Last paragraph missing') + } + editor.commands.setTextSelection({ from, to: from + characters }) + const paragraph = Array.from(editor.view.dom.querySelectorAll('p')).find( + (element) => element.textContent === 'Last paragraph' + ) + const text = paragraph?.firstChild + if (!(text instanceof Text)) { + throw new Error('Last paragraph text missing') + } + const range = document.createRange() + range.setStart(text, 0) + range.setEnd(text, characters) + window.getSelection()?.removeAllRanges() + window.getSelection()?.addRange(range) + return from +} + +function comment(lineNumber = 29): DiffComment { + return { + id: 'note', + worktreeId: 'workspace', + filePath: 'notes.md', + source: 'markdown', + lineNumber, + selectedText: 'Last', + body: 'Review', + createdAt: 1, + side: 'modified' + } +} + +afterEach(() => { + for (const editor of editors.splice(0)) { + editor.destroy() + } + window.getSelection()?.removeAllRanges() + document.body.replaceChildren() + vi.restoreAllMocks() +}) + +describe('Markdown review source block reuse', () => { + it('preserves source lines when the document interaction host changes', () => { + const context = createRichMarkdownHtmlSuperscriptLinkContext({ + sourceFilePath: '/repo/notes.md', + worktreeId: 'workspace', + worktreeRoot: '/repo', + sourceOwner: { kind: 'local' } + }) + const { editor } = createEditor( + 'First [1]\n\nLast paragraph', + context + ) + const blocks = getRichMarkdownCommentBlocks(editor) + const doc = editor.state.doc + const serialize = vi.spyOn(editor.markdown!, 'serialize') + const instrumentedBlocks = getRichMarkdownCommentBlocks(editor) + serialize.mockClear() + context.update({ + sourceFilePath: '/remote/notes.md', + worktreeId: 'remote-workspace', + worktreeRoot: '/remote', + sourceOwner: { kind: 'ssh', connectionId: 'connection' } + }) + expect(editor.state.doc).toBe(doc) + expect(getRichMarkdownCommentBlocks(editor)).toBe(instrumentedBlocks) + expect(serialize).not.toHaveBeenCalled() + serialize.mockRestore() + expect(buildRichMarkdownCommentBlocks(editor)).toEqual(blocks) + }) + + it('shares one source map across highlights, comment clicks, selection targets and the rail', () => { + const { editor, root } = createEditor() + const from = selectLastParagraph(editor) + vi.spyOn(Range.prototype, 'getBoundingClientRect').mockReturnValue(new DOMRect(10, 20, 80, 20)) + const serialize = vi.spyOn(editor.markdown!, 'serialize') + const json = vi.spyOn(editor, 'getJSON') + const note = comment() + const expected = [{ from, to: from + 4 }] + + expect(getRichMarkdownAnnotationHighlightRanges(editor, [note], 20)).toEqual(expected) + expect(serialize).toHaveBeenCalledTimes(2 * editor.state.doc.childCount - 1) + expect(json).toHaveBeenCalledTimes(1) + serialize.mockClear() + json.mockClear() + + for (let index = 0; index < 20; index++) { + expect(getRichMarkdownAnnotationHighlightRanges(editor, [note], 20)).toEqual(expected) + expect(getRichMarkdownAnnotationHighlightRangesForComment(editor, note, 20)).toEqual(expected) + expect(getRichMarkdownCommentAtPos(editor, [note], 20, from + 2)).toBe(note) + expect(getRichMarkdownAnnotationTarget(editor, root)).toMatchObject({ + from, + to: from + 4, + selectedText: 'Last', + lineNumber: 9 + }) + expect(getRichMarkdownReviewRailBlocks(editor)).toBe(getRichMarkdownCommentBlocks(editor)) + } + expect(serialize).not.toHaveBeenCalled() + expect(json).not.toHaveBeenCalled() + }) + + it('updates selected text and viewport geometry without rebuilding source lines', () => { + const { editor, root } = createEditor() + const rect = vi.spyOn(Range.prototype, 'getBoundingClientRect') + rect.mockReturnValue(new DOMRect(10, 20, 80, 20)) + selectLastParagraph(editor) + const serialize = vi.spyOn(editor.markdown!, 'serialize') + expect(getRichMarkdownAnnotationTarget(editor, root)).toMatchObject({ + selectedText: 'Last', + buttonTop: 48, + lineNumber: 9 + }) + serialize.mockClear() + const doc = editor.state.doc + selectLastParagraph(editor, 9) + rect.mockReturnValue(new DOMRect(10, 120, 140, 20)) + expect(getRichMarkdownAnnotationTarget(editor, root)).toMatchObject({ + selectedText: 'Last para', + buttonTop: 148, + lineNumber: 9 + }) + expect(editor.state.doc).toBe(doc) + expect(serialize).not.toHaveBeenCalled() + }) + + it('rebuilds source lines after editing a multiline block and undoing it', () => { + const { editor, root } = createEditor() + vi.spyOn(Range.prototype, 'getBoundingClientRect').mockReturnValue(new DOMRect(10, 20, 80, 20)) + selectLastParagraph(editor) + expect(getRichMarkdownAnnotationTarget(editor, root)?.lineNumber).toBe(9) + const serialize = vi.spyOn(editor.markdown!, 'serialize') + let codeFrom: number | undefined + editor.state.doc.forEach((node, offset) => { + if (node.type.name === 'codeBlock') { + codeFrom = offset + 1 + } + }) + if (codeFrom === undefined) { + throw new Error('Code block missing') + } + editor.view.dispatch(editor.state.tr.insertText('new\n', codeFrom)) + selectLastParagraph(editor) + expect(getRichMarkdownAnnotationTarget(editor, root)?.lineNumber).toBe(10) + expect(serialize).toHaveBeenCalledTimes(2 * editor.state.doc.childCount - 1) + expect( + getRichMarkdownCommentAtPos(editor, [comment(30)], 20, editor.state.selection.from) + ).toEqual(comment(30)) + expect(serialize).toHaveBeenCalledTimes(2 * editor.state.doc.childCount - 1) + serialize.mockClear() + + expect(editor.commands.undo()).toBe(true) + selectLastParagraph(editor) + expect(getRichMarkdownAnnotationTarget(editor, root)?.lineNumber).toBe(9) + expect(serialize).toHaveBeenCalledTimes(2 * editor.state.doc.childCount - 1) + serialize.mockClear() + expect(getRichMarkdownAnnotationTarget(editor, root)?.lineNumber).toBe(9) + expect(serialize).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/editor/rich-markdown-comment-blocks.ts b/src/renderer/src/components/editor/rich-markdown-comment-blocks.ts new file mode 100644 index 00000000000..261b982f0bd --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-comment-blocks.ts @@ -0,0 +1,82 @@ +import type { Editor, JSONContent } from '@tiptap/core' +import { countRichMarkdownReviewMarkdownLines } from './rich-markdown-review-line-count' + +export type RichMarkdownCommentBlock = { + key: string + startLine: number + endLine: number + from: number + to: number +} + +function serializeRichMarkdownJson(editor: Editor, content: JSONContent[]): string { + return (editor.markdown?.serialize({ type: 'doc', content }) ?? '').trimEnd() +} + +export function buildRichMarkdownCommentBlocks(editor: Editor): RichMarkdownCommentBlock[] { + const jsonContent = editor.getJSON().content ?? [] + const blocks: RichMarkdownCommentBlock[] = [] + let nextLine = 1 + let previousNodeJson: JSONContent | null = null + let previousNodeLineCount = 0 + + editor.state.doc.forEach((node, nodeOffset, index) => { + const nodeJson = jsonContent[index] + if (!nodeJson) { + return + } + const nodeMarkdown = serializeRichMarkdownJson(editor, [nodeJson]) + const nodeLineCount = countRichMarkdownReviewMarkdownLines(nodeMarkdown) + if (previousNodeJson) { + const pairMarkdown = serializeRichMarkdownJson(editor, [previousNodeJson, nodeJson]) + const separatorLineCount = Math.max( + 0, + countRichMarkdownReviewMarkdownLines(pairMarkdown) - previousNodeLineCount - nodeLineCount + ) + nextLine += separatorLineCount + } + const startLine = nextLine + const endLine = Math.max(startLine, startLine + nodeLineCount - 1) + const from = nodeOffset + 1 + blocks.push({ + key: `${index}:${startLine}-${endLine}`, + startLine, + endLine, + from, + to: from + Math.max(0, node.nodeSize - 1) + }) + nextLine = endLine + 1 + previousNodeJson = nodeJson + previousNodeLineCount = nodeLineCount + }) + + if (blocks.length === 0) { + blocks.push({ key: 'empty:1-1', startLine: 1, endLine: 1, from: 1, to: 1 }) + } + + return blocks +} + +type RichMarkdownCommentBlocksSnapshot = { + doc: Editor['state']['doc'] + markdown: Editor['markdown'] + serialize: NonNullable['serialize'] | undefined + blocks: readonly RichMarkdownCommentBlock[] +} + +// Keep only the current document per editor; scrolling changes geometry, not source lines. +const blocksByEditor = new WeakMap() + +export function getRichMarkdownCommentBlocks(editor: Editor): readonly RichMarkdownCommentBlock[] { + const doc = editor.state.doc + const markdown = editor.markdown + const serialize = markdown?.serialize + const cached = blocksByEditor.get(editor) + if (cached?.doc === doc && cached.markdown === markdown && cached.serialize === serialize) { + return cached.blocks + } + + const blocks = buildRichMarkdownCommentBlocks(editor) + blocksByEditor.set(editor, { doc, markdown, serialize, blocks }) + return blocks +} diff --git a/src/renderer/src/components/editor/rich-markdown-review-annotations.ts b/src/renderer/src/components/editor/rich-markdown-review-annotations.ts index 90bd170a5c1..f466a38b321 100644 --- a/src/renderer/src/components/editor/rich-markdown-review-annotations.ts +++ b/src/renderer/src/components/editor/rich-markdown-review-annotations.ts @@ -1,6 +1,5 @@ import type { Dispatch, SetStateAction } from 'react' import type { Editor } from '@tiptap/react' -import type { JSONContent } from '@tiptap/core' import type { DiffComment } from '../../../../shared/diff-comment-types' import type { RichMarkdownAnnotationHighlightRange } from './rich-markdown-annotation-highlight' import { @@ -10,7 +9,14 @@ import { import type { RichMarkdownReviewNotePosition } from './rich-markdown-review-note-layout' import { findRichMarkdownSelectedTextRanges } from './rich-markdown-review-text-ranges' import { getRichMarkdownSelectionVisibleText } from './rich-markdown-visible-text-map' -import { countRichMarkdownReviewMarkdownLines } from './rich-markdown-review-line-count' +import { + getRichMarkdownCommentBlocks, + type RichMarkdownCommentBlock +} from './rich-markdown-comment-blocks' +export { + buildRichMarkdownCommentBlocks, + type RichMarkdownCommentBlock +} from './rich-markdown-comment-blocks' export { countRichMarkdownReviewMarkdownLines } from './rich-markdown-review-line-count' const RICH_MARKDOWN_ANNOTATION_BUTTON_SIZE_PX = 24 @@ -22,14 +28,6 @@ const RICH_MARKDOWN_ANNOTATION_POPOVER_WIDTH_PX = 420 const RICH_MARKDOWN_ANNOTATION_POPOVER_RIGHT_OFFSET_PX = 24 const RICH_MARKDOWN_ANNOTATION_POPOVER_MIN_HEIGHT_PX = 220 -export type RichMarkdownCommentBlock = { - key: string - startLine: number - endLine: number - from: number - to: number -} - export type RichMarkdownComposerState = { lineNumber: number startLine?: number @@ -45,54 +43,6 @@ export type RichMarkdownAnnotationTarget = RichMarkdownComposerState & { buttonLeft: number } -function serializeRichMarkdownJson(editor: Editor, content: JSONContent[]): string { - return (editor.markdown?.serialize({ type: 'doc', content }) ?? '').trimEnd() -} - -export function buildRichMarkdownCommentBlocks(editor: Editor): RichMarkdownCommentBlock[] { - const jsonContent = editor.getJSON().content ?? [] - const blocks: RichMarkdownCommentBlock[] = [] - let nextLine = 1 - let previousNodeJson: JSONContent | null = null - let previousNodeLineCount = 0 - - editor.state.doc.forEach((node, nodeOffset, index) => { - const nodeJson = jsonContent[index] - if (!nodeJson) { - return - } - const nodeMarkdown = serializeRichMarkdownJson(editor, [nodeJson]) - const nodeLineCount = countRichMarkdownReviewMarkdownLines(nodeMarkdown) - if (previousNodeJson) { - const pairMarkdown = serializeRichMarkdownJson(editor, [previousNodeJson, nodeJson]) - const separatorLineCount = Math.max( - 0, - countRichMarkdownReviewMarkdownLines(pairMarkdown) - previousNodeLineCount - nodeLineCount - ) - nextLine += separatorLineCount - } - const startLine = nextLine - const endLine = Math.max(startLine, startLine + nodeLineCount - 1) - const from = nodeOffset + 1 - blocks.push({ - key: `${index}:${startLine}-${endLine}`, - startLine, - endLine, - from, - to: from + Math.max(0, node.nodeSize - 1) - }) - nextLine = endLine + 1 - previousNodeJson = nodeJson - previousNodeLineCount = nodeLineCount - }) - - if (blocks.length === 0) { - blocks.push({ key: 'empty:1-1', startLine: 1, endLine: 1, from: 1, to: 1 }) - } - - return blocks -} - export function clampRichMarkdownAnnotationTarget( editor: Editor, target: RichMarkdownAnnotationTarget @@ -122,8 +72,7 @@ export function getRichMarkdownAnnotationHighlightRanges( if (comments.length === 0) { return [] } - // Why once: block resolution re-serializes the doc; per comment it was O(n*doc). - const blocks = buildRichMarkdownCommentBlocks(editor) + const blocks = getRichMarkdownCommentBlocks(editor) return comments.flatMap((comment) => getRichMarkdownAnnotationHighlightRangesForComment( editor, @@ -138,10 +87,9 @@ export function getRichMarkdownAnnotationHighlightRangesForComment( editor: Editor, comment: DiffComment, markdownSourceLineOffset: number, - // Why optional: callers looping over comments pass one shared build. prebuiltBlocks?: readonly RichMarkdownCommentBlock[] ): RichMarkdownAnnotationHighlightRange[] { - const blocks = prebuiltBlocks ?? buildRichMarkdownCommentBlocks(editor) + const blocks = prebuiltBlocks ?? getRichMarkdownCommentBlocks(editor) const selectedText = comment.selectedText?.trim() if (!selectedText) { return [] @@ -173,7 +121,7 @@ export function getRichMarkdownCommentAtPos( if (comments.length === 0) { return null } - const blocks = buildRichMarkdownCommentBlocks(editor) + const blocks = getRichMarkdownCommentBlocks(editor) return ( comments.find((comment) => getRichMarkdownAnnotationHighlightRangesForComment( @@ -216,7 +164,7 @@ export function getRichMarkdownCommentAnchorTop( } function getRichMarkdownSelectionRange(editor: Editor): RichMarkdownComposerState { - const blocks = buildRichMarkdownCommentBlocks(editor) + const blocks = getRichMarkdownCommentBlocks(editor) const { from, to, empty } = editor.state.selection const selectedBlocks = empty ? blocks.filter((block) => block.from <= from && from <= block.to) diff --git a/src/renderer/src/components/editor/rich-markdown-review-note-positioning.ts b/src/renderer/src/components/editor/rich-markdown-review-note-positioning.ts index 4fdc12a51b9..b7cc8bc63e2 100644 --- a/src/renderer/src/components/editor/rich-markdown-review-note-positioning.ts +++ b/src/renderer/src/components/editor/rich-markdown-review-note-positioning.ts @@ -1,7 +1,7 @@ import type { Editor } from '@tiptap/react' import type { DiffComment } from '../../../../shared/diff-comment-types' import { getRichMarkdownCommentAnchorTop } from './rich-markdown-review-annotations' -import { getRichMarkdownReviewRailBlocks } from './rich-markdown-review-rail-blocks' +import { getRichMarkdownCommentBlocks } from './rich-markdown-comment-blocks' import { stackRichMarkdownReviewNotePositions, type RichMarkdownReviewNotePosition @@ -21,7 +21,7 @@ export function measureRichMarkdownReviewNotePositions({ markdownSourceLineOffset }: MeasureRichMarkdownReviewNotePositionsOptions): RichMarkdownReviewNotePosition[] { const containerRect = container.getBoundingClientRect() - const blocks = getRichMarkdownReviewRailBlocks(editor) + const blocks = getRichMarkdownCommentBlocks(editor) const nextPositions = markdownComments .map((comment): RichMarkdownReviewNotePosition | null => { const bodyLineNumber = Math.max(1, comment.lineNumber - markdownSourceLineOffset) diff --git a/src/renderer/src/components/editor/rich-markdown-review-rail-benchmark.test.ts b/src/renderer/src/components/editor/rich-markdown-review-rail-benchmark.test.ts index 7090769f71f..d64fcc667b8 100644 --- a/src/renderer/src/components/editor/rich-markdown-review-rail-benchmark.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-review-rail-benchmark.test.ts @@ -48,7 +48,8 @@ function measureBaseline({ block, containerRect, container.scrollTop, - markdownSourceLineOffset + markdownSourceLineOffset, + buildRichMarkdownCommentBlocks(editor) ) return top === null ? null : { comment, top } }) diff --git a/src/renderer/src/components/editor/rich-markdown-review-rail-blocks.ts b/src/renderer/src/components/editor/rich-markdown-review-rail-blocks.ts index 35dd1bb9eb5..9cb8bf0b64d 100644 --- a/src/renderer/src/components/editor/rich-markdown-review-rail-blocks.ts +++ b/src/renderer/src/components/editor/rich-markdown-review-rail-blocks.ts @@ -1,31 +1 @@ -import type { Editor } from '@tiptap/core' -import { - buildRichMarkdownCommentBlocks, - type RichMarkdownCommentBlock -} from './rich-markdown-review-annotations' - -type ReviewRailBlocks = { - doc: Editor['state']['doc'] - markdown: Editor['markdown'] - serialize: NonNullable['serialize'] | undefined - blocks: readonly RichMarkdownCommentBlock[] -} - -// Keep only the current document per editor; scrolling changes geometry, not source lines. -const blocksByEditor = new WeakMap() - -export function getRichMarkdownReviewRailBlocks( - editor: Editor -): readonly RichMarkdownCommentBlock[] { - const doc = editor.state.doc - const markdown = editor.markdown - const serialize = markdown?.serialize - const cached = blocksByEditor.get(editor) - if (cached?.doc === doc && cached.markdown === markdown && cached.serialize === serialize) { - return cached.blocks - } - - const blocks = buildRichMarkdownCommentBlocks(editor) - blocksByEditor.set(editor, { doc, markdown, serialize, blocks }) - return blocks -} +export { getRichMarkdownCommentBlocks as getRichMarkdownReviewRailBlocks } from './rich-markdown-comment-blocks' diff --git a/tests/e2e/markdown-review-selection-performance.spec.ts b/tests/e2e/markdown-review-selection-performance.spec.ts new file mode 100644 index 00000000000..a9ee0035599 --- /dev/null +++ b/tests/e2e/markdown-review-selection-performance.spec.ts @@ -0,0 +1,209 @@ +import type { Locator, Page } from '@stablyai/playwright-test' +import type { Editor, JSONContent } from '@tiptap/core' +import type { MarkdownManager } from '@tiptap/markdown' +import { expect, test } from './helpers/orca-app' +import { + cleanupMarkdownFixture, + createMarkdownFixture, + getActiveWorktreeContext, + openMarkdownFixture, + waitForRichMarkdownEditor +} from './helpers/markdown-editor-fixture' + +const PARAGRAPH_COUNT = Number(process.env.ORCA_MARKDOWN_REVIEW_PERF_PARAGRAPHS ?? '500') +const targetIndex = Math.floor(PARAGRAPH_COUNT / 2) +const paragraph = (index: number) => + `Paragraph ${index}. Ordinary editing and selection of plain prose.` +const SOURCE = Array.from({ length: PARAGRAPH_COUNT }, (_, index) => paragraph(index)).join('\n\n') + +type ReviewSelectionMetrics = { + calls: number + jsonCalls: number + serializeMs: number + jsonMs: number + frameGaps: number[] +} + +type ReviewSelectionProbe = { + snapshot: () => ReviewSelectionMetrics + reset: () => void + restore: () => void +} + +type PageRichMarkdownReviewEditorElement = HTMLElement & { + editor?: Editor & { markdown?: MarkdownManager } + __reviewSelectionProbe?: ReviewSelectionProbe +} + +async function frames(page: Page) { + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())) + ) + ) +} + +async function measure(editor: Locator, reset = false): Promise { + return editor.evaluate((element, reset) => { + const editorElement = + element.closest('.rich-markdown-editor') + const probe = editorElement?.__reviewSelectionProbe + if (!probe) { + throw new Error('Review selection probe missing') + } + if (reset) { + probe.reset() + } + return probe.snapshot() + }, reset) +} + +test('selection and scrolling reuse Markdown review source lines', async ({ + orcaPage, + registerPostElectronShutdownCleanup +}, testInfo) => { + test.setTimeout(180_000) + expect(Buffer.byteLength(SOURCE)).toBeLessThan(600 * 1024) + const context = await getActiveWorktreeContext(orcaPage) + const filePath = await createMarkdownFixture( + context, + '.orca-e2e-markdown-review-perf', + 'review-selection', + testInfo.workerIndex, + SOURCE + ) + registerPostElectronShutdownCleanup(() => cleanupMarkdownFixture(filePath)) + await openMarkdownFixture(orcaPage, context, filePath) + const editor = await waitForRichMarkdownEditor(orcaPage) + await expect(editor.locator('p')).toHaveCount(PARAGRAPH_COUNT, { timeout: 60_000 }) + await editor.evaluate((element) => { + const editorElement = + element.closest('.rich-markdown-editor') + const instance = editorElement?.editor + if (!editorElement || !instance) { + throw new Error('Editor unavailable') + } + const markdown = instance.markdown + if (!markdown) { + throw new Error('Markdown manager missing') + } + const serialize = markdown.serialize + const getJSON = instance.getJSON + let calls = 0 + let jsonCalls = 0 + let serializeMs = 0 + let jsonMs = 0 + let frameGaps: number[] = [] + let previousFrame: number | null = null + let frameId = 0 + const tick = (now: number) => { + if (previousFrame !== null) { + frameGaps.push(now - previousFrame) + } + previousFrame = now + frameId = requestAnimationFrame(tick) + } + frameId = requestAnimationFrame(tick) + markdown.serialize = (content: JSONContent): string => { + const start = performance.now() + try { + calls++ + return serialize.call(markdown, content) + } finally { + serializeMs += performance.now() - start + } + } + instance.getJSON = (): ReturnType => { + const start = performance.now() + try { + jsonCalls++ + return getJSON.call(instance) + } finally { + jsonMs += performance.now() - start + } + } + editorElement.__reviewSelectionProbe = { + snapshot: () => ({ calls, jsonCalls, serializeMs, jsonMs, frameGaps }), + reset: () => { + calls = 0 + jsonCalls = 0 + serializeMs = 0 + jsonMs = 0 + frameGaps = [] + previousFrame = null + }, + restore: () => { + cancelAnimationFrame(frameId) + markdown.serialize = serialize + instance.getJSON = getJSON + } + } + }) + try { + const target = editor.getByText(paragraph(targetIndex), { exact: true }) + await target.evaluate((element) => element.scrollIntoView({ block: 'center' })) + const point = await target.evaluate((element) => { + const text = element.firstChild + if (!(text instanceof Text)) { + throw new Error('Paragraph text missing') + } + const range = document.createRange() + range.setStart(text, 0) + range.collapse(true) + const rect = range.getBoundingClientRect() + return { x: rect.left, y: rect.top + rect.height / 2 } + }) + await orcaPage.mouse.click(point.x, point.y) + await orcaPage.keyboard.press('Shift+ArrowRight') + await expect( + orcaPage.getByRole('button', { name: 'Add review note', exact: true }) + ).toBeVisible() + await frames(orcaPage) + const cold = await measure(editor) + await measure(editor, true) + for (let index = 0; index < 20; index++) { + await orcaPage.keyboard.press('Shift+ArrowRight') + await frames(orcaPage) + } + const selection = await measure(editor) + expect(await orcaPage.evaluate(() => window.getSelection()?.toString())).toBe( + paragraph(targetIndex).slice(0, 21) + ) + const viewport = orcaPage.locator('.rich-markdown-editor-shell .overflow-auto') + await measure(editor, true) + for (let index = 0; index < 10; index++) { + await viewport.evaluate((element, index) => { + element.scrollTop += index % 2 ? -8 : 8 + }, index) + await frames(orcaPage) + } + const scroll = await measure(editor) + const result = { + paragraphs: PARAGRAPH_COUNT, + sourceBytes: Buffer.byteLength(SOURCE), + cold, + selection, + scroll + } + await testInfo.attach('review-selection-metrics', { + body: JSON.stringify(result, null, 2), + contentType: 'application/json' + }) + process.stdout.write(`${JSON.stringify(result)}\n`) + await expect(editor).toBeFocused() + const tab = orcaPage.locator('[data-tab-id]').filter({ hasText: 'review-selection' }).last() + await expect(tab.locator('span.rounded-full')).toHaveCount(0) + await orcaPage.screenshot({ path: testInfo.outputPath('review-selection.png') }) + expect(selection.calls).toBe(0) + expect(selection.jsonCalls).toBe(0) + expect(scroll.calls).toBe(0) + expect(scroll.jsonCalls).toBe(0) + } finally { + await editor.evaluate((element) => { + const editorElement = + element.closest('.rich-markdown-editor') + editorElement?.__reviewSelectionProbe?.restore() + }) + } +}) From ddefd523e052cc83897937569d322a727f55dd78 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 4 Oct 2026 13:20:01 -0700 Subject: [PATCH 66/67] Keep selected text navigation from rewriting document links (#25175) * Keep selected text navigation from rewriting document links * Check model selection before document link arrow coverage --- .../rich-markdown-doc-link-keyboard.test.ts | 99 +++++++++++++++++++ .../editor/rich-markdown-doc-link.ts | 2 +- tests/e2e/markdown-doc-link-selection.spec.ts | 63 ++++++++++++ 3 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 src/renderer/src/components/editor/rich-markdown-doc-link-keyboard.test.ts create mode 100644 tests/e2e/markdown-doc-link-selection.spec.ts diff --git a/src/renderer/src/components/editor/rich-markdown-doc-link-keyboard.test.ts b/src/renderer/src/components/editor/rich-markdown-doc-link-keyboard.test.ts new file mode 100644 index 00000000000..add8be94d44 --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-doc-link-keyboard.test.ts @@ -0,0 +1,99 @@ +// @vitest-environment happy-dom + +import { Editor } from '@tiptap/react' +import StarterKit from '@tiptap/starter-kit' +import { NodeSelection } from '@tiptap/pm/state' +import { afterEach, describe, expect, it } from 'vitest' +import { createMarkdownDocLink } from './rich-markdown-doc-link' +import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport' + +const editors: Editor[] = [] + +function docLinkEditor(): Editor { + const editor = new Editor({ + extensions: [StarterKit, createMarkdownDocLink(createRichMarkdownEditorCodec().transport)], + content: { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { type: 'text', text: 'before' }, + { type: 'markdownDocLink', attrs: { target: 'Guide' } }, + { type: 'text', text: 'after' } + ] + } + ] + } + }) + editors.push(editor) + return editor +} + +function handleKey(editor: Editor, key: string, modifiers: KeyboardEventInit = {}): boolean { + const event = new KeyboardEvent('keydown', { key, ...modifiers }) + let handled = false + editor.view.someProp('handleKeyDown', (handler) => { + if (!handler(editor.view, event)) { + return false + } + handled = true + return true + }) + return handled +} + +afterEach(() => editors.splice(0).forEach((editor) => editor.destroy())) + +describe('document link arrow navigation', () => { + it.each([ + { key: 'ArrowLeft', from: 8, to: 13 }, + { key: 'ArrowLeft', from: 13, to: 8 }, + { key: 'ArrowRight', from: 7, to: 13 }, + { key: 'ArrowRight', from: 13, to: 7 } + ])('leaves $key selection $from..$to for native collapse', ({ key, from, to }) => { + const editor = docLinkEditor() + editor.commands.setTextSelection({ from, to }) + const before = editor.state.doc + const selection = editor.state.selection + expect(handleKey(editor, key)).toBe(false) + expect(editor.state.doc.eq(before)).toBe(true) + expect(editor.state.selection.eq(selection)).toBe(true) + }) + + it.each([ + { key: 'ArrowLeft', position: 8, expectedCaret: 14 }, + { key: 'ArrowRight', position: 7, expectedCaret: 9 } + ])( + 'opens an adjacent link for editing with $key at an empty caret', + ({ key, position, expectedCaret }) => { + const editor = docLinkEditor() + editor.commands.setTextSelection(position) + expect(handleKey(editor, key)).toBe(true) + expect(editor.state.doc.textContent).toBe('before[[Guide]]after') + expect(editor.state.selection.from).toBe(expectedCaret) + expect(editor.state.selection.empty).toBe(true) + } + ) + + it.each([{ shiftKey: true }, { altKey: true }, { metaKey: true }, { ctrlKey: true }])( + 'preserves modified arrow handling: %j', + (modifiers) => { + const editor = docLinkEditor() + editor.commands.setTextSelection(8) + const before = editor.state.doc + expect(handleKey(editor, 'ArrowLeft', modifiers)).toBe(false) + expect(editor.state.doc.eq(before)).toBe(true) + } + ) + + it('preserves node selection and keys away from a link', () => { + const editor = docLinkEditor() + editor.view.dispatch(editor.state.tr.setSelection(NodeSelection.create(editor.state.doc, 7))) + const before = editor.state.doc + expect(handleKey(editor, 'ArrowLeft')).toBe(false) + editor.commands.setTextSelection(1) + expect(handleKey(editor, 'ArrowRight')).toBe(false) + expect(editor.state.doc.eq(before)).toBe(true) + }) +}) diff --git a/src/renderer/src/components/editor/rich-markdown-doc-link.ts b/src/renderer/src/components/editor/rich-markdown-doc-link.ts index 1ae68d44bfe..4b26e88ac91 100644 --- a/src/renderer/src/components/editor/rich-markdown-doc-link.ts +++ b/src/renderer/src/components/editor/rich-markdown-doc-link.ts @@ -244,7 +244,7 @@ export function createMarkdownDocLink(transport: RichMarkdownSourceTransport) { return false } const { state } = view - if (!(state.selection instanceof TextSelection)) { + if (!(state.selection instanceof TextSelection) || !state.selection.empty) { return false } const { $from } = state.selection diff --git a/tests/e2e/markdown-doc-link-selection.spec.ts b/tests/e2e/markdown-doc-link-selection.spec.ts new file mode 100644 index 00000000000..2a79abe45e8 --- /dev/null +++ b/tests/e2e/markdown-doc-link-selection.spec.ts @@ -0,0 +1,63 @@ +import { test, expect } from './helpers/orca-app' +import type { Editor } from '@tiptap/core' +import { + cleanupMarkdownFixture, + createMarkdownFixture, + getActiveWorktreeContext, + openMarkdownFixture, + waitForRichMarkdownEditor +} from './helpers/markdown-editor-fixture' +import { waitForSessionReady, waitForActiveWorktree } from './helpers/store' + +type PageRichMarkdownLinkEditorElement = HTMLElement & { + editor?: Editor +} + +test('collapses a selection beside a document link without rewriting the link', async ({ + orcaPage, + registerPostElectronShutdownCleanup +}, testInfo) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + const context = await getActiveWorktreeContext(orcaPage) + const filePath = await createMarkdownFixture( + context, + '.orca-e2e-markdown-links', + 'selection', + testInfo.workerIndex, + '[[Guide]]after\n\nSelecting text beside a document link should keep the link intact.' + ) + registerPostElectronShutdownCleanup(() => cleanupMarkdownFixture(filePath)) + await openMarkdownFixture(orcaPage, context, filePath) + const editor = await waitForRichMarkdownEditor(orcaPage) + await expect(editor.locator('[data-doc-link-target="Guide"]')).toHaveCount(1) + await orcaPage.evaluate(() => { + const editorElement = + document.querySelector('.rich-markdown-editor') + const instance = editorElement?.editor + if (!editorElement || !instance) { + throw new Error('Editor unavailable') + } + editorElement.focus() + if (!instance.commands.setTextSelection({ from: 2, to: 7 })) { + throw new Error('Selection unavailable') + } + }) + await expect.poll(() => orcaPage.evaluate(() => window.getSelection()?.toString())).toBe('after') + await expect + .poll(() => + orcaPage.evaluate(() => { + const selection = + document.querySelector('.rich-markdown-editor')?.editor + ?.state.selection + return selection ? { from: selection.from, to: selection.to, empty: selection.empty } : null + }) + ) + .toEqual({ from: 2, to: 7, empty: false }) + await expect(editor).toBeFocused() + await orcaPage.keyboard.press('ArrowLeft') + await expect(editor.locator('[data-doc-link-target="Guide"]')).toHaveCount(1) + await expect(editor.locator('p').first()).toHaveText('Guideafter') + await expect.poll(() => orcaPage.evaluate(() => window.getSelection()?.toString())).toBe('') + await orcaPage.screenshot({ path: testInfo.outputPath('link-after-collapse.png') }) +}) From b99d28e32fd10b77352a7e0bba10b54bbd2e0f86 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:01:29 -0700 Subject: [PATCH 67/67] A resent chat message gets its recorded answer, never an early refusal or a made-up record (#25158) * fix(native-chat): a resent send id gets its recorded answer, never an early refusal or a made-up record The host now looks a resent send id up before preparing the session. A row that settled refused answers with its refusal before the chat is opened. A resend whose chat cannot be opened or made ready answers unknown instead of a refusal. A /clear in flight refuses only ids the ledger does not hold. A send row now records the journal epoch it was admitted into. An unsettled row with nothing written in that same epoch runs for the first time; under a later epoch the host answers unknown instead of reconstructing a submission it never had. The host advertises agent-session.send-answers-proof.v1. * test(native-chat): pass the ledger row to the thread-goal rerun check * fix(native-chat): a send's answer commits with its write, and a resend is answered before any write A send (and /compact) settles its ledger row `succeeded` in the same SQLite transaction as the submission or queued draft that accepts it, so a row still `pending` proves nothing was written and a resend runs it for the first time. The unknown-before-run mark and the per-row journal epoch go. A resent id is answered from its row and the journal before preparation starts an agent and before any write transaction: a recorded refusal with nothing opened; otherwise the conversation is opened (no agent start for a send) and replayed, and a conversation that will not open answers unknown. * fix(native-chat): a ledger refusal is answered first, and a /clear refuses only a send's first run An id the ledger refuses (expired, conflict, invalid, capacity) is answered as admission would, with no journal read, preparation or write, so a closed chat or a read-only store answers it too. A re-read after the replay open that comes back refused returns that refusal. Whether a /clear is in flight is read when a send arrives and applied in the send's preparation for a first run only: an id the ledger holds by the send's turn, including one whose earlier attempt was queued ahead of the clear, is answered from its record. MutationPlan makes settlesWithWrite and settledOutcome exclusive; the capability text no longer promises a refused id never sends. * docs(native-chat): say what a replay's preparation does for every plan --- .../journal-queued-messages.ts | 35 ++- .../journal-row-writer.ts | 23 +- .../agent-session-journal/journal-store.ts | 9 +- ...structured-agent-session-host-mutations.ts | 5 +- ...ctured-agent-session-mutation-admission.ts | 138 ++++++++-- ...structured-agent-session-mutation-plans.ts | 28 +- ...agent-session-operation-settlement.test.ts | 61 ++--- ...ured-agent-session-operation-settlement.ts | 75 ++++-- ...tured-agent-session-queued-command.test.ts | 4 +- ...tructured-agent-session-queued-messages.ts | 22 +- ...structured-agent-session-replay-outcome.ts | 19 +- ...ctured-agent-session-resend-answer.test.ts | 241 ++++++++++++++++++ ...gent-session-resend-ledger-refusal.test.ts | 157 ++++++++++++ ...ent-session-restart-failure-filing.test.ts | 17 +- ...ssion-restart-interruption-test-harness.ts | 16 ++ ...ed-agent-session-restart-ownership.test.ts | 55 ++-- ...red-agent-session-send-preparation.test.ts | 45 +++- ...ructured-agent-session-send-preparation.ts | 22 +- .../structured-agent-session-send.test.ts | 115 ++++----- .../structured-agent-session-turns.ts | 9 +- ...uctured-conversation-command-controller.ts | 8 +- .../runtime/agent-session-record-store.ts | 15 +- .../agent-session-store-transactions.test.ts | 51 ++++ .../agent-session-store-transactions.ts | 26 ++ src/shared/agent-session-mutation-envelope.ts | 21 +- src/shared/protocol-version.ts | 15 ++ 26 files changed, 957 insertions(+), 275 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-resend-answer.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-resend-ledger-refusal.test.ts diff --git a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts index a66d43697f6..0828740ca6f 100644 --- a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts +++ b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts @@ -13,7 +13,7 @@ import { import type { JournalHostDatabase } from './journal-host-database' import type { JournalReducerState } from './journal-reducer' import type { JournalRow } from './journal-row-schema' -import type { JournalRowTransactionHook } from './journal-row-writer' +import type { JournalOperationReceipt, JournalRowTransactionHook } from './journal-row-writer' import type { JournalSubmissionConsume } from './journal-store-contracts' import { adoptQueuedMessages, holdQueuedMessages } from './queued-message-holds' import { @@ -101,14 +101,18 @@ export class JournalQueuedMessages { return queuedMessagesSettledByOp(this.deps.database().db, this.deps.sessionId, settledByOp) } - /** `carriedFrom`: a /clear's carry. The card is its own 'cleared' pause, so it lands paused. */ - insert(input: { - messageId: string - body: AgentJournalMessageItem - fingerprint: string - hostInstance: string - carriedFrom?: string - }): Promise { + /** `carriedFrom`: a /clear's carry. The card is its own 'cleared' pause, so it lands paused. + * `receipt`: the send's ledger answer, committed with the draft only when this inserts it. */ + insert( + input: { + messageId: string + body: AgentJournalMessageItem + fingerprint: string + hostInstance: string + carriedFrom?: string + }, + receipt?: JournalOperationReceipt + ): Promise { const { sessionId } = this.deps let inserted = false return this.transact( @@ -121,14 +125,17 @@ export class JournalQueuedMessages { } inserted = true const { epoch, lastSequence } = this.deps.state() - return insertQueuedMessage(db, { + const row = insertQueuedMessage(db, { ...input, sessionId, queuedAt: { epoch, sequence: lastSequence }, now: this.deps.now() }) + receipt?.write(db) + return row }, - () => inserted + () => inserted, + receipt?.committed ) } @@ -206,15 +213,17 @@ export class JournalQueuedMessages { } /** One standalone draft-table transaction on the journal's queue; one that - * changed rows bumps the revision and notifies after COMMIT. */ + * changed rows bumps the revision and notifies after COMMIT, `adopted` first. */ private transact( run: (db: Database.Database) => JournalWriteResult, - changed: (result: T) => boolean + changed: (result: T) => boolean, + adopted?: () => void ): Promise { return this.deps.serialize(() => { assertJournalWritable(this.deps.readOnly(), this.deps.sessionId) const result = this.deps.database().transaction(run) if (changed(result)) { + adopted?.() this.changeRevision++ this.deps.committed() } diff --git a/src/main/native-chat/agent-session-journal/journal-row-writer.ts b/src/main/native-chat/agent-session-journal/journal-row-writer.ts index b07fda9459b..ca274120a7b 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-writer.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-writer.ts @@ -11,6 +11,14 @@ import type { JournalWriteBody } from './journal-write-queue' * nothing can interleave inside the transaction. */ export type JournalRowTransactionHook = (db: Database.Database, row: JournalRow) => void +/** An operation's ledger answer, committed with the journal write that makes it true: `write` runs + * inside that transaction on the same connection, `committed` synchronously right after its + * COMMIT and never after a rollback. */ +export type JournalOperationReceipt = { + write: (db: Database.Database) => void + committed: () => void +} + export type JournalRowWriterDeps = { sessionId: string now: () => number @@ -35,7 +43,8 @@ export class JournalRowWriter { enqueue( build: (seq: number, ts: number) => JournalRow, - hook?: JournalRowTransactionHook + hook?: JournalRowTransactionHook, + receipt?: JournalOperationReceipt ): Promise { return this.deps.serialize(() => { assertJournalWritable(this.deps.readOnly(), this.deps.sessionId) @@ -46,6 +55,7 @@ export class JournalRowWriter { this.deps.database().transaction((db) => { insertJournalRow(db, this.deps.sessionId, row) hook?.(db, row) + receipt?.write(db) this.runBookkeeping(db, row) }) } catch (error) { @@ -54,7 +64,8 @@ export class JournalRowWriter { } // COMMIT landed, so the row is durable: adopt it before anything that can // fail. Rejecting here instead would leave the next append reusing a - // sequence the table already holds. + // sequence the table already holds. The ledger first: it cannot throw, the fold can. + receipt?.committed() this.deps.commit(row) return row }) @@ -64,9 +75,13 @@ export class JournalRowWriter { * replay uses — all inside one serialized step — answering where the row landed. */ append( build: (seq: number, ts: number) => JournalRow, - hook?: JournalRowTransactionHook + hook?: JournalRowTransactionHook, + receipt?: JournalOperationReceipt ): Promise { - return this.enqueue(build, hook).then((row) => ({ epoch: row.epoch, sequence: row.seq })) + return this.enqueue(build, hook, receipt).then((row) => ({ + epoch: row.epoch, + sequence: row.seq + })) } private runBookkeeping(db: Database.Database, row: JournalRow): void { diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts index 5a4880155de..2349524ccfe 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -63,7 +63,7 @@ import { journalStopEventRowBuilder } from './journal-stop-and-resume-rows' import type { AgentJournalEpochReason, JournalStopEvent } from './journal-row-schema' -import type { JournalRowWriter } from './journal-row-writer' +import type { JournalOperationReceipt, JournalRowWriter } from './journal-row-writer' import type { JournalEpochController } from './journal-epoch-controller' import { JournalWriteQueue } from './journal-write-queue' import { createJournalStoreCollaborators } from './journal-store-collaborators' @@ -340,11 +340,14 @@ export class AgentSessionJournal { input: JournalSubmissionInput, /** Present: this submission is a queued draft's conversion, and the draft's * state transition commits in the SAME transaction — exactly-once consume. */ - consume?: JournalSubmissionConsume + consume?: JournalSubmissionConsume, + /** The send's ledger answer, committed with this row. */ + receipt?: JournalOperationReceipt ): Promise { return this.rowWriter.append( journalSubmissionRowBuilder(() => this.state, this.identity.providerHandle, input, consume), - consume && queuedMessageConsumeHook(this.queuedMessages, input.clientMessageId, consume) + consume && queuedMessageConsumeHook(this.queuedMessages, input.clientMessageId, consume), + receipt ) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index 3fbb96ddaf8..1133342270d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts @@ -61,7 +61,8 @@ export function sendStructuredAgentSessionTurn( * prompt never set it. */ userSend?: true beforeRun?: () => void - } + }, + arrival?: Parameters[2] ): Promise> { const plan = sendPlan(params) return mutateStructuredAgentSession( @@ -80,7 +81,7 @@ export function sendStructuredAgentSessionTurn( (await plan.run(ctx)) ) }, - sendPreparation(context, params.envelope) + sendPreparation(context, params.envelope, arrival) ) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts index 5bb072f2594..3ed04e1f2fe 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts @@ -4,16 +4,24 @@ // own admission rules by sitting next to the call site. // // Admission is two-phase for a call that brings a `prepareSession`. The ledger's -// answer comes first and places nothing; a call it will admit may then give the -// session an owner, and only after that are the row placed and the lease -// checked — against the lease as it stands once the owner is there. +// answer comes first and places nothing. An id it refuses is answered then, with +// nothing opened; so is a recorded id that settled refused. Any other recorded id +// is answered after the plan's own preparation for a replay (a send's only opens +// the conversation), from the journal, with no admit write. A call the ledger +// admits, or a replay that proves nothing landed, may then give the session an +// owner, and only after that are the row placed and the lease checked — against +// the lease as it stands once the owner is there. import { admitAgentSessionMutation, agentSessionFingerprintConflict, + agentSessionLedgerRefusal, computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import type { AgentSessionOperationDecision } from '../../../shared/agent-session-operation-ledger' +import type { + AgentSessionOperationDecision, + AgentSessionOperationRow +} from '../../../shared/agent-session-operation-ledger' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { refuse, @@ -36,7 +44,10 @@ import type { AgentSessionJournal } from '../agent-session-journal/journal-store import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import type { MutationPlan } from './structured-agent-session-mutation-plans' import { runSettledAgentSessionMutation } from './structured-agent-session-operation-settlement' -import { resolveAgentSessionReplayOutcome } from './structured-agent-session-replay-outcome' +import { + agentSessionOperationOutcomeUnknown, + resolveAgentSessionReplayOutcome +} from './structured-agent-session-replay-outcome' import type { AgentSessionTurnContext } from './structured-agent-session-turns' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' @@ -103,12 +114,27 @@ export async function admitAndRunAgentSessionMutation( if (!ledger) { return refuseAgentSessionMutation(AGENT_SESSION_NOT_ATTACHED) } - if (ledger.decision.decision !== 'refused') { - const prepared = await request.prepareSession(ledger.decision.decision, ledger.record) - if (!prepared.ok) { - return prepared + if (ledger.decision.decision === 'refused') { + // Nothing to read, prepare or write: a closed chat or a store that takes no write answers alike. + return refuseAgentSessionMutation(agentSessionLedgerRefusal(envelope, ledger.decision)) + } + if (ledger.decision.decision === 'replay') { + const answered = await answerRecordedOperation( + request, + request.prepareSession, + ledger.decision.row, + ledger.record, + hostFingerprint + ) + if (answered !== 'rerun') { + return answered } } + const record = request.store.getRecord(envelope.sessionId) ?? ledger.record + const prepared = await request.prepareSession('admit', record) + if (!prepared.ok) { + return prepared + } } const journal = request.journal() if (!journal) { @@ -151,18 +177,9 @@ export async function admitAndRunAgentSessionMutation( const fence = record.lease.runtimeFence const context = turnContext(request, journal, fence) if (admission.decision === 'replay') { - const replay = resolveAgentSessionReplayOutcome({ - operationId: envelope.clientOperationId, - outcome: admission.row.outcome, - reconstruct: () => plan.replay(context, admission.row.outcome), - rerunWhenReplayMissing: plan.rerunWhenReplayMissing?.(context), - recoverUnknownFromDurableState: plan.recoverUnknownFromDurableState - }) - if (replay.decision === 'refuse') { - return refuseAgentSessionMutation(replay.refusal) - } - if (replay.decision === 'replay') { - return { ok: true, replayed: true, fence, cursor: journal.cursor(), value: replay.value } + const replayed = replayRecordedOperation(request, context, admission.row) + if (replayed !== 'rerun') { + return replayed } // Nothing durable landed, so this id is about to run for the first time. A // refused call leaves its ledger row behind, and replaying past the lease @@ -197,6 +214,85 @@ export async function admitAndRunAgentSessionMutation( : refuseAgentSessionMutation(outcome.refusal) } +/** + * A resend of a recorded id, answered with no admit write: a refusal its first run recorded, with + * nothing opened; otherwise, after the plan's own preparation for a replay, from the conversation + * its run wrote to. `rerun` when nothing durable landed, so the call runs as a first run. + */ +async function answerRecordedOperation( + request: AgentSessionMutationRequest, + prepareSession: NonNullable['prepareSession']>, + row: AgentSessionOperationRow, + record: AgentSessionRecord, + hostFingerprint: string +): Promise | 'rerun'> { + const { plan, envelope } = request + if (row.outcome.status === 'failed') { + const replay = resolveAgentSessionReplayOutcome({ + operationId: envelope.clientOperationId, + outcome: row.outcome, + reconstruct: () => null + }) + if (replay.decision === 'refuse') { + return refuseAgentSessionMutation(replay.refusal) + } + } + const prepared = await prepareSession('replay', record) + if (!prepared.ok) { + return prepared + } + const journal = request.journal() + // Read again after the open: the row as it stands, against the record the open left. + const current = request.store.evaluateMutationOperation({ + callerKey: request.callerKey, + envelope, + hostFingerprint, + now: request.now(), + ...(plan.operationIdScope ? { operationIdScope: plan.operationIdScope } : {}) + }) + if (!journal || !current) { + return refuseAgentSessionMutation( + agentSessionOperationOutcomeUnknown(envelope.clientOperationId) + ) + } + if (current.decision.decision === 'refused') { + return refuseAgentSessionMutation(agentSessionLedgerRefusal(envelope, current.decision)) + } + if (current.decision.decision === 'admit') { + // The row is gone since: the first-run path decides it from scratch. + return 'rerun' + } + const context = turnContext(request, journal, current.record.lease.runtimeFence) + return replayRecordedOperation(request, context, current.decision.row) +} + +/** The recorded answer from the journal, or `rerun` when the plan says nothing durable landed. */ +function replayRecordedOperation( + { plan, envelope }: AgentSessionMutationRequest, + context: AgentSessionTurnContext, + row: AgentSessionOperationRow +): AgentSessionMutationResult | 'rerun' { + const replay = resolveAgentSessionReplayOutcome({ + operationId: envelope.clientOperationId, + outcome: row.outcome, + reconstruct: () => plan.replay(context, row.outcome), + rerunWhenReplayMissing: plan.rerunWhenReplayMissing?.(context), + recoverUnknownFromDurableState: plan.recoverUnknownFromDurableState + }) + if (replay.decision === 'refuse') { + return refuseAgentSessionMutation(replay.refusal) + } + return replay.decision === 'replay' + ? { + ok: true, + replayed: true, + fence: context.fence, + cursor: context.journal.cursor(), + value: replay.value + } + : 'rerun' +} + /** The committed ledger's admission, placing nothing: a failed commit left memory as it was. */ function admitWithoutLedgerRow( { store, logger }: Pick, 'store' | 'logger'>, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts index a81ea5270c8..6a2a6268ae9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts @@ -3,7 +3,9 @@ // // The replay half matters more than it looks. The ledger records only that an // operation happened, so the durable answer usually comes back out of the -// journal. Send is fail-closed: admission alone cannot prove non-delivery. +// journal. Send is fail-closed: admission alone cannot prove non-delivery, so +// its success commits with the row that accepts it, and a row still pending is +// one that wrote nothing. import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' @@ -51,13 +53,23 @@ export type MutationPlan = { conversationWrite?: true /** Still runs, decided from the committed ledger, when its ledger row cannot be written. */ runsWithoutLedgerRow?: true - markUnknownBeforeRun?: boolean run: (ctx: AgentSessionTurnContext) => Promise> replay: (ctx: AgentSessionTurnContext, outcome: AgentSessionOperationOutcome) => TValue | null rerunWhenReplayMissing?: (ctx: AgentSessionTurnContext) => boolean recoverUnknownFromDurableState?: boolean - settledOutcome?: (value: TValue) => AgentSessionOperationOutcome -} +} & ( + | { + /** Its success is the row its run writes, so it commits in that row's transaction + * (`AgentSessionTurnContext.operationReceipt`): a row left pending wrote nothing. That + * success is fixed before the value exists, so it records no `settledOutcome`. */ + settlesWithWrite: true + settledOutcome?: never + } + | { + settlesWithWrite?: never + settledOutcome?: (value: TValue) => AgentSessionOperationOutcome + } +) export function sendPlan(params: { envelope: AgentSessionMutationEnvelope @@ -74,7 +86,7 @@ export function sendPlan(params: { method: 'agentSession.send', operationIdScope: 'global', conversationWrite: true, - markUnknownBeforeRun: true, + settlesWithWrite: true, // `delivery` joins the OPERATION fingerprint only; the submission row keeps // the body-only fingerprint the reducer's echo-aliasing recomputes. fields: { body: params.body, ...(params.delivery ? { delivery: params.delivery } : {}) }, @@ -104,7 +116,9 @@ export function sendPlan(params: { if (submission) { return { clientMessageId, submission } } - if (outcome.status === 'failed') { + // A pending row wrote nothing, so the send runs for the first time. Succeeded: accepted, + // then a new epoch dropped its row. Unknown: only builds before this one wrote that. + if (outcome.status === 'failed' || outcome.status === 'pending') { return null } const resolvedAt = ctx.now() @@ -141,7 +155,7 @@ export function conversationCommandPlan(params: { return { method: 'agentSession.conversationCommand', conversationWrite: true, - markUnknownBeforeRun: true, + settlesWithWrite: true, fields: { command: STRUCTURED_AGENT_SESSION_COMPACT_COMMAND }, recoverUnknownFromDurableState: true, run: async (ctx) => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts index 203e31f4ea0..c7901edc1c1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts @@ -42,27 +42,18 @@ async function context(): Promise { } } -/** Longer than any bookkeeping bound: a refusal must already be answered by then. */ -const SETTLED_WAIT_MS = 2_000 - afterEach(() => { vi.useRealTimers() vi.restoreAllMocks() }) -it('returns a pre-dispatch refusal without waiting on redundant uncertainty persistence', async () => { +it('rethrows a throw from a plan answered by its write, writing nothing and leaving the row pending', async () => { const ctx = await context() const { store } = hostTestState() - const stalled = Promise.withResolvers() - const refusing = Promise.withResolvers() - const writes = vi - .spyOn(store, 'recordOperationOutcome') - .mockResolvedValueOnce() - .mockImplementation(() => stalled.promise) + const writes = vi.spyOn(store, 'recordOperationOutcome') const refusal = new AgentSessionPreDispatchError('agent_session_restart_work_superseded') - let returned = false vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - const result = runSettledAgentSessionMutation({ + const result = await runSettledAgentSessionMutation({ store, operationCallerKey: 'test', envelope: envelope('agentSession.send', {}), @@ -70,42 +61,30 @@ it('returns a pre-dispatch refusal without waiting on redundant uncertainty pers plan: { method: 'agentSession.send', fields: {}, - markUnknownBeforeRun: true, + settlesWithWrite: true, run: async () => { - refusing.resolve() throw refusal }, replay: () => null } - }).catch((error: unknown) => { - returned = true - return error - }) - try { - await refusing.promise - await vi.advanceTimersByTimeAsync(SETTLED_WAIT_MS) - expect(returned).toBe(true) - expect(writes).toHaveBeenCalledOnce() - expect(hostTestState().dispatch).not.toHaveBeenCalled() - expect(vi.getTimerCount()).toBe(0) - } finally { - stalled.resolve() - expect(await result).toBe(refusal) - } + }).catch((error: unknown) => error) + expect(result).toBe(refusal) + expect(writes).not.toHaveBeenCalled() + expect(hostTestState().dispatch).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) }) -it.each([1, 2])( - 'preserves a proven refusal through %s failed bookkeeping writes', - async (failures) => { +it.each([ + { plan: 'answered by its write', settlesWithWrite: true as const, failures: 1 }, + { plan: 'settled after its run', settlesWithWrite: undefined, failures: 2 } +])( + 'preserves a proven refusal of a plan $plan through $failures failed bookkeeping writes', + async ({ settlesWithWrite, failures }) => { const ctx = await context() const { store, dispatch } = hostTestState() const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}) - let writes = 0 - vi.spyOn(store, 'recordOperationOutcome').mockImplementation(async () => { - writes += 1 - if (writes > 1 && writes <= failures + 1) { - throw new Error('private unbounded disk detail') - } + const writes = vi.spyOn(store, 'recordOperationOutcome').mockImplementation(async () => { + throw new Error('private unbounded disk detail') }) const refusal = { ok: false as const, @@ -120,12 +99,13 @@ it.each([1, 2])( plan: { method: 'agentSession.send', fields: {}, - markUnknownBeforeRun: true, + ...(settlesWithWrite ? { settlesWithWrite } : {}), run, replay: () => null } }) expect(result).toEqual(refusal) + expect(writes).toHaveBeenCalledTimes(failures) expect(run).toHaveBeenCalledOnce() expect(dispatch).not.toHaveBeenCalled() expect(JSON.stringify(warning.mock.calls)).not.toContain('private unbounded disk detail') @@ -160,7 +140,7 @@ it('accepts without touching the provider', async () => { it('refuses a superseded send at acceptance, recording and dispatching nothing', async () => { const ctx = await context() const { store } = hostTestState() - vi.spyOn(store, 'recordOperationOutcome').mockResolvedValue() + const writes = vi.spyOn(store, 'recordOperationOutcome').mockResolvedValue() const beforeRun = vi.fn(() => { throw new AgentSessionPreDispatchError('agent_session_restart_work_superseded') }) @@ -175,6 +155,7 @@ it('refuses a superseded send at acceptance, recording and dispatching nothing', plan: sendPlan({ envelope: operation, body, beforeRun }) }).catch((error: unknown) => error) expect(result).toBeInstanceOf(AgentSessionPreDispatchError) + expect(writes).not.toHaveBeenCalled() expect(ctx.journal.submissions()).toEqual([]) expect(hostTestState().dispatch).not.toHaveBeenCalled() expect(vi.getTimerCount()).toBe(0) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts index 24de6ff708b..175afeaec35 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts @@ -1,4 +1,5 @@ import type { AgentSessionMutationEnvelope } from '../../../shared/agent-session-wire' +import type { JournalOperationReceipt } from '../agent-session-journal/journal-row-writer' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { MutationPlan } from './structured-agent-session-mutation-plans' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' @@ -18,20 +19,35 @@ export async function runSettledAgentSessionMutation(input: { plan: MutationPlan context: AgentSessionTurnContext }): Promise> { + const operation = { + callerKey: input.operationCallerKey, + operationId: input.envelope.clientOperationId + } const settle = ( outcome: Parameters[0]['outcome'] - ) => - input.store.recordOperationOutcome({ - callerKey: input.operationCallerKey, - operationId: input.envelope.clientOperationId, - outcome - }) + ) => input.store.recordOperationOutcome({ ...operation, outcome }) + const receipt = input.plan.settlesWithWrite + ? observedReceipt( + input.store.operationOutcomeReceipt({ + ...operation, + outcome: { status: 'succeeded', sessionId: input.envelope.sessionId } + }) + ) + : undefined + const context = receipt ? { ...input.context, operationReceipt: receipt } : input.context let outcome: TurnOutcome | undefined try { - if (input.plan.markUnknownBeforeRun) { - await settle({ status: 'unknown' }) + const ran = await input.plan.run(context) + if (receipt?.wasCommitted() && !ran.ok) { + // The row says accepted, so a refusal past it (a fold that failed after COMMIT) is a fault. + throw new Error( + `operation ${operation.operationId} was accepted, then refused: ${ran.refusal.code}` + ) } - outcome = await input.plan.run(input.context) + if (receipt?.wasCommitted()) { + return ran + } + outcome = ran await settle( outcome.ok ? (input.plan.settledOutcome?.(outcome.value) ?? { @@ -48,29 +64,42 @@ export async function runSettledAgentSessionMutation(input: { ) return outcome } catch (error) { - // The pre-run uncertainty is already durable; refusing before dispatch adds no new uncertainty. - if (input.plan.markUnknownBeforeRun && error instanceof AgentSessionPreDispatchError) { - throw error - } const { logger, sessionId } = input.context - try { - await settle({ status: 'unknown' }) - } catch { - // Bookkeeping must not replace the operation's proof of whether dispatch began. - logger.warn('recording an operation as unknown failed', { - scope: 'operation-unknown-settlement', - sessionId, - operationId: input.envelope.clientOperationId - }) + // Its success commits with its write, so a throw leaves the row pending: nothing was written. + if (!input.plan.settlesWithWrite) { + try { + await settle({ status: 'unknown' }) + } catch { + // Bookkeeping must not replace the operation's proof of whether dispatch began. + logger.warn('recording an operation as unknown failed', { + scope: 'operation-unknown-settlement', + sessionId, + operationId: operation.operationId + }) + } } if (outcome && !outcome.ok) { logger.warn('recording a refused operation failed', { scope: 'operation-refused-settlement', sessionId, - operationId: input.envelope.clientOperationId + operationId: operation.operationId }) return outcome } throw error } } + +function observedReceipt( + receipt: JournalOperationReceipt +): JournalOperationReceipt & { wasCommitted: () => boolean } { + let committed = false + return { + write: receipt.write, + committed: () => { + receipt.committed() + committed = true + }, + wasCommitted: () => committed + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-command.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-command.test.ts index 2be4a8ea12b..144a43302b7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-command.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-command.test.ts @@ -114,8 +114,10 @@ describe('/clear', () => { try { const cleared = command('clear') await eventually(() => expect(committing).toHaveBeenCalledOnce()) - expect(await rig.send('sent while clearing', 'queue-if-active').result).toEqual(WAIT_REFUSAL) + // Judged on arrival, answered on its turn: behind the clear. + const sent = rig.send('sent while clearing', 'queue-if-active').result release?.() + expect(await sent).toEqual(WAIT_REFUSAL) const done = await cleared const replacementId = done.ok ? done.value.replacementSessionId : undefined if (!replacementId) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts index ac1fdbb0617..176fd8e11fb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts @@ -23,6 +23,7 @@ import { isUnsettledQueuedMessage } from '../agent-session-journal/queued-messag import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { AgentSessionTurnContext } from './structured-agent-session-turns' import { QueuedMessageNotConsumableError } from '../agent-session-journal/journal-queued-messages' import type { QueuedMessageRow } from '../agent-session-journal/queued-message-table' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' @@ -185,11 +186,7 @@ export async function maybeQueueStructuredAgentSessionSend( context: { deps: { store: { getRecord: (sessionId: string) => AgentSessionRecord | null } } }, - ctx: { - sessionId: string - journal: AgentSessionJournal - fence: number - }, + ctx: Pick, params: { envelope: { clientOperationId: string } body: AgentJournalMessageItem @@ -231,12 +228,15 @@ export async function maybeQueueStructuredAgentSessionSend( } // The insert notifies through the journal's commit listener: publication and // the drain re-derive with no call here to forget. - const row = await ctx.journal.queuedMessages.insert({ - messageId: clientMessageId, - body: params.body, - fingerprint: queuedMessageFingerprint(ctx.sessionId, params.body), - hostInstance: structuredAgentSessionHostInstance() - }) + const row = await ctx.journal.queuedMessages.insert( + { + messageId: clientMessageId, + body: params.body, + fingerprint: queuedMessageFingerprint(ctx.sessionId, params.body), + hostInstance: structuredAgentSessionHostInstance() + }, + ctx.operationReceipt + ) return { ok: true, value: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-replay-outcome.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-replay-outcome.ts index 00fa33a30fb..cea36c6546a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-replay-outcome.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-replay-outcome.ts @@ -13,6 +13,16 @@ export type AgentSessionReplayOutcomeDecision = | { decision: 'rerun' } | { decision: 'refuse'; refusal: AgentSessionWireRefusal } +/** A recorded id this host cannot answer from what it holds, so it neither ran it again nor + * refused it: the caller resends under the same id. */ +export function agentSessionOperationOutcomeUnknown(operationId: string): AgentSessionWireRefusal { + return refuse( + 'agent_session_operation_unknown', + { reason: 'outcomeUnknown' }, + `The outcome of operation ${operationId} is unknown; it was not run again.` + ) +} + export function resolveAgentSessionReplayOutcome(input: { operationId: string outcome: AgentSessionOperationOutcome @@ -41,14 +51,7 @@ export function resolveAgentSessionReplayOutcome(input: { if (input.rerunWhenReplayMissing) { return { decision: 'rerun' } } - return { - decision: 'refuse', - refusal: refuse( - 'agent_session_operation_unknown', - { reason: 'outcomeUnknown' }, - `The outcome of operation ${operationId} is unknown; it was not run again.` - ) - } + return { decision: 'refuse', refusal: agentSessionOperationOutcomeUnknown(operationId) } } const recorded = input.reconstruct() if (recorded) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-resend-answer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-resend-answer.test.ts new file mode 100644 index 00000000000..74e4cfb4815 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-resend-answer.test.ts @@ -0,0 +1,241 @@ +// What a resend of a send id gets: the answer its record holds, never a refusal made before the +// host looked the id up, and never a made-up record. + +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { DISPATCH_DOUBT_SUBMISSION_MISSING } from '../agent-session-journal/journal-dispatch-doubt-reasons' +import { persistRewindRecord } from './structured-rewind-recovery' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import type { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + attach, + CALLER, + envelope, + hostTestState +} from './structured-agent-session-host-test-harness' +import { + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestMessage +} from './structured-agent-session-host-test-data' +import { createQueuedMessageTestRig } from './structured-agent-session-queued-message-rig.test-fixture' + +let root: string +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost +let dispatch: Mock +let acquire: Mock + +beforeEach(() => { + ;({ root, store, host, dispatch, acquire } = hostTestState()) +}) + +afterEach(() => vi.restoreAllMocks()) + +function hostJournal(): AgentSessionJournal { + return ( + host as unknown as { sessions: Map } + ).sessions.get(SESSION)!.journal +} + +function sendParams(text: string) { + const body = hostTestMessage(text) + return { envelope: envelope('agentSession.send', { body }), body } +} + +async function deliveredOnce(): Promise { + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledTimes(1)) +} + +describe('a resent send id', () => { + it('is answered from a refused row without opening the chat again', async () => { + await attach() + vi.spyOn(hostJournal(), 'appendSubmission').mockRejectedValueOnce(new Error('disk full')) + const params = sendParams('refused once') + const first = await host.send(CALLER, params) + expect(first).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_operation_invalid' } + }) + await host.close(SESSION, 'evict') + expect(host.hasSession(SESSION)).toBe(false) + + const resent = await host.send(CALLER, params) + + expect(resent).toMatchObject({ + ok: false, + refusal: { + code: 'agent_session_operation_invalid', + details: { reason: 'journalWriteFailed' } + } + }) + // The answer came from the ledger alone: the closed chat was not opened to give it. + expect(host.hasSession(SESSION)).toBe(false) + expect(dispatch).not.toHaveBeenCalled() + }) + + it('answers unknown, never a refusal, when the chat holding its answer cannot be opened', async () => { + await attach() + const params = sendParams('recorded, then the chat would not open') + await host.send(CALLER, params) + await deliveredOnce() + await host.close(SESSION, 'evict') + const connection = openTestJournalHostDatabase(root).db + const prepare = connection.prepare.bind(connection) + vi.spyOn(connection, 'prepare').mockImplementation((sql: string) => { + if (sql.includes('journal_')) { + throw Object.assign(new Error('database disk image is malformed'), { + code: 'ERR_SQLITE_ERROR', + errcode: 11 + }) + } + return prepare(sql) + }) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + await expect(host.send(CALLER, params)).resolves.toMatchObject({ + ok: false, + refusal: { code: 'agent_session_operation_unknown', details: { reason: 'outcomeUnknown' } } + }) + // A new id meets the same chat as a first run, and is refused for what it is. + await expect(host.send(CALLER, sendParams('a new message'))).resolves.toMatchObject({ + ok: false, + refusal: { code: 'agent_session_journal_unreadable' } + }) + expect(dispatch).toHaveBeenCalledTimes(1) + }) + + it('is answered from its record while a /clear is in flight; a new id is refused', async () => { + await attach() + const params = sendParams('sent before the clear') + expect(await host.send(CALLER, params)).toMatchObject({ ok: true, replayed: false }) + + const clearing = host.conversationCommand(CALLER, { + command: 'clear', + envelope: envelope('agentSession.conversationCommand', { command: 'clear' }) + }) + const resent = host.send(CALLER, params) + const fresh = host.send(CALLER, sendParams('typed during the clear')) + await clearing + + await expect(resent).resolves.toMatchObject({ + ok: true, + replayed: true, + value: { submission: { clientMessageId: params.envelope.clientOperationId } } + }) + await expect(fresh).resolves.toMatchObject({ + ok: false, + refusal: { details: { reason: 'conversationCommandInFlight' } } + }) + }) + + it('waits for an original still being accepted and answers with its submission', async () => { + await attach() + const journal = hostJournal() + const append = journal.appendSubmission.bind(journal) + let release!: () => void + const held = new Promise((resolve) => { + release = resolve + }) + vi.spyOn(journal, 'appendSubmission').mockImplementationOnce(async (...args) => { + await held + return append(...args) + }) + const params = sendParams('resent while the first is mid-write') + + const original = host.send(CALLER, params) + const resent = host.send(CALLER, params) + await vi.waitFor(() => expect(journal.appendSubmission).toHaveBeenCalledTimes(1)) + release() + + const [first, second] = await Promise.all([original, resent]) + expect(first).toMatchObject({ ok: true, replayed: false }) + expect(second).toMatchObject({ + ok: true, + replayed: true, + value: { submission: { clientMessageId: params.envelope.clientOperationId } } + }) + if (!second.ok || !('submission' in second.value)) { + throw new Error('expected the submission arm') + } + expect(second.value.submission.reason).not.toBe(DISPATCH_DOUBT_SUBMISSION_MISSING) + await deliveredOnce() + expect(journal.submissions()).toHaveLength(1) + expect( + store + .listOperationRows() + .filter((row) => row.operationId === params.envelope.clientOperationId) + ).toHaveLength(1) + }) + + it('is answered from the chat while a rewind is in doubt, starting no agent', async () => { + await attach() + const params = sendParams('sent before the rewind') + await host.send(CALLER, params) + await deliveredOnce() + await host.close(SESSION, 'evict') + // Only the provider can settle this rewind, so a send's first run would start it. + await persistRewindRecord(store, SESSION, store.getRecord(SESSION)!.lease.runtimeFence, { + operationId: 'rewind-op', + callerKey: CALLER.callerKey, + itemId: 'orca:rewound', + providerItemId: `codex:${THREAD}:turn-1:0`, + expectedEpoch: 'epoch-before', + phase: 'prepared', + retained: [] + }) + acquire.mockClear() + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + await expect(host.send(CALLER, params)).resolves.toMatchObject({ + ok: true, + replayed: true, + value: { submission: { clientMessageId: params.envelope.clientOperationId } } + }) + expect(acquire).not.toHaveBeenCalled() + expect(store.getRecord(SESSION)?.rewind?.phase).toBe('prepared') + }) + + it('is answered from a store a newer Orca wrote, which takes no write', async () => { + await attach() + const params = sendParams('sent, then a newer Orca wrote the store') + await host.send(CALLER, params) + await deliveredOnce() + await host.close(SESSION, 'evict') + Object.defineProperty(openTestJournalHostDatabase(root), 'readOnly', { value: true }) + expect(store.readOnly).toBe(true) + + await expect(host.send(CALLER, params)).resolves.toMatchObject({ + ok: true, + replayed: true, + value: { submission: { clientMessageId: params.envelope.clientOperationId } } + }) + expect(dispatch).toHaveBeenCalledTimes(1) + }) +}) + +describe('a send queued behind a running turn', () => { + it('commits its accepted row with its draft, so a failed settlement loses no answer', async () => { + const rig = await createQueuedMessageTestRig() + try { + await rig.workingSend() + const settle = vi + .spyOn(rig.store, 'recordOperationOutcome') + .mockRejectedValue(new Error('operation settlement failed')) + const queued = rig.send('queued behind the turn', 'queue-if-active') + + await expect(queued.result).resolves.toMatchObject({ + ok: true, + value: { queued: { messageId: queued.id, state: 'waiting' } } + }) + expect( + rig.store.listOperationRows().find((row) => row.operationId === queued.id) + ).toMatchObject({ outcome: { status: 'succeeded' } }) + expect(settle).not.toHaveBeenCalled() + } finally { + await rig.dispose() + } + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-resend-ledger-refusal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-resend-ledger-refusal.test.ts new file mode 100644 index 00000000000..e286c8a58ae --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-resend-ledger-refusal.test.ts @@ -0,0 +1,157 @@ +// A send id the ledger refuses is answered with that refusal and nothing else: no chat opened, no +// write. A /clear in flight refuses only a send's first run, judged when the send arrived. + +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS } from '../../../shared/agent-session-host-authority' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import type { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + attach, + CALLER, + envelope, + hostTestState +} from './structured-agent-session-host-test-harness' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + hostTestMessage +} from './structured-agent-session-host-test-data' + +const EXPIRED = { + ok: false, + refusal: { + code: 'agent_session_operation_expired', + details: { reason: 'operationExpired' } + } +} + +let root: string +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost +let dispatch: Mock + +beforeEach(() => { + ;({ root, store, host, dispatch } = hostTestState()) +}) + +afterEach(() => vi.restoreAllMocks()) + +function hostJournal(): AgentSessionJournal { + return ( + host as unknown as { sessions: Map } + ).sessions.get(SESSION)!.journal +} + +function sendParams(text: string, clientOperationId?: string) { + const body = hostTestMessage(text) + return { + envelope: envelope( + 'agentSession.send', + { body }, + clientOperationId ? { clientOperationId } : {} + ), + body + } +} + +/** An id minted more than a day ago, which no ledger row holds any more. */ +function expiredParams(text: string) { + return sendParams( + text, + `${NOW - AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS - 60_000}-${'e'.repeat(32)}` + ) +} + +function clear() { + return host.conversationCommand(CALLER, { + command: 'clear', + envelope: envelope('agentSession.conversationCommand', { command: 'clear' }) + }) +} + +describe('an expired send id', () => { + it('is answered expired while its chat is closed, not as a chat this host lacks', async () => { + await attach() + await host.close(SESSION, 'evict') + + await expect(host.send(CALLER, expiredParams('long gone'))).resolves.toMatchObject(EXPIRED) + expect(host.hasSession(SESSION)).toBe(false) + }) + + it('is answered expired by a store a newer Orca wrote', async () => { + await attach() + const database = openTestJournalHostDatabase(root) + Object.defineProperty(database, 'readOnly', { value: true }) + expect(store.readOnly).toBe(true) + try { + await expect(host.send(CALLER, expiredParams('long gone'))).resolves.toMatchObject(EXPIRED) + } finally { + // Teardown stops the live child, which writes. + Object.defineProperty(database, 'readOnly', { value: false }) + } + }) + + it('is answered expired when its row lapses while the chat opens for the resend', async () => { + await attach() + const params = sendParams('recorded, then it lapsed') + await host.send(CALLER, params) + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledTimes(1)) + const evaluate = store.evaluateMutationOperation + // The second read, after the open, sees the clock past the row's whole retention. + vi.spyOn(store, 'evaluateMutationOperation') + .mockImplementationOnce(evaluate) + .mockImplementationOnce((args) => + evaluate({ ...args, now: args.now + 3 * AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS }) + ) + + await expect(host.send(CALLER, params)).resolves.toMatchObject(EXPIRED) + expect(dispatch).toHaveBeenCalledTimes(1) + }) +}) + +describe('a /clear in flight', () => { + it('answers a resend from the attempt queued ahead of it, delivering once', async () => { + await attach() + const journal = hostJournal() + const append = journal.appendSubmission.bind(journal) + const held = Promise.withResolvers() + // An earlier send holds the session's queue, so attempt 1 waits there when the clear arrives. + vi.spyOn(journal, 'appendSubmission').mockImplementationOnce(async (...args) => { + await held.promise + return append(...args) + }) + const earlier = host.send(CALLER, sendParams('holds the queue')) + const params = sendParams('queued ahead of the clear') + const id = params.envelope.clientOperationId + const attempt = host.send(CALLER, params) + await vi.waitFor(() => expect(journal.appendSubmission).toHaveBeenCalledTimes(1)) + + const clearing = clear() + const resent = host.send(CALLER, params) + held.resolve() + const [first, second] = await Promise.all([attempt, resent, earlier, clearing]) + + expect(first).toMatchObject({ ok: true, replayed: false }) + expect(second).toMatchObject({ + ok: true, + replayed: true, + value: { submission: { clientMessageId: id } } + }) + expect(journal.submissions().filter((entry) => entry.clientMessageId === id)).toHaveLength(1) + expect( + dispatch.mock.calls.filter(([input]) => input.clientMessageId === id).length + ).toBeLessThanOrEqual(1) + }) + + it('answers an expired id expired', async () => { + await attach() + const clearing = clear() + const expired = host.send(CALLER, expiredParams('typed long ago')) + await clearing + + await expect(expired).resolves.toMatchObject(EXPIRED) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts index f1fe0a40b42..b1cbc83a48e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts @@ -5,10 +5,10 @@ import { AGENT_SESSION_RESTART_CONTINUATION_UNCONFIRMED_NOTE } from '../../../shared/agent-session-restart-continuation' import { StructuredAgentSessionResumeAdmission } from './structured-agent-session-restart-resume-runner' -import { STRUCTURED_AGENT_SESSION_RESTART_CONTINUATION_CALLER } from './structured-agent-session-restart-resume-wiring' import { interruptedRestart, - statusNotes + statusNotes, + throwAfterContinuationAccepted } from './structured-agent-session-restart-interruption-test-harness' import { CALLER, envelope } from './structured-agent-session-host-test-harness' import { @@ -66,19 +66,10 @@ it('says so in the chat when the agent cannot start for the continuation', async // A send that throws after Orca may have taken it cannot be proven undelivered: filed unconfirmed, // and it stays on record while the agent that may be carrying on keeps running. it('keeps an unconfirmed failure while the agent the continuation started keeps running', async () => { - const { host, store } = await interruptedRestart() + const { host } = await interruptedRestart() vi.spyOn(console, 'warn').mockImplementation(() => {}) await host.restartResume.list() - const settle = store.recordOperationOutcome.bind(store) - vi.spyOn(store, 'recordOperationOutcome').mockImplementation(async (input) => { - if ( - input.callerKey === STRUCTURED_AGENT_SESSION_RESTART_CONTINUATION_CALLER && - input.outcome.status === 'succeeded' - ) { - throw new Error('operation outcome could not be persisted') - } - return settle(input) - }) + throwAfterContinuationAccepted() const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts index 94746daf3ec..fe97834a189 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts @@ -13,6 +13,7 @@ import type { AgentSessionRecordStore } from '../../runtime/agent-session-record import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { parseAgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' +import { AgentSessionJournal } from '../agent-session-journal/journal-store' import { StructuredAgentSessionHost } from './structured-agent-session-host' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' import { StructuredAgentSessionResumeAdmission } from './structured-agent-session-restart-resume-runner' @@ -160,6 +161,21 @@ export async function interruptedRestart( return { ...hostTestState(), host, store, log, closeSession, marker, clock } } +/** The continuation's submission commits, then its send throws: a send Orca may have taken. */ +export function throwAfterContinuationAccepted(): void { + const append = AgentSessionJournal.prototype.appendSubmission + vi.spyOn(AgentSessionJournal.prototype, 'appendSubmission').mockImplementation(async function ( + this: AgentSessionJournal, + ...args: Parameters + ) { + const cursor = await append.apply(this, args) + if (args[0].origin === 'host') { + throw new Error('the accepted continuation could not be answered') + } + return cursor + }) +} + export async function statusNotes(host: StructuredAgentSessionHost) { return (await host.journalSnapshot(SESSION)).items.flatMap((item) => item.body.kind === 'status' ? [{ text: item.body.text, tone: item.body.tone }] : [] diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts index a46551b4500..4019582e1fd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts @@ -18,7 +18,8 @@ import { interruptedRestart, startAgent, statusNotes, - supersededRefusal + supersededRefusal, + throwAfterContinuationAccepted } from './structured-agent-session-restart-interruption-test-harness' import { attach, @@ -141,38 +142,30 @@ it('replays the same logical continuation through the durable send ledger', asyn }) // A send that throws after Orca may have taken it is not proof it was not delivered. -it.each([false, true])( - 'keeps a continuation unconfirmed when its acceptance cannot be recorded (uncertainty write fails: %s)', - async (uncertaintyFails) => { - const { host, store } = await interruptedRestart() - const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}) - expect(await host.restartResume.list()).toHaveLength(1) - const settle = store.recordOperationOutcome.bind(store) - const recording = vi.spyOn(store, 'recordOperationOutcome') - recording.mockImplementation(async (input) => { - // Only the continuation's own record: the start it makes records its attach as usual. - if ( - input.callerKey === STRUCTURED_AGENT_SESSION_RESTART_CONTINUATION_CALLER && - (input.outcome.status === 'succeeded' || uncertaintyFails) - ) { - throw new Error('operation outcome could not be persisted') - } - return settle(input) - }) +it('keeps a continuation unconfirmed when its send throws after acceptance', async () => { + const { host, store } = await interruptedRestart() + vi.spyOn(console, 'warn').mockImplementation(() => {}) + expect(await host.restartResume.list()).toHaveLength(1) + throwAfterContinuationAccepted() - const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') + const result = await host.restartResume + .continueAfterRestart([SESSION], 'modal') + .finally(() => vi.restoreAllMocks()) - expect(result.continued).toMatchObject([{ sessionId: SESSION, outcome: 'unknown' }]) - // Filed as unconfirmed, with a warning in the chat. - expect(result.failed).toMatchObject([{ sessionId: SESSION, outcome: 'unconfirmed' }]) - expect(await statusNotes(host)).toContainEqual({ - text: AGENT_SESSION_RESTART_CONTINUATION_UNCONFIRMED_NOTE, - tone: 'warning' - }) - recording.mockRestore() - warning.mockRestore() - } -) + expect(result.continued).toMatchObject([{ sessionId: SESSION, outcome: 'unknown' }]) + // Filed as unconfirmed, with a warning in the chat. + expect(result.failed).toMatchObject([{ sessionId: SESSION, outcome: 'unconfirmed' }]) + expect(await statusNotes(host)).toContainEqual({ + text: AGENT_SESSION_RESTART_CONTINUATION_UNCONFIRMED_NOTE, + tone: 'warning' + }) + // Its acceptance committed with its submission: a resend replays it. + expect( + store + .listOperationRows() + .find((row) => row.callerKey === STRUCTURED_AGENT_SESSION_RESTART_CONTINUATION_CALLER) + ).toMatchObject({ outcome: { status: 'succeeded' } }) +}) // The continuation is accepted, then its start fails: the message is rejected with the cause and // the failure is filed, and nothing is stopped because nothing started. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts index cf746e29b74..01f4868d380 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts @@ -274,10 +274,12 @@ describe('a send with no live owner', () => { expect(acquire).toHaveBeenCalledOnce() }) - it('restarts nothing for a send the ledger holds but the journal never saw', async () => { - const params = sendParams('claimed, then the host died') - // The row was claimed and the host went down before the journal write: on replay, admission - // reconstructs an unknown-outcome submission and never needs an owner. + /** A row admitted for this send, then the host died before the journal write, left as `outcome` + * says: `pending` by this build, `unknown` by a build that marked it before running. */ + async function admittedThenHostDied( + params: ReturnType, + outcome: 'pending' | 'unknown' + ) { await store.admitMutationOperation({ callerKey: CALLER.callerKey, envelope: params.envelope, @@ -285,11 +287,13 @@ describe('a send with no live owner', () => { now: NOW, operationIdScope: 'global' }) - await store.recordOperationOutcome({ - callerKey: CALLER.callerKey, - operationId: params.envelope.clientOperationId, - outcome: { status: 'unknown' } - }) + if (outcome === 'unknown') { + await store.recordOperationOutcome({ + callerKey: CALLER.callerKey, + operationId: params.envelope.clientOperationId, + outcome: { status: 'unknown' } + }) + } await host.handleAdapterEvent({ type: 'ended', sessionId: SESSION, @@ -300,16 +304,31 @@ describe('a send with no live owner', () => { }) expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') acquire.mockClear() - - const result = await host.send(CALLER, { + return { ...params, envelope: { ...params.envelope, expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 0 } - }) + } + } - expect(result).toMatchObject({ + it('runs a send admitted but never run for the first time, restarting the owner once', async () => { + const resent = await admittedThenHostDied(sendParams('admitted, then the host died'), 'pending') + + await expect(host.send(CALLER, resent)).resolves.toMatchObject({ + ok: true, + replayed: false, + value: { submission: { dispatchState: 'pending' } } + }) + await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) + expect(acquire).toHaveBeenCalledOnce() + }) + + it("restarts nothing for an older build's unknown row the journal never saw", async () => { + const resent = await admittedThenHostDied(sendParams('marked unknown, then died'), 'unknown') + + await expect(host.send(CALLER, resent)).resolves.toMatchObject({ ok: true, replayed: true, value: { submission: { dispatchState: 'unknown', recovered: true } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts index 77413f45634..e74947054e4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts @@ -22,7 +22,9 @@ import { AGENT_SESSION_NOT_ATTACHED, type AgentSessionMutationSessionPreparation } from './structured-agent-session-mutation-admission' +import { agentSessionOperationOutcomeUnknown } from './structured-agent-session-replay-outcome' import { rewindRefusal } from './structured-rewind-refusal' +import { conversationCommandInFlight } from './structured-conversation-command-admission' import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' @@ -132,17 +134,29 @@ export function openWithAgent( } /** A rewind still in doubt once the conversation is open is one only its provider can settle — - * the open settles every other — so a send starts the agent, whose attach recovers it. */ + * the open settles every other — so a send starts the agent, whose attach recovers it. A resend + * of a recorded id needs only the conversation, its answer's source: it starts nothing, and an + * open that fails leaves that answer unknown, never refused. `clearInFlight`: a /clear was running + * when this send arrived, which refuses only its first run. */ export function sendPreparation( context: Pick, - envelope: AgentSessionMutationEnvelope -): () => Promise { - return async () => { + envelope: AgentSessionMutationEnvelope, + arrival: { clearInFlight?: boolean } = {} +): (ledger: 'admit' | 'replay') => Promise { + return async (ledger) => { + if (ledger === 'admit' && arrival.clearInFlight) { + return { ok: false, refusal: conversationCommandInFlight() } + } const opened = await openConversationForWrite( context.openConversation, envelope, context.deps.logger ) + if (ledger === 'replay') { + return opened.ok + ? opened + : { ok: false, refusal: agentSessionOperationOutcomeUnknown(envelope.clientOperationId) } + } const phase = context.deps.store.getRecord(envelope.sessionId)?.rewind?.phase return opened.ok && (phase === 'prepared' || phase === 'provider-succeeded') ? context.ensureAgent(envelope.sessionId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts index f0163b22791..f382eda54e4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts @@ -303,23 +303,20 @@ describe('send', () => { expect(journal.submissions()).toHaveLength(1) }) - it('reconstructs an accepted send after the ledger settlement is lost', async () => { + it('answers a send whose ledger settlement fails, its row committed with the submission', async () => { await attach() - const persist = store.recordOperationOutcome.bind(store) - let failSettlement = true - vi.spyOn(store, 'recordOperationOutcome').mockImplementation(async (input) => { - if (failSettlement && input.outcome.status === 'succeeded') { - failSettlement = false - throw new Error('operation settlement failed') - } - return persist(input) - }) - const body = hostTestMessage('accepted before settlement failed') + vi.spyOn(store, 'recordOperationOutcome').mockRejectedValue( + new Error('operation settlement failed') + ) + const body = hostTestMessage('accepted while settlement fails') const params = { envelope: envelope('agentSession.send', { body }), body } + const clientMessageId = params.envelope.clientOperationId - await expect(host.send(CALLER, params)).rejects.toThrow('operation settlement failed') - // The submission was recorded before the ledger write failed, so it is still delivered. - await delivered(params.envelope.clientOperationId) + await expect(host.send(CALLER, params)).resolves.toMatchObject({ ok: true, replayed: false }) + expect( + store.listOperationRows().find((row) => row.operationId === clientMessageId) + ).toMatchObject({ outcome: { status: 'succeeded' } }) + await delivered(clientMessageId) await expect(host.send(CALLER, params)).resolves.toMatchObject({ ok: true, replayed: true, @@ -328,51 +325,54 @@ describe('send', () => { expect(dispatch).toHaveBeenCalledTimes(1) }) - it('never reruns an admission-only send after the caller changes', async () => { + it('runs a send that threw before writing for the first time when it is resent, once', async () => { await attach() - const settlement = vi - .spyOn(store, 'recordOperationOutcome') - .mockRejectedValue(new Error('operation settlement failed')) const body = hostTestMessage('first delivery after caller recovery') const params = { envelope: envelope('agentSession.send', { body }), body } + const clientMessageId = params.envelope.clientOperationId + const beforeRun = () => { + throw new Error('host fault before the write') + } - await expect(host.send(CALLER, params)).rejects.toThrow('operation settlement failed') - expect(dispatch).not.toHaveBeenCalled() - settlement.mockRestore() + await expect(host.send(CALLER, { ...params, beforeRun })).rejects.toThrow( + 'host fault before the write' + ) + expect(hostJournal().submissions()).toHaveLength(0) + // Its success would have committed with its write, so a row still pending wrote nothing. + expect( + store.listOperationRows().find((row) => row.operationId === clientMessageId) + ).toMatchObject({ outcome: { status: 'pending' } }) await expect(host.send({ callerKey: 'client-after-recovery' }, params)).resolves.toMatchObject({ ok: true, - replayed: true, - value: { - submission: { - dispatchState: 'unknown', - reason: DISPATCH_DOUBT_SUBMISSION_MISSING - } - } + replayed: false, + value: { submission: { clientMessageId, dispatchState: 'pending' } } }) - expect(dispatch).not.toHaveBeenCalled() + await delivered(clientMessageId) expect( - store.listOperationRows().find((row) => row.operationId === params.envelope.clientOperationId) - ).toMatchObject({ callerKey: CALLER.callerKey, outcome: { status: 'pending' } }) + store.listOperationRows().find((row) => row.operationId === clientMessageId) + ).toMatchObject({ callerKey: CALLER.callerKey, outcome: { status: 'succeeded' } }) + await expect(host.send(CALLER, params)).resolves.toMatchObject({ ok: true, replayed: true }) + expect(dispatch).toHaveBeenCalledTimes(1) }) - it('never redelivers after admission survives without its journal submission', async () => { + it("answers an older build's unknown row a new epoch emptied as recorded, never redelivering", async () => { await attach() - const persist = store.recordOperationOutcome.bind(store) - const settlement = vi - .spyOn(store, 'recordOperationOutcome') - .mockImplementation(async (input) => { - if (input.outcome.status === 'succeeded') { - throw new Error('operation settlement failed') - } - return persist(input) - }) const body = hostTestMessage('delivered before epoch recovery') const params = { envelope: envelope('agentSession.send', { body }), body } - - await expect(host.send(CALLER, params)).rejects.toThrow('operation settlement failed') - settlement.mockRestore() + await host.send(CALLER, params) await delivered(params.envelope.clientOperationId) + // What a build that marked a send unknown before running it leaves behind. + await store.recordOperationOutcome({ + callerKey: CALLER.callerKey, + operationId: params.envelope.clientOperationId, + outcome: { status: 'pending' } + }) + await store.recordOperationOutcome({ + callerKey: CALLER.callerKey, + operationId: params.envelope.clientOperationId, + outcome: { status: 'unknown' } + }) const journal = hostJournal() await journal.rollEpoch('schema_unreadable', store.getRecord(SESSION)?.lease.runtimeFence ?? 1) expect(journal.submissions()).toHaveLength(0) @@ -394,33 +394,22 @@ describe('send', () => { expect(journal.submissions()).toHaveLength(0) }) - it('fails closed when a legacy pending row survives without its submission', async () => { + it('answers an accepted send a new epoch dropped as recorded, never by running it again', async () => { await attach() - const body = hostTestMessage('legacy pending send after caller recovery') + const body = hostTestMessage('accepted, then the epoch was replaced') const params = { envelope: envelope('agentSession.send', { body }), body } - await host.send(CALLER, params) await delivered(params.envelope.clientOperationId) - expect(dispatch).toHaveBeenCalledTimes(1) - await store.recordOperationOutcome({ - callerKey: CALLER.callerKey, - operationId: params.envelope.clientOperationId, - outcome: { status: 'pending' } - }) + await hostJournal().rollEpoch( + 'schema_unreadable', + store.getRecord(SESSION)?.lease.runtimeFence ?? 1 + ) - const journal = ( - host as unknown as { sessions: Map } - ).sessions.get(SESSION)!.journal - await journal.rollEpoch('schema_unreadable', store.getRecord(SESSION)?.lease.runtimeFence ?? 1) - - await expect(host.send({ callerKey: 'client-after-recovery' }, params)).resolves.toMatchObject({ + await expect(host.send(CALLER, params)).resolves.toMatchObject({ ok: true, replayed: true, value: { - submission: { - dispatchState: 'unknown', - reason: DISPATCH_DOUBT_SUBMISSION_MISSING - } + submission: { dispatchState: 'unknown', reason: DISPATCH_DOUBT_SUBMISSION_MISSING } } }) expect(dispatch).toHaveBeenCalledTimes(1) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts index fa600a229dc..629d7e71790 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts @@ -24,6 +24,7 @@ import { import { isAgentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' import { DISPATCH_DOUBT_PERSISTENCE_FAILED } from '../agent-session-journal/journal-dispatch-doubt-reasons' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { JournalOperationReceipt } from '../agent-session-journal/journal-row-writer' import type { AgentSessionDispatchOutcome, StructuredAgentSessionAdapter, @@ -63,6 +64,8 @@ export type AgentSessionTurnContext = { providerChildPhase?: () => StructuredAgentSessionProviderChildPhase | undefined /** Who a Stop's refusal row names. */ failureTextContext?: AgentSessionFailureWordsContext + /** The operation's success, committed with the row that accepts it (`MutationPlan.settlesWithWrite`). */ + operationReceipt?: JournalOperationReceipt now: () => number } @@ -142,7 +145,11 @@ export async function performSend( } } try { - await ctx.journal.appendSubmission({ ...input, fence: ctx.fence, handoverRecorded: true }) + await ctx.journal.appendSubmission( + { ...input, fence: ctx.fence, handoverRecorded: true }, + undefined, + ctx.operationReceipt + ) } catch (error) { // Damage SQLite proves is the chat's, and no retry writes past it: say so, as an open does. So // does a chat holding a newer Orca's rows, which only an update writes past, and a refusal the diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command-controller.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command-controller.ts index 3c7f71dbe1f..338bb494a4c 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command-controller.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command-controller.ts @@ -17,13 +17,15 @@ export class StructuredConversationCommandController { private readonly context: () => StructuredAgentSessionMutationContext, private readonly host: Pick ) {} + /** Whether a clear is in flight is read as the send arrives; it refuses only a first run, so an + * id with a recorded answer by the send's turn gets that answer, behind the clear. */ send = ( caller: StructuredAgentSessionCaller, params: Parameters[2] ): ReturnType => - this.pending.has(params.envelope.sessionId) - ? Promise.resolve({ ok: false, refusal: conversationCommandInFlight() }) - : sendStructuredAgentSessionTurn(this.context(), caller, params) + sendStructuredAgentSessionTurn(this.context(), caller, params, { + clearInFlight: this.pending.has(params.envelope.sessionId) + }) run = (caller: StructuredAgentSessionCaller, params: ConversationCommandParams) => { if (params.command === 'compact') { diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts index cd87b024c0f..3ef04041a43 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -13,7 +13,6 @@ import { agentSessionOperationKey, type AgentSessionOperationClaim, type AgentSessionOperationDecision, - type AgentSessionOperationOutcome, type AgentSessionOperationRow } from '../../shared/agent-session-operation-ledger' import { @@ -68,9 +67,12 @@ import { import type { AgentSessionStoreState } from './agent-session-record-store-file' import { setAgentSessionTabVisibility, showAgentSessionTabs } from './agent-session-tab-table' import type { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' +import type { JournalOperationReceipt } from '../native-chat/agent-session-journal/journal-row-writer' import { loadAgentSessionStoreRows } from './agent-session-record-rows' import { AgentSessionStoreTransactions } from './agent-session-store-transactions' +type AgentSessionOperationSettlement = Parameters[1] + export const AGENT_SESSION_LEASE_TTL_MS = 30_000, AGENT_SESSION_LEASE_RENEW_INTERVAL_MS = 10_000 @@ -296,14 +298,15 @@ export class AgentSessionRecordStore { }): Promise => this.transact((draft) => claimAgentSessionOperationInto(draft, args)) - async recordOperationOutcome(args: { - callerKey?: string - operationId: string - outcome: AgentSessionOperationOutcome - }): Promise { + async recordOperationOutcome(args: AgentSessionOperationSettlement): Promise { await this.transact((draft) => settleAgentSessionOperationInto(draft, args)) } + /** The same settlement, committed by the journal write that makes it true. It changes only the + * ledger, so no record listener is owed. */ + operationOutcomeReceipt = (args: AgentSessionOperationSettlement): JournalOperationReceipt => + this.transactions.receipt((draft) => settleAgentSessionOperationInto(draft, args)) + replaceSessionOptions = (args: AgentSessionOptionsReplacement): Promise => this.mutate(args.sessionId, (record) => replaceAgentSessionRecordOptions(record, args)) diff --git a/src/main/runtime/agent-session-store-transactions.test.ts b/src/main/runtime/agent-session-store-transactions.test.ts index 5ab6cad9e02..ab268c08db7 100644 --- a/src/main/runtime/agent-session-store-transactions.test.ts +++ b/src/main/runtime/agent-session-store-transactions.test.ts @@ -140,6 +140,57 @@ describe('writing a transaction', () => { }) }) +describe('a receipt', () => { + const operationId = `${NOW}-${'7'.repeat(32)}` + const outcome = { status: 'succeeded' as const, sessionId: 'chat-a-0001' } + + async function pendingOperation(): Promise { + const store = await openTestAgentSessionRecordStore(root) + await liveChat(store, 'chat-a-0001') + await store.admitOperation({ callerKey: 'client-1', operationId, fingerprint: 'fp', now: NOW }) + return store + } + + async function persistedStatus(): Promise { + const reopened = await openTestAgentSessionRecordStore(root) + return reopened.getOperationRow('client-1', operationId)?.outcome.status + } + + // Written inside the caller's journal transaction, so it commits or rolls back with that write. + it('shows its rows in memory only once committed is called after the commit', async () => { + const store = await pendingOperation() + const receipt = store.operationOutcomeReceipt({ callerKey: 'client-1', operationId, outcome }) + + openTestJournalHostDatabase(root).transaction((db) => { + receipt.write(db) + expect(store.getOperationRow('client-1', operationId)?.outcome.status).toBe('pending') + }) + expect(store.getOperationRow('client-1', operationId)?.outcome.status).toBe('pending') + expect(await persistedStatus()).toBe('succeeded') + + receipt.committed() + expect(store.getOperationRow('client-1', operationId)?.outcome).toEqual(outcome) + }) + + it('leaves memory and rows as they were when the transaction rolls back', async () => { + const store = await pendingOperation() + const receipt = store.operationOutcomeReceipt({ callerKey: 'client-1', operationId, outcome }) + + expect(() => + openTestJournalHostDatabase(root).transaction((db) => { + receipt.write(db) + throw new Error('the journal row was refused') + }) + ).toThrow('the journal row was refused') + + expect(store.getOperationRow('client-1', operationId)?.outcome.status).toBe('pending') + expect(await persistedStatus()).toBe('pending') + // The next store transaction diffs from what committed, not from the discarded draft. + await store.setConversationName('chat-a-0001', 'after') + expect(await persistedStatus()).toBe('pending') + }) +}) + describe('a change a load would refuse', () => { it('rejects a tab id that could not prefix a pane key, and keeps memory and rows as they were', async () => { const store = await openTestAgentSessionRecordStore(root) diff --git a/src/main/runtime/agent-session-store-transactions.ts b/src/main/runtime/agent-session-store-transactions.ts index 879ff4a160e..31bddace873 100644 --- a/src/main/runtime/agent-session-store-transactions.ts +++ b/src/main/runtime/agent-session-store-transactions.ts @@ -8,6 +8,7 @@ // the async boundary every awaiting caller was written against. import type { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' +import type { JournalOperationReceipt } from '../native-chat/agent-session-journal/journal-row-writer' import { journalOpenRefusalError } from '../native-chat/agent-session-journal/journal-open-failure' import { AgentSessionJournalError } from '../native-chat/agent-session-journal/journal-write-guards' import type { AgentSessionStoreState } from './agent-session-record-store-file' @@ -104,6 +105,31 @@ export class AgentSessionStoreTransactions { return run } + /** + * `apply`'s rows, written inside a journal transaction the caller runs and adopted once it + * commits. Exact without the queue: `write` and `committed` run in one synchronous step, so no + * store transaction can commit between the draft's staging and its adoption. + */ + receipt(apply: (draft: AgentSessionStoreState) => void): JournalOperationReceipt { + let staged: StagedStoreTransaction | null = null + return { + write: (db) => { + if (this.journalDatabase.readOnly) { + throw readOnlyStoreRefusal() + } + staged = this.stage(apply) + const writes = staged.writes + if (writes) { + writeAgentSessionStoreRows(db, writes) + } + }, + committed: () => { + staged?.adopt() + staged = null + } + } + } + private commit(apply: (draft: AgentSessionStoreState) => T, inMemoryWhenReadOnly: boolean): T { const readOnly = this.journalDatabase.readOnly if (readOnly && !inMemoryWhenReadOnly) { diff --git a/src/shared/agent-session-mutation-envelope.ts b/src/shared/agent-session-mutation-envelope.ts index 770c184b562..a3acf31af4f 100644 --- a/src/shared/agent-session-mutation-envelope.ts +++ b/src/shared/agent-session-mutation-envelope.ts @@ -103,14 +103,7 @@ export function admitAgentSessionMutation(input: { return { decision: 'refused', refusal: mismatch } } if (ledger.decision === 'refused') { - return { - decision: 'refused', - refusal: refuse( - ledger.code, - ledger.details, - `Operation ${envelope.clientOperationId} was refused: ${ledger.code}.` - ) - } + return { decision: 'refused', refusal: agentSessionLedgerRefusal(envelope, ledger) } } if (ledger.decision === 'replay') { return { decision: 'replay', row: ledger.row } @@ -125,6 +118,18 @@ export function admitAgentSessionMutation(input: { return { decision: 'admit', row: ledger.row } } +/** What the ledger's own refusal of an operation id answers. */ +export function agentSessionLedgerRefusal( + envelope: Pick, + ledger: Extract +): AgentSessionWireRefusal { + return refuse( + ledger.code, + ledger.details, + `Operation ${envelope.clientOperationId} was refused: ${ledger.code}.` + ) +} + /** Why the single admission oracle said no, mapped to what the client can do * about it. The predicate itself is never re-implemented here. */ function refuseUnlessWriterAdmitted(lease: AgentSessionLease): AgentSessionWireRefusal | null { diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 47deda80d81..0ab7a210580 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -196,6 +196,20 @@ export const AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY = // mobile client lacks the capability; mobile must first show a rejected message in place. export const AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY = 'agent-session.accepted-send.v1' as const +// Why: a host advertising this answers a resent send id from its record before anything else may +// refuse it, so a refusal `agentSession.send` RETURNS is proof; a thrown error never is, a thrown +// refusal included (host not installed, journal database won't open, host disabled). Reading a +// returned `ok: false`: `agent_session_operation_unknown` with `outcomeUnknown` or `resultLost` — +// the host cannot tell yet, resend the same id; with `rewindUnconfirmed` — settled, nothing was +// written. `agent_session_operation_expired` — only the transcript can tell. An +// `agent_session_operation_conflict` or `messageIdReused` — the id holds a different payload, +// which proves nothing about this message; nor does `sessionNotAttached` (the chat's record is +// gone or unreadable on this host). Any other — the chat holds no message under that id and none +// is in flight, but a resend of that id may still run as a new send, so a client that hands the +// text back must not resend the old id. An older host may refuse an id it recorded: none of this +// holds there. +export const AGENT_SESSION_SEND_ANSWERS_PROOF_RUNTIME_CAPABILITY = + 'agent-session.send-answers-proof.v1' as const // Why: `agentSession.send`'s params are strict, so an older host rejects `delivery`; and only a // capable client can render the `queued` result arm, the draft list, and returned cards. DARK ON // PURPOSE — not in RUNTIME_CAPABILITIES: advertising still requires the integrated Codex steer @@ -400,6 +414,7 @@ export const RUNTIME_CAPABILITIES = [ // The host side: it accepts a send before any agent has it, and a Stop with no writer before a // turn starts, so a client may gate on either. AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY, + AGENT_SESSION_SEND_ANSWERS_PROOF_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_REVEAL_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RESUME_HISTORY_RUNTIME_CAPABILITY,