From 89ba968230d7e704b5754a43ef64fa7b91c6c84e Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 8 Oct 2026 06:14:29 -0700 Subject: [PATCH 01/90] ci: keep shared unit fixture out of package and browser jobs (#26575) --- .oxlintrc.json | 28 ++++++++++++ .../check-changed-code-quality.test.mjs | 34 +++++++++++++- config/scripts/pr-code-change-scope.mjs | 11 ++++- config/scripts/pr-e2e-source-routing.mjs | 3 +- .../scripts/pr-unit-support-routing.test.mjs | 45 +++++++++++++++++++ 5 files changed, 117 insertions(+), 4 deletions(-) create mode 100644 config/scripts/pr-unit-support-routing.test.mjs diff --git a/.oxlintrc.json b/.oxlintrc.json index fec10ce318ec..78dee1acd90f 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -140,6 +140,34 @@ "unicorn/throw-new-error": "error" }, "overrides": [ + { + "files": ["**/*.{js,cjs,mjs,jsx,ts,tsx,mts,cts}"], + "excludeFiles": [ + "**/*.{test,spec}.{js,cjs,mjs,jsx,ts,tsx,mts,cts}", + "**/__tests__/**", + "src/renderer/src/runtime/web-runtime-browser-creation-placement-test-rig.ts" + ], + "rules": { + "no-restricted-imports": [ + "error", + { + "paths": [ + { + "name": "@linear/sdk", + "allowTypeImports": true, + "message": "Value-importing @linear/sdk hoists its ~2.6MB CJS bundle into the eager top-level require block and defeats the lazy loader. Use `import type` plus loadLinearSdk() from src/main/linear/linear-sdk.ts." + } + ], + "patterns": [ + { + "regex": "(?i)(?:^|[/\\\\])(?:web-session-tabs-sync-test-harness|web-runtime-browser-creation-placement-test-rig)(?:\\.[cm]?[jt]sx?)?(?:[?#].*)?$", + "message": "This module is unit-test support. Import the production web-session owner instead." + } + ] + } + ] + } + }, { "files": ["src/renderer/src/**/*.{ts,tsx}"], "rules": { diff --git a/config/scripts/check-changed-code-quality.test.mjs b/config/scripts/check-changed-code-quality.test.mjs index 783ee140adcf..e1e93b3d53b4 100644 --- a/config/scripts/check-changed-code-quality.test.mjs +++ b/config/scripts/check-changed-code-quality.test.mjs @@ -131,10 +131,10 @@ describe('unloaded plugin directive unused warning', () => { // Assembled so no line here is itself a directive the gate would scan. const directive = (rule) => `/* oxlint-disable ${rule} -- reason */` - const withFixture = (firstLine, assert) => { + const withFixture = (firstLine, assert, filename = 'fixture.ts') => { const directory = mkdtempSync(path.join(root, 'config', 'anti-slop-directive-test-')) try { - const file = path.join(directory, 'fixture.ts') + const file = path.join(directory, filename) writeFileSync(file, [firstLine, 'export const value = 1', ''].join('\n')) assert({ message: 'Unused oxlint-disable directive (no problems were reported).', @@ -186,6 +186,36 @@ describe('unloaded plugin directive unused warning', () => { return JSON.parse(result.stdout).diagnostics } + it.each([ + "import './web-session-tabs-sync-test-harness'", + "export * from '@/runtime/web-session-tabs-sync-test-harness'", + "void import('@renderer/runtime/web-session-tabs-sync-test-harness.ts')", + "import '../runtime/web-runtime-browser-creation-placement-test-rig'" + ])('rejects unit-support imports in production source: %s', (source) => { + withFixture(source, ({ filename }) => { + const diagnostics = scanFixture('code quality', filename).filter( + (diagnostic) => diagnostic.code === 'eslint(no-restricted-imports)' + ) + expect(diagnostics).toHaveLength(1) + }) + }) + + it.each(['fixture.test.ts', 'fixture.spec.ts'])( + 'allows unit-support imports from the existing test convention: %s', + (filename) => { + withFixture( + "import '@/runtime/web-session-tabs-sync-test-harness'", + (diagnostic) => { + const diagnostics = scanFixture('code quality', diagnostic.filename).filter( + (finding) => finding.code === 'eslint(no-restricted-imports)' + ) + expect(diagnostics).toEqual([]) + }, + filename + ) + } + ) + it('accepts a used Doctor directive only through its loaded scan', () => { const source = [ "import { useEffect, useState } from 'react'", diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index 841cc9bd8e77..50ac7fec598d 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -564,8 +564,17 @@ function isProductBundlePath(file, extraPrefixes) { return matchesPrefix(file, extraPrefixes) } +// This shared fixture is consumed only by unit suites and their placement rig. +export function isUnitTestSupportSource(file) { + return file === 'src/renderer/src/runtime/web-session-tabs-sync-test-harness.ts' +} + function isTestFile(file) { - return /\.(?:test|spec)\.(?:js|cjs|mjs|ts|tsx)$/.test(file) || file.includes('/__tests__/') + return ( + isUnitTestSupportSource(file) || + /\.(?:test|spec)\.(?:js|cjs|mjs|ts|tsx)$/.test(file) || + file.includes('/__tests__/') + ) } function isDesktopIrrelevantPath(file) { diff --git a/config/scripts/pr-e2e-source-routing.mjs b/config/scripts/pr-e2e-source-routing.mjs index f080b28cb8d6..07b65c947d52 100644 --- a/config/scripts/pr-e2e-source-routing.mjs +++ b/config/scripts/pr-e2e-source-routing.mjs @@ -1,7 +1,8 @@ import process from 'node:process' import { pathToFileURL } from 'node:url' +import { isUnitTestSupportSource } from './pr-code-change-scope.mjs' -const isProductSource = (file) => !/\.test\.tsx?$/.test(file) +const isProductSource = (file) => !/\.test\.tsx?$/.test(file) && !isUnitTestSupportSource(file) // Why config/patches: the xterm fork owns the helper textarea an input method attaches to, so a // patch edit can break composition without touching a file named "ime". diff --git a/config/scripts/pr-unit-support-routing.test.mjs b/config/scripts/pr-unit-support-routing.test.mjs new file mode 100644 index 000000000000..318b74bc7800 --- /dev/null +++ b/config/scripts/pr-unit-support-routing.test.mjs @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import { classifyPrJobs } from './pr-code-change-scope.mjs' +import { selectPrE2eSpecs } from './pr-e2e-source-routing.mjs' + +const harness = 'src/renderer/src/runtime/web-session-tabs-sync-test-harness.ts' + +describe('shared web-session unit support routing', () => { + it('keeps unit checks and lint while excluding packaging for the exact harness', () => { + const result = classifyPrJobs([harness]) + expect(result.should_run).toBe(true) + expect(result.static_analysis).toBe(true) + expect(result.typecheck).toBe(true) + expect(result.test).toBe(true) + expect(result.package).toBe(false) + expect(result.package_windows).toBe(false) + }) + + it('retains packaging for unknown helpers and mixed product changes', () => { + for (const file of [ + 'src/renderer/src/runtime/web-session-tabs-sync-next-test-harness.ts', + 'src/renderer/src/runtime/web-session-tabs-sync.ts' + ]) { + const result = classifyPrJobs([harness, file]) + expect(result.should_run, file).toBe(true) + expect(result.test, file).toBe(true) + expect(result.package, file).toBe(true) + expect(result.package_windows, file).toBe(true) + } + }) + + it('does not turn the exact unit harness into a two-app E2E', () => { + expect(selectPrE2eSpecs([harness])).toEqual([]) + }) + + it('retains E2E selection for unknown helpers, product changes and selected specs', () => { + const spec = 'tests/e2e/paired-client-hosted-browser-restart-survival.spec.ts' + for (const file of [ + 'src/renderer/src/runtime/web-session-tabs-sync-next-test-harness.ts', + 'src/renderer/src/runtime/web-session-tabs-sync.ts', + spec + ]) { + expect(selectPrE2eSpecs([harness, file]), file).toContain(spec) + } + }) +}) From 907cb6e6038efda0fd1ee1975a3787200d0801b6 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 8 Oct 2026 07:10:24 -0700 Subject: [PATCH 02/90] ci: restore five unit runners and prune source-only tests (#26437) * ci: reduce unit runner demand and reuse the loaded preview frame * test: remove source spelling probes without behavioral assertions --- .github/workflows/pr.yml | 4 +- config/scripts/ci-unit-selection.mjs | 2 +- .../mobile-web-app-preview-arm-driver.mjs | 5 +- config/scripts/pr-preflight-gates.test.mjs | 2 +- src/main/pi/prefill-extension-source.test.ts | 14 -- src/renderer/src/lazy-use-ref-ratchet.test.ts | 130 ------------------ 6 files changed, 8 insertions(+), 149 deletions(-) delete mode 100644 src/main/pi/prefill-extension-source.test.ts delete mode 100644 src/renderer/src/lazy-use-ref-ratchet.test.ts diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 2bde5f48f5fb..0d27ea922046 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -432,8 +432,8 @@ jobs: 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' }} - # Set the repository variable to '5' to roll back full PR shards. - ORCA_UNIT_FULL_SHARD_COUNT: ${{ vars.ORCA_UNIT_FULL_SHARD_COUNT || '10' }} + # Five shards use fewer runner minutes; the repository variable can still request ten. + ORCA_UNIT_FULL_SHARD_COUNT: ${{ vars.ORCA_UNIT_FULL_SHARD_COUNT || '5' }} run: | if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi node config/scripts/ci-unit-plan.mjs diff --git a/config/scripts/ci-unit-selection.mjs b/config/scripts/ci-unit-selection.mjs index 0d2bbe68c29b..5a71c5f87fe0 100644 --- a/config/scripts/ci-unit-selection.mjs +++ b/config/scripts/ci-unit-selection.mjs @@ -35,7 +35,7 @@ export function selectUnitFiles(files, changed, graphInput) { // the account's slots and made the unit matrix 68% of daily slot demand. Against the checked-in // baseline, five shards each carry 24.7 test-minutes over four workers plus ~2.6 minutes of fixed // setup, so ~8.8 minutes -- less than the 10.5-minute p95 queue the oversharding was causing. -// Full PR workflows request ten; daily callers and selected drafts retain five. +// Full PR workflows and daily callers use five; selected drafts may use fewer. export const FULL_SHARD_COUNT = 5 export function planUnitSelection({ diff --git a/config/scripts/mobile-web-app-preview-arm-driver.mjs b/config/scripts/mobile-web-app-preview-arm-driver.mjs index 6ab780587558..5cc5b98f2496 100644 --- a/config/scripts/mobile-web-app-preview-arm-driver.mjs +++ b/config/scripts/mobile-web-app-preview-arm-driver.mjs @@ -186,7 +186,10 @@ export async function openPreviewArm( if (act) { // Recorded, never swallowed: a click that never landed and a click that produced no // navigation are the same empty counter, and only one of them is the product's doing. - await act({ page, frame: previewFrame(page) }).catch((error) => { + if (!artifactFrame || artifactFrame.isDetached()) { + throw new Error('The loaded preview frame is missing before its action') + } + await act({ page, frame: artifactFrame }).catch((error) => { actError = String(error).split('\n')[0] }) } diff --git a/config/scripts/pr-preflight-gates.test.mjs b/config/scripts/pr-preflight-gates.test.mjs index 443457a426a7..56b7926a6932 100644 --- a/config/scripts/pr-preflight-gates.test.mjs +++ b/config/scripts/pr-preflight-gates.test.mjs @@ -32,7 +32,7 @@ it('shares one setup and runs the unchanged compiler after static checks finish' 'node config/scripts/ci-unit-plan.mjs' ]) expect(plan.env.ORCA_UNIT_SELECTION_MODE).toContain('vars.ORCA_UNIT_SELECTION_MODE') - expect(plan.env.ORCA_UNIT_FULL_SHARD_COUNT).toBe("${{ vars.ORCA_UNIT_FULL_SHARD_COUNT || '10' }}") + expect(plan.env.ORCA_UNIT_FULL_SHARD_COUNT).toBe("${{ vars.ORCA_UNIT_FULL_SHARD_COUNT || '5' }}") expect(steps.indexOf(plan)).toBeLessThan(steps.indexOf(compiler)) expect(steps.indexOf(compiler)).toBeGreaterThan( steps.findIndex((step) => step.wait?.includes('localization-extraction')) diff --git a/src/main/pi/prefill-extension-source.test.ts b/src/main/pi/prefill-extension-source.test.ts deleted file mode 100644 index 9e36edd69bfd..000000000000 --- a/src/main/pi/prefill-extension-source.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { getPiPrefillExtensionSource } from './prefill-extension-source' - -describe('getPiPrefillExtensionSource', () => { - it('accepts OMP session_start events without a reason field', () => { - const source = getPiPrefillExtensionSource('omp') - expect(source).toContain('process.env.ORCA_OMP_PREFILL') - expect(source).not.toContain("event.reason !== 'startup'") - }) - - it('keeps Pi startup reason filtering', () => { - expect(getPiPrefillExtensionSource('pi')).toContain("event.reason !== 'startup'") - }) -}) diff --git a/src/renderer/src/lazy-use-ref-ratchet.test.ts b/src/renderer/src/lazy-use-ref-ratchet.test.ts deleted file mode 100644 index c0b23821bcb2..000000000000 --- a/src/renderer/src/lazy-use-ref-ratchet.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { readFileSync, readdirSync } from 'node:fs' -import path from 'node:path' -import { describe, expect, it } from 'vitest' - -/** - * `useRef(create())` evaluates its argument on EVERY render and discards every result after the - * first, so any real work there is pure waste. The lazy form keeps the same value with none of the - * churn: - * - * const ref = useRef(undefined!) - * ref.current ??= create() - * - * Use a `useState` lazy initializer instead when the seed can legitimately be null/undefined, or - * an explicit `seededRef` guard when both are true. - */ -const RENDERER_ROOT = import.meta.dirname - -function collectSourceFiles(dir: string, out: string[] = []): string[] { - for (const entry of readdirSync(dir, { withFileTypes: true })) { - const full = path.join(dir, entry.name) - if (entry.isDirectory()) { - if (entry.name === 'node_modules' || entry.name === 'dist') { - continue - } - collectSourceFiles(full, out) - continue - } - if (!/\.tsx?$/.test(entry.name) || /\.(test|spec)\.tsx?$/.test(entry.name)) { - continue - } - out.push(full) - } - return out -} - -/** Reads the balanced argument text of the `useRef(...)` starting at `from`. */ -function readUseRefArgument(source: string, from: number): { arg: string; end: number } | null { - let i = from - while (source[i] === ' ') { - i++ - } - if (source[i] === '<') { - let depth = 0 - while (i < source.length) { - if (source[i] === '<') { - depth++ - } else if (source[i] === '>') { - depth-- - if (depth === 0) { - i++ - break - } - } - i++ - } - } - while (source[i] === ' ') { - i++ - } - if (source[i] !== '(') { - return null - } - const argStart = i + 1 - let depth = 0 - while (i < source.length) { - if (source[i] === '(') { - depth++ - } else if (source[i] === ')') { - depth-- - if (depth === 0) { - return { arg: source.slice(argStart, i).trim(), end: i + 1 } - } - } - i++ - } - return null -} - -// Allowed because none of these is work that a render repeats for nothing: -// - an empty collection literal, which the repo keeps in the direct form -// - `undefined!`, the lazy-seed marker -// - a function literal, which is the ref's payload rather than its initialization -// - a primitive coercion of an already-computed value -const ALLOWED_ARGUMENT = new RegExp( - [ - '^new (Map|Set|WeakMap|WeakSet)(<[\\s\\S]*>)?\\(\\)$', - '^undefined!$', - '^(async )?\\([\\s\\S]*?\\)\\s*(:[^=]*)?=>[\\s\\S]*$', - '^(Boolean|Number|String)\\([\\s\\S]*\\)$', - '^[A-Za-z_$][\\w$.?\\[\\]\'"]*$' - ].join('|') -) - -function findNonLazyUseRefs(file: string): string[] { - const source = readFileSync(file, 'utf8') - const findings: string[] = [] - let index = 0 - while ((index = source.indexOf('useRef', index)) !== -1) { - const start = index - index += 'useRef'.length - if (/[\w$.]/.test(source[start - 1] ?? '')) { - continue - } - const parsed = readUseRefArgument(source, index) - if (!parsed) { - continue - } - index = parsed.end - const arg = parsed.arg - if (arg === '' || ALLOWED_ARGUMENT.test(arg)) { - continue - } - // Only a call or constructor invocation actually burns work per render. - if (!/\(/.test(arg) && !/\bnew\b/.test(arg)) { - continue - } - const line = source.slice(0, start).split('\n').length - findings.push( - `${path.relative(RENDERER_ROOT, file)}:${line} useRef(${arg.replace(/\s+/g, ' ').slice(0, 90)})` - ) - } - return findings -} - -describe('renderer useRef initializers', () => { - it('never does work in the useRef argument', () => { - const findings = collectSourceFiles(RENDERER_ROOT).flatMap(findNonLazyUseRefs) - expect(findings).toEqual([]) - }) -}) From 17703d0f536297356f115b08884bb945c801052a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 8 Oct 2026 09:55:51 -0700 Subject: [PATCH 03/90] fix(native-chat): never refuse a user's write because the retry record is full (#26543) * fix(native-chat): never refuse a user's write because the retry record is full The record that lets a retried chat write or agent launch replay instead of running twice refused every new write once it held 512 ids for one caller or 4,096 in total. Background agent traffic fills that in a normal day, so the user's own message was refused with "too many requests in the last day". Remove the count limit from the durable record, the in-memory launch record and the SSH relay's launch record; each entry still lives exactly as long as the retry window it protects. Clients still understand the refusal from older hosts, and its message now says to update Orca on that computer. Co-Authored-By: Claude Opus 5.5 * fix(native-chat): drop leftover cap wording and dead launch fallback; cover create records past the old cap * fix(native-chat): keep refusal and mailbox comments true after merge --------- Co-authored-by: Claude Opus 5.5 --- ...ctured-agent-session-refusal-retry.test.ts | 32 ++------------- ...ca-runtime-agent-session-operation.test.ts | 22 ++++++++++ src/main/runtime/orca-runtime-core.ts | 4 -- .../orca-runtime-create-agent-session.ts | 19 --------- ...rca-runtime-get-pty-record-for-pane-key.ts | 4 +- .../structured-mailbox-pointer-delivery.ts | 5 ++- .../structured-mailbox-pointer-host.test.ts | 2 +- .../structured-mailbox-pointer-host.ts | 6 +-- .../agent-launch-restart-replay.test.ts | 17 ++++---- src/relay/pty-handler-spawn-admission.test.ts | 28 +++++++++++++ src/relay/pty-handler.ts | 4 -- ...-structured-agent-session-options.test.tsx | 2 +- .../src/i18n/en-runtime-required.json | 2 +- src/renderer/src/i18n/locales/en.json | 2 +- src/renderer/src/i18n/locales/es.json | 2 +- src/renderer/src/i18n/locales/fr.json | 2 +- src/renderer/src/i18n/locales/ja.json | 2 +- src/renderer/src/i18n/locales/ko.json | 2 +- src/renderer/src/i18n/locales/zh.json | 2 +- .../src/lib/agent-launch-through-host.test.ts | 35 ---------------- .../src/lib/agent-launch-through-host.ts | 31 -------------- .../agent-session-operation-ledger.test.ts | 40 ++++++++++++------- src/shared/agent-session-operation-ledger.ts | 35 ++++------------ .../agent-session-refusal-notice.test.ts | 6 ++- src/shared/agent-session-refusal-notice.ts | 6 +-- .../agent-session-refusal-reason-words.ts | 2 +- src/shared/agent-session-write-notice-copy.ts | 3 +- 27 files changed, 122 insertions(+), 195 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts index 55489d66c268..2a201e94ef0a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts @@ -3,7 +3,6 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi, type Mock } from 'vitest' import { AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS } from '../../../shared/agent-session-host-authority' -import { AGENT_SESSION_DURABLE_OPERATION_PER_CLIENT_LIMIT } from '../../../shared/agent-session-operation-ledger' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { @@ -198,20 +197,6 @@ async function setLease( await harness.store.transitionHandoff(SESSION, update) } -async function fillOperationLedger(harness: Harness): Promise { - while ( - harness.store.listOperationRows().filter((row) => row.callerKey === CALLER.callerKey).length < - AGENT_SESSION_DURABLE_OPERATION_PER_CLIENT_LIMIT - ) { - await harness.store.admitOperation({ - callerKey: CALLER.callerKey, - operationId: operationId(), - fingerprint: 'capacity-fixture', - now: NOW - }) - } -} - // sendPlan and setOptionPlan have no unsupported branch. const UNREACHABLE = new Set([ 'agentSession.send:structured_agent_session_unsupported', @@ -237,7 +222,10 @@ const UNREACHABLE = new Set([ // fails rejects the accepted message rather than refusing the call. 'agentSession.send:agent_session_conflict', 'agentSession.send:execution_owner_reconciling', - 'agentSession.send:agent_session_owner_restart_failed' + 'agentSession.send:agent_session_owner_restart_failed', + // The ledger has no count limit; only an older host still refuses with it. + 'agentSession.setOption:agent_session_operation_capacity', + 'agentSession.send:agent_session_operation_capacity' ]) describe('agentSessionRefusalOperationState host oracle', () => { @@ -315,18 +303,6 @@ describe('agentSessionRefusalOperationState host oracle', () => { } } - const capacity = await createHarness() - await fillOperationLedger(capacity) - for (const method of METHODS) { - const spec = { method, operationId: operationId() } - record( - await assertHostAgreement(capacity, spec, 'agent_session_operation_capacity', async () => ({ - harness: await createHarness(), - spec - })) - ) - } - const unknown = await createHarness() unknown.setOption.mockRejectedValueOnce(new Error('reply lost')) const optionUnknown = { method: 'agentSession.setOption' as const, operationId: operationId() } diff --git a/src/main/runtime/orca-runtime-agent-session-operation.test.ts b/src/main/runtime/orca-runtime-agent-session-operation.test.ts index 37b329670d27..e20ef08cc580 100644 --- a/src/main/runtime/orca-runtime-agent-session-operation.test.ts +++ b/src/main/runtime/orca-runtime-agent-session-operation.test.ts @@ -336,6 +336,28 @@ describe('agent-session create operation ledger', () => { expect(createTerminal).toHaveBeenCalledTimes(2) }) + it('starts a create however many unexpired operations are held, and still replays the first', async () => { + // Why: a count limit (512 per caller, 4,096 in all) refused a user's create for unrelated traffic. + const runtime = createRuntime() + const createTerminal = vi.spyOn(runtime, 'createTerminal').mockResolvedValue(terminal()) + const now = Date.now() + const ids = Array.from( + { length: 4_097 }, + (_, index) => `${now}-${index.toString(16).padStart(32, '0')}` + ) + const dispositions = new Set() + for (const id of ids) { + const created = await runtime.createAgentSession(request(id), { clientId: 'device-a' }) + dispositions.add(created.disposition) + } + + expect([...dispositions]).toEqual(['created']) + await expect( + runtime.createAgentSession(request(ids[0]!), { clientId: 'device-a' }) + ).resolves.toMatchObject({ disposition: 'replayed' }) + expect(createTerminal).toHaveBeenCalledTimes(4_097) + }) + it('rejects an expired unseen operation before terminal creation', async () => { const runtime = createRuntime() const createTerminal = vi.spyOn(runtime, 'createTerminal').mockResolvedValue(terminal()) diff --git a/src/main/runtime/orca-runtime-core.ts b/src/main/runtime/orca-runtime-core.ts index bf5821215e8f..a25b2a4d35ac 100644 --- a/src/main/runtime/orca-runtime-core.ts +++ b/src/main/runtime/orca-runtime-core.ts @@ -58,10 +58,6 @@ export function isCursorAgentOrchestrationTarget( return [leaf.lastOscTitle, leaf.paneTitle, tabTitle].some(isCursorAgentTitle) } -export const AGENT_SESSION_OPERATION_PER_CLIENT_LIMIT = 512 - -export const AGENT_SESSION_OPERATION_GLOBAL_LIMIT = 4_096 - // Why: long enough for a phone to reconnect and retry a create whose response // was lost, short enough that an intentional later re-resume forks fresh. export const MOBILE_TERMINAL_CREATE_RESULT_TTL_MS = 60_000 diff --git a/src/main/runtime/orca-runtime-create-agent-session.ts b/src/main/runtime/orca-runtime-create-agent-session.ts index b2ba25b7d739..dc8ea3f2aeeb 100644 --- a/src/main/runtime/orca-runtime-create-agent-session.ts +++ b/src/main/runtime/orca-runtime-create-agent-session.ts @@ -11,10 +11,6 @@ import { parseAgentSessionOperationTimestamp } from '../../shared/agent-session-host-authority' import { createHash } from 'node:crypto' -import { - AGENT_SESSION_OPERATION_GLOBAL_LIMIT, - AGENT_SESSION_OPERATION_PER_CLIENT_LIMIT -} from './orca-runtime-core' import { isTuiAgentEnabled } from '../../shared/tui-agent-selection' import { resolveAgentStartupPlanInputs } from '../../shared/agent-startup-plan-inputs' import { buildExecutionHostAgentStartupPlan } from '../opencode/opencode-model-startup-plan' @@ -90,21 +86,6 @@ export class OrcaRuntimeWithCreateAgentSession extends OrcaRuntimeWithGetAgentSe // be reinterpreted as permission to start another fresh agent. throw new Error('agent_session_operation_expired') } - let callerOperationCount = 0 - const callerPrefix = `${callerKey}\0` - for (const key of this.agentSessionCreateOperations.keys()) { - if (key.startsWith(callerPrefix)) { - callerOperationCount += 1 - } - } - if ( - callerOperationCount >= AGENT_SESSION_OPERATION_PER_CLIENT_LIMIT || - this.agentSessionCreateOperations.size >= AGENT_SESSION_OPERATION_GLOBAL_LIMIT - ) { - // Why: tombstones cannot be evicted early without making an old replay - // capable of spawning again; reject new IDs until retained entries age out. - throw new Error('agent_session_operation_capacity') - } let retainReplayFence = false const reclaim: AgentSessionCreateOperation['reclaim'] = {} const operation = (async (): Promise => { diff --git a/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts b/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts index f5ad8fa45b82..f4688f982ba8 100644 --- a/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts +++ b/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts @@ -313,8 +313,8 @@ export class OrcaRuntimeWithGetPtyRecordForPaneKey extends OrcaRuntimeWithPruneM * success and the peer waiting on a reply hangs. * * The worker's ACTIVE dispatch is preferred when it has one, so peer and coordinator nudges share - * one operation-ledger budget and one set of retain rules. A worker BETWEEN dispatches is still - * nudged, under a session-scoped budget: the mail is durable, the session is live, and a dispatch + * one caller key and one set of retain rules. A worker BETWEEN dispatches is still nudged, under + * a session-scoped caller key: the mail is durable, the session is live, and a dispatch * says nothing about whether delivery is safe — the idle gate and the writer lease do that. */ protected resolveStructuredWorkerDirectMailboxTarget( diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts index d8a369ce4977..d82d93e5c9fd 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts @@ -37,8 +37,9 @@ export type StructuredPointerTarget = { sessionId: string /** * The dispatch whose mailbox this is, or null for direct peer mail addressed to the worker's own - * handle outside any dispatch. Nothing downstream needs a dispatch to deliver — it only scopes - * the operation-ledger budget — so a worker between dispatches is nudged, not dropped. + * handle outside any dispatch. Nothing downstream needs a dispatch to deliver — it only names + * the caller on the operation row and the mail's source — so a worker between dispatches is + * nudged, not dropped. */ dispatchId: string | null } diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts index fb0b8313cfd5..dfef7cd05776 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts @@ -114,7 +114,7 @@ describe('structured mailbox pointer host', () => { body: { kind: 'message', role: 'user', blocks: [] } } as never) ).resolves.toEqual({ kind: 'sent', state: expected }) - // Per-dispatch, so one worker's nudges cannot exhaust the shared operation-ledger budget. + // Per-dispatch caller key: names the dispatch the nudge is for. expect(send.mock.calls[0]![0]).toEqual({ callerKey: structuredPointerCallerKey('d1') }) expect(send.mock.calls[0]![1]!.retryUnknown).toBeUndefined() }) diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts index f81ef2c229f3..94b54389a559 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts @@ -19,14 +19,14 @@ import { } from './structured-session-pointer-delivery' import { sendAgentTurn } from './send-agent-turn' -/** Per-dispatch so one worker's nudges cannot exhaust the shared runtime operation-ledger budget. */ +/** Names the dispatch a nudge belongs to on the operation row it writes. */ export function structuredPointerCallerKey(dispatchId: string): string { return `trusted-local:orchestration:${dispatchId}` } /** - * The same budget for direct peer mail, which is addressed to the worker's own handle and has no - * dispatch to scope to. + * The caller key for direct peer mail, which is addressed to the worker's own handle and has no + * dispatch to name. * * A separate key rather than a reshaped one: the ledger is keyed on (callerKey, operationId), so * changing the dispatch key's shape would orphan every nudge already in flight under the old one. diff --git a/src/main/runtime/rpc/methods/agent-launch-restart-replay.test.ts b/src/main/runtime/rpc/methods/agent-launch-restart-replay.test.ts index 16abdd519933..00ed70257c93 100644 --- a/src/main/runtime/rpc/methods/agent-launch-restart-replay.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-restart-replay.test.ts @@ -516,7 +516,7 @@ describe('a caller cannot claim an identity', () => { }) }) -describe('the ledger stays bounded', () => { +describe('one row per launch, with no limit on how many a caller holds', () => { it('keeps one row per launch, retained from admission, across both writes', async () => { let releasePaste: (pasted: boolean) => void = () => {} deliverTerminalPrompt.mockImplementationOnce( @@ -536,9 +536,10 @@ describe('the ledger stays bounded', () => { expect(row()?.recordedAt).toBe(afterFirstWrite?.recordedAt) }) - it('holds the desktop to the same per-caller cap as every other caller', async () => { + it('starts the desktop launch however many unexpired operations the caller already holds', async () => { + // Why: a full ledger used to refuse the user's launch for traffic unrelated to it. const now = Date.now() - for (let index = 0; index < 512; index += 1) { + for (let index = 0; index < 600; index += 1) { await store.admitOperation({ callerKey: 'trusted-local:desktop', operationId: `${now}-${index.toString(16).padStart(32, '0')}`, @@ -548,11 +549,9 @@ describe('the ledger stays bounded', () => { } const host = hostRuntime() - await expect(launch(host, PROMPTED_LAUNCH, { ...DESKTOP_IPC })).rejects.toThrow( - 'agent_session_operation_capacity' - ) - expect(host.createTerminal).not.toHaveBeenCalled() - // Another caller's namespace is not starved by it. - await expect(launch(host)).resolves.toMatchObject({ outcome: { kind: 'terminal' } }) + await expect(launch(host, PROMPTED_LAUNCH, { ...DESKTOP_IPC })).resolves.toMatchObject({ + outcome: { kind: 'terminal' } + }) + expect(host.createTerminal).toHaveBeenCalledTimes(1) }) }) diff --git a/src/relay/pty-handler-spawn-admission.test.ts b/src/relay/pty-handler-spawn-admission.test.ts index 7e0dc3f70c63..c28e2693854a 100644 --- a/src/relay/pty-handler-spawn-admission.test.ts +++ b/src/relay/pty-handler-spawn-admission.test.ts @@ -212,6 +212,34 @@ describe('PtyHandler', () => { expect(handler.activePtyCount).toBe(1) }) + it('starts a create however many create records it holds, and still replays an earlier one', async () => { + // Why: a 4,096-record limit refused a user's create for unrelated traffic. + const request = (operationId: string) => ({ + cols: 80, + rows: 24, + agentSessionCreateOperationId: operationId + }) + await dispatcher.callRequest('pty.spawn', request('a'.repeat(43))) + const records = handler['agentSessionCreateOperations'] + const held = records.get('a'.repeat(43))! + for (let index = records.size; index < 4_096; index += 1) { + records.set(`held-${index}`, held) + } + + await expect(dispatcher.callRequest('pty.spawn', request('b'.repeat(43)))).resolves.toEqual({ + id: testPtyId(2), + incarnationId: expect.any(String), + shellReadyArmed: false + }) + await expect(dispatcher.callRequest('pty.spawn', request('a'.repeat(43)))).resolves.toEqual({ + id: testPtyId(1), + incarnationId: expect.any(String), + shellReadyArmed: false + }) + expect(records.size).toBe(4_097) + expect(mockPtySpawn).toHaveBeenCalledTimes(2) + }) + it('retains an operation fence when publication fails after native spawn', async () => { const operationId = 'f'.repeat(43) mockPtySpawn.mockReturnValue({ diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 750880d55f95..6ccf242ae30d 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -313,7 +313,6 @@ type RelayAgentSessionCreateResult = { const AGENT_SESSION_CREATE_OPERATION_ID_PATTERN = /^[A-Za-z0-9_-]{43}$/ const AGENT_SESSION_CREATE_OPERATION_RETENTION_MS = 24 * 60 * 60 * 1000 -const AGENT_SESSION_CREATE_OPERATION_LIMIT = 4_096 type PendingPtyOutput = RelayPtySourceOutput & { data: string @@ -1878,9 +1877,6 @@ export class PtyHandler { const { sourceActivation: _staleActivation, ...stableResult } = result return { ...stableResult, ...(sourceActivation ? { sourceActivation } : {}) } } - if (this.agentSessionCreateOperations.size >= AGENT_SESSION_CREATE_OPERATION_LIMIT) { - throw new Error('agent_session_operation_capacity') - } const operation = this.spawnOnce(params, context) this.agentSessionCreateOperations.set(operationId, operation) try { diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-options.test.tsx index 3dba8823ab9f..8474a20c8337 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-options.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.test.tsx @@ -430,7 +430,7 @@ describe('useStructuredAgentSessionOptions', () => { }) await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith( - "Orca has received too many requests in the last day. The setting wasn't changed." + "The setting wasn't changed. Orca on the computer running this chat has hit a request limit. Update Orca there, then try again." ) ) expect(mocks.enqueue).not.toHaveBeenCalled() diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index d7a0d5dc6141..dde13094adf4 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -3016,7 +3016,7 @@ "attachmentExpired": "This attachment expired.", "backgroundTasksRunning": "Background tasks are still running.", "cannotRunHere": "Orca can't run this agent in a chat here.", - "capacity": "Orca has received too many requests in the last day.", + "capacity": "Orca on the computer running this chat has hit a request limit. Update Orca there, then try again.", "chatNotFound": "Orca can't find this chat.", "chatSavedByNewerOrca": "This chat was saved by a newer Orca.", "clearUnfinished": "The last /clear didn't finish.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index d1bfaf4285e1..23b880c49463 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18073,7 +18073,7 @@ "notDoneCommand": "The command didn't run.", "notDoneGoal": "The goal wasn't changed.", "restartFailed": "The agent couldn't restart.", - "capacity": "Orca has received too many requests in the last day.", + "capacity": "Orca on the computer running this chat has hit a request limit. Update Orca there, then try again.", "outcomeUnknown": "Orca couldn't confirm what happened. Check the chat.", "sendOutcomeLost": "Orca couldn't confirm your message reached the agent. Check the chat, then send it again if needed.", "questionChanged": "This question was already answered or has changed.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 750fbef31672..9ac13f4bd6c3 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -17840,7 +17840,7 @@ "notDoneCommand": "El comando no se ejecutó.", "notDoneGoal": "El objetivo no se cambió.", "restartFailed": "El agente no pudo reiniciarse.", - "capacity": "Orca ha recibido demasiadas solicitudes en el último día.", + "capacity": "Orca en el equipo que ejecuta este chat alcanzó un límite de solicitudes. Actualice Orca en ese equipo y vuelva a intentarlo.", "outcomeUnknown": "Orca no pudo confirmar qué pasó. Revise el chat.", "sendOutcomeLost": "Orca no pudo confirmar que su mensaje llegó al agente. Revise el chat y vuelva a enviarlo si es necesario.", "questionChanged": "Esta pregunta ya se respondió o ha cambiado.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 92ac812a7988..2c071883f175 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17941,7 +17941,7 @@ "notDoneCommand": "La commande n'a pas été exécutée.", "notDoneGoal": "L'objectif n'a pas été modifié.", "restartFailed": "L'agent n'a pas pu redémarrer.", - "capacity": "Orca a reçu trop de requêtes au cours de la dernière journée.", + "capacity": "Orca sur l'ordinateur qui exécute ce chat a atteint une limite de requêtes. Mettez à jour Orca sur cet ordinateur, puis réessayez.", "outcomeUnknown": "Orca n'a pas pu confirmer ce qui s'est passé. Vérifiez le chat.", "sendOutcomeLost": "Orca n'a pas pu confirmer que votre message est parvenu à l'agent. Vérifiez le chat, puis renvoyez-le si nécessaire.", "questionChanged": "Cette question a déjà reçu une réponse ou a changé.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index caed57c905b4..cdb717eb1825 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17875,7 +17875,7 @@ "notDoneCommand": "コマンドは実行されませんでした。", "notDoneGoal": "目標は変更されませんでした。", "restartFailed": "Agent を再起動できませんでした。", - "capacity": "Orca が過去 1 日に受け取ったリクエストが多すぎます。", + "capacity": "このチャットを実行しているコンピューターの Orca がリクエストの上限に達しました。そのコンピューターで Orca を更新してから、もう一度お試しください。", "outcomeUnknown": "Orca は何が起きたかを確認できませんでした。チャットを確認してください。", "sendOutcomeLost": "Orca はメッセージが Agent に届いたことを確認できませんでした。チャットを確認し、必要であればもう一度送信してください。", "questionChanged": "この質問はすでに回答済みか、変更されています。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 2684f911e6c1..10ab06446601 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17875,7 +17875,7 @@ "notDoneCommand": "명령이 실행되지 않았습니다.", "notDoneGoal": "목표가 변경되지 않았습니다.", "restartFailed": "에이전트를 다시 시작하지 못했습니다.", - "capacity": "Orca가 지난 하루 동안 너무 많은 요청을 받았습니다.", + "capacity": "이 채팅을 실행하는 컴퓨터의 Orca가 요청 한도에 도달했습니다. 해당 컴퓨터에서 Orca를 업데이트한 다음 다시 시도하세요.", "outcomeUnknown": "Orca가 무슨 일이 있었는지 확인하지 못했습니다. 채팅을 확인하세요.", "sendOutcomeLost": "Orca가 메시지가 에이전트에 전달되었는지 확인하지 못했습니다. 채팅을 확인하고 필요하면 다시 보내세요.", "questionChanged": "이 질문은 이미 답변되었거나 변경되었습니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 60f088cf0b4a..31421a767571 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17840,7 +17840,7 @@ "notDoneCommand": "命令未运行。", "notDoneGoal": "目标未更改。", "restartFailed": "智能体无法重新启动。", - "capacity": "Orca 在过去一天内收到的请求过多。", + "capacity": "运行此聊天的计算机上的 Orca 已达到请求上限。请在那台计算机上更新 Orca,然后重试。", "outcomeUnknown": "Orca 无法确认发生了什么。请查看聊天。", "sendOutcomeLost": "Orca 无法确认你的消息已送达智能体。请查看聊天,如有需要请重新发送。", "questionChanged": "此问题已被回答或已更改。", diff --git a/src/renderer/src/lib/agent-launch-through-host.test.ts b/src/renderer/src/lib/agent-launch-through-host.test.ts index 1967e6d8097c..abccb529cc46 100644 --- a/src/renderer/src/lib/agent-launch-through-host.test.ts +++ b/src/renderer/src/lib/agent-launch-through-host.test.ts @@ -205,39 +205,4 @@ describe('a desktop launch through the host', () => { callRuntimeRpc.mockRejectedValueOnce(rpcError('agent_launch_tab_closed')) await expect(launch().outcome).resolves.toEqual({ kind: 'closed-by-user' }) }) - - it('still starts the agent in the same tab when the launch record is full', async () => { - const unrecorded = deferred() - callRuntimeRpc - .mockRejectedValueOnce(rpcError('agent_session_operation_capacity')) - .mockReturnValueOnce(unrecorded.promise) - const { tabId, outcome } = launch() - const paneKey = lastPaneKey() - await vi.waitFor(() => expect(callRuntimeRpc).toHaveBeenCalledTimes(2)) - - const [, method, params] = callRuntimeRpc.mock.calls[1]! - expect(method).toBe('agent.launch') - expect(params).not.toHaveProperty('operationId') - // The same pane, held until the answer. - expect(params.paneKey).toBe(paneKey) - const leafId = launchTab(tabId)!.agentLaunchPane!.leafId - expect(agentLaunchPaneSpawnHold(tabId, leafId)).not.toBeNull() - - unrecorded.resolve(terminalResult(paneKey)) - await expect(outcome).resolves.toEqual({ kind: 'started' }) - }) - - it('takes its tab back when the unrecorded launch fails before the host revealed it', async () => { - callRuntimeRpc - .mockRejectedValueOnce(rpcError('agent_session_operation_capacity')) - .mockRejectedValueOnce(rpcError('worktree_not_found')) - const { tabId, outcome } = launch() - - await expect(outcome).resolves.toEqual({ - kind: 'not-started', - unconfirmed: false, - code: 'worktree_not_found' - }) - expect(launchTab(tabId)).toBeUndefined() - }) }) diff --git a/src/renderer/src/lib/agent-launch-through-host.ts b/src/renderer/src/lib/agent-launch-through-host.ts index 89a1c36fb268..872c62a99e2d 100644 --- a/src/renderer/src/lib/agent-launch-through-host.ts +++ b/src/renderer/src/lib/agent-launch-through-host.ts @@ -106,33 +106,6 @@ function launchParams(args: HostAgentLaunchArgs) { } } -/** - * The ledger is bookkeeping: a desktop past its per-caller row cap still gets its agent, through the - * host's unrecorded launch into the same pane. The refused launch already took back the tab it was - * shown, so the tab closes and returns at spawn (rare: 512 launches a day); the host's reveal at - * spawn hands the pane over (`terminal-presentation-ipc-bridge`). - */ -async function launchWithoutRecord( - args: HostAgentLaunchArgs, - pane: { tabId: string; leafId: string } -): Promise { - try { - return outcomeFromResult( - await callRuntimeRpc({ kind: 'local' }, 'agent.launch', { - ...launchParams(args), - paneKey: makePaneKey(pane.tabId, pane.leafId) - }) - ) - } catch (error) { - const code = error instanceof RuntimeRpcCallError ? error.code : undefined - // A pane the host never revealed would open as a shell; one it did holds the agent's terminal. - if (isAgentLaunchPaneSpawnHeld(pane.tabId, pane.leafId)) { - closeLaunchTab(args.worktreeId, pane.tabId) - } - return { kind: 'not-started', unconfirmed: false, ...(code ? { code } : {}) } - } -} - async function settleLaunch( args: HostAgentLaunchArgs, pane: { tabId: string; leafId: string }, @@ -146,10 +119,6 @@ async function settleLaunch( if (code === AGENT_LAUNCH_TAB_CLOSED_CODE) { return { kind: 'closed-by-user' } } - if (code === 'agent_session_operation_capacity') { - // Awaited: the pane stays held until the unrecorded launch has its answer. - return await launchWithoutRecord(args, pane) - } // The host took the pane once it showed the tab; a pane it never took would open as a shell, // and a refused one is the host's to take back. const hostTookPane = !isAgentLaunchPaneSpawnHeld(pane.tabId, pane.leafId) diff --git a/src/shared/agent-session-operation-ledger.test.ts b/src/shared/agent-session-operation-ledger.test.ts index 109f196fd97e..7c4cd70e8561 100644 --- a/src/shared/agent-session-operation-ledger.test.ts +++ b/src/shared/agent-session-operation-ledger.test.ts @@ -28,8 +28,6 @@ function evaluate( operationId: string fingerprint: string now: number - perClientLimit: number - globalLimit: number }> = {} ) { return evaluateAgentSessionOperation({ @@ -115,21 +113,33 @@ describe('operation admission', () => { ).toBe('admit') }) - it('refuses new ids at the per-client and global caps rather than evicting tombstones', () => { + it('admits a fresh id however many unexpired rows other callers hold', () => { + // Why: a count cap filled by background agent traffic used to refuse the user's own send. const rows = new Map() - admit(rows, { operationId: operationId(NOW, 'b'.repeat(32)) }) - expect(evaluate(rows, { perClientLimit: 1 })).toEqual({ - decision: 'refused', - code: 'agent_session_operation_capacity', - details: { reason: 'operationCapacity' } - }) - // A different caller is still refused once the global cap is reached. - expect(evaluate(rows, { callerKey: 'client-2', globalLimit: 1 })).toEqual({ - decision: 'refused', - code: 'agent_session_operation_capacity', - details: { reason: 'operationCapacity' } + for (let index = 0; index < 5_000; index += 1) { + admit(rows, { + callerKey: `agent-${index % 10}`, + operationId: operationId(NOW - index, index.toString(16).padStart(32, '0')) + }) + } + for (let index = 0; index < 600; index += 1) { + admit(rows, { + callerKey: 'desktop', + operationId: operationId(NOW - index, `d${index.toString(16).padStart(31, '0')}`) + }) + } + const fresh = evaluate(rows, { + callerKey: 'desktop', + operationId: operationId(NOW, 'e'.repeat(32)) }) - expect(evaluate(rows, { callerKey: 'client-2', perClientLimit: 1 }).decision).toBe('admit') + expect(fresh.decision).toBe('admit') + // Retries of earlier ids still replay rather than running again. + expect( + evaluate(rows, { + callerKey: 'agent-0', + operationId: operationId(NOW, '0'.repeat(32)) + }).decision + ).toBe('replay') }) }) diff --git a/src/shared/agent-session-operation-ledger.ts b/src/shared/agent-session-operation-ledger.ts index 0467c361dc2b..8c34edc7bcb0 100644 --- a/src/shared/agent-session-operation-ledger.ts +++ b/src/shared/agent-session-operation-ledger.ts @@ -11,10 +11,14 @@ import { * Durable client-operation ledger. * * `terminal.ensureAgentSession` / `terminal.createAgentSession` already enforce timestamped - * operation ids with fingerprint conflict detection, age expiry, capacity limits, and tombstone - * retention — but in memory, so a host restart turns "replay this create" into "spawn another - * agent". These are the same rules over rows that survive a restart; the store writes a row in - * the same atomic transaction as the lease reservation. + * operation ids with fingerprint conflict detection, age expiry, and tombstone retention — but in + * memory, so a host restart turns "replay this create" into "spawn another agent". These are the + * same rules over rows that survive a restart; the store writes a row in the same atomic + * transaction as the lease reservation. + * + * There is no count limit. Rows are bookkeeping for retries, and a full ledger refused every + * caller's next write, including the user's own send behind unrelated agent traffic. A row's only + * lifetime is the replay window it protects (`agentSessionOperationExpiry`). */ import { @@ -27,9 +31,6 @@ import { type AgentSessionConversationCommandResult } from './agent-session-conversation-command' -export const AGENT_SESSION_DURABLE_OPERATION_PER_CLIENT_LIMIT = 512 -export const AGENT_SESSION_DURABLE_OPERATION_GLOBAL_LIMIT = 4_096 - export type AgentSessionOperationOutcome = | { status: 'pending' } | { @@ -116,7 +117,6 @@ export type AgentSessionOperationRefusalCode = | 'agent_session_operation_invalid' | 'agent_session_operation_conflict' | 'agent_session_operation_expired' - | 'agent_session_operation_capacity' export type AgentSessionOperationDecision = | { decision: 'replay'; row: AgentSessionOperationRow } @@ -271,8 +271,6 @@ export function evaluateAgentSessionOperation(args: { operationId: string fingerprint: string now: number - perClientLimit?: number - globalLimit?: number }): AgentSessionOperationDecision { const { rows, callerKey, operationId, fingerprint, now } = args const operationTimestamp = parseAgentSessionOperationTimestamp(operationId) @@ -307,23 +305,6 @@ export function evaluateAgentSessionOperation(args: { details: { reason: 'operationExpired' } } } - const perClientLimit = args.perClientLimit ?? AGENT_SESSION_DURABLE_OPERATION_PER_CLIENT_LIMIT - const globalLimit = args.globalLimit ?? AGENT_SESSION_DURABLE_OPERATION_GLOBAL_LIMIT - let callerCount = 0 - for (const row of rows.values()) { - if (row.callerKey === callerKey) { - callerCount += 1 - } - } - if (callerCount >= perClientLimit || rows.size >= globalLimit) { - // Why: tombstones cannot be evicted early without making an old replay capable of spawning - // again; reject new ids until retained rows age out. - return { - decision: 'refused', - code: 'agent_session_operation_capacity', - details: { reason: 'operationCapacity' } - } - } return { decision: 'admit', row: pendingAgentSessionOperationRow({ callerKey, operationId, fingerprint, now }) diff --git a/src/shared/agent-session-refusal-notice.test.ts b/src/shared/agent-session-refusal-notice.test.ts index 4ca0ebf2f4f5..ca9ebfb67c39 100644 --- a/src/shared/agent-session-refusal-notice.test.ts +++ b/src/shared/agent-session-refusal-notice.test.ts @@ -53,7 +53,7 @@ const HOST_TEXT = 'Expected runtime fence 1; the session is at 3.' const CAUSES: Partial> = { // Only send preparation, when the owner it restarted for this send failed to start. agent_session_owner_restart_failed: 'restartFailed', - // Only the ledger, when a day's retained operation ids fill a client's or the host's quota. + // Only an older host's ledger, when a day's retained operation ids filled its quota. agent_session_operation_capacity: 'capacity', // A replayed operation with no recorded outcome, or a send behind a rewind whose outcome is // unrecorded: either way Orca cannot say what happened. @@ -162,7 +162,9 @@ describe('the notice for every failure and write', () => { const phoneResend = write === 'composer-send' && RESEND_CAN_WORK.has(codeOf(failure)) expect(parts.includes('tryAgainComposerSend'), cell).toBe(phoneResend) expect(/again/i.test(english), cell).toBe( - phoneResend || codeOf(failure) === 'structured_agent_session_unsupported' + phoneResend || + codeOf(failure) === 'structured_agent_session_unsupported' || + codeOf(failure) === 'agent_session_operation_capacity' ) } for (const reason of [null, DISPATCH_REJECTED_WRITE_FAILED, DISPATCH_REJECTED_QUEUE_FULL]) { diff --git a/src/shared/agent-session-refusal-notice.ts b/src/shared/agent-session-refusal-notice.ts index b3038c6170f7..c95f175f0cb0 100644 --- a/src/shared/agent-session-refusal-notice.ts +++ b/src/shared/agent-session-refusal-notice.ts @@ -167,10 +167,10 @@ export function agentSessionWriteNoticeParts( case 'agent_session_ownership_unknown': case 'execution_owner_reconciling': return agentSessionWriteNotDoneParts(write) - // Counted across every chat and freed only as a day's requests age out, so trying again now - // would likely be refused again. + // Only an older Orca host (one that capped its operation records) refuses this way; updating + // it is the fix, since retrying soon would be refused again. case 'agent_session_operation_capacity': - return ['capacity', notDone] + return [notDone, 'capacity'] // The phone resends under the same id, which the host refuses the same way again. The rest // stand for reasons the code does not name (a cleared conversation, a pending question, a // provider's own rejection...), so any cause or next step could be false. diff --git a/src/shared/agent-session-refusal-reason-words.ts b/src/shared/agent-session-refusal-reason-words.ts index 5862da9261d3..d29f165ffe4e 100644 --- a/src/shared/agent-session-refusal-reason-words.ts +++ b/src/shared/agent-session-refusal-reason-words.ts @@ -150,7 +150,7 @@ const REASON_WORDS = { handoffInFlight: codeWords('retry') }, agent_session_operation_expired: { operationExpired: codeWords('retry') }, - agent_session_operation_capacity: { operationCapacity: codeWords('wait') }, + agent_session_operation_capacity: { operationCapacity: codeWords('updateOrca') }, agent_session_operation_unknown: { outcomeUnknown: codeWords('checkChat'), resultLost: codeWords('checkChat'), diff --git a/src/shared/agent-session-write-notice-copy.ts b/src/shared/agent-session-write-notice-copy.ts index 3e0e3277c036..f01532c35a2a 100644 --- a/src/shared/agent-session-write-notice-copy.ts +++ b/src/shared/agent-session-write-notice-copy.ts @@ -26,7 +26,8 @@ export const AGENT_SESSION_WRITE_NOTICE_COPY = { notDoneCommand: "The command didn't run.", notDoneGoal: "The goal wasn't changed.", restartFailed: "The agent couldn't restart.", - capacity: 'Orca has received too many requests in the last day.', + capacity: + 'Orca on the computer running this chat has hit a request limit. Update Orca there, then try again.', outcomeUnknown: "Orca couldn't confirm what happened. Check the chat.", sendOutcomeLost: "Orca couldn't confirm your message reached the agent. Check the chat, then send it again if needed.", From 51e28956ebe9c50399b3fad83983e46db3f26f47 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 8 Oct 2026 10:04:28 -0700 Subject: [PATCH 04/90] fix(native-chat): never refuse a queued card because 20 were waiting, and keep the queue from covering the chat (#26553) * fix(orchestration): keep automatic mail out of the user's message queue Orchestration mail reached a busy chat as ordinary queued message cards, and the queue refused at 20 unsettled cards or 1 MiB with a bare invalid refusal, so automatic notices could fill it and refuse the user's own next queued message. Mail now stays unread while the chat is working, waiting on a prompt or holding unsettled cards, and is coalesced into one delivery when it becomes free; batch identity is recorded so nothing is delivered twice. Human drafts no longer share an aggregate quota, and a failed settled receipt keeps its code, details and message for diagnosis. Co-Authored-By: OpenAI Codex gpt-6.1-sol Co-Authored-By: Claude Opus 5.5 * fix(native-chat): never refuse a message because automatic mail filled the queue Drop the 20-card queue limit and the mail-delivery rework; keep only a byte bound on what queued cards publish (it rides every opening frame beside the history page), refused with a reason that says what to do. The desktop queue list now scrolls instead of squeezing the transcript. * fix(native-chat): keep room for the person's message in a queue other agents fill Other agents' cards stop one maximal message short of the queued-bytes bound, so background work alone can never refuse a person's send. The person's newly queued card scrolls into view, and the refusal's step and host message now name only what the person can do. * fix(native-chat): open a loaded queue on its next card and scroll before paint --------- Co-authored-by: OpenAI Codex gpt-6.1-sol Co-authored-by: Claude Opus 5.5 --- .../queued-message-table.ts | 2 +- ...agent-session-operation-settlement.test.ts | 30 ++++++++ ...ured-agent-session-operation-settlement.ts | 1 + ...ured-agent-session-queued-messages.test.ts | 8 +-- ...tructured-agent-session-queued-messages.ts | 34 +++------ ...ent-session-queued-published-bytes.test.ts | 71 +++++++++++++++++++ ...ed-agent-session-queued-published-bytes.ts | 47 ++++++++++++ .../structured-agent-session-queued-send.ts | 1 + .../structured-agent-session-queued-stop.ts | 2 +- ...ctured-chat-coordinator-mail-queue.test.ts | 40 ++++++++++- .../NativeChatQueuedMessageList.test.tsx | 47 +++++++++++- .../NativeChatQueuedMessageList.tsx | 20 +++++- .../agent-session-write-notice-text.ts | 3 + .../src/i18n/en-runtime-required.json | 2 + src/renderer/src/i18n/locales/en.json | 2 + src/renderer/src/i18n/locales/es.json | 2 + src/renderer/src/i18n/locales/fr.json | 2 + src/renderer/src/i18n/locales/ja.json | 2 + src/renderer/src/i18n/locales/ko.json | 2 + src/renderer/src/i18n/locales/zh.json | 2 + src/shared/agent-session-refusal-details.ts | 2 + .../agent-session-refusal-reason-words.ts | 1 + src/shared/agent-session-write-notice-copy.ts | 2 + 23 files changed, 289 insertions(+), 36 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-queued-published-bytes.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-queued-published-bytes.ts diff --git a/src/main/native-chat/agent-session-journal/queued-message-table.ts b/src/main/native-chat/agent-session-journal/queued-message-table.ts index c9b380a25352..852ebc5d7ddf 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-table.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-table.ts @@ -26,7 +26,7 @@ export type QueuedMessageState = 'waiting' | 'dispatched' | 'returned' | 'withdr * (`QUEUED_MESSAGE_RETIRED_HOLD_REASONS`) as none. */ export type QueuedMessageHoldReason = typeof QUEUED_MESSAGE_PAUSED_SEND_FAILED -/** Definitively unsettled: what Stop, /clear, Edit and the budget count, and +/** Definitively unsettled: what Stop, /clear, Edit and the published-bytes bound count, and * what the published list shows. Pending/unknown/accepted deliveries and * tombstones stay outside it. */ export function isUnsettledQueuedMessage(row: Pick): boolean { 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 2c62c3f945da..0ed7c85e2b81 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 @@ -164,3 +164,33 @@ it('refuses a superseded send at acceptance, recording and dispatching nothing', expect(hostTestState().dispatch).not.toHaveBeenCalled() expect(vi.getTimerCount()).toBe(0) }) + +it('keeps the original refusal reason and message in its settled receipt', async () => { + const ctx = await context() + const { store } = hostTestState() + const writes = vi.spyOn(store, 'recordOperationOutcome').mockResolvedValue() + const body = hostTestMessage('message') + const operation = envelope('agentSession.send', { body }) + const refusal = { + code: 'agent_session_operation_invalid' as const, + details: { reason: 'journalWriteFailed' as const }, + message: 'The message could not be saved.' + } + expect( + await runSettledAgentSessionMutation({ + store, + operationCallerKey: 'test', + envelope: operation, + context: ctx, + plan: { + ...sendPlan({ envelope: operation, body }), + run: async () => ({ ok: false, refusal }) + } + }) + ).toMatchObject({ ok: false, refusal }) + expect(writes).toHaveBeenCalledWith({ + callerKey: 'test', + operationId: operation.clientOperationId, + outcome: { status: 'failed', ...refusal } + }) +}) 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 175afeaec355..c6f21301ef4c 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 @@ -57,6 +57,7 @@ export async function runSettledAgentSessionMutation(input: { : { status: 'failed', code: outcome.refusal.code, + message: outcome.refusal.message, ...(outcome.refusal.details ? { details: outcome.refusal.details } : {}), // The row's own field, which builds before details read; copied from the legacy mirror. ...(outcome.refusal.rewindReason ? { rewindReason: outcome.refusal.rewindReason } : {}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts index 8e9344352358..51b4c2f61f3e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts @@ -124,14 +124,14 @@ describe('accept', () => { expect(await drafts()).toHaveLength(0) }) - it('refuses past the draft-count budget with a readable message', async () => { + it('accepts a human message beyond twenty retained drafts', async () => { await workingSend() for (let index = 0; index < 20; index += 1) { expect(await send(`draft ${index}`, 'queue-if-active').result).toMatchObject({ ok: true }) } - expect(await send('one too many', 'queue-if-active').result).toMatchObject({ - ok: false, - refusal: { message: expect.stringContaining('queue is full') } + expect(await send('another message', 'queue-if-active').result).toMatchObject({ + ok: true, + value: { queued: { position: 21, state: 'waiting' } } }) }) }) 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 bceef9cb308e..23a8af3582a6 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 @@ -17,7 +17,7 @@ import { createStructuredAgentSessionOperationId } from '../../../shared/structu import { agentSessionSendBodyFingerprint } from '../../../shared/structured-agent-session-send-mutation' import { queuedSendAnswer } from './structured-agent-session-queued-send-answer' import { structuredAgentSessionSendBlock } from './structured-agent-session-send-preparation' -import { isUnsettledQueuedMessage } from '../agent-session-journal/queued-message-table' +import { queuedMessagesPublishedBytesRefusal } from './structured-agent-session-queued-published-bytes' 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' @@ -34,11 +34,6 @@ import type { StructuredAgentSessionLogger } from './structured-agent-session-lo import { agentSessionAttachmentExpiredRefusal } from './structured-agent-session-turns' import { isAgentSessionAttachmentExpiredError } from '../agent-session-attachments/agent-session-attachment-claims' -/** Budget at accept, in the send schema's own unit (`Buffer.byteLength` of the - * serialized blocks); refused readably rather than trimmed. */ -export const QUEUED_MESSAGES_MAX_COUNT = 20 -export const QUEUED_MESSAGES_MAX_TOTAL_BYTES = 1024 * 1024 - /** Text-only v1: any image block routes to the immediate path. */ export function queuedMessageBodyIsTextOnly(body: AgentJournalMessageItem): boolean { return body.blocks.every((block) => block.type === 'text') @@ -168,25 +163,6 @@ export function shouldQueueStructuredAgentSessionSend(input: { return oldestActionableQueuedMessage(input.journal) !== null } -/** The accept-side budget refusal, or null when the draft fits. */ -export function queuedMessageBudgetRefusal( - journal: AgentSessionJournal, - body: AgentJournalMessageItem -): AgentSessionWireRefusal | null { - const unsettled = journal.queuedMessages.list().filter(isUnsettledQueuedMessage) - const bytes = unsettled.reduce( - (sum, row) => sum + Buffer.byteLength(JSON.stringify(row.body.blocks), 'utf8'), - Buffer.byteLength(JSON.stringify(body.blocks), 'utf8') - ) - if (unsettled.length >= QUEUED_MESSAGES_MAX_COUNT || bytes > QUEUED_MESSAGES_MAX_TOTAL_BYTES) { - return { - code: 'agent_session_operation_invalid', - message: 'The message queue is full. Send again after the current turn ends.' - } - } - return null -} - /** * The accept branch: a capable send while the session is working (or behind an * actionable backlog) becomes a draft instead of a submission. Returns null for @@ -204,6 +180,8 @@ export async function maybeQueueStructuredAgentSessionSend( delivery?: 'queue-if-active' /** A person's send at a chat surface: every attachment it names must still be stored. */ userSend?: true + /** A person's message the host sends for them. */ + personsMessage?: true } ): Promise< | { ok: true; value: AgentSessionSendResult } @@ -233,7 +211,11 @@ export async function maybeQueueStructuredAgentSessionSend( ) { return null } - const refusal = queuedMessageBudgetRefusal(ctx.journal, params.body) + const refusal = queuedMessagesPublishedBytesRefusal( + ctx.journal, + params.body, + params.userSend === true || params.personsMessage === true + ) if (refusal) { return { ok: false, refusal } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-published-bytes.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-published-bytes.test.ts new file mode 100644 index 000000000000..b262b7a1fd73 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-published-bytes.test.ts @@ -0,0 +1,71 @@ +// The queue has no card count limit; only the bytes its cards publish are bounded, because every +// card rides each opening frame a remote client reads. Past that bound a send is refused in words +// that say what to do, deleting a card makes room again, and other agents' cards always leave room +// for the person's largest message. + +import { afterEach, beforeEach, expect, it } from 'vitest' +import { agentSessionRefusalNotice } from '../../../shared/agent-session-refusal-notice' +import { MAX_PROMPT_BYTES } from '../../../shared/rpc-contract/structured-agent-session-params' +import { + QUEUED_MESSAGES_PERSON_RESERVE_BYTES, + QUEUED_MESSAGES_PUBLISHED_MAX_BYTES +} from './structured-agent-session-queued-published-bytes' +import { + createQueuedMessageTestRig, + type QueuedMessageTestRig +} from './structured-agent-session-queued-message-rig.test-fixture' +import { hostTestMessage } from './structured-agent-session-host-test-data' + +let rig: QueuedMessageTestRig + +beforeEach(async () => { + rig = await createQueuedMessageTestRig() +}) + +afterEach(() => rig.dispose()) + +/** Text whose message body (or, with `blocksOnly`, whose blocks) serializes to exactly `bytes`. */ +function textOfBytes(bytes: number, blocksOnly = false): string { + const empty = hostTestMessage('') + const overhead = Buffer.byteLength(JSON.stringify(blocksOnly ? empty.blocks : empty), 'utf8') + return 'x'.repeat(bytes - overhead) +} + +it('refuses a draft past the published-bytes bound in words that say what to do', async () => { + await rig.workingSend() + const text = 'x'.repeat(Math.ceil(QUEUED_MESSAGES_PUBLISHED_MAX_BYTES * 0.6)) + const first = await rig.send(text, 'queue-if-active').result + if (!first.ok || !('queued' in first.value)) { + throw new Error('expected the first draft queued') + } + const refused = await rig.send(text, 'queue-if-active').result + if (refused.ok) { + throw new Error('expected a refusal') + } + expect(refused.refusal.details).toEqual({ reason: 'queueTooLarge' }) + expect(refused.refusal.message).not.toMatch(/frame|publish/i) + expect(agentSessionRefusalNotice(refused.refusal, 'composer-send')).toBe( + 'Too much text is waiting in the queue. Your message was not sent. Delete a queued message, or wait for one to go through, then try again.' + ) + expect(await rig.deleteQueued(first.value.queued.messageId)).toMatchObject({ ok: true }) + expect(await rig.send(text, 'queue-if-active').result).toMatchObject({ + ok: true, + value: { queued: { state: 'waiting' } } + }) +}) + +it("leaves room for the person's largest message however much other agents queue", async () => { + await rig.workingSend() + const agentRoom = QUEUED_MESSAGES_PUBLISHED_MAX_BYTES - QUEUED_MESSAGES_PERSON_RESERVE_BYTES + expect( + await rig.send(textOfBytes(agentRoom), 'queue-if-active', { internal: true }).result + ).toMatchObject({ ok: true, value: { queued: { state: 'waiting' } } }) + const agentRefused = await rig.send('one more task', 'queue-if-active', { internal: true }).result + expect(agentRefused).toMatchObject({ + ok: false, + refusal: { details: { reason: 'queueTooLarge' } } + }) + expect( + await rig.send(textOfBytes(MAX_PROMPT_BYTES, true), 'queue-if-active').result + ).toMatchObject({ ok: true, value: { queued: { state: 'waiting' } } }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-published-bytes.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-published-bytes.ts new file mode 100644 index 000000000000..4e1222b83429 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-published-bytes.ts @@ -0,0 +1,47 @@ +// The one bound on a conversation's queue: the bytes its unsettled cards publish. There is no +// card count limit; the cards' bodies ride every hydrating frame and history answer. + +import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' +import { refuse } from '../../../shared/agent-session-wire-refusals' +import { REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES } from '../../../shared/remote-runtime-memory-limits' +import { MAX_PROMPT_BYTES } from '../../../shared/rpc-contract/structured-agent-session-params' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { AGENT_SESSION_HISTORY_MAX_PAGE_BYTES } from './agent-session-history-page-bounds' +import { unsettledQueuedMessages } from './structured-agent-session-queued-stop' + +/** A frame bound, not a queue limit: every unsettled card's body rides each hydrating frame and + * history answer beside a full page, and a frame past the outbound cap closes the remote session. */ +export const QUEUED_MESSAGES_PUBLISHED_MAX_BYTES = + (REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES - AGENT_SESSION_HISTORY_MAX_PAGE_BYTES) / 2 + +/** Kept free of other agents' cards, so they alone never refuse a person's message: the largest + * message's blocks plus its body's own fields. */ +export const QUEUED_MESSAGES_PERSON_RESERVE_BYTES = MAX_PROMPT_BYTES + 1024 + +/** Refused readably rather than trimmed, when the published cards would outgrow their frame room. + * Another agent's card must also leave the person's reserve free. */ +export function queuedMessagesPublishedBytesRefusal( + journal: AgentSessionJournal, + body: AgentJournalMessageItem, + fromPerson: boolean +): AgentSessionWireRefusal | null { + const bytes = unsettledQueuedMessages(journal).reduce( + (sum, row) => sum + publishedBodyBytes(row.body), + publishedBodyBytes(body) + ) + const limit = fromPerson + ? QUEUED_MESSAGES_PUBLISHED_MAX_BYTES + : QUEUED_MESSAGES_PUBLISHED_MAX_BYTES - QUEUED_MESSAGES_PERSON_RESERVE_BYTES + return bytes > limit + ? refuse( + 'agent_session_operation_invalid', + { reason: 'queueTooLarge' }, + "Too much text is waiting in this chat's queue. Delete a queued message or wait for one to go through." + ) + : null +} + +function publishedBodyBytes(body: AgentJournalMessageItem): number { + return Buffer.byteLength(JSON.stringify(body), 'utf8') +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send.ts index 7ce3d43b4798..1652fbca97f2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send.ts @@ -15,6 +15,7 @@ export async function runQueueableStructuredAgentSessionSend( body: AgentJournalMessageItem delivery?: 'queue-if-active' userSend?: true + personsMessage?: true }, immediate: () => Promise> ): Promise> { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts index 964cefc1b2f5..fea528147494 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts @@ -19,7 +19,7 @@ import { structuredAgentSessionStoppedTurnId } from './structured-agent-session-turn-stop-notes' -/** The one unsettled-card predicate /clear's carry and the budget share: +/** The one unsettled-card predicate /clear's carry and the published-bytes bound share: * waiting or returned. Pending/unknown/accepted deliveries stay outside it. */ export function unsettledQueuedMessages(journal: AgentSessionJournal): QueuedMessageRow[] { return journal.queuedMessages.list().filter(isUnsettledQueuedMessage) diff --git a/src/main/runtime/structured-chat-coordinator-mail-queue.test.ts b/src/main/runtime/structured-chat-coordinator-mail-queue.test.ts index 22af4ddb2a16..420fa3cd4183 100644 --- a/src/main/runtime/structured-chat-coordinator-mail-queue.test.ts +++ b/src/main/runtime/structured-chat-coordinator-mail-queue.test.ts @@ -4,7 +4,8 @@ import './rpc/unused-default-rpc-methods.test-fixture' // to end on the coordinator-mail rig. import { describe, expect, it, vi } from 'vitest' -import type { FakeConnection } from './structured-chat-coordinator-fake-codex-fixture' +import { structuredAgentSessionMessageSendMutation } from '../../shared/structured-agent-session-send-mutation' +import { operationId, type FakeConnection } from './structured-chat-coordinator-fake-codex-fixture' import { idOf } from './rpc/orchestration-session-caller-test-fixture' import { COORDINATOR, @@ -149,4 +150,41 @@ describe("a busy chat's orchestration pointer waits in its queue", () => { expect(await queuedCardTexts()).toEqual([ptyPointer(`run:${runId}`)]) await endTurn() }) + + // The queue once refused every send past twenty cards, so a busy coordinator's mail locked the + // person out of their own chat. + it("still queues the person's message behind more than twenty mail cards", async () => { + const chat = await openChat(COORDINATOR) + const { runId, taskId } = await coordinatorRunAndTask() + await runningUserTurn(chat) + await finishWorker(taskId) + await vi.waitFor(async () => expect(await queuedCardTexts()).toHaveLength(1), WAIT) + for (let index = 1; index <= 24; index += 1) { + await call('orchestration.send', { + from: 'term_worker', + to: `run:${runId}`, + subject: `mail ${index}` + }) + await vi.waitFor(async () => expect(await queuedCardTexts()).toHaveLength(index + 1), WAIT) + } + const sent = await host.send( + { callerKey: 'test-surface' }, + { + ...structuredAgentSessionMessageSendMutation({ + sessionId: COORDINATOR, + clientOperationId: operationId(), + expectedRuntimeFence: host.deps.store.getRecord(COORDINATOR)!.lease.runtimeFence, + body: { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'human message' }] + }, + delivery: 'queue-if-active' + }), + userSend: true + } + ) + expect(sent).toMatchObject({ ok: true, value: { queued: { position: 26, state: 'waiting' } } }) + expect((await queuedCardTexts()).at(-1)).toBe('human message') + }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx index fc43152b8a77..b914b31196b4 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx @@ -5,7 +5,7 @@ // captions derived client-side per state. import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest' const mocks = vi.hoisted(() => ({ updateSettings: vi.fn() @@ -579,4 +579,49 @@ describe('NativeChatQueuedMessageList', () => { expect(owner.steer).toHaveBeenCalledWith('kept') } ) + + // A long queue scrolls inside its own bounded box; a card the person queues is scrolled to, + // while another agent's card leaves the view on the next card to send. + it("scrolls only to a card the person just queued, inside the list's own bound", () => { + const from = { kind: 'agent' as const, senders: [], orchestration: null } + const first = [card({ messageId: 'a', position: 1 }), card({ messageId: 'b', position: 2 })] + const view = renderList(controller(first)) + const list = screen.getByRole('list', { name: 'Queued messages' }) + expect(list.className).toMatch(/\bmax-h-40\b/) + expect(list.className).toMatch(/\boverflow-y-auto\b/) + Object.defineProperty(list, 'scrollHeight', { configurable: true, value: 500 }) + const rerender = (cards: QueuedMessageCard[]): void => + view.rerender( + + + + ) + rerender([...first, card({ messageId: 'mail', position: 3, from })]) + expect(list.scrollTop).toBe(0) + rerender([ + ...first, + card({ messageId: 'mail', position: 3, from }), + card({ messageId: 'mine', position: 4 }) + ]) + expect(list.scrollTop).toBe(500) + }) + + // The queue a chat opens with arrives after the list mounts; it opens on the next card to send. + it('does not scroll for cards that load after the list mounts', () => { + // The list mounts with the cards, so its height is stubbed where it will be read. + const height = vi.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockReturnValue(500) + onTestFinished(() => height.mockRestore()) + const view = renderList(controller([])) + view.rerender( + + + + ) + expect(screen.getByRole('list', { name: 'Queued messages' }).scrollTop).toBe(0) + }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx index ea5b454f9974..964881a3b4b6 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx @@ -1,4 +1,4 @@ -import { useRef } from 'react' +import { useLayoutEffect, useRef } from 'react' import { Pause, Play } from 'lucide-react' import { Button } from '@/components/ui/button' import { useAppStore } from '../../store' @@ -26,6 +26,20 @@ export function NativeChatQueuedMessageList({ const queueRef = useRef(null) const { cards, pause } = controller const newest = cards.at(-1) + const listRef = useRef(null) + const newestPosition = newest?.position ?? 0 + const newestIsPersons = newest !== undefined && !newest.from + const shownPosition = useRef(newestPosition) + // Before paint, so the new card never shows a frame before the scroll. + useLayoutEffect(() => { + // From 0 the queue is loading or holds one card: nothing to scroll to. + const appended = shownPosition.current > 0 && newestPosition > shownPosition.current + shownPosition.current = newestPosition + // The list opens on the next card to send; a card the person just queued is shown instead. + if (appended && newestIsPersons && listRef.current) { + listRef.current.scrollTop = listRef.current.scrollHeight + } + }, [newestPosition, newestIsPersons]) // Only a host that queues sends has queueing to turn off; a kept card shows without it. const turnOffQueueing = controller.queueCapable ? () => void updateSettings({ nativeChatQueueFollowUps: false }) @@ -54,11 +68,13 @@ export function NativeChatQueuedMessageList({ /> ) : null}
    {cards.map((card) => ( string> = { translate('components.native-chat.writeNotice.messagesUnsettled', COPY.messagesUnsettled), settleEarlierMessage: () => translate('components.native-chat.writeNotice.settleEarlierMessage', COPY.settleEarlierMessage), + queueTooLarge: () => + translate('components.native-chat.writeNotice.queueTooLarge', COPY.queueTooLarge), + shrinkQueue: () => translate('components.native-chat.writeNotice.shrinkQueue', COPY.shrinkQueue), optionRejected: () => translate('components.native-chat.writeNotice.optionRejected', COPY.optionRejected), goalsUnsupported: () => diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index dde13094adf4..43e1931d0560 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -3045,6 +3045,7 @@ "ownerUnproven": "The previous agent in this chat may still be running.", "promptPending": "The agent is waiting for an answer to a question or approval.", "questionChanged": "This question was already answered or has changed.", + "queueTooLarge": "Too much text is waiting in the queue.", "quitTerminalAgent": "Quit that agent to continue the chat here.", "reattachFile": "Remove it and attach it again.", "recordFailed": "Orca couldn't save this to the chat's history.", @@ -3054,6 +3055,7 @@ "savedByNewerOrca": "Chats were saved by a newer Orca.", "sendOutcomeLost": "Orca couldn't confirm your message reached the agent. Check the chat, then send it again if needed.", "settleEarlierMessage": "Wait for your earlier message to go through, or retry it.", + "shrinkQueue": "Delete a queued message, or wait for one to go through, then try again.", "startNewChat": "Start a new chat to continue.", "terminalAgentHoldsChat": "This chat is still open in a terminal agent.", "tryAgain": "Try again.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 23b880c49463..857c2455b225 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18106,6 +18106,8 @@ "waitForBackgroundTasks": "Wait for the background tasks to finish.", "messagesUnsettled": "A message you sent earlier isn't confirmed yet.", "settleEarlierMessage": "Wait for your earlier message to go through, or retry it.", + "queueTooLarge": "Too much text is waiting in the queue.", + "shrinkQueue": "Delete a queued message, or wait for one to go through, then try again.", "optionRejected": "The agent didn't accept this setting.", "goalsUnsupported": "This agent doesn't support goals.", "agentRefused": "The agent turned this down.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 9ac13f4bd6c3..df693078d7af 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -17873,6 +17873,8 @@ "waitForBackgroundTasks": "Espere a que terminen las tareas en segundo plano.", "messagesUnsettled": "Un mensaje que envió antes todavía no está confirmado.", "settleEarlierMessage": "Espere a que se entregue su mensaje anterior o vuelva a intentarlo.", + "queueTooLarge": "Hay demasiado texto esperando en la cola.", + "shrinkQueue": "Elimine un mensaje de la cola o espere a que se entregue uno y vuelva a intentarlo.", "optionRejected": "El agente no aceptó este ajuste.", "goalsUnsupported": "Este agente no admite objetivos.", "agentRefused": "El agente lo rechazó.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 2c071883f175..22c327e3ddb3 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17974,6 +17974,8 @@ "waitForBackgroundTasks": "Attendez la fin des tâches en arrière-plan.", "messagesUnsettled": "Un message que vous avez envoyé plus tôt n'est pas encore confirmé.", "settleEarlierMessage": "Attendez que votre message précédent soit transmis, ou renvoyez-le.", + "queueTooLarge": "Trop de texte est en attente dans la file.", + "shrinkQueue": "Supprimez un message de la file, ou attendez qu'un message soit transmis, puis réessayez.", "optionRejected": "L'agent n'a pas accepté ce réglage.", "goalsUnsupported": "Cet agent ne prend pas en charge les objectifs.", "agentRefused": "L'agent l'a refusé.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index cdb717eb1825..8439aa421b99 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17908,6 +17908,8 @@ "waitForBackgroundTasks": "バックグラウンドタスクが終わるまでお待ちください。", "messagesUnsettled": "以前に送信したメッセージはまだ確認されていません。", "settleEarlierMessage": "以前のメッセージが届くまで待つか、再試行してください。", + "queueTooLarge": "キューで待機中のテキストが多すぎます。", + "shrinkQueue": "キュー内のメッセージを削除するか、送信されるまで待ってから、もう一度お試しください。", "optionRejected": "Agent はこの設定を受け入れませんでした。", "goalsUnsupported": "この Agent は目標に対応していません。", "agentRefused": "Agent がこれを拒否しました。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 10ab06446601..95effe588693 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17908,6 +17908,8 @@ "waitForBackgroundTasks": "백그라운드 작업이 끝날 때까지 기다리세요.", "messagesUnsettled": "앞서 보낸 메시지가 아직 확인되지 않았습니다.", "settleEarlierMessage": "앞서 보낸 메시지가 전달될 때까지 기다리거나 다시 시도하세요.", + "queueTooLarge": "대기열에 기다리는 텍스트가 너무 많습니다.", + "shrinkQueue": "대기 중인 메시지를 삭제하거나 전달될 때까지 기다린 후 다시 시도하세요.", "optionRejected": "에이전트가 이 설정을 받아들이지 않았습니다.", "goalsUnsupported": "이 에이전트는 목표를 지원하지 않습니다.", "agentRefused": "에이전트가 이를 거부했습니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 31421a767571..b98a4778a4bf 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17873,6 +17873,8 @@ "waitForBackgroundTasks": "请等待后台任务完成。", "messagesUnsettled": "您之前发送的一条消息尚未确认。", "settleEarlierMessage": "请等待之前的消息送达,或重试该消息。", + "queueTooLarge": "队列中等待的文本过多。", + "shrinkQueue": "请删除一条排队的消息,或等待其送达,然后重试。", "optionRejected": "智能体未接受此设置。", "goalsUnsupported": "此智能体不支持目标。", "agentRefused": "智能体拒绝了此请求。", diff --git a/src/shared/agent-session-refusal-details.ts b/src/shared/agent-session-refusal-details.ts index 1473e030a7d5..ae52282631a8 100644 --- a/src/shared/agent-session-refusal-details.ts +++ b/src/shared/agent-session-refusal-details.ts @@ -41,6 +41,8 @@ export const AGENT_SESSION_REFUSAL_REASONS = { 'promptPending', 'backgroundTasksRunning', 'messagesUnsettled', + /** The chat's queued messages hold too much text to show on every client. */ + 'queueTooLarge', 'rewindRefused', 'rewindUnconfirmed', // A prompt card or an option diff --git a/src/shared/agent-session-refusal-reason-words.ts b/src/shared/agent-session-refusal-reason-words.ts index d29f165ffe4e..03c5c1c04dd2 100644 --- a/src/shared/agent-session-refusal-reason-words.ts +++ b/src/shared/agent-session-refusal-reason-words.ts @@ -83,6 +83,7 @@ const REASON_WORDS = { promptPending: causeWords('promptPending', 'actFirst', 'answerFirst'), backgroundTasksRunning: causeWords('backgroundTasksRunning', 'wait', 'waitForBackgroundTasks'), messagesUnsettled: causeWords('messagesUnsettled', 'actFirst', 'settleEarlierMessage'), + queueTooLarge: causeWords('queueTooLarge', 'actFirst', 'shrinkQueue'), // The rewind control words its own refusals; anywhere else says only that it did not happen. rewindRefused: codeWords('hostFinding'), rewindUnconfirmed: codeWords('hostFinding'), diff --git a/src/shared/agent-session-write-notice-copy.ts b/src/shared/agent-session-write-notice-copy.ts index f01532c35a2a..084f23eb1f7f 100644 --- a/src/shared/agent-session-write-notice-copy.ts +++ b/src/shared/agent-session-write-notice-copy.ts @@ -62,6 +62,8 @@ export const AGENT_SESSION_WRITE_NOTICE_COPY = { waitForBackgroundTasks: 'Wait for the background tasks to finish.', messagesUnsettled: "A message you sent earlier isn't confirmed yet.", settleEarlierMessage: 'Wait for your earlier message to go through, or retry it.', + queueTooLarge: 'Too much text is waiting in the queue.', + shrinkQueue: 'Delete a queued message, or wait for one to go through, then try again.', optionRejected: "The agent didn't accept this setting.", goalsUnsupported: "This agent doesn't support goals.", agentRefused: 'The agent turned this down.', From 7bdb0b67e6b3d7fd1df6c863dadb24cba60d5848 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 8 Oct 2026 10:12:44 -0700 Subject: [PATCH 05/90] Native chat: inline visuals for Grok, OpenCode and OMP chats (#26530) * Native chat: give Grok, OpenCode and OMP chats inline visuals Each ACP agent loads the chat-visuals skill its own way for one launch, without touching the user's agent config: Grok through agent --plugin-dir, OpenCode 2.x through inline skills.paths, OMP through a PI_CONFIG_FILES overlay that keeps the user's own skill folders. A chat whose agent can't load it starts without visuals. * Native chat: load OMP chat visuals through --plugin-dir OMP's settings layers replace lists, so the PI_CONFIG_FILES overlay hid a repo's or profile's own skill folders in Orca's OMP chats, and its files were never removed. omp acp takes --plugin-dir like Grok, which adds the skill beside the user's own plugins and skill folders and leaves their config untouched. * Native chat: note OMP releases that skip --plugin-dir skills --- src/main/acp/acp-launch-specs.ts | 33 +++- .../acp-structured-adapter.test-support.ts | 2 +- ...p-structured-launch-resolution-omp.test.ts | 4 +- .../acp/acp-structured-launch-resolution.ts | 65 ++++++-- .../acp/acp-structured-launch-visuals.test.ts | 149 ++++++++++++++++++ src/main/acp/acp-structured-launch-visuals.ts | 77 +++++++++ .../acp-structured-session-adapter.test.ts | 15 +- src/main/acp/acp-visuals-skill.test.ts | 80 ++++++++++ src/main/acp/acp-visuals-skill.ts | 90 +++++++++++ .../structured-agent-runtime-registrations.ts | 6 +- 10 files changed, 499 insertions(+), 22 deletions(-) create mode 100644 src/main/acp/acp-structured-launch-visuals.test.ts create mode 100644 src/main/acp/acp-structured-launch-visuals.ts create mode 100644 src/main/acp/acp-visuals-skill.test.ts create mode 100644 src/main/acp/acp-visuals-skill.ts diff --git a/src/main/acp/acp-launch-specs.ts b/src/main/acp/acp-launch-specs.ts index 0cc9feae43e0..c3b6bb9cb367 100644 --- a/src/main/acp/acp-launch-specs.ts +++ b/src/main/acp/acp-launch-specs.ts @@ -18,13 +18,20 @@ import { openCodeStoredUserMessagesReader } from '../opencode/opencode-acp-store import type { AcpStoredUserMessagesReader } from './acp-recovery-history' import { isStableCliVersionFrom, isStableCliVersionOnLine } from '../agent-cli-version-probe' import type { TuiAgent } from '../../shared/tui-agent' +import { + loadGrokVisualsSkill, + loadOmpVisualsSkill, + loadOpenCodeVisualsSkill, + type AcpVisualsSkillLoader +} from './acp-visuals-skill' export type AcpLaunchSpec = { /** The Orca agent id, which names the agent's records, its catalog label and its settings. */ agent: TuiAgent command: string - /** Built per launch: `fullAccess` is the Agent Permissions setting's bypass posture. */ - args(input: { fullAccess: boolean }): string[] + /** Built per launch: `fullAccess` is the Agent Permissions setting's bypass posture; + * `pluginDir` is a plugin folder `visualsSkill` asked the agent to load. */ + args(input: { fullAccess: boolean; pluginDir: string | null }): string[] /** Laid over the child's environment last, after the account and the user's own variables. */ env: Readonly> /** Rewrites what the child would inherit from Orca's own plumbing; returns the keys it must not @@ -50,13 +57,20 @@ export type AcpLaunchSpec = { imagePrompts?: true /** The agent's own store of a session's user messages, read for restart recovery only. */ readStoredUserMessages?: AcpStoredUserMessagesReader + /** How a launch loads the inline-visuals skill; absent, the agent's chats have no visuals. */ + visualsSkill?: AcpVisualsSkillLoader } const GROK_LAUNCH_SPEC: AcpLaunchSpec = { agent: 'grok', command: 'grok', // `--always-approve` only for full access, as the user's setting chooses. - args: ({ fullAccess }) => ['agent', ...(fullAccess ? ['--always-approve'] : []), 'stdio'], + args: ({ fullAccess, pluginDir }) => [ + 'agent', + ...(fullAccess ? ['--always-approve'] : []), + ...(pluginDir ? ['--plugin-dir', pluginDir] : []), + 'stdio' + ], env: {}, dialect: GROK_ACP_DIALECT, loginCommand: ['grok', 'login'], @@ -68,7 +82,8 @@ const GROK_LAUNCH_SPEC: AcpLaunchSpec = { ? 'cached_token' : undefined, account: directoryAccountBinding('GROK_HOME', (homePath) => join(homePath, '.grok')), - installDirectories: ({ env }) => (env.GROK_HOME ? [join(env.GROK_HOME, 'bin')] : []) + installDirectories: ({ env }) => (env.GROK_HOME ? [join(env.GROK_HOME, 'bin')] : []), + visualsSkill: loadGrokVisualsSkill } // `opencode acp` on 1.x serves in-process; on 2.x it starts a private `opencode serve --stdio` child @@ -96,15 +111,16 @@ const OPENCODE_LAUNCH_SPEC: AcpLaunchSpec = { supportsVersion: (version) => OPENCODE_ACP_RELEASE_LINES.some((line) => isStableCliVersionOnLine(version, line)), imagePrompts: true, - readStoredUserMessages: openCodeStoredUserMessagesReader() + readStoredUserMessages: openCodeStoredUserMessagesReader(), + visualsSkill: loadOpenCodeVisualsSkill } // OMP serves ACP through `omp acp`; its environment reaches it as the user set it. const OMP_LAUNCH_SPEC: AcpLaunchSpec = { agent: 'omp', command: 'omp', - // `omp acp` takes no flags: full access answers each permission request yes. - args: () => ['acp'], + // Full access answers each permission request yes. + args: ({ pluginDir }) => ['acp', ...(pluginDir ? ['--plugin-dir', pluginDir] : [])], env: {}, dialect: OMP_ACP_DIALECT, // OMP signs in from its own `/login`. @@ -116,7 +132,8 @@ const OMP_LAUNCH_SPEC: AcpLaunchSpec = { // OMP's installers use directories the shared resolver already searches after PATH. installDirectories: () => [], // Stable releases from 17.0.5, the release verified to serve `omp acp`. - supportsVersion: (version) => isStableCliVersionFrom(version, '17.0.5') + supportsVersion: (version) => isStableCliVersionFrom(version, '17.0.5'), + visualsSkill: loadOmpVisualsSkill } export const ACP_LAUNCH_SPECS: readonly AcpLaunchSpec[] = [ diff --git a/src/main/acp/acp-structured-adapter.test-support.ts b/src/main/acp/acp-structured-adapter.test-support.ts index a81fb2c8198e..644611322d17 100644 --- a/src/main/acp/acp-structured-adapter.test-support.ts +++ b/src/main/acp/acp-structured-adapter.test-support.ts @@ -175,7 +175,7 @@ export async function openAcpAdapterRig( resolveLaunch: async () => ({ spec, command: `/opt/bin/${spec.command}`, - args: spec.args({ fullAccess: false }), + args: spec.args({ fullAccess: false, pluginDir: null }), cwd: '/workspace/project', env: { PATH: '/usr/bin', ORCA_PANE_KEY: 'tab-1:pane-1', ORCA_AGENT_HOOK_PORT: '1234' }, envToDelete: [], diff --git a/src/main/acp/acp-structured-launch-resolution-omp.test.ts b/src/main/acp/acp-structured-launch-resolution-omp.test.ts index 8e34dee2e9d7..74d9be9fbe39 100644 --- a/src/main/acp/acp-structured-launch-resolution-omp.test.ts +++ b/src/main/acp/acp-structured-launch-resolution-omp.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import type { AgentSessionRecord } from '../../shared/agent-session-record' import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' +import { NATIVE_CHAT_VISUALS_DIR_ENV } from '../native-chat/native-chat-visuals-delivery' import { createProviderSpawnSpec } from '../provider-process/provider-process-supervisor' import { ACP_CHILD_ENV_TO_DELETE, acpLaunchSpecFor } from './acp-launch-specs' import { @@ -64,7 +65,8 @@ describe('OMP ACP launch resolution', () => { ANTHROPIC_API_KEY: 'sk-test' } const launch = await resolver(ompRecord(), { launchEnv: userEnv })({ identity }) - expect(launch.envToDelete).toEqual([]) + // Only an inherited visuals folder: this chat has none. + expect(launch.envToDelete).toEqual([NATIVE_CHAT_VISUALS_DIR_ENV]) const child = createProviderSpawnSpec( { command: launch.command, diff --git a/src/main/acp/acp-structured-launch-resolution.ts b/src/main/acp/acp-structured-launch-resolution.ts index 8c13e2f3e620..fb0211c624e5 100644 --- a/src/main/acp/acp-structured-launch-resolution.ts +++ b/src/main/acp/acp-structured-launch-resolution.ts @@ -29,8 +29,13 @@ import { spelledProviderTimelineItemKey } from '../native-chat/agent-session-timeline/provider-timeline-identity' import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' +import { + NATIVE_CHAT_VISUALS_DIR_ENV, + withNativeChatVisualsEnv +} from '../native-chat/native-chat-visuals-delivery' import type { AcpLaunchSpec } from './acp-launch-specs' import { acpSessionNotRestoredItem } from './acp-session-reopen-failure' +import { loadAcpVisualsSkill, type AcpLaunchVisualsDeps } from './acp-structured-launch-visuals' export type AcpStructuredLaunch = { spec: AcpLaunchSpec @@ -73,7 +78,7 @@ export type AcpStructuredLaunchResolverDeps = { /** The environment the child process inherits at spawn; this process's own by default. */ inheritedEnv?: NodeJS.ProcessEnv probeVersion?: typeof probeAgentCliVersion -} +} & AcpLaunchVisualsDeps /** The binary a launch with `env` spawns: the user's Command setting when set, else the stock * binary from PATH, then the agent's own install directories. A create's version check resolves @@ -102,16 +107,37 @@ export function resolveAcpLaunchCommand( ) } +/** Whether `command` is a release the spec runs a structured chat on (true when it names none), + * and the version it printed. Asked only when the spec names releases or `wantVersion`. */ +async function probeAcpLaunchVersion( + spec: AcpLaunchSpec, + launch: { command: string; cwd: string; env: Record }, + wantVersion: boolean, + probe: typeof probeAgentCliVersion = probeAgentCliVersion +): Promise<{ supported: boolean; version: string | null }> { + const { supportsVersion } = spec + if (!supportsVersion && !wantVersion) { + return { supported: true, version: null } + } + const seen: { version: string | null } = { version: null } + const answered = await probe( + { program: launch.command, cwd: launch.cwd, env: launch.env }, + (version) => { + seen.version = version + return supportsVersion?.(version) ?? true + } + ) + // Without named releases a failed probe costs only what needed the version. + return { supported: answered || !supportsVersion, version: answered ? seen.version : null } +} + /** Whether `command` is a release the spec runs a structured chat on; true when it names none. */ export async function acpLaunchVersionSupported( spec: AcpLaunchSpec, launch: { command: string; cwd: string; env: Record }, probe: typeof probeAgentCliVersion = probeAgentCliVersion ): Promise { - return ( - !spec.supportsVersion || - probe({ program: launch.command, cwd: launch.cwd, env: launch.env }, spec.supportsVersion) - ) + return (await probeAcpLaunchVersion(spec, launch, false, probe)).supported } export function createAcpStructuredLaunchResolver( @@ -146,21 +172,42 @@ export function createAcpStructuredLaunchResolver( ...(deps.resolveCommandSettings ? { commandSettings: deps.resolveCommandSettings() } : {}) }) const cwd = await deps.resolveWorkspacePath(location.workspaceId) + const visuals = spec.visualsSkill + ? ((await deps.prepareVisuals?.(identity.sessionId)) ?? null) + : null // Again at every launch: the binary on PATH may have changed since the chat was created. - if (!(await acpLaunchVersionSupported(spec, { command, cwd, env }, deps.probeVersion))) { + const launchVersion = await probeAcpLaunchVersion( + spec, + { command, cwd, env }, + visuals !== null, + deps.probeVersion + ) + if (!launchVersion.supported) { throw agentSessionRefusalError('structured_agent_session_unsupported', { reason: 'hostUnsupported' }) } + const skill = visuals + ? await loadAcpVisualsSkill(spec, deps, { + sessionId: identity.sessionId, + visuals, + version: launchVersion.version, + env, + envToDelete, + inherited: deps.inheritedEnv ?? process.env + }) + : null + // Without visuals, a folder Orca itself inherited (started from a chat) names another chat's. + const childEnv = withNativeChatVisualsEnv({ ...env, ...skill?.env }, skill ? visuals : null) const fullAccess = deps.resolveFullAccess?.(spec.agent) ?? false const head = agentSessionProviderHandleChainHead(record.providerHandleChain) return { spec, command, - args: spec.args({ fullAccess }), + args: spec.args({ fullAccess, pluginDir: skill?.pluginDir ?? null }), cwd, - env, - envToDelete, + env: childEnv, + envToDelete: skill ? envToDelete : [...envToDelete, NATIVE_CHAT_VISUALS_DIR_ENV], fullAccess, resume: head ? { diff --git a/src/main/acp/acp-structured-launch-visuals.test.ts b/src/main/acp/acp-structured-launch-visuals.test.ts new file mode 100644 index 000000000000..35c782f521e1 --- /dev/null +++ b/src/main/acp/acp-structured-launch-visuals.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' +import { NATIVE_CHAT_VISUALS_DIR_ENV } from '../native-chat/native-chat-visuals-delivery' +import { resolveProviderChildEnv } from '../provider-process/provider-process-launch' +import { acpLaunchSpecFor, type AcpLaunchSpec } from './acp-launch-specs' +import { + createAcpStructuredLaunchResolver, + type AcpStructuredLaunchResolverDeps +} from './acp-structured-launch-resolution' + +const SKILL = { pluginDir: '/app/plugin', skillsRoot: '/app/plugin/skills' } +const FOLDER = '/state/native-chat-visuals/0123' +const identity = { + sessionId: 'session-alpha-1', + workspaceId: 'workspace-1', + hostId: 'local', + providerHandle: null +} + +function record(spec: AcpLaunchSpec): AgentSessionRecord { + const accountHome: AgentSessionRecord['accountHome'] = + spec.agent === 'opencode' + ? { kind: 'opencode', locator: { kind: 'unmanaged' } } + : spec.agent === 'omp' + ? { variable: 'PI_CODING_AGENT_DIR', path: '/home/user/.omp/agent' } + : { variable: 'GROK_HOME', path: '/home/user/.grok' } + return { + ...agentSessionRecordFixture(), + provider: spec.agent, + providerHandleChain: [], + accountHome + } +} + +function resolve( + agent: string, + options: { + visuals?: boolean + version?: string + inheritedEnv?: NodeJS.ProcessEnv + launchEnv?: Record + deps?: Partial + spec?: Partial + } = {} +) { + const spec = { ...acpLaunchSpecFor(agent)!, ...options.spec } + const probeVersion = vi.fn>( + async (_input, supports) => supports(options.version ?? '1.0.46') + ) + const prepareVisuals = vi.fn(async () => + options.visuals === false ? null : { folder: FOLDER, skill: SKILL } + ) + const logger = { warn: vi.fn(), error: vi.fn() } + const launch = createAcpStructuredLaunchResolver(spec, { + store: { getRecord: () => record(spec) }, + readJournal: () => null, + resolveWorkspacePath: async () => '/repo/worktree', + resolveEnvironment: async () => ({ PATH: '/usr/bin', HOME: '/home/user' }), + resolveLaunchEnv: () => options.launchEnv ?? {}, + resolveCommand: (command) => `/resolved/${command}`, + inheritedEnv: options.inheritedEnv ?? {}, + probeVersion, + prepareVisuals, + logger, + ...options.deps + })({ identity: { ...identity, agent } }) + return { launch, probeVersion, prepareVisuals, logger } +} + +describe('ACP launch visuals', () => { + it("gives Grok the plugin folder and the chat's visuals folder", async () => { + const launch = await resolve('grok').launch + expect(launch.args).toEqual(['agent', '--plugin-dir', SKILL.pluginDir, 'stdio']) + expect(launch.env[NATIVE_CHAT_VISUALS_DIR_ENV]).toBe(FOLDER) + expect(launch.envToDelete).not.toContain(NATIVE_CHAT_VISUALS_DIR_ENV) + }) + + it('starts a Grok too old for --plugin-dir without visuals, never naming an inherited folder', async () => { + const { launch, logger } = resolve('grok', { + version: '1.0.10', + inheritedEnv: { [NATIVE_CHAT_VISUALS_DIR_ENV]: '/another/chat' } + }) + const resolved = await launch + expect(resolved.args).toEqual(['agent', 'stdio']) + expect( + resolveProviderChildEnv(resolved, { [NATIVE_CHAT_VISUALS_DIR_ENV]: '/another/chat' })[ + NATIVE_CHAT_VISUALS_DIR_ENV + ] + ).toBeUndefined() + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('cannot load the visuals skill'), + expect.objectContaining({ version: '1.0.10' }) + ) + }) + + it('asks Grok for its version only when the chat has visuals', async () => { + const { launch, probeVersion } = resolve('grok', { visuals: false }) + expect((await launch).args).toEqual(['agent', 'stdio']) + expect(probeVersion).not.toHaveBeenCalled() + }) + + it("adds the skills root to OpenCode's inline config, keeping the user's own", async () => { + const launch = await resolve('opencode', { + version: '2.0.14', + inheritedEnv: { OPENCODE_CONFIG_CONTENT: JSON.stringify({ autoupdate: false }) } + }).launch + expect(launch.args).toEqual(['acp']) + expect(JSON.parse(launch.env.OPENCODE_CONFIG_CONTENT)).toEqual({ + autoupdate: false, + skills: { paths: [SKILL.skillsRoot] } + }) + expect(launch.env[NATIVE_CHAT_VISUALS_DIR_ENV]).toBe(FOLDER) + }) + + // OMP's settings layers (global, profile, project, overlays) replace lists, so any config of + // Orca's would hide the user's own skill folders; a plugin folder adds beside them. + it("gives OMP the plugin folder, leaving the user's config files as they set them", async () => { + const launch = await resolve('omp', { + version: '17.0.5', + launchEnv: { PI_CONFIG_FILES: '/user/overlay.yml' } + }).launch + expect(launch.args).toEqual(['acp', '--plugin-dir', SKILL.pluginDir]) + expect(launch.env.PI_CONFIG_FILES).toBe('/user/overlay.yml') + expect(launch.env[NATIVE_CHAT_VISUALS_DIR_ENV]).toBe(FOLDER) + }) + + it('still starts the chat when the skill cannot be prepared', async () => { + const { launch, logger } = resolve('omp', { + version: '17.0.5', + spec: { + visualsSkill: async () => { + throw new Error('unreadable') + } + } + }) + const resolved = await launch + expect(resolved.args).toEqual(['acp']) + expect(resolved.envToDelete).toContain(NATIVE_CHAT_VISUALS_DIR_ENV) + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('could not be prepared'), + expect.anything() + ) + }) + + it('still refuses a release the agent does not run structured chats on', async () => { + await expect(resolve('omp', { version: '16.0.0' }).launch).rejects.toThrow() + }) +}) diff --git a/src/main/acp/acp-structured-launch-visuals.ts b/src/main/acp/acp-structured-launch-visuals.ts new file mode 100644 index 000000000000..9d25a91dc3f4 --- /dev/null +++ b/src/main/acp/acp-structured-launch-visuals.ts @@ -0,0 +1,77 @@ +// What an ACP launch gets for inline visuals: the chat's folder, and whatever its agent needs to +// load the skill. Best effort: a chat whose agent can't load it starts without visuals. + +import type { StructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import type { + NativeChatVisualsLaunch, + PrepareNativeChatVisuals +} from '../native-chat/native-chat-visuals-delivery' +import type { AcpLaunchSpec } from './acp-launch-specs' +import type { AcpVisualsSkillLaunch } from './acp-visuals-skill' + +export type AcpLaunchVisualsDeps = { + /** Prepares a chat's inline-visuals folder; absent, no chat gets visuals. */ + prepareVisuals?: PrepareNativeChatVisuals + logger?: StructuredAgentSessionLogger +} + +/** The environment the child will see: what it inherits, the launch's laid over, then the strips. */ +function childEnvironmentView( + env: Readonly>, + inherited: NodeJS.ProcessEnv, + envToDelete: readonly string[] +): Record { + const view: Record = {} + for (const [key, value] of Object.entries(inherited)) { + if (value !== undefined) { + view[key] = value + } + } + Object.assign(view, env) + for (const key of envToDelete) { + delete view[key] + } + return view +} + +/** How this launch's agent loads the skill for the chat's prepared `visuals`; null leaves the chat + * without visuals. Never throws. */ +export async function loadAcpVisualsSkill( + spec: AcpLaunchSpec, + deps: AcpLaunchVisualsDeps, + input: { + sessionId: string + visuals: NativeChatVisualsLaunch + version: string | null + env: Readonly> + envToDelete: readonly string[] + inherited: NodeJS.ProcessEnv + } +): Promise { + const { visualsSkill } = spec + if (!visualsSkill) { + return null + } + try { + const skill = await visualsSkill({ + skill: input.visuals.skill, + version: input.version, + env: childEnvironmentView(input.env, input.inherited, input.envToDelete) + }) + if (!skill) { + deps.logger?.warn(`${spec.agent} cannot load the visuals skill; the chat has no visuals`, { + scope: 'nativeChatVisuals.acp', + sessionId: input.sessionId, + version: input.version + }) + } + return skill + } catch (error) { + deps.logger?.warn(`${spec.agent} visuals skill could not be prepared`, { + scope: 'nativeChatVisuals.acp', + sessionId: input.sessionId, + error + }) + return null + } +} diff --git a/src/main/acp/acp-structured-session-adapter.test.ts b/src/main/acp/acp-structured-session-adapter.test.ts index 3fad79da2362..ad3dbcd2e3ff 100644 --- a/src/main/acp/acp-structured-session-adapter.test.ts +++ b/src/main/acp/acp-structured-session-adapter.test.ts @@ -456,8 +456,19 @@ describe('ACP structured session adapter: close and exit', () => { describe('Grok launch spec', () => { it('runs `grok agent stdio`, asking for always-approve only with full access', () => { - expect(GROK.args({ fullAccess: false })).toEqual(['agent', 'stdio']) - expect(GROK.args({ fullAccess: true })).toEqual(['agent', '--always-approve', 'stdio']) + expect(GROK.args({ fullAccess: false, pluginDir: null })).toEqual(['agent', 'stdio']) + expect(GROK.args({ fullAccess: true, pluginDir: null })).toEqual([ + 'agent', + '--always-approve', + 'stdio' + ]) + expect(GROK.args({ fullAccess: true, pluginDir: '/orca/visuals' })).toEqual([ + 'agent', + '--always-approve', + '--plugin-dir', + '/orca/visuals', + 'stdio' + ]) expect(GROK.env).toEqual({}) }) }) diff --git a/src/main/acp/acp-visuals-skill.test.ts b/src/main/acp/acp-visuals-skill.test.ts new file mode 100644 index 000000000000..3b0b4f700745 --- /dev/null +++ b/src/main/acp/acp-visuals-skill.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest' +import { + loadGrokVisualsSkill, + loadOmpVisualsSkill, + loadOpenCodeVisualsSkill, + withOpenCodeSkillsPath, + type AcpVisualsSkillInput +} from './acp-visuals-skill' + +const SKILL = { pluginDir: '/app/plugin', skillsRoot: '/app/plugin/skills' } + +function input(overrides: Partial = {}): AcpVisualsSkillInput { + return { + skill: SKILL, + version: null, + env: {}, + ...overrides + } +} + +describe('Grok visuals skill', () => { + it('names the plugin folder only on a release known to take --plugin-dir', async () => { + await expect(loadGrokVisualsSkill(input({ version: '1.0.46' }))).resolves.toEqual({ + pluginDir: '/app/plugin' + }) + await expect(loadGrokVisualsSkill(input({ version: '1.0.44' }))).resolves.toEqual({ + pluginDir: '/app/plugin' + }) + await expect(loadGrokVisualsSkill(input({ version: '1.0.43' }))).resolves.toBeNull() + await expect(loadGrokVisualsSkill(input({ version: null }))).resolves.toBeNull() + }) +}) + +describe('OpenCode visuals skill', () => { + it('adds the skills root as the only inline config when the user has none', async () => { + await expect(loadOpenCodeVisualsSkill(input({ version: '2.0.14' }))).resolves.toEqual({ + env: { OPENCODE_CONFIG_CONTENT: JSON.stringify({ skills: { paths: [SKILL.skillsRoot] } }) } + }) + }) + + it("keeps the user's own inline config and skill paths", () => { + const merged = withOpenCodeSkillsPath( + JSON.stringify({ model: 'm', skills: { paths: ['/mine'], urls: ['https://x'] } }), + SKILL.skillsRoot + ) + expect(JSON.parse(merged!)).toEqual({ + model: 'm', + skills: { paths: ['/mine', SKILL.skillsRoot], urls: ['https://x'] } + }) + expect( + JSON.parse(withOpenCodeSkillsPath(JSON.stringify({ skills: ['/mine'] }), SKILL.skillsRoot)!) + ).toEqual({ skills: ['/mine', SKILL.skillsRoot] }) + }) + + it('leaves a config it cannot extend safely alone', async () => { + for (const content of ['{ // jsonc\n}', '[]', '{"skills": 3}', '{"skills": {"paths": "/x"}}']) { + expect(withOpenCodeSkillsPath(content, SKILL.skillsRoot)).toBeNull() + } + await expect( + loadOpenCodeVisualsSkill( + input({ version: '2.0.14', env: { OPENCODE_CONFIG_CONTENT: 'not json' } }) + ) + ).resolves.toBeNull() + }) + + it("skips 1.x, where the inline list would replace the user's own skill paths", async () => { + await expect(loadOpenCodeVisualsSkill(input({ version: '1.18.31' }))).resolves.toBeNull() + await expect(loadOpenCodeVisualsSkill(input({ version: null }))).resolves.toBeNull() + }) +}) + +describe('OMP visuals skill', () => { + it("names the plugin folder and leaves the user's own config files alone", async () => { + await expect( + loadOmpVisualsSkill( + input({ version: '17.0.5', env: { PI_CONFIG_FILES: '/user/overlay.yml' } }) + ) + ).resolves.toEqual({ pluginDir: '/app/plugin' }) + }) +}) diff --git a/src/main/acp/acp-visuals-skill.ts b/src/main/acp/acp-visuals-skill.ts new file mode 100644 index 000000000000..da08a733923f --- /dev/null +++ b/src/main/acp/acp-visuals-skill.ts @@ -0,0 +1,90 @@ +// How each ACP agent loads the chat-visuals skill for one launch, without touching the user's own +// config: ACP itself has no way to hand an agent a skill. + +import { isStableCliVersionFrom } from '../agent-cli-version-probe' +import type { NativeChatVisualsSkillLocation } from '../native-chat/native-chat-visuals-skill-location' + +export type AcpVisualsSkillInput = { + skill: NativeChatVisualsSkillLocation + /** The `--version` the launch's binary printed; null when it printed none. */ + version: string | null + /** What the child will see: its inherited environment with the launch's laid over it. */ + env: Readonly> +} + +/** What a launch adds so the agent finds the skill: a plugin folder the agent's args name, and/or + * variables laid over its environment. */ +export type AcpVisualsSkillLaunch = { + pluginDir?: string + env?: Record +} + +/** Null: this launch can't load the skill, so the chat starts without visuals. */ +export type AcpVisualsSkillLoader = ( + input: AcpVisualsSkillInput +) => Promise + +// Why 1.0.44: the oldest release checked that has `agent --plugin-dir`; an unknown flag stops Grok. +const GROK_PLUGIN_DIR_FIRST_VERSION = '1.0.44' + +/** Grok takes Claude's plugin layout through `agent --plugin-dir`, for this process only. */ +export const loadGrokVisualsSkill: AcpVisualsSkillLoader = async ({ skill, version }) => + version !== null && isStableCliVersionFrom(version, GROK_PLUGIN_DIR_FIRST_VERSION) + ? { pluginDir: skill.pluginDir } + : null + +/** `content` (the user's own inline config, if any) with `skillsRoot` added to its skill paths; + * null when that config is not one this can extend safely. */ +export function withOpenCodeSkillsPath( + content: string | undefined, + skillsRoot: string +): string | null { + let config: unknown + try { + config = content?.trim() ? JSON.parse(content) : {} + } catch { + // JSONC or a typo: rewriting it would drop the user's settings. + return null + } + if (typeof config !== 'object' || config === null || Array.isArray(config)) { + return null + } + const skills: unknown = 'skills' in config ? config.skills : undefined + // 2.x also takes a bare list of paths and URLs. + if (Array.isArray(skills)) { + return JSON.stringify({ ...config, skills: [...skills, skillsRoot] }) + } + if (skills === undefined) { + return JSON.stringify({ ...config, skills: { paths: [skillsRoot] } }) + } + if (typeof skills !== 'object' || skills === null) { + return null + } + const paths: unknown = 'paths' in skills ? skills.paths : [] + return Array.isArray(paths) + ? JSON.stringify({ ...config, skills: { ...skills, paths: [...paths, skillsRoot] } }) + : null +} + +// Why 2.x: 2.x adds inline `skills.paths` to the config files' list; 1.x lets it replace the +// user's own, so their skills would vanish from Orca's chats. +const OPENCODE_MERGED_SKILL_PATHS_FIRST_VERSION = '2.0.14' + +/** OpenCode reads `skills.paths` from the inline config variable. */ +export const loadOpenCodeVisualsSkill: AcpVisualsSkillLoader = async ({ skill, env, version }) => { + if ( + version === null || + !isStableCliVersionFrom(version, OPENCODE_MERGED_SKILL_PATHS_FIRST_VERSION) + ) { + return null + } + const content = withOpenCodeSkillsPath(env.OPENCODE_CONFIG_CONTENT, skill.skillsRoot) + return content === null ? null : { env: { OPENCODE_CONFIG_CONTENT: content } } +} + +// No gate: 17.0.5+ has `--plugin-dir`, but 18.1.5-18.1.8 skip its skills by default (no visuals). +/** OMP takes Claude's plugin layout through `--plugin-dir` too, for this process only, beside the + * user's own plugins and skill folders. */ +export const loadOmpVisualsSkill: AcpVisualsSkillLoader = async ({ skill }) => ({ + pluginDir: skill.pluginDir +}) diff --git a/src/main/runtime/structured-agent-runtime-registrations.ts b/src/main/runtime/structured-agent-runtime-registrations.ts index 060a87153530..20cc9ee2523c 100644 --- a/src/main/runtime/structured-agent-runtime-registrations.ts +++ b/src/main/runtime/structured-agent-runtime-registrations.ts @@ -223,7 +223,11 @@ function acpRegistration(spec: AcpLaunchSpec): StructuredAgentRuntimeRegistratio ...(deps.resolveAgentCommandSettings ? { resolveCommandSettings: deps.resolveAgentCommandSettings } : {}), - ...(deps.resolveAgentFullAccess ? { resolveFullAccess: deps.resolveAgentFullAccess } : {}) + ...(deps.resolveAgentFullAccess + ? { resolveFullAccess: deps.resolveAgentFullAccess } + : {}), + ...nativeChatVisualsFor(deps), + logger: deps.logger }), connect: (launch, options) => createAcpAgentConnection(launch, options), ...(deps.readProcessStartTime ? { readProcessStartTime: deps.readProcessStartTime } : {}), From 884be9ad3900a57b86b33be4b4437e2fae7fc652 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 8 Oct 2026 10:13:54 -0700 Subject: [PATCH 06/90] feat(native-chat): show each chat's resume as it happens, and start more at once (#26361) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(native-chat): show each chat's resume as it happens, and start more at once Resuming interrupted chats sent one request for the whole batch, and the host answered only after every agent had replied, so "Resuming 21 chats" stayed up until the slowest chat answered and the dialog could only say "Resuming…" for every row. The host also started 3 agents at a time. The desktop app now sends one request per chat, so each chat's result lands as soon as it has one. The host keeps one start limit across all of those requests, sized to the machine's cores instead of a fixed 3. The status bar reads "Resuming chats 9/21". Reopening the dialog mid-run shows a gray spinner where each resuming chat's checkbox was, with its step (waiting to start, starting, waiting for a reply) and time in the tooltip, then a check or the failure row when it settles. A line above the list counts where the chats stand, with filters for In progress, Resumed and Need you. * fix(native-chat): publish restart progress from one host action * Keep chat resume progress stable through reply handoff * Fix restart progress ownership after skipped requests * Keep completed chat resume summaries until viewed and closed * chore(i18n): drop status-bar keys the merge restored --- .../structured-agent-session-host-types.ts | 2 + .../structured-agent-session-host.ts | 3 +- ...red-agent-session-restart-audience.test.ts | 1 + ...ructured-agent-session-restart-cap.test.ts | 50 ++- ...-agent-session-restart-offer-withdrawal.ts | 2 +- ...ed-agent-session-restart-ownership.test.ts | 1 + ...d-agent-session-restart-resume-contract.ts | 2 + ...tured-agent-session-restart-resume-host.ts | 129 +++--- ...nt-session-restart-resume-progress.test.ts | 249 +++++++++++ ...d-agent-session-restart-resume-progress.ts | 73 ++++ ...red-agent-session-restart-resume-runner.ts | 84 ++-- ...red-agent-session-restart-resume-wiring.ts | 1 + ...tured-agent-session-restart-resume.test.ts | 48 ++- .../structured-agent-session-status-feed.ts | 2 + ...d-agent-session-status-summary-equality.ts | 1 + ...structured-agent-session-status-summary.ts | 2 + .../NativeChatResumeOnRestartAgentRow.tsx | 12 +- .../NativeChatResumeOnRestartGroups.tsx | 9 +- ...tiveChatResumeOnRestartModal.list.test.tsx | 6 +- .../NativeChatResumeOnRestartModal.test.tsx | 405 ++++++------------ .../NativeChatResumeOnRestartModal.tsx | 146 ++++--- .../components/NativeChatResumeRunPanel.tsx | 116 +++++ .../NativeChatResumeRunStatusIcon.tsx | 98 +++++ .../components/NativeChatResumeRunSummary.tsx | 123 ++++++ .../components/NativeChatResumeTreeRow.tsx | 25 +- ...-chat-restart-action-notifications.test.ts | 7 +- ...ative-chat-restart-action-notifications.ts | 57 ++- .../native-chat-resume-bulk-progress.test.tsx | 341 +++++++++++++++ ...tive-chat-resume-failed-chat-watch.test.ts | 6 +- .../native-chat-resume-finished-run.test.tsx | 195 +++++++++ .../native-chat-resume-modal.test-support.tsx | 234 ++++++++++ .../native-chat-resume-offered-chat-watch.ts | 106 +++++ ...at-resume-on-restart-outcome-toast.test.ts | 42 +- .../native-chat-resume-on-restart-store.ts | 259 +++++------ .../native-chat-resume-run-status-watch.ts | 27 ++ .../components/native-chat-resume-run-view.ts | 108 +++++ .../components/native-chat-resume-run.test.ts | 170 ++++++++ .../src/components/native-chat-resume-run.ts | 71 +++ ...tive-chat-resume-skipped-progress.test.tsx | 160 +++++++ .../native-chat-resume-tree-state.ts | 9 +- .../NativeChatResumeStatusSegment.tsx | 59 ++- src/renderer/src/i18n/locales/en.json | 37 +- src/renderer/src/i18n/locales/es.json | 37 +- src/renderer/src/i18n/locales/fr.json | 37 +- src/renderer/src/i18n/locales/ja.json | 37 +- src/renderer/src/i18n/locales/ko.json | 37 +- src/renderer/src/i18n/locales/zh.json | 37 +- ...agent-session-status-snapshot-fold.test.ts | 36 ++ .../agent-session-status-snapshot-fold.ts | 3 +- src/shared/agent-session-wire.ts | 5 + 50 files changed, 3005 insertions(+), 702 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-progress.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-progress.ts create mode 100644 src/renderer/src/components/NativeChatResumeRunPanel.tsx create mode 100644 src/renderer/src/components/NativeChatResumeRunStatusIcon.tsx create mode 100644 src/renderer/src/components/NativeChatResumeRunSummary.tsx create mode 100644 src/renderer/src/components/native-chat-resume-bulk-progress.test.tsx create mode 100644 src/renderer/src/components/native-chat-resume-finished-run.test.tsx create mode 100644 src/renderer/src/components/native-chat-resume-modal.test-support.tsx create mode 100644 src/renderer/src/components/native-chat-resume-offered-chat-watch.ts create mode 100644 src/renderer/src/components/native-chat-resume-run-status-watch.ts create mode 100644 src/renderer/src/components/native-chat-resume-run-view.ts create mode 100644 src/renderer/src/components/native-chat-resume-run.test.ts create mode 100644 src/renderer/src/components/native-chat-resume-run.ts create mode 100644 src/renderer/src/components/native-chat-resume-skipped-progress.test.tsx create mode 100644 src/shared/agent-session-status-snapshot-fold.test.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index 2230efa15c9b..34fe554d31cd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -109,6 +109,8 @@ export type StructuredAgentSessionHostSession = { * observed exit. */ child: StructuredAgentSessionProviderChild | null lastEndedChild?: StructuredAgentSessionEndedChild + /** Owned by one live restart action, so another caller cannot replace its progress. */ + restartResume?: NonNullable & { operationId: string } } export type StructuredAgentSessionHostDeps = { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index e0f193ba0b49..9a033b5f831b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -152,7 +152,8 @@ export class StructuredAgentSessionHost { }) this.restartResume = createStructuredAgentSessionRestartResume(deps, this.sessions, { ...structuredAgentSessionRestartResumeSurfaces(this, this.now), - readChildWork: this.clientDelivery.readChildWork + readChildWork: this.clientDelivery.readChildWork, + publishStatus: this.clientDelivery.publishStatusAndSettlement }) this.lifetime = createStructuredAgentSessionConversationLifetime({ context: () => this.lifetimeContext(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-audience.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-audience.test.ts index f468d0bd12ee..c53f9b659517 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-audience.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-audience.test.ts @@ -41,6 +41,7 @@ it('neither continues nor reserves an offer the caller cannot show, named or not expect(await host.restartResume.continueAfterRestart(named, 'modal', cannotShowCodex)).toEqual({ resumed: [], continued: [], + ...(named ? { skipped: [SESSION] } : {}), sessions: [], failed: [] }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-cap.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-cap.test.ts index 155785ae35ef..59adcf21a68e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-cap.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-cap.test.ts @@ -1,4 +1,4 @@ -// "Resume all" starts agents a few at a time. A continuation is a send, so a chat counts as started +// Resumes start agents a few at a time host-wide. A continuation is a send, so a chat counts as started // once its agent took the message or its start failed — not when the message was accepted, which // is instant and would let every agent start at once. @@ -9,7 +9,6 @@ import { } from './structured-agent-session-restart-continuation' import { resumeStructuredAgentSessionsFromRestart, - STRUCTURED_AGENT_SESSION_RESUME_CONCURRENCY, StructuredAgentSessionResumeAdmission } from './structured-agent-session-restart-resume-runner' import { marker } from './structured-agent-session-restart-resume-test-harness' @@ -17,6 +16,45 @@ import { createStructuredAgentSessionLogger } from './structured-agent-session-l type Handover = { dispatchState: string; reason?: string | null } | undefined +const LIMIT = 3 + +it('publishes queued chats separately from the chat holding the start slot', async () => { + const first = Promise.withResolvers() + const phases = new Map() + const action = resumeStructuredAgentSessionsFromRestart( + { + admission: new StructuredAgentSessionResumeAdmission(1), + onPhase: (sessionId, phase) => phases.set(sessionId, phase), + consumeMarker: async () => true, + resume: async (sessionId) => { + if (sessionId === 'a') { + await first.promise + } + } + }, + ['a', 'b'].map((sessionId) => ({ + sessionId, + workspaceId: 'folder', + executionHostId: 'local', + workspaceKind: 'folder', + agent: 'codex', + work: { kind: 'turn', id: 'turn' }, + trigger: 'quit', + recordedAt: 1, + latestPrompt: '' + })), + 'modal' + ) + try { + await vi.waitFor(() => expect(phases.get('a')).toBe('starting')) + expect(phases.get('b')).toBe('queued') + } finally { + first.resolve() + await action + } + expect(phases.get('b')).toBe('starting') +}) + it('holds each slot from accept until handover or rejection, and frees it when the wait ends (P2-27)', async () => { const handovers = new Map>() let waiting = 0 @@ -42,7 +80,7 @@ it('holds each slot from accept until handover or rejection, and frees it when t const sessions = Array.from({ length: 8 }, (_, index) => `session-${index}`) const outcomes = resumeStructuredAgentSessionsFromRestart( { - admission: new StructuredAgentSessionResumeAdmission(), + admission: new StructuredAgentSessionResumeAdmission(LIMIT), consumeMarker: async () => true, resume: async (sessionId) => { const started = await startStructuredAgentSessionContinuation( @@ -65,10 +103,10 @@ it('holds each slot from accept until handover or rejection, and frees it when t handovers.get(sessionId)!.resolve(handover) } - await vi.waitFor(() => expect(waiting).toBe(STRUCTURED_AGENT_SESSION_RESUME_CONCURRENCY)) + await vi.waitFor(() => expect(waiting).toBe(LIMIT)) // Accepted but not yet handed over: a slow start keeps its slot. await new Promise((resolve) => setTimeout(resolve, 20)) - expect(handovers.size).toBe(STRUCTURED_AGENT_SESSION_RESUME_CONCURRENCY) + expect(handovers.size).toBe(LIMIT) await settle('session-0', { dispatchState: 'pending' }) await settle('session-1', { dispatchState: 'rejected', reason: 'Codex could not start.' }) // The session closed, or too many waited: the wait proves nothing, and the slot is freed. @@ -78,7 +116,7 @@ it('holds each slot from accept until handover or rejection, and frees it when t } const results = await outcomes - expect(mostWaiting).toBe(STRUCTURED_AGENT_SESSION_RESUME_CONCURRENCY) + expect(mostWaiting).toBe(LIMIT) expect(results.find((result) => result.sessionId === 'session-1')).toMatchObject({ outcome: 'refused', reason: 'Codex could not start.' diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.ts index 4924860b05f2..96aabe1483bb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.ts @@ -21,7 +21,7 @@ export type StructuredAgentSessionRestartOfferWithdrawal = ReturnType< export type StructuredAgentSessionRestartOfferSession = Pick< StructuredAgentSessionHostSession, - 'journal' | 'child' | 'lastEndedChild' + 'journal' | 'child' | 'lastEndedChild' | 'restartResume' > export function createStructuredAgentSessionRestartOfferWithdrawal(deps: { 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 0d73656b3477..8614f4353200 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 @@ -326,6 +326,7 @@ it('fails closed on corrupt recovery storage while an ordinary send still works' expect(await host.restartResume.continueAfterRestart([SESSION], 'modal')).toEqual({ resumed: [], continued: [], + skipped: [SESSION], sessions: [], failed: [] }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-contract.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-contract.ts index 1cbbd8236ad0..d15ab6c874c6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-contract.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-contract.ts @@ -33,6 +33,8 @@ export type StructuredAgentSessionRestartResume = { ) => Promise<{ resumed: StructuredAgentSessionResumeOutcome[] continued: StructuredAgentSessionContinuationOutcome[] + /** Requested chats excluded from this action. */ + skipped?: string[] sessions?: StructuredAgentSessionResumeCandidate[] failed?: StructuredAgentSessionResumeFailure[] }> diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts index 120aefb6f205..e1a4e14cd0d9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts @@ -3,6 +3,7 @@ // action removes them — or files what went wrong. Starting the chat's agent again withdraws them. import { randomUUID } from 'node:crypto' +import { createRestartResumeProgress } from './structured-agent-session-restart-resume-progress' import { AgentSessionRefusalError, agentSessionRefusalFromReference @@ -139,13 +140,14 @@ export function createStructuredAgentSessionRestartResume( stillResumable: (marker) => deriveAtSend([marker], 'may-be-held').candidates.length === 1 } - /** One explicit action: reserve the offers, then continue each through `continueOne`, a few at - * a time. The runner counts a chat as done once `continueOne` returns. */ + /** One explicit action: reserve the offers, then continue each through `continueOne`, as many at + * once as the admission's host-wide start limit allows. A chat is done once `continueOne` returns. */ const run = async ( sessionIds: readonly string[] | undefined, owner: string, audience: StructuredAgentSessionRestartAudience | undefined, - continueOne: (marker: AgentSessionResumeMarker, continuationId: string) => Promise + continueOne: (marker: AgentSessionResumeMarker, continuationId: string) => Promise, + progress: ReturnType ) => { // An explicit action supersedes teardown witnesses captured by this host. The durable mutation // lane below also drains a publication already in flight before completion. @@ -159,6 +161,7 @@ export function createStructuredAgentSessionRestartResume( const eligible = restartRowsFor(derived.candidates, audience).filter((candidate) => requested.has(candidate.sessionId) ) + progress.skipExcluded(sessionIds ?? [], eligible) if (eligible.length === 0) { return null } @@ -178,10 +181,16 @@ export function createStructuredAgentSessionRestartResume( )) ?? [] const markersBySession = new Map(reserved.map((marker) => [marker.sessionId, marker])) const candidates = derive(reserved, 'may-be-held').candidates + progress.skipExcluded( + eligible.map((candidate) => candidate.sessionId), + candidates + ) try { const outcomes = await resumeStructuredAgentSessionsFromRestart( { admission, + onPhase: progress.set, + onRefused: (sessionId) => progress.set(sessionId, 'refused'), consumeMarker: async (sessionId) => { const marker = markersBySession.get(sessionId) return ( @@ -219,55 +228,75 @@ export function createStructuredAgentSessionRestartResume( owner, audience ) => { - const continued: StructuredAgentSessionContinuationOutcome[] = [] - const verdicts: Promise[] = [] - // A chat holds its slot until its agent took the continuation or its start failed, so a batch - // never starts more agents at once than the runner allows; the provider's answer comes after. - const action = await run(sessionIds, owner, audience, async (marker, continuationId) => { - const started = await startStructuredAgentSessionContinuation( - continuationDeps(continuationHost, () => continuationHost.stillResumable(marker)), - marker.sessionId, - marker, - continuationId + const progress = createRestartResumeProgress(sessions, surfaces.publishStatus) + try { + const continued: StructuredAgentSessionContinuationOutcome[] = [] + const verdicts: Promise[] = [] + // A chat holds its slot until its agent took the continuation or its start failed, so a batch + // never starts more agents at once than the runner allows; the provider's answer comes after. + const action = await run( + sessionIds, + owner, + audience, + async (marker, continuationId) => { + const started = await startStructuredAgentSessionContinuation( + continuationDeps(continuationHost, () => continuationHost.stillResumable(marker)), + marker.sessionId, + marker, + continuationId + ) + if ('done' in started) { + continued.push(started.done) + progress.verdict(started.done) + const { outcome, reason, refusal } = started.done + if (outcome === 'refused') { + // Thrown as a refusal so the filed failure keeps its details beside the code. + throw refusal + ? new AgentSessionRefusalError( + agentSessionRefusalFromReference(refusal, refusal.code) + ) + : new Error(reason ?? 'agent_session_continuation_refused') + } + return + } + verdicts.push( + started.verdict().then((outcome) => { + continued.push(outcome) + progress.verdict(outcome) + }) + ) + }, + progress ) - if ('done' in started) { - continued.push(started.done) - const { outcome, reason, refusal } = started.done - if (outcome === 'refused') { - // Thrown as a refusal so the filed failure keeps its details beside the code. - throw refusal - ? new AgentSessionRefusalError(agentSessionRefusalFromReference(refusal, refusal.code)) - : new Error(reason ?? 'agent_session_continuation_refused') - } - return + await Promise.all(verdicts) + const resumed = action?.outcomes ?? [] + if (action) { + await failures.settle(action.operationId, resumed, { + candidates: action.candidates, + markers: action.markers, + failureAfterResume: (sessionId) => { + const outcome = continued.find((entry) => entry.sessionId === sessionId) + return outcome ? continuationFailureOutcome(outcome.outcome) : null + }, + failureReason: (sessionId) => { + const outcome = continued.find((entry) => entry.sessionId === sessionId) + return outcome?.reason ?? outcome?.outcome ?? 'agent_session_continuation_unknown' + } + }) } - verdicts.push(started.verdict().then((outcome) => void continued.push(outcome))) - }) - await Promise.all(verdicts) - const resumed = action?.outcomes ?? [] - if (action) { - await failures.settle(action.operationId, resumed, { - candidates: action.candidates, - markers: action.markers, - failureAfterResume: (sessionId) => { - const outcome = continued.find((entry) => entry.sessionId === sessionId) - return outcome ? continuationFailureOutcome(outcome.outcome) : null - }, - failureReason: (sessionId) => { - const outcome = continued.find((entry) => entry.sessionId === sessionId) - return outcome?.reason ?? outcome?.outcome ?? 'agent_session_continuation_unknown' - } - }) - } - continued.push(...unstartedRestartRefusals(resumed, continued)) - return { - resumed, - continued, - ...(await remainingRestartRows( - () => list(audience), - () => listFailures(audience), - deps.logger - )) + continued.push(...unstartedRestartRefusals(resumed, continued)) + return { + resumed, + continued, + ...(progress.skipped.length > 0 ? { skipped: progress.skipped } : {}), + ...(await remainingRestartRows( + () => list(audience), + () => listFailures(audience), + deps.logger + )) + } + } finally { + progress.clear() } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-progress.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-progress.test.ts new file mode 100644 index 000000000000..cb38b4fdce06 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-progress.test.ts @@ -0,0 +1,249 @@ +import * as runner from './structured-agent-session-restart-resume-runner' +import { afterEach, expect, it, vi } from 'vitest' +import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' +import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' +import { + interruptedRestart, + startAgent, + throwAfterContinuationAccepted +} from './structured-agent-session-restart-interruption-test-harness' +import { HOST_TEST_SESSION as SESSION } from './structured-agent-session-host-test-data' +import { createRestartResumeProgress } from './structured-agent-session-restart-resume-progress' +import { journal } from './structured-agent-session-restart-resume-test-harness' +import type { StructuredAgentSessionRestartOfferSession } from './structured-agent-session-restart-offer-withdrawal' + +afterEach(() => vi.restoreAllMocks()) + +it.each(['queued', 'starting', 'continued', 'refused', 'unconfirmed'] as const)( + "keeps %s owned by the admitted action through another action's skip and cleanup", + (phase) => { + const session: StructuredAgentSessionRestartOfferSession = { journal: journal([]), child: null } + const sessions = new Map([[SESSION, session]]) + const publish = vi.fn() + const excluded = createRestartResumeProgress(sessions, publish) + const admitted = createRestartResumeProgress(sessions, publish) + const other = createRestartResumeProgress(sessions, publish) + excluded.set(SESSION, 'skipped') + const skipped = session.restartResume + other.set(SESSION, 'skipped') + expect(session.restartResume).toBe(skipped) + admitted.set(SESSION, 'queued') + admitted.set(SESSION, phase) + const owned = session.restartResume + expect(owned?.phase).toBe(phase) + expect(owned?.operationId).not.toBe(skipped?.operationId) + excluded.set(SESSION, 'skipped') + other.set(SESSION, 'queued') + other.set(SESSION, 'refused') + excluded.clear() + other.clear() + expect(session.restartResume).toBe(owned) + admitted.clear() + expect(session.restartResume).toBeUndefined() + expect(publish).toHaveBeenCalledTimes(phase === 'queued' ? 3 : 4) + } +) + +it('publishes a real continuation through an overlapping audience exclusion', async () => { + const { host, dispatch } = await interruptedRestart() + await host.restartResume.list() + const phases: (string | undefined)[] = [] + const release = host.subscribeStatus({ + id: 'overlap', + emit: (event) => { + if (event.type === 'status') { + phases.push(event.session.restartResume?.phase) + } + } + }) + const excludedAtReply = Promise.withResolvers() + const releaseExcluded = Promise.withResolvers() + const list = AgentSessionRecoveryCapsule.prototype.list + let reads = 0 + vi.spyOn(AgentSessionRecoveryCapsule.prototype, 'list').mockImplementation(async function ( + this: AgentSessionRecoveryCapsule, + ...args + ) { + if (++reads === 2) { + excludedAtReply.resolve() + await releaseExcluded.promise + } + return list.apply(this, args) + }) + const releaseDispatch = Promise.withResolvers() + const dispatchNormally = dispatch.getMockImplementation() + dispatch.mockImplementationOnce(async (input) => { + await releaseDispatch.promise + if (!dispatchNormally) { + throw new Error('missing provider dispatch') + } + return dispatchNormally(input) + }) + const excluded = host.restartResume.continueAfterRestart( + [SESSION], + 'excluded-caller', + (agent) => agent !== 'codex' + ) + let admitted: ReturnType | undefined + try { + await excludedAtReply.promise + expect(phases).toEqual(['skipped']) + admitted = host.restartResume.continueAfterRestart([SESSION], 'admitted-caller') + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) + expect(phases).toContain('queued') + expect(phases.at(-1)).toBe('starting') + releaseExcluded.resolve() + expect(await excluded).toMatchObject({ skipped: [SESSION], continued: [] }) + expect(phases.at(-1)).toBe('starting') + releaseDispatch.resolve() + expect(await admitted).toMatchObject({ + continued: [{ sessionId: SESSION, outcome: 'continued' }] + }) + expect(phases).toContain('continued') + expect(phases.at(-1)).toBeUndefined() + expect(dispatch).toHaveBeenCalledOnce() + } finally { + releaseExcluded.resolve() + releaseDispatch.resolve() + await Promise.allSettled([excluded, admitted]) + release() + } +}) + +it('publishes skipped when an offer disappears during reservation without starting its agent', async () => { + const { host, acquire, dispatch } = await interruptedRestart() + expect(await host.restartResume.list()).toHaveLength(1) + vi.spyOn(AgentSessionRecoveryCapsule.prototype, 'beginResume').mockResolvedValueOnce([]) + const phases: (string | undefined)[] = [] + const release = host.subscribeStatus({ + id: 'reservation-skipped', + emit: (event) => { + if (event.type === 'status') { + phases.push(event.session.restartResume?.phase) + } + } + }) + try { + const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') + expect(result).toMatchObject({ skipped: [SESSION], resumed: [], continued: [] }) + expect(phases).toEqual(['skipped', undefined]) + expect(acquire).not.toHaveBeenCalled() + expect(dispatch).not.toHaveBeenCalled() + } finally { + release() + } +}) + +it('publishes skipped for an offer started elsewhere and sends no continuation', async () => { + const state = await interruptedRestart() + expect(await state.host.restartResume.list()).toHaveLength(1) + await startAgent(state) + state.acquire.mockClear() + const summaries: AgentSessionStatusSummary[] = [] + const release = state.host.subscribeStatus({ + id: 'skipped', + emit: (event) => { + if (event.type === 'status') { + summaries.push(event.session) + } + } + }) + try { + const result = await state.host.restartResume.continueAfterRestart([SESSION], 'modal') + expect(result).toMatchObject({ skipped: [SESSION], resumed: [], continued: [] }) + expect(state.acquire).not.toHaveBeenCalled() + expect(state.dispatch).not.toHaveBeenCalled() + expect(summaries.map((summary) => summary.restartResume?.phase)).toContain('skipped') + expect(summaries.at(-1)).not.toHaveProperty('restartResume') + } finally { + release() + } +}) + +it.each(['continued', 'refused', 'unconfirmed'] as const)( + 'publishes %s before bookkeeping finishes, then clears progress on return', + async (phase) => { + const { host, acquire } = await interruptedRestart() + await host.restartResume.list() + const summaries: AgentSessionStatusSummary[] = [] + const release = host.subscribeStatus({ + id: 'progress', + emit: (event) => { + if (event.type === 'status') { + summaries.push(event.session) + } + } + }) + if (phase === 'refused') { + acquire.mockRejectedValueOnce(new Error('start refused')) + } + if (phase === 'unconfirmed') { + throwAfterContinuationAccepted() + } + const bookkeeping = Promise.withResolvers() + const complete = AgentSessionRecoveryCapsule.prototype.completeResume + vi.spyOn(AgentSessionRecoveryCapsule.prototype, 'completeResume').mockImplementation( + async function (this: AgentSessionRecoveryCapsule, ...args) { + await bookkeeping.promise + return complete.apply(this, args) + } + ) + let finished = false + const action = host.restartResume.continueAfterRestart([SESSION], 'modal').then((result) => { + finished = true + return result + }) + try { + await vi.waitFor(() => expect(summaries.at(-1)?.restartResume?.phase).toBe(phase)) + expect(finished).toBe(false) + expect(summaries.map((summary) => summary.restartResume?.phase)).toContain('queued') + expect(summaries.map((summary) => summary.restartResume?.phase)).toContain('starting') + // Reopening takes a snapshot of the same live action, without needing its earlier frames. + const snapshots: AgentSessionStatusSummary[] = [] + const closeSnapshot = host.subscribeStatus({ + id: 'reopen', + emit: (event) => { + if (event.type === 'snapshot') { + snapshots.push(...event.sessions) + } + } + }) + expect(snapshots.find((summary) => summary.sessionId === SESSION)?.restartResume?.phase).toBe( + phase + ) + closeSnapshot() + } finally { + bookkeeping.resolve() + await action + release() + } + expect(summaries.at(-1)).not.toHaveProperty('restartResume') + } +) + +it('clears progress when the resume action throws', async () => { + const { host } = await interruptedRestart() + await host.restartResume.list() + const summaries: AgentSessionStatusSummary[] = [] + const release = host.subscribeStatus({ + id: 'progress', + emit: (event) => { + if (event.type === 'status') { + summaries.push(event.session) + } + } + }) + const resume = runner.resumeStructuredAgentSessionsFromRestart + vi.spyOn(runner, 'resumeStructuredAgentSessionsFromRestart').mockImplementation( + async (...args) => { + await resume(...args) + throw new Error('resume action failed') + } + ) + await expect(host.restartResume.continueAfterRestart([SESSION], 'modal')).rejects.toThrow( + 'resume action failed' + ) + expect(summaries.some((summary) => summary.restartResume?.phase === 'starting')).toBe(true) + expect(summaries.at(-1)).not.toHaveProperty('restartResume') + release() +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-progress.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-progress.ts new file mode 100644 index 000000000000..1989ecc42114 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-progress.ts @@ -0,0 +1,73 @@ +import { randomUUID } from 'node:crypto' +import type { StructuredAgentSessionContinuationOutcome } from './structured-agent-session-restart-continuation' +import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' +import type { StructuredAgentSessionRestartOfferSession } from './structured-agent-session-restart-offer-withdrawal' + +type Phase = NonNullable['phase'] + +/** Progress belongs to the action, never to the durable recovery obligation. */ +export function createRestartResumeProgress( + sessions: ReadonlyMap, + publish: ((sessionId: string) => void) | undefined +) { + const operationId = randomUUID() + const owned = new Map() + const skipped: string[] = [] + let active = true + return { + skipped, + skipExcluded(requested: readonly string[], admitted: readonly { sessionId: string }[]): void { + const admittedIds = new Set(admitted.map((entry) => entry.sessionId)) + for (const sessionId of new Set(requested)) { + if (!admittedIds.has(sessionId)) { + skipped.push(sessionId) + this.set(sessionId, 'skipped') + } + } + }, + set(sessionId: string, phase: Phase): void { + if (!active) { + return + } + const session = sessions.get(sessionId) + if (!session) { + return + } + if (phase === 'queued' || phase === 'skipped') { + // Excluding a request cannot take progress away from an admitted action. + if ( + session.restartResume && + (phase === 'skipped' || session.restartResume.phase !== 'skipped') + ) { + return + } + owned.set(sessionId, session) + } else if (session.restartResume?.operationId !== operationId) { + return + } + session.restartResume = { operationId, phase } + publish?.(sessionId) + }, + verdict(outcome: StructuredAgentSessionContinuationOutcome): void { + this.set( + outcome.sessionId, + outcome.outcome === 'continued' + ? 'continued' + : outcome.outcome === 'refused' + ? 'refused' + : 'unconfirmed' + ) + }, + clear(): void { + active = false + for (const [sessionId, session] of owned) { + if (session.restartResume?.operationId !== operationId) { + continue + } + delete session.restartResume + publish?.(sessionId) + } + owned.clear() + } + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-runner.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-runner.ts index a298e23f454e..14471e808dc3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-runner.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-runner.ts @@ -12,12 +12,13 @@ import { isAgentSessionRefusalError, type AgentSessionAnyRefusalDetails } from '../../../shared/agent-session-wire-refusals' -import { forEachWithConcurrency } from '../../../shared/map-with-concurrency' +import { availableParallelism } from 'node:os' +import { PrioritySemaphore } from '../../../shared/priority-semaphore' import type { StructuredAgentSessionResumeCandidate } from './structured-agent-session-restart-resume-set' -/** Providers are expensive to start and 20-30 marked chats is an ordinary morning. Resumes go out - * a few at a time so a launch cannot spawn every app-server at once. */ -export const STRUCTURED_AGENT_SESSION_RESUME_CONCURRENCY = 3 +/** Agents STARTING at once on this host, across every resume in flight. A start is a CPU-bound CLI + * boot, so one per core; once started, every chat runs at the same time anyway. */ +export const STRUCTURED_AGENT_SESSION_RESUME_CONCURRENCY = Math.max(1, availableParallelism()) export const STRUCTURED_AGENT_SESSION_RESUME_IN_PROGRESS = 'agent_session_resume_already_in_progress' @@ -54,27 +55,47 @@ function resumeAdmissionOwner(error: unknown): string | null { } /** - * One resume per session at a time, whoever is asking. + * One resume per session at a time, whoever is asking, and a bounded number starting host-wide. * * Two surfaces can reach for the same chat at once — the banner's "Resume all" and a user clicking * one row — and both would otherwise send the continuation twice, and leave the loser's * refusal looking like a real failure. The second caller is told who holds it instead. + * + * The start limit spans callers, so simultaneous actions share the same host budget. */ export class StructuredAgentSessionResumeAdmission { private readonly owners = new Map() + private readonly starts: PrioritySemaphore + + constructor(startLimit: number = STRUCTURED_AGENT_SESSION_RESUME_CONCURRENCY) { + this.starts = new PrioritySemaphore(startLimit) + } liveOwner(sessionId: string): string | null { return this.owners.get(sessionId) ?? null } - async run(sessionId: string, owner: string, task: () => Promise): Promise { + async run( + sessionId: string, + owner: string, + task: () => Promise, + onPhase?: (phase: 'queued' | 'starting') => void + ): Promise { const live = this.owners.get(sessionId) if (live !== undefined) { throw new StructuredAgentSessionResumeInProgressError(live) } + // Owned while it waits for a slot, so a second caller is told it is already on its way. this.owners.set(sessionId, owner) try { - return await task() + onPhase?.('queued') + const release = await this.starts.acquire(0) + try { + onPhase?.('starting') + return await task() + } finally { + release() + } } finally { this.owners.delete(sessionId) } @@ -83,11 +104,12 @@ export class StructuredAgentSessionResumeAdmission { export type StructuredAgentSessionResumeRunnerDeps = { admission: StructuredAgentSessionResumeAdmission + onPhase?: (sessionId: string, phase: 'queued' | 'starting') => void + onRefused?: (sessionId: string) => void /** Validates this action's durable reservation. False means the candidate is no longer eligible. */ consumeMarker: (sessionId: string) => Promise /** Continues the reserved session; resolves once its agent took the message or refused it. */ resume: (sessionId: string) => Promise - concurrency?: number } export async function resumeStructuredAgentSessionsFromRestart( @@ -95,15 +117,16 @@ export async function resumeStructuredAgentSessionsFromRestart( candidates: readonly StructuredAgentSessionResumeCandidate[], owner: string ): Promise { - const outcomes: StructuredAgentSessionResumeOutcome[] = [] - await forEachWithConcurrency( - candidates, - deps.concurrency ?? STRUCTURED_AGENT_SESSION_RESUME_CONCURRENCY, - async (candidate) => { - outcomes.push(await resumeOne(deps, candidate.sessionId, owner)) - } + // The admission's start limit staggers these, shared with every other resume on this host. + return Promise.all( + candidates.map(async (candidate) => { + const outcome = await resumeOne(deps, candidate.sessionId, owner) + if (outcome.outcome === 'refused') { + deps.onRefused?.(candidate.sessionId) + } + return outcome + }) ) - return outcomes } async function resumeOne( @@ -112,19 +135,24 @@ async function resumeOne( owner: string ): Promise { try { - return await deps.admission.run(sessionId, owner, async () => { - // Validate BEFORE provider acquisition. The durable reservation is removed only after the - // action succeeds, and a failed acquisition reopens it for the next explicit attempt. - if (!(await deps.consumeMarker(sessionId))) { - return { - sessionId, - outcome: 'refused' as const, - reason: STRUCTURED_AGENT_SESSION_RESUME_NOT_ELIGIBLE + return await deps.admission.run( + sessionId, + owner, + async () => { + // Validate BEFORE provider acquisition. The durable reservation is removed only after the + // action succeeds, and a failed acquisition reopens it for the next explicit attempt. + if (!(await deps.consumeMarker(sessionId))) { + return { + sessionId, + outcome: 'refused' as const, + reason: STRUCTURED_AGENT_SESSION_RESUME_NOT_ELIGIBLE + } } - } - await deps.resume(sessionId) - return { sessionId, outcome: 'resumed' as const } - }) + await deps.resume(sessionId) + return { sessionId, outcome: 'resumed' as const } + }, + (phase) => deps.onPhase?.(sessionId, phase) + ) } catch (error) { const reason = error instanceof Error ? error.message : String(error) const owner = resumeAdmissionOwner(error) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-wiring.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-wiring.ts index 1c17dc11e301..4a35bee89776 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-wiring.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-wiring.ts @@ -34,6 +34,7 @@ export type StructuredAgentSessionRestartResumeSurfaces = { ) => Promise<{ value: AgentSessionSendResult } | undefined> /** The session's child records, the host's one read of them. */ readChildWork: (sessionId: string) => readonly AgentChildWorkView[] | undefined + publishStatus?: (sessionId: string) => void now: () => number } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts index b16c7277fc10..bc88fe1c4152 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts @@ -5,6 +5,7 @@ // finished; missing one is an annoyance. Each test removes exactly one input from an otherwise // resumable session, so deleting the matching guard turns that test red. +import { availableParallelism } from 'node:os' import { describe, expect, it, vi } from 'vitest' import { structuredAgentSessionResumableSet } from './structured-agent-session-restart-resume-set' import { @@ -558,14 +559,19 @@ describe('the resumable set', () => { }) describe('spending a marker', () => { - function runner(overrides: { resume?: () => Promise; concurrency?: number } = {}) { + function runner( + overrides: { + resume?: () => Promise + admission?: StructuredAgentSessionResumeAdmission + } = {} + ) { const consumed = new Set() const resume = overrides.resume ?? vi.fn(async () => {}) return { resume, consumed, deps: { - admission: new StructuredAgentSessionResumeAdmission(), + admission: overrides.admission ?? new StructuredAgentSessionResumeAdmission(), // Stands in for the durable store: the first caller spends it, later ones find it gone. consumeMarker: async (sessionId: string) => { if (consumed.has(sessionId)) { @@ -574,8 +580,7 @@ describe('spending a marker', () => { consumed.add(sessionId) return true }, - resume, - ...(overrides.concurrency === undefined ? {} : { concurrency: overrides.concurrency }) + resume } } } @@ -670,6 +675,7 @@ describe('spending a marker', () => { let live = 0 let peak = 0 const { deps } = runner({ + admission: new StructuredAgentSessionResumeAdmission(3), resume: async () => { live += 1 peak = Math.max(peak, live) @@ -684,7 +690,39 @@ describe('spending a marker', () => { const outcomes = await resumeStructuredAgentSessionsFromRestart(deps, candidates, 'banner') // Unbounded fan-out would peak at all 12 — which is the spawn storm this exists to prevent. - expect(peak).toBe(STRUCTURED_AGENT_SESSION_RESUME_CONCURRENCY) + expect(peak).toBe(3) expect(outcomes).toHaveLength(candidates.length) }) + + // Different callers share one host-wide start limit. + it('shares the start limit across separate resume requests', async () => { + let live = 0 + let peak = 0 + const { deps } = runner({ + admission: new StructuredAgentSessionResumeAdmission(3), + resume: async () => { + live += 1 + peak = Math.max(peak, live) + await new Promise((resolve) => setTimeout(resolve, 5)) + live -= 1 + } + }) + + const outcomes = await Promise.all( + Array.from({ length: 10 }, (_, index) => + resumeStructuredAgentSessionsFromRestart( + deps, + [candidate(`session-separate-${index}`)], + 'modal' + ) + ) + ) + + expect(peak).toBe(3) + expect(outcomes.flat().every((outcome) => outcome.outcome === 'resumed')).toBe(true) + }) + + it('sizes the default start limit to this machine', () => { + expect(STRUCTURED_AGENT_SESSION_RESUME_CONCURRENCY).toBe(Math.max(1, availableParallelism())) + }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts index e053d401200f..2a26af994dcb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts @@ -46,6 +46,7 @@ type StatusFeedSession = { journal: AgentSessionJournal params: { location: AgentSessionRecord['location']; provider: AgentSessionRecord['provider'] } child?: Pick | null + restartResume?: AgentSessionStatusSummary['restartResume'] } export type StructuredAgentSessionStatusFeedDeps = { @@ -164,6 +165,7 @@ export class StructuredAgentSessionStatusFeed { hostExecutionOwned: _hostExecutionOwned, hostExecutionPhase: _hostExecutionPhase, stopping: _stopping, + restartResume: _restartResume, ...retained } = previous this.published.set(sessionId, { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary-equality.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary-equality.ts index f5c3aba19781..ed9371e8e42c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary-equality.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary-equality.ts @@ -20,6 +20,7 @@ export function structuredStatusSummariesEqual( a.status === b.status && a.hostExecutionOwned === b.hostExecutionOwned && a.hostExecutionPhase === b.hostExecutionPhase && + a.restartResume?.phase === b.restartResume?.phase && a.rewindBlockedReason === b.rewindBlockedReason && // A moved state clock changes ranking; row activity alone, including a subagent's, does not. // An idle state the journal cannot date still republishes, since readers date it by `updatedAt`, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary.ts index 1b5c8d2b0924..70980484b772 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary.ts @@ -22,6 +22,7 @@ export function structuredAgentSessionStatusSummary({ session: { params: { location: AgentSessionRecord['location']; provider: AgentSessionRecord['provider'] } child?: Pick | null + restartResume?: AgentSessionStatusSummary['restartResume'] } journal: AgentSessionJournal record: AgentSessionRecord | null @@ -47,6 +48,7 @@ export function structuredAgentSessionStatusSummary({ hostExecutionPhase: session.child.phase } : {}), + ...(session.restartResume ? { restartResume: { phase: session.restartResume.phase } } : {}), ...projected, // Only a working session is still being stopped; any other status already ended that work. ...(stopping && projected.status === 'working' ? { stopping: true as const } : {}), diff --git a/src/renderer/src/components/NativeChatResumeOnRestartAgentRow.tsx b/src/renderer/src/components/NativeChatResumeOnRestartAgentRow.tsx index 0483b0df5a73..47c0c4466793 100644 --- a/src/renderer/src/components/NativeChatResumeOnRestartAgentRow.tsx +++ b/src/renderer/src/components/NativeChatResumeOnRestartAgentRow.tsx @@ -45,7 +45,8 @@ export function ResumeCandidateRow({ disabled, onCheckedChange, failure, - onFailureAction + onFailureAction, + renderStatus }: { candidate: ResumeCandidate /** Named in the checkbox's accessible name: several rows otherwise read identically. */ @@ -57,6 +58,7 @@ export function ResumeCandidateRow({ /** Present when an earlier resume of this chat did not carry on. */ failure?: ResumeFailure onFailureAction?: (action: ResumeFailureAction, sessionId: string) => void + renderStatus?: (sessionId: string, title: string) => React.ReactNode }): React.JSX.Element { const agentLabel = formatAgentTypeLabel(candidate.agent) const title = @@ -65,6 +67,7 @@ export function ResumeCandidateRow({ const model = candidate.model?.trim() ?? '' const activity = resumeActivityLabel(candidate.activity) const depth = useContext(ResumeTreeDepthContext) + const status = renderStatus?.(candidate.sessionId, title) const act = (action: ResumeFailureAction) => onFailureAction?.(action, candidate.sessionId) return ( ) } diff --git a/src/renderer/src/components/NativeChatResumeOnRestartGroups.tsx b/src/renderer/src/components/NativeChatResumeOnRestartGroups.tsx index 0444159c26b8..60497e8a7b7f 100644 --- a/src/renderer/src/components/NativeChatResumeOnRestartGroups.tsx +++ b/src/renderer/src/components/NativeChatResumeOnRestartGroups.tsx @@ -160,6 +160,7 @@ function WorkspaceNode({ onCheckedChange={chat.onCheckedChange} failure={chat.failure} onFailureAction={tree.onFailureAction} + renderStatus={chat.renderStatus} /> ) })} @@ -343,7 +344,9 @@ export function ResumeOnRestartGroups({ selected, onToggle, failureFor, - onFailureAction + onFailureAction, + renderStatus, + selectableIds }: { candidates: readonly ResumeCandidate[] listedAt: number @@ -377,7 +380,9 @@ export function ResumeOnRestartGroups({ repoIdOf, ancestorsOf, failureFor, - onFailureAction + onFailureAction, + renderStatus, + selectableIds } // Why: the machine is worth a level only when it is not obvious. const showMachines = diff --git a/src/renderer/src/components/NativeChatResumeOnRestartModal.list.test.tsx b/src/renderer/src/components/NativeChatResumeOnRestartModal.list.test.tsx index bac1011793ed..1d778c697462 100644 --- a/src/renderer/src/components/NativeChatResumeOnRestartModal.list.test.tsx +++ b/src/renderer/src/components/NativeChatResumeOnRestartModal.list.test.tsx @@ -148,7 +148,7 @@ it('keeps initial focus inside the dialog when reopened during resume', async () ) await act(async () => button('Resume 2 chats').click()) - const opener = button('Resuming 2 chats') + const opener = button('Resuming chats 0/2') await act(async () => { opener.focus() opener.click() @@ -214,10 +214,10 @@ it('disables Select all and the workspace checkbox while a resume runs', async ( ) await act(async () => button('Resume 2 chats').click()) - await act(async () => button('Resuming 2 chats').click()) + await act(async () => button('Resuming chats 0/2').click()) const list = document.querySelector('[aria-label="Chats that would be resumed"]')! const boxes = [...list.querySelectorAll('[role="checkbox"]')] - expect(boxes).toHaveLength(4) + expect(boxes).toHaveLength(2) for (const box of boxes) { expect(box.hasAttribute('disabled')).toBe(true) } diff --git a/src/renderer/src/components/NativeChatResumeOnRestartModal.test.tsx b/src/renderer/src/components/NativeChatResumeOnRestartModal.test.tsx index 2ed87aa5f310..5c4190870203 100644 --- a/src/renderer/src/components/NativeChatResumeOnRestartModal.test.tsx +++ b/src/renderer/src/components/NativeChatResumeOnRestartModal.test.tsx @@ -1,121 +1,75 @@ // @vitest-environment happy-dom - +import { + createResumeModalFixture, + offered, + failure, + type RestartRpc, + type ResumeStatusStream +} from './native-chat-resume-modal.test-support' import { act, StrictMode } from 'react' import { toast } from 'sonner' -import { createRoot, type Root } from 'react-dom/client' -import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { expect, it, vi, type Mock } from 'vitest' import { useAppStore } from '../store' import { getDefaultSettings } from '../../../shared/constants' import { NativeChatResumeOnRestartModal } from './NativeChatResumeOnRestartModal' import { NativeChatResumeStatusSegment } from './status-bar/NativeChatResumeStatusSegment' -import { TooltipProvider } from './ui/tooltip' import { lastToastShow } from './native-chat-resume-toast.test-support' -import type { ResumeCandidate } from './native-chat-resume-on-restart-grouping' import { - button, chatBox, chatBoxes, - dontAskAgain, - failure, - offered + dontAskAgain } from './native-chat-resume-on-restart-modal.test-support' import { - consumeNativeChatResumeOnRestartDialogRequest, getNativeChatResumeOnRestartDialogRequest, requestNativeChatResumeOnRestartDialog } from './native-chat-resume-on-restart-dialog' import { - _resetNativeChatRestartOffer, getNativeChatRestartOffer, refreshNativeChatRestartOffer } from './native-chat-resume-on-restart-store' -const rpc = vi.hoisted(() => vi.fn()) -const activate = vi.hoisted(() => vi.fn(async () => true)) +const rpc: Mock = vi.hoisted(() => vi.fn()) +const statusStream: ResumeStatusStream = vi.hoisted(() => ({ + emit: (_event: Parameters[0]) => {}, + snapshot: new Map() +})) +const activate: Mock<() => Promise> = vi.hoisted(() => vi.fn(async () => true)) vi.mock('@/runtime/structured-agent-session-client', () => ({ callStructuredAgentSession: rpc, - // A failed row opens the status feed; these cases never drive it. - subscribeStructuredAgentSessionStatus: () => new Promise(() => {}) + subscribeStructuredAgentSessionStatus: async ( + _target: unknown, + emit: (event: Parameters[0]) => void + ) => { + statusStream.emit = emit + emit({ type: 'snapshot', sessions: [...statusStream.snapshot.values()] }) + return { unsubscribe: () => {} } + } })) vi.mock('@/lib/activate-ai-vault-structured-session', () => ({ activateAiVaultStructuredSession: activate })) vi.mock('sonner', () => ({ toast: vi.fn() })) -globalThis.IS_REACT_ACT_ENVIRONMENT = true -let root: Root -let container: HTMLDivElement - -/** Outcome rows carry tooltips, so every mount needs the provider the app shell supplies. */ -async function mount(node: React.ReactNode): Promise { - await act(async () => root.render({node})) -} - -function offerIds(): string[] { - return getNativeChatRestartOffer().candidates.map((candidate) => candidate.sessionId) -} - -/** What each toast said: its title, and its description when it has one. */ -function toasts(): unknown[][] { - return vi - .mocked(toast) - .mock.calls.map(([title, options]) => - options?.description === undefined ? [title] : [title, options.description] - ) -} - -beforeEach(() => { - rpc.mockReset() - _resetNativeChatRestartOffer() - consumeNativeChatResumeOnRestartDialogRequest() - vi.mocked(toast).mockClear() - useAppStore.setState(useAppStore.getInitialState(), true) - useAppStore.setState({ - settings: { ...getDefaultSettings(''), experimentalStructuredNativeChat: true }, - updateSettings: async (changes) => { - useAppStore.setState((state) => ({ - settings: { ...getDefaultSettings(''), ...state.settings, ...changes } - })) - } - }) - container = document.createElement('div') - document.body.append(container) - root = createRoot(container) -}) - -afterEach(() => { - act(() => root.unmount()) - container.remove() - useAppStore.setState(useAppStore.getInitialState(), true) - _resetNativeChatRestartOffer() - consumeNativeChatResumeOnRestartDialogRequest() -}) +const { mount, button, offerIds, toasts, fakeHost, runStatus, calls } = createResumeModalFixture( + rpc, + statusStream +) it('keeps next-launch preference out of the current resume action', async () => { - const action = Promise.withResolvers() - rpc.mockImplementation(async (_target, calledMethod) => { - if (calledMethod === 'agentSession.restartResumable') { - return { sessions: offered } - } - return action.promise - }) + const host = fakeHost() + host.hold('a') await mount() await act(async () => chatBox('b').click()) await act(async () => dontAskAgain().click()) await act(async () => button('Resume 1 chat').click()) expect(useAppStore.getState().settings?.nativeChatResumeWorkOnRestart).toBe(true) - expect(rpc.mock.calls.map((call) => [call[1], call[2]])).toEqual([ + expect(calls()).toEqual([ ['agentSession.restartResumable', undefined], ['agentSession.restartContinue', { sessionIds: ['a'] }] ]) - await act(async () => - action.resolve({ - resumed: [{ sessionId: 'a', outcome: 'resumed' }], - continued: [{ sessionId: 'a', outcome: 'continued' }], - sessions: [] - }) - ) - expect(rpc).toHaveBeenCalledTimes(2) + await host.release('a') + // The reply already carries the remaining list. + expect(calls().slice(2)).toEqual([]) }) // One primary action and one way out of it; the body copy carries the transparency. @@ -240,18 +194,15 @@ it('never re-offers a resumed chat when the status entry reopens the dialog', as await act(async () => button('1 chat to resume').click()) expect(document.querySelector('[role="dialog"]')).not.toBeNull() expect(offerIds()).toEqual(['b']) - // One offered row — never the resumed chat again. + // The resumed chat is history, while only the offered chat remains selectable. expect(chatBoxes()).toHaveLength(1) + expect(runStatus('Prompt a')).toBe('Prompt a: Resumed') }) // The resume outlives the dialog, as a skill update does: the status bar carries it while in flight. it('closes on Resume and shows the resume in the status bar until the host answers', async () => { - const continued = Promise.withResolvers() - rpc.mockImplementation((_target, method) => - method === 'agentSession.restartResumable' - ? Promise.resolve({ sessions: offered }) - : continued.promise - ) + const host = fakeHost() + host.hold('a', 'b') await mount( <> @@ -260,42 +211,53 @@ it('closes on Resume and shows the resume in the status bar until the host answe ) await act(async () => button('Resume 2 chats').click()) expect(document.querySelector('[role="dialog"]')).toBeNull() - expect(button('Resuming 2 chats. Click to open details.').textContent).toBe('Resuming 2 chats') + expect(button('Resuming chats, 0 of 2 done. Click to open details.').textContent).toBe( + 'Resuming chats 0/2' + ) // Counted once, as in flight, not also as still to resume. expect(document.body.textContent).not.toContain('chats to resume') // Reopening mid-run shows the run, without a re-read that could race the host's answer. const reads = rpc.mock.calls.length - await act(async () => button('Resuming 2 chats').click()) + await act(async () => button('Resuming chats 0/2').click()) expect(rpc.mock.calls.length).toBe(reads) + expect(document.querySelector('[role="dialog"]')?.textContent).toContain('Resuming 2 chats') expect(button('Resuming…').disabled).toBe(true) + // Each chat being resumed shows a spinner where its checkbox was; tree nodes stay disabled. + expect(runStatus('Prompt a')).toMatch(/^Prompt a: Waiting to start · \d+s$/) + expect(runStatus('Prompt b')).toMatch(/^Prompt b: Waiting to start · \d+s$/) + expect(chatBoxes()).toHaveLength(0) + const status = document.querySelector('[role="img"][aria-label^="Prompt a: "]') + expect(status?.parentElement?.classList.contains('w-7')).toBe(true) + expect(status?.parentElement?.parentElement?.firstElementChild?.contains(status)).toBe(true) + expect(status?.closest('[role="treeitem"]')?.getAttribute('aria-level')).toBe('2') + expect(status?.closest('label')).toBeNull() + + // Each chat settles on its own answer, not when the slowest one does. + await host.release('a') + expect(runStatus('Prompt a')).toBe('Prompt a: Resumed') + expect(runStatus('Prompt b')).toMatch(/^Prompt b: Waiting to start/) + expect(button('Resuming chats 1/2')).toBeTruthy() expect(toast).not.toHaveBeenCalled() - await act(async () => - continued.resolve({ - resumed: offered.map(({ sessionId }) => ({ sessionId, outcome: 'resumed' })), - continued: offered.map(({ sessionId }) => ({ sessionId, outcome: 'continued' })), - sessions: [] - }) - ) - expect(document.body.textContent).not.toContain('Resuming') + await host.release('b') + expect(document.body.textContent).not.toContain('Resuming chats') + // The dialog the user reopened stays on the run it was following until they close it. + expect(document.querySelector('[role="dialog"]')?.textContent).toContain('Resumed 2 of 2 chats') + // The click is answered once, when the run settles, across chats that are off-screen. + expect(toasts()).toEqual([['Resumed 2 chats']]) + await act(async () => button('Done').click()) expect(document.querySelector('[role="dialog"]')).toBeNull() // Nothing is left to show, so the reopen request is retired rather than left to latch. expect(getNativeChatResumeOnRestartDialogRequest()).toBe(false) - // The click is answered once, when the run settles, across chats that are off-screen. - expect(toasts()).toEqual([['Resumed 2 chats']]) }) // A dialog the user reopened mid-run is theirs: the run's answer must not close it over a chat // they left out of the resume and can now act on. it('keeps a dialog reopened mid-resume open over the chats still offered', async () => { const third = { ...offered[1]!, sessionId: 'c', latestPrompt: 'Prompt c' } - const continued = Promise.withResolvers() - rpc.mockImplementation((_target, method) => - method === 'agentSession.restartResumable' - ? Promise.resolve({ sessions: [...offered, third] }) - : continued.promise - ) + const host = fakeHost({ sessions: [...offered, third] }) + host.hold('a', 'b') await mount( <> @@ -307,23 +269,18 @@ it('keeps a dialog reopened mid-resume open over the chats still offered', async expect(document.querySelector('[role="dialog"]')).toBeNull() await act(async () => button('1 chat to resume').click()) expect(document.querySelector('[role="dialog"]')).not.toBeNull() - // Mid-run the ticks say what is running, so the chat left out reads as left out. + // Mid-run the chats being resumed show where they stand; the chat left out reads as left out. const rowC = () => document.querySelector('[role="checkbox"][aria-label*="Prompt c"]') - expect(chatBox('a').getAttribute('data-state')).toBe('checked') - expect(chatBox('b').getAttribute('data-state')).toBe('checked') + expect(runStatus('Prompt a')).toMatch(/Waiting to start/) + expect(runStatus('Prompt b')).toMatch(/Waiting to start/) expect(rowC()?.getAttribute('data-state')).toBe('unchecked') - await act(async () => - continued.resolve({ - resumed: offered.map(({ sessionId }) => ({ sessionId, outcome: 'resumed' })), - continued: offered.map(({ sessionId }) => ({ sessionId, outcome: 'continued' })), - sessions: [third] - }) - ) + await host.release('a', 'b') expect(rowC()?.getAttribute('data-state')).toBe('checked') const dialog = document.querySelector('[role="dialog"]') expect(dialog?.textContent).toContain('Prompt c') - expect(dialog?.textContent).not.toContain('Prompt a') + expect(dialog?.textContent).toContain('Resumed 2 of 2 chats') + expect(runStatus('Prompt a')).toBe('Prompt a: Resumed') // The run is over, so the chat left out is actionable again, and this opening ticks it afresh. expect(button('Dismiss all').disabled).toBe(false) expect(chatBox('c').getAttribute('data-state')).toBe('checked') @@ -347,15 +304,7 @@ it('starts each opening from the default ticks, not the ones left at the last cl // chats the host already handed back sends the user to a status entry that re-reads, finds nothing, // and does nothing. it('settles the offer for the chats a resume reattached', async () => { - rpc.mockImplementation(async (_target, method) => - method === 'agentSession.restartResumable' - ? { sessions: offered } - : { - resumed: [{ sessionId: 'a', outcome: 'resumed' }], - continued: [{ sessionId: 'a', outcome: 'continued' }], - sessions: [offered[1]!] - } - ) + fakeHost() await mount() await act(async () => chatBox('b').click()) await act(async () => button('Resume 1 chat').click()) @@ -372,15 +321,7 @@ it('resumes and continues once when the launch begins opted in', async () => { nativeChatResumeWorkOnRestart: true } }) - rpc.mockImplementation(async (_target, method) => - method === 'agentSession.restartResumable' - ? { sessions: offered } - : { - resumed: offered.map(({ sessionId }) => ({ sessionId, outcome: 'resumed' })), - continued: offered.map(({ sessionId }) => ({ sessionId, outcome: 'continued' })), - sessions: [] - } - ) + fakeHost() await mount( @@ -398,9 +339,10 @@ it('resumes and continues once when the launch begins opted in', async () => { await act(async () => useAppStore.getState().updateSettings({ experimentalStructuredNativeChat: true }) ) - expect(rpc.mock.calls.map((call) => [call[1], call[2]])).toEqual([ + // One bulk action, with its remaining list in the reply. + expect(calls()).toEqual([ ['agentSession.restartResumable', undefined], - ['agentSession.restartContinue', {}] + ['agentSession.restartContinue', { sessionIds: ['a', 'b'] }] ]) // Answered once, as a click is, however often the settings above re-render the surfaces. expect(toasts()).toEqual([['Resumed 2 chats']]) @@ -417,23 +359,15 @@ it('shows an opted-in launch resume in the status bar while it runs', async () = nativeChatResumeWorkOnRestart: true } }) - const continued = Promise.withResolvers() - rpc.mockImplementation((_target, method) => - method === 'agentSession.restartResumable' - ? Promise.resolve({ sessions: offered }) - : continued.promise - ) + const host = fakeHost() + host.hold('a', 'b') await mount() - expect(button('Resuming 2 chats').getAttribute('aria-label')).toBe( - 'Resuming 2 chats. Click to open details.' - ) - await act(async () => - continued.resolve({ - resumed: offered.map(({ sessionId }) => ({ sessionId, outcome: 'resumed' })), - continued: offered.map(({ sessionId }) => ({ sessionId, outcome: 'continued' })), - sessions: [] - }) + expect(button('Resuming chats 0/2').getAttribute('aria-label')).toBe( + 'Resuming chats, 0 of 2 done. Click to open details.' ) + await host.release('b') + expect(button('Resuming chats 1/2')).toBeTruthy() + await host.release('a') expect(document.body.textContent).not.toContain('Resuming') }) @@ -446,16 +380,8 @@ it('reports chats an opted-in launch could not carry on in one toast and the sta nativeChatResumeWorkOnRestart: true } }) - rpc.mockImplementation(async (_target, method) => - method === 'agentSession.restartResumable' - ? { sessions: offered } - : { - resumed: [], - continued: [{ sessionId: 'a', outcome: 'refused' }], - sessions: [offered[1]!], - failed: [failure('a')] - } - ) + // The host carries neither chat on: it refuses one and never reaches the other. + fakeHost({}, (sessionId) => (sessionId === 'a' ? 'refused' : 'skipped')) await mount( <> @@ -472,23 +398,12 @@ it('reports chats an opted-in launch could not carry on in one toast and the sta it('dispatches the selected action while a future preference save is still pending', async () => { const saved = Promise.withResolvers() useAppStore.setState({ updateSettings: () => saved.promise }) - rpc.mockImplementation(async (_target, method) => - method === 'agentSession.restartResumable' - ? { sessions: offered } - : { - resumed: [{ sessionId: 'a', outcome: 'resumed' }], - continued: [{ sessionId: 'a', outcome: 'continued' }], - sessions: [] - } - ) + fakeHost() await mount() await act(async () => chatBox('b').click()) await act(async () => dontAskAgain().click()) await act(async () => button('Resume 1 chat').click()) - expect(rpc.mock.calls.at(-1)?.slice(1)).toEqual([ - 'agentSession.restartContinue', - { sessionIds: ['a'] } - ]) + expect(calls()).toContainEqual(['agentSession.restartContinue', { sessionIds: ['a'] }]) await act(async () => saved.reject(new Error('settings write failed'))) expect(rpc).toHaveBeenCalledTimes(2) }) @@ -501,18 +416,7 @@ it.each([ ] as const)( 'reports a %s continuation once in a toast and in the status bar', async (outcome, label, said) => { - rpc.mockImplementation(async (_target, method) => - method === 'agentSession.restartResumable' - ? { sessions: offered } - : { - continued: offered.map(({ sessionId }) => ({ - sessionId, - outcome: outcome === 'refused' ? 'refused' : 'unknown' - })), - sessions: [], - failed: offered.map(({ sessionId }) => ({ ...failure(sessionId), outcome })) - } - ) + fakeHost({}, () => (outcome === 'refused' ? 'refused' : 'unknown')) await mount( <> @@ -522,6 +426,7 @@ it.each([ await act(async () => button('Resume 2 chats').click()) expect(button(label)).toBeTruthy() expect(toasts()).toEqual([[said]]) + // One offer read and one bulk action. expect(rpc).toHaveBeenCalledTimes(2) } ) @@ -574,7 +479,7 @@ it('reports the chats a lost resume request named once, as failed, until it is r const show = lastToastShow() expect(show).toBeDefined() // A lost action response is followed by a read-only reconciliation, never a retry. - expect(rpc.mock.calls.map((call) => [call[1], call[2]])).toEqual([ + expect(calls()).toEqual([ ['agentSession.restartResumable', undefined], ['agentSession.restartContinue', { sessionIds: ['a', 'b'] }], ['agentSession.restartResumable', undefined] @@ -601,34 +506,14 @@ it('reports the chats a lost resume request named once, as failed, until it is r 'Orca couldn’t resume this chat. Open it to continue manually.' ) await act(async () => button('Retry').click()) - expect(rpc.mock.calls.at(-1)?.slice(1)).toEqual([ - 'agentSession.restartContinue', - { sessionIds: ['b'] } - ]) + expect(calls().at(-1)).toEqual(['agentSession.restartContinue', { sessionIds: ['b'] }]) expect(getNativeChatRestartOffer()).toMatchObject({ candidates: [], failed: [] }) expect(toasts().at(-1)).toEqual(['Resumed 1 chat']) }) // One click, one toast across chats mostly off-screen; its Show opens the list behind it. it('answers a mixed Resume with one toast whose Show opens the dialog', async () => { - let sessions: ResumeCandidate[] = offered - let failed: unknown[] = [] - rpc.mockImplementation(async (_target, method) => { - if (method === 'agentSession.restartResumable') { - return { sessions, failed } - } - sessions = [] - failed = [failure('b')] - return { - resumed: offered.map(({ sessionId }) => ({ sessionId, outcome: 'resumed' })), - continued: [ - { sessionId: 'a', outcome: 'continued' }, - { sessionId: 'b', outcome: 'refused', reason: 'agent_session_restart_work_superseded' } - ], - sessions, - failed - } - }) + fakeHost({}, (sessionId) => (sessionId === 'b' ? 'refused' : 'continued')) await mount() await act(async () => button('Resume 2 chats').click()) expect(document.querySelector('[role="dialog"]')).toBeNull() @@ -636,8 +521,9 @@ it('answers a mixed Resume with one toast whose Show opens the dialog', async () // Show opens the dialog the click closed, over a fresh read of the list. await act(async () => lastToastShow()?.()) const dialog = document.querySelector('[role="dialog"]') + expect(dialog?.textContent).toContain('Resumed 1 of 2 chats') expect(dialog?.textContent).toContain('Prompt b') - expect(dialog?.textContent).not.toContain('Prompt a') + expect(runStatus('Prompt a')).toBe('Prompt a: Resumed') }) // A Dismiss the host never took leaves the chat shown as failed, not back as a plain offer. @@ -676,24 +562,9 @@ function dialogControls(): (string | null)[] { ].map((entry) => entry.textContent?.trim() || entry.getAttribute('aria-label')) } -// A chat the resume could not carry on stays in the same dialog — same title, checkboxes and -// footer — with its row saying what went wrong and what to do. +// The finished summary keeps successful chats alongside current failures and their actions. it('lists a chat the resume could not carry on when the dialog reopens, with what to do', async () => { - let remaining: unknown[] = [] - rpc.mockImplementation(async (_target, method) => - method === 'agentSession.restartResumable' - ? { sessions: offered, failed: remaining } - : ((remaining = [failure('b')]), - { - resumed: offered.map(({ sessionId }) => ({ sessionId, outcome: 'resumed' })), - continued: [ - { sessionId: 'a', outcome: 'continued' }, - { sessionId: 'b', outcome: 'refused', reason: 'agent_session_restart_work_superseded' } - ], - sessions: [], - failed: remaining - }) - ) + fakeHost({}, (sessionId) => (sessionId === 'b' ? 'refused' : 'continued')) await mount() await act(async () => button('Resume 2 chats').click()) // Resume hands off to the status bar; its failure entry reopens the list. @@ -701,17 +572,19 @@ it('lists a chat the resume could not carry on when the dialog reopens, with wha await act(async () => requestNativeChatResumeOnRestartDialog()) const dialog = document.querySelector('[role="dialog"]') expect(dialog).not.toBeNull() - // Unchanged chrome: the title, the preference box, and the two footer actions. - expect(dialog?.textContent).toContain('Resume interrupted chats?') + expect(dialog?.textContent).toContain('Resumed 1 of 2 chats') expect(dialog?.textContent).toContain("Don't ask again (resume automatically)") expect(dialog?.textContent).not.toContain('Dismiss failed') - // The resumed chat left the list as it always did; the failed one is a row with a checkbox. - expect(dialog?.textContent).not.toContain('Prompt a') + expect(runStatus('Prompt a')).toBe('Prompt a: Resumed') expect(chatBoxes()).toHaveLength(1) expect(document.querySelector('[aria-label="Prompt b: Couldn’t resume"]')).not.toBeNull() expect(dialog?.textContent).toContain('To resume:') expect(dialog?.textContent).toContain('Open the chat and reply.') expect(dialogControls()).toEqual([ + 'All2', + 'In progress0', + 'Resumed1', + 'Need you1', 'Dismiss "Prompt b" in workspace', 'Open chat', 'Dismiss all', @@ -736,18 +609,7 @@ it('lists a chat the resume could not carry on when the dialog reopens, with wha it.each(['footer', 'row'] as const)( 'retries a failed chat by name from the %s when a retry can succeed', async (from) => { - let failed = [failure('b', 'agent_session_conflict')] - rpc.mockImplementation(async (_target, method) => - method === 'agentSession.restartResumable' - ? { sessions: [], failed } - : ((failed = []), - { - resumed: [{ sessionId: 'b', outcome: 'resumed' }], - continued: [{ sessionId: 'b', outcome: 'continued' }], - sessions: [], - failed - }) - ) + fakeHost({ sessions: [], failed: [failure('b', 'agent_session_conflict')] }) await mount() // Old failures never raise the launch dialog by themselves; the status entry does. expect(document.querySelector('[role="dialog"]')).toBeNull() @@ -759,48 +621,40 @@ it.each(['footer', 'row'] as const)( expect(chatBox('b').getAttribute('data-state')).toBe('checked') await act(async () => button(from === 'footer' ? 'Resume 1 chat' : 'Retry').click()) - expect(rpc.mock.calls.at(-1)?.slice(1)).toEqual([ - 'agentSession.restartContinue', - { sessionIds: ['b'] } - ]) + expect(calls()).toContainEqual(['agentSession.restartContinue', { sessionIds: ['b'] }]) expect(toasts()).toEqual([['Resumed 1 chat']]) - expect(document.querySelector('[role="dialog"]')).toBeNull() + if (from === 'footer') { + // Resume hands the run to the status bar. + expect(document.querySelector('[role="dialog"]')).toBeNull() + } else { + // A row action leaves the dialog open, where the retried chat shows how it went. + expect(document.querySelector('[role="dialog"]')?.textContent).toContain( + 'Resumed 1 of 1 chat' + ) + expect(runStatus('Prompt b')).toBe('Prompt b: Resumed') + } } ) // A row action acts on its row, as Dismiss does: a retry that clears the last failure must not close // the dialog over a chat still offered. it('keeps the dialog open over the chats still offered after a row Retry succeeds', async () => { - let failed = [failure('b', 'agent_session_conflict')] - rpc.mockImplementation(async (_target, method) => - method === 'agentSession.restartResumable' - ? { sessions: [offered[0]], failed } - : ((failed = []), - { - resumed: [{ sessionId: 'b', outcome: 'resumed' }], - continued: [{ sessionId: 'b', outcome: 'continued' }], - sessions: [offered[0]], - failed - }) - ) + fakeHost({ sessions: [offered[0]!], failed: [failure('b', 'agent_session_conflict')] }) await mount() await act(async () => button('Retry').click()) const dialog = document.querySelector('[role="dialog"]') expect(dialog?.textContent).toContain('Prompt a') - expect(dialog?.textContent).not.toContain('Prompt b') + expect(runStatus('Prompt b')).toBe('Prompt b: Resumed') + expect(document.querySelector('[role="checkbox"][aria-label*="Prompt a"]')).not.toBeNull() }) // A dialog closed and reopened mid-retry is the user's again: the retry settling must not close it. it('keeps a dialog reopened mid-retry open when the retry settles', async () => { - const continued = Promise.withResolvers() - rpc.mockImplementation((_target, method) => - method === 'agentSession.restartResumable' - ? Promise.resolve({ - sessions: [offered[0]], - failed: [failure('b', 'agent_session_conflict')] - }) - : continued.promise - ) + const host = fakeHost({ + sessions: [offered[0]!], + failed: [failure('b', 'agent_session_conflict')] + }) + host.hold('b') await mount( <> @@ -812,20 +666,13 @@ it('keeps a dialog reopened mid-retry open when the retry settles', async () => expect(document.querySelector('[role="dialog"]')).toBeNull() await act(async () => button('1 chat to resume').click()) expect(document.querySelector('[role="dialog"]')).not.toBeNull() - // Only the retried row is running, so only it reads as ticked until the retry settles. + // Only the retried row is running, so only it shows a spinner until the retry settles. const tick = (prompt: string) => document.querySelector(`[role="checkbox"][aria-label*="${prompt}"]`)?.getAttribute('data-state') expect(tick('Prompt a')).toBe('unchecked') - expect(tick('Prompt b')).toBe('checked') + expect(runStatus('Prompt b')).toMatch(/Waiting to start/) - await act(async () => - continued.resolve({ - resumed: [{ sessionId: 'b', outcome: 'resumed' }], - continued: [{ sessionId: 'b', outcome: 'continued' }], - sessions: [offered[0]], - failed: [] - }) - ) + await host.release('b') expect(document.querySelector('[role="dialog"]')?.textContent).toContain('Prompt a') expect(tick('Prompt a')).toBe('checked') }) diff --git a/src/renderer/src/components/NativeChatResumeOnRestartModal.tsx b/src/renderer/src/components/NativeChatResumeOnRestartModal.tsx index 640c62c9f3e4..0394caa14d77 100644 --- a/src/renderer/src/components/NativeChatResumeOnRestartModal.tsx +++ b/src/renderer/src/components/NativeChatResumeOnRestartModal.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useRef, useState, useSyncExternalStore } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react' import { useNativeChatRestartOfferEnabled } from './native-chat-restart-offer-gate' import { RotateCcw } from 'lucide-react' import { Button } from './ui/button' @@ -35,9 +35,13 @@ import { import { continueNativeChatRestartOffer, dismissNativeChatRestartOffer, + markFinishedNativeChatRestartRunShown, + releaseFinishedNativeChatRestartRun, useNativeChatRestartOffer, - useNativeChatRestartResuming + useNativeChatRestartResuming, + useNativeChatRestartRun } from './native-chat-resume-on-restart-store' +import { useResumeRunPanel } from './NativeChatResumeRunPanel' /** * What would be resumed, shown before anything runs. @@ -50,7 +54,9 @@ import { * the same RPC, which re-derives the same predicate and staggers the same way. * * Resume closes the dialog at once and the status-bar entry carries the run, then any chat it could - * not carry on. The run lives in the store, as a skill update's does, so the dialog is one view of it. + * not carry on. The run lives in the store, as a skill update's does, so the dialog is one view of it: + * reopened mid-run, each chat in it shows where it stands in its checkbox's place, and the dialog + * stays on that run until it is closed after the run has finished. * * A chat an earlier resume could not carry on is listed too, as the same row plus what went wrong * and what to do; selecting it and resuming is a retry, unless the host says a retry cannot run. @@ -175,9 +181,26 @@ export function NativeChatResumeOnRestartModal(): React.JSX.Element | null { ), [selectable, overrides, failureBySession] ) + const selectableIds = useMemo(() => new Set(selectable), [selectable]) const selected = useMemo(() => new Set(chosen), [chosen]) // Mid-run the ticks show what is running; this opening's own ticks may name chats left out of it. const ticked = useMemo(() => (busy ? new Set(resuming) : selected), [busy, resuming, selected]) + const failureFor = useCallback( + (sessionId: string) => failureBySession.get(sessionId), + [failureBySession] + ) + const run = useNativeChatRestartRun() + const runPanel = useResumeRunPanel({ + run, + rows, + failureFor, + open + }) + useEffect(() => { + if (offerEnabled && open && run) { + markFinishedNativeChatRestartRunShown(run) + } + }, [offerEnabled, open, run]) const allSelection = resumeSelectionState(selectable, ticked) const toggleSelected = useCallback((sessionId: string, checked: boolean) => { @@ -203,6 +226,7 @@ export function NativeChatResumeOnRestartModal(): React.JSX.Element | null { /** Closing is a snooze: the host keeps the offer and the status bar keeps the way back to it. */ const snooze = useCallback((): void => { consumeNativeChatResumeOnRestartDialogRequest() + releaseFinishedNativeChatRestartRun() void persistPreference() }, [persistPreference]) @@ -211,6 +235,7 @@ export function NativeChatResumeOnRestartModal(): React.JSX.Element | null { // Bookkeeping never gates the user's own action: the dialog closes here whatever the host // answers, rather than being trapped open behind a rejected promise. consumeNativeChatResumeOnRestartDialogRequest() + releaseFinishedNativeChatRestartRun() await dismissNativeChatRestartOffer() }, [persistPreference]) @@ -230,12 +255,13 @@ export function NativeChatResumeOnRestartModal(): React.JSX.Element | null { // Opening is read-only and keeps the record: the user's own send in that chat settles it. The // dialog gets out of the way of the chat it just opened. consumeNativeChatResumeOnRestartDialogRequest() + releaseFinishedNativeChatRestartRun() await activateAiVaultStructuredSession({ structuredSession: { workspaceId: failure.workspaceId, sessionId } }) } - if (!offerEnabled || !open || rows.length === 0) { + if (!offerEnabled || !open || (rows.length === 0 && !runPanel)) { return null } @@ -273,25 +299,29 @@ export function NativeChatResumeOnRestartModal(): React.JSX.Element | null { {/* Plain wrapper owns the icon spacing; DialogTitle owns its own. */} - - - {translate( - 'auto.components.NativeChatResumeOnRestartModal.title', - 'Resume interrupted chats?' - )} - + {runPanel?.title ?? ( + + + {translate( + 'auto.components.NativeChatResumeOnRestartModal.title', + 'Resume interrupted chats?' + )} + + )} - {interruptedByUpdate - ? translate( - 'auto.components.NativeChatResumeOnRestartModal.updateBody', - 'These chats were working when Orca installed an update. Resuming restores each one where it stopped, with its full context, and asks the agent to check what it was doing before carrying on. Your own prompt is not re-sent.' - ) - : translate( - 'auto.components.NativeChatResumeOnRestartModal.body', - 'These chats were working when Orca closed. Resuming restores each one where it stopped, with its full context, and asks the agent to check what it was doing before carrying on. Your own prompt is not re-sent.' - )} + {runPanel?.description ?? + (interruptedByUpdate + ? translate( + 'auto.components.NativeChatResumeOnRestartModal.updateBody', + 'These chats were working when Orca installed an update. Resuming restores each one where it stopped, with its full context, and asks the agent to check what it was doing before carrying on. Your own prompt is not re-sent.' + ) + : translate( + 'auto.components.NativeChatResumeOnRestartModal.body', + 'These chats were working when Orca closed. Resuming restores each one where it stopped, with its full context, and asks the agent to check what it was doing before carrying on. Your own prompt is not re-sent.' + ))} + {runPanel?.summary}
    failureBySession.get(sessionId)} + failureFor={failureFor} onFailureAction={(action, sessionId) => void actOnFailure(action, sessionId)} + renderStatus={runPanel?.renderStatus} + selectableIds={selectableIds} /> @@ -370,34 +402,48 @@ export function NativeChatResumeOnRestartModal(): React.JSX.Element | null { - {/* Quiet, explicit cleanup of the durable records. */} - - + {rows.length === 0 ? ( + + ) : ( + <> + {/* Quiet, explicit cleanup of the durable records. */} + + + + )} diff --git a/src/renderer/src/components/NativeChatResumeRunPanel.tsx b/src/renderer/src/components/NativeChatResumeRunPanel.tsx new file mode 100644 index 000000000000..83746c6b0554 --- /dev/null +++ b/src/renderer/src/components/NativeChatResumeRunPanel.tsx @@ -0,0 +1,116 @@ +import { useState } from 'react' +import { Loader2 } from 'lucide-react' +import { translate } from '@/i18n/i18n' +import { useNow } from '@/hooks/use-now' +import type { ResumeCandidate, ResumeFailure } from './native-chat-resume-on-restart-grouping' +import { resumeRunInFlight, type ResumeRun } from './native-chat-resume-run' +import { resumeRunView, type ResumeRunFilter } from './native-chat-resume-run-view' +import { ResumeRunStatusIcon } from './NativeChatResumeRunStatusIcon' +import { ResumeRunSummary } from './NativeChatResumeRunSummary' + +/** + * The dialog's view of a run it is following: its title, the progress line and filters, the rows + * in the order that matters, and each row's status icon. Null when no run is being followed, so + * the dialog is then exactly the offer it was. + */ + +export type ResumeRunPanel = { + title: React.ReactNode + /** Null once the run is over: the offer's own copy then describes what is left. */ + description: string | null + summary: React.ReactNode + rows: ResumeCandidate[] + renderStatus: (sessionId: string, title: string) => React.ReactNode +} + +function runTitle(run: ResumeRun, total: number, resumed: number): React.ReactNode { + if (!resumeRunInFlight(run)) { + if (total === 1) { + return translate( + 'auto.components.NativeChatResumeRunPanel.resumedTitleOne', + 'Resumed {{value0}} of 1 chat', + { value0: resumed } + ) + } + return translate( + 'auto.components.NativeChatResumeRunPanel.resumedTitle', + 'Resumed {{value0}} of {{value1}} chats', + { value0: resumed, value1: total } + ) + } + return ( + + + {total === 1 + ? translate('auto.components.NativeChatResumeRunPanel.resumingTitleOne', 'Resuming 1 chat') + : translate( + 'auto.components.NativeChatResumeRunPanel.resumingTitle', + 'Resuming {{value0}} chats', + { value0: total } + )} + + ) +} + +export function useResumeRunPanel({ + run, + rows, + failureFor, + open +}: { + run: ResumeRun | null + rows: readonly ResumeCandidate[] + failureFor: (sessionId: string) => ResumeFailure | undefined + open: boolean +}): ResumeRunPanel | null { + // Each opening starts on All, as the dialog's own ticks start from their defaults. + const [filter, setFilter] = useState<{ value: ResumeRunFilter; open: boolean }>({ + value: 'all', + open + }) + if (filter.open !== open) { + setFilter({ value: 'all', open }) + } + const inFlight = run !== null && resumeRunInFlight(run) + // Ticks only while a visible run is moving: the row timers and the running clock read it. + const now = useNow(1000, open && inFlight) + const view = run ? resumeRunView(run, rows, failureFor, filter.value) : null + const inFlightIds = view + ? [...view.statusBySession].flatMap(([sessionId, status]) => + status.kind === 'in-flight' ? [sessionId] : [] + ) + : [] + const phaseFor = (sessionId: string) => { + const status = view?.statusBySession.get(sessionId) + return status?.kind === 'in-flight' ? status.phase : null + } + if (!run || !view) { + return null + } + return { + title: runTitle(run, view.counts.total, view.counts.resumed), + description: inFlight + ? translate( + 'auto.components.NativeChatResumeRunPanel.description', + 'Each chat is restored with its full context and asked to check where it stopped. You can close this; the status bar keeps track.' + ) + : null, + summary: ( + setFilter({ value, open })} + /> + ), + rows: view.rows, + renderStatus: (sessionId, title) => { + const status = view.statusBySession.get(sessionId) + return status ? ( + + ) : null + } + } +} diff --git a/src/renderer/src/components/NativeChatResumeRunStatusIcon.tsx b/src/renderer/src/components/NativeChatResumeRunStatusIcon.tsx new file mode 100644 index 000000000000..9a9170fd34ed --- /dev/null +++ b/src/renderer/src/components/NativeChatResumeRunStatusIcon.tsx @@ -0,0 +1,98 @@ +import { AlertCircle, Check, Clock, Loader2 } from 'lucide-react' +import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip' +import { translate } from '@/i18n/i18n' +import { formatNativeChatDuration } from '../../../shared/native-chat-turn-status' +import type { ResumeRunRowStatus } from './native-chat-resume-run-view' + +/** Tooltips keep per-chat progress from adding another label to each row. */ + +export type ResumeRunStartPhase = 'starting' | 'ready' | null + +/** In flight, the host's feed says how far the agent got; the resume itself does not report it. */ +function inFlightText(phase: ResumeRunStartPhase): string { + return phase === 'ready' + ? translate( + 'auto.components.NativeChatResumeRunStatusIcon.waitingForReply', + 'Waiting for a reply' + ) + : phase === 'starting' + ? translate('auto.components.NativeChatResumeRunStatusIcon.starting', 'Starting') + : translate( + 'auto.components.NativeChatResumeRunStatusIcon.waitingToStart', + 'Waiting to start' + ) +} + +function statusText(status: ResumeRunRowStatus, phase: ResumeRunStartPhase, now: number): string { + switch (status.kind) { + case 'in-flight': + return translate( + 'auto.components.NativeChatResumeRunStatusIcon.inFlight', + '{{value0}} · {{value1}}', + { + value0: inFlightText(phase), + value1: formatNativeChatDuration((now - status.startedAt) / 1000) + } + ) + case 'resumed': + return translate('auto.components.NativeChatResumeRunStatusIcon.resumed', 'Resumed') + case 'unconfirmed': + return translate( + 'auto.components.NativeChatResumeOutcomeRow.unconfirmed', + 'Couldn’t confirm the chat was resumed' + ) + case 'refused': + return translate('auto.components.NativeChatResumeOutcomeRow.failed', 'Couldn’t resume') + } +} + +function StatusGlyph({ status }: { status: ResumeRunRowStatus }): React.JSX.Element { + switch (status.kind) { + case 'in-flight': + return + case 'resumed': + return + case 'unconfirmed': + // The same glyphs a failure row uses, so a status does not change look when its row arrives. + return + case 'refused': + return + } +} + +export function ResumeRunStatusIcon({ + status, + phase, + now, + title +}: { + status: ResumeRunRowStatus + phase: ResumeRunStartPhase + now: number + /** The chat's name, so the accessible name says which chat this is. */ + title: string +}): React.JSX.Element { + const text = statusText(status, phase, now) + return ( + + + {/* Focusable so keyboard users reach the same tooltip. */} + + + + + + {text} + + + ) +} diff --git a/src/renderer/src/components/NativeChatResumeRunSummary.tsx b/src/renderer/src/components/NativeChatResumeRunSummary.tsx new file mode 100644 index 000000000000..f7ed32622ac9 --- /dev/null +++ b/src/renderer/src/components/NativeChatResumeRunSummary.tsx @@ -0,0 +1,123 @@ +import { ToggleGroup, ToggleGroupItem } from './ui/toggle-group' +import { translate } from '@/i18n/i18n' +import { formatNativeChatDuration } from '../../../shared/native-chat-turn-status' +import type { ResumeRunFilter, ResumeRunView } from './native-chat-resume-run-view' +import type { ResumeRunStartPhase } from './NativeChatResumeRunStatusIcon' + +/** Counts and filters use the same categories so narrowing the list preserves its meaning. */ + +function progressText( + counts: ResumeRunView['counts'], + phases: readonly ResumeRunStartPhase[] +): string { + const parts = [ + translate('auto.components.NativeChatResumeRunSummary.done', '{{value0}} of {{value1}} done', { + value0: counts.done, + value1: counts.total + }) + ] + const waitingToStart = phases.filter((phase) => phase === null).length + const starting = phases.filter((phase) => phase === 'starting').length + const waitingForReply = phases.filter((phase) => phase === 'ready').length + if (starting > 0) { + parts.push( + translate('auto.components.NativeChatResumeRunSummary.starting', '{{value0}} starting', { + value0: starting + }) + ) + } + if (waitingForReply > 0) { + parts.push( + translate( + 'auto.components.NativeChatResumeRunSummary.waitingForReply', + '{{value0}} waiting for a reply', + { value0: waitingForReply } + ) + ) + } + if (waitingToStart > 0) { + parts.push( + translate( + 'auto.components.NativeChatResumeRunSummary.waitingToStart', + '{{value0}} waiting to start', + { value0: waitingToStart } + ) + ) + } + return parts.join(' · ') +} + +export function ResumeRunSummary({ + counts, + phases, + startedAt, + now, + filter, + onFilterChange +}: { + counts: ResumeRunView['counts'] + /** One per chat still in flight. */ + phases: readonly ResumeRunStartPhase[] + startedAt: number + now: number + filter: ResumeRunFilter + onFilterChange: (filter: ResumeRunFilter) => void +}): React.JSX.Element { + const filters: readonly [ResumeRunFilter, string, number][] = [ + ['all', translate('auto.components.NativeChatResumeRunSummary.all', 'All'), counts.all], + [ + 'in-progress', + translate('auto.components.NativeChatResumeRunSummary.inProgress', 'In progress'), + counts.inProgress + ], + [ + 'resumed', + translate('auto.components.NativeChatResumeRunSummary.resumed', 'Resumed'), + counts.resumed + ], + [ + 'attention', + translate('auto.components.NativeChatResumeRunSummary.attention', 'Need you'), + counts.attention + ] + ] + return ( +
    +
    + {progressText(counts, phases)} + {counts.inProgress > 0 && ( + + {translate('auto.components.NativeChatResumeRunSummary.running', 'Running {{value0}}', { + value0: formatNativeChatDuration((now - startedAt) / 1000) + })} + + )} +
    + { + // Pressing the selected filter again would clear it; the list always has one. + if ( + next === 'all' || + next === 'in-progress' || + next === 'resumed' || + next === 'attention' + ) { + onFilterChange(next) + } + }} + aria-label={translate('auto.components.NativeChatResumeRunSummary.filter', 'Show chats')} + > + {filters.map(([value, label, count]) => ( + + {label} + {count} + + ))} + +
    + ) +} diff --git a/src/renderer/src/components/NativeChatResumeTreeRow.tsx b/src/renderer/src/components/NativeChatResumeTreeRow.tsx index fe5df6e9d9c2..56f637e805cd 100644 --- a/src/renderer/src/components/NativeChatResumeTreeRow.tsx +++ b/src/renderer/src/components/NativeChatResumeTreeRow.tsx @@ -27,6 +27,7 @@ export function ResumeTreeRow({ disabled, onCheckedChange, checkboxLabel, + checkboxSlot, compact = false, trailing, below, @@ -42,6 +43,8 @@ export function ResumeTreeRow({ disabled: boolean onCheckedChange: (checked: boolean) => void checkboxLabel: string + /** A run status replaces this leaf's checkbox in the same column. */ + checkboxSlot?: React.ReactNode /** A chat row sits a little tighter than a group row. */ compact?: boolean /** Controls beside the row that must not toggle its checkbox. */ @@ -51,26 +54,30 @@ export function ResumeTreeRow({ children: React.ReactNode }): React.JSX.Element { const indent = depth * RESUME_TREE_INDENT_PX + const Content = checkboxSlot ? 'div' : 'label' return (
    - + {expanded !== undefined && ( // Laid over its slot rather than inside the label, so pressing it never toggles the // checkbox. Off the tab order: Left/Right on the row's checkbox do the same. diff --git a/src/renderer/src/components/native-chat-restart-action-notifications.test.ts b/src/renderer/src/components/native-chat-restart-action-notifications.test.ts index a9b4ccb562de..b6d0b305fee4 100644 --- a/src/renderer/src/components/native-chat-restart-action-notifications.test.ts +++ b/src/renderer/src/components/native-chat-restart-action-notifications.test.ts @@ -19,17 +19,18 @@ function titles(): unknown[] { return vi.mocked(toast).mock.calls.map(([text]) => text) } -it('says how many chats were resumed', () => { +it.each([true, false])('offers Show for successful chats with a failure list: %s', (listed) => { announceRestartResults( ['a', 'b'], [ { sessionId: 'a', outcome: 'continued' }, { sessionId: 'b', outcome: 'continued' } ], - [], + listed ? [] : undefined, show ) - expect(vi.mocked(toast).mock.calls).toEqual([['Resumed 2 chats']]) + expect(titles()).toEqual(['Resumed 2 chats']) + expect(lastToastShow()).toBeDefined() }) it('offers Show for chats a resume could not carry on', () => { diff --git a/src/renderer/src/components/native-chat-restart-action-notifications.ts b/src/renderer/src/components/native-chat-restart-action-notifications.ts index f2d41b29abd3..5b6c3356124d 100644 --- a/src/renderer/src/components/native-chat-restart-action-notifications.ts +++ b/src/renderer/src/components/native-chat-restart-action-notifications.ts @@ -75,18 +75,25 @@ function descriptionFrom(lines: readonly string[]): ReactNode { ) } -/** One toast per resume. No names: the dialog behind Show has the list. Unconfirmed chats get their - * own count because the agent may well be working; "couldn't be resumed" would invite a second - * send. Without a failure list there is nothing for Show to open. */ +/** One toast per resume; Show opens the run summary. Unconfirmed chats may already be working. */ function announceResume( continued: number, refused: number, unconfirmed: number, show: (() => Promise) | undefined ): void { + const action = + show === undefined + ? {} + : { + action: { + label: translate('auto.components.NativeChatResumeOnRestartModal.show', 'Show'), + onClick: () => void show() + } + } if (refused === 0 && unconfirmed === 0) { if (continued > 0) { - toast(continuedCountText(continued)) + toast(continuedCountText(continued), action) } return } @@ -97,14 +104,7 @@ function announceResume( ] toast(title, { ...(lines.length === 0 ? {} : { description: descriptionFrom(lines) }), - ...(show === undefined - ? {} - : { - action: { - label: translate('auto.components.NativeChatResumeOnRestartModal.show', 'Show'), - onClick: () => void show() - } - }) + ...action }) } @@ -118,6 +118,25 @@ export function restartChatsNotContinued( return [...new Set(requested)].filter((sessionId) => bySession.get(sessionId) !== 'continued') } +/** Reconcile once with the action's confirmed list; later dismissals are not successes. */ +export function restartContinuationHistory( + requested: readonly string[], + reported: readonly RestartContinuationOutcome[] | undefined, + failed: readonly Pick[] | undefined +): readonly RestartContinuationOutcome[] { + const results = Array.isArray(reported) + ? reported + : requested.map((sessionId) => ({ sessionId, outcome: 'unknown' as const })) + const failedIds = new Set(failed?.map((entry) => entry.sessionId)) + return results.map((entry) => + failed !== undefined && + !failedIds.has(entry.sessionId) && + (entry.outcome === 'unknown' || entry.outcome === 'pending') + ? { ...entry, outcome: 'continued' as const } + : entry + ) +} + export function announceRestartResults( requested: readonly string[], /** Undefined for an answer without outcomes, which may still have sent the message. */ @@ -127,9 +146,7 @@ export function announceRestartResults( /** Opens the dialog over a fresh read; never rejects. */ show: () => Promise ): void { - const results = Array.isArray(reportedResults) - ? reportedResults - : requested.map((sessionId) => ({ sessionId, outcome: 'unknown' as const })) + const results = restartContinuationHistory(requested, reportedResults, hostFailed) const notContinued = restartChatsNotContinued(requested, results) const failed = new Map(hostFailed?.map((failure) => [failure.sessionId, failure.outcome])) // A host that lists failures has already dropped chats that moved on by themselves or that the @@ -141,20 +158,16 @@ export function announceRestartResults( const outcomes = new Map(results.map((result) => [result.sessionId, result.outcome])) const sentUnconfirmed = (sessionId: string): boolean => outcomes.get(sessionId) === 'pending' || outcomes.get(sessionId) === 'unknown' - // An unconfirmed send the host no longer lists was seen carrying on (or answered by the user), so - // it was resumed and asked to continue; left out of both counts, the resume would say nothing. - const seenCarryingOn = notContinued.filter( - (sessionId) => hostFailed !== undefined && !failed.has(sessionId) && sentUnconfirmed(sessionId) - ) // The filed outcome is what the list shows, so the toast uses it too. const unconfirmed = (sessionId: string): boolean => (failed.get(sessionId) ?? (sentUnconfirmed(sessionId) ? 'unconfirmed' : 'refused')) === 'unconfirmed' const unconfirmedCount = reported.filter(unconfirmed).length + const continuedCount = new Set(requested).size - notContinued.length announceResume( - new Set(requested).size - notContinued.length + seenCarryingOn.length, + continuedCount, reported.length - unconfirmedCount, unconfirmedCount, - hostFailed === undefined ? undefined : show + hostFailed === undefined && continuedCount === 0 ? undefined : show ) } diff --git a/src/renderer/src/components/native-chat-resume-bulk-progress.test.tsx b/src/renderer/src/components/native-chat-resume-bulk-progress.test.tsx new file mode 100644 index 000000000000..869430476514 --- /dev/null +++ b/src/renderer/src/components/native-chat-resume-bulk-progress.test.tsx @@ -0,0 +1,341 @@ +// @vitest-environment happy-dom +import { + createResumeModalFixture, + offered, + failure, + type RestartRpc, + type ResumeStatusStream +} from './native-chat-resume-modal.test-support' +import { act } from 'react' +import { expect, it, vi, type Mock } from 'vitest' +import { NativeChatResumeOnRestartModal } from './NativeChatResumeOnRestartModal' +import { chatBox } from './native-chat-resume-on-restart-modal.test-support' +import { NativeChatResumeStatusSegment } from './status-bar/NativeChatResumeStatusSegment' +import type { ResumeCandidate, ResumeFailure } from './native-chat-resume-on-restart-grouping' +import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' +import { requestNativeChatResumeOnRestartDialog } from './native-chat-resume-on-restart-dialog' +import { + continueNativeChatRestartOffer, + getNativeChatRestartOffer, + refreshNativeChatRestartOffer +} from './native-chat-resume-on-restart-store' + +const rpc: Mock = vi.hoisted(() => vi.fn()) +const statusStream: ResumeStatusStream = vi.hoisted(() => ({ + emit: (_event: Parameters[0]) => {}, + snapshot: new Map() +})) +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: rpc, + subscribeStructuredAgentSessionStatus: async ( + _target: unknown, + emit: (event: Parameters[0]) => void + ) => { + statusStream.emit = emit + emit({ type: 'snapshot', sessions: [...statusStream.snapshot.values()] }) + return { unsubscribe: () => {} } + } +})) +vi.mock('@/lib/activate-ai-vault-structured-session', () => ({ + activateAiVaultStructuredSession: vi.fn(async () => true) +})) +vi.mock('sonner', () => ({ toast: vi.fn() })) + +const { mount, button, offerIds, toasts, fakeHost, runStatus } = createResumeModalFixture( + rpc, + statusStream +) + +function emitPhase( + sessionId: string, + phase?: NonNullable['phase'] +): void { + statusStream.emit({ + type: 'status', + session: { + sessionId, + workspaceId: 'workspace', + agent: 'codex', + status: 'idle', + latestPrompt: '', + updatedAt: 1, + ...(phase ? { restartResume: { phase } } : {}) + } + }) +} + +it('resumes a 21-chat selection with one action and no redundant listing', async () => { + const candidates = Array.from({ length: 21 }, (_, index) => ({ + ...offered[0]!, + sessionId: `chat-${index}` + })) + rpc.mockImplementation(async (_target, method) => + method === 'agentSession.restartResumable' + ? { sessions: candidates, failed: [] } + : { + sessions: [], + failed: [], + continued: candidates.map(({ sessionId }) => ({ sessionId, outcome: 'continued' })) + } + ) + await refreshNativeChatRestartOffer() + await continueNativeChatRestartOffer(candidates.map(({ sessionId }) => sessionId)) + expect(rpc.mock.calls.map((call) => [call[1], call[2]])).toEqual([ + ['agentSession.restartResumable', undefined], + ['agentSession.restartContinue', { sessionIds: candidates.map(({ sessionId }) => sessionId) }] + ]) + expect(toasts()).toEqual([['Resumed 21 chats']]) +}) + +it('uses the host unconfirmed result after a lost bulk reply without reporting a failure', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + let reads = 0 + rpc.mockImplementation(async (_target, method) => { + if (method === 'agentSession.restartContinue') { + throw new Error('reply lost') + } + return reads++ === 0 + ? { sessions: offered, failed: [] } + : { + sessions: [], + failed: [ + { ...failure('a'), outcome: 'unconfirmed' }, + { ...failure('b'), outcome: 'unconfirmed' } + ] + } + }) + try { + await refreshNativeChatRestartOffer() + await continueNativeChatRestartOffer(['a', 'b']) + expect(toasts()).toEqual([['Couldn’t confirm 2 chats were resumed']]) + expect(getNativeChatRestartOffer().failed.map((row) => row.outcome)).toEqual([ + 'unconfirmed', + 'unconfirmed' + ]) + } finally { + warn.mockRestore() + } +}) + +it('publishes a refusal reply without needing a second host read', async () => { + let reads = 0 + rpc.mockImplementation(async (_target, method) => { + if (method === 'agentSession.restartResumable') { + if (reads++ > 0) { + throw new Error('listing unavailable') + } + return { sessions: offered, failed: [] } + } + return { + sessions: [offered[1]], + failed: [failure('a')], + continued: [{ sessionId: 'a', outcome: 'refused' }] + } + }) + await mount() + await act(async () => chatBox('b').click()) + await act(async () => button('Resume 1 chat').click()) + expect(reads).toBe(1) + expect(offerIds()).toEqual(['b']) + expect(getNativeChatRestartOffer().failed.map((row) => row.sessionId)).toEqual(['a']) + expect(toasts()).toEqual([['1 chat couldn’t be resumed']]) +}) + +it('keeps the run in flight after every feed verdict until the fallback list is published', async () => { + const answer = Promise.withResolvers<{ + continued: { sessionId: string; outcome: 'continued' }[] + }>() + const listing = Promise.withResolvers<{ sessions: ResumeCandidate[]; failed: ResumeFailure[] }>() + let reads = 0 + rpc.mockImplementation(async (_target, method) => + method === 'agentSession.restartContinue' + ? answer.promise + : reads++ === 0 + ? { sessions: offered, failed: [] } + : listing.promise + ) + await mount( + <> + + + + ) + await act(async () => button('Resume 2 chats').click()) + await act(async () => button('Resuming chats 0/2').click()) + await act(async () => { + for (const { sessionId } of offered) { + statusStream.emit({ + type: 'status', + session: { + sessionId, + workspaceId: 'workspace', + agent: 'codex', + status: 'idle', + latestPrompt: '', + updatedAt: 1, + restartResume: { phase: 'continued' } + } + }) + } + answer.resolve({ + continued: offered.map(({ sessionId }) => ({ sessionId, outcome: 'continued' })) + }) + }) + expect(button('Resuming chats 2/2')).toBeTruthy() + await act(async () => offered.forEach(({ sessionId }) => emitPhase(sessionId))) + expect(button('Resuming chats 2/2')).toBeTruthy() + expect(runStatus('Prompt a')).toBe('Prompt a: Resumed') + expect(button('Resuming…').disabled).toBe(true) + expect(document.body.textContent).not.toContain('chats to resume') + await act(async () => listing.resolve({ sessions: [], failed: [] })) + expect(offerIds()).toEqual([]) + expect(button('Done').disabled).toBe(false) + expect(document.body.textContent).not.toContain('Resuming chats') +}) + +it('retains success and refusal frames through cleanup before a delayed bulk reply', async () => { + const reply = Promise.withResolvers() + rpc.mockImplementation(async (_target, method) => + method === 'agentSession.restartResumable' ? { sessions: offered, failed: [] } : reply.promise + ) + await mount( + <> + + + + ) + await act(async () => button('Resume 2 chats').click()) + // Verdicts and cleanup can arrive together before React gets a render. + await act(async () => { + emitPhase('a', 'continued') + emitPhase('b', 'refused') + emitPhase('a') + emitPhase('b') + }) + expect(button('Resuming chats 2/2')).toBeTruthy() + await act(async () => button('Resuming chats 2/2').click()) + expect(runStatus('Prompt a')).toBe('Prompt a: Resumed') + expect(runStatus('Prompt b')).toBe('Prompt b: Couldn’t resume') + expect(document.querySelector('[role="dialog"]')?.textContent).toContain('Need you1') + await act(async () => + reply.resolve({ + sessions: [], + failed: [failure('b')], + continued: [ + { sessionId: 'a', outcome: 'continued' }, + { sessionId: 'b', outcome: 'refused' } + ] + }) + ) + expect(document.querySelector('[role="dialog"]')?.textContent).toContain('Resumed 1 of 2 chats') +}) + +it('removes a skipped selection from pending rows and all progress counts', async () => { + const host = fakeHost({}, (sessionId) => (sessionId === 'a' ? 'skipped' : 'continued')) + host.hold('b') + await mount( + <> + + + + ) + host.state.sessions = host.state.sessions.filter((row) => row.sessionId !== 'a') + await act(async () => button('Resume 2 chats').click()) + await act(async () => button('Resuming chats 0/1').click()) + const dialog = () => document.querySelector('[role="dialog"]')?.textContent + expect(dialog()).not.toContain('Prompt a') + expect(dialog()).toContain('All1') + expect(dialog()).toContain('0 of 1 done') + expect(dialog()).toContain('In progress1') + await host.release('b') + expect(dialog()).toContain('Resumed 1 of 1 chat') + expect(dialog()).toContain('1 of 1 done') + expect(dialog()).toContain('All1') + expect(dialog()).toContain('In progress0') + expect(runStatus('Prompt b')).toBe('Prompt b: Resumed') + expect(toasts()).toEqual([['Resumed 1 chat']]) +}) + +it('uses reply skipped ids even when the skip frame was missed', async () => { + rpc.mockImplementation(async (_target, method) => + method === 'agentSession.restartResumable' + ? { sessions: offered, failed: [] } + : { + sessions: [], + failed: [], + skipped: ['b'], + continued: [{ sessionId: 'a', outcome: 'continued' }] + } + ) + await mount() + await act(async () => continueNativeChatRestartOffer(['a', 'b'])) + expect(document.querySelector('[role="dialog"]')?.textContent).toContain('Resumed 1 of 1 chat') + expect(document.querySelector('[role="dialog"]')?.textContent).toContain('All1') + expect(runStatus('Prompt b')).toBeNull() +}) + +it.each(['unknown', 'pending'] as const)( + 'reconciles %s with the reply failure list for both dialog and toast', + async (outcome) => { + rpc.mockImplementation(async (_target, method) => + method === 'agentSession.restartResumable' + ? { sessions: [offered[0]], failed: [] } + : { sessions: [], failed: [], continued: [{ sessionId: 'a', outcome }] } + ) + await mount() + await act(async () => continueNativeChatRestartOffer(['a'])) + expect(document.querySelector('[role="dialog"]')?.textContent).toContain('Resumed 1 of 1 chat') + expect(runStatus('Prompt a')).toBe('Prompt a: Resumed') + expect(toasts()).toEqual([['Resumed 1 chat']]) + } +) + +it('dismissing a failure after the reply never promotes it to resumed history', async () => { + fakeHost({ sessions: [offered[0]!] }, () => 'unknown') + await mount() + await act(async () => continueNativeChatRestartOffer(['a'])) + expect(document.querySelector('[role="dialog"]')?.textContent).toContain('Resumed 0 of 1 chat') + const dismiss = document.querySelector('button[aria-label^="Dismiss"]') + expect(dismiss).not.toBeNull() + await act(async () => dismiss?.click()) + expect(document.querySelector('[role="dialog"]')?.textContent).toContain('Resumed 0 of 1 chat') + expect(document.querySelector('[role="dialog"]')?.textContent).toContain('Need you0') + expect(runStatus('Prompt a')).toBeNull() + expect(toasts()).toEqual([['Couldn’t confirm 1 chat was resumed']]) +}) + +it('keeps an unknown reply unconfirmed when it carries no confirmed failure list', async () => { + rpc.mockImplementation(async (_target, method) => + method === 'agentSession.restartResumable' + ? { sessions: [offered[0]], failed: [] } + : { sessions: [], continued: [{ sessionId: 'a', outcome: 'unknown' }] } + ) + await mount() + await act(async () => continueNativeChatRestartOffer(['a'])) + expect(document.querySelector('[role="dialog"]')?.textContent).toContain('Resumed 0 of 1 chat') + expect(runStatus('Prompt a')).toBeNull() + expect(toasts()).toEqual([['Couldn’t confirm 1 chat was resumed']]) +}) + +it('a retry hides its old failure, then a dismissed final failure leaves Need you', async () => { + const host = fakeHost( + { sessions: [], failed: [failure('b', 'agent_session_conflict')] }, + () => 'refused', + 'agent_session_conflict' + ) + host.hold('b') + await mount() + await act(async () => requestNativeChatResumeOnRestartDialog()) + await act(async () => button('Retry').click()) + expect(runStatus('Prompt b')).toMatch(/Waiting to start/) + expect(document.querySelector('[role="dialog"]')?.textContent).not.toContain('Another Orca') + expect(document.querySelector('[aria-label*="Couldn’t resume"]')).toBeNull() + await host.release('b') + expect(document.querySelector('[role="dialog"]')?.textContent).toContain('Need you1') + const dismiss = document.querySelector('button[aria-label^="Dismiss"]') + expect(dismiss).not.toBeNull() + await act(async () => dismiss?.click()) + expect(getNativeChatRestartOffer().failed).toEqual([]) + expect(document.querySelector('[role="dialog"]')?.textContent).toContain('Need you0') + expect(runStatus('Prompt b')).toBeNull() +}) diff --git a/src/renderer/src/components/native-chat-resume-failed-chat-watch.test.ts b/src/renderer/src/components/native-chat-resume-failed-chat-watch.test.ts index 4e0697f228b8..81e90f10c914 100644 --- a/src/renderer/src/components/native-chat-resume-failed-chat-watch.test.ts +++ b/src/renderer/src/components/native-chat-resume-failed-chat-watch.test.ts @@ -183,21 +183,25 @@ it('never lets a re-read that was already in flight bring back a dismissed failu it('waits for a resume in flight instead of re-reading under it', async () => { await listFailure({ failed: [failure] }) const acting = Promise.withResolvers() + let failed: unknown[] = [failure] mocks.rpc.mockImplementation((_target: unknown, method: string) => method === 'agentSession.restartContinue' ? acting.promise - : Promise.resolve({ sessions: [], failed: [failure] }) + : Promise.resolve({ sessions: [], failed }) ) const retry = continueNativeChatRestartOffer(['a']) hostEmit()({ type: 'status', session: summary('working', 'Carry on please', Date.now() + 1) }) await vi.advanceTimersByTimeAsync(500) expect(offerReads()).toBe(1) + // The action reply carries the remaining list, so no extra read is needed. + failed = [] acting.resolve({ sessions: [], failed: [], continued: [{ sessionId: 'a', outcome: 'continued' }] }) await retry + expect(offerReads()).toBe(1) expect(getNativeChatRestartOffer().failed).toEqual([]) }) diff --git a/src/renderer/src/components/native-chat-resume-finished-run.test.tsx b/src/renderer/src/components/native-chat-resume-finished-run.test.tsx new file mode 100644 index 000000000000..9f1b4c126aea --- /dev/null +++ b/src/renderer/src/components/native-chat-resume-finished-run.test.tsx @@ -0,0 +1,195 @@ +// @vitest-environment happy-dom +import { act } from 'react' +import { expect, it, vi, type Mock } from 'vitest' +import { NativeChatResumeOnRestartModal } from './NativeChatResumeOnRestartModal' +import { NativeChatResumeStatusSegment } from './status-bar/NativeChatResumeStatusSegment' +import { + createResumeModalFixture, + type RestartRpc, + type ResumeStatusStream +} from './native-chat-resume-modal.test-support' +import { lastToastShow } from './native-chat-resume-toast.test-support' +import { + _resetNativeChatRestartOffer, + getNativeChatRestartRun, + refreshNativeChatRestartOffer, + releaseFinishedNativeChatRestartRun +} from './native-chat-resume-on-restart-store' +import { + consumeNativeChatResumeOnRestartDialogRequest, + requestNativeChatResumeOnRestartDialog +} from './native-chat-resume-on-restart-dialog' +import { useAppStore } from '../store' +import { chatBox, namedBox } from './native-chat-resume-on-restart-modal.test-support' + +const rpc: Mock = vi.hoisted(() => vi.fn()) +const statusStream: ResumeStatusStream = vi.hoisted(() => ({ + emit: (_event: Parameters[0]) => {}, + snapshot: new Map() +})) +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: rpc, + subscribeStructuredAgentSessionStatus: async ( + _target: unknown, + emit: ResumeStatusStream['emit'] + ) => { + statusStream.emit = emit + emit({ type: 'snapshot', sessions: [...statusStream.snapshot.values()] }) + return { unsubscribe: () => {} } + } +})) +vi.mock('sonner', () => ({ toast: vi.fn() })) + +const { mount, button, fakeHost, runStatus, toasts } = createResumeModalFixture(rpc, statusStream) +const dialog = () => document.querySelector('[role="dialog"]')?.textContent + +async function mountSurfaces(): Promise { + await mount( + <> + + + + ) +} + +it.each(['status entry', 'toast Show'] as const)( + 'opens every finished result from %s after Resume closed the dialog', + async (source) => { + fakeHost({}, (sessionId) => (sessionId === 'a' ? 'continued' : 'refused'), 'unknown') + await mountSurfaces() + await act(async () => button('Resume 2 chats').click()) + expect(dialog()).toBeUndefined() + await act(async () => + source === 'status entry' ? button('1 chat failed to resume').click() : lastToastShow()?.() + ) + expect(dialog()).toContain('Resumed 1 of 2 chats') + expect(dialog()).toContain('All2') + expect(dialog()).toContain('Need you1') + expect(runStatus('Prompt a')).toBe('Prompt a: Resumed') + expect(runStatus('Prompt b')).toBe('Prompt b: Couldn’t resume') + expect(button('Retry')).toBeTruthy() + expect(button('Open chat')).toBeTruthy() + expect(button('Dismiss "Prompt b" in workspace')).toBeTruthy() + expect(toasts()).toEqual([['1 chat couldn’t be resumed', 'Resumed 1 chat']]) + } +) + +it.each(['status entry', 'toast Show'] as const)( + 'returns to the host failure offer from %s after the finished view is closed', + async (source) => { + fakeHost({}, (sessionId) => (sessionId === 'a' ? 'continued' : 'refused')) + await mountSurfaces() + await act(async () => button('Resume 2 chats').click()) + await act(async () => button('1 chat failed to resume').click()) + expect(dialog()).toContain('Resumed 1 of 2 chats') + await act(async () => button('Close').click()) + await act(async () => + source === 'status entry' ? button('1 chat failed to resume').click() : lastToastShow()?.() + ) + expect(dialog()).toContain('Resume interrupted chats?') + expect(dialog()).toContain('Prompt b') + expect(dialog()).not.toContain('Prompt a') + } +) + +it('keeps the unseen summary when a view was opened and closed only during the run', async () => { + const host = fakeHost({}, (sessionId) => (sessionId === 'a' ? 'continued' : 'unknown')) + host.hold('a', 'b') + await mountSurfaces() + await act(async () => button('Resume 2 chats').click()) + await act(async () => button('Resuming chats 0/2').click()) + expect(dialog()).toContain('Resuming 2 chats') + await act(async () => button('Close').click()) + await host.release('a', 'b') + await act(async () => button('1 chat to check').click()) + expect(dialog()).toContain('Resumed 1 of 2 chats') + expect(runStatus('Prompt a')).toBe('Prompt a: Resumed') + expect(runStatus('Prompt b')).toBe('Prompt b: Couldn’t confirm the chat was resumed') +}) + +it('shows the all-success summary from its toast without a status entry', async () => { + fakeHost() + await mountSurfaces() + await act(async () => button('Resume 2 chats').click()) + expect(dialog()).toBeUndefined() + expect(document.body.textContent).not.toContain('chat') + expect(lastToastShow()).toBeDefined() + await act(async () => lastToastShow()?.()) + expect(dialog()).toContain('Resumed 2 of 2 chats') + expect(runStatus('Prompt a')).toBe('Prompt a: Resumed') + expect(runStatus('Prompt b')).toBe('Prompt b: Resumed') + await act(async () => button('Done').click()) + await act(async () => lastToastShow()?.()) + expect(dialog()).toBeUndefined() +}) + +it('replaces the finished summary when Retry starts a new run', async () => { + const host = fakeHost({}, (sessionId) => (sessionId === 'a' ? 'continued' : 'refused'), 'unknown') + await mountSurfaces() + await act(async () => button('Resume 2 chats').click()) + await act(async () => button('1 chat failed to resume').click()) + host.hold('b') + await act(async () => button('Retry').click()) + expect(dialog()).toContain('Resuming 1 chat') + expect(dialog()).not.toContain('Prompt a') + await host.release('b') + expect(dialog()).toContain('Resumed 0 of 1 chat') +}) + +it('selects only current offers from a workspace that also contains finished run history', async () => { + const host = fakeHost() + await mountSurfaces() + await act(async () => chatBox('b').click()) + await act(async () => button('Resume 1 chat').click()) + await act(async () => lastToastShow()?.()) + const workspace = namedBox('Select all chats in workspace') + expect(workspace.closest('label')?.textContent).toContain('1 of 1') + expect(runStatus('Prompt a')).toBe('Prompt a: Resumed') + await act(async () => workspace.click()) + expect(chatBox('b').getAttribute('aria-checked')).toBe('false') + await act(async () => workspace.click()) + expect(button('Resume 1 chat').disabled).toBe(false) + host.hold('b') + await act(async () => button('Resume 1 chat').click()) + expect(rpc.mock.calls.at(-1)?.[2]).toEqual({ sessionIds: ['b'] }) + await host.release('b') +}) + +it('does not consume a finished run if its requested view never rendered', async () => { + fakeHost() + await mountSurfaces() + await act(async () => button('Resume 2 chats').click()) + await act(async () => { + requestNativeChatResumeOnRestartDialog() + consumeNativeChatResumeOnRestartDialogRequest() + releaseFinishedNativeChatRestartRun() + }) + expect(dialog()).toBeUndefined() + await act(async () => lastToastShow()?.()) + expect(dialog()).toContain('Resumed 2 of 2 chats') +}) + +it('keeps the run out of settings and storage and loses it on a fresh renderer store', async () => { + fakeHost({}, (sessionId) => (sessionId === 'a' ? 'continued' : 'refused')) + await mountSurfaces() + const settings = useAppStore.getState().settings + const saveSettings = vi.fn(async () => {}) + useAppStore.setState({ updateSettings: saveSettings }) + const storage = vi.spyOn(Storage.prototype, 'setItem') + try { + await act(async () => button('Resume 2 chats').click()) + expect(saveSettings).not.toHaveBeenCalled() + expect(useAppStore.getState().settings).toBe(settings) + expect(storage).not.toHaveBeenCalled() + await mount(null) + _resetNativeChatRestartOffer() + expect(getNativeChatRestartRun()).toBeNull() + await refreshNativeChatRestartOffer() + await mountSurfaces() + await act(async () => button('1 chat failed to resume').click()) + expect(dialog()).toContain('Resume interrupted chats?') + expect(dialog()).not.toContain('Prompt a') + } finally { + storage.mockRestore() + } +}) diff --git a/src/renderer/src/components/native-chat-resume-modal.test-support.tsx b/src/renderer/src/components/native-chat-resume-modal.test-support.tsx new file mode 100644 index 000000000000..0bbc9dcdf310 --- /dev/null +++ b/src/renderer/src/components/native-chat-resume-modal.test-support.tsx @@ -0,0 +1,234 @@ +import { act } from 'react' +import { toast } from 'sonner' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, vi, type Mock } from 'vitest' +import { useAppStore } from '../store' +import { getDefaultSettings } from '../../../shared/constants' +import type { + AgentSessionStatusEvent, + AgentSessionStatusSummary +} from '../../../shared/agent-session-wire' +import { resetStructuredAgentSessionStatusFeedsForTests } from '@/runtime/structured-agent-session-status-feed' +import { TooltipProvider } from './ui/tooltip' +import type { ResumeCandidate, ResumeFailure } from './native-chat-resume-on-restart-grouping' +import { consumeNativeChatResumeOnRestartDialogRequest } from './native-chat-resume-on-restart-dialog' +import { + _resetNativeChatRestartOffer, + getNativeChatRestartOffer +} from './native-chat-resume-on-restart-store' + +export const offered: ResumeCandidate[] = ['a', 'b'].map((sessionId) => ({ + sessionId, + workspaceId: 'workspace', + agent: 'codex', + trigger: 'quit', + latestPrompt: `Prompt ${sessionId}`, + recordedAt: 1_800_000_000_000, + executionHostId: 'local', + workspaceKind: 'git-worktree' +})) + +/** A chat the host acted on and could not carry on, as it reports it. */ +export function failure( + sessionId: string, + reason = 'agent_session_restart_work_superseded' +): ResumeFailure { + const candidate = offered.find((entry) => entry.sessionId === sessionId)! + return { + ...candidate, + failedAt: candidate.recordedAt + 60_000, + outcome: 'refused', + reason + } +} + +export type RestartRpc = ( + target: unknown, + method: string, + params?: { sessionIds?: string[] } +) => Promise +export type ResumeStatusStream = { + emit: (event: AgentSessionStatusEvent) => void + snapshot: Map +} + +export function createResumeModalFixture(rpc: Mock, statusStream: ResumeStatusStream) { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + let root: Root + let container: HTMLDivElement + /** Outcome rows carry tooltips, so every mount needs the provider the app shell supplies. */ + async function mount(node: React.ReactNode): Promise { + await act(async () => root.render({node})) + } + + function button(text: string): HTMLButtonElement { + const found = [...document.querySelectorAll('button')].find( + (entry) => entry.textContent?.trim() === text || entry.getAttribute('aria-label') === text + ) + if (!found) { + throw new Error(`Missing button: ${text}`) + } + return found + } + + function checkbox(index: number): HTMLElement { + const found = document.querySelectorAll('[role="checkbox"]')[index] + if (!found) { + throw new Error(`Missing checkbox: ${index}`) + } + return found + } + + function offerIds(): string[] { + return getNativeChatRestartOffer().candidates.map((candidate) => candidate.sessionId) + } + + /** What each toast said: its title, and its description when it has one. */ + function toasts(): unknown[][] { + return vi + .mocked(toast) + .mock.calls.map(([title, options]) => + options?.description === undefined ? [title] : [title, options.description] + ) + } + + type Verdict = 'continued' | 'refused' | 'unknown' | 'skipped' + + /** A bulk host action publishes each verdict before returning its authoritative list. */ + function fakeHost( + init: { sessions?: ResumeCandidate[]; failed?: ResumeFailure[] } = {}, + verdictFor: (sessionId: string) => Verdict = () => 'continued', + reason = 'agent_session_restart_work_superseded' + ) { + const state = { sessions: [...(init.sessions ?? offered)], failed: [...(init.failed ?? [])] } + const held = new Map>() + rpc.mockImplementation( + async (_target, method, params: { sessionIds?: string[] } | undefined) => { + if (method === 'agentSession.restartResumable') { + return { sessions: state.sessions, failed: state.failed } + } + if (method === 'agentSession.restartResumableDismiss') { + state.failed = state.failed.filter( + (entry) => !params?.sessionIds?.includes(entry.sessionId) + ) + state.sessions = state.sessions.filter( + (entry) => !params?.sessionIds?.includes(entry.sessionId) + ) + return { sessions: state.sessions, failed: state.failed } + } + const continued = await Promise.all( + (params?.sessionIds ?? []).map(async (sessionId) => { + await held.get(sessionId)?.promise + const verdict = verdictFor(sessionId) + if (verdict === 'skipped') { + const summary: AgentSessionStatusSummary = { + sessionId, + workspaceId: 'workspace', + agent: 'codex', + status: 'idle', + latestPrompt: '', + updatedAt: 1, + restartResume: { phase: 'skipped' } + } + statusStream.snapshot.set(sessionId, summary) + statusStream.emit({ type: 'status', session: summary }) + return [] + } + state.sessions = state.sessions.filter((entry) => entry.sessionId !== sessionId) + state.failed = state.failed.filter((entry) => entry.sessionId !== sessionId) + const phase = verdict === 'unknown' ? 'unconfirmed' : verdict + if (verdict !== 'continued') { + state.failed.push({ + ...failure(sessionId, reason), + outcome: phase === 'refused' ? 'refused' : 'unconfirmed' + }) + } + const summary: AgentSessionStatusSummary = { + sessionId, + workspaceId: 'workspace', + agent: 'codex', + status: 'idle', + latestPrompt: '', + updatedAt: 1, + restartResume: { phase } + } + statusStream.snapshot.set(sessionId, summary) + statusStream.emit({ type: 'status', session: summary }) + return [{ sessionId, outcome: verdict }] + }) + ) + const skipped: string[] = [] + for (const sessionId of params?.sessionIds ?? []) { + const previous = statusStream.snapshot.get(sessionId) + if (!previous) { + continue + } + if (previous.restartResume?.phase === 'skipped') { + skipped.push(sessionId) + } + const { restartResume: _restartResume, ...summary } = previous + statusStream.snapshot.set(sessionId, summary) + statusStream.emit({ type: 'status', session: summary }) + } + return { + resumed: [], + continued: continued.flat(), + skipped, + sessions: state.sessions, + failed: state.failed + } + } + ) + return { + state, + hold: (...sessionIds: string[]) => + sessionIds.forEach((sessionId) => held.set(sessionId, Promise.withResolvers())), + release: async (...sessionIds: string[]) => + act(async () => sessionIds.forEach((sessionId) => held.get(sessionId)?.resolve())) + } + } + + /** The status icon a run shows in a chat's checkbox slot, found by its chat and what it says. */ + function runStatus(prompt: string): string | null { + return ( + document + .querySelector(`[role="img"][aria-label^="${prompt}: "]`) + ?.getAttribute('aria-label') ?? null + ) + } + + function calls(): unknown[][] { + return rpc.mock.calls.map((call) => [call[1], call[2]]) + } + + beforeEach(() => { + rpc.mockReset() + resetStructuredAgentSessionStatusFeedsForTests() + statusStream.snapshot.clear() + statusStream.emit = () => {} + _resetNativeChatRestartOffer() + consumeNativeChatResumeOnRestartDialogRequest() + vi.mocked(toast).mockClear() + useAppStore.setState(useAppStore.getInitialState(), true) + useAppStore.setState({ + settings: { ...getDefaultSettings(''), experimentalStructuredNativeChat: true }, + updateSettings: async (changes) => { + useAppStore.setState((state) => ({ + settings: { ...getDefaultSettings(''), ...state.settings, ...changes } + })) + } + }) + container = document.createElement('div') + document.body.append(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => root.unmount()) + container.remove() + useAppStore.setState(useAppStore.getInitialState(), true) + _resetNativeChatRestartOffer() + consumeNativeChatResumeOnRestartDialogRequest() + }) + return { mount, button, checkbox, offerIds, toasts, fakeHost, runStatus, calls } +} diff --git a/src/renderer/src/components/native-chat-resume-offered-chat-watch.ts b/src/renderer/src/components/native-chat-resume-offered-chat-watch.ts new file mode 100644 index 000000000000..679fe312bcb3 --- /dev/null +++ b/src/renderer/src/components/native-chat-resume-offered-chat-watch.ts @@ -0,0 +1,106 @@ +import type { StructuredAgentSessionStatusFeedOwner } from '@/runtime/structured-agent-session-status-feed' +import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' + +/** + * Asks the host again once an offered or failed chat shows new activity, so a message the user + * sent there, or its agent starting, retires its entry here too. The host stays the judge; this + * only asks again. + * + * Held only while something is offered or failed. Keyed on status and prompt rather than every + * summary, so an agent streaming in such a chat costs one re-read, not one per tool call. + */ + +const OFFERED_CHAT_REFRESH_DELAY_MS = 500 + +function activityKey(summary: AgentSessionStatusSummary): string { + return `${summary.status ?? ''}\u0000${summary.latestPrompt}` +} + +export function createOfferedChatWatch(deps: { + feed: () => StructuredAgentSessionStatusFeedOwner + offeredIds: () => Set + /** When the list being watched arrived; a first sighting older than it is not news. */ + listedAt: () => number + refresh: () => void +}): { sync: () => void; release: () => void } { + let watch: { + feed: StructuredAgentSessionStatusFeedOwner + seen: Map + release: () => void + } | null = null + let pendingRefresh: ReturnType | null = null + + const notice = (): void => { + if (!watch) { + return + } + const { feed, seen } = watch + const snapshot = feed.getSnapshot() + let changed = false + for (const sessionId of deps.offeredIds()) { + const summary = snapshot.get(sessionId) + if (!summary) { + continue + } + const key = activityKey(summary) + const previous = seen.get(sessionId) + if (previous !== key) { + seen.set(sessionId, key) + // A first sighting is news only if newer than the list; a change to a known chat always is, + // since the host may have answered the list just before the change was delivered here. + changed ||= previous !== undefined || summary.updatedAt > deps.listedAt() + } + } + if (changed && pendingRefresh === null) { + pendingRefresh = setTimeout(() => { + pendingRefresh = null + deps.refresh() + }, OFFERED_CHAT_REFRESH_DELAY_MS) + } + } + + const release = (): void => { + if (pendingRefresh !== null) { + clearTimeout(pendingRefresh) + pendingRefresh = null + } + watch?.release() + watch = null + } + + const sync = (): void => { + const offeredIds = deps.offeredIds() + if (offeredIds.size === 0) { + release() + return + } + if (!watch) { + const feed = deps.feed() + const unsubscribe = feed.subscribe(notice) + const deactivate = feed.activate() + watch = { + feed, + seen: new Map(), + release: () => { + unsubscribe() + deactivate() + } + } + } + const { feed, seen } = watch + for (const sessionId of seen.keys()) { + if (!offeredIds.has(sessionId)) { + seen.delete(sessionId) + } + } + // What the feed already holds is what this listing answered. + for (const sessionId of offeredIds) { + const summary = feed.getSnapshot().get(sessionId) + if (summary && !seen.has(sessionId)) { + seen.set(sessionId, activityKey(summary)) + } + } + } + + return { sync, release } +} diff --git a/src/renderer/src/components/native-chat-resume-on-restart-outcome-toast.test.ts b/src/renderer/src/components/native-chat-resume-on-restart-outcome-toast.test.ts index 9b8c90eb4333..068e9ea95a78 100644 --- a/src/renderer/src/components/native-chat-resume-on-restart-outcome-toast.test.ts +++ b/src/renderer/src/components/native-chat-resume-on-restart-outcome-toast.test.ts @@ -55,11 +55,10 @@ function hostLostAfterListing(): void { }) } -/** How each caller starts a resume: a click names its chats; an opted-in launch names none and - * reports the ones it offered. */ +/** Clicks and opted-in launches use the same bulk action over their selection. */ const resumes = { click: () => continueNativeChatRestartOffer(['a', 'b']), - launch: () => continueNativeChatRestartOffer(undefined, ['a', 'b']) + launch: () => continueNativeChatRestartOffer(offered.map((candidate) => candidate.sessionId)) } // With the host unreachable there is no list to narrow by: every chat the resume reported failed, @@ -93,19 +92,23 @@ it('answers an opted-in launch that loses its resume request with one toast and // The same toast and copy as a click: the chats it resumed ride along under the one it could not. it('answers a mixed opted-in launch with one toast', async () => { - const failed = [{ ...offered[1]!, failedAt: 1, outcome: 'refused', reason: 'unknown' }] - rpc.mockImplementation(async (_target, method) => - method === 'agentSession.restartResumable' - ? { sessions: offered, failed: [] } - : { - continued: [ - { sessionId: 'a', outcome: 'continued' }, - { sessionId: 'b', outcome: 'refused' } - ], - sessions: [], - failed - } - ) + const failure = { ...offered[1]!, failedAt: 1, outcome: 'refused', reason: 'unknown' } + let acted = false + rpc.mockImplementation(async (_target, method, params: { sessionIds: string[] } | undefined) => { + const failed = acted ? [failure] : [] + if (method === 'agentSession.restartResumable') { + return { sessions: acted ? [] : offered, failed } + } + acted = true + return { + continued: (params?.sessionIds ?? []).map((sessionId) => ({ + sessionId, + outcome: sessionId === 'b' ? 'refused' : 'continued' + })), + sessions: [], + failed: [failure] + } + }) await refreshNativeChatRestartOffer() await resumes.launch() expect(vi.mocked(toast).mock.calls).toEqual([ @@ -126,9 +129,8 @@ it('raises no toast when the host had nothing left for an opted-in launch to res expect(toast).not.toHaveBeenCalled() }) -// The agent was seen carrying on while the toast was up, so the host retired the failure: Show -// re-reads and opens nothing, rather than latching a request for a dialog with no rows to draw. -it('opens nothing from Show once the host no longer lists the chat', async () => { +// A withdrawn failure cannot recreate attention, but the unseen run summary stays available. +it('opens the retained summary from Show after the host withdraws a failure', async () => { let failed = [{ ...offered[0]!, failedAt: 1, outcome: 'unconfirmed', reason: 'unknown' }] rpc.mockImplementation(async (_target, method) => method === 'agentSession.restartResumable' @@ -145,5 +147,5 @@ it('opens nothing from Show once the host no longer lists the chat', async () => await new Promise((resolve) => setTimeout(resolve, 0)) expect(rpc.mock.calls.at(-1)?.[1]).toBe('agentSession.restartResumable') expect(getNativeChatRestartOffer().failed).toEqual([]) - expect(getNativeChatResumeOnRestartDialogRequest()).toBe(false) + expect(getNativeChatResumeOnRestartDialogRequest()).toBe(true) }) diff --git a/src/renderer/src/components/native-chat-resume-on-restart-store.ts b/src/renderer/src/components/native-chat-resume-on-restart-store.ts index 9111eae70dbe..33e5faa58791 100644 --- a/src/renderer/src/components/native-chat-resume-on-restart-store.ts +++ b/src/renderer/src/components/native-chat-resume-on-restart-store.ts @@ -1,14 +1,11 @@ import { useEffect, useSyncExternalStore } from 'react' import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' -import { - getStructuredAgentSessionStatusFeed, - type StructuredAgentSessionStatusFeedOwner -} from '@/runtime/structured-agent-session-status-feed' -import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' +import { getStructuredAgentSessionStatusFeed } from '@/runtime/structured-agent-session-status-feed' import { useAppStore } from '../store' import { markNativeChatLaunchResumeDecided } from './native-chat-launch-resume-decision' import { announceRestartResults, + restartContinuationHistory, type RestartContinuationOutcome } from './native-chat-restart-action-notifications' import { @@ -16,6 +13,12 @@ import { type ResumeCandidate, type ResumeFailure } from './native-chat-resume-on-restart-grouping' +import { + beginResumeRun, + resumeRunInFlight, + resumeRunPendingIds, + type ResumeRun +} from './native-chat-resume-run' import { forgetUnsentResumes, markUnsentResumes, @@ -25,6 +28,8 @@ import { consumeNativeChatResumeOnRestartDialogRequest, requestNativeChatResumeOnRestartDialog } from './native-chat-resume-on-restart-dialog' +import { createOfferedChatWatch } from './native-chat-resume-offered-chat-watch' +import { watchResumeRunStatus } from './native-chat-resume-run-status-watch' /** * Which interrupted chats the host is still offering to resume, and every action that moves that. @@ -57,11 +62,10 @@ let launch: Promise | undefined * a re-read that raced it must neither pre-empt nor undo. */ let actionsBegun = 0 let actionsSettled = 0 -/** The chats each in-flight continue call names, so the status bar can report the resume after - * the dialog that started it has closed. Held only for the call's lifetime; never persisted. */ -const resumeBatches = new Set() -const NOTHING_RESUMING: readonly string[] = [] -let resuming: readonly string[] = NOTHING_RESUMING +/** Memory-only history: retires after viewing and closing its finished summary, or on replacement. */ +let run: ResumeRun | null = null +let releaseRunStatus: (() => void) | undefined +let resuming: readonly string[] = resumeRunPendingIds(null) const listeners = new Set<() => void>() const LAUNCH_READ_RETRY_DELAYS_MS = [100, 250, 500] as const @@ -76,130 +80,65 @@ function emit(): void { * actually moves the offer. */ function publish(next: NativeChatRestartOffer): void { offer = next - syncOfferedChatWatch() + offeredChatWatch.sync() emit() } /** A confirmed host answer. One with nothing left also retires any open request for the dialog, - * which has nothing to show; a failed read only hides rows, so it keeps the request. */ + * unless it is showing a run; a failed read only hides rows, so it keeps the request. */ function publishAnswer(answer: NativeChatRestartOffer): void { const next = withUnsentResumes(answer) publish(next) - if (next.candidates.length === 0 && next.failed.length === 0) { + if (next.candidates.length === 0 && next.failed.length === 0 && run === null) { consumeNativeChatResumeOnRestartDialogRequest() } } -function syncResuming(): void { - resuming = resumeBatches.size === 0 ? NOTHING_RESUMING : [...new Set([...resumeBatches].flat())] +function setRun(next: ResumeRun | null): void { + run = next + const pending = resumeRunPendingIds(next) + // Same ids, same array, so readers of `resuming` re-render only when it changes. + resuming = pending.join('\0') === resuming.join('\0') ? resuming : pending emit() } -/** - * Re-reads the host once an offered or failed chat shows new activity, so a message the user sent - * there, or its agent starting, retires its entry here too. The host stays the judge; this only - * asks again. - * - * Held only while something is offered or failed. Keyed on status and prompt rather than every - * summary, so an agent streaming in such a chat costs one re-read, not one per tool call. - */ -const OFFERED_CHAT_REFRESH_DELAY_MS = 500 -let offeredChatWatch: { - feed: StructuredAgentSessionStatusFeedOwner - seen: Map - release: () => void -} | null = null -let offeredChatRefresh: ReturnType | null = null +// Re-reads once an offered or failed chat shows new activity. Skipped while an action is in +// flight: that action ends by publishing the host's answer, which a racing re-read must not undo. +const offeredChatWatch = createOfferedChatWatch({ + feed: () => getStructuredAgentSessionStatusFeed(LOCAL), + offeredIds: () => new Set([...offer.candidates, ...offer.failed].map((entry) => entry.sessionId)), + listedAt: () => offer.listedAt, + refresh: () => { + if (actionsBegun === actionsSettled) { + const issued = actionsBegun + void readNativeChatRestartOffer(() => actionsBegun === issued) + } + } +}) -function offeredChatIds(): Set { - return new Set([...offer.candidates, ...offer.failed].map((entry) => entry.sessionId)) +export function getNativeChatRestartOffer(): NativeChatRestartOffer { + return offer } -function offeredChatActivityKey(summary: AgentSessionStatusSummary): string { - return `${summary.status ?? ''}\u0000${summary.latestPrompt}` +export function getNativeChatRestartResuming(): readonly string[] { + return resuming } -function syncOfferedChatWatch(): void { - const offeredIds = offeredChatIds() - if (offeredIds.size === 0) { - releaseOfferedChatWatch() - return - } - if (!offeredChatWatch) { - const feed = getStructuredAgentSessionStatusFeed(LOCAL) - const unsubscribe = feed.subscribe(noticeOfferedChatActivity) - const deactivate = feed.activate() - offeredChatWatch = { - feed, - seen: new Map(), - release: () => { - unsubscribe() - deactivate() - } - } - } - const { feed, seen } = offeredChatWatch - for (const sessionId of seen.keys()) { - if (!offeredIds.has(sessionId)) { - seen.delete(sessionId) - } - } - // What the feed already holds is what this listing answered. - for (const sessionId of offeredIds) { - const summary = feed.getSnapshot().get(sessionId) - if (summary && !seen.has(sessionId)) { - seen.set(sessionId, offeredChatActivityKey(summary)) - } - } +export function getNativeChatRestartRun(): ResumeRun | null { + return run } -function noticeOfferedChatActivity(): void { - if (!offeredChatWatch) { - return - } - const { feed, seen } = offeredChatWatch - const snapshot = feed.getSnapshot() - let changed = false - for (const sessionId of offeredChatIds()) { - const summary = snapshot.get(sessionId) - if (!summary) { - continue - } - const key = offeredChatActivityKey(summary) - const previous = seen.get(sessionId) - if (previous !== key) { - seen.set(sessionId, key) - // A first sighting is news only if newer than the list; a change to a known chat always is, - // since the host may have answered the list just before the change was delivered here. - changed ||= previous !== undefined || summary.updatedAt > offer.listedAt - } - } - if (changed && offeredChatRefresh === null) { - offeredChatRefresh = setTimeout(() => { - offeredChatRefresh = null - if (actionsBegun === actionsSettled) { - const issued = actionsBegun - void readNativeChatRestartOffer(() => actionsBegun === issued) - } - }, OFFERED_CHAT_REFRESH_DELAY_MS) +/** A finished summary must be shown before closing can retire it. */ +export function markFinishedNativeChatRestartRunShown(shown: ResumeRun): void { + if (run === shown && !shown.inFlight && !shown.finishedViewShown) { + setRun({ ...shown, finishedViewShown: true }) } } -function releaseOfferedChatWatch(): void { - if (offeredChatRefresh !== null) { - clearTimeout(offeredChatRefresh) - offeredChatRefresh = null +export function releaseFinishedNativeChatRestartRun(): void { + if (run?.finishedViewShown && !resumeRunInFlight(run)) { + setRun(null) } - offeredChatWatch?.release() - offeredChatWatch = null -} - -export function getNativeChatRestartOffer(): NativeChatRestartOffer { - return offer -} - -export function getNativeChatRestartResuming(): readonly string[] { - return resuming } function subscribe(listener: () => void): () => void { @@ -266,60 +205,77 @@ export async function reopenNativeChatRestartOffer(): Promise { if (resuming.length === 0) { await readNativeChatRestartOffer() } - if (resuming.length > 0 || offer.candidates.length > 0 || offer.failed.length > 0) { + if (run !== null || offer.candidates.length > 0 || offer.failed.length > 0) { requestNativeChatResumeOnRestartDialog() } } -/** - * Reattach the offered chats, ask each agent to carry on, then replace the offer with the host's - * authoritative remaining list. This keeps the modal and status bar synchronized after every - * action, even when the dialog's snapshot became stale while it was open. - * - * `sessionIds` is the dialog's selection. An opted-in launch names nothing, so the host acts on - * whatever it still offers rather than on a list this side captured a moment earlier, and passes - * `reported` instead: the chats the status bar shows as resuming. - * - * Ends in one toast saying what it did across those chats, whether a click or an opted-in launch - * started it; each chat's note stays the record. Never rejects; a lost answer is followed by a - * re-read, never a retry. The chats a rejected request named and the host still offers show as - * failed, with Retry. - */ -export async function continueNativeChatRestartOffer( - sessionIds: readonly string[] | undefined, - reported: readonly string[] = sessionIds ?? [] -): Promise { +/** One host action owns the selection; its status feed reports each chat before the reply. */ +export async function continueNativeChatRestartOffer(sessionIds: readonly string[]): Promise { + const requested = [...new Set(sessionIds)] + if (requested.length === 0) { + return + } actionsBegun += 1 - forgetUnsentResumes(sessionIds) - const batch = [...reported] - let outcome: Parameters - resumeBatches.add(batch) - syncResuming() - // `resuming` now names the chats, so the launch's one resume decision is made. + forgetUnsentResumes(requested) + const rows = new Map([...offer.candidates, ...offer.failed].map((row) => [row.sessionId, row])) + let actionRun = beginResumeRun( + requested.flatMap((sessionId) => rows.get(sessionId) ?? []), + Date.now() + ) + setRun(actionRun) + releaseRunStatus?.() + const release = watchResumeRunStatus( + () => (run === actionRun ? actionRun : null), + (next) => { + actionRun = next + setRun(next) + } + ) + releaseRunStatus = release markNativeChatLaunchResumeDecided() + let outcome: Parameters + let continued: readonly RestartContinuationOutcome[] | undefined + let skipped = new Set() try { const result = await callStructuredAgentSession< - HostOfferPayload & { continued?: RestartContinuationOutcome[] } - >(LOCAL, 'agentSession.restartContinue', sessionIds ? { sessionIds } : {}) + HostOfferPayload & { continued?: RestartContinuationOutcome[]; skipped?: string[] } + >(LOCAL, 'agentSession.restartContinue', { sessionIds: requested }) const listed = Array.isArray(result.failed) ? failedFrom(result) : undefined if (Array.isArray(result.sessions)) { publishAnswer({ candidates: result.sessions, failed: listed ?? [], listedAt: Date.now() }) } else { await refreshNativeChatRestartOffer() } - outcome = [batch, result.continued, listed, reopenNativeChatRestartOffer] + skipped = new Set(result.skipped) + const actedOn = requested.filter((sessionId) => !skipped.has(sessionId)) + continued = restartContinuationHistory(actedOn, result.continued, listed) + outcome = [actedOn, continued, listed, reopenNativeChatRestartOffer] } catch (error) { - // The row's reason is this side's own code, so the real error is kept in the log. console.warn('[native-chat-resume] resume request failed before reaching the chats', error) - markUnsentResumes(batch, Date.now()) + markUnsentResumes(requested, Date.now()) const read = await readNativeChatRestartOffer() - // Nothing reached the chats, so each is a failure of this resume as the list now shows it. - const listed = read.available ? offer.failed : undefined - outcome = [batch, [], listed, reopenNativeChatRestartOffer] + outcome = [ + requested, + [], + read.available ? offer.failed : undefined, + reopenNativeChatRestartOffer + ] } finally { + release() + if (releaseRunStatus === release) { + releaseRunStatus = undefined + } actionsSettled += 1 - resumeBatches.delete(batch) - syncResuming() + // Publish the offer first: reopening must never expose the old selection as actionable. + if (run === actionRun) { + setRun({ + ...actionRun, + entries: actionRun.entries.filter((entry) => !skipped.has(entry.candidate.sessionId)), + inFlight: false, + continued + }) + } } announceRestartResults(...outcome) } @@ -391,7 +347,7 @@ async function loadLaunchOffer(): Promise { requestNativeChatResumeOnRestartDialog() return } - await continueNativeChatRestartOffer(undefined, allResumeSessionIds(offered)) + await continueNativeChatRestartOffer(allResumeSessionIds(offered)) } /** @@ -415,13 +371,20 @@ export function useNativeChatRestartResuming(): readonly string[] { return useSyncExternalStore(subscribe, getNativeChatRestartResuming, getNativeChatRestartResuming) } +/** The selection and reply history a reopened progress dialog follows. */ +export function useNativeChatRestartRun(): ResumeRun | null { + return useSyncExternalStore(subscribe, getNativeChatRestartRun, getNativeChatRestartRun) +} + /** @internal - tests need a clean module between cases. */ export function _resetNativeChatRestartOffer(): void { - releaseOfferedChatWatch() + releaseRunStatus?.() + releaseRunStatus = undefined + offeredChatWatch.release() offer = EMPTY forgetUnsentResumes(undefined) - resumeBatches.clear() - resuming = NOTHING_RESUMING + run = null + resuming = resumeRunPendingIds(null) actionsBegun = 0 actionsSettled = 0 launch = undefined diff --git a/src/renderer/src/components/native-chat-resume-run-status-watch.ts b/src/renderer/src/components/native-chat-resume-run-status-watch.ts new file mode 100644 index 000000000000..08e2d28763ff --- /dev/null +++ b/src/renderer/src/components/native-chat-resume-run-status-watch.ts @@ -0,0 +1,27 @@ +import { getStructuredAgentSessionStatusFeed } from '@/runtime/structured-agent-session-status-feed' +import { observeResumeRun, type ResumeRun } from './native-chat-resume-run' + +/** Capture every frame, even while the dialog is closed or React batches renders. */ +export function watchResumeRunStatus( + current: () => ResumeRun | null, + publish: (run: ResumeRun) => void +): () => void { + const feed = getStructuredAgentSessionStatusFeed({ kind: 'local' }) + const notice = () => { + const run = current() + if (!run) { + return + } + const snapshot = feed.getSnapshot() + const next = observeResumeRun(run, (sessionId) => snapshot.get(sessionId)) + if (next !== run) { + publish(next) + } + } + const unsubscribe = feed.subscribe(notice) + const deactivate = feed.activate() + return () => { + unsubscribe() + deactivate() + } +} diff --git a/src/renderer/src/components/native-chat-resume-run-view.ts b/src/renderer/src/components/native-chat-resume-run-view.ts new file mode 100644 index 000000000000..07223a63a869 --- /dev/null +++ b/src/renderer/src/components/native-chat-resume-run-view.ts @@ -0,0 +1,108 @@ +import type { ResumeCandidate, ResumeFailure } from './native-chat-resume-on-restart-grouping' +import type { ResumeRun, ResumeRunHostStatus } from './native-chat-resume-run' + +/** Only current host failures create attention obligations after a run. */ + +/** Where one chat of the run stands, as the leading icon shows it in place of the checkbox. */ +export type ResumeRunRowStatus = + | { kind: 'in-flight'; startedAt: number; phase: 'starting' | 'ready' | null } + | { kind: 'resumed' } + | { kind: 'refused' } + | { kind: 'unconfirmed' } + +export type ResumeRunFilter = 'all' | 'in-progress' | 'resumed' | 'attention' + +type Category = Exclude | 'other' + +export type ResumeRunView = { + rows: ResumeCandidate[] + statusBySession: ReadonlyMap + counts: Readonly<{ + /** Every row the list holds, in the run or not. */ + all: number + /** Chats in this run that still need resuming or did; a chat nobody needed is not counted. */ + total: number + done: number + inProgress: number + resumed: number + /** Failed or unconfirmed, this run's or an earlier one's: the rows that need the user. */ + attention: number + }> +} + +// What needs the user first, then what is still moving, then what is done. +const ORDER: Record = { attention: 0, 'in-progress': 1, resumed: 2, other: 3 } + +export function resumeRunView( + run: ResumeRun, + listed: readonly ResumeCandidate[], + failureFor: (sessionId: string) => ResumeFailure | undefined, + filter: ResumeRunFilter, + hostStatusFor: (sessionId: string) => ResumeRunHostStatus | undefined = () => undefined +): ResumeRunView { + const listedById = new Map(listed.map((row) => [row.sessionId, row])) + const continuedBySession = new Map( + run.continued?.map((entry) => [entry.sessionId, entry.outcome]) + ) + const statusBySession = new Map() + const placed: { row: ResumeCandidate; category: Category; index: number }[] = [] + const inRun = new Set() + let inRunCount = 0 + for (const entry of run.entries) { + const { sessionId } = entry.candidate + inRun.add(sessionId) + const row = listedById.get(sessionId) ?? entry.candidate + const failure = failureFor(sessionId) + const hostStatus = run.inFlight ? (entry.observedStatus ?? hostStatusFor(sessionId)) : undefined + const phase = hostStatus?.restartResume?.phase + if (run.inFlight && phase === 'skipped') { + continue + } + inRunCount += 1 + if (run.inFlight && (phase === undefined || phase === 'queued' || phase === 'starting')) { + const hostPhase = hostStatus?.hostExecutionPhase + statusBySession.set(sessionId, { + kind: 'in-flight', + startedAt: run.startedAt, + phase: phase === 'starting' ? (hostPhase === 'ready' ? 'ready' : 'starting') : null + }) + placed.push({ row, category: 'in-progress', index: placed.length }) + } else if ( + phase === 'continued' || + (!run.inFlight && !failure && continuedBySession.get(sessionId) === 'continued') + ) { + statusBySession.set(sessionId, { kind: 'resumed' }) + placed.push({ row, category: 'resumed', index: placed.length }) + } else if (run.inFlight && (phase === 'refused' || phase === 'unconfirmed')) { + statusBySession.set(sessionId, { kind: phase }) + placed.push({ row, category: 'attention', index: placed.length }) + } else if (failure) { + placed.push({ row, category: 'attention', index: placed.length }) + } else if (listedById.has(sessionId)) { + placed.push({ row, category: 'other', index: placed.length }) + } + } + for (const row of listed) { + if (!inRun.has(row.sessionId)) { + const category = failureFor(row.sessionId) ? 'attention' : 'other' + placed.push({ row, category, index: placed.length }) + } + } + const count = (category: Category) => placed.filter((entry) => entry.category === category).length + const inProgress = count('in-progress') + return { + rows: placed + .filter((entry) => filter === 'all' || entry.category === filter) + .sort((a, b) => ORDER[a.category] - ORDER[b.category] || a.index - b.index) + .map((entry) => entry.row), + statusBySession, + counts: { + all: placed.length, + total: inRunCount, + done: inRunCount - inProgress, + inProgress, + resumed: count('resumed'), + attention: count('attention') + } + } +} diff --git a/src/renderer/src/components/native-chat-resume-run.test.ts b/src/renderer/src/components/native-chat-resume-run.test.ts new file mode 100644 index 000000000000..21badcab4d7f --- /dev/null +++ b/src/renderer/src/components/native-chat-resume-run.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it } from 'vitest' +import type { ResumeCandidate } from './native-chat-resume-on-restart-grouping' +import { + beginResumeRun, + observeResumeRun, + resumeRunInFlight, + resumeRunPendingIds, + type ResumeRunHostStatus +} from './native-chat-resume-run' +import { resumeRunView } from './native-chat-resume-run-view' + +const row = (sessionId: string): ResumeCandidate => ({ + sessionId, + workspaceId: 'folder', + agent: 'codex', + trigger: 'quit', + latestPrompt: `Prompt ${sessionId}`, + recordedAt: 1 +}) +const failure = { ...row('a'), failedAt: 2, outcome: 'unconfirmed' as const, reason: 'pending' } +const run = beginResumeRun([row('a'), row('b')], 10) + +describe('the dialog over a bulk action', () => { + it('never regresses a seen phase or verdict when live progress clears', () => { + let observed = run + for (const phase of ['queued', 'starting', 'continued'] as const) { + observed = observeResumeRun(observed, () => ({ restartResume: { phase } })) + const before = resumeRunView(observed, [], () => undefined, 'all') + expect(observeResumeRun(observed, () => undefined)).toBe(observed) + expect(observeResumeRun(observed, () => ({ restartResume: { phase: 'queued' } }))).toBe( + observed + ) + expect(resumeRunView(observed, [], () => undefined, 'all')).toEqual(before) + } + expect(resumeRunView(observed, [], () => undefined, 'all').counts.done).toBe(2) + expect(observeResumeRun(observed, () => ({ restartResume: { phase: 'refused' } }))).toBe( + observed + ) + expect(observeResumeRun({ ...observed, inFlight: false }, () => undefined).inFlight).toBe(false) + }) + + it('remembers readiness and excludes skipped chats from rows and the denominator', () => { + const observed = observeResumeRun(run, (id) => ({ + restartResume: { phase: id === 'a' ? 'skipped' : 'starting' }, + hostExecutionPhase: 'ready' + })) + const view = resumeRunView(observed, [row('a')], () => undefined, 'all') + expect(view.rows).toEqual([row('b')]) + expect(view.counts).toMatchObject({ all: 1, total: 1, done: 0, inProgress: 1 }) + expect(view.statusBySession.get('b')).toEqual({ + kind: 'in-flight', + phase: 'ready', + startedAt: 10 + }) + const admitted = observeResumeRun(observed, () => ({ restartResume: { phase: 'starting' } })) + expect(admitted.entries[1]).toBe(observed.entries[1]) + expect(resumeRunView(admitted, [], () => undefined, 'all').counts.total).toBe(2) + }) + + it("real action progress supersedes another request's provisional skip", () => { + let observed = observeResumeRun(run, () => ({ restartResume: { phase: 'skipped' } })) + for (const phase of ['queued', 'starting', 'continued'] as const) { + observed = observeResumeRun(observed, () => ({ restartResume: { phase } })) + expect(observed.entries[0]?.observedStatus?.restartResume?.phase).toBe(phase) + expect(observeResumeRun(observed, () => ({ restartResume: { phase: 'skipped' } }))).toBe( + observed + ) + } + expect(resumeRunView(observed, [], () => undefined, 'all').counts).toMatchObject({ + total: 2, + resumed: 2 + }) + }) + it('settles a chat from its host verdict while the request remains in flight', () => { + const statusFor = (id: string): ResumeRunHostStatus => ({ + restartResume: { phase: id === 'a' ? 'continued' : 'queued' } + }) + const view = resumeRunView(run, [], () => undefined, 'all', statusFor) + expect(view.rows.map((entry) => entry.sessionId)).toEqual(['b', 'a']) + expect(view.statusBySession.get('a')).toEqual({ kind: 'resumed' }) + expect(view.counts).toMatchObject({ total: 2, done: 1, resumed: 1, inProgress: 1 }) + expect(resumeRunInFlight(run)).toBe(true) + expect(resumeRunPendingIds(run)).toEqual(['a', 'b']) + }) + + it('degrades an older host to waiting until the reply, even if its ordinary status is ready', () => { + const view = resumeRunView( + run, + [], + () => undefined, + 'all', + () => ({ hostExecutionPhase: 'ready' }) + ) + expect(view.counts.inProgress).toBe(2) + expect(view.statusBySession.get('a')).toEqual({ kind: 'in-flight', phase: null, startedAt: 10 }) + }) + + it('distinguishes waiting for a start slot from holding one and waiting for delivery', () => { + for (const hostExecutionPhase of ['starting', 'ready'] as const) { + const view = resumeRunView( + run, + [], + () => undefined, + 'all', + () => ({ restartResume: { phase: 'starting' }, hostExecutionPhase }) + ) + expect(view.statusBySession.get('a')).toEqual({ + kind: 'in-flight', + phase: hostExecutionPhase, + startedAt: 10 + }) + } + }) + + it('a retry shows its current progress over the previous failure', () => { + const view = resumeRunView(run, [failure], () => failure, 'all') + expect(view.counts.attention).toBe(0) + expect(view.statusBySession.get('a')?.kind).toBe('in-flight') + }) + + it('withdrawn and dismissed failures leave Need you after the reply', () => { + const finished = { + ...run, + inFlight: false, + continued: [{ sessionId: 'a', outcome: 'unknown' as const }] + } + const failed = resumeRunView( + finished, + [failure], + (id) => (id === 'a' ? failure : undefined), + 'attention' + ) + expect(failed.rows).toEqual([failure]) + const withdrawn = resumeRunView(finished, [], () => undefined, 'attention') + expect(withdrawn.rows).toEqual([]) + expect(withdrawn.counts.attention).toBe(0) + }) + + it('retains successful history but always shows current host failures', () => { + const finished = { + ...run, + inFlight: false, + continued: [{ sessionId: 'a', outcome: 'continued' as const }] + } + expect(resumeRunView(finished, [], () => undefined, 'resumed').rows).toEqual([row('a')]) + const failed = resumeRunView( + finished, + [failure], + (id) => (id === 'a' ? failure : undefined), + 'attention' + ) + expect(failed.rows).toEqual([failure]) + expect(failed.statusBySession.has('a')).toBe(false) + expect(resumeRunPendingIds(finished)).toBe(resumeRunPendingIds(null)) + }) + + it('shows final per-chat failures from the feed before the batch returns', () => { + for (const phase of ['refused', 'unconfirmed'] as const) { + const view = resumeRunView( + run, + [], + () => undefined, + 'attention', + () => ({ restartResume: { phase } }) + ) + expect(view.counts.attention).toBe(2) + expect(view.statusBySession.get('a')).toEqual({ kind: phase }) + } + }) +}) diff --git a/src/renderer/src/components/native-chat-resume-run.ts b/src/renderer/src/components/native-chat-resume-run.ts new file mode 100644 index 000000000000..5f7cc7669dcc --- /dev/null +++ b/src/renderer/src/components/native-chat-resume-run.ts @@ -0,0 +1,71 @@ +import type { ResumeCandidate } from './native-chat-resume-on-restart-grouping' +import type { RestartContinuationOutcome } from './native-chat-restart-action-notifications' +import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' + +export type ResumeRunHostStatus = Pick< + AgentSessionStatusSummary, + 'restartResume' | 'hostExecutionPhase' +> + +/** Selection and history belong to this window; live progress and failures belong to the host. */ +export type ResumeRun = Readonly<{ + startedAt: number + entries: readonly Readonly<{ candidate: ResumeCandidate; observedStatus?: ResumeRunHostStatus }>[] + inFlight: boolean + continued?: readonly RestartContinuationOutcome[] + finishedViewShown?: boolean +}> + +function progressRank(status: ResumeRunHostStatus | undefined): number { + const phase = status?.restartResume?.phase + if (phase === 'starting') { + return status?.hostExecutionPhase === 'ready' ? 4 : 3 + } + if (phase === 'queued') { + return 2 + } + if (phase === 'skipped') { + return 1 + } + return phase === 'continued' || phase === 'refused' || phase === 'unconfirmed' ? 5 : 0 +} + +/** Clearing live fields cannot undo progress this action already showed. */ +export function observeResumeRun( + run: ResumeRun, + statusFor: (sessionId: string) => ResumeRunHostStatus | undefined +): ResumeRun { + if (!run.inFlight) { + return run + } + let changed = false + const entries = run.entries.map((entry) => { + const status = statusFor(entry.candidate.sessionId) + if (progressRank(status) <= progressRank(entry.observedStatus)) { + return entry + } + changed = true + return { + ...entry, + observedStatus: { + restartResume: status?.restartResume, + hostExecutionPhase: status?.hostExecutionPhase + } + } + }) + return changed ? { ...run, entries } : run +} + +export function resumeRunInFlight(run: ResumeRun | null): boolean { + return run?.inFlight ?? false +} + +export function beginResumeRun(candidates: readonly ResumeCandidate[], now: number): ResumeRun { + return { startedAt: now, entries: candidates.map((candidate) => ({ candidate })), inFlight: true } +} + +const NONE: readonly string[] = [] +/** Keep the selection protected until the action's authoritative reply has been published. */ +export function resumeRunPendingIds(run: ResumeRun | null): readonly string[] { + return run?.inFlight ? run.entries.map((entry) => entry.candidate.sessionId) : NONE +} diff --git a/src/renderer/src/components/native-chat-resume-skipped-progress.test.tsx b/src/renderer/src/components/native-chat-resume-skipped-progress.test.tsx new file mode 100644 index 000000000000..acdc13bda73e --- /dev/null +++ b/src/renderer/src/components/native-chat-resume-skipped-progress.test.tsx @@ -0,0 +1,160 @@ +// @vitest-environment happy-dom +import { act } from 'react' +import { expect, it, vi, type Mock } from 'vitest' +import { + createResumeModalFixture, + offered, + failure, + type RestartRpc, + type ResumeStatusStream +} from './native-chat-resume-modal.test-support' +import { NativeChatResumeOnRestartModal } from './NativeChatResumeOnRestartModal' +import { NativeChatResumeStatusSegment } from './status-bar/NativeChatResumeStatusSegment' +import { requestNativeChatResumeOnRestartDialog } from './native-chat-resume-on-restart-dialog' +import { getNativeChatRestartOffer } from './native-chat-resume-on-restart-store' +import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' + +const rpc: Mock = vi.hoisted(() => vi.fn()) +const statusStream: ResumeStatusStream = vi.hoisted(() => ({ + emit: (_event: Parameters[0]) => {}, + snapshot: new Map() +})) +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: rpc, + subscribeStructuredAgentSessionStatus: async ( + _target: unknown, + emit: ResumeStatusStream['emit'] + ) => { + statusStream.emit = emit + emit({ type: 'snapshot', sessions: [...statusStream.snapshot.values()] }) + return { unsubscribe: () => {} } + } +})) +vi.mock('@/lib/activate-ai-vault-structured-session', () => ({ + activateAiVaultStructuredSession: vi.fn(async () => true) +})) +vi.mock('sonner', () => ({ toast: vi.fn() })) + +const { mount, button, toasts, runStatus } = createResumeModalFixture(rpc, statusStream) +const dialog = () => document.querySelector('[role="dialog"]')?.textContent + +function emitPhase(phase?: NonNullable['phase']) { + statusStream.emit({ + type: 'status', + session: { + sessionId: 'a', + workspaceId: 'workspace', + agent: 'codex', + status: 'idle', + latestPrompt: '', + updatedAt: 1, + ...(phase ? { restartResume: { phase } } : {}) + } + }) +} + +it.each(['received', 'missed'] as const)( + "resumes 1 of 1 after another action's skip when real progress frames are %s", + async (frames) => { + const reply = Promise.withResolvers() + rpc.mockImplementation(async (_target, method) => + method === 'agentSession.restartResumable' + ? { sessions: [offered[0]], failed: [] } + : reply.promise + ) + await mount( + <> + + + + ) + await act(async () => button('Resume 1 chat').click()) + await act(async () => emitPhase('skipped')) + await act(async () => button('Resuming chats 0/0').click()) + expect(dialog()).not.toContain('Prompt a') + if (frames === 'received') { + await act(async () => emitPhase('queued')) + expect(button('Resuming chats 0/1')).toBeTruthy() + expect(runStatus('Prompt a')).toMatch(/Waiting to start/) + await act(async () => { + emitPhase('starting') + emitPhase('continued') + emitPhase() + }) + expect(button('Resuming chats 1/1')).toBeTruthy() + expect(runStatus('Prompt a')).toBe('Prompt a: Resumed') + } + await act(async () => + reply.resolve({ + sessions: [], + failed: [], + continued: [{ sessionId: 'a', outcome: 'continued' }] + }) + ) + expect(dialog()).toContain('Resumed 1 of 1 chat') + expect(dialog()).toContain('All1') + expect(runStatus('Prompt a')).toBe('Prompt a: Resumed') + expect(toasts()).toEqual([['Resumed 1 chat']]) + } +) + +it.each(['returned', 'lost'] as const)( + 'keeps current failure rows and counts after a skipped retry with a %s reply', + async (delivery) => { + const reply = Promise.withResolvers() + const initial = [ + { ...failure('a', 'agent_session_conflict'), retryable: true }, + { ...failure('b'), outcome: 'unconfirmed' as const } + ] + const reconciled = [{ ...initial[0], retryable: false }, initial[1]] + let reads = 0 + rpc.mockImplementation(async (_target, method) => + method === 'agentSession.restartContinue' + ? reply.promise + : { sessions: [], failed: reads++ === 0 ? initial : reconciled } + ) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + await mount( + <> + + + + ) + await act(async () => requestNativeChatResumeOnRestartDialog()) + await act(async () => button('Retry').click()) + await act(async () => { + emitPhase('skipped') + emitPhase() + }) + await act(async () => { + if (delivery === 'lost') { + reply.reject(new Error('lost reply after skipped retry')) + } else { + reply.resolve({ sessions: [], failed: reconciled, skipped: ['a'], continued: [] }) + } + }) + expect(getNativeChatRestartOffer().failed.map((row) => row.sessionId)).toEqual(['a', 'b']) + expect(dialog()).toContain('Prompt a') + expect(dialog()).toContain('Prompt b') + expect(dialog()).toContain('All2') + expect(dialog()).toContain('Need you2') + expect(dialog()).toContain( + delivery === 'lost' ? 'Resumed 0 of 1 chat' : 'Resumed 0 of 0 chats' + ) + expect(button('2 chats to check')).toBeTruthy() + expect( + [...document.querySelectorAll('button')].filter( + (entry) => entry.textContent === 'Open chat' + ) + ).toHaveLength(2) + expect(document.querySelectorAll('button[aria-label^="Dismiss"]')).toHaveLength(2) + expect(runStatus('Prompt a')).toBe('Prompt a: Couldn’t resume') + if (delivery === 'lost') { + expect(toasts()).toEqual([['1 chat couldn’t be resumed']]) + } + } finally { + warn.mockRestore() + } + } +) diff --git a/src/renderer/src/components/native-chat-resume-tree-state.ts b/src/renderer/src/components/native-chat-resume-tree-state.ts index cd99733cad28..74b809eb8d7c 100644 --- a/src/renderer/src/components/native-chat-resume-tree-state.ts +++ b/src/renderer/src/components/native-chat-resume-tree-state.ts @@ -43,6 +43,9 @@ export const ResumeTreeDepthContext = createContext(0) export type FailureProps = { failureFor?: (sessionId: string) => ResumeFailure | undefined onFailureAction?: (action: ResumeFailureAction, sessionId: string) => void + renderStatus?: (sessionId: string, title: string) => React.ReactNode + /** Current offer membership excludes completed run history from group selection. */ + selectableIds?: ReadonlySet } /** What every node needs from the tree as a whole. */ @@ -69,8 +72,12 @@ export function chatState(candidate: ResumeCandidate, tree: TreeProps) { checked: tree.selected.has(key), onCheckedChange: (checked: boolean) => tree.onToggle(key, checked), failure, + renderStatus: tree.renderStatus + ? (_sessionId: string, title: string) => tree.renderStatus?.(key, title) + : undefined, // A group checkbox never ticks a failure a retry cannot fix. - selectable: !failure || resumeFailureSelectable(failure) + selectable: + (tree.selectableIds?.has(key) ?? true) && (!failure || resumeFailureSelectable(failure)) } } diff --git a/src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.tsx b/src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.tsx index be3b743b4244..f51ab5fe2078 100644 --- a/src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.tsx @@ -5,8 +5,10 @@ import { translate } from '@/i18n/i18n' import { reopenNativeChatRestartOffer, useNativeChatRestartOffer, - useNativeChatRestartResuming + useNativeChatRestartRun } from '../native-chat-resume-on-restart-store' +import { resumeRunInFlight } from '../native-chat-resume-run' +import { resumeRunView } from '../native-chat-resume-run-view' // Why: closing the resume dialog is a snooze, not a decline — the host keeps the offer. This is // then the only surface left carrying it, so it is always rendered rather than gated by @@ -51,30 +53,19 @@ function Segment({ type SegmentText = { label: string; ariaLabel: string; tooltip: string } -function resumingText(count: number): SegmentText { +/** Chats answered out of the chats asked, each counted as its own answer arrives. */ +function resumingText(done: number, total: number): SegmentText { return { - label: - count === 1 - ? translate( - 'auto.components.status.bar.NativeChatResumeStatusSegment.resumingLabelOne', - 'Resuming 1 chat' - ) - : translate( - 'auto.components.status.bar.NativeChatResumeStatusSegment.resumingLabel', - 'Resuming {{value0}} chats', - { value0: count } - ), - ariaLabel: - count === 1 - ? translate( - 'auto.components.status.bar.NativeChatResumeStatusSegment.resumingAriaOne', - 'Resuming 1 chat. Click to open details.' - ) - : translate( - 'auto.components.status.bar.NativeChatResumeStatusSegment.resumingAria', - 'Resuming {{value0}} chats. Click to open details.', - { value0: count } - ), + label: translate( + 'auto.components.status.bar.NativeChatResumeStatusSegment.resumingProgressLabel', + 'Resuming chats {{value0}}/{{value1}}', + { value0: done, value1: total } + ), + ariaLabel: translate( + 'auto.components.status.bar.NativeChatResumeStatusSegment.resumingProgressAria', + 'Resuming chats, {{value0}} of {{value1}} done. Click to open details.', + { value0: done, value1: total } + ), tooltip: translate( 'auto.components.status.bar.NativeChatResumeStatusSegment.resumingTooltip', 'Restoring interrupted chats and asking them to carry on…' @@ -152,27 +143,29 @@ export function NativeChatResumeStatusSegment({ }): React.JSX.Element | null { const offerEnabled = useNativeChatRestartOfferEnabled() const { candidates, failed } = useNativeChatRestartOffer(offerEnabled) - const resumingIds = useNativeChatRestartResuming() + const run = useNativeChatRestartRun() + const failureBySession = new Map(failed.map((entry) => [entry.sessionId, entry])) + const view = run ? resumeRunView(run, candidates, (id) => failureBySession.get(id), 'all') : null if (!offerEnabled) { return null } - // A chat being resumed is counted once, as in flight, until the host answers for it. - const inFlight = new Set(resumingIds) - const waiting = failed.filter((failure) => !inFlight.has(failure.sessionId)) - const resuming = inFlight.size - const pending = candidates.filter((candidate) => !inFlight.has(candidate.sessionId)).length + // Count the selection once until the action publishes the host's remaining list. + const running = run !== null && resumeRunInFlight(run) + const inRun = new Set(running ? run.entries.map((entry) => entry.candidate.sessionId) : []) + const waiting = failed.filter((failure) => !inRun.has(failure.sessionId)) + const pending = candidates.filter((candidate) => !inRun.has(candidate.sessionId)).length const failures = waiting.length // An unconfirmed chat may be working, so "failed" would invite a duplicate "continue". const unconfirmed = waiting.some((failure) => failure.outcome === 'unconfirmed') return ( <> - {resuming > 0 && ( + {running && ( } - {...resumingText(resuming)} + {...resumingText(view?.counts.done ?? 0, view?.counts.total ?? 0)} /> )} {pending > 0 && ( diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 857c2455b225..6f82ff57f19a 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -2141,7 +2141,8 @@ "collapseNode": "Collapse {{value0}}", "expandNode": "Expand {{value0}}", "selectMachine": "Select all chats on {{value0}}", - "sshHost": "SSH" + "sshHost": "SSH", + "done": "Done" }, "StarNagCard": { "92b0f9d921": "is authenticated and try again.", @@ -4316,11 +4317,9 @@ "checkAriaOne": "1 chat to check after resuming. Click for details.", "checkAria": "{{value0}} chats to check after resuming. Click for details.", "checkTooltip": "Chats Orca couldn’t resume, or couldn’t confirm it resumed, after the restart. Click for details.", - "resumingLabelOne": "Resuming 1 chat", - "resumingLabel": "Resuming {{value0}} chats", - "resumingAriaOne": "Resuming 1 chat. Click to open details.", - "resumingAria": "Resuming {{value0}} chats. Click to open details.", - "resumingTooltip": "Restoring interrupted chats and asking them to carry on…" + "resumingTooltip": "Restoring interrupted chats and asking them to carry on…", + "resumingProgressLabel": "Resuming chats {{value0}}/{{value1}}", + "resumingProgressAria": "Resuming chats, {{value0}} of {{value1}} done. Click to open details." } } }, @@ -17844,6 +17843,32 @@ "hostOnly": "Cursor sign-in details are only readable on the computer running Orca." } } + }, + "NativeChatResumeRunPanel": { + "resumedTitle": "Resumed {{value0}} of {{value1}} chats", + "resumingTitleOne": "Resuming 1 chat", + "resumingTitle": "Resuming {{value0}} chats", + "description": "Each chat is restored with its full context and asked to check where it stopped. You can close this; the status bar keeps track.", + "resumedTitleOne": "Resumed {{value0}} of 1 chat" + }, + "NativeChatResumeRunStatusIcon": { + "waitingForReply": "Waiting for a reply", + "starting": "Starting", + "waitingToStart": "Waiting to start", + "inFlight": "{{value0}} · {{value1}}", + "resumed": "Resumed" + }, + "NativeChatResumeRunSummary": { + "done": "{{value0}} of {{value1}} done", + "starting": "{{value0}} starting", + "waitingForReply": "{{value0}} waiting for a reply", + "waitingToStart": "{{value0}} waiting to start", + "all": "All", + "inProgress": "In progress", + "resumed": "Resumed", + "attention": "Need you", + "running": "Running {{value0}}", + "filter": "Show chats" } }, "i18n": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index df693078d7af..ae84e19e609a 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -4056,16 +4056,14 @@ "checkAriaOne": "1 chat por revisar tras la reanudación. Haz clic para ver los detalles.", "checkAria": "{{value0}} chats por revisar tras la reanudación. Haz clic para ver los detalles.", "checkTooltip": "Chats que Orca no pudo reanudar, o no pudo confirmar que reanudó, tras el reinicio. Haz clic para ver los detalles.", - "resumingLabelOne": "Reanudando 1 chat", - "resumingLabel": "Reanudando {{value0}} chats", - "resumingAriaOne": "Reanudando 1 chat. Haz clic para ver los detalles.", - "resumingAria": "Reanudando {{value0}} chats. Haz clic para ver los detalles.", "resumingTooltip": "Restaurando los chats interrumpidos y pidiéndoles que continúen…", "labelOne": "1 chat para reanudar", "label": "{{value0}} chats para reanudar", "ariaLabelOne": "1 chat disponible para reanudar", "ariaLabel": "{{value0}} chats disponibles para reanudar", - "tooltip": "Abrir chats interrumpidos disponibles para reanudar" + "tooltip": "Abrir chats interrumpidos disponibles para reanudar", + "resumingProgressLabel": "Reanudando chats {{value0}}/{{value1}}", + "resumingProgressAria": "Reanudando chats, {{value0}} de {{value1}} listos. Haz clic para ver los detalles." }, "RuntimeHostStatusRow": { "previous_connection_closed": "La conexión anterior cerrada.", @@ -17550,7 +17548,8 @@ "collapseNode": "Contraer {{value0}}", "expandNode": "Expandir {{value0}}", "selectMachine": "Seleccionar todos los chats en {{value0}}", - "sshHost": "SSH" + "sshHost": "SSH", + "done": "Listo" }, "NativeChatResumeOutcomeRow": { "openChat": "Abrir chat", @@ -17650,6 +17649,32 @@ "HostedReviewUnlinkMenuItem": { "label": "Desvincular {{value0}} del espacio de trabajo", "description": "Orca ocultará los detalles de {{value0}} {{value1}} en este espacio de trabajo. El {{value0}} y la rama en {{value2}} no se modificarán." + }, + "NativeChatResumeRunPanel": { + "resumedTitle": "Se reanudaron {{value0}} de {{value1}} chats", + "resumedTitleOne": "{{value0}} de 1 chat reanudado", + "resumingTitleOne": "Reanudando 1 chat", + "resumingTitle": "Reanudando {{value0}} chats", + "description": "Cada chat se restaura con todo su contexto y se le pide que revise dónde se detuvo. Puedes cerrar esto; la barra de estado sigue el progreso." + }, + "NativeChatResumeRunStatusIcon": { + "waitingForReply": "Esperando respuesta", + "starting": "Iniciando", + "waitingToStart": "Esperando para iniciar", + "inFlight": "{{value0}} · {{value1}}", + "resumed": "Reanudado" + }, + "NativeChatResumeRunSummary": { + "done": "{{value0}} de {{value1}} listos", + "starting": "{{value0}} iniciando", + "waitingForReply": "{{value0}} esperando respuesta", + "waitingToStart": "{{value0}} esperando para iniciar", + "all": "Todos", + "inProgress": "En curso", + "resumed": "Reanudados", + "attention": "Requieren tu atención", + "running": "En curso desde hace {{value0}}", + "filter": "Mostrar chats" } }, "i18n": { diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 22c327e3ddb3..9499fcd818cb 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -4178,11 +4178,9 @@ "checkAriaOne": "1 chat à vérifier après la reprise. Cliquez pour plus de détails.", "checkAria": "{{value0}} chats à vérifier après la reprise. Cliquez pour plus de détails.", "checkTooltip": "Chats qu'Orca n'a pas pu reprendre, ou dont il n'a pas pu confirmer la reprise, après le redémarrage. Cliquez pour plus de détails.", - "resumingLabelOne": "Reprise d’1 conversation", - "resumingLabel": "Reprise de {{value0}} conversations", - "resumingAriaOne": "Reprise d’1 conversation. Cliquez pour voir les détails.", - "resumingAria": "Reprise de {{value0}} conversations. Cliquez pour voir les détails.", - "resumingTooltip": "Restauration des conversations interrompues et demande de poursuite…" + "resumingTooltip": "Restauration des conversations interrompues et demande de poursuite…", + "resumingProgressLabel": "Reprise des discussions {{value0}}/{{value1}}", + "resumingProgressAria": "Reprise des discussions, {{value0}} sur {{value1}} terminées. Cliquez pour voir les détails." }, "provider": { "extra": { @@ -17670,7 +17668,8 @@ "collapseNode": "Réduire {{value0}}", "expandNode": "Développer {{value0}}", "selectMachine": "Sélectionner toutes les discussions sur {{value0}}", - "sshHost": "SSH" + "sshHost": "SSH", + "done": "Terminé" }, "linear-issue-attribute-filter-coverage-notice": { "statusPartialTeamCoverage": "Filtrage des statuts des équipes {{value0}} sur {{value1}} : les problèmes des équipes restantes ne sont pas inclus.", @@ -17751,6 +17750,32 @@ "hostOnly": "Les détails de connexion Cursor ne sont lisibles que sur l'ordinateur qui exécute Orca." } } + }, + "NativeChatResumeRunPanel": { + "resumedTitle": "{{value0}} discussions sur {{value1}} reprises", + "resumedTitleOne": "{{value0}} discussion sur 1 reprise", + "resumingTitleOne": "Reprise d’une discussion", + "resumingTitle": "Reprise de {{value0}} discussions", + "description": "Chaque discussion est restaurée avec tout son contexte, et l’agent vérifie où il s’était arrêté. Vous pouvez fermer cette fenêtre ; la barre d’état suit la progression." + }, + "NativeChatResumeRunStatusIcon": { + "waitingForReply": "En attente de réponse", + "starting": "Démarrage", + "waitingToStart": "En attente de démarrage", + "inFlight": "{{value0}} · {{value1}}", + "resumed": "Reprise" + }, + "NativeChatResumeRunSummary": { + "done": "{{value0}} sur {{value1}} terminées", + "starting": "{{value0}} en démarrage", + "waitingForReply": "{{value0}} en attente de réponse", + "waitingToStart": "{{value0}} en attente de démarrage", + "all": "Toutes", + "inProgress": "En cours", + "resumed": "Reprises", + "attention": "À traiter", + "running": "En cours depuis {{value0}}", + "filter": "Afficher les discussions" } }, "i18n": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 8439aa421b99..94e3234e1a4e 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -4073,11 +4073,9 @@ "checkAriaOne": "再開後に確認が必要なチャット 1 件。詳細はクリックしてください。", "checkAria": "再開後に確認が必要なチャット {{value0}} 件。詳細はクリックしてください。", "checkTooltip": "再起動後に Orca が再開できなかった、または再開を確認できなかったチャット。詳細はクリックしてください。", - "resumingLabelOne": "1 件のチャットを再開中", - "resumingLabel": "{{value0}} 件のチャットを再開中", - "resumingAriaOne": "1 件のチャットを再開中。詳細はクリックしてください。", - "resumingAria": "{{value0}} 件のチャットを再開中。詳細はクリックしてください。", - "resumingTooltip": "中断されたチャットを復元し、続行を依頼しています…" + "resumingTooltip": "中断されたチャットを復元し、続行を依頼しています…", + "resumingProgressLabel": "チャットを再開中 {{value0}}/{{value1}}", + "resumingProgressAria": "チャットを再開中。{{value1}} 件中 {{value0}} 件完了。クリックすると詳細を表示します。" }, "provider": { "extra": { @@ -17554,7 +17552,8 @@ "collapseNode": "{{value0}} を折りたたむ", "expandNode": "{{value0}} を展開します", "selectMachine": "{{value0}} のチャットをすべて選択", - "sshHost": "SSH" + "sshHost": "SSH", + "done": "完了" }, "linear-issue-attribute-filter-coverage-notice": { "statusPartialTeamCoverage": "{{value0}}/{{value1}} チームステータスをフィルタリングしています。残りのチームからの問題は含まれません。", @@ -17650,6 +17649,32 @@ "hostOnly": "Cursor のサインイン詳細は、Orca を実行しているコンピュータでのみ読み取れます。" } } + }, + "NativeChatResumeRunPanel": { + "resumedTitle": "{{value1}} 件中 {{value0}} 件のチャットを再開しました", + "resumedTitleOne": "1 件中 {{value0}} 件のチャットを再開しました", + "resumingTitleOne": "1 件のチャットを再開中", + "resumingTitle": "{{value0}} 件のチャットを再開中", + "description": "各チャットは全コンテキストとともに復元され、中断した箇所を確認するよう依頼されます。このダイアログは閉じても構いません。進行状況はステータスバーで確認できます。" + }, + "NativeChatResumeRunStatusIcon": { + "waitingForReply": "返信を待っています", + "starting": "起動中", + "waitingToStart": "起動待ち", + "inFlight": "{{value0}} · {{value1}}", + "resumed": "再開しました" + }, + "NativeChatResumeRunSummary": { + "done": "{{value1}} 件中 {{value0}} 件完了", + "starting": "{{value0}} 件起動中", + "waitingForReply": "{{value0}} 件が返信待ち", + "waitingToStart": "{{value0}} 件が起動待ち", + "all": "すべて", + "inProgress": "進行中", + "resumed": "再開済み", + "attention": "要対応", + "running": "経過 {{value0}}", + "filter": "表示するチャット" } }, "i18n": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 95effe588693..27ca7a35e5da 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -4073,11 +4073,9 @@ "checkAriaOne": "재개한 후 확인할 채팅 1개. 자세히 보려면 클릭하세요.", "checkAria": "재개한 후 확인할 채팅 {{value0}}개. 자세히 보려면 클릭하세요.", "checkTooltip": "다시 시작한 후 Orca가 재개하지 못했거나 재개했는지 확인하지 못한 채팅. 자세히 보려면 클릭하세요.", - "resumingLabelOne": "채팅 1개 재개 중", - "resumingLabel": "채팅 {{value0}}개 재개 중", - "resumingAriaOne": "채팅 1개 재개 중. 자세히 보려면 클릭하세요.", - "resumingAria": "채팅 {{value0}}개 재개 중. 자세히 보려면 클릭하세요.", - "resumingTooltip": "중단된 채팅을 복원하고 계속하도록 요청하는 중…" + "resumingTooltip": "중단된 채팅을 복원하고 계속하도록 요청하는 중…", + "resumingProgressLabel": "채팅 재개 중 {{value0}}/{{value1}}", + "resumingProgressAria": "채팅 재개 중, {{value1}}개 중 {{value0}}개 완료. 자세히 보려면 클릭하세요." }, "provider": { "extra": { @@ -17554,7 +17552,8 @@ "collapseNode": "{{value0}} 접기", "expandNode": "{{value0}} 확장", "selectMachine": "{{value0}}의 채팅 모두 선택", - "sshHost": "SSH" + "sshHost": "SSH", + "done": "완료" }, "linear-issue-attribute-filter-coverage-notice": { "statusPartialTeamCoverage": "{{value1}} 팀 상태 중 {{value0}} 필터링 중 - 나머지 팀의 문제는 포함되지 않습니다.", @@ -17650,6 +17649,32 @@ "hostOnly": "Cursor 로그인 정보는 Orca가 실행 중인 컴퓨터에서만 읽을 수 있습니다." } } + }, + "NativeChatResumeRunPanel": { + "resumedTitle": "채팅 {{value1}}개 중 {{value0}}개를 재개했습니다", + "resumedTitleOne": "채팅 1개 중 {{value0}}개를 재개했습니다", + "resumingTitleOne": "채팅 1개 재개 중", + "resumingTitle": "채팅 {{value0}}개 재개 중", + "description": "각 채팅은 전체 컨텍스트와 함께 복원되며, 멈춘 지점을 확인하도록 요청됩니다. 이 창을 닫아도 상태 표시줄에서 진행 상황을 계속 볼 수 있습니다." + }, + "NativeChatResumeRunStatusIcon": { + "waitingForReply": "응답 대기 중", + "starting": "시작 중", + "waitingToStart": "시작 대기 중", + "inFlight": "{{value0}} · {{value1}}", + "resumed": "재개됨" + }, + "NativeChatResumeRunSummary": { + "done": "{{value1}}개 중 {{value0}}개 완료", + "starting": "{{value0}}개 시작 중", + "waitingForReply": "{{value0}}개 응답 대기 중", + "waitingToStart": "{{value0}}개 시작 대기 중", + "all": "전체", + "inProgress": "진행 중", + "resumed": "재개됨", + "attention": "확인 필요", + "running": "경과 {{value0}}", + "filter": "표시할 채팅" } }, "i18n": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index b98a4778a4bf..4d0c918ef554 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -4073,11 +4073,9 @@ "checkAriaOne": "恢复后有 1 个待检查的聊天。点击查看详情。", "checkAria": "恢复后有 {{value0}} 个待检查的聊天。点击查看详情。", "checkTooltip": "重启后 Orca 未能恢复或未能确认已恢复的聊天。点击查看详情。", - "resumingLabelOne": "正在恢复 1 个聊天", - "resumingLabel": "正在恢复 {{value0}} 个聊天", - "resumingAriaOne": "正在恢复 1 个聊天。点击查看详情。", - "resumingAria": "正在恢复 {{value0}} 个聊天。点击查看详情。", - "resumingTooltip": "正在恢复中断的聊天并请它们继续…" + "resumingTooltip": "正在恢复中断的聊天并请它们继续…", + "resumingProgressLabel": "正在恢复聊天 {{value0}}/{{value1}}", + "resumingProgressAria": "正在恢复聊天,已完成 {{value0}}/{{value1}}。点击查看详情。" }, "provider": { "extra": { @@ -17554,7 +17552,8 @@ "collapseNode": "折叠 {{value0}}", "expandNode": "展开 {{value0}}", "selectMachine": "选择 {{value0}} 上的全部聊天", - "sshHost": "SSH" + "sshHost": "SSH", + "done": "完成" }, "linear-issue-attribute-filter-coverage-notice": { "statusPartialTeamCoverage": "过滤 {{value1}} 团队状态中的 {{value0}} — 不包括其余团队的问题。", @@ -17650,6 +17649,32 @@ "hostOnly": "Cursor 登录详情仅可在运行 Orca 的电脑上读取。" } } + }, + "NativeChatResumeRunPanel": { + "resumedTitle": "已恢复 {{value0}}/{{value1}} 个聊天", + "resumedTitleOne": "已恢复 {{value0}}/1 个聊天", + "resumingTitleOne": "正在恢复 1 个聊天", + "resumingTitle": "正在恢复 {{value0}} 个聊天", + "description": "每个聊天都会带着完整上下文恢复,并被要求检查它停在了哪里。你可以关闭此窗口,状态栏会继续显示进度。" + }, + "NativeChatResumeRunStatusIcon": { + "waitingForReply": "等待回复", + "starting": "正在启动", + "waitingToStart": "等待启动", + "inFlight": "{{value0}} · {{value1}}", + "resumed": "已恢复" + }, + "NativeChatResumeRunSummary": { + "done": "已完成 {{value0}}/{{value1}}", + "starting": "{{value0}} 个正在启动", + "waitingForReply": "{{value0}} 个等待回复", + "waitingToStart": "{{value0}} 个等待启动", + "all": "全部", + "inProgress": "进行中", + "resumed": "已恢复", + "attention": "需要处理", + "running": "已运行 {{value0}}", + "filter": "显示聊天" } }, "i18n": { diff --git a/src/shared/agent-session-status-snapshot-fold.test.ts b/src/shared/agent-session-status-snapshot-fold.test.ts new file mode 100644 index 000000000000..916728490f82 --- /dev/null +++ b/src/shared/agent-session-status-snapshot-fold.test.ts @@ -0,0 +1,36 @@ +import { expect, it } from 'vitest' +import type { AgentSessionStatusSummary } from './agent-session-wire' +import { + foldAgentSessionStatusEvent, + revokeAgentSessionStatusLive +} from './agent-session-status-snapshot-fold' + +const summary: AgentSessionStatusSummary = { + sessionId: 'a', + workspaceId: 'folder', + agent: 'codex', + status: 'idle', + latestPrompt: 'Continue', + updatedAt: 1 +} + +it.each(['queued', 'starting', 'continued', 'refused', 'unconfirmed', 'skipped'] as const)( + 'folds %s progress and revokes it on contact loss, even without a provider child', + (phase) => { + const snapshot = foldAgentSessionStatusEvent(new Map(), { + type: 'snapshot', + sessions: [{ ...summary, restartResume: { phase } }] + }) + expect(snapshot.get('a')?.restartResume?.phase).toBe(phase) + const disconnected = revokeAgentSessionStatusLive(snapshot) + expect(disconnected.get('a')).toEqual(summary) + expect(revokeAgentSessionStatusLive(disconnected)).toBe(disconnected) + } +) + +it('a clearing frame removes progress rather than merging the previous optional field', () => { + const snapshot = new Map([['a', { ...summary, restartResume: { phase: 'continued' as const } }]]) + expect( + foldAgentSessionStatusEvent(snapshot, { type: 'status', session: summary }).get('a') + ).toEqual(summary) +}) diff --git a/src/shared/agent-session-status-snapshot-fold.ts b/src/shared/agent-session-status-snapshot-fold.ts index ce88e1d2d824..2c1486b087b9 100644 --- a/src/shared/agent-session-status-snapshot-fold.ts +++ b/src/shared/agent-session-status-snapshot-fold.ts @@ -35,7 +35,7 @@ export function revokeAgentSessionStatusLive( ): AgentSessionStatusSnapshot { let next: Map | null = null for (const [sessionId, summary] of snapshot) { - if (!summary.hostExecutionOwned && !summary.stopping) { + if (!summary.hostExecutionOwned && !summary.stopping && !summary.restartResume) { continue } next ??= new Map(snapshot) @@ -43,6 +43,7 @@ export function revokeAgentSessionStatusLive( hostExecutionOwned: _owned, hostExecutionPhase: _phase, stopping: _stopping, + restartResume: _restartResume, ...retained } = summary next.set(sessionId, retained) diff --git a/src/shared/agent-session-wire.ts b/src/shared/agent-session-wire.ts index e66a0c08015f..bfcd45c7cf34 100644 --- a/src/shared/agent-session-wire.ts +++ b/src/shared/agent-session-wire.ts @@ -240,6 +240,11 @@ export type AgentSessionStatusSummary = { /** With `hostExecutionOwned`: whether that child has proven its start. `starting` is a * published session whose provider has not yet answered startup; absent on older hosts. */ hostExecutionPhase?: 'starting' | 'ready' + /** This restart action's progress, derived by the live host and never persisted. + * Cleared when the action returns; absent on older hosts. */ + restartResume?: { + phase: 'queued' | 'starting' | 'continued' | 'refused' | 'unconfirmed' | 'skipped' + } latestPrompt: string /** Provider model in force for the next turn; absent until the host has read the options. */ model?: string From 79a8cff3d6d9bf711eda25cbfe68cd0b37dc68ba Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 8 Oct 2026 10:17:26 -0700 Subject: [PATCH 07/90] Fix Mermaid diagrams that stay on error after replies finish (#26480) * Fix Mermaid diagram recovery after streaming replies * Clarify when the Mermaid render queue releases tasks --- .../components/editor/MermaidBlock.test.tsx | 108 ++++++++++++++++++ .../src/components/editor/MermaidBlock.tsx | 37 +++--- .../NativeChatMarkdown.mermaid.test.tsx | 70 ++++++++++++ .../native-chat/NativeChatMarkdown.tsx | 1 + .../components/sidebar/CommentMarkdown.tsx | 10 +- .../comment-markdown-element-renderers.tsx | 7 +- 6 files changed, 211 insertions(+), 22 deletions(-) create mode 100644 src/renderer/src/components/editor/MermaidBlock.test.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatMarkdown.mermaid.test.tsx diff --git a/src/renderer/src/components/editor/MermaidBlock.test.tsx b/src/renderer/src/components/editor/MermaidBlock.test.tsx new file mode 100644 index 000000000000..af0415eb07e6 --- /dev/null +++ b/src/renderer/src/components/editor/MermaidBlock.test.tsx @@ -0,0 +1,108 @@ +// @vitest-environment happy-dom +import '@testing-library/jest-dom/vitest' +import { act, cleanup, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mermaid = vi.hoisted(() => ({ + initialize: vi.fn(), + render: vi.fn<(id: string, content: string) => Promise<{ svg: string }>>(), + sanitize: vi.fn<(svg: string) => string>() +})) +vi.mock('mermaid', () => ({ default: mermaid })) +vi.mock('dompurify', () => ({ default: { sanitize: mermaid.sanitize } })) + +import MermaidBlock from './MermaidBlock' + +const partial = 'flowchart TD\n A["Sync with main ' +const complete = `${partial}once"]` +const diagram = 'Sync with main once' + +beforeEach(() => { + mermaid.render.mockReset() + mermaid.render.mockResolvedValue({ svg: diagram }) + mermaid.sanitize.mockReset().mockImplementation((svg) => svg) +}) +afterEach(cleanup) + +describe('MermaidBlock rendering', () => { + it('replaces an invalid partial source error with the completed diagram', async () => { + mermaid.render.mockRejectedValueOnce(new Error('Parse error on line 2')) + const { container, rerender } = render() + await screen.findByText(/Diagram error: Parse error on line 2/) + expect(container.querySelector('pre')?.textContent).toBe(partial) + + rerender() + await waitFor(() => + expect(container.querySelector('svg')).toHaveTextContent('Sync with main once') + ) + expect(screen.queryByText(/Diagram error:/)).not.toBeInTheDocument() + expect(container.querySelector('pre')).toBeNull() + }) + + it.each(['success', 'failure'])( + 'ignores an obsolete render %s after content changes', + async (outcome) => { + const obsolete = Promise.withResolvers<{ svg: string }>() + const current = Promise.withResolvers<{ svg: string }>() + mermaid.render.mockReturnValueOnce(obsolete.promise).mockReturnValueOnce(current.promise) + const { container, rerender } = render() + await waitFor(() => expect(mermaid.render).toHaveBeenCalledTimes(1)) + rerender() + + await act(async () => { + if (outcome === 'success') { + obsolete.resolve({ svg: 'Obsolete' }) + } else { + obsolete.reject(new Error('Obsolete parse error')) + } + await obsolete.promise.catch(() => {}) + }) + await waitFor(() => expect(mermaid.render).toHaveBeenCalledTimes(2)) + expect(container).not.toHaveTextContent('Obsolete') + await act(async () => current.resolve({ svg: diagram })) + await waitFor(() => + expect(container.querySelector('svg')).toHaveTextContent('Sync with main once') + ) + expect(container.querySelector('.mermaid-error')).toBeNull() + } + ) + + it('sanitizes the successful SVG before displaying it', async () => { + const unsafeSvg = + 'Safe diagram' + mermaid.render.mockResolvedValue({ svg: unsafeSvg }) + mermaid.sanitize.mockReturnValueOnce('Safe diagram') + const { container } = render() + await screen.findByText('Safe diagram') + expect(mermaid.sanitize).toHaveBeenCalledWith(unsafeSvg, { USE_PROFILES: { svg: true } }) + expect(container.querySelector('script')).toBeNull() + expect(container.querySelector('svg')).not.toHaveAttribute('onload') + }) + + it('keeps new diagrams queued behind a render already in progress', async () => { + const first = Promise.withResolvers<{ svg: string }>() + const second = Promise.withResolvers<{ svg: string }>() + mermaid.render.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise) + const { rerender } = render( + <> + + + + ) + await waitFor(() => expect(mermaid.render).toHaveBeenCalledTimes(1)) + await act(async () => first.resolve({ svg: diagram })) + await waitFor(() => expect(mermaid.render).toHaveBeenCalledTimes(2)) + + rerender( + <> + + + + + ) + await act(async () => {}) + expect(mermaid.render).toHaveBeenCalledTimes(2) + await act(async () => second.resolve({ svg: diagram })) + await waitFor(() => expect(mermaid.render).toHaveBeenCalledTimes(3)) + }) +}) diff --git a/src/renderer/src/components/editor/MermaidBlock.tsx b/src/renderer/src/components/editor/MermaidBlock.tsx index f25de3983ffa..a29b64d84644 100644 --- a/src/renderer/src/components/editor/MermaidBlock.tsx +++ b/src/renderer/src/components/editor/MermaidBlock.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useId, useRef, useState } from 'react' +import React, { useEffect, useId, useState } from 'react' import type mermaidNamespace from 'mermaid' import DOMPurify from 'dompurify' import { getMermaidConfig } from './mermaid-config' @@ -29,18 +29,21 @@ type MermaidBlockProps = { // where one render can clobber another's temporary DOM node. Serializing all // render calls through a single promise chain avoids this. // -// The queue is replaced with a fresh promise after each render completes so -// that old .then() closures (which capture containerRef, content, and id) +// The queue is replaced with a fresh promise once all waiting renders complete so +// that old .then() closures (which capture content and id) // become unreachable and can be GC'd. Without this, the chain grows with // every MermaidBlock mount/unmount cycle for the lifetime of the renderer. let renderQueue: Promise = Promise.resolve() function enqueueRender(fn: () => Promise): void { - renderQueue = renderQueue.then(fn, fn).then(() => { + const tail = renderQueue.then(fn, fn).then(() => { // Why: collapse the chain back to a single resolved promise so previous // closures do not remain reachable through a growing .then() chain. - renderQueue = Promise.resolve() + if (renderQueue === tail) { + renderQueue = Promise.resolve() + } }) + renderQueue = tail } /** @@ -53,8 +56,7 @@ export default function MermaidBlock({ htmlLabels = false }: MermaidBlockProps): React.JSX.Element { const id = useId().replace(/:/g, '_') - const containerRef = useRef(null) - const [error, setError] = useState(null) + const [result, setResult] = useState<{ svg: string } | { error: string } | null>(null) useEffect(() => { let cancelled = false @@ -72,18 +74,17 @@ export default function MermaidBlock({ // broken foreignObject label path again. mermaid.initialize(getMermaidConfig(isDark, htmlLabels)) const { svg } = await mermaid.render(`mermaid-${id}`, content) - if (!cancelled && containerRef.current) { + if (!cancelled) { // Why: although mermaid uses DOMPurify internally, we add an explicit // sanitization pass as defense-in-depth against XSS in case upstream // behaviour changes or a mermaid version ships without sanitization. - containerRef.current.innerHTML = DOMPurify.sanitize(svg, { - USE_PROFILES: { svg: true } + setResult({ + svg: DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true } }) }) - setError(null) } } catch (err) { if (!cancelled) { - setError(err instanceof Error ? err.message : 'Invalid mermaid syntax') + setResult({ error: err instanceof Error ? err.message : 'Invalid mermaid syntax' }) // Mermaid leaves an error element in the DOM on failure — clean it up. const errorEl = document.getElementById(`d${`mermaid-${id}`}`) errorEl?.remove() @@ -99,11 +100,12 @@ export default function MermaidBlock({ } }, [content, htmlLabels, isDark, id]) - if (error) { + if (result && 'error' in result) { return (
    - {translate('auto.components.editor.MermaidBlock.dcc132e691', 'Diagram error:')} {error} + {translate('auto.components.editor.MermaidBlock.dcc132e691', 'Diagram error:')}{' '} + {result.error}
               {content}
    @@ -112,5 +114,10 @@ export default function MermaidBlock({
         )
       }
     
    -  return 
    + return ( +
    + ) } diff --git a/src/renderer/src/components/native-chat/NativeChatMarkdown.mermaid.test.tsx b/src/renderer/src/components/native-chat/NativeChatMarkdown.mermaid.test.tsx new file mode 100644 index 000000000000..8a9eda3b2459 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatMarkdown.mermaid.test.tsx @@ -0,0 +1,70 @@ +// @vitest-environment happy-dom +import '@testing-library/jest-dom/vitest' +import { cleanup, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mermaid = vi.hoisted(() => ({ initialize: vi.fn(), render: vi.fn() })) +vi.mock('mermaid', () => ({ default: mermaid })) +vi.mock('dompurify', () => ({ default: { sanitize: (svg: string) => svg } })) +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: { settings: { theme: string } }) => unknown) => + selector({ settings: { theme: 'light' } }) +})) + +import { NativeChatMarkdown } from './NativeChatMarkdown' +import { NativeChatCodeBlock } from './NativeChatCodeBlock' +import { TooltipProvider } from '@/components/ui/tooltip' + +const partial = 'flowchart TD\n A["Sync with main ' +const complete = `${partial}once"]` + +function reply(source: string, streaming: boolean): React.JSX.Element { + return ( + + + + ) +} + +beforeEach(() => { + mermaid.render.mockReset() + mermaid.render.mockResolvedValue({ svg: 'Sync with main once' }) +}) +afterEach(cleanup) + +describe('native chat Mermaid fences', () => { + it('shows partial source while streaming and renders the diagram when the reply finishes', async () => { + const { container, rerender } = render(reply(partial, true)) + expect(container.querySelector('[data-native-chat-code-content]')?.textContent).toContain( + partial + ) + expect(container.querySelector('[data-code-language="mermaid"]')).toBeInTheDocument() + expect(container.querySelector('.mermaid-block')).toBeNull() + expect(mermaid.render).not.toHaveBeenCalled() + + rerender(reply(complete, true)) + expect(container.querySelector('pre')?.textContent).toContain(complete) + expect(mermaid.render).not.toHaveBeenCalled() + + rerender(reply(complete, false)) + await waitFor(() => + expect(container.querySelector('svg')).toHaveTextContent('Sync with main once') + ) + expect(mermaid.render).toHaveBeenCalledWith(expect.any(String), complete) + expect(container.querySelector('pre')).toBeNull() + expect(screen.queryByText(/Diagram error:/)).not.toBeInTheDocument() + }) + + it('still shows the existing error and source for an invalid finished reply', async () => { + mermaid.render.mockRejectedValue(new Error('Invalid mermaid syntax')) + const { container } = render(reply(partial, false)) + await screen.findByText(/Diagram error: Invalid mermaid syntax/) + expect(container.querySelector('pre')?.textContent).toBe(partial.trimEnd()) + expect(container.querySelector('svg')).toBeNull() + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatMarkdown.tsx b/src/renderer/src/components/native-chat/NativeChatMarkdown.tsx index 4033b51fc620..d93b2110787e 100644 --- a/src/renderer/src/components/native-chat/NativeChatMarkdown.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMarkdown.tsx @@ -27,6 +27,7 @@ export function NativeChatMarkdown({ extension && streaming ? withoutPendingNativeChatVisualDirectiveTail(content) : content } extension={extension} + renderMermaid={!streaming} className={cn('native-chat-markdown', className)} /> ) diff --git a/src/renderer/src/components/sidebar/CommentMarkdown.tsx b/src/renderer/src/components/sidebar/CommentMarkdown.tsx index 190d93a768d0..8ea88eb578b6 100644 --- a/src/renderer/src/components/sidebar/CommentMarkdown.tsx +++ b/src/renderer/src/components/sidebar/CommentMarkdown.tsx @@ -220,6 +220,7 @@ type CommentMarkdownProps = React.ComponentPropsWithoutRef<'div'> & { linkifyFilePaths?: boolean expandImages?: boolean renderCodeBlock?: DocumentCodeBlockRenderer + renderMermaid?: boolean extension?: CommentMarkdownExtension } @@ -238,6 +239,7 @@ const CommentMarkdown = React.memo( linkifyFilePaths = false, expandImages = false, renderCodeBlock, + renderMermaid = true, extension, ...rest }, @@ -246,17 +248,17 @@ const CommentMarkdown = React.memo( const baseComponents = React.useMemo(() => { if (!onLinkClick) { return variant === 'document' - ? renderCodeBlock - ? createDocumentCommentMarkdownComponents(undefined, renderCodeBlock) + ? renderCodeBlock || !renderMermaid + ? createDocumentCommentMarkdownComponents(undefined, renderCodeBlock, renderMermaid) : documentCommentMarkdownComponents : expandImages ? createCompactCommentMarkdownComponents(undefined, true) : compactCommentMarkdownComponents } return variant === 'document' - ? createDocumentCommentMarkdownComponents(onLinkClick, renderCodeBlock) + ? createDocumentCommentMarkdownComponents(onLinkClick, renderCodeBlock, renderMermaid) : createCompactCommentMarkdownComponents(onLinkClick, expandImages) - }, [expandImages, renderCodeBlock, variant, onLinkClick]) + }, [expandImages, renderCodeBlock, renderMermaid, variant, onLinkClick]) const components = React.useMemo( () => (extension ? { ...baseComponents, ...extension.components } : baseComponents), [baseComponents, extension] diff --git a/src/renderer/src/components/sidebar/comment-markdown-element-renderers.tsx b/src/renderer/src/components/sidebar/comment-markdown-element-renderers.tsx index bfcfedfa92a9..9ca2a24271be 100644 --- a/src/renderer/src/components/sidebar/comment-markdown-element-renderers.tsx +++ b/src/renderer/src/components/sidebar/comment-markdown-element-renderers.tsx @@ -250,7 +250,8 @@ export function createCompactCommentMarkdownComponents( export function createDocumentCommentMarkdownComponents( onLinkClick?: CommentMarkdownLinkClickHandler, - renderCodeBlock?: DocumentCodeBlockRenderer + renderCodeBlock?: DocumentCodeBlockRenderer, + renderMermaid = true ): Components { return { p: ({ children }) =>

    {children}

    , @@ -272,7 +273,7 @@ export function createDocumentCommentMarkdownComponents( ), code: ({ className, children }) => - isMermaidFence(className) ? ( + renderMermaid && isMermaidFence(className) ? ( renderMermaidFence( children, 'my-3 min-w-0 max-w-full overflow-x-auto rounded-md border border-border/60 p-3 [&_.mermaid-block]:min-w-0 [&_.mermaid-block_pre]:my-0 [&_.mermaid-block_pre]:max-h-80 [&_.mermaid-block_pre]:max-w-full [&_.mermaid-block_pre]:overflow-x-auto [&_.mermaid-block_pre]:rounded-md [&_.mermaid-block_pre]:bg-accent [&_.mermaid-block_pre]:p-3 [&_.mermaid-block_pre]:font-mono [&_.mermaid-block_pre]:text-[12px]' @@ -284,7 +285,7 @@ export function createDocumentCommentMarkdownComponents( ), // Mermaid fences render a
    , which is invalid inside
    , so unwrap them.
         pre: ({ children }) =>
    -      isMermaidPre(children) ? (
    +      renderMermaid && isMermaidPre(children) ? (
             <>{children}
           ) : renderCodeBlock ? (
             renderCodeBlock({ children, language: extractCodeFenceLanguage(children) })
    
    From cc291dba1dcf62be1dd9473efdcc863073d23c39 Mon Sep 17 00:00:00 2001
    From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
    Date: Thu, 8 Oct 2026 10:30:49 -0700
    Subject: [PATCH 08/90] Keep picked skills and pending uploads when native chat
     composers close (#26538)
    
    * Preserve native chat skills and uploads when composers close
    
    * Own menu image pastes before saving begins
    
    * Keep paste ownership independent of image presentation
    
    * Show pending pastes so stalled saves can be removed
    
    * Register confirmed image pastes and preserve preview ownership
    
    * Keep clipboard image presence and saving on one source selector
    
    * Describe clipboard image formats in upload routing fixtures
    ---
     ...ard-agent-session-attachment-paste.test.ts |   6 +-
     .../window/clipboard-image-source.test.ts     |  72 +++++
     src/main/window/clipboard-image-source.ts     |  33 +++
     .../window/clipboard-ipc-handlers.test.ts     | 131 ++++-----
     src/main/window/clipboard-ipc-handlers.ts     |  24 +-
     .../clipboard-runtime-owned-ssh-paste.test.ts |   3 +-
     .../window/clipboard-windows-image-file.ts    |  13 +-
     .../NativeChatComposer.paste-remount.test.tsx | 229 +++++++++++++--
     .../native-chat/NativeChatComposer.tsx        |   1 +
     .../native-chat/NativeChatComposerField.tsx   |   7 +-
     ...ative-chat-composer-draft-addition.test.ts |  41 +++
     .../native-chat-composer-draft-addition.ts    |   5 +-
     .../native-chat-composer-draft-journal.ts     |   1 -
     .../native-chat-composer-draft-store.ts       |   6 +-
     .../native-chat-composer-paste-options.ts     |  23 ++
     ...e-chat-composer-upload-completion.test.tsx | 230 +++++++++++++++
     .../native-chat/native-chat-draft-cache.ts    |   9 +-
     ...tive-chat-pending-attachment-cache.test.ts |  40 +--
     .../native-chat-pending-attachment-cache.ts   |  40 +--
     .../native-chat-prompt-document-text.test.ts  |  66 +++++
     .../native-chat-prompt-document-text.ts       |  59 ++++
     .../native-chat-prompt-document.ts            |  13 +-
     .../native-chat-session-attachment-drop.ts    |   8 +-
     ...-native-chat-composer-attachments.test.tsx |  40 ++-
     .../use-native-chat-composer-attachments.ts   |  89 +++---
     ...-native-chat-composer-image-input.test.tsx |   6 +-
     ...omposer-paste-session-attachments.test.tsx |  17 +-
     .../use-native-chat-composer-paste.test.tsx   |  36 ++-
     .../use-native-chat-composer-paste.ts         | 114 ++++----
     ...-native-chat-external-attachments.test.tsx |  13 +-
     ...e-native-chat-menu-paste-lifetime.test.tsx | 273 ++++++++++++++++++
     .../use-native-chat-paste-lifetime.test.tsx   | 141 +++++++++
     .../use-native-chat-paste-lifetime.ts         |  87 ++++--
     33 files changed, 1525 insertions(+), 351 deletions(-)
     create mode 100644 src/main/window/clipboard-image-source.test.ts
     create mode 100644 src/main/window/clipboard-image-source.ts
     create mode 100644 src/renderer/src/components/native-chat/native-chat-composer-paste-options.ts
     create mode 100644 src/renderer/src/components/native-chat/native-chat-composer-upload-completion.test.tsx
     create mode 100644 src/renderer/src/components/native-chat/native-chat-prompt-document-text.test.ts
     create mode 100644 src/renderer/src/components/native-chat/native-chat-prompt-document-text.ts
     create mode 100644 src/renderer/src/components/native-chat/use-native-chat-menu-paste-lifetime.test.tsx
     create mode 100644 src/renderer/src/components/native-chat/use-native-chat-paste-lifetime.test.tsx
    
    diff --git a/src/main/window/clipboard-agent-session-attachment-paste.test.ts b/src/main/window/clipboard-agent-session-attachment-paste.test.ts
    index 8db77235abf8..ba32e82c3b91 100644
    --- a/src/main/window/clipboard-agent-session-attachment-paste.test.ts
    +++ b/src/main/window/clipboard-agent-session-attachment-paste.test.ts
    @@ -10,7 +10,11 @@ const mocks = vi.hoisted(() => ({
     
     vi.mock('electron', () => ({
       app: { getPath: vi.fn(() => '/tmp/client-user-data') },
    -  clipboard: { readImage: mocks.readImage },
    +  clipboard: {
    +    readImage: mocks.readImage,
    +    availableFormats: () => ['image/png'],
    +    readBuffer: () => Buffer.alloc(0)
    +  },
       ipcMain: { removeHandler: vi.fn(), handle: mocks.handle },
       nativeImage: { createFromBuffer: vi.fn() }
     }))
    diff --git a/src/main/window/clipboard-image-source.test.ts b/src/main/window/clipboard-image-source.test.ts
    new file mode 100644
    index 000000000000..cebd03f19590
    --- /dev/null
    +++ b/src/main/window/clipboard-image-source.test.ts
    @@ -0,0 +1,72 @@
    +import { describe, expect, it, vi } from 'vitest'
    +import { readClipboardImageSource } from './clipboard-image-source'
    +
    +function clipboardReader(filePath = '', itemCount = 1, formats: string[] = ['FileNameW']) {
    +  const shellItems = Buffer.alloc(4 + 4 * (itemCount + 1))
    +  shellItems.writeUInt32LE(itemCount)
    +  const buffers: Record = {
    +    FileNameW: Buffer.from(`${filePath}\0`, 'utf16le'),
    +    'Shell IDList Array': shellItems
    +  }
    +  return {
    +    availableFormats: vi.fn(() => formats),
    +    readBuffer: vi.fn((format: string) => buffers[format] ?? Buffer.alloc(0))
    +  }
    +}
    +
    +describe('readClipboardImageSource', () => {
    +  it.each(['darwin', 'linux', 'win32'] as const)(
    +    'recognizes native image formats on %s without decoding',
    +    (platform) => {
    +      const clipboard = clipboardReader('', 1, ['text/plain', 'image/png'])
    +      expect(readClipboardImageSource(clipboard, platform)).toEqual({
    +        kind: 'native',
    +        windowsFileFormats: null
    +      })
    +    }
    +  )
    +
    +  it.each(['darwin', 'linux', 'win32'] as const)('ignores ordinary text on %s', (platform) => {
    +    expect(readClipboardImageSource(clipboardReader('', 1, ['text/plain']), platform)).toBeNull()
    +  })
    +
    +  it.each([
    +    'C:\\Users\\alice\\图片\\shot.PNG',
    +    '\\\\server\\share\\shot.jpeg',
    +    '\\\\?\\C:\\Users\\alice\\shot.jpg',
    +    '\\\\?\\UNC\\server\\share\\shot.png'
    +  ])('recognizes a supported single Windows image file: %s', (filePath) => {
    +    const clipboard = clipboardReader(filePath)
    +    const source = readClipboardImageSource(clipboard, 'win32')
    +    expect(source?.kind).toBe('windows-file')
    +    expect(source?.windowsFileFormats).toEqual({
    +      fileNameW: clipboard.readBuffer('FileNameW'),
    +      shellIdListArray: clipboard.readBuffer('Shell IDList Array')
    +    })
    +  })
    +
    +  it.each([
    +    ['C:\\shot.pdf', 1],
    +    ['C:\\shot.webp', 1],
    +    ['C:\\shot.png', 2],
    +    ['shot.png', 1],
    +    ['C:shot.png', 1],
    +    ['\\\\server\\pipe\\shot.png', 1],
    +    ['C:\\one.png\0C:\\two.png', 1]
    +  ])('rejects an unsupported file source: %s (%s items)', (filePath, itemCount) => {
    +    expect(readClipboardImageSource(clipboardReader(filePath, itemCount), 'win32')).toBeNull()
    +  })
    +
    +  it.each(['darwin', 'linux'] as const)('never reads Windows file formats on %s', (platform) => {
    +    const clipboard = clipboardReader('C:\\shot.png')
    +    expect(readClipboardImageSource(clipboard, platform)).toBeNull()
    +    expect(clipboard.readBuffer).not.toHaveBeenCalled()
    +  })
    +
    +  it('keeps the copied-file fallback beside advertised native image data', () => {
    +    const clipboard = clipboardReader('C:\\shot.png', 1, ['image/png', 'FileNameW'])
    +    const source = readClipboardImageSource(clipboard, 'win32')
    +    expect(source?.kind).toBe('native')
    +    expect(source?.windowsFileFormats?.fileNameW).toEqual(clipboard.readBuffer('FileNameW'))
    +  })
    +})
    diff --git a/src/main/window/clipboard-image-source.ts b/src/main/window/clipboard-image-source.ts
    new file mode 100644
    index 000000000000..f5dc72a02477
    --- /dev/null
    +++ b/src/main/window/clipboard-image-source.ts
    @@ -0,0 +1,33 @@
    +import { clipboardFormatsIncludeImage } from '../../shared/clipboard-image'
    +import {
    +  readWindowsCopiedImageFilePath,
    +  type WindowsClipboardFileFormats
    +} from './clipboard-windows-image-file'
    +
    +type ClipboardImageReader = {
    +  availableFormats: () => string[]
    +  readBuffer: (format: string) => Buffer
    +}
    +
    +type ClipboardImageSource =
    +  | { kind: 'native'; windowsFileFormats: WindowsClipboardFileFormats | null }
    +  | { kind: 'windows-file'; windowsFileFormats: WindowsClipboardFileFormats }
    +
    +/** Select image sources without decoding pixels or reading a copied file. */
    +export function readClipboardImageSource(
    +  clipboard: ClipboardImageReader,
    +  platform: NodeJS.Platform = process.platform
    +): ClipboardImageSource | null {
    +  const formats =
    +    platform === 'win32'
    +      ? {
    +          fileNameW: clipboard.readBuffer('FileNameW'),
    +          shellIdListArray: clipboard.readBuffer('Shell IDList Array')
    +        }
    +      : null
    +  const windowsFileFormats = formats && readWindowsCopiedImageFilePath(formats) ? formats : null
    +  if (clipboardFormatsIncludeImage(clipboard.availableFormats())) {
    +    return { kind: 'native', windowsFileFormats }
    +  }
    +  return windowsFileFormats ? { kind: 'windows-file', windowsFileFormats } : null
    +}
    diff --git a/src/main/window/clipboard-ipc-handlers.test.ts b/src/main/window/clipboard-ipc-handlers.test.ts
    index 2d283a50de2e..dc7168f7d65a 100644
    --- a/src/main/window/clipboard-ipc-handlers.test.ts
    +++ b/src/main/window/clipboard-ipc-handlers.test.ts
    @@ -226,6 +226,7 @@ describe('registerClipboardHandlers', () => {
         clipboardReadBufferMock.mockReset()
         clipboardReadBufferMock.mockReturnValue(Buffer.alloc(0))
         clipboardWriteTextMock.mockReset()
    +    clipboardAvailableFormatsMock.mockReturnValue(['image/png'])
         clipboardReadImageMock.mockReset()
         clipboardWriteImageMock.mockReset()
         clipboardWriteBufferMock.mockReset()
    @@ -571,62 +572,72 @@ describe('registerClipboardHandlers', () => {
         }
       })
     
    -  it('routes a Windows Explorer FileNameW image through the target-aware attachment flow', async () => {
    -    const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
    -    const sourcePath = 'C:\\Users\\alice\\图片\\copied-image.png'
    -    const png = Buffer.from([4, 3, 2, 1])
    -    clipboardReadImageMock.mockReturnValue({ isEmpty: () => true })
    -    clipboardReadBufferMock.mockImplementation((format: string) =>
    -      format === 'FileNameW' ? Buffer.from(`${sourcePath}\0`, 'utf16le') : shellIdListArray(1)
    -    )
    -    const source = Buffer.alloc(24)
    -    Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(source)
    -    source.writeUInt32BE(13, 8)
    -    source.write('IHDR', 12, 'ascii')
    -    source.writeUInt32BE(1, 16)
    -    source.writeUInt32BE(1, 20)
    -    const close = vi.fn().mockResolvedValue(undefined)
    -    fsOpenMock.mockResolvedValue({
    -      close,
    -      stat: vi.fn().mockResolvedValue({ isFile: () => true, size: source.byteLength }),
    -      read: vi.fn(async (buffer: Buffer, offset: number, length: number, position: number) => {
    -        const bytesRead = Math.min(Math.max(source.byteLength - position, 0), length)
    -        source.copy(buffer, offset, position, position + bytesRead)
    -        return { buffer, bytesRead }
    -      })
    -    })
    -    nativeImageCreateFromBufferMock.mockReturnValue({
    -      getSize: () => ({ height: 1, width: 1 }),
    -      isEmpty: () => false,
    -      toPNG: () => png
    -    })
    -    const writeFileBase64 = vi.fn().mockResolvedValue(undefined)
    -    getSshFilesystemProviderMock.mockReturnValue({
    -      getTempDir: vi.fn().mockResolvedValue('/var/tmp'),
    -      writeFileBase64
    -    })
    -
    -    try {
    -      registerClipboardHandlers({} as never)
    -
    -      const handler = getRegisteredHandlers().get('clipboard:saveImageAsTempFile')
    -      await expect(handler?.(makeClipboardEvent(), { connectionId: 'ssh-1' })).resolves.toBe(
    -        '/var/tmp/orca-paste-1760000000000-00000000-0000-4000-8000-000000000000.png'
    +  it.each([false, true])(
    +    'routes a Windows Explorer image with native format advertised: %s',
    +    async (advertisesImage) => {
    +      setTrustedClipboardRendererWebContentsId(17)
    +      const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
    +      clipboardAvailableFormatsMock.mockReturnValue(
    +        advertisesImage ? ['image/png', 'FileNameW'] : ['FileNameW']
           )
    -      expect(clipboardReadBufferMock).toHaveBeenCalledWith('FileNameW')
    -      expect(clipboardReadBufferMock).toHaveBeenCalledWith('Shell IDList Array')
    -      expect(fsOpenMock).toHaveBeenCalledWith(sourcePath, 'r')
    -      expect(nativeImageCreateFromBufferMock).toHaveBeenCalledWith(source)
    -      expect(close).toHaveBeenCalled()
    -      expect(writeFileBase64).toHaveBeenCalledWith(
    -        '/var/tmp/orca-paste-1760000000000-00000000-0000-4000-8000-000000000000.png',
    -        png.toString('base64')
    +      const sourcePath = 'C:\\Users\\alice\\图片\\copied-image.png'
    +      const png = Buffer.from([4, 3, 2, 1])
    +      clipboardReadImageMock.mockReturnValue({ isEmpty: () => true })
    +      clipboardReadBufferMock.mockImplementation((format: string) =>
    +        format === 'FileNameW' ? Buffer.from(`${sourcePath}\0`, 'utf16le') : shellIdListArray(1)
           )
    -      expect(fsWriteFileMock).not.toHaveBeenCalled()
    -    } finally {
    -      platformSpy.mockRestore()
    +      const source = Buffer.alloc(24)
    +      Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(source)
    +      source.writeUInt32BE(13, 8)
    +      source.write('IHDR', 12, 'ascii')
    +      source.writeUInt32BE(1, 16)
    +      source.writeUInt32BE(1, 20)
    +      const close = vi.fn().mockResolvedValue(undefined)
    +      fsOpenMock.mockResolvedValue({
    +        close,
    +        stat: vi.fn().mockResolvedValue({ isFile: () => true, size: source.byteLength }),
    +        read: vi.fn(async (buffer: Buffer, offset: number, length: number, position: number) => {
    +          const bytesRead = Math.min(Math.max(source.byteLength - position, 0), length)
    +          source.copy(buffer, offset, position, position + bytesRead)
    +          return { buffer, bytesRead }
    +        })
    +      })
    +      nativeImageCreateFromBufferMock.mockReturnValue({
    +        getSize: () => ({ height: 1, width: 1 }),
    +        isEmpty: () => false,
    +        toPNG: () => png
    +      })
    +      const writeFileBase64 = vi.fn().mockResolvedValue(undefined)
    +      getSshFilesystemProviderMock.mockReturnValue({
    +        getTempDir: vi.fn().mockResolvedValue('/var/tmp'),
    +        writeFileBase64
    +      })
    +
    +      try {
    +        registerClipboardHandlers({} as never)
    +
    +        const probe = getRegisteredHandlers().get('clipboard:hasImage')
    +        expect(probe?.(makeClipboardEvent())).toBe(true)
    +        expect(() => probe?.(makeClipboardEvent({ id: 42 }))).toThrow()
    +        const handler = getRegisteredHandlers().get('clipboard:saveImageAsTempFile')
    +        await expect(handler?.(makeClipboardEvent(), { connectionId: 'ssh-1' })).resolves.toBe(
    +          '/var/tmp/orca-paste-1760000000000-00000000-0000-4000-8000-000000000000.png'
    +        )
    +        expect(clipboardReadBufferMock).toHaveBeenCalledWith('FileNameW')
    +        expect(clipboardReadBufferMock).toHaveBeenCalledWith('Shell IDList Array')
    +        expect(fsOpenMock).toHaveBeenCalledWith(sourcePath, 'r')
    +        expect(nativeImageCreateFromBufferMock).toHaveBeenCalledWith(source)
    +        expect(close).toHaveBeenCalled()
    +        expect(writeFileBase64).toHaveBeenCalledWith(
    +          '/var/tmp/orca-paste-1760000000000-00000000-0000-4000-8000-000000000000.png',
    +          png.toString('base64')
    +        )
    +        expect(fsWriteFileMock).not.toHaveBeenCalled()
    +      } finally {
    +        platformSpy.mockRestore()
    +      }
         }
    -  })
    +  )
     
       it('saves clipboard images through the selected remote runtime host', async () => {
         const png = Buffer.alloc(512 * 1024)
    @@ -874,18 +885,4 @@ describe('registerClipboardHandlers', () => {
         expect(nativeImageCreateFromBufferMock).toHaveBeenCalled()
         expect(clipboardWriteImageMock).not.toHaveBeenCalled()
       })
    -
    -  it.each([
    -    [['text/plain'], false],
    -    [['text/plain', 'image/png'], true]
    -  ])('reports image presence for %j from the format list without decoding', (formats, expected) => {
    -    setTrustedClipboardRendererWebContentsId(17)
    -    clipboardAvailableFormatsMock.mockReturnValue(formats)
    -    // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: registering handlers never reads the store for clipboard image presence.
    -    registerClipboardHandlers({} as never)
    -    const probe = getRegisteredHandlers().get('clipboard:hasImage')
    -    expect(probe?.(makeClipboardEvent())).toBe(expected)
    -    expect(clipboardReadImageMock).not.toHaveBeenCalled()
    -    expect(() => probe?.(makeClipboardEvent({ id: 42 }))).toThrow()
    -  })
     })
    diff --git a/src/main/window/clipboard-ipc-handlers.ts b/src/main/window/clipboard-ipc-handlers.ts
    index 05a09a54bb81..80dad3bd02b4 100644
    --- a/src/main/window/clipboard-ipc-handlers.ts
    +++ b/src/main/window/clipboard-ipc-handlers.ts
    @@ -25,7 +25,6 @@ import {
       assertClipboardImageBase64LengthWithinLimit,
       assertClipboardImageByteLengthWithinLimit,
       assertClipboardImageDimensionsWithinLimit,
    -  clipboardFormatsIncludeImage,
       type ClipboardImageThumbnail
     } from '../../shared/clipboard-image'
     import {
    @@ -41,6 +40,7 @@ import {
     import { saveClipboardImageBufferInRuntime } from './clipboard-runtime-image-upload'
     import { uploadPastedImageToAgentSessionAttachments } from '../ipc/agent-session-attachment-upload'
     import { readWindowsClipboardImageFileAsPng } from './clipboard-windows-image-file'
    +import { readClipboardImageSource } from './clipboard-image-source'
     import { readClipboardCopiedFilePaths } from './clipboard-copied-file-paths'
     import { buildClipboardImageThumbnail } from './clipboard-image-thumbnail'
     import { writeClipboardTextAndVerify } from './clipboard-text-write-verify'
    @@ -141,7 +141,7 @@ export function registerClipboardHandlers(store: Store): void {
       })
       ipcMain.handle('clipboard:hasImage', (event): boolean => {
         assertTrustedClipboardSender(event)
    -    return clipboardFormatsIncludeImage(clipboard.availableFormats())
    +    return readClipboardImageSource(clipboard) !== null
       })
       // Why: a file-manager copy also carries the files' names as text, which a paste must not type.
       ipcMain.handle('clipboard:readFilePaths', (event): string[] => {
    @@ -155,21 +155,19 @@ export function registerClipboardHandlers(store: Store): void {
         'clipboard:saveImageAsTempFile',
         async (event, args?: SaveClipboardImageAsTempFileArgs) => {
           assertTrustedClipboardSender(event)
    +      const source = readClipboardImageSource(clipboard)
    +      if (!source) {
    +        return null
    +      }
           const image = clipboard.readImage()
           if (image.isEmpty()) {
    -        if (process.platform !== 'win32') {
    +        if (!source.windowsFileFormats) {
               return null
             }
    -        const copiedFilePng = await readWindowsClipboardImageFileAsPng(
    -          {
    -            fileNameW: clipboard.readBuffer('FileNameW'),
    -            shellIdListArray: clipboard.readBuffer('Shell IDList Array')
    -          },
    -          {
    -            createImageFromBuffer: (buffer) => nativeImage.createFromBuffer(buffer),
    -            openFile: (filePath) => open(filePath, 'r')
    -          }
    -        )
    +        const copiedFilePng = await readWindowsClipboardImageFileAsPng(source.windowsFileFormats, {
    +          createImageFromBuffer: (buffer) => nativeImage.createFromBuffer(buffer),
    +          openFile: (filePath) => open(filePath, 'r')
    +        })
             return copiedFilePng ? saveClipboardImageBufferForTarget(copiedFilePng, args) : null
           }
           assertClipboardImageDimensionsWithinLimit(image.getSize())
    diff --git a/src/main/window/clipboard-runtime-owned-ssh-paste.test.ts b/src/main/window/clipboard-runtime-owned-ssh-paste.test.ts
    index 1bc2915508f3..e2844455c38c 100644
    --- a/src/main/window/clipboard-runtime-owned-ssh-paste.test.ts
    +++ b/src/main/window/clipboard-runtime-owned-ssh-paste.test.ts
    @@ -15,13 +15,14 @@ const PNG = Buffer.from([0, 1, 2, 3])
     vi.mock('electron', () => ({
       app: { getPath: vi.fn(() => '/tmp') },
       clipboard: {
    +    availableFormats: () => ['image/png'],
         readImage: () => ({
           getSize: () => ({ height: 1, width: 1 }),
           isEmpty: () => false,
           toPNG: () => PNG
         }),
         readText: vi.fn(),
    -    readBuffer: vi.fn(),
    +    readBuffer: vi.fn(() => Buffer.alloc(0)),
         writeText: vi.fn(),
         writeImage: vi.fn(),
         writeBuffer: vi.fn()
    diff --git a/src/main/window/clipboard-windows-image-file.ts b/src/main/window/clipboard-windows-image-file.ts
    index 39099332958e..17baa1008466 100644
    --- a/src/main/window/clipboard-windows-image-file.ts
    +++ b/src/main/window/clipboard-windows-image-file.ts
    @@ -84,6 +84,15 @@ export function readWindowsCopiedFilePath({
       return hasAtMostOneShellItem(shellIdListArray) ? decodeFileNameW(fileNameW) : null
     }
     
    +export function readWindowsCopiedImageFilePath(
    +  formats: WindowsClipboardFileFormats
    +): string | null {
    +  const filePath = readWindowsCopiedFilePath(formats)
    +  return filePath && IMAGE_FILE_EXTENSION_SET.has(win32.extname(filePath).toLowerCase())
    +    ? filePath
    +    : null
    +}
    +
     function readPngDimensions(source: Buffer): { height: number; width: number } | null {
       if (
         source.byteLength < 24 ||
    @@ -164,8 +173,8 @@ export async function readWindowsClipboardImageFileAsPng(
       formats: WindowsClipboardFileFormats,
       { createImageFromBuffer, openFile }: WindowsClipboardImageFileDeps
     ): Promise {
    -  const filePath = readWindowsCopiedFilePath(formats)
    -  if (!filePath || !IMAGE_FILE_EXTENSION_SET.has(win32.extname(filePath).toLowerCase())) {
    +  const filePath = readWindowsCopiedImageFilePath(formats)
    +  if (!filePath) {
         return null
       }
     
    diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.paste-remount.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.paste-remount.test.tsx
    index f5509b5b10df..327397db8b0b 100644
    --- a/src/renderer/src/components/native-chat/NativeChatComposer.paste-remount.test.tsx
    +++ b/src/renderer/src/components/native-chat/NativeChatComposer.paste-remount.test.tsx
    @@ -4,23 +4,41 @@
     // replaces the composer. The real composer, attachment and paste hooks keep the upload, and Send
     // waits for it, whenever the composer comes back; a rich-text paste's image is owed from the start.
     
    -import { act, cleanup, render } from '@testing-library/react'
    +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
     import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
    -import type { ClipboardEventLike } from './native-chat-clipboard-payload'
    +import { createRef, type RefObject } from 'react'
    +import type { NativeChatComposerHandle } from './native-chat-composer-types'
    +import type { NativeChatAttachmentOwner } from './native-chat-attachment-upload'
    +import type {
    +  NativeChatComposerField as NativeChatComposerFieldComponent,
    +  NativeChatComposerFieldProps,
    +  NativeChatComposerImageAttachment
    +} from './NativeChatComposerField'
     
     type FieldProps = {
    -  onPaste?: (event: ClipboardEventLike) => void
    -  imageAttachments?: { path: string; pending?: boolean }[]
    +  imageAttachments?: readonly NativeChatComposerImageAttachment[]
       sendButtonDisabled?: boolean
    +  onRemoveImageAttachment: (id: string) => void
     }
     
     const mocks = vi.hoisted(() => {
    -  const state: { fieldProps: FieldProps | null } = { fieldProps: null }
    +  const state: { fieldProps: FieldProps | null; owner: NativeChatAttachmentOwner } = {
    +    fieldProps: null,
    +    owner: {
    +      kind: 'runtime-session',
    +      environmentId: 'env-1',
    +      pairingRevision: 1,
    +      sessionId: 'session-1'
    +    }
    +  }
       return { state, prepare: vi.fn(), save: vi.fn() }
     })
     
     vi.mock('../../store', () => {
       const state = {
    +    tabsByWorktree: {},
    +    worktrees: [],
    +    repos: [],
         dictationState: 'idle',
         settings: { voice: { enabled: false }, nativeChatSessionOptions: {} },
         agentStatusByPaneKey: {},
    @@ -43,24 +61,25 @@ vi.mock('@/lib/native-chat-telemetry', () => ({
       emitNativeChatPickerOpened: vi.fn(),
       emitNativeChatSendClassified: vi.fn()
     }))
    -vi.mock('./NativeChatComposerField', () => ({
    -  NativeChatComposerField: (props: FieldProps) => {
    -    mocks.state.fieldProps = props
    -    return 
    +vi.mock('./NativeChatComposerField', async (importOriginal) => { + const original = await importOriginal<{ + NativeChatComposerField: typeof NativeChatComposerFieldComponent + }>() + return { + NativeChatComposerField: (props: NativeChatComposerFieldProps) => { + mocks.state.fieldProps = props + return + } } -})) +}) +vi.mock('./NativeChatComposerActions', () => ({ NativeChatComposerActions: () => null })) vi.mock('./use-native-chat-skills', () => ({ useNativeChatSkills: () => ({ status: 'ready', skills: [], error: null, retry: () => {} }) })) vi.mock('./use-native-chat-external-attachments', () => ({ useNativeChatExternalAttachments: () => ({ attachExternalPaths: vi.fn(), - resolveAttachmentOwner: () => ({ - kind: 'runtime-session', - environmentId: 'env-1', - pairingRevision: 1, - sessionId: 'session-1' - }) + resolveAttachmentOwner: () => mocks.state.owner }) })) vi.mock('./native-chat-attachment-upload', async (importOriginal) => ({ @@ -71,6 +90,7 @@ vi.mock('./native-chat-attachment-upload', async (importOriginal) => ({ import { NativeChatComposer } from './NativeChatComposer' import { clearNativeChatDraftCacheForTests, + readNativeChatDraftCache, writeNativeChatDraftCache } from './native-chat-draft-cache' import { @@ -81,9 +101,13 @@ import { const PANE = 'tab-1::session-1' const STORED = '/srv/agent-session-attachments/0b6f8a52-4a3e-4c4e-9a59-1d5d1f2b8c01/orca-paste.png' -function composer(): React.JSX.Element { +function composer( + acceptsImages = true, + composerRef?: RefObject +): React.JSX.Element { return ( ): void { }) } -function imagePaste(text?: string): ClipboardEventLike { +function imagePaste(text?: string): ClipboardEvent { const data = new DataTransfer() data.items.add(new File(['image'], 'image.png', { type: 'image/png' })) if (text) { @@ -147,6 +172,12 @@ beforeEach(() => { clearNativeChatAttachmentCacheForTests() clearNativeChatDraftCacheForTests() mocks.state.fieldProps = null + mocks.state.owner = { + kind: 'runtime-session', + environmentId: 'env-1', + pairingRevision: 1, + sessionId: 'session-1' + } mocks.prepare.mockResolvedValue({ ok: true, target: { @@ -163,7 +194,9 @@ beforeEach(() => { }) afterEach(() => { + vi.useRealTimers() cleanup() + vi.restoreAllMocks() vi.unstubAllGlobals() }) @@ -177,7 +210,7 @@ describe('a paste into a chat on a paired server', () => { ) installPreloadApi({ saveClipboardImageAsTempFile: mocks.save }) const view = render(composer()) - await act(async () => mocks.state.fieldProps?.onPaste?.(imagePaste())) + await act(async () => fireEvent(screen.getByRole('textbox'), imagePaste())) expect(mocks.state.fieldProps?.imageAttachments).toMatchObject([{ pending: true }]) // The agent asks a question: its card takes the composer's place while the image uploads. @@ -196,7 +229,7 @@ describe('a paste into a chat on a paired server', () => { // Something typed, so only the pending image can hold Send. writeNativeChatDraftCache(PANE, 'look at this') const first = render(composer()) - await act(async () => mocks.state.fieldProps?.onPaste?.(imagePaste())) + await act(async () => fireEvent(screen.getByRole('textbox'), imagePaste())) first.unmount() // Back from the prompt card while the image is still on its way to the server. @@ -217,7 +250,7 @@ describe('a paste into a chat on a paired server', () => { writeNativeChatDraftCache(PANE, 'look at this caption') render(composer()) expect(mocks.state.fieldProps?.sendButtonDisabled).toBe(false) - await act(async () => mocks.state.fieldProps?.onPaste?.(imagePaste('caption'))) + await act(async () => fireEvent(screen.getByRole('textbox'), imagePaste('caption'))) // The text is in; the image is not yet anywhere, but it is owed to this message. expect(mocks.state.fieldProps?.sendButtonDisabled).toBe(true) @@ -229,7 +262,7 @@ describe('a paste into a chat on a paired server', () => { const finishUpload = holdUpload() writeNativeChatDraftCache(PANE, 'look at this caption') const first = render(composer()) - await act(async () => mocks.state.fieldProps?.onPaste?.(imagePaste('caption'))) + await act(async () => fireEvent(screen.getByRole('textbox'), imagePaste('caption'))) // A prompt card replaces the composer while the server is still being asked. first.unmount() render(composer()) @@ -254,3 +287,155 @@ describe('a paste into a chat on a paired server', () => { expect(mocks.state.fieldProps?.imageAttachments).toMatchObject([{ path: STORED }]) }) }) + +it('keeps a local image paste after the real composer closes and reopens', async () => { + mocks.state.owner = { kind: 'local' } + mocks.save.mockClear() + const finishUpload = holdUpload() + const first = render(composer()) + await act(async () => fireEvent(screen.getByRole('textbox'), imagePaste())) + await waitFor(() => expect(mocks.save).toHaveBeenCalled()) + expect(mocks.state.fieldProps?.imageAttachments).toMatchObject([{ pending: true }]) + first.unmount() + await act(async () => finishUpload('/local/native-chat-pastes/orca-paste-1-image.png')) + render(composer()) + expect(mocks.state.fieldProps?.imageAttachments).toMatchObject([ + { path: '/local/native-chat-pastes/orca-paste-1-image.png' } + ]) + await waitFor(() => expect(mocks.state.fieldProps?.imageAttachments?.[0]?.pending).toBeFalsy()) +}) + +it.each([ + { kind: 'local', acceptsImages: false }, + { kind: 'ssh', acceptsImages: false }, + { kind: 'local', acceptsImages: true }, + { kind: 'ssh', acceptsImages: true } +] as const)( + 'can remove a stalled $kind paste after reopen with acceptsImages=$acceptsImages', + async ({ kind, acceptsImages }) => { + mocks.state.owner = + kind === 'local' + ? { kind: 'local' } + : { + kind: 'ssh', + connectionId: 'ssh-1', + worktreePath: '/remote/folder', + expectedExecutionHostId: 'ssh:ssh-1', + expectedSshTargetId: 'ssh-1', + expectedSshConnectionGeneration: 1 + } + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + const finishUpload = holdUpload() + writeNativeChatDraftCache(PANE, 'look at this') + const first = render(composer(acceptsImages)) + expect(mocks.state.fieldProps?.sendButtonDisabled).toBe(false) + await act(async () => fireEvent(screen.getByRole('textbox'), imagePaste())) + first.unmount() + render(composer(acceptsImages)) + await act(async () => vi.advanceTimersByTimeAsync(24 * 60 * 60 * 1000)) + expect(mocks.state.fieldProps?.sendButtonDisabled).toBe(true) + expect(screen.getByRole('button', { name: 'Saving pasted image…' })).toBeTruthy() + await act(async () => + fireEvent.click(screen.getByRole('button', { name: 'Remove attachment' })) + ) + expect(mocks.state.fieldProps?.sendButtonDisabled).toBe(false) + await act(async () => + finishUpload( + kind === 'local' + ? '/local/native-chat-pastes/orca-paste-1-image.png' + : '/tmp/orca-paste-1-image.png' + ) + ) + expect(readNativeChatAttachmentCache(PANE)).toEqual([]) + expect(readNativeChatDraftCache(PANE)).toBe('look at this') + expect(mocks.state.fieldProps?.imageAttachments).toEqual([]) + } +) + +it.each([true, false])( + 'pastes ordinary menu text without image feedback or holding Send, acceptsImages=%s', + async (acceptsImages) => { + mocks.state.owner = { kind: 'local' } + mocks.save.mockClear() + mocks.save.mockReturnValue(new Promise(() => {})) + installPreloadApi({ + saveClipboardImageAsTempFile: mocks.save, + readClipboardText: async () => 'ordinary text', + readClipboardFilePaths: async () => [], + readClipboardImageThumbnail: async () => null, + clipboardHasImage: async () => false + }) + const handle = createRef() + writeNativeChatDraftCache(PANE, 'look at this') + render(composer(acceptsImages, handle)) + await act(async () => handle.current?.pasteFromClipboard()) + expect(screen.getByRole('textbox').textContent).toContain('ordinary text') + expect(screen.queryByRole('button', { name: 'Saving pasted image…' })).toBeNull() + expect(mocks.state.fieldProps?.sendButtonDisabled).toBe(false) + expect(mocks.save).not.toHaveBeenCalled() + } +) + +it.each([true, false])( + 'releases all previews when a paired paste is removed before acceptance, acceptsImages=%s', + async (acceptsImages) => { + const prepare = Promise.withResolvers<{ + ok: true + target: { + environmentId: string + sessionId: string + expectedEnvironmentPairingRevision: number + } + }>() + mocks.prepare.mockReturnValue(prepare.promise) + const finishUpload = holdUpload() + const allocated = new Set() + const released = new Set() + vi.spyOn(URL, 'createObjectURL').mockImplementation(() => { + const url = 'blob:late-acceptance' + allocated.add(url) + return url + }) + vi.spyOn(URL, 'revokeObjectURL').mockImplementation((url) => { + released.add(url) + }) + const view = render(composer(acceptsImages)) + await act(async () => fireEvent(screen.getByRole('textbox'), imagePaste('caption'))) + await act(async () => + fireEvent.click(screen.getByRole('button', { name: 'Remove attachment' })) + ) + await act(async () => + prepare.resolve({ + ok: true, + target: { + environmentId: 'env-1', + sessionId: 'session-1', + expectedEnvironmentPairingRevision: 1 + } + }) + ) + await act(async () => finishUpload(STORED)) + expect(readNativeChatAttachmentCache(PANE)).toEqual([]) + view.unmount() + expect([...allocated].filter((url) => !released.has(url))).toEqual([]) + } +) + +it('starts no upload when the composer closes before image presence is known', async () => { + mocks.state.owner = { kind: 'local' } + mocks.save.mockClear() + const presence = Promise.withResolvers() + installPreloadApi({ + saveClipboardImageAsTempFile: mocks.save, + readClipboardText: async () => '', + readClipboardFilePaths: async () => [], + clipboardHasImage: () => presence.promise + }) + const handle = createRef() + const view = render(composer(true, handle)) + await act(async () => handle.current?.pasteFromClipboard()) + view.unmount() + await act(async () => presence.resolve(true)) + expect(mocks.save).not.toHaveBeenCalled() + expect(readNativeChatAttachmentCache(PANE)).toEqual([]) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index f9519ed859f1..62315c377ece 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -197,6 +197,7 @@ const NativeChatComposerPane = forwardRef !attachment.hidden) const draftNotSaved = useNativeChatComposerDraftUnsaved(draftScopeKey) const optionCount = autocomplete.mode === 'slash' @@ -282,9 +279,9 @@ export function NativeChatComposerField({ '[contain:paint]' )} > - {shownAttachments.length > 0 ? ( + {imageAttachments.length > 0 ? (
    - {shownAttachments.map((attachment) => ( + {imageAttachments.map((attachment) => ( { expect(next.drafts.readNativeChatDraftCache('agent-session:s2')).toBe('please go\n\ngo') expect(storage.drafts.get('agent-session:s2')?.text).toBe('please go\n\ngo') }) + + it.each(['delayed load', 'crash replay'])( + 'keeps picked nodes when appending through %s', + async (mode) => { + const document = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { type: 'nativeChatSkill', attrs: { token: '$review' } }, + { type: 'text', text: '-long' } + ] + } + ] + } + storage.drafts.set(SCOPE, { text: '$review-long', document, images: [], savedAt: 1 }) + let next: DraftModules + if (mode === 'crash replay') { + const crashed = await reload({ + using: { ...storage, write: () => new Promise(() => {}) } + }) + crashed.drafts.appendNativeChatDraftCache(SCOPE, '@/stored/file.pdf') + crashed.store.clearNativeChatComposerDraftsForTests() + next = await reload() + } else { + const { using, land } = slowLoading() + next = await reload({ using, hydrate: false }) + await next.store.waitForNativeChatComposerDrafts(1) + next.drafts.appendNativeChatDraftCache(SCOPE, '@/stored/file.pdf') + land() + await next.store.waitForNativeChatComposerDrafts(1_000) + } + await next.store.nativeChatComposerDraftWritesSettled() + expect(next.drafts.readNativeChatDraftCache(SCOPE)).toBe('$review-long\n\n@/stored/file.pdf') + expect(next.store.readNativeChatComposerDraft(SCOPE).document?.content?.[0]).toEqual( + document.content[0] + ) + expect(storage.drafts.get(SCOPE)?.document?.content?.[0]).toEqual(document.content[0]) + } + ) }) diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-addition.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-addition.ts index 76b82af53935..441ce2264cc9 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-addition.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-addition.ts @@ -3,6 +3,7 @@ // loaded later, or replayed from the journal after a crash. import { appendReturnedDraftText } from '../../../../shared/returned-draft-text' +import { appendPromptDocumentText } from './native-chat-prompt-document-text' import { basename } from '@/lib/path' import type { NativeChatComposerDraft, @@ -25,7 +26,7 @@ export function withNativeChatComposerDraftAddition( draft: NativeChatComposerDraft, addition: NativeChatComposerDraftAddition, options: { once?: boolean } = {} -): Pick { +): Pick { const text = appendReturnedDraftText(draft.text, addition.text ?? '') const images = [...draft.images] for (const { id, path, connectionId } of addition.images ?? []) { @@ -42,5 +43,5 @@ export function withNativeChatComposerDraftAddition( images[placeholder] = image } } - return { text, images } + return { text, images, document: appendPromptDocumentText(draft.document, draft.text, text) } } diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-journal.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-journal.ts index c1b979e36456..dc4a210d6cc6 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-journal.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-journal.ts @@ -145,7 +145,6 @@ export function replayNativeChatComposerDraftJournal( drafts.set(scopeKey, { ...from, ...next, - ...(next.text === from.text ? {} : { document: undefined }), savedAt: at }) changed.add(scopeKey) diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-store.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.ts index 60dddf06d2f7..2842251fd6aa 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-store.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.ts @@ -189,7 +189,7 @@ function withAddition( options: { once?: boolean } = {} ): T { const next = withNativeChatComposerDraftAddition(draft, addition, options) - return { ...draft, ...next, ...(next.text === draft.text ? {} : { document: undefined }) } + return { ...draft, ...next } } /** @@ -206,13 +206,13 @@ export function appendToNativeChatComposerDraft( ): boolean { const before = records.get(scopeKey) const current = readNativeChatComposerDraft(scopeKey) - const { text, images } = withAddition(current, addition) + const { text, images, document } = withAddition(current, addition) updateNativeChatComposerDraft( scopeKey, { text, images, - ...(text === current.text ? {} : { document: undefined }), + document, ...(owner ? { owner } : {}) }, 'immediate', diff --git a/src/renderer/src/components/native-chat/native-chat-composer-paste-options.ts b/src/renderer/src/components/native-chat/native-chat-composer-paste-options.ts new file mode 100644 index 000000000000..1d4a17d41f9d --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-composer-paste-options.ts @@ -0,0 +1,23 @@ +import type { AgentType } from '../../../../shared/agent-status-types' +import type { NativeChatAttachmentOwner } from './native-chat-attachment-upload' + +export type UseNativeChatComposerPasteArgs = { + targetKey?: string + attachmentScopeKey?: string + agent: AgentType + /** Re-read after awaits so a mid-paste disable guards the composer. */ + disabled: boolean + caret: number + /** Resolved at paste time: SSH panes must save the clipboard image on the + * remote host, or the attached path names a file the agent cannot read. */ + resolveAttachmentOwner: () => NativeChatAttachmentOwner + attachResolvedPaths: (paths: string[], connectionId?: string | null) => void + beginPendingImageAttachment: (previewUrl?: string, pendingName?: string) => string | null + resolvePendingImageAttachment: (id: string, path: string, connectionId?: string | null) => void + /** Supplies a pending chip's thumbnail. */ + revealPendingImageAttachment?: (id: string, previewUrl?: string) => void + dropPendingImageAttachment: (id: string) => void + insertTypedText: (text: string) => boolean + setCaret: (caret: number) => void + setNotice: (notice: string | null, errorText?: string) => void +} diff --git a/src/renderer/src/components/native-chat/native-chat-composer-upload-completion.test.tsx b/src/renderer/src/components/native-chat/native-chat-composer-upload-completion.test.tsx new file mode 100644 index 000000000000..97d06b69b520 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-composer-upload-completion.test.tsx @@ -0,0 +1,230 @@ +// @vitest-environment happy-dom +import { createRef } from 'react' +import { act, cleanup, render, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { useNativeChatComposerAttachments } from './use-native-chat-composer-attachments' +import { attachNativeChatSessionAttachmentPaths } from './native-chat-session-attachment-drop' +import { NativeChatPromptEditor } from './NativeChatPromptEditor' +import type { NativeChatComposerInput } from './native-chat-composer-input' +import { + clearNativeChatComposerDraftsForTests, + hydrateNativeChatComposerDrafts, + nativeChatComposerDraftWritesSettled, + readNativeChatComposerDraft, + structuredAgentSessionDraftScopeKey as scope, + updateNativeChatComposerDraft +} from './native-chat-composer-draft-store' +import { + createMemoryNativeChatComposerDraftStorage, + setNativeChatComposerDraftStorageForTests +} from './native-chat-composer-draft-storage' +import { + clearNativeChatPendingAttachmentsForTests, + nativeChatPendingAttachmentSnapshot, + takeNativeChatPendingAttachment +} from './native-chat-pending-attachment-cache' + +// Only host replies are controlled; pending-chip ownership and upload completion are production code. +vi.mock('@/runtime/runtime-rpc-client', () => ({ + callRuntimeRpc: vi.fn(async () => ({ + runtimeId: 'paired-host', + capabilities: ['agent-session.attachments.v1'] + })) +})) +let previousApi: typeof window.api +beforeEach(async () => { + previousApi = window.api + setNativeChatComposerDraftStorageForTests(createMemoryNativeChatComposerDraftStorage()) + await hydrateNativeChatComposerDrafts() +}) +afterEach(async () => { + cleanup() + await nativeChatComposerDraftWritesSettled() + clearNativeChatPendingAttachmentsForTests() + clearNativeChatComposerDraftsForTests() + window.api = previousApi +}) +async function uploadFixture( + extension: string, + { removed = false, withSkill = false, switchScope = false } = {} +) { + const path = `/srv/agent-session-attachments/u1/report.${extension}` + const sourcePath = `/local/report.${extension}` + let finish = () => {} + Object.defineProperty(window, 'api', { + configurable: true, + writable: true, + value: { + fs: { + uploadPathsToAgentSessionAttachments: () => + new Promise((resolve) => { + finish = () => resolve({ uploaded: [{ sourcePath, path }], skipped: [], failed: [] }) + }) + } + } + }) + updateNativeChatComposerDraft(scope('a'), { text: 'next question' }, 'immediate') + if (withSkill) { + const inputRef = createRef() + const picker = render( + + updateNativeChatComposerDraft(scope('a'), { text: input.value }, 'immediate') + } + onSelect={() => {}} + /> + ) + act(() => { + inputRef.current?.insertSkill?.(0, 4, '$review') + if (inputRef.current) { + inputRef.current.value = '$review-long' + } + }) + await vi.waitFor(() => + expect(picker.container.querySelector('[data-native-chat-skill]')).not.toBeNull() + ) + picker.unmount() + } + const textareaRef = createRef() + const view = renderHook( + ({ draftScope }) => { + return useNativeChatComposerAttachments({ + attachmentScopeKey: draftScope, + allowWithoutTarget: true, + caret: 0, + disabled: false, + isComposing: () => false, + resolveTarget: () => null, + textareaRef, + setCaret: () => {}, + setDraft: (updater) => + updateNativeChatComposerDraft( + draftScope, + { text: updater(readNativeChatComposerDraft(draftScope).text) }, + 'immediate' + ), + setNotice: () => {} + }) + }, + { initialProps: { draftScope: scope('a') } } + ) + let uploaded = Promise.resolve() + act(() => { + uploaded = attachNativeChatSessionAttachmentPaths({ + paths: [sourcePath], + owner: { + kind: 'runtime-session', + environmentId: 'paired', + sessionId: 'a', + pairingRevision: 1 + }, + chips: view.result.current.pendingChips, + isAbandoned: () => false, + ownerStillCurrent: () => true, + setNotice: () => {} + }) + }) + await Promise.resolve() + await Promise.resolve() + const chip = nativeChatPendingAttachmentSnapshot(scope('a'))[0] + if (!chip) { + throw new Error('Expected pending upload chip') + } + expect(chip.pendingName).toBe(`report.${extension}`) + const targetScope = scope('a') + if (switchScope) { + act(() => view.rerender({ draftScope: scope('b') })) + render( + + updateNativeChatComposerDraft(scope('b'), { text: input.value }, 'immediate') + } + onSelect={() => {}} + /> + ) + } + if (removed) { + act(() => takeNativeChatPendingAttachment(targetScope, chip.id)) + } + if (!switchScope) { + view.unmount() + } + finish() + await uploaded + return { + path, + targetScope, + other: readNativeChatComposerDraft(scope('b')), + otherInput: textareaRef.current?.value, + target: readNativeChatComposerDraft(targetScope), + pending: nativeChatPendingAttachmentSnapshot(targetScope) + } +} +it.each(['pdf', 'png'])( + 'settles an unmounted paired %s upload in its owning draft', + async (extension) => { + const result = await uploadFixture(extension) + expect(result.pending).toEqual([]) + if (extension === 'pdf') { + expect(result.target.text).toContain(`@${result.path}`) + } else { + expect(result.target.images[0]?.path).toBe(result.path) + } + expect(result.other.text).toBe('') + expect(result.other.images).toEqual([]) + } +) + +it('does not attach a file reference removed while uploading', async () => { + const result = await uploadFixture('pdf', { removed: true }) + expect(result.pending).toEqual([]) + expect(result.target.text).toBe('next question') +}) + +it('keeps the original file owner when the mounted composer switches draft scopes', async () => { + const result = await uploadFixture('pdf', { switchScope: true }) + expect(result.pending).toEqual([]) + expect(result.target.text).toBe(`next question\n\n@${result.path}`) + expect(result.otherInput).toBe('other question') + expect(result.other.images).toEqual([]) +}) + +it.each(['pdf', 'png'])( + 'preserves a picked skill and its literal suffix after an unmounted %s upload', + async (extension) => { + const result = await uploadFixture(extension, { withSkill: true }) + expect(result.pending).toEqual([]) + const inputRef = createRef() + const view = render( + {}} + onSelect={() => {}} + /> + ) + expect(inputRef.current?.value).toContain('$review-long') + if (extension === 'pdf') { + expect(inputRef.current?.value).toContain(`@${result.path}`) + } else { + expect(result.target.images[0]?.path).toBe(result.path) + } + await vi.waitFor(() => { + expect(view.container.querySelectorAll('[data-native-chat-skill]')).toHaveLength(1) + expect(view.container.querySelector('[data-native-chat-skill]')?.textContent).toBe('Review') + }) + } +) diff --git a/src/renderer/src/components/native-chat/native-chat-draft-cache.ts b/src/renderer/src/components/native-chat/native-chat-draft-cache.ts index 8d086d5ac35f..f4f7eee53bd8 100644 --- a/src/renderer/src/components/native-chat/native-chat-draft-cache.ts +++ b/src/renderer/src/components/native-chat/native-chat-draft-cache.ts @@ -1,5 +1,6 @@ import type { NativeChatComposerImageAttachment } from './NativeChatComposerField' import type { JSONContent } from '@tiptap/react' +import type { NativeChatComposerDraftOwner } from './native-chat-composer-draft-storage' // The composer's in-progress draft text and its editor document, keyed by the same stable pane // scope as image attachments. The composer unmounts when the pane toggles back to the hosted // terminal, so without this the typed-but-unsent draft would be lost on every TUI/GUI round-trip. @@ -40,13 +41,17 @@ const appendListeners = new Map /** Puts text back after whatever is typed, and tells a mounted composer to show it. True once it * is durable, so the copy it came from may go. */ -export function appendNativeChatDraftCache(scopeKey: string, text: string): boolean { +export function appendNativeChatDraftCache( + scopeKey: string, + text: string, + owner?: NativeChatComposerDraftOwner +): boolean { if (text === '') { return true } const previous = readNativeChatDraftCache(scopeKey) // Durable now: the copy it came from (a send handed back, a queued card) goes right after this. - const durable = appendToNativeChatComposerDraft(scopeKey, { text }) + const durable = appendToNativeChatComposerDraft(scopeKey, { text }, owner) appendListeners.get(scopeKey)?.forEach((listener) => listener(text, previous)) return durable } diff --git a/src/renderer/src/components/native-chat/native-chat-pending-attachment-cache.test.ts b/src/renderer/src/components/native-chat/native-chat-pending-attachment-cache.test.ts index 3dccd946f89b..96a187899a1c 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending-attachment-cache.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending-attachment-cache.test.ts @@ -1,13 +1,12 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' import { addNativeChatPendingAttachment, clearNativeChatPendingAttachmentsForTests, dropNativeChatPendingAttachmentsForTab, dropNativeChatPendingAttachmentsOwnedBy, nativeChatPendingAttachmentSnapshot, - revealNativeChatPendingAttachment, settleNativeChatPendingAttachment, - subscribeToNativeChatPendingAttachments, + settleNativeChatPendingAttachmentReferences, takeNativeChatPendingAttachment } from './native-chat-pending-attachment-cache' import { @@ -58,22 +57,6 @@ describe('the pane pending attachment cache', () => { expect(takeNativeChatPendingAttachment('pane-b', 'b1')).toBeUndefined() }) - it('shows a hidden chip and tells every subscriber, with a new snapshot only on change', () => { - const listener = vi.fn() - const unsubscribe = subscribeToNativeChatPendingAttachments('pane-c', listener) - addNativeChatPendingAttachment('pane-c', { id: 'c1', path: '', pending: true, hidden: true }) - const hidden = nativeChatPendingAttachmentSnapshot('pane-c') - - expect(nativeChatPendingAttachmentSnapshot('pane-c')).toBe(hidden) - revealNativeChatPendingAttachment('pane-c', 'c1') - - expect(nativeChatPendingAttachmentSnapshot('pane-c')).toEqual([ - { id: 'c1', path: '', pending: true } - ]) - expect(listener).toHaveBeenCalledTimes(2) - unsubscribe() - }) - it('settles into a draft owned by the workspace it began in, after its chat stopped naming one', () => { const conversation = structuredAgentSessionDraftScopeKey('session-1') let open = true @@ -89,6 +72,25 @@ describe('the pane pending attachment cache', () => { expect(readNativeChatComposerDraft(conversation).images).toEqual([]) }) + it('keeps reference settlement owned by the workspace after its chat closes', () => { + const conversation = structuredAgentSessionDraftScopeKey('session-1') + let open = true + setNativeChatComposerDraftOwnerResolver((scopeKey) => + open && scopeKey === conversation ? OWNER : undefined + ) + addPending(conversation, 'file') + open = false + + settleNativeChatPendingAttachmentReferences(conversation, [ + { id: 'file', path: '/store/a.pdf' } + ]) + + expect(readNativeChatComposerDraft(conversation).text).toBe('@/store/a.pdf') + expect(nativeChatPendingAttachmentSnapshot(conversation)).toEqual([]) + deleteNativeChatComposerDraftsOwnedBy(OWNER) + expect(readNativeChatComposerDraft(conversation).text).toBe('') + }) + it('drops the chips begun in a removed workspace on that host only', () => { const resolved = new Map([ ['tab-1:leaf', OWNER], diff --git a/src/renderer/src/components/native-chat/native-chat-pending-attachment-cache.ts b/src/renderer/src/components/native-chat/native-chat-pending-attachment-cache.ts index 3d4e477d0cee..a2c771a6568d 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending-attachment-cache.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending-attachment-cache.ts @@ -1,15 +1,11 @@ -// A composer's chips still on their way (a save, an upload, a server's answer), per pane scope, -// owned here rather than by the composer. A prompt card unmounts the composer, so anything owed to -// the message must outlive it: the composer that comes back shows the same chips, Send waits for -// them, and each settles into the scope's draft whichever composer, if any, is showing. They are -// never saved with the draft, so a restored draft cannot bring back an upload as if it were -// attached. Every chip dies with its operation: settled or dropped when the save or upload settles -// (each bounded by its call timeout), removed by the user, or dropped with the drafts of a tab or -// workspace the user closed or removed, so none settles into a draft nothing could find or delete. +// Pending operations belong to draft scopes and outlive their composers. They are not persisted. +// Completion, failure, explicit removal, draft/workspace deletion or renderer exit ends ownership. import { useCallback, useSyncExternalStore } from 'react' import type { NativeChatComposerImageAttachment } from './NativeChatComposerField' import type { NativeChatComposerDraftOwner } from './native-chat-composer-draft-storage' +import { appendNativeChatDraftCache } from './native-chat-draft-cache' +import { formatNativeChatFileReference } from '../../../../shared/agent-image-paste' import { appendToNativeChatComposerDraft, nativeChatDraftScopeTabId, @@ -115,17 +111,23 @@ export function settleNativeChatPendingAttachment( return true } -/** Shows a pending chip that was held out of sight, such as while a server was asked first. */ -export function revealNativeChatPendingAttachment(scopeKey: string, id: string): void { - const current = nativeChatPendingAttachmentSnapshot(scopeKey) - if (current.some((attachment) => attachment.id === id && attachment.hidden)) { - writePending( - scopeKey, - current.map((attachment) => { - const { hidden: _hidden, ...shown } = attachment - return attachment.id === id ? shown : attachment - }) - ) +/** File results settle only while their operation still belongs to this draft. */ +export function settleNativeChatPendingAttachmentReferences( + scopeKey: string, + references: { id: string; path: string }[], + insertAtCaret?: (paths: string[]) => void +): void { + const owner = pendingOwners.get(scopeKey) + const paths = references.flatMap(({ id, path }) => + takeNativeChatPendingAttachment(scopeKey, id) ? [path] : [] + ) + if (paths.length === 0) { + return + } + if (insertAtCaret) { + insertAtCaret(paths) + } else { + appendNativeChatDraftCache(scopeKey, paths.map(formatNativeChatFileReference).join(' '), owner) } } diff --git a/src/renderer/src/components/native-chat/native-chat-prompt-document-text.test.ts b/src/renderer/src/components/native-chat/native-chat-prompt-document-text.test.ts new file mode 100644 index 000000000000..24f3056c1ee4 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-prompt-document-text.test.ts @@ -0,0 +1,66 @@ +import { expect, it } from 'vitest' +import { withNativeChatComposerDraftAddition } from './native-chat-composer-draft-addition' +import type { NativeChatComposerDraft } from './native-chat-composer-draft-storage' + +it.each(['spaces', 'hard breaks', 'empty paragraphs'])( + 'trims trailing %s while keeping picked skills and earlier paragraphs', + (trailing) => { + const first = { type: 'paragraph', content: [{ type: 'text', text: 'first' }] } + const picked = { type: 'nativeChatSkill', attrs: { token: '$review' } } + const draft: NativeChatComposerDraft = { + text: `first\n$review${trailing === 'spaces' ? ' ' : '\n\n'}`, + images: [], + document: { + type: 'doc', + content: [ + first, + { + type: 'paragraph', + content: [ + picked, + ...(trailing === 'spaces' + ? [{ type: 'text', text: ' ' }] + : trailing === 'hard breaks' + ? [{ type: 'hardBreak' }, { type: 'hardBreak' }] + : []) + ] + }, + ...(trailing === 'empty paragraphs' + ? [ + { type: 'paragraph', content: [] }, + { type: 'paragraph', content: [] } + ] + : []) + ] + } + } + const result = withNativeChatComposerDraftAddition(draft, { text: 'go' }) + expect(result.text).toBe('first\n$review\n\ngo') + expect(result.document?.content).toEqual([ + first, + { type: 'paragraph', content: [picked] }, + { type: 'paragraph', content: [] }, + { type: 'paragraph', content: [{ type: 'text', text: 'go' }] } + ]) + } +) + +it('replaces an all-whitespace document with literal returned text', () => { + const result = withNativeChatComposerDraftAddition( + { + text: ' \n', + images: [], + document: { + type: 'doc', + content: [ + { type: 'paragraph', content: [{ type: 'text', text: ' ' }] }, + { type: 'paragraph', content: [] } + ] + } + }, + { text: '$review' } + ) + expect(result.document?.content).toEqual([ + { type: 'paragraph', content: [{ type: 'text', text: '$review' }] } + ]) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-prompt-document-text.ts b/src/renderer/src/components/native-chat/native-chat-prompt-document-text.ts new file mode 100644 index 000000000000..6151a994c95d --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-prompt-document-text.ts @@ -0,0 +1,59 @@ +import type { JSONContent } from '@tiptap/react' + +export function promptTextContent(text: string): JSONContent { + return { + type: 'doc', + content: text.split('\n').map((line) => ({ + type: 'paragraph', + content: line ? [{ type: 'text', text: line }] : [] + })) + } +} + +/** Applies an appended text change without replacing existing picked nodes. */ +export function appendPromptDocumentText( + document: JSONContent | undefined, + before: string, + after: string +): JSONContent | undefined { + if (!document || before === after) { + return document + } + const kept = before.trimEnd() + if (kept === '') { + return promptTextContent(after) + } + const blocks = [...(document.content ?? [])] + while (blocks.length > 0) { + const block = blocks.pop() + const content = [...(block?.content ?? [])] + while (content.length > 0) { + const node = content.at(-1) + if (node?.type === 'hardBreak') { + content.pop() + } else if (node?.type === 'text') { + const text = node.text?.trimEnd() ?? '' + if (text === '') { + content.pop() + } else { + content[content.length - 1] = { ...node, text } + break + } + } else { + break + } + } + if (content.length > 0 && block) { + const [first, ...rest] = promptTextContent(after.slice(kept.length)).content ?? [] + return { + ...document, + content: [ + ...blocks, + { ...block, content: [...content, ...(first?.content ?? [])] }, + ...rest + ] + } + } + } + return promptTextContent(after) +} diff --git a/src/renderer/src/components/native-chat/native-chat-prompt-document.ts b/src/renderer/src/components/native-chat/native-chat-prompt-document.ts index 7ed5fd8c1306..96bba1a75fda 100644 --- a/src/renderer/src/components/native-chat/native-chat-prompt-document.ts +++ b/src/renderer/src/components/native-chat/native-chat-prompt-document.ts @@ -1,6 +1,7 @@ import { NativeChatSkillPill } from './NativeChatSkillPill' -import { ReactNodeViewRenderer, Node, type JSONContent } from '@tiptap/react' +import { ReactNodeViewRenderer, Node } from '@tiptap/react' import type { Node as ProseMirrorNode } from '@tiptap/pm/model' +export { promptTextContent } from './native-chat-prompt-document-text' export const NativeChatSkill = Node.create({ name: 'nativeChatSkill', @@ -24,16 +25,6 @@ export const NativeChatSkill = Node.create({ ] }) -export function promptTextContent(text: string): JSONContent { - return { - type: 'doc', - content: text.split('\n').map((line) => ({ - type: 'paragraph', - content: line ? [{ type: 'text', text: line }] : [] - })) - } -} - /** Each boundary maps a plain-text caret to a document position, including atomic skills. */ export function promptTextMap(doc: ProseMirrorNode): { text: string; positions: number[] } { let text = '' diff --git a/src/renderer/src/components/native-chat/native-chat-session-attachment-drop.ts b/src/renderer/src/components/native-chat/native-chat-session-attachment-drop.ts index 0d655d8de8a3..e51be382d945 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-attachment-drop.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-attachment-drop.ts @@ -18,7 +18,7 @@ export type NativeChatPendingAttachmentChips = { drop: (id: string) => boolean /** Adds stored files as `@path` references to the scope's draft, which keeps them through an * input-method composition or a remount. */ - attachReferences: (paths: string[]) => void + attachReferences: (references: { id: string; path: string }[]) => void } /** @@ -86,7 +86,7 @@ export async function attachNativeChatSessionAttachmentPaths(args: { return } const notAttached: { name: string; reason: string }[] = [] - const references: string[] = [] + const references: { id: string; path: string }[] = [] for (const { path, chipId } of pending) { const storedPath = stored.get(path) if (!storedPath) { @@ -100,9 +100,7 @@ export async function attachNativeChatSessionAttachmentPaths(args: { continue } // Other files become `@path` references, as every attach does, unless their chip was removed. - if (args.chips.drop(chipId)) { - references.push(storedPath) - } + references.push({ id: chipId, path: storedPath }) } // Inserting at the caret clears the notice, so what failed is said after. if (references.length > 0) { diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx index 5208dfe6e8ac..ddf559077896 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx @@ -477,13 +477,14 @@ describe('useNativeChatComposerAttachments', () => { it('keeps an upload that finishes while the composer is unmounted, for when it returns', async () => { const probe = await renderProbe('pty-gone') const chips = probe.latest().pendingChips - const begun: { image?: string | null; removed?: string | null } = {} + const begun: { image?: string | null; removed?: string | null; reference?: string | null } = {} act(() => { begun.image = chips.begin(undefined, 'shot.png') begun.removed = chips.begin(undefined, 'old.png') + begun.reference = chips.begin(undefined, 'notes.pdf') }) - const { image, removed } = begun - if (!image || !removed) { + const { image, removed, reference } = begun + if (!image || !removed || !reference) { throw new Error('expected pending chips') } act(() => probe.latest().removeImageAttachment(removed)) @@ -492,7 +493,7 @@ describe('useNativeChatComposerAttachments', () => { chips.resolve(image, '/srv/agent-session-attachments/u1/shot.png') chips.resolve(removed, '/srv/agent-session-attachments/u2/old.png') - chips.attachReferences(['/srv/agent-session-attachments/u3/notes.pdf']) + chips.attachReferences([{ id: reference, path: '/srv/agent-session-attachments/u3/notes.pdf' }]) expect(readNativeChatAttachmentCache('pty-gone')).toEqual([ { id: image, path: '/srv/agent-session-attachments/u1/shot.png' } @@ -531,11 +532,33 @@ describe('useNativeChatComposerAttachments', () => { act(() => back.root.unmount()) }) + it('gives uploads unique IDs across composers remounted in the same clock tick', async () => { + const now = vi.spyOn(Date, 'now').mockReturnValue(1_000) + try { + const first = await renderProbe('pty-ids', true) + const ids: (string | null)[] = [] + act(() => ids.push(first.latest().pendingChips.begin(undefined, 'first.pdf'))) + act(() => first.root.unmount()) + const next = await renderProbe('pty-ids', true) + act(() => ids.push(next.latest().pendingChips.begin(undefined, 'second.pdf'))) + expect(new Set(ids).size).toBe(2) + expect(nativeChatPendingAttachmentSnapshot('pty-ids')).toHaveLength(2) + act(() => next.root.unmount()) + } finally { + now.mockRestore() + } + }) + it('inserts a stored file at the caret while the composer is showing and not composing', async () => { const probe = await renderProbe('pty-caret', true) - act(() => - probe.latest().pendingChips.attachReferences(['/srv/agent-session-attachments/u6/a.pdf']) - ) + act(() => { + const chips = probe.latest().pendingChips + const id = chips.begin(undefined, 'a.pdf') + if (!id) { + throw new Error('expected pending chip') + } + chips.attachReferences([{ id, path: '/srv/agent-session-attachments/u6/a.pdf' }]) + }) expect(probe.draft()).toBe('@/srv/agent-session-attachments/u6/a.pdf ') expect(readNativeChatDraftCache('pty-caret')).toBe('') act(() => probe.root.unmount()) @@ -555,8 +578,7 @@ describe('useNativeChatComposerAttachments', () => { const id: string = chipId // The upload finishes mid-composition: the reference waits for the composition to settle. act(() => { - expect(chips.drop(id)).toBe(true) - chips.attachReferences(['/srv/agent-session-attachments/u4/notes.pdf']) + chips.attachReferences([{ id, path: '/srv/agent-session-attachments/u4/notes.pdf' }]) }) // A prompt card takes the composer's place before the composition settles. act(() => probe.root.unmount()) diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts index b360492493ff..2eb20ac79545 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts @@ -1,12 +1,5 @@ import type { NativeChatComposerInput } from './native-chat-composer-input' -import { - useCallback, - useEffect, - useMemo, - useRef, - useSyncExternalStore, - type RefObject -} from 'react' +import { useCallback, useEffect, useMemo, useSyncExternalStore, type RefObject } from 'react' import { nativeChatComposerTargetIsRemote, nativeChatLocalAttachmentUnsupportedNotice, @@ -26,17 +19,15 @@ import type { NativeChatPendingAttachmentChips } from './native-chat-session-att import { addNativeChatPendingAttachment, clearNativeChatPendingAttachments, - revealNativeChatPendingAttachment, settleNativeChatPendingAttachment, + settleNativeChatPendingAttachmentReferences, + nativeChatPendingAttachmentSnapshot, takeNativeChatPendingAttachment, useNativeChatPendingAttachments } from './native-chat-pending-attachment-cache' -import { - appendNativeChatAttachmentCache, - appendNativeChatDraftCache -} from './native-chat-draft-cache' +import { appendNativeChatAttachmentCache } from './native-chat-draft-cache' import { useNativeChatComposerAttachmentPreviews } from './use-native-chat-composer-attachment-previews' -import { formatNativeChatFileReference } from '../../../../shared/agent-image-paste' +import { createUuidV4 } from '../../../../shared/uuid-v4' export type UseNativeChatComposerAttachmentsArgs = { attachmentScopeKey: string @@ -75,11 +66,7 @@ export function useNativeChatComposerAttachments({ clearImageAttachments: () => void flushPendingAttachments: () => void removeImageAttachment: (id: string) => void - beginPendingImageAttachment: ( - previewUrl?: string, - pendingName?: string, - options?: { hidden?: true } - ) => string | null + beginPendingImageAttachment: (previewUrl?: string, pendingName?: string) => string | null resolvePendingImageAttachment: (id: string, path: string, connectionId?: string | null) => void revealPendingImageAttachment: (id: string, previewUrl?: string) => void dropPendingImageAttachment: (id: string) => boolean @@ -117,19 +104,18 @@ export function useNativeChatComposerAttachments({ ], [pending, previews, restoring, settled] ) - const imageAttachmentCounter = useRef(0) - const mountedRef = useRef(true) + const mountedRef = useMemo( + () => ({ scopeKey: attachmentScopeKey, current: true }), + [attachmentScopeKey] + ) useEffect(() => { mountedRef.current = true return () => { mountedRef.current = false } - }, []) + }, [mountedRef]) - const nextAttachmentId = useCallback((): string => { - imageAttachmentCounter.current += 1 - return `${Date.now()}-${imageAttachmentCounter.current}` - }, []) + const nextAttachmentId = useCallback(() => createUuidV4(), []) // Client-local paths cannot cross into a runtime target; workspace-owned // paths may only bypass this after the internal drop ownership gate. @@ -181,10 +167,8 @@ export function useNativeChatComposerAttachments({ // Placeholder chip shown the instant a paste starts, so a clipboard image that // takes a beat to save (or upload over SSH) never reads as a dropped paste. const beginPendingImageAttachment = useCallback( - (previewUrl?: string, pendingName?: string, options?: { hidden?: true }): string | null => { - // Without image input the saved paste is attached by path once it lands, so no image chip; - // a named upload chip is a file, not an image, and still shows. - if (disabledRef.current || (!acceptsImages && pendingName === undefined)) { + (previewUrl?: string, pendingName?: string): string | null => { + if (disabledRef.current) { return null } if (attachmentTargetBlocked()) { @@ -196,14 +180,12 @@ export function useNativeChatComposerAttachments({ id, path: '', pending: true, - ...(pendingName ? { pendingName } : {}), - ...(options?.hidden ? { hidden: true } : {}) + ...(pendingName ? { pendingName } : {}) }) setPreview(id, previewUrl) return id }, [ - acceptsImages, attachmentScopeKey, attachmentTargetBlocked, disabledRef, @@ -213,17 +195,35 @@ export function useNativeChatComposerAttachments({ ] ) + const attachPendingReferences = useCallback( + (references: { id: string; path: string }[]) => { + settleNativeChatPendingAttachmentReferences( + attachmentScopeKey, + references, + mountedRef.current && !disabledRef.current && !isComposing() + ? (paths) => attachResolvedPaths(paths, null) + : undefined + ) + }, + [attachResolvedPaths, attachmentScopeKey, disabledRef, isComposing, mountedRef] + ) + const resolvePendingImageAttachment = useCallback( (id: string, path: string, connectionId?: string | null) => { - settleNativeChatPendingAttachment(attachmentScopeKey, id, path, connectionId) + if (acceptsImages) { + settleNativeChatPendingAttachment(attachmentScopeKey, id, path, connectionId) + } else { + attachPendingReferences([{ id, path }]) + } }, - [attachmentScopeKey] + [acceptsImages, attachPendingReferences, attachmentScopeKey] ) const revealPendingImageAttachment = useCallback( (id: string, previewUrl?: string) => { - setPreview(id, previewUrl) - revealNativeChatPendingAttachment(attachmentScopeKey, id) + if (nativeChatPendingAttachmentSnapshot(attachmentScopeKey).some((chip) => chip.id === id)) { + setPreview(id, previewUrl) + } }, [attachmentScopeKey, setPreview] ) @@ -245,23 +245,12 @@ export function useNativeChatComposerAttachments({ drop: dropPendingImageAttachment, // At the caret, as every attach does; mid-composition or once the composer is gone, into the // scope's draft, which keeps it until the composition settles or the composer comes back. - attachReferences: (paths) => { - if (mountedRef.current && !isComposing()) { - attachResolvedPaths(paths, null) - return - } - appendNativeChatDraftCache( - attachmentScopeKey, - paths.map(formatNativeChatFileReference).join(' ') - ) - } + attachReferences: attachPendingReferences }), [ - attachResolvedPaths, - attachmentScopeKey, + attachPendingReferences, beginPendingImageAttachment, dropPendingImageAttachment, - isComposing, resolvePendingImageAttachment ] ) diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-image-input.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-composer-image-input.test.tsx index 93e289e44625..715368683561 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-image-input.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-image-input.test.tsx @@ -87,10 +87,10 @@ describe('structured composer image input', () => { expect(result.pendingChip).not.toBeNull() }) - it('references an image by path, with no chip, when the agent takes none', async () => { + it('references an image by path, with a removable pending operation, when the agent takes none', async () => { const result = await attach(false, '/tmp/shot.png') - expect(result.chips).toEqual([]) - expect(result.pendingChip).toBeNull() + expect(result.chips).toEqual(['']) + expect(result.pendingChip).not.toBeNull() expect(result.draft).toContain('/tmp/shot.png') }) diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-paste-session-attachments.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-composer-paste-session-attachments.test.tsx index 421462ae1447..e08c92eea4f3 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-paste-session-attachments.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-paste-session-attachments.test.tsx @@ -49,7 +49,7 @@ vi.stubGlobal('URL', { import { useNativeChatComposerPaste } from './use-native-chat-composer-paste' type HookApi = ReturnType -type Chip = { id: string; path: string; pending: boolean; hidden?: true } +type Chip = { id: string; path: string; pending: boolean } const sessionOwner: NativeChatAttachmentOwner = { kind: 'runtime-session', @@ -87,20 +87,18 @@ async function renderPaste(args: { setCaret: () => {}, resolveAttachmentOwner: () => sessionOwner, attachResolvedPaths: args.attachResolvedPaths ?? (() => {}), - beginPendingImageAttachment: (_preview, _name, options) => { + beginPendingImageAttachment: () => { counter += 1 chips.push({ id: `chip-${counter}`, path: '', - pending: true, - ...(options?.hidden ? { hidden: true } : {}) + pending: true }) return `chip-${counter}` }, revealPendingImageAttachment: (id) => { const chip = chips.find((candidate) => candidate.id === id) if (chip) { - delete chip.hidden revealed += 1 } }, @@ -151,7 +149,7 @@ beforeEach(() => { vi.resetAllMocks() mocks.readClipboardText.mockResolvedValue('') mocks.readClipboardImageThumbnail.mockResolvedValue(null) - mocks.clipboardHasImage.mockResolvedValue(false) + mocks.clipboardHasImage.mockResolvedValue(true) mocks.readClipboardFilePaths.mockResolvedValue([]) mocks.prepareNativeChatSessionAttachmentUpload.mockResolvedValue({ ok: true, @@ -195,11 +193,12 @@ describe('pasting into a structured chat on a paired server', () => { expect(probe.chips).toEqual([{ id: 'chip-1', path: storedPath, pending: false }]) }) - it('attaches the server path when no placeholder chip was shown', async () => { + it('settles the server path when no thumbnail was shown', async () => { const attachResolvedPaths = vi.fn() const probe = await renderPaste({ attachResolvedPaths }) await act(async () => probe.api().pasteFromClipboard()) - expect(attachResolvedPaths).toHaveBeenCalledExactlyOnceWith([storedPath], null) + expect(probe.chips).toEqual([{ id: 'chip-1', path: storedPath, pending: false }]) + expect(attachResolvedPaths).not.toHaveBeenCalled() }) it('refuses on a server without the attachment store and saves nothing', async () => { @@ -236,7 +235,7 @@ describe('pasting into a structured chat on a paired server', () => { await act(async () => probe.api().handlePaste(imagePasteEvent('caption'))) expect(insertTypedText).toHaveBeenCalledWith('caption') expect(setNotice).not.toHaveBeenCalledWith('needs newer server') - // The image was owed to the message only out of sight: no chip showed, so none flashed. + // The operation ends quietly when this server cannot store its image. expect(probe.begun()).toBe(1) expect(probe.revealed()).toBe(0) expect(probe.chips).toEqual([]) diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-paste.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-composer-paste.test.tsx index cca232c91401..8f42d0832080 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-paste.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-paste.test.tsx @@ -53,6 +53,7 @@ function createChipStore(): { chips: FakeChip[] begin: (previewUrl?: string) => string | null resolve: (id: string, path: string, connectionId?: string | null) => void + reveal: (id: string, previewUrl?: string) => void drop: (id: string) => void connectionIds: (string | null | undefined)[] } { @@ -62,6 +63,12 @@ function createChipStore(): { return { chips, connectionIds, + reveal: (id, previewUrl) => { + const chip = chips.find((candidate) => candidate.id === id) + if (chip) { + chip.previewUrl = previewUrl + } + }, begin: (previewUrl) => { counter += 1 const id = `chip-${counter}` @@ -93,6 +100,7 @@ type ProbeArgs = { attachResolvedPaths: (paths: string[], connectionId?: string | null) => void beginPendingImageAttachment: (previewUrl?: string) => string | null resolvePendingImageAttachment: (id: string, path: string, connectionId?: string | null) => void + revealPendingImageAttachment: (id: string, previewUrl?: string) => void dropPendingImageAttachment: (id: string) => void insertTypedText: (text: string) => boolean setNotice: (notice: string | null) => void @@ -134,8 +142,9 @@ async function renderProbe(args: { disabled, resolveAttachmentOwner: args.resolveAttachmentOwner, attachResolvedPaths: args.attachResolvedPaths ?? (() => {}), - beginPendingImageAttachment: store.begin, + beginPendingImageAttachment: args.agent === 'omp' ? () => null : store.begin, resolvePendingImageAttachment: store.resolve, + revealPendingImageAttachment: store.reveal, dropPendingImageAttachment: store.drop, insertTypedText: args.insertTypedText ?? (() => true), setNotice: args.setNotice ?? (() => {}), @@ -175,7 +184,7 @@ beforeEach(() => { vi.resetAllMocks() mocks.readClipboardText.mockResolvedValue('') mocks.readClipboardImageThumbnail.mockResolvedValue(null) - mocks.clipboardHasImage.mockResolvedValue(false) + mocks.clipboardHasImage.mockResolvedValue(true) mocks.readClipboardFilePaths.mockResolvedValue([]) mocks.saveClipboardImageAsTempFile.mockResolvedValue(null) }) @@ -260,11 +269,7 @@ describe('useNativeChatComposerPaste', () => { } }) expect(mocks.saveClipboardImageAsTempFile).toHaveBeenCalledWith({ connectionId: 'conn-1' }) - if (source === 'event') { - expect(store.chips[0]?.path).toBe('/remote/tmp/omp.png') - } else { - expect(attachResolvedPaths).toHaveBeenCalledWith(['/remote/tmp/omp.png'], 'conn-1') - } + expect(attachResolvedPaths).toHaveBeenCalledWith(['/remote/tmp/omp.png'], 'conn-1') expect(setNotice.mock.calls.every(([notice]) => notice === null)).toBe(true) } ) @@ -389,7 +394,7 @@ describe('useNativeChatComposerPaste', () => { expect(store.chips[0]).toMatchObject({ path: '/tmp/orca-paste-2.png', pending: false }) }) - it('attaches directly when no clipboard preview was available', async () => { + it('settles the owned operation when no clipboard preview was available', async () => { mocks.readClipboardImageThumbnail.mockResolvedValue(null) mocks.saveClipboardImageAsTempFile.mockResolvedValue('C:\\Temp\\orca-paste-3.png') const store = createChipStore() @@ -402,8 +407,8 @@ describe('useNativeChatComposerPaste', () => { await act(async () => { probe.latest().pasteFromClipboard() }) - expect(store.chips).toHaveLength(0) - expect(attachResolvedPaths).toHaveBeenCalledWith(['C:\\Temp\\orca-paste-3.png'], null) + expect(store.chips[0]).toMatchObject({ path: 'C:\\Temp\\orca-paste-3.png', pending: false }) + expect(attachResolvedPaths).not.toHaveBeenCalled() }) it('inserts text independently of a failed image save', async () => { @@ -425,8 +430,7 @@ describe('useNativeChatComposerPaste', () => { }) it('still falls through to text when the clipboard holds no image', async () => { - mocks.readClipboardImageThumbnail.mockResolvedValue(null) - mocks.saveClipboardImageAsTempFile.mockResolvedValue(null) + mocks.clipboardHasImage.mockResolvedValue(false) mocks.readClipboardText.mockResolvedValue('hello') const insertTypedText = vi.fn() const store = createChipStore() @@ -787,17 +791,21 @@ describe('file-manager copies', () => { mocks.saveClipboardImageAsTempFile.mockResolvedValue('/tmp/shot.png') const insertTypedText = vi.fn(() => true) const attachResolvedPaths = vi.fn() + const store = createChipStore() const probe = await renderProbe({ resolveAttachmentOwner: () => ({ kind: 'local' }), insertTypedText, - attachResolvedPaths + attachResolvedPaths, + store }) await act(async () => probe.latest().pasteFromClipboard()) expect(insertTypedText).not.toHaveBeenCalled() - expect(attachResolvedPaths).toHaveBeenCalledExactlyOnceWith(['/tmp/shot.png'], null) + expect(store.chips[0]).toMatchObject({ path: '/tmp/shot.png', pending: false }) + expect(attachResolvedPaths).not.toHaveBeenCalled() }) it('types the label when no image came with the files', async () => { + mocks.clipboardHasImage.mockResolvedValue(false) mocks.readClipboardText.mockResolvedValue('notes.txt') mocks.readClipboardFilePaths.mockResolvedValue(['/Users/me/notes.txt']) const insertTypedText = vi.fn(() => true) diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-paste.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-paste.ts index 1701c069b342..b9d5cc465ccf 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-paste.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-paste.ts @@ -3,7 +3,6 @@ import { useNativeChatPasteLifetime } from './use-native-chat-paste-lifetime' import { nativeChatAttachmentOwnerUnchanged } from './native-chat-resolved-path-ownership' import { assertClipboardTextWithinLimit } from '../../../../shared/clipboard-text' import { setNativeChatPasteFailure } from './native-chat-composer-notice' -import type { AgentType } from '../../../../shared/agent-status-types' import { NATIVE_CHAT_CONTEXT_PASTE_MAX_BYTES } from './native-chat-composer-target' import { nativeChatPasteUnavailableNotice } from '@/lib/native-chat-paste-request' import { @@ -22,33 +21,12 @@ import { saveNativeChatClipboardImage } from './native-chat-clipboard-image-save' -export type UseNativeChatComposerPasteArgs = { - targetKey?: string - agent: AgentType - /** Live composer-disabled state (no pty / presence-lock); read at await-resume - * via a ref so a flip mid-paste doesn't write into a guarded composer. */ - disabled: boolean - caret: number - /** Resolved at paste time: SSH panes must save the clipboard image on the - * remote host, or the attached path names a file the agent cannot read. */ - resolveAttachmentOwner: () => NativeChatAttachmentOwner - attachResolvedPaths: (paths: string[], connectionId?: string | null) => void - beginPendingImageAttachment: ( - previewUrl?: string, - pendingName?: string, - options?: { hidden?: true } - ) => string | null - resolvePendingImageAttachment: (id: string, path: string, connectionId?: string | null) => void - /** Shows a chip begun hidden, with its thumbnail. */ - revealPendingImageAttachment?: (id: string, previewUrl?: string) => void - dropPendingImageAttachment: (id: string) => void - insertTypedText: (text: string) => boolean - setCaret: (caret: number) => void - setNotice: (notice: string | null, errorText?: string) => void -} +import type { UseNativeChatComposerPasteArgs } from './native-chat-composer-paste-options' +export type { UseNativeChatComposerPasteArgs } from './native-chat-composer-paste-options' export function useNativeChatComposerPaste({ targetKey, + attachmentScopeKey, disabled, caret, resolveAttachmentOwner, @@ -71,6 +49,7 @@ export function useNativeChatComposerPaste({ const { lifetime, track, startImageChip, keepStoreUploadAfterUnmount } = useNativeChatPasteLifetime({ targetKey, + attachmentScopeKey, beginPendingImageAttachment, resolvePendingImageAttachment, revealPendingImageAttachment, @@ -83,6 +62,15 @@ export function useNativeChatComposerPaste({ setNotice(nativeChatPasteUnavailableNotice()) } }, [lifetime, setNotice]) + const dropImagePaste = useCallback( + (pendingId: string | null) => { + if (pendingId) { + lifetime.pending.delete(pendingId) + dropPendingImageAttachment(pendingId) + } + }, + [lifetime, dropPendingImageAttachment] + ) // Image failures do not decide whether text can be inserted. Beside pasted text, a server too old // to store the image drops only the image rendition, as an owner that takes no images does. @@ -112,15 +100,11 @@ export function useNativeChatComposerPaste({ [canPaste, setNotice] ) - /** Settle the chip started at paste time, or attach directly when the paste - * produced no placeholder (no clipboard preview was available). */ + /** Settle the operation started at paste time, or attach directly if chips are unavailable. */ const settleImagePaste = useCallback( (pendingId: string | null, path: string, originalOwner: NativeChatAttachmentOwner) => { if (!nativeChatAttachmentOwnerUnchanged(originalOwner, resolveAttachmentOwner())) { - if (pendingId) { - lifetime.pending.delete(pendingId) - dropPendingImageAttachment(pendingId) - } + dropImagePaste(pendingId) setNotice(nativeChatWorktreeNotReadyNotice()) return } @@ -135,7 +119,7 @@ export function useNativeChatComposerPaste({ [ attachResolvedPaths, lifetime, - dropPendingImageAttachment, + dropImagePaste, resolveAttachmentOwner, resolvePendingImageAttachment, setNotice @@ -185,28 +169,23 @@ export function useNativeChatComposerPaste({ // Why: snapshot the caret before the async temp-file round-trip — `caret` // state can move (further typing/selection) while the await is in flight. const caretAtPaste = caret - // Beside pasted text, a server too old to store the image drops it quietly, so its chip stays - // out of sight until the server answers; Send waits for it from the start all the same. + // Wait for server acceptance before minting a preview; the operation is visible from the start. const awaitServer = Boolean(text) && owner.kind === 'runtime-session' - const chip = startImageChip(owner, imageFile, { - hidden: awaitServer, + const { id: pendingId, reveal } = startImageChip(owner, imageFile, { + deferPreview: awaitServer, canShow: () => ownerAcceptsClipboardImage(owner) && canPaste() }) - const pendingId = chip.id void (async () => { const saved = await saveClipboardImageForOwner( owner, async () => Boolean(text), - awaitServer ? chip.reveal : undefined + awaitServer ? reveal : undefined ) if (keepStoreUploadAfterUnmount(pendingId, saved)) { return } if (saved.status !== 'saved' || !canPaste()) { - if (pendingId) { - lifetime.pending.delete(pendingId) - dropPendingImageAttachment(pendingId) - } + dropImagePaste(pendingId) return } settleImagePaste(pendingId, saved.tempPath, owner) @@ -217,9 +196,8 @@ export function useNativeChatComposerPaste({ }, [ canPaste, - lifetime, caret, - dropPendingImageAttachment, + dropImagePaste, insertTypedText, showPasteUnavailable, resolveAttachmentOwner, @@ -278,26 +256,44 @@ export function useNativeChatComposerPaste({ } return } - const thumbnailPromise = window.api.ui.readClipboardImageThumbnail().catch(() => null) - const savePromise = saveClipboardImageForOwner(owner, async () => { - const read = await textRead - return Boolean(read?.text) && read?.labelsFiles !== true + const hasImage = await window.api.ui.clipboardHasImage().catch((error) => { + if (canPaste()) { + setNativeChatPasteFailure(setNotice, error) + } + return null }) - const thumbnail = await thumbnailPromise - const pendingId = - thumbnail && canPaste() ? beginPendingImageAttachment(thumbnail.dataUrl) : null + if (hasImage !== true) { + const read = await textRead + if (read?.labelsFiles) { + insertText(read.text) + } + return + } + if (!canPaste()) { + return + } + // Ownership starts before saving; a thumbnail only supplies its preview. + const pendingId = beginPendingImageAttachment() if (pendingId) { - track(pendingId, thumbnail?.dataUrl ?? '', owner) + track(pendingId, '', owner) } - const saved = await savePromise + void window.api.ui + .readClipboardImageThumbnail() + .catch(() => null) + .then((thumbnail) => { + if (pendingId && thumbnail) { + revealPendingImageAttachment?.(pendingId, thumbnail.dataUrl) + } + }) + const saved = await saveClipboardImageForOwner(owner, async () => { + const read = await textRead + return Boolean(read?.text) && read?.labelsFiles !== true + }) if (keepStoreUploadAfterUnmount(pendingId, saved)) { return } if (!canPaste() || saved.status !== 'saved') { - if (pendingId) { - lifetime.pending.delete(pendingId) - dropPendingImageAttachment(pendingId) - } + dropImagePaste(pendingId) // A file's label is typed only when no image came with it. const read = saved.status === 'empty' ? await textRead : null if (read?.labelsFiles) { @@ -309,9 +305,9 @@ export function useNativeChatComposerPaste({ })() }, [ beginPendingImageAttachment, + revealPendingImageAttachment, canPaste, - lifetime, - dropPendingImageAttachment, + dropImagePaste, insertTypedText, keepStoreUploadAfterUnmount, track, diff --git a/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx index 74d257e5af1a..f92095aaad12 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx @@ -587,9 +587,9 @@ describe('useNativeChatExternalAttachments', () => { '/srv/agent-session-attachments/u1/shot.png', null ) - expect(chips.drop).toHaveBeenCalledExactlyOnceWith('chip-2') + expect(chips.drop).not.toHaveBeenCalled() expect(chips.attachReferences).toHaveBeenCalledExactlyOnceWith([ - '/srv/agent-session-attachments/u2/notes.md' + { id: 'chip-2', path: '/srv/agent-session-attachments/u2/notes.md' } ]) // The client never reads its own disk for these paths: the server stores the bytes. expect(mocks.stat).not.toHaveBeenCalled() @@ -653,11 +653,11 @@ describe('useNativeChatExternalAttachments', () => { ) ) expect(chips.attachReferences).toHaveBeenCalledExactlyOnceWith([ - '/srv/agent-session-attachments/u1/notes.md' + { id: 'chip-1', path: '/srv/agent-session-attachments/u1/notes.md' } ]) }) - it('does not insert a file whose chip the user removed while it uploaded', async () => { + it('keeps each file operation ID through reference settlement, including removed chips', async () => { const upload = deferred() mocks.uploadNativeChatSessionAttachmentPaths.mockReturnValueOnce(upload.promise) const chips = trackingChips() @@ -681,7 +681,8 @@ describe('useNativeChatExternalAttachments', () => { ) expect(chips.attachReferences).toHaveBeenCalledExactlyOnceWith([ - '/srv/agent-session-attachments/u2/notes.md' + { id: 'chip-1', path: '/srv/agent-session-attachments/u1/report.pdf' }, + { id: 'chip-2', path: '/srv/agent-session-attachments/u2/notes.md' } ]) }) @@ -709,7 +710,7 @@ describe('useNativeChatExternalAttachments', () => { expect(chips.drop).toHaveBeenCalledWith('chip-1') expect(chips.attachReferences).toHaveBeenCalledExactlyOnceWith([ - '/srv/agent-session-attachments/u2/notes.md' + { id: 'chip-2', path: '/srv/agent-session-attachments/u2/notes.md' } ]) // The cause, the size limit here, rides in the same notice. expect(notices).toEqual([ diff --git a/src/renderer/src/components/native-chat/use-native-chat-menu-paste-lifetime.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-menu-paste-lifetime.test.tsx new file mode 100644 index 000000000000..cbfed516c277 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-menu-paste-lifetime.test.tsx @@ -0,0 +1,273 @@ +// @vitest-environment happy-dom +import { createRef } from 'react' +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { useNativeChatComposerPaste } from './use-native-chat-composer-paste' +import { useNativeChatComposerAttachments } from './use-native-chat-composer-attachments' +import type { NativeChatComposerInput } from './native-chat-composer-input' +import type { NativeChatAttachmentOwner } from './native-chat-attachment-upload' +import { nativeChatImageSendBlock } from './native-chat-image-reattach' +import { + clearNativeChatComposerDraftsForTests, + hydrateNativeChatComposerDrafts, + nativeChatComposerDraftWritesSettled, + readNativeChatComposerDraft, + setNativeChatComposerDraftOwnerResolver, + updateNativeChatComposerDraft, + structuredAgentSessionDraftScopeKey as scope +} from './native-chat-composer-draft-store' +import { + createMemoryNativeChatComposerDraftStorage, + setNativeChatComposerDraftStorageForTests +} from './native-chat-composer-draft-storage' +import { + clearNativeChatPendingAttachmentsForTests, + dropNativeChatPendingAttachmentsOwnedBy, + nativeChatPendingAttachmentSnapshot +} from './native-chat-pending-attachment-cache' + +let previousApi: typeof window.api +const draftOwner = { workspaceId: 'folder-1', executionHostId: 'local' as const } +beforeEach(async () => { + previousApi = window.api + setNativeChatComposerDraftStorageForTests(createMemoryNativeChatComposerDraftStorage()) + await hydrateNativeChatComposerDrafts() + setNativeChatComposerDraftOwnerResolver(() => draftOwner) +}) +afterEach(async () => { + vi.useRealTimers() + cleanup() + await nativeChatComposerDraftWritesSettled() + clearNativeChatPendingAttachmentsForTests() + clearNativeChatComposerDraftsForTests() + window.api = previousApi +}) + +async function fixture( + kind: 'local' | 'ssh', + acceptsImages = true, + source: 'menu' | 'event' = 'menu' +) { + const save = Promise.withResolvers() + const thumbnail = Promise.withResolvers<{ + dataUrl: string + width: number + height: number + } | null>() + const owner: NativeChatAttachmentOwner = + kind === 'ssh' + ? { + kind: 'ssh', + connectionId: 'ssh-1', + worktreePath: '/remote/folder', + expectedExecutionHostId: 'ssh:ssh-1', + expectedSshTargetId: 'ssh-1', + expectedSshConnectionGeneration: 1 + } + : { kind: 'local' } + const path = + kind === 'ssh' + ? '/tmp/orca-paste-1-image.png' + : '/local/native-chat-pastes/orca-paste-1-image.png' + const saveCall = vi.fn(() => save.promise) + Object.defineProperty(window, 'api', { + configurable: true, + writable: true, + value: { + ui: { + readClipboardText: async () => '', + readClipboardFilePaths: async () => [], + clipboardHasImage: async () => true, + readClipboardImageThumbnail: () => thumbnail.promise, + saveClipboardImageAsTempFile: saveCall + } + } + }) + const input = createRef() + const view = renderHook(() => { + const attachments = useNativeChatComposerAttachments({ + attachmentScopeKey: scope('menu'), + allowWithoutTarget: true, + acceptsImages, + caret: 0, + disabled: false, + isComposing: () => false, + resolveTarget: () => null, + textareaRef: input, + setCaret: () => {}, + setDraft: (update) => + updateNativeChatComposerDraft( + scope('menu'), + { + text: update(readNativeChatComposerDraft(scope('menu')).text) + }, + 'immediate' + ), + setNotice: () => {} + }) + const paste = useNativeChatComposerPaste({ + targetKey: 'menu', + attachmentScopeKey: scope('menu'), + agent: acceptsImages ? 'claude' : 'omp', + disabled: false, + caret: 0, + resolveAttachmentOwner: () => owner, + ...attachments, + insertTypedText: () => true, + setCaret: () => {}, + setNotice: () => {} + }) + return { paste, attachments } + }) + await act(async () => { + if (source === 'menu') { + view.result.current.paste.pasteFromClipboard() + } else { + const data = new DataTransfer() + data.items.add(new File(['png'], 'image.png', { type: 'image/png' })) + view.result.current.paste.handlePaste(new ClipboardEvent('paste', { clipboardData: data })) + } + }) + await vi.waitFor(() => + expect(saveCall).toHaveBeenCalledExactlyOnceWith( + kind === 'ssh' ? { connectionId: 'ssh-1' } : { forNativeChatDraft: true } + ) + ) + return { view, save, thumbnail, path } +} + +const referencePastes = [ + { kind: 'local', source: 'menu' }, + { kind: 'ssh', source: 'menu' }, + { kind: 'local', source: 'event' }, + { kind: 'ssh', source: 'event' } +] as const + +it.each( + referencePastes.flatMap((paste) => + [true, false].map((acceptsImages) => ({ ...paste, acceptsImages })) + ) +)( + 'can abandon stalled $kind $source operations after reopen with acceptsImages=$acceptsImages', + async ({ kind, source, acceptsImages }) => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + const { view, save, thumbnail, path } = await fixture(kind, acceptsImages, source) + view.unmount() + const reopened = renderHook(() => + useNativeChatComposerAttachments({ + attachmentScopeKey: scope('menu'), + acceptsImages, + allowWithoutTarget: true, + caret: 0, + disabled: false, + isComposing: () => false, + resolveTarget: () => null, + textareaRef: createRef(), + setCaret: () => {}, + setDraft: () => {}, + setNotice: () => {} + }) + ) + await act(async () => vi.advanceTimersByTimeAsync(24 * 60 * 60 * 1000)) + expect(reopened.result.current.imageAttachments).toHaveLength(1) + expect(nativeChatImageSendBlock(reopened.result.current.imageAttachments).holdsSend).toBe(true) + const id = reopened.result.current.imageAttachments[0].id + await act(async () => reopened.result.current.removeImageAttachment(id)) + expect(nativeChatImageSendBlock(reopened.result.current.imageAttachments).holdsSend).toBe(false) + await act(async () => save.resolve(path)) + await act(async () => + thumbnail.resolve({ dataUrl: 'data:image/png;base64,AA', width: 1, height: 1 }) + ) + expect(nativeChatPendingAttachmentSnapshot(scope('menu'))).toEqual([]) + expect(readNativeChatComposerDraft(scope('menu')).images).toEqual([]) + expect(readNativeChatComposerDraft(scope('menu')).text).toBe('') + } +) +it.each(referencePastes)( + 'keeps a $kind $source paste as a reference after close', + async ({ kind, source }) => { + const { view, save, thumbnail, path } = await fixture(kind, false, source) + await act(async () => + thumbnail.resolve({ dataUrl: 'data:image/png;base64,AA', width: 1, height: 1 }) + ) + expect(view.result.current.attachments.imageAttachments).toHaveLength(1) + view.unmount() + await act(async () => save.resolve(path)) + expect(nativeChatPendingAttachmentSnapshot(scope('menu'))).toEqual([]) + expect(readNativeChatComposerDraft(scope('menu')).text).toContain(path) + expect(readNativeChatComposerDraft(scope('menu')).images).toEqual([]) + } +) +it.each(referencePastes)( + 'keeps mounted $kind $source paste references without image chips', + async ({ kind, source }) => { + const { save, thumbnail, path } = await fixture(kind, false, source) + await act(async () => thumbnail.resolve(null)) + await act(async () => save.resolve(path)) + expect(readNativeChatComposerDraft(scope('menu')).text).toContain(path) + expect(readNativeChatComposerDraft(scope('menu')).images).toEqual([]) + } +) + +it.each(['local', 'ssh'] as const)( + 'preserves menu %s uploads closed before a delayed or absent thumbnail', + async (kind) => { + let completed = 0 + for (const preview of ['delayed', 'missing', 'present'] as const) { + const { view, save, thumbnail, path } = await fixture(kind) + if (preview !== 'delayed') { + await act(async () => + thumbnail.resolve( + preview === 'present' + ? { dataUrl: 'data:image/png;base64,AA', width: 1, height: 1 } + : null + ) + ) + } + view.unmount() + await act(async () => save.resolve(path)) + expect(nativeChatPendingAttachmentSnapshot(scope('menu'))).toEqual([]) + expect(readNativeChatComposerDraft(scope('menu')).images).toHaveLength(++completed) + expect(readNativeChatComposerDraft(scope('menu')).images.at(-1)).toMatchObject({ + path, + ...(kind === 'ssh' ? { connectionId: 'ssh-1' } : {}) + }) + // The save also settles when a thumbnail has not answered at all. + if (preview === 'delayed') { + await act(async () => thumbnail.resolve(null)) + } + } + } +) + +it.each(['local', 'ssh'] as const)( + 'settles menu %s images without a thumbnail while mounted', + async (kind) => { + const { save, thumbnail, path } = await fixture(kind) + await act(async () => thumbnail.resolve(null)) + await act(async () => save.resolve(path)) + expect(readNativeChatComposerDraft(scope('menu')).images[0]).toMatchObject({ path }) + } +) + +it.each([true, false])( + 'ends pending operations for acceptsImages=%s on removal, deletion or failure', + async (acceptsImages) => { + for (const end of ['remove', 'workspace', 'failure'] as const) { + const { view, save, thumbnail, path } = await fixture('local', acceptsImages) + if (end === 'remove') { + await act(async () => view.result.current.attachments.clearImageAttachments()) + } else if (end === 'workspace') { + dropNativeChatPendingAttachmentsOwnedBy(draftOwner) + } + view.unmount() + await act(async () => save.resolve(end === 'failure' ? null : path)) + await act(async () => + thumbnail.resolve({ dataUrl: 'data:image/png;base64,AA', width: 1, height: 1 }) + ) + expect(nativeChatPendingAttachmentSnapshot(scope('menu'))).toEqual([]) + expect(readNativeChatComposerDraft(scope('menu')).images).toEqual([]) + expect(readNativeChatComposerDraft(scope('menu')).text).toBe('') + } + } +) diff --git a/src/renderer/src/components/native-chat/use-native-chat-paste-lifetime.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-paste-lifetime.test.tsx new file mode 100644 index 000000000000..59a8d22d0f9f --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-paste-lifetime.test.tsx @@ -0,0 +1,141 @@ +// @vitest-environment happy-dom +import { cleanup, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, expect, it } from 'vitest' +import { useNativeChatPasteLifetime } from './use-native-chat-paste-lifetime' +import { + clearNativeChatComposerDraftsForTests, + hydrateNativeChatComposerDrafts, + nativeChatComposerDraftWritesSettled, + readNativeChatComposerDraft, + setNativeChatComposerDraftOwnerResolver, + structuredAgentSessionDraftScopeKey as scope +} from './native-chat-composer-draft-store' +import { + type NativeChatComposerDraftOwner, + createMemoryNativeChatComposerDraftStorage, + setNativeChatComposerDraftStorageForTests +} from './native-chat-composer-draft-storage' +import { + addNativeChatPendingAttachment, + clearNativeChatPendingAttachmentsForTests, + dropNativeChatPendingAttachmentsOwnedBy, + nativeChatPendingAttachmentSnapshot, + settleNativeChatPendingAttachment, + takeNativeChatPendingAttachment +} from './native-chat-pending-attachment-cache' +import type { NativeChatAttachmentOwner } from './native-chat-attachment-upload' + +const WORKSPACE: NativeChatComposerDraftOwner = { + workspaceId: 'workspace-1', + executionHostId: 'local' +} +const owners: NativeChatAttachmentOwner[] = [ + { kind: 'local' }, + { + kind: 'ssh', + connectionId: 'ssh-1', + worktreePath: '/remote/worktree', + expectedExecutionHostId: 'ssh:ssh-1', + expectedSshTargetId: 'ssh-1', + expectedSshConnectionGeneration: 1 + }, + { kind: 'runtime-session', environmentId: 'paired', sessionId: 'a', pairingRevision: 1 } +] + +beforeEach(async () => { + setNativeChatComposerDraftStorageForTests(createMemoryNativeChatComposerDraftStorage()) + await hydrateNativeChatComposerDrafts() + setNativeChatComposerDraftOwnerResolver(() => WORKSPACE) +}) +afterEach(async () => { + cleanup() + await nativeChatComposerDraftWritesSettled() + clearNativeChatPendingAttachmentsForTests() + clearNativeChatComposerDraftsForTests() +}) + +function fixture(owner: NativeChatAttachmentOwner) { + const view = renderHook( + ({ draftScope }) => + useNativeChatPasteLifetime({ + targetKey: draftScope, + attachmentScopeKey: draftScope, + beginPendingImageAttachment: () => 'upload', + resolvePendingImageAttachment: (id, path, connectionId) => { + settleNativeChatPendingAttachment(draftScope, id, path, connectionId) + }, + dropPendingImageAttachment: (id) => { + takeNativeChatPendingAttachment(draftScope, id) + } + }), + { initialProps: { draftScope: scope('a') } } + ) + addNativeChatPendingAttachment(scope('a'), { id: 'upload', path: '', pending: true }) + view.result.current.track('upload', '', owner) + return { view, lifetime: view.result.current } +} + +it.each(owners)('keeps a $kind upload when its composer closes', (owner) => { + const { view, lifetime } = fixture(owner) + view.unmount() + expect(nativeChatPendingAttachmentSnapshot(scope('a'))).toHaveLength(1) + expect( + lifetime.keepStoreUploadAfterUnmount('upload', { + status: 'saved', + tempPath: '/stored/image.png' + }) + ).toBe(true) + expect(nativeChatPendingAttachmentSnapshot(scope('a'))).toEqual([]) + expect(readNativeChatComposerDraft(scope('a')).images).toEqual([ + { + id: 'upload', + path: '/stored/image.png', + ...(owner.kind === 'ssh' ? { connectionId: 'ssh-1' } : {}) + } + ]) + expect(readNativeChatComposerDraft(scope('b')).images).toEqual([]) +}) + +it.each(owners)('keeps a $kind upload with its original scope after a target switch', (owner) => { + const { view, lifetime } = fixture(owner) + view.rerender({ draftScope: scope('b') }) + expect( + lifetime.keepStoreUploadAfterUnmount('upload', { + status: 'saved', + tempPath: '/stored/image.png' + }) + ).toBe(true) + expect(readNativeChatComposerDraft(scope('a')).images).toHaveLength(1) + expect(readNativeChatComposerDraft(scope('b')).images).toEqual([]) +}) + +it.each(owners)('does not settle a removed $kind upload', (owner) => { + const { view, lifetime } = fixture(owner) + takeNativeChatPendingAttachment(scope('a'), 'upload') + view.unmount() + lifetime.keepStoreUploadAfterUnmount('upload', { status: 'saved', tempPath: '/stored/image.png' }) + expect(readNativeChatComposerDraft(scope('a')).images).toEqual([]) +}) + +it.each(owners)('drops a failed $kind upload after teardown', (owner) => { + const { view, lifetime } = fixture(owner) + view.unmount() + expect(lifetime.keepStoreUploadAfterUnmount('upload', { status: 'error' })).toBe(true) + expect(nativeChatPendingAttachmentSnapshot(scope('a'))).toEqual([]) + expect(readNativeChatComposerDraft(scope('a')).images).toEqual([]) +}) + +it.each(owners)( + 'does not recreate a draft after its workspace is removed during a $kind upload', + (owner) => { + const { view, lifetime } = fixture(owner) + view.unmount() + dropNativeChatPendingAttachmentsOwnedBy(WORKSPACE) + lifetime.keepStoreUploadAfterUnmount('upload', { + status: 'saved', + tempPath: '/stored/image.png' + }) + expect(nativeChatPendingAttachmentSnapshot(scope('a'))).toEqual([]) + expect(readNativeChatComposerDraft(scope('a')).images).toEqual([]) + } +) diff --git a/src/renderer/src/components/native-chat/use-native-chat-paste-lifetime.ts b/src/renderer/src/components/native-chat/use-native-chat-paste-lifetime.ts index f801303dab14..41ee0f684381 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-paste-lifetime.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-paste-lifetime.ts @@ -1,19 +1,14 @@ import { useCallback, useLayoutEffect, useMemo, useRef } from 'react' import type { NativeChatAttachmentOwner } from './native-chat-attachment-upload' +import { nativeChatPendingAttachmentSnapshot } from './native-chat-pending-attachment-cache' /** - * The pending chips of pastes this composer started, released when it unmounts or changes target. - * A paste uploading into a paired server's store outlives the composer, which a prompt card - * unmounts: its chip stays pending in the scope's attachment cache, where its result settles for - * the composer's return (`native-chat-pending-attachment-cache.ts`). + * Preview URLs belong to this composer; pending uploads belong to the draft's attachment cache. */ export function useNativeChatPasteLifetime(args: { targetKey?: string - beginPendingImageAttachment: ( - previewUrl?: string, - pendingName?: string, - options?: { hidden?: true } - ) => string | null + attachmentScopeKey?: string + beginPendingImageAttachment: (previewUrl?: string, pendingName?: string) => string | null /** Files the result into the composer's scope even once this instance is unmounted. */ resolvePendingImageAttachment: (id: string, path: string, connectionId?: string | null) => void revealPendingImageAttachment?: (id: string, previewUrl?: string) => void @@ -21,19 +16,23 @@ export function useNativeChatPasteLifetime(args: { }): { lifetime: { active: boolean; pending: Map } track: (pendingId: string, preview: string, owner: NativeChatAttachmentOwner) => void - /** A paste's chip, shown at once with the clipboard's own thumbnail, or held out of sight - * (`hidden`) until `reveal`; `id` is null when the composer refused it. */ + /** Register the visible operation; `reveal` supplies its preview once the host accepts it. */ startImageChip: ( owner: NativeChatAttachmentOwner, imageFile: Blob, - options: { hidden: boolean; canShow: () => boolean } + options: { deferPreview: boolean; canShow: () => boolean } ) => { id: string | null; reveal: () => void } keepStoreUploadAfterUnmount: ( pendingId: string | null, saved: { status: string; tempPath?: string } ) => boolean } { - const { targetKey, beginPendingImageAttachment, revealPendingImageAttachment } = args + const { + targetKey, + attachmentScopeKey, + beginPendingImageAttachment, + revealPendingImageAttachment + } = args const { resolvePendingImageAttachment, dropPendingImageAttachment } = args const dropPendingRef = useRef(dropPendingImageAttachment) useLayoutEffect(() => { @@ -42,11 +41,12 @@ export function useNativeChatPasteLifetime(args: { const lifetime = useMemo( () => ({ targetKey, + attachmentScopeKey, active: false, pending: new Map(), - storeUploads: new Set() + uploads: new Map() }), - [targetKey] + [attachmentScopeKey, targetKey] ) useLayoutEffect(() => { lifetime.active = true @@ -56,19 +56,23 @@ export function useNativeChatPasteLifetime(args: { if (preview.startsWith('blob:')) { URL.revokeObjectURL(preview) } - if (!lifetime.storeUploads.has(id)) { + if ( + lifetime.uploads.get(id)?.kind !== 'runtime-session' && + !( + attachmentScopeKey && + nativeChatPendingAttachmentSnapshot(attachmentScopeKey).some((chip) => chip.id === id) + ) + ) { dropPendingRef.current(id) } } lifetime.pending.clear() } - }, [lifetime]) + }, [attachmentScopeKey, lifetime]) const track = useCallback( (pendingId: string, preview: string, owner: NativeChatAttachmentOwner) => { lifetime.pending.set(pendingId, preview) - if (owner.kind === 'runtime-session') { - lifetime.storeUploads.add(pendingId) - } + lifetime.uploads.set(pendingId, owner) }, [lifetime] ) @@ -76,16 +80,22 @@ export function useNativeChatPasteLifetime(args: { ( owner: NativeChatAttachmentOwner, imageFile: Blob, - options: { hidden: boolean; canShow: () => boolean } + options: { deferPreview: boolean; canShow: () => boolean } ): { id: string | null; reveal: () => void } => { - if (options.hidden) { - const id = beginPendingImageAttachment(undefined, undefined, { hidden: true }) + if (options.deferPreview) { + const id = beginPendingImageAttachment() if (id) { track(id, '', owner) } // Through the scope's cache, so it shows even in a composer that came back since. const reveal = (): void => { - if (!id) { + if ( + !id || + (attachmentScopeKey && + !nativeChatPendingAttachmentSnapshot(attachmentScopeKey).some( + (chip) => chip.id === id + )) + ) { return } const previewUrl = lifetime.active ? URL.createObjectURL(imageFile) : undefined @@ -108,21 +118,42 @@ export function useNativeChatPasteLifetime(args: { } return { id, reveal: () => {} } }, - [beginPendingImageAttachment, lifetime, revealPendingImageAttachment, track] + [attachmentScopeKey, beginPendingImageAttachment, lifetime, revealPendingImageAttachment, track] ) const keepStoreUploadAfterUnmount = useCallback( (pendingId: string | null, saved: { status: string; tempPath?: string }): boolean => { - if (!pendingId || lifetime.active || !lifetime.storeUploads.has(pendingId)) { + if (!pendingId || lifetime.active) { + if (pendingId) { + lifetime.uploads.delete(pendingId) + } + return false + } + const owner = lifetime.uploads.get(pendingId) + if ( + owner?.kind !== 'runtime-session' && + !( + attachmentScopeKey && + nativeChatPendingAttachmentSnapshot(attachmentScopeKey).some( + (chip) => chip.id === pendingId + ) + ) + ) { + lifetime.uploads.delete(pendingId) return false } + lifetime.uploads.delete(pendingId) if (saved.status === 'saved' && saved.tempPath) { - resolvePendingImageAttachment(pendingId, saved.tempPath, null) + resolvePendingImageAttachment( + pendingId, + saved.tempPath, + owner?.kind === 'ssh' ? owner.connectionId : null + ) } else { dropPendingImageAttachment(pendingId) } return true }, - [dropPendingImageAttachment, lifetime, resolvePendingImageAttachment] + [attachmentScopeKey, dropPendingImageAttachment, lifetime, resolvePendingImageAttachment] ) return { lifetime, track, startImageChip, keepStoreUploadAfterUnmount } } From 6b43714c54fd3f5cb4e5b1c3460085f6128c9bd3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 8 Oct 2026 10:39:19 -0700 Subject: [PATCH 09/90] feat(agent-sessions): rename "Continue in New Session" to "Hand Off to Another Agent" (#26375) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(session-history): resume a native chat's conversation in a new CLI session Agent Session History offered "Resume in New Chat" to move a terminal session into native chat, but nothing went the other way: once a native chat owned a conversation, its row only reopened the chat, and Orca refused any terminal resume of it so two programs never write one conversation. Rows a native chat owns (Claude, Codex) now offer "Resume in New CLI". It forks: the terminal runs `claude --resume --fork-session` or `codex fork `, so the CLI gets the full history under a new id and the chat is unchanged. The host's resume guard lets a Claude fork through, and the fork tab records no provider session, so a restart never re-enters the chat's conversation. "Resume in New Chat" is renamed "Resume in New Native Chat". * fix(session-history): size the Resume in New CLI tooltip to its text The tooltip balanced one wrapped string inside a box sized before balancing, leaving a gap beside its lines. It is now two block lines with no fixed width. * feat(agent-sessions): rename "Continue in New Session" to "Hand Off to Another Agent" Agent Session History rows now offer "Resume in New Native Chat", "Resume in New CLI" and "Continue in New Session…", which read as three versions of one action. Only the first two carry the same conversation; the third starts a new session, often with another agent, seeded with a summary or the transcript. Rename it everywhere it appears (history sidebar, terminal pane header and menu, native chat menu, the dialog title) and, in the history row menus, move it below "Copy Resume Command" behind a separator so the Resume items read as one group. * fix(session-history): run a CLI fork under the selected Codex account and tighten the fork guard - Resume in New CLI now asks the host for the same Codex account home plain Resume uses, so a fork no longer starts under whichever account's copy of the rollout sorted first. The preparation call carries an optional `fork` flag that skips only the owned-conversation refusal; plain resume still refuses. - An older paired host that refuses the fork now shows "Update Orca on the host that runs this chat to resume it in a new CLI." instead of a raw error code. - The host guard admits `--fork-session` only before a `--` terminator and never alongside `--session-id`, since either shape lets Claude write the owned conversation. - Tests for the click path, the menu item's eligibility, the fork home preparation, and the tightened guard. * fix(session-history): read a Codex rollout's identity from its first session record A fork of an older (non-paginated) Codex thread writes its own session record first and then copies the parent's history, the parent's session record included. The history scanner kept the last one it saw, so the fork's row took the parent's id, title, folder and branch: on a native chat's fork it looked like the chat itself, and Resume on it opened the chat instead of the fork. Codex itself treats the first session record as the thread's own and ignores later ones; the scanner now does the same, as Orca's duplicate-rollout check already did. * fix(session-history): set up status hooks in the Codex home a CLI fork keeps When Resume in New CLI can't move a Codex fork to the selected account (that account has no copy of the conversation yet, or the system default is selected), the fork runs in the account home the history row came from. A new Codex tab only gets Orca's status hooks in the selected account's home, and the fork carries no provider session, so nothing set up the hooks in that row home. In a home only native chat had used, the fork tab could show no working or idle status. The fork's home preparation now readies that home the same way a resumed session's home is readied (hook install or refresh, plus the daemon-socket guard). That code is moved into prepareCodexPinnedLaunchHome and shared by both paths. The fork tab still carries no provider session, so it never reopens the chat's conversation on restart. * fix(session-history): ready only a host-verified Codex home for a CLI fork The fork's hook setup ran in whatever Codex home the request named, so a paired client could have Orca write hook and config files into any directory. Plain Resume only readies a home it has verified: one this host lists for Codex sessions that actually holds the rollout. The fork now uses that same check, through a shared trustedCodexResumeHomes list, and readies the home only when the verified home is the one the fork will run in. With no Codex runtime home service it does nothing, as plain Resume does. A failed check or setup is logged and never stops the fork from launching. Tests cover the untrusted home, a missing runtime home service, a rollout on another host, a WSL home, a fork moved into the real home, and a failed setup. * fix(session-history): show the host-update message when the desktop wraps the fork refusal When the local preparation step for Resume in New CLI is refused, Electron hands the renderer the error as "Error invoking remote method '…': Error: agent_session_conflict". The fork's failure text matched only the bare code, so the toast showed that raw line instead of asking the user to update Orca on the host. It now unwraps the message with the shared IPC error helper before matching. * test(agent-session-history): cover Hand Off placement and separator; rename Codex docs heading * fix(agent-session-history): drop the ellipsis from the Hand Off to Another Agent label --- docs/site/content/docs/agents/codex.mdx | 4 +- .../AgentSessionContinuationDialog.tsx | 2 +- .../use-native-chat-context-menu.tsx | 4 +- .../AiVaultSessionActionMenuItems.test.tsx | 63 +++++++++++++++++++ .../AiVaultSessionActionMenuItems.tsx | 30 ++++++--- .../right-sidebar/AiVaultSessionDetails.tsx | 4 +- .../SessionRowTrailingActions.test.tsx | 2 +- .../SessionRowTrailingActions.tsx | 8 +-- .../AgentSessionContinuationMenuItem.tsx | 4 +- .../TerminalContextMenu.test.tsx | 2 +- .../TerminalPaneHeaderOverlay.test.tsx | 2 +- .../TerminalPaneHeaderOverlay.tsx | 8 +-- src/renderer/src/i18n/locales/en.json | 4 +- src/renderer/src/i18n/locales/es.json | 4 +- src/renderer/src/i18n/locales/fr.json | 4 +- src/renderer/src/i18n/locales/ja.json | 4 +- src/renderer/src/i18n/locales/ko.json | 4 +- src/renderer/src/i18n/locales/zh.json | 4 +- .../src/lib/agent-launch-route-input.test.ts | 2 +- 19 files changed, 117 insertions(+), 42 deletions(-) create mode 100644 src/renderer/src/components/right-sidebar/AiVaultSessionActionMenuItems.test.tsx diff --git a/docs/site/content/docs/agents/codex.mdx b/docs/site/content/docs/agents/codex.mdx index 80b8ca1cd0c8..5ecf8281d2c7 100644 --- a/docs/site/content/docs/agents/codex.mdx +++ b/docs/site/content/docs/agents/codex.mdx @@ -26,9 +26,9 @@ Many Codex users run multiple accounts to stretch rate limits. Orca's account sw When Codex spawns Task subagents, Orca can show them as child rows under the parent agent on the worktree agent list and [Agent Dashboard](/docs/model/agents-sessions#agent-dashboard). Expand the chevron to see each child; click a child to focus the parent terminal (subagents do not own a separate pane). -## Continue in a new session +## Hand off to another agent -From an agent terminal's header or context menu, choose **Continue in New Session…**. Orca starts a fresh agent session (same or different CLI) and injects a bounded handoff prompt from the prior transcript or captured context. The original session is left alone — this is not `codex resume`. +From an agent terminal's header or context menu, choose **Hand Off to Another Agent**. Orca starts a fresh agent session (same or different CLI) and injects a bounded handoff prompt from the prior transcript or captured context. The original session is left alone — this is not `codex resume`. ## Restart chip diff --git a/src/renderer/src/components/agent-session-continuation/AgentSessionContinuationDialog.tsx b/src/renderer/src/components/agent-session-continuation/AgentSessionContinuationDialog.tsx index 1f7b2ebb391e..96d802da2cdd 100644 --- a/src/renderer/src/components/agent-session-continuation/AgentSessionContinuationDialog.tsx +++ b/src/renderer/src/components/agent-session-continuation/AgentSessionContinuationDialog.tsx @@ -164,7 +164,7 @@ export function AgentSessionContinuationDialog({ {translate( 'components.agentSessionContinuation.dialogTitle', - 'Continue in New Session' + 'Hand Off to Another Agent' )} diff --git a/src/renderer/src/components/native-chat/use-native-chat-context-menu.tsx b/src/renderer/src/components/native-chat/use-native-chat-context-menu.tsx index fcbf30d1436f..0819e50adda9 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-context-menu.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-context-menu.tsx @@ -251,8 +251,8 @@ export function useNativeChatContextMenu({ {translate( - 'components.agentSessionContinuation.continueInNewSession', - 'Continue in New Session…' + 'components.agentSessionContinuation.handOffToAnotherAgent', + 'Hand Off to Another Agent' )} ) : null} diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionActionMenuItems.test.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionActionMenuItems.test.tsx new file mode 100644 index 000000000000..2d4be528c520 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionActionMenuItems.test.tsx @@ -0,0 +1,63 @@ +// @vitest-environment happy-dom +import type { ComponentProps } from 'react' +import { cleanup, render } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { TooltipProvider } from '@/components/ui/tooltip' +import { SessionActionMenuItems } from './AiVaultSessionActionMenuItems' + +afterEach(cleanup) + +const HAND_OFF = 'Hand Off to Another Agent' +const SEPARATOR = '---' + +function renderMenuOrder( + props: Partial> = {} +): string[] { + render( + + + Open + + + + + + ) + return Array.from(document.querySelectorAll('[role="menuitem"], [role="separator"]')).map( + (node) => (node.getAttribute('role') === 'separator' ? SEPARATOR : (node.textContent ?? '')) + ) +} + +describe('SessionActionMenuItems hand-off placement', () => { + it('places Hand Off after Copy Resume Command, behind a separator', () => { + const order = renderMenuOrder({ onCopyResume: vi.fn() }) + const handOff = order.indexOf(HAND_OFF) + + expect(order.slice(0, handOff + 1)).toEqual([ + 'Resume in New Tab', + 'Copy Resume Command', + SEPARATOR, + HAND_OFF + ]) + }) + + it('omits the separator when nothing renders above Hand Off', () => { + const order = renderMenuOrder({ resumeHidden: true }) + + expect(order[0]).toBe(HAND_OFF) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionActionMenuItems.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionActionMenuItems.tsx index 5f0b6f403378..41500b301008 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionActionMenuItems.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionActionMenuItems.tsx @@ -64,6 +64,15 @@ export function SessionActionMenuItems({ const Item = menuKind === 'context' ? ContextMenuItem : DropdownMenuItem const Separator = menuKind === 'context' ? ContextMenuSeparator : DropdownMenuSeparator const hasLocalPathActions = Boolean(onOpenLog || onRevealLog || onOpenCwd) + // Why: Jump/Resume items act on this conversation; the hand-off starts a new one, so it sits apart. + const hasItemsAboveHandOff = Boolean( + onJumpToOriginalPane || + showJumpToWorktree || + !resumeHidden || + onResumeInNewChat || + onResumeInNewCli || + onCopyResume + ) const deleteLabel = translate('auto.components.right.sidebar.AiVaultSessionRow.delete', 'Delete') const deleteItem = ( ) : null} - {onContinueInNewSession ? ( - - - {translate( - 'components.agentSessionContinuation.continueInNewSession', - 'Continue in New Session…' - )} - - ) : null} {onCopyResume ? ( @@ -148,6 +148,18 @@ export function SessionActionMenuItems({ )} ) : null} + {onContinueInNewSession ? ( + <> + {hasItemsAboveHandOff ? : null} + + + {translate( + 'components.agentSessionContinuation.handOffToAnotherAgent', + 'Hand Off to Another Agent' + )} + + + ) : null} {hasLocalPathActions ? ( <> diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx index 04e308d8bace..ab0856022c76 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx @@ -196,8 +196,8 @@ export function SessionInlineDetails({ > {translate( - 'components.agentSessionContinuation.continueInNewSession', - 'Continue in New Session…' + 'components.agentSessionContinuation.handOffToAnotherAgent', + 'Hand Off to Another Agent' )} ) : null} diff --git a/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.test.tsx b/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.test.tsx index 9e48965099b8..0e99b404c42d 100644 --- a/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.test.tsx +++ b/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.test.tsx @@ -35,7 +35,7 @@ describe('SessionRowTrailingActions', () => { const markup = renderActions(vi.fn()) expect(markup).toContain('data-testid="ai-vault-session-continue-in-new-session"') - expect(markup).toContain('aria-label="Continue in New Session…"') + expect(markup).toContain('aria-label="Hand Off to Another Agent"') // Why: edge-usage action lives in the hover group; the gating class is what // keeps it click-proof while the row is unhovered. const buttonMarkup = markup.split('data-testid="ai-vault-session-continue-in-new-session"')[0] diff --git a/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.tsx b/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.tsx index e93f3147850b..10c15ecf8356 100644 --- a/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.tsx +++ b/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.tsx @@ -180,8 +180,8 @@ export function SessionRowTrailingActions({ variant="ghost" size="icon-xs" aria-label={translate( - 'components.agentSessionContinuation.continueInNewSession', - 'Continue in New Session…' + 'components.agentSessionContinuation.handOffToAnotherAgent', + 'Hand Off to Another Agent' )} draggable={false} onClick={(event) => { @@ -196,8 +196,8 @@ export function SessionRowTrailingActions({ {translate( - 'components.agentSessionContinuation.continueInNewSession', - 'Continue in New Session…' + 'components.agentSessionContinuation.handOffToAnotherAgent', + 'Hand Off to Another Agent' )} diff --git a/src/renderer/src/components/terminal-pane/AgentSessionContinuationMenuItem.tsx b/src/renderer/src/components/terminal-pane/AgentSessionContinuationMenuItem.tsx index ae8340016bf1..726eecca6bc5 100644 --- a/src/renderer/src/components/terminal-pane/AgentSessionContinuationMenuItem.tsx +++ b/src/renderer/src/components/terminal-pane/AgentSessionContinuationMenuItem.tsx @@ -13,8 +13,8 @@ export function AgentSessionContinuationMenuItem({ {translate( - 'components.agentSessionContinuation.continueInNewSession', - 'Continue in New Session…' + 'components.agentSessionContinuation.handOffToAnotherAgent', + 'Hand Off to Another Agent' )} ) diff --git a/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx b/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx index 54e334098628..1e1ebccfe91f 100644 --- a/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx @@ -165,7 +165,7 @@ describe('TerminalContextMenu', () => { }) const handoffItem = items.list.find( - (item) => childrenText(item.children) === 'Continue in New Session…' + (item) => childrenText(item.children) === 'Hand Off to Another Agent' ) expect(handoffItem).toBeDefined() diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.test.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.test.tsx index a5444f44715f..006fbf05864b 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.test.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.test.tsx @@ -289,7 +289,7 @@ describe('TerminalPaneHeaderOverlay', () => { onContinueAgentSessionInNewSession }) const handoff = container.querySelector( - 'button[aria-label="Continue in New Session…"]' + 'button[aria-label="Hand Off to Another Agent"]' ) expect(handoff).not.toBeNull() diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.tsx index fef4e48ae591..f15f69548220 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.tsx @@ -235,8 +235,8 @@ export default function TerminalPaneHeaderOverlay({ size="icon-xs" className="pane-title-split-trigger" aria-label={translate( - 'components.agentSessionContinuation.continueInNewSession', - 'Continue in New Session…' + 'components.agentSessionContinuation.handOffToAnotherAgent', + 'Hand Off to Another Agent' )} onClick={(event) => { event.stopPropagation() @@ -248,8 +248,8 @@ export default function TerminalPaneHeaderOverlay({ {translate( - 'components.agentSessionContinuation.continueInNewSession', - 'Continue in New Session…' + 'components.agentSessionContinuation.handOffToAnotherAgent', + 'Hand Off to Another Agent' )} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 6f82ff57f19a..78ea24b02359 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18928,8 +18928,8 @@ } }, "agentSessionContinuation": { - "continueInNewSession": "Continue in New Session…", - "dialogTitle": "Continue in New Session", + "handOffToAnotherAgent": "Hand Off to Another Agent", + "dialogTitle": "Hand Off to Another Agent", "dialogDescription": "Start a fresh Agent session from this stopping point. The original session stays unchanged.", "untitledSession": "Current session", "originalAgent": "Original Agent: {{agent}}", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index ae84e19e609a..419d2b198311 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -18686,8 +18686,8 @@ } }, "agentSessionContinuation": { - "continueInNewSession": "Continuar en una sesión nueva…", - "dialogTitle": "Continuar en una sesión nueva", + "handOffToAnotherAgent": "Pasar a otro agente", + "dialogTitle": "Pasar a otro agente", "dialogDescription": "Inicia una sesión nueva del agente desde este punto. La sesión original no cambia.", "untitledSession": "Sesión actual", "originalAgent": "Agente original: {{agent}}", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 9499fcd818cb..32e40c276a9a 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -18787,8 +18787,8 @@ } }, "agentSessionContinuation": { - "continueInNewSession": "Continuer dans une nouvelle session…", - "dialogTitle": "Continuer dans une nouvelle session", + "handOffToAnotherAgent": "Transférer à un autre Agent", + "dialogTitle": "Transférer à un autre Agent", "dialogDescription": "Démarrez une nouvelle session d'Agent depuis ce point d'arrêt. La session d'origine reste inchangée.", "untitledSession": "Session actuelle", "originalAgent": "Agent d'origine : {{agent}}", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 94e3234e1a4e..77c15790b976 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -18721,8 +18721,8 @@ } }, "agentSessionContinuation": { - "continueInNewSession": "新規セッションで続ける…", - "dialogTitle": "新規セッションで続ける", + "handOffToAnotherAgent": "別の Agent に引き継ぐ", + "dialogTitle": "別の Agent に引き継ぐ", "dialogDescription": "この時点から新しい Agent セッションを開始します。元のセッションは変更されません。", "untitledSession": "現在のセッション", "originalAgent": "元の Agent: {{agent}}", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 27ca7a35e5da..72690cc48020 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -18721,8 +18721,8 @@ } }, "agentSessionContinuation": { - "continueInNewSession": "새 세션에서 계속…", - "dialogTitle": "새 세션에서 계속", + "handOffToAnotherAgent": "다른 에이전트에게 인계", + "dialogTitle": "다른 에이전트에게 인계", "dialogDescription": "이 지점부터 새 에이전트 세션을 시작합니다. 원래 세션은 변경되지 않습니다.", "untitledSession": "현재 세션", "originalAgent": "원래 에이전트: {{agent}}", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 4d0c918ef554..1cedc9f579e9 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -18686,8 +18686,8 @@ } }, "agentSessionContinuation": { - "continueInNewSession": "在新会话中继续…", - "dialogTitle": "在新会话中继续", + "handOffToAnotherAgent": "移交给另一个智能体", + "dialogTitle": "移交给另一个智能体", "dialogDescription": "从当前进度启动一个新的智能体会话,原会话保持不变。", "untitledSession": "当前会话", "originalAgent": "原智能体:{{agent}}", diff --git a/src/renderer/src/lib/agent-launch-route-input.test.ts b/src/renderer/src/lib/agent-launch-route-input.test.ts index 8a1ac92a224a..3903f814c75a 100644 --- a/src/renderer/src/lib/agent-launch-route-input.test.ts +++ b/src/renderer/src/lib/agent-launch-route-input.test.ts @@ -463,7 +463,7 @@ describe('workspaceKindForWorktreeId', () => { }) describe('a cwd that names the workspace root', () => { - // "Continue in New Session…" always names a cwd; at the root it must not force a terminal. + // "Hand Off to Another Agent" always names a cwd; at the root it must not force a terminal. beforeEach(stageLocalStructuredHost) const withRoot = (): AgentLaunchRouteStore => From 3e27e62f434ec612474bb9631d19e5c89f341805 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 8 Oct 2026 10:47:55 -0700 Subject: [PATCH 10/90] Native chat: open files from tool rows through the terminal's file link menu (#26511) * Native chat: open tool-row files through the terminal's file link popover Tool rows that name a file (Read, Write, Edit, edit cards) now link the file name. Chat file links, in tool rows and in reply text, show the same popover the terminal shows for file paths, built from one shared function. * Fix native chat filename activation and path resolution --- .../components/markdown-href-routing.test.ts | 8 + .../native-chat/NativeChatDiffCard.tsx | 19 +- .../NativeChatToolFileTarget.test.tsx | 105 +++++++ .../native-chat/NativeChatToolFileTarget.tsx | 64 ++++ .../native-chat/NativeChatToolLine.tsx | 19 +- .../NativeChatToolRunMemberList.tsx | 1 + .../native-chat/native-chat-file-link.test.ts | 40 +++ .../native-chat/native-chat-file-link.ts | 7 +- .../native-chat-link-click-behavior.ts | 19 ++ .../use-native-chat-file-link-click.test.tsx | 276 +++++++++++++++++- .../use-native-chat-file-link-click.ts | 71 ++++- .../use-native-chat-link-actions.ts | 15 +- .../terminal-file-link-actions.ts | 33 ++- .../lib/terminal-path-normalization.test.ts | 48 +++ .../src/lib/terminal-path-normalization.ts | 23 +- src/shared/native-chat-href-routing.test.ts | 24 ++ src/shared/native-chat-href-routing.ts | 30 +- 17 files changed, 721 insertions(+), 81 deletions(-) create mode 100644 src/renderer/src/components/native-chat/NativeChatToolFileTarget.test.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatToolFileTarget.tsx create mode 100644 src/renderer/src/components/native-chat/native-chat-link-click-behavior.ts create mode 100644 src/renderer/src/lib/terminal-path-normalization.test.ts diff --git a/mobile/src/components/markdown-href-routing.test.ts b/mobile/src/components/markdown-href-routing.test.ts index 99ffce93386b..7c65000d623f 100644 --- a/mobile/src/components/markdown-href-routing.test.ts +++ b/mobile/src/components/markdown-href-routing.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { routeMarkdownHref } from './markdown-href-routing' +import { createNativeChatFileHref } from '../../../src/shared/native-chat-href-routing' describe('routeMarkdownHref', () => { it('routes web and mail links to the system handler', () => { @@ -32,6 +33,13 @@ describe('routeMarkdownHref', () => { }) }) + it('preserves the existing wrapped reply-location contract', () => { + expect(routeMarkdownHref(createNativeChatFileHref(' docs/report.md:12:4 '))).toEqual({ + kind: 'file', + pathText: 'docs/report.md:12:4' + }) + }) + it('routes file: URIs to the file opener', () => { expect(routeMarkdownHref('file:///Users/me/wt/src/app.tsx')).toEqual({ kind: 'file', diff --git a/src/renderer/src/components/native-chat/NativeChatDiffCard.tsx b/src/renderer/src/components/native-chat/NativeChatDiffCard.tsx index 791516203541..6c7b16cbc670 100644 --- a/src/renderer/src/components/native-chat/NativeChatDiffCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatDiffCard.tsx @@ -6,6 +6,8 @@ import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' import { DiffLineCounts } from '../right-sidebar/source-control/listing/diff-line-counts' import { NativeChatCopyButton } from './NativeChatCopyButton' +import { NativeChatToolFileTarget } from './NativeChatToolFileTarget' +import type { CommentMarkdownLinkClickHandler } from '@/components/sidebar/CommentMarkdown' import { unifiedLineNumber, type NativeChatEditFile, @@ -141,11 +143,13 @@ export function NativeChatDiffCard({ file, revealSignal, onReveal, - disclosureKey + disclosureKey, + onLinkClick }: { file: NativeChatEditFile revealSignal?: number onReveal?: (element: HTMLElement) => void + onLinkClick?: CommentMarkdownLinkClickHandler /** Identity this card's open state is remembered under while it is unmounted. */ disclosureKey?: string }): React.JSX.Element { @@ -197,14 +201,13 @@ export function NativeChatDiffCard({ → ) : null} - - {file.path} + // A deleted file has nothing left to open. + onLinkClick={file.changeKind === 'deleted' ? undefined : onLinkClick} + /> {file.truncated ? ( diff --git a/src/renderer/src/components/native-chat/NativeChatToolFileTarget.test.tsx b/src/renderer/src/components/native-chat/NativeChatToolFileTarget.test.tsx new file mode 100644 index 000000000000..a3310a9f0b35 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatToolFileTarget.test.tsx @@ -0,0 +1,105 @@ +// @vitest-environment happy-dom +import '@testing-library/jest-dom/vitest' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { NativeChatToolLine } from './NativeChatToolLine' +import { NativeChatDiffCard } from './NativeChatDiffCard' +import { createNativeChatFileHref } from '../../../../shared/native-chat-href-routing' +import type { NativeChatEditFile } from '../../../../shared/native-chat-edit-model' + +afterEach(cleanup) + +const editFile = (overrides: Partial): NativeChatEditFile => ({ + path: 'src/main.ts', + oldPath: null, + changeKind: 'edited', + lines: [{ kind: 'add', text: 'x', oldLineNumber: null, newLineNumber: 1 }], + added: 1, + removed: 0, + lineNumbersKnown: true, + truncated: false, + ...overrides +}) + +describe('tool row file targets', () => { + it('opens a read file in Orca without toggling the row', () => { + const onLinkClick = vi.fn() + render( + + ) + const link = screen.getByRole('link', { name: '/repo/src/main.ts' }) + expect(link).toHaveTextContent('main.ts') + fireEvent.click(link) + expect(onLinkClick).toHaveBeenCalledWith( + expect.anything(), + createNativeChatFileHref('/repo/src/main.ts', 'literal') + ) + expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'false') + expect(screen.getByRole('button')).toHaveAccessibleName(/Read.*\/repo\/src\/main.ts/) + }) + + it('opens from the keyboard', () => { + const onLinkClick = vi.fn() + render( + + ) + fireEvent.keyDown(screen.getByRole('link'), { key: 'Enter' }) + expect(onLinkClick).toHaveBeenCalledWith( + expect.anything(), + createNativeChatFileHref('new.ts', 'literal') + ) + }) + + it('leaves the name plain when the chat cannot open files, and for non-file tools', () => { + render( + + ) + expect(screen.queryByRole('link')).toBeNull() + cleanup() + render( + + ) + expect(screen.queryByRole('link')).toBeNull() + }) + + it('opens an edited file from its diff card without expanding the card', () => { + const onLinkClick = vi.fn() + render() + fireEvent.click(screen.getByRole('link', { name: 'src/main.ts' })) + expect(onLinkClick).toHaveBeenCalledWith( + expect.anything(), + createNativeChatFileHref('src/main.ts', 'literal') + ) + expect(screen.getByRole('button', { expanded: false })).toBeInTheDocument() + }) + + it('opens the new name of a renamed file, and nothing for a deleted one', () => { + render( + + ) + expect(screen.getAllByRole('link').map((link) => link.textContent)).toEqual(['new.ts']) + cleanup() + render( + + ) + expect(screen.queryByRole('link')).toBeNull() + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatToolFileTarget.tsx b/src/renderer/src/components/native-chat/NativeChatToolFileTarget.tsx new file mode 100644 index 000000000000..ca783cffd44e --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatToolFileTarget.tsx @@ -0,0 +1,64 @@ +import type { CommentMarkdownLinkClickHandler } from '@/components/sidebar/CommentMarkdown' +import { cn } from '@/lib/utils' +import { createNativeChatFileHref } from '../../../../shared/native-chat-href-routing' + +/** The file a tool row names: its base name on screen, the full path in its title and + * accessible name. With a link handler it opens the file in Orca instead of toggling the row. */ +export function NativeChatToolFileTarget({ + path, + label, + className, + onLinkClick +}: { + path: string + label: string + className: string + onLinkClick?: CommentMarkdownLinkClickHandler +}): React.JSX.Element { + if (!onLinkClick) { + return ( + <> + + {path} + + ) + } + return ( + { + // The row around this is its expand button; opening the file must not also toggle it. + event.stopPropagation() + onLinkClick(event, createNativeChatFileHref(path, 'literal')) + }} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + event.stopPropagation() + event.currentTarget.dispatchEvent( + new MouseEvent('click', { + bubbles: true, + cancelable: true, + detail: 0, + altKey: event.altKey, + ctrlKey: event.ctrlKey, + metaKey: event.metaKey, + shiftKey: event.shiftKey + }) + ) + } + }} + > + {label} + + ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatToolLine.tsx b/src/renderer/src/components/native-chat/NativeChatToolLine.tsx index e2da7527543f..fbef701dc534 100644 --- a/src/renderer/src/components/native-chat/NativeChatToolLine.tsx +++ b/src/renderer/src/components/native-chat/NativeChatToolLine.tsx @@ -16,6 +16,7 @@ import { NativeChatToolName } from './NativeChatToolAnnotations' import { NativeChatToolIcon } from './NativeChatToolIcon' +import { NativeChatToolFileTarget } from './NativeChatToolFileTarget' import { NativeChatDiffView } from './NativeChatDiffView' import { nativeChatToolLineLabel } from './native-chat-tool-line-label' import { diffFromText, diffFromToolCall } from './native-chat-diff' @@ -24,6 +25,8 @@ import { useNativeChatDisclosure } from './native-chat-disclosure-store' import { truncateToolDetail } from './native-chat-tool-summary' const NO_SEARCH_RESULTS: NonNullable = [] +const TARGET_CLASS = + 'min-w-0 truncate text-chat-foreground transition-colors group-hover/tool-line:text-chat-foreground-strong' /** What an opened row shows. Its own component so a row diffs only while it is open. */ function ToolLineDetail({ @@ -156,16 +159,18 @@ export function NativeChatToolLine({ (isCall ? : name)} )} - {(label?.target ?? resultPreview) ? ( - + {label?.filePath ? ( + + ) : (label?.target ?? resultPreview) ? ( + {label?.target ?? resultPreview} ) : null} - {label?.filePath ? {label.filePath} : null} {/* Held at the row's right edge, so the carets of a run line up in one column. */} {isCall ? : null} diff --git a/src/renderer/src/components/native-chat/NativeChatToolRunMemberList.tsx b/src/renderer/src/components/native-chat/NativeChatToolRunMemberList.tsx index 68a18d6a84dc..76f375a3af99 100644 --- a/src/renderer/src/components/native-chat/NativeChatToolRunMemberList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatToolRunMemberList.tsx @@ -85,6 +85,7 @@ export function NativeChatToolRunMemberList({ : undefined } onReveal={onRevealDiff} + onLinkClick={onLinkClick} disclosureKey={ disclosureId === undefined ? undefined diff --git a/src/renderer/src/components/native-chat/native-chat-file-link.test.ts b/src/renderer/src/components/native-chat/native-chat-file-link.test.ts index 8d911033097a..25de726b0134 100644 --- a/src/renderer/src/components/native-chat/native-chat-file-link.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-file-link.test.ts @@ -4,6 +4,7 @@ import type { TerminalTab } from '../../../../shared/terminal-tab-types' import type { AppState } from '@/store/types' import { folderWorkspaceKey } from '../../../../shared/workspace-scope' import { FLOATING_TERMINAL_WORKTREE_ID, getDefaultSettings } from '../../../../shared/constants' +import { createNativeChatFileHref } from '../../../../shared/native-chat-href-routing' import { findNativeChatTabOwnerWorktreeId, resolveNativeChatFileLink, @@ -195,6 +196,45 @@ describe('floating workspace native chat', () => { }) describe('resolveNativeChatFileLink', () => { + it.each([ + ['/repo/report:12', '/repo/report:12'], + ['/repo/report:0', '/repo/report:0'], + ['/repo/report:12:4', '/repo/report:12:4'], + ['/repo/ leading ', '/repo/ leading '], + [' leading ', '/repo/worktree/ leading '], + ['notes%23?#.md', '/repo/worktree/notes%23?#.md'], + ['https:notes.md', '/repo/worktree/https:notes.md'], + ['file:notes.md', '/repo/worktree/file:notes.md'], + ['../sibling/readme.md', '/repo/sibling/readme.md'] + ])('resolves literal tool path %j without parsing a reply location', (path, absolutePath) => { + expect(resolveNativeChatFileLink(createNativeChatFileHref(path, 'literal'), context)).toEqual({ + absolutePath, + line: null, + column: null + }) + }) + + it('keeps wrapped reply locations distinct from literal tool paths', () => { + expect( + resolveNativeChatFileLink(createNativeChatFileHref('/repo/report:12:4'), context) + ).toEqual({ + absolutePath: '/repo/report', + line: 12, + column: 4 + }) + expect( + resolveNativeChatFileLink(createNativeChatFileHref('/repo/report:0'), context) + ).toBeNull() + }) + + it('resolves parent-relative reply locations against the same base', () => { + expect(resolveNativeChatFileLink('../sibling/readme.md:12:4', context)).toEqual({ + absolutePath: '/repo/sibling/readme.md', + line: 12, + column: 4 + }) + }) + it('resolves repo-relative file links against the chat worktree', () => { expect(resolveNativeChatFileLink('docs/guide.md', context)).toEqual({ absolutePath: '/repo/worktree/docs/guide.md', diff --git a/src/renderer/src/components/native-chat/native-chat-file-link.ts b/src/renderer/src/components/native-chat/native-chat-file-link.ts index e1b4f8c6b0f1..983edabbada0 100644 --- a/src/renderer/src/components/native-chat/native-chat-file-link.ts +++ b/src/renderer/src/components/native-chat/native-chat-file-link.ts @@ -1,7 +1,8 @@ import { routeNativeChatHref } from '../../../../shared/native-chat-href-routing' import { parseExplicitFileLinkTarget, - resolveExplicitFileLinkTarget + resolveExplicitFileLinkTarget, + resolveExplicitFileLinkTargetPath } from '@/lib/explicit-file-link-target' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import type { AppState } from '@/store/types' @@ -156,5 +157,9 @@ export function resolveNativeChatFileLink( if (route.kind !== 'file') { return null } + if (route.pathKind === 'literal') { + const absolutePath = resolveExplicitFileLinkTargetPath(route.pathText, context.worktreePath) + return absolutePath ? { absolutePath, line: null, column: null } : null + } return resolvePathText(route.pathText, route.line, context) } diff --git a/src/renderer/src/components/native-chat/native-chat-link-click-behavior.ts b/src/renderer/src/components/native-chat/native-chat-link-click-behavior.ts new file mode 100644 index 000000000000..d24bedb1f4e2 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-link-click-behavior.ts @@ -0,0 +1,19 @@ +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import { + terminalLinkClickBehaviorFor, + type TerminalLinkClickBehavior +} from '@/components/terminal-pane/terminal-link-click-behavior' + +/** What a plain click on a transcript link does. A profile that only turned the + * terminal popover off keeps opening chat links outright, as before actions existed. */ +export function nativeChatPlainLinkClickBehavior( + settings: + | Pick + | null + | undefined +): TerminalLinkClickBehavior { + if (settings?.terminalLinkClickBehavior === undefined) { + return settings?.terminalLinkActionPopoverEnabled === false ? 'open' : 'actions' + } + return terminalLinkClickBehaviorFor(settings) +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-file-link-click.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-file-link-click.test.tsx index 3bc8ef51739a..0b17bc0f3761 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-file-link-click.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-file-link-click.test.tsx @@ -2,19 +2,33 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import CommentMarkdown from '@/components/sidebar/CommentMarkdown' +import type { LinkActionRequest } from '@/components/link-actions/link-action-request' import type { NativeChatFileLinkContext } from './native-chat-file-link' import { useNativeChatFileLinkClick } from './use-native-chat-file-link-click' - -const mocks = vi.hoisted(() => ({ - openDetectedFilePath: vi.fn(), - showNotFound: vi.fn(), - showUnverifiable: vi.fn(), - showUnresolved: vi.fn() -})) +import { NativeChatToolLine } from './NativeChatToolLine' +import { NativeChatDiffCard } from './NativeChatDiffCard' + +const mocks = vi.hoisted(() => { + const noSettings: Record = {} + return { + openDetectedFilePath: vi.fn(), + showNotFound: vi.fn(), + showUnverifiable: vi.fn(), + showUnresolved: vi.fn(), + buildFileLinkActions: vi.fn(), + settings: noSettings + } +}) vi.mock('@/components/terminal-pane/terminal-file-open-routing', () => ({ openDetectedFilePath: mocks.openDetectedFilePath })) +vi.mock('@/components/terminal-pane/terminal-file-link-actions', () => ({ + buildFileLinkActions: mocks.buildFileLinkActions +})) +vi.mock('./native-chat-http-link-source-owner', () => ({ + resolveNativeChatHttpLinkSourceOwner: () => ({ kind: 'local' }) +})) vi.mock('./native-chat-file-link-toasts', () => ({ showFileLinkNotFoundToast: mocks.showNotFound, showFileLinkUnverifiableToast: mocks.showUnverifiable, @@ -23,7 +37,7 @@ vi.mock('./native-chat-file-link-toasts', () => ({ vi.mock('@/store', () => ({ useAppStore: Object.assign( (selector: (state: Record) => unknown) => selector({}), - { getState: () => ({ settings: {} }) } + { getState: () => ({ settings: mocks.settings }) } ) })) @@ -36,8 +50,9 @@ const context: NativeChatFileLinkContext = { function Transcript(props: { markdown: string linkContext?: NativeChatFileLinkContext + request?: (request: LinkActionRequest) => void }): React.JSX.Element { - const onLinkClick = useNativeChatFileLinkClick(props.linkContext ?? context) + const onLinkClick = useNativeChatFileLinkClick(props.linkContext ?? context, props.request) return ( void + linkContext?: NativeChatFileLinkContext +}): React.JSX.Element { + const onLinkClick = useNativeChatFileLinkClick(linkContext, request) + return surface === 'tool' ? ( + + ) : ( + + ) +} + function failLastOpen(verdict: 'missing' | 'unverifiable', error: unknown = new Error('x')): void { mocks.openDetectedFilePath.mock.calls.at(-1)?.[3].onOpenFailure({ verdict, error }) } @@ -60,6 +110,156 @@ function failLastOpen(verdict: 'missing' | 'unverifiable', error: unknown = new afterEach(() => { cleanup() vi.clearAllMocks() + vi.restoreAllMocks() + mocks.settings = {} +}) + +describe('tool and edit-card file activation', () => { + it.each([ + ['Mac', { metaKey: true }, 'none', 'Enter', 'tool'], + ['Linux', { ctrlKey: true }, 'actions', ' ', 'tool'], + ['Windows', { ctrlKey: true }, 'open', 'Enter', 'diff'], + ['Mac', { shiftKey: true }, 'actions', ' ', 'diff'], + ['Linux', { shiftKey: true }, 'open', 'Enter', 'tool'], + ['Windows', { shiftKey: true }, 'none', ' ', 'diff'] + ] as const)( + 'preserves %s keyboard intent with %j and %s policy', + (platform, modifiers, terminalLinkClickBehavior, key, surface) => { + vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue(platform) + mocks.settings = { terminalLinkClickBehavior } + const request = vi.fn() + render() + const link = screen.getByRole('link') + link.focus() + + fireEvent.keyDown(link, { key, ...modifiers }) + + expect(mocks.openDetectedFilePath).toHaveBeenCalledExactlyOnceWith( + '/repo/src/app.ts', + null, + null, + expect.objectContaining({ openWithSystemDefault: 'shiftKey' in modifiers }) + ) + expect(request).not.toHaveBeenCalled() + expect(screen.getByRole('button', { expanded: false }).getAttribute('aria-expanded')).toBe( + 'false' + ) + expect(document.activeElement).toBe(link) + } + ) + + it.each(['Enter', ' '])( + 'anchors plain %j activation under the link and restores its focus', + (key) => { + mocks.buildFileLinkActions.mockReturnValue({ destination: '/repo/src/app.ts', kind: 'file' }) + const request = vi.fn<(request: LinkActionRequest) => void>() + render() + const link = screen.getByRole('link') + vi.spyOn(link, 'getBoundingClientRect').mockReturnValue(new DOMRect(10, 20, 30, 40)) + + fireEvent.keyDown(link, { key }) + + expect(request).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ anchorX: 10, anchorY: 60 }) + ) + expect(mocks.openDetectedFilePath).not.toHaveBeenCalled() + request.mock.calls[0]?.[0].restoreFocus() + expect(document.activeElement).toBe(link) + expect(screen.getByRole('button').getAttribute('aria-expanded')).toBe('false') + } + ) + + it.each([ + ['/repo/report:12', '/repo/report:12', 'tool'], + ['/repo/report:0', '/repo/report:0', 'diff'], + ['/repo/report:12:4', '/repo/report:12:4', 'diff'], + ['/repo/ leading ', '/repo/ leading ', 'tool'], + [' leading ', '/repo/ leading ', 'diff'], + ['notes%23?#.md', '/repo/notes%23?#.md', 'tool'], + ['https:notes.md', '/repo/https:notes.md', 'diff'], + ['file:notes.md', '/repo/file:notes.md', 'tool'], + ['../sibling/readme.md', '/sibling/readme.md', 'tool'] + ] as const)( + 'opens literal target %j as %j from the %s surface', + (path, absolutePath, surface) => { + mocks.settings = { terminalLinkClickBehavior: 'open' } + render() + + fireEvent.click(screen.getByRole('link')) + + expect(mocks.openDetectedFilePath).toHaveBeenCalledExactlyOnceWith( + absolutePath, + null, + null, + expect.objectContaining({ worktreePath: '/repo' }) + ) + } + ) + + it('builds the menu for the exact sibling target without opening it', () => { + const request = vi.fn() + render() + + fireEvent.click(screen.getByRole('link')) + + expect(mocks.buildFileLinkActions).toHaveBeenCalledExactlyOnceWith( + '/sibling/readme.md', + null, + null, + expect.objectContaining({ worktreeId: 'wt-1' }), + { kind: 'local' } + ) + expect(request).toHaveBeenCalledOnce() + expect(mocks.openDetectedFilePath).not.toHaveBeenCalled() + }) + + it.each(['tool', 'reply'] as const)( + 'keeps an unresolved %s plain click quiet under none', + (surface) => { + mocks.settings = { terminalLinkClickBehavior: 'none' } + const request = vi.fn() + render( + surface === 'tool' ? ( + + ) : ( + + ) + ) + + fireEvent.click(screen.getByRole('link')) + + expect(mocks.showUnresolved).not.toHaveBeenCalled() + expect(mocks.showNotFound).not.toHaveBeenCalled() + expect(mocks.openDetectedFilePath).not.toHaveBeenCalled() + expect(request).not.toHaveBeenCalled() + } + ) + + it.each(['tool', 'reply'] as const)( + 'reports an unresolved %s modifier action under none', + (surface) => { + mocks.settings = { terminalLinkClickBehavior: 'none' } + const request = vi.fn() + render( + surface === 'tool' ? ( + + ) : ( + + ) + ) + + if (surface === 'tool') { + fireEvent.keyDown(screen.getByRole('link'), { key: 'Enter', shiftKey: true }) + } else { + fireEvent.click(screen.getByRole('link'), { shiftKey: true }) + } + + expect(mocks.showUnresolved).toHaveBeenCalledExactlyOnceWith('~/notes.md') + expect(mocks.showNotFound).not.toHaveBeenCalled() + expect(mocks.openDetectedFilePath).not.toHaveBeenCalled() + expect(request).not.toHaveBeenCalled() + } + ) }) describe('useNativeChatFileLinkClick', () => { @@ -197,4 +397,62 @@ describe('useNativeChatFileLinkClick', () => { expect(mocks.showUnresolved).toHaveBeenCalledWith('~/.claude/plans/plan.md') expect(mocks.showNotFound).not.toHaveBeenCalled() }) + + describe('with the file popover', () => { + const rows = { destination: '/repo/src/app.ts', kind: 'file', primary: { label: 'Open file' } } + + it('offers the terminal file actions on a plain click', () => { + mocks.buildFileLinkActions.mockReturnValue(rows) + const request = vi.fn() + render() + + clickLink('src/app.ts:12') + + expect(mocks.openDetectedFilePath).not.toHaveBeenCalled() + expect(mocks.buildFileLinkActions).toHaveBeenCalledWith( + '/repo/src/app.ts', + 12, + null, + expect.objectContaining({ worktreeId: 'wt-1', onOpenFailure: expect.any(Function) }), + { kind: 'local' } + ) + expect(request).toHaveBeenCalledWith(expect.objectContaining(rows)) + }) + + it.each([ + [ + 'a modifier click', + { [navigator.userAgent.includes('Mac') ? 'metaKey' : 'ctrlKey']: true }, + false + ], + ['a Shift click', { shiftKey: true }, true] + ])('opens outright on %s', (_name, modifiers, openWithSystemDefault) => { + const request = vi.fn() + render() + + fireEvent.click(screen.getByRole('link', { name: 'src/app.ts' }), modifiers) + + expect(request).not.toHaveBeenCalled() + expect(mocks.openDetectedFilePath).toHaveBeenCalledWith( + '/repo/src/app.ts', + null, + null, + expect.objectContaining({ openWithSystemDefault }) + ) + }) + + it.each([ + ['open', 1], + ['none', 0] + ])('follows a %s link-click setting', (terminalLinkClickBehavior, opens) => { + mocks.settings = { terminalLinkClickBehavior } + const request = vi.fn() + render() + + clickLink('src/app.ts') + + expect(request).not.toHaveBeenCalled() + expect(mocks.openDetectedFilePath).toHaveBeenCalledTimes(opens) + }) + }) }) diff --git a/src/renderer/src/components/native-chat/use-native-chat-file-link-click.ts b/src/renderer/src/components/native-chat/use-native-chat-file-link-click.ts index d63276354fc2..f78856cfc4cb 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-file-link-click.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-file-link-click.ts @@ -1,47 +1,88 @@ -import { useCallback } from 'react' +import { useCallback, type RefObject } from 'react' import type { CommentMarkdownLinkClickHandler } from '@/components/sidebar/CommentMarkdown' -import { openDetectedFilePath } from '@/components/terminal-pane/terminal-file-open-routing' +import type { LinkActionRequest } from '@/components/link-actions/link-action-request' +import { buildFileLinkActions } from '@/components/terminal-pane/terminal-file-link-actions' +import { + openDetectedFilePath, + type FileOpenFailure +} from '@/components/terminal-pane/terminal-file-open-routing' +import { isTerminalLinkActionActivation } from '@/components/terminal-pane/terminal-link-activation' +import { useAppStore } from '../../store' import { routeNativeChatHref } from '../../../../shared/native-chat-href-routing' import { resolveNativeChatFileLink, type NativeChatFileLinkContext } from './native-chat-file-link' +import { nativeChatPlainLinkClickBehavior } from './native-chat-link-click-behavior' +import { resolveNativeChatHttpLinkSourceOwner } from './native-chat-http-link-source-owner' import { showFileLinkNotFoundToast, showFileLinkUnresolvedToast, showFileLinkUnverifiableToast } from './native-chat-file-link-toasts' +/** A plain click offers the terminal's file popover (when that is the user's link-click + * setting); a modifier click opens in Orca, and Shift opens the default app. */ export function useNativeChatFileLinkClick( - context: NativeChatFileLinkContext | null + context: NativeChatFileLinkContext | null, + request?: (request: LinkActionRequest) => void, + rootRef?: RefObject ): CommentMarkdownLinkClickHandler | undefined { const openFileLink = useCallback( (event, href) => { if (!context) { return } - const target = resolveNativeChatFileLink(href, context) - if (!target) { - const route = routeNativeChatHref(href) - if (route.kind === 'file') { - // Why: e.g. `~/x` when the home folder cannot be inferred; never a dead click. - event.preventDefault() - showFileLinkUnresolvedToast(route.pathText) - } + const route = routeNativeChatHref(href) + if (route.kind !== 'file') { return } event.preventDefault() event.stopPropagation() - openDetectedFilePath(target.absolutePath, target.line, target.column, { + const state = useAppStore.getState() + const behavior = isTerminalLinkActionActivation(event) + ? nativeChatPlainLinkClickBehavior(state.settings) + : 'open' + if (behavior === 'none') { + return + } + const target = resolveNativeChatFileLink(href, context) + if (!target) { + showFileLinkUnresolvedToast(route.pathText) + return + } + const deps = { worktreeId: context.worktreeId, worktreePath: context.worktreePath, runtimeEnvironmentId: context.runtimeEnvironmentId, - openWithSystemDefault: event.shiftKey, // Why: an underlined link must answer every click, so a miss says why. - onOpenFailure: (failure) => + onOpenFailure: (failure: FileOpenFailure) => failure.verdict === 'unverifiable' ? showFileLinkUnverifiableToast(target.absolutePath, failure.error) : showFileLinkNotFoundToast(target.absolutePath) + } + if (behavior === 'open' || !request) { + openDetectedFilePath(target.absolutePath, target.line, target.column, { + ...deps, + openWithSystemDefault: event.shiftKey + }) + return + } + const anchor = event.currentTarget + // A keyboard-activated link has no pointer position; anchor under the link instead. + const keyboardAnchor = event.detail === 0 ? anchor.getBoundingClientRect() : null + request({ + anchorX: keyboardAnchor?.left ?? event.clientX, + anchorY: keyboardAnchor?.bottom ?? event.clientY, + restoreFocus: () => + (anchor.isConnected ? anchor : rootRef?.current)?.focus({ preventScroll: true }), + ...buildFileLinkActions( + target.absolutePath, + target.line, + target.column, + deps, + resolveNativeChatHttpLinkSourceOwner(state, context.worktreeId) + ) }) }, - [context] + [context, request, rootRef] ) return context ? openFileLink : undefined } diff --git a/src/renderer/src/components/native-chat/use-native-chat-link-actions.ts b/src/renderer/src/components/native-chat/use-native-chat-link-actions.ts index 2afdd379cd66..e6565837529f 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-link-actions.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-link-actions.ts @@ -13,7 +13,7 @@ import { resolveNativeChatHttpLinkSourceOwner } from './native-chat-http-link-source-owner' import { handleNativeChatWebLink } from './native-chat-web-link-actions' -import { terminalLinkClickBehaviorFor } from '@/components/terminal-pane/terminal-link-click-behavior' +import { nativeChatPlainLinkClickBehavior } from './native-chat-link-click-behavior' import { useNativeChatFileLinkClick } from './use-native-chat-file-link-click' export type NativeChatLinkActions = { @@ -22,15 +22,15 @@ export type NativeChatLinkActions = { closeLinkActions: (dismissed?: LinkActionRequest) => void } -/** Transcript links: file targets open in Orca, http(s) targets offer the same - * destination popover the terminal shows. */ +/** Transcript links offer the same destination popovers the terminal shows: file + * targets open in Orca or the default app, http(s) targets in a browser. */ export function useNativeChatLinkActions( context: NativeChatFileLinkContext | null, rootRef: RefObject, scope: { sessionId: string | null; isVisible: boolean } ): NativeChatLinkActions { - const openFileLink = useNativeChatFileLinkClick(context) const [linkActionRequest, setLinkActionRequest] = useState(null) + const openFileLink = useNativeChatFileLinkClick(context, setLinkActionRequest, rootRef) const scopeKey = JSON.stringify([ context?.worktreeId, context?.runtimeEnvironmentId, @@ -62,12 +62,7 @@ export function useNativeChatLinkActions( // Read at click time: settings and workspace ownership must not re-render the transcript. const state = useAppStore.getState() const sourceOwner = resolveNativeChatHttpLinkSourceOwner(state, context.worktreeId) - const plainClickBehavior = - state.settings?.terminalLinkClickBehavior === undefined - ? state.settings?.terminalLinkActionPopoverEnabled === false - ? 'open' - : 'actions' - : terminalLinkClickBehaviorFor(state.settings) + const plainClickBehavior = nativeChatPlainLinkClickBehavior(state.settings) const anchor = event.currentTarget handleNativeChatWebLink(event, route.url, { worktreeId: context.worktreeId, diff --git a/src/renderer/src/components/terminal-pane/terminal-file-link-actions.ts b/src/renderer/src/components/terminal-pane/terminal-file-link-actions.ts index 0ef281514a0c..a1fb79fd63d6 100644 --- a/src/renderer/src/components/terminal-pane/terminal-file-link-actions.ts +++ b/src/renderer/src/components/terminal-pane/terminal-file-link-actions.ts @@ -1,9 +1,12 @@ +import type { LinkActionRequest } from '@/components/link-actions/link-action-request' +import type { HttpLinkSourceOwner } from '@/lib/http-link-routing' import { getTerminalFileContext, mapTerminalFilePath, openDetectedFilePath, shouldOpenTerminalFileWithSystemDefault, - terminalLinkWslDistro + terminalLinkWslDistro, + type FileOpenFailure } from './terminal-file-open-routing' import { isTerminalLinkDirectActivation } from './terminal-link-activation' import { @@ -25,8 +28,14 @@ export type TerminalFileLinkActionDeps = { worktreePath: string runtimeEnvironmentId?: string | null wslDistro?: string | null + onOpenFailure?: (failure: FileOpenFailure) => void } +export type FileLinkActions = Pick< + LinkActionRequest, + 'destination' | 'kind' | 'primary' | 'alternate' | 'secondaryActions' +> + export function handleTerminalFileLink( filePath: string, line: number | null, @@ -44,7 +53,21 @@ export function handleTerminalFileLink( }) return true } + const actions = buildFileLinkActions(filePath, line, column, deps, actionContext?.sourceOwner) + return requestTerminalLinkAction(event, actionContext, { + ...actions, + destination: actionDestination ?? actions.destination + }) +} +/** The file-link popover's rows (open in Orca, default app, reveal), shared by terminal and chat. */ +export function buildFileLinkActions( + filePath: string, + line: number | null, + column: number | null, + deps: TerminalFileLinkActionDeps, + sourceOwner: HttpLinkSourceOwner | undefined +): FileLinkActions { const mappedPath = mapTerminalFilePath( filePath, deps.worktreePath, @@ -103,7 +126,7 @@ export function handleTerminalFileLink( const canReveal = !worktreeRoot && canOpenWithSystemDefault && - actionContext?.sourceOwner?.kind === 'local' && + sourceOwner?.kind === 'local' && !isRevealInFileManagerBlocked(useAppStore.getState().settings, { runtimeEnvironmentId: deps.runtimeEnvironmentId }) @@ -115,8 +138,8 @@ export function handleTerminalFileLink( run: () => revealInFileManager(mappedPath) } : null - return requestTerminalLinkAction(event, actionContext, { - destination: actionDestination ?? mappedPath, + return { + destination: mappedPath, kind: worktreeRoot ? 'workspace' : 'file', primary: { label: worktreeRoot @@ -132,5 +155,5 @@ export function handleTerminalFileLink( }, ...(systemDefaultRow ? { alternate: systemDefaultRow } : {}), ...(revealRow ? { secondaryActions: [revealRow] } : {}) - }) + } } diff --git a/src/renderer/src/lib/terminal-path-normalization.test.ts b/src/renderer/src/lib/terminal-path-normalization.test.ts new file mode 100644 index 000000000000..c56e2cc17adc --- /dev/null +++ b/src/renderer/src/lib/terminal-path-normalization.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' +import { joinAbsolutePath, resolveTildePath } from './terminal-path-normalization' + +describe('joinAbsolutePath', () => { + it.each([ + ['/repo', '../sibling/readme.md', '/sibling/readme.md'], + ['/repo/worktree', '../../sibling/readme.md', '/sibling/readme.md'], + ['/repo/worktree', 'src/../../readme.md', '/repo/readme.md'], + ['/repo/./worktree/..', '../sibling/readme.md', '/sibling/readme.md'], + ['/repo', '../../../readme.md', '/readme.md'], + ['/repo', '..', '/'], + ['/', '../../readme.md', '/readme.md'], + ['/', '', '/'], + ['/', '/readme.md', '/readme.md'], + [String.raw`c:\repo\worktree`, String.raw`..\sibling\readme.md`, 'C:/repo/sibling/readme.md'], + ['C:/repo/worktree', '../../../readme.md', 'C:/readme.md'], + ['C:/repo', '..', 'C:/'], + ['C:/', '../readme.md', 'C:/readme.md'], + ['C:/', '', 'C:/'], + [ + String.raw`\\server\share\repo`, + String.raw`..\sibling\readme.md`, + '//server/share/sibling/readme.md' + ], + ['//server/share/repo', '../../../readme.md', '//server/share/readme.md'], + ['//server/share/repo', '..', '//server/share'], + ['//server/share', '../../readme.md', '//server/share/readme.md'], + ['//server/share', '', '//server/share'] + ])('joins %j and %j without escaping its filesystem root', (base, relative, expected) => { + expect(joinAbsolutePath(base, relative)).toBe(expected) + }) + + it('cannot resolve a relative base', () => { + expect(joinAbsolutePath('repo', '../readme.md')).toBeNull() + }) +}) + +describe('resolveTildePath', () => { + it('resolves parent segments against an inferred home', () => { + expect(resolveTildePath('~/../shared/readme.md', '/home/me/repo')).toBe( + '/home/shared/readme.md' + ) + }) + + it('retains the drive root when traversing beyond an explicit home', () => { + expect(resolveTildePath('~/../../../readme.md', 'C:/repo', 'C:/Users/me')).toBe('C:/readme.md') + }) +}) diff --git a/src/renderer/src/lib/terminal-path-normalization.ts b/src/renderer/src/lib/terminal-path-normalization.ts index 50a86d2c1531..853c7995cf79 100644 --- a/src/renderer/src/lib/terminal-path-normalization.ts +++ b/src/renderer/src/lib/terminal-path-normalization.ts @@ -124,23 +124,8 @@ export function joinAbsolutePath(basePath: string, relativePath: string): string return null } - return normalizeJoinedPath(normalizedBase, relativePath) -} - -function normalizeJoinedPath(basePath: NormalizedAbsolutePath, relativePath: string): string { - const normalizedBaseSegments = normalizeSegments(basePath.normalized) - const relativeSegments = normalizeSegments(relativePath) - const joinedSegments = [...normalizedBaseSegments, ...relativeSegments] - - if (basePath.rootKind === 'unc') { - const [server, share, ...rest] = joinedSegments - return rest.length > 0 ? `//${server}/${share}/${rest.join('/')}` : `//${server}/${share}` - } - - if (basePath.rootKind === 'windows') { - const [drive, ...rest] = joinedSegments - return rest.length > 0 ? `${drive}/${rest.join('/')}` : drive - } - - return `/${joinedSegments.join('/')}`.replace(/\/+$/, '') || '/' + const base = normalizedBase.normalized.replace(/\/$/, '') + const suffix = relativePath.replace(/^[\\/]+/, '') + // Resolve parent segments against the base while its drive or UNC share remains the root. + return normalizeAbsolutePath(`${base}/${suffix}`)?.normalized ?? null } diff --git a/src/shared/native-chat-href-routing.test.ts b/src/shared/native-chat-href-routing.test.ts index 5ba1149fe3e5..75ae157da8c0 100644 --- a/src/shared/native-chat-href-routing.test.ts +++ b/src/shared/native-chat-href-routing.test.ts @@ -89,6 +89,30 @@ describe('routeNativeChatHref', () => { }) }) + it.each([ + '/repo/report:12', + '/repo/report:0', + '/repo/report:12:4', + ' leading and trailing ', + '/repo/ leading ', + 'notes%23?#.md', + 'https:notes.md', + 'file:notes.md', + '#orca-native-chat-file=notes.md' + ])('preserves the exact literal path %j without URL or location interpretation', (pathText) => { + expect(routeNativeChatHref(createNativeChatFileHref(pathText, 'literal'))).toEqual({ + kind: 'file', + pathText, + line: null, + pathKind: 'literal' + }) + }) + + it('rejects an empty or malformed literal-path wrapper', () => { + expect(routeNativeChatHref(createNativeChatFileHref('', 'literal'))).toEqual({ kind: 'none' }) + expect(routeNativeChatHref('#orca-native-chat-file-path=%ZZ')).toEqual({ kind: 'none' }) + }) + it('reads a bare file name with a line suffix as a file, not a scheme', () => { expect(routeNativeChatHref('README.md:5')).toEqual({ kind: 'file', diff --git a/src/shared/native-chat-href-routing.ts b/src/shared/native-chat-href-routing.ts index ca281f24f0c0..679e3b72e5ef 100644 --- a/src/shared/native-chat-href-routing.ts +++ b/src/shared/native-chat-href-routing.ts @@ -3,7 +3,7 @@ import { fileUriToFilesystemPath } from './file-uri-path' export type NativeChatHrefRoute = | { kind: 'web'; url: string } - | { kind: 'file'; pathText: string; line: number | null } + | { kind: 'file'; pathText: string; line: number | null; pathKind?: 'literal' } | { kind: 'none' } const WEB_SCHEME_PATTERN = /^(?:https?|mailto):/i @@ -11,19 +11,31 @@ const SCHEME_PATTERN = /^[A-Za-z][A-Za-z0-9+.-]*:/ // Why: `README.md:5` is a file location; the scheme pattern alone reads `README.md:` as a scheme. const BARE_FILE_LOCATION_PATTERN = /^[^\s:/\\?#]+\.[\p{L}\p{N}_+-]+:\d+(?::\d+)?$/u export const NATIVE_CHAT_FILE_HREF_PREFIX = '#orca-native-chat-file=' +const NATIVE_CHAT_LITERAL_FILE_HREF_PREFIX = '#orca-native-chat-file-path=' const MAX_NATIVE_CHAT_FILE_HREF_DECODES = 4 -/** Wraps literal file-location text (`path`, `path:line[:col]`); routing never re-parses it as a URL. */ -export function createNativeChatFileHref(pathText: string): string { - return `${NATIVE_CHAT_FILE_HREF_PREFIX}${encodeURIComponent(pathText)}` +/** Location text keeps reply parsing; known tool paths retain every filename character. */ +export function createNativeChatFileHref( + pathText: string, + pathKind: 'location' | 'literal' = 'location' +): string { + const prefix = + pathKind === 'literal' ? NATIVE_CHAT_LITERAL_FILE_HREF_PREFIX : NATIVE_CHAT_FILE_HREF_PREFIX + return `${prefix}${encodeURIComponent(pathText)}` } -function decodeNativeChatFileHref(href: string): string | null { - if (!href.startsWith(NATIVE_CHAT_FILE_HREF_PREFIX)) { +function decodeNativeChatFileHref( + href: string, + prefix = NATIVE_CHAT_FILE_HREF_PREFIX +): string | null { + if (!href.startsWith(prefix)) { return null } try { - const decoded = decodeURIComponent(href.slice(NATIVE_CHAT_FILE_HREF_PREFIX.length)) + const decoded = decodeURIComponent(href.slice(prefix.length)) + if (prefix === NATIVE_CHAT_LITERAL_FILE_HREF_PREFIX) { + return decoded || null + } return decoded && !decoded.startsWith(NATIVE_CHAT_FILE_HREF_PREFIX) ? decoded : null } catch { return null @@ -70,6 +82,10 @@ export function routeNativeChatHref(href: string | null | undefined): NativeChat if (!trimmed) { return { kind: 'none' } } + if (trimmed.startsWith(NATIVE_CHAT_LITERAL_FILE_HREF_PREFIX)) { + const pathText = decodeNativeChatFileHref(trimmed, NATIVE_CHAT_LITERAL_FILE_HREF_PREFIX) + return pathText ? { kind: 'file', pathText, line: null, pathKind: 'literal' } : { kind: 'none' } + } let isLiteralFileLocation = false for (let depth = 0; depth < MAX_NATIVE_CHAT_FILE_HREF_DECODES; depth += 1) { const encodedFileHref = decodeNativeChatFileHref(trimmed) From d997ef06f58ae72b14c11179002ec98c681a0a61 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 8 Oct 2026 11:01:04 -0700 Subject: [PATCH 11/90] Warn on slow profile saves instead of failing them (#26447) * Stop timing out slow profile-state writes; warn instead Profile-state writes used to fault after 30 seconds. A stalled main loop or a slow disk could then drop the writer and show a "could not confirm your save" dialog, even when the write was still in progress. Slow requests now record a diagnostic breadcrumb and keep waiting for the worker's reply. Close waits for the worker to actually exit, and a termination request alone no longer counts as proof. Pane-alias and migration-listener changes now save only their own sections, not a full state rebuild. Quit teardown also keeps the profile admission held until the final flush finishes, so a pending write is no longer released early at the deadline. * fix tests * Warn when profile saves run slow Slow profile-state writes used to leave only a diagnostic log entry, so users had no way to tell that recent changes were not yet confirmed saved. The writer now reports when a save write passes the slow-warning threshold and reports recovery when the reply arrives. Writes are never failed or retried because of the delay. - Main keeps the current delayed/healthy status and publishes changes to the renderer, so a reopened window gets the correct state. - The renderer shows one persistent warning toast while a save is delayed and dismisses it on recovery. - Observer errors are logged and do not interrupt saving. - The web build reports saving as healthy because it does not use the desktop profile writer. * Fix save-delay toast dismissal racing with a re-shown warning The save-delay warning used one fixed toast ID. Sonner applies a dismissal a frame late, so a dismissal could remove a warning that had just been shown again with the same ID. The hook now tracks the ID of each delayed-save warning it shows and dismisses only that toast. A new delay gets a fresh ID. Add Sonner-level tests for remount, StrictMode cleanup, and recover-then- delay races. Harden the e2e spec with a paused clock and a retry around main-process evaluate. * Report slow profile revision checks and exports as save delays Revision checks and JSON exports run on the same profile-state worker as saves, so a slow one holds up later writes just like a slow write does. The save-delay warning only tracked write-* commands, so those stalls went unreported. Every command except initialize and close now raises the warning. Tests cover the export-latest path and assert the delay signal fires once with true before clearing. * Clarify slow-save toast to name profile storage The delayed-save warning said recent changes were not yet confirmed saved, which overstated the risk while Orca was still retrying. The real condition is a slow profile-state write, so the toast now names profile storage and says later saves may wait until that operation finishes. The copy is updated in every locale, and the unit and e2e tests that assert it are updated to match. --- config/scripts/pr-code-change-scope.mjs | 3 +- src/main/ipc/app.ts | 2 + .../profile-persistence-deadline.test.ts | 2 +- src/main/orcad/orcad-entry.test.ts | 81 ++- ...istence-pane-alias-selective-saves.test.ts | 263 ++++++++++ .../profile-state-caller-cancellation.test.ts | 118 ++++- ...file-state-selective-serialization.test.ts | 34 ++ .../state-serialization-secret-handling.ts | 4 +- src/main/persistence/loading-store/store.ts | 4 +- .../profile-state-live-store-factory.ts | 1 + .../profile-state-startup-authority.ts | 2 + .../profile-state-worker-authority.ts | 1 + .../profile-state-writer-close.test.ts | 96 +++- .../profile-state-writer-connection.ts | 80 ++- .../profile-state-writer-deadline.test.ts | 230 -------- .../profile-state-writer-deadline.ts | 124 ----- .../profile-state-writer-diagnostics.ts | 45 +- .../profile-state-writer-electron-fixture.ts | 16 +- ...ofile-state-writer-protocol-faults.test.ts | 28 +- .../profile-state-writer-request.ts | 38 +- .../profile-state-writer-sleep.test.ts | 496 +++++++++++------- .../profile-state-writer-slow-warning.ts | 41 ++ ...rofile-state-writer-stall.electron.test.ts | 13 +- .../profile-state-writer-thread.ts | 20 +- .../profile-state-writer-worker.test.ts | 62 ++- .../main-process-quit-pending-writer.test.ts | 107 ++++ .../startup/main-process-ready-foundation.ts | 4 +- .../startup/profile-state-save-delay.test.ts | 39 ++ src/main/startup/profile-state-save-delay.ts | 20 + .../profile-state-write-failure.test.ts | 26 +- src/preload/api/app-api.ts | 3 + src/preload/api/app-bridge.ts | 8 + .../src/app-shell/use-app-shell-services.ts | 2 + ...le-state-save-delay-notice.sonner.test.tsx | 115 ++++ ...e-profile-state-save-delay-notice.test.tsx | 126 +++++ .../use-profile-state-save-delay-notice.ts | 62 +++ src/renderer/src/i18n/locales/en.json | 4 + src/renderer/src/i18n/locales/es.json | 4 + src/renderer/src/i18n/locales/fr.json | 4 + src/renderer/src/i18n/locales/ja.json | 4 + src/renderer/src/i18n/locales/ko.json | 4 + src/renderer/src/i18n/locales/zh.json | 4 + .../src/web/preload-api/web-app-api.ts | 3 + tests/e2e/profile-state-save-delay.spec.ts | 65 +++ 44 files changed, 1681 insertions(+), 727 deletions(-) create mode 100644 src/main/persistence-pane-alias-selective-saves.test.ts delete mode 100644 src/main/persistence/profile-state/profile-state-writer-deadline.test.ts delete mode 100644 src/main/persistence/profile-state/profile-state-writer-deadline.ts create mode 100644 src/main/persistence/profile-state/profile-state-writer-slow-warning.ts create mode 100644 src/main/startup/main-process-quit-pending-writer.test.ts create mode 100644 src/main/startup/profile-state-save-delay.test.ts create mode 100644 src/main/startup/profile-state-save-delay.ts create mode 100644 src/renderer/src/app-shell/use-profile-state-save-delay-notice.sonner.test.tsx create mode 100644 src/renderer/src/app-shell/use-profile-state-save-delay-notice.test.tsx create mode 100644 src/renderer/src/app-shell/use-profile-state-save-delay-notice.ts create mode 100644 tests/e2e/profile-state-save-delay.spec.ts diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index 50ac7fec598d..7f566f5d8ddb 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -350,7 +350,8 @@ const LINUX_PACKAGE_TESTS = [ 'src/main/browser/browser-route-tcp-egress.electron.test.ts', 'src/main/browser/browser-route-webrtc-egress.electron.test.ts', 'src/main/browser/browser-route-h3-egress.electron.test.ts', - 'src/main/browser/browser-route-dns-prefetch.electron.test.ts' + 'src/main/browser/browser-route-dns-prefetch.electron.test.ts', + 'src/main/persistence/profile-state/profile-state-writer-stall.electron.test.ts' ] const WINDOWS_PACKAGE_TESTS = [ diff --git a/src/main/ipc/app.ts b/src/main/ipc/app.ts index 63299bd99d62..637edad4dd35 100644 --- a/src/main/ipc/app.ts +++ b/src/main/ipc/app.ts @@ -26,6 +26,7 @@ import { registerMacSymbolicHotkeysProbeHandler } from './macos-symbolic-hotkeys import { registerRendererShutdownCheckpointHandler } from './renderer-shutdown-checkpoint' import { readMacKeyboardLayoutSnapshot } from './macos-keyboard-layout-snapshot' import { registerMacKeyboardLayoutChangeNotifications } from './macos-keyboard-layout-change-notifications' +import { isProfileStateSaveDelayed } from '../startup/profile-state-save-delay' const KEYBOARD_INPUT_SOURCE_TIMEOUT_MS = 500 const MAC_HITOOLBOX_DOMAIN = 'com.apple.HIToolbox' @@ -256,6 +257,7 @@ async function readKeyboardInputSourceId(): Promise { export function registerAppHandlers(store: Store, options: RegisterAppHandlersOptions = {}): void { registerRendererShutdownCheckpointHandler(store) registerMacKeyboardLayoutChangeNotifications() + ipcMain.handle('app:isProfileStateSaveDelayed', isProfileStateSaveDelayed) ipcMain.handle('app:getFeatureWallAssetBaseUrl', (): string => getFeatureWallAssetBaseUrl()) diff --git a/src/main/orca-profiles/profile-persistence-deadline.test.ts b/src/main/orca-profiles/profile-persistence-deadline.test.ts index 87e0507c5072..0929425ecd3c 100644 --- a/src/main/orca-profiles/profile-persistence-deadline.test.ts +++ b/src/main/orca-profiles/profile-persistence-deadline.test.ts @@ -6,7 +6,7 @@ import { flushActiveProfileBeforeFileMutation } from './profile-persistence-dead afterEach(() => vi.useRealTimers()) describe('profile persistence deadline', () => { - it('allows the writer request deadline to finish before imposing maintenance cancellation', async () => { + it('allows a slow writer request to finish before imposing maintenance cancellation', async () => { vi.useFakeTimers() const result = Promise.withResolvers() const beginProfileMaintenance = vi.fn( diff --git a/src/main/orcad/orcad-entry.test.ts b/src/main/orcad/orcad-entry.test.ts index 8d7df876b7de..74046415ff0c 100644 --- a/src/main/orcad/orcad-entry.test.ts +++ b/src/main/orcad/orcad-entry.test.ts @@ -2,9 +2,27 @@ import { describe, expect, it, vi } from 'vitest' import { flushOrcadProfileStoreForShutdown, installOrcadShutdownSignals, - ORCAD_SHUTDOWN_DEADLINE_MS + ORCAD_SHUTDOWN_DEADLINE_MS, + startOrcadWithLifecycle } from './orcad-lifecycle' +/** Installs the real signal handlers with process.exit captured instead of exiting. */ +function captureShutdown() { + vi.useFakeTimers() + const signals = new Map void>() + vi.spyOn(process, 'on').mockImplementation((event, listener) => { + signals.set(String(event), listener) + return process + }) + const exitCodes: (number | undefined)[] = [] + vi.spyOn(process, 'exit').mockImplementation((code) => { + exitCodes.push(typeof code === 'number' ? code : undefined) + throw new Error('process exit') + }) + vi.spyOn(console, 'error').mockImplementation(() => {}) + return { exitCodes, sigterm: () => signals.get('SIGTERM')?.() } +} + describe('orcad profile-state shutdown', () => { it('keeps one bounded shutdown even when stop signals repeat', () => { vi.useFakeTimers() @@ -57,6 +75,67 @@ describe('orcad profile-state shutdown', () => { } }) + it('exits at the deadline while headless startup still waits on the writer', async () => { + const { exitCodes, sigterm } = captureShutdown() + const releaseAdmission = vi.fn() + try { + // Writer initialization never acknowledges, so startup never returns a handle. + const startup = startOrcadWithLifecycle( + () => new Promise(() => {}), + async (runtimeCleanupSucceeded) => { + if (runtimeCleanupSucceeded) { + releaseAdmission() + } + } + ) + installOrcadShutdownSignals(async () => (await startup).stop()) + sigterm() + await vi.advanceTimersByTimeAsync(ORCAD_SHUTDOWN_DEADLINE_MS - 1) + expect(exitCodes).toEqual([]) + await expect(vi.advanceTimersByTimeAsync(1)).rejects.toThrow('process exit') + expect(exitCodes).toEqual([1]) + expect(releaseAdmission).not.toHaveBeenCalled() + } finally { + vi.restoreAllMocks() + vi.useRealTimers() + } + }) + + it('exits at the deadline without claiming success or releasing admission while a flush is pending', async () => { + const { exitCodes, sigterm } = captureShutdown() + const releaseAdmission = vi.fn() + const store = { + flushFinalOrThrowAsync: vi.fn(() => new Promise(() => {})), + freezeWritesAsync: vi.fn(async () => {}) + } + try { + const startup = startOrcadWithLifecycle( + async (registerCleanup) => { + registerCleanup(() => flushOrcadProfileStoreForShutdown(store)) + return {} + }, + async (runtimeCleanupSucceeded) => { + if (runtimeCleanupSucceeded) { + releaseAdmission() + } + } + ) + await startup + installOrcadShutdownSignals(async () => (await startup).stop()) + sigterm() + await expect(vi.advanceTimersByTimeAsync(ORCAD_SHUTDOWN_DEADLINE_MS)).rejects.toThrow( + 'process exit' + ) + expect(store.flushFinalOrThrowAsync).toHaveBeenCalledOnce() + expect(exitCodes).toEqual([1]) + expect(store.freezeWritesAsync).not.toHaveBeenCalled() + expect(releaseAdmission).not.toHaveBeenCalled() + } finally { + vi.restoreAllMocks() + vi.useRealTimers() + } + }) + it('flushes durably before closing the profile store', async () => { const events: string[] = [] const store = { diff --git a/src/main/persistence-pane-alias-selective-saves.test.ts b/src/main/persistence-pane-alias-selective-saves.test.ts new file mode 100644 index 000000000000..a863f891fca9 --- /dev/null +++ b/src/main/persistence-pane-alias-selective-saves.test.ts @@ -0,0 +1,263 @@ +import { closeTestStores, createStore, dataFile, testState } from './persistence-test-harness' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { agentHookServer } from './agent-hooks/server' +import { + clearMigrationUnsupportedPty, + setMigrationUnsupportedPty, + setMigrationUnsupportedPtyPersistenceListener +} from './agent-hooks/migration-unsupported-pty-state' +import { StateSerializationSecretHandlingOperations } from './persistence/loading-store/state-serialization-secret-handling' +import { + openProfileStateDatabaseReadOnly, + profileStateDatabaseFile +} from './persistence/profile-state/profile-state-database' +import { ProfileStateSqliteAuthority } from './persistence/profile-state/profile-state-sqlite-authority' +import { getDefaultWorkspaceSession } from '../shared/constants' +import { makePaneKey } from '../shared/stable-pane-id' + +vi.mock('electron', () => ({ + app: { getPath: () => testState.dir }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (plaintext: string) => Buffer.from(`encrypted:${plaintext}`, 'utf-8'), + decryptString: (ciphertext: Buffer) => ciphertext.toString('utf-8').slice('encrypted:'.length) + } +})) +vi.mock('./telemetry/client', () => ({ track: vi.fn() })) +vi.mock('./telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn() })) + +const SOURCE = makePaneKey('tab-source', '11111111-1111-4111-8111-111111111111') +const TARGET = makePaneKey('tab-target', '22222222-2222-4222-8222-222222222222') +const FINAL = makePaneKey('tab-final', '33333333-3333-4333-8333-333333333333') +const migrationEntry = { + ptyId: 'ssh:ssh-1@@legacy-pty', + worktreeId: 'repo-1::/worktree', + tabId: 'tab-1', + reason: 'legacy-numeric-pane-key' as const, + source: 'ssh' as const, + updatedAt: 1_700_000_000_123 +} + +type DocumentRow = { domain: string; payload: string; revision: number } + +function isDocumentRow(row: unknown): row is DocumentRow { + return ( + typeof row === 'object' && + row !== null && + 'domain' in row && + typeof row.domain === 'string' && + 'payload' in row && + typeof row.payload === 'string' && + 'revision' in row && + typeof row.revision === 'number' + ) +} + +/** Reads every document in one statement, independent of the Store under test. */ +function readDocuments(): Map { + const opened = openProfileStateDatabaseReadOnly( + profileStateDatabaseFile(dirname(dataFile())), + 'persistence-test' + ) + try { + const rows = opened.db + .prepare('SELECT domain, payload, revision FROM profile_state_documents') + .all() + return new Map(rows.filter(isDocumentRow).map((row) => [row.domain, row])) + } finally { + opened.db.close() + } +} + +async function runBackgroundSave(store: Awaited>): Promise { + vi.advanceTimersByTime(1_000) + await store.waitForPendingWrite() +} + +describe('pane alias and migration listeners save only their sections', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-alias-saves-')) + }) + + afterEach(async () => { + vi.useRealTimers() + vi.restoreAllMocks() + await closeTestStores() + agentHookServer.setPaneKeyAliasPersistenceListener(null) + setMigrationUnsupportedPtyPersistenceListener(null) + agentHookServer.retirePaneAuthority(TARGET) + for (const ptyId of ['pty-1', 'ssh:ssh-1@@pty-1', 'local-pty']) { + agentHookServer.clearPaneKeyAliasesForPty(ptyId) + } + clearMigrationUnsupportedPty(migrationEntry.ptyId) + rmSync(testState.dir, { recursive: true, force: true }) + }) + + async function durableBaseline() { + const store = await createStore() + store.flushOrThrow() + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const fullPreparation = vi.spyOn( + StateSerializationSecretHandlingOperations.prototype, + 'buildStateToSave' + ) + const domainWrites = vi.spyOn(ProfileStateSqliteAuthority.prototype, 'writeSerializedDomains') + return { store, fullPreparation, domainWrites, before: readDocuments() } + } + + function expectOnlyChanged( + before: Map, + after: Map, + changed: readonly string[] + ): void { + for (const [domain, row] of before) { + if (!changed.includes(domain)) { + expect(after.get(domain), domain).toEqual(row) + } + } + } + + it('writes an alias transfer as its own section with timestamps intact', async () => { + const { store, fullPreparation, domainWrites, before } = await durableBaseline() + agentHookServer.transferPaneAuthority(SOURCE, TARGET, 'pty-1', 1_700_000_000_456) + await runBackgroundSave(store) + + expect(fullPreparation).not.toHaveBeenCalled() + expect(domainWrites.mock.calls.map(([replacements]) => replacements)).toEqual([ + [{ domain: 'legacyPaneKeyAliasEntries', payload: expect.any(String) }] + ]) + const after = readDocuments() + expect(JSON.parse(after.get('legacyPaneKeyAliasEntries')?.payload ?? 'null')).toEqual([ + { ptyId: 'pty-1', legacyPaneKey: SOURCE, stablePaneKey: TARGET, updatedAt: 1_700_000_000_456 } + ]) + expectOnlyChanged(before, after, ['legacyPaneKeyAliasEntries']) + }) + + it('saves a chained alias update', async () => { + const { store } = await durableBaseline() + agentHookServer.transferPaneAuthority(SOURCE, TARGET, 'pty-1', 10) + await runBackgroundSave(store) + agentHookServer.transferPaneAuthority(TARGET, FINAL, 'pty-1', 20) + await runBackgroundSave(store) + + const payload = readDocuments().get('legacyPaneKeyAliasEntries')?.payload ?? 'null' + expect(JSON.parse(payload)).toEqual([ + expect.objectContaining({ stablePaneKey: FINAL, updatedAt: 20 }) + ]) + }) + + it('writes an empty migration list as an empty array, not a deletion', async () => { + const { store, fullPreparation, domainWrites, before } = await durableBaseline() + setMigrationUnsupportedPty(migrationEntry) + await runBackgroundSave(store) + expect( + JSON.parse(readDocuments().get('migrationUnsupportedPtyEntries')?.payload ?? '') + ).toEqual([migrationEntry]) + clearMigrationUnsupportedPty(migrationEntry.ptyId) + await runBackgroundSave(store) + + expect(fullPreparation).not.toHaveBeenCalled() + expect(domainWrites.mock.calls.map(([replacements]) => replacements)).toEqual([ + [{ domain: 'migrationUnsupportedPtyEntries', payload: JSON.stringify([migrationEntry]) }], + [{ domain: 'migrationUnsupportedPtyEntries', payload: '[]' }] + ]) + const after = readDocuments() + expect(after.get('migrationUnsupportedPtyEntries')?.payload).toBe('[]') + expectOnlyChanged(before, after, ['migrationUnsupportedPtyEntries']) + }) + + it('commits a co-batched SSH host session with the alias in one transaction', async () => { + const { store, fullPreparation, domainWrites, before } = await durableBaseline() + const remoteSession = { ...getDefaultWorkspaceSession(), activeRepoId: 'remote-repo' } + store.setWorkspaceSession(remoteSession, 'ssh:ssh-1') + agentHookServer.transferPaneAuthority(SOURCE, TARGET, 'ssh:ssh-1@@pty-1', 30) + await runBackgroundSave(store) + + expect(fullPreparation).not.toHaveBeenCalled() + expect(domainWrites).toHaveBeenCalledOnce() + expect(domainWrites.mock.calls[0]?.[0].map(({ domain }) => domain).toSorted()).toEqual([ + 'legacyPaneKeyAliasEntries', + 'workspaceSessionsByHostId' + ]) + const after = readDocuments() + const sessions = JSON.parse(after.get('workspaceSessionsByHostId')?.payload ?? '{}') + expect(sessions['ssh:ssh-1']).toMatchObject({ activeRepoId: 'remote-repo' }) + expect(after.get('workspaceSessionsByHostId')?.revision).toBe( + after.get('legacyPaneKeyAliasEntries')?.revision + ) + expectOnlyChanged(before, after, ['legacyPaneKeyAliasEntries', 'workspaceSessionsByHostId']) + }) + + it('keeps an unscoped save full when an alias joins it', async () => { + const { store, fullPreparation, domainWrites } = await durableBaseline() + store.setMobileClientTabSelections({}) + agentHookServer.transferPaneAuthority(SOURCE, TARGET, 'pty-1', 40) + await runBackgroundSave(store) + + expect(fullPreparation).toHaveBeenCalled() + expect(domainWrites).not.toHaveBeenCalled() + const after = readDocuments() + expect(after.get('mobileClientTabSelectionsByDeviceId')?.payload).toBe('{}') + expect(JSON.parse(after.get('legacyPaneKeyAliasEntries')?.payload ?? '[]')).toEqual([ + expect.objectContaining({ stablePaneKey: TARGET, updatedAt: 40 }) + ]) + }) + + it('persists a renderer session publish and the alias it creates from one background save', async () => { + const { store, before } = await durableBaseline() + store.setWorkspaceSession({ + ...getDefaultWorkspaceSession(), + activeWorktreeId: 'wt1', + activeTabId: 'tab1', + tabsByWorktree: { + wt1: [ + { + id: 'tab1', + worktreeId: 'wt1', + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + ptyId: 'local-pty' + } + ] + }, + terminalLayoutsByTabId: { + tab1: { + root: { type: 'leaf', leafId: 'pane:1' }, + activeLeafId: 'pane:1', + expandedLeafId: null, + ptyIdsByLeafId: { 'pane:1': 'local-pty' } + } + } + }) + const root = store.getWorkspaceSession().terminalLayoutsByTabId.tab1?.root + const stableLeafId = root?.type === 'leaf' ? root.leafId : null + expect(stableLeafId).not.toBe('pane:1') + await runBackgroundSave(store) + + const after = readDocuments() + const session = JSON.parse(after.get('workspaceSession')?.payload ?? '{}') + expect(session).toMatchObject({ + activeTabId: 'tab1', + terminalLayoutsByTabId: { + tab1: { + root: { leafId: stableLeafId }, + ptyIdsByLeafId: { [stableLeafId ?? '']: 'local-pty' } + } + } + }) + expect(JSON.parse(after.get('legacyPaneKeyAliasEntries')?.payload ?? '[]')).toEqual([ + expect.objectContaining({ + ptyId: 'local-pty', + legacyPaneKey: 'tab1:1', + stablePaneKey: makePaneKey('tab1', stableLeafId ?? '') + }) + ]) + expectOnlyChanged(before, after, ['legacyPaneKeyAliasEntries', 'workspaceSession']) + }) +}) diff --git a/src/main/persistence/loading-store/profile-state-caller-cancellation.test.ts b/src/main/persistence/loading-store/profile-state-caller-cancellation.test.ts index 728c63f6b8d0..e42b6ec64253 100644 --- a/src/main/persistence/loading-store/profile-state-caller-cancellation.test.ts +++ b/src/main/persistence/loading-store/profile-state-caller-cancellation.test.ts @@ -1,4 +1,14 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { agentHookServer } from '../../agent-hooks/server' +import { + clearMigrationUnsupportedPty, + setMigrationUnsupportedPty +} from '../../agent-hooks/migration-unsupported-pty-state' +import { getDefaultWorkspaceSession } from '../../../shared/constants' +import { makePaneKey } from '../../../shared/stable-pane-id' +import { openProfileStateDatabase } from '../profile-state/profile-state-database' +import { readProfileStateRevision } from '../profile-state/profile-state-revision' +import { ProfileStateWriterError } from '../profile-state/profile-state-writer-errors' import { createWorkerMaintenanceFixture, maintenanceBarrier @@ -13,7 +23,113 @@ vi.mock('../../ssh/ssh-config-parser', () => ({ sshConfigHostsToTargets: () => [] })) +const SOURCE_A = makePaneKey('tab-a', '11111111-1111-4111-8111-111111111111') +const TARGET_A = makePaneKey('tab-a2', '22222222-2222-4222-8222-222222222222') +const TARGET_A_2 = makePaneKey('tab-a3', '55555555-5555-4555-8555-555555555555') +const TARGET_A_3 = makePaneKey('tab-a4', '66666666-6666-4666-8666-666666666666') +const SOURCE_B = makePaneKey('tab-b', '33333333-3333-4333-8333-333333333333') +const TARGET_B = makePaneKey('tab-b2', '44444444-4444-4444-8444-444444444444') +const MIGRATION_ENTRY = { + ptyId: 'pty-legacy', + reason: 'legacy-numeric-pane-key' as const, + source: 'local' as const, + updatedAt: 5 +} + +afterEach(() => { + agentHookServer.clearPaneKeyAliasesForPty('pty-a') + agentHookServer.clearPaneKeyAliasesForPty('pty-b') + clearMigrationUnsupportedPty(MIGRATION_ENTRY.ptyId) +}) + +/** Seed two durable aliases, then hold the next alias commit at the given point. */ +async function holdAliasCommit(point: 'after-commit' | 'before-failure') { + const fixture = await createWorkerMaintenanceFixture() + const { store, authority } = fixture + agentHookServer.transferPaneAuthority(SOURCE_A, TARGET_A, 'pty-a', 1) + agentHookServer.transferPaneAuthority(SOURCE_B, TARGET_B, 'pty-b', 1) + await store.flushPendingOrThrowAsync() + const started = maintenanceBarrier() + const release = maintenanceBarrier() + const write = authority.writeSerializedDomains.bind(authority) + const writes = vi.spyOn(authority, 'writeSerializedDomains') + writes.mockImplementationOnce(async (domains) => { + if (point === 'after-commit') { + await write(domains) + } + started.resolve() + await release.promise + if (point === 'before-failure') { + throw new ProfileStateWriterError('SQLITE_BUSY', 'busy', 'known-failure') + } + }) + // Commit A also carries a section no later edit touches, so only restored intent can save it. + agentHookServer.transferPaneAuthority(TARGET_A, TARGET_A_2, 'pty-a', 2) + setMigrationUnsupportedPty(MIGRATION_ENTRY) + const controller = new AbortController() + const pending = store.flushPendingOrThrowAsync({ signal: controller.signal }) + const settled = pending.catch((error: unknown) => error) + await started.promise + // Newer edits arrive while commit A is unacknowledged. + agentHookServer.transferPaneAuthority(TARGET_A_2, TARGET_A_3, 'pty-a', 3) + agentHookServer.clearPaneKeyAliasesForPty('pty-b') + store.setWorkspaceSession( + { ...getDefaultWorkspaceSession(), activeRepoId: 'remote-repo' }, + 'ssh:ssh-1' + ) + return { ...fixture, writes, controller, release, settled } +} + +function expectLatestOwedState(state: { + legacyPaneKeyAliasEntries?: unknown + migrationUnsupportedPtyEntries?: unknown + workspaceSessionsByHostId?: Record +}): void { + expect(state.migrationUnsupportedPtyEntries).toEqual([MIGRATION_ENTRY]) + expect(state.legacyPaneKeyAliasEntries).toEqual([ + { ptyId: 'pty-a', legacyPaneKey: SOURCE_A, stablePaneKey: TARGET_A_3, updatedAt: 3 } + ]) + expect(state.workspaceSessionsByHostId?.['ssh:ssh-1']).toMatchObject({ + activeRepoId: 'remote-repo' + }) +} + describe('profile flush caller cancellation', () => { + it('keeps newer alias, deletion, and remote edits owed after a delayed alias commit', async () => { + const { store, readState, writes, controller, release, settled, databaseFile, profileId } = + await holdAliasCommit('after-commit') + controller.abort() + expect(await settled).toMatchObject({ message: expect.stringContaining('aborted') }) + // Commit A is durable, but nothing newer is reported or persisted early. + expect(readState().legacyPaneKeyAliasEntries).toEqual( + expect.arrayContaining([expect.objectContaining({ ptyId: 'pty-a', updatedAt: 2 })]) + ) + release.resolve() + await store.flushPendingOrThrowAsync() + expectLatestOwedState(readState()) + // A's replacement is not replayed: the drain writes only the newer values. + expect(writes).toHaveBeenCalledTimes(2) + const readRevision = () => { + const opened = openProfileStateDatabase(databaseFile, profileId) + try { + return readProfileStateRevision(opened.db) + } finally { + opened.db.close() + } + } + const revision = readRevision() + await store.flushPendingOrThrowAsync() + expect(readRevision()).toBe(revision) + }) + + it('restores dirty intent after a confirmed failure that held newer edits', async () => { + const { store, readState, release, settled } = await holdAliasCommit('before-failure') + release.resolve() + expect(await settled).toMatchObject({ code: 'SQLITE_BUSY', outcome: 'known-failure' }) + await store.flushPendingOrThrowAsync() + expectLatestOwedState(readState()) + }) + it('releases a canceled waiter while its admitted write completes and later saving continues', async () => { const { store, authority, readState } = await createWorkerMaintenanceFixture() const started = maintenanceBarrier() diff --git a/src/main/persistence/loading-store/profile-state-selective-serialization.test.ts b/src/main/persistence/loading-store/profile-state-selective-serialization.test.ts index acb8c0f31054..864f56b0a518 100644 --- a/src/main/persistence/loading-store/profile-state-selective-serialization.test.ts +++ b/src/main/persistence/loading-store/profile-state-selective-serialization.test.ts @@ -259,6 +259,40 @@ describe('selective profile domain serialization', () => { expect(encodeCount).toBe(0) }) + it('selects pane alias and migration sections exactly as the full save serializes them', () => { + const state = getDefaultPersistedState('/synthetic-profile') + state.legacyPaneKeyAliasEntries = [ + { ptyId: 'ssh:ssh-1@@b', legacyPaneKey: 'b', stablePaneKey: 'tab:2', updatedAt: 2.5 }, + { ptyId: 'a', legacyPaneKey: 'a', stablePaneKey: 'tab:1', updatedAt: 1 } + ] + state.migrationUnsupportedPtyEntries = [] + const serialization = new StateSerializationSecretHandlingOperations({ + state, + protectedSecrets: new ProtectedSecretPersistence() + }) + const domains = ['legacyPaneKeyAliasEntries', 'migrationUnsupportedPtyEntries'] + const full = serialization.buildStateToSave(true).domains ?? [] + + expect(serialization.buildStateDomainsToSave(new Set(domains))?.replacements).toEqual( + domains.map((domain) => full.find((replacement) => replacement.domain === domain)) + ) + expect(full.find(({ domain }) => domain === 'migrationUnsupportedPtyEntries')?.payload).toBe( + '[]' + ) + + // A missing section is a deletion in both paths: omitted from a full save, null when selected. + Reflect.deleteProperty(state, 'migrationUnsupportedPtyEntries') + expect( + serialization + .buildStateToSave(true) + .domains?.some(({ domain }) => domain === 'migrationUnsupportedPtyEntries') + ).toBe(false) + expect( + serialization.buildStateDomainsToSave(new Set(['migrationUnsupportedPtyEntries'])) + ?.replacements + ).toEqual([{ domain: 'migrationUnsupportedPtyEntries', payload: null }]) + }) + it('retains the full-write fallback for unknown domains or pending secret encryption', () => { const protectedSecrets = new ProtectedSecretPersistence() const serialization = new StateSerializationSecretHandlingOperations({ diff --git a/src/main/persistence/loading-store/state-serialization-secret-handling.ts b/src/main/persistence/loading-store/state-serialization-secret-handling.ts index c4346545ef4c..df4101229686 100644 --- a/src/main/persistence/loading-store/state-serialization-secret-handling.ts +++ b/src/main/persistence/loading-store/state-serialization-secret-handling.ts @@ -108,7 +108,9 @@ export class StateSerializationSecretHandlingOperations { } break case 'sshRemotePtyLeases': - stateToSave[domain] = this.runtime.state.sshRemotePtyLeases + case 'legacyPaneKeyAliasEntries': + case 'migrationUnsupportedPtyEntries': + stateToSave[domain] = this.runtime.state[domain] break default: return undefined diff --git a/src/main/persistence/loading-store/store.ts b/src/main/persistence/loading-store/store.ts index bf8c1b7e318e..9a3f0d5cc988 100644 --- a/src/main/persistence/loading-store/store.ts +++ b/src/main/persistence/loading-store/store.ts @@ -138,11 +138,11 @@ export class Store { } setMigrationUnsupportedPtyPersistenceListener((entries) => { this.state.migrationUnsupportedPtyEntries = entries - scheduleSave(this.domains.scheduling) + scheduleSave(this.domains.scheduling, ['migrationUnsupportedPtyEntries']) }) agentHookServer.setPaneKeyAliasPersistenceListener((entries) => { this.state.legacyPaneKeyAliasEntries = entries - scheduleSave(this.domains.scheduling) + scheduleSave(this.domains.scheduling, ['legacyPaneKeyAliasEntries']) }) if ( normalized.changed || diff --git a/src/main/persistence/profile-state/profile-state-live-store-factory.ts b/src/main/persistence/profile-state/profile-state-live-store-factory.ts index 8a5e29c46e85..83a63500c8b6 100644 --- a/src/main/persistence/profile-state/profile-state-live-store-factory.ts +++ b/src/main/persistence/profile-state/profile-state-live-store-factory.ts @@ -13,6 +13,7 @@ export async function createLiveProfileStateStore( workerPath?: string backupWorkerPath?: string onFailure?: (error: Error) => void + onSaveDelayChanged?: (delayed: boolean) => void } = {} ): Promise { const { initialState: initial, ...prepared } = prepareProfileStateStore(options) diff --git a/src/main/persistence/profile-state/profile-state-startup-authority.ts b/src/main/persistence/profile-state/profile-state-startup-authority.ts index 10307ac359f4..9659e725d7da 100644 --- a/src/main/persistence/profile-state/profile-state-startup-authority.ts +++ b/src/main/persistence/profile-state/profile-state-startup-authority.ts @@ -16,6 +16,7 @@ export type ProfileStateStartupAuthorityOptions = Omit< runtime: ProfileStateStartupRuntime storageAuthority: AutomationStorageAuthority onPersistenceFailure?: (error: Error) => void + onPersistenceSaveDelayChanged?: (delayed: boolean) => void } export class ProfileStateStartupAuthorityError extends Error { @@ -37,6 +38,7 @@ export async function createProfileStateStoreForStartup( throw new ProfileStateStartupAuthorityError() } return createLiveProfileStateStore(options, { + onSaveDelayChanged: options.onPersistenceSaveDelayChanged, onFailure: options.onPersistenceFailure ?? ((error) => diff --git a/src/main/persistence/profile-state/profile-state-worker-authority.ts b/src/main/persistence/profile-state/profile-state-worker-authority.ts index 43e2d182dc58..06b0631fb402 100644 --- a/src/main/persistence/profile-state/profile-state-worker-authority.ts +++ b/src/main/persistence/profile-state/profile-state-worker-authority.ts @@ -25,6 +25,7 @@ export class ProfileStateWorkerAuthority implements AsyncProfileStateAuthority { workerPath?: string backupWorkerPath?: string onFailure?: (error: Error) => void + onSaveDelayChanged?: (delayed: boolean) => void } = {} ) { this.writer = new ProfileStateWriteWorkerClient(initialization, options) diff --git a/src/main/persistence/profile-state/profile-state-writer-close.test.ts b/src/main/persistence/profile-state/profile-state-writer-close.test.ts index cf8b6a442585..45004627b1bb 100644 --- a/src/main/persistence/profile-state/profile-state-writer-close.test.ts +++ b/src/main/persistence/profile-state/profile-state-writer-close.test.ts @@ -22,14 +22,14 @@ const fixture = await vi.hoisted(async () => { return { Worker } }) vi.mock('node:worker_threads', () => ({ Worker: fixture.Worker })) +const diagnostics = vi.hoisted(() => ({ slow: vi.fn() })) vi.mock('./profile-state-writer-diagnostics', () => ({ recordProfileStateWriterFault: vi.fn(), - recordProfileStateWriterGrace: vi.fn(), - recordProfileStateWriterTimeout: vi.fn() + recordProfileStateWriterSlow: diagnostics.slow })) const clients: ProfileStateWriteWorkerClient[] = [] -const TIMEOUT_MS = 30_000 +const WARNING_MS = 30_000 afterEach(async () => { for (const worker of fixture.Worker.instances.splice(0)) { @@ -37,6 +37,7 @@ afterEach(async () => { } await Promise.all(clients.splice(0).map((client) => client.close().catch(() => {}))) vi.useRealTimers() + diagnostics.slow.mockReset() }) async function createClient() { @@ -44,7 +45,7 @@ async function createClient() { const onFailure = vi.fn() const client = new ProfileStateWriteWorkerClient( { databasePath: 'unused.db', profileId: 'close-test', revision: 1 }, - { workerPath: 'unused.cjs', timeoutMs: TIMEOUT_MS, onFailure } + { workerPath: 'unused.cjs', slowWarningMs: WARNING_MS, onFailure } ) clients.push(client) await client.ready @@ -55,29 +56,56 @@ async function createClient() { return { client, worker, onFailure } } -it.each([true, false])( - 'keeps close pending until the terminated worker exits (acknowledged: %s)', - async (acknowledged) => { - const { client, worker } = await createClient() - worker.acknowledgeClose = acknowledged - let settled = false - const closing = client - .close() - .catch((error: unknown) => error) - .finally(() => (settled = true)) - await waitForPoll() - vi.advanceTimersByTime(TIMEOUT_MS) - await waitForPoll() - await waitForPoll() - expect(worker.terminate).toHaveBeenCalledOnce() - expect(settled).toBe(false) - worker.emit('exit', 1) - expect(await closing).toMatchObject({ - code: acknowledged ? 'profile-state-writer-close-timeout' : 'profile-state-writer-timeout' - }) - expect(vi.getTimerCount()).toBe(0) - } -) +async function isSettled(promise: Promise): Promise { + let settled = false + void promise.then( + () => (settled = true), + () => (settled = true) + ) + await waitForPoll() + return settled +} + +it('holds an acknowledged close until actual exit without terminating the worker', async () => { + const { client, worker, onFailure } = await createClient() + const closing = client.close() + await waitForPoll() + vi.advanceTimersByTime(WARNING_MS * 10) + expect(await isSettled(closing)).toBe(false) + expect(worker.terminate).not.toHaveBeenCalled() + expect(diagnostics.slow).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ command: 'close', phase: 'awaiting-exit' }) + ) + worker.emit('exit', 0) + await expect(closing).resolves.toBeUndefined() + expect(onFailure).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) +}) + +it('keeps an unacknowledged close pending, then holds abort until the terminated worker exits', async () => { + const { client, worker, onFailure } = await createClient() + worker.acknowledgeClose = false + const closing = client.close().catch((error: unknown) => error) + await waitForPoll() + vi.advanceTimersByTime(WARNING_MS * 10) + expect(await isSettled(closing)).toBe(false) + expect(worker.terminate).not.toHaveBeenCalled() + expect(onFailure).not.toHaveBeenCalled() + expect(diagnostics.slow).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ command: 'close', phase: 'awaiting-reply' }) + ) + const aborting = client.abort() + expect(worker.terminate).toHaveBeenCalledOnce() + // A termination request is not proof of exit. + vi.advanceTimersByTime(WARNING_MS * 10) + expect(await isSettled(aborting)).toBe(false) + expect(await isSettled(closing)).toBe(false) + worker.emit('exit', 1) + await aborting + expect(await closing).toMatchObject({ code: 'profile-state-writer-aborted' }) + expect(onFailure).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(0) +}) it('completes a clean close only after the acknowledged worker exits', async () => { const { client, worker, onFailure } = await createClient() @@ -91,3 +119,17 @@ it('completes a clean close only after the acknowledged worker exits', async () expect(onFailure).not.toHaveBeenCalled() expect(vi.getTimerCount()).toBe(0) }) + +it('joins an aborted writer without reporting a close request that was never sent', async () => { + const { client, worker } = await createClient() + const aborting = client.abort() + const closing = client.close() + await waitForPoll() + vi.advanceTimersByTime(WARNING_MS * 10) + expect(await isSettled(closing)).toBe(false) + expect(diagnostics.slow).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + worker.emit('exit', 1) + await aborting + await closing +}) diff --git a/src/main/persistence/profile-state/profile-state-writer-connection.ts b/src/main/persistence/profile-state/profile-state-writer-connection.ts index 4ba179589806..5c4f927b63f2 100644 --- a/src/main/persistence/profile-state/profile-state-writer-connection.ts +++ b/src/main/persistence/profile-state/profile-state-writer-connection.ts @@ -16,14 +16,18 @@ import { type ProfileStateWriterInitialization } from './profile-state-writer-protocol' import { recordProfileStateWriterFault } from './profile-state-writer-diagnostics' +import { + PROFILE_STATE_WRITER_SLOW_WARNING_MS, + startProfileStateWriterSlowWarning +} from './profile-state-writer-slow-warning' import { ProfileStateWriterThread } from './profile-state-writer-thread' -const REQUEST_TIMEOUT_MS = 30_000 - export type ProfileStateWriterConnectionOptions = { workerPath?: string - timeoutMs?: number + /** Delay before one diagnostic breadcrumb; never fails the request. */ + slowWarningMs?: number onFailure?: (error: Error) => void + onSaveDelayChanged?: (delayed: boolean) => void reportInitializationFailure?: boolean /** Monotonic milliseconds; tests replace it to model a stalled main loop. */ clock?: () => number @@ -39,7 +43,8 @@ export class ProfileStateWriterConnection { private draining = false private closePromise: Promise | undefined private closeAcknowledged = false - private readonly timeoutMs: number + private saveDelayed = false + private readonly slowWarningMs: number private readonly initialRevision: number private latestRevision: number | undefined @@ -48,7 +53,7 @@ export class ProfileStateWriterConnection { private readonly options: ProfileStateWriterConnectionOptions = {} ) { this.initialRevision = initialization.revision - this.timeoutMs = options.timeoutMs ?? REQUEST_TIMEOUT_MS + this.slowWarningMs = options.slowWarningMs ?? PROFILE_STATE_WRITER_SLOW_WARNING_MS const pending = this.createPending(0, 'initialize') this.active = pending this.ready = pending.promise.then(() => {}) @@ -68,8 +73,7 @@ export class ProfileStateWriterConnection { this.faultWith('profile-state-writer-exit', message, { exitCode: code }) } } - }, - { timeoutMs: this.timeoutMs, now: options.clock } + } ) } catch (cause) { this.faultWith('profile-state-writer-unavailable', 'Profile state writer could not start', { @@ -117,18 +121,12 @@ export class ProfileStateWriterConnection { private async finishClose(): Promise { await this.active?.promise.catch(() => {}) if (!this.failure && !this.didExit) { + const closeRequestId = this.nextId try { await this.dispatch({ command: 'close' }) } finally { - if (!(await (this.thread?.waitForExit() ?? true))) { - this.faultWith( - 'profile-state-writer-close-timeout', - 'Profile state writer did not exit after close', - { outcome: 'indeterminate' } - ) - } - // Termination is asynchronous; close must retain ownership until exit is confirmed. - await this.thread?.exitPromise + // Neither elapsed time nor a termination request proves exit; ownership waits for it. + await this.awaitExit(closeRequestId) } if (this.failure) { throw this.failure @@ -138,6 +136,28 @@ export class ProfileStateWriterConnection { } } + private async awaitExit(requestId: number): Promise { + const thread = this.thread + if (!thread || thread.exited) { + return + } + const clearSlowWarning = startProfileStateWriterSlowWarning({ + warningMs: this.slowWarningMs, + phase: 'awaiting-exit', + now: this.options.clock, + request: { + command: 'close', + requestId, + acknowledgedRevision: this.lastAcknowledgedRevision + } + }) + try { + await thread.exitPromise + } finally { + clearSlowWarning() + } + } + protected assertDispatchable(closing = false): void { if (this.failure) { throw this.failure @@ -189,17 +209,28 @@ export class ProfileStateWriterConnection { id: number, command: PendingProfileStateWriterRequest['command'] ): PendingProfileStateWriterRequest { - const pending = createProfileStateWriterRequest(id, command, this.timeoutMs, { + return createProfileStateWriterRequest(id, command, this.slowWarningMs, { now: this.options.clock, - acknowledgedRevision: () => this.lastAcknowledgedRevision, - onTimeout: () => { - // A cleared or superseded request must never fault its successor. - if (this.active === pending) { - this.faultWith('profile-state-writer-timeout', 'Profile state writer command timed out') + acknowledgedRevision: this.lastAcknowledgedRevision, + onSlow: () => { + // Revision checks and exports occupy the same worker and can hold up later saves. + if (this.active?.id === id && command !== 'initialize' && command !== 'close') { + this.reportSaveDelay(true) } } }) - return pending + } + + private reportSaveDelay(delayed: boolean): void { + if (this.saveDelayed === delayed) { + return + } + this.saveDelayed = delayed + try { + this.options.onSaveDelayChanged?.(delayed) + } catch (error) { + console.error('[persistence] Could not report delayed saving:', error) + } } private receive(value: unknown): void { @@ -243,7 +274,8 @@ export class ProfileStateWriterConnection { if (!pending) { return } - pending.clearDeadline() + pending.clearSlowWarning() + this.reportSaveDelay(false) if (response) { pending.resolve(response) } else { diff --git a/src/main/persistence/profile-state/profile-state-writer-deadline.test.ts b/src/main/persistence/profile-state/profile-state-writer-deadline.test.ts deleted file mode 100644 index 95bd251d6cbb..000000000000 --- a/src/main/persistence/profile-state/profile-state-writer-deadline.test.ts +++ /dev/null @@ -1,230 +0,0 @@ -import { setImmediate as waitForPoll } from 'node:timers/promises' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { - publishSystemResume, - publishSystemSuspend, - type SystemPowerLifecycleListener -} from '../../system-power-lifecycle' -import { - createProfileStateWriterDeadline, - PROFILE_STATE_WRITER_MAX_GRACES, - PROFILE_STATE_WRITER_OVERDUE_GRACE_MS -} from './profile-state-writer-deadline' - -const TIMEOUT_MS = 30_000 - -async function drainTimeoutCheck(): Promise { - await waitForPoll() - await waitForPoll() -} - -afterEach(() => { - vi.useRealTimers() - publishSystemResume() -}) - -function harness() { - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - const clock = { now: 1_000 } - let power!: SystemPowerLifecycleListener - const unsubscribe = vi.fn() - const onTimeout = vi.fn() - const onGrace = vi.fn() - const deadline = createProfileStateWriterDeadline(TIMEOUT_MS, onTimeout, { - now: () => clock.now, - onGrace, - subscribePowerLifecycle: (listener) => { - power = listener - // The real lifecycle replays its current state during subscription. - listener.onResume() - return unsubscribe - } - }) - // Advance fake timers and the monotonic clock together, as a running loop would. - const run = (ms: number) => { - clock.now += ms - vi.advanceTimersByTime(ms) - } - // A stalled loop: monotonic time passes but no callback runs. - const stall = (ms: number) => { - clock.now += ms - } - return { deadline, onTimeout, onGrace, unsubscribe, run, stall, power: () => power } -} - -describe('profile state writer deadline', () => { - it('lets a queued reply clear the deadline after the first poll', async () => { - const { deadline, onTimeout, unsubscribe, run } = harness() - run(TIMEOUT_MS) - setImmediate(() => deadline.clear()) - await drainTimeoutCheck() - expect(onTimeout).not.toHaveBeenCalled() - expect(unsubscribe).toHaveBeenCalledOnce() - expect(vi.getTimerCount()).toBe(0) - }) - - it('times out a request after allowing queued replies to drain', async () => { - const { onTimeout, onGrace, unsubscribe, run } = harness() - run(TIMEOUT_MS - 1) - expect(onTimeout).not.toHaveBeenCalled() - run(1) - expect(onGrace).not.toHaveBeenCalled() - expect(onTimeout).not.toHaveBeenCalled() - await drainTimeoutCheck() - expect(onTimeout).toHaveBeenCalledExactlyOnceWith( - expect.objectContaining({ overdueMs: 0, graces: 0, powerState: 'awake' }) - ) - expect(unsubscribe).toHaveBeenCalledOnce() - expect(vi.getTimerCount()).toBe(0) - }) - - it('grants a fresh window when the callback arrives after a stalled loop', async () => { - const { onTimeout, onGrace, run, stall } = harness() - stall(3 * 60 * 60_000) - run(TIMEOUT_MS) - expect(onTimeout).not.toHaveBeenCalled() - expect(onGrace).toHaveBeenCalledExactlyOnceWith( - expect.objectContaining({ overdueMs: 3 * 60 * 60_000, graces: 1 }) - ) - run(TIMEOUT_MS - 1) - expect(onTimeout).not.toHaveBeenCalled() - run(1) - await drainTimeoutCheck() - expect(onTimeout).toHaveBeenCalledExactlyOnceWith( - expect.objectContaining({ overdueMs: 0, graces: 1 }) - ) - }) - - it('does not grant a full extra window for ordinary scheduling jitter', async () => { - const { onTimeout, onGrace, run, stall } = harness() - stall(PROFILE_STATE_WRITER_OVERDUE_GRACE_MS - 1) - run(TIMEOUT_MS) - expect(onGrace).not.toHaveBeenCalled() - await drainTimeoutCheck() - expect(onTimeout).toHaveBeenCalledOnce() - }) - - it('bounds grace across repeated stalls so a hung worker still times out', async () => { - const { onTimeout, onGrace, run, stall } = harness() - for (let episode = 0; episode < PROFILE_STATE_WRITER_MAX_GRACES; episode += 1) { - stall(60 * 60_000) - run(TIMEOUT_MS) - expect(onTimeout).not.toHaveBeenCalled() - } - stall(60 * 60_000) - run(TIMEOUT_MS) - expect(onGrace).toHaveBeenCalledTimes(PROFILE_STATE_WRITER_MAX_GRACES) - await drainTimeoutCheck() - expect(onTimeout).toHaveBeenCalledExactlyOnceWith( - expect.objectContaining({ graces: PROFILE_STATE_WRITER_MAX_GRACES }) - ) - expect(vi.getTimerCount()).toBe(0) - }) - - it('spends one grace to give a fresh window on resume', async () => { - const { onTimeout, onGrace, run, power } = harness() - run(TIMEOUT_MS - 1_000) - power().onSuspend() - power().onResume() - run(TIMEOUT_MS - 1) - expect(onTimeout).not.toHaveBeenCalled() - run(1) - await drainTimeoutCheck() - expect(onGrace).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ graces: 1 })) - expect(onTimeout).toHaveBeenCalledOnce() - }) - - it('stays bounded when a suspend is never followed by resume', async () => { - const { onTimeout, onGrace, run } = harness() - // Dark wake: the loop runs on time, but the system still reports suspended. - publishSystemSuspend() - for (let window = 0; window < PROFILE_STATE_WRITER_MAX_GRACES; window += 1) { - run(TIMEOUT_MS) - } - expect(onGrace).toHaveBeenCalledTimes(PROFILE_STATE_WRITER_MAX_GRACES) - expect(onTimeout).not.toHaveBeenCalled() - run(TIMEOUT_MS) - await drainTimeoutCheck() - expect(onTimeout).toHaveBeenCalledExactlyOnceWith( - expect.objectContaining({ powerState: 'suspended' }) - ) - }) - - it.each([false, true])( - 'bounds repeated resumes with paired suspend events: %s', - async (paired) => { - const { deadline, onTimeout, run, power } = harness() - try { - for (let cycle = 0; cycle <= PROFILE_STATE_WRITER_MAX_GRACES; cycle += 1) { - run(TIMEOUT_MS - 1) - if (paired) { - power().onSuspend() - } - power().onResume() - } - run(1) - await drainTimeoutCheck() - expect(onTimeout).toHaveBeenCalledExactlyOnceWith( - expect.objectContaining({ graces: PROFILE_STATE_WRITER_MAX_GRACES }) - ) - expect(vi.getTimerCount()).toBe(0) - } finally { - deadline.clear() - } - } - ) - - it('shares the grace limit between overdue timers and resume events', async () => { - const { deadline, onTimeout, onGrace, run, stall, power } = harness() - try { - for (let cycle = 1; cycle < PROFILE_STATE_WRITER_MAX_GRACES; cycle += 1) { - stall(PROFILE_STATE_WRITER_OVERDUE_GRACE_MS) - run(TIMEOUT_MS) - } - power().onResume() - run(TIMEOUT_MS) - // An exhausted resume must not cancel the final queued timeout check. - power().onResume() - await drainTimeoutCheck() - expect(onGrace).toHaveBeenCalledTimes(PROFILE_STATE_WRITER_MAX_GRACES) - expect(onTimeout).toHaveBeenCalledOnce() - expect(vi.getTimerCount()).toBe(0) - } finally { - deadline.clear() - } - }) - - it('cancels an expired timeout check when a reply clears the deadline', async () => { - const { deadline, onTimeout, unsubscribe, run } = harness() - run(TIMEOUT_MS) - deadline.clear() - await drainTimeoutCheck() - expect(onTimeout).not.toHaveBeenCalled() - expect(unsubscribe).toHaveBeenCalledOnce() - expect(vi.getTimerCount()).toBe(0) - }) - - it('cancels an expired timeout check when resume re-arms the deadline', async () => { - const { onTimeout, run, power } = harness() - run(TIMEOUT_MS) - power().onResume() - await drainTimeoutCheck() - expect(onTimeout).not.toHaveBeenCalled() - run(TIMEOUT_MS) - await drainTimeoutCheck() - expect(onTimeout).toHaveBeenCalledOnce() - expect(vi.getTimerCount()).toBe(0) - }) - - it('releases its timer and subscription when cleared, and ignores later power events', () => { - const { deadline, onTimeout, unsubscribe, run, power } = harness() - deadline.clear() - deadline.clear() - expect(unsubscribe).toHaveBeenCalledOnce() - expect(vi.getTimerCount()).toBe(0) - power().onResume() - run(10 * TIMEOUT_MS) - expect(vi.getTimerCount()).toBe(0) - expect(onTimeout).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/persistence/profile-state/profile-state-writer-deadline.ts b/src/main/persistence/profile-state/profile-state-writer-deadline.ts deleted file mode 100644 index 9bc2db835004..000000000000 --- a/src/main/persistence/profile-state/profile-state-writer-deadline.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { - getSystemPowerState, - subscribeSystemPowerLifecycle, - type SystemPowerLifecycleListener, - type SystemPowerState -} from '../../system-power-lifecycle' - -/** - * A timer this late proves the main loop, not the worker, missed the window: a - * reply may already be queued behind it. Timers and performance.now() share - * libuv's monotonic clock, so wall-clock changes cannot fake or hide lateness. - */ -export const PROFILE_STATE_WRITER_OVERDUE_GRACE_MS = 5_000 -/** Stalls, suspended expiries, and resumes share one budget per request. */ -export const PROFILE_STATE_WRITER_MAX_GRACES = 3 - -export type ProfileStateWriterDeadlineExpiry = { - timeoutMs: number - elapsedMs: number - overdueMs: number - graces: number - powerState: SystemPowerState -} - -export type ProfileStateWriterDeadlineOptions = { - now?: () => number - subscribePowerLifecycle?: (listener: SystemPowerLifecycleListener) => () => void - onGrace?: (expiry: ProfileStateWriterDeadlineExpiry) => void -} - -/** - * Power events and stalled-loop expiries can grant bounded extra time. - * A final expiry lets queued replies drain before faulting. - */ -export function createProfileStateWriterDeadline( - timeoutMs: number, - onTimeout: (expiry: ProfileStateWriterDeadlineExpiry) => void, - { - now = () => performance.now(), - subscribePowerLifecycle = subscribeSystemPowerLifecycle, - onGrace - }: ProfileStateWriterDeadlineOptions = {} -): { clear: () => void } { - const startedAt = now() - let armedAt = startedAt - let graces = 0 - let timer: ReturnType | undefined - let timeoutCheck: ReturnType | undefined - let active = true - const arm = (): void => { - clearTimeout(timer) - clearImmediate(timeoutCheck) - timeoutCheck = undefined - armedAt = now() - timer = setTimeout(expire, timeoutMs) - } - const clear = (): void => { - if (!active) { - return - } - active = false - clearTimeout(timer) - timer = undefined - clearImmediate(timeoutCheck) - timeoutCheck = undefined - unsubscribe() - } - const describeExpiry = (): ProfileStateWriterDeadlineExpiry => { - const current = now() - return { - timeoutMs, - elapsedMs: Math.max(0, current - startedAt), - overdueMs: Math.max(0, current - armedAt - timeoutMs), - graces, - powerState: getSystemPowerState() - } - } - const grantGrace = (expiry: ProfileStateWriterDeadlineExpiry): boolean => { - if (graces >= PROFILE_STATE_WRITER_MAX_GRACES) { - return false - } - graces += 1 - arm() - onGrace?.({ ...expiry, graces }) - return true - } - function expire(): void { - timer = undefined - if (!active) { - return - } - const expiry = describeExpiry() - if ( - (expiry.overdueMs >= PROFILE_STATE_WRITER_OVERDUE_GRACE_MS || - expiry.powerState !== 'awake') && - grantGrace(expiry) - ) { - return - } - // Windows can deliver a queued worker reply after the first poll following a stall. - timeoutCheck = setImmediate(() => { - timeoutCheck = setImmediate(() => { - timeoutCheck = undefined - if (active) { - clear() - onTimeout(expiry) - } - }) - }) - } - // Subscription replays the current state synchronously; only later resumes re-arm. - let subscribed = false - const unsubscribe = subscribePowerLifecycle({ - onSuspend: () => {}, - onResume: () => { - if (subscribed && active) { - grantGrace(describeExpiry()) - } - } - }) - subscribed = true - arm() - return { clear } -} diff --git a/src/main/persistence/profile-state/profile-state-writer-diagnostics.ts b/src/main/persistence/profile-state/profile-state-writer-diagnostics.ts index c68559dc7966..9d4cbcc5f266 100644 --- a/src/main/persistence/profile-state/profile-state-writer-diagnostics.ts +++ b/src/main/persistence/profile-state/profile-state-writer-diagnostics.ts @@ -4,7 +4,6 @@ import { recordDurableCrashBreadcrumb } from '../../crash-reporting/durable-crash-breadcrumb' import { getSystemPowerState } from '../../system-power-lifecycle' -import type { ProfileStateWriterDeadlineExpiry } from './profile-state-writer-deadline' import { profileStateWriterFailureOutcome } from './profile-state-writer-errors' export type ProfileStateWriterDiagnosticRequest = { @@ -22,52 +21,32 @@ function errorFields(error: unknown): CrashReportBreadcrumbData { return { errorCode: code, outcome: profileStateWriterFailureOutcome(error) } } -function expiryFields(expiry: ProfileStateWriterDeadlineExpiry): CrashReportBreadcrumbData { - return { - timeoutMs: expiry.timeoutMs, - elapsedMs: Math.round(expiry.elapsedMs), - overdueMs: Math.round(expiry.overdueMs), - graces: expiry.graces, - powerState: expiry.powerState - } -} +export type ProfileStateWriterSlowPhase = 'awaiting-reply' | 'awaiting-exit' -export function recordProfileStateWriterGrace( - request: ProfileStateWriterDiagnosticRequest, - expiry: ProfileStateWriterDeadlineExpiry +/** Elapsed is monotonic time without an observed completion, not CPU time. */ +export function recordProfileStateWriterSlow( + request: ProfileStateWriterDiagnosticRequest & { + phase: ProfileStateWriterSlowPhase + elapsedMs: number + } ): void { - // Coalesced: a long stall can grant grace to consecutive requests in one burst. + // Coalesced: one stall can leave consecutive requests slow in a single burst. recordCoalescedDurableCrashBreadcrumb({ - name: 'profile_state_writer_deadline_grace', - data: { ...request, ...expiryFields(expiry) }, - coalesceKey: 'profile_state_writer_deadline_grace', + name: 'profile_state_writer_slow', + data: request, + coalesceKey: 'profile_state_writer_slow', minIntervalMs: 60_000 }) } -export function recordProfileStateWriterTimeout( - request: ProfileStateWriterDiagnosticRequest, - expiry: ProfileStateWriterDeadlineExpiry -): void { - recordDurableCrashBreadcrumb('profile_state_writer_timeout', { - ...request, - ...expiryFields(expiry) - }) -} - export function recordProfileStateWriterFault( error: unknown, active: { command: string; id: number } | undefined, acknowledgedRevision: number, exitCode?: number ): void { - const fields = errorFields(error) - // Timeouts already carry their deadline timings in profile_state_writer_timeout. - if (fields.errorCode === 'profile-state-writer-timeout') { - return - } recordDurableCrashBreadcrumb('profile_state_writer_failed', { - ...fields, + ...errorFields(error), ...(active && { command: active.command, requestId: active.id }), acknowledgedRevision, ...(exitCode === undefined ? {} : { exitCode }), diff --git a/src/main/persistence/profile-state/profile-state-writer-electron-fixture.ts b/src/main/persistence/profile-state/profile-state-writer-electron-fixture.ts index 12f946c27bb2..b2d81f5db6fa 100644 --- a/src/main/persistence/profile-state/profile-state-writer-electron-fixture.ts +++ b/src/main/persistence/profile-state/profile-state-writer-electron-fixture.ts @@ -4,13 +4,14 @@ import { writeFileSync } from 'node:fs' import { join } from 'node:path' import { applyBackgroundActivationPolicy } from '../../window/foreground-activation-policy' import { openProfileStateDatabase } from './profile-state-database' +import { readProfileStateSnapshot } from './profile-state-documents' import { readProfileStateRevision } from './profile-state-revision' import { ProfileStateWriteWorkerClient } from './profile-state-writer-worker-client' const root = process.argv[2] -const timeoutMs = Number(process.argv[3]) -if (!root || !Number.isFinite(timeoutMs) || timeoutMs <= 0) { - throw new Error('Expected an isolated fixture directory and a positive timeout') +const slowWarningMs = Number(process.argv[3]) +if (!root || !Number.isFinite(slowWarningMs) || slowWarningMs <= 0) { + throw new Error('Expected an isolated fixture directory and a positive warning threshold') } app.setPath('userData', root) app.disableHardwareAcceleration() @@ -42,7 +43,7 @@ async function run(): Promise { const initialization = { databasePath, profileId, revision: 0, counters } const client = new ProfileStateWriteWorkerClient(initialization, { workerPath: join(root, 'observed-worker.cjs'), - timeoutMs, + slowWarningMs, onFailure: (error) => { failures.push(error.message) console.error('[writer-fixture] failure', { @@ -73,7 +74,7 @@ async function run(): Promise { new Int32Array(new SharedArrayBuffer(4)), 0, 0, - Math.max(0, timeoutMs + 100 - (performance.now() - started)) + Math.max(0, slowWarningMs + 100 - (performance.now() - started)) ) resolve() } catch (error) { @@ -93,9 +94,11 @@ async function run(): Promise { } const opened = openProfileStateDatabase(databasePath, profileId) const durableRevision = readProfileStateRevision(opened.db) + const durableState: unknown = JSON.parse(readProfileStateSnapshot(opened.db).json) opened.db.close() assert.deepEqual(revisions, [2, 3, 4, 5]) assert.equal(durableRevision, 6) + assert.deepEqual(durableState, { ui: { marker: 'after' } }) assert.equal(Atomics.load(counts, 1), 1) assert.deepEqual(failures, []) writeFileSync( @@ -103,9 +106,10 @@ async function run(): Promise { JSON.stringify({ electron: process.versions.electron, node: process.versions.node, - timeoutMs, + slowWarningMs, revisions, durableRevision, + durableState, workerStarts: Atomics.load(counts, 1), failures }) diff --git a/src/main/persistence/profile-state/profile-state-writer-protocol-faults.test.ts b/src/main/persistence/profile-state/profile-state-writer-protocol-faults.test.ts index f73cb891893a..ceb31638d7f7 100644 --- a/src/main/persistence/profile-state/profile-state-writer-protocol-faults.test.ts +++ b/src/main/persistence/profile-state/profile-state-writer-protocol-faults.test.ts @@ -33,7 +33,7 @@ function clientFor( ) const client = new ProfileStateWriteWorkerClient( { databasePath: join(root, 'unused.db'), profileId: 'protocol-test', revision: 1 }, - { workerPath, timeoutMs: 1000, onFailure } + { workerPath, slowWarningMs: 50, onFailure } ) clients.push(client) return client @@ -88,20 +88,26 @@ describe('writer protocol refuses uncertain acknowledgements', () => { }) }) - it('faults an unanswered request and rejects later work without retry', async () => { + it('keeps an unanswered request pending past the slow warning without faulting', async () => { const notify = vi.fn() const client = clientFor('', undefined, notify) await client.ready - await expect( - client.writeSerializedDomains([{ domain: 'settings', payload: '{}' }]) - ).rejects.toMatchObject({ code: 'profile-state-writer-timeout', outcome: 'indeterminate' }) - await expect(client.assertCurrentRevision()).rejects.toMatchObject({ - code: 'profile-state-writer-timeout' - }) - await client.close() - expect(notify).toHaveBeenCalledExactlyOnceWith( - expect.objectContaining({ code: 'profile-state-writer-timeout' }) + let settled = false + const write = client + .writeSerializedDomains([{ domain: 'settings', payload: '{}' }]) + .finally(() => (settled = true)) + await new Promise((resolve) => setTimeout(resolve, 200)) + expect(settled).toBe(false) + expect(notify).not.toHaveBeenCalled() + expect(() => client.assertWritable()).toThrow( + expect.objectContaining({ code: 'profile-state-writer-busy' }) ) + const rejected = expect(write).rejects.toMatchObject({ + code: 'profile-state-writer-aborted', + outcome: 'indeterminate' + }) + await client.abort() + await rejected }) it('reports an idle writer exit even without a subsequent save', async () => { diff --git a/src/main/persistence/profile-state/profile-state-writer-request.ts b/src/main/persistence/profile-state/profile-state-writer-request.ts index dcd867b052c8..7f1949f88c3a 100644 --- a/src/main/persistence/profile-state/profile-state-writer-request.ts +++ b/src/main/persistence/profile-state/profile-state-writer-request.ts @@ -1,8 +1,4 @@ -import { createProfileStateWriterDeadline } from './profile-state-writer-deadline' -import { - recordProfileStateWriterGrace, - recordProfileStateWriterTimeout -} from './profile-state-writer-diagnostics' +import { startProfileStateWriterSlowWarning } from './profile-state-writer-slow-warning' import type { ProfileStateWriterCommand, ProfileStateWriterResponse @@ -15,7 +11,7 @@ export type PendingProfileStateWriterRequest = { promise: Promise resolve: (response: SuccessfulProfileStateWriterResponse) => void reject: (error: Error) => void - clearDeadline: () => void + clearSlowWarning: () => void } export function isExpectedProfileStateWriterSuccess( @@ -49,30 +45,28 @@ export function isExpectedProfileStateWriterSuccess( return response.exportedRevision === undefined } -/** Each request owns its deadline; timeouts and grace leave a durable breadcrumb. */ +/** A request stays pending until its matching reply or a real fault; slowness only warns. */ export function createProfileStateWriterRequest( id: number, command: PendingProfileStateWriterRequest['command'], - timeoutMs: number, - handlers: { onTimeout: () => void; acknowledgedRevision: () => number; now?: () => number } + slowWarningMs: number, + diagnostics: { acknowledgedRevision: number; now?: () => number; onSlow?: () => void } ): PendingProfileStateWriterRequest { - const describe = () => ({ - command, - requestId: id, - acknowledgedRevision: handlers.acknowledgedRevision() + const clearSlowWarning = startProfileStateWriterSlowWarning({ + warningMs: slowWarningMs, + phase: 'awaiting-reply', + now: diagnostics.now, + onSlow: diagnostics.onSlow, + request: { + command, + requestId: id, + acknowledgedRevision: diagnostics.acknowledgedRevision + } }) - const deadline = createProfileStateWriterDeadline( - timeoutMs, - (expiry) => { - recordProfileStateWriterTimeout(describe(), expiry) - handlers.onTimeout() - }, - { now: handlers.now, onGrace: (expiry) => recordProfileStateWriterGrace(describe(), expiry) } - ) return { id, command, ...Promise.withResolvers(), - clearDeadline: deadline.clear + clearSlowWarning } } diff --git a/src/main/persistence/profile-state/profile-state-writer-sleep.test.ts b/src/main/persistence/profile-state/profile-state-writer-sleep.test.ts index 8763397c1be8..8d55bd3c9eb5 100644 --- a/src/main/persistence/profile-state/profile-state-writer-sleep.test.ts +++ b/src/main/persistence/profile-state/profile-state-writer-sleep.test.ts @@ -1,13 +1,11 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { setImmediate as waitForPoll } from 'node:timers/promises' import { afterEach, expect, it, vi } from 'vitest' import type * as SystemPowerLifecycle from '../../system-power-lifecycle' import { publishSystemResume, publishSystemSuspend } from '../../system-power-lifecycle' -import { - PROFILE_STATE_WRITER_MAX_GRACES, - PROFILE_STATE_WRITER_OVERDUE_GRACE_MS -} from './profile-state-writer-deadline' +import type * as WriterDiagnostics from './profile-state-writer-diagnostics' import { ProfileStateWriteWorkerClient } from './profile-state-writer-worker-client' const power = vi.hoisted(() => ({ subscriptions: 0 })) @@ -27,23 +25,44 @@ vi.mock('../../system-power-lifecycle', async (importOriginal) => { } } }) +const diagnostics = vi.hoisted(() => ({ slow: vi.fn() })) +vi.mock('./profile-state-writer-diagnostics', async (importOriginal) => ({ + ...(await importOriginal()), + recordProfileStateWriterSlow: diagnostics.slow +})) -const clients: ProfileStateWriteWorkerClient[] = [] -const roots: string[] = [] -const TIMEOUT_MS = 30_000 +const WARNING_MS = 30_000 const OVERNIGHT_MS = 8 * 60 * 60 * 1000 +const UNLIMITED = 2 ** 30 +// Shared-memory slots: replies posted, replies allowed, exit allowed, worker starts. +const REPLIES = 0 +const ALLOWED_REPLIES = 1 +const EXIT_ALLOWED = 2 +const STARTS = 3 + +const fixtures: { + client: ProfileStateWriteWorkerClient + releaseReplies: () => void + releaseExit: () => void +}[] = [] +const roots: string[] = [] afterEach(async () => { vi.useRealTimers() publishSystemResume() - await Promise.all(clients.splice(0).map((client) => client.close().catch(() => {}))) + diagnostics.slow.mockReset() + for (const fixture of fixtures) { + fixture.releaseReplies() + fixture.releaseExit() + } + await Promise.all(fixtures.splice(0).map(({ client }) => client.close().catch(() => {}))) for (const root of roots.splice(0)) { rmSync(root, { recursive: true, force: true }) } }) -/** A worker that counts replies in shared memory, so a test can block until one is queued. */ -function createClient({ acknowledgeWrites = true } = {}) { +/** A worker whose replies and post-close exit can be held behind shared-memory gates. */ +function createClient({ holdReplies = false, holdExit = false } = {}) { const root = mkdtempSync(join(tmpdir(), 'orca-writer-sleep-')) roots.push(root) const workerPath = join(root, 'writer.cjs') @@ -51,45 +70,76 @@ function createClient({ acknowledgeWrites = true } = {}) { workerPath, ` const { parentPort, workerData } = require('node:worker_threads') - const replies = new Int32Array(workerData.replies) + const shared = new Int32Array(workerData.shared) + const awaitAtLeast = (index, minimum) => { + for (let value = Atomics.load(shared, index); value < minimum; value = Atomics.load(shared, index)) { + Atomics.wait(shared, index, value) + } + } + const reply = message => { + awaitAtLeast(${ALLOWED_REPLIES}, Atomics.load(shared, ${REPLIES}) + 1) + parentPort.postMessage(message) + Atomics.add(shared, ${REPLIES}, 1) + Atomics.notify(shared, ${REPLIES}) + } + Atomics.add(shared, ${STARTS}, 1) let revision = 1 - parentPort.postMessage({ id: 0, ok: true, revision }) + reply({ id: 0, ok: true, revision }) parentPort.on('message', request => { - if (request.command.startsWith('write-')) { - if (!workerData.acknowledgeWrites) return - revision++ + if (request.command.startsWith('write-')) revision++ + const exported = request.command.startsWith('export-') ? { exportedRevision: revision } : {} + reply({ id: request.id, ok: true, revision, ...exported }) + if (request.command === 'close') { + awaitAtLeast(${EXIT_ALLOWED}, 1) + parentPort.close() } - parentPort.postMessage({ id: request.id, ok: true, revision }) - Atomics.add(replies, 0, 1) - Atomics.notify(replies, 0) - if (request.command === 'close') parentPort.close() }) ` ) - const replies = new SharedArrayBuffer(4) + const buffer = new SharedArrayBuffer(16) + const shared = new Int32Array(buffer) + shared[ALLOWED_REPLIES] = holdReplies ? 0 : UNLIMITED + shared[EXIT_ALLOWED] = holdExit ? 0 : 1 + const store = (index: number, value: number) => { + Atomics.store(shared, index, value) + Atomics.notify(shared, index) + } + const clock = { now: 0 } + const onFailure = vi.fn() + const onSaveDelayChanged = vi.fn() + // The fixture worker reads its shared gates from workerData alongside the protocol fields. const initialization = { databasePath: join(root, 'unused.db'), profileId: 'sleep-test', revision: 1, - replies, - acknowledgeWrites + shared: buffer } - const clock = { now: 0 } - const onFailure = vi.fn() const client = new ProfileStateWriteWorkerClient(initialization, { workerPath, onFailure, + onSaveDelayChanged, clock: () => clock.now }) - clients.push(client) - const counter = new Int32Array(replies) - return { + const fixture = { client, onFailure, - /** Block the main thread until the worker has posted reply number `count`. */ + onSaveDelayChanged, + root, + workerStarts: () => Atomics.load(shared, STARTS), + /** Hold every reply not yet posted. */ + holdReplies: () => store(ALLOWED_REPLIES, Atomics.load(shared, REPLIES)), + releaseReplies: () => store(ALLOWED_REPLIES, UNLIMITED), + releaseExit: () => store(EXIT_ALLOWED, 1), + /** Block the main thread until the worker has posted reply number `count` (init is 1). */ awaitQueuedReply: (count: number) => { - while (Atomics.load(counter, 0) < count) { - Atomics.wait(counter, 0, Atomics.load(counter, 0), 5_000) + const deadline = performance.now() + 5_000 + for (let posted = Atomics.load(shared, REPLIES); posted < count;) { + const remaining = deadline - performance.now() + if (remaining <= 0) { + throw new Error(`Worker posted ${posted} of ${count} replies`) + } + Atomics.wait(shared, REPLIES, posted, remaining) + posted = Atomics.load(shared, REPLIES) } }, /** Monotonic time passes while the main loop runs no callbacks. */ @@ -102,200 +152,282 @@ function createClient({ acknowledgeWrites = true } = {}) { vi.advanceTimersByTime(ms) } } + fixtures.push(fixture) + return fixture } -it('accepts an acknowledgment queued behind an overdue timeout with no power events', async () => { - const { client, onFailure, awaitQueuedReply, stall, run } = createClient() - await client.ready +function useFakeTimers(): void { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - const write = client.writeSerializedDomains([]).catch((error: unknown) => error) - awaitQueuedReply(1) +} + +async function isSettled(promise: Promise): Promise { + let settled = false + void promise.then( + () => (settled = true), + () => (settled = true) + ) + await waitForPoll() + await waitForPoll() + return settled +} + +it('accepts an acknowledgment queued behind an overdue warning timer', async () => { + const { client, onFailure, onSaveDelayChanged, awaitQueuedReply, stall, run } = createClient() + await client.ready + useFakeTimers() + const write = client.writeSerializedDomains([]) + awaitQueuedReply(2) // The incident: the loop stalls for hours, then expired timers run before queued messages. stall(3 * 60 * 60 * 1000) - run(TIMEOUT_MS) + run(WARNING_MS) expect(await write).toBe(2) + expect(onSaveDelayChanged.mock.calls).toEqual([[true], [false]]) await expect(client.writeSerializedDomains([])).resolves.toBe(3) expect(onFailure).not.toHaveBeenCalled() + // Only codes, counters, and timings: no payloads, paths, or profile ids. + expect(diagnostics.slow).toHaveBeenCalledExactlyOnceWith({ + command: 'write-domains', + requestId: 1, + acknowledgedRevision: 1, + phase: 'awaiting-reply', + elapsedMs: 3 * 60 * 60 * 1000 + WARNING_MS + }) expect(vi.getTimerCount()).toBe(0) }) -it.each([0, 1, PROFILE_STATE_WRITER_OVERDUE_GRACE_MS - 1])( - 'keeps saving across repeated queued replies when the timeout is %i ms overdue', - async (overdueMs) => { - const { client, onFailure, awaitQueuedReply, stall, run } = createClient() - await client.ready - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - for (let writeNumber = 1; writeNumber <= 4; writeNumber += 1) { - const write = client.writeSerializedDomains([]).catch((error: unknown) => error) - awaitQueuedReply(writeNumber) - stall(overdueMs) - run(TIMEOUT_MS) - expect(await write).toBe(writeNumber + 1) - expect(vi.getTimerCount()).toBe(0) - expect(power.subscriptions).toBe(0) - } - expect(onFailure).not.toHaveBeenCalled() - } -) - -it('accepts a reply delivered before its timeout and releases the deadline', async () => { - const { client, onFailure } = createClient() +it('clears the warning when the reply arrives first so a stale timer cannot warn', async () => { + const { client, onFailure, onSaveDelayChanged, run } = createClient() await client.ready - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + useFakeTimers() await expect(client.writeSerializedDomains([])).resolves.toBe(2) expect(vi.getTimerCount()).toBe(0) - expect(power.subscriptions).toBe(0) + run(WARNING_MS * 2) + expect(diagnostics.slow).not.toHaveBeenCalled() expect(onFailure).not.toHaveBeenCalled() + expect(onSaveDelayChanged).not.toHaveBeenCalled() }) -it.each(['before sleep', 'during sleep'])( - 'accepts a save started %s across an overnight suspend and keeps saving', - async (timing) => { - const { client, onFailure, awaitQueuedReply, stall, run } = createClient() - await client.ready - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - if (timing === 'during sleep') { - publishSystemSuspend() +it('keeps initialization pending past the warning and admits the same worker', async () => { + useFakeTimers() + const { client, onFailure, onSaveDelayChanged, run, releaseReplies, workerStarts } = createClient( + { + holdReplies: true } - const write = client - .writeSerializedDomains([{ domain: 'settings', payload: '{}' }]) - .catch((error: unknown) => error) - awaitQueuedReply(1) - publishSystemSuspend() - stall(OVERNIGHT_MS) - run(TIMEOUT_MS) - publishSystemResume() - expect(await write).toBe(2) - await expect(client.assertCurrentRevision()).resolves.toBe(2) - await expect(client.writeSerializedDomains([])).resolves.toBe(3) + ) + run(WARNING_MS * 3) + expect(await isSettled(client.ready)).toBe(false) + releaseReplies() + await expect(client.ready).resolves.toBeUndefined() + await expect(client.writeSerializedDomains([])).resolves.toBe(2) + expect(diagnostics.slow).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ command: 'initialize', requestId: 0, phase: 'awaiting-reply' }) + ) + expect(workerStarts()).toBe(1) + expect(onFailure).not.toHaveBeenCalled() + expect(onSaveDelayChanged).not.toHaveBeenCalled() +}) + +it.each([ + ['write-domains', (client: ProfileStateWriteWorkerClient) => client.writeSerializedDomains([])], + [ + 'write-state', + (client: ProfileStateWriteWorkerClient) => client.writeSerializedState(Buffer.from('{}')) + ], + [ + 'write-complete', + (client: ProfileStateWriteWorkerClient) => client.writeCompleteSerializedDomains([]) + ], + [ + 'write-automation', + (client: ProfileStateWriteWorkerClient) => client.writeSerializedAutomationRuns([], []) + ], + ['assert-revision', (client: ProfileStateWriteWorkerClient) => client.assertCurrentRevision()], + [ + 'export-json', + (client: ProfileStateWriteWorkerClient, root: string) => + client.writeJsonExport(join(root, 'export.json')) + ], + [ + 'export-latest', + (client: ProfileStateWriteWorkerClient, root: string) => + client.writeLatestJsonExport(join(root, 'export.json')) + ] +])( + 'accepts a delayed %s reply after the warning and keeps the same worker', + async (command, start) => { + const fixture = createClient() + const { client, onFailure, onSaveDelayChanged, root, run, workerStarts } = fixture + await client.ready + useFakeTimers() + fixture.holdReplies() + const pending = start(client, root) + run(WARNING_MS * 3) + expect(await isSettled(pending)).toBe(false) + expect(onSaveDelayChanged).toHaveBeenCalledExactlyOnceWith(true) + expect(() => client.assertWritable()).toThrow( + expect.objectContaining({ code: 'profile-state-writer-busy' }) + ) + fixture.releaseReplies() + expect(await pending).toBeGreaterThanOrEqual(1) + expect(diagnostics.slow).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ command, requestId: 1, phase: 'awaiting-reply' }) + ) + await expect(client.writeSerializedDomains([])).resolves.toBeGreaterThan(1) + expect(workerStarts()).toBe(1) expect(onFailure).not.toHaveBeenCalled() + expect(onSaveDelayChanged.mock.calls).toEqual([[true], [false]]) expect(vi.getTimerCount()).toBe(0) - expect(power.subscriptions).toBe(0) } ) -it('saves during a dark wake that never publishes resume', async () => { - const { client, onFailure, awaitQueuedReply, run } = createClient() +it('warns once for a never-replying request without faulting or replacing the worker', async () => { + const fixture = createClient() + const { client, onFailure, onSaveDelayChanged, run, workerStarts } = fixture await client.ready - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - publishSystemSuspend() - const write = client.writeSerializedDomains([]).catch((error: unknown) => error) - awaitQueuedReply(1) - // The loop runs on time while power state still reads suspended. - run(TIMEOUT_MS) - expect(await write).toBe(2) - await expect(client.writeSerializedDomains([])).resolves.toBe(3) - expect(onFailure).not.toHaveBeenCalled() -}) - -it('allows initialization during suspend without retiring the writer', async () => { - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - publishSystemSuspend() - const { client, stall, run } = createClient() - const ready = client.ready.catch((error: unknown) => error) - stall(OVERNIGHT_MS) - run(TIMEOUT_MS) - publishSystemResume() - expect(await ready).toBeUndefined() - expect(() => client.assertWritable()).not.toThrow() -}) - -it('still retires a hung writer once the loop runs a full window on time after grace', async () => { - const { client, onFailure, stall, run } = createClient({ acknowledgeWrites: false }) - await client.ready - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - const write = client.writeSerializedDomains([]).catch((error: unknown) => error) - stall(3 * 60 * 60 * 1000) - run(TIMEOUT_MS) - run(TIMEOUT_MS - 1) + useFakeTimers() + fixture.holdReplies() + const write = client.writeSerializedDomains([]) + for (let window = 0; window < 10; window += 1) { + run(WARNING_MS) + } + run(OVERNIGHT_MS) + expect(await isSettled(write)).toBe(false) + expect(diagnostics.slow).toHaveBeenCalledOnce() expect(onFailure).not.toHaveBeenCalled() - run(1) - expect(await write).toMatchObject({ - code: 'profile-state-writer-timeout', + expect(onSaveDelayChanged).toHaveBeenCalledExactlyOnceWith(true) + expect(workerStarts()).toBe(1) + expect(vi.getTimerCount()).toBe(0) + // Explicit abort keeps the existing fault path and its exit wait. + const rejected = expect(write).rejects.toMatchObject({ + code: 'profile-state-writer-aborted', outcome: 'indeterminate' }) - await expect(client.assertCurrentRevision()).rejects.toMatchObject({ - code: 'profile-state-writer-timeout' - }) - expect(onFailure).toHaveBeenCalledOnce() - expect(vi.getTimerCount()).toBe(0) - expect(power.subscriptions).toBe(0) + const aborting = client.abort() + fixture.releaseReplies() + await aborting + await rejected + expect(diagnostics.slow).toHaveBeenCalledOnce() + expect(onSaveDelayChanged.mock.calls).toEqual([[true], [false]]) }) -it('still retires a hung writer after its awake deadline following resume', async () => { - const { client, onFailure, stall, run } = createClient({ acknowledgeWrites: false }) +it('keeps saving if the delayed-save observer throws and clears each delayed request', async () => { + const fixture = createClient() + const { client, onSaveDelayChanged, onFailure, run } = fixture await client.ready - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - const write = client.writeSerializedDomains([]).catch((error: unknown) => error) - run(TIMEOUT_MS - 1_000) - publishSystemSuspend() - stall(OVERNIGHT_MS) - publishSystemResume() - run(TIMEOUT_MS - 1) - expect(onFailure).not.toHaveBeenCalled() - run(1) - expect(await write).toMatchObject({ code: 'profile-state-writer-timeout' }) - expect(onFailure).toHaveBeenCalledOnce() + useFakeTimers() + onSaveDelayChanged.mockImplementation(() => { + throw new Error('renderer unavailable') + }) + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + for (let revision = 2; revision <= 3; revision += 1) { + fixture.holdReplies() + const write = client.writeSerializedDomains([]) + run(WARNING_MS) + fixture.releaseReplies() + expect(await write).toBe(revision) + } + expect(onSaveDelayChanged.mock.calls).toEqual([[true], [false], [true], [false]]) + expect(onFailure).not.toHaveBeenCalled() + expect(consoleError).toHaveBeenCalledTimes(4) + expect(vi.getTimerCount()).toBe(0) + } finally { + consoleError.mockRestore() + } }) -it('bounds repeated stalls so a hung writer cannot wait forever', async () => { - const { client, stall, run } = createClient({ acknowledgeWrites: false }) +it('keeps saving when the diagnostics sink throws', async () => { + const fixture = createClient() + const { client, onFailure, run } = fixture await client.ready - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - const write = client.writeSerializedDomains([]).catch((error: unknown) => error) - let settled = false - void write.then(() => (settled = true)) - for (let episode = 0; episode < PROFILE_STATE_WRITER_MAX_GRACES; episode += 1) { - stall(60 * 60 * 1000) - run(TIMEOUT_MS) - await Promise.resolve() - expect(settled).toBe(false) + useFakeTimers() + diagnostics.slow.mockImplementationOnce(() => { + throw new Error('sink unavailable') + }) + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + fixture.holdReplies() + const write = client.writeSerializedDomains([]) + run(WARNING_MS) + fixture.releaseReplies() + expect(await write).toBe(2) + await expect(client.writeSerializedDomains([])).resolves.toBe(3) + expect(onFailure).not.toHaveBeenCalled() + expect(consoleError).toHaveBeenCalledOnce() + } finally { + consoleError.mockRestore() } - stall(60 * 60 * 1000) - run(TIMEOUT_MS) - expect(await write).toMatchObject({ code: 'profile-state-writer-timeout' }) }) -it.each([true, false])( - 'settles after repeated resumes with a queued reply: %s', - async (acknowledgeWrites) => { - const { client, onFailure, awaitQueuedReply, run } = createClient({ acknowledgeWrites }) +it.each(['before sleep', 'during sleep'])( + 'accepts a save started %s across an overnight suspend without power subscriptions', + async (timing) => { + const fixture = createClient() + const { client, onFailure, stall, run } = fixture await client.ready - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - const write = client.writeSerializedDomains([]).catch((error: unknown) => error) - if (acknowledgeWrites) { - awaitQueuedReply(1) - } - for (let cycle = 0; cycle <= PROFILE_STATE_WRITER_MAX_GRACES; cycle += 1) { - run(TIMEOUT_MS - 1) + useFakeTimers() + if (timing === 'during sleep') { publishSystemSuspend() - publishSystemResume() } - run(1) - if (acknowledgeWrites) { - expect(await write).toBe(2) - await expect(client.writeSerializedDomains([])).resolves.toBe(3) - expect(onFailure).not.toHaveBeenCalled() - } else { - expect(await write).toMatchObject({ code: 'profile-state-writer-timeout' }) - expect(onFailure).toHaveBeenCalledOnce() - } - expect(vi.getTimerCount()).toBe(0) + fixture.holdReplies() + const write = client.writeSerializedDomains([{ domain: 'settings', payload: '{}' }]) + publishSystemSuspend() + stall(OVERNIGHT_MS) + run(WARNING_MS) + publishSystemResume() + run(WARNING_MS) + expect(await isSettled(write)).toBe(false) + fixture.releaseReplies() + expect(await write).toBe(2) + await expect(client.assertCurrentRevision()).resolves.toBe(2) + await expect(client.writeSerializedDomains([])).resolves.toBe(3) + expect(onFailure).not.toHaveBeenCalled() + expect(diagnostics.slow).toHaveBeenCalledOnce() expect(power.subscriptions).toBe(0) + expect(vi.getTimerCount()).toBe(0) } ) -it('releases deadlines and power subscriptions after abort and close', async () => { - const aborted = createClient({ acknowledgeWrites: false }) - await aborted.client.ready - const pending = aborted.client.writeSerializedDomains([]).catch((error: unknown) => error) - expect(power.subscriptions).toBe(1) - await aborted.client.abort() - expect(await pending).toMatchObject({ code: 'profile-state-writer-aborted' }) - expect(power.subscriptions).toBe(0) +it('keeps close pending past the warning until its delayed acknowledgment and exit', async () => { + const fixture = createClient() + const { client, onFailure, run } = fixture + await client.ready + useFakeTimers() + fixture.holdReplies() + const closing = client.close() + await waitForPoll() + run(WARNING_MS * 3) + expect(await isSettled(closing)).toBe(false) + fixture.releaseReplies() + await expect(closing).resolves.toBeUndefined() + expect(diagnostics.slow).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ command: 'close', phase: 'awaiting-reply' }) + ) + expect(onFailure).not.toHaveBeenCalled() +}) - const closed = createClient() - await closed.client.ready - await closed.client.close() - expect(power.subscriptions).toBe(0) +it('waits for actual exit after close acknowledgment with one awaiting-exit warning', async () => { + const fixture = createClient({ holdExit: true }) + const { client, onFailure, run, awaitQueuedReply } = fixture + await client.ready + useFakeTimers() + const closing = client.close() + // close() dispatches after a microtask; let it post before blocking on the reply. + await waitForPoll() + awaitQueuedReply(2) + await waitForPoll() + run(WARNING_MS) + run(WARNING_MS * 2) + expect(await isSettled(closing)).toBe(false) + expect(diagnostics.slow).toHaveBeenCalledExactlyOnceWith({ + command: 'close', + requestId: 1, + acknowledgedRevision: 1, + phase: 'awaiting-exit', + elapsedMs: WARNING_MS + }) + fixture.releaseExit() + await expect(closing).resolves.toBeUndefined() + expect(onFailure).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) }) diff --git a/src/main/persistence/profile-state/profile-state-writer-slow-warning.ts b/src/main/persistence/profile-state/profile-state-writer-slow-warning.ts new file mode 100644 index 000000000000..dec651fb2555 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-writer-slow-warning.ts @@ -0,0 +1,41 @@ +import { + recordProfileStateWriterSlow, + type ProfileStateWriterDiagnosticRequest, + type ProfileStateWriterSlowPhase +} from './profile-state-writer-diagnostics' + +export const PROFILE_STATE_WRITER_SLOW_WARNING_MS = 30_000 + +export type ProfileStateWriterSlowWarningOptions = { + warningMs: number + phase: ProfileStateWriterSlowPhase + request: ProfileStateWriterDiagnosticRequest + /** Monotonic milliseconds; tests replace it to model a stalled main loop. */ + now?: () => number + onSlow?: () => void +} + +/** Elapsed time cannot prove failure; warn once without settling the request. */ +export function startProfileStateWriterSlowWarning({ + warningMs, + phase, + request, + now = () => performance.now(), + onSlow +}: ProfileStateWriterSlowWarningOptions): () => void { + const startedAt = now() + const timer = setTimeout(() => { + onSlow?.() + try { + recordProfileStateWriterSlow({ + ...request, + phase, + elapsedMs: Math.max(0, Math.round(now() - startedAt)) + }) + } catch (error) { + console.error('[persistence] Could not record slow profile state writer:', error) + } + }, warningMs) + timer.unref?.() + return () => clearTimeout(timer) +} diff --git a/src/main/persistence/profile-state/profile-state-writer-stall.electron.test.ts b/src/main/persistence/profile-state/profile-state-writer-stall.electron.test.ts index 58175d0c0459..51f2399c6748 100644 --- a/src/main/persistence/profile-state/profile-state-writer-stall.electron.test.ts +++ b/src/main/persistence/profile-state/profile-state-writer-stall.electron.test.ts @@ -9,12 +9,12 @@ import { resolveElectronProbeLaunch } from '../../browser/electron-probe-display const root = mkdtempSync(join(tmpdir(), 'orca-writer-electron-')) const entry = join(root, 'main.cjs') -const timeoutMs = Number(process.env.ORCA_PROFILE_STALL_TIMEOUT_MS ?? 2_000) -const processTimeoutMs = timeoutMs * 6 + 30_000 +const slowWarningMs = Number(process.env.ORCA_PROFILE_STALL_WARNING_MS ?? 2_000) +const processTimeoutMs = slowWarningMs * 6 + 30_000 beforeAll(async () => { - if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { - throw new Error('Invalid stall timeout') + if (!Number.isFinite(slowWarningMs) || slowWarningMs <= 0) { + throw new Error('Invalid stall warning threshold') } await Promise.all( [ @@ -60,7 +60,7 @@ it( } const launch = resolveElectronProbeLaunch({ electronBinary, - electronArgs: [entry, root, String(timeoutMs)], + electronArgs: [entry, root, String(slowWarningMs)], platform: process.platform, display: process.env.DISPLAY }) @@ -77,9 +77,10 @@ it( const result: unknown = JSON.parse(readFileSync(join(root, 'result.json'), 'utf8')) expect(result).toMatchObject({ electron: expect.any(String), - timeoutMs, + slowWarningMs, revisions: [2, 3, 4, 5], durableRevision: 6, + durableState: { ui: { marker: 'after' } }, workerStarts: 1, failures: [] }) diff --git a/src/main/persistence/profile-state/profile-state-writer-thread.ts b/src/main/persistence/profile-state/profile-state-writer-thread.ts index 855df2fa6b07..ec425df6e8d5 100644 --- a/src/main/persistence/profile-state/profile-state-writer-thread.ts +++ b/src/main/persistence/profile-state/profile-state-writer-thread.ts @@ -1,5 +1,4 @@ import { Worker } from 'node:worker_threads' -import { createProfileStateWriterDeadline } from './profile-state-writer-deadline' /** Owns one writer thread's lifetime so recovery can prove it exited before replacing it. */ export class ProfileStateWriterThread { @@ -14,8 +13,7 @@ export class ProfileStateWriterThread { message: (value: unknown) => void error: (cause: Error) => void exit: (code: number) => void - }, - private readonly deadline: { timeoutMs: number; now?: () => number } + } ) { this.worker = new Worker(workerPath, { workerData, execArgv: [] }) this.worker.on('message', handlers.message) @@ -44,20 +42,4 @@ export class ProfileStateWriterThread { void this.worker.terminate().catch(() => {}) } } - - /** Resolve false when the thread has not exited within one awake deadline. */ - waitForExit(): Promise { - if (this.exitedFlag) { - return Promise.resolve(true) - } - const timedOut = Promise.withResolvers() - const deadline = createProfileStateWriterDeadline( - this.deadline.timeoutMs, - () => timedOut.resolve(false), - { now: this.deadline.now } - ) - return Promise.race([this.exit.promise.then(() => true), timedOut.promise]).finally( - deadline.clear - ) - } } diff --git a/src/main/persistence/profile-state/profile-state-writer-worker.test.ts b/src/main/persistence/profile-state/profile-state-writer-worker.test.ts index b79b8a2d7411..494427a014ac 100644 --- a/src/main/persistence/profile-state/profile-state-writer-worker.test.ts +++ b/src/main/persistence/profile-state/profile-state-writer-worker.test.ts @@ -2,7 +2,7 @@ import { build } from 'esbuild' import { mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' -import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest' import { ProfileStateSqliteAuthority } from './profile-state-sqlite-authority' import { ProfileStateRevisionConflictError } from './profile-state-document-validation' import { openProfileStateDatabase } from './profile-state-database' @@ -58,9 +58,12 @@ function fixture() { function clientFor( initialization: ReturnType['initialization'], path = workerPath, - timeoutMs = 5000 + slowWarningMs = 5000 ) { - const client = new ProfileStateWriteWorkerClient(initialization, { workerPath: path, timeoutMs }) + const client = new ProfileStateWriteWorkerClient(initialization, { + workerPath: path, + slowWarningMs + }) clients.push(client) return client } @@ -206,6 +209,35 @@ describe('persistent profile state write worker', () => { }) }) + it('accepts a commit acknowledged after the slow warning and keeps the same worker', async () => { + const f = fixture() + const delayed = script( + f.root, + ` + const { MessagePort } = require('node:worker_threads') + const send = MessagePort.prototype.postMessage + MessagePort.prototype.postMessage = function(value, ...rest) { + if (value?.id === 1 && value.ok) { setTimeout(() => send.call(this, value, ...rest), 200); return } + return send.call(this, value, ...rest) + } + require(${JSON.stringify(workerPath)}) + ` + ) + const client = clientFor(f.initialization, delayed, 20) + await client.ready + expect( + await client.writeSerializedDomains([{ domain: 'settings', payload: '{"delayed":true}' }]) + ).toBe(2) + expect(await client.writeSerializedDomains([{ domain: 'ui', payload: '{"after":true}' }])).toBe( + 3 + ) + await client.close() + expect(readState(f.path, f.profileId)).toEqual({ + settings: { delayed: true }, + ui: { after: true } + }) + }) + it('waits for actual worker exit after receiving its close acknowledgement', async () => { const f = fixture() const marker = join(f.root, 'worker-exited.txt') @@ -254,7 +286,7 @@ describe('persistent profile state write worker', () => { expect(readState(f.path, f.profileId)).toEqual({ settings: { committed: true } }) }) - it.each(['mismatch', 'timeout', 'exit'])( + it.each(['mismatch', 'exit'])( 'fails closed after a dispatched %s and awaits actual exit', async (mode) => { const f = fixture() @@ -271,24 +303,10 @@ describe('persistent profile state write worker', () => { ) const client = clientFor(f.initialization, broken) await client.ready - if (mode === 'timeout') { - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - } - try { - const failure = client - .writeSerializedDomains([{ domain: 'settings', payload: '{}' }]) - .catch((error: unknown) => error) - if (mode === 'timeout') { - await vi.advanceTimersByTimeAsync(5000) - } - const observedFailure = await failure - expect(profileStateWriterFailureOutcome(observedFailure)).toBe('indeterminate') - if (mode === 'timeout') { - expect(observedFailure).toMatchObject({ code: 'profile-state-writer-timeout' }) - } - } finally { - vi.useRealTimers() - } + const failure = await client + .writeSerializedDomains([{ domain: 'settings', payload: '{}' }]) + .catch((error: unknown) => error) + expect(profileStateWriterFailureOutcome(failure)).toBe('indeterminate') await client.close() expect(readState(f.path, f.profileId)).toEqual({ settings: { theme: 'dark' } }) rmSync(f.root, { recursive: true }) diff --git a/src/main/startup/main-process-quit-pending-writer.test.ts b/src/main/startup/main-process-quit-pending-writer.test.ts new file mode 100644 index 000000000000..cd2ddf840621 --- /dev/null +++ b/src/main/startup/main-process-quit-pending-writer.test.ts @@ -0,0 +1,107 @@ +import { EventEmitter } from 'node:events' +import { afterEach, expect, it, vi } from 'vitest' +import { WILL_QUIT_TEARDOWN_DEADLINE_MS } from '../quit-teardown-deadline' + +const settled = () => vi.fn(async () => {}) +const idle = () => vi.fn() + +// Every teardown member settles except the profile store, so only the writer can hold quit. +const dependencies: Record> = { + '../ipc/filesystem-watcher': { closeAllWatchers: settled() }, + '../ipc/worktree-base-directory-watcher': { disposeWorktreeBaseDirectoryWatchers: settled() }, + '../ipc/folder-repo-git-upgrade': { stopFolderRepoGitUpgradeWatch: idle() }, + '../ipc/pty': { killAllPty: idle() }, + '../daemon/daemon-init': { disconnectDaemon: settled(), shutdownDaemon: settled() }, + '../ipc/ssh-shutdown-drain': { beginSshShutdown: settled() }, + '../agent-hooks/server': { agentHookServer: { stop: idle() } }, + '../agent-hooks/wsl-hook-relay-manager': { wslHookRelayManager: { disposeAll: idle() } }, + '../agent-hooks/managed-agent-hook-controls': { + removeManagedAgentHooksAsync: vi.fn(async () => []) + }, + '../runtime/structured-agent-session-runtime': { stopStructuredAgentSessionRuntime: settled() }, + '../runtime/structured-agent-session-runtime-teardown': { + setStructuredAgentSessionTeardownTrigger: idle() + }, + '../runtime/orca-runtime-files': { awaitRuntimeFileWatcherUnsubscribes: settled() }, + '../runtime/runtime-metadata': { clearRuntimeMetadataIfOwned: idle() }, + '../browser/paired-runtime-browser-client-host-runtime': { + shutdownPairedRuntimeBrowserClientHosts: settled() + }, + '../browser/browser-manager': { browserManager: { setBrowserGuestStateChangedListener: idle() } }, + '../browser/local-ssh-browser-route': { closeAllLocalSshBrowserRoutes: settled() }, + '../codex/codex-state-db-backfill-recovery': { stopCodexStateDbBackfillRecoveries: settled() }, + '../codex/codex-account-session-bridge': { stopCodexAccountSessionBridges: idle() }, + '../git/local-repo-ref-maintenance': { awaitPackedRefsLockRelease: settled() }, + '../worktree-background-removal': { stopBackgroundWorktreeRemovals: idle() }, + '../dock/unread-badge': { setUnreadDockBadgeCount: idle() }, + '../tray/system-tray': { destroySystemTray: idle() }, + '../telemetry/client': { shutdownTelemetry: settled() }, + '../observability': { shutdownObservability: settled() }, + '../updater': { isQuittingForUpdate: vi.fn(() => false) }, + '../updater-lifecycle-diagnostics': { recordUpdaterLifecycle: idle() }, + '../macos-tcc-prompt-notice': { stopTccPromptNotice: idle() }, + '../terminal-history-gc': { cancelHistoryGc: idle() }, + './window-all-closed-quit-policy': { shouldQuitWhenAllWindowsClosed: vi.fn(() => false) }, + './configure-process': { isDevParentShutdownRequested: vi.fn(() => false) }, + '../persistence': { getCanonicalUserDataPath: vi.fn(() => '/unused') } +} + +afterEach(() => { + vi.doUnmock('electron') + vi.doUnmock('./main-process-state') + for (const moduleName of Object.keys(dependencies)) { + vi.doUnmock(moduleName) + } + vi.restoreAllMocks() + vi.useRealTimers() + vi.resetModules() +}) + +async function startQuit(flushFinalOrThrowAsync: () => Promise) { + vi.resetModules() + vi.useFakeTimers() + const app = Object.assign(new EventEmitter(), { quit: vi.fn() }) + const admission = { release: vi.fn() } + const store = { + flushFinalOrThrowAsync: vi.fn(flushFinalOrThrowAsync), + freezeWritesAsync: settled() + } + const profileStateAdmission: typeof admission | undefined = admission + const state = { isQuitting: false, watcherShutdownDone: true, store, profileStateAdmission } + vi.doMock('electron', () => ({ app })) + vi.doMock('./main-process-state', () => ({ mainProcessState: state })) + for (const [moduleName, exports] of Object.entries(dependencies)) { + vi.doMock(moduleName, () => exports) + } + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const { installMainProcessQuitHandlers } = await import('./main-process-quit') + const { quitTeardownStartGate } = await import('../quit-teardown-start-gate') + quitTeardownStartGate.resetForTests() + installMainProcessQuitHandlers() + app.emit('before-quit', { defaultPrevented: false }) + app.emit('will-quit', { preventDefault: vi.fn() }) + return { app, admission, store, state } +} + +it('quits at the teardown deadline without releasing admission while the writer is pending', async () => { + const { app, admission, store, state } = await startQuit(() => new Promise(() => {})) + await vi.advanceTimersByTimeAsync(WILL_QUIT_TEARDOWN_DEADLINE_MS - 1) + expect(app.quit).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(1) + expect(app.quit).toHaveBeenCalledOnce() + expect(console.warn).toHaveBeenCalledWith('[shutdown] Quit teardown deadline reached', { + pendingTeardowns: ['state'] + }) + expect(store.flushFinalOrThrowAsync).toHaveBeenCalledOnce() + expect(store.freezeWritesAsync).not.toHaveBeenCalled() + expect(admission.release).not.toHaveBeenCalled() + expect(state.profileStateAdmission).toBe(admission) +}) + +it('releases admission only after the final flush and freeze complete', async () => { + const { app, admission, store } = await startQuit(async () => {}) + await vi.advanceTimersByTimeAsync(0) + expect(store.freezeWritesAsync).toHaveBeenCalledOnce() + expect(admission.release).toHaveBeenCalledOnce() + expect(app.quit).toHaveBeenCalledOnce() +}) diff --git a/src/main/startup/main-process-ready-foundation.ts b/src/main/startup/main-process-ready-foundation.ts index 9e4156aaa6d2..015a7eba6544 100644 --- a/src/main/startup/main-process-ready-foundation.ts +++ b/src/main/startup/main-process-ready-foundation.ts @@ -49,6 +49,7 @@ import { createWslCliReconciliationStartupBarrier } from './wsl-cli-reconciliati import { agentHookServer } from '../agent-hooks/server' import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' import { reportProfileStateWriteFailure } from './profile-state-write-failure' +import { reportProfileStateSaveDelay } from './profile-state-save-delay' export async function initializeReadyFoundation(): Promise { logStartupMilestone('app-ready') @@ -140,7 +141,8 @@ export async function initializeReadyFoundation(): Promise { profileId: profile.profile.id, runtime: 'desktop', storageAuthority: state.isServeMode ? 'runtime' : 'desktop', - onPersistenceFailure: reportProfileStateWriteFailure + onPersistenceFailure: reportProfileStateWriteFailure, + onPersistenceSaveDelayChanged: reportProfileStateSaveDelay }) state.profileStateStartup = { backend: profileState.backend, diff --git a/src/main/startup/profile-state-save-delay.test.ts b/src/main/startup/profile-state-save-delay.test.ts new file mode 100644 index 000000000000..a360d1c97b53 --- /dev/null +++ b/src/main/startup/profile-state-save-delay.test.ts @@ -0,0 +1,39 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' + +const send = vi.hoisted(() => vi.fn()) +vi.mock('../ipc/ui', () => ({ sendToTrustedUIRenderer: send })) + +import { isProfileStateSaveDelayed, reportProfileStateSaveDelay } from './profile-state-save-delay' + +beforeEach(() => { + reportProfileStateSaveDelay(false) + send.mockReset() +}) +afterEach(() => vi.restoreAllMocks()) + +it('retains current status for a reopened renderer and publishes only transitions', () => { + expect(isProfileStateSaveDelayed()).toBe(false) + reportProfileStateSaveDelay(true) + reportProfileStateSaveDelay(true) + expect(isProfileStateSaveDelayed()).toBe(true) + expect(send).toHaveBeenCalledExactlyOnceWith('app:profileStateSaveDelayChanged', true) + reportProfileStateSaveDelay(false) + reportProfileStateSaveDelay(false) + expect(isProfileStateSaveDelayed()).toBe(false) + expect(send.mock.calls).toEqual([ + ['app:profileStateSaveDelayChanged', true], + ['app:profileStateSaveDelayChanged', false] + ]) +}) + +it('preserves status when its renderer disappears during publication', () => { + send.mockImplementationOnce(() => { + throw new Error('destroyed renderer') + }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + expect(() => reportProfileStateSaveDelay(true)).not.toThrow() + expect(isProfileStateSaveDelayed()).toBe(true) + expect(warn).toHaveBeenCalledOnce() + reportProfileStateSaveDelay(false) + expect(isProfileStateSaveDelayed()).toBe(false) +}) diff --git a/src/main/startup/profile-state-save-delay.ts b/src/main/startup/profile-state-save-delay.ts new file mode 100644 index 000000000000..7cd965d93747 --- /dev/null +++ b/src/main/startup/profile-state-save-delay.ts @@ -0,0 +1,20 @@ +import { sendToTrustedUIRenderer } from '../ipc/ui' + +let saveDelayed = false + +export function isProfileStateSaveDelayed(): boolean { + return saveDelayed +} + +export function reportProfileStateSaveDelay(delayed: boolean): void { + if (saveDelayed === delayed) { + return + } + saveDelayed = delayed + try { + sendToTrustedUIRenderer('app:profileStateSaveDelayChanged', delayed) + } catch (error) { + // The snapshot remains available when the renderer reopens. + console.warn('[persistence] Could not publish delayed saving:', error) + } +} diff --git a/src/main/startup/profile-state-write-failure.test.ts b/src/main/startup/profile-state-write-failure.test.ts index 685fa4aeb63d..cc72b49d90b3 100644 --- a/src/main/startup/profile-state-write-failure.test.ts +++ b/src/main/startup/profile-state-write-failure.test.ts @@ -46,9 +46,9 @@ class FakeWindow extends EventEmitter { moveTop = this.reveal } -const timeout = new ProfileStateWriterError( - 'profile-state-writer-timeout', - 'timed out', +const workerExit = new ProfileStateWriterError( + 'profile-state-writer-exit', + 'exited', 'indeterminate' ) const conflict = new ProfileStateWriterError( @@ -79,7 +79,7 @@ afterEach(async () => { it('attaches the alert to the visible main window and explains an unconfirmed write', async () => { const window = new FakeWindow(true) fixture.state.mainWindow = window - reportProfileStateWriteFailure(timeout) + reportProfileStateWriteFailure(workerExit) await vi.waitFor(() => expect(fixture.show).toHaveBeenCalledOnce()) expect(fixture.show).toHaveBeenCalledWith(window, { type: 'error', @@ -89,7 +89,7 @@ it('attaches the alert to the visible main window and explains an unconfirmed wr 'Orca could not confirm whether your most recent change was saved. New changes will not be saved until you restart Orca.', buttons: ['OK'] }) - expect(recordProfileStateWriteFailureReport).toHaveBeenCalledWith(timeout, 'dialog') + expect(recordProfileStateWriteFailureReport).toHaveBeenCalledWith(workerExit, 'dialog') }) it('tells the user saved changes are safe when no write was left unresolved', async () => { @@ -105,11 +105,11 @@ it('tells the user saved changes are safe when no write was left unresolved', as it('waits for a hidden window to be shown by the user instead of revealing it', async () => { const window = new FakeWindow(false) fixture.state.mainWindow = window - reportProfileStateWriteFailure(timeout) + reportProfileStateWriteFailure(workerExit) await new Promise((resolve) => setImmediate(resolve)) expect(fixture.show).not.toHaveBeenCalled() expect(window.reveal).not.toHaveBeenCalled() - expect(recordProfileStateWriteFailureReport).toHaveBeenCalledWith(timeout, 'deferred') + expect(recordProfileStateWriteFailureReport).toHaveBeenCalledWith(workerExit, 'deferred') window.visible = true window.emit('show') await vi.waitFor(() => @@ -118,7 +118,7 @@ it('waits for a hidden window to be shown by the user instead of revealing it', }) it('never falls back to a parentless alert when no main window exists', async () => { - reportProfileStateWriteFailure(timeout) + reportProfileStateWriteFailure(workerExit) await new Promise((resolve) => setImmediate(resolve)) expect(fixture.show).not.toHaveBeenCalled() const window = new FakeWindow(true) @@ -136,7 +136,7 @@ it('waits for a replacement after the main window is destroyed', async () => { const destroyed = new FakeWindow(true) destroyed.destroyed = true fixture.state.mainWindow = destroyed - reportProfileStateWriteFailure(timeout) + reportProfileStateWriteFailure(workerExit) await new Promise((resolve) => setImmediate(resolve)) expect(fixture.show).not.toHaveBeenCalled() const replacement = new FakeWindow(true) @@ -151,7 +151,7 @@ it('shows one alert for concurrent failures', async () => { fixture.state.mainWindow = new FakeWindow(true) let close!: () => void fixture.show.mockReturnValue(new Promise((resolve) => (close = () => resolve({ response: 0 })))) - reportProfileStateWriteFailure(timeout) + reportProfileStateWriteFailure(workerExit) reportProfileStateWriteFailure(conflict) await vi.waitFor(() => expect(fixture.show).toHaveBeenCalledOnce()) expect(recordProfileStateWriteFailureReport).toHaveBeenCalledWith(conflict, 'duplicate') @@ -162,19 +162,19 @@ it.each(['background', 'serve'])('keeps %s runs free of native dialogs', async ( fixture.background = mode === 'background' fixture.state.isServeMode = mode === 'serve' fixture.state.mainWindow = new FakeWindow(true) - reportProfileStateWriteFailure(timeout) + reportProfileStateWriteFailure(workerExit) await new Promise((resolve) => setImmediate(resolve)) expect(fixture.show).not.toHaveBeenCalled() expect(console.error).toHaveBeenCalledWith( expect.stringContaining('stopped saving'), expect.any(Error) ) - expect(recordProfileStateWriteFailureReport).toHaveBeenCalledWith(timeout, 'suppressed') + expect(recordProfileStateWriteFailureReport).toHaveBeenCalledWith(workerExit, 'suppressed') }) it('handles a failed dialog without an unhandled rejection', async () => { fixture.state.mainWindow = new FakeWindow(true) fixture.show.mockRejectedValue(new Error('window system unavailable')) - reportProfileStateWriteFailure(timeout) + reportProfileStateWriteFailure(workerExit) await vi.waitFor(() => expect(console.warn).toHaveBeenCalled()) }) diff --git a/src/preload/api/app-api.ts b/src/preload/api/app-api.ts index 55f82e23568a..162a7dd54293 100644 --- a/src/preload/api/app-api.ts +++ b/src/preload/api/app-api.ts @@ -14,6 +14,9 @@ import type { KeyboardLayoutSnapshot } from '../../shared/keyboard-layout-snapsh import type { KeyboardLayoutChangeEvent } from '../../shared/keyboard-layout-events' export type AppApi = { + /** A local profile storage operation is delayed and holds up subsequent saves. */ + isProfileStateSaveDelayed: () => Promise + onProfileStateSaveDelayChanged: (callback: (delayed: boolean) => void) => () => void /** Returns the app identity currently exposed to native chrome and the titlebar. */ getIdentity: () => Promise /** Returns a URL base for feature-wall assets. In dev this is Vite /@fs; diff --git a/src/preload/api/app-bridge.ts b/src/preload/api/app-bridge.ts index 76c97645bff1..e0a8c79308c8 100644 --- a/src/preload/api/app-bridge.ts +++ b/src/preload/api/app-bridge.ts @@ -13,6 +13,14 @@ import { awaitBeforeUnloadCheckpoint, startupDiagnosticsEnabled } from '../prelo import type { PreloadApi } from '../api-types' export const appApi = { + isProfileStateSaveDelayed: (): Promise => + ipcRenderer.invoke('app:isProfileStateSaveDelayed'), + onProfileStateSaveDelayChanged: (callback: (delayed: boolean) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, delayed: boolean): void => + callback(delayed === true) + ipcRenderer.on('app:profileStateSaveDelayChanged', listener) + return () => ipcRenderer.removeListener('app:profileStateSaveDelayChanged', listener) + }, getIdentity: (): Promise => ipcRenderer.invoke('app:getIdentity'), getFeatureWallAssetBaseUrl: (): Promise => ipcRenderer.invoke('app:getFeatureWallAssetBaseUrl'), 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 246670406f99..9f833364ab15 100644 --- a/src/renderer/src/app-shell/use-app-shell-services.ts +++ b/src/renderer/src/app-shell/use-app-shell-services.ts @@ -25,6 +25,7 @@ import { useCodexSharedSettingsNotice } from '../components/terminal-pane/codex- import { useClaudeAccountSignInNotice } from '../lib/claude-account-sign-in-notice' import { useVisibleReviewRefreshReporting } from './use-visible-review-refresh-reporting' import { useVisibleHostedReviewRefresh } from './use-visible-hosted-review-refresh' +import { useProfileStateSaveDelayNotice } from './use-profile-state-save-delay-notice' /** * App-level subscriptions that must outlive any individual surface. Each one is here because @@ -45,6 +46,7 @@ export function useAppShellServices(): void { useHostStructuredAgentsSync() // Subscribe to IPC push events useIpcEvents() + useProfileStateSaveDelayNotice() useRemoteRuntimeRecoveryTriggers() useVisibleReviewRefreshReporting() useVisibleHostedReviewRefresh({ enabled: workspaceSessionReady }) diff --git a/src/renderer/src/app-shell/use-profile-state-save-delay-notice.sonner.test.tsx b/src/renderer/src/app-shell/use-profile-state-save-delay-notice.sonner.test.tsx new file mode 100644 index 000000000000..471dfaf3ed5c --- /dev/null +++ b/src/renderer/src/app-shell/use-profile-state-save-delay-notice.sonner.test.tsx @@ -0,0 +1,115 @@ +// @vitest-environment happy-dom +import { act, cleanup, render } from '@testing-library/react' +import { StrictMode } from 'react' +import { Toaster } from 'sonner' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { useProfileStateSaveDelayNotice } from './use-profile-state-save-delay-notice' + +vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) + +const title = 'Profile storage is taking longer than usual' +const frames: FrameRequestCallback[] = [] +const listeners = new Set<(delayed: boolean) => void>() + +function Notice() { + useProfileStateSaveDelayNotice() + return +} + +beforeEach(() => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + let nextFrameId = 0 + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + frames.push(callback) + return ++nextFrameId + }) + Object.defineProperty(window, 'api', { + configurable: true, + value: { + app: { + isProfileStateSaveDelayed: () => Promise.resolve(true), + onProfileStateSaveDelayChanged: (listener: (delayed: boolean) => void) => { + listeners.add(listener) + return () => listeners.delete(listener) + } + } + } + }) +}) + +afterEach(() => { + cleanup() + listeners.clear() + frames.length = 0 + vi.useRealTimers() + vi.unstubAllGlobals() +}) + +async function advanceTimers(ms = 0): Promise { + await act(async () => { + await vi.advanceTimersByTimeAsync(ms) + }) +} + +async function finishDismissal(): Promise { + // Sonner publishes dismissal on one frame and applies it on the next. + for (let frame = 0; frame < 3; frame += 1) { + await act(async () => { + for (const callback of frames.splice(0)) { + callback(performance.now()) + } + await vi.advanceTimersByTimeAsync(0) + }) + } + await advanceTimers(400) +} + +function publish(delayed: boolean): void { + act(() => listeners.forEach((listener) => listener(delayed))) +} + +it('keeps the restored warning when an old shell dismissal arrives after remount', async () => { + const first = render() + await advanceTimers() + expect(first.queryByText(title)).not.toBeNull() + first.unmount() + + const reopened = render() + await advanceTimers() + expect(reopened.queryByText(title)).not.toBeNull() + await finishDismissal() + expect(reopened.queryAllByText(title)).toHaveLength(1) + + publish(false) + await finishDismissal() + expect(reopened.queryByText(title)).toBeNull() +}) + +it('does not let StrictMode cleanup dismiss the initial delayed snapshot', async () => { + const view = render( + + + + ) + await advanceTimers() + await finishDismissal() + expect(listeners.size).toBe(1) + expect(view.queryAllByText(title)).toHaveLength(1) +}) + +it('keeps a new warning if recovery and another delay arrive before a dismissal frame', async () => { + const view = render() + await advanceTimers() + expect(view.queryByText(title)).not.toBeNull() + + publish(false) + publish(true) + publish(true) + await advanceTimers() + await finishDismissal() + expect(view.queryAllByText(title)).toHaveLength(1) + + publish(false) + await finishDismissal() + expect(view.queryByText(title)).toBeNull() +}) diff --git a/src/renderer/src/app-shell/use-profile-state-save-delay-notice.test.tsx b/src/renderer/src/app-shell/use-profile-state-save-delay-notice.test.tsx new file mode 100644 index 000000000000..d673aba02c1e --- /dev/null +++ b/src/renderer/src/app-shell/use-profile-state-save-delay-notice.test.tsx @@ -0,0 +1,126 @@ +// @vitest-environment happy-dom +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' + +const notice = vi.hoisted(() => { + let nextToastId = 1 + return { + warning: vi.fn( + (_title: string, options: { id?: string | number }) => options.id ?? nextToastId++ + ), + dismiss: vi.fn() + } +}) +vi.mock('sonner', () => ({ toast: notice })) +vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) + +import { useProfileStateSaveDelayNotice } from './use-profile-state-save-delay-notice' + +let snapshot: ReturnType> +const listeners = new Set<(delayed: boolean) => void>() +const subscribe = vi.fn((listener: (delayed: boolean) => void) => { + listeners.add(listener) + return () => listeners.delete(listener) +}) +const read = vi.fn(() => snapshot.promise) + +beforeEach(() => { + vi.clearAllMocks() + snapshot = Promise.withResolvers() + Object.defineProperty(window, 'api', { + configurable: true, + value: { + app: { + isProfileStateSaveDelayed: read, + onProfileStateSaveDelayChanged: subscribe + } + } + }) +}) +afterEach(() => { + cleanup() + listeners.clear() +}) + +function publish(delayed: boolean): void { + act(() => listeners.forEach((listener) => listener(delayed))) +} + +async function resolveSnapshot(delayed: boolean): Promise { + await act(async () => { + snapshot.resolve(delayed) + await snapshot.promise + }) +} + +it('restores a persistent warning from main and clears it on completion', async () => { + renderHook(useProfileStateSaveDelayNotice) + expect(subscribe.mock.invocationCallOrder[0]).toBeLessThan(read.mock.invocationCallOrder[0]) + await resolveSnapshot(true) + expect(notice.warning).toHaveBeenCalledExactlyOnceWith( + 'Profile storage is taking longer than usual', + { + id: undefined, + description: 'Further saves may be delayed while this operation finishes.', + duration: Infinity, + dismissible: false, + closeButton: false + } + ) + publish(false) + expect(notice.dismiss).toHaveBeenLastCalledWith(notice.warning.mock.results[0].value) +}) + +it('does not let an old healthy snapshot erase a newly delayed save', async () => { + renderHook(useProfileStateSaveDelayNotice) + publish(true) + await resolveSnapshot(false) + expect(notice.warning).toHaveBeenCalledOnce() + expect(notice.dismiss).not.toHaveBeenCalled() +}) + +it('does not let an old delayed snapshot restore a recovered warning', async () => { + renderHook(useProfileStateSaveDelayNotice) + publish(false) + await resolveSnapshot(true) + expect(notice.warning).not.toHaveBeenCalled() + expect(notice.dismiss).not.toHaveBeenCalled() +}) + +it('removes its listener and ignores snapshots or queued events after unmount', async () => { + const view = renderHook(useProfileStateSaveDelayNotice) + const queuedListener = subscribe.mock.calls[0][0] + view.unmount() + expect(listeners.size).toBe(0) + act(() => queuedListener(true)) + await resolveSnapshot(true) + expect(notice.warning).not.toHaveBeenCalled() + expect(notice.dismiss).not.toHaveBeenCalled() +}) + +it('rehydrates a still-delayed save after the app shell remounts', async () => { + const first = renderHook(useProfileStateSaveDelayNotice) + await resolveSnapshot(true) + first.unmount() + renderHook(useProfileStateSaveDelayNotice) + await act(async () => { + await snapshot.promise + }) + expect(notice.warning).toHaveBeenCalledTimes(2) + expect(notice.warning.mock.results[0].value).not.toBe(notice.warning.mock.results[1].value) + expect(notice.dismiss).toHaveBeenCalledExactlyOnceWith(notice.warning.mock.results[0].value) +}) + +it('reuses one ID while delayed but gives the next delayed episode a new ID', () => { + renderHook(useProfileStateSaveDelayNotice) + publish(true) + const firstId = notice.warning.mock.results[0].value + publish(true) + expect(notice.warning.mock.calls[1][1].id).toBe(firstId) + publish(false) + publish(false) + expect(notice.dismiss).toHaveBeenCalledExactlyOnceWith(firstId) + publish(true) + expect(notice.warning.mock.calls[2][1].id).toBeUndefined() + expect(notice.warning.mock.results[2].value).not.toBe(firstId) +}) diff --git a/src/renderer/src/app-shell/use-profile-state-save-delay-notice.ts b/src/renderer/src/app-shell/use-profile-state-save-delay-notice.ts new file mode 100644 index 000000000000..99815c1c3f48 --- /dev/null +++ b/src/renderer/src/app-shell/use-profile-state-save-delay-notice.ts @@ -0,0 +1,62 @@ +import { useEffect } from 'react' +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' + +export function useProfileStateSaveDelayNotice(): void { + useEffect(() => { + const app = window.api?.app + if (!app?.onProfileStateSaveDelayChanged || !app.isProfileStateSaveDelayed) { + return + } + let disposed = false + let receivedChange = false + // Deferred dismissal must target this warning, never its replacement. + let toastId: ReturnType | undefined + const dismissNotice = (): void => { + if (toastId !== undefined) { + toast.dismiss(toastId) + toastId = undefined + } + } + const present = (delayed: boolean): void => { + if (disposed) { + return + } + if (!delayed) { + dismissNotice() + return + } + toastId = toast.warning( + translate('app.saving.delayedTitle', 'Profile storage is taking longer than usual'), + { + id: toastId, + description: translate( + 'app.saving.delayedDescription', + 'Further saves may be delayed while this operation finishes.' + ), + duration: Infinity, + dismissible: false, + closeButton: false + } + ) + } + const unsubscribe = app.onProfileStateSaveDelayChanged((delayed) => { + receivedChange = true + present(delayed) + }) + void app + .isProfileStateSaveDelayed() + .then((delayed) => { + // A pushed change is newer than the initial snapshot read. + if (!receivedChange) { + present(delayed) + } + }) + .catch((error: unknown) => console.warn('[persistence] Could not read saving status:', error)) + return () => { + disposed = true + unsubscribe() + dismissNotice() + } + }, []) +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 78ea24b02359..81cf2f502962 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -44,6 +44,10 @@ } }, "app": { + "saving": { + "delayedTitle": "Profile storage is taking longer than usual", + "delayedDescription": "Further saves may be delayed while this operation finishes." + }, "recoverableError": { "rootTitle": "Orca hit a renderer error.", "rootDescription": "The app shell could not finish rendering. Retry to remount it, or relaunch Orca if the error persists.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 419d2b198311..76bbfc173fc2 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -1,5 +1,9 @@ { "app": { + "saving": { + "delayedTitle": "El almacenamiento del perfil está tardando más de lo habitual", + "delayedDescription": "Los siguientes guardados pueden retrasarse hasta que termine esta operación." + }, "recoverableError": { "rootTitle": "Orca tuvo un error de renderizado.", "rootDescription": "El shell de la aplicación no pudo terminar de renderizarse. Vuelva a intentar volver a montarlo o reinicie Orca si el error persiste.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 32e40c276a9a..99821d665151 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -1,5 +1,9 @@ { "app": { + "saving": { + "delayedTitle": "Le stockage du profil prend plus de temps que d’habitude", + "delayedDescription": "Les prochains enregistrements peuvent être retardés jusqu’à la fin de cette opération." + }, "recoverableError": { "rootTitle": "Orca a rencontré une erreur de rendu.", "rootDescription": "Le shell de l'application n'a pas pu terminer son rendu. Réessayez pour le remonter, ou relancez Orca si l'erreur persiste.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 77c15790b976..830d40bd3aef 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -1,5 +1,9 @@ { "app": { + "saving": { + "delayedTitle": "プロファイルのストレージ処理に通常より時間がかかっています", + "delayedDescription": "この処理が完了するまで、その後の保存が遅れる場合があります。" + }, "recoverableError": { "rootTitle": "Orca でレンダラーエラーが発生しました。", "rootDescription": "アプリシェルのレンダリングを完了できませんでした。再マウントを試すか、エラーが解決しない場合は Orca を再起動してください。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 72690cc48020..355225c9e462 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -1,5 +1,9 @@ { "app": { + "saving": { + "delayedTitle": "프로필 저장소 작업에 평소보다 오래 걸리고 있습니다", + "delayedDescription": "이 작업이 완료될 때까지 이후 저장이 지연될 수 있습니다." + }, "recoverableError": { "rootTitle": "Orca에 렌더러 오류가 발생했습니다.", "rootDescription": "앱 셸에서 렌더링을 완료할 수 없습니다. 다시 마운트해 보거나 오류가 지속되면 Orca를 다시 시작하세요.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 1cedc9f579e9..ecadc83a4c58 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -1,5 +1,9 @@ { "app": { + "saving": { + "delayedTitle": "配置存储操作耗时比平时更长", + "delayedDescription": "在此操作完成前,后续保存可能会延迟。" + }, "recoverableError": { "rootTitle": "Orca 遇到渲染器错误。", "rootDescription": "应用 shell 无法完成渲染。重试以重新挂载,如果错误仍然存在,请重新启动 Orca。", diff --git a/src/renderer/src/web/preload-api/web-app-api.ts b/src/renderer/src/web/preload-api/web-app-api.ts index 1e1793e321f3..3ec2862644b9 100644 --- a/src/renderer/src/web/preload-api/web-app-api.ts +++ b/src/renderer/src/web/preload-api/web-app-api.ts @@ -8,6 +8,9 @@ import { UI_STORAGE_KEY, writeJson } from './web-storage' export function createWebAppApi(): Partial { return { app: { + // Browser preferences do not use the desktop profile writer. + isProfileStateSaveDelayed: () => Promise.resolve(false), + onProfileStateSaveDelayChanged: () => () => undefined, getIdentity: () => Promise.resolve({ name: 'Orca', diff --git a/tests/e2e/profile-state-save-delay.spec.ts b/tests/e2e/profile-state-save-delay.spec.ts new file mode 100644 index 000000000000..2bbb689596bd --- /dev/null +++ b/tests/e2e/profile-state-save-delay.spec.ts @@ -0,0 +1,65 @@ +import { retryTransientMainEvaluate } from './helpers/electron-main-evaluate-retry' +import { expect, test } from './helpers/orca-app' + +test.use({ seedTestRepo: false }) + +test('shows one delayed-save notice, restores it after reload, and clears on recovery', async ({ + electronApp, + orcaPage +}, testInfo) => { + expect(await orcaPage.evaluate(() => window.api.app.isProfileStateSaveDelayed())).toBe(false) + + const setSaveDelayed = async (delayed: boolean): Promise => { + // Simulate the writer's status without stalling the app or touching real profile data. + await retryTransientMainEvaluate(() => + electronApp.evaluate(({ ipcMain }, value) => { + ipcMain.removeHandler('app:isProfileStateSaveDelayed') + ipcMain.handle('app:isProfileStateSaveDelayed', () => value) + }, delayed) + ) + const window = await electronApp.browserWindow(orcaPage) + await window.evaluate((browserWindow, value) => { + browserWindow.webContents.send('app:profileStateSaveDelayChanged', value) + }, delayed) + await window.dispose() + } + + const notice = orcaPage.locator('[data-sonner-toast]').filter({ + hasText: 'Profile storage is taking longer than usual' + }) + await setSaveDelayed(true) + await expect(notice).toHaveCount(1) + await expect(notice).toBeVisible() + await expect(notice).toContainText('Further saves may be delayed while this operation finishes.') + await expect(notice.getByRole('button')).toHaveCount(0) + await expect(notice).toHaveCSS('opacity', '1') + const screenshot = testInfo.outputPath('delayed-save-notice.png') + await orcaPage.screenshot({ path: screenshot }) + await testInfo.attach('delayed-save-notice', { path: screenshot, contentType: 'image/png' }) + + await setSaveDelayed(true) + await expect(notice).toHaveCount(1) + + await orcaPage.clock.install() + await orcaPage.clock.pauseAt(await orcaPage.evaluate(() => Date.now() + 1_000)) + try { + // Hold dismissal frames until the replacement warning has been published. + await setSaveDelayed(false) + await setSaveDelayed(true) + await setSaveDelayed(true) + await orcaPage.clock.runFor(50) + await expect(notice.and(orcaPage.locator('[data-removed="true"]'))).toHaveCount(1) + await orcaPage.clock.runFor(400) + await expect(notice).toHaveCount(1) + await expect(notice).toBeVisible() + } finally { + await orcaPage.clock.resume() + } + + await orcaPage.reload() + await expect(notice).toBeVisible() + expect(await orcaPage.evaluate(() => window.api.app.isProfileStateSaveDelayed())).toBe(true) + + await setSaveDelayed(false) + await expect(notice).toHaveCount(0) +}) From b6b4d68cd844c921c7c5191cef72d5adafc0dafa Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 8 Oct 2026 11:19:10 -0700 Subject: [PATCH 12/90] fix(omp): show exit 0 for a command that finished normally (#26494) * fix(omp): show exit 0 for a command that finished normally * fix(omp): require foreground timing before inferring success --- src/main/acp/acp-dialects/omp-dialect.test.ts | 113 +++++++++++++++++- src/main/acp/acp-dialects/omp-dialect.ts | 36 +++++- .../acp/acp-structured-omp-recordings.test.ts | 12 +- 3 files changed, 146 insertions(+), 15 deletions(-) diff --git a/src/main/acp/acp-dialects/omp-dialect.test.ts b/src/main/acp/acp-dialects/omp-dialect.test.ts index 5c6b3cb059b7..635844e87327 100644 --- a/src/main/acp/acp-dialects/omp-dialect.test.ts +++ b/src/main/acp/acp-dialects/omp-dialect.test.ts @@ -29,20 +29,121 @@ describe('OMP tool updates', () => { expect(normalize(update)).toBe(update) }) - it('keeps output that only looks like a notice when OMP sent no matching detail', () => { + it.each([0, 3])('keeps an exit %s notice when OMP sent no matching detail', (code) => { + const output = `done\n\nCommand exited with code ${code}` const update = normalize({ toolCallId: 'call-1', status: 'completed', rawOutput: { - content: [{ type: 'text', text: 'done\n\nCommand exited with code 3' }], - details: {} + content: [{ type: 'text', text: output }], + details: { wallTimeMs: 12 } }, - content: [text('$ ./run'), text('done\n\nCommand exited with code 3')] + content: [text('$ ./run'), text(output)] }) expect(update).toMatchObject({ content: [], - rawOutput: { stdout: 'done\n\nCommand exited with code 3' } + rawOutput: { stdout: output, exitCode: 0 } }) - expect(update.rawOutput).not.toHaveProperty('exitCode') + }) + + const command = ( + status: ToolCallUpdate['status'], + details: Record, + rawInput?: unknown + ): ToolCallUpdate => ({ + toolCallId: 'call-1', + status, + ...(rawInput === undefined ? {} : { rawInput }), + rawOutput: { content: [{ type: 'text', text: 'ok' }], details }, + content: [text('$ ./run'), text('ok')] + }) + + it('shows exit 0 for a foreground command that completed, since OMP omits a zero exit', () => { + expect(normalize(command('completed', { wallTimeMs: 12 })).rawOutput).toMatchObject({ + stdout: 'ok', + exitCode: 0 + }) + expect( + normalize(command('completed', { wallTimeMs: 0, signal: null })).rawOutput + ).toMatchObject({ + exitCode: 0 + }) + }) + + it('infers no exit code for a command that is running, timed out, signalled or in the background', () => { + for (const update of [ + command('in_progress', { wallTimeMs: 12 }), + command('completed', { wallTimeMs: 12, timedOut: true }), + command('completed', { wallTimeMs: 12, signal: 'SIGTERM' }), + command('completed', { wallTimeMs: 12, async: { jobId: 'job-1' } }), + command('completed', { wallTimeMs: 12 }, { command: './run', async: true }) + ]) { + expect(normalize(update).rawOutput).not.toHaveProperty('exitCode') + } + }) + + it('infers no exit code without foreground timing metadata', () => { + for (const details of [undefined, {}, { signal: null }]) { + const update = normalize({ + toolCallId: 'call-1', + status: 'completed', + rawOutput: { content: [{ type: 'text', text: 'ok' }], ...(details ? { details } : {}) }, + content: [text('$ ./run'), text('ok')] + }) + expect(update.rawOutput).toMatchObject({ stdout: 'ok' }) + expect(update.rawOutput).not.toHaveProperty('exitCode') + } + }) + + it('does not confuse OMP v18.4 service-start completion with a process exit', () => { + for (const timing of [{}, { wallTimeMs: 12 }]) { + const update = normalize({ + toolCallId: 'service-call', + status: 'completed', + rawOutput: { + content: [{ type: 'text', text: 'web: running pid=123 ready' }], + details: { + ...timing, + service: { name: 'web', state: 'running', ready: true, timedOut: false, pid: 123 } + } + }, + content: [text('$ python3 -m http.server 8765'), text('web: running pid=123 ready')] + }) + expect(update.rawOutput).toMatchObject({ stdout: 'web: running pid=123 ready' }) + expect(update.rawOutput).not.toHaveProperty('exitCode') + } + }) + + it('leaves an existing generic command result intact', () => { + for (const result of [ + { exitCode: 3 }, + { exit_code: 3 }, + { stdout: 'original' }, + { stderr: 'error' }, + { output_for_prompt: 'original' }, + { signal: 'SIGTERM' }, + { timed_out: true } + ]) { + const update = command('completed', { wallTimeMs: 12 }) + update.rawOutput = { + ...result, + content: [{ type: 'text', text: 'ok' }], + details: { wallTimeMs: 12 } + } + expect(normalize(update)).toBe(update) + } + }) + + it("keeps a failed command's reported exit code", () => { + const update = normalize({ + toolCallId: 'call-1', + status: 'failed', + rawOutput: { + content: [{ type: 'text', text: 'hi\n\nCommand exited with code 3' }], + details: { exitCode: 3 } + }, + content: [text('$ ./run'), text('hi\n\nCommand exited with code 3')] + }) + expect(update.rawOutput).toMatchObject({ stdout: 'hi', exitCode: 3 }) }) }) diff --git a/src/main/acp/acp-dialects/omp-dialect.ts b/src/main/acp/acp-dialects/omp-dialect.ts index cb7e321d8228..80c3fa4ee08c 100644 --- a/src/main/acp/acp-dialects/omp-dialect.ts +++ b/src/main/acp/acp-dialects/omp-dialect.ts @@ -5,16 +5,23 @@ import type { AcpDialect } from './acp-dialect' // OMP sends a tool's result as `rawOutput: {content: [{type: 'text', text}], details}`, with a // command's non-zero exit at `details.exitCode`, and repeats the text as content behind a // `$ ` echo. The shared reader takes content text first, so that echo became the output. +// A zero exit is left out; completed foreground results carry wall time, while service and +// background launch results do not establish a process exit. const textBlockSchema = z.looseObject({ type: z.literal('text'), text: z.string() }) const toolResultSchema = z.looseObject({ content: z.array(z.unknown()), details: z .looseObject({ exitCode: z.number().int().safe().optional(), - wallTimeMs: z.number().optional() + wallTimeMs: z.number().nonnegative().optional(), + timedOut: z.unknown().optional(), + signal: z.unknown().optional(), + async: z.unknown().optional(), + service: z.unknown().optional() }) .optional() }) +const asyncInputSchema = z.looseObject({ async: z.literal(true) }) function contentText(block: ToolCallContent | undefined): string | undefined { return block?.type === 'content' && block.content.type === 'text' ? block.content.text : undefined @@ -36,6 +43,16 @@ function normalizeToolUpdate(update: ToolCallUpdate): ToolCallUpdate { return update } const parsed = toolResultSchema.safeParse(update.rawOutput) + if ( + parsed.success && + (['exitCode', 'exit_code', 'stdout', 'stderr', 'output_for_prompt'].some( + (key) => parsed.data[key] !== undefined + ) || + parsed.data.signal != null || + parsed.data.timed_out === true) + ) { + return update + } const texts = parsed.success ? parsed.data.content.flatMap((block) => textBlockSchema.safeParse(block).data?.text ?? []) : [] @@ -50,12 +67,23 @@ function normalizeToolUpdate(update: ToolCallUpdate): ToolCallUpdate { if (!parsed.success) { return { ...update, content } } - const exitCode = parsed.data.details?.exitCode + const details = parsed.data.details const stdout = commandOutput( texts.join('\n'), - exitCode, - parsed.data.details?.wallTimeMs !== undefined + details?.exitCode, + details?.wallTimeMs !== undefined ) + const exitCode = + details?.exitCode ?? + (update.status === 'completed' && + details?.wallTimeMs !== undefined && + details?.timedOut !== true && + (details?.signal === undefined || details.signal === null) && + details?.async === undefined && + details?.service === undefined && + !asyncInputSchema.safeParse(update.rawInput).success + ? 0 + : undefined) return { ...update, content, diff --git a/src/main/acp/acp-structured-omp-recordings.test.ts b/src/main/acp/acp-structured-omp-recordings.test.ts index 4c0dc40c03d6..f7a63f632f44 100644 --- a/src/main/acp/acp-structured-omp-recordings.test.ts +++ b/src/main/acp/acp-structured-omp-recordings.test.ts @@ -82,17 +82,19 @@ describe('OMP recordings through the adapter', () => { }) }) - it("a successful command's row shows its output", async () => { + it("a successful command's row shows its output and exit code 0", async () => { const { rig, replay } = await replaying('omp-v17-shell-ok') await allowOnce(rig, replay) await waitFor(() => expect(replay.awaiting).toBeNull()) await waitFor(async () => expect(await turns(rig)).toMatchObject([{ state: 'completed', outcome: 'success' }]) ) - const tool = await toolRow(rig) - expect(tool).toMatchObject({ state: 'completed', output: { head: 'ok', truncated: false } }) - // OMP reports an exit code only when it is not zero. - expect(tool).not.toHaveProperty('exitCode') + // OMP reports an exit code only when it is not zero; a completed foreground command exited 0. + expect(await toolRow(rig)).toMatchObject({ + state: 'completed', + exitCode: 0, + output: { head: 'ok', truncated: false } + }) }) it('Stop during a running command ends the turn and leaves the row without the command as output', async () => { From 439086089e697a2822b190534784c5d2ccd3abd0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:35:36 +0000 Subject: [PATCH 13/90] 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 73c3c5db4caa..f19ff0002eff 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 98m + + downloads: 99m @@ -15,7 +15,7 @@ downloads downloads - 98m - 98m + 99m + 99m From 9ca9b4e36b9f61012d490a0642c52b980f3634f2 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:37:17 -0400 Subject: [PATCH 14/90] Revert Claude per-account folders (#24434, #26405) before release (#26638) Users with a saved Claude account were signed out of every Claude feature in Orca until they signed in again, and proxy setups (API key plus ANTHROPIC_BASE_URL) got 401s after signing in. Reverting before release to rework the upgrade path. The terminal daemon protocol moves forward to 43 so dev builds' v42 daemons with the claude function are not reused; v41 and v42 stay attachable. Nothing is deleted: per-account folders are left unused and the old saved-account store was never touched. --- .github/workflows/pr.yml | 1 - .../claude-account-windows-spawn-repro.mjs | 342 +++++++++ ...de-pty-windows-pty-teardown-patch.test.mjs | 4 +- src/cli/handlers/account-list-format.ts | 7 +- src/cli/handlers/account.test.ts | 238 +++--- src/cli/handlers/account.ts | 120 +-- .../daemon-bash-rcfile.txt | 24 - .../daemon-fish-init.txt | 32 +- .../daemon-zsh-zshenv.txt | 24 - .../local-bash-rcfile.txt | 24 - .../local-fish-init.txt | 32 +- .../local-zsh-zshenv.txt | 24 - .../relay-zsh-zshenv.txt | 24 - .../agent-auth-restart-preservation.test.ts | 100 ++- src/main/agent-auth-restart-preservation.ts | 20 +- .../claude-account-folder.test.ts | 106 --- .../claude-accounts/claude-account-folder.ts | 153 ---- .../claude-account-registration.ts | 330 +++++--- .../claude-account-selection.ts | 272 +++---- ...-account-service-account-selection.test.ts | 488 ++++++++++++ ...claude-account-service-add-account.test.ts | 373 +++++++++ .../claude-account-service-api-parity.test.ts | 90 +++ ...account-service-config-dir-capture.test.ts | 250 ++++++ ...account-service-credential-capture.test.ts | 150 ++++ ...aude-account-service-login-process.test.ts | 684 +++++++++++++++++ ...de-account-service-reauth-rollback.test.ts | 420 +++++++++++ .../claude-account-service-test-harness.ts | 41 + .../claude-account-service.test.ts | 349 --------- .../claude-accounts/claude-auth-capture.ts | 176 +++++ .../claude-command-process.test.ts | 75 -- .../claude-accounts/claude-command-process.ts | 38 +- ...ude-credential-persistence-ratchet.test.ts | 281 ------- .../claude-login-completion.oracle.test.ts | 192 +++++ .../claude-accounts/claude-login-session.ts | 121 ++- .../claude-managed-auth-storage.ts | 285 +++++++ .../claude-profile-installed-router.ts | 6 +- .../claude-profile-paths.test.ts | 21 +- .../claude-accounts/claude-profile-paths.ts | 40 +- .../claude-profile-provisioning.test.ts | 78 +- .../claude-profile-provisioning.ts | 26 +- .../claude-profile-router.test.ts | 249 +----- .../claude-accounts/claude-profile-router.ts | 116 +-- .../claude-profile-setup.test.ts | 20 +- .../claude-accounts/claude-profile-setup.ts | 13 +- .../claude-profile-shell-composition.test.ts | 42 +- .../claude-profile-wsl-paths.ts | 9 - .../claude-profile-wsl-router.test.ts | 121 +-- .../claude-profile-wsl-router.ts | 57 +- .../claude-windows-interactive-login.test.ts | 148 ++++ src/main/claude-accounts/environment.ts | 27 +- src/main/claude-accounts/keychain.test.ts | 288 ++++++- src/main/claude-accounts/keychain.ts | 107 ++- .../claude-accounts/live-pty-gate.test.ts | 142 ++++ src/main/claude-accounts/live-pty-gate.ts | 174 +++++ src/main/claude-accounts/managed-auth-path.ts | 112 +++ .../claude-accounts/oauth-refresh.test.ts | 186 +++++ src/main/claude-accounts/oauth-refresh.ts | 170 +++++ .../runtime-auth-path-materialization.test.ts | 58 ++ ...ime-auth-service-account-switching.test.ts | 509 +++++++++++++ ...time-auth-service-deselect-restore.test.ts | 611 +++++++++++++++ ...me-auth-service-keychain-snapshots.test.ts | 712 ++++++++++++++++++ ...untime-auth-service-launch-refresh.test.ts | 248 ++++++ ...ntime-auth-service-materialization.test.ts | 490 ++++++++++++ ...ime-auth-service-readback-identity.test.ts | 607 +++++++++++++++ ...service-shared-credential-failures.test.ts | 85 +++ ...me-auth-service-shared-credentials.test.ts | 292 +++++++ ...e-auth-service-snapshot-validation.test.ts | 550 ++++++++++++++ .../runtime-auth-service-test-harness.ts | 277 +++++++ .../runtime-auth-service-wsl-runtime.test.ts | 312 ++++++++ .../runtime-auth-service.test.ts | 96 --- .../claude-accounts/runtime-auth-service.ts | 234 +++--- ...runtime-auth-shared-credentials-fixture.ts | 51 ++ .../runtime-auth-credential-identity.ts | 209 +++++ .../runtime-auth-credential-matching.ts | 152 ++++ .../runtime-auth/runtime-auth-file-storage.ts | 82 ++ .../runtime-auth-keychain-snapshots.ts | 135 ++++ .../runtime-auth-managed-credentials.ts | 152 ++++ .../runtime-auth/runtime-auth-preparation.ts | 119 +++ .../runtime-auth/runtime-auth-readback.ts | 163 ++++ .../runtime-auth-runtime-state.ts | 238 ++++++ .../runtime-auth-snapshot-capture.ts | 119 +++ .../runtime-auth-snapshot-restore.ts | 239 ++++++ .../runtime-auth/runtime-auth-state.ts | 19 + .../runtime-auth/runtime-auth-sync.ts | 289 +++++++ .../runtime-auth/runtime-auth-types.ts | 46 +- .../claude-accounts/runtime-paths.test.ts | 6 + src/main/claude-accounts/runtime-paths.ts | 2 + src/main/claude-accounts/service.ts | 212 ++++-- .../shared-credential-fields.test.ts | 137 ++++ .../shared-credential-fields.ts | 166 ++++ .../shared-credential-reconciliation.test.ts | 129 ++++ src/main/claude/claude-model-catalog-probe.ts | 1 + .../claude-stream-json-connection.test.ts | 83 ++ .../claude/claude-stream-json-connection.ts | 20 + .../claude-structured-auth-parity.test.ts | 139 +++- ...-structured-effort-default-at-rest.test.ts | 2 - ...-structured-launch-account-routing.test.ts | 14 +- .../claude/claude-structured-launch-home.ts | 2 +- ...laude-structured-launch-resolution.test.ts | 109 ++- .../claude-structured-launch-resolution.ts | 80 +- .../claude-structured-session-acquisition.ts | 16 +- ...n-foreground-confirmation-protocol.test.ts | 2 +- .../daemon-init-provider-installation.test.ts | 20 + src/main/daemon/daemon-protocol-version.ts | 11 +- src/main/daemon/daemon-provider-init.ts | 31 + src/main/daemon/daemon-provider-state.ts | 14 +- .../pty-subprocess/shell-launch-plan.ts | 8 +- src/main/ipc/claude-accounts.ts | 14 +- .../pty-listener-teardown-and-orphans.test.ts | 5 + .../ipc/pty-spawn-env-terminal-basics.test.ts | 48 +- .../ipc/pty/ipc/claude-old-terminal.test.ts | 36 - src/main/ipc/pty/ipc/claude-old-terminal.ts | 23 - src/main/ipc/pty/ipc/spawn-commit.ts | 4 + src/main/ipc/pty/ipc/spawn-env.ts | 5 + src/main/ipc/pty/ipc/spawn-preflight.ts | 5 + src/main/ipc/pty/provider/bind-listeners.ts | 2 + src/main/ipc/pty/provider/liveness.ts | 2 + src/main/ipc/pty/provider/local-configure.ts | 2 + src/main/ipc/pty/provider/state-cleanup.ts | 3 + src/main/ipc/pty/register-handlers.ts | 3 - src/main/ipc/pty/runtime/spawn-commit.ts | 4 + .../spawn-preflight-requested-shell.test.ts | 1 - src/main/ipc/pty/runtime/spawn-preflight.ts | 8 + .../macos-keychain/generic-password.test.ts | 24 +- src/main/macos-keychain/generic-password.ts | 39 + .../structured-agent-session-adapter.ts | 2 - .../structured-agent-session-failure-text.ts | 2 - ...ent-session-pre-spawn-first-answer.test.ts | 16 - ...structured-managed-account-support.test.ts | 136 ++++ ...aude-structured-managed-account-support.ts | 68 ++ ...tured-agent-session-create-support.test.ts | 80 +- ...structured-agent-session-create-support.ts | 33 +- ...tence-worktree-lineage-and-backups.test.ts | 61 ++ .../leasing-ssh-ptys/ssh-target-state.ts | 37 + .../normalize-loaded-profile-state.ts | 2 + .../profile-state-direct-flush.test.ts | 10 + .../loading-store/ssh-profile-operations.ts | 33 +- .../loading-store/store-domain-composition.ts | 2 +- .../pane-alias-normalization.ts | 24 + .../local-pty-windows-spawn-environment.ts | 8 +- src/main/providers/windows-shell-args.test.ts | 16 +- src/main/providers/windows-shell-args.ts | 14 +- .../rate-limits/claude-active-usage-fetch.ts | 286 +++++-- .../rate-limits/claude-cli-usage-fetch.ts | 117 +++ .../claude-fetcher-cli-fallback.test.ts | 490 ++++++++++++ ....ts => claude-fetcher-extra-usage.test.ts} | 108 ++- .../claude-fetcher-fable-usage.test.ts | 362 +++++++++ ...laude-fetcher-keychain-credentials.test.ts | 491 ++++++++++++ ...aude-fetcher-managed-account-usage.test.ts | 425 +++++++++++ .../claude-fetcher-test-harness.ts | 67 ++ src/main/rate-limits/claude-fetcher.ts | 6 +- .../claude-managed-account-credentials.ts | 127 ++++ .../claude-managed-account-usage.ts | 140 ++-- .../rate-limits/claude-managed-usage-panel.ts | 113 +++ .../rate-limits/claude-oauth-credentials.ts | 175 +++-- src/main/rate-limits/claude-oauth-recovery.ts | 172 +++++ .../rate-limits/claude-profile-usage.test.ts | 271 ------- .../rate-limits/claude-pty-reset-parser.ts | 307 ++++++++ .../rate-limits/claude-pty-stop-markers.ts | 14 + .../rate-limits/claude-pty-usage-parser.ts | 166 ++++ src/main/rate-limits/claude-pty.test.ts | 554 ++++++++++++++ src/main/rate-limits/claude-pty.ts | 354 +++++++++ .../claude-usage-error-classification.test.ts | 40 +- .../claude-usage-error-classification.ts | 17 + .../claude-usage-refresh-plan.test.ts | 32 + .../rate-limits/claude-usage-refresh-plan.ts | 47 ++ src/main/rate-limits/claude-usage-result.ts | 114 ++- .../rate-limits/hidden-pty-cleanup.test.ts | 135 ++++ src/main/rate-limits/hidden-pty-cleanup.ts | 58 ++ .../service-account-target-selection.test.ts | 4 +- .../service-inactive-account-previews.test.ts | 16 +- .../service-live-claude-usage.test.ts | 107 ++- .../service/service-account-refresh.ts | 11 + .../service/service-fetch-policy.ts | 9 +- ...structured-agent-session-create-support.ts | 3 +- .../runtime/orca-runtime-get-worktree-ps.ts | 2 + ...ime-structured-claude-account-gate.test.ts | 112 +++ ...time-structured-claude-gate-wiring.test.ts | 64 ++ src/main/runtime/rpc/methods/accounts.test.ts | 57 +- src/main/runtime/rpc/methods/accounts.ts | 60 +- .../runtime/runtime-account-controller.ts | 21 +- .../runtime-service-command-surface.ts | 8 +- ...-agent-create-support-opencode-rig.test.ts | 3 +- .../structured-agent-launch-support.ts | 10 +- .../structured-agent-runtime-registrations.ts | 7 + .../structured-agent-session-runtime.ts | 3 + .../structured-claude-runtime-adapter.ts | 5 + ...ll-wrapper-generated-file-snapshot.test.ts | 2 - .../startup/main-process-account-services.ts | 1 + .../startup/main-process-ready-foundation.ts | 20 + .../main-process-ready-identity-write.test.ts | 9 + .../startup/main-window-core-services.test.ts | 1 + src/main/startup/main-window-core-services.ts | 8 +- src/preload/api/pty-api.ts | 2 - .../pty-bridge-stream-and-serialization.ts | 2 - .../src/app-shell/use-app-shell-services.ts | 2 - .../native-chat/NativeChatNoticeRow.tsx | 18 - .../NativeChatStructuredSession.tsx | 65 +- .../agent-session-failure-words-text.ts | 10 - .../native-chat-claude-sign-in.test.tsx | 116 --- .../native-chat/native-chat-claude-sign-in.ts | 77 -- .../native-chat-structured-session-notices.ts | 30 +- .../settings/accounts-pane-account-actions.ts | 31 +- .../accounts-pane-claude-section.test.tsx | 156 ---- .../settings/accounts-pane-claude-section.tsx | 65 +- .../accounts-pane-removal-dialogs.tsx | 4 +- .../status-bar/ClaudeSwitcherMenu.tsx | 98 +-- .../status-bar/InlineProviderUsage.tsx | 8 +- .../status-bar/StatusBarProviderSegment.tsx | 7 +- .../status-bar/StatusBarSurface.tsx | 4 +- .../status-bar/claude-status-sign-in.test.tsx | 242 ------ .../status-bar/inline-usage-bars.test.tsx | 16 - .../status-bar/status-bar-claude-accounts.ts | 22 +- .../status-bar-runtime-groups.test.ts | 46 -- .../status-bar/status-bar-runtime-targets.ts | 5 - .../src/components/status-bar/tooltip.test.ts | 41 +- .../components/status-bar/usage-error-copy.ts | 35 +- .../usage-provider-settings-target.test.ts | 36 +- .../usage-provider-settings-target.ts | 18 - .../status-bar/usage-roster-row-state.test.ts | 2 +- .../status-bar/use-status-bar-controller.ts | 8 +- .../terminal-pane/ClaudeOldTerminalBanner.tsx | 143 ---- .../terminal-pane/CodexOldTerminalDialog.tsx | 54 ++ .../terminal-pane/CodexSharedServerBanner.tsx | 150 +++- .../terminal-pane/OldTerminalDialog.tsx | 45 -- .../terminal-pane/PaneTopWarningBanner.tsx | 96 --- .../TerminalPaneRuntimePortals.tsx | 7 - .../claude-old-terminal-banner.test.tsx | 154 ---- .../terminal-pane/open-terminal-beside-tab.ts | 29 - .../src/i18n/en-runtime-required.json | 2 - src/renderer/src/i18n/locales/en.json | 62 +- src/renderer/src/i18n/locales/es.json | 17 +- src/renderer/src/i18n/locales/fr.json | 17 +- src/renderer/src/i18n/locales/ja.json | 17 +- src/renderer/src/i18n/locales/ko.json | 17 +- src/renderer/src/i18n/locales/zh.json | 17 +- .../lib/claude-account-sign-in-notice.test.ts | 111 --- .../src/lib/claude-account-sign-in-notice.ts | 63 -- .../src/lib/claude-account-sign-in.ts | 37 - .../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/web/preload-api/web-terminal-api.ts | 1 - src/shared/agent-session-failure-copy.ts | 7 - src/shared/agent-session-failure-words.ts | 2 - src/shared/agent-session-failure.ts | 2 - src/shared/agent-session-refusal-details.ts | 4 - .../agent-session-refusal-reason-words.ts | 2 - ...claude-profile-activation-contract.test.ts | 18 - src/shared/claude-profile-routing.ts | 17 +- src/shared/claude-shell-function.test.ts | 101 +-- src/shared/claude-shell-function.ts | 54 +- src/shared/constants.ts | 1 + src/shared/global-settings-types.ts | 2 - .../local-build-compatibility-contract.json | 4 +- .../local-build-compatibility-contract.ts | 4 +- src/shared/managed-account-types.ts | 21 - src/shared/persisted-state-types.ts | 2 + src/shared/persisted-ui-state-types.ts | 2 - src/shared/protocol-version.ts | 3 - src/shared/rate-limit-types.ts | 1 + src/shared/rpc-contract/accounts-params.ts | 13 - src/shared/rpc-contract/client-ui-params.ts | 1 - .../rpc-params-catalog.generated.ts | 5 - ...ctured-agent-session-dispatch-rejection.ts | 4 - src/shared/wsl-login-shell-command.ts | 5 - 267 files changed, 19855 insertions(+), 5493 deletions(-) create mode 100644 config/scripts/claude-account-windows-spawn-repro.mjs delete mode 100644 src/main/claude-accounts/claude-account-folder.test.ts delete mode 100644 src/main/claude-accounts/claude-account-folder.ts create mode 100644 src/main/claude-accounts/claude-account-service-account-selection.test.ts create mode 100644 src/main/claude-accounts/claude-account-service-add-account.test.ts create mode 100644 src/main/claude-accounts/claude-account-service-api-parity.test.ts create mode 100644 src/main/claude-accounts/claude-account-service-config-dir-capture.test.ts create mode 100644 src/main/claude-accounts/claude-account-service-credential-capture.test.ts create mode 100644 src/main/claude-accounts/claude-account-service-login-process.test.ts create mode 100644 src/main/claude-accounts/claude-account-service-reauth-rollback.test.ts create mode 100644 src/main/claude-accounts/claude-account-service-test-harness.ts delete mode 100644 src/main/claude-accounts/claude-account-service.test.ts create mode 100644 src/main/claude-accounts/claude-auth-capture.ts delete mode 100644 src/main/claude-accounts/claude-command-process.test.ts delete mode 100644 src/main/claude-accounts/claude-credential-persistence-ratchet.test.ts create mode 100644 src/main/claude-accounts/claude-login-completion.oracle.test.ts create mode 100644 src/main/claude-accounts/claude-managed-auth-storage.ts create mode 100644 src/main/claude-accounts/claude-windows-interactive-login.test.ts create mode 100644 src/main/claude-accounts/live-pty-gate.test.ts create mode 100644 src/main/claude-accounts/live-pty-gate.ts create mode 100644 src/main/claude-accounts/managed-auth-path.ts create mode 100644 src/main/claude-accounts/oauth-refresh.test.ts create mode 100644 src/main/claude-accounts/oauth-refresh.ts create mode 100644 src/main/claude-accounts/runtime-auth-path-materialization.test.ts create mode 100644 src/main/claude-accounts/runtime-auth-service-account-switching.test.ts create mode 100644 src/main/claude-accounts/runtime-auth-service-deselect-restore.test.ts create mode 100644 src/main/claude-accounts/runtime-auth-service-keychain-snapshots.test.ts create mode 100644 src/main/claude-accounts/runtime-auth-service-launch-refresh.test.ts create mode 100644 src/main/claude-accounts/runtime-auth-service-materialization.test.ts create mode 100644 src/main/claude-accounts/runtime-auth-service-readback-identity.test.ts create mode 100644 src/main/claude-accounts/runtime-auth-service-shared-credential-failures.test.ts create mode 100644 src/main/claude-accounts/runtime-auth-service-shared-credentials.test.ts create mode 100644 src/main/claude-accounts/runtime-auth-service-snapshot-validation.test.ts create mode 100644 src/main/claude-accounts/runtime-auth-service-test-harness.ts create mode 100644 src/main/claude-accounts/runtime-auth-service-wsl-runtime.test.ts delete mode 100644 src/main/claude-accounts/runtime-auth-service.test.ts create mode 100644 src/main/claude-accounts/runtime-auth-shared-credentials-fixture.ts create mode 100644 src/main/claude-accounts/runtime-auth/runtime-auth-credential-identity.ts create mode 100644 src/main/claude-accounts/runtime-auth/runtime-auth-credential-matching.ts create mode 100644 src/main/claude-accounts/runtime-auth/runtime-auth-file-storage.ts create mode 100644 src/main/claude-accounts/runtime-auth/runtime-auth-keychain-snapshots.ts create mode 100644 src/main/claude-accounts/runtime-auth/runtime-auth-managed-credentials.ts create mode 100644 src/main/claude-accounts/runtime-auth/runtime-auth-preparation.ts create mode 100644 src/main/claude-accounts/runtime-auth/runtime-auth-readback.ts create mode 100644 src/main/claude-accounts/runtime-auth/runtime-auth-runtime-state.ts create mode 100644 src/main/claude-accounts/runtime-auth/runtime-auth-snapshot-capture.ts create mode 100644 src/main/claude-accounts/runtime-auth/runtime-auth-snapshot-restore.ts create mode 100644 src/main/claude-accounts/runtime-auth/runtime-auth-state.ts create mode 100644 src/main/claude-accounts/runtime-auth/runtime-auth-sync.ts create mode 100644 src/main/claude-accounts/shared-credential-fields.test.ts create mode 100644 src/main/claude-accounts/shared-credential-fields.ts create mode 100644 src/main/claude-accounts/shared-credential-reconciliation.test.ts delete mode 100644 src/main/ipc/pty/ipc/claude-old-terminal.test.ts delete mode 100644 src/main/ipc/pty/ipc/claude-old-terminal.ts create mode 100644 src/main/native-chat/claude-structured-managed-account-support.test.ts create mode 100644 src/main/native-chat/claude-structured-managed-account-support.ts create mode 100644 src/main/rate-limits/claude-cli-usage-fetch.ts create mode 100644 src/main/rate-limits/claude-fetcher-cli-fallback.test.ts rename src/main/rate-limits/{claude-oauth-usage-extra-usage.test.ts => claude-fetcher-extra-usage.test.ts} (66%) create mode 100644 src/main/rate-limits/claude-fetcher-fable-usage.test.ts create mode 100644 src/main/rate-limits/claude-fetcher-keychain-credentials.test.ts create mode 100644 src/main/rate-limits/claude-fetcher-managed-account-usage.test.ts create mode 100644 src/main/rate-limits/claude-fetcher-test-harness.ts create mode 100644 src/main/rate-limits/claude-managed-account-credentials.ts create mode 100644 src/main/rate-limits/claude-managed-usage-panel.ts create mode 100644 src/main/rate-limits/claude-oauth-recovery.ts delete mode 100644 src/main/rate-limits/claude-profile-usage.test.ts create mode 100644 src/main/rate-limits/claude-pty-reset-parser.ts create mode 100644 src/main/rate-limits/claude-pty-stop-markers.ts create mode 100644 src/main/rate-limits/claude-pty-usage-parser.ts create mode 100644 src/main/rate-limits/claude-pty.test.ts create mode 100644 src/main/rate-limits/claude-pty.ts create mode 100644 src/main/rate-limits/claude-usage-refresh-plan.test.ts create mode 100644 src/main/rate-limits/claude-usage-refresh-plan.ts create mode 100644 src/main/rate-limits/hidden-pty-cleanup.test.ts create mode 100644 src/main/rate-limits/hidden-pty-cleanup.ts create mode 100644 src/main/runtime/orca-runtime-structured-claude-account-gate.test.ts create mode 100644 src/main/runtime/orca-runtime-structured-claude-gate-wiring.test.ts delete mode 100644 src/renderer/src/components/native-chat/native-chat-claude-sign-in.test.tsx delete mode 100644 src/renderer/src/components/native-chat/native-chat-claude-sign-in.ts delete mode 100644 src/renderer/src/components/settings/accounts-pane-claude-section.test.tsx delete mode 100644 src/renderer/src/components/status-bar/claude-status-sign-in.test.tsx delete mode 100644 src/renderer/src/components/terminal-pane/ClaudeOldTerminalBanner.tsx create mode 100644 src/renderer/src/components/terminal-pane/CodexOldTerminalDialog.tsx delete mode 100644 src/renderer/src/components/terminal-pane/OldTerminalDialog.tsx delete mode 100644 src/renderer/src/components/terminal-pane/PaneTopWarningBanner.tsx delete mode 100644 src/renderer/src/components/terminal-pane/claude-old-terminal-banner.test.tsx delete mode 100644 src/renderer/src/components/terminal-pane/open-terminal-beside-tab.ts delete mode 100644 src/renderer/src/lib/claude-account-sign-in-notice.test.ts delete mode 100644 src/renderer/src/lib/claude-account-sign-in-notice.ts delete mode 100644 src/renderer/src/lib/claude-account-sign-in.ts delete mode 100644 src/shared/claude-profile-activation-contract.test.ts diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 0d27ea922046..6f957ce9fe3a 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -1275,7 +1275,6 @@ jobs: src/main/wsl/wsl-runner.test.ts src/main/wsl/wsl-guest-environment.test.ts src/main/wsl/wsl-executable-path.win32.test.ts - src/main/claude-accounts/claude-profile-history-windows.test.ts src/main/wsl/wsl-w1-w3-contract.test.ts src/shared/source-scan/source-tree-scan.test.ts src/main/cli/wsl-cli-powershell-boundary.test.ts diff --git a/config/scripts/claude-account-windows-spawn-repro.mjs b/config/scripts/claude-account-windows-spawn-repro.mjs new file mode 100644 index 000000000000..eaf04762e5ba --- /dev/null +++ b/config/scripts/claude-account-windows-spawn-repro.mjs @@ -0,0 +1,342 @@ +import { spawn } from 'node:child_process' +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { buildWindowsCommandInvocation } from '../../src/main/claude-accounts/windows-command-invocation.ts' + +const strategy = process.argv[2] +if (!['baseline', 'candidate', 'explicit-cmd'].includes(strategy)) { + throw new Error( + 'Usage: node config/scripts/claude-account-windows-spawn-repro.mjs ' + ) +} +if (process.platform !== 'win32') { + throw new Error('This reproduction requires a physical Windows host.') +} + +const expectedArgs = [ + '', + 'two words', + 'amp&ersand', + 'pipe|value', + 'lessvalue', + 'caret^value', + 'trailing\\', + 'two-trailing\\\\', + '(parentheses)', + '100%', + '%ORCA_ARG_TRAP%', + 'bang!value', + '한글-λ' +] +const tempRoot = await mkdtemp(join(tmpdir(), 'orca-claude-spawn-')) +const reportedDir = join(tempRoot, 'Profile with spaces 한글') +const reportedCapturePath = join(reportedDir, 'capture.json') +const reportedPidPath = join(reportedDir, 'pids.json') +const reportedShimPath = join(reportedDir, 'claude fixture.cmd') +const reportedFixturePath = join(reportedDir, 'capture-child.cjs') +const fixtureDir = join(tempRoot, 'Profile space & ^ (paren) %ORCA_PATH_TRAP% !bang! 한글') +const capturePath = join(fixtureDir, 'capture.json') +const pidPath = join(fixtureDir, 'pids.json') +const shimPath = join(fixtureDir, 'claude fixture.cmd') +const fixturePath = join(fixtureDir, 'capture-child.cjs') +const fixtureEnv = { + ...process.env, + CLAUDE_CONFIG_DIR: join(fixtureDir, 'config space & ^ (paren) %ORCA_ENV_LITERAL% !bang! 한글'), + ORCA_ARG_TRAP: 'EXPANDED_ARG', + ORCA_PATH_TRAP: 'EXPANDED_PATH', + ORCA_FIXTURE_CAPTURE: capturePath, + ORCA_FIXTURE_PIDS: pidPath, + ORCA_FIXTURE_NODE: process.execPath +} +const reportedEnv = { + ...fixtureEnv, + CLAUDE_CONFIG_DIR: join(reportedDir, 'config with spaces 한글'), + ORCA_FIXTURE_CAPTURE: reportedCapturePath, + ORCA_FIXTURE_PIDS: reportedPidPath +} + +function quoteForCandidate(value) { + return `"${value.replace(/"/g, '""')}"` +} + +function launch(args, command = shimPath, env = fixtureEnv) { + if (strategy === 'baseline') { + return spawn(command, args, { cwd: tempRoot, env, shell: true, windowsHide: true }) + } + if (strategy === 'candidate') { + return spawn(quoteForCandidate(command), args, { + cwd: tempRoot, + env, + shell: true, + windowsHide: true + }) + } + const invocation = buildWindowsCommandInvocation(command, args) + return spawn(invocation.command, invocation.args, { + cwd: tempRoot, + env, + shell: false, + windowsVerbatimArguments: invocation.windowsVerbatimArguments, + windowsHide: true + }) +} + +function collect(child) { + return new Promise((resolve) => { + let stdout = '' + let stderr = '' + child.stdout?.on('data', (chunk) => (stdout += chunk.toString())) + child.stderr?.on('data', (chunk) => (stderr += chunk.toString())) + child.on('error', (error) => resolve({ code: null, stdout, stderr, error: error.message })) + child.on('close', (code) => resolve({ code, stdout, stderr, error: null })) + }) +} + +async function waitForFile(path, timeoutMs = 5_000) { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + try { + return JSON.parse(await readFile(path, 'utf8')) + } catch { + await new Promise((resolve) => setTimeout(resolve, 25)) + } + } + throw new Error(`Timed out waiting for fixture output: ${path}`) +} + +async function taskExists(pid) { + const result = await collect( + spawn('tasklist.exe', ['/fi', `PID eq ${pid}`, '/fo', 'csv', '/nh'], { + windowsHide: true + }) + ) + if (result.error || result.code !== 0) { + throw new Error(`tasklist failed for PID ${pid}: ${result.error ?? result.stderr}`) + } + return result.stdout.includes(`"${pid}"`) +} + +async function killTree(pid) { + const result = await collect( + spawn('taskkill.exe', ['/pid', String(pid), '/t', '/f'], { windowsHide: true }) + ) + if (result.error || result.code !== 0) { + throw new Error(`taskkill failed for PID ${pid}: ${result.error ?? result.stderr}`) + } +} + +async function waitForTreeExit(pids, timeoutMs = 5_000) { + const deadline = Date.now() + timeoutMs + let alive = {} + do { + alive = Object.fromEntries( + await Promise.all( + Object.entries(pids).map(async ([name, pid]) => [name, await taskExists(pid)]) + ) + ) + if (!Object.values(alive).some(Boolean)) { + return alive + } + await new Promise((resolve) => setTimeout(resolve, 50)) + } while (Date.now() < deadline) + return alive +} + +const results = { + strategy, + reportedPath: null, + pathMatrix: {}, + argvMatrix: {}, + hostilePathAndArgv: null, + error: null, + cancellation: null +} +const fixtureSource = + `const { spawn } = require('node:child_process')\n` + + `const { writeFileSync } = require('node:fs')\n` + + `if (process.argv[2] === '--exit-error') { process.stderr.write('fixture error: 한글 & ^ % !\\n'); process.exit(23) }\n` + + `if (process.argv[2] === '--linger') {\n` + + ` const grandchild = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { windowsHide: true })\n` + + ` writeFileSync(process.env.ORCA_FIXTURE_PIDS, JSON.stringify({ child: process.pid, grandchild: grandchild.pid }))\n` + + ` setInterval(() => {}, 1000)\n` + + `} else {\n` + + ` writeFileSync(process.env.ORCA_FIXTURE_CAPTURE, JSON.stringify({ argv: process.argv.slice(2), configDir: process.env.CLAUDE_CONFIG_DIR }))\n` + + `}\n` +const shimSource = '@echo off\r\n"%ORCA_FIXTURE_NODE%" "%~dp0capture-child.cjs" %*\r\n' +let lingeringShellPid = null +try { + await mkdir(fixtureDir, { recursive: true }) + await mkdir(reportedDir, { recursive: true }) + await writeFile(fixturePath, fixtureSource, 'utf8') + await writeFile(shimPath, shimSource, 'utf8') + await writeFile(reportedFixturePath, fixtureSource, 'utf8') + await writeFile(reportedShimPath, shimSource, 'utf8') + + const reportedArgs = ['auth', 'status', '--json'] + const reportedRun = await collect(launch(reportedArgs, reportedShimPath, reportedEnv)) + let reportedCapture = null + try { + reportedCapture = await waitForFile(reportedCapturePath, 1_000) + } catch {} + results.reportedPath = { + ...reportedRun, + actual: reportedCapture, + expected: { argv: reportedArgs, configDir: reportedEnv.CLAUDE_CONFIG_DIR }, + pass: + reportedRun.code === 0 && + JSON.stringify(reportedCapture) === + JSON.stringify({ argv: reportedArgs, configDir: reportedEnv.CLAUDE_CONFIG_DIR }) + } + + for (const [name, segment] of Object.entries({ + spaces: 'profile space', + ampersand: 'profile&name', + caret: 'profile^name', + parentheses: 'profile(name)', + percent: 'profile%ORCA_PATH_TRAP%', + bang: 'profile!name', + unicode: 'profile-한글-λ' + })) { + const directory = join(tempRoot, segment) + const captureFile = join(directory, 'capture.json') + const command = join(directory, 'claude fixture.cmd') + await mkdir(directory, { recursive: true }) + await writeFile(join(directory, 'capture-child.cjs'), fixtureSource, 'utf8') + await writeFile(command, shimSource, 'utf8') + const env = { + ...fixtureEnv, + CLAUDE_CONFIG_DIR: join(directory, 'config'), + ORCA_FIXTURE_CAPTURE: captureFile + } + const run = await collect(launch(reportedArgs, command, env)) + let actual = null + try { + actual = await waitForFile(captureFile, 500) + } catch {} + results.pathMatrix[name] = { + code: run.code, + stderr: run.stderr.trim(), + actual: actual?.argv ?? null, + pass: run.code === 0 && JSON.stringify(actual?.argv) === JSON.stringify(reportedArgs) + } + } + + for (const [name, value] of Object.entries({ + empty: '', + spaces: 'two words', + ampersand: 'amp&ersand', + pipe: 'pipe|value', + lessThan: 'lessvalue', + caret: 'caret^value', + trailingBackslash: 'trailing\\', + twoTrailingBackslashes: 'two-trailing\\\\', + parentheses: '(parentheses)', + percent: '%ORCA_ARG_TRAP%', + bang: 'bang!value', + unicode: '한글-λ' + })) { + const args = ['prefix', value, 'suffix'] + const captureFile = join(reportedDir, `capture-${name}.json`) + const env = { ...reportedEnv, ORCA_FIXTURE_CAPTURE: captureFile } + const run = await collect(launch(args, reportedShimPath, env)) + let actual = null + try { + actual = await waitForFile(captureFile, 500) + } catch {} + results.argvMatrix[name] = { + code: run.code, + stderr: run.stderr.trim(), + actual: actual?.argv ?? null, + pass: run.code === 0 && JSON.stringify(actual?.argv) === JSON.stringify(args) + } + } + + const argvRun = await collect(launch(expectedArgs)) + let capture = null + try { + capture = await waitForFile(capturePath, 1_000) + } catch {} + results.hostilePathAndArgv = { + ...argvRun, + actual: capture, + expected: { argv: expectedArgs, configDir: fixtureEnv.CLAUDE_CONFIG_DIR }, + pass: + argvRun.code === 0 && + JSON.stringify(capture) === + JSON.stringify({ argv: expectedArgs, configDir: fixtureEnv.CLAUDE_CONFIG_DIR }) + } + + results.error = await collect(launch(['--exit-error'], reportedShimPath, reportedEnv)) + results.error.pass = + results.error.code === 23 && results.error.stderr.includes('fixture error: 한글 & ^ % !') + + const lingering = launch(['--linger'], reportedShimPath, reportedEnv) + lingeringShellPid = lingering.pid + const lingeringResult = collect(lingering) + try { + const pids = await waitForFile(reportedPidPath) + await killTree(lingering.pid) + const alive = await waitForTreeExit({ + shell: lingering.pid, + child: pids.child, + grandchild: pids.grandchild + }) + results.cancellation = { + shell: lingering.pid, + ...pids, + alive, + pass: !Object.values(alive).some(Boolean) + } + } catch (error) { + if (await taskExists(lingering.pid)) { + await killTree(lingering.pid) + } + const launchResult = await Promise.race([ + lingeringResult, + new Promise((resolve) => + setTimeout( + () => resolve({ code: null, error: 'fixture did not exit after cleanup' }), + 5_000 + ) + ) + ]) + results.cancellation = { + shell: lingering.pid, + launchResult, + pass: false, + error: error instanceof Error ? error.message : String(error) + } + } +} finally { + if (lingeringShellPid && (await taskExists(lingeringShellPid))) { + await killTree(lingeringShellPid) + } + try { + let pids + try { + pids = JSON.parse(await readFile(reportedPidPath, 'utf8')) + } catch { + pids = JSON.parse(await readFile(pidPath, 'utf8')) + } + for (const pid of [pids.child, pids.grandchild]) { + if (await taskExists(pid)) { + await killTree(pid) + } + } + } catch {} + await rm(tempRoot, { recursive: true, force: true }) +} + +console.log(JSON.stringify(results, null, 2)) +process.exitCode = + results.reportedPath?.pass && + Object.values(results.pathMatrix).every((result) => result.pass) && + Object.values(results.argvMatrix).every((result) => result.pass) && + results.hostilePathAndArgv?.pass && + results.error?.pass && + results.cancellation?.pass + ? 0 + : 1 diff --git a/config/scripts/node-pty-windows-pty-teardown-patch.test.mjs b/config/scripts/node-pty-windows-pty-teardown-patch.test.mjs index fc0894d58f6b..d9ee133a70e3 100644 --- a/config/scripts/node-pty-windows-pty-teardown-patch.test.mjs +++ b/config/scripts/node-pty-windows-pty-teardown-patch.test.mjs @@ -10,8 +10,8 @@ // Those numbers are the `!useConptyDll` branch, which is the branch a RELAY runs. Every desktop // site that opens a terminal pane sets `useConptyDll: true` and takes the other branch, where // upstream already destroys the input socket. The hidden rate-limit probe -// (`src/main/rate-limits/codex-pty-rate-limit-probe.ts`) does omit the option and so does run this -// hunk, but no user-visible pane does. The divergence pinned below is about which +// (`src/main/rate-limits/claude-pty.ts`) does omit the option and so does run this hunk, but no +// user-visible pane does. The divergence pinned below is about which // branch each host runs for terminals -- not about a regression in the panes users open. import { createRequire } from 'node:module' import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' diff --git a/src/cli/handlers/account-list-format.ts b/src/cli/handlers/account-list-format.ts index 59df0c6b3a48..66da6d4b1b71 100644 --- a/src/cli/handlers/account-list-format.ts +++ b/src/cli/handlers/account-list-format.ts @@ -3,7 +3,7 @@ import type { ManagedDataAccountsState } from '../../shared/managed-account-type // Why: Claude and Codex managed-account summaries both carry id+email+active id, // so one formatter renders either provider's block. type AccountsBlock = { - accounts: readonly { id: string; email: string; needsSignIn?: true }[] + accounts: readonly { id: string; email: string }[] activeAccountId: string | null activeAccountIdsByRuntime?: { host: string | null @@ -35,10 +35,7 @@ export function formatAccountsBlock(label: string, block: AccountsBlock): string ...Object.values(block.activeAccountIdsByRuntime?.wsl ?? {}) ]) const lines = block.accounts.map( - (account) => - ` ${account.email}${activeAccountIds.has(account.id) ? ' (active)' : ''}${ - account.needsSignIn ? ' (sign in again in Orca Settings > AI Provider Accounts)' : '' - }` + (account) => ` ${account.email}${activeAccountIds.has(account.id) ? ' (active)' : ''}` ) return `Managed ${label} accounts (${block.accounts.length}):\n${lines.join('\n')}` } diff --git a/src/cli/handlers/account.test.ts b/src/cli/handlers/account.test.ts index e19fd4a8ad29..8b2fe9d5ae7e 100644 --- a/src/cli/handlers/account.test.ts +++ b/src/cli/handlers/account.test.ts @@ -1,5 +1,6 @@ import type * as NodeCliCommandResolutionModule from '../../shared/node-cli-command-resolution' import { EventEmitter } from 'node:events' +import { createHash } from 'node:crypto' import { existsSync } from 'node:fs' import { delimiter } from 'node:path' import type * as NodeFs from 'node:fs' @@ -62,10 +63,7 @@ import { WINDOWS_BATCH_UNSAFE_ARGUMENTS_ERROR, WINDOWS_BATCH_UNSAFE_CHARACTERS_LABEL } from '../../shared/windows-batch-spawn' -import { - ACCOUNT_IMPORT_RUNTIME_CAPABILITY, - CLAUDE_SIGN_IN_RUNTIME_CAPABILITY -} from '../../shared/protocol-version' +import { ACCOUNT_IMPORT_RUNTIME_CAPABILITY } from '../../shared/protocol-version' function successfulChild(): EventEmitter { const child = new EventEmitter() @@ -133,14 +131,8 @@ describe('account CLI handlers', () => { ok: true, result: method === 'status.get' - ? { - capabilities: [ACCOUNT_IMPORT_RUNTIME_CAPABILITY, CLAUDE_SIGN_IN_RUNTIME_CAPABILITY] - } - : method === 'accounts.beginClaudeSignIn' - ? { accountId: 'draft', configDir: '/fake/final-profile', runtime: 'host' } - : accountState( - method.includes('Claude') ? 'claude@example.com' : 'codex@example.com' - ), + ? { capabilities: [ACCOUNT_IMPORT_RUNTIME_CAPABILITY] } + : accountState(method.includes('Claude') ? 'claude@example.com' : 'codex@example.com'), _meta: { runtimeId: 'test-runtime' } }) ) @@ -314,17 +306,37 @@ describe('account CLI handlers', () => { expect(pathValues).toContain(`${nodeBin}${delimiter}${effectivePathBefore}`) }) - it('runs login in the final profile and never reads, writes or cleans up Keychain credentials', async () => { + it('removes scoped Claude credentials and restores the legacy Keychain item', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) + readKeychainMock.mockResolvedValue('legacy-credentials') + await ACCOUNT_HANDLERS['account add'](context('claude')) - expect(spawnMock.mock.calls[0]?.[2].env.CLAUDE_CONFIG_DIR).toBe('/fake/final-profile') - expect(callMock).toHaveBeenCalledWith( - 'accounts.finishClaudeSignIn', - { accountId: 'draft', runtime: 'host', wslDistro: undefined }, - { timeoutMs: 300000 } + + const configDir = spawnMock.mock.calls[0]?.[2].env.CLAUDE_CONFIG_DIR + expect(deleteKeychainMock).toHaveBeenCalledWith(configDir) + expect(writeKeychainMock).toHaveBeenCalledWith('legacy-credentials') + expect(callMock).toHaveBeenCalledWith('accounts.addClaudeFromConfigDir', { + configDir, + previousLegacyCredentialsSha256: createHash('sha256') + .update('legacy-credentials') + .digest('hex') + }) + expect(existsSync(configDir)).toBe(false) + }) + + it('attributes a Claude account added through the WSL bridge to the cwd distro', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + + await ACCOUNT_HANDLERS['account add']( + context('claude', false, String.raw`\\wsl.localhost\Ubuntu-22.04\home\user\project`) ) - expect(readKeychainMock).not.toHaveBeenCalled() - expect(writeKeychainMock).not.toHaveBeenCalled() - expect(deleteKeychainMock).not.toHaveBeenCalled() + + const configDir = spawnMock.mock.calls[0]?.[2].env.CLAUDE_CONFIG_DIR + expect(callMock).toHaveBeenCalledWith('accounts.addClaudeFromConfigDir', { + configDir, + runtime: 'wsl', + wslDistro: 'Ubuntu-22.04' + }) }) it('attributes a Codex account added through the WSL bridge to the cwd distro', async () => { @@ -352,9 +364,8 @@ describe('account CLI handlers', () => { ) expect(callMock).toHaveBeenCalledWith( - 'accounts.beginClaudeSignIn', - expect.objectContaining({ runtime: 'wsl', wslDistro: 'Debian' }), - { timeoutMs: 300000 } + 'accounts.addClaudeFromConfigDir', + expect.objectContaining({ runtime: 'wsl', wslDistro: 'Debian' }) ) }) @@ -364,14 +375,10 @@ describe('account CLI handlers', () => { Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) process.env.ORCA_CLI_WSL_DISTRO = 'Ubuntu Work' await ACCOUNT_HANDLERS['account add'](context(agent, false, String.raw`C:\work with spaces`)) - expect( - callMock.mock.calls.some( - ([method, params]) => - method === - (agent === 'claude' ? 'accounts.beginClaudeSignIn' : 'accounts.addCodexFromHome') && - params.wslDistro === 'Ubuntu Work' - ) - ).toBe(true) + expect(callMock).toHaveBeenCalledWith( + agent === 'claude' ? 'accounts.addClaudeFromConfigDir' : 'accounts.addCodexFromHome', + expect.objectContaining({ runtime: 'wsl', wslDistro: 'Ubuntu Work' }) + ) } ) @@ -463,6 +470,62 @@ describe('account CLI handlers', () => { exitSpy.mockRestore() }) + it('waits for in-flight cleanup when a second signal arrives', async () => { + // Why: a boolean latch lets the second signal's process.exit fire while the + // first cleanup is still inside a Keychain call, stranding the credentials. + Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) + readKeychainMock.mockResolvedValue('legacy-credentials') + let releaseKeychainDelete: (() => void) | undefined + deleteKeychainMock.mockImplementationOnce( + () => + new Promise((resolvePromise) => { + releaseKeychainDelete = () => resolvePromise() + }) + ) + const child = Object.assign(new EventEmitter(), { kill: vi.fn() }) + let configDir = '' + spawnMock.mockImplementation((_command, _args, options: { env: Record }) => { + configDir = options.env.CLAUDE_CONFIG_DIR + return child + }) + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never) + const sigintBefore = process.listeners('SIGINT') + const sigtermBefore = process.listeners('SIGTERM') + + const pending = ACCOUNT_HANDLERS['account add'](context('claude')).catch(() => {}) + await vi.waitFor(() => expect(configDir).not.toBe('')) + + const onSigint = newSignalListener('SIGINT', sigintBefore) + const onSigterm = newSignalListener('SIGTERM', sigtermBefore) + // Why: `rawListeners` exposes the `once` wrapper, so this fails if the handler + // is registered with `once` — where a second Ctrl-C falls through to Node's + // default and kills the process mid-cleanup. + expect(process.rawListeners('SIGINT')).toContain(onSigint) + + onSigint('SIGINT') + await vi.waitFor(() => expect(child.kill).toHaveBeenCalledWith('SIGINT')) + child.emit('exit', 1) + child.emit('close', 1) + await vi.waitFor(() => expect(deleteKeychainMock).toHaveBeenCalledWith(configDir)) + onSigterm('SIGTERM') + await new Promise((resolvePromise) => { + setImmediate(resolvePromise) + }) + + expect(exitSpy).not.toHaveBeenCalled() + expect(writeKeychainMock).not.toHaveBeenCalled() + expect(existsSync(configDir)).toBe(true) + + releaseKeychainDelete?.() + await vi.waitFor(() => expect(exitSpy).toHaveBeenCalled()) + expect(writeKeychainMock).toHaveBeenCalledWith('legacy-credentials') + expect(existsSync(configDir)).toBe(false) + expect(child.kill).toHaveBeenCalledOnce() + + await pending + exitSpy.mockRestore() + }) + it('warns that the account may already be registered when interrupted mid-RPC', async () => { // Why: the runtime finishes the add independently of this process, so an // interrupt after sign-in cannot honestly be reported as "not added". @@ -477,9 +540,7 @@ describe('account CLI handlers', () => { ? Promise.resolve({ id: 'test', ok: true, - result: { - capabilities: [ACCOUNT_IMPORT_RUNTIME_CAPABILITY, CLAUDE_SIGN_IN_RUNTIME_CAPABILITY] - }, + result: { capabilities: [ACCOUNT_IMPORT_RUNTIME_CAPABILITY] }, _meta: { runtimeId: 'test-runtime' } }) : method === 'accounts.list' @@ -508,6 +569,32 @@ describe('account CLI handlers', () => { exitSpy.mockRestore() }) + it('stays armed for signals until post-success cleanup finishes', async () => { + // Why: detaching the handlers before cleanup leaves the multi-second Keychain + // calls covered only by Node's default handling, which kills mid-cleanup. + Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) + readKeychainMock.mockResolvedValue('legacy-credentials') + let releaseKeychainDelete: (() => void) | undefined + deleteKeychainMock.mockImplementationOnce( + () => + new Promise((resolvePromise) => { + releaseKeychainDelete = () => resolvePromise() + }) + ) + + const listenersBefore = process.listeners('SIGINT') + + const pending = ACCOUNT_HANDLERS['account add'](context('claude')) + await vi.waitFor(() => expect(deleteKeychainMock).toHaveBeenCalled()) + + // Why: cleanup is still in flight here, so this add's guard must still be installed. + const handler = newSignalListener('SIGINT', listenersBefore) + + releaseKeychainDelete?.() + await pending + expect(process.listeners('SIGINT')).not.toContain(handler) + }) + it('fails before the login when the runtime is unreachable', async () => { // Why: discovering a dead runtime after sign-in wastes a full OAuth round trip. callMock.mockRejectedValue(new Error('runtime not running')) @@ -519,40 +606,22 @@ describe('account CLI handlers', () => { expect(spawnMock).not.toHaveBeenCalled() }) - it('still adds Codex through an older host that advertises only the retired import capability', async () => { + it('fails before login when the running runtime predates account imports', async () => { callMock.mockResolvedValue({ id: 'test', ok: true, - result: { capabilities: [ACCOUNT_IMPORT_RUNTIME_CAPABILITY] }, + result: { capabilities: [] }, _meta: { runtimeId: 'test-runtime' } }) - spawnMock.mockImplementation(() => { - throw new Error('stop after the capability gate') - }) - await expect(ACCOUNT_HANDLERS['account add'](context('codex'))).rejects.toThrow() - expect(spawnMock).toHaveBeenCalled() + await expect(ACCOUNT_HANDLERS['account add'](context('codex'))).rejects.toThrow( + 'runtime is too old' + ) + expect(callMock).toHaveBeenCalledOnce() + expect(callMock).toHaveBeenCalledWith('status.get') + expect(spawnMock).not.toHaveBeenCalled() }) - it.each(['claude', 'codex'] as const)( - 'refuses %s login before spawning when its capability is absent', - async (agent) => { - callMock.mockResolvedValue({ - id: 'test', - ok: true, - result: { capabilities: [] }, - _meta: { runtimeId: 'test-runtime' } - }) - - await expect(ACCOUNT_HANDLERS['account add'](context(agent))).rejects.toThrow( - 'The running Orca runtime is too old to add accounts from the CLI.' - ) - expect(callMock).toHaveBeenCalledOnce() - expect(callMock).toHaveBeenCalledWith('status.get') - expect(spawnMock).not.toHaveBeenCalled() - } - ) - it.each(['environment', 'pairing-code'])( 'rejects --%s instead of silently ignoring it', async (flag) => { @@ -598,9 +667,7 @@ describe('account CLI handlers', () => { ? Promise.resolve({ id: 'test', ok: true, - result: { - capabilities: [ACCOUNT_IMPORT_RUNTIME_CAPABILITY, CLAUDE_SIGN_IN_RUNTIME_CAPABILITY] - }, + result: { capabilities: [ACCOUNT_IMPORT_RUNTIME_CAPABILITY] }, _meta: { runtimeId: 'test-runtime' } }) : method === 'accounts.list' @@ -632,14 +699,15 @@ describe('account CLI handlers', () => { expect(logSpy).not.toHaveBeenCalled() }) - it('does not touch a locked Keychain while the CLI signs in', async () => { - readKeychainMock.mockRejectedValue(new Error('locked')) - deleteKeychainMock.mockRejectedValue(new Error('locked')) - writeKeychainMock.mockRejectedValue(new Error('locked')) - await ACCOUNT_HANDLERS['account add'](context('claude')) - expect(readKeychainMock).not.toHaveBeenCalled() - expect(deleteKeychainMock).not.toHaveBeenCalled() - expect(writeKeychainMock).not.toHaveBeenCalled() + it('fails a successful Claude add when Keychain cleanup fails', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) + readKeychainMock.mockResolvedValue('legacy-credentials') + deleteKeychainMock.mockRejectedValueOnce(new Error('Keychain denied cleanup')) + + await expect(ACCOUNT_HANDLERS['account add'](context('claude'))).rejects.toThrow( + 'Failed to clean up Claude login artifacts' + ) + expect(writeKeychainMock).toHaveBeenCalledWith('legacy-credentials') }) it('rejects `--agent` with no value instead of defaulting to Claude', async () => { @@ -673,32 +741,6 @@ describe('account CLI handlers', () => { expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('claude@example.com (active)')) }) - it('tells the user which saved Claude accounts need a fresh sign-in', async () => { - callMock.mockResolvedValue({ - id: 'test', - ok: true, - result: { - claude: { - accounts: [ - { id: 'old', email: 'old@example.com', needsSignIn: true }, - { id: 'ok', email: 'ok@example.com' } - ], - activeAccountId: 'old' - }, - codex: { accounts: [], activeAccountId: null } - }, - _meta: { runtimeId: 'test-runtime' } - }) - - await ACCOUNT_HANDLERS['account list']({ ...context('claude'), flags: new Map() }) - - expect(logSpy).toHaveBeenCalledWith( - expect.stringContaining( - ' old@example.com (active) (sign in again in Orca Settings > AI Provider Accounts)\n ok@example.com\n' - ) - ) - }) - it('lists accounts without forcing a provider usage refresh', async () => { // Why: the forced lane bypasses the poll throttle and costs one serial // round-trip per managed account, and this output shows no usage numbers. diff --git a/src/cli/handlers/account.ts b/src/cli/handlers/account.ts index db2cd31c8b8a..d4081c3c5da1 100644 --- a/src/cli/handlers/account.ts +++ b/src/cli/handlers/account.ts @@ -1,4 +1,5 @@ import { spawn } from 'node:child_process' +import { createHash } from 'node:crypto' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { delimiter, join } from 'node:path' @@ -8,10 +9,10 @@ import { RuntimeClientError } from '../runtime-client' import { stripElectronRunAsNode } from '../runtime/launch' import { rejectRemoteSelectionFlags } from '../remote-selection-flag-rejection' import { - buildWslExecArgs, - buildWslLoginShellCommand, - quotePosixShell -} from '../../shared/wsl-login-shell-command' + deleteActiveClaudeKeychainCredentialsStrict, + readActiveClaudeKeychainCredentialsStrict, + writeActiveClaudeKeychainCredentials +} from '../../main/claude-accounts/keychain' import { getVersionManagerBinPaths, resolveCliCommand, @@ -23,13 +24,9 @@ import { WINDOWS_BATCH_UNSAFE_CHARACTERS_LABEL } from '../../shared/windows-batch-spawn' import { stdioForWindowsInteractiveChild } from '../../shared/windows-console-input' -import { - ACCOUNT_IMPORT_RUNTIME_CAPABILITY, - CLAUDE_SIGN_IN_RUNTIME_CAPABILITY -} from '../../shared/protocol-version' +import { ACCOUNT_IMPORT_RUNTIME_CAPABILITY } from '../../shared/protocol-version' import type { RuntimeStatus } from '../../shared/runtime-types' import type { - ClaudeAccountSignIn, ClaudeRateLimitAccountsState, CodexRateLimitAccountsState, ManagedDataAccountsState @@ -143,53 +140,79 @@ async function runAgentLoginInTerminal( }) } -/** Signs in to a new account folder the host created, then registers it (superset AddAccountDialog). */ +async function cleanupClaudeLoginArtifacts( + configDir: string, + legacyCredentials: string | null, + restoreLegacyCredentials: boolean +): Promise { + const errors: unknown[] = [] + if (process.platform === 'darwin') { + try { + await deleteActiveClaudeKeychainCredentialsStrict(configDir) + } catch (error) { + errors.push(error) + } + if (restoreLegacyCredentials) { + try { + await (legacyCredentials + ? writeActiveClaudeKeychainCredentials(legacyCredentials) + : deleteActiveClaudeKeychainCredentialsStrict()) + } catch (error) { + errors.push(error) + } + } + } + try { + rmSync(configDir, { recursive: true, force: true }) + } catch (error) { + errors.push(error) + } + if (errors.length > 0) { + throw new AggregateError(errors, 'Failed to clean up Claude login artifacts.') + } +} + +/** Logs into a Claude account in a temp config dir, then registers it with the local runtime. */ async function addClaudeAccount({ client, cwd, json }: HandlerContext): Promise { - const { result: signIn } = await client.call( - 'accounts.beginClaudeSignIn', - getWslAccountTarget(cwd) ?? {}, - { timeoutMs: 300_000 } - ) + const configDir = mkdtempSync(join(tmpdir(), 'orca-account-add-claude-')) const session: InteractiveLoginSession = { child: null, registering: false, terminationPromise: null } + let legacyCredentials: string | null = null + let restoreLegacyCredentials = false const result = await withInteractiveLoginCleanup( session, - // Why always: the host keeps a folder that became an account, and deletes an abandoned one. - // Never thrown: an older running Orca lacks the method, and the add itself already finished. async () => { - const { accountId, runtime, wslDistro } = signIn - await client - .call('accounts.cancelClaudeSignIn', { accountId, runtime, wslDistro }) - .catch((error: unknown) => console.warn('[account] Could not clean up sign-in:', error)) + await cleanupClaudeLoginArtifacts(configDir, legacyCredentials, restoreLegacyCredentials) }, async () => { - if (signIn.runtime === 'wsl' && signIn.wslDistro) { - const login = `exec env CLAUDE_CONFIG_DIR=${quotePosixShell(signIn.configDir)} claude auth login` - await runAgentLoginInTerminal( - 'wsl.exe', - buildWslExecArgs(signIn.wslDistro, ['/bin/sh', '-c', buildWslLoginShellCommand(login)]), - {}, - json, - session - ) - } else { - await runAgentLoginInTerminal( - 'claude', - ['auth', 'login'], - { CLAUDE_CONFIG_DIR: signIn.configDir }, - json, - session - ) + if (process.platform === 'darwin') { + legacyCredentials = await readActiveClaudeKeychainCredentialsStrict() + restoreLegacyCredentials = true } - session.registering = true - return client.call( - 'accounts.finishClaudeSignIn', - { accountId: signIn.accountId, runtime: signIn.runtime, wslDistro: signIn.wslDistro }, - { timeoutMs: 300_000 } + await runAgentLoginInTerminal( + 'claude', + ['auth', 'login', '--claudeai'], + { + CLAUDE_CONFIG_DIR: configDir + }, + json, + session ) + session.registering = true + return client.call('accounts.addClaudeFromConfigDir', { + configDir, + ...getWslAccountTarget(cwd), + ...(process.platform === 'darwin' + ? { + previousLegacyCredentialsSha256: legacyCredentials + ? createHash('sha256').update(legacyCredentials).digest('hex') + : null + } + : {}) + }) } ) printResult(result, json, (state) => formatAccountsBlock('Claude', state)) @@ -242,14 +265,9 @@ function rejectAccountRemoteSelectionFlags(ctx: HandlerContext, command: string) ) } -async function assertAccountImportSupported( - { client }: HandlerContext, - agent: 'claude' | 'codex' -): Promise { +async function assertAccountImportSupported({ client }: HandlerContext): Promise { const status = await client.call('status.get') - const capability = - agent === 'claude' ? CLAUDE_SIGN_IN_RUNTIME_CAPABILITY : ACCOUNT_IMPORT_RUNTIME_CAPABILITY - if (!status.result.capabilities?.includes(capability)) { + if (!status.result.capabilities?.includes(ACCOUNT_IMPORT_RUNTIME_CAPABILITY)) { throw new RuntimeClientError( 'incompatible_runtime', 'The running Orca runtime is too old to add accounts from the CLI. Update or restart Orca and try again.' @@ -282,7 +300,7 @@ export const ACCOUNT_HANDLERS: Record = { return } // Why: fail on runtime version skew before burning a full OAuth round trip. - await assertAccountImportSupported(ctx, agent) + await assertAccountImportSupported(ctx) await ctx.client.call('accounts.list', { refreshUsage: false }) await (agent === 'claude' ? addClaudeAccount(ctx) : addCodexAccount(ctx)) }, 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 eb280f1900bf..c8de61a9fdac 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt @@ -138,30 +138,6 @@ if [[ -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then } fi unset __orca_codex_binary -__orca_claude_binary="$(unalias claude 2>/dev/null || :; command -v claude 2>/dev/null || :)" -if [[ -n "${ORCA_CLAUDE_PROFILE_POINTER:-}" && -n "${__orca_claude_binary:-}" && -x "${__orca_claude_binary}" ]]; then - function claude { - local __orca_claude_home __orca_claude_pointer="${ORCA_CLAUDE_PROFILE_POINTER:-}" - # Why: a WSL pane's pointer is relative to the guest home, which the host cannot know at spawn. - case "$__orca_claude_pointer" in '~/'*) __orca_claude_pointer="${HOME:-}/${__orca_claude_pointer#??}" ;; esac - __orca_claude_home="$(cat "$__orca_claude_pointer" 2>/dev/null || :)" - if [ -n "${CLAUDE_CONFIG_DIR:-}" ] && [ "$CLAUDE_CONFIG_DIR" != "${ORCA_CLAUDE_INJECTED_CONFIG_DIR:-}" ]; then - command claude "$@"; return - fi - # Why: Orca's value replaced the user's own at spawn, so System default restores theirs. - if [ -z "$__orca_claude_home" ] && [ -n "${CLAUDE_CONFIG_DIR:-}" ] && [ -n "${ORCA_CLAUDE_USER_CONFIG_DIR:-}" ]; then - ( unset ORCA_CLAUDE_INJECTED_CONFIG_DIR; export CLAUDE_CONFIG_DIR="$ORCA_CLAUDE_USER_CONFIG_DIR"; command claude "$@" ); return - fi - if [ -z "$__orca_claude_home" ]; then - ( unset CLAUDE_CONFIG_DIR ORCA_CLAUDE_INJECTED_CONFIG_DIR; command claude "$@" ); return - fi - case "$__orca_claude_home" in /*|[A-Za-z]:*) ;; *) return 1 ;; esac - # Why -m 700: matches the folder Orca's setup creates; a credentials folder stays private. - [ -d "$__orca_claude_home" ] || mkdir -p -m 700 -- "$__orca_claude_home" 2>/dev/null - ( unset ANTHROPIC_API_KEY ANTHROPIC_AUTH_TOKEN CLAUDE_CODE_OAUTH_TOKEN AWS_BEARER_TOKEN_BEDROCK; case "${ANTHROPIC_CUSTOM_HEADERS:-}" in *[aA][uU][tT][hH][oO][rR][iI][zZ][aA][tT][iI][oO][nN]*|*[xX]-[aA][pP][iI]-[kK][eE][yY]*|*[aA][pP][iI]-[kK][eE][yY]*|*[bB][eE][aA][rR][eE][rR]*) unset ANTHROPIC_CUSTOM_HEADERS ;; esac; export CLAUDE_CONFIG_DIR="$__orca_claude_home" ORCA_CLAUDE_INJECTED_CONFIG_DIR="$__orca_claude_home"; command claude "$@" ) - } -fi -unset __orca_claude_binary # Why: emit OSC 133 C/D so terminal-command-lifecycle can drop stale agent # status when the foreground command exits — mirrors the zsh daemon wrapper. diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-fish-init.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-fish-init.txt index 42fb21032ecc..ff24125e2c00 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-fish-init.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-fish-init.txt @@ -17,34 +17,4 @@ if test "$__orca_codex_type" = file command codex $argv end end -set -e __orca_codex_type -set -l __orca_claude_type (type -t claude 2>/dev/null) -if test -n "$ORCA_CLAUDE_PROFILE_POINTER"; and test "$__orca_claude_type" = file - function claude - # Why: a WSL pane's pointer is relative to the guest home, which the host cannot know at spawn. - set -l pointer (string replace -r '^~/' "$HOME/" -- "$ORCA_CLAUDE_PROFILE_POINTER") - set -l profile (cat "$pointer" 2>/dev/null) - if test -n "$CLAUDE_CONFIG_DIR"; and test "$CLAUDE_CONFIG_DIR" != "$ORCA_CLAUDE_INJECTED_CONFIG_DIR" - command claude $argv - return $status - end - if test -z "$profile"; and test -n "$CLAUDE_CONFIG_DIR"; and test -n "$ORCA_CLAUDE_USER_CONFIG_DIR" - env -u ORCA_CLAUDE_INJECTED_CONFIG_DIR CLAUDE_CONFIG_DIR="$ORCA_CLAUDE_USER_CONFIG_DIR" claude $argv - return $status - end - if test -z "$profile" - env -u CLAUDE_CONFIG_DIR -u ORCA_CLAUDE_INJECTED_CONFIG_DIR claude $argv - return $status - end - if not string match -qr '^(/|[A-Za-z]:)' -- "$profile" - return 1 - end - test -d "$profile"; or mkdir -p -m 700 -- "$profile" 2>/dev/null - set -l headers - if string match -irq 'authorization|x-api-key|api-key|bearer' -- "$ANTHROPIC_CUSTOM_HEADERS" - set headers -u ANTHROPIC_CUSTOM_HEADERS - end - env $headers -u ANTHROPIC_API_KEY -u ANTHROPIC_AUTH_TOKEN -u CLAUDE_CODE_OAUTH_TOKEN -u AWS_BEARER_TOKEN_BEDROCK CLAUDE_CONFIG_DIR="$profile" ORCA_CLAUDE_INJECTED_CONFIG_DIR="$profile" claude $argv - end -end -set -e __orca_claude_type +set -e __orca_codex_type \ No newline at end of file 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 22c221bb9434..18850b4a0efc 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt @@ -220,30 +220,6 @@ __orca_deferred_init() { } fi unset __orca_codex_binary - __orca_claude_binary="$(unalias claude 2>/dev/null || :; command -v claude 2>/dev/null || :)" - if [[ -n "${ORCA_CLAUDE_PROFILE_POINTER:-}" && -n "${__orca_claude_binary:-}" && -x "${__orca_claude_binary}" ]]; then - function claude { - local __orca_claude_home __orca_claude_pointer="${ORCA_CLAUDE_PROFILE_POINTER:-}" - # Why: a WSL pane's pointer is relative to the guest home, which the host cannot know at spawn. - case "$__orca_claude_pointer" in '~/'*) __orca_claude_pointer="${HOME:-}/${__orca_claude_pointer#??}" ;; esac - __orca_claude_home="$(cat "$__orca_claude_pointer" 2>/dev/null || :)" - if [ -n "${CLAUDE_CONFIG_DIR:-}" ] && [ "$CLAUDE_CONFIG_DIR" != "${ORCA_CLAUDE_INJECTED_CONFIG_DIR:-}" ]; then - command claude "$@"; return - fi - # Why: Orca's value replaced the user's own at spawn, so System default restores theirs. - if [ -z "$__orca_claude_home" ] && [ -n "${CLAUDE_CONFIG_DIR:-}" ] && [ -n "${ORCA_CLAUDE_USER_CONFIG_DIR:-}" ]; then - ( unset ORCA_CLAUDE_INJECTED_CONFIG_DIR; export CLAUDE_CONFIG_DIR="$ORCA_CLAUDE_USER_CONFIG_DIR"; command claude "$@" ); return - fi - if [ -z "$__orca_claude_home" ]; then - ( unset CLAUDE_CONFIG_DIR ORCA_CLAUDE_INJECTED_CONFIG_DIR; command claude "$@" ); return - fi - case "$__orca_claude_home" in /*|[A-Za-z]:*) ;; *) return 1 ;; esac - # Why -m 700: matches the folder Orca's setup creates; a credentials folder stays private. - [ -d "$__orca_claude_home" ] || mkdir -p -m 700 -- "$__orca_claude_home" 2>/dev/null - ( unset ANTHROPIC_API_KEY ANTHROPIC_AUTH_TOKEN CLAUDE_CODE_OAUTH_TOKEN AWS_BEARER_TOKEN_BEDROCK; case "${ANTHROPIC_CUSTOM_HEADERS:-}" in *[aA][uU][tT][hH][oO][rR][iI][zZ][aA][tT][iI][oO][nN]*|*[xX]-[aA][pP][iI]-[kK][eE][yY]*|*[aA][pP][iI]-[kK][eE][yY]*|*[bB][eE][aA][rR][eE][rR]*) unset ANTHROPIC_CUSTOM_HEADERS ;; esac; export CLAUDE_CONFIG_DIR="$__orca_claude_home" ORCA_CLAUDE_INJECTED_CONFIG_DIR="$__orca_claude_home"; command claude "$@" ) - } - fi - unset __orca_claude_binary if [[ -n "${_orca_histfile:-}" ]]; then HISTFILE="$_orca_histfile" 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 a43c7bf2c396..05f3da594e03 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt @@ -148,30 +148,6 @@ if [[ -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then } fi unset __orca_codex_binary -__orca_claude_binary="$(unalias claude 2>/dev/null || :; command -v claude 2>/dev/null || :)" -if [[ -n "${ORCA_CLAUDE_PROFILE_POINTER:-}" && -n "${__orca_claude_binary:-}" && -x "${__orca_claude_binary}" ]]; then - function claude { - local __orca_claude_home __orca_claude_pointer="${ORCA_CLAUDE_PROFILE_POINTER:-}" - # Why: a WSL pane's pointer is relative to the guest home, which the host cannot know at spawn. - case "$__orca_claude_pointer" in '~/'*) __orca_claude_pointer="${HOME:-}/${__orca_claude_pointer#??}" ;; esac - __orca_claude_home="$(cat "$__orca_claude_pointer" 2>/dev/null || :)" - if [ -n "${CLAUDE_CONFIG_DIR:-}" ] && [ "$CLAUDE_CONFIG_DIR" != "${ORCA_CLAUDE_INJECTED_CONFIG_DIR:-}" ]; then - command claude "$@"; return - fi - # Why: Orca's value replaced the user's own at spawn, so System default restores theirs. - if [ -z "$__orca_claude_home" ] && [ -n "${CLAUDE_CONFIG_DIR:-}" ] && [ -n "${ORCA_CLAUDE_USER_CONFIG_DIR:-}" ]; then - ( unset ORCA_CLAUDE_INJECTED_CONFIG_DIR; export CLAUDE_CONFIG_DIR="$ORCA_CLAUDE_USER_CONFIG_DIR"; command claude "$@" ); return - fi - if [ -z "$__orca_claude_home" ]; then - ( unset CLAUDE_CONFIG_DIR ORCA_CLAUDE_INJECTED_CONFIG_DIR; command claude "$@" ); return - fi - case "$__orca_claude_home" in /*|[A-Za-z]:*) ;; *) return 1 ;; esac - # Why -m 700: matches the folder Orca's setup creates; a credentials folder stays private. - [ -d "$__orca_claude_home" ] || mkdir -p -m 700 -- "$__orca_claude_home" 2>/dev/null - ( unset ANTHROPIC_API_KEY ANTHROPIC_AUTH_TOKEN CLAUDE_CODE_OAUTH_TOKEN AWS_BEARER_TOKEN_BEDROCK; case "${ANTHROPIC_CUSTOM_HEADERS:-}" in *[aA][uU][tT][hH][oO][rR][iI][zZ][aA][tT][iI][oO][nN]*|*[xX]-[aA][pP][iI]-[kK][eE][yY]*|*[aA][pP][iI]-[kK][eE][yY]*|*[bB][eE][aA][rR][eE][rR]*) unset ANTHROPIC_CUSTOM_HEADERS ;; esac; export CLAUDE_CONFIG_DIR="$__orca_claude_home" ORCA_CLAUDE_INJECTED_CONFIG_DIR="$__orca_claude_home"; command claude "$@" ) - } -fi -unset __orca_claude_binary # Why: emit OSC 133 C/D so terminal-command-lifecycle can drop stale agent # status when the foreground command (e.g. an interrupted Claude/Codex CLI) diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/local-fish-init.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-fish-init.txt index 42fb21032ecc..ff24125e2c00 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/local-fish-init.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/local-fish-init.txt @@ -17,34 +17,4 @@ if test "$__orca_codex_type" = file command codex $argv end end -set -e __orca_codex_type -set -l __orca_claude_type (type -t claude 2>/dev/null) -if test -n "$ORCA_CLAUDE_PROFILE_POINTER"; and test "$__orca_claude_type" = file - function claude - # Why: a WSL pane's pointer is relative to the guest home, which the host cannot know at spawn. - set -l pointer (string replace -r '^~/' "$HOME/" -- "$ORCA_CLAUDE_PROFILE_POINTER") - set -l profile (cat "$pointer" 2>/dev/null) - if test -n "$CLAUDE_CONFIG_DIR"; and test "$CLAUDE_CONFIG_DIR" != "$ORCA_CLAUDE_INJECTED_CONFIG_DIR" - command claude $argv - return $status - end - if test -z "$profile"; and test -n "$CLAUDE_CONFIG_DIR"; and test -n "$ORCA_CLAUDE_USER_CONFIG_DIR" - env -u ORCA_CLAUDE_INJECTED_CONFIG_DIR CLAUDE_CONFIG_DIR="$ORCA_CLAUDE_USER_CONFIG_DIR" claude $argv - return $status - end - if test -z "$profile" - env -u CLAUDE_CONFIG_DIR -u ORCA_CLAUDE_INJECTED_CONFIG_DIR claude $argv - return $status - end - if not string match -qr '^(/|[A-Za-z]:)' -- "$profile" - return 1 - end - test -d "$profile"; or mkdir -p -m 700 -- "$profile" 2>/dev/null - set -l headers - if string match -irq 'authorization|x-api-key|api-key|bearer' -- "$ANTHROPIC_CUSTOM_HEADERS" - set headers -u ANTHROPIC_CUSTOM_HEADERS - end - env $headers -u ANTHROPIC_API_KEY -u ANTHROPIC_AUTH_TOKEN -u CLAUDE_CODE_OAUTH_TOKEN -u AWS_BEARER_TOKEN_BEDROCK CLAUDE_CONFIG_DIR="$profile" ORCA_CLAUDE_INJECTED_CONFIG_DIR="$profile" claude $argv - end -end -set -e __orca_claude_type +set -e __orca_codex_type \ No newline at end of file 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 e6e492c48bcf..b3c8a92869ca 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt @@ -227,30 +227,6 @@ __orca_deferred_init() { } fi unset __orca_codex_binary - __orca_claude_binary="$(unalias claude 2>/dev/null || :; command -v claude 2>/dev/null || :)" - if [[ -n "${ORCA_CLAUDE_PROFILE_POINTER:-}" && -n "${__orca_claude_binary:-}" && -x "${__orca_claude_binary}" ]]; then - function claude { - local __orca_claude_home __orca_claude_pointer="${ORCA_CLAUDE_PROFILE_POINTER:-}" - # Why: a WSL pane's pointer is relative to the guest home, which the host cannot know at spawn. - case "$__orca_claude_pointer" in '~/'*) __orca_claude_pointer="${HOME:-}/${__orca_claude_pointer#??}" ;; esac - __orca_claude_home="$(cat "$__orca_claude_pointer" 2>/dev/null || :)" - if [ -n "${CLAUDE_CONFIG_DIR:-}" ] && [ "$CLAUDE_CONFIG_DIR" != "${ORCA_CLAUDE_INJECTED_CONFIG_DIR:-}" ]; then - command claude "$@"; return - fi - # Why: Orca's value replaced the user's own at spawn, so System default restores theirs. - if [ -z "$__orca_claude_home" ] && [ -n "${CLAUDE_CONFIG_DIR:-}" ] && [ -n "${ORCA_CLAUDE_USER_CONFIG_DIR:-}" ]; then - ( unset ORCA_CLAUDE_INJECTED_CONFIG_DIR; export CLAUDE_CONFIG_DIR="$ORCA_CLAUDE_USER_CONFIG_DIR"; command claude "$@" ); return - fi - if [ -z "$__orca_claude_home" ]; then - ( unset CLAUDE_CONFIG_DIR ORCA_CLAUDE_INJECTED_CONFIG_DIR; command claude "$@" ); return - fi - case "$__orca_claude_home" in /*|[A-Za-z]:*) ;; *) return 1 ;; esac - # Why -m 700: matches the folder Orca's setup creates; a credentials folder stays private. - [ -d "$__orca_claude_home" ] || mkdir -p -m 700 -- "$__orca_claude_home" 2>/dev/null - ( unset ANTHROPIC_API_KEY ANTHROPIC_AUTH_TOKEN CLAUDE_CODE_OAUTH_TOKEN AWS_BEARER_TOKEN_BEDROCK; case "${ANTHROPIC_CUSTOM_HEADERS:-}" in *[aA][uU][tT][hH][oO][rR][iI][zZ][aA][tT][iI][oO][nN]*|*[xX]-[aA][pP][iI]-[kK][eE][yY]*|*[aA][pP][iI]-[kK][eE][yY]*|*[bB][eE][aA][rR][eE][rR]*) unset ANTHROPIC_CUSTOM_HEADERS ;; esac; export CLAUDE_CONFIG_DIR="$__orca_claude_home" ORCA_CLAUDE_INJECTED_CONFIG_DIR="$__orca_claude_home"; command claude "$@" ) - } - fi - unset __orca_claude_binary if [[ -n "${_orca_histfile:-}" ]]; then HISTFILE="$_orca_histfile" fi 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 1fcf476a1a0b..21dce8480e63 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt @@ -190,30 +190,6 @@ __orca_deferred_init() { } fi unset __orca_codex_binary - __orca_claude_binary="$(unalias claude 2>/dev/null || :; command -v claude 2>/dev/null || :)" - if [[ -n "${ORCA_CLAUDE_PROFILE_POINTER:-}" && -n "${__orca_claude_binary:-}" && -x "${__orca_claude_binary}" ]]; then - function claude { - local __orca_claude_home __orca_claude_pointer="${ORCA_CLAUDE_PROFILE_POINTER:-}" - # Why: a WSL pane's pointer is relative to the guest home, which the host cannot know at spawn. - case "$__orca_claude_pointer" in '~/'*) __orca_claude_pointer="${HOME:-}/${__orca_claude_pointer#??}" ;; esac - __orca_claude_home="$(cat "$__orca_claude_pointer" 2>/dev/null || :)" - if [ -n "${CLAUDE_CONFIG_DIR:-}" ] && [ "$CLAUDE_CONFIG_DIR" != "${ORCA_CLAUDE_INJECTED_CONFIG_DIR:-}" ]; then - command claude "$@"; return - fi - # Why: Orca's value replaced the user's own at spawn, so System default restores theirs. - if [ -z "$__orca_claude_home" ] && [ -n "${CLAUDE_CONFIG_DIR:-}" ] && [ -n "${ORCA_CLAUDE_USER_CONFIG_DIR:-}" ]; then - ( unset ORCA_CLAUDE_INJECTED_CONFIG_DIR; export CLAUDE_CONFIG_DIR="$ORCA_CLAUDE_USER_CONFIG_DIR"; command claude "$@" ); return - fi - if [ -z "$__orca_claude_home" ]; then - ( unset CLAUDE_CONFIG_DIR ORCA_CLAUDE_INJECTED_CONFIG_DIR; command claude "$@" ); return - fi - case "$__orca_claude_home" in /*|[A-Za-z]:*) ;; *) return 1 ;; esac - # Why -m 700: matches the folder Orca's setup creates; a credentials folder stays private. - [ -d "$__orca_claude_home" ] || mkdir -p -m 700 -- "$__orca_claude_home" 2>/dev/null - ( unset ANTHROPIC_API_KEY ANTHROPIC_AUTH_TOKEN CLAUDE_CODE_OAUTH_TOKEN AWS_BEARER_TOKEN_BEDROCK; case "${ANTHROPIC_CUSTOM_HEADERS:-}" in *[aA][uU][tT][hH][oO][rR][iI][zZ][aA][tT][iI][oO][nN]*|*[xX]-[aA][pP][iI]-[kK][eE][yY]*|*[aA][pP][iI]-[kK][eE][yY]*|*[bB][eE][aA][rR][eE][rR]*) unset ANTHROPIC_CUSTOM_HEADERS ;; esac; export CLAUDE_CONFIG_DIR="$__orca_claude_home" ORCA_CLAUDE_INJECTED_CONFIG_DIR="$__orca_claude_home"; command claude "$@" ) - } - fi - unset __orca_claude_binary if [[ -n "${_orca_histfile:-}" ]]; then HISTFILE="$_orca_histfile" fi diff --git a/src/main/agent-auth-restart-preservation.test.ts b/src/main/agent-auth-restart-preservation.test.ts index b5056e836066..0b0ff8ca6225 100644 --- a/src/main/agent-auth-restart-preservation.test.ts +++ b/src/main/agent-auth-restart-preservation.test.ts @@ -8,7 +8,7 @@ describe('preserveAgentAuthBeforeRestart', () => { vi.restoreAllMocks() }) - it('syncs Codex and flushes the store', async () => { + it('syncs Codex then Claude before flushing the store', async () => { const calls: string[] = [] await preserveAgentAuthBeforeRestart({ @@ -17,6 +17,11 @@ describe('preserveAgentAuthBeforeRestart', () => { calls.push('codex') }) }, + claudeRuntimeAuth: { + syncForCurrentSelection: vi.fn(async () => { + calls.push('claude') + }) + }, store: { flushPendingOrThrowAsync: vi.fn(async () => { calls.push('flush') @@ -24,7 +29,7 @@ describe('preserveAgentAuthBeforeRestart', () => { } }) - expect(calls).toEqual(['codex', 'flush']) + expect(calls).toEqual(['codex', 'claude', 'flush']) }) it('drains retained WSL Codex auth before flushing the store', async () => { @@ -101,6 +106,27 @@ describe('preserveAgentAuthBeforeRestart', () => { expect(JSON.stringify(warn.mock.calls)).not.toContain('token-secret') }) + it('does not start Claude after host Codex exhausts the lifecycle budget', async () => { + vi.useFakeTimers() + const startedAt = Date.now() + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const syncClaude = vi.fn(async () => {}) + + await preserveAgentAuthBeforeRestart({ + codexRuntimeHome: { + syncForCurrentSelection: vi.fn(() => { + vi.setSystemTime(startedAt + 2_000) + }) + }, + claudeRuntimeAuth: { syncForCurrentSelection: syncClaude } + }) + + expect(syncClaude).not.toHaveBeenCalled() + expect(warn).toHaveBeenCalledWith( + '[agent-auth-restart] Claude auth preservation exceeded 0ms; continuing restart/update' + ) + }) + it('checkpoints admitted state without waiting for ongoing edits when auth services are missing', async () => { const flushPendingOrThrowAsync = vi.fn() @@ -122,15 +148,83 @@ describe('preserveAgentAuthBeforeRestart', () => { throw new Error('codex-token-secret') }) }, + claudeRuntimeAuth: { + syncForCurrentSelection: vi.fn(async () => { + throw new Error('claude-token-secret') + }) + }, store: { flushPendingOrThrowAsync } }) ).resolves.toBeUndefined() expect(flushPendingOrThrowAsync).toHaveBeenCalledTimes(1) - expect(warn).toHaveBeenCalledTimes(1) + expect(warn).toHaveBeenCalledTimes(2) expect(JSON.stringify(warn.mock.calls)).not.toContain('token-secret') }) + it('releases the lifecycle path on timeout without canceling in-flight sync', async () => { + vi.useFakeTimers() + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const calls: string[] = [] + let finishClaude!: () => void + + const preservation = preserveAgentAuthBeforeRestart({ + claudeRuntimeAuth: { + syncForCurrentSelection: vi.fn(async () => { + calls.push('claude-start') + await new Promise((resolve) => { + finishClaude = resolve + }) + calls.push('claude-finish') + }) + }, + store: { + flushPendingOrThrowAsync: vi.fn(async () => { + calls.push('flush') + }) + } + }) + + await vi.advanceTimersByTimeAsync(2_000) + await preservation + + expect(calls).toEqual(['claude-start', 'flush']) + expect(warn).toHaveBeenCalledWith( + '[agent-auth-restart] Claude auth preservation exceeded 2000ms; continuing restart/update' + ) + + finishClaude() + await Promise.resolve() + + expect(calls).toEqual(['claude-start', 'flush', 'claude-finish']) + }) + + it('shares the original lifecycle timeout between Claude and store preservation', async () => { + vi.useFakeTimers() + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + let settled = false + + const preservation = preserveAgentAuthBeforeRestart({ + claudeRuntimeAuth: { + syncForCurrentSelection: vi.fn(() => new Promise(() => {})) + }, + store: { + flushPendingOrThrowAsync: vi.fn(() => new Promise(() => {})) + } + }).then(() => { + settled = true + }) + + await vi.advanceTimersByTimeAsync(2_000) + await vi.runOnlyPendingTimersAsync() + await preservation + + expect(settled).toBe(true) + expect(warn).toHaveBeenCalledWith( + '[agent-auth-restart] Store persistence exceeded 0ms; continuing restart/update' + ) + }) + it('bounds a store flush that never settles', async () => { vi.useFakeTimers() const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) diff --git a/src/main/agent-auth-restart-preservation.ts b/src/main/agent-auth-restart-preservation.ts index a41be9a01833..d00641088cc2 100644 --- a/src/main/agent-auth-restart-preservation.ts +++ b/src/main/agent-auth-restart-preservation.ts @@ -1,3 +1,4 @@ +import type { ClaudeRuntimeAuthService } from './claude-accounts/runtime-auth-service' import type { CodexRuntimeHomeService } from './codex-accounts/runtime-home-service' import type { Store } from './persistence' @@ -5,17 +6,23 @@ const AUTH_PRESERVATION_TIMEOUT_MS = 2_000 type CodexRuntimeAuthSync = Pick & Partial> +type ClaudeRuntimeAuthSync = Pick type ShutdownStore = Pick -type AuthPreservationStep = 'Codex auth preservation' | 'Store persistence' +type AuthPreservationStep = + | 'Codex auth preservation' + | 'Claude auth preservation' + | 'Store persistence' export type AgentAuthRestartPreservationOptions = { codexRuntimeHome?: CodexRuntimeAuthSync | null + claudeRuntimeAuth?: ClaudeRuntimeAuthSync | null store?: ShutdownStore | null } export async function preserveAgentAuthBeforeRestart({ codexRuntimeHome, + claudeRuntimeAuth, store }: AgentAuthRestartPreservationOptions): Promise { const startedAt = Date.now() @@ -23,6 +30,17 @@ export async function preserveAgentAuthBeforeRestart({ // Why: the drain owns guest-process timeouts; a shared 2s cutoff can relaunch before promotion. const wslCodexPreservation = runWslCodexPreservationStep(codexRuntimeHome) + const claudeRemainingMs = remainingLifecycleTime(startedAt) + if (claudeRuntimeAuth && claudeRemainingMs > 0) { + await runWithinLifecycleTimeout( + 'Claude auth preservation', + () => claudeRuntimeAuth.syncForCurrentSelection(), + claudeRemainingMs + ) + } else if (claudeRuntimeAuth) { + logStepTimeout('Claude auth preservation', 0) + } + const storePreservation = store ? runWithinLifecycleTimeout( 'Store persistence', diff --git a/src/main/claude-accounts/claude-account-folder.test.ts b/src/main/claude-accounts/claude-account-folder.test.ts deleted file mode 100644 index 5374afed213a..000000000000 --- a/src/main/claude-accounts/claude-account-folder.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' -import type * as Os from 'node:os' - -const keychain = vi.hoisted(() => ({ items: new Map(), userHome: '' })) -vi.mock('node:os', async (original) => ({ - ...(await original()), - homedir: () => keychain.userHome -})) -vi.mock('../macos-keychain/generic-password', () => ({ - execSecurityCommand: async (args: string[]) => { - const service = args[2] - const count = keychain.items.get(service) ?? 0 - if (count === 0) { - throw new Error('The specified item could not be found in the keychain.') - } - keychain.items.set(service, count - 1) - return { stdout: '', stderr: '' } - } -})) - -import { readClaudeFolderLogin, removeClaudeAccountFolder } from './claude-account-folder' -import { claudeKeychainService } from './keychain' - -const roots: string[] = [] -afterEach(() => { - keychain.items.clear() - roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true })) -}) - -function fixture() { - const root = mkdtempSync(join(tmpdir(), 'claude-account-folder-')) - roots.push(root) - keychain.userHome = root - const dataRoot = join(root, 'data') - const home = join(dataRoot, 'claude-profiles', 'a', 'home') - mkdirSync(home, { recursive: true }) - return { root, dataRoot, home } -} - -describe('claude account folder', () => { - it("reads the folder's login and treats a signed-out state file as no login", () => { - const { home } = fixture() - const stateFile = join(home, '.claude.json') - expect(readClaudeFolderLogin(stateFile)).toBeNull() - writeFileSync(stateFile, JSON.stringify({ oauthAccount: { emailAddress: ' a@example.test ' } })) - expect(readClaudeFolderLogin(stateFile)).toEqual({ - email: 'a@example.test', - organizationUuid: null, - organizationName: null - }) - writeFileSync(stateFile, JSON.stringify({ numStartups: 2 })) - expect(readClaudeFolderLogin(stateFile)).toBeNull() - }) - - it('reuses a recent answer within maxAgeMs even though the file changed', () => { - const { home } = fixture() - const stateFile = join(home, '.claude.json') - writeFileSync(stateFile, JSON.stringify({ oauthAccount: { emailAddress: 'a@example.test' } })) - expect(readClaudeFolderLogin(stateFile, 5_000)?.email).toBe('a@example.test') - writeFileSync(stateFile, JSON.stringify({ oauthAccount: { emailAddress: 'b@example.test!' } })) - expect(readClaudeFolderLogin(stateFile, 5_000)?.email).toBe('a@example.test') - expect(readClaudeFolderLogin(stateFile)?.email).toBe('b@example.test!') - }) - - // A real symlink needs Developer Mode on Windows (EPERM otherwise). - it.skipIf(process.platform === 'win32')( - 'unlinks shared history before deleting, and leaves the older layout alone', - async () => { - const { root, dataRoot, home } = fixture() - const history = join(root, '.claude', 'projects') - mkdirSync(history, { recursive: true }) - writeFileSync(join(history, 'chat.jsonl'), '{}') - symlinkSync(history, join(home, 'projects')) - const older = join(dataRoot, 'claude-accounts', 'a') - mkdirSync(older, { recursive: true }) - - await removeClaudeAccountFolder(dataRoot, 'a') - expect(existsSync(join(dataRoot, 'claude-profiles', 'a'))).toBe(false) - expect(existsSync(join(history, 'chat.jsonl'))).toBe(true) - expect(existsSync(older)).toBe(true) - } - ) - - it.skipIf(process.platform !== 'darwin')( - 'deletes every Keychain item Claude may have keyed to the folder', - async () => { - const { dataRoot, home } = fixture() - const rest = home.slice(keychain.userHome.length) - const spellings = [home, `${home}/`, `~${rest}`, `$HOME${rest}/`] - for (const spelling of spellings) { - keychain.items.set(claudeKeychainService(spelling), 2) - } - const unrelated = claudeKeychainService(join(keychain.userHome, 'other')) - keychain.items.set(unrelated, 1) - - await removeClaudeAccountFolder(dataRoot, 'a') - for (const spelling of spellings) { - expect(keychain.items.get(claudeKeychainService(spelling))).toBe(0) - } - expect(keychain.items.get(unrelated)).toBe(1) - } - ) -}) diff --git a/src/main/claude-accounts/claude-account-folder.ts b/src/main/claude-accounts/claude-account-folder.ts deleted file mode 100644 index 7e4bfe8e3efd..000000000000 --- a/src/main/claude-accounts/claude-account-folder.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { - existsSync, - lstatSync, - readdirSync, - rmdirSync, - rmSync, - statSync, - unlinkSync -} from 'node:fs' -import { homedir } from 'node:os' -import { dirname, join } from 'node:path' -import { z } from 'zod' -import { resolveClaudeGlobalConfigFile } from '../claude/claude-folder-trust-file' -import { execSecurityCommand } from '../macos-keychain/generic-password' -import { describeClaudeProfile, readClaudeProfileObject } from './claude-profile-paths' -import { claudeConfigDirSpellings, claudeKeychainService } from './keychain' - -export type ClaudeFolderLogin = { - email: string - organizationUuid: string | null - organizationName: string | null -} - -const text = z - .string() - .transform((value) => value.trim() || null) - .nullish() - .catch(null) -const oauthAccount = z.object({ - emailAddress: text, - organizationUuid: text, - organizationName: text -}) - -/** Claude's state file for a config folder; undefined is `~/.claude`, whose file sits in home. */ -export function claudeStateFile(configDir: string | undefined, userHome = homedir()): string { - return resolveClaudeGlobalConfigFile({ - env: { CLAUDE_CONFIG_DIR: configDir }, - homeDir: userHome, - style: process.platform === 'win32' ? 'win32' : 'posix', - exists: existsSync - }) -} - -// Why memoized: a state file grows with history and the account list is read on every refresh. -const logins = new Map< - string, - { mtimeMs: number; size: number; readAt: number; login: ClaudeFolderLogin | null } ->() - -/** The login parsed Claude state names; null before Claude finishes a sign-in. */ -export function claudeStateLogin(state: Record): ClaudeFolderLogin | null { - const parsed = oauthAccount.safeParse(state.oauthAccount) - const email = parsed.success ? parsed.data.emailAddress : null - return parsed.success && email - ? { - email, - organizationUuid: parsed.data.organizationUuid ?? null, - organizationName: parsed.data.organizationName ?? null - } - : null -} - -/** - * The login a Claude state file names; null when signed out (superset U/profiles.ts:121-137). - * `maxAgeMs` reuses a recent answer without a stat, for a file Claude rewrites constantly. - */ -export function readClaudeFolderLogin(stateFile: string, maxAgeMs = 0): ClaudeFolderLogin | null { - const recent = logins.get(stateFile) - if (recent && Date.now() - recent.readAt < maxAgeMs) { - return recent.login - } - let stat: { mtimeMs: number; size: number } - try { - stat = statSync(stateFile) - } catch { - logins.delete(stateFile) - return null - } - if (recent?.mtimeMs === stat.mtimeMs && recent.size === stat.size) { - recent.readAt = Date.now() - return recent.login - } - const read = readClaudeProfileObject(stateFile) - const login = read.kind === 'present' ? claudeStateLogin(read.value) : null - logins.set(stateFile, { mtimeMs: stat.mtimeMs, size: stat.size, readAt: Date.now(), login }) - return login -} - -/** - * Deletes a host account's folder and, on macOS, the Keychain items Claude keyed to it - * (superset profile-remove.ts). Only `claude-profiles/` is reachable from an id, so an - * older Orca's `claude-accounts/` folders are never touched. - */ -export async function removeClaudeAccountFolder( - dataRoot: string, - accountId: string -): Promise { - const { home } = describeClaudeProfile(dataRoot, accountId, { - executionHostId: 'local', - runtime: 'host' - }) - if (process.platform === 'darwin') { - for (const spelling of claudeConfigDirSpellings(home, homedir())) { - // Why a loop: items are filed per Keychain account name, and each delete removes one. - for (let attempt = 0; attempt < 4; attempt += 1) { - const deleted = await execSecurityCommand([ - 'delete-generic-password', - '-s', - claudeKeychainService(spelling) - ]).then( - () => true, - () => false - ) - if (!deleted) { - break - } - } - } - } - const folder = dirname(home) - unlinkLinks(folder) - rmSync(folder, { recursive: true, force: true }) -} - -// Why first: history is linked into ~/.claude, and a recursive delete that followed a Windows -// junction would delete the user's own history. -function unlinkLinks(dir: string): void { - let names: string[] - try { - names = readdirSync(dir) - } catch { - return - } - for (const name of names) { - const path = join(dir, name) - // Why no throw: an entry may vanish between the listing and the stat. - const stat = lstatSync(path, { throwIfNoEntry: false }) - if (!stat) { - continue - } - if (stat.isSymbolicLink()) { - try { - unlinkSync(path) - } catch { - // A Windows directory link is removed as a directory. - rmdirSync(path) - } - } else if (stat.isDirectory()) { - unlinkLinks(path) - } - } -} diff --git a/src/main/claude-accounts/claude-account-registration.ts b/src/main/claude-accounts/claude-account-registration.ts index 13ec68d7dee8..bebc26be6804 100644 --- a/src/main/claude-accounts/claude-account-registration.ts +++ b/src/main/claude-accounts/claude-account-registration.ts @@ -1,152 +1,250 @@ import { randomUUID } from 'node:crypto' import type { - ClaudeAccountSignIn, - ClaudeRateLimitAccountsState, - ClaudeSignInRequest + ClaudeManagedAccount, + ClaudeRateLimitAccountsState } from '../../shared/managed-account-types' -import { claudeStateFile, readClaudeFolderLogin } from './claude-account-folder' -import type { - ClaudeAccountRuntime, - ClaudeAccountSelection, - ClaudeAccountStore, - ClaudeAccountUsage -} from './claude-account-selection' -import type { ClaudeCommandConfig } from './claude-command-process' +import type { Store } from '../persistence' +import type { RateLimitService } from '../rate-limits/service' import { findDuplicateClaudeAccount } from './claude-duplicate-account' +import type { CapturedClaudeAuth } from './claude-auth-capture' +import type { + ClaudeManagedAuthLocation, + ClaudeManagedAuthSnapshot, + ClaudeManagedAuthTarget +} from './claude-managed-auth-storage' +import type { ClaudeRuntimeAuthService } from './runtime-auth-service' import { getClaudeSelectionTargetForAccount, - normalizeClaudeAccountSelectionTarget, - type ClaudeAccountSelectionTarget + normalizeClaudeRuntimeSelection } from './runtime-selection' +import type { ClaudeAccountSelection } from './claude-account-selection' -export const CLAUDE_SIGN_IN_NOT_FINISHED_MESSAGE = - 'Claude has not finished signing in to this account yet. Finish the sign-in in the terminal, then try again.' -const CLAUDE_LOGIN_WITHOUT_ACCOUNT_MESSAGE = - 'Claude login completed, but Orca could not resolve the account email.' +type ClaudeAccountRegistrationDependencies = { + store: Store + rateLimits: RateLimitService + runtimeAuth: ClaudeRuntimeAuthService + selection: ClaudeAccountSelection + createManagedAuth: ( + accountId: string, + target?: ClaudeManagedAuthTarget + ) => Promise + assertManagedAuth: (path: string, accountId: string) => Promise + removeManagedAuth: (accountId: string, path: string) => Promise + writeManagedAuth: (accountId: string, path: string, captured: CapturedClaudeAuth) => Promise + writeCredentials: (accountId: string, path: string, value: string) => Promise + writeOauth: (accountId: string, path: string, value: unknown) => Promise + readSnapshot: (accountId: string, path: string) => Promise + restoreCredentials: ( + accountId: string, + path: string, + snapshot: ClaudeManagedAuthSnapshot + ) => Promise + restoreOauth: ( + accountId: string, + path: string, + snapshot: ClaudeManagedAuthSnapshot + ) => Promise + login: (location: ClaudeManagedAuthLocation) => Promise + captureExisting: ( + configDir: string, + previousLegacyCredentialsSha256?: string | null + ) => Promise +} + +class DuplicateClaudeAccountError extends Error {} -/** - * Sign-in runs `claude auth login` against the account's own folder: hidden from Settings, in - * the user's terminal from the CLI. A folder becomes an account only once it holds a login - * (superset U/profiles.ts:132,178), so an abandoned sign-in never shows as a row. - */ export class ClaudeAccountRegistration { - constructor( - private readonly deps: { - store: ClaudeAccountStore - rateLimits: ClaudeAccountUsage - runtimeAuth: ClaudeAccountRuntime - selection: ClaudeAccountSelection - } - ) {} + constructor(private readonly dependencies: ClaudeAccountRegistrationDependencies) {} - /** A new folder, or a saved account's own folder when `accountId` is given. */ - async begin(request: ClaudeSignInRequest): Promise { - const { accountId, target, folder } = await this.prepare(request) - return { accountId, configDir: folder.configDir, ...target } + async add(target?: ClaudeManagedAuthTarget): Promise { + const accountId = randomUUID() + const location = await this.dependencies.createManagedAuth(accountId, target) + const previousSettings = this.dependencies.store.getSettings() + try { + const captured = await this.dependencies.login(location) + return await this.persist(accountId, location, previousSettings, captured) + } catch (error) { + await this.cleanupFailedAdd(accountId, location.managedAuthPath, previousSettings, error) + throw error + } } - /** Runs `login` into the account's folder, then saves it; a failed new folder is deleted. */ - async signIn( - request: ClaudeSignInRequest, - login: (folder: ClaudeCommandConfig) => Promise + async addFromConfigDir( + configDir: string, + options?: ClaudeManagedAuthTarget & { previousLegacyCredentialsSha256?: string | null } ): Promise { - const { accountId, target, folder } = await this.prepare(request) - const signIn = { accountId, ...target } + const accountId = randomUUID() + const location = await this.dependencies.createManagedAuth(accountId, options) + const previousSettings = this.dependencies.store.getSettings() try { - await login({ - windowsPath: folder.readPath, - linuxPath: target.runtime === 'wsl' ? folder.configDir : null, - wslDistro: target.runtime === 'wsl' ? (target.wslDistro ?? null) : null - }) - return await this.finish(signIn, CLAUDE_LOGIN_WITHOUT_ACCOUNT_MESSAGE) - } catch (error) { - // Why caught: the sign-in's own failure is what the user needs to see. - await this.cancel(signIn).catch((cleanupError: unknown) => - console.warn( - '[claude-accounts] Could not delete an unfinished sign-in folder:', - cleanupError - ) + const captured = await this.dependencies.captureExisting( + configDir, + options?.previousLegacyCredentialsSha256 ) + return await this.persist(accountId, location, previousSettings, captured) + } catch (error) { + await this.cleanupFailedAdd(accountId, location.managedAuthPath, previousSettings, error) throw error } } - private async prepare(request: ClaudeSignInRequest) { - const saved = request.accountId ? this.deps.selection.requireAccount(request.accountId) : null - const target = saved ? getClaudeSelectionTargetForAccount(saved) : signInTarget(request) - const accountId = saved?.id ?? randomUUID() - const folder = await this.deps.runtimeAuth.prepareAccountFolder(accountId, target) - return { accountId, target, folder } - } + async reauthenticate(accountId: string): Promise { + const account = this.dependencies.selection.requireAccount(accountId) + const managedAuthPath = await this.dependencies.assertManagedAuth( + account.managedAuthPath, + accountId + ) + const previousSettings = this.dependencies.store.getSettings() + const previousAuth = await this.dependencies.readSnapshot(accountId, managedAuthPath) + const captured = await this.dependencies.login({ + managedAuthPath, + managedAuthRuntime: account.managedAuthRuntime ?? 'host', + wslDistro: account.wslDistro ?? null, + wslLinuxAuthPath: account.wslLinuxAuthPath ?? null + }) + if (!captured.identity.email) { + throw new Error('Claude login completed, but Orca could not resolve the account email.') + } - async cancel(signIn: Omit): Promise { - if (!this.deps.selection.findAccount(signIn.accountId)) { - await this.deps.runtimeAuth.removeAccountFolder(signIn.accountId, signInTarget(signIn)) + const settings = this.dependencies.store.getSettings() + const now = Date.now() + const nextAccounts = settings.claudeManagedAccounts.map((entry) => + entry.id === accountId + ? { + ...entry, + email: captured.identity.email!, + organizationUuid: captured.identity.organizationUuid, + organizationName: captured.identity.organizationName, + updatedAt: now, + lastAuthenticatedAt: now + } + : entry + ) + let wroteCredentials = false + try { + await this.dependencies.writeOauth(accountId, managedAuthPath, captured.oauthAccount) + await this.dependencies.writeCredentials(accountId, managedAuthPath, captured.credentialsJson) + wroteCredentials = true + this.dependencies.store.updateSettings({ claudeManagedAccounts: nextAccounts }) + this.dependencies.runtimeAuth.clearLastWrittenCredentialsJson(accountId) + this.dependencies.rateLimits.evictInactiveClaudeCache(accountId) + const target = getClaudeSelectionTargetForAccount(account) + await this.dependencies.selection.syncRuntimeAuth(target) + await this.dependencies.rateLimits.refreshForClaudeAccountChange(undefined, target) + return this.dependencies.selection.snapshot() + } catch (error) { + await this.rollbackReauthentication( + accountId, + managedAuthPath, + previousAuth, + previousSettings, + nextAccounts, + wroteCredentials + ) + throw error } } - async finish( - signIn: Omit, - notSignedInMessage = CLAUDE_SIGN_IN_NOT_FINISHED_MESSAGE + private async persist( + accountId: string, + location: ClaudeManagedAuthLocation, + previousSettings: ReturnType, + captured: CapturedClaudeAuth ): Promise { - const { store, rateLimits, runtimeAuth, selection } = this.deps - const saved = selection.findAccount(signIn.accountId) - const target = saved ? getClaudeSelectionTargetForAccount(saved) : signInTarget(signIn) - // Why set up again: onboarding is merged only into a state file the login just created. - const folder = await runtimeAuth.prepareAccountFolder(signIn.accountId, target) - const login = readClaudeFolderLogin(claudeStateFile(folder.readPath)) - if (!login) { - throw new Error(notSignedInMessage) + if (!captured.identity.email) { + throw new Error('Claude login completed, but Orca could not resolve the account email.') } - const accounts = store.getSettings().claudeManagedAccounts - const scope = { - managedAuthRuntime: target.runtime ?? 'host', - wslDistro: target.runtime === 'wsl' ? (target.wslDistro ?? null) : null - } as const if ( - !saved && - findDuplicateClaudeAccount(accounts, { - email: login.email, - organizationUuid: login.organizationUuid, - ...scope + findDuplicateClaudeAccount(previousSettings.claudeManagedAccounts, { + email: captured.identity.email, + organizationUuid: captured.identity.organizationUuid, + managedAuthRuntime: location.managedAuthRuntime, + wslDistro: location.wslDistro }) ) { - await runtimeAuth.removeAccountFolder(signIn.accountId, target) - throw new Error('This Claude account is already added.') + throw new DuplicateClaudeAccountError('This Claude account is already added.') } + await this.dependencies.writeManagedAuth(accountId, location.managedAuthPath, captured) const now = Date.now() - selection.saveSettings({ - claudeManagedAccounts: [ - ...accounts.filter((entry) => entry.id !== signIn.accountId), - { - id: signIn.accountId, - email: login.email, - organizationUuid: login.organizationUuid, - organizationName: login.organizationName, - managedAuthPath: folder.readPath, - ...scope, - wslLinuxAuthPath: target.runtime === 'wsl' ? folder.configDir : null, - authMethod: 'subscription-oauth', - createdAt: saved?.createdAt ?? now, - updatedAt: now, - lastAuthenticatedAt: now - } - ] + const account: ClaudeManagedAccount = { + id: accountId, + email: captured.identity.email, + managedAuthPath: location.managedAuthPath, + managedAuthRuntime: location.managedAuthRuntime, + wslDistro: location.wslDistro, + wslLinuxAuthPath: location.wslLinuxAuthPath, + authMethod: 'subscription-oauth', + organizationUuid: captured.identity.organizationUuid, + organizationName: captured.identity.organizationName, + createdAt: now, + updatedAt: now, + lastAuthenticatedAt: now + } + const selection = normalizeClaudeRuntimeSelection(previousSettings) + this.dependencies.store.updateSettings({ + claudeManagedAccounts: [...previousSettings.claudeManagedAccounts, account], + activeClaudeManagedAccountId: selection.host, + activeClaudeManagedAccountIdsByRuntime: selection }) - rateLimits.evictInactiveClaudeCache(signIn.accountId) - // Why publish: a selected account's terminals follow its folder only once it exists. - await runtimeAuth.syncForCurrentSelection(target) - selection.refreshUsage(undefined, target) - return selection.list() + this.dependencies.runtimeAuth.clearLastWrittenCredentialsJson(accountId) + this.dependencies.rateLimits.evictInactiveClaudeCache(accountId) + return this.dependencies.selection.snapshot() + } + + private async cleanupFailedAdd( + accountId: string, + managedAuthPath: string, + previousSettings: ReturnType, + error: unknown + ): Promise { + if (error instanceof DuplicateClaudeAccountError) { + await this.dependencies.removeManagedAuth(accountId, managedAuthPath) + return + } + this.dependencies.selection.restoreSettings(previousSettings) + try { + await this.dependencies.runtimeAuth.forceMaterializeCurrentSelectionForRollback() + } catch (rollbackError) { + console.warn('[claude-accounts] Rollback rematerialization failed:', rollbackError) + } + await this.dependencies.removeManagedAuth(accountId, managedAuthPath) } -} -function signInTarget(request: ClaudeAccountSelectionTarget): ClaudeAccountSelectionTarget { - const target = normalizeClaudeAccountSelectionTarget(request) - if (target.runtime === 'wsl' && !target.wslDistro) { - throw new Error('Choose a WSL distro before signing in.') + private async rollbackReauthentication( + accountId: string, + path: string, + snapshot: ClaudeManagedAuthSnapshot, + previousSettings: ReturnType, + nextAccounts: ClaudeManagedAccount[], + wroteCredentials: boolean + ): Promise { + let restoredCredentials = false + try { + await this.dependencies.restoreCredentials(accountId, path, snapshot) + restoredCredentials = true + } catch (rollbackError) { + console.warn( + '[claude-accounts] Failed to restore managed credentials during rollback:', + rollbackError + ) + } + if (restoredCredentials || !wroteCredentials) { + try { + await this.dependencies.restoreOauth(accountId, path, snapshot) + } catch (rollbackError) { + console.warn( + '[claude-accounts] Failed to restore managed oauth metadata during rollback:', + rollbackError + ) + } + } + if (restoredCredentials) { + this.dependencies.selection.restoreSettings(previousSettings) + await this.dependencies.runtimeAuth.forceMaterializeCurrentSelectionForRollback() + } else if (wroteCredentials) { + this.dependencies.store.updateSettings({ claudeManagedAccounts: nextAccounts }) + } else { + this.dependencies.selection.restoreSettings(previousSettings) + } } - return target.runtime === 'wsl' - ? { runtime: 'wsl', wslDistro: target.wslDistro } - : { runtime: 'host' } } diff --git a/src/main/claude-accounts/claude-account-selection.ts b/src/main/claude-accounts/claude-account-selection.ts index c0b089e902cf..d81f2346eb70 100644 --- a/src/main/claude-accounts/claude-account-selection.ts +++ b/src/main/claude-accounts/claude-account-selection.ts @@ -3,10 +3,9 @@ import type { ClaudeManagedAccountSummary, ClaudeRateLimitAccountsState } from '../../shared/managed-account-types' -import type { GlobalSettings } from '../../shared/global-settings-types' +import type { Store } from '../persistence' import type { RateLimitService } from '../rate-limits/service' -import { claudeStateFile, readClaudeFolderLogin } from './claude-account-folder' -import { savedWslClaudeAccountHome } from './claude-profile-wsl-paths' +import { beginClaudeAuthSwitch, endClaudeAuthSwitch } from './live-pty-gate' import type { ClaudeRuntimeAuthService } from './runtime-auth-service' import { getClaudeSelectionTargetForAccount, @@ -19,137 +18,59 @@ import { type ClaudeAccountSelectionTarget } from './runtime-selection' -export const CLAUDE_ACCOUNT_NEEDS_SIGN_IN_MESSAGE = - 'Sign in to this Claude account again before selecting it.' - -export const CLAUDE_ACCOUNT_FOLDER_IN_USE_MESSAGE = - "Orca couldn't delete this Claude account's folder. Quit any Claude running in this account, then remove it again." - -export type ClaudeAccountSettings = Pick< - GlobalSettings, - | 'claudeManagedAccounts' - | 'activeClaudeManagedAccountId' - | 'activeClaudeManagedAccountIdsByRuntime' -> - -export type ClaudeAccountStore = { - getSettings: () => ClaudeAccountSettings - updateSettings: ( - patch: Partial, - options?: { notifyListeners?: boolean } - ) => unknown -} - -export type ClaudeAccountRuntime = Pick< - ClaudeRuntimeAuthService, - 'syncForCurrentSelection' | 'publishAll' | 'removeAccountFolder' | 'prepareAccountFolder' -> & { - router: Pick< - ClaudeRuntimeAuthService['router'], - 'accountHome' | 'userConfigDir' | 'copiedLoginIntoSystemDefault' - > -} - -export type ClaudeAccountUsage = Pick< - RateLimitService, - 'evictInactiveClaudeCache' | 'refreshForClaudeAccountChange' -> - export class ClaudeAccountSelection { constructor( - private readonly store: ClaudeAccountStore, - private readonly rateLimits: ClaudeAccountUsage, - private readonly runtimeAuth: ClaudeAccountRuntime + private readonly store: Store, + private readonly rateLimits: RateLimitService, + private readonly runtimeAuth: ClaudeRuntimeAuthService, + private readonly removeManagedAuth: (accountId: string, path: string) => Promise ) {} - /** Each row is labelled with the login its folder holds (superset U/profiles.ts:121-137). */ list(): ClaudeRateLimitAccountsState { - this.pruneSelection() - const settings = this.store.getSettings() - const selection = normalizeClaudeRuntimeSelection(settings) - const accounts = settings.claudeManagedAccounts - .map((account) => this.summarize(account)) - .sort((a, b) => b.updatedAt - a.updatedAt) - const userConfigDir = this.runtimeAuth.router.userConfigDir() - // Why only with accounts, and at most every 5 s: the personal state file is large and every - // running Claude rewrites it, so a list would otherwise re-parse it on main each time. - const systemDefault = - accounts.length > 0 ? readClaudeFolderLogin(claudeStateFile(userConfigDir), 5_000) : null - return { - accounts, - activeAccountId: selection.host, - activeAccountIdsByRuntime: selection, - ...(systemDefault ? { systemDefaultEmail: systemDefault.email } : {}), - ...(systemDefault && this.runtimeAuth.router.copiedLoginIntoSystemDefault() - ? { systemDefaultMayBeCopied: true } - : {}) - } - } - - private summarize(account: ClaudeManagedAccount): ClaudeManagedAccountSummary { - const summary: ClaudeManagedAccountSummary = { - id: account.id, - email: account.email, - managedAuthRuntime: account.managedAuthRuntime ?? 'host', - wslDistro: account.wslDistro ?? null, - authMethod: account.authMethod ?? 'unknown', - organizationUuid: account.organizationUuid ?? null, - organizationName: account.organizationName ?? null, - createdAt: account.createdAt, - updatedAt: account.updatedAt, - lastAuthenticatedAt: account.lastAuthenticatedAt - } - if (account.managedAuthRuntime === 'wsl') { - // Why no read: listing must not start a stopped distro. Only an older Orca's folder layout - // is known to hold no login; a launch refuses a missing folder with its own reason. - return savedWslClaudeAccountHome(account) ? summary : { ...summary, needsSignIn: true } - } - const login = readClaudeFolderLogin( - claudeStateFile(this.runtimeAuth.router.accountHome(account.id)) - ) - return login - ? { - ...summary, - email: login.email, - organizationUuid: login.organizationUuid, - organizationName: login.organizationName - } - : { ...summary, needsSignIn: true } + this.normalizeActiveSelection() + return this.snapshot() } async remove(accountId: string): Promise { const account = this.requireAccount(accountId) const settings = this.store.getSettings() - const target = getClaudeSelectionTargetForAccount(account) - const wasSelected = getSelectedClaudeAccountIdForTarget(settings, target) === accountId + const nextAccounts = settings.claudeManagedAccounts.filter((entry) => entry.id !== accountId) const nextSelection = removeClaudeAccountIdFromSelection( normalizeClaudeRuntimeSelection(settings), accountId ) - this.saveSettings({ - activeClaudeManagedAccountId: - settings.activeClaudeManagedAccountId === accountId ? null : nextSelection.host, - activeClaudeManagedAccountIdsByRuntime: nextSelection - }) - // Why before the delete: no new launch may pick the folder while it is being removed. - await this.runtimeAuth.syncForCurrentSelection(target) + const nextActiveId = + settings.activeClaudeManagedAccountId === accountId ? null : nextSelection.host + const target = getClaudeSelectionTargetForAccount(account) + const wasSelected = getSelectedClaudeAccountIdForTarget(settings, target) === accountId try { - await this.runtimeAuth.removeAccountFolder(accountId, target) + if (wasSelected) { + this.store.updateSettings({ + activeClaudeManagedAccountId: nextActiveId, + activeClaudeManagedAccountIdsByRuntime: nextSelection + }) + await this.syncRuntimeAuth(target) + this.store.updateSettings({ claudeManagedAccounts: nextAccounts }) + } else { + this.store.updateSettings({ + claudeManagedAccounts: nextAccounts, + activeClaudeManagedAccountId: nextActiveId, + activeClaudeManagedAccountIdsByRuntime: nextSelection + }) + await this.syncRuntimeAuth(target) + } + await this.removeManagedAuth(accountId, account.managedAuthPath) + this.rateLimits.evictInactiveClaudeCache(accountId) + await this.rateLimits.refreshForClaudeAccountChange( + wasSelected ? accountId : undefined, + target + ) + return this.snapshot() } catch (error) { - // Why keep the row: the folder (on Windows, with its history) is still there to retry. - console.warn('[claude-accounts] Could not delete the account folder:', error) - throw new Error(CLAUDE_ACCOUNT_FOLDER_IN_USE_MESSAGE) + this.restoreSettings(settings) + await this.runtimeAuth.forceMaterializeCurrentSelectionForRollback() + throw error } - this.saveSettings({ - claudeManagedAccounts: this.store - .getSettings() - .claudeManagedAccounts.filter((entry) => entry.id !== accountId) - }) - // Why again: the last account's removal also removes the which-account file. - await this.runtimeAuth.syncForCurrentSelection(target) - this.rateLimits.evictInactiveClaudeCache(accountId) - this.refreshUsage(wasSelected ? accountId : undefined, target) - return this.list() } async select( @@ -160,90 +81,111 @@ export class ClaudeAccountSelection { if (accountId !== null) { const account = this.requireAccount(accountId) const accountTarget = getClaudeSelectionTargetForAccount(account) - const requested = normalizeClaudeAccountSelectionTarget(target ?? accountTarget) - const owned = normalizeClaudeAccountSelectionTarget(accountTarget) + const requestedTarget = normalizeClaudeAccountSelectionTarget(target ?? accountTarget) + const normalizedAccountTarget = normalizeClaudeAccountSelectionTarget(accountTarget) if ( - requested.runtime !== owned.runtime || - (requested.wslDistro !== null && requested.wslDistro !== owned.wslDistro) + requestedTarget.runtime !== normalizedAccountTarget.runtime || + (requestedTarget.wslDistro !== null && + requestedTarget.wslDistro !== normalizedAccountTarget.wslDistro) ) { throw new Error('That Claude account belongs to a different runtime.') } - // Why on the host: the CLI and paired clients select without the renderer's check. - if (this.summarize(account).needsSignIn) { - throw new Error(CLAUDE_ACCOUNT_NEEDS_SIGN_IN_MESSAGE) - } effectiveTarget = accountTarget } - const previous = this.store.getSettings() - const outgoingAccountId = getSelectedClaudeAccountIdForTarget(previous, effectiveTarget) + const previousSettings = this.store.getSettings() + const outgoingAccountId = getSelectedClaudeAccountIdForTarget(previousSettings, effectiveTarget) const nextSelection = setSelectedClaudeAccountIdForTarget( - normalizeClaudeRuntimeSelection(previous), + normalizeClaudeRuntimeSelection(previousSettings), accountId, effectiveTarget ) - this.saveSettings({ + this.store.updateSettings({ activeClaudeManagedAccountId: effectiveTarget?.runtime === 'wsl' ? nextSelection.host : accountId, activeClaudeManagedAccountIdsByRuntime: nextSelection }) try { - await this.runtimeAuth.syncForCurrentSelection(effectiveTarget) + await this.syncRuntimeAuth(effectiveTarget) + await this.rateLimits.refreshForClaudeAccountChange(outgoingAccountId, effectiveTarget) + return this.snapshot() } catch (error) { - this.saveSettings({ - activeClaudeManagedAccountId: previous.activeClaudeManagedAccountId, - activeClaudeManagedAccountIdsByRuntime: previous.activeClaudeManagedAccountIdsByRuntime - }) - await this.runtimeAuth.publishAll().catch((rollbackError: unknown) => { - console.warn('[claude-accounts] Rollback republish failed:', rollbackError) - }) + this.restoreSettings(previousSettings) + await this.runtimeAuth.forceMaterializeCurrentSelectionForRollback() throw error } - this.refreshUsage(outgoingAccountId, effectiveTarget) - return this.list() } - /** Bookkeeping: a usage failure never undoes the account change that triggered it. */ - refreshUsage( - outgoingAccountId: string | null | undefined, - target: ClaudeAccountSelectionTarget | undefined - ): void { - void this.rateLimits - .refreshForClaudeAccountChange(outgoingAccountId ?? undefined, target) - .catch((error: unknown) => - console.warn('[claude-accounts] Usage unavailable after account change:', error) - ) - } - - findAccount(accountId: string): ClaudeManagedAccount | undefined { - return this.store.getSettings().claudeManagedAccounts.find((entry) => entry.id === accountId) + snapshot(): ClaudeRateLimitAccountsState { + const settings = this.store.getSettings() + return { + accounts: settings.claudeManagedAccounts + .map(toClaudeAccountSummary) + .sort((a, b) => b.updatedAt - a.updatedAt), + activeAccountId: normalizeClaudeRuntimeSelection(settings).host, + activeAccountIdsByRuntime: normalizeClaudeRuntimeSelection(settings) + } } requireAccount(accountId: string): ClaudeManagedAccount { - const account = this.findAccount(accountId) + const account = this.store + .getSettings() + .claudeManagedAccounts.find((entry) => entry.id === accountId) if (!account) { throw new Error('That Claude account no longer exists.') } return account } - // Why notify: every window's switcher and Settings read the selection from settings, including - // selections made by the CLI, a paired client or another window. - saveSettings(patch: Partial): void { - this.store.updateSettings(patch, { notifyListeners: true }) + restoreSettings(settings: ReturnType): void { + this.store.updateSettings({ + claudeManagedAccounts: settings.claudeManagedAccounts, + activeClaudeManagedAccountId: settings.activeClaudeManagedAccountId, + activeClaudeManagedAccountIdsByRuntime: settings.activeClaudeManagedAccountIdsByRuntime + }) } - private pruneSelection(): void { + async syncRuntimeAuth( + target?: ClaudeAccountSelectionTarget, + operation?: () => Promise + ): Promise { + beginClaudeAuthSwitch() + try { + await (operation ? operation() : this.runtimeAuth.syncForCurrentSelection(target)) + } finally { + endClaudeAuthSwitch() + } + } + + private normalizeActiveSelection(): void { const settings = this.store.getSettings() - const current = normalizeClaudeRuntimeSelection(settings) - const next = pruneInvalidClaudeRuntimeSelection(current, settings.claudeManagedAccounts) + const currentSelection = normalizeClaudeRuntimeSelection(settings) + const nextSelection = pruneInvalidClaudeRuntimeSelection( + currentSelection, + settings.claudeManagedAccounts + ) if ( - next.host !== settings.activeClaudeManagedAccountId || - JSON.stringify(next) !== JSON.stringify(current) + nextSelection.host !== settings.activeClaudeManagedAccountId || + JSON.stringify(nextSelection) !== JSON.stringify(currentSelection) ) { this.store.updateSettings({ - activeClaudeManagedAccountId: next.host, - activeClaudeManagedAccountIdsByRuntime: next + activeClaudeManagedAccountId: nextSelection.host, + activeClaudeManagedAccountIdsByRuntime: nextSelection }) } } } + +function toClaudeAccountSummary(account: ClaudeManagedAccount): ClaudeManagedAccountSummary { + return { + id: account.id, + email: account.email, + managedAuthRuntime: account.managedAuthRuntime ?? 'host', + wslDistro: account.wslDistro ?? null, + authMethod: account.authMethod ?? 'unknown', + organizationUuid: account.organizationUuid ?? null, + organizationName: account.organizationName ?? null, + createdAt: account.createdAt, + updatedAt: account.updatedAt, + lastAuthenticatedAt: account.lastAuthenticatedAt + } +} diff --git a/src/main/claude-accounts/claude-account-service-account-selection.test.ts b/src/main/claude-accounts/claude-account-service-account-selection.test.ts new file mode 100644 index 000000000000..f055475506bc --- /dev/null +++ b/src/main/claude-accounts/claude-account-service-account-selection.test.ts @@ -0,0 +1,488 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + resetClaudeKeychainMocks, + restorePlatform, + setPlatform +} from './claude-account-service-test-harness' + +const CLAUDE_SERVICE_TEST_ROOT = join(tmpdir(), 'orca-claude-service-selection-test') + +vi.mock('electron', () => ({ + app: { + getPath: () => CLAUDE_SERVICE_TEST_ROOT + } +})) + +const commandMocks = vi.hoisted(() => ({ + resolveClaudeCommand: vi.fn(() => 'claude') +})) + +vi.mock('../codex-cli/command', () => ({ + resolveClaudeCommand: commandMocks.resolveClaudeCommand +})) + +vi.mock('./keychain', () => ({ + deleteActiveClaudeKeychainCredentialsStrict: vi.fn(async () => {}), + deleteManagedClaudeKeychainCredentials: vi.fn(async () => {}), + readActiveClaudeKeychainCredentials: vi.fn(), + readActiveClaudeKeychainCredentialsStrict: vi.fn(), + readManagedClaudeKeychainCredentials: vi.fn(), + writeActiveClaudeKeychainCredentials: vi.fn(async () => {}), + writeManagedClaudeKeychainCredentials: vi.fn(async () => {}) +})) + +describe('ClaudeAccountService credential capture', () => { + let tempDir: string | null = null + + beforeEach(() => { + setPlatform('darwin') + tempDir = null + resetClaudeKeychainMocks() + }) + + afterEach(() => { + restorePlatform() + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }) + } + }) + + it('refreshes rate limits without recaching a removed active account', async () => { + setPlatform('linux') + tempDir = CLAUDE_SERVICE_TEST_ROOT + rmSync(tempDir, { recursive: true, force: true }) + const managedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') + mkdirSync(managedAuthPath, { recursive: true }) + writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') + writeFileSync(join(managedAuthPath, '.credentials.json'), '{"old":true}\n', 'utf-8') + let settings = { + claudeManagedAccounts: [ + { + id: 'account-1', + email: 'old@example.com', + managedAuthPath, + authMethod: 'subscription-oauth', + organizationUuid: null, + organizationName: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeClaudeManagedAccountId: 'account-1' + } + const store = { + getSettings: vi.fn(() => settings), + updateSettings: vi.fn((updates: Partial) => { + settings = { ...settings, ...updates } + return settings + }) + } + const runtimeAuth = { + syncForCurrentSelection: vi.fn(async () => {}), + forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) + } + const rateLimits = { + evictInactiveClaudeCache: vi.fn(), + refreshForClaudeAccountChange: vi.fn(async () => ({ accounts: [], activeAccountId: null })) + } + const { ClaudeAccountService } = await import('./service') + const service = new ClaudeAccountService( + store as never, + rateLimits as never, + runtimeAuth as never + ) + + await service.removeAccount('account-1') + + expect(rateLimits.evictInactiveClaudeCache).toHaveBeenCalledWith('account-1') + expect(rateLimits.refreshForClaudeAccountChange).toHaveBeenCalledWith('account-1', { + runtime: 'host' + }) + expect(settings).toMatchObject({ + claudeManagedAccounts: [], + activeClaudeManagedAccountId: null + }) + }) + + it('switches the active Claude account while PTYs are live', async () => { + setPlatform('linux') + tempDir = CLAUDE_SERVICE_TEST_ROOT + rmSync(tempDir, { recursive: true, force: true }) + const firstAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') + const secondAuthPath = join(tempDir, 'claude-accounts', 'account-2', 'auth') + mkdirSync(firstAuthPath, { recursive: true }) + mkdirSync(secondAuthPath, { recursive: true }) + let settings = { + claudeManagedAccounts: [ + { + id: 'account-1', + email: 'first@example.com', + managedAuthPath: firstAuthPath, + managedAuthRuntime: 'host', + wslDistro: null, + wslLinuxAuthPath: null, + authMethod: 'subscription-oauth', + organizationUuid: null, + organizationName: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + }, + { + id: 'account-2', + email: 'second@example.com', + managedAuthPath: secondAuthPath, + managedAuthRuntime: 'host', + wslDistro: null, + wslLinuxAuthPath: null, + authMethod: 'subscription-oauth', + organizationUuid: null, + organizationName: null, + createdAt: 2, + updatedAt: 2, + lastAuthenticatedAt: 2 + } + ], + activeClaudeManagedAccountId: 'account-1', + activeClaudeManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } + } + const store = { + getSettings: vi.fn(() => settings), + updateSettings: vi.fn((updates: Partial) => { + settings = { ...settings, ...updates } + return settings + }) + } + const runtimeAuth = { + syncForCurrentSelection: vi.fn(async () => {}), + forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) + } + const rateLimits = { + refreshForClaudeAccountChange: vi.fn(async () => ({ accounts: [], activeAccountId: null })) + } + const { ClaudeAccountService } = await import('./service') + const { markClaudePtyExited, markClaudePtySpawned } = await import('./live-pty-gate') + const service = new ClaudeAccountService( + store as never, + rateLimits as never, + runtimeAuth as never + ) + + markClaudePtySpawned('live-claude-pty') + try { + await service.selectAccount('account-2') + } finally { + markClaudePtyExited('live-claude-pty') + } + + expect(settings.activeClaudeManagedAccountId).toBe('account-2') + expect(settings.activeClaudeManagedAccountIdsByRuntime).toEqual({ + host: 'account-2', + wsl: {} + }) + expect(runtimeAuth.syncForCurrentSelection).toHaveBeenCalledWith({ runtime: 'host' }) + expect(rateLimits.refreshForClaudeAccountChange).toHaveBeenCalledWith('account-1', { + runtime: 'host' + }) + }) + + it('restores the previous selection when a Claude account switch fails', async () => { + setPlatform('linux') + tempDir = CLAUDE_SERVICE_TEST_ROOT + rmSync(tempDir, { recursive: true, force: true }) + const firstAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') + const secondAuthPath = join(tempDir, 'claude-accounts', 'account-2', 'auth') + mkdirSync(firstAuthPath, { recursive: true }) + mkdirSync(secondAuthPath, { recursive: true }) + let settings = { + claudeManagedAccounts: [ + { + id: 'account-1', + email: 'first@example.com', + managedAuthPath: firstAuthPath, + managedAuthRuntime: 'host', + wslDistro: null, + wslLinuxAuthPath: null, + authMethod: 'subscription-oauth', + organizationUuid: null, + organizationName: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + }, + { + id: 'account-2', + email: 'second@example.com', + managedAuthPath: secondAuthPath, + managedAuthRuntime: 'host', + wslDistro: null, + wslLinuxAuthPath: null, + authMethod: 'subscription-oauth', + organizationUuid: null, + organizationName: null, + createdAt: 2, + updatedAt: 2, + lastAuthenticatedAt: 2 + } + ], + activeClaudeManagedAccountId: 'account-1', + activeClaudeManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } + } + const store = { + getSettings: vi.fn(() => settings), + updateSettings: vi.fn((updates: Partial) => { + settings = { ...settings, ...updates } + return settings + }) + } + const runtimeAuth = { + syncForCurrentSelection: vi.fn(async () => { + throw new Error('runtime sync failed') + }), + forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) + } + const rateLimits = { + refreshForClaudeAccountChange: vi.fn(async () => ({ accounts: [], activeAccountId: null })) + } + const { ClaudeAccountService } = await import('./service') + const service = new ClaudeAccountService( + store as never, + rateLimits as never, + runtimeAuth as never + ) + + await expect(service.selectAccount('account-2')).rejects.toThrow('runtime sync failed') + + expect(settings.activeClaudeManagedAccountId).toBe('account-1') + expect(settings.activeClaudeManagedAccountIdsByRuntime).toEqual({ + host: 'account-1', + wsl: {} + }) + expect(runtimeAuth.forceMaterializeCurrentSelectionForRollback).toHaveBeenCalled() + expect(rateLimits.refreshForClaudeAccountChange).not.toHaveBeenCalled() + }) + + it('selects a WSL account without changing the Windows active account', async () => { + setPlatform('linux') + tempDir = CLAUDE_SERVICE_TEST_ROOT + rmSync(tempDir, { recursive: true, force: true }) + const hostAuthPath = join(tempDir, 'claude-accounts', 'host-account', 'auth') + const wslAuthPath = join(tempDir, 'claude-accounts', 'wsl-account', 'auth') + mkdirSync(hostAuthPath, { recursive: true }) + mkdirSync(wslAuthPath, { recursive: true }) + let settings = { + claudeManagedAccounts: [ + { + id: 'host-account', + email: 'host@example.com', + managedAuthPath: hostAuthPath, + managedAuthRuntime: 'host', + wslDistro: null, + wslLinuxAuthPath: null, + authMethod: 'subscription-oauth', + organizationUuid: null, + organizationName: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + }, + { + id: 'wsl-account', + email: 'wsl@example.com', + managedAuthPath: wslAuthPath, + managedAuthRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxAuthPath: '/home/jin/.local/share/orca/claude-accounts/wsl-account/auth', + authMethod: 'subscription-oauth', + organizationUuid: null, + organizationName: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeClaudeManagedAccountId: 'host-account', + activeClaudeManagedAccountIdsByRuntime: { host: 'host-account', wsl: { Ubuntu: null } } + } + const store = { + getSettings: vi.fn(() => settings), + updateSettings: vi.fn((updates: Partial) => { + settings = { ...settings, ...updates } + return settings + }) + } + const runtimeAuth = { + syncForCurrentSelection: vi.fn(async () => {}), + forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) + } + const rateLimits = { + refreshForClaudeAccountChange: vi.fn(async () => ({ accounts: [], activeAccountId: null })) + } + const { ClaudeAccountService } = await import('./service') + const service = new ClaudeAccountService( + store as never, + rateLimits as never, + runtimeAuth as never + ) + + const snapshot = await service.selectAccountForTarget('wsl-account', { + runtime: 'wsl', + wslDistro: 'Ubuntu' + }) + + expect(settings.activeClaudeManagedAccountId).toBe('host-account') + expect(settings.activeClaudeManagedAccountIdsByRuntime).toEqual({ + host: 'host-account', + wsl: { Ubuntu: 'wsl-account' } + }) + expect(snapshot.activeAccountIdsByRuntime).toEqual({ + host: 'host-account', + wsl: { Ubuntu: 'wsl-account' } + }) + expect(runtimeAuth.syncForCurrentSelection).toHaveBeenCalledWith({ + runtime: 'wsl', + wslDistro: 'Ubuntu' + }) + expect(rateLimits.refreshForClaudeAccountChange).toHaveBeenCalledWith(null, { + runtime: 'wsl', + wslDistro: 'Ubuntu' + }) + }) + + it('rejects selecting a WSL account for the Windows target', async () => { + setPlatform('linux') + tempDir = CLAUDE_SERVICE_TEST_ROOT + rmSync(tempDir, { recursive: true, force: true }) + const wslAuthPath = join(tempDir, 'claude-accounts', 'wsl-account', 'auth') + mkdirSync(wslAuthPath, { recursive: true }) + const settings = { + claudeManagedAccounts: [ + { + id: 'wsl-account', + email: 'wsl@example.com', + managedAuthPath: wslAuthPath, + managedAuthRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxAuthPath: '/home/jin/.local/share/orca/claude-accounts/wsl-account/auth', + authMethod: 'subscription-oauth', + organizationUuid: null, + organizationName: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeClaudeManagedAccountId: null, + activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: null } } + } + const store = { + getSettings: vi.fn(() => settings), + updateSettings: vi.fn() + } + const runtimeAuth = { + syncForCurrentSelection: vi.fn(async () => {}), + forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) + } + const rateLimits = { + refreshForClaudeAccountChange: vi.fn(async () => ({ accounts: [], activeAccountId: null })) + } + const { ClaudeAccountService } = await import('./service') + const service = new ClaudeAccountService( + store as never, + rateLimits as never, + runtimeAuth as never + ) + + await expect( + service.selectAccountForTarget('wsl-account', { runtime: 'host' }) + ).rejects.toThrow('different runtime') + expect(runtimeAuth.syncForCurrentSelection).not.toHaveBeenCalled() + expect(rateLimits.refreshForClaudeAccountChange).not.toHaveBeenCalled() + }) + + it('removes a WSL account without clearing the Windows active account', async () => { + setPlatform('linux') + tempDir = CLAUDE_SERVICE_TEST_ROOT + rmSync(tempDir, { recursive: true, force: true }) + const hostAuthPath = join(tempDir, 'claude-accounts', 'host-account', 'auth') + const wslAuthPath = join(tempDir, 'claude-accounts', 'wsl-account', 'auth') + mkdirSync(hostAuthPath, { recursive: true }) + mkdirSync(wslAuthPath, { recursive: true }) + writeFileSync(join(wslAuthPath, '.orca-managed-claude-auth'), 'wsl-account\n', 'utf-8') + let settings = { + claudeManagedAccounts: [ + { + id: 'host-account', + email: 'host@example.com', + managedAuthPath: hostAuthPath, + managedAuthRuntime: 'host', + wslDistro: null, + wslLinuxAuthPath: null, + authMethod: 'subscription-oauth', + organizationUuid: null, + organizationName: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + }, + { + id: 'wsl-account', + email: 'wsl@example.com', + managedAuthPath: wslAuthPath, + managedAuthRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxAuthPath: '/home/jin/.local/share/orca/claude-accounts/wsl-account/auth', + authMethod: 'subscription-oauth', + organizationUuid: null, + organizationName: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeClaudeManagedAccountId: 'host-account', + activeClaudeManagedAccountIdsByRuntime: { + host: 'host-account', + wsl: { Ubuntu: 'wsl-account' } + } + } + const store = { + getSettings: vi.fn(() => settings), + updateSettings: vi.fn((updates: Partial) => { + settings = { ...settings, ...updates } + return settings + }) + } + const runtimeAuth = { + syncForCurrentSelection: vi.fn(async () => {}), + forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) + } + const rateLimits = { + evictInactiveClaudeCache: vi.fn(), + refreshForClaudeAccountChange: vi.fn(async () => ({ accounts: [], activeAccountId: null })) + } + const { ClaudeAccountService } = await import('./service') + const service = new ClaudeAccountService( + store as never, + rateLimits as never, + runtimeAuth as never + ) + + await service.removeAccount('wsl-account') + + expect(settings.activeClaudeManagedAccountId).toBe('host-account') + expect(settings.activeClaudeManagedAccountIdsByRuntime).toEqual({ + host: 'host-account', + wsl: { Ubuntu: null } + }) + expect(rateLimits.evictInactiveClaudeCache).toHaveBeenCalledWith('wsl-account') + expect(rateLimits.refreshForClaudeAccountChange).toHaveBeenCalledWith('wsl-account', { + runtime: 'wsl', + wslDistro: 'Ubuntu' + }) + }) +}) diff --git a/src/main/claude-accounts/claude-account-service-add-account.test.ts b/src/main/claude-accounts/claude-account-service-add-account.test.ts new file mode 100644 index 000000000000..3e17f4c00ebb --- /dev/null +++ b/src/main/claude-accounts/claude-account-service-add-account.test.ts @@ -0,0 +1,373 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + resetClaudeKeychainMocks, + restorePlatform, + setPlatform +} from './claude-account-service-test-harness' + +const CLAUDE_SERVICE_TEST_ROOT = join(tmpdir(), 'orca-claude-service-add-test') + +vi.mock('electron', () => ({ + app: { + getPath: () => CLAUDE_SERVICE_TEST_ROOT + } +})) + +const commandMocks = vi.hoisted(() => ({ + resolveClaudeCommand: vi.fn(() => 'claude') +})) + +vi.mock('../codex-cli/command', () => ({ + resolveClaudeCommand: commandMocks.resolveClaudeCommand +})) + +vi.mock('./keychain', () => ({ + deleteActiveClaudeKeychainCredentialsStrict: vi.fn(async () => {}), + deleteManagedClaudeKeychainCredentials: vi.fn(async () => {}), + readActiveClaudeKeychainCredentials: vi.fn(), + readActiveClaudeKeychainCredentialsStrict: vi.fn(), + readManagedClaudeKeychainCredentials: vi.fn(), + writeActiveClaudeKeychainCredentials: vi.fn(async () => {}), + writeManagedClaudeKeychainCredentials: vi.fn(async () => {}) +})) + +describe('ClaudeAccountService credential capture', () => { + let tempDir: string | null = null + + beforeEach(() => { + setPlatform('darwin') + tempDir = null + resetClaudeKeychainMocks() + }) + + afterEach(() => { + restorePlatform() + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }) + } + }) + + it('adds an account without switching the active Claude auth while PTYs are live', async () => { + setPlatform('linux') + tempDir = CLAUDE_SERVICE_TEST_ROOT + rmSync(tempDir, { recursive: true, force: true }) + const hostAuthPath = join(tempDir, 'claude-accounts', 'host-account', 'auth') + mkdirSync(hostAuthPath, { recursive: true }) + let settings = { + claudeManagedAccounts: [ + { + id: 'host-account', + email: 'host@example.com', + managedAuthPath: hostAuthPath, + managedAuthRuntime: 'host', + wslDistro: null, + wslLinuxAuthPath: null, + authMethod: 'subscription-oauth', + organizationUuid: null, + organizationName: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeClaudeManagedAccountId: 'host-account', + activeClaudeManagedAccountIdsByRuntime: { host: 'host-account', wsl: { Ubuntu: null } } + } + const store = { + getSettings: vi.fn(() => settings), + updateSettings: vi.fn((updates: Partial) => { + settings = { ...settings, ...updates } + return settings + }) + } + const runtimeAuth = { + clearLastWrittenCredentialsJson: vi.fn(), + syncForCurrentSelection: vi.fn(async () => {}), + forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) + } + const rateLimits = { + evictInactiveClaudeCache: vi.fn(), + refreshForClaudeAccountChange: vi.fn(async () => ({ accounts: [], activeAccountId: null })) + } + const { ClaudeAccountService } = await import('./service') + const { markClaudePtyExited, markClaudePtySpawned } = await import('./live-pty-gate') + const service = new ClaudeAccountService( + store as never, + rateLimits as never, + runtimeAuth as never + ) + ;( + service as unknown as { + runClaudeLoginAndCapture(): Promise<{ + credentialsJson: string + oauthAccount: unknown + identity: { email: string; organizationUuid: null; organizationName: null } + }> + } + ).runClaudeLoginAndCapture = vi.fn(async () => ({ + credentialsJson: '{"new":true}\n', + oauthAccount: { newOauth: true }, + identity: { email: 'new@example.com', organizationUuid: null, organizationName: null } + })) + + markClaudePtySpawned('live-claude-pty') + try { + await service.addAccount({ runtime: 'host' }) + } finally { + markClaudePtyExited('live-claude-pty') + } + + expect(settings.claudeManagedAccounts).toHaveLength(2) + expect(settings.claudeManagedAccounts[1].email).toBe('new@example.com') + expect(settings.activeClaudeManagedAccountId).toBe('host-account') + expect(settings.activeClaudeManagedAccountIdsByRuntime).toEqual({ + host: 'host-account', + wsl: { Ubuntu: null } + }) + expect(runtimeAuth.syncForCurrentSelection).not.toHaveBeenCalled() + expect(rateLimits.refreshForClaudeAccountChange).not.toHaveBeenCalled() + expect(rateLimits.evictInactiveClaudeCache).toHaveBeenCalledWith( + settings.claudeManagedAccounts[1].id + ) + }) + + it('reports the original add failure and still removes managed auth when rollback rematerialization fails', async () => { + // Why: this is the desktop add path. Previously the rollback's rematerialize + // was unguarded, so when it threw it replaced the real add error and skipped + // safeRemoveManagedAuth, leaking the throwaway auth dir. + setPlatform('linux') + tempDir = CLAUDE_SERVICE_TEST_ROOT + rmSync(tempDir, { recursive: true, force: true }) + const hostAuthPath = join(tempDir, 'claude-accounts', 'host-account', 'auth') + mkdirSync(hostAuthPath, { recursive: true }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + let settings = { + claudeManagedAccounts: [ + { + id: 'host-account', + email: 'host@example.com', + managedAuthPath: hostAuthPath, + managedAuthRuntime: 'host', + wslDistro: null, + wslLinuxAuthPath: null, + authMethod: 'subscription-oauth', + organizationUuid: null, + organizationName: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeClaudeManagedAccountId: 'host-account', + activeClaudeManagedAccountIdsByRuntime: { host: 'host-account', wsl: { Ubuntu: null } } + } + const store = { + getSettings: vi.fn(() => settings), + updateSettings: vi.fn((updates: Partial) => { + settings = { ...settings, ...updates } + return settings + }) + } + const runtimeAuth = { + clearLastWrittenCredentialsJson: vi.fn(), + syncForCurrentSelection: vi.fn(async () => {}), + forceMaterializeCurrentSelectionForRollback: vi.fn(async () => { + throw new Error('rematerialize failed') + }) + } + const rateLimits = { + evictInactiveClaudeCache: vi.fn(), + refreshForClaudeAccountChange: vi.fn(async () => ({ accounts: [], activeAccountId: null })) + } + const { ClaudeAccountService } = await import('./service') + const service = new ClaudeAccountService( + store as never, + rateLimits as never, + runtimeAuth as never + ) + ;( + service as unknown as { + runClaudeLoginAndCapture(): Promise<{ + credentialsJson: string + oauthAccount: unknown + identity: { email: string; organizationUuid: null; organizationName: null } + }> + } + ).runClaudeLoginAndCapture = vi.fn(async () => ({ + credentialsJson: '{"new":true}\n', + oauthAccount: null, + identity: { email: 'new@example.com', organizationUuid: null, organizationName: null } + })) + ;(service as unknown as { writeManagedAuth(): Promise }).writeManagedAuth = vi.fn( + async () => { + throw new Error('managed auth write failed') + } + ) + + await expect(service.addAccount({ runtime: 'host' })).rejects.toThrow( + 'managed auth write failed' + ) + + expect(runtimeAuth.forceMaterializeCurrentSelectionForRollback).toHaveBeenCalled() + expect(settings.claudeManagedAccounts).toHaveLength(1) + // Why: the throwaway account directory must be gone even though rollback threw. + expect(readdirSync(join(tempDir, 'claude-accounts'))).toEqual(['host-account']) + warn.mockRestore() + }) + + it('rejects adding a Claude account whose identity already exists', async () => { + setPlatform('linux') + tempDir = CLAUDE_SERVICE_TEST_ROOT + rmSync(tempDir, { recursive: true, force: true }) + const existingAuthPath = join(tempDir, 'claude-accounts', 'existing-account', 'auth') + mkdirSync(existingAuthPath, { recursive: true }) + const existingMarkerPath = join(existingAuthPath, '.orca-managed-claude-auth') + writeFileSync(existingMarkerPath, 'existing-account\n', 'utf-8') + let settings = { + claudeManagedAccounts: [ + { + id: 'existing-account', + email: 'new@example.com', + managedAuthPath: existingAuthPath, + managedAuthRuntime: 'host', + wslDistro: null, + wslLinuxAuthPath: null, + authMethod: 'subscription-oauth', + organizationUuid: null, + organizationName: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeClaudeManagedAccountId: 'existing-account', + activeClaudeManagedAccountIdsByRuntime: { host: 'existing-account', wsl: {} } + } + const store = { + getSettings: vi.fn(() => settings), + updateSettings: vi.fn((updates: Partial) => { + settings = { ...settings, ...updates } + return settings + }) + } + const runtimeAuth = { + clearLastWrittenCredentialsJson: vi.fn(), + syncForCurrentSelection: vi.fn(async () => {}), + forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) + } + const rateLimits = { + evictInactiveClaudeCache: vi.fn(), + refreshForClaudeAccountChange: vi.fn(async () => ({ accounts: [], activeAccountId: null })) + } + const { ClaudeAccountService } = await import('./service') + const service = new ClaudeAccountService( + store as never, + rateLimits as never, + runtimeAuth as never + ) + ;( + service as unknown as { + runClaudeLoginAndCapture(): Promise<{ + credentialsJson: string + oauthAccount: unknown + identity: { email: string; organizationUuid: string | null; organizationName: null } + }> + } + ).runClaudeLoginAndCapture = vi.fn(async () => ({ + credentialsJson: '{"new":true}\n', + oauthAccount: { newOauth: true }, + identity: { email: 'new@example.com', organizationUuid: null, organizationName: null } + })) + + await expect(service.addAccount({ runtime: 'host' })).rejects.toThrow( + 'This Claude account is already added.' + ) + + expect(settings.claudeManagedAccounts).toHaveLength(1) + expect(readFileSync(existingMarkerPath, 'utf-8')).toBe('existing-account\n') + // The guard fires before credentials/settings change, so rollback I/O + // would only add latency and could mask the duplicate error. + expect(store.updateSettings).not.toHaveBeenCalled() + expect(runtimeAuth.forceMaterializeCurrentSelectionForRollback).not.toHaveBeenCalled() + // The rejected add's throwaway managed-auth dir must be cleaned up, leaving + // only the pre-existing account's dir behind. + expect(readdirSync(join(tempDir, 'claude-accounts')).sort()).toEqual(['existing-account']) + }) + + it('adds a Claude account with the same email under a different organization', async () => { + setPlatform('linux') + tempDir = CLAUDE_SERVICE_TEST_ROOT + rmSync(tempDir, { recursive: true, force: true }) + const existingAuthPath = join(tempDir, 'claude-accounts', 'existing-account', 'auth') + mkdirSync(existingAuthPath, { recursive: true }) + writeFileSync( + join(existingAuthPath, '.orca-managed-claude-auth'), + 'existing-account\n', + 'utf-8' + ) + let settings = { + claudeManagedAccounts: [ + { + id: 'existing-account', + email: 'new@example.com', + managedAuthPath: existingAuthPath, + managedAuthRuntime: 'host', + wslDistro: null, + wslLinuxAuthPath: null, + authMethod: 'subscription-oauth', + organizationUuid: 'org-A', + organizationName: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeClaudeManagedAccountId: 'existing-account', + activeClaudeManagedAccountIdsByRuntime: { host: 'existing-account', wsl: {} } + } + const store = { + getSettings: vi.fn(() => settings), + updateSettings: vi.fn((updates: Partial) => { + settings = { ...settings, ...updates } + return settings + }) + } + const runtimeAuth = { + clearLastWrittenCredentialsJson: vi.fn(), + syncForCurrentSelection: vi.fn(async () => {}), + forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) + } + const rateLimits = { + evictInactiveClaudeCache: vi.fn(), + refreshForClaudeAccountChange: vi.fn(async () => ({ accounts: [], activeAccountId: null })) + } + const { ClaudeAccountService } = await import('./service') + const service = new ClaudeAccountService( + store as never, + rateLimits as never, + runtimeAuth as never + ) + ;( + service as unknown as { + runClaudeLoginAndCapture(): Promise<{ + credentialsJson: string + oauthAccount: unknown + identity: { email: string; organizationUuid: string | null; organizationName: null } + }> + } + ).runClaudeLoginAndCapture = vi.fn(async () => ({ + credentialsJson: '{"new":true}\n', + oauthAccount: { newOauth: true }, + identity: { email: 'new@example.com', organizationUuid: 'org-B', organizationName: null } + })) + + await service.addAccount({ runtime: 'host' }) + + expect(settings.claudeManagedAccounts).toHaveLength(2) + expect(settings.claudeManagedAccounts[1].email).toBe('new@example.com') + expect(settings.claudeManagedAccounts[1].organizationUuid).toBe('org-B') + }) +}) diff --git a/src/main/claude-accounts/claude-account-service-api-parity.test.ts b/src/main/claude-accounts/claude-account-service-api-parity.test.ts new file mode 100644 index 000000000000..09ec7813ffe8 --- /dev/null +++ b/src/main/claude-accounts/claude-account-service-api-parity.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it, vi } from 'vitest' +import type { ClaudeRateLimitAccountsState } from '../../shared/managed-account-types' +import { ClaudeAccountService } from './service' +import type { ClaudeAccountAddTarget } from './service' + +vi.mock('electron', () => ({ app: { getPath: () => '/tmp/orca-claude-api-parity' } })) + +function createService(): ClaudeAccountService { + const settings = { + claudeManagedAccounts: [], + activeClaudeManagedAccountId: null, + activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: {} } + } + return new ClaudeAccountService( + { getSettings: () => settings, updateSettings: vi.fn() } as never, + {} as never, + { getRuntimeConfigDir: () => '/tmp/claude' } as never + ) +} + +function deferred(): { promise: Promise; resolve: () => void } { + let resolve!: () => void + return { promise: new Promise((done) => (resolve = done)), resolve } +} + +describe('ClaudeAccountService mutation serialization', () => { + it('serializes mutations within one service', async () => { + const service = createService() + const first = deferred() + const calls: string[] = [] + const registration = ( + service as unknown as { + registration: { + add(target?: ClaudeAccountAddTarget): Promise + } + } + ).registration + registration.add = vi.fn(async (target) => { + calls.push(target?.wslDistro ?? 'host') + if (calls.length === 1) { + await first.promise + } + return { + accounts: [], + activeAccountId: null, + activeAccountIdsByRuntime: { host: null, wsl: {} } + } + }) + + const firstAdd = service.addAccount({ runtime: 'wsl', wslDistro: 'one' }) + const secondAdd = service.addAccount({ runtime: 'wsl', wslDistro: 'two' }) + await vi.waitFor(() => expect(calls).toEqual(['one'])) + first.resolve() + await Promise.all([firstAdd, secondAdd]) + expect(calls).toEqual(['one', 'two']) + }) + + it('isolates mutation and cancellation state between runtime hosts', async () => { + const firstService = createService() + const secondService = createService() + const first = deferred() + const firstAdd = vi.fn(async () => { + await first.promise + return { + accounts: [], + activeAccountId: null, + activeAccountIdsByRuntime: { host: null, wsl: {} } + } + }) + const secondAdd = vi.fn(async () => ({ + accounts: [], + activeAccountId: null, + activeAccountIdsByRuntime: { host: null, wsl: {} } + })) + ;(firstService as unknown as { registration: { add: typeof firstAdd } }).registration.add = + firstAdd + ;(secondService as unknown as { registration: { add: typeof secondAdd } }).registration.add = + secondAdd + ;( + firstService as unknown as { cancelPendingClaudeLogin: () => boolean } + ).cancelPendingClaudeLogin = vi.fn(() => true) + + const pendingFirst = firstService.addAccount() + await expect(secondService.addAccount()).resolves.toMatchObject({ accounts: [] }) + expect(firstService.cancelPendingLogin()).toBe(true) + expect(secondService.cancelPendingLogin()).toBe(false) + first.resolve() + await pendingFirst + }) +}) diff --git a/src/main/claude-accounts/claude-account-service-config-dir-capture.test.ts b/src/main/claude-accounts/claude-account-service-config-dir-capture.test.ts new file mode 100644 index 000000000000..8ed94bebc6eb --- /dev/null +++ b/src/main/claude-accounts/claude-account-service-config-dir-capture.test.ts @@ -0,0 +1,250 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createHash } from 'node:crypto' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { ClaudeManagedAccount } from '../../shared/managed-account-types' +import { + readActiveClaudeKeychainCredentialsStrict, + writeManagedClaudeKeychainCredentials +} from './keychain' +import { restorePlatform, setPlatform } from './claude-account-service-test-harness' + +const CLAUDE_SERVICE_TEST_ROOT = join(tmpdir(), 'orca-claude-service-config-dir-test') + +vi.mock('electron', () => ({ + app: { + getPath: () => CLAUDE_SERVICE_TEST_ROOT + } +})) + +const commandMocks = vi.hoisted(() => ({ + resolveClaudeCommand: vi.fn(() => 'claude') +})) + +vi.mock('../codex-cli/command', () => ({ + resolveClaudeCommand: commandMocks.resolveClaudeCommand +})) + +vi.mock('./keychain', () => ({ + deleteActiveClaudeKeychainCredentialsStrict: vi.fn(async () => {}), + deleteManagedClaudeKeychainCredentials: vi.fn(async () => {}), + readActiveClaudeKeychainCredentials: vi.fn(), + readActiveClaudeKeychainCredentialsStrict: vi.fn(), + readManagedClaudeKeychainCredentials: vi.fn(), + writeActiveClaudeKeychainCredentials: vi.fn(async () => {}), + writeManagedClaudeKeychainCredentials: vi.fn(async () => {}) +})) + +describe('ClaudeAccountService.addAccountFromConfigDir', () => { + const managedRoot = CLAUDE_SERVICE_TEST_ROOT + let sourceDir: string | null = null + + beforeEach(() => { + setPlatform('linux') + rmSync(managedRoot, { recursive: true, force: true }) + sourceDir = null + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockReset() + vi.mocked(writeManagedClaudeKeychainCredentials).mockReset().mockResolvedValue() + }) + + afterEach(() => { + restorePlatform() + rmSync(managedRoot, { recursive: true, force: true }) + if (sourceDir) { + rmSync(sourceDir, { recursive: true, force: true }) + } + }) + + function makeDeps() { + let settings = { + claudeManagedAccounts: [] as ClaudeManagedAccount[], + activeClaudeManagedAccountId: null as string | null, + activeClaudeManagedAccountIdsByRuntime: { host: null as string | null, wsl: {} } + } + const store = { + getSettings: vi.fn(() => settings), + updateSettings: vi.fn((updates: Partial) => { + settings = { ...settings, ...updates } + return settings + }) + } + const rateLimits = { evictInactiveClaudeCache: vi.fn() } + const runtimeAuth = { + clearLastWrittenCredentialsJson: vi.fn(), + forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) + } + return { store, rateLimits, runtimeAuth, getSettings: () => settings } + } + + it('registers a managed account by capturing an authenticated config dir', async () => { + sourceDir = mkdtempSync(join(tmpdir(), 'orca-claude-source-')) + writeFileSync( + join(sourceDir, '.credentials.json'), + '{"claudeAiOauth":{"accessToken":"tok"}}\n', + 'utf-8' + ) + writeFileSync( + join(sourceDir, '.claude.json'), + JSON.stringify({ oauthAccount: { emailAddress: 'new@example.com' } }), + 'utf-8' + ) + + const deps = makeDeps() + const { ClaudeAccountService } = await import('./service') + const service = new ClaudeAccountService( + deps.store as never, + deps.rateLimits as never, + deps.runtimeAuth as never + ) + // Why: avoid spawning a real `claude auth status` subprocess in the test. + ;(service as unknown as { runClaudeCommand: () => Promise }).runClaudeCommand = vi.fn( + async () => '{"email":"new@example.com"}' + ) + + const result = await service.addAccountFromConfigDir(sourceDir) + + const accounts = deps.getSettings().claudeManagedAccounts + expect(accounts).toHaveLength(1) + expect(accounts[0].email).toBe('new@example.com') + expect(result.accounts[0]?.email).toBe('new@example.com') + expect(readFileSync(join(accounts[0].managedAuthPath, '.credentials.json'), 'utf-8')).toBe( + '{"claudeAiOauth":{"accessToken":"tok"}}\n' + ) + expect(deps.runtimeAuth.clearLastWrittenCredentialsJson).toHaveBeenCalledWith(accounts[0].id) + }) + + it('captures only the config-scoped macOS Keychain credential', async () => { + setPlatform('darwin') + sourceDir = mkdtempSync(join(tmpdir(), 'orca-claude-source-keychain-')) + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockImplementation(async (configDir) => + configDir ? '{"claudeAiOauth":{"accessToken":"scoped"}}' : 'legacy-credentials' + ) + const deps = makeDeps() + const { ClaudeAccountService } = await import('./service') + const service = new ClaudeAccountService( + deps.store as never, + deps.rateLimits as never, + deps.runtimeAuth as never + ) + ;(service as unknown as { runClaudeCommand: () => Promise }).runClaudeCommand = vi.fn( + async () => '{"email":"new@example.com"}' + ) + + await service.addAccountFromConfigDir(sourceDir) + + expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenCalledWith(sourceDir) + expect(writeManagedClaudeKeychainCredentials).toHaveBeenCalledWith( + expect.any(String), + '{"claudeAiOauth":{"accessToken":"scoped"}}' + ) + }) + + it('does not mistake an unchanged legacy Keychain credential for the temp login', async () => { + setPlatform('darwin') + sourceDir = mkdtempSync(join(tmpdir(), 'orca-claude-source-keychain-empty-')) + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockImplementation(async (configDir) => + configDir ? null : 'legacy-credentials' + ) + const deps = makeDeps() + const { ClaudeAccountService } = await import('./service') + const service = new ClaudeAccountService( + deps.store as never, + deps.rateLimits as never, + deps.runtimeAuth as never + ) + ;(service as unknown as { runClaudeCommand: () => Promise }).runClaudeCommand = vi.fn( + async () => '{"email":"existing@example.com"}' + ) + + await expect(service.addAccountFromConfigDir(sourceDir)).rejects.toThrow( + 'no OAuth credentials were captured' + ) + expect(deps.getSettings().claudeManagedAccounts).toHaveLength(0) + }) + + it('captures a legacy Keychain credential that changed after login began', async () => { + setPlatform('darwin') + sourceDir = mkdtempSync(join(tmpdir(), 'orca-claude-source-keychain-legacy-')) + const previousCredentials = '{"claudeAiOauth":{"accessToken":"previous"}}' + const newCredentials = '{"claudeAiOauth":{"accessToken":"new","email":"new@example.com"}}' + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockImplementation(async (configDir) => + configDir ? null : newCredentials + ) + const deps = makeDeps() + const { ClaudeAccountService } = await import('./service') + const service = new ClaudeAccountService( + deps.store as never, + deps.rateLimits as never, + deps.runtimeAuth as never + ) + ;(service as unknown as { runClaudeCommand: () => Promise }).runClaudeCommand = vi.fn( + async () => '{"email":"new@example.com"}' + ) + + await service.addAccountFromConfigDir(sourceDir, { + previousLegacyCredentialsSha256: createHash('sha256') + .update(previousCredentials) + .digest('hex') + }) + + expect(writeManagedClaudeKeychainCredentials).toHaveBeenCalledWith( + expect.any(String), + newCredentials + ) + expect(deps.getSettings().claudeManagedAccounts[0]?.email).toBe('new@example.com') + }) + + it('still registers when the daemon cannot spawn `claude auth status`', async () => { + // Why: `allowFailure` covers a non-zero exit but not a spawn error, so a daemon + // started with a minimal PATH would hard-fail an add the user already signed in for. + sourceDir = mkdtempSync(join(tmpdir(), 'orca-claude-source-nostatus-')) + writeFileSync( + join(sourceDir, '.credentials.json'), + '{"claudeAiOauth":{"accessToken":"tok"}}\n', + 'utf-8' + ) + writeFileSync( + join(sourceDir, '.claude.json'), + JSON.stringify({ oauthAccount: { emailAddress: 'new@example.com' } }), + 'utf-8' + ) + + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const deps = makeDeps() + const { ClaudeAccountService } = await import('./service') + const service = new ClaudeAccountService( + deps.store as never, + deps.rateLimits as never, + deps.runtimeAuth as never + ) + ;(service as unknown as { runClaudeCommand: () => Promise }).runClaudeCommand = vi.fn( + async () => { + throw Object.assign(new Error('spawn claude ENOENT'), { code: 'ENOENT' }) + } + ) + + const result = await service.addAccountFromConfigDir(sourceDir) + + expect(result.accounts[0]?.email).toBe('new@example.com') + expect(deps.getSettings().claudeManagedAccounts).toHaveLength(1) + warn.mockRestore() + }) + + it('rejects and rolls back when the config dir has no credentials', async () => { + sourceDir = mkdtempSync(join(tmpdir(), 'orca-claude-source-empty-')) + const deps = makeDeps() + const { ClaudeAccountService } = await import('./service') + const service = new ClaudeAccountService( + deps.store as never, + deps.rateLimits as never, + deps.runtimeAuth as never + ) + + await expect(service.addAccountFromConfigDir(sourceDir)).rejects.toThrow( + /No Claude credentials found/ + ) + expect(deps.getSettings().claudeManagedAccounts).toHaveLength(0) + expect(deps.runtimeAuth.forceMaterializeCurrentSelectionForRollback).toHaveBeenCalled() + }) +}) diff --git a/src/main/claude-accounts/claude-account-service-credential-capture.test.ts b/src/main/claude-accounts/claude-account-service-credential-capture.test.ts new file mode 100644 index 000000000000..641ef58b5bee --- /dev/null +++ b/src/main/claude-accounts/claude-account-service-credential-capture.test.ts @@ -0,0 +1,150 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + readActiveClaudeKeychainCredentials, + readActiveClaudeKeychainCredentialsStrict, + writeActiveClaudeKeychainCredentials +} from './keychain' +import { + createService, + resetClaudeKeychainMocks, + restorePlatform, + setPlatform +} from './claude-account-service-test-harness' + +const CLAUDE_SERVICE_TEST_ROOT = join(tmpdir(), 'orca-claude-service-capture-test') + +vi.mock('electron', () => ({ + app: { + getPath: () => CLAUDE_SERVICE_TEST_ROOT + } +})) + +const commandMocks = vi.hoisted(() => ({ + resolveClaudeCommand: vi.fn(() => 'claude') +})) + +vi.mock('../codex-cli/command', () => ({ + resolveClaudeCommand: commandMocks.resolveClaudeCommand +})) + +vi.mock('./keychain', () => ({ + deleteActiveClaudeKeychainCredentialsStrict: vi.fn(async () => {}), + deleteManagedClaudeKeychainCredentials: vi.fn(async () => {}), + readActiveClaudeKeychainCredentials: vi.fn(), + readActiveClaudeKeychainCredentialsStrict: vi.fn(), + readManagedClaudeKeychainCredentials: vi.fn(), + writeActiveClaudeKeychainCredentials: vi.fn(async () => {}), + writeManagedClaudeKeychainCredentials: vi.fn(async () => {}) +})) + +async function readCapturedCredentials( + configDir: string, + previousLegacyKeychain: string | null +): Promise { + const { ClaudeAccountService } = await import('./service') + const service = new ClaudeAccountService( + createService() as never, + createService() as never, + createService() as never + ) + return ( + service as unknown as { + readCapturedCredentials( + configDir: string, + previousLegacyKeychain: string | null + ): Promise + } + ).readCapturedCredentials(configDir, previousLegacyKeychain) +} + +describe('ClaudeAccountService credential capture', () => { + let tempDir: string | null = null + + beforeEach(() => { + setPlatform('darwin') + tempDir = null + resetClaudeKeychainMocks() + }) + + afterEach(() => { + restorePlatform() + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }) + } + }) + + it('accepts scoped Keychain capture even when it matches the previous legacy item', async () => { + vi.mocked(readActiveClaudeKeychainCredentialsStrict) + .mockResolvedValueOnce('same-account') + .mockResolvedValueOnce('same-account') + + await expect(readCapturedCredentials('/tmp/claude-config', 'same-account')).resolves.toBe( + 'same-account' + ) + + expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenCalledWith('/tmp/claude-config') + expect(readActiveClaudeKeychainCredentials).not.toHaveBeenCalled() + }) + + it('rejects unchanged legacy fallback when scoped capture is missing', async () => { + vi.mocked(readActiveClaudeKeychainCredentialsStrict) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce('previous') + + await expect(readCapturedCredentials('/tmp/claude-config', 'previous')).resolves.toBeNull() + + expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenNthCalledWith( + 1, + '/tmp/claude-config' + ) + expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenNthCalledWith(2) + }) + + it('accepts changed legacy fallback for old Claude Code builds', async () => { + vi.mocked(readActiveClaudeKeychainCredentialsStrict) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce('new-legacy') + + await expect(readCapturedCredentials('/tmp/claude-config', 'previous')).resolves.toBe( + 'new-legacy' + ) + + expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenNthCalledWith( + 1, + '/tmp/claude-config' + ) + expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenNthCalledWith(2) + }) + + it('falls back to captured credentials file on macOS', async () => { + tempDir = mkdtempSync(join(tmpdir(), 'orca-claude-capture-')) + writeFileSync(join(tempDir, '.credentials.json'), '{"token":"file"}\n', 'utf-8') + vi.mocked(readActiveClaudeKeychainCredentialsStrict) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce('previous') + + await expect(readCapturedCredentials(tempDir, 'previous')).resolves.toBe('{"token":"file"}\n') + }) + + it('fails login capture when legacy Keychain cleanup fails', async () => { + vi.mocked(readActiveClaudeKeychainCredentials).mockResolvedValue('previous-legacy') + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValue('captured-scoped') + vi.mocked(writeActiveClaudeKeychainCredentials).mockRejectedValue(new Error('restore failed')) + const { ClaudeAccountService } = await import('./service') + const service = new ClaudeAccountService( + createService() as never, + createService() as never, + createService() as never + ) + const testService = service as unknown as { + runClaudeCommand: () => Promise + runClaudeLoginAndCapture(): Promise<{ credentialsJson: string }> + } + testService.runClaudeCommand = vi.fn(async () => '{"account":{"email":"user@example.com"}}') + + await expect(testService.runClaudeLoginAndCapture()).rejects.toThrow('restore failed') + }) +}) diff --git a/src/main/claude-accounts/claude-account-service-login-process.test.ts b/src/main/claude-accounts/claude-account-service-login-process.test.ts new file mode 100644 index 000000000000..8aee047e5fa5 --- /dev/null +++ b/src/main/claude-accounts/claude-account-service-login-process.test.ts @@ -0,0 +1,684 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { EventEmitter } from 'node:events' +import { writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { PassThrough } from 'node:stream' +import type { ClaudeManagedAccount } from '../../shared/managed-account-types' +import { readActiveClaudeKeychainCredentials } from './keychain' +import { getCmdExePath } from '../../shared/windows-batch-spawn' +import { + createService, + resetClaudeKeychainMocks, + restorePlatform, + setPlatform +} from './claude-account-service-test-harness' + +const CLAUDE_SERVICE_TEST_ROOT = join(tmpdir(), 'orca-claude-service-login-test') + +vi.mock('electron', () => ({ + app: { + getPath: () => CLAUDE_SERVICE_TEST_ROOT + } +})) + +const commandMocks = vi.hoisted(() => ({ + resolveClaudeCommand: vi.fn(() => 'claude') +})) + +vi.mock('../codex-cli/command', () => ({ + resolveClaudeCommand: commandMocks.resolveClaudeCommand +})) + +vi.mock('./keychain', () => ({ + deleteActiveClaudeKeychainCredentialsStrict: vi.fn(async () => {}), + deleteManagedClaudeKeychainCredentials: vi.fn(async () => {}), + readActiveClaudeKeychainCredentials: vi.fn(), + readActiveClaudeKeychainCredentialsStrict: vi.fn(), + readManagedClaudeKeychainCredentials: vi.fn(), + writeActiveClaudeKeychainCredentials: vi.fn(async () => {}), + writeManagedClaudeKeychainCredentials: vi.fn(async () => {}) +})) + +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the doubles below implement every member the add-account login path reaches; this case drives the service only through addAccount. +const asServiceDouble = (double: unknown): T => double as T + +describe('ClaudeAccountService credential capture', () => { + beforeEach(() => { + setPlatform('darwin') + resetClaudeKeychainMocks() + }) + + afterEach(() => { + restorePlatform() + }) + + it('removes command listeners when Claude sign-in times out', async () => { + vi.resetModules() + vi.useFakeTimers() + const child = new EventEmitter() as EventEmitter & { + stdin: PassThrough + stdout: PassThrough + stderr: PassThrough + kill: () => void + } + child.stdin = new PassThrough() + child.stdout = new PassThrough() + child.stderr = new PassThrough() + child.kill = vi.fn() + const destroyStdin = vi.spyOn(child.stdin, 'destroy') + const spawnMock = vi.fn(() => child) + vi.doMock('node:child_process', () => ({ spawn: spawnMock })) + + try { + const { ClaudeAccountService } = await import('./service') + const service = new ClaudeAccountService( + createService() as never, + createService() as never, + createService() as never + ) + const commandPromise = ( + service as unknown as { + runClaudeCommand( + args: string[], + configDir: { windowsPath: string; linuxPath: string | null; wslDistro: string | null }, + timeoutMs: number, + options?: { keepStdinOpen?: boolean } + ): Promise + } + ).runClaudeCommand( + ['login'], + { windowsPath: '/tmp/claude-auth', linuxPath: null, wslDistro: null }, + 1000, + { keepStdinOpen: true } + ) + const rejection = expect(commandPromise).rejects.toThrow( + 'Claude sign-in took too long to finish.' + ) + + await vi.advanceTimersByTimeAsync(1000) + + await rejection + expect(child.kill).toHaveBeenCalledTimes(1) + expect(destroyStdin).toHaveBeenCalledTimes(1) + expect(child.stdout.listenerCount('data')).toBe(0) + expect(child.stderr.listenerCount('data')).toBe(0) + expect(child.listenerCount('error')).toBe(0) + expect(child.listenerCount('close')).toBe(0) + } finally { + vi.useRealTimers() + vi.doUnmock('node:child_process') + } + }) + + it('owns the complete cmd.exe command line for a resolved Windows Claude command', async () => { + setPlatform('win32') + vi.resetModules() + commandMocks.resolveClaudeCommand.mockReturnValueOnce( + 'C:\\Users\\First Last\\AppData\\Roaming\\npm\\claude.cmd' + ) + const child = new EventEmitter() as EventEmitter & { + stdout: PassThrough + stderr: PassThrough + kill: ReturnType + } + child.stdout = new PassThrough() + child.stderr = new PassThrough() + child.kill = vi.fn() + const spawnMock = vi.fn(() => { + child.stdout.write('{"email":"user@example.com"}\n') + queueMicrotask(() => child.emit('close', 0)) + return child + }) + vi.doMock('node:child_process', () => ({ spawn: spawnMock })) + + try { + const { ClaudeAccountService } = await import('./service') + const service = new ClaudeAccountService( + createService() as never, + createService() as never, + createService() as never + ) + await ( + service as unknown as { + runClaudeCommand( + args: string[], + configDir: { windowsPath: string; linuxPath: string | null; wslDistro: string | null }, + timeoutMs: number + ): Promise + } + ).runClaudeCommand( + ['auth', 'status', '--json'], + { windowsPath: 'C:\\tmp\\claude-auth', linuxPath: null, wslDistro: null }, + 1000 + ) + + expect(spawnMock).toHaveBeenCalledWith( + process.env.ComSpec ?? 'cmd.exe', + [ + '/d', + '/v:off', + '/s', + '/c', + '""C:\\Users\\First Last\\AppData\\Roaming\\npm\\claude.cmd" "auth" "status" "--json""' + ], + expect.objectContaining({ shell: false, windowsVerbatimArguments: true }) + ) + } finally { + vi.doUnmock('node:child_process') + } + }) + + it('keeps WSL execution separate from Windows command resolution', async () => { + setPlatform('win32') + vi.resetModules() + commandMocks.resolveClaudeCommand.mockClear() + const child = new EventEmitter() as EventEmitter & { + stdout: PassThrough + stderr: PassThrough + kill: ReturnType + } + child.stdout = new PassThrough() + child.stderr = new PassThrough() + child.kill = vi.fn() + const spawnMock = vi.fn(() => { + child.stdout.write('{"email":"user@example.com"}\n') + queueMicrotask(() => child.emit('close', 0)) + return child + }) + vi.doMock('node:child_process', () => ({ spawn: spawnMock })) + + try { + const { ClaudeAccountService } = await import('./service') + const service = new ClaudeAccountService( + createService() as never, + createService() as never, + createService() as never + ) + await ( + service as unknown as { + runClaudeCommand( + args: string[], + configDir: { windowsPath: string; linuxPath: string | null; wslDistro: string | null }, + timeoutMs: number + ): Promise + } + ).runClaudeCommand( + ['auth', 'status', '--json'], + { + windowsPath: 'C:\\tmp\\claude-auth', + linuxPath: '/home/user/.config/orca auth', + wslDistro: 'Ubuntu Test' + }, + 1000 + ) + + expect(commandMocks.resolveClaudeCommand).not.toHaveBeenCalled() + expect(spawnMock).toHaveBeenCalledWith( + 'wsl.exe', + [ + '-d', + 'Ubuntu Test', + '--exec', + 'bash', + '-lc', + "export CLAUDE_CONFIG_DIR='/home/user/.config/orca auth'; export CLAUDE_SECURESTORAGE_CONFIG_DIR='/home/user/.config/orca auth'; exec claude 'auth' 'status' '--json'" + ], + expect.objectContaining({ shell: false, windowsVerbatimArguments: false }) + ) + } finally { + vi.doUnmock('node:child_process') + } + }) + + it('pipes stdin only for the explicit Claude account login command', async () => { + setPlatform('linux') + vi.resetModules() + vi.mocked(readActiveClaudeKeychainCredentials).mockResolvedValue(null) + const loginChild = new EventEmitter() as EventEmitter & { + stdin: PassThrough + stdout: PassThrough + stderr: PassThrough + kill: ReturnType + } + loginChild.stdin = new PassThrough() + loginChild.stdout = new PassThrough() + loginChild.stderr = new PassThrough() + loginChild.kill = vi.fn() + const statusChild = new EventEmitter() as EventEmitter & { + stdout: PassThrough + stderr: PassThrough + kill: ReturnType + } + statusChild.stdout = new PassThrough() + statusChild.stderr = new PassThrough() + statusChild.kill = vi.fn() + const spawnMock = vi.fn( + (_command: string, args: string[], options: { env: NodeJS.ProcessEnv }) => { + if (args[1] === 'login') { + writeFileSync( + join(options.env.CLAUDE_CONFIG_DIR!, '.credentials.json'), + '{"claudeAiOauth":{"email":"user@example.com","accessToken":"token"}}\n', + 'utf-8' + ) + queueMicrotask(() => loginChild.emit('close', 0)) + return loginChild + } + statusChild.stdout.write('{"email":"user@example.com"}\n') + queueMicrotask(() => statusChild.emit('close', 0)) + return statusChild + } + ) + vi.doMock('node:child_process', () => ({ spawn: spawnMock })) + + try { + const { ClaudeAccountService } = await import('./service') + let settings = { + claudeManagedAccounts: [] as ClaudeManagedAccount[], + activeClaudeManagedAccountId: null, + activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: {} } + } + const store = { + getSettings: vi.fn(() => settings), + updateSettings: vi.fn((updates: Partial) => { + settings = { ...settings, ...updates } + return settings + }) + } + const runtimeAuth = { + clearLastWrittenCredentialsJson: vi.fn(), + forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) + } + const rateLimits = { + evictInactiveClaudeCache: vi.fn(), + refreshForClaudeAccountChange: vi.fn() + } + const service = new ClaudeAccountService( + store as never, + rateLimits as never, + runtimeAuth as never + ) + + await service.addAccount() + + expect(spawnMock).toHaveBeenNthCalledWith( + 1, + 'claude', + ['auth', 'login', '--claudeai'], + expect.objectContaining({ stdio: ['pipe', 'pipe', 'pipe'] }) + ) + expect(spawnMock).toHaveBeenNthCalledWith( + 2, + 'claude', + ['auth', 'status', '--json'], + expect.objectContaining({ stdio: ['ignore', 'pipe', 'pipe'] }) + ) + expect(settings.claudeManagedAccounts[0]?.email).toBe('user@example.com') + } finally { + vi.doUnmock('node:child_process') + } + }) + + it('rejects immediately when Claude sign-in is denied', async () => { + vi.resetModules() + const child = new EventEmitter() as EventEmitter & { + stdin: PassThrough + stdout: PassThrough + stderr: PassThrough + kill: ReturnType + pid: number + } + child.stdin = new PassThrough() + child.stdout = new PassThrough() + child.stderr = new PassThrough() + child.kill = vi.fn() + child.pid = 4242 + const destroyStdin = vi.spyOn(child.stdin, 'destroy') + const spawnMock = vi.fn(() => child) + vi.doMock('node:child_process', () => ({ spawn: spawnMock })) + // Denial must tear down the whole detached login/browser tree (process-group kill on POSIX), + // not just the direct child — otherwise the orphaned auth processes the `detached` spawn guards against leak. + const killTree = vi.spyOn(process, 'kill').mockReturnValue(true) + + try { + const { ClaudeAccountService } = await import('./service') + const service = new ClaudeAccountService( + createService() as never, + createService() as never, + createService() as never + ) + const commandPromise = ( + service as unknown as { + runClaudeCommand( + args: string[], + configDir: { windowsPath: string; linuxPath: string | null; wslDistro: string | null }, + timeoutMs: number, + options?: { keepStdinOpen?: boolean } + ): Promise + } + ).runClaudeCommand( + ['login'], + { windowsPath: '/tmp/claude-auth', linuxPath: null, wslDistro: null }, + 180_000, + { keepStdinOpen: true } + ) + + child.stderr.write('OAuth authorization failed: access_denied\n') + + await expect(commandPromise).rejects.toThrow('Claude sign-in was denied. Please try again.') + expect(killTree).toHaveBeenCalledWith(-child.pid) + expect(child.kill).not.toHaveBeenCalled() + expect(destroyStdin).toHaveBeenCalledTimes(1) + expect(child.stdout.listenerCount('data')).toBe(0) + expect(child.stderr.listenerCount('data')).toBe(0) + expect(child.listenerCount('error')).toBe(0) + expect(child.listenerCount('close')).toBe(0) + } finally { + killTree.mockRestore() + vi.doUnmock('node:child_process') + } + }) + + it('cancels an in-flight Claude account add', async () => { + vi.resetModules() + const child = new EventEmitter() as EventEmitter & { + stdin: PassThrough + stdout: PassThrough + stderr: PassThrough + kill: ReturnType + } + child.stdin = new PassThrough() + child.stdout = new PassThrough() + child.stderr = new PassThrough() + child.kill = vi.fn() + const destroyStdin = vi.spyOn(child.stdin, 'destroy') + const spawnMock = vi.fn(() => child) + vi.doMock('node:child_process', () => ({ spawn: spawnMock })) + + try { + const { ClaudeAccountService } = await import('./service') + let settings = { + claudeManagedAccounts: [], + activeClaudeManagedAccountId: null, + activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: {} } + } + const store = { + getSettings: vi.fn(() => settings), + updateSettings: vi.fn((updates: Partial) => { + settings = { ...settings, ...updates } + return settings + }) + } + const runtimeAuth = { + clearLastWrittenCredentialsJson: vi.fn(), + forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) + } + const rateLimits = { + evictInactiveClaudeCache: vi.fn(), + refreshForClaudeAccountChange: vi.fn() + } + const service = new ClaudeAccountService( + store as never, + rateLimits as never, + runtimeAuth as never + ) + + const addPromise = service.addAccount() + await vi.waitFor(() => { + expect(spawnMock).toHaveBeenCalledTimes(1) + }) + + expect(service.cancelPendingLogin()).toBe(true) + await expect(addPromise).rejects.toThrow('Claude sign-in was cancelled.') + expect(child.kill).toHaveBeenCalledTimes(1) + expect(destroyStdin).toHaveBeenCalledTimes(1) + expect(service.cancelPendingLogin()).toBe(false) + expect(settings.claudeManagedAccounts).toEqual([]) + expect(child.stdout.listenerCount('data')).toBe(0) + expect(child.stderr.listenerCount('data')).toBe(0) + expect(child.listenerCount('error')).toBe(0) + expect(child.listenerCount('close')).toBe(0) + } finally { + vi.doUnmock('node:child_process') + } + }) + + it('honors cancel before Claude login command starts', async () => { + setPlatform('linux') + vi.resetModules() + let releaseKeychainRead: (value: string | null) => void = () => {} + vi.mocked(readActiveClaudeKeychainCredentials).mockReturnValue( + new Promise((resolve) => { + releaseKeychainRead = resolve + }) + ) + const spawnMock = vi.fn() + vi.doMock('node:child_process', () => ({ spawn: spawnMock })) + + try { + const { ClaudeAccountService } = await import('./service') + let settings = { + claudeManagedAccounts: [], + activeClaudeManagedAccountId: null, + activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: {} } + } + const store = { + getSettings: vi.fn(() => settings), + updateSettings: vi.fn((updates: Partial) => { + settings = { ...settings, ...updates } + return settings + }) + } + const runtimeAuth = { + clearLastWrittenCredentialsJson: vi.fn(), + forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) + } + const rateLimits = { + evictInactiveClaudeCache: vi.fn(), + refreshForClaudeAccountChange: vi.fn() + } + const service = new ClaudeAccountService( + store as never, + rateLimits as never, + runtimeAuth as never + ) + + const addPromise = service.addAccount() + await vi.waitFor(() => { + expect(readActiveClaudeKeychainCredentials).toHaveBeenCalled() + }) + + expect(service.cancelPendingLogin()).toBe(true) + expect(service.cancelPendingLogin()).toBe(false) + expect(spawnMock).not.toHaveBeenCalled() + releaseKeychainRead(null) + await expect(addPromise).rejects.toThrow('Claude sign-in was cancelled.') + expect(spawnMock).not.toHaveBeenCalled() + expect(service.cancelPendingLogin()).toBe(false) + expect(settings.claudeManagedAccounts).toEqual([]) + } finally { + vi.doUnmock('node:child_process') + } + }) + + it('supersedes the login a closed Settings pane abandoned instead of queueing behind it', async () => { + setPlatform('linux') + vi.resetModules() + vi.mocked(readActiveClaudeKeychainCredentials).mockResolvedValue(null) + const children: (EventEmitter & { stdin: PassThrough; kill: ReturnType })[] = [] + // No pid: the POSIX teardown signals -pid as a process group, which must + // never reach a real group from a test double. + const spawnMock = vi.fn(() => { + const child = Object.assign(new EventEmitter(), { + stdin: new PassThrough(), + stdout: new PassThrough(), + stderr: new PassThrough(), + kill: vi.fn() + }) + children.push(child) + return child + }) + vi.doMock('node:child_process', () => ({ spawn: spawnMock })) + + try { + const { ClaudeAccountService } = await import('./service') + let settings = { + claudeManagedAccounts: [], + activeClaudeManagedAccountId: null, + activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: {} } + } + const store = { + getSettings: vi.fn(() => settings), + updateSettings: vi.fn((updates: Partial) => { + settings = { ...settings, ...updates } + return settings + }) + } + const runtimeAuth = { + clearLastWrittenCredentialsJson: vi.fn(), + syncForCurrentSelection: vi.fn(async () => {}), + forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) + } + const rateLimits = { + evictInactiveClaudeCache: vi.fn(), + refreshForClaudeAccountChange: vi.fn() + } + const service = new ClaudeAccountService( + asServiceDouble(store), + asServiceDouble(rateLimits), + asServiceDouble(runtimeAuth) + ) + + const abandoned = service.addAccount({ runtime: 'host' }) + const abandonedRejection = expect(abandoned).rejects.toThrow('Claude sign-in was cancelled.') + await vi.waitFor(() => { + expect(children.length).toBe(1) + }) + + // The user reopens Settings and clicks Add Account again. + const retry = service.addAccount({ runtime: 'host' }) + const retryRejection = expect(retry).rejects.toThrow() + + await abandonedRejection + expect(children[0].kill).toHaveBeenCalled() + // Why: the point of the fix — the second login starts now, not after the + // abandoned one's whole sign-in deadline elapses. + await vi.waitFor(() => { + expect(children.length).toBe(2) + }) + + children[1].emit('close', 1) + await retryRejection + } finally { + vi.doUnmock('node:child_process') + } + }) + + it('uses taskkill to cancel the Windows Claude login process tree', async () => { + setPlatform('win32') + vi.resetModules() + vi.mocked(readActiveClaudeKeychainCredentials).mockResolvedValue(null) + const child = new EventEmitter() as EventEmitter & { + pid: number + stdin: PassThrough + stdout: PassThrough + stderr: PassThrough + kill: ReturnType + } + child.pid = 1234 + child.stdin = new PassThrough() + child.stdout = new PassThrough() + child.stderr = new PassThrough() + child.kill = vi.fn() + const destroyStdin = vi.spyOn(child.stdin, 'destroy') + const taskkill = new EventEmitter() + const spawnMock = vi.fn((command: string) => (command === 'taskkill.exe' ? taskkill : child)) + const cleanupInteractiveLogin = vi.fn() + let publishTerminationPid: (pid: number) => void = () => {} + const buildInteractiveLoginSpawn = vi.fn(() => ({ + command: getCmdExePath(), + args: ['/d', '/c', 'start', '', '/wait', 'claude', 'auth', 'login', '--claudeai'], + stdio: 'ignore' as const, + windowsHide: true, + cleanup: cleanupInteractiveLogin, + getTerminationPid: () => null, + waitForTerminationPid: () => + new Promise((resolve) => { + publishTerminationPid = resolve + }) + })) + vi.doMock('node:child_process', () => ({ spawn: spawnMock })) + vi.doMock('../../shared/windows-interactive-login-spawn', () => ({ + buildWindowsHostInteractiveLoginSpawn: buildInteractiveLoginSpawn + })) + + try { + const { ClaudeAccountService } = await import('./service') + let settings = { + claudeManagedAccounts: [], + activeClaudeManagedAccountId: null, + activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: {} } + } + const store = { + getSettings: vi.fn(() => settings), + updateSettings: vi.fn((updates: Partial) => { + settings = { ...settings, ...updates } + return settings + }) + } + const runtimeAuth = { + clearLastWrittenCredentialsJson: vi.fn(), + forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) + } + const rateLimits = { + evictInactiveClaudeCache: vi.fn(), + refreshForClaudeAccountChange: vi.fn() + } + const service = new ClaudeAccountService( + store as never, + rateLimits as never, + runtimeAuth as never + ) + + const addPromise = service.addAccount() + await vi.waitFor(() => { + expect(buildInteractiveLoginSpawn).toHaveBeenCalledWith('claude', [ + 'auth', + 'login', + '--claudeai' + ]) + expect(spawnMock).toHaveBeenCalledWith( + getCmdExePath(), + ['/d', '/c', 'start', '', '/wait', 'claude', 'auth', 'login', '--claudeai'], + expect.objectContaining({ stdio: 'ignore', windowsHide: true }) + ) + }) + + expect(service.cancelPendingLogin()).toBe(true) + const rejection = expect(addPromise).rejects.toThrow('Claude sign-in was cancelled.') + expect(child.kill).not.toHaveBeenCalled() + expect(spawnMock).not.toHaveBeenCalledWith( + 'taskkill.exe', + expect.anything(), + expect.anything() + ) + publishTerminationPid(9876) + await vi.waitFor(() => { + expect(spawnMock).toHaveBeenCalledWith( + 'taskkill.exe', + ['/pid', '9876', '/t', '/f'], + expect.objectContaining({ stdio: 'ignore', windowsHide: true }) + ) + }) + expect(destroyStdin).not.toHaveBeenCalled() + taskkill.emit('close', 0) + await rejection + expect(destroyStdin).toHaveBeenCalledTimes(1) + expect(cleanupInteractiveLogin).toHaveBeenCalledTimes(1) + expect(service.cancelPendingLogin()).toBe(false) + } finally { + vi.doUnmock('node:child_process') + vi.doUnmock('../../shared/windows-interactive-login-spawn') + } + }) +}) diff --git a/src/main/claude-accounts/claude-account-service-reauth-rollback.test.ts b/src/main/claude-accounts/claude-account-service-reauth-rollback.test.ts new file mode 100644 index 000000000000..2ad1eb6356d1 --- /dev/null +++ b/src/main/claude-accounts/claude-account-service-reauth-rollback.test.ts @@ -0,0 +1,420 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + readManagedClaudeKeychainCredentials, + writeManagedClaudeKeychainCredentials +} from './keychain' +import { + resetClaudeKeychainMocks, + restorePlatform, + setPlatform +} from './claude-account-service-test-harness' + +const CLAUDE_SERVICE_TEST_ROOT = join(tmpdir(), 'orca-claude-service-reauth-test') + +vi.mock('electron', () => ({ + app: { + getPath: () => CLAUDE_SERVICE_TEST_ROOT + } +})) + +const commandMocks = vi.hoisted(() => ({ + resolveClaudeCommand: vi.fn(() => 'claude') +})) + +vi.mock('../codex-cli/command', () => ({ + resolveClaudeCommand: commandMocks.resolveClaudeCommand +})) + +vi.mock('./keychain', () => ({ + deleteActiveClaudeKeychainCredentialsStrict: vi.fn(async () => {}), + deleteManagedClaudeKeychainCredentials: vi.fn(async () => {}), + readActiveClaudeKeychainCredentials: vi.fn(), + readActiveClaudeKeychainCredentialsStrict: vi.fn(), + readManagedClaudeKeychainCredentials: vi.fn(), + writeActiveClaudeKeychainCredentials: vi.fn(async () => {}), + writeManagedClaudeKeychainCredentials: vi.fn(async () => {}) +})) + +describe('ClaudeAccountService credential capture', () => { + let tempDir: string | null = null + + beforeEach(() => { + setPlatform('darwin') + tempDir = null + resetClaudeKeychainMocks() + }) + + afterEach(() => { + restorePlatform() + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }) + } + }) + + it('restores previous managed auth when reauth materialization fails', async () => { + setPlatform('linux') + tempDir = CLAUDE_SERVICE_TEST_ROOT + rmSync(tempDir, { recursive: true, force: true }) + const managedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') + mkdirSync(managedAuthPath, { recursive: true }) + writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') + writeFileSync(join(managedAuthPath, '.credentials.json'), '{"old":true}\n', 'utf-8') + writeFileSync(join(managedAuthPath, 'oauth-account.json'), '{"oldOauth":true}\n', 'utf-8') + let settings = { + claudeManagedAccounts: [ + { + id: 'account-1', + email: 'old@example.com', + managedAuthPath, + authMethod: 'subscription-oauth', + organizationUuid: null, + organizationName: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeClaudeManagedAccountId: 'account-1' + } + const store = { + getSettings: vi.fn(() => settings), + updateSettings: vi.fn((updates: Partial) => { + settings = { ...settings, ...updates } + return settings + }) + } + const runtimeAuth = { + clearLastWrittenCredentialsJson: vi.fn(), + forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}), + syncForCurrentSelection: vi.fn(async () => { + throw new Error('materialize failed') + }) + } + const rateLimits = { evictInactiveClaudeCache: vi.fn(), refreshForClaudeAccountChange: vi.fn() } + const { ClaudeAccountService } = await import('./service') + const service = new ClaudeAccountService( + store as never, + rateLimits as never, + runtimeAuth as never + ) + ;( + service as unknown as { + runClaudeLoginAndCapture(): Promise<{ + credentialsJson: string + oauthAccount: unknown + identity: { email: string; organizationUuid: null; organizationName: null } + }> + } + ).runClaudeLoginAndCapture = vi.fn(async () => ({ + credentialsJson: '{"new":true}\n', + oauthAccount: { newOauth: true }, + identity: { email: 'new@example.com', organizationUuid: null, organizationName: null } + })) + + await expect(service.reauthenticateAccount('account-1')).rejects.toThrow('materialize failed') + + expect(readFileSync(join(managedAuthPath, '.credentials.json'), 'utf-8')).toBe('{"old":true}\n') + expect(readFileSync(join(managedAuthPath, 'oauth-account.json'), 'utf-8')).toBe( + '{"oldOauth":true}\n' + ) + expect(store.getSettings().claudeManagedAccounts[0].email).toBe('old@example.com') + expect(runtimeAuth.forceMaterializeCurrentSelectionForRollback).toHaveBeenCalled() + }) + + it('restores settings without rematerializing when managed-auth rollback write fails', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + tempDir = CLAUDE_SERVICE_TEST_ROOT + rmSync(tempDir, { recursive: true, force: true }) + const managedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') + mkdirSync(managedAuthPath, { recursive: true }) + writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') + writeFileSync(join(managedAuthPath, 'oauth-account.json'), '{"oldOauth":true}\n', 'utf-8') + vi.mocked(readManagedClaudeKeychainCredentials).mockResolvedValue('{"old":true}\n') + vi.mocked(writeManagedClaudeKeychainCredentials) + .mockResolvedValueOnce() + .mockRejectedValueOnce(new Error('managed restore failed')) + let settings = { + claudeManagedAccounts: [ + { + id: 'account-1', + email: 'old@example.com', + managedAuthPath, + authMethod: 'subscription-oauth', + organizationUuid: null, + organizationName: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeClaudeManagedAccountId: 'account-1' + } + const store = { + getSettings: vi.fn(() => settings), + updateSettings: vi.fn((updates: Partial) => { + settings = { ...settings, ...updates } + return settings + }) + } + const runtimeAuth = { + clearLastWrittenCredentialsJson: vi.fn(), + forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}), + syncForCurrentSelection: vi.fn(async () => { + throw new Error('materialize failed') + }) + } + const rateLimits = { evictInactiveClaudeCache: vi.fn(), refreshForClaudeAccountChange: vi.fn() } + const { ClaudeAccountService } = await import('./service') + const service = new ClaudeAccountService( + store as never, + rateLimits as never, + runtimeAuth as never + ) + ;( + service as unknown as { + runClaudeLoginAndCapture(): Promise<{ + credentialsJson: string + oauthAccount: unknown + identity: { email: string; organizationUuid: null; organizationName: null } + }> + } + ).runClaudeLoginAndCapture = vi.fn(async () => ({ + credentialsJson: '{"new":true}\n', + oauthAccount: { newOauth: true }, + identity: { email: 'new@example.com', organizationUuid: null, organizationName: null } + })) + + await expect(service.reauthenticateAccount('account-1')).rejects.toThrow('materialize failed') + + expect(store.getSettings().claudeManagedAccounts[0].email).toBe('new@example.com') + expect(runtimeAuth.forceMaterializeCurrentSelectionForRollback).not.toHaveBeenCalled() + expect(warn).toHaveBeenCalledWith( + '[claude-accounts] Failed to restore managed credentials during rollback:', + expect.any(Error) + ) + warn.mockRestore() + }) + + it('restores oauth metadata when new credential write and credential rollback fail', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + tempDir = CLAUDE_SERVICE_TEST_ROOT + rmSync(tempDir, { recursive: true, force: true }) + const managedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') + mkdirSync(managedAuthPath, { recursive: true }) + writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') + writeFileSync(join(managedAuthPath, 'oauth-account.json'), '{"oldOauth":true}\n', 'utf-8') + vi.mocked(readManagedClaudeKeychainCredentials).mockResolvedValue('{"old":true}\n') + vi.mocked(writeManagedClaudeKeychainCredentials) + .mockRejectedValueOnce(new Error('new credentials failed')) + .mockRejectedValueOnce(new Error('credential rollback failed')) + let settings = { + claudeManagedAccounts: [ + { + id: 'account-1', + email: 'old@example.com', + managedAuthPath, + authMethod: 'subscription-oauth', + organizationUuid: null, + organizationName: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeClaudeManagedAccountId: 'account-1' + } + const store = { + getSettings: vi.fn(() => settings), + updateSettings: vi.fn((updates: Partial) => { + settings = { ...settings, ...updates } + return settings + }) + } + const runtimeAuth = { + clearLastWrittenCredentialsJson: vi.fn(), + forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}), + syncForCurrentSelection: vi.fn() + } + const rateLimits = { evictInactiveClaudeCache: vi.fn(), refreshForClaudeAccountChange: vi.fn() } + const { ClaudeAccountService } = await import('./service') + const service = new ClaudeAccountService( + store as never, + rateLimits as never, + runtimeAuth as never + ) + ;( + service as unknown as { + runClaudeLoginAndCapture(): Promise<{ + credentialsJson: string + oauthAccount: unknown + identity: { email: string; organizationUuid: null; organizationName: null } + }> + } + ).runClaudeLoginAndCapture = vi.fn(async () => ({ + credentialsJson: '{"new":true}\n', + oauthAccount: { newOauth: true }, + identity: { email: 'new@example.com', organizationUuid: null, organizationName: null } + })) + + await expect(service.reauthenticateAccount('account-1')).rejects.toThrow( + 'new credentials failed' + ) + + expect(readFileSync(join(managedAuthPath, 'oauth-account.json'), 'utf-8')).toBe( + '{"oldOauth":true}\n' + ) + expect(store.getSettings().claudeManagedAccounts[0].email).toBe('old@example.com') + expect(runtimeAuth.forceMaterializeCurrentSelectionForRollback).not.toHaveBeenCalled() + expect(warn).toHaveBeenCalledWith( + '[claude-accounts] Failed to restore managed credentials during rollback:', + expect.any(Error) + ) + warn.mockRestore() + }) + + it('restores old metadata when rollback restores credentials but oauth restore fails', async () => { + setPlatform('linux') + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + tempDir = CLAUDE_SERVICE_TEST_ROOT + rmSync(tempDir, { recursive: true, force: true }) + const managedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') + const oauthPath = join(managedAuthPath, 'oauth-account.json') + mkdirSync(managedAuthPath, { recursive: true }) + writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') + writeFileSync(join(managedAuthPath, '.credentials.json'), '{"old":true}\n', 'utf-8') + writeFileSync(oauthPath, '{"oldOauth":true}\n', 'utf-8') + let settings = { + claudeManagedAccounts: [ + { + id: 'account-1', + email: 'old@example.com', + managedAuthPath, + authMethod: 'subscription-oauth', + organizationUuid: null, + organizationName: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeClaudeManagedAccountId: 'account-1' + } + const store = { + getSettings: vi.fn(() => settings), + updateSettings: vi.fn((updates: Partial) => { + settings = { ...settings, ...updates } + return settings + }) + } + const runtimeAuth = { + clearLastWrittenCredentialsJson: vi.fn(), + forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}), + syncForCurrentSelection: vi.fn(async () => { + rmSync(oauthPath, { force: true }) + mkdirSync(oauthPath) + throw new Error('materialize failed') + }) + } + const rateLimits = { evictInactiveClaudeCache: vi.fn(), refreshForClaudeAccountChange: vi.fn() } + const { ClaudeAccountService } = await import('./service') + const service = new ClaudeAccountService( + store as never, + rateLimits as never, + runtimeAuth as never + ) + ;( + service as unknown as { + runClaudeLoginAndCapture(): Promise<{ + credentialsJson: string + oauthAccount: unknown + identity: { email: string; organizationUuid: null; organizationName: null } + }> + } + ).runClaudeLoginAndCapture = vi.fn(async () => ({ + credentialsJson: '{"new":true}\n', + oauthAccount: { newOauth: true }, + identity: { email: 'new@example.com', organizationUuid: null, organizationName: null } + })) + + await expect(service.reauthenticateAccount('account-1')).rejects.toThrow('materialize failed') + + expect(readFileSync(join(managedAuthPath, '.credentials.json'), 'utf-8')).toBe('{"old":true}\n') + expect(store.getSettings().claudeManagedAccounts[0].email).toBe('old@example.com') + expect(runtimeAuth.forceMaterializeCurrentSelectionForRollback).toHaveBeenCalled() + expect(warn).toHaveBeenCalled() + warn.mockRestore() + }) + + it('evicts inactive rate-limit cache after successful reauth', async () => { + setPlatform('linux') + tempDir = CLAUDE_SERVICE_TEST_ROOT + rmSync(tempDir, { recursive: true, force: true }) + const managedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') + mkdirSync(managedAuthPath, { recursive: true }) + writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') + writeFileSync(join(managedAuthPath, '.credentials.json'), '{"old":true}\n', 'utf-8') + writeFileSync(join(managedAuthPath, 'oauth-account.json'), '{"oldOauth":true}\n', 'utf-8') + let settings = { + claudeManagedAccounts: [ + { + id: 'account-1', + email: 'old@example.com', + managedAuthPath, + authMethod: 'subscription-oauth', + organizationUuid: null, + organizationName: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeClaudeManagedAccountId: null + } + const store = { + getSettings: vi.fn(() => settings), + updateSettings: vi.fn((updates: Partial) => { + settings = { ...settings, ...updates } + return settings + }) + } + const runtimeAuth = { + clearLastWrittenCredentialsJson: vi.fn(), + syncForCurrentSelection: vi.fn(async () => {}), + forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) + } + const rateLimits = { + evictInactiveClaudeCache: vi.fn(), + refreshForClaudeAccountChange: vi.fn(async () => ({ accounts: [], activeAccountId: null })) + } + const { ClaudeAccountService } = await import('./service') + const service = new ClaudeAccountService( + store as never, + rateLimits as never, + runtimeAuth as never + ) + ;( + service as unknown as { + runClaudeLoginAndCapture(): Promise<{ + credentialsJson: string + oauthAccount: unknown + identity: { email: string; organizationUuid: null; organizationName: null } + }> + } + ).runClaudeLoginAndCapture = vi.fn(async () => ({ + credentialsJson: '{"new":true}\n', + oauthAccount: { newOauth: true }, + identity: { email: 'new@example.com', organizationUuid: null, organizationName: null } + })) + + await service.reauthenticateAccount('account-1') + + expect(rateLimits.evictInactiveClaudeCache).toHaveBeenCalledWith('account-1') + expect(rateLimits.refreshForClaudeAccountChange).toHaveBeenCalledWith(undefined, { + runtime: 'host' + }) + expect(settings.claudeManagedAccounts[0].email).toBe('new@example.com') + }) +}) diff --git a/src/main/claude-accounts/claude-account-service-test-harness.ts b/src/main/claude-accounts/claude-account-service-test-harness.ts new file mode 100644 index 000000000000..d66d1f3ea51b --- /dev/null +++ b/src/main/claude-accounts/claude-account-service-test-harness.ts @@ -0,0 +1,41 @@ +import { vi } from 'vitest' +import { + deleteActiveClaudeKeychainCredentialsStrict, + readActiveClaudeKeychainCredentials, + readActiveClaudeKeychainCredentialsStrict, + readManagedClaudeKeychainCredentials, + writeActiveClaudeKeychainCredentials, + writeManagedClaudeKeychainCredentials +} from './keychain' + +// Each suite declares its own `vi.mock('./keychain', ...)` (factories are hoisted and +// cannot close over this module); this only re-arms the doubles between tests. +export function resetClaudeKeychainMocks(): void { + vi.mocked(readActiveClaudeKeychainCredentials).mockReset() + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockReset() + vi.mocked(readManagedClaudeKeychainCredentials).mockReset() + vi.mocked(deleteActiveClaudeKeychainCredentialsStrict).mockClear() + vi.mocked(writeActiveClaudeKeychainCredentials).mockReset() + vi.mocked(writeActiveClaudeKeychainCredentials).mockResolvedValue() + vi.mocked(writeManagedClaudeKeychainCredentials).mockReset() + vi.mocked(writeManagedClaudeKeychainCredentials).mockResolvedValue() +} + +const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + +export function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { + configurable: true, + value: platform + }) +} + +export function restorePlatform(): void { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } +} + +export function createService(): unknown { + return {} +} diff --git a/src/main/claude-accounts/claude-account-service.test.ts b/src/main/claude-accounts/claude-account-service.test.ts deleted file mode 100644 index 55c4304ca626..000000000000 --- a/src/main/claude-accounts/claude-account-service.test.ts +++ /dev/null @@ -1,349 +0,0 @@ -import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { getDefaultSettings } from '../../shared/constants' -import type { GlobalSettings } from '../../shared/global-settings-types' -import type { ClaudeManagedAccount } from '../../shared/managed-account-types' -import { CLAUDE_SIGN_IN_NOT_FINISHED_MESSAGE } from './claude-account-registration' -import { - CLAUDE_ACCOUNT_FOLDER_IN_USE_MESSAGE, - CLAUDE_ACCOUNT_NEEDS_SIGN_IN_MESSAGE -} from './claude-account-selection' -import type { ClaudeCommandConfig, ClaudeCommandOptions } from './claude-command-process' -import type { ClaudeAccountSelectionTarget } from './runtime-selection' -import { ClaudeAccountService } from './service' - -const roots: string[] = [] -afterEach(() => roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true }))) - -function account(id: string, extra: Partial = {}): ClaudeManagedAccount { - return { - id, - email: `${id}@example.test`, - managedAuthPath: '', - managedAuthRuntime: 'host', - wslDistro: null, - authMethod: 'subscription-oauth', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1, - ...extra - } -} - -function fixture(accounts: ClaudeManagedAccount[] = [account('a'), account('b')]) { - const root = mkdtempSync(join(tmpdir(), 'claude-accounts-')) - roots.push(root) - const home = (id: string) => join(root, id, 'home') - // Stands in for Claude finishing `claude auth login` in a folder. - const signIn = (id: string, email: string) => { - mkdirSync(home(id), { recursive: true }) - writeFileSync( - join(home(id), '.claude.json'), - JSON.stringify({ oauthAccount: { emailAddress: email } }) - ) - } - let settings: GlobalSettings = { - ...getDefaultSettings('/tmp'), - claudeManagedAccounts: accounts, - activeClaudeManagedAccountId: 'a', - activeClaudeManagedAccountIdsByRuntime: { host: 'a', wsl: {} } - } - const runtimeAuth = { - router: { - accountHome: home, - userConfigDir: () => join(root, 'personal'), - copiedLoginIntoSystemDefault: () => existsSync(join(root, 'claude-runtime-auth')) - }, - syncForCurrentSelection: vi.fn(async (_target?: ClaudeAccountSelectionTarget) => {}), - publishAll: vi.fn(async () => {}), - prepareAccountFolder: vi.fn(async (id: string) => { - mkdirSync(home(id), { recursive: true }) - return { configDir: home(id), readPath: home(id) } - }), - removeAccountFolder: vi.fn(async (id: string) => rmSync(join(root, id), { recursive: true })), - getRuntimeConfigDir: () => '/unused' - } - // Stands in for the hidden `claude auth login`; each test decides how it ends. - const runLogin = vi.fn< - ( - args: string[], - config: ClaudeCommandConfig, - timeoutMs: number, - options?: ClaudeCommandOptions - ) => Promise - >(async () => '') - const service = new ClaudeAccountService( - { - getSettings: () => settings, - updateSettings: (patch) => { - settings = { ...settings, ...patch } - } - }, - { - evictInactiveClaudeCache: vi.fn(), - refreshForClaudeAccountChange: vi.fn().mockResolvedValue(undefined) - }, - runtimeAuth, - runLogin - ) - return { root, home, service, runtimeAuth, runLogin, signIn, settings: () => settings } -} - -// The id the last sign-in prepared a folder for. -function newId(f: ReturnType): string { - return f.runtimeAuth.prepareAccountFolder.mock.calls.at(-1)![0] -} - -describe('ClaudeAccountService', () => { - it("labels each row with its folder's login and asks a folder without one to sign in", () => { - const f = fixture([ - account('a'), - account('b'), - account('old-wsl', { - managedAuthRuntime: 'wsl', - wslDistro: 'Ubuntu', - wslLinuxAuthPath: '/home/u/.local/share/orca/claude-accounts/old-wsl/auth' - }), - account('new-wsl', { - managedAuthRuntime: 'wsl', - wslDistro: 'Ubuntu', - wslLinuxAuthPath: '/home/u/.local/share/orca/claude-profiles/new-wsl/home' - }) - ]) - f.signIn('a', 'now-a@example.test') - const byId = new Map(f.service.listAccounts().accounts.map((row) => [row.id, row])) - expect(byId.get('a')).toMatchObject({ email: 'now-a@example.test' }) - expect(byId.get('a')?.needsSignIn).toBeUndefined() - expect(byId.get('b')).toMatchObject({ email: 'b@example.test', needsSignIn: true }) - expect(byId.get('old-wsl')).toMatchObject({ needsSignIn: true }) - expect(byId.get('new-wsl')?.needsSignIn).toBeUndefined() - }) - - it("reports System default's login from the user's own folder", () => { - const f = fixture() - mkdirSync(join(f.root, 'personal')) - writeFileSync( - join(f.root, 'personal', '.claude.json'), - JSON.stringify({ oauthAccount: { emailAddress: 'me@example.test' } }) - ) - expect(f.service.listAccounts()).toMatchObject({ - systemDefaultEmail: 'me@example.test' - }) - expect(f.service.listAccounts()).not.toHaveProperty('userClaudeConfigDir') - expect(f.service.listAccounts().systemDefaultMayBeCopied).toBeUndefined() - mkdirSync(join(f.root, 'claude-runtime-auth')) - expect(f.service.listAccounts().systemDefaultMayBeCopied).toBe(true) - }) - - it('saves an account only once its folder holds a login', async () => { - const f = fixture() - const begun = await f.service.beginSignIn({ runtime: 'host' }) - expect(begun).toMatchObject({ - runtime: 'host', - configDir: join(f.root, begun.accountId, 'home') - }) - await expect(f.service.finishSignIn(begun)).rejects.toThrow(CLAUDE_SIGN_IN_NOT_FINISHED_MESSAGE) - expect(f.settings().claudeManagedAccounts).toHaveLength(2) - - f.signIn(begun.accountId, 'new@example.test') - await f.service.finishSignIn(begun) - expect(f.settings().claudeManagedAccounts.at(-1)).toMatchObject({ - id: begun.accountId, - email: 'new@example.test', - managedAuthPath: begun.configDir - }) - }) - - it('refuses a second account for the same login and deletes its folder', async () => { - const f = fixture() - const begun = await f.service.beginSignIn({ runtime: 'host' }) - f.signIn(begun.accountId, 'B@example.test') - await expect(f.service.finishSignIn(begun)).rejects.toThrow('already added') - expect(f.runtimeAuth.removeAccountFolder).toHaveBeenCalledWith(begun.accountId, { - runtime: 'host' - }) - expect(f.settings().claudeManagedAccounts).toHaveLength(2) - }) - - it('relabels a saved account signed in again under another login', async () => { - const f = fixture() - const begun = await f.service.beginSignIn({ accountId: 'b' }) - f.signIn('b', 'other@example.test') - await f.service.finishSignIn(begun) - expect(f.settings().claudeManagedAccounts.find((entry) => entry.id === 'b')).toMatchObject({ - email: 'other@example.test', - createdAt: 1 - }) - }) - - it('signs in with a hidden login straight into the new account folder', async () => { - const f = fixture() - f.runLogin.mockImplementationOnce(async (_args, config) => { - f.signIn(newId(f), 'new@example.test') - expect(config).toEqual({ windowsPath: f.home(newId(f)), linuxPath: null, wslDistro: null }) - return '' - }) - await f.service.addAccount({ runtime: 'host' }) - expect(f.runLogin).toHaveBeenCalledWith( - ['auth', 'login', '--claudeai'], - expect.anything(), - expect.any(Number), - expect.objectContaining({ keepStdinOpen: true }) - ) - expect(f.settings().claudeManagedAccounts.at(-1)).toMatchObject({ - id: newId(f), - email: 'new@example.test', - managedAuthPath: f.home(newId(f)) - }) - expect(f.runtimeAuth.removeAccountFolder).not.toHaveBeenCalled() - }) - - it('runs a WSL login against the guest folder', async () => { - const f = fixture() - f.runtimeAuth.prepareAccountFolder.mockImplementation(async (id: string) => { - mkdirSync(f.home(id), { recursive: true }) - return { configDir: `/home/u/claude-profiles/${id}/home`, readPath: f.home(id) } - }) - f.runLogin.mockImplementationOnce(async (_args, config) => { - f.signIn(newId(f), 'wsl@example.test') - expect(config).toEqual({ - windowsPath: f.home(newId(f)), - linuxPath: `/home/u/claude-profiles/${newId(f)}/home`, - wslDistro: 'Ubuntu' - }) - return '' - }) - await f.service.addAccount({ runtime: 'wsl', wslDistro: 'Ubuntu' }) - expect(f.settings().claudeManagedAccounts.at(-1)).toMatchObject({ - email: 'wsl@example.test', - managedAuthRuntime: 'wsl', - wslDistro: 'Ubuntu', - wslLinuxAuthPath: `/home/u/claude-profiles/${newId(f)}/home` - }) - }) - - it('leaves no row or folder when the hidden login is cancelled', async () => { - const f = fixture() - f.runLogin.mockImplementationOnce( - (_args, _config, _timeoutMs, options) => - new Promise((_resolve, reject) => - options?.signal?.addEventListener('abort', () => - reject(new Error('Claude sign-in was cancelled.')) - ) - ) - ) - const adding = f.service.addAccount({ runtime: 'host' }) - await vi.waitFor(() => expect(f.runLogin).toHaveBeenCalled()) - expect(f.service.cancelPendingLogin()).toBe(true) - await expect(adding).rejects.toThrow('Claude sign-in was cancelled.') - expect(f.service.cancelPendingLogin()).toBe(false) - expect(f.settings().claudeManagedAccounts).toHaveLength(2) - expect(f.runtimeAuth.removeAccountFolder).toHaveBeenCalledWith(newId(f), { runtime: 'host' }) - expect(existsSync(join(f.root, newId(f)))).toBe(false) - }) - - it('leaves no row or folder when the hidden login times out or finds no login', async () => { - const f = fixture() - f.runLogin.mockRejectedValueOnce(new Error('Claude sign-in took too long to finish.')) - await expect(f.service.addAccount({ runtime: 'host' })).rejects.toThrow('took too long') - await expect(f.service.addAccount({ runtime: 'host' })).rejects.toThrow( - 'could not resolve the account email' - ) - expect(f.settings().claudeManagedAccounts).toHaveLength(2) - expect(f.runtimeAuth.removeAccountFolder).toHaveBeenCalledTimes(2) - }) - - it('refuses a hidden login to an already added account and deletes its folder', async () => { - const f = fixture() - f.runLogin.mockImplementationOnce(async () => { - f.signIn(newId(f), 'B@example.test') - return '' - }) - await expect(f.service.addAccount({ runtime: 'host' })).rejects.toThrow( - 'This Claude account is already added.' - ) - expect(f.settings().claudeManagedAccounts).toHaveLength(2) - expect(existsSync(join(f.root, newId(f)))).toBe(false) - }) - - it('signs a saved account in again inside its own folder and keeps it on failure', async () => { - const f = fixture() - f.runLogin.mockImplementationOnce(async (_args, config) => { - expect(config.windowsPath).toBe(f.home('b')) - f.signIn('b', 'b@example.test') - return '' - }) - await f.service.reauthenticateAccount('b') - expect(f.runtimeAuth.prepareAccountFolder).toHaveBeenCalledWith('b', { runtime: 'host' }) - expect(f.settings().claudeManagedAccounts.find((entry) => entry.id === 'b')).toMatchObject({ - email: 'b@example.test', - createdAt: 1 - }) - f.runLogin.mockRejectedValueOnce(new Error('Claude sign-in was cancelled.')) - await expect(f.service.reauthenticateAccount('b')).rejects.toThrow('cancelled') - expect(f.runtimeAuth.removeAccountFolder).not.toHaveBeenCalled() - expect(f.settings().claudeManagedAccounts.map((entry) => entry.id)).toEqual(['a', 'b']) - }) - - it('clears the selection, republishes, then deletes the folder on remove', async () => { - const f = fixture() - f.signIn('a', 'a@example.test') - await f.service.removeAccount('a') - expect(f.settings().claudeManagedAccounts.map((entry) => entry.id)).toEqual(['b']) - expect(f.settings().activeClaudeManagedAccountIdsByRuntime?.host).toBeNull() - expect(f.runtimeAuth.syncForCurrentSelection).toHaveBeenCalledWith({ runtime: 'host' }) - expect(f.runtimeAuth.removeAccountFolder).toHaveBeenCalledWith('a', { runtime: 'host' }) - }) - - it("deletes a cancelled sign-in's folder but never a saved account's", async () => { - const f = fixture() - const begun = await f.service.beginSignIn({ runtime: 'host' }) - await f.service.cancelSignIn({ accountId: begun.accountId, runtime: 'host' }) - expect(f.runtimeAuth.removeAccountFolder).toHaveBeenCalledWith(begun.accountId, { - runtime: 'host' - }) - f.runtimeAuth.removeAccountFolder.mockClear() - await f.service.cancelSignIn({ accountId: 'a', runtime: 'host' }) - expect(f.runtimeAuth.removeAccountFolder).not.toHaveBeenCalled() - }) - - it('keeps the row when its folder cannot be deleted, so the user can retry', async () => { - const f = fixture() - f.runtimeAuth.removeAccountFolder.mockRejectedValueOnce( - Object.assign(new Error('resource busy'), { code: 'EBUSY' }) - ) - await expect(f.service.removeAccount('a')).rejects.toThrow(CLAUDE_ACCOUNT_FOLDER_IN_USE_MESSAGE) - expect(f.settings().claudeManagedAccounts.map((entry) => entry.id)).toEqual(['a', 'b']) - expect(f.settings().activeClaudeManagedAccountIdsByRuntime?.host).toBeNull() - }) - - it('puts the previous account back when publishing a new selection fails', async () => { - const f = fixture() - f.signIn('b', 'b@example.test') - f.runtimeAuth.syncForCurrentSelection.mockRejectedValueOnce(new Error('publish failed')) - await expect(f.service.selectAccount('b')).rejects.toThrow('publish failed') - expect(f.settings().activeClaudeManagedAccountIdsByRuntime?.host).toBe('a') - expect(f.runtimeAuth.publishAll).toHaveBeenCalled() - }) - - it('refuses to select an account whose folder holds no login', async () => { - const f = fixture() - await expect(f.service.selectAccount('b')).rejects.toThrow(CLAUDE_ACCOUNT_NEEDS_SIGN_IN_MESSAGE) - expect(f.settings().activeClaudeManagedAccountIdsByRuntime?.host).toBe('a') - f.signIn('b', 'b@example.test') - await f.service.selectAccount('b') - expect(f.settings().activeClaudeManagedAccountIdsByRuntime?.host).toBe('b') - }) - - it('refuses to select a WSL account for the host', async () => { - const f = fixture([ - account('a'), - account('w', { managedAuthRuntime: 'wsl', wslDistro: 'Ubuntu' }) - ]) - await expect(f.service.selectAccountForTarget('w', { runtime: 'host' })).rejects.toThrow( - 'That Claude account belongs to a different runtime.' - ) - }) -}) diff --git a/src/main/claude-accounts/claude-auth-capture.ts b/src/main/claude-accounts/claude-auth-capture.ts new file mode 100644 index 000000000000..2acaa4408683 --- /dev/null +++ b/src/main/claude-accounts/claude-auth-capture.ts @@ -0,0 +1,176 @@ +import { createHash } from 'node:crypto' +import { existsSync, readFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { readActiveClaudeKeychainCredentialsStrict } from './keychain' + +export type ClaudeIdentity = { + email: string | null + organizationUuid: string | null + organizationName: string | null +} + +export type CapturedClaudeAuth = { + credentialsJson: string + oauthAccount: unknown + identity: ClaudeIdentity +} + +export type ClaudeCaptureCommand = ( + args: string[], + configDir: { windowsPath: string; linuxPath: string | null; wslDistro: string | null }, + timeoutMs: number, + options?: { allowFailure?: boolean } +) => Promise + +const STATUS_TIMEOUT_MS = 20_000 + +export async function captureClaudeAuthFromExistingConfigDir( + configDir: string, + previousLegacyCredentialsSha256: string | null | undefined, + runCommand: ClaudeCaptureCommand +): Promise { + const trimmed = configDir.trim() + if (!trimmed) { + throw new Error('A Claude config directory path is required.') + } + const resolvedDir = resolve(trimmed) + if (process.platform !== 'darwin' && !existsSync(join(resolvedDir, '.credentials.json'))) { + throw new Error( + `No Claude credentials found in ${resolvedDir}. Run \`claude login\` into this directory first.` + ) + } + + let status = '' + try { + status = await runCommand( + ['auth', 'status', '--json'], + { windowsPath: resolvedDir, linuxPath: null, wslDistro: null }, + STATUS_TIMEOUT_MS, + { allowFailure: true } + ) + } catch (error) { + console.warn('[claude-accounts] Could not read `claude auth status`:', error) + } + const currentLegacyKeychain = await readActiveClaudeKeychainCredentialsStrict() + return captureClaudeAuthFromConfigDir( + resolvedDir, + status, + currentLegacyKeychain, + previousLegacyCredentialsSha256 + ) +} + +export async function captureClaudeAuthFromConfigDir( + configDir: string, + statusOutput: string, + previousLegacyKeychain: string | null, + previousLegacyCredentialsSha256?: string | null, + readCredentials = readCapturedClaudeCredentials +): Promise { + const credentialsJson = await readCredentials( + configDir, + previousLegacyKeychain, + previousLegacyCredentialsSha256 + ) + if (!credentialsJson) { + throw new Error('Claude login completed, but no OAuth credentials were captured.') + } + const oauthAccount = readClaudeOauthAccount(configDir) + return { + credentialsJson, + oauthAccount, + identity: resolveClaudeIdentity(statusOutput, oauthAccount, credentialsJson) + } +} + +export async function readCapturedClaudeCredentials( + configDir: string, + previousLegacyKeychain: string | null, + previousLegacyCredentialsSha256?: string | null +): Promise { + if (process.platform === 'darwin') { + const scopedCredentialsJson = await readActiveClaudeKeychainCredentialsStrict(configDir) + if (scopedCredentialsJson) { + return scopedCredentialsJson + } + const legacyCredentialsJson = await readActiveClaudeKeychainCredentialsStrict() + const legacyChanged = + previousLegacyCredentialsSha256 === undefined + ? legacyCredentialsJson !== previousLegacyKeychain + : legacyCredentialsJson !== null && + createHash('sha256').update(legacyCredentialsJson).digest('hex') !== + previousLegacyCredentialsSha256 + if (legacyCredentialsJson && legacyChanged) { + return legacyCredentialsJson + } + } + const credentialsPath = join(configDir, '.credentials.json') + return existsSync(credentialsPath) ? readFileSync(credentialsPath, 'utf-8') : null +} + +function readClaudeOauthAccount(configDir: string): unknown { + for (const configPath of [join(configDir, '.claude.json'), join(configDir, '.config.json')]) { + if (!existsSync(configPath)) { + continue + } + try { + const parsed = JSON.parse(readFileSync(configPath, 'utf-8')) as Record + if (parsed.oauthAccount) { + return parsed.oauthAccount + } + } catch { + continue + } + } + return null +} + +function resolveClaudeIdentity( + statusOutput: string, + oauthAccount: unknown, + credentialsJson: string +): ClaudeIdentity { + const status = parseJsonObject(statusOutput) + const oauth = asRecord(oauthAccount) + const credentialOauth = asRecord(parseJsonObject(credentialsJson)?.claudeAiOauth) + return { + email: normalizeField( + readString(status, 'email') ?? + readString(oauth, 'emailAddress') ?? + readString(oauth, 'email') ?? + readString(credentialOauth, 'email') + ), + organizationUuid: normalizeField( + readString(status, 'organizationUuid') ?? + readString(status, 'organizationId') ?? + readString(oauth, 'organizationUuid') ?? + readString(oauth, 'organizationId') + ), + organizationName: normalizeField( + readString(status, 'organizationName') ?? readString(oauth, 'organizationName') + ) + } +} + +function parseJsonObject(value: string): Record | null { + try { + return asRecord(JSON.parse(value) as unknown) + } catch { + return null + } +} + +function asRecord(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null +} + +function readString(value: Record | null, key: string): string | null { + return typeof value?.[key] === 'string' ? value[key] : null +} + +function normalizeField(value: string | null | undefined): string | null { + const trimmed = value?.trim() + return trimmed ? trimmed : null +} diff --git a/src/main/claude-accounts/claude-command-process.test.ts b/src/main/claude-accounts/claude-command-process.test.ts deleted file mode 100644 index 514a8ed4d487..000000000000 --- a/src/main/claude-accounts/claude-command-process.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { EventEmitter } from 'node:events' -import { PassThrough } from 'node:stream' -import { afterEach, describe, expect, it, vi } from 'vitest' - -type SpawnSpec = { program: string; args?: readonly string[]; env?: NodeJS.ProcessEnv } -const spawned = vi.hoisted(() => { - const specs: SpawnSpec[] = [] - return { specs } -}) - -vi.mock('../../shared/child-process/run-process', () => ({ - spawnProcess: (spec: SpawnSpec) => { - spawned.specs.push(spec) - const child = Object.assign(new EventEmitter(), { - stdin: new PassThrough(), - stdout: new PassThrough(), - stderr: new PassThrough(), - pid: undefined, - kill: vi.fn() - }) - queueMicrotask(() => child.emit('close', 0)) - return child - } -})) -vi.mock('../codex-cli/command', () => ({ resolveClaudeCommand: () => 'claude' })) -vi.mock('../wsl/wsl-executable-path', () => ({ - resolveWslExecutablePath: () => 'C:\\Windows\\System32\\wsl.exe' -})) - -import { buildWslLoginShellCommand } from '../../shared/wsl-login-shell-command' -import { runClaudeCommandProcess } from './claude-command-process' - -const originalPlatform = process.platform -afterEach(() => { - spawned.specs.length = 0 - Object.defineProperty(process, 'platform', { value: originalPlatform }) -}) - -describe('runClaudeCommandProcess', () => { - it("logs in on the host with CLAUDE_CONFIG_DIR set to the account's folder", async () => { - Object.defineProperty(process, 'platform', { value: 'darwin' }) - await runClaudeCommandProcess( - ['auth', 'login', '--claudeai'], - { windowsPath: '/data/claude-profiles/a/home', linuxPath: null, wslDistro: null }, - 1000 - ) - expect(spawned.specs[0]).toMatchObject({ - program: 'claude', - args: ['auth', 'login', '--claudeai'] - }) - expect(spawned.specs[0].env?.CLAUDE_CONFIG_DIR).toBe('/data/claude-profiles/a/home') - }) - - it("logs in through the distro's login shell with --exec for a WSL account", async () => { - Object.defineProperty(process, 'platform', { value: 'win32' }) - await runClaudeCommandProcess( - ['auth', 'login', '--claudeai'], - { - windowsPath: '\\\\wsl.localhost\\Ubuntu\\home\\u\\a', - linuxPath: "/home/u/it's/a", - wslDistro: 'Ubuntu' - }, - 1000 - ) - const spec = spawned.specs[0] - expect(spec.program).toBe('C:\\Windows\\System32\\wsl.exe') - expect(spec.args?.slice(0, 5)).toEqual(['-d', 'Ubuntu', '--exec', '/bin/sh', '-c']) - expect(spec.args?.[5]).toBe( - buildWslLoginShellCommand( - `exec env CLAUDE_CONFIG_DIR='/home/u/it'\\''s/a' claude 'auth' 'login' '--claudeai'` - ) - ) - expect(spec.env?.CLAUDE_CONFIG_DIR).toBe(process.env.CLAUDE_CONFIG_DIR) - }) -}) diff --git a/src/main/claude-accounts/claude-command-process.ts b/src/main/claude-accounts/claude-command-process.ts index 98b867d02a78..78fe109f79f8 100644 --- a/src/main/claude-accounts/claude-command-process.ts +++ b/src/main/claude-accounts/claude-command-process.ts @@ -4,13 +4,7 @@ import { buildWindowsHostInteractiveLoginSpawn, type WindowsHostInteractiveLoginSpawn } from '../../shared/windows-interactive-login-spawn' -import { - buildWslExecArgs, - buildWslLoginShellCommand, - quotePosixShell -} from '../../shared/wsl-login-shell-command' import { resolveClaudeCommand } from '../codex-cli/command' -import { resolveWslExecutablePath } from '../wsl/wsl-executable-path' import { buildWindowsCommandInvocation } from './windows-command-invocation' import { terminateClaudeProcess } from './claude-login-process-termination' @@ -25,10 +19,15 @@ export type ClaudeCommandConfig = { } export type ClaudeCommandOptions = { + allowFailure?: boolean signal?: AbortSignal keepStdinOpen?: boolean } +function shellQuote(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'` +} + export function runClaudeCommandProcess( args: string[], configDir: ClaudeCommandConfig, @@ -150,7 +149,7 @@ export function runClaudeCommandProcess( return } settle(() => { - if (code === 0) { + if (code === 0 || options?.allowFailure) { resolvePromise(output) return } @@ -189,9 +188,12 @@ type ClaudeSpawnConfig = { windowsVerbatimArguments: boolean } -// Why only this variable: account terminals set just CLAUDE_CONFIG_DIR, so Claude keys the login the same way. function claudeConfigDirEnv(configDir: string): NodeJS.ProcessEnv { - return { CLAUDE_CONFIG_DIR: configDir } + return { + CLAUDE_CONFIG_DIR: configDir, + // Why: Claude Code 2.1.220+ hashes this for the Keychain service name. + CLAUDE_SECURESTORAGE_CONFIG_DIR: configDir + } } function resolveClaudeInvocation( @@ -212,15 +214,15 @@ function resolveClaudeInvocation( } : configDir.linuxPath && configDir.wslDistro ? { - // Same login as the CLI's WSL sign-in: the distro's login shell finds an nvm/mise claude. - command: resolveWslExecutablePath(), - args: buildWslExecArgs(configDir.wslDistro, [ - '/bin/sh', - '-c', - buildWslLoginShellCommand( - `exec env CLAUDE_CONFIG_DIR=${quotePosixShell(configDir.linuxPath)} claude ${args.map(quotePosixShell).join(' ')}` - ) - ]), + command: 'wsl.exe', + args: [ + '-d', + configDir.wslDistro, + '--exec', + 'bash', + '-lc', + `export CLAUDE_CONFIG_DIR=${shellQuote(configDir.linuxPath)}; export CLAUDE_SECURESTORAGE_CONFIG_DIR=${shellQuote(configDir.linuxPath)}; exec claude ${args.map(shellQuote).join(' ')}` + ], env: process.env, windowsVerbatimArguments: false } diff --git a/src/main/claude-accounts/claude-credential-persistence-ratchet.test.ts b/src/main/claude-accounts/claude-credential-persistence-ratchet.test.ts deleted file mode 100644 index 4156b58ced4b..000000000000 --- a/src/main/claude-accounts/claude-credential-persistence-ratchet.test.ts +++ /dev/null @@ -1,281 +0,0 @@ -import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs' -import { join, relative } from 'node:path' -import { expect, it } from 'vitest' -import { stripComments } from '../../shared/source-scan/source-tree-scan' - -const src = join(__dirname, '../..') - -/** - * Every place a Claude login could be written from: accounts, usage, launch, hooks, the CLI, the - * relay and the generated shell scripts. Plan §9 lists these; credential checks run on all of them. - */ -const credentialRoots: readonly (string | { root: string; only: RegExp })[] = [ - 'main/claude-accounts', - // Other providers there refresh their own logins; only Claude's usage is in scope. - { root: 'main/rate-limits', only: /[\\/]claude[^\\/]*\.ts$/ }, - 'main/claude', - 'main/native-chat', - 'main/ipc/pty', - 'main/daemon', - 'main/providers', - 'main/macos-keychain', - 'cli', - 'relay', - 'shared' -] - -/** - * Only noncredential profile surfaces may persist bytes, and each only with the primitives it - * uses today; a new primitive in one of them is a review, not a silent pass. - */ -const fsAllowances: Record = { - 'claude-accounts/claude-profile-history.ts': [ - 'mkdirSync', - 'renameSync', - 'rmdirSync', - 'symlinkSync' - ], - 'claude-accounts/claude-profile-paths.ts': ['mkdirSync', 'writeFileAtomically'], - // The which-account file. - 'claude-accounts/claude-profile-router.ts': ['mkdirSync', 'rmSync', 'writeFileAtomically'], - // Removing an account deletes its folder, links first. - 'claude-accounts/claude-account-folder.ts': ['rmSync', 'rmdirSync', 'unlinkSync'], - 'claude-accounts/claude-profile-prompt-history.ts': [ - 'appendFileSync', - 'linkSync', - 'openSync', - 'renameSync', - 'rmSync', - 'symlinkSync', - 'unlinkSync', - 'writeFileSync', - 'writeFileAtomically' - ], - 'claude-accounts/claude-profile-provisioning.ts': ['mkdirSync', 'rmSync', 'writeFileAtomically'], - 'claude-accounts/claude-profile-sharing.ts': [ - 'mkdirSync', - 'rmdirSync', - 'symlinkSync', - 'unlinkSync', - 'writeFileAtomically' - ], - // The guest helper answers the host on stdout. - 'claude-accounts/claude-profile-wsl-entry.ts': ['write'] -} -const mutation = - /^(?:write(?:File|Json|Sync|v|.*Credentials|.*Keychain)?$|write(?:File|Json)|appendFile|copyFile|cp(?:Sync)?$|rename(?:Sync)?$|unlink(?:Sync)?$|rm(?:dir)?(?:Sync)?$|truncate(?:Sync)?$|createWriteStream|symlink(?:Sync)?$|link(?:Sync)?$|chmod(?:Sync)?$|mkdir(?:Sync)?$|mkdtemp(?:Sync)?$|open(?:Sync)?$|delete.*Keychain|refreshClaudeOauth)/ - -function persistencePrimitives(text: string): string[] { - const found = new Set() - // Import-side names survive aliases, including destructuring and wrapped helper calls. - for (const match of text.matchAll(/\b([a-zA-Z_$][\w$]*)\s*(?=\(|as\s|[:,}])/g)) { - if (mutation.test(match[1])) { - found.add(match[1]) - } - } - return [...found] -} - -const WRITE_CALL = - /\b(?:write\w*|append\w*|copy\w*|cp(?:Sync)?|rename\w*|link\w*|symlink\w*|createWriteStream)\s*\(/g -// The one runner that takes `security` argv from its callers; each caller must spell the verb. -const SECURITY_RUNNER = 'main/macos-keychain/generic-password.ts' -// Removing an account deletes the Keychain items Claude made for its folder; nothing adds one. -const KEYCHAIN_DELETE_OWNER = 'main/claude-accounts/claude-account-folder.ts' - -/** Names bound, directly or through another such name, to the credentials file's path. */ -function credentialFileNames(text: string): Set { - const declarations: { name: string; init: string }[] = [] - const lines = text.split('\n') - lines.forEach((line, index) => { - const declared = /^(\s*)(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)/.exec(line) - if (!declared) { - return - } - const indent = declared[1].length - let end = index + 1 - while (end < lines.length && /^\s*$|^\s+/.test(lines[end])) { - const lead = /^\s*/.exec(lines[end])?.[0].length ?? 0 - if (lines[end].trim() && lead <= indent && !/^\s*[)\]}]/.test(lines[end])) { - break - } - end += 1 - } - declarations.push({ name: declared[2], init: lines.slice(index, end).join('\n') }) - }) - const names = new Set() - for (let changed = true; changed;) { - changed = false - for (const { name, init } of declarations) { - const tainted = - init.includes('.credentials.json') || - [...names].some((other) => new RegExp(`\\b${other}\\b`).test(init.replace(name, ''))) - if (tainted && !names.has(name)) { - names.add(name) - changed = true - } - } - } - return names -} - -/** Credential writes refused everywhere scanned, whatever form the call takes. */ -function credentialMutations(text: string, file = ''): string[] { - const found: string[] = [] - // A `security` launch whose verb is not spelled out could be a write, so only the runner may - // forward argv, and its callers must spell the verb. - if ( - (file !== SECURITY_RUNNER && - /\b(?:exec(?:File)?(?:Sync|Async)?|spawn(?:Sync)?|runProcess|spawnProcess)\s*\(\s*['"`]security['"`]\s*,\s*(?!\[\s*['"])/.test( - text - )) || - /\bprogram\s*:\s*['"`]security['"`]\s*,\s*args\s*:\s*(?!\[\s*['"])/.test(text) || - /(? new RegExp(`\\b${name}\\b`).test(args))) { - found.push('credentials file write through a name') - break - } - } - // Argv (`['add-generic-password', …]`) and shell (`security add-generic-password`) forms. - if ( - /add-generic-password/.test(text) || - (file !== KEYCHAIN_DELETE_OWNER && /delete-generic-password/.test(text)) - ) { - found.push('keychain write') - } - if (/\b(?:write|delete|store|save)\w*Keychain\w*\b/.test(text)) { - found.push('keychain helper') - } - if (/oauth\/token|grant_type|\brefresh_token\b/.test(text)) { - found.push('token refresh') - } - // A statement that names the credentials file together with anything that writes, copies, - // moves or opens it for writing (TS calls and generated shell alike). - for (const statement of text.split(/;|\n\s*\n/)) { - if ( - statement.includes('.credentials.json') && - /\b(?:write\w*|append\w*|copy\w*|cp(?:Sync)?|rename\w*|link\w*|symlink\w*|createWriteStream|mv|tee|install)\b|\bopen(?:Sync)?\s*\([\s\S]{0,200}?,\s*['"`](?:w|a|r\+|wx|ax)|>\s*["'$]/.test( - statement - ) - ) { - found.push('credentials file write') - } - } - return found -} - -function files(dir: string): string[] { - if (!existsSync(dir)) { - return [] - } - return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { - const file = join(dir, entry.name) - return entry.isDirectory() - ? entry.name === 'node_modules' || entry.name === '__fixtures__' - ? [] - : files(file) - : /\.(?:ts|tsx|mts|cts|js|mjs|cjs|sh)$/.test(entry.name) && - !/\.test\.|\.spec\.|test-harness|harness\.ts|test-fixtures|test-utils/.test(entry.name) - ? [file] - : [] - }) -} - -it('keeps every Claude credential writer removed, in every form and on every launch path', () => { - const violations = credentialRoots - .flatMap((entry) => { - const { root, only } = typeof entry === 'string' ? { root: entry, only: null } : entry - const dir = join(src, root) - const found = statSync(dir, { throwIfNoEntry: false })?.isDirectory() ? files(dir) : [] - return only ? found.filter((file) => only.test(file)) : found - }) - .flatMap((file) => - credentialMutations( - stripComments(readFileSync(file, 'utf8')), - relative(src, file).replaceAll('\\', '/') - ).map((kind) => `${relative(src, file)}: ${kind}`) - ) - expect(violations).toEqual([]) -}) - -it('confines filesystem persistence in accounts and usage to named profile surfaces', () => { - const main = join(src, 'main') - const candidates = [ - ...files(__dirname), - ...files(join(main, 'rate-limits')).filter((file) => /\/claude[^/]*\.ts$/.test(file)) - ] - const violations = candidates.flatMap((file) => { - const name = relative(main, file).replaceAll('\\', '/') - const allowed = new Set(fsAllowances[name] ?? []) - return persistencePrimitives(stripComments(readFileSync(file, 'utf8'))) - .filter((primitive) => !allowed.has(primitive)) - .map((primitive) => `${name}: ${primitive}`) - }) - expect(violations).toEqual([]) - const cli = readFileSync(join(main, '../cli/handlers/account.ts'), 'utf8') - expect(cli).not.toMatch(/Keychain|orca-account-add-claude|addClaudeFromConfigDir/) - const auth = readFileSync(join(__dirname, 'runtime-auth-service.ts'), 'utf8') - expect(auth).not.toMatch(/extends ClaudeRuntimeAuth|snapshot|credentialsJson|readBack/i) -}) - -it.each([ - "import { writeFile as save } from 'node:fs/promises'; save(path, token)", - "import * as fs from 'node:fs'; const save = () => fs.writeFileSync(path, token)", - "import { writeFileAtomically as persist } from '../codex-accounts/fs-utils'; persist(path, token)", - 'fs.copyFile(source, destination)', - 'fs.symlink(credentialPath, destination)', - "const fd = openSync(join(dir, 'x'), 'w'); writeSync(fd, token)", - 'await fs.promises.cp(a, b)', - 'fs.cpSync(a, b, { recursive: true })' -])('mutation control: the filesystem census rejects %s', (source) => { - expect(persistencePrimitives(source).length).toBeGreaterThan(0) -}) - -it.each([ - 'security add-generic-password -s Claude -w token', - "await execFileAsync('security', ['add-generic-password', '-U', '-s', service, '-w', token])", - "execFileSync('security', ['delete-generic-password', '-s', 'Claude Code-credentials'])", - "import { writeKeychainPassword as persist } from '../macos-keychain/generic-password'; persist(service, user, token)", - "const fd = openSync(join(dir, '.credentials.json'), 'w'); writeSync(fd, token)", - "await fs.promises.cp(join(a, '.credentials.json'), join(b, '.credentials.json'))", - "copyFileSync(join(from, '.credentials.json'), join(home, '.credentials.json'))", - "const script = 'cp source .credentials.json'", - 'const script = `printf %s "$token" > "$HOME/.claude/.credentials.json"`', - "fetch('https://api.anthropic.com/v1/oauth/token')", - "body: JSON.stringify({ grant_type: 'refresh_token' })", - "execFile('security', [['add', 'generic', 'password'].join('-'), '-s', service])", - "spawn('security', verbArgs)", - 'execSecurityCommand(args)', - "const CREDENTIALS_FILE = '.credentials.json'\n\ncopyFileSync(join(from, CREDENTIALS_FILE), join(to, CREDENTIALS_FILE))", - "const name = '.credentials.json'\nfunction save(dir) {\n const target = join(dir, name)\n\n writeFileSync(target, token)\n}" -])('mutation control: the credential check rejects %s', (source) => { - expect(credentialMutations(source).length).toBeGreaterThan(0) -}) - -it.each([ - "const file = await readFile(join(home, '.credentials.json'), 'utf8')", - "readKeychainPassword('Claude Code-credentials', user)", - "execSecurityCommand(['find-generic-password', '-s', service, '-w'])", - "const file = join(home, '.credentials.json')\nconst raw = await readFile(file, 'utf8')" -])('the credential check leaves a read alone: %s', (source) => { - expect(credentialMutations(source)).toEqual([]) -}) - -it('lets only account removal delete a Keychain item, and nothing add one', () => { - const remove = "execSecurityCommand(['delete-generic-password', '-s', service])" - expect(credentialMutations(remove, KEYCHAIN_DELETE_OWNER)).toEqual([]) - expect(credentialMutations(remove, 'main/claude-accounts/service.ts')).not.toEqual([]) - expect( - credentialMutations( - "execSecurityCommand(['add-generic-password', '-s', service])", - KEYCHAIN_DELETE_OWNER - ) - ).not.toEqual([]) -}) diff --git a/src/main/claude-accounts/claude-login-completion.oracle.test.ts b/src/main/claude-accounts/claude-login-completion.oracle.test.ts new file mode 100644 index 000000000000..1f2fd963183f --- /dev/null +++ b/src/main/claude-accounts/claude-login-completion.oracle.test.ts @@ -0,0 +1,192 @@ +import { EventEmitter } from 'node:events' +import { PassThrough } from 'node:stream' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const processMocks = vi.hoisted(() => ({ + spawn: vi.fn() +})) + +vi.mock('node:child_process', () => ({ + execFileSync: vi.fn(), + spawn: processMocks.spawn +})) + +vi.mock('electron', () => ({ + app: { getPath: () => 'C:\\orca-review-11407' } +})) + +vi.mock('../codex-cli/command', () => ({ + resolveClaudeCommand: () => 'claude.exe' +})) + +vi.mock('./keychain', () => ({ + deleteActiveClaudeKeychainCredentialsStrict: vi.fn(), + deleteManagedClaudeKeychainCredentials: vi.fn(), + readActiveClaudeKeychainCredentials: vi.fn(), + readActiveClaudeKeychainCredentialsStrict: vi.fn(), + readManagedClaudeKeychainCredentials: vi.fn(), + writeActiveClaudeKeychainCredentials: vi.fn(), + writeManagedClaudeKeychainCredentials: vi.fn() +})) + +type CommandConfig = { + windowsPath: string + linuxPath: string | null + wslDistro: string | null +} + +type FakeChild = EventEmitter & { + stdin: PassThrough + stdout: PassThrough + stderr: PassThrough + kill: ReturnType + pid: number +} + +type CommandRunner = { + runClaudeCommand( + args: string[], + config: CommandConfig, + timeoutMs: number, + options?: { keepStdinOpen?: boolean; signal?: AbortSignal } + ): Promise +} + +const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + +function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { configurable: true, value: platform }) +} + +function createChild(): FakeChild { + const child = new EventEmitter() as FakeChild + child.stdin = new PassThrough() + child.stdout = new PassThrough() + child.stderr = new PassThrough() + child.kill = vi.fn() + child.pid = 11407 + return child +} + +async function createRunner(): Promise { + const { ClaudeAccountService } = await import('./service') + return new ClaudeAccountService({} as never, {} as never, {} as never) as unknown as CommandRunner +} + +async function flushPromiseCallbacks(): Promise { + await Promise.resolve() + await Promise.resolve() +} + +describe('native Windows Claude login completion oracle', () => { + beforeEach(() => { + vi.useFakeTimers() + processMocks.spawn.mockReset() + }) + + afterEach(() => { + vi.useRealTimers() + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + }) + + it('settles exact native Windows auth login on exit exactly once and cleans resources', async () => { + setPlatform('win32') + const child = createChild() + const destroyStdin = vi.spyOn(child.stdin, 'destroy') + const destroyStdout = vi.spyOn(child.stdout, 'destroy') + const destroyStderr = vi.spyOn(child.stderr, 'destroy') + const abortController = new AbortController() + const removeAbortListener = vi.spyOn(abortController.signal, 'removeEventListener') + processMocks.spawn.mockReturnValue(child) + const runner = await createRunner() + let completions = 0 + const command = runner + .runClaudeCommand( + ['auth', 'login', '--claudeai'], + { windowsPath: 'C:\\isolated-auth', linuxPath: null, wslDistro: null }, + 1000, + { keepStdinOpen: true, signal: abortController.signal } + ) + .then(() => { + completions += 1 + }) + + child.emit('exit', 0) + await flushPromiseCallbacks() + + try { + expect(completions).toBe(1) + expect(child.listenerCount('error')).toBe(0) + expect(child.listenerCount('exit')).toBe(0) + expect(child.listenerCount('close')).toBe(0) + expect(child.stdout.listenerCount('data')).toBe(0) + expect(child.stderr.listenerCount('data')).toBe(0) + expect(destroyStdin).toHaveBeenCalledTimes(1) + expect(destroyStdout).toHaveBeenCalledTimes(1) + expect(destroyStderr).toHaveBeenCalledTimes(1) + expect(removeAbortListener).toHaveBeenCalledTimes(1) + + child.emit('close', 0) + await vi.advanceTimersByTimeAsync(1001) + + expect(completions).toBe(1) + expect(child.kill).not.toHaveBeenCalled() + expect(destroyStdin).toHaveBeenCalledTimes(1) + expect(destroyStdout).toHaveBeenCalledTimes(1) + expect(destroyStderr).toHaveBeenCalledTimes(1) + expect(removeAbortListener).toHaveBeenCalledTimes(1) + } finally { + child.emit('close', 0) + await command + } + }) + + it.each([ + { + name: 'non-Windows auth login', + platform: 'linux' as const, + args: ['auth', 'login', '--claudeai'], + config: { windowsPath: '/isolated-auth', linuxPath: null, wslDistro: null } + }, + { + name: 'unrelated native Windows command containing login', + platform: 'win32' as const, + args: ['config', 'login'], + config: { windowsPath: 'C:\\isolated-auth', linuxPath: null, wslDistro: null } + }, + { + name: 'WSL auth login', + platform: 'win32' as const, + args: ['auth', 'login', '--claudeai'], + config: { + windowsPath: 'C:\\isolated-auth', + linuxPath: '/home/orca/isolated-auth', + wslDistro: 'Ubuntu-Oracle' + } + } + ])('preserves close completion for $name', async ({ platform, args, config }) => { + setPlatform(platform) + const child = createChild() + processMocks.spawn.mockReturnValue(child) + const runner = await createRunner() + let completions = 0 + const command = runner.runClaudeCommand(args, config, 1000).then(() => { + completions += 1 + }) + + child.emit('exit', 0) + await flushPromiseCallbacks() + + expect(completions).toBe(0) + expect(child.listenerCount('close')).toBe(1) + + child.emit('close', 0) + await command + + expect(completions).toBe(1) + expect(child.listenerCount('close')).toBe(0) + expect(child.kill).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/claude-accounts/claude-login-session.ts b/src/main/claude-accounts/claude-login-session.ts index 47160b506b22..ce9a398bb865 100644 --- a/src/main/claude-accounts/claude-login-session.ts +++ b/src/main/claude-accounts/claude-login-session.ts @@ -1,6 +1,19 @@ +import { mkdtempSync, realpathSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { toWindowsWslPath } from '../wsl' +import { runWslProcess } from '../wsl/wsl-runner' +import type { CapturedClaudeAuth } from './claude-auth-capture' import type { ClaudeCommandConfig, ClaudeCommandOptions } from './claude-command-process' +import type { ClaudeManagedAuthLocation } from './claude-managed-auth-storage' +import { + deleteActiveClaudeKeychainCredentialsStrict, + readActiveClaudeKeychainCredentials, + writeActiveClaudeKeychainCredentials +} from './keychain' const LOGIN_TIMEOUT_MS = 180_000 +const STATUS_TIMEOUT_MS = 20_000 type ClaudeLoginSessionDependencies = { runCommand: ( @@ -9,14 +22,19 @@ type ClaudeLoginSessionDependencies = { timeoutMs: number, options?: ClaudeCommandOptions ) => Promise + capture: ( + configDir: string, + statusOutput: string, + previousLegacyKeychain: string | null + ) => Promise setCancel: (cancel: (() => boolean) | null) => void } -/** Runs a hidden `claude auth login` that writes its login straight into the account's folder. */ export async function runClaudeLoginSession( - folder: ClaudeCommandConfig, + location: ClaudeManagedAuthLocation, dependencies: ClaudeLoginSessionDependencies -): Promise { +): Promise { + const tempConfig = await createTemporaryClaudeConfigDir(location) const controller = new AbortController() dependencies.setCancel(() => { if (controller.signal.aborted) { @@ -25,12 +43,107 @@ export async function runClaudeLoginSession( controller.abort() return true }) + const previousLegacyKeychain = await readActiveClaudeKeychainCredentials() + let captured: CapturedClaudeAuth | null = null + let captureError: unknown = null + let cleanupError: unknown = null try { - await dependencies.runCommand(['auth', 'login', '--claudeai'], folder, LOGIN_TIMEOUT_MS, { + if (controller.signal.aborted) { + throw new Error('Claude sign-in was cancelled.') + } + await dependencies.runCommand(['auth', 'login', '--claudeai'], tempConfig, LOGIN_TIMEOUT_MS, { signal: controller.signal, keepStdinOpen: true }) + dependencies.setCancel(null) + const status = await dependencies.runCommand( + ['auth', 'status', '--json'], + tempConfig, + STATUS_TIMEOUT_MS, + { allowFailure: true } + ) + captured = await dependencies.capture(tempConfig.windowsPath, status, previousLegacyKeychain) + } catch (error) { + captureError = error } finally { + if (process.platform === 'darwin') { + try { + await deleteActiveClaudeKeychainCredentialsStrict(tempConfig.windowsPath) + } catch (error) { + console.warn('[claude-accounts] Failed to clean temporary Claude Keychain item:', error) + } + try { + await (previousLegacyKeychain + ? writeActiveClaudeKeychainCredentials(previousLegacyKeychain) + : deleteActiveClaudeKeychainCredentialsStrict()) + } catch (error) { + cleanupError = error + } + } + await removeTemporaryClaudeConfigDir(tempConfig) dependencies.setCancel(null) } + if (captureError) { + throw captureError + } + if (cleanupError) { + throw cleanupError + } + return captured! +} + +async function createTemporaryClaudeConfigDir( + location: ClaudeManagedAuthLocation +): Promise { + if (location.managedAuthRuntime !== 'wsl') { + const created = mkdtempSync(join(tmpdir(), 'orca-claude-login-')) + let windowsPath = created + try { + windowsPath = realpathSync(created) + } catch { + // Keep the mkdtemp path if the temp root cannot be resolved. + } + return { + windowsPath, + linuxPath: null, + wslDistro: null + } + } + if (!location.wslDistro) { + throw new Error('Could not resolve the active WSL distribution for Claude login.') + } + const created = await runWslProcess({ + distro: location.wslDistro, + loginPath: 'none', + shell: 'bash', + script: 'mktemp -d "${TMPDIR:-/tmp}/orca-claude-login.XXXXXX"', + timeoutMs: 5000 + }) + const linuxPath = created.stdout.replaceAll(String.fromCharCode(0), '').trim() + if (created.code !== 0 || created.timedOut || !linuxPath.startsWith('/')) { + throw new Error('Could not create a temporary WSL Claude login directory.') + } + return { + windowsPath: toWindowsWslPath(linuxPath, location.wslDistro), + linuxPath, + wslDistro: location.wslDistro + } +} + +async function removeTemporaryClaudeConfigDir(config: ClaudeCommandConfig): Promise { + if (config.linuxPath && config.wslDistro) { + try { + await runWslProcess({ + distro: config.wslDistro, + loginPath: 'none', + program: 'rm', + args: ['-rf', '--', config.linuxPath], + timeoutMs: 5000 + }) + } catch { + // Cleanup cannot mask the login result. + } + return + } + rmSync(config.windowsPath, { recursive: true, force: true }) } diff --git a/src/main/claude-accounts/claude-managed-auth-storage.ts b/src/main/claude-accounts/claude-managed-auth-storage.ts new file mode 100644 index 000000000000..b430979655c6 --- /dev/null +++ b/src/main/claude-accounts/claude-managed-auth-storage.ts @@ -0,0 +1,285 @@ +import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { join, relative, resolve, sep } from 'node:path' +import { parseWslUncPath } from '../../shared/wsl-paths' +import { toWindowsWslPath } from '../wsl' +import { runWslProcess } from '../wsl/wsl-runner' +import { stripSharedClaudeCredentialFields } from './shared-credential-fields' +import { + getClaudeManagedAccountsRoot, + readClaudeManagedAuthFile, + resolveOwnedClaudeManagedAuthPath, + writeClaudeManagedAuthFile +} from './managed-auth-path' +import { + deleteManagedClaudeKeychainCredentials, + readManagedClaudeKeychainCredentials, + writeManagedClaudeKeychainCredentials +} from './keychain' + +export type ClaudeManagedAuthLocation = { + managedAuthPath: string + managedAuthRuntime: 'host' | 'wsl' + wslDistro: string | null + wslLinuxAuthPath: string | null +} + +export type ClaudeManagedAuthSnapshot = { + credentialsJson: string | null + oauthAccountJson: string | null +} + +export type ClaudeManagedAuthTarget = { + runtime?: 'host' | 'wsl' + wslDistro?: string | null +} + +function shellQuote(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'` +} + +export class ClaudeManagedAuthStorage { + async create( + accountId: string, + target?: ClaudeManagedAuthTarget + ): Promise { + const wslAuth = await this.tryCreateWsl(accountId, target) + if (wslAuth) { + return wslAuth + } + const managedAuthPath = join(this.getRoot(), accountId, 'auth') + mkdirSync(managedAuthPath, { recursive: true, mode: 0o700 }) + writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), `${accountId}\n`, { + encoding: 'utf-8', + mode: 0o600 + }) + return { + managedAuthPath: await this.assertOwned(managedAuthPath, accountId), + managedAuthRuntime: 'host', + wslDistro: null, + wslLinuxAuthPath: null + } + } + + async writeAuth( + accountId: string, + managedAuthPath: string, + captured: { credentialsJson: string; oauthAccount: unknown } + ): Promise { + await this.writeCredentials(accountId, managedAuthPath, captured.credentialsJson) + await this.writeOauthAccount(accountId, managedAuthPath, captured.oauthAccount) + } + + async writeCredentials( + accountId: string, + managedAuthPath: string, + credentialsJson: string + ): Promise { + const trustedPath = await this.assertOwned(managedAuthPath, accountId) + if (!parseWslUncPath(trustedPath)) { + credentialsJson = stripSharedClaudeCredentialFields(credentialsJson) + } + if (process.platform === 'darwin') { + await writeManagedClaudeKeychainCredentials(accountId, credentialsJson) + } else { + writeClaudeManagedAuthFile(trustedPath, '.credentials.json', credentialsJson) + } + } + + async writeOauthAccount( + accountId: string, + managedAuthPath: string, + oauthAccount: unknown + ): Promise { + const trustedPath = await this.assertOwned(managedAuthPath, accountId) + writeClaudeManagedAuthFile( + trustedPath, + 'oauth-account.json', + `${JSON.stringify(oauthAccount, null, 2)}\n` + ) + } + + async readSnapshot( + accountId: string, + managedAuthPath: string + ): Promise { + const trustedPath = await this.assertOwned(managedAuthPath, accountId) + return { + credentialsJson: + process.platform === 'darwin' + ? await readManagedClaudeKeychainCredentials(accountId) + : readClaudeManagedAuthFile(trustedPath, '.credentials.json'), + oauthAccountJson: readClaudeManagedAuthFile(trustedPath, 'oauth-account.json') + } + } + + async restoreCredentials( + accountId: string, + managedAuthPath: string, + snapshot: ClaudeManagedAuthSnapshot + ): Promise { + const trustedPath = await this.assertOwned(managedAuthPath, accountId) + if (process.platform === 'darwin') { + await (snapshot.credentialsJson !== null + ? writeManagedClaudeKeychainCredentials(accountId, snapshot.credentialsJson) + : deleteManagedClaudeKeychainCredentials(accountId)) + } else if (snapshot.credentialsJson !== null) { + writeClaudeManagedAuthFile(trustedPath, '.credentials.json', snapshot.credentialsJson) + } else { + rmSync(join(trustedPath, '.credentials.json'), { force: true }) + } + } + + async restoreOauth( + accountId: string, + managedAuthPath: string, + snapshot: ClaudeManagedAuthSnapshot + ): Promise { + const trustedPath = await this.assertOwned(managedAuthPath, accountId) + if (snapshot.oauthAccountJson !== null) { + writeClaudeManagedAuthFile(trustedPath, 'oauth-account.json', snapshot.oauthAccountJson) + } else { + rmSync(join(trustedPath, 'oauth-account.json'), { force: true }) + } + } + + async remove(accountId: string, candidatePath: string): Promise { + try { + const managedAuthPath = await this.assertOwned(candidatePath, accountId) + rmSync(resolve(managedAuthPath, '..'), { recursive: true, force: true }) + } catch (error) { + console.warn('[claude-accounts] Refusing to remove untrusted managed auth:', error) + } + await deleteManagedClaudeKeychainCredentials(accountId) + } + + async assertOwned(candidatePath: string, expectedAccountId?: string): Promise { + const wslInfo = parseWslUncPath(candidatePath) + if (wslInfo) { + return this.assertOwnedWsl(candidatePath, wslInfo, expectedAccountId) + } + this.getRoot() + const accountId = expectedAccountId ?? this.readAccountId(candidatePath) + if (!accountId || (expectedAccountId && accountId !== expectedAccountId)) { + throw new Error('Managed Claude auth directory does not exist on disk.') + } + const trustedPath = resolveOwnedClaudeManagedAuthPath(accountId, candidatePath, { + adoptLegacyMarker: true + }) + if (!trustedPath) { + throw new Error('Managed Claude auth storage is not owned by Orca.') + } + return trustedPath + } + + private async tryCreateWsl( + accountId: string, + target?: ClaudeManagedAuthTarget + ): Promise { + if (process.platform !== 'win32' || target?.runtime !== 'wsl') { + return null + } + const requestedDistro = target.wslDistro?.trim() || undefined + const info = await runWslProcess({ + distro: requestedDistro, + loginPath: 'none', + shell: 'bash', + script: 'printf "%s\\n%s\\n" "$WSL_DISTRO_NAME" "$HOME"', + timeoutMs: 5000 + }) + const [rawDistro, rawHome] = + info.code === 0 && !info.timedOut + ? info.stdout + .replaceAll(String.fromCharCode(0), '') + .split(/\r?\n/) + .map((line) => line.trim()) + : [] + const distro = requestedDistro || rawDistro + const home = rawHome + if (!distro || !home?.startsWith('/')) { + throw new Error('Could not resolve the active WSL home directory for Claude login.') + } + const linuxPath = `${home.replace(/\/$/, '')}/.local/share/orca/claude-accounts/${accountId}/auth` + const created = await runWslProcess({ + distro, + loginPath: 'none', + shell: 'bash', + script: 'umask 077; mkdir -p "$1" && printf \'%s\\n\' "$2" > "$1/.orca-managed-claude-auth"', + args: [linuxPath, accountId], + timeoutMs: 5000 + }) + if (created.code !== 0 || created.timedOut) { + throw new Error('Could not create the managed WSL Claude auth directory.') + } + const managedAuthPath = toWindowsWslPath(linuxPath, distro) + return { + managedAuthPath: await this.assertOwned(managedAuthPath, accountId), + managedAuthRuntime: 'wsl', + wslDistro: distro, + wslLinuxAuthPath: linuxPath + } + } + + private async assertOwnedWsl( + candidatePath: string, + wslInfo: NonNullable>, + expectedAccountId?: string + ): Promise { + if ( + !wslInfo.linuxPath.includes('/.local/share/orca/claude-accounts/') || + !wslInfo.linuxPath.endsWith('/auth') + ) { + throw new Error('Managed WSL Claude auth storage is outside Orca account storage.') + } + if (process.platform !== 'win32') { + if ( + !existsSync(candidatePath) || + !existsSync(join(candidatePath, '.orca-managed-claude-auth')) + ) { + throw new Error('Managed Claude auth storage is not owned by Orca.') + } + return candidatePath + } + try { + const expected = expectedAccountId + ? `test "$(cat "$candidate_real/.orca-managed-claude-auth")" = ${shellQuote(expectedAccountId)}` + : 'test -n "$(cat "$candidate_real/.orca-managed-claude-auth")"' + const owned = await runWslProcess({ + distro: wslInfo.distro, + loginPath: 'none', + shell: 'bash', + script: [ + 'set -euo pipefail', + `candidate=${shellQuote(wslInfo.linuxPath)}`, + 'managed_root="${HOME%/}/.local/share/orca/claude-accounts"', + 'candidate_real=$(readlink -f -- "$candidate")', + 'managed_root_real=$(readlink -f -- "$managed_root")', + 'test -f "$candidate_real/.orca-managed-claude-auth"', + expected, + 'case "$candidate_real" in "$managed_root_real"/*/auth) printf "%s\\n" "$candidate_real" ;; *) exit 35 ;; esac' + ].join('\n'), + timeoutMs: 5000 + }) + const canonicalPath = owned.stdout.trim() + if (owned.code !== 0 || owned.timedOut || !canonicalPath) { + throw new Error('Managed Claude auth directory does not exist on disk.') + } + return toWindowsWslPath(canonicalPath, wslInfo.distro) + } catch (error) { + throw new Error('Managed WSL Claude auth storage is outside Orca account storage.', { + cause: error + }) + } + } + + private getRoot(): string { + const root = getClaudeManagedAccountsRoot() + mkdirSync(root, { recursive: true, mode: 0o700 }) + return root + } + + private readAccountId(candidatePath: string): string | null { + const relativePath = relative(resolve(this.getRoot()), resolve(candidatePath)) + const parts = relativePath.split(sep) + return parts.length === 2 && parts[1] === 'auth' ? parts[0] : null + } +} diff --git a/src/main/claude-accounts/claude-profile-installed-router.ts b/src/main/claude-accounts/claude-profile-installed-router.ts index c4efc9f39e42..a0c04e3c9baa 100644 --- a/src/main/claude-accounts/claude-profile-installed-router.ts +++ b/src/main/claude-accounts/claude-profile-installed-router.ts @@ -26,7 +26,7 @@ export function listClaudeProfileHomes(dataRoot: string): string[] { let installed: ClaudeProfileRouter | undefined -/** Installed by the desktop's account services; workers, child processes and orcad have none. */ +/** Installed by the host runtime only when routing is enabled; workers and child processes have none. */ export function installClaudeProfileRouter(router: ClaudeProfileRouter | undefined): void { installed = router } @@ -46,8 +46,8 @@ export function withClaudeProfileTerminalEnv } /** - * Account `` folders the System default readers cannot see. Setup links history into the - * System default on every platform, so only a folder setup could not link (e.g. across drives) adds any. + * Account `` folders the System default readers cannot see. Step 1 links history into the + * System default on macOS/Linux, so only Windows (or a cross-filesystem refusal) adds any. */ export function claudeProfileHistoryDirs(surface: 'projects' | 'transcripts'): string[] { return (installed?.accountHomes() ?? []).map((home) => join(home, surface)).filter(isDirectory) diff --git a/src/main/claude-accounts/claude-profile-paths.test.ts b/src/main/claude-accounts/claude-profile-paths.test.ts index 070b486eeb20..f0d04f0fd734 100644 --- a/src/main/claude-accounts/claude-profile-paths.test.ts +++ b/src/main/claude-accounts/claude-profile-paths.test.ts @@ -12,6 +12,7 @@ import { join, resolve } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' vi.mock('electron', () => ({ app: { getPath: () => '/unused-test-path' } })) import { + assertOutsideDefaultClaudeHomes, describeClaudeProfile, prepareClaudeProfileDirectory, readClaudeProfileObject, @@ -56,16 +57,10 @@ describe('Claude profile namespace', () => { target, home: join(dir, 'claude-profiles/account-a/home') }) - const markSetUp = prepareClaudeProfileDirectory(dir, profile, userHome) - const marker = join(dir, 'claude-profiles/account-a/profile.json') - expect(existsSync(marker)).toBe(false) - markSetUp() - expect(JSON.parse(readFileSync(marker, 'utf8'))).toEqual({ - version: 1, - accountId: 'account-a', - runtime: 'wsl', - distro: 'Ubuntu' - }) + prepareClaudeProfileDirectory(dir, profile, userHome) + expect( + JSON.parse(readFileSync(join(dir, 'claude-profiles/account-a/profile.json'), 'utf8')) + ).toEqual({ version: 1, accountId: 'account-a', runtime: 'wsl', distro: 'Ubuntu' }) // The host id is the caller's view of the host, so another caller's spelling is the same profile. prepareClaudeProfileDirectory( dir, @@ -126,9 +121,11 @@ describe('Claude profile namespace', () => { it.runIf(caseInsensitive)('refuses a case-only alias of the default home', () => { const userHome = root() mkdirSync(join(userHome, '.claude')) - const dataRoot = join(userHome, '.CLAUDE', 'orca') + expect(() => assertOutsideDefaultClaudeHomes(join(userHome, '.CLAUDE'), userHome)).toThrow( + 'separate directories' + ) expect(() => - prepareClaudeProfileDirectory(dataRoot, describeClaudeProfile(dataRoot, 'a', local), userHome) + assertOutsideDefaultClaudeHomes(join(userHome, '.CLAUDE', 'nested'), userHome) ).toThrow('separate directories') }) it("reads the user's own CLAUDE_CONFIG_DIR but never Orca's injected one", () => { diff --git a/src/main/claude-accounts/claude-profile-paths.ts b/src/main/claude-accounts/claude-profile-paths.ts index ece12937405c..74ee5222c8e0 100644 --- a/src/main/claude-accounts/claude-profile-paths.ts +++ b/src/main/claude-accounts/claude-profile-paths.ts @@ -1,10 +1,6 @@ import { lstatSync, mkdirSync, readFileSync, realpathSync } from 'node:fs' import * as hostPath from 'node:path' import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' -import { - CLAUDE_INJECTED_CONFIG_DIR_ENV, - CLAUDE_USER_CONFIG_DIR_ENV -} from '../../shared/claude-profile-routing' import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' import type { ExecutionHostId } from '../../shared/execution-host' import { writeFileAtomically } from '../codex-accounts/fs-utils' @@ -97,23 +93,23 @@ function readOwnershipMarker(file: string): string | null { return ownershipRecord(version, accountId, runtime, distro) } -// Written when a first setup finishes, so its absence means no setup has finished here yet. +/** + * The only gate before writing into a profile: namespace, containment, no linked components, + * outside Claude's default homes, and an ownership marker beside the home. Refuses before creating anything. + */ +// Written by setup's ownership gate when setup starts, not when it completes: its absence means +// setup never started here, and its presence does not prove setup finished. export function claudeProfileMarkerPath(profile: ClaudeProfileDescriptor): string { const path = profile.target.runtime === 'wsl' ? hostPath.posix : hostPath return path.join(path.dirname(profile.home), 'profile.json') } -/** - * The only gate before writing into a profile: namespace, containment, no linked components, - * outside Claude's default homes, and an ownership marker beside the home. Refuses before creating - * anything. Returns the step that writes the marker, which setup runs last. - */ export function prepareClaudeProfileDirectory( dataRoot: string, profile: ClaudeProfileDescriptor, userHome: string, userConfigDir?: string -): () => void { +): void { let expected: ClaudeProfileDescriptor try { expected = describeClaudeProfile(dataRoot, profile.accountId, profile.target) @@ -139,14 +135,12 @@ export function prepareClaudeProfileDirectory( ) } mkdirSync(profile.home, { recursive: true, mode: 0o700 }) - return () => { - if (marker === null) { - writeFileAtomically(markerPath, `${record}\n`, { mode: 0o600 }) - } + if (marker === null) { + writeFileAtomically(markerPath, `${record}\n`, { mode: 0o600 }) } } -function assertClaudeProfileDescendant(root: string, destination: string): void { +export function assertClaudeProfileDescendant(root: string, destination: string): void { const suffix = relative(resolve(root), resolve(destination)) if (!suffix || suffix === '..' || suffix.startsWith(`..${sep}`) || isAbsolute(suffix)) { throw new ClaudeProfileSurfaceError( @@ -190,7 +184,7 @@ function canonicalPath(file: string): string { } } -function assertDistinctClaudeProfile(profile: string, defaultHome: string): void { +export function assertDistinctClaudeProfile(profile: string, defaultHome: string): void { const left = canonicalPath(profile) const right = canonicalPath(defaultHome) for (const [root, destination] of [ @@ -208,7 +202,7 @@ function assertDistinctClaudeProfile(profile: string, defaultHome: string): void } /** Claude's default homes: a profile may never be, contain, or sit inside one. */ -function assertOutsideDefaultClaudeHomes( +export function assertOutsideDefaultClaudeHomes( profileHome: string, userHome: string, userConfigDir?: string @@ -220,18 +214,16 @@ function assertOutsideDefaultClaudeHomes( } } +/** Set beside every CLAUDE_CONFIG_DIR Orca injects, so its own value never reads as the user's. */ +export const CLAUDE_INJECTED_CONFIG_DIR_ENV = 'ORCA_CLAUDE_INJECTED_CONFIG_DIR' + /** The user's own CLAUDE_CONFIG_DIR (their System default), or undefined for `~/.claude`. */ export function readUserClaudeConfigDir(env: NodeJS.ProcessEnv): string | undefined { const configDir = env.CLAUDE_CONFIG_DIR?.trim() const injected = env[CLAUDE_INJECTED_CONFIG_DIR_ENV]?.trim() - if (!configDir) { + if (!configDir || (injected && resolve(injected) === resolve(configDir))) { return undefined } - if (injected && resolve(injected) === resolve(configDir)) { - // Why: an outer Orca's injected value carries the user's own beside it. - const user = env[CLAUDE_USER_CONFIG_DIR_ENV]?.trim() - return user ? resolve(user) : undefined - } return resolve(configDir) } diff --git a/src/main/claude-accounts/claude-profile-provisioning.test.ts b/src/main/claude-accounts/claude-profile-provisioning.test.ts index 4bc12a4738a9..66bf0346d90d 100644 --- a/src/main/claude-accounts/claude-profile-provisioning.test.ts +++ b/src/main/claude-accounts/claude-profile-provisioning.test.ts @@ -13,15 +13,13 @@ vi.mock('../codex-accounts/fs-utils', async (original) => { return { ...actual, writeFileAtomically: vi.fn(actual.writeFileAtomically) } }) import { writeFileAtomically } from '../codex-accounts/fs-utils' -import { provisionClaudeProfile } from './claude-profile-provisioning' +import { CLAUDE_PROFILE_MEMORY_IMPORT, provisionClaudeProfile } from './claude-profile-provisioning' const USER_HOOK = { matcher: '', hooks: [{ type: 'command', command: 'notify-me' }] } const ORCA_HOOK = { matcher: '', hooks: [{ type: 'command', command: '"$HOME/.orca/agent-hooks/claude-hook.sh"' }] } -// What Claude writes on a finished sign-in. -const LOGIN = { emailAddress: 'p@example.com' } const roots: string[] = [] function fixture() { const root = fs.realpathSync(fs.mkdtempSync(join(tmpdir(), 'claude-profile-setup-'))) @@ -35,8 +33,13 @@ function fixture() { const read = (file: string): Record => JSON.parse(fs.readFileSync(file, 'utf8')) return { root, userHome, profileHome, source, json, read } } -const provision = (f: { profileHome: string; userHome: string }) => - provisionClaudeProfile({ profileHome: f.profileHome, userHome: f.userHome, platform: 'linux' }) +const provision = (f: { profileHome: string; userHome: string }, trustKeys?: string[]) => + provisionClaudeProfile({ + profileHome: f.profileHome, + userHome: f.userHome, + trustKeys, + platform: 'linux' + }) afterEach(() => { vi.clearAllMocks() for (const dir of roots.splice(0)) { @@ -216,7 +219,7 @@ describe('dormant Claude profile provisioning', () => { const f = fixture() f.json(join(f.source, 'settings.json'), { model: 'a' }) f.json(join(f.userHome, '.claude.json'), { mcpServers: { a: {} }, theme: 'dark' }) - f.json(join(f.profileHome, '.claude.json'), { userID: 'p', oauthAccount: LOGIN }) + f.json(join(f.profileHome, '.claude.json'), { userID: 'p' }) await provision(f) fs.writeFileSync(join(f.source, 'settings.json'), '{bad') fs.writeFileSync(join(f.userHome, '.claude.json'), '{bad') @@ -230,18 +233,17 @@ describe('dormant Claude profile provisioning', () => { await provision(f) expect(f.read(join(f.profileHome, '.claude.json'))).toEqual({ userID: 'p', - oauthAccount: LOGIN, theme: 'dark', hasCompletedOnboarding: true }) }) - it('requires existing state, merges MCP/theme/onboarding and never copies or writes credentials', async () => { + it('requires existing state, merges MCP/theme/onboarding/trust and never copies or writes credentials', async () => { const f = fixture() fs.writeFileSync(join(f.source, '.credentials.json'), 'SOURCE_CREDENTIAL_BYTES') f.json(join(f.userHome, '.claude.json'), { mcpServers: { local: { command: 'example' } }, theme: 'dark', - oauthAccount: { emailAddress: 'source@example.com' }, + oauthAccount: { email: 'source' }, userID: 'source-id' }) await provision(f) @@ -249,18 +251,18 @@ describe('dormant Claude profile provisioning', () => { expect(fs.existsSync(join(f.profileHome, '.credentials.json'))).toBe(false) fs.writeFileSync(join(f.profileHome, '.credentials.json'), 'PROFILE_CREDENTIAL_BYTES') f.json(join(f.profileHome, '.claude.json'), { - oauthAccount: { emailAddress: 'profile@example.com' }, + oauthAccount: { email: 'profile' }, userID: 'profile-id', projects: { '/work': { allowedTools: ['Read'] } } }) - await provision(f) + await provision(f, ['/work']) expect(f.read(join(f.profileHome, '.claude.json'))).toEqual({ - oauthAccount: { emailAddress: 'profile@example.com' }, + oauthAccount: { email: 'profile' }, userID: 'profile-id', mcpServers: { local: { command: 'example' } }, theme: 'dark', hasCompletedOnboarding: true, - projects: { '/work': { allowedTools: ['Read'] } } + projects: { '/work': { allowedTools: ['Read'], hasTrustDialogAccepted: true } } }) expect(fs.readFileSync(join(f.source, '.credentials.json'), 'utf8')).toBe( 'SOURCE_CREDENTIAL_BYTES' @@ -275,50 +277,44 @@ describe('dormant Claude profile provisioning', () => { } } }) - it('leaves a folder Claude has not signed in yet to Claude’s own onboarding', async () => { + it('still forces onboarding and trust when the personal state is unreadable', async () => { const f = fixture() - f.json(join(f.userHome, '.claude.json'), { theme: 'dark', mcpServers: { a: {} } }) - // Claude writes its state file at the theme pick, before the sign-in. - f.json(join(f.profileHome, '.claude.json'), { theme: 'light' }) - expect((await provision(f)).surfaces['.claude.json']).toBe('absent') - expect(f.read(join(f.profileHome, '.claude.json'))).toEqual({ theme: 'light' }) - f.json(join(f.profileHome, '.claude.json'), { theme: 'light', oauthAccount: LOGIN }) - expect((await provision(f)).surfaces['.claude.json']).toBe('merged') + fs.writeFileSync(join(f.userHome, '.claude.json'), '{"theme": "da') + f.json(join(f.profileHome, '.claude.json'), { userID: 'p' }) + const report = await provision(f, ['/work']) expect(f.read(join(f.profileHome, '.claude.json'))).toEqual({ - theme: 'light', - oauthAccount: LOGIN, - mcpServers: { a: {} }, - hasCompletedOnboarding: true + userID: 'p', + hasCompletedOnboarding: true, + projects: { '/work': { hasTrustDialogAccepted: true } } }) + expect(report.warnings).toContainEqual( + expect.objectContaining({ surface: '.claude.json', code: 'unreadable' }) + ) }) - it('still forces onboarding when the personal state is unreadable', async () => { + it('skips only folder trust when the profile projects value is malformed', async () => { const f = fixture() - fs.writeFileSync(join(f.userHome, '.claude.json'), '{"theme": "da') - f.json(join(f.profileHome, '.claude.json'), { userID: 'p', oauthAccount: LOGIN }) - const report = await provision(f) + f.json(join(f.userHome, '.claude.json'), { theme: 'dark' }) + f.json(join(f.profileHome, '.claude.json'), { userID: 'p', projects: 'bad' }) + const report = await provision(f, ['/work']) + expect(report.surfaces['.claude.json']).toBe('merged') expect(f.read(join(f.profileHome, '.claude.json'))).toEqual({ userID: 'p', - oauthAccount: LOGIN, + projects: 'bad', + theme: 'dark', hasCompletedOnboarding: true }) - expect(report.warnings).toContainEqual( - expect.objectContaining({ surface: '.claude.json', code: 'unreadable' }) - ) }) it('skips the state write while Claude holds its lock and records nothing for it', async () => { const f = fixture() f.json(join(f.userHome, '.claude.json'), { theme: 'dark' }) - f.json(join(f.profileHome, '.claude.json'), { userID: 'p', oauthAccount: LOGIN }) + f.json(join(f.profileHome, '.claude.json'), { userID: 'p' }) fs.mkdirSync(join(f.profileHome, '.claude.json.lock')) const report = await provision(f) expect(report.surfaces['.claude.json']).toBe('failed') expect(report.warnings).toContainEqual( expect.objectContaining({ surface: '.claude.json', code: 'locked' }) ) - expect(f.read(join(f.profileHome, '.claude.json'))).toEqual({ - userID: 'p', - oauthAccount: LOGIN - }) + expect(f.read(join(f.profileHome, '.claude.json'))).toEqual({ userID: 'p' }) fs.rmdirSync(join(f.profileHome, '.claude.json.lock')) expect((await provision(f)).surfaces['.claude.json']).toBe('merged') expect(f.read(join(f.profileHome, '.claude.json')).theme).toBe('dark') @@ -376,7 +372,9 @@ describe('dormant Claude profile provisioning', () => { expect(fs.existsSync(join(f.profileHome, 'CLAUDE.md'))).toBe(false) fs.writeFileSync(join(f.source, 'CLAUDE.md'), 'personal instructions') expect((await provision(f)).surfaces['CLAUDE.md']).toBe('synced') - expect(fs.readFileSync(join(f.profileHome, 'CLAUDE.md'), 'utf8')).toBe('@~/.claude/CLAUDE.md\n') + expect(fs.readFileSync(join(f.profileHome, 'CLAUDE.md'), 'utf8')).toBe( + CLAUDE_PROFILE_MEMORY_IMPORT + ) fs.writeFileSync(join(f.source, 'CLAUDE.md'), 'edited personal instructions') expect((await provision(f)).surfaces['CLAUDE.md']).toBe('unchanged') }) @@ -390,7 +388,7 @@ describe('dormant Claude profile provisioning', () => { f.json(join(userConfigDir, 'settings.json'), { model: 'custom' }) f.json(join(userConfigDir, '.claude.json'), { theme: 'custom' }) f.json(join(f.userHome, '.claude.json'), { theme: 'home' }) - f.json(join(f.profileHome, '.claude.json'), { userID: 'p', oauthAccount: LOGIN }) + f.json(join(f.profileHome, '.claude.json'), { userID: 'p' }) await provisionClaudeProfile({ ...f, userConfigDir, platform: 'linux' }) expect(fs.realpathSync(join(f.profileHome, 'skills'))).toBe(join(userConfigDir, 'skills')) expect(fs.readFileSync(join(f.profileHome, 'CLAUDE.md'), 'utf8')).toBe('custom instructions') diff --git a/src/main/claude-accounts/claude-profile-provisioning.ts b/src/main/claude-accounts/claude-profile-provisioning.ts index 8c72cf291a14..752f7fee3f38 100644 --- a/src/main/claude-accounts/claude-profile-provisioning.ts +++ b/src/main/claude-accounts/claude-profile-provisioning.ts @@ -2,10 +2,10 @@ import { existsSync } from 'node:fs' import { join, resolve } from 'node:path' import { writeFileAtomically } from '../codex-accounts/fs-utils' import { + applyClaudeFolderTrust, resolveClaudeGlobalConfigFile, updateClaudeGlobalConfig } from '../claude/claude-folder-trust-file' -import { claudeStateLogin } from './claude-account-folder' import { readClaudeProfileObject, resolveClaudeDefaultHome } from './claude-profile-paths' import { lstatIfPresent } from './claude-profile-prompt-history' import { @@ -39,7 +39,7 @@ export const CLAUDE_PROFILE_RESOURCE_DIRS = [ ] as const // Copied, not linked: a rename-replace save (Claude's own, or an editor's) would cut a link. export const CLAUDE_PROFILE_RESOURCE_FILES = ['CLAUDE.md', 'keybindings.json'] as const -const CLAUDE_PROFILE_MEMORY_IMPORT = '@~/.claude/CLAUDE.md\n' +export const CLAUDE_PROFILE_MEMORY_IMPORT = '@~/.claude/CLAUDE.md\n' const PRIVATE_KEYS = new Set([ 'apiKeyHelper', 'awsAuthRefresh', @@ -103,6 +103,7 @@ async function mergeState(args: { source: string target: string ledger: ClaudeProfileLedger + trustKeys: readonly string[] report: ClaudeProfileReport }): Promise { if (lstatIfPresent(args.target)?.isSymbolicLink()) { @@ -110,7 +111,7 @@ async function mergeState(args: { } const input = readClaudeProfileObject(args.source) if (input.kind === 'unavailable') { - // Why: onboarding doesn't depend on the personal state; only its shared keys wait. + // Why: onboarding and trust don't depend on the personal state; only its shared keys wait. const error = new ClaudeProfileSurfaceError('unreadable', 'Personal Claude state is unreadable') warnClaudeProfile(args.report, '.claude.json', error) } @@ -119,15 +120,7 @@ async function mergeState(args: { SHARED_STATE_KEYS.filter((key) => key in source).map((key) => [key, source[key]]) ) let written: Record = {} - let signedOut = false const outcome = await updateClaudeGlobalConfig(args.target, (current) => { - // Why oauthAccount: Claude writes it only on a finished sign-in, while it writes this file - // earlier in onboarding (theme pick). It's read under Claude's own lock, on every platform; - // credentials sit in the Keychain on macOS. A flag set before then skips Claude's sign-in. - signedOut = !claudeStateLogin(current) - if (signedOut) { - return { kind: 'unchanged' } - } const config = { ...current } written = { ...args.ledger.keys['.claude.json'] } let changed = mergeClaudeProfileKeys(config, desired, written).length > 0 @@ -142,10 +135,15 @@ async function mergeState(args: { config.hasCompletedOnboarding = true changed = true } + // Why: a malformed `projects` refuses only trust; onboarding and shared keys still apply. + const trust = args.trustKeys.length > 0 ? applyClaudeFolderTrust(config, args.trustKeys) : null + if (trust?.kind === 'changed') { + return trust + } return changed ? { kind: 'changed', config } : { kind: 'unchanged' } }) - if (outcome === 'missing-config' || (outcome === 'unchanged' && signedOut)) { - // Why: no completed login yet; writing would fabricate an account or skip Claude's sign-in. + if (outcome === 'missing-config') { + // Why: no state file means no completed login; writing one would fabricate an account. return 'absent' } if (outcome === 'locked' || outcome === 'unreadable') { @@ -165,6 +163,7 @@ export async function provisionClaudeProfile(args: { /** The user's own CLAUDE_CONFIG_DIR; `~/.claude` when unset. */ userConfigDir?: string platform?: NodeJS.Platform + trustKeys?: readonly string[] }): Promise { const platform = args.platform ?? process.platform const defaultHome = resolveClaudeDefaultHome(args.userHome, args.userConfigDir) @@ -211,6 +210,7 @@ export async function provisionClaudeProfile(args: { source: statePath(args.userConfigDir), target: statePath(args.profileHome), ledger, + trustKeys: args.trustKeys ?? [], report }) ) diff --git a/src/main/claude-accounts/claude-profile-router.test.ts b/src/main/claude-accounts/claude-profile-router.test.ts index 3db8218f016f..5391072d0bbe 100644 --- a/src/main/claude-accounts/claude-profile-router.test.ts +++ b/src/main/claude-accounts/claude-profile-router.test.ts @@ -1,6 +1,4 @@ -import { spawnSync } from 'node:child_process' import { - chmodSync, existsSync, mkdirSync, mkdtempSync, @@ -11,44 +9,22 @@ import { } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' import type { ClaudeManagedAccount } from '../../shared/managed-account-types' import type { ClaudeProfileSetupReport } from './claude-profile-setup' -import { - claudeProfileMarkerPath, - describeClaudeProfile, - prepareClaudeProfileDirectory, - type ClaudeProfileDescriptor -} from './claude-profile-paths' import { CLAUDE_PROFILE_MISSING_MESSAGE, - CLAUDE_PROFILE_SETUP_FAILED_MESSAGE -} from '../../shared/claude-profile-routing' -import { getPosixClaudeShellFunction } from '../../shared/claude-shell-function' -import { ClaudeProfileRouter, type ClaudeProfileRouterSettings } from './claude-profile-router' + CLAUDE_PROFILE_SETUP_FAILED_MESSAGE, + ClaudeProfileRouter, + type ClaudeProfileRouterSettings +} from './claude-profile-router' import { claudeProfileHistoryDirs, installClaudeProfileRouter } from './claude-profile-installed-router' -// Why: removal deletes macOS Keychain items; the test must never reach the real Keychain. -// The login shell exports what a Dock launch's own env lacks. -const shell = vi.hoisted(() => ({ hang: false })) -vi.mock('../startup/login-shell-environment', () => ({ - resolveLoginShellEnvironment: () => - shell.hang - ? new Promise(() => {}) - : Promise.resolve({ CLAUDE_CONFIG_DIR: resolve('/shell/own') }) -})) -vi.mock('../macos-keychain/generic-password', () => ({ - execSecurityCommand: async () => { - throw new Error('The specified item could not be found in the keychain.') - } -})) - const roots: string[] = [] afterEach(() => { - shell.hang = false installClaudeProfileRouter(undefined) roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true })) }) @@ -76,19 +52,13 @@ function fixture(env: NodeJS.ProcessEnv = {}) { agentStatusHooksEnabled: false, disabledTuiAgents: [] } - // Stands in for the worker; like it, stays pending until settled and marks the folder last. + // Stands in for the worker; each setup stays pending until the test settles it. const setup: { calls: number; outcome: ClaudeProfileSetupReport['outcome']; settle: () => void } = { calls: 0, outcome: 'prepared', settle: () => {} } - const runSetup = ({ profile }: { profile: ClaudeProfileDescriptor }) => { + const runSetup = () => { setup.calls += 1 - mkdirSync(profile.home, { recursive: true }) return new Promise((resolve) => { - setup.settle = () => { - if (setup.outcome === 'prepared') { - writeFileSync(claudeProfileMarkerPath(profile), '{}') - } - resolve({ outcome: setup.outcome, warnings: [], surfaces: {} }) - } + setup.settle = () => resolve({ outcome: setup.outcome, warnings: [], surfaces: {} }) }) } const router = new ClaudeProfileRouter({ @@ -103,36 +73,13 @@ function fixture(env: NodeJS.ProcessEnv = {}) { } describe('ClaudeProfileRouter', () => { - it('prepares an account folder for sign-in only when setup succeeds', async () => { - const f = fixture({ CLAUDE_CONFIG_DIR: resolve('/custom/claude') }) - expect(f.router.accountHome('b')).toBe(f.home('b')) - expect(f.router.userConfigDir()).toBe(resolve('/custom/claude')) - const prepared = f.router.prepareAccount('b') - await vi.waitFor(() => expect(f.setup.calls).toBe(1)) - f.setup.settle() - await expect(prepared).resolves.toBe(f.home('b')) - f.setup.outcome = 'refused' - const refused = f.router.prepareAccount('b') - await vi.waitFor(() => expect(f.setup.calls).toBe(2)) - f.setup.settle() - await expect(refused).rejects.toThrow(CLAUDE_PROFILE_SETUP_FAILED_MESSAGE) - }) - - it("reads an older Orca's copy-based switching from the System default snapshot it left", () => { - const f = fixture() - expect(f.router.copiedLoginIntoSystemDefault()).toBe(false) - mkdirSync(join(f.dataRoot, 'claude-runtime-auth'), { recursive: true }) - writeFileSync(join(f.dataRoot, 'claude-runtime-auth', 'system-default-auth.json'), '{}') - expect(f.router.copiedLoginIntoSystemDefault()).toBe(true) - }) - - it('publishes the selected folder, System default as empty, and no file without accounts', async () => { + it('publishes the selected folder, System default as empty, and no file without accounts', () => { const f = fixture() mkdirSync(f.home('a'), { recursive: true }) f.router.publish() expect(readFileSync(f.router.pointerPath, 'utf8')).toBe(f.home('a')) // publish returned while its setup is still running in the background. - await vi.waitFor(() => expect(f.setup.calls).toBe(1)) + expect(f.setup.calls).toBe(1) f.settings.activeClaudeManagedAccountId = null f.router.publish() @@ -143,108 +90,42 @@ describe('ClaudeProfileRouter', () => { expect(existsSync(f.router.pointerPath)).toBe(false) }) - it('makes a launch wait for a first setup that never ran or is still running, reusing it', async () => { + it('makes a launch wait only for a folder that was never set up, reusing the running setup', async () => { const f = fixture() mkdirSync(f.home('a'), { recursive: true }) + f.router.publish() let launched = false const launch = f.router.prepareLaunch().then((prepared) => { launched = true return prepared }) - await vi.waitFor(() => expect(f.setup.calls).toBe(1)) - await new Promise((resolve) => setTimeout(resolve, 10)) + await Promise.resolve() expect(launched).toBe(false) - const second = f.router.prepareLaunch() - f.setup.outcome = 'refused' - f.setup.settle() - await expect(launch).rejects.toMatchObject({ reason: 'claudeAccountSetupFailed' }) - await expect(second).rejects.toThrow(CLAUDE_PROFILE_SETUP_FAILED_MESSAGE) expect(f.setup.calls).toBe(1) - }) - - it('never makes a launch wait on, or fail with, a re-run of a set-up folder', async () => { - const f = fixture() - mkdirSync(f.home('a'), { recursive: true }) - const first = f.router.prepareLaunch() - await vi.waitFor(() => expect(f.setup.calls).toBe(1)) f.setup.settle() - await expect(first).resolves.toMatchObject({ configDir: f.home('a') }) + await expect(launch).resolves.toMatchObject({ configDir: f.home('a') }) - // Startup or a switch re-runs setup; it stays pending, then fails. f.setup.outcome = 'refused' - f.router.publish() - await vi.waitFor(() => expect(f.setup.calls).toBe(2)) - await expect(f.router.prepareLaunch()).resolves.toMatchObject({ configDir: f.home('a') }) + const refused = f.router.prepareLaunch() f.setup.settle() - await new Promise((resolve) => setTimeout(resolve, 10)) + await expect(refused).rejects.toThrow(CLAUDE_PROFILE_SETUP_FAILED_MESSAGE) + + // Set up once (setup's marker exists): launches stop waiting. + writeFileSync(join(f.dataRoot, 'claude-profiles', 'a', 'profile.json'), '{}') await expect(f.router.prepareLaunch()).resolves.toMatchObject({ configDir: f.home('a') }) expect(f.setup.calls).toBe(2) }) - it('makes a launch redo a first setup that was cut off after its ownership gate', async () => { - const f = fixture() - mkdirSync(f.home('a'), { recursive: true }) - const profile = describeClaudeProfile(f.dataRoot, 'a', { - executionHostId: 'local', - runtime: 'host' - }) - // The worker timed out, or the app quit, after the gate ran. - const cutOff = new ClaudeProfileRouter({ - getSettings: () => f.settings, - dataRoot: f.dataRoot, - userHome: f.userHome, - env: {}, - runSetup: async (args) => { - prepareClaudeProfileDirectory(args.dataRoot, args.profile, args.userHome) - throw new Error('Claude account setup timed out') - } - }) - await expect(cutOff.prepareLaunch()).rejects.toThrow(CLAUDE_PROFILE_SETUP_FAILED_MESSAGE) - expect(existsSync(claudeProfileMarkerPath(profile))).toBe(false) - - const launch = f.router.prepareLaunch() - await vi.waitFor(() => expect(f.setup.calls).toBe(1)) - f.setup.settle() - await expect(launch).resolves.toMatchObject({ configDir: f.home('a') }) - }) - - it("launches a set-up account without waiting for the login shell's env", async () => { - shell.hang = true - const f = fixture() - mkdirSync(f.home('a'), { recursive: true }) - writeFileSync(join(f.home('a'), '..', 'profile.json'), '{}') - const router = new ClaudeProfileRouter({ - getSettings: () => f.settings, - dataRoot: f.dataRoot, - userHome: f.userHome - }) - await expect(router.prepareLaunch()).resolves.toMatchObject({ configDir: f.home('a') }) - }) - - it("sets up a pre-update account's missing folder without a login, for Claude's own first run", async () => { + it('names a never-signed-in folder without creating it, and refuses to launch it', async () => { const f = fixture() f.settings.activeClaudeManagedAccountId = 'b' - // Before its setup, a terminal still opens; its claude function creates the folder itself. - expect(() => f.router.preparation()).toThrow(CLAUDE_PROFILE_MISSING_MESSAGE) - expect(f.router.terminalEnv()).toEqual({ ORCA_CLAUDE_PROFILE_POINTER: f.router.pointerPath }) f.router.publish() expect(readFileSync(f.router.pointerPath, 'utf8')).toBe(f.home('b')) - await vi.waitFor(() => expect(f.setup.calls).toBe(1)) - expect(existsSync(f.home('b'))).toBe(true) - // Claude's first run is clean only without an onboarding-done state file. - expect(existsSync(join(f.home('b'), '.claude.json'))).toBe(false) - const launch = f.router.prepareLaunch() - f.setup.settle() - await expect(launch).resolves.toMatchObject({ configDir: f.home('b') }) - expect(f.setup.calls).toBe(1) - }) - - it('makes a launch set up a missing folder first', async () => { - const f = fixture() - const launch = f.router.prepareLaunch() - await vi.waitFor(() => expect(f.setup.calls).toBe(1)) - f.setup.settle() - await expect(launch).resolves.toMatchObject({ configDir: f.home('a') }) + expect(f.setup.calls).toBe(0) + expect(existsSync(f.home('b'))).toBe(false) + expect(() => f.router.preparation()).toThrow(CLAUDE_PROFILE_MISSING_MESSAGE) + // A terminal still opens; its claude function refuses from the pointer instead. + expect(f.router.terminalEnv()).toEqual({ ORCA_CLAUDE_PROFILE_POINTER: f.router.pointerPath }) }) it('injects the account with its twin, and nothing over the user’s own System default', () => { @@ -268,92 +149,12 @@ describe('ClaudeProfileRouter', () => { expect(f.router.preparation().envPatch).not.toHaveProperty('CLAUDE_CONFIG_DIR') }) - it('carries the user’s own value beside an account terminal’s injected one', () => { - const f = fixture({ CLAUDE_CONFIG_DIR: resolve('/user/own') }) - mkdirSync(f.home('a'), { recursive: true }) - expect(f.router.terminalEnv()).toEqual({ - ORCA_CLAUDE_PROFILE_POINTER: f.router.pointerPath, - CLAUDE_CONFIG_DIR: f.home('a'), - ORCA_CLAUDE_INJECTED_CONFIG_DIR: f.home('a'), - ORCA_CLAUDE_USER_CONFIG_DIR: resolve('/user/own') - }) - expect(f.router.preparation().envPatch).not.toHaveProperty('ORCA_CLAUDE_USER_CONFIG_DIR') - f.settings.activeClaudeManagedAccountId = null - expect(f.router.terminalEnv()).toEqual({ ORCA_CLAUDE_PROFILE_POINTER: f.router.pointerPath }) - const none = fixture() - mkdirSync(none.home('a'), { recursive: true }) - expect(none.router.terminalEnv()).not.toHaveProperty('ORCA_CLAUDE_USER_CONFIG_DIR') - }) - - // Why not win32: runs the POSIX claude function under bash. - it.skipIf(process.platform === 'win32')( - 'gives an account terminal back the inherited CLAUDE_CONFIG_DIR, or none, on System default', - () => { - for (const inherited of [{ CLAUDE_CONFIG_DIR: resolve('/user/own') }, {}]) { - const f = fixture(inherited) - mkdirSync(f.home('a'), { recursive: true }) - const bin = join(f.root, 'bin') - mkdirSync(bin) - writeFileSync(join(bin, 'claude'), '#!/bin/sh\nprintf "%s" "${CLAUDE_CONFIG_DIR-unset}"\n') - chmodSync(join(bin, 'claude'), 0o700) - const claude = (env: Record): string => - spawnSync( - '/bin/bash', - ['--norc', '--noprofile', '-c', `${getPosixClaudeShellFunction()}\nclaude`], - { env: { ...env, PATH: `${bin}:/usr/bin:/bin` }, encoding: 'utf8' } - ).stdout - // The pane opens while account A is selected, so Orca replaces the inherited value. - f.router.publish() - const pane = { ...inherited, ...f.router.terminalEnv() } - expect(claude(pane)).toBe(f.home('a')) - f.settings.activeClaudeManagedAccountId = null - f.router.publish() - expect(claude(pane)).toBe(inherited.CLAUDE_CONFIG_DIR ?? 'unset') - } - } - ) - - it('removes a folder only after its running setup, so the setup cannot bring it back', async () => { - const f = fixture() - mkdirSync(f.home('a'), { recursive: true }) - f.router.publish() - await vi.waitFor(() => expect(f.setup.calls).toBe(1)) - const removal = f.router.removeAccount('a') - await new Promise((resolve) => setTimeout(resolve, 10)) - // Setup writes into the folder before it settles, as the worker does. - mkdirSync(f.home('a'), { recursive: true }) - writeFileSync(join(f.home('a'), 'settings.json'), '{}') - f.setup.settle() - await removal - expect(existsSync(join(f.dataRoot, 'claude-profiles', 'a'))).toBe(false) - }) - - it('takes System default from the login shell, which a Dock launch does not inherit', async () => { - const f = fixture() - f.settings.activeClaudeManagedAccountId = null - const router = new ClaudeProfileRouter({ - getSettings: () => f.settings, - dataRoot: f.dataRoot, - userHome: f.userHome - }) - await expect(router.prepareLaunch()).resolves.toMatchObject({ - configDir: resolve('/shell/own'), - provenance: 'system' - }) - }) - it('treats a CLAUDE_CONFIG_DIR an outer Orca injected as not the user’s', () => { const f = fixture({ CLAUDE_CONFIG_DIR: '/outer/profile', ORCA_CLAUDE_INJECTED_CONFIG_DIR: '/outer/profile' }) expect(f.router.systemDefaultHome()).toBe(join(f.userHome, '.claude')) - const carried = fixture({ - CLAUDE_CONFIG_DIR: '/outer/profile', - ORCA_CLAUDE_INJECTED_CONFIG_DIR: '/outer/profile', - ORCA_CLAUDE_USER_CONFIG_DIR: '/user/own' - }) - expect(carried.router.systemDefaultHome()).toBe(resolve('/user/own')) }) // Why not win32: creating the link needs privileges there. diff --git a/src/main/claude-accounts/claude-profile-router.ts b/src/main/claude-accounts/claude-profile-router.ts index fa44e53b5630..3b12f6f7c82d 100644 --- a/src/main/claude-accounts/claude-profile-router.ts +++ b/src/main/claude-accounts/claude-profile-router.ts @@ -2,19 +2,13 @@ import { existsSync, mkdirSync, rmSync } from 'node:fs' import { homedir } from 'node:os' import { dirname, join } from 'node:path' import { isAgentStatusHooksEnabledForAgent } from '../../shared/agent-status-hooks-setting' -import { - CLAUDE_INJECTED_CONFIG_DIR_ENV, - CLAUDE_PROFILE_MISSING_MESSAGE, - CLAUDE_PROFILE_POINTER_ENV, - CLAUDE_PROFILE_SETUP_FAILED_MESSAGE, - CLAUDE_USER_CONFIG_DIR_ENV -} from '../../shared/claude-profile-routing' +import { CLAUDE_PROFILE_POINTER_ENV } from '../../shared/claude-profile-routing' import type { GlobalSettings } from '../../shared/global-settings-types' import { probeClaudeCliVersion } from '../claude/claude-hook-event-versions' import { writeFileAtomically } from '../codex-accounts/fs-utils' import { resolveClaudeCommand } from '../codex-cli/command' -import { AgentSessionPreSpawnError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { + CLAUDE_INJECTED_CONFIG_DIR_ENV, claudeProfileMarkerPath, describeClaudeProfile, type ClaudeProfileDescriptor, @@ -31,8 +25,6 @@ import { } from './runtime-selection' import { wslClaudeProfilePointer } from './claude-profile-wsl-paths' import { isDirectory, listClaudeProfileHomes } from './claude-profile-installed-router' -import { removeClaudeAccountFolder } from './claude-account-folder' -import { resolveLoginShellEnvironment } from '../startup/login-shell-environment' export type ClaudeProfileRouterSettings = Pick< GlobalSettings, @@ -43,6 +35,12 @@ export type ClaudeProfileRouterSettings = Pick< | 'disabledTuiAgents' > +export const CLAUDE_PROFILE_SETUP_FAILED_MESSAGE = + 'The selected Claude account could not be set up. Try again or choose another account.' + +export const CLAUDE_PROFILE_MISSING_MESSAGE = + "The selected Claude account's folder is missing. Sign in to it again or choose another account." + /** * Routes this host's Claude launches to the selected account's folder. Settings own the selection; * the pointer file mirrors it for `claude` typed in an already-open terminal. @@ -50,25 +48,17 @@ export type ClaudeProfileRouterSettings = Pick< export class ClaudeProfileRouter { readonly pointerPath: string private readonly setups = new Map>() - private env: NodeJS.ProcessEnv - private readonly envReady: Promise constructor( private readonly args: { getSettings: () => ClaudeProfileRouterSettings dataRoot: string userHome?: string - /** Tests replace the login shell's env. */ env?: NodeJS.ProcessEnv /** Tests replace the worker. */ runSetup?: typeof runClaudeProfileSetupInWorker } ) { this.pointerPath = join(args.dataRoot, 'claude-profiles', 'selected-host') - this.env = args.env ?? process.env - // Why the login shell's: a Dock launch lacks the CLAUDE_CONFIG_DIR an rc exports. Chats share it. - this.envReady = args.env - ? Promise.resolve() - : resolveLoginShellEnvironment().then((env) => (this.env = env)) } private get userHome(): string { @@ -77,19 +67,24 @@ export class ClaudeProfileRouter { private selectedProfile(): ClaudeProfileDescriptor | null { const id = getSelectedClaudeAccountIdForTarget(this.args.getSettings(), { runtime: 'host' }) - return id ? this.describe(id) : null + return id + ? describeClaudeProfile(this.args.dataRoot, id, { executionHostId: 'local', runtime: 'host' }) + : null } /** The user's System default: their own CLAUDE_CONFIG_DIR, else ~/.claude. */ systemDefaultHome(): string { - return resolveClaudeDefaultHome(this.userHome, this.userConfigDir()) + return resolveClaudeDefaultHome( + this.userHome, + readUserClaudeConfigDir(this.args.env ?? process.env) + ) } /** Null for System default. Throws for a missing folder: falling back would run the wrong account. */ selectedHome(): string | null { const home = this.selectedProfile()?.home ?? null if (home !== null && !isDirectory(home)) { - throw claudeProfileMissing() + throw new Error(CLAUDE_PROFILE_MISSING_MESSAGE) } return home } @@ -103,72 +98,26 @@ export class ClaudeProfileRouter { const profile = this.selectedProfile() mkdirSync(dirname(this.pointerPath), { recursive: true, mode: 0o700 }) writeFileAtomically(this.pointerPath, profile?.home ?? '', { mode: 0o600 }) - // Why even a missing folder: setup creates it without a login, so Claude's own first run - // signs in there (an account saved before per-account folders has none yet). - if (profile) { + // Why the existence check: setup creates the folder, and only sign-in may create an account. + if (profile && isDirectory(profile.home)) { this.setUp(profile).catch((error: unknown) => { console.warn('[claude-profile] Account setup failed:', error) }) } } - /** Waits for a first setup that never finished, running or not; otherwise launches at once. */ + /** Waits for setup only for a folder that was never set up; otherwise launches at once. */ async prepareLaunch(): Promise { const profile = this.selectedProfile() - if (!profile) { - // Only System default reads the login shell's env here; setup awaits it itself. - await this.envReady - return this.preparation() - } - // Why the marker: setup writes it last, so a missing folder is set up too. A re-run of a - // set-up folder never blocks. - if (!existsSync(claudeProfileMarkerPath(profile))) { + if (profile && isDirectory(profile.home) && !existsSync(claudeProfileMarkerPath(profile))) { const report = await this.setUp(profile).catch(() => null) if (report?.outcome !== 'prepared') { - throw claudeProfileSetupFailed() + throw new Error(CLAUDE_PROFILE_SETUP_FAILED_MESSAGE) } } return this.preparation() } - /** An account's folder on this host, whether or not it exists yet. */ - accountHome(accountId: string): string { - return this.describe(accountId).home - } - - /** Creates and sets up an account's folder for sign-in; the login itself is Claude's. */ - async prepareAccount(accountId: string): Promise { - const profile = this.describe(accountId) - const report = await this.setUp(profile).catch(() => null) - if (report?.outcome !== 'prepared') { - throw new Error(CLAUDE_PROFILE_SETUP_FAILED_MESSAGE) - } - return profile.home - } - - /** Deletes an account's folder after any setup running for it, which would otherwise recreate it. */ - async removeAccount(accountId: string): Promise { - await this.setups.get(accountId)?.catch(() => {}) - await removeClaudeAccountFolder(this.args.dataRoot, accountId) - } - - /** True once an older Orca copied an account's login into System default: it left this snapshot. */ - copiedLoginIntoSystemDefault(): boolean { - return existsSync(join(this.args.dataRoot, 'claude-runtime-auth', 'system-default-auth.json')) - } - - /** The user's own CLAUDE_CONFIG_DIR, which wins over the selection in their terminals. */ - userConfigDir(): string | undefined { - return readUserClaudeConfigDir(this.env) - } - - private describe(accountId: string): ClaudeProfileDescriptor { - return describeClaudeProfile(this.args.dataRoot, accountId, { - executionHostId: 'local', - runtime: 'host' - }) - } - /** One setup per account at a time; a later request reuses the running one. */ private setUp(profile: ClaudeProfileDescriptor): Promise { const running = this.setups.get(profile.accountId) @@ -181,14 +130,13 @@ export class ClaudeProfileRouter { } private async runSetup(profile: ClaudeProfileDescriptor): Promise { - await this.envReady const hooks = isAgentStatusHooksEnabledForAgent(this.args.getSettings(), 'claude') const claudeVersion = hooks ? await probeClaudeCliVersion(resolveClaudeCommand()) : null const report = await (this.args.runSetup ?? runClaudeProfileSetupInWorker)({ dataRoot: this.args.dataRoot, profile, userHome: this.userHome, - userConfigDir: this.userConfigDir(), + userConfigDir: readUserClaudeConfigDir(this.args.env ?? process.env), hooks, claudeVersion: claudeVersion ?? undefined }) @@ -215,12 +163,7 @@ export class ClaudeProfileRouter { return { [CLAUDE_PROFILE_POINTER_ENV]: `~/${wslClaudeProfilePointer(this.args.dataRoot)}` } } try { - const env = this.launchEnv() - const userConfigDir = this.userConfigDir() - // Why: the injected value replaces the user's own; the claude function restores it on System default. - return env.CLAUDE_CONFIG_DIR && userConfigDir - ? { ...env, [CLAUDE_USER_CONFIG_DIR_ENV]: userConfigDir } - : env + return this.launchEnv() } catch { return { [CLAUDE_PROFILE_POINTER_ENV]: this.pointerPath } } @@ -244,16 +187,3 @@ export class ClaudeProfileRouter { return listClaudeProfileHomes(this.args.dataRoot) } } - -// Typed so a chat names the situation; a terminal reads the same message. -export function claudeProfileMissing(): AgentSessionPreSpawnError { - return new AgentSessionPreSpawnError(new Error(CLAUDE_PROFILE_MISSING_MESSAGE), { - reason: 'claudeAccountFolderMissing' - }) -} - -export function claudeProfileSetupFailed(): AgentSessionPreSpawnError { - return new AgentSessionPreSpawnError(new Error(CLAUDE_PROFILE_SETUP_FAILED_MESSAGE), { - reason: 'claudeAccountSetupFailed' - }) -} diff --git a/src/main/claude-accounts/claude-profile-setup.test.ts b/src/main/claude-accounts/claude-profile-setup.test.ts index 0ead45be168d..0195805eff32 100644 --- a/src/main/claude-accounts/claude-profile-setup.test.ts +++ b/src/main/claude-accounts/claude-profile-setup.test.ts @@ -48,7 +48,7 @@ function fixture() { installHooks, platform: 'linux' }) - return { root, defaultHome, dataRoot, service, installHooks, setup } + return { root, defaultHome, dataRoot, service, setup } } // Why: setup runs as 'linux', so it creates real symlinks, which Windows needs privilege for. @@ -83,24 +83,6 @@ describe('Claude account profile setup', () => { expect(existsSync(join(home, '.credentials.json'))).toBe(false) expect((await f.setup()).warnings).toEqual([]) }) - itLinks('marks the folder set up only after its last step', async () => { - const f = fixture() - const marker = join(f.dataRoot, 'claude-profiles/a/profile.json') - let markedDuringHooks: boolean | undefined - const report = await provisionClaudeAccountProfile({ - dataRoot: f.dataRoot, - profile: describeClaudeProfile(f.dataRoot, 'a', local), - userHome: state.home, - installHooks: (target) => { - markedDuringHooks = existsSync(marker) - return f.installHooks(target) - }, - platform: 'linux' - }) - expect(report.outcome).toBe('prepared') - expect(markedDuringHooks).toBe(false) - expect(existsSync(marker)).toBe(true) - }) itLinks("brings the user's later hooks into an account set up while they had none", async () => { const f = fixture() const settings = join(f.defaultHome, 'settings.json') diff --git a/src/main/claude-accounts/claude-profile-setup.ts b/src/main/claude-accounts/claude-profile-setup.ts index 7d132e1c86d4..1687d9cf0e8b 100644 --- a/src/main/claude-accounts/claude-profile-setup.ts +++ b/src/main/claude-accounts/claude-profile-setup.ts @@ -51,21 +51,16 @@ export async function provisionClaudeAccountProfile(args: { userConfigDir?: string /** Null when Orca's Claude hooks are turned off. Runs after the settings merge so its entries survive it. */ installHooks: ((target: { configDir: string }) => AgentHookInstallStatus) | null + trustKeys?: readonly string[] platform?: NodeJS.Platform }): Promise { const platform = args.platform ?? process.platform const report = createClaudeProfileReport() - let markSetUp: () => void try { if (args.profile.target.runtime === 'wsl' && platform === 'win32') { throw new ClaudeProfileSurfaceError('invalid-profile', 'WSL profiles are set up in the guest') } - markSetUp = prepareClaudeProfileDirectory( - args.dataRoot, - args.profile, - args.userHome, - args.userConfigDir - ) + prepareClaudeProfileDirectory(args.dataRoot, args.profile, args.userHome, args.userConfigDir) } catch (error) { report.surfaces.profile = 'failed' warnClaudeProfile(report, 'profile', error) @@ -75,7 +70,7 @@ export async function provisionClaudeAccountProfile(args: { const shared = { profileHome: home, userHome: args.userHome, userConfigDir: args.userConfigDir } for (const step of [ () => shareClaudeProfileHistory({ ...shared, platform }), - () => provisionClaudeProfile({ ...shared, platform }) + () => provisionClaudeProfile({ ...shared, platform, trustKeys: args.trustKeys }) ]) { try { const part = await step() @@ -97,7 +92,5 @@ export async function provisionClaudeAccountProfile(args: { recordInstalledHooks(home) return 'merged' }) - // Last: a setup cut off before here leaves no marker, so the next launch waits for a full one. - markSetUp() return { outcome: 'prepared', ...report } } diff --git a/src/main/claude-accounts/claude-profile-shell-composition.test.ts b/src/main/claude-accounts/claude-profile-shell-composition.test.ts index fb4ed6255e10..4bf99fdac515 100644 --- a/src/main/claude-accounts/claude-profile-shell-composition.test.ts +++ b/src/main/claude-accounts/claude-profile-shell-composition.test.ts @@ -1,13 +1,21 @@ +import type * as ProfileRouting from '../../shared/claude-profile-routing' import { spawnSync } from 'node:child_process' import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, expect, it } from 'vitest' +import { afterEach, expect, it, vi } from 'vitest' +const gate = vi.hoisted(() => ({ enabled: true })) +vi.mock('../../shared/claude-profile-routing', async (original) => ({ + ...(await original()), + claudeProfileRoutingEnabled: () => gate.enabled +})) const FISH = ['/opt/homebrew/bin/fish', '/usr/local/bin/fish', '/usr/bin/fish'].find(existsSync) ?? '/usr/bin/fish' const roots: string[] = [] afterEach(() => { + gate.enabled = true + vi.resetModules() roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true })) }) function fishInit({ args }: { args: string[] | null }): string { @@ -17,7 +25,9 @@ function fishInit({ args }: { args: string[] | null }): string { } return init } -async function composed() { +async function composed(enabled: boolean) { + gate.enabled = enabled + vi.resetModules() const local = await import('../providers/local-pty-shell-ready') const daemon = await import('../daemon/shell-ready') const powershell = await import('../powershell-osc133-bootstrap') @@ -43,19 +53,35 @@ async function composed() { } } -it('gives every composed shell startup and the plain fish vendor snippet the claude function', async () => { - const on = await composed() +it('keeps the composed bash, zsh, fish and PowerShell startup text dormant with the gate off', async () => { + const off = await composed(false) + for (const text of [...off.fish, off.powershell]) { + expect(text).not.toContain('claude') + } + for (const text of [...off.bash, ...off.zsh]) { + expect(text).not.toContain('ORCA_CLAUDE') + } + const on = await composed(true) for (const text of [...on.bash, ...on.zsh]) { expect(text).toContain('ORCA_CLAUDE_INJECTED_CONFIG_DIR') } - const { getFishVendorConfSnippet } = await import('../fish-xdg-data-dirs-handoff') +}) + +it('gives a plain fish tab the claude function through the vendor snippet only with the gate on', async () => { + gate.enabled = false + vi.resetModules() + const off = await import('../fish-xdg-data-dirs-handoff') + expect(off.getFishVendorConfSnippet()).not.toContain('claude') + gate.enabled = true + vi.resetModules() + const on = await import('../fish-xdg-data-dirs-handoff') const { getFishClaudeShellFunction } = await import('../../shared/claude-shell-function') expect(getFishClaudeShellFunction()).toContain('function claude') - expect(getFishVendorConfSnippet()).toContain(getFishClaudeShellFunction()) + expect(on.getFishVendorConfSnippet()).toContain(getFishClaudeShellFunction()) }) it('starts the PowerShell claude function on its own line after the codex fragment', async () => { - const on = await composed() + const on = await composed(true) expect(on.powershell).toContain( 'Remove-Variable orcaCodexCommand -ErrorAction SilentlyContinue\n$orcaClaudeCommand' ) @@ -69,7 +95,7 @@ it.skipIf(!existsSync(FISH))( mkdirSync(join(root, 'bin')) writeFileSync(join(root, 'bin', 'claude'), '#!/bin/sh\nexit 0\n') chmodSync(join(root, 'bin', 'claude'), 0o700) - const on = await composed() + const on = await composed(true) for (const init of on.fish) { const result = spawnSync( '/usr/bin/env', diff --git a/src/main/claude-accounts/claude-profile-wsl-paths.ts b/src/main/claude-accounts/claude-profile-wsl-paths.ts index 39d2bf677282..a5096c040eea 100644 --- a/src/main/claude-accounts/claude-profile-wsl-paths.ts +++ b/src/main/claude-accounts/claude-profile-wsl-paths.ts @@ -25,12 +25,3 @@ export function wslClaudeProfilePointer(hostDataRoot: string): string { const build = basename(hostDataRoot).replace(/[^\w.-]/g, '_') return `.local/share/orca/claude-profiles/selected-wsl-${build}` } - -/** A saved WSL account's guest folder; null for an older Orca's folder, which holds no login. */ -export function savedWslClaudeAccountHome(account: { - id: string - wslLinuxAuthPath?: string | null -}): string | null { - const home = account.wslLinuxAuthPath - return home?.endsWith(`/claude-profiles/${account.id}/home`) ? home : null -} diff --git a/src/main/claude-accounts/claude-profile-wsl-router.test.ts b/src/main/claude-accounts/claude-profile-wsl-router.test.ts index 00c16d881d20..decde2f9d35a 100644 --- a/src/main/claude-accounts/claude-profile-wsl-router.test.ts +++ b/src/main/claude-accounts/claude-profile-wsl-router.test.ts @@ -6,7 +6,6 @@ import { mkdtempSync, readFileSync, rmSync, - symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -38,12 +37,10 @@ vi.mock('../wsl/wsl-runner', () => ({ import { CLAUDE_PROFILE_MISSING_MESSAGE, - CLAUDE_PROFILE_SETUP_FAILED_MESSAGE -} from '../../shared/claude-profile-routing' -import type { ClaudeProfileRouterSettings } from './claude-profile-router' -import { prepareClaudeProfileDirectory } from './claude-profile-paths' + CLAUDE_PROFILE_SETUP_FAILED_MESSAGE, + type ClaudeProfileRouterSettings +} from './claude-profile-router' import { ClaudeWslProfileRouter } from './claude-profile-wsl-router' -import { wslClaudeProfile } from './claude-profile-wsl-paths' // Why skipped on Windows: the guest is Linux; these run its scripts and Node bundle as the guest. const posixHost = process.platform !== 'win32' @@ -78,14 +75,12 @@ function fixture() { const router = new ClaudeWslProfileRouter({ getSettings: () => settings, dataRoot: join(root, 'orca-dev'), - // Like the guest helper, marks the folder only once it finishes. runSetup: async () => { setup.calls += 1 await setup.gate if (setup.fail) { throw new Error('refused') } - mkdirSync(profileHome, { recursive: true }) writeFileSync(join(profileHome, '..', 'profile.json'), '{}') } }) @@ -95,10 +90,14 @@ function fixture() { } describe.skipIf(!posixHost)('ClaudeWslProfileRouter', () => { - it('writes the guest pointer per build, sets up even a missing folder, and removes it with the last account', async () => { + it('writes the guest pointer per build, sets up only a signed-in folder, and removes it with the last account', async () => { const f = fixture() await f.router.publish('Ubuntu') expect(readFileSync(f.pointer, 'utf8')).toBe(f.profileHome) + expect(f.setup.calls).toBe(0) + + mkdirSync(f.profileHome, { recursive: true }) + await f.router.publish('Ubuntu') await vi.waitFor(() => expect(f.setup.calls).toBe(1)) f.wsl.Ubuntu = null @@ -110,21 +109,15 @@ describe.skipIf(!posixHost)('ClaudeWslProfileRouter', () => { expect(existsSync(f.pointer)).toBe(false) }) - it('sets up a missing folder, waits for a first setup never run or still running, then launches at once', async () => { + it('refuses a missing folder, waits for a never-set-up one, then launches at once', async () => { const f = fixture() - await expect(f.router.preparation('Ubuntu')).rejects.toThrow(CLAUDE_PROFILE_MISSING_MESSAGE) - f.setup.fail = true - let release = () => {} - f.setup.gate = new Promise((resolve) => (release = resolve)) - const launch = f.router.prepareLaunch('Ubuntu') - await vi.waitFor(() => expect(f.setup.calls).toBe(1)) - const second = f.router.prepareLaunch('Ubuntu') - await new Promise((resolve) => setTimeout(resolve, 50)) - release() - await expect(launch).rejects.toThrow(CLAUDE_PROFILE_SETUP_FAILED_MESSAGE) - await expect(second).rejects.toThrow(CLAUDE_PROFILE_SETUP_FAILED_MESSAGE) - expect(f.setup.calls).toBe(1) + await expect(f.router.prepareLaunch('Ubuntu')).rejects.toThrow(CLAUDE_PROFILE_MISSING_MESSAGE) + mkdirSync(f.profileHome, { recursive: true }) + f.setup.fail = true + await expect(f.router.prepareLaunch('Ubuntu')).rejects.toThrow( + CLAUDE_PROFILE_SETUP_FAILED_MESSAGE + ) f.setup.fail = false const prepared = await f.router.prepareLaunch('Ubuntu') expect(prepared).toMatchObject({ @@ -138,94 +131,10 @@ describe.skipIf(!posixHost)('ClaudeWslProfileRouter', () => { ORCA_CLAUDE_INJECTED_CONFIG_DIR: f.profileHome } }) - // The failed first setup left no marker, so this launch ran setup again; the next does not. - expect(f.setup.calls).toBe(2) await f.router.prepareLaunch('Ubuntu') expect(f.setup.calls).toBe(2) }) - it('makes a launch redo a first setup that was cut off after its ownership gate', async () => { - const f = fixture() - mkdirSync(f.profileHome, { recursive: true }) - // The guest helper failed, or timed out, after the gate ran. - const cutOff = new ClaudeWslProfileRouter({ - getSettings: () => f.settings, - dataRoot: join(guest.home, '..', 'orca-dev'), - runSetup: async (distro, home, accountId) => { - const { dataRoot, profile } = wslClaudeProfile(home, distro, accountId) - prepareClaudeProfileDirectory(dataRoot, profile, home) - throw new Error('timed out') - } - }) - await expect(cutOff.prepareLaunch('Ubuntu')).rejects.toThrow( - CLAUDE_PROFILE_SETUP_FAILED_MESSAGE - ) - expect(existsSync(join(f.profileHome, '..', 'profile.json'))).toBe(false) - - await f.router.prepareLaunch('Ubuntu') - expect(f.setup.calls).toBe(1) - }) - - it('never makes a launch wait on, or fail with, a re-run of a set-up folder', async () => { - const f = fixture() - mkdirSync(f.profileHome, { recursive: true }) - writeFileSync(join(f.profileHome, '..', 'profile.json'), '{}') - f.setup.fail = true - let release = () => {} - f.setup.gate = new Promise((resolve) => (release = resolve)) - await f.router.publish('Ubuntu') - await vi.waitFor(() => expect(f.setup.calls).toBe(1)) - await expect(f.router.prepareLaunch('Ubuntu')).resolves.toMatchObject({ - configDir: f.profileHome - }) - release() - await new Promise((resolve) => setTimeout(resolve, 10)) - await expect(f.router.prepareLaunch('Ubuntu')).resolves.toMatchObject({ - configDir: f.profileHome - }) - expect(f.setup.calls).toBe(1) - }) - - it('sets up a new account folder for sign-in and deletes it without following its links', async () => { - const f = fixture() - const sharedHistory = join(guest.home, '.claude', 'projects') - mkdirSync(sharedHistory) - writeFileSync(join(sharedHistory, 'chat.jsonl'), '{}') - // The stub setup marks account a's folder. - mkdirSync(f.profileHome, { recursive: true }) - const home = await f.router.prepareAccount('Ubuntu', 'b') - expect(home).toBe(join(guest.home, '.local/share/orca/claude-profiles/b/home')) - expect(f.setup.calls).toBe(1) - mkdirSync(home, { recursive: true }) - symlinkSync(sharedHistory, join(home, 'projects')) - - await f.router.removeAccount('Ubuntu', 'b') - expect(existsSync(join(home, '..'))).toBe(false) - expect(existsSync(join(sharedHistory, 'chat.jsonl'))).toBe(true) - - f.setup.fail = true - await expect(f.router.prepareAccount('Ubuntu', 'c')).rejects.toThrow( - CLAUDE_PROFILE_SETUP_FAILED_MESSAGE - ) - }) - - it('removes a guest folder only after its running setup, so the setup cannot bring it back', async () => { - const f = fixture() - mkdirSync(f.profileHome, { recursive: true }) - let release = () => {} - // Like the guest helper, setup recreates the folder it writes into. - f.setup.gate = new Promise((resolve) => (release = resolve)).then(() => { - mkdirSync(f.profileHome, { recursive: true }) - }) - await f.router.publish('Ubuntu') - await vi.waitFor(() => expect(f.setup.calls).toBe(1)) - const removal = f.router.removeAccount('Ubuntu', 'a') - await new Promise((resolve) => setTimeout(resolve, 50)) - release() - await removal - expect(existsSync(join(f.profileHome, '..'))).toBe(false) - }) - it('writes a missing guest pointer before a launch returns', async () => { const f = fixture() mkdirSync(f.profileHome, { recursive: true }) diff --git a/src/main/claude-accounts/claude-profile-wsl-router.ts b/src/main/claude-accounts/claude-profile-wsl-router.ts index 10c8273d9195..0fba0eb59a68 100644 --- a/src/main/claude-accounts/claude-profile-wsl-router.ts +++ b/src/main/claude-accounts/claude-profile-wsl-router.ts @@ -2,21 +2,21 @@ import { existsSync } from 'node:fs' import { lstat, readFile } from 'node:fs/promises' import { join, posix } from 'node:path' import { getAppEnvironment } from '../../shared/app-environment' -import { - CLAUDE_INJECTED_CONFIG_DIR_ENV, - CLAUDE_PROFILE_POINTER_ENV, - CLAUDE_PROFILE_SETUP_FAILED_MESSAGE -} from '../../shared/claude-profile-routing' +import { CLAUDE_PROFILE_POINTER_ENV } from '../../shared/claude-profile-routing' import { WSL_CLAUDE_PROFILE_HELPER_FILENAME } from '../../shared/relay-artifacts' import { parseWslUncPath, toWindowsWslPath } from '../../shared/wsl-paths' import { getWslHomeAsync, listRunningWslDistrosAsync } from '../wsl' import { ensureWslPinnedRuntime } from '../wsl/wsl-pinned-runtime' import { relayBundleCandidates } from '../ssh/relay-bundle-paths' import { runWslProcess, type WslSpec } from '../wsl/wsl-runner' -import { claudeProfileMarkerPath, type ClaudeProfileDescriptor } from './claude-profile-paths' import { - claudeProfileMissing, - claudeProfileSetupFailed, + CLAUDE_INJECTED_CONFIG_DIR_ENV, + claudeProfileMarkerPath, + type ClaudeProfileDescriptor +} from './claude-profile-paths' +import { + CLAUDE_PROFILE_MISSING_MESSAGE, + CLAUDE_PROFILE_SETUP_FAILED_MESSAGE, type ClaudeProfileRouterSettings } from './claude-profile-router' import { wslClaudeProfile, wslClaudeProfilePointer } from './claude-profile-wsl-paths' @@ -90,23 +90,22 @@ export class ClaudeWslProfileRouter { return } await writePointer(distro, this.pointerIn(home), profile?.home ?? '') - // Why even a missing folder: setup creates it without a login, for Claude's own first run. - if (profile) { + // Why the existence check: setup creates the folder, and only sign-in may create an account. + if (profile && (await guestStat(distro, profile.home))?.isDirectory()) { this.setUp(distro, home, profile.accountId).catch((error: unknown) => { console.warn('[claude-profile] WSL account setup failed:', error) }) } } - /** Waits for a first setup that never finished, running or not; otherwise launches at once. */ + /** Waits for setup only for a folder that was never set up; otherwise launches at once. */ async prepareLaunch(distro: string): Promise { const { home, profile } = await this.resolve(distro) - // Why the marker: setup writes it last, so a missing folder is set up too. A re-run of a - // set-up folder never blocks. - if (profile && !(await hasMarker(distro, profile))) { + await this.assertPresent(distro, profile) + if (profile && !(await guestStat(distro, claudeProfileMarkerPath(profile)))?.isFile()) { await this.setUp(distro, home, profile.accountId).catch((error: unknown) => { console.warn('[claude-profile] WSL account setup failed:', error) - throw claudeProfileSetupFailed() + throw new Error(CLAUDE_PROFILE_SETUP_FAILED_MESSAGE) }) } // Why: a missing or stale guest pointer would run the pane's `claude` under another account. @@ -124,32 +123,10 @@ export class ClaudeWslProfileRouter { return this.preparationFor(distro, home, profile) } - /** Creates and sets up an account's guest folder for sign-in; the login itself is Claude's. */ - async prepareAccount(distro: string, accountId: string): Promise { - const { home } = await this.resolve(distro) - const { profile } = wslClaudeProfile(home, distro, accountId) - await this.setUp(distro, home, accountId).catch((error: unknown) => { - console.warn('[claude-profile] WSL account setup failed:', error) - throw new Error(CLAUDE_PROFILE_SETUP_FAILED_MESSAGE) - }) - return profile.home - } - - /** - * Deletes an account's guest folder after any setup running for it, which would otherwise - * recreate it. Linux `rm -r` never follows the history links. - */ - async removeAccount(distro: string, accountId: string): Promise { - await this.setups.get(accountId)?.catch(() => {}) - const { home } = await this.resolve(distro) - const folder = posix.dirname(wslClaudeProfile(home, distro, accountId).profile.home) - await runGuest(distro, { script: 'rm -rf -- "$1"', args: [folder], loginPath: 'none' }) - } - /** Falling back would run the wrong account. */ private async assertPresent(distro: string, profile: ClaudeProfileDescriptor | null) { if (profile && !(await guestStat(distro, profile.home))?.isDirectory()) { - throw claudeProfileMissing() + throw new Error(CLAUDE_PROFILE_MISSING_MESSAGE) } } @@ -195,10 +172,6 @@ async function guestStat(distro: string, linuxPath: string) { return lstat(toWindowsWslPath(linuxPath, distro)).catch(() => null) } -async function hasMarker(distro: string, profile: ClaudeProfileDescriptor): Promise { - return (await guestStat(distro, claudeProfileMarkerPath(profile)))?.isFile() ?? false -} - /** Writes in the guest only when the file differs, so a launch reads it over the share instead. */ async function writePointer(distro: string, pointer: string, home: string): Promise { // Why the timeout: a hung share must not stall startup's serialized publish; the guest write decides. diff --git a/src/main/claude-accounts/claude-windows-interactive-login.test.ts b/src/main/claude-accounts/claude-windows-interactive-login.test.ts new file mode 100644 index 000000000000..69ca24c21a8f --- /dev/null +++ b/src/main/claude-accounts/claude-windows-interactive-login.test.ts @@ -0,0 +1,148 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { EventEmitter } from 'node:events' +import { getCmdExePath } from '../../shared/windows-batch-spawn' +import { createService, restorePlatform, setPlatform } from './claude-account-service-test-harness' + +vi.mock('electron', () => ({ + app: { + getPath: () => '/tmp/orca-claude-windows-login-test' + } +})) + +vi.mock('../codex-cli/command', () => ({ + resolveClaudeCommand: vi.fn(() => 'C:\\Tools\\claude.cmd') +})) + +describe('Claude Windows host interactive login', () => { + afterEach(() => { + restorePlatform() + }) + + it('resolves a Windows auth login whose child has no piped streams', async () => { + setPlatform('win32') + vi.resetModules() + const child = new EventEmitter() as EventEmitter & { + stdout: null + stderr: null + kill: ReturnType + pid: number + } + child.stdout = null + child.stderr = null + child.kill = vi.fn() + child.pid = 4242 + const spawnMock = vi.fn(() => { + queueMicrotask(() => child.emit('exit', 0)) + return child + }) + const buildInteractiveLoginSpawn = vi.fn(() => ({ + command: getCmdExePath(), + args: [ + '/d', + '/c', + 'start', + '', + '/wait', + 'C:\\Tools\\claude.cmd', + 'auth', + 'login', + '--claudeai' + ], + stdio: 'ignore' as const, + windowsHide: true + })) + vi.doMock('node:child_process', () => ({ spawn: spawnMock })) + vi.doMock('../../shared/windows-interactive-login-spawn', () => ({ + buildWindowsHostInteractiveLoginSpawn: buildInteractiveLoginSpawn + })) + + try { + const { ClaudeAccountService } = await import('./service') + const service = new ClaudeAccountService( + createService() as never, + createService() as never, + createService() as never + ) + await ( + service as unknown as { + runClaudeCommand( + args: string[], + configDir: { windowsPath: string; linuxPath: string | null; wslDistro: string | null }, + timeoutMs: number + ): Promise + } + ).runClaudeCommand( + ['auth', 'login', '--claudeai'], + { windowsPath: 'C:\\tmp\\claude-auth', linuxPath: null, wslDistro: null }, + 1000 + ) + + expect(buildInteractiveLoginSpawn).toHaveBeenCalledWith('C:\\Tools\\claude.cmd', [ + 'auth', + 'login', + '--claudeai' + ]) + expect(spawnMock).toHaveBeenCalledWith( + getCmdExePath(), + expect.arrayContaining(['start', '', '/wait']), + expect.objectContaining({ + stdio: 'ignore', + windowsHide: true + }) + ) + expect(child.kill).not.toHaveBeenCalled() + } finally { + vi.doUnmock('node:child_process') + vi.doUnmock('../../shared/windows-interactive-login-spawn') + } + }) + + it('times out a denied Windows login that stays alive without piped output', async () => { + setPlatform('win32') + vi.resetModules() + vi.useFakeTimers() + const child = new EventEmitter() as EventEmitter & { + stdout: null + stderr: null + kill: ReturnType + pid: number + } + child.stdout = null + child.stderr = null + child.kill = vi.fn() + child.pid = 0 + const spawnMock = vi.fn(() => child) + vi.doMock('node:child_process', () => ({ spawn: spawnMock })) + + try { + const { ClaudeAccountService } = await import('./service') + const service = new ClaudeAccountService( + createService() as never, + createService() as never, + createService() as never + ) + const login = ( + service as unknown as { + runClaudeCommand( + args: string[], + configDir: { windowsPath: string; linuxPath: string | null; wslDistro: string | null }, + timeoutMs: number + ): Promise + } + ).runClaudeCommand( + ['auth', 'login', '--claudeai'], + { windowsPath: 'C:\\tmp\\claude-auth', linuxPath: null, wslDistro: null }, + 1000 + ) + const rejection = expect(login).rejects.toThrow('Claude sign-in took too long to finish.') + + await vi.advanceTimersByTimeAsync(3_000) + + await rejection + expect(child.kill).toHaveBeenCalledOnce() + } finally { + vi.useRealTimers() + vi.doUnmock('node:child_process') + } + }) +}) diff --git a/src/main/claude-accounts/environment.ts b/src/main/claude-accounts/environment.ts index 918de5250f5e..3fd7871c70d7 100644 --- a/src/main/claude-accounts/environment.ts +++ b/src/main/claude-accounts/environment.ts @@ -1,9 +1,4 @@ import type { ClaudeManagedAccount } from '../../shared/managed-account-types' -import { - CLAUDE_INJECTED_CONFIG_DIR_ENV, - CLAUDE_PROFILE_POINTER_ENV, - CLAUDE_USER_CONFIG_DIR_ENV -} from '../../shared/claude-profile-routing' export const CLAUDE_AUTH_ENV_VARS = [ 'ANTHROPIC_API_KEY', @@ -13,9 +8,8 @@ export const CLAUDE_AUTH_ENV_VARS = [ ] as const export type ClaudeEnvPatch = { - [CLAUDE_PROFILE_POINTER_ENV]?: string - [CLAUDE_INJECTED_CONFIG_DIR_ENV]?: string - [CLAUDE_USER_CONFIG_DIR_ENV]?: string + ORCA_CLAUDE_PROFILE_POINTER?: string + ORCA_CLAUDE_INJECTED_CONFIG_DIR?: string CLAUDE_CONFIG_DIR?: string ANTHROPIC_CUSTOM_HEADERS?: string } @@ -41,15 +35,11 @@ export function applyClaudeEnvPatch( } } - for (const key of [ - CLAUDE_PROFILE_POINTER_ENV, - CLAUDE_INJECTED_CONFIG_DIR_ENV, - CLAUDE_USER_CONFIG_DIR_ENV - ] as const) { - const value = patch[key] - if (value) { - baseEnv[key] = value - } + if (patch.ORCA_CLAUDE_PROFILE_POINTER) { + baseEnv.ORCA_CLAUDE_PROFILE_POINTER = patch.ORCA_CLAUDE_PROFILE_POINTER + } + if (patch.ORCA_CLAUDE_INJECTED_CONFIG_DIR) { + baseEnv.ORCA_CLAUDE_INJECTED_CONFIG_DIR = patch.ORCA_CLAUDE_INJECTED_CONFIG_DIR } if (patch.CLAUDE_CONFIG_DIR) { baseEnv.CLAUDE_CONFIG_DIR = patch.CLAUDE_CONFIG_DIR @@ -66,6 +56,9 @@ export function applyClaudeEnvPatch( export const CLAUDE_AUTH_ENV_CONFLICT_MESSAGE = 'This Claude launch defines explicit Anthropic auth environment variables. Remove those overrides before using a managed Claude account.' +export const CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE = + 'A Claude account switch is in progress. Try again after it finishes.' + /** * Whether a launch on the host runtime must drop inherited Anthropic auth. * diff --git a/src/main/claude-accounts/keychain.test.ts b/src/main/claude-accounts/keychain.test.ts index 3bd725ba068f..3ee2c6b77593 100644 --- a/src/main/claude-accounts/keychain.test.ts +++ b/src/main/claude-accounts/keychain.test.ts @@ -1,21 +1,277 @@ -import { afterEach, expect, it, vi } from 'vitest' +import { createHash } from 'node:crypto' +import { execFile } from 'node:child_process' +import { mkdtempSync, realpathSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + deleteActiveClaudeKeychainCredentials, + readActiveClaudeKeychainCredentials, + readActiveClaudeKeychainCredentialsStrict, + writeActiveClaudeKeychainCredentials, + writeActiveClaudeKeychainCredentialsForRuntime +} from './keychain' -const reads = vi.hoisted(() => vi.fn(async () => null)) -vi.mock('../macos-keychain/generic-password', () => ({ readKeychainPassword: reads })) - -import { readActiveClaudeKeychainCredentialsStrict } from './keychain' +vi.mock('node:child_process', () => ({ + execFile: vi.fn() +})) +const execFileMock = vi.mocked(execFile) +const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') const originalUser = process.env.USER -afterEach(() => { - process.env.USER = originalUser - reads.mockClear() -}) +const originalUsername = process.env.USERNAME +const TEST_USER = 'orca-test-user' +const SSO_USER = 'sso.user@example.com' +let configDir: string + +function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { + configurable: true, + value: platform + }) +} + +function serviceForConfigDir(configDir: string): string { + const suffix = createHash('sha256').update(configDir).digest('hex').slice(0, 8) + return `Claude Code-credentials-${suffix}` +} + +function invokeExecFileCallback( + callback: unknown, + error: Error | null, + stdout: string, + stderr: string +): void { + const execCallback = callback as (error: Error | null, stdout: string, stderr: string) => void + execCallback(error, stdout, stderr) +} + +describe('Claude Keychain credentials', () => { + beforeEach(() => { + configDir = realpathSync(mkdtempSync(join(tmpdir(), 'orca-claude-keychain-'))) + setPlatform('darwin') + execFileMock.mockReset() + process.env.USER = TEST_USER + delete process.env.USERNAME + }) + + afterEach(() => { + rmSync(configDir, { recursive: true, force: true }) + vi.useRealTimers() + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + if (originalUser === undefined) { + delete process.env.USER + } else { + process.env.USER = originalUser + } + if (originalUsername === undefined) { + delete process.env.USERNAME + } else { + process.env.USERNAME = originalUsername + } + }) + + it('reads config-scoped Claude Code 2.1 credentials before legacy credentials', async () => { + const scopedService = serviceForConfigDir(configDir) + execFileMock.mockImplementationOnce((_file, _args, _options, callback) => { + invokeExecFileCallback(callback, null, '{"claudeAiOauth":{"accessToken":"scoped"}}\n', '') + return null as never + }) + + await expect(readActiveClaudeKeychainCredentials(configDir)).resolves.toBe( + '{"claudeAiOauth":{"accessToken":"scoped"}}' + ) + + expect(execFileMock).toHaveBeenCalledTimes(1) + expect(execFileMock.mock.calls[0][1]).toEqual([ + 'find-generic-password', + '-s', + scopedService, + '-a', + TEST_USER, + '-w' + ]) + }) + + it('falls back to the legacy unsuffixed Claude Code credentials service', async () => { + const notFound = Object.assign(new Error('not found'), { code: 44 }) + execFileMock + .mockImplementationOnce((_file, _args, _options, callback) => { + invokeExecFileCallback(callback, notFound, '', 'could not be found') + return null as never + }) + .mockImplementationOnce((_file, _args, _options, callback) => { + invokeExecFileCallback(callback, null, 'legacy\n', '') + return null as never + }) + + await expect(readActiveClaudeKeychainCredentials(configDir)).resolves.toBe('legacy') + + expect(execFileMock.mock.calls[1][1]).toEqual([ + 'find-generic-password', + '-s', + 'Claude Code-credentials', + '-a', + TEST_USER, + '-w' + ]) + }) + + it('writes active credentials to the config-scoped Claude Code service', async () => { + const scopedService = serviceForConfigDir(configDir) + execFileMock.mockImplementationOnce((_file, _args, _options, callback) => { + invokeExecFileCallback(callback, null, '', '') + return null as never + }) + + await writeActiveClaudeKeychainCredentials('credentials-json', configDir) + + expect(execFileMock.mock.calls[0][1]).toEqual([ + 'add-generic-password', + '-U', + '-s', + scopedService, + '-a', + TEST_USER, + '-w', + 'credentials-json' + ]) + }) + + it('writes runtime credentials to scoped and legacy services for old Claude Code compatibility', async () => { + const scopedService = serviceForConfigDir(configDir) + execFileMock.mockImplementation((_file, _args, _options, callback) => { + invokeExecFileCallback(callback, null, '', '') + return null as never + }) + + await writeActiveClaudeKeychainCredentialsForRuntime('credentials-json', configDir) + + expect(execFileMock.mock.calls.map((call) => call[1])).toEqual([ + [ + 'add-generic-password', + '-U', + '-s', + scopedService, + '-a', + TEST_USER, + '-w', + 'credentials-json' + ], + [ + 'add-generic-password', + '-U', + '-s', + 'Claude Code-credentials', + '-a', + TEST_USER, + '-w', + 'credentials-json' + ] + ]) + }) + + it('strictly reads only the requested active credentials service', async () => { + const scopedService = serviceForConfigDir(configDir) + execFileMock.mockImplementationOnce((_file, _args, _options, callback) => { + invokeExecFileCallback(callback, null, 'scoped\n', '') + return null as never + }) + + await expect(readActiveClaudeKeychainCredentialsStrict(configDir)).resolves.toBe('scoped') + + expect(execFileMock).toHaveBeenCalledTimes(1) + expect(execFileMock.mock.calls[0][1]).toEqual([ + 'find-generic-password', + '-s', + scopedService, + '-a', + TEST_USER, + '-w' + ]) + }) + + it('rejects when a keychain read never reports completion', async () => { + vi.useFakeTimers() + const killMock = vi.fn() + execFileMock.mockImplementationOnce(() => ({ kill: killMock }) as never) + + let settled = false + let rejected: unknown + const readPromise = readActiveClaudeKeychainCredentialsStrict(configDir).then( + (credentials) => { + settled = true + return credentials + }, + (error: unknown) => { + settled = true + rejected = error + return null + } + ) + + await vi.advanceTimersByTimeAsync(3000) + + expect(settled).toBe(true) + await readPromise + expect(rejected).toEqual( + expect.objectContaining({ message: 'security timed out after 3000ms' }) + ) + expect(killMock).toHaveBeenCalled() + }) + + it('deletes both scoped and legacy active credentials for config-dir cleanup', async () => { + const scopedService = serviceForConfigDir(configDir) + execFileMock.mockImplementation((_file, _args, _options, callback) => { + invokeExecFileCallback(callback, null, '', '') + return null as never + }) + + await deleteActiveClaudeKeychainCredentials(configDir) + + expect(execFileMock.mock.calls.map((call) => call[1])).toEqual([ + ['delete-generic-password', '-s', scopedService, '-a', TEST_USER], + ['delete-generic-password', '-s', 'Claude Code-credentials', '-a', TEST_USER] + ]) + }) + + it('looks up claude-code-user when $USER contains @ (#12857)', async () => { + process.env.USER = SSO_USER + execFileMock.mockImplementationOnce((_file, _args, _options, callback) => { + invokeExecFileCallback(callback, null, '{"claudeAiOauth":{"accessToken":"ok"}}\n', '') + return null as never + }) + + await expect(readActiveClaudeKeychainCredentials()).resolves.toBe( + '{"claudeAiOauth":{"accessToken":"ok"}}' + ) + expect(execFileMock.mock.calls[0][1]).toEqual([ + 'find-generic-password', + '-s', + 'Claude Code-credentials', + '-a', + 'claude-code-user', + '-w' + ]) + }) + + it('cleans both Claude Code and raw $USER Keychain accounts after a failed SSO login', async () => { + process.env.USER = SSO_USER + const scopedService = serviceForConfigDir(configDir) + execFileMock.mockImplementation((_file, _args, _options, callback) => { + invokeExecFileCallback(callback, null, '', '') + return null as never + }) + + await deleteActiveClaudeKeychainCredentials(configDir) -it('reads the claude-code-user item when $USER is not a valid Keychain account (#12857)', async () => { - process.env.USER = 'first@example.com' - await readActiveClaudeKeychainCredentialsStrict() - expect(reads).toHaveBeenCalledWith('Claude Code-credentials', 'claude-code-user') - process.env.USER = 'first.last' - await readActiveClaudeKeychainCredentialsStrict() - expect(reads).toHaveBeenLastCalledWith('Claude Code-credentials', 'first.last') + expect(execFileMock.mock.calls.map((call) => call[1])).toEqual([ + ['delete-generic-password', '-s', scopedService, '-a', 'claude-code-user'], + ['delete-generic-password', '-s', scopedService, '-a', SSO_USER], + ['delete-generic-password', '-s', 'Claude Code-credentials', '-a', 'claude-code-user'], + ['delete-generic-password', '-s', 'Claude Code-credentials', '-a', SSO_USER] + ]) + }) }) diff --git a/src/main/claude-accounts/keychain.ts b/src/main/claude-accounts/keychain.ts index 14070e6bb631..aa4dcfa64dbc 100644 --- a/src/main/claude-accounts/keychain.ts +++ b/src/main/claude-accounts/keychain.ts @@ -2,18 +2,36 @@ import { createHash } from 'node:crypto' import { lstatSync, realpathSync } from 'node:fs' import { userInfo } from 'node:os' import { basename, dirname, join } from 'node:path' -import { readKeychainPassword } from '../macos-keychain/generic-password' +import { + deleteKeychainPassword, + readKeychainPassword, + writeKeychainPassword +} from '../macos-keychain/generic-password' const ACTIVE_CLAUDE_SERVICE = 'Claude Code-credentials' +const ORCA_CLAUDE_SERVICE = 'Orca Claude Code Managed Credentials' +export async function readActiveClaudeKeychainCredentials( + configDir?: string +): Promise { + for (const service of getActiveClaudeServices(configDir)) { + const credentials = await readKeychainPassword(service, getKeychainUser()) + if (credentials) { + return credentials + } + } + return null +} + export async function readActiveClaudeKeychainCredentialsStrict( configDir?: string ): Promise { if (!configDir) { - return readKeychainPassword(claudeKeychainService(), getKeychainUser()) + return readKeychainPassword(getActiveClaudeService(), getKeychainUser()) } - // Keep both lexical and canonical profile aliases scoped; never try System Default. + // Why: macOS tmp is /var → /private/var. Claude hashes the realpath; a + // mkdtemp login dir would miss the Keychain item if we only hashed the raw path. for (const dir of claudeConfigDirKeychainAliases(configDir)) { - const credentials = await readKeychainPassword(claudeKeychainService(dir), getKeychainUser()) + const credentials = await readKeychainPassword(getActiveClaudeService(dir), getKeychainUser()) if (credentials) { return credentials } @@ -21,6 +39,63 @@ export async function readActiveClaudeKeychainCredentialsStrict( return null } +export async function writeActiveClaudeKeychainCredentials( + contents: string, + configDir?: string +): Promise { + await writeKeychainPassword(getActiveClaudeService(configDir), getKeychainUser(), contents) +} + +export async function writeActiveClaudeKeychainCredentialsForRuntime( + contents: string, + configDir: string +): Promise { + const user = getKeychainUser() + const scopedService = getActiveClaudeService(configDir) + await writeKeychainPassword(scopedService, user, contents) + if (scopedService !== ACTIVE_CLAUDE_SERVICE) { + await writeKeychainPassword(ACTIVE_CLAUDE_SERVICE, user, contents) + } +} + +export async function deleteActiveClaudeKeychainCredentials(configDir?: string): Promise { + for (const service of getActiveClaudeServices(configDir)) { + for (const account of getKeychainUsersForCleanup()) { + await deleteKeychainPassword(service, account) + } + } +} + +export async function deleteActiveClaudeKeychainCredentialsStrict( + configDir?: string +): Promise { + const dirs = configDir ? claudeConfigDirKeychainAliases(configDir) : [undefined] + for (const dir of dirs) { + for (const account of getKeychainUsersForCleanup()) { + await deleteKeychainPassword(getActiveClaudeService(dir), account, { + failOnAccessError: true + }) + } + } +} + +export async function readManagedClaudeKeychainCredentials( + accountId: string +): Promise { + return readKeychainPassword(ORCA_CLAUDE_SERVICE, accountId) +} + +export async function writeManagedClaudeKeychainCredentials( + accountId: string, + contents: string +): Promise { + await writeKeychainPassword(ORCA_CLAUDE_SERVICE, accountId, contents) +} + +export async function deleteManagedClaudeKeychainCredentials(accountId: string): Promise { + await deleteKeychainPassword(ORCA_CLAUDE_SERVICE, accountId) +} + const KEYCHAIN_ACCOUNT_PATTERN = /^[a-zA-Z0-9._-]+$/ const CLAUDE_CODE_FALLBACK_USER = 'claude-code-user' @@ -36,7 +111,13 @@ function getKeychainUser(): string { return KEYCHAIN_ACCOUNT_PATTERN.test(user) ? user : CLAUDE_CODE_FALLBACK_USER } -export function claudeKeychainService(configDir?: string): string { +function getKeychainUsersForCleanup(): string[] { + const derived = getKeychainUser() + const raw = process.env.USER || process.env.USERNAME + return raw && raw !== derived ? [derived, raw] : [derived] +} + +function getActiveClaudeService(configDir?: string): string { if (!configDir) { return ACTIVE_CLAUDE_SERVICE } @@ -92,16 +173,10 @@ export function claudeConfigDirKeychainAliases(configDir: string): string[] { return aliases } -/** Every spelling of a folder Claude may have hashed into its Keychain item name (superset U/profiles.ts). */ -export function claudeConfigDirSpellings(configDir: string, userHome: string): string[] { - const spellings = new Set() - for (const dir of claudeConfigDirKeychainAliases(configDir)) { - const trimmed = dir.replace(/[\\/]+$/, '') - const rest = trimmed.startsWith(`${userHome}/`) ? trimmed.slice(userHome.length) : null - for (const spelling of [trimmed, ...(rest ? [`~${rest}`, `$HOME${rest}`] : [])]) { - spellings.add(spelling) - spellings.add(`${spelling}/`) - } +function getActiveClaudeServices(configDir?: string): string[] { + if (!configDir) { + return [ACTIVE_CLAUDE_SERVICE] } - return [...spellings] + const scoped = claudeConfigDirKeychainAliases(configDir).map((dir) => getActiveClaudeService(dir)) + return [...new Set([...scoped, ACTIVE_CLAUDE_SERVICE])] } diff --git a/src/main/claude-accounts/live-pty-gate.test.ts b/src/main/claude-accounts/live-pty-gate.test.ts new file mode 100644 index 000000000000..7359bbd98055 --- /dev/null +++ b/src/main/claude-accounts/live-pty-gate.test.ts @@ -0,0 +1,142 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + attachClaudeLivePtyPersistence, + beginClaudeAuthSwitch, + confirmSeededClaudeLivePtys, + endClaudeAuthSwitch, + hasLiveClaudePtys, + isClaudeAuthSwitchInProgress, + markClaudePtyExited, + markClaudePtySpawned, + onLiveClaudePtysDrained, + seedLiveClaudePtysFromPersistence +} from './live-pty-gate' + +describe('Claude live PTY gate', () => { + afterEach(() => { + markClaudePtyExited('live-claude-pty') + markClaudePtyExited('seeded-pty-1') + markClaudePtyExited('seeded-pty-2') + confirmSeededClaudeLivePtys([]) + attachClaudeLivePtyPersistence(null) + endClaudeAuthSwitch() + }) + + it('allows switching while Claude PTYs are live', () => { + markClaudePtySpawned('live-claude-pty') + + beginClaudeAuthSwitch() + + expect(isClaudeAuthSwitchInProgress()).toBe(true) + }) + + it('still rejects overlapping account switches', () => { + beginClaudeAuthSwitch() + + expect(() => beginClaudeAuthSwitch()).toThrow('already in progress') + }) + + it('counts seeded session ids as live until confirmed dead', () => { + seedLiveClaudePtysFromPersistence(['seeded-pty-1', 'seeded-pty-2']) + + expect(hasLiveClaudePtys()).toBe(true) + + confirmSeededClaudeLivePtys(['seeded-pty-1']) + + expect(hasLiveClaudePtys()).toBe(true) + + confirmSeededClaudeLivePtys([]) + + expect(hasLiveClaudePtys()).toBe(true) + + markClaudePtyExited('seeded-pty-1') + + expect(hasLiveClaudePtys()).toBe(false) + }) + + it('releases seeded ids the daemon no longer knows', () => { + const removeClaudeLivePtySessionId = vi.fn() + attachClaudeLivePtyPersistence({ + addClaudeLivePtySessionId: vi.fn(), + removeClaudeLivePtySessionId + }) + seedLiveClaudePtysFromPersistence(['seeded-pty-1', 'seeded-pty-2']) + + confirmSeededClaudeLivePtys(['seeded-pty-2']) + + expect(hasLiveClaudePtys()).toBe(true) + expect(removeClaudeLivePtySessionId).toHaveBeenCalledWith('seeded-pty-1') + expect(removeClaudeLivePtySessionId).not.toHaveBeenCalledWith('seeded-pty-2') + }) + + it('keeps a seeded id confirmed by a real spawn out of later pruning', () => { + seedLiveClaudePtysFromPersistence(['seeded-pty-1']) + markClaudePtySpawned('seeded-pty-1') + + confirmSeededClaudeLivePtys([]) + + expect(hasLiveClaudePtys()).toBe(true) + }) + + it('notifies drain listeners only when the last live Claude PTY exits', () => { + const onDrained = vi.fn() + const unsubscribe = onLiveClaudePtysDrained(onDrained) + try { + markClaudePtySpawned('live-claude-pty') + markClaudePtySpawned('seeded-pty-1') + + markClaudePtyExited('live-claude-pty') + expect(onDrained).not.toHaveBeenCalled() + + markClaudePtyExited('seeded-pty-1') + expect(onDrained).toHaveBeenCalledTimes(1) + + // Why: exits with no live PTYs left must not fire again — the drain + // signal marks the 1 -> 0 transition, not every teardown call. + markClaudePtyExited('seeded-pty-1') + expect(onDrained).toHaveBeenCalledTimes(1) + } finally { + unsubscribe() + } + }) + + it('notifies drain listeners when seed reconciliation releases the last live id', () => { + const onDrained = vi.fn() + const unsubscribe = onLiveClaudePtysDrained(onDrained) + try { + seedLiveClaudePtysFromPersistence(['seeded-pty-1']) + + confirmSeededClaudeLivePtys([]) + + expect(onDrained).toHaveBeenCalledTimes(1) + } finally { + unsubscribe() + } + }) + + it('stops notifying an unsubscribed drain listener', () => { + const onDrained = vi.fn() + const unsubscribe = onLiveClaudePtysDrained(onDrained) + unsubscribe() + + markClaudePtySpawned('live-claude-pty') + markClaudePtyExited('live-claude-pty') + + expect(onDrained).not.toHaveBeenCalled() + }) + + it('persists spawns and exits when persistence is attached', () => { + const addClaudeLivePtySessionId = vi.fn() + const removeClaudeLivePtySessionId = vi.fn() + attachClaudeLivePtyPersistence({ + addClaudeLivePtySessionId, + removeClaudeLivePtySessionId + }) + + markClaudePtySpawned('live-claude-pty') + expect(addClaudeLivePtySessionId).toHaveBeenCalledWith('live-claude-pty') + + markClaudePtyExited('live-claude-pty') + expect(removeClaudeLivePtySessionId).toHaveBeenCalledWith('live-claude-pty') + }) +}) diff --git a/src/main/claude-accounts/live-pty-gate.ts b/src/main/claude-accounts/live-pty-gate.ts new file mode 100644 index 000000000000..caab66c34308 --- /dev/null +++ b/src/main/claude-accounts/live-pty-gate.ts @@ -0,0 +1,174 @@ +const liveClaudePtyIds = new Set() +// Why: ids restored from persistence at startup, not yet confirmed against the +// daemon. They keep the OAuth refresh gate closed so an early managed refresh +// cannot rotate the single-use refresh token out from under a Claude CLI that +// survived the app restart inside the daemon. +const seededUnconfirmedPtyIds = new Set() +let switchInProgress = false +// Woken by endClaudeAuthSwitch so a caller past the point of no return can wait the +// swap out instead of refusing. See whenClaudeAuthSwitchSettles. +const switchSettledListeners = new Set<() => void>() + +/** A managed account swap is a credential-file rewrite, not a network round trip; + * anything past this is a wedged switch, and refusing beats waiting forever. */ +export const CLAUDE_AUTH_SWITCH_SETTLE_TIMEOUT_MS = 15_000 + +export type ClaudeLivePtyPersistence = { + addClaudeLivePtySessionId(sessionId: string): void + removeClaudeLivePtySessionId(sessionId: string): void +} + +let persistence: ClaudeLivePtyPersistence | null = null + +export function attachClaudeLivePtyPersistence(target: ClaudeLivePtyPersistence | null): void { + persistence = target +} + +// Why: a live claude defers the managed OAuth refresh ("Waiting for Claude +// session"); consumers need the 1 -> 0 transition to recover promptly instead +// of waiting out the usage-fetch failure backoff. +type LiveClaudePtyDrainListener = () => void +const drainListeners = new Set() + +export function onLiveClaudePtysDrained(listener: LiveClaudePtyDrainListener): () => void { + drainListeners.add(listener) + return () => drainListeners.delete(listener) +} + +function notifyDrainedOnTransition(hadLivePtys: boolean): void { + if (!hadLivePtys || liveClaudePtyIds.size > 0) { + return + } + for (const listener of drainListeners) { + listener() + } +} + +export function seedLiveClaudePtysFromPersistence(sessionIds: readonly string[]): void { + for (const sessionId of sessionIds) { + liveClaudePtyIds.add(sessionId) + seededUnconfirmedPtyIds.add(sessionId) + } +} + +export function hasSeededUnconfirmedClaudePtys(): boolean { + return seededUnconfirmedPtyIds.size > 0 +} + +/** + * Reconcile seeded ids against the daemon's live session list. Seeded ids the + * daemon no longer knows are dead — release them so they cannot defer OAuth + * refresh forever. Seeded ids that are still alive stay in the gate even if + * their pane never reattaches: that daemon process still owns the credentials. + */ +export function confirmSeededClaudeLivePtys(aliveSessionIds: readonly string[]): void { + const hadLivePtys = liveClaudePtyIds.size > 0 + const alive = new Set(aliveSessionIds) + for (const sessionId of seededUnconfirmedPtyIds) { + if (!alive.has(sessionId)) { + liveClaudePtyIds.delete(sessionId) + persistence?.removeClaudeLivePtySessionId(sessionId) + } + } + seededUnconfirmedPtyIds.clear() + notifyDrainedOnTransition(hadLivePtys) +} + +export function markClaudePtySpawned(ptyId: string): void { + liveClaudePtyIds.add(ptyId) + seededUnconfirmedPtyIds.delete(ptyId) + persistence?.addClaudeLivePtySessionId(ptyId) +} + +export function markClaudePtyExited(ptyId: string): void { + const hadLivePtys = liveClaudePtyIds.size > 0 + liveClaudePtyIds.delete(ptyId) + seededUnconfirmedPtyIds.delete(ptyId) + persistence?.removeClaudeLivePtySessionId(ptyId) + notifyDrainedOnTransition(hadLivePtys) +} + +/** + * Register a structured Claude child with the same gate the terminal path uses. + * + * The gate is what makes the managed OAuth refresh defer instead of rotating a + * single-use refresh token out from under a running Claude (runtime-auth-sync.ts). + * A structured session's child is as much a live Claude as a PTY's is, so it has to + * hold the gate too — otherwise a refresh mid-turn breaks its next API call while an + * identical terminal session is protected. + * + * Deliberately not persisted, unlike markClaudePtySpawned: these children are direct + * children of this process and cannot survive a restart, so seeding them back on the + * next launch would hold the gate closed for a process that is provably gone. + */ +export function markClaudeStructuredChildSpawned(childKey: string): void { + liveClaudePtyIds.add(structuredChildGateId(childKey)) +} + +export function markClaudeStructuredChildExited(childKey: string): void { + const hadLivePtys = liveClaudePtyIds.size > 0 + liveClaudePtyIds.delete(structuredChildGateId(childKey)) + notifyDrainedOnTransition(hadLivePtys) +} + +// Namespaced so a structured child can never collide with a daemon PTY session id, +// which confirmSeededClaudeLivePtys reconciles against the daemon's own list. +function structuredChildGateId(childKey: string): string { + return `claude-structured:${childKey}` +} + +export function hasLiveClaudePtys(): boolean { + return liveClaudePtyIds.size > 0 +} + +export function beginClaudeAuthSwitch(): void { + if (switchInProgress) { + throw new Error('A Claude account switch is already in progress.') + } + switchInProgress = true +} + +export function endClaudeAuthSwitch(): void { + const wasInProgress = switchInProgress + switchInProgress = false + if (!wasInProgress) { + return + } + // Each listener removes itself as it settles; Set iteration is defined over that. + for (const listener of switchSettledListeners) { + listener() + } +} + +/** + * Resolves `true` once no account switch is running, `false` if one is still running + * at the deadline. + * + * Exists for callers that have already done irreversible work — a structured acquire + * has closed the old child by the time it resolves its launch, so turning a switch + * into a refusal there strands the user with a dead session and no replacement. + * Waiting for the swap and then launching against it is the recoverable answer; + * refusing is only correct when nothing has been torn down yet. + */ +export function whenClaudeAuthSwitchSettles( + timeoutMs = CLAUDE_AUTH_SWITCH_SETTLE_TIMEOUT_MS +): Promise { + if (!switchInProgress) { + return Promise.resolve(true) + } + return new Promise((resolve) => { + const settle = (settled: boolean): void => { + switchSettledListeners.delete(listener) + clearTimeout(timer) + resolve(settled) + } + const listener = (): void => settle(true) + switchSettledListeners.add(listener) + const timer = setTimeout(() => settle(false), timeoutMs) + timer.unref?.() + }) +} + +export function isClaudeAuthSwitchInProgress(): boolean { + return switchInProgress +} diff --git a/src/main/claude-accounts/managed-auth-path.ts b/src/main/claude-accounts/managed-auth-path.ts new file mode 100644 index 000000000000..2914b72e0334 --- /dev/null +++ b/src/main/claude-accounts/managed-auth-path.ts @@ -0,0 +1,112 @@ +import { existsSync, lstatSync, readFileSync, realpathSync, writeFileSync } from 'node:fs' +import { join, relative, resolve, sep } from 'node:path' +import { app } from 'electron' +import { writeFileAtomically } from '../codex-accounts/fs-utils' + +const MANAGED_AUTH_MARKER = '.orca-managed-claude-auth' + +export function getClaudeManagedAccountsRoot(): string { + return join(app.getPath('userData'), 'claude-accounts') +} + +export function resolveOwnedClaudeManagedAuthPath( + accountId: string, + candidatePath: string, + options: { adoptLegacyMarker?: boolean } = {} +): string | null { + const rootPath = getClaudeManagedAccountsRoot() + const resolvedCandidate = resolve(candidatePath) + if (!existsSync(resolvedCandidate) || !existsSync(rootPath)) { + return null + } + try { + if (lstatSync(resolvedCandidate).isSymbolicLink()) { + return null + } + const canonicalCandidate = realpathSync(resolvedCandidate) + const canonicalRoot = realpathSync(rootPath) + if ( + canonicalCandidate === canonicalRoot || + !canonicalCandidate.startsWith(canonicalRoot + sep) + ) { + return null + } + const relativePath = relative(canonicalRoot, canonicalCandidate) + const relativeParts = relativePath.split(sep) + const escaped = relativePath.startsWith('..') || relativePath.includes(`..${sep}`) + if ( + escaped || + relativeParts.length !== 2 || + relativeParts[0] !== accountId || + relativeParts[1] !== 'auth' + ) { + return null + } + const markerPath = join(canonicalCandidate, MANAGED_AUTH_MARKER) + const markerValid = isManagedAuthMarkerValid(markerPath, accountId) + if (!markerValid && options.adoptLegacyMarker) { + writeFileSync(markerPath, `${accountId}\n`, { encoding: 'utf-8', mode: 0o600, flag: 'wx' }) + } + if (!markerValid && !isManagedAuthMarkerValid(markerPath, accountId)) { + return null + } + return canonicalCandidate + } catch { + return null + } +} + +export function readClaudeManagedAuthFile( + managedAuthPath: string, + filename: '.credentials.json' | 'oauth-account.json' +): string | null { + const filePath = resolve(managedAuthPath, filename) + try { + if (!isOwnedChildFile(managedAuthPath, filePath)) { + return null + } + return readFileSync(filePath, 'utf-8') + } catch { + return null + } +} + +export function writeClaudeManagedAuthFile( + managedAuthPath: string, + filename: '.credentials.json' | 'oauth-account.json', + contents: string +): void { + const filePath = resolve(managedAuthPath, filename) + if (existsSync(filePath) && !isOwnedChildFile(managedAuthPath, filePath)) { + throw new Error('Managed Claude auth child file is not owned by Orca.') + } + writeFileAtomically(filePath, contents, { mode: 0o600 }) +} + +function isManagedAuthMarkerValid(markerPath: string, accountId: string): boolean { + try { + if ( + !existsSync(markerPath) || + lstatSync(markerPath).isSymbolicLink() || + !lstatSync(markerPath).isFile() + ) { + return false + } + return readFileSync(markerPath, 'utf-8').trim() === accountId + } catch { + return false + } +} + +function isOwnedChildFile(managedAuthPath: string, filePath: string): boolean { + if ( + !existsSync(filePath) || + lstatSync(filePath).isSymbolicLink() || + !lstatSync(filePath).isFile() + ) { + return false + } + const canonicalAuthPath = realpathSync(managedAuthPath) + const canonicalFilePath = realpathSync(filePath) + return canonicalFilePath.startsWith(canonicalAuthPath + sep) +} diff --git a/src/main/claude-accounts/oauth-refresh.test.ts b/src/main/claude-accounts/oauth-refresh.test.ts new file mode 100644 index 000000000000..dd5e01c245a5 --- /dev/null +++ b/src/main/claude-accounts/oauth-refresh.test.ts @@ -0,0 +1,186 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + applyRefreshedToken, + isOauthTokenExpiring, + parseClaudeOauthBlob, + readRefreshToken, + refreshClaudeOauthCredentials +} from './oauth-refresh' + +const { netFetchMock } = vi.hoisted(() => ({ + netFetchMock: vi.fn() +})) + +vi.mock('electron', () => ({ + net: { fetch: netFetchMock }, + session: { defaultSession: {} } +})) + +vi.mock('../network/proxy-settings', () => ({ + ensureElectronProxyFromEnvironment: vi.fn().mockResolvedValue({ source: 'none' }) +})) + +const NOW = 1_700_000_000_000 + +function credentials(overrides: Record = {}): string { + return JSON.stringify({ + claudeAiOauth: { + accessToken: 'old-access', + refreshToken: 'old-refresh', + expiresAt: NOW + 60 * 60 * 1000, + scopes: ['user:inference', 'user:profile'], + ...overrides + } + }) +} + +describe('parseClaudeOauthBlob', () => { + it('returns the oauth block', () => { + expect(parseClaudeOauthBlob(credentials())?.accessToken).toBe('old-access') + }) + + it('returns null for non-JSON or missing block', () => { + expect(parseClaudeOauthBlob('not json')).toBeNull() + expect(parseClaudeOauthBlob('{}')).toBeNull() + expect(parseClaudeOauthBlob('{"claudeAiOauth":[]}')).toBeNull() + }) +}) + +describe('readRefreshToken', () => { + it('reads a present token', () => { + expect(readRefreshToken(credentials())).toBe('old-refresh') + }) + + it('returns null for blank or missing tokens', () => { + expect(readRefreshToken(credentials({ refreshToken: ' ' }))).toBeNull() + expect(readRefreshToken(credentials({ refreshToken: undefined }))).toBeNull() + }) +}) + +describe('isOauthTokenExpiring', () => { + it('is false when well within validity', () => { + expect(isOauthTokenExpiring(credentials(), NOW)).toBe(false) + }) + + it('is true within the 5-minute buffer', () => { + expect(isOauthTokenExpiring(credentials({ expiresAt: NOW + 60 * 1000 }), NOW)).toBe(true) + }) + + it('is true when already expired', () => { + expect(isOauthTokenExpiring(credentials({ expiresAt: NOW - 1000 }), NOW)).toBe(true) + }) + + it('treats missing/non-numeric expiry as expiring', () => { + expect(isOauthTokenExpiring(credentials({ expiresAt: undefined }), NOW)).toBe(true) + expect(isOauthTokenExpiring(credentials({ expiresAt: 'soon' }), NOW)).toBe(true) + }) + + it('is false for credentials without an oauth block', () => { + expect(isOauthTokenExpiring('{}', NOW)).toBe(false) + }) +}) + +describe('applyRefreshedToken', () => { + it('rotates access + refresh token and recomputes expiry', () => { + const updated = applyRefreshedToken( + credentials(), + { access_token: 'new-access', expires_in: 3600, refresh_token: 'new-refresh' }, + NOW + ) + const oauth = parseClaudeOauthBlob(updated!)! + expect(oauth.accessToken).toBe('new-access') + expect(oauth.refreshToken).toBe('new-refresh') + expect(oauth.expiresAt).toBe(NOW + 3600 * 1000) + }) + + it('keeps the existing refresh token when the server does not rotate it', () => { + const updated = applyRefreshedToken( + credentials(), + { access_token: 'new-access', expires_in: 3600 }, + NOW + ) + expect(parseClaudeOauthBlob(updated!)!.refreshToken).toBe('old-refresh') + }) + + it('preserves unrelated top-level fields', () => { + const raw = JSON.stringify({ + claudeAiOauth: { accessToken: 'a', refreshToken: 'r' }, + somethingElse: { keep: true } + }) + const updated = applyRefreshedToken(raw, { access_token: 'b' }, NOW) + expect(JSON.parse(updated!).somethingElse).toEqual({ keep: true }) + }) + + it('splits scope string into scopes array', () => { + const updated = applyRefreshedToken( + credentials(), + { access_token: 'b', scope: 'user:inference user:profile' }, + NOW + ) + expect(parseClaudeOauthBlob(updated!)!.scopes).toEqual(['user:inference', 'user:profile']) + }) + + it('returns null when the response lacks an access token', () => { + expect(applyRefreshedToken(credentials(), {}, NOW)).toBeNull() + expect(applyRefreshedToken('not json', { access_token: 'b' }, NOW)).toBeNull() + }) +}) + +describe('refreshClaudeOauthCredentials', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + it('returns null without a refresh token (no network call)', async () => { + const result = await refreshClaudeOauthCredentials( + credentials({ refreshToken: undefined }), + NOW + ) + expect(result).toBeNull() + expect(netFetchMock).not.toHaveBeenCalled() + }) + + it('posts a form-urlencoded refresh grant and persists the rotation', async () => { + netFetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ + access_token: 'fresh-access', + expires_in: 3600, + refresh_token: 'fresh-refresh' + }) + }) + + const result = await refreshClaudeOauthCredentials(credentials(), NOW) + + expect(netFetchMock).toHaveBeenCalledTimes(1) + const [url, init] = netFetchMock.mock.calls[0] + expect(url).toBe('https://platform.claude.com/v1/oauth/token') + expect(init.method).toBe('POST') + expect(init.headers['Content-Type']).toBe('application/x-www-form-urlencoded') + const body = new URLSearchParams(init.body) + expect(body.get('grant_type')).toBe('refresh_token') + expect(body.get('refresh_token')).toBe('old-refresh') + expect(body.get('client_id')).toBe('9d1c250a-e61b-44d9-88ed-5944d1962f5e') + + const oauth = parseClaudeOauthBlob(result!)! + expect(oauth.accessToken).toBe('fresh-access') + expect(oauth.refreshToken).toBe('fresh-refresh') + }) + + it('returns null on a non-ok response and logs the status for diagnosability', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + netFetchMock.mockResolvedValue({ ok: false, status: 429, json: async () => ({}) }) + expect(await refreshClaudeOauthCredentials(credentials(), NOW)).toBeNull() + expect(warn).toHaveBeenCalledWith(expect.stringContaining('429')) + warn.mockRestore() + }) + + it('returns null when the request throws (never rejects)', async () => { + netFetchMock.mockRejectedValue(new Error('network down')) + await expect(refreshClaudeOauthCredentials(credentials(), NOW)).resolves.toBeNull() + }) +}) diff --git a/src/main/claude-accounts/oauth-refresh.ts b/src/main/claude-accounts/oauth-refresh.ts new file mode 100644 index 000000000000..cc717ef51ee8 --- /dev/null +++ b/src/main/claude-accounts/oauth-refresh.ts @@ -0,0 +1,170 @@ +import { net, session } from 'electron' +import { ensureElectronProxyFromEnvironment } from '../network/proxy-settings' + +// Why: the OAuth client id and token endpoint are the public Claude Code +// values, verified against the installed `claude` binary (2.1.177) and the +// claude-swap reference tool. Orca owns the refresh so a single-use refresh +// token is rotated and persisted atomically, instead of being scraped back +// after the CLI rotates it (the lossy path that strands stale tokens). +const OAUTH_TOKEN_URL = 'https://platform.claude.com/v1/oauth/token' +const OAUTH_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e' + +// Refresh slightly ahead of expiry so a token doesn't expire mid-launch. The +// CLI uses the same 5-minute skew for its own refresh decision. +const OAUTH_EXPIRY_BUFFER_MS = 5 * 60 * 1000 +const REFRESH_TIMEOUT_MS = 10_000 + +type ClaudeOauthBlob = { + accessToken?: unknown + refreshToken?: unknown + expiresAt?: unknown + scopes?: unknown + [key: string]: unknown +} + +type ClaudeCredentials = { + claudeAiOauth?: ClaudeOauthBlob + [key: string]: unknown +} + +type TokenEndpointResponse = { + access_token?: unknown + expires_in?: unknown + refresh_token?: unknown + scope?: unknown +} + +/** + * Parse the `claudeAiOauth` object from a credentials JSON string. + * Returns null when the string is not parseable or lacks the OAuth block. + */ +export function parseClaudeOauthBlob(credentialsJson: string): ClaudeOauthBlob | null { + try { + const parsed = JSON.parse(credentialsJson) as ClaudeCredentials + const oauth = parsed?.claudeAiOauth + return oauth && typeof oauth === 'object' && !Array.isArray(oauth) ? oauth : null + } catch { + return null + } +} + +/** Read a stored refresh token, or null when absent/blank. */ +export function readRefreshToken(credentialsJson: string): string | null { + const oauth = parseClaudeOauthBlob(credentialsJson) + const token = oauth?.refreshToken + return typeof token === 'string' && token.trim() !== '' ? token.trim() : null +} + +/** + * Whether the stored access token is expired or within the refresh buffer. + * + * A missing/non-numeric `expiresAt` is treated as "needs refresh" so a blob + * with no usable expiry metadata still gets a proactive refresh attempt rather + * than being trusted indefinitely. `now` is injectable for tests. + */ +export function isOauthTokenExpiring(credentialsJson: string, now: number = Date.now()): boolean { + const oauth = parseClaudeOauthBlob(credentialsJson) + if (!oauth) { + return false + } + const expiresAt = oauth.expiresAt + if (typeof expiresAt !== 'number' || !Number.isFinite(expiresAt)) { + return true + } + return now + OAUTH_EXPIRY_BUFFER_MS >= expiresAt +} + +/** + * Merge a token-endpoint response into the stored credentials, returning the + * updated credentials JSON. Preserves every field the caller already had + * (including the refresh token when the server does not rotate it) and only + * overwrites what the response provides. Returns null on malformed input. + */ +export function applyRefreshedToken( + credentialsJson: string, + response: TokenEndpointResponse, + now: number = Date.now() +): string | null { + let parsed: ClaudeCredentials + try { + parsed = JSON.parse(credentialsJson) as ClaudeCredentials + } catch { + return null + } + const accessToken = response.access_token + if (typeof accessToken !== 'string' || accessToken.trim() === '') { + return null + } + const oauth: ClaudeOauthBlob = { ...parsed.claudeAiOauth } + oauth.accessToken = accessToken + if (typeof response.expires_in === 'number' && Number.isFinite(response.expires_in)) { + oauth.expiresAt = now + response.expires_in * 1000 + } + // Rotation: keep the existing refresh token unless the server issued a new + // one. Single-use refresh tokens make persisting the rotated value the whole + // point of owning refresh. + if (typeof response.refresh_token === 'string' && response.refresh_token.trim() !== '') { + oauth.refreshToken = response.refresh_token + } + if (typeof response.scope === 'string' && response.scope.trim() !== '') { + oauth.scopes = response.scope.split(' ') + } + parsed.claudeAiOauth = oauth + return JSON.stringify(parsed) +} + +/** + * Refresh the OAuth token for a stored credentials blob. + * + * Returns the updated credentials JSON (with the rotated refresh token and new + * access token) on success, or null on any failure. Never throws — callers + * treat null as "keep the existing credentials", so a transient network error + * is never worse than today's behavior. + */ +export async function refreshClaudeOauthCredentials( + credentialsJson: string, + now: number = Date.now() +): Promise { + const refreshToken = readRefreshToken(credentialsJson) + if (!refreshToken) { + return null + } + + await ensureElectronProxyFromEnvironment({ + proxySession: session.defaultSession, + probeUrl: OAUTH_TOKEN_URL + }).catch(() => {}) + + try { + // Why: the `claude` CLI posts grant_type=refresh_token as + // application/x-www-form-urlencoded with the public client id. net.fetch + // routes through Chromium's stack so the env proxy bridge above applies. + const res = await net.fetch(OAUTH_TOKEN_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: refreshToken, + client_id: OAUTH_CLIENT_ID + }).toString(), + signal: AbortSignal.timeout(REFRESH_TIMEOUT_MS) + }) + if (!res.ok) { + // Why: surface the status (never the token) so a throttle (429) or a + // dead refresh token (400/401 invalid_grant) is diagnosable in the + // field, instead of a silent null that looks identical to success. + // Callers keep the existing credentials on null — a transient 429 just + // means the still-valid token is reused until the next attempt. + console.warn(`[claude-oauth-refresh] token endpoint returned ${res.status}`) + return null + } + const data = (await res.json()) as TokenEndpointResponse + return applyRefreshedToken(credentialsJson, data, now) + } catch (error) { + console.warn( + '[claude-oauth-refresh] token refresh request failed:', + error instanceof Error ? error.message : error + ) + return null + } +} diff --git a/src/main/claude-accounts/runtime-auth-path-materialization.test.ts b/src/main/claude-accounts/runtime-auth-path-materialization.test.ts new file mode 100644 index 000000000000..dbccd87cee5e --- /dev/null +++ b/src/main/claude-accounts/runtime-auth-path-materialization.test.ts @@ -0,0 +1,58 @@ +import { existsSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getDefaultSettings } from '../../shared/constants' + +const testState = { + fakeHomeDir: '', + previousConfigDir: undefined as string | undefined +} + +vi.mock('electron', () => ({ app: { getPath: () => testState.fakeHomeDir } })) + +vi.mock('node:os', async () => { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() + const actual = await vi.importActual('node:os') + return { ...actual, homedir: () => testState.fakeHomeDir } +}) + +const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + +beforeEach(() => { + testState.fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-claude-rate-limit-path-')) + testState.previousConfigDir = process.env.CLAUDE_CONFIG_DIR + delete process.env.CLAUDE_CONFIG_DIR +}) + +afterEach(() => { + rmSync(testState.fakeHomeDir, { recursive: true, force: true }) + if (testState.previousConfigDir === undefined) { + delete process.env.CLAUDE_CONFIG_DIR + } else { + process.env.CLAUDE_CONFIG_DIR = testState.previousConfigDir + } + testState.fakeHomeDir = '' +}) + +describe('Claude runtime auth path materialization', () => { + it('does not create the config directory while preparing a background usage fetch', async () => { + const settings = { + ...getDefaultSettings(testState.fakeHomeDir), + disabledTuiAgents: ['claude'] as const, + claudeManagedAccounts: [], + activeClaudeManagedAccountId: null + } + const store = { + getSettings: vi.fn(() => settings), + updateSettings: vi.fn() + } + const service = new ClaudeRuntimeAuthService(store as never) + + const preparation = await service.prepareForRateLimitFetch() + + expect(preparation.configDir).toBe(join(testState.fakeHomeDir, '.claude')) + expect(preparation.provenance).toBe('system') + expect(existsSync(preparation.configDir)).toBe(false) + }) +}) diff --git a/src/main/claude-accounts/runtime-auth-service-account-switching.test.ts b/src/main/claude-accounts/runtime-auth-service-account-switching.test.ts new file mode 100644 index 000000000000..249e403b075b --- /dev/null +++ b/src/main/claude-accounts/runtime-auth-service-account-switching.test.ts @@ -0,0 +1,509 @@ +import { + cleanupRuntimeAuthTestState, + createClaudeAccount, + createClaudeCredentialsJson, + createClaudeCredentialsWithoutEmail, + createElectronMock, + createKeychainMock, + createManagedClaudeAuth, + createOauthRefreshMock, + createSettings, + createStore, + readManagedCredentialsForTest, + readRuntimeOauthAccountForTest, + resetRuntimeAuthTestState, + testState +} from './runtime-auth-service-test-harness' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' + +vi.mock('electron', () => createElectronMock()) + +vi.mock('./oauth-refresh', () => createOauthRefreshMock()) + +vi.mock('node:os', async () => { + const actual = await vi.importActual('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() + return { + ...actual, + homedir: () => testState.fakeHomeDir + } +}) + +vi.mock('./keychain', () => createKeychainMock()) + +describe('ClaudeRuntimeAuthService', () => { + beforeEach(() => { + resetRuntimeAuthTestState() + }) + + afterEach(() => { + cleanupRuntimeAuthTestState() + }) + + it('saves a verified file refresh but refuses to overwrite unreadable keychain state', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const originalCredentials = createClaudeCredentialsJson('user@example.com', 'original') + const refreshedCredentials = createClaudeCredentialsJson('user@example.com', 'refreshed') + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + originalCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + writeFileSync(runtimeCredentialsPath, refreshedCredentials, 'utf-8') + testState.throwScopedKeychainRead = true + testState.throwLegacyKeychainRead = true + await expect(service.syncForCurrentSelection()).rejects.toThrow('scoped keychain read failed') + + expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(refreshedCredentials) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(refreshedCredentials) + expect(testState.scopedKeychainCredentials).toBe(originalCredentials) + expect(testState.legacyKeychainCredentials).toBe(originalCredentials) + warn.mockRestore() + }) + + it('captures a fresh system-default snapshot when re-entering managed mode', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const systemCredentials1 = createClaudeCredentialsJson('system1@example.com', 'system1') + const systemCredentials2 = createClaudeCredentialsJson('system2@example.com', 'system2') + const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') + writeFileSync(runtimeCredentialsPath, systemCredentials1, 'utf-8') + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + managedCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + settings.activeClaudeManagedAccountId = null + await service.syncForCurrentSelection() + writeFileSync(runtimeCredentialsPath, systemCredentials2, 'utf-8') + + settings.activeClaudeManagedAccountId = 'account-1' + await service.syncForCurrentSelection() + settings.activeClaudeManagedAccountId = null + await service.syncForCurrentSelection() + + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials2) + }) + + it('refreshes keychain and oauth snapshot surfaces when the credentials file is unchanged', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') + const systemCredentials1 = createClaudeCredentialsJson('system1@example.com', 'system1') + const systemCredentials2 = createClaudeCredentialsJson('system2@example.com', 'system2') + const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') + const systemOauthAccount1 = { accountUuid: 'system-account-1' } + const systemOauthAccount2 = { accountUuid: 'system-account-2' } + writeFileSync(runtimeCredentialsPath, systemCredentials1, 'utf-8') + writeFileSync( + runtimeConfigPath, + `${JSON.stringify({ oauthAccount: systemOauthAccount1 })}\n`, + 'utf-8' + ) + testState.scopedKeychainCredentials = systemCredentials1 + testState.legacyKeychainCredentials = systemCredentials1 + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + managedCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = 'account-1' + await service.syncForCurrentSelection() + settings.activeClaudeManagedAccountId = null + await service.syncForCurrentSelection() + + writeFileSync(runtimeCredentialsPath, managedCredentials, 'utf-8') + writeFileSync( + runtimeConfigPath, + `${JSON.stringify({ oauthAccount: systemOauthAccount2 })}\n`, + 'utf-8' + ) + testState.scopedKeychainCredentials = systemCredentials2 + testState.legacyKeychainCredentials = systemCredentials2 + settings.activeClaudeManagedAccountId = 'account-1' + await service.syncForCurrentSelection() + settings.activeClaudeManagedAccountId = null + await service.syncForCurrentSelection() + + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials1) + expect(readRuntimeOauthAccountForTest()).toEqual(systemOauthAccount2) + expect(testState.scopedKeychainCredentials).toBe(systemCredentials2) + expect(testState.legacyKeychainCredentials).toBe(systemCredentials2) + }) + + it('reads back refreshed credentials for the outgoing Claude account before switching', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const account1Original = createClaudeCredentialsJson('one@example.com', 'one-original') + const account1Refreshed = createClaudeCredentialsJson('one@example.com', 'one-refreshed') + const account2Credentials = createClaudeCredentialsJson('two@example.com', 'two') + const managedAuthPath1 = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + account1Original + ) + const managedAuthPath2 = createManagedClaudeAuth( + testState.userDataDir, + 'account-2', + account2Credentials + ) + const settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' }), + createClaudeAccount('account-2', managedAuthPath2, { email: 'two@example.com' }) + ], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + writeFileSync(runtimeCredentialsPath, account1Refreshed, 'utf-8') + settings.activeClaudeManagedAccountId = 'account-2' + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe(account1Refreshed) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account2Credentials) + }) + + it('switches accounts without persisting unverified live runtime credentials', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const account1Original = createClaudeCredentialsJson('one@example.com', 'one-original', 'org-a') + const unverifiedLiveCredentials = createClaudeCredentialsWithoutEmail('one-live', 'org-b') + const account2Credentials = createClaudeCredentialsJson('two@example.com', 'two', 'org-c') + const managedAuthPath1 = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + account1Original + ) + const managedAuthPath2 = createManagedClaudeAuth( + testState.userDataDir, + 'account-2', + account2Credentials + ) + const settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('account-1', managedAuthPath1, { + email: 'one@example.com', + organizationUuid: 'org-a' + }), + createClaudeAccount('account-2', managedAuthPath2, { + email: 'two@example.com', + organizationUuid: 'org-c' + }) + ], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const { markClaudePtyExited, markClaudePtySpawned } = await import('./live-pty-gate') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + markClaudePtySpawned('live-claude-pty') + try { + writeFileSync(runtimeCredentialsPath, unverifiedLiveCredentials, 'utf-8') + settings.activeClaudeManagedAccountId = 'account-2' + + await service.syncForCurrentSelection() + } finally { + markClaudePtyExited('live-claude-pty') + } + + expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe(account1Original) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account2Credentials) + if (process.platform === 'darwin') { + expect(testState.scopedKeychainCredentials).toBe(account2Credentials) + } + }) + + it('routes refreshed Claude credentials to the matching managed account', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const account1Original = createClaudeCredentialsJson('one@example.com', 'one-original') + const account1Refreshed = createClaudeCredentialsJson('one@example.com', 'one-refreshed') + const account2Credentials = createClaudeCredentialsJson('two@example.com', 'two') + const managedAuthPath1 = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + account1Original + ) + const managedAuthPath2 = createManagedClaudeAuth( + testState.userDataDir, + 'account-2', + account2Credentials + ) + const settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' }), + createClaudeAccount('account-2', managedAuthPath2, { email: 'two@example.com' }) + ], + activeClaudeManagedAccountId: 'account-2' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + // A stale account-1 Claude process refreshed the shared runtime file after + // Orca selected account-2. Persist that refresh to account-1, then restore + // the selected account in the shared Claude runtime credentials. + writeFileSync(runtimeCredentialsPath, account1Refreshed, 'utf-8') + testState.scopedKeychainCredentials = account1Refreshed + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe(account1Refreshed) + expect(readManagedCredentialsForTest('account-2', managedAuthPath2)).toBe(account2Credentials) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account2Credentials) + if (process.platform === 'darwin') { + expect(testState.scopedKeychainCredentials).toBe(account2Credentials) + expect(testState.legacyKeychainCredentials).toBe(account2Credentials) + } + }) + + it('rejects stale cold-start read-back for inactive matching account', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const account1ManagedNewer = createClaudeCredentialsJson( + 'one@example.com', + 'one-managed-newer', + null, + 5_000 + ) + const account1RuntimeStale = createClaudeCredentialsJson( + 'one@example.com', + 'one-runtime-stale', + null, + 2_000 + ) + const account2Credentials = createClaudeCredentialsJson('two@example.com', 'two', null, 1_000) + writeFileSync(runtimeCredentialsPath, account1RuntimeStale, 'utf-8') + const managedAuthPath1 = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + account1ManagedNewer + ) + const managedAuthPath2 = createManagedClaudeAuth( + testState.userDataDir, + 'account-2', + account2Credentials + ) + const settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' }), + createClaudeAccount('account-2', managedAuthPath2, { email: 'two@example.com' }) + ], + activeClaudeManagedAccountId: 'account-2' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe(account1ManagedNewer) + expect(readManagedCredentialsForTest('account-2', managedAuthPath2)).toBe(account2Credentials) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account2Credentials) + }) + + it('rejects ambiguous Claude read-back instead of choosing a managed account', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const originalCredentials = createClaudeCredentialsJson('same@example.com', 'same-original') + const refreshedCredentials = createClaudeCredentialsJson('same@example.com', 'same-refreshed') + const activeCredentials = createClaudeCredentialsJson('active@example.com', 'active') + const managedAuthPath1 = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + originalCredentials + ) + const managedAuthPath2 = createManagedClaudeAuth( + testState.userDataDir, + 'account-2', + originalCredentials + ) + const managedAuthPath3 = createManagedClaudeAuth( + testState.userDataDir, + 'account-3', + activeCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('account-1', managedAuthPath1, { email: 'same@example.com' }), + createClaudeAccount('account-2', managedAuthPath2, { email: 'same@example.com' }), + createClaudeAccount('account-3', managedAuthPath3, { email: 'active@example.com' }) + ], + activeClaudeManagedAccountId: 'account-3' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + writeFileSync(runtimeCredentialsPath, refreshedCredentials, 'utf-8') + testState.scopedKeychainCredentials = refreshedCredentials + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe(originalCredentials) + expect(readManagedCredentialsForTest('account-2', managedAuthPath2)).toBe(originalCredentials) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(activeCredentials) + if (process.platform === 'darwin') { + expect(testState.scopedKeychainCredentials).toBe(activeCredentials) + expect(testState.legacyKeychainCredentials).toBe(activeCredentials) + } + }) + + it('rejects same-email read-back when another account needs organization proof', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const noOrgCredentials = createClaudeCredentialsJson('same@example.com', 'no-org') + const orgCredentials = createClaudeCredentialsJson('same@example.com', 'org', 'org-b') + const refreshedWithoutOrg = createClaudeCredentialsJson('same@example.com', 'refreshed') + const managedAuthPath1 = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + noOrgCredentials + ) + const managedAuthPath2 = createManagedClaudeAuth( + testState.userDataDir, + 'account-2', + orgCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('account-1', managedAuthPath1, { email: 'same@example.com' }), + createClaudeAccount('account-2', managedAuthPath2, { + email: 'same@example.com', + organizationUuid: 'org-b' + }) + ], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + writeFileSync(runtimeCredentialsPath, refreshedWithoutOrg, 'utf-8') + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe(noOrgCredentials) + expect(readManagedCredentialsForTest('account-2', managedAuthPath2)).toBe(orgCredentials) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(noOrgCredentials) + }) + + it('ignores unrelated org-scoped accounts when reading back no-org credentials', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const account1Credentials = createClaudeCredentialsJson('one@example.com', 'one') + const account1RefreshedCredentials = createClaudeCredentialsJson( + 'one@example.com', + 'one-refreshed' + ) + const account2Credentials = createClaudeCredentialsJson('two@example.com', 'two', 'org-b') + const managedAuthPath1 = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + account1Credentials + ) + const managedAuthPath2 = createManagedClaudeAuth( + testState.userDataDir, + 'account-2', + account2Credentials + ) + const settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' }), + createClaudeAccount('account-2', managedAuthPath2, { + email: 'two@example.com', + organizationUuid: 'org-b' + }) + ], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + writeFileSync(runtimeCredentialsPath, account1RefreshedCredentials, 'utf-8') + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe( + account1RefreshedCredentials + ) + expect(readManagedCredentialsForTest('account-2', managedAuthPath2)).toBe(account2Credentials) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account1RefreshedCredentials) + }) + + it('rejects same-email read-back with conflicting organization for no-org accounts', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const noOrgCredentials = createClaudeCredentialsJson('same@example.com', 'no-org') + const orgCredentials = createClaudeCredentialsJson('same@example.com', 'org', 'org-b') + const conflictingOrgCredentials = createClaudeCredentialsJson( + 'same@example.com', + 'conflicting-org', + 'org-c' + ) + const managedAuthPath1 = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + noOrgCredentials + ) + const managedAuthPath2 = createManagedClaudeAuth( + testState.userDataDir, + 'account-2', + orgCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('account-1', managedAuthPath1, { email: 'same@example.com' }), + createClaudeAccount('account-2', managedAuthPath2, { + email: 'same@example.com', + organizationUuid: 'org-b' + }) + ], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + writeFileSync(runtimeCredentialsPath, conflictingOrgCredentials, 'utf-8') + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe(noOrgCredentials) + expect(readManagedCredentialsForTest('account-2', managedAuthPath2)).toBe(orgCredentials) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(noOrgCredentials) + }) +}) diff --git a/src/main/claude-accounts/runtime-auth-service-deselect-restore.test.ts b/src/main/claude-accounts/runtime-auth-service-deselect-restore.test.ts new file mode 100644 index 000000000000..efd650618b41 --- /dev/null +++ b/src/main/claude-accounts/runtime-auth-service-deselect-restore.test.ts @@ -0,0 +1,611 @@ +import { + cleanupRuntimeAuthTestState, + createClaudeAccount, + createClaudeCredentialsJson, + createClaudeCredentialsWithoutEmail, + createElectronMock, + createKeychainMock, + createManagedClaudeAuth, + createOauthRefreshMock, + createSettings, + createStore, + readManagedCredentialsForTest, + readRuntimeOauthAccountForTest, + resetRuntimeAuthTestState, + setPlatform, + testState +} from './runtime-auth-service-test-harness' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' + +vi.mock('electron', () => createElectronMock()) + +vi.mock('./oauth-refresh', () => createOauthRefreshMock()) + +vi.mock('node:os', async () => { + const actual = await vi.importActual('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() + return { + ...actual, + homedir: () => testState.fakeHomeDir + } +}) + +vi.mock('./keychain', () => createKeychainMock()) + +describe('ClaudeRuntimeAuthService', () => { + beforeEach(() => { + resetRuntimeAuthTestState() + }) + + afterEach(() => { + cleanupRuntimeAuthTestState() + }) + + it('restores the system default after rejecting unverifiable managed credentials', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') + writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') + const originalCredentials = createClaudeCredentialsWithoutEmail('original') + const refreshedCredentials = createClaudeCredentialsWithoutEmail('refreshed') + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + originalCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = 'account-1' + await service.syncForCurrentSelection() + + writeFileSync(runtimeCredentialsPath, refreshedCredentials, 'utf-8') + await service.syncForCurrentSelection() + + settings.activeClaudeManagedAccountId = null + await service.syncForCurrentSelection() + + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) + }) + + it('restores system default after same-identity managed Claude refresh on deselect', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') + const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed', 'org-1') + const externalCredentials = createClaudeCredentialsJson('user@example.com', 'external', 'org-1') + writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + managedCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('account-1', managedAuthPath, { + organizationUuid: 'org-1' + }) + ] + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = 'account-1' + await service.syncForCurrentSelection() + + writeFileSync(runtimeCredentialsPath, externalCredentials, 'utf-8') + settings.activeClaudeManagedAccountId = null + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(externalCredentials) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) + }) + + it('preserves external stale Claude credentials without writing them to managed storage', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') + const selectedCredentials = createClaudeCredentialsJson('selected@example.com', 'selected') + const staleCredentials = createClaudeCredentialsJson('stale@example.com', 'stale') + writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') + testState.scopedKeychainCredentials = systemCredentials + testState.legacyKeychainCredentials = systemCredentials + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + selectedCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('account-1', managedAuthPath, { email: 'selected@example.com' }) + ] + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = 'account-1' + await service.syncForCurrentSelection() + + writeFileSync(runtimeCredentialsPath, staleCredentials, 'utf-8') + settings.activeClaudeManagedAccountId = null + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(selectedCredentials) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(staleCredentials) + expect(testState.scopedKeychainCredentials).toBe(systemCredentials) + expect(testState.legacyKeychainCredentials).toBe(systemCredentials) + }) + + it('does not persist unverifiable stale Claude credentials into another active account', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const account1Credentials = createClaudeCredentialsJson('one@example.com', 'one') + const account2Credentials = createClaudeCredentialsJson('two@example.com', 'two') + const staleUnverifiableCredentials = createClaudeCredentialsWithoutEmail('stale') + const managedAuthPath1 = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + account1Credentials + ) + const managedAuthPath2 = createManagedClaudeAuth( + testState.userDataDir, + 'account-2', + account2Credentials + ) + const settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' }), + createClaudeAccount('account-2', managedAuthPath2, { email: 'two@example.com' }) + ], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + settings.activeClaudeManagedAccountId = 'account-2' + await service.syncForCurrentSelection() + writeFileSync(runtimeCredentialsPath, staleUnverifiableCredentials, 'utf-8') + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-2', managedAuthPath2)).toBe(account2Credentials) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account2Credentials) + }) + + it('materializes only the selected managed account into shared Claude runtime files', async () => { + setPlatform('linux') + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const account1Credentials = createClaudeCredentialsJson('one@example.com', 'one-token') + const account2Credentials = createClaudeCredentialsJson('two@example.com', 'two-token') + const account1Oauth = '{"accountUuid":"account-1","emailAddress":"one@example.com"}\n' + const account2Oauth = '{"accountUuid":"account-2","emailAddress":"two@example.com"}\n' + const managedAuthPath1 = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + account1Credentials, + account1Oauth + ) + const managedAuthPath2 = createManagedClaudeAuth( + testState.userDataDir, + 'account-2', + account2Credentials, + account2Oauth + ) + const settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' }), + createClaudeAccount('account-2', managedAuthPath2, { email: 'two@example.com' }) + ], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + + await service.syncForCurrentSelection() + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account1Credentials) + expect(readRuntimeOauthAccountForTest()).toEqual({ + accountUuid: 'account-1', + emailAddress: 'one@example.com' + }) + + settings.activeClaudeManagedAccountId = 'account-2' + await service.syncForCurrentSelection() + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account2Credentials) + expect(readRuntimeOauthAccountForTest()).toEqual({ + accountUuid: 'account-2', + emailAddress: 'two@example.com' + }) + + settings.activeClaudeManagedAccountId = 'account-1' + await service.syncForCurrentSelection() + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account1Credentials) + expect(readRuntimeOauthAccountForTest()).toEqual({ + accountUuid: 'account-1', + emailAddress: 'one@example.com' + }) + + // Why: switching rewrites only the shared Claude runtime surface; the + // managed account files remain per-account sources of truth. + expect(readFileSync(join(managedAuthPath1, '.credentials.json'), 'utf-8')).toBe( + account1Credentials + ) + expect(readFileSync(join(managedAuthPath2, '.credentials.json'), 'utf-8')).toBe( + account2Credentials + ) + expect(readFileSync(join(managedAuthPath1, 'oauth-account.json'), 'utf-8')).toBe(account1Oauth) + expect(readFileSync(join(managedAuthPath2, 'oauth-account.json'), 'utf-8')).toBe(account2Oauth) + }) + + it('does not carry the reauth read-back skip across Claude account switches', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const account1Credentials = createClaudeCredentialsJson('one@example.com', 'one') + const account2Credentials = createClaudeCredentialsJson('two@example.com', 'two') + const account2RefreshedCredentials = createClaudeCredentialsJson( + 'two@example.com', + 'two-refreshed' + ) + const managedAuthPath1 = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + account1Credentials + ) + const managedAuthPath2 = createManagedClaudeAuth( + testState.userDataDir, + 'account-2', + account2Credentials + ) + const settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' }), + createClaudeAccount('account-2', managedAuthPath2, { email: 'two@example.com' }) + ], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + service.clearLastWrittenCredentialsJson() + settings.activeClaudeManagedAccountId = 'account-2' + await service.syncForCurrentSelection() + + writeFileSync(runtimeCredentialsPath, account2RefreshedCredentials, 'utf-8') + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-2', managedAuthPath2)).toBe( + account2RefreshedCredentials + ) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account2RefreshedCredentials) + }) + + it('does not apply inactive-account Claude reauth skip to the active account', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const account1Credentials = createClaudeCredentialsJson('one@example.com', 'one') + const account1RefreshedCredentials = createClaudeCredentialsJson( + 'one@example.com', + 'one-refreshed' + ) + const account2Credentials = createClaudeCredentialsJson('two@example.com', 'two') + const managedAuthPath1 = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + account1Credentials + ) + const managedAuthPath2 = createManagedClaudeAuth( + testState.userDataDir, + 'account-2', + account2Credentials + ) + const settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' }), + createClaudeAccount('account-2', managedAuthPath2, { email: 'two@example.com' }) + ], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + writeFileSync(runtimeCredentialsPath, account1RefreshedCredentials, 'utf-8') + service.clearLastWrittenCredentialsJson('account-2') + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe( + account1RefreshedCredentials + ) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account1RefreshedCredentials) + }) + + it('keeps external Claude logout when deselecting managed account', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') + const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') + writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + managedCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = 'account-1' + await service.syncForCurrentSelection() + + rmSync(runtimeCredentialsPath, { force: true }) + testState.activeKeychainCredentials = null + testState.scopedKeychainCredentials = null + testState.legacyKeychainCredentials = null + settings.activeClaudeManagedAccountId = null + await service.syncForCurrentSelection() + + expect(existsSync(runtimeCredentialsPath)).toBe(false) + expect(testState.scopedKeychainCredentials).toBeNull() + expect(testState.legacyKeychainCredentials).toBeNull() + }) + + it('preserves unknown runtime auth when invalid active account has no ownership proof', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') + const snapshotPath = join( + testState.userDataDir, + 'claude-runtime-auth', + 'system-default-auth.json' + ) + const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') + const staleManagedCredentials = createClaudeCredentialsJson('managed@example.com', 'managed') + const systemOauthAccount = { accountUuid: 'system-account' } + mkdirSync(join(testState.userDataDir, 'claude-runtime-auth'), { recursive: true }) + writeFileSync( + snapshotPath, + `${JSON.stringify({ + credentialsJson: systemCredentials, + configOauthAccount: systemOauthAccount, + keychainCredentialsJson: systemCredentials, + scopedKeychainCredentialsJson: systemCredentials, + legacyKeychainCredentialsJson: systemCredentials, + capturedAt: Date.now() + })}\n`, + 'utf-8' + ) + writeFileSync(runtimeCredentialsPath, staleManagedCredentials, 'utf-8') + writeFileSync( + runtimeConfigPath, + `${JSON.stringify({ oauthAccount: { accountUuid: 'missing-account' } })}\n`, + 'utf-8' + ) + testState.scopedKeychainCredentials = staleManagedCredentials + testState.legacyKeychainCredentials = staleManagedCredentials + const settings = createSettings({ + activeClaudeManagedAccountId: 'missing-account' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + const preparation = await service.prepareForClaudeLaunch() + + expect(store.updateSettings).toHaveBeenCalledWith({ + activeClaudeManagedAccountId: null, + activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: {} } + }) + expect(preparation.configDir).toBe(join(testState.fakeHomeDir, '.claude')) + expect(preparation.stripAuthEnv).toBe(false) + expect(preparation.provenance).toBe('system') + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(staleManagedCredentials) + expect(readRuntimeOauthAccountForTest()).toEqual({ accountUuid: 'missing-account' }) + expect(testState.scopedKeychainCredentials).toBe(staleManagedCredentials) + expect(testState.legacyKeychainCredentials).toBe(staleManagedCredentials) + }) + + it('restores system snapshot when active account credentials are missing but runtime matches account', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') + const snapshotPath = join( + testState.userDataDir, + 'claude-runtime-auth', + 'system-default-auth.json' + ) + const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') + const staleManagedCredentials = createClaudeCredentialsJson('managed@example.com', 'managed') + const systemOauthAccount = { accountUuid: 'system-account' } + const managedAuthPath = join(testState.userDataDir, 'claude-accounts', 'account-1', 'auth') + mkdirSync(join(testState.userDataDir, 'claude-runtime-auth'), { recursive: true }) + mkdirSync(managedAuthPath, { recursive: true }) + writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') + writeFileSync( + snapshotPath, + `${JSON.stringify({ + credentialsJson: systemCredentials, + configOauthAccount: systemOauthAccount, + keychainCredentialsJson: systemCredentials, + scopedKeychainCredentialsJson: systemCredentials, + legacyKeychainCredentialsJson: systemCredentials, + capturedAt: Date.now() + })}\n`, + 'utf-8' + ) + writeFileSync(runtimeCredentialsPath, staleManagedCredentials, 'utf-8') + writeFileSync( + runtimeConfigPath, + `${JSON.stringify({ oauthAccount: { accountUuid: 'account-1' } })}\n`, + 'utf-8' + ) + testState.scopedKeychainCredentials = staleManagedCredentials + testState.legacyKeychainCredentials = staleManagedCredentials + const settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('account-1', managedAuthPath, { email: 'managed@example.com' }) + ], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + const preparation = await service.prepareForClaudeLaunch() + + expect(preparation.provenance).toBe('system') + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(staleManagedCredentials) + expect(readRuntimeOauthAccountForTest()).toEqual({ accountUuid: 'account-1' }) + expect(testState.scopedKeychainCredentials).toBe(staleManagedCredentials) + expect(testState.legacyKeychainCredentials).toBe(staleManagedCredentials) + }) + + it('keeps missing-managed selection until cleanup can retry after keychain failure', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') + const snapshotPath = join( + testState.userDataDir, + 'claude-runtime-auth', + 'system-default-auth.json' + ) + const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') + const staleManagedCredentials = createClaudeCredentialsJson('managed@example.com', 'managed') + const systemOauthAccount = { accountUuid: 'system-account' } + const managedAuthPath = join(testState.userDataDir, 'claude-accounts', 'account-1', 'auth') + mkdirSync(join(testState.userDataDir, 'claude-runtime-auth'), { recursive: true }) + mkdirSync(managedAuthPath, { recursive: true }) + writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') + writeFileSync( + snapshotPath, + `${JSON.stringify({ + credentialsJson: systemCredentials, + configOauthAccount: systemOauthAccount, + keychainCredentialsJson: systemCredentials, + scopedKeychainCredentialsJson: systemCredentials, + legacyKeychainCredentialsJson: systemCredentials, + capturedAt: Date.now() + })}\n`, + 'utf-8' + ) + writeFileSync(runtimeCredentialsPath, staleManagedCredentials, 'utf-8') + writeFileSync( + runtimeConfigPath, + `${JSON.stringify({ oauthAccount: { accountUuid: 'account-1' } })}\n`, + 'utf-8' + ) + testState.scopedKeychainCredentials = staleManagedCredentials + testState.legacyKeychainCredentials = staleManagedCredentials + const settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('account-1', managedAuthPath, { email: 'managed@example.com' }) + ], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + testState.throwScopedKeychainWrite = true + const failedPreparation = await service.prepareForClaudeLaunch() + + expect(failedPreparation.provenance).toBe('system') + expect(store.getSettings().activeClaudeManagedAccountId).toBeNull() + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(staleManagedCredentials) + expect(readRuntimeOauthAccountForTest()).toEqual({ accountUuid: 'account-1' }) + expect(testState.scopedKeychainCredentials).toBe(staleManagedCredentials) + + testState.throwScopedKeychainWrite = false + const preparation = await service.prepareForClaudeLaunch() + + expect(preparation.provenance).toBe('system') + expect(store.getSettings().activeClaudeManagedAccountId).toBeNull() + expect(testState.scopedKeychainCredentials).toBe(staleManagedCredentials) + expect(testState.legacyKeychainCredentials).toBe(staleManagedCredentials) + }) + + it('restores missing-managed oauth metadata when only keychain proves ownership', async () => { + const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') + const snapshotPath = join( + testState.userDataDir, + 'claude-runtime-auth', + 'system-default-auth.json' + ) + const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') + const staleManagedCredentials = createClaudeCredentialsJson('managed@example.com', 'managed') + const systemOauthAccount = { accountUuid: 'system-account' } + const managedAuthPath = join(testState.userDataDir, 'claude-accounts', 'account-1', 'auth') + mkdirSync(join(testState.userDataDir, 'claude-runtime-auth'), { recursive: true }) + mkdirSync(managedAuthPath, { recursive: true }) + writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') + writeFileSync( + snapshotPath, + `${JSON.stringify({ + credentialsJson: systemCredentials, + configOauthAccount: systemOauthAccount, + keychainCredentialsJson: systemCredentials, + scopedKeychainCredentialsJson: systemCredentials, + legacyKeychainCredentialsJson: systemCredentials, + capturedAt: Date.now() + })}\n`, + 'utf-8' + ) + writeFileSync( + runtimeConfigPath, + `${JSON.stringify({ oauthAccount: { accountUuid: 'account-1' } })}\n`, + 'utf-8' + ) + testState.scopedKeychainCredentials = staleManagedCredentials + testState.legacyKeychainCredentials = staleManagedCredentials + const settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('account-1', managedAuthPath, { email: 'managed@example.com' }) + ], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.prepareForClaudeLaunch() + + expect(readRuntimeOauthAccountForTest()).toEqual({ accountUuid: 'account-1' }) + expect(testState.scopedKeychainCredentials).toBe(staleManagedCredentials) + expect(testState.legacyKeychainCredentials).toBe(staleManagedCredentials) + }) + + it('preserves unknown runtime auth when invalid active account has no system snapshot', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') + const staleManagedCredentials = createClaudeCredentialsJson('managed@example.com', 'managed') + writeFileSync(runtimeCredentialsPath, staleManagedCredentials, 'utf-8') + writeFileSync( + runtimeConfigPath, + `${JSON.stringify({ oauthAccount: { accountUuid: 'missing-account' } })}\n`, + 'utf-8' + ) + testState.scopedKeychainCredentials = staleManagedCredentials + testState.legacyKeychainCredentials = staleManagedCredentials + const settings = createSettings({ + activeClaudeManagedAccountId: 'missing-account' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + const preparation = await service.prepareForClaudeLaunch() + + expect(preparation.provenance).toBe('system') + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(staleManagedCredentials) + expect(readRuntimeOauthAccountForTest()).toEqual({ accountUuid: 'missing-account' }) + expect(testState.scopedKeychainCredentials).toBe(staleManagedCredentials) + expect(testState.legacyKeychainCredentials).toBe(staleManagedCredentials) + }) +}) diff --git a/src/main/claude-accounts/runtime-auth-service-keychain-snapshots.test.ts b/src/main/claude-accounts/runtime-auth-service-keychain-snapshots.test.ts new file mode 100644 index 000000000000..7a80c5be002a --- /dev/null +++ b/src/main/claude-accounts/runtime-auth-service-keychain-snapshots.test.ts @@ -0,0 +1,712 @@ +import { + cleanupRuntimeAuthTestState, + createClaudeAccount, + createClaudeCredentialsJson, + createElectronMock, + createKeychainMock, + createManagedClaudeAuth, + createOauthRefreshMock, + createSettings, + createStore, + expectedRuntimeConfigDir, + readManagedCredentialsForTest, + readRuntimeOauthAccountForTest, + resetRuntimeAuthTestState, + testState +} from './runtime-auth-service-test-harness' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' + +vi.mock('electron', () => createElectronMock()) + +vi.mock('./oauth-refresh', () => createOauthRefreshMock()) + +vi.mock('node:os', async () => { + const actual = await vi.importActual('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() + return { + ...actual, + homedir: () => testState.fakeHomeDir + } +}) + +vi.mock('./keychain', () => createKeychainMock()) + +describe('ClaudeRuntimeAuthService', () => { + beforeEach(() => { + resetRuntimeAuthTestState() + }) + + afterEach(() => { + cleanupRuntimeAuthTestState() + }) + + it('reads back refreshed active keychain credentials on macOS', async () => { + if (process.platform !== 'darwin') { + return + } + + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const originalCredentials = createClaudeCredentialsJson('user@example.com', 'original') + const refreshedCredentials = createClaudeCredentialsJson('user@example.com', 'refreshed') + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + originalCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + testState.scopedKeychainCredentials = refreshedCredentials + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(refreshedCredentials) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(refreshedCredentials) + }) + + it('reads back refreshed legacy keychain credentials on old Claude Code builds', async () => { + if (process.platform !== 'darwin') { + return + } + + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const originalCredentials = createClaudeCredentialsJson('user@example.com', 'original') + const refreshedCredentials = createClaudeCredentialsJson('user@example.com', 'refreshed') + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + originalCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + testState.scopedKeychainCredentials = originalCredentials + testState.legacyKeychainCredentials = refreshedCredentials + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(refreshedCredentials) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(refreshedCredentials) + expect(testState.scopedKeychainCredentials).toBe(refreshedCredentials) + expect(testState.legacyKeychainCredentials).toBe(refreshedCredentials) + }) + + it('rejects stale legacy keychain credentials after a fresher managed write', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const staleCredentials = createClaudeCredentialsJson('user@example.com', 'stale', null, 1_000) + const managedCredentials = createClaudeCredentialsJson( + 'user@example.com', + 'managed-newer', + null, + 2_000 + ) + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + managedCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + testState.scopedKeychainCredentials = managedCredentials + testState.legacyKeychainCredentials = staleCredentials + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(managedCredentials) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(managedCredentials) + expect(testState.scopedKeychainCredentials).toBe(managedCredentials) + expect(testState.legacyKeychainCredentials).toBe(managedCredentials) + }) + + it('uses fresher file credentials when scoped keychain is stale', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const staleCredentials = createClaudeCredentialsJson('user@example.com', 'stale', null, 1_000) + const managedCredentials = createClaudeCredentialsJson( + 'user@example.com', + 'managed', + null, + 2_000 + ) + const refreshedCredentials = createClaudeCredentialsJson( + 'user@example.com', + 'refreshed', + null, + 3_000 + ) + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + managedCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + writeFileSync(runtimeCredentialsPath, refreshedCredentials, 'utf-8') + testState.scopedKeychainCredentials = staleCredentials + testState.legacyKeychainCredentials = managedCredentials + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(refreshedCredentials) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(refreshedCredentials) + expect(testState.scopedKeychainCredentials).toBe(refreshedCredentials) + expect(testState.legacyKeychainCredentials).toBe(refreshedCredentials) + }) + + it('restores system default when mismatched Claude keychain auth appears before deselect', async () => { + if (process.platform !== 'darwin') { + return + } + + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') + const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') + const externalKeychainCredentials = createClaudeCredentialsJson( + 'external@example.com', + 'external' + ) + writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') + testState.scopedKeychainCredentials = systemCredentials + testState.legacyKeychainCredentials = systemCredentials + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + managedCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = 'account-1' + await service.syncForCurrentSelection() + + testState.scopedKeychainCredentials = externalKeychainCredentials + testState.legacyKeychainCredentials = externalKeychainCredentials + settings.activeClaudeManagedAccountId = null + await service.syncForCurrentSelection() + + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) + expect(testState.scopedKeychainCredentials).toBe(externalKeychainCredentials) + expect(testState.legacyKeychainCredentials).toBe(externalKeychainCredentials) + }) + + it('restores unchanged scoped keychain while preserving external legacy keychain logout', async () => { + if (process.platform !== 'darwin') { + return + } + + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') + const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') + writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') + testState.scopedKeychainCredentials = systemCredentials + testState.legacyKeychainCredentials = systemCredentials + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + managedCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = 'account-1' + await service.syncForCurrentSelection() + + testState.legacyKeychainCredentials = null + settings.activeClaudeManagedAccountId = null + await service.syncForCurrentSelection() + + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) + expect(testState.scopedKeychainCredentials).toBe(systemCredentials) + expect(testState.legacyKeychainCredentials).toBeNull() + }) + + it('preserves external scoped keychain login while restoring unchanged legacy keychain', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') + const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') + const externalScopedCredentials = createClaudeCredentialsJson( + 'external@example.com', + 'external' + ) + writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') + testState.scopedKeychainCredentials = systemCredentials + testState.legacyKeychainCredentials = systemCredentials + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + managedCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const { readActiveClaudeKeychainCredentials } = await import('./keychain') + const service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = 'account-1' + await service.syncForCurrentSelection() + expect(readActiveClaudeKeychainCredentials).toHaveBeenCalledWith(expectedRuntimeConfigDir()) + + testState.scopedKeychainCredentials = externalScopedCredentials + settings.activeClaudeManagedAccountId = null + await service.syncForCurrentSelection() + + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) + expect(testState.scopedKeychainCredentials).toBe(externalScopedCredentials) + expect(testState.legacyKeychainCredentials).toBe(systemCredentials) + }) + + it('restores oauth metadata when credentials prove managed ownership', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') + const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') + const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') + const systemOauthAccount = { accountUuid: 'system-account' } + const externalOauthAccount = { accountUuid: 'external-account' } + writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') + writeFileSync( + runtimeConfigPath, + `${JSON.stringify({ oauthAccount: systemOauthAccount })}\n`, + 'utf-8' + ) + testState.scopedKeychainCredentials = systemCredentials + testState.legacyKeychainCredentials = systemCredentials + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + managedCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = 'account-1' + await service.syncForCurrentSelection() + + writeFileSync( + runtimeConfigPath, + `${JSON.stringify({ oauthAccount: externalOauthAccount })}\n`, + 'utf-8' + ) + settings.activeClaudeManagedAccountId = null + await service.syncForCurrentSelection() + + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) + expect(readRuntimeOauthAccountForTest()).toEqual(systemOauthAccount) + }) + + it('restores owned oauth metadata when external credentials change but metadata does not', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') + const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') + const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') + const externalCredentials = createClaudeCredentialsJson('external@example.com', 'external') + const systemOauthAccount = { accountUuid: 'system-account' } + writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') + writeFileSync( + runtimeConfigPath, + `${JSON.stringify({ oauthAccount: systemOauthAccount })}\n`, + 'utf-8' + ) + testState.scopedKeychainCredentials = systemCredentials + testState.legacyKeychainCredentials = systemCredentials + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + managedCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = 'account-1' + await service.syncForCurrentSelection() + + writeFileSync(runtimeCredentialsPath, externalCredentials, 'utf-8') + settings.activeClaudeManagedAccountId = null + await service.syncForCurrentSelection() + + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(externalCredentials) + expect(readRuntimeOauthAccountForTest()).toEqual(systemOauthAccount) + }) + + it('restores owned oauth metadata when only keychain proves managed ownership', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') + const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') + const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') + const externalCredentials = createClaudeCredentialsJson('external@example.com', 'external') + const systemOauthAccount = { accountUuid: 'system-account' } + writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') + writeFileSync( + runtimeConfigPath, + `${JSON.stringify({ oauthAccount: systemOauthAccount })}\n`, + 'utf-8' + ) + testState.scopedKeychainCredentials = systemCredentials + testState.legacyKeychainCredentials = systemCredentials + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + managedCredentials, + 'null\n' + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = 'account-1' + await service.syncForCurrentSelection() + + writeFileSync(runtimeCredentialsPath, externalCredentials, 'utf-8') + settings.activeClaudeManagedAccountId = null + await service.syncForCurrentSelection() + + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(externalCredentials) + expect(readRuntimeOauthAccountForTest()).toEqual(systemOauthAccount) + expect(testState.scopedKeychainCredentials).toBe(systemCredentials) + expect(testState.legacyKeychainCredentials).toBe(systemCredentials) + }) + + it('uses managed credentials as ownership baseline after restart with partial external changes', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') + const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') + const externalScopedCredentials = createClaudeCredentialsJson( + 'external@example.com', + 'external' + ) + const snapshotPath = join( + testState.userDataDir, + 'claude-runtime-auth', + 'system-default-auth.json' + ) + mkdirSync(join(testState.userDataDir, 'claude-runtime-auth'), { recursive: true }) + writeFileSync( + snapshotPath, + `${JSON.stringify({ + credentialsJson: systemCredentials, + configOauthAccount: null, + keychainCredentialsJson: systemCredentials, + scopedKeychainCredentialsJson: systemCredentials, + legacyKeychainCredentialsJson: systemCredentials, + capturedAt: Date.now() + })}\n`, + 'utf-8' + ) + writeFileSync(runtimeCredentialsPath, managedCredentials, 'utf-8') + testState.scopedKeychainCredentials = externalScopedCredentials + testState.legacyKeychainCredentials = managedCredentials + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + managedCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = null + await service.syncForCurrentSelection() + + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) + expect(testState.scopedKeychainCredentials).toBe(externalScopedCredentials) + expect(testState.legacyKeychainCredentials).toBe(systemCredentials) + }) + + it('preserves external file and scoped login while restoring unchanged legacy keychain', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') + const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') + const externalCredentials = createClaudeCredentialsJson('external@example.com', 'external') + writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') + testState.scopedKeychainCredentials = systemCredentials + testState.legacyKeychainCredentials = systemCredentials + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + managedCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = 'account-1' + await service.syncForCurrentSelection() + + writeFileSync(runtimeCredentialsPath, externalCredentials, 'utf-8') + testState.scopedKeychainCredentials = externalCredentials + settings.activeClaudeManagedAccountId = null + await service.syncForCurrentSelection() + + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(externalCredentials) + expect(testState.scopedKeychainCredentials).toBe(externalCredentials) + expect(testState.legacyKeychainCredentials).toBe(systemCredentials) + }) + + it('restores legacy keychain credentials from old system-default snapshots', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const snapshotPath = join( + testState.userDataDir, + 'claude-runtime-auth', + 'system-default-auth.json' + ) + const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') + const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') + mkdirSync(join(testState.userDataDir, 'claude-runtime-auth'), { recursive: true }) + writeFileSync( + snapshotPath, + `${JSON.stringify({ + credentialsJson: systemCredentials, + configOauthAccount: null, + keychainCredentialsJson: systemCredentials, + capturedAt: Date.now() + })}\n`, + 'utf-8' + ) + writeFileSync(runtimeCredentialsPath, managedCredentials, 'utf-8') + testState.scopedKeychainCredentials = managedCredentials + testState.legacyKeychainCredentials = managedCredentials + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + managedCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = null + await service.syncForCurrentSelection() + + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) + expect(testState.scopedKeychainCredentials).toBe(systemCredentials) + expect(testState.legacyKeychainCredentials).toBe(systemCredentials) + }) + + it('does not recapture managed file as system default after a partial keychain write failure', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') + const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') + writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') + testState.scopedKeychainCredentials = systemCredentials + testState.legacyKeychainCredentials = systemCredentials + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + managedCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = 'account-1' + testState.throwRuntimeKeychainWrite = true + await expect(service.syncForCurrentSelection()).rejects.toThrow('runtime keychain write failed') + + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) + expect(testState.scopedKeychainCredentials).toBe(systemCredentials) + testState.throwRuntimeKeychainWrite = false + await service.syncForCurrentSelection() + + settings.activeClaudeManagedAccountId = null + await service.syncForCurrentSelection() + + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) + expect(testState.scopedKeychainCredentials).toBe(systemCredentials) + expect(testState.legacyKeychainCredentials).toBe(systemCredentials) + }) + + it('restores scoped keychain after legacy runtime keychain write fails', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') + const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') + writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') + testState.scopedKeychainCredentials = systemCredentials + testState.legacyKeychainCredentials = systemCredentials + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + managedCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = 'account-1' + testState.throwLegacyRuntimeKeychainWrite = true + await expect(service.syncForCurrentSelection()).rejects.toThrow( + 'legacy runtime keychain write failed' + ) + + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) + expect(testState.scopedKeychainCredentials).toBe(systemCredentials) + expect(testState.legacyKeychainCredentials).toBe(systemCredentials) + }) + + it('keeps managed ownership baseline when keychain restore fails and retries', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') + const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') + writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') + testState.scopedKeychainCredentials = systemCredentials + testState.legacyKeychainCredentials = systemCredentials + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + managedCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = 'account-1' + await service.syncForCurrentSelection() + + settings.activeClaudeManagedAccountId = null + testState.throwScopedKeychainWrite = true + await expect(service.syncForCurrentSelection()).rejects.toThrow('scoped keychain write failed') + + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) + expect(testState.scopedKeychainCredentials).toBe(managedCredentials) + testState.throwScopedKeychainWrite = false + await service.syncForCurrentSelection() + + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) + expect(testState.scopedKeychainCredentials).toBe(systemCredentials) + expect(testState.legacyKeychainCredentials).toBe(systemCredentials) + }) + + it('preserves previous keychain snapshot after restart following partial restore failure', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') + const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') + writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') + testState.scopedKeychainCredentials = systemCredentials + testState.legacyKeychainCredentials = systemCredentials + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + managedCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + let service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = 'account-1' + await service.syncForCurrentSelection() + settings.activeClaudeManagedAccountId = null + testState.throwScopedKeychainWrite = true + await expect(service.syncForCurrentSelection()).rejects.toThrow('scoped keychain write failed') + + testState.throwScopedKeychainWrite = false + service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = 'account-1' + await service.syncForCurrentSelection() + settings.activeClaudeManagedAccountId = null + await service.syncForCurrentSelection() + + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) + expect(testState.scopedKeychainCredentials).toBe(systemCredentials) + expect(testState.legacyKeychainCredentials).toBe(systemCredentials) + }) + + it('does not enter managed mode when keychain snapshot capture fails', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') + const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') + writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') + testState.scopedKeychainCredentials = systemCredentials + testState.legacyKeychainCredentials = systemCredentials + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + managedCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] + }) + const store = createStore(settings) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = 'account-1' + testState.throwScopedKeychainRead = true + await expect(service.syncForCurrentSelection()).rejects.toThrow( + 'Cannot capture current Claude Keychain credentials' + ) + + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) + expect(testState.scopedKeychainCredentials).toBe(systemCredentials) + expect(testState.legacyKeychainCredentials).toBe(systemCredentials) + warn.mockRestore() + }) +}) diff --git a/src/main/claude-accounts/runtime-auth-service-launch-refresh.test.ts b/src/main/claude-accounts/runtime-auth-service-launch-refresh.test.ts new file mode 100644 index 000000000000..85d68d30e8b8 --- /dev/null +++ b/src/main/claude-accounts/runtime-auth-service-launch-refresh.test.ts @@ -0,0 +1,248 @@ +import { + cleanupRuntimeAuthTestState, + createClaudeAccount, + createClaudeCredentialsJson, + createElectronMock, + createKeychainMock, + createManagedClaudeAuth, + createOauthRefreshMock, + createSettings, + createStore, + readManagedCredentialsForTest, + resetRuntimeAuthTestState, + testState +} from './runtime-auth-service-test-harness' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { isOauthTokenExpiring, refreshClaudeOauthCredentials } from './oauth-refresh' + +vi.mock('electron', () => createElectronMock()) + +vi.mock('./oauth-refresh', () => createOauthRefreshMock()) + +vi.mock('node:os', async () => { + const actual = await vi.importActual('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() + return { + ...actual, + homedir: () => testState.fakeHomeDir + } +}) + +vi.mock('./keychain', () => createKeychainMock()) + +describe('ClaudeRuntimeAuthService', () => { + beforeEach(() => { + resetRuntimeAuthTestState() + }) + + afterEach(() => { + cleanupRuntimeAuthTestState() + }) + + it('does not clobber fresh Claude credentials after clearLastWrittenCredentialsJson', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const originalCredentials = createClaudeCredentialsJson('user@example.com', 'original') + const reauthedCredentials = createClaudeCredentialsJson('user@example.com', 'reauthed') + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + originalCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + testState.managedKeychainCredentials.set('account-1', reauthedCredentials) + writeFileSync(join(managedAuthPath, '.credentials.json'), reauthedCredentials, 'utf-8') + service.clearLastWrittenCredentialsJson() + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(reauthedCredentials) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(reauthedCredentials) + }) + + it('leaves host system-default credentials untouched before launch', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const expired = createClaudeCredentialsJson('system@example.com', 'system-expired', null, 1_000) + writeFileSync(runtimeCredentialsPath, expired, 'utf-8') + testState.scopedKeychainCredentials = expired + testState.legacyKeychainCredentials = expired + const settings = createSettings({ + activeClaudeManagedAccountId: null + }) + const store = createStore(settings) + + vi.mocked(isOauthTokenExpiring).mockReturnValue(true) + vi.mocked(refreshClaudeOauthCredentials).mockResolvedValue( + createClaudeCredentialsJson('system@example.com', 'system-refreshed') + ) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + const preparation = await service.prepareForClaudeLaunch() + + expect(isOauthTokenExpiring).not.toHaveBeenCalled() + expect(refreshClaudeOauthCredentials).not.toHaveBeenCalled() + expect(preparation.provenance).toBe('system') + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(expired) + expect(testState.scopedKeychainCredentials).toBe(expired) + expect(testState.legacyKeychainCredentials).toBe(expired) + + vi.mocked(isOauthTokenExpiring).mockReturnValue(false) + vi.mocked(refreshClaudeOauthCredentials).mockResolvedValue(null) + }) + + it('proactively refreshes and persists an expiring account on switch-in', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const account1Stale = createClaudeCredentialsJson('one@example.com', 'one-stale', null, 1_000) + const account1Refreshed = createClaudeCredentialsJson( + 'one@example.com', + 'one-refreshed', + null, + 9_999_999_999_999 + ) + const managedAuthPath1 = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + account1Stale + ) + // Start on the system default (no active managed account), then switch in. + const settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' }) + ], + activeClaudeManagedAccountId: null + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + // Now switch into account-1: token is expiring, so the service must refresh + // and persist the rotation before materializing. + vi.mocked(isOauthTokenExpiring).mockReturnValueOnce(true) + vi.mocked(refreshClaudeOauthCredentials).mockResolvedValueOnce(account1Refreshed) + store.updateSettings({ activeClaudeManagedAccountId: 'account-1' }) + await service.syncForCurrentSelection() + + expect(refreshClaudeOauthCredentials).toHaveBeenCalledWith(account1Stale) + expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe(account1Refreshed) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account1Refreshed) + }) + + it('refreshes the active account with an expired token when no Claude PTY is live', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const expired = createClaudeCredentialsJson('one@example.com', 'one-expired', null, 1_000) + const refreshedCreds = createClaudeCredentialsJson( + 'one@example.com', + 'one-refreshed', + null, + 9_999_999_999_999 + ) + const managedAuthPath1 = createManagedClaudeAuth(testState.userDataDir, 'account-1', expired) + // account-1 is ALREADY the active account (seeded), so this is a re-sync of + // the active account, not a switch-in — the path that was previously missed. + const settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' }) + ], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + vi.mocked(isOauthTokenExpiring).mockReturnValue(true) + vi.mocked(refreshClaudeOauthCredentials).mockResolvedValue(refreshedCreds) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + expect(refreshClaudeOauthCredentials).toHaveBeenCalled() + expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe(refreshedCreds) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(refreshedCreds) + + vi.mocked(isOauthTokenExpiring).mockReturnValue(false) + vi.mocked(refreshClaudeOauthCredentials).mockResolvedValue(null) + }) + + it('does not refresh the active account while a Claude PTY is live', async () => { + const expired = createClaudeCredentialsJson('one@example.com', 'one-expired', null, 1_000) + const managedAuthPath1 = createManagedClaudeAuth(testState.userDataDir, 'account-1', expired) + const settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' }) + ], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + vi.mocked(isOauthTokenExpiring).mockReturnValue(true) + vi.mocked(refreshClaudeOauthCredentials).mockResolvedValue( + createClaudeCredentialsJson('one@example.com', 'should-not-be-used', null, 9_999_999_999_999) + ) + + const { markClaudePtySpawned, markClaudePtyExited } = await import('./live-pty-gate') + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + + markClaudePtySpawned('pty-live-1') + try { + const preparation = await service.prepareForRateLimitFetch() + // A live Claude owns the credentials; refreshing here would race its + // rotation, so the proactive refresh must be skipped entirely. + expect(refreshClaudeOauthCredentials).not.toHaveBeenCalled() + expect(preparation.managedRefreshDeferredByLivePty).toBe(true) + } finally { + markClaudePtyExited('pty-live-1') + vi.mocked(isOauthTokenExpiring).mockReturnValue(false) + vi.mocked(refreshClaudeOauthCredentials).mockResolvedValue(null) + } + }) + + it('adopts a rotated-refresh-token runtime credential on cold-start read-back', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + // Same expiry on both sides (cold start), but the runtime refresh token has + // rotated — proof the CLI refreshed. Must be read back into managed storage. + const managedCredentials = createClaudeCredentialsJson( + 'one@example.com', + 'one-old', + null, + 3_000 + ) + const runtimeRotated = `${JSON.stringify({ + claudeAiOauth: { + email: 'one@example.com', + accessToken: 'one-rotated', + refreshToken: 'one-rotated-refresh', + expiresAt: 3_000 + } + })}\n` + writeFileSync(runtimeCredentialsPath, runtimeRotated, 'utf-8') + const managedAuthPath1 = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + managedCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' }) + ], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe(runtimeRotated) + }) +}) diff --git a/src/main/claude-accounts/runtime-auth-service-materialization.test.ts b/src/main/claude-accounts/runtime-auth-service-materialization.test.ts new file mode 100644 index 000000000000..94bdab9b4d82 --- /dev/null +++ b/src/main/claude-accounts/runtime-auth-service-materialization.test.ts @@ -0,0 +1,490 @@ +import { + cleanupRuntimeAuthTestState, + createClaudeAccount, + createClaudeCredentialsJson, + createElectronMock, + createKeychainMock, + createManagedClaudeAuth, + createOauthRefreshMock, + createSettings, + createStore, + expectedRuntimeConfigDir, + hostPlatform, + readRuntimeOauthAccountForTest, + resetRuntimeAuthTestState, + setPlatform, + testState +} from './runtime-auth-service-test-harness' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + chmodSync, + existsSync, + lstatSync, + mkdirSync, + readFileSync, + rmSync, + statSync, + symlinkSync, + writeFileSync +} from 'node:fs' +import { join } from 'node:path' +import type { GlobalSettings } from '../../shared/global-settings-types' + +vi.mock('electron', () => createElectronMock()) + +vi.mock('./oauth-refresh', () => createOauthRefreshMock()) + +vi.mock('node:os', async () => { + const actual = await vi.importActual('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() + return { + ...actual, + homedir: () => testState.fakeHomeDir + } +}) + +vi.mock('./keychain', () => createKeychainMock()) + +describe('ClaudeRuntimeAuthService', () => { + beforeEach(() => { + resetRuntimeAuthTestState() + }) + + afterEach(() => { + cleanupRuntimeAuthTestState() + }) + + it('creates and recreates the runtime directory when materializing managed credentials', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + rmSync(expectedRuntimeConfigDir(), { recursive: true, force: true }) + const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + managedCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(managedCredentials) + + rmSync(expectedRuntimeConfigDir(), { recursive: true, force: true }) + await service.prepareForClaudeLaunch() + + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(managedCredentials) + expect(testState.runtimeWriteConfigDir).toBe(expectedRuntimeConfigDir()) + }) + + it('restores system default instead of materializing corrupt managed credentials', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') + writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') + testState.scopedKeychainCredentials = systemCredentials + testState.legacyKeychainCredentials = systemCredentials + const managedAuthPath = createManagedClaudeAuth(testState.userDataDir, 'account-1', '{not-json') + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + expect(store.getSettings().activeClaudeManagedAccountId).toBeNull() + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) + expect(testState.scopedKeychainCredentials).toBe(systemCredentials) + expect(testState.legacyKeychainCredentials).toBe(systemCredentials) + }) + + it('restores system default instead of materializing wrong-shaped managed credentials', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') + writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') + testState.scopedKeychainCredentials = systemCredentials + testState.legacyKeychainCredentials = systemCredentials + const managedAuthPath = createManagedClaudeAuth(testState.userDataDir, 'account-1', '{}\n') + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + expect(store.getSettings().activeClaudeManagedAccountId).toBeNull() + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) + expect(testState.scopedKeychainCredentials).toBe(systemCredentials) + expect(testState.legacyKeychainCredentials).toBe(systemCredentials) + }) + + it('does not materialize managed credentials from unowned auth paths', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') + const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') + writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') + const unownedAuthPath = join(testState.fakeHomeDir, 'unowned-claude-auth') + mkdirSync(unownedAuthPath, { recursive: true }) + writeFileSync(join(unownedAuthPath, '.credentials.json'), managedCredentials, 'utf-8') + writeFileSync( + join(unownedAuthPath, 'oauth-account.json'), + `${JSON.stringify({ accountUuid: 'account-1' })}\n`, + 'utf-8' + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', unownedAuthPath)], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + expect(store.getSettings().activeClaudeManagedAccountId).toBeNull() + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) + }) + + it('adopts canonical legacy managed auth paths without existing markers', async () => { + setPlatform('linux') + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') + const managedAuthPath = join(testState.userDataDir, 'claude-accounts', 'account-1', 'auth') + mkdirSync(managedAuthPath, { recursive: true }) + writeFileSync(join(managedAuthPath, '.credentials.json'), managedCredentials, 'utf-8') + writeFileSync( + join(managedAuthPath, 'oauth-account.json'), + '{"accountUuid":"account-1"}\n', + 'utf-8' + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + const markerPath = join(managedAuthPath, '.orca-managed-claude-auth') + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(managedCredentials) + expect(lstatSync(markerPath).isFile()).toBe(true) + expect(readFileSync(markerPath, 'utf-8')).toBe('account-1\n') + }) + + it('rejects symlinked managed credential children', async () => { + setPlatform('linux') + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') + const escapedCredentials = createClaudeCredentialsJson('user@example.com', 'escaped') + const managedAuthPath = join(testState.userDataDir, 'claude-accounts', 'account-1', 'auth') + const escapedCredentialsPath = join(testState.fakeHomeDir, 'escaped-credentials.json') + mkdirSync(managedAuthPath, { recursive: true }) + writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') + writeFileSync( + join(managedAuthPath, 'oauth-account.json'), + '{"accountUuid":"account-1"}\n', + 'utf-8' + ) + writeFileSync(escapedCredentialsPath, escapedCredentials, 'utf-8') + symlinkSync(escapedCredentialsPath, join(managedAuthPath, '.credentials.json')) + writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + expect(store.getSettings().activeClaudeManagedAccountId).toBeNull() + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) + }) + + it('restores system auth when switching from an owned account to an unowned account', async () => { + setPlatform('linux') + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') + const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') + const ownedCredentials = createClaudeCredentialsJson('owned@example.com', 'owned') + const unownedCredentials = createClaudeCredentialsJson('unowned@example.com', 'unowned') + const systemOauthAccount = { accountUuid: 'system-account' } + writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') + writeFileSync( + runtimeConfigPath, + `${JSON.stringify({ oauthAccount: systemOauthAccount })}\n`, + 'utf-8' + ) + const ownedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + ownedCredentials + ) + const unownedAuthPath = join(testState.fakeHomeDir, 'unowned-claude-auth') + mkdirSync(unownedAuthPath, { recursive: true }) + writeFileSync(join(unownedAuthPath, '.credentials.json'), unownedCredentials, 'utf-8') + let settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('account-1', ownedAuthPath, { email: 'owned@example.com' }), + createClaudeAccount('account-2', unownedAuthPath, { email: 'unowned@example.com' }) + ], + activeClaudeManagedAccountId: null + }) + const store = { + getSettings: vi.fn(() => settings), + updateSettings: vi.fn((updates: Partial) => { + settings = { + ...settings, + ...updates, + notifications: { + ...settings.notifications, + ...updates.notifications + } + } + return settings + }) + } + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + store.updateSettings({ activeClaudeManagedAccountId: 'account-1' }) + await service.syncForCurrentSelection() + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(ownedCredentials) + + store.updateSettings({ activeClaudeManagedAccountId: 'account-2' }) + await service.syncForCurrentSelection() + + expect(store.getSettings().activeClaudeManagedAccountId).toBeNull() + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) + expect(readRuntimeOauthAccountForTest()).toEqual(systemOauthAccount) + }) + + it('restores system auth when the previously synced account is no longer in settings', async () => { + setPlatform('linux') + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') + const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') + const ownedCredentials = createClaudeCredentialsJson('owned@example.com', 'owned') + const systemOauthAccount = { accountUuid: 'system-account' } + writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') + writeFileSync( + runtimeConfigPath, + `${JSON.stringify({ oauthAccount: systemOauthAccount })}\n`, + 'utf-8' + ) + const ownedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + ownedCredentials + ) + const unownedAuthPath = join(testState.fakeHomeDir, 'unowned-claude-auth') + mkdirSync(unownedAuthPath, { recursive: true }) + let settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('account-1', ownedAuthPath, { email: 'owned@example.com' }) + ], + activeClaudeManagedAccountId: null + }) + const store = { + getSettings: vi.fn(() => settings), + updateSettings: vi.fn((updates: Partial) => { + settings = { + ...settings, + ...updates, + notifications: { + ...settings.notifications, + ...updates.notifications + } + } + return settings + }) + } + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + store.updateSettings({ activeClaudeManagedAccountId: 'account-1' }) + await service.syncForCurrentSelection() + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(ownedCredentials) + + store.updateSettings({ + claudeManagedAccounts: [ + createClaudeAccount('account-2', unownedAuthPath, { email: 'unowned@example.com' }) + ], + activeClaudeManagedAccountId: 'account-2' + }) + await service.syncForCurrentSelection() + + expect(store.getSettings().activeClaudeManagedAccountId).toBeNull() + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) + expect(readRuntimeOauthAccountForTest()).toEqual(systemOauthAccount) + }) + + it('restores system auth when switching from an owned account to missing credentials', async () => { + setPlatform('linux') + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') + const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') + const account1Credentials = createClaudeCredentialsJson('one@example.com', 'one') + const systemOauthAccount = { accountUuid: 'system-account' } + writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') + writeFileSync( + runtimeConfigPath, + `${JSON.stringify({ oauthAccount: systemOauthAccount })}\n`, + 'utf-8' + ) + const managedAuthPath1 = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + account1Credentials + ) + const managedAuthPath2 = join(testState.userDataDir, 'claude-accounts', 'account-2', 'auth') + mkdirSync(managedAuthPath2, { recursive: true }) + writeFileSync(join(managedAuthPath2, '.orca-managed-claude-auth'), 'account-2\n', 'utf-8') + writeFileSync( + join(managedAuthPath2, 'oauth-account.json'), + '{"accountUuid":"account-2"}\n', + 'utf-8' + ) + let settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' }), + createClaudeAccount('account-2', managedAuthPath2, { email: 'two@example.com' }) + ], + activeClaudeManagedAccountId: null + }) + const store = { + getSettings: vi.fn(() => settings), + updateSettings: vi.fn((updates: Partial) => { + settings = { + ...settings, + ...updates, + notifications: { + ...settings.notifications, + ...updates.notifications + } + } + return settings + }) + } + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + store.updateSettings({ activeClaudeManagedAccountId: 'account-1' }) + await service.syncForCurrentSelection() + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account1Credentials) + + store.updateSettings({ activeClaudeManagedAccountId: 'account-2' }) + await service.syncForCurrentSelection() + + expect(store.getSettings().activeClaudeManagedAccountId).toBeNull() + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) + expect(readRuntimeOauthAccountForTest()).toEqual(systemOauthAccount) + }) + + it('removes runtime credentials when deselecting with a missing system-default snapshot', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') + writeFileSync(runtimeCredentialsPath, managedCredentials, 'utf-8') + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + managedCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + + settings.activeClaudeManagedAccountId = null + await service.syncForCurrentSelection() + + expect(existsSync(runtimeCredentialsPath)).toBe(false) + if (process.platform === 'darwin') { + expect(testState.scopedKeychainCredentials).toBeNull() + expect(testState.legacyKeychainCredentials).toBeNull() + } + }) + + it('preserves unreadable runtime credentials instead of overwriting unknown connector grants', async () => { + if (hostPlatform === 'win32') { + return + } + + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') + const rotatedCredentials = createClaudeCredentialsJson('user@example.com', 'rotated') + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + managedCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = 'account-1' + await service.syncForCurrentSelection() + + testState.managedKeychainCredentials.set('account-1', rotatedCredentials) + writeFileSync(join(managedAuthPath, '.credentials.json'), rotatedCredentials, 'utf-8') + chmodSync(runtimeCredentialsPath, 0o000) + try { + await expect(service.syncForCurrentSelection()).rejects.toMatchObject({ code: 'EACCES' }) + } finally { + if (existsSync(runtimeCredentialsPath)) { + chmodSync(runtimeCredentialsPath, 0o600) + } + warn.mockRestore() + } + + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(managedCredentials) + expect(testState.scopedKeychainCredentials).toBe(managedCredentials) + }) + + it('tightens credential file permissions when unchanged content is already present', async () => { + if (hostPlatform === 'win32') { + return + } + + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + managedCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = 'account-1' + await service.syncForCurrentSelection() + chmodSync(runtimeCredentialsPath, 0o644) + await service.syncForCurrentSelection() + + expect(statSync(runtimeCredentialsPath).mode & 0o777).toBe(0o600) + }) +}) diff --git a/src/main/claude-accounts/runtime-auth-service-readback-identity.test.ts b/src/main/claude-accounts/runtime-auth-service-readback-identity.test.ts new file mode 100644 index 000000000000..2806d44e82a5 --- /dev/null +++ b/src/main/claude-accounts/runtime-auth-service-readback-identity.test.ts @@ -0,0 +1,607 @@ +import { + cleanupRuntimeAuthTestState, + createClaudeAccount, + createClaudeCredentialsJson, + createClaudeCredentialsWithoutEmail, + createElectronMock, + createKeychainMock, + createManagedClaudeAuth, + createOauthRefreshMock, + createSettings, + createStore, + readManagedCredentialsForTest, + resetRuntimeAuthTestState, + setPlatform, + testState +} from './runtime-auth-service-test-harness' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' + +vi.mock('electron', () => createElectronMock()) + +vi.mock('./oauth-refresh', () => createOauthRefreshMock()) + +vi.mock('node:os', async () => { + const actual = await vi.importActual('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() + return { + ...actual, + homedir: () => testState.fakeHomeDir + } +}) + +vi.mock('./keychain', () => createKeychainMock()) + +describe('ClaudeRuntimeAuthService', () => { + beforeEach(() => { + resetRuntimeAuthTestState() + }) + + afterEach(() => { + cleanupRuntimeAuthTestState() + }) + + it('reads back refreshed credentials when the Claude identity still matches', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const originalCredentials = createClaudeCredentialsJson('user@example.com', 'original') + const refreshedCredentials = createClaudeCredentialsJson('user@example.com', 'refreshed') + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + originalCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = 'account-1' + await service.syncForCurrentSelection() + + writeFileSync(runtimeCredentialsPath, refreshedCredentials, 'utf-8') + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(refreshedCredentials) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(refreshedCredentials) + }) + + it('rejects wrong-shaped refreshed credentials during read-back', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const originalCredentials = createClaudeCredentialsJson('user@example.com', 'original') + const malformedRefresh = `${JSON.stringify({ + claudeAiOauth: { + email: 'user@example.com', + expiresAt: Date.now() + 120_000 + } + })}\n` + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + originalCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = 'account-1' + await service.syncForCurrentSelection() + + writeFileSync(runtimeCredentialsPath, malformedRefresh, 'utf-8') + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(originalCredentials) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(originalCredentials) + }) + + it('reads back verified same-account credentials on first sync after restart', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const originalCredentials = createClaudeCredentialsJson( + 'user@example.com', + 'original', + null, + 1_000 + ) + const refreshedCredentials = createClaudeCredentialsJson( + 'user@example.com', + 'refreshed', + null, + 2_000 + ) + writeFileSync(runtimeCredentialsPath, refreshedCredentials, 'utf-8') + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + originalCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(refreshedCredentials) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(refreshedCredentials) + }) + + it('rejects older same-account Claude credentials on first sync after restart', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const staleRuntimeCredentials = createClaudeCredentialsJson( + 'user@example.com', + 'stale', + null, + 1_000 + ) + const managedCredentials = createClaudeCredentialsJson( + 'user@example.com', + 'managed-newer', + null, + 2_000 + ) + writeFileSync(runtimeCredentialsPath, staleRuntimeCredentials, 'utf-8') + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + managedCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(managedCredentials) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(managedCredentials) + }) + + it('rejects runtime read-back from a different Claude identity', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const selectedCredentials = createClaudeCredentialsJson('user@example.com', 'selected') + const staleCredentials = createClaudeCredentialsJson('other@example.com', 'stale') + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + selectedCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = 'account-1' + await service.syncForCurrentSelection() + + writeFileSync(runtimeCredentialsPath, staleCredentials, 'utf-8') + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(selectedCredentials) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(selectedCredentials) + }) + + it('rejects runtime read-back from the same Claude email in a different organization', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const selectedCredentials = createClaudeCredentialsJson('user@example.com', 'selected', 'org-b') + const staleCredentials = createClaudeCredentialsJson('user@example.com', 'stale', 'org-a') + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + selectedCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('account-1', managedAuthPath, { organizationUuid: 'org-b' }) + ], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + writeFileSync(runtimeCredentialsPath, staleCredentials, 'utf-8') + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(selectedCredentials) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(selectedCredentials) + }) + + it('rejects same-email Claude read-back using stored managed organization identity', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const selectedCredentials = createClaudeCredentialsJson('user@example.com', 'selected', 'org-b') + const staleCredentials = createClaudeCredentialsJson('user@example.com', 'stale', 'org-a') + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + selectedCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = 'account-1' + await service.syncForCurrentSelection() + + writeFileSync(runtimeCredentialsPath, staleCredentials, 'utf-8') + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(selectedCredentials) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(selectedCredentials) + }) + + it('rejects same-email Claude read-back using stored oauth-account organization identity', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const selectedCredentials = createClaudeCredentialsJson('user@example.com', 'selected') + const staleCredentials = createClaudeCredentialsJson('user@example.com', 'stale', 'org-a') + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + selectedCredentials, + '{"organizationUuid":"org-b"}\n' + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + writeFileSync(runtimeCredentialsPath, staleCredentials, 'utf-8') + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(selectedCredentials) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(selectedCredentials) + }) + + it('rejects no-email Claude read-back when organization identity conflicts', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const selectedCredentials = createClaudeCredentialsJson('user@example.com', 'selected', 'org-b') + const staleCredentials = createClaudeCredentialsWithoutEmail('stale', 'org-a') + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + selectedCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('account-1', managedAuthPath, { organizationUuid: 'org-b' }) + ], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + writeFileSync(runtimeCredentialsPath, staleCredentials, 'utf-8') + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(selectedCredentials) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(selectedCredentials) + }) + + it('rejects no-email refreshed credentials even when organization identity matches', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const originalCredentials = createClaudeCredentialsJson('user@example.com', 'original', 'org-a') + const refreshedCredentials = createClaudeCredentialsWithoutEmail('refreshed', 'org-a') + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + originalCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('account-1', managedAuthPath, { organizationUuid: 'org-a' }) + ], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + writeFileSync(runtimeCredentialsPath, refreshedCredentials, 'utf-8') + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(originalCredentials) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(originalCredentials) + }) + + it('preserves rejected runtime refreshes while a Claude terminal is live on Windows', async () => { + setPlatform('win32') + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const originalCredentials = createClaudeCredentialsJson('user@example.com', 'original', 'org-a') + const refreshedCredentials = createClaudeCredentialsWithoutEmail('refreshed', 'org-a') + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + originalCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('account-1', managedAuthPath, { organizationUuid: 'org-a' }) + ], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const { markClaudePtyExited, markClaudePtySpawned } = await import('./live-pty-gate') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + markClaudePtySpawned('live-claude-pty') + try { + writeFileSync(runtimeCredentialsPath, refreshedCredentials, 'utf-8') + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(refreshedCredentials) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(refreshedCredentials) + } finally { + markClaudePtyExited('live-claude-pty') + } + + await service.syncForCurrentSelection() + + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(refreshedCredentials) + }) + + it('does not persist live runtime refreshes with conflicting organization identity', async () => { + setPlatform('win32') + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const originalCredentials = createClaudeCredentialsJson('user@example.com', 'original', 'org-a') + const conflictingCredentials = createClaudeCredentialsWithoutEmail('refreshed', 'org-b') + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + originalCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('account-1', managedAuthPath, { organizationUuid: 'org-a' }) + ], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const { markClaudePtyExited, markClaudePtySpawned } = await import('./live-pty-gate') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + markClaudePtySpawned('live-claude-pty') + try { + writeFileSync(runtimeCredentialsPath, conflictingCredentials, 'utf-8') + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(originalCredentials) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(conflictingCredentials) + } finally { + markClaudePtyExited('live-claude-pty') + } + }) + + it('rematerializes managed credentials over a wiped runtime blob while a Claude terminal is live', async () => { + setPlatform('win32') + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const originalCredentials = createClaudeCredentialsJson('user@example.com', 'original', 'org-a') + // Why: Claude CLI wipes tokens in place (keeps identity fields) after an + // invalid_grant refresh — the exact blob shape this regression guards. + const parsedOriginal = JSON.parse(originalCredentials) as { + claudeAiOauth: Record + } + const wipedCredentials = `${JSON.stringify({ + claudeAiOauth: { + ...parsedOriginal.claudeAiOauth, + accessToken: '', + refreshToken: '', + expiresAt: 0 + } + })}\n` + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + originalCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('account-1', managedAuthPath, { organizationUuid: 'org-a' }) + ], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const { markClaudePtyExited, markClaudePtySpawned } = await import('./live-pty-gate') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + markClaudePtySpawned('live-claude-pty') + try { + writeFileSync(runtimeCredentialsPath, wipedCredentials, 'utf-8') + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(originalCredentials) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(originalCredentials) + } finally { + markClaudePtyExited('live-claude-pty') + } + }) + + it('rejects unverifiable refreshed runtime credentials', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const originalCredentials = createClaudeCredentialsWithoutEmail('original') + const refreshedCredentials = createClaudeCredentialsWithoutEmail('refreshed') + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + originalCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + writeFileSync(runtimeCredentialsPath, refreshedCredentials, 'utf-8') + await service.syncForCurrentSelection() + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(originalCredentials) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(originalCredentials) + }) + + it('reads back identity-less refreshed credentials when the refresh token matches', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const refreshToken = 'same-managed-refresh-token' + const originalCredentials = createClaudeCredentialsWithoutEmail('original', null, { + expiresAt: 1_000, + refreshToken + }) + const refreshedCredentials = createClaudeCredentialsWithoutEmail('refreshed', null, { + expiresAt: 2_000, + refreshToken + }) + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + originalCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('account-1', managedAuthPath, { organizationUuid: 'org-from-account' }) + ], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + writeFileSync(runtimeCredentialsPath, refreshedCredentials, 'utf-8') + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(refreshedCredentials) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(refreshedCredentials) + }) + + it('reads back identity-less refreshed credentials when runtime oauth metadata matches', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') + const originalCredentials = createClaudeCredentialsJson( + 'user@example.com', + 'original', + 'org-a', + 1_000 + ) + const refreshedCredentials = createClaudeCredentialsWithoutEmail('refreshed', null, { + expiresAt: 2_000, + refreshToken: 'rotated-refresh-token' + }) + writeFileSync(runtimeConfigPath, '{}\n', 'utf-8') + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + originalCredentials, + '{"accountUuid":"account-uuid-1","emailAddress":"user@example.com","organizationUuid":"org-a"}\n' + ) + const settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('account-1', managedAuthPath, { + email: 'user@example.com', + organizationUuid: 'org-a' + }) + ], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + writeFileSync(runtimeCredentialsPath, refreshedCredentials, 'utf-8') + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(refreshedCredentials) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(refreshedCredentials) + }) + + it('rules out other identity-less accounts with different refresh tokens', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const account1RefreshToken = 'account-1-refresh-token' + const account1OriginalCredentials = createClaudeCredentialsWithoutEmail('account-1', null, { + expiresAt: 1_000, + refreshToken: account1RefreshToken + }) + const account1RefreshedCredentials = createClaudeCredentialsWithoutEmail( + 'account-1-refreshed', + null, + { + expiresAt: 2_000, + refreshToken: account1RefreshToken + } + ) + const account2Credentials = createClaudeCredentialsWithoutEmail('account-2', null, { + refreshToken: 'account-2-refresh-token' + }) + const managedAuthPath1 = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + account1OriginalCredentials + ) + const managedAuthPath2 = createManagedClaudeAuth( + testState.userDataDir, + 'account-2', + account2Credentials + ) + const settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('account-1', managedAuthPath1), + createClaudeAccount('account-2', managedAuthPath2, { email: 'other@example.com' }) + ], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + writeFileSync(runtimeCredentialsPath, account1RefreshedCredentials, 'utf-8') + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe( + account1RefreshedCredentials + ) + expect(readManagedCredentialsForTest('account-2', managedAuthPath2)).toBe(account2Credentials) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account1RefreshedCredentials) + }) +}) diff --git a/src/main/claude-accounts/runtime-auth-service-shared-credential-failures.test.ts b/src/main/claude-accounts/runtime-auth-service-shared-credential-failures.test.ts new file mode 100644 index 000000000000..4066063c5625 --- /dev/null +++ b/src/main/claude-accounts/runtime-auth-service-shared-credential-failures.test.ts @@ -0,0 +1,85 @@ +import { + cleanupRuntimeAuthTestState, + createElectronMock, + createKeychainMock, + createOauthRefreshMock, + resetRuntimeAuthTestState, + testState +} from './runtime-auth-service-test-harness' +import { + createSharedCredentialRuntime, + sharedFields, + withSharedFields +} from './runtime-auth-shared-credentials-fixture' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { readFileSync, writeFileSync } from 'node:fs' + +vi.mock('electron', () => createElectronMock()) +vi.mock('./oauth-refresh', () => createOauthRefreshMock()) +vi.mock('./keychain', () => createKeychainMock()) +vi.mock('node:os', async () => { + const actual = await vi.importActual('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() + return { ...actual, homedir: () => testState.fakeHomeDir } +}) + +describe('shared connector credential write failures', () => { + beforeEach(resetRuntimeAuthTestState) + afterEach(cleanupRuntimeAuthTestState) + + it('keeps the committed baseline across a partial rollback so a rotated grant can be retried', async () => { + const { service, settings, runtimePath, first } = await createSharedCredentialRuntime() + settings.activeClaudeManagedAccountId = 'first' + await service.syncForCurrentSelection() + const rotated = { + ...sharedFields, + mcpOAuth: { figma: { accessToken: 'new-access', refreshToken: 'new-refresh' } } + } + testState.scopedKeychainCredentials = withSharedFields(first, rotated) + settings.activeClaudeManagedAccountId = 'second' + testState.throwLegacyRuntimeKeychainWrite = true + await expect(service.syncForCurrentSelection()).rejects.toThrow( + 'legacy runtime keychain write failed' + ) + expect(JSON.parse(readFileSync(runtimePath, 'utf-8'))).toMatchObject(rotated) + testState.throwLegacyRuntimeKeychainWrite = false + settings.activeClaudeManagedAccountId = 'first' + await service.forceMaterializeCurrentSelectionForRollback() + expect(JSON.parse(readFileSync(runtimePath, 'utf-8'))).toMatchObject(rotated) + expect(JSON.parse(readFileSync(runtimePath, 'utf-8')).claudeAiOauth.accessToken).toBe('first') + expect(testState.scopedKeychainCredentials).toBe(readFileSync(runtimePath, 'utf-8')) + expect(testState.legacyKeychainCredentials).toBe(testState.scopedKeychainCredentials) + }) + + it('preserves disjoint live grants when the first switch fails after writing only the scoped item', async () => { + const { service, settings, runtimePath, system } = await createSharedCredentialRuntime() + const figma = sharedFields.mcpOAuth.figma + testState.scopedKeychainCredentials = withSharedFields(system, { + ...sharedFields, + mcpOAuth: { figma } + }) + testState.legacyKeychainCredentials = withSharedFields(system, { + ...sharedFields, + mcpOAuth: { notion: { accessToken: 'notion-access', refreshToken: 'notion-refresh' } } + }) + writeFileSync(runtimePath, system) + settings.activeClaudeManagedAccountId = 'first' + testState.throwLegacyRuntimeKeychainWrite = true + await expect(service.syncForCurrentSelection()).rejects.toThrow( + 'legacy runtime keychain write failed' + ) + expect(JSON.parse(readFileSync(runtimePath, 'utf-8')).mcpOAuth).toEqual({ + figma, + notion: { accessToken: 'notion-access', refreshToken: 'notion-refresh' } + }) + testState.throwLegacyRuntimeKeychainWrite = false + await service.syncForCurrentSelection() + const runtime = JSON.parse(readFileSync(runtimePath, 'utf-8')) + expect(runtime.mcpOAuth).toEqual({ + figma, + notion: { accessToken: 'notion-access', refreshToken: 'notion-refresh' } + }) + expect(runtime.claudeAiOauth.accessToken).toBe('first') + expect(testState.scopedKeychainCredentials).toBe(readFileSync(runtimePath, 'utf-8')) + expect(testState.legacyKeychainCredentials).toBe(testState.scopedKeychainCredentials) + }) +}) diff --git a/src/main/claude-accounts/runtime-auth-service-shared-credentials.test.ts b/src/main/claude-accounts/runtime-auth-service-shared-credentials.test.ts new file mode 100644 index 000000000000..f3bb1f001378 --- /dev/null +++ b/src/main/claude-accounts/runtime-auth-service-shared-credentials.test.ts @@ -0,0 +1,292 @@ +import { + cleanupRuntimeAuthTestState, + createClaudeCredentialsJson, + createElectronMock, + createKeychainMock, + createOauthRefreshMock, + createStore, + readManagedCredentialsForTest, + resetRuntimeAuthTestState, + testState +} from './runtime-auth-service-test-harness' +import { + createSharedCredentialRuntime, + sharedFields, + withSharedFields +} from './runtime-auth-shared-credentials-fixture' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' + +vi.mock('electron', () => createElectronMock()) +vi.mock('./oauth-refresh', () => createOauthRefreshMock()) +vi.mock('./keychain', () => createKeychainMock()) +vi.mock('node:os', async () => { + const actual = await vi.importActual('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() + return { ...actual, homedir: () => testState.fakeHomeDir } +}) + +describe('shared Claude connector credentials', () => { + beforeEach(resetRuntimeAuthTestState) + afterEach(cleanupRuntimeAuthTestState) + + it.each(['scoped', 'legacy', 'file'] as const)( + 'preserves connector grants stored only in %s when there is no previous Orca write', + async (surface) => { + const { service, settings, runtimePath, system } = await createSharedCredentialRuntime() + testState.scopedKeychainCredentials = system + testState.legacyKeychainCredentials = system + writeFileSync(runtimePath, system) + if (surface === 'scoped') { + testState.scopedKeychainCredentials = withSharedFields(system) + } else if (surface === 'legacy') { + testState.legacyKeychainCredentials = withSharedFields(system) + } else { + writeFileSync(runtimePath, withSharedFields(system)) + } + settings.activeClaudeManagedAccountId = 'first' + await service.syncForCurrentSelection() + expect(JSON.parse(readFileSync(runtimePath, 'utf-8'))).toMatchObject(sharedFields) + } + ) + + it.each(['darwin', 'linux', 'win32'] as const)( + 'excludes connector secrets when capturing a managed account on %s', + async (platform) => { + const { firstPath, first } = await createSharedCredentialRuntime(platform) + const { ClaudeManagedAuthStorage } = await import('./claude-managed-auth-storage') + await new ClaudeManagedAuthStorage().writeCredentials( + 'first', + firstPath, + withSharedFields(first) + ) + expect(JSON.parse(readManagedCredentialsForTest('first', firstPath) ?? '')).toEqual( + JSON.parse(first) + ) + } + ) + + it.each(['scoped', 'legacy', 'file'] as const)( + 'propagates connector revocations from the %s surface and does not resurrect frozen account grants', + async (surface) => { + const { service, settings, runtimePath, first, secondPath, second } = + await createSharedCredentialRuntime() + settings.activeClaudeManagedAccountId = 'first' + await service.syncForCurrentSelection() + if (surface === 'scoped') { + testState.scopedKeychainCredentials = first + } else if (surface === 'legacy') { + testState.legacyKeychainCredentials = first + } else { + writeFileSync(runtimePath, first) + } + testState.managedKeychainCredentials.set('second', withSharedFields(second)) + writeFileSync(join(secondPath, '.credentials.json'), withSharedFields(second)) + settings.activeClaudeManagedAccountId = 'second' + await service.syncForCurrentSelection() + expect(JSON.parse(readFileSync(runtimePath, 'utf-8'))).toEqual(JSON.parse(second)) + expect(JSON.parse(testState.scopedKeychainCredentials ?? '')).toEqual(JSON.parse(second)) + expect(JSON.parse(testState.legacyKeychainCredentials ?? '')).toEqual(JSON.parse(second)) + } + ) + + it('preserves grants refreshed only in the keychain when returning to the system default', async () => { + const { service, settings, runtimePath, first, system } = await createSharedCredentialRuntime() + settings.activeClaudeManagedAccountId = 'first' + await service.syncForCurrentSelection() + const rotated = { + ...sharedFields, + mcpOAuth: { figma: { accessToken: 'rotated', refreshToken: 'rotated' } } + } + testState.legacyKeychainCredentials = withSharedFields(first, rotated) + settings.activeClaudeManagedAccountId = null + await service.syncForCurrentSelection() + expect(JSON.parse(readFileSync(runtimePath, 'utf-8'))).toMatchObject(rotated) + expect(JSON.parse(testState.scopedKeychainCredentials ?? '')).toMatchObject(rotated) + expect(JSON.parse(testState.legacyKeychainCredentials ?? '')).toMatchObject(rotated) + const nextRotation = { + ...sharedFields, + mcpOAuth: { figma: { accessToken: 'rotated-again', refreshToken: 'rotated-again' } } + } + testState.scopedKeychainCredentials = withSharedFields(system, nextRotation) + settings.activeClaudeManagedAccountId = 'first' + await service.syncForCurrentSelection() + expect(JSON.parse(readFileSync(runtimePath, 'utf-8'))).toMatchObject(nextRotation) + expect(JSON.parse(testState.legacyKeychainCredentials ?? '')).toMatchObject(nextRotation) + }) + + it.each(['darwin', 'linux', 'win32'] as const)( + 'keeps connector grants through account switches, syncs, restart, and deselect on %s', + async (platform) => { + const state = await createSharedCredentialRuntime(platform) + const { service, settings, runtimePath, first, second, firstPath, secondPath } = state + for (const id of ['first', 'second', 'first']) { + settings.activeClaudeManagedAccountId = id + await service.syncForCurrentSelection() + await service.syncForCurrentSelection() + const runtime = JSON.parse(readFileSync(runtimePath, 'utf-8')) + expect(runtime).toMatchObject(sharedFields) + expect(runtime.claudeAiOauth.accessToken).toBe(id) + if (platform === 'darwin') { + expect(testState.scopedKeychainCredentials).toBe(readFileSync(runtimePath, 'utf-8')) + expect(testState.legacyKeychainCredentials).toBe(testState.scopedKeychainCredentials) + } + } + expect(JSON.parse(readManagedCredentialsForTest('first', firstPath) ?? '')).toEqual( + JSON.parse(first) + ) + expect(JSON.parse(readManagedCredentialsForTest('second', secondPath) ?? '')).toEqual( + JSON.parse(second) + ) + const rotated = { + ...sharedFields, + mcpOAuth: { figma: { accessToken: 'rotated-access', refreshToken: 'rotated-refresh' } } + } + const live = withSharedFields(first, rotated) + writeFileSync(runtimePath, live) + testState.scopedKeychainCredentials = live + testState.legacyKeychainCredentials = live + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime auth uses only getSettings/updateSettings from this store mock. + const restarted = new ClaudeRuntimeAuthService(createStore(settings) as never) + await restarted.syncForCurrentSelection() + settings.activeClaudeManagedAccountId = null + await restarted.syncForCurrentSelection() + const restored = JSON.parse(readFileSync(runtimePath, 'utf-8')) + expect(restored).toMatchObject(rotated) + expect(restored.claudeAiOauth.accessToken).toBe('system') + if (platform === 'darwin') { + expect(JSON.parse(testState.scopedKeychainCredentials ?? '')).toMatchObject(rotated) + expect(JSON.parse(testState.legacyKeychainCredentials ?? '')).toMatchObject(rotated) + } + } + ) + + it.each(['scoped', 'legacy', 'file'] as const)( + 'preserves MCP rotations written only to the %s surface while adopting a Claude refresh', + async (surface) => { + const { service, settings, runtimePath, firstPath } = await createSharedCredentialRuntime() + settings.activeClaudeManagedAccountId = 'first' + await service.syncForCurrentSelection() + const refreshed = createClaudeCredentialsJson( + 'first@example.com', + 'refreshed', + null, + Date.now() + 120_000 + ) + const rotated = { + ...sharedFields, + mcpOAuth: { figma: { accessToken: 'new-access', refreshToken: 'new-refresh' } } + } + const live = withSharedFields(refreshed, rotated) + if (surface === 'scoped') { + testState.scopedKeychainCredentials = live + } + if (surface === 'legacy') { + testState.legacyKeychainCredentials = live + } + if (surface === 'file') { + writeFileSync(runtimePath, live) + } + await service.syncForCurrentSelection() + expect(JSON.parse(readFileSync(runtimePath, 'utf-8'))).toMatchObject(rotated) + expect(JSON.parse(readManagedCredentialsForTest('first', firstPath) ?? '')).toEqual( + JSON.parse(refreshed) + ) + } + ) + + it('preserves conflicting MCP grants after restart even when the Claude account token is newer', async () => { + const { service, settings, runtimePath, firstPath } = await createSharedCredentialRuntime() + settings.activeClaudeManagedAccountId = 'first' + await service.syncForCurrentSelection() + const refreshed = createClaudeCredentialsJson( + 'first@example.com', + 'refreshed', + null, + Date.now() + 120_000 + ) + const rotated = { + ...sharedFields, + mcpOAuth: { figma: { accessToken: 'new-access', refreshToken: 'new-refresh' } } + } + testState.legacyKeychainCredentials = withSharedFields(refreshed, rotated) + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime auth uses only getSettings/updateSettings from this store mock. + const restarted = new ClaudeRuntimeAuthService(createStore(settings) as never) + await expect(restarted.syncForCurrentSelection()).rejects.toThrow( + 'live connector credentials conflict' + ) + expect(JSON.parse(readFileSync(runtimePath, 'utf-8'))).toMatchObject(sharedFields) + expect(JSON.parse(testState.scopedKeychainCredentials ?? '')).toMatchObject(sharedFields) + expect(JSON.parse(testState.legacyKeychainCredentials ?? '')).toMatchObject(rotated) + expect(JSON.parse(readManagedCredentialsForTest('first', firstPath) ?? '')).toEqual( + JSON.parse(refreshed) + ) + }) + + it('leaves all credentials untouched when the active keychain cannot be read', async () => { + const { service, settings, runtimePath } = await createSharedCredentialRuntime() + settings.activeClaudeManagedAccountId = 'first' + await service.syncForCurrentSelection() + const before = readFileSync(runtimePath, 'utf-8') + settings.activeClaudeManagedAccountId = 'second' + testState.throwScopedKeychainRead = true + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + await expect(service.syncForCurrentSelection()).rejects.toThrow('scoped keychain read failed') + expect(readFileSync(runtimePath, 'utf-8')).toBe(before) + expect(testState.scopedKeychainCredentials).toBe(before) + expect(testState.legacyKeychainCredentials).toBe(before) + testState.throwScopedKeychainRead = false + await service.syncForCurrentSelection() + expect(JSON.parse(readFileSync(runtimePath, 'utf-8'))).toMatchObject(sharedFields) + expect(JSON.parse(readFileSync(runtimePath, 'utf-8')).claudeAiOauth.accessToken).toBe('second') + warn.mockRestore() + }) + + it.each(['scoped', 'legacy', 'file'] as const)( + 'refuses to overwrite malformed live credentials in the %s surface', + async (surface) => { + const { service, settings, runtimePath } = await createSharedCredentialRuntime() + settings.activeClaudeManagedAccountId = 'first' + await service.syncForCurrentSelection() + if (surface === 'scoped') { + testState.scopedKeychainCredentials = '{broken' + } else if (surface === 'legacy') { + testState.legacyKeychainCredentials = '{broken' + } else { + writeFileSync(runtimePath, '{broken') + } + const fileBefore = readFileSync(runtimePath, 'utf-8') + const scopedBefore = testState.scopedKeychainCredentials + const legacyBefore = testState.legacyKeychainCredentials + settings.activeClaudeManagedAccountId = 'second' + await expect(service.syncForCurrentSelection()).rejects.toThrow( + 'Cannot preserve malformed Claude runtime credentials' + ) + expect(readFileSync(runtimePath, 'utf-8')).toBe(fileBefore) + expect(testState.scopedKeychainCredentials).toBe(scopedBefore) + expect(testState.legacyKeychainCredentials).toBe(legacyBefore) + } + ) + + it('keeps newly authorized MCP grants when returning to a signed-out system default', async () => { + const { service, settings, runtimePath, first } = await createSharedCredentialRuntime() + // A missing system credential is a signed-out default, with no connector grants yet. + rmSync(runtimePath) + testState.scopedKeychainCredentials = null + testState.legacyKeychainCredentials = null + settings.activeClaudeManagedAccountId = 'first' + await service.syncForCurrentSelection() + const live = withSharedFields(first) + writeFileSync(runtimePath, live) + testState.scopedKeychainCredentials = live + testState.legacyKeychainCredentials = live + settings.activeClaudeManagedAccountId = null + await service.syncForCurrentSelection() + expect(existsSync(runtimePath)).toBe(true) + expect(JSON.parse(readFileSync(runtimePath, 'utf-8'))).toEqual(sharedFields) + expect(JSON.parse(testState.scopedKeychainCredentials ?? '')).toEqual(sharedFields) + expect(JSON.parse(testState.legacyKeychainCredentials ?? '')).toEqual(sharedFields) + }) +}) diff --git a/src/main/claude-accounts/runtime-auth-service-snapshot-validation.test.ts b/src/main/claude-accounts/runtime-auth-service-snapshot-validation.test.ts new file mode 100644 index 000000000000..1df2430b8f6a --- /dev/null +++ b/src/main/claude-accounts/runtime-auth-service-snapshot-validation.test.ts @@ -0,0 +1,550 @@ +import { + cleanupRuntimeAuthTestState, + createClaudeAccount, + createClaudeCredentialsJson, + createElectronMock, + createKeychainMock, + createManagedClaudeAuth, + createOauthRefreshMock, + createSettings, + createStore, + readRuntimeOauthAccountForTest, + resetRuntimeAuthTestState, + testState +} from './runtime-auth-service-test-harness' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' + +vi.mock('electron', () => createElectronMock()) + +vi.mock('./oauth-refresh', () => createOauthRefreshMock()) + +vi.mock('node:os', async () => { + const actual = await vi.importActual('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() + return { + ...actual, + homedir: () => testState.fakeHomeDir + } +}) + +vi.mock('./keychain', () => createKeychainMock()) + +describe('ClaudeRuntimeAuthService', () => { + beforeEach(() => { + resetRuntimeAuthTestState() + }) + + afterEach(() => { + cleanupRuntimeAuthTestState() + }) + + it('treats corrupt system-default snapshots as missing and clears owned runtime auth', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') + const snapshotPath = join( + testState.userDataDir, + 'claude-runtime-auth', + 'system-default-auth.json' + ) + const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') + mkdirSync(join(testState.userDataDir, 'claude-runtime-auth'), { recursive: true }) + writeFileSync(snapshotPath, '{not-json', 'utf-8') + writeFileSync(runtimeCredentialsPath, managedCredentials, 'utf-8') + writeFileSync( + runtimeConfigPath, + `${JSON.stringify({ oauthAccount: { accountUuid: 'account-1' } })}\n`, + 'utf-8' + ) + testState.scopedKeychainCredentials = managedCredentials + testState.legacyKeychainCredentials = managedCredentials + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + managedCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = null + await service.syncForCurrentSelection() + + expect(existsSync(snapshotPath)).toBe(false) + expect(existsSync(runtimeCredentialsPath)).toBe(false) + expect(readRuntimeOauthAccountForTest()).toBeNull() + expect(testState.scopedKeychainCredentials).toBeNull() + expect(testState.legacyKeychainCredentials).toBeNull() + warn.mockRestore() + }) + + it('treats wrong-shaped system-default snapshots as missing and clears owned runtime auth', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') + const snapshotPath = join( + testState.userDataDir, + 'claude-runtime-auth', + 'system-default-auth.json' + ) + const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') + mkdirSync(join(testState.userDataDir, 'claude-runtime-auth'), { recursive: true }) + writeFileSync( + snapshotPath, + `${JSON.stringify({ + credentialsJson: { token: 'system' }, + keychainCredentialsJson: managedCredentials, + scopedKeychainCredentialsJson: { token: 'scoped' }, + legacyKeychainCredentialsJson: managedCredentials, + capturedAt: Date.now() + })}\n`, + 'utf-8' + ) + writeFileSync(runtimeCredentialsPath, managedCredentials, 'utf-8') + writeFileSync( + runtimeConfigPath, + `${JSON.stringify({ oauthAccount: { accountUuid: 'account-1' } })}\n`, + 'utf-8' + ) + testState.scopedKeychainCredentials = managedCredentials + testState.legacyKeychainCredentials = managedCredentials + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + managedCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = null + await service.syncForCurrentSelection() + + expect(existsSync(snapshotPath)).toBe(false) + expect(existsSync(runtimeCredentialsPath)).toBe(false) + expect(readRuntimeOauthAccountForTest()).toBeNull() + expect(testState.scopedKeychainCredentials).toBeNull() + expect(testState.legacyKeychainCredentials).toBeNull() + warn.mockRestore() + }) + + it('treats snapshots missing all keychain credential fields as invalid', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const snapshotPath = join( + testState.userDataDir, + 'claude-runtime-auth', + 'system-default-auth.json' + ) + const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') + mkdirSync(join(testState.userDataDir, 'claude-runtime-auth'), { recursive: true }) + writeFileSync( + snapshotPath, + `${JSON.stringify({ + credentialsJson: null, + configOauthAccount: null, + capturedAt: Date.now() + })}\n`, + 'utf-8' + ) + writeFileSync(runtimeCredentialsPath, managedCredentials, 'utf-8') + testState.scopedKeychainCredentials = managedCredentials + testState.legacyKeychainCredentials = managedCredentials + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + managedCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = null + await service.syncForCurrentSelection() + + expect(existsSync(snapshotPath)).toBe(false) + expect(existsSync(runtimeCredentialsPath)).toBe(false) + expect(testState.scopedKeychainCredentials).toBeNull() + expect(testState.legacyKeychainCredentials).toBeNull() + warn.mockRestore() + }) + + it('treats snapshots missing credentialsJson as invalid and clears missing-managed runtime auth', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') + const snapshotPath = join( + testState.userDataDir, + 'claude-runtime-auth', + 'system-default-auth.json' + ) + const staleManagedCredentials = createClaudeCredentialsJson('managed@example.com', 'managed') + const managedAuthPath = join(testState.userDataDir, 'claude-accounts', 'account-1', 'auth') + mkdirSync(managedAuthPath, { recursive: true }) + writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') + mkdirSync(join(testState.userDataDir, 'claude-runtime-auth'), { recursive: true }) + writeFileSync( + snapshotPath, + `${JSON.stringify({ + configOauthAccount: null, + keychainCredentialsJson: null, + capturedAt: Date.now() + })}\n`, + 'utf-8' + ) + writeFileSync(runtimeCredentialsPath, staleManagedCredentials, 'utf-8') + writeFileSync( + runtimeConfigPath, + `${JSON.stringify({ oauthAccount: { accountUuid: 'account-1' } })}\n`, + 'utf-8' + ) + testState.scopedKeychainCredentials = staleManagedCredentials + testState.legacyKeychainCredentials = staleManagedCredentials + const settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('account-1', managedAuthPath, { email: 'managed@example.com' }) + ], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.prepareForClaudeLaunch() + + expect(existsSync(snapshotPath)).toBe(true) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(staleManagedCredentials) + expect(readRuntimeOauthAccountForTest()).toEqual({ accountUuid: 'account-1' }) + expect(testState.scopedKeychainCredentials).toBe(staleManagedCredentials) + expect(testState.legacyKeychainCredentials).toBe(staleManagedCredentials) + warn.mockRestore() + }) + + it('clears missing-managed oauth metadata when only keychain proves ownership', async () => { + const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') + const staleManagedCredentials = createClaudeCredentialsJson('managed@example.com', 'managed') + const managedAuthPath = join(testState.userDataDir, 'claude-accounts', 'account-1', 'auth') + mkdirSync(managedAuthPath, { recursive: true }) + writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') + writeFileSync( + runtimeConfigPath, + `${JSON.stringify({ oauthAccount: { accountUuid: 'account-1' } })}\n`, + 'utf-8' + ) + testState.scopedKeychainCredentials = staleManagedCredentials + testState.legacyKeychainCredentials = staleManagedCredentials + const settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('account-1', managedAuthPath, { email: 'managed@example.com' }) + ], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.prepareForClaudeLaunch() + + expect(readRuntimeOauthAccountForTest()).toEqual({ accountUuid: 'account-1' }) + expect(testState.scopedKeychainCredentials).toBe(staleManagedCredentials) + expect(testState.legacyKeychainCredentials).toBe(staleManagedCredentials) + }) + + it('preserves missing-managed oauth metadata without credential ownership proof', async () => { + const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') + const externalCredentials = createClaudeCredentialsJson('external@example.com', 'external') + const managedAuthPath = join(testState.userDataDir, 'claude-accounts', 'account-1', 'auth') + const managedOauthAccount = { accountUuid: 'account-1' } + mkdirSync(managedAuthPath, { recursive: true }) + writeFileSync( + runtimeConfigPath, + `${JSON.stringify({ oauthAccount: managedOauthAccount })}\n`, + 'utf-8' + ) + testState.scopedKeychainCredentials = externalCredentials + testState.legacyKeychainCredentials = externalCredentials + const settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('account-1', managedAuthPath, { email: 'managed@example.com' }) + ], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.prepareForClaudeLaunch() + + expect(readRuntimeOauthAccountForTest()).toEqual(managedOauthAccount) + expect(testState.scopedKeychainCredentials).toBe(externalCredentials) + expect(testState.legacyKeychainCredentials).toBe(externalCredentials) + }) + + it('preserves invalid external runtime oauth metadata when deselecting', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') + const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') + const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') + writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') + writeFileSync(runtimeConfigPath, `${JSON.stringify({})}\n`, 'utf-8') + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + managedCredentials, + 'null\n' + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = 'account-1' + await service.syncForCurrentSelection() + + writeFileSync(runtimeConfigPath, '{not-json', 'utf-8') + settings.activeClaudeManagedAccountId = null + await service.syncForCurrentSelection() + + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) + expect(readFileSync(runtimeConfigPath, 'utf-8')).toBe('{not-json') + }) + + it('preserves invalid runtime config while materializing managed credentials', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') + const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') + const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') + writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') + writeFileSync(runtimeConfigPath, '{not-json', 'utf-8') + testState.scopedKeychainCredentials = systemCredentials + testState.legacyKeychainCredentials = systemCredentials + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + managedCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = 'account-1' + await service.syncForCurrentSelection() + + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(managedCredentials) + expect(testState.scopedKeychainCredentials).toBe(managedCredentials) + expect(testState.legacyKeychainCredentials).toBe(managedCredentials) + expect(readFileSync(runtimeConfigPath, 'utf-8')).toBe('{not-json') + + settings.activeClaudeManagedAccountId = null + await service.syncForCurrentSelection() + + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) + expect(readFileSync(runtimeConfigPath, 'utf-8')).toBe('{not-json') + }) + + it('preserves non-object runtime config while materializing managed credentials', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') + const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') + const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') + writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') + writeFileSync(runtimeConfigPath, '[]', 'utf-8') + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + managedCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = 'account-1' + await service.syncForCurrentSelection() + + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(managedCredentials) + expect(readFileSync(runtimeConfigPath, 'utf-8')).toBe('[]') + }) + + it('does not use skipped oauth writes as ownership proof on deselect', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') + const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') + const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') + const externalCredentials = createClaudeCredentialsJson('external@example.com', 'external') + const managedOauthAccount = { accountUuid: 'account-1' } + writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') + writeFileSync(runtimeConfigPath, '{not-json', 'utf-8') + testState.scopedKeychainCredentials = systemCredentials + testState.legacyKeychainCredentials = systemCredentials + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + managedCredentials, + `${JSON.stringify(managedOauthAccount)}\n` + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = 'account-1' + await service.syncForCurrentSelection() + + writeFileSync(runtimeCredentialsPath, externalCredentials, 'utf-8') + writeFileSync( + runtimeConfigPath, + `${JSON.stringify({ oauthAccount: managedOauthAccount })}\n`, + 'utf-8' + ) + testState.scopedKeychainCredentials = externalCredentials + testState.legacyKeychainCredentials = externalCredentials + settings.activeClaudeManagedAccountId = null + await service.syncForCurrentSelection() + + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(externalCredentials) + expect(readRuntimeOauthAccountForTest()).toEqual(managedOauthAccount) + expect(testState.scopedKeychainCredentials).toBe(externalCredentials) + expect(testState.legacyKeychainCredentials).toBe(externalCredentials) + }) + + it('preserves external oauth logout when managed oauth metadata is null', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') + const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') + const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') + writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') + writeFileSync( + runtimeConfigPath, + `${JSON.stringify({ oauthAccount: { accountUuid: 'system-account' } })}\n`, + 'utf-8' + ) + testState.scopedKeychainCredentials = systemCredentials + testState.legacyKeychainCredentials = systemCredentials + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + managedCredentials, + 'null\n' + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = 'account-1' + await service.syncForCurrentSelection() + + rmSync(runtimeCredentialsPath, { force: true }) + testState.scopedKeychainCredentials = null + testState.legacyKeychainCredentials = null + writeFileSync(runtimeConfigPath, `${JSON.stringify({})}\n`, 'utf-8') + settings.activeClaudeManagedAccountId = null + await service.syncForCurrentSelection() + + expect(existsSync(runtimeCredentialsPath)).toBe(false) + expect(readRuntimeOauthAccountForTest()).toBeNull() + expect(testState.scopedKeychainCredentials).toBeNull() + expect(testState.legacyKeychainCredentials).toBeNull() + }) + + it('restores reordered owned oauth metadata using stable json equality', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') + const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') + const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') + const systemOauthAccount = { accountUuid: 'system-account', emailAddress: 'system@example.com' } + writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') + writeFileSync( + runtimeConfigPath, + `${JSON.stringify({ oauthAccount: systemOauthAccount })}\n`, + 'utf-8' + ) + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + managedCredentials, + '{"accountUuid":"account-1","emailAddress":"user@example.com"}\n' + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = 'account-1' + await service.syncForCurrentSelection() + + writeFileSync( + runtimeConfigPath, + '{"oauthAccount":{"emailAddress":"user@example.com","accountUuid":"account-1"}}\n', + 'utf-8' + ) + settings.activeClaudeManagedAccountId = null + await service.syncForCurrentSelection() + + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) + expect(readRuntimeOauthAccountForTest()).toEqual(systemOauthAccount) + }) + + it('restores owned oauth metadata during rollback after removing the added account', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') + const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') + const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') + const systemOauthAccount = { accountUuid: 'system-account' } + writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') + writeFileSync( + runtimeConfigPath, + `${JSON.stringify({ oauthAccount: systemOauthAccount })}\n`, + 'utf-8' + ) + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + managedCredentials, + '{"accountUuid":"account-1"}\n' + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = 'account-1' + await service.syncForCurrentSelection() + + settings.activeClaudeManagedAccountId = null + settings.claudeManagedAccounts = [] + await service.forceMaterializeCurrentSelectionForRollback() + + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) + expect(readRuntimeOauthAccountForTest()).toEqual(systemOauthAccount) + }) +}) diff --git a/src/main/claude-accounts/runtime-auth-service-test-harness.ts b/src/main/claude-accounts/runtime-auth-service-test-harness.ts new file mode 100644 index 000000000000..874e7534ff1a --- /dev/null +++ b/src/main/claude-accounts/runtime-auth-service-test-harness.ts @@ -0,0 +1,277 @@ +import { vi } from 'vitest' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { getDefaultSettings } from '../../shared/constants' +import type { GlobalSettings } from '../../shared/global-settings-types' +import type { ClaudeManagedAccount } from '../../shared/managed-account-types' + +const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') +export const hostPlatform = process.platform +export const testState = { + userDataDir: '', + fakeHomeDir: '', + activeKeychainCredentials: null as string | null, + scopedKeychainCredentials: null as string | null, + legacyKeychainCredentials: null as string | null, + throwScopedKeychainRead: false, + throwLegacyKeychainRead: false, + throwRuntimeKeychainWrite: false, + throwLegacyRuntimeKeychainWrite: false, + throwScopedKeychainWrite: false, + runtimeWriteConfigDir: null as string | null, + managedKeychainCredentials: new Map() +} + +export function expectedRuntimeConfigDir(): string { + return join(testState.fakeHomeDir, '.claude') +} + +export function createElectronMock() { + return { + app: { + getPath: () => testState.userDataDir + } + } +} + +// Why: these tests exercise materialize/read-back/snapshot logic, not the +// network OAuth refresh (covered by oauth-refresh.test.ts). Default the token +// to "not expiring" so the proactive switch-in refresh never fires here and +// existing expectations hold; individual tests can override these mocks. +export function createOauthRefreshMock() { + return { + isOauthTokenExpiring: vi.fn(() => false), + refreshClaudeOauthCredentials: vi.fn(async () => null) + } +} + +export function createKeychainMock() { + return { + readActiveClaudeKeychainCredentials: vi.fn(async (configDir?: string) => { + if (configDir) { + if (configDir !== expectedRuntimeConfigDir()) { + return testState.legacyKeychainCredentials + } + return testState.scopedKeychainCredentials ?? testState.legacyKeychainCredentials + } + return testState.legacyKeychainCredentials + }), + writeActiveClaudeKeychainCredentials: vi.fn(async (contents: string, configDir?: string) => { + if (configDir) { + if (configDir !== expectedRuntimeConfigDir()) { + throw new Error(`Unexpected Claude config dir: ${configDir}`) + } + if (testState.throwScopedKeychainWrite) { + throw new Error('scoped keychain write failed') + } + testState.scopedKeychainCredentials = contents + } else { + testState.legacyKeychainCredentials = contents + } + testState.activeKeychainCredentials = contents + }), + deleteActiveClaudeKeychainCredentials: vi.fn(async () => { + testState.scopedKeychainCredentials = null + testState.legacyKeychainCredentials = null + testState.activeKeychainCredentials = null + }), + deleteActiveClaudeKeychainCredentialsStrict: vi.fn(async (configDir?: string) => { + if (configDir) { + if (configDir !== expectedRuntimeConfigDir()) { + throw new Error(`Unexpected Claude config dir: ${configDir}`) + } + testState.scopedKeychainCredentials = null + } else { + testState.legacyKeychainCredentials = null + } + testState.activeKeychainCredentials = null + }), + readActiveClaudeKeychainCredentialsStrict: vi.fn(async (configDir?: string) => + configDir + ? (() => { + if (testState.throwScopedKeychainRead) { + throw new Error('scoped keychain read failed') + } + return configDir === expectedRuntimeConfigDir() + ? testState.scopedKeychainCredentials + : null + })() + : (() => { + if (testState.throwLegacyKeychainRead) { + throw new Error('legacy keychain read failed') + } + return testState.legacyKeychainCredentials + })() + ), + writeActiveClaudeKeychainCredentialsForRuntime: vi.fn( + async (contents: string, configDir: string) => { + if (configDir !== expectedRuntimeConfigDir()) { + throw new Error(`Unexpected Claude config dir: ${configDir}`) + } + if (testState.throwRuntimeKeychainWrite) { + throw new Error('runtime keychain write failed') + } + testState.runtimeWriteConfigDir = configDir + testState.scopedKeychainCredentials = contents + if (testState.throwLegacyRuntimeKeychainWrite) { + throw new Error('legacy runtime keychain write failed') + } + testState.legacyKeychainCredentials = contents + testState.activeKeychainCredentials = contents + } + ), + readManagedClaudeKeychainCredentials: vi.fn( + async (accountId: string) => testState.managedKeychainCredentials.get(accountId) ?? null + ), + writeManagedClaudeKeychainCredentials: vi.fn(async (accountId: string, contents: string) => { + testState.managedKeychainCredentials.set(accountId, contents) + }) + } +} + +export function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { + configurable: true, + value: platform + }) +} + +/** Shared `beforeEach` body: fresh platform, module registry, and temp homes. */ +export function resetRuntimeAuthTestState(): void { + setPlatform('darwin') + vi.resetModules() + vi.clearAllMocks() + testState.activeKeychainCredentials = null + testState.scopedKeychainCredentials = null + testState.legacyKeychainCredentials = null + testState.throwScopedKeychainRead = false + testState.throwLegacyKeychainRead = false + testState.throwRuntimeKeychainWrite = false + testState.throwLegacyRuntimeKeychainWrite = false + testState.throwScopedKeychainWrite = false + testState.runtimeWriteConfigDir = null + testState.managedKeychainCredentials.clear() + testState.userDataDir = mkdtempSync(join(tmpdir(), 'orca-claude-runtime-')) + testState.fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-claude-home-')) + mkdirSync(join(testState.fakeHomeDir, '.claude'), { recursive: true }) +} + +/** Shared `afterEach` body. */ +export function cleanupRuntimeAuthTestState(): void { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + rmSync(testState.userDataDir, { recursive: true, force: true }) + rmSync(testState.fakeHomeDir, { recursive: true, force: true }) +} + +export function createSettings(overrides: Partial = {}): GlobalSettings { + return { + ...getDefaultSettings(testState.fakeHomeDir), + ...overrides + } +} + +export function createStore(settings: GlobalSettings) { + return { + getSettings: vi.fn(() => settings), + updateSettings: vi.fn((updates: Partial) => { + settings = { + ...settings, + ...updates, + notifications: { + ...settings.notifications, + ...updates.notifications + } + } + return settings + }) + } +} + +export function createManagedClaudeAuth( + rootDir: string, + accountId: string, + credentialsJson: string, + oauthAccountJson = `{"accountUuid":"${accountId}"}\n` +): string { + const managedAuthPath = join(rootDir, 'claude-accounts', accountId, 'auth') + mkdirSync(managedAuthPath, { recursive: true }) + writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), `${accountId}\n`, 'utf-8') + writeFileSync(join(managedAuthPath, '.credentials.json'), credentialsJson, 'utf-8') + writeFileSync(join(managedAuthPath, 'oauth-account.json'), oauthAccountJson, 'utf-8') + testState.managedKeychainCredentials.set(accountId, credentialsJson) + return managedAuthPath +} + +export function createClaudeAccount( + id: string, + managedAuthPath: string, + overrides: Partial = {} +): ClaudeManagedAccount { + return { + id, + email: 'user@example.com', + managedAuthPath, + authMethod: 'subscription-oauth', + organizationUuid: null, + organizationName: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1, + ...overrides + } +} + +export function createClaudeCredentialsJson( + email: string, + accessToken: string, + organizationUuid: string | null = null, + expiresAt = Date.now() + 60_000 +): string { + return `${JSON.stringify({ + claudeAiOauth: { + email, + ...(organizationUuid ? { organizationUuid } : {}), + accessToken, + refreshToken: `${accessToken}-refresh`, + expiresAt + } + })}\n` +} + +export function createClaudeCredentialsWithoutEmail( + accessToken: string, + organizationUuid: string | null = null, + options: { expiresAt?: number; refreshToken?: string } = {} +): string { + return `${JSON.stringify({ + claudeAiOauth: { + ...(organizationUuid ? { organizationUuid } : {}), + accessToken, + refreshToken: options.refreshToken ?? `${accessToken}-refresh`, + expiresAt: options.expiresAt ?? Date.now() + 60_000 + } + })}\n` +} + +export function readManagedCredentialsForTest( + accountId: string, + managedAuthPath: string +): string | null { + if (process.platform === 'darwin') { + return testState.managedKeychainCredentials.get(accountId) ?? null + } + return readFileSync(join(managedAuthPath, '.credentials.json'), 'utf-8') +} + +export function readRuntimeOauthAccountForTest(): unknown { + const configPath = join(testState.fakeHomeDir, '.claude.json') + if (!existsSync(configPath)) { + return null + } + return ( + (JSON.parse(readFileSync(configPath, 'utf-8')) as Record).oauthAccount ?? null + ) +} diff --git a/src/main/claude-accounts/runtime-auth-service-wsl-runtime.test.ts b/src/main/claude-accounts/runtime-auth-service-wsl-runtime.test.ts new file mode 100644 index 000000000000..61c3323b6327 --- /dev/null +++ b/src/main/claude-accounts/runtime-auth-service-wsl-runtime.test.ts @@ -0,0 +1,312 @@ +import { + cleanupRuntimeAuthTestState, + createClaudeAccount, + createClaudeCredentialsJson, + createElectronMock, + createKeychainMock, + createManagedClaudeAuth, + createOauthRefreshMock, + createSettings, + createStore, + resetRuntimeAuthTestState, + setPlatform, + testState +} from './runtime-auth-service-test-harness' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdirSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import type * as ClaudeProfileRouting from '../../shared/claude-profile-routing' + +vi.mock('electron', () => createElectronMock()) + +vi.mock('./oauth-refresh', () => createOauthRefreshMock()) + +vi.mock('node:os', async () => { + const actual = await vi.importActual('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() + return { + ...actual, + homedir: () => testState.fakeHomeDir + } +}) + +vi.mock('./keychain', () => createKeychainMock()) + +describe('ClaudeRuntimeAuthService', () => { + beforeEach(() => { + resetRuntimeAuthTestState() + }) + + afterEach(() => { + cleanupRuntimeAuthTestState() + }) + + it('uses account WSL runtime for untargeted Claude preparation instead of stale terminal WSL settings', async () => { + setPlatform('win32') + vi.doMock('../wsl', () => ({ + getDefaultWslDistro: () => 'Ubuntu', + getWslHome: () => null, + toWindowsWslPath: (value: string) => value + })) + const ubuntuAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'ubuntu-account', + createClaudeCredentialsJson('ubuntu@example.com', 'ubuntu-token') + ) + const settings = createSettings({ + localAccountRuntime: 'wsl', + localAccountWslDistro: 'Ubuntu', + terminalWindowsShell: 'wsl.exe', + terminalWindowsWslDistro: 'Debian', + claudeManagedAccounts: [ + createClaudeAccount('ubuntu-account', ubuntuAuthPath, { + managedAuthRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxAuthPath: '/home/alice/.local/share/orca/claude-accounts/ubuntu/auth' + }) + ], + activeClaudeManagedAccountId: null, + activeClaudeManagedAccountIdsByRuntime: { + host: null, + wsl: { Ubuntu: 'ubuntu-account' } + } + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + expect(service.getRuntimeConfigDir({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( + ubuntuAuthPath + ) + const preparation = await service.prepareForClaudeLaunch() + + expect(preparation).toMatchObject({ + runtime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxConfigDir: '/home/alice/.local/share/orca/claude-accounts/ubuntu/auth', + provenance: 'managed:ubuntu-account:wsl:Ubuntu', + stripAuthEnv: true + }) + }) + + it('uses the global WSL runtime for untargeted Claude preparation under auto', async () => { + setPlatform('win32') + vi.doMock('../wsl', () => ({ + getDefaultWslDistro: () => 'Ubuntu', + getWslHome: () => null, + toWindowsWslPath: (value: string) => value + })) + const ubuntuAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'ubuntu-account', + createClaudeCredentialsJson('ubuntu@example.com', 'ubuntu-token') + ) + const settings = createSettings({ + localAccountRuntime: 'auto', + localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' }, + claudeManagedAccounts: [ + createClaudeAccount('ubuntu-account', ubuntuAuthPath, { + managedAuthRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxAuthPath: '/home/alice/.local/share/orca/claude-accounts/ubuntu/auth' + }) + ], + activeClaudeManagedAccountId: null, + activeClaudeManagedAccountIdsByRuntime: { + host: null, + wsl: { Ubuntu: 'ubuntu-account' } + } + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + const preparation = await service.prepareForClaudeLaunch() + + expect(preparation).toMatchObject({ + runtime: 'wsl', + wslDistro: 'Ubuntu', + provenance: 'managed:ubuntu-account:wsl:Ubuntu' + }) + }) + + it('ignores a persisted WSL account-runtime pin on non-Windows hosts', async () => { + setPlatform('darwin') + vi.doMock('../wsl', () => ({ + getDefaultWslDistro: () => 'Ubuntu', + getWslHome: () => null, + toWindowsWslPath: (value: string) => value + })) + const settings = createSettings({ + localAccountRuntime: 'wsl', + localAccountWslDistro: 'Ubuntu' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + const preparation = await service.prepareForClaudeLaunch() + + expect(preparation).toMatchObject({ + runtime: 'host', + wslDistro: null, + provenance: 'system', + stripAuthEnv: false + }) + }) + + it('keeps untargeted Claude preparation on host when account runtime is host', async () => { + setPlatform('win32') + vi.doMock('../wsl', () => ({ + getDefaultWslDistro: () => 'Ubuntu', + getWslHome: () => null, + toWindowsWslPath: (value: string) => value + })) + const settings = createSettings({ + localAccountRuntime: 'host', + terminalWindowsShell: 'wsl.exe', + terminalWindowsWslDistro: 'Debian' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + const preparation = await service.prepareForClaudeLaunch() + + expect(preparation).toMatchObject({ + runtime: 'host', + wslDistro: null, + provenance: 'system', + stripAuthEnv: false + }) + }) + + it('clears a selected WSL managed account when its credentials are missing', async () => { + const managedAuthPath = join(testState.userDataDir, 'claude-accounts', 'account-1', 'auth') + mkdirSync(managedAuthPath, { recursive: true }) + writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') + const settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('account-1', managedAuthPath, { + managedAuthRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxAuthPath: '/home/alice/.local/share/orca/claude-accounts/account-1/auth' + }) + ], + activeClaudeManagedAccountId: null, + activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'account-1' } } + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + const preparation = await service.prepareForClaudeLaunch({ + runtime: 'wsl', + wslDistro: 'Ubuntu' + }) + + expect(store.updateSettings).toHaveBeenCalledWith({ + activeClaudeManagedAccountId: null, + activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: null } } + }) + expect(preparation.runtime).toBe('wsl') + expect(preparation.provenance).toBe('wsl:Ubuntu:system') + expect(preparation.stripAuthEnv).toBe(true) + }) + + it('uses the default distro selection for WSL-default Claude preparation', async () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + vi.doMock('../wsl', () => ({ + getDefaultWslDistro: () => 'Ubuntu', + getWslHome: () => join(testState.userDataDir, 'wsl-home'), + toWindowsWslPath: (value: string) => value + })) + const ubuntuAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'ubuntu-account', + createClaudeCredentialsJson('ubuntu@example.com', 'ubuntu-token') + ) + const debianAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'debian-account', + createClaudeCredentialsJson('debian@example.com', 'debian-token') + ) + const settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('ubuntu-account', ubuntuAuthPath, { + managedAuthRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxAuthPath: '/home/alice/.local/share/orca/claude-accounts/ubuntu/auth' + }), + createClaudeAccount('debian-account', debianAuthPath, { + managedAuthRuntime: 'wsl', + wslDistro: 'Debian', + wslLinuxAuthPath: '/home/alice/.local/share/orca/claude-accounts/debian/auth' + }) + ], + activeClaudeManagedAccountId: null, + activeClaudeManagedAccountIdsByRuntime: { + host: null, + wsl: { Ubuntu: 'ubuntu-account', Debian: 'debian-account' } + } + }) + const store = createStore(settings) + + try { + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + const preparation = await service.prepareForClaudeLaunch({ + runtime: 'wsl', + wslDistro: null + }) + + expect(preparation).toMatchObject({ + runtime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxConfigDir: '/home/alice/.local/share/orca/claude-accounts/ubuntu/auth', + provenance: 'managed:ubuntu-account:wsl:Ubuntu', + stripAuthEnv: true + }) + } finally { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + } + }) + + it('routes a WSL distro through its guest router, and a failed guest publish never fails a select', async () => { + setPlatform('win32') + const wslRouter = { + prepareLaunch: vi.fn(async (distro: string) => ({ runtime: 'wsl', wslDistro: distro })), + publish: vi.fn(async () => { + throw new Error('distro is gone') + }), + runningDistros: vi.fn(async () => []) + } + vi.doMock('../../shared/claude-profile-routing', async (original) => ({ + ...(await original()), + claudeProfileRoutingEnabled: () => true + })) + vi.doMock('./claude-profile-wsl-router', () => ({ + ClaudeWslProfileRouter: function ClaudeWslProfileRouter() { + return wslRouter + } + })) + try { + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const store = createStore(createSettings()) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the service reads only getSettings/updateSettings, which the harness store implements. + const service = new ClaudeRuntimeAuthService(store as never) + await expect( + service.prepareForClaudeLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + ).resolves.toMatchObject({ wslDistro: 'Ubuntu' }) + await expect( + service.syncForCurrentSelection({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + ).resolves.toBeUndefined() + expect(wslRouter.publish).toHaveBeenCalledWith('Ubuntu') + } finally { + vi.doUnmock('../../shared/claude-profile-routing') + vi.doUnmock('./claude-profile-wsl-router') + } + }) +}) diff --git a/src/main/claude-accounts/runtime-auth-service.test.ts b/src/main/claude-accounts/runtime-auth-service.test.ts deleted file mode 100644 index 1cfc4c7d3857..000000000000 --- a/src/main/claude-accounts/runtime-auth-service.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { getDefaultSettings } from '../../shared/constants' -import type { GlobalSettings } from '../../shared/global-settings-types' - -const fakes = vi.hoisted(() => ({ - host: { - prepareLaunch: async () => ({ runtime: 'host' }), - publish: () => {}, - systemDefaultHome: () => '/host/.claude' - }, - wsl: { - prepareLaunch: async (distro: string) => ({ runtime: 'wsl', wslDistro: distro }), - publish: async (_distro: string): Promise => {}, - runningDistros: async (): Promise => [] - } -})) -vi.mock('../../shared/app-environment', () => ({ - getAppEnvironment: () => ({ getPath: () => '/data/orca' }) -})) -vi.mock('../wsl', () => ({ - getDefaultWslDistro: () => 'Ubuntu', - getWslHome: (distro: string) => `/wsl/${distro}/home` -})) -vi.mock('./claude-profile-router', () => ({ - ClaudeProfileRouter: function ClaudeProfileRouter() { - return fakes.host - } -})) -vi.mock('./claude-profile-wsl-router', () => ({ - ClaudeWslProfileRouter: function ClaudeWslProfileRouter() { - return fakes.wsl - } -})) -vi.mock('./claude-profile-installed-router', () => ({ installClaudeProfileRouter: () => {} })) - -import { ClaudeRuntimeAuthService } from './runtime-auth-service' - -const platform = Object.getOwnPropertyDescriptor(process, 'platform')! -function service(platformName: NodeJS.Platform, overrides: Partial = {}) { - Object.defineProperty(process, 'platform', { configurable: true, value: platformName }) - const settings = { ...getDefaultSettings('/home/user'), ...overrides } - return new ClaudeRuntimeAuthService({ getSettings: () => settings }) -} - -beforeEach(() => { - vi.spyOn(console, 'warn').mockImplementation(() => {}) -}) -afterEach(() => { - Object.defineProperty(process, 'platform', platform) - vi.restoreAllMocks() -}) - -describe('ClaudeRuntimeAuthService', () => { - it('keeps a stale WSL account setting on the host off Windows', async () => { - const auth = service('darwin', { localAccountRuntime: 'wsl', localAccountWslDistro: 'Ubuntu' }) - expect(auth.getRuntimeConfigDir()).toBe('/host/.claude') - await expect(auth.prepareForClaudeLaunch()).resolves.toEqual({ runtime: 'host' }) - }) - - it('routes a WSL target that names no distro to the default distro', async () => { - const auth = service('win32') - await expect(auth.prepareForClaudeLaunch({ runtime: 'wsl', wslDistro: null })).resolves.toEqual( - { runtime: 'wsl', wslDistro: 'Ubuntu' } - ) - expect(auth.getRuntimeConfigDir({ runtime: 'wsl', wslDistro: null })).toBe( - '/wsl/Ubuntu/home/.claude' - ) - }) - - it('never fails a select over a failed guest publish, and publishes one pass at a time', async () => { - const auth = service('win32') - vi.spyOn(fakes.wsl, 'publish').mockRejectedValue(new Error('distro is gone')) - await expect( - auth.syncForCurrentSelection({ runtime: 'wsl', wslDistro: 'Ubuntu' }) - ).resolves.toBeUndefined() - - const order: string[] = [] - let release = () => {} - const gate = new Promise((resolve) => (release = resolve)) - vi.spyOn(fakes.host, 'publish').mockImplementation(() => order.push('host')) - vi.spyOn(fakes.wsl, 'runningDistros') - .mockImplementationOnce(async () => { - await gate - order.push('first guests') - return [] - }) - .mockResolvedValue([]) - const first = auth.publishAll() - const second = auth.publishAll() - await new Promise((resolve) => setTimeout(resolve, 10)) - expect(order).toEqual(['host']) - release() - await Promise.all([first, second]) - expect(order).toEqual(['host', 'first guests', 'host']) - }) -}) diff --git a/src/main/claude-accounts/runtime-auth-service.ts b/src/main/claude-accounts/runtime-auth-service.ts index b15bffbc4829..adbbce30da01 100644 --- a/src/main/claude-accounts/runtime-auth-service.ts +++ b/src/main/claude-accounts/runtime-auth-service.ts @@ -1,156 +1,164 @@ -import { join } from 'node:path' import { getAppEnvironment } from '../../shared/app-environment' -import { toWindowsWslPath } from '../../shared/wsl-paths' -import { resolveLocalAccountRuntimeTarget } from '../../shared/local-account-runtime' -import type { Store } from '../persistence' -import { getDefaultWslDistro, getWslHome } from '../wsl' +import { claudeProfileRoutingEnabled } from '../../shared/claude-profile-routing' import { ClaudeProfileRouter } from './claude-profile-router' import { ClaudeWslProfileRouter } from './claude-profile-wsl-router' -import { installClaudeProfileRouter } from './claude-profile-installed-router' -import type { ClaudeAccountSelectionTarget } from './runtime-selection' +import { + getClaudeProfileRouter, + installClaudeProfileRouter +} from './claude-profile-installed-router' +import type { Store } from '../persistence' +import { + getSelectedClaudeAccountIdForTarget, + type ClaudeAccountSelectionTarget +} from './runtime-selection' +import { ClaudeRuntimeAuthSync } from './runtime-auth/runtime-auth-sync' import type { ClaudeRuntimeAuthPreparation } from './runtime-auth/runtime-auth-types' export type { ClaudeRuntimeAuthPreparation } from './runtime-auth/runtime-auth-types' -/** `configDir` is the CLAUDE_CONFIG_DIR value; `readPath` is where this host reads it (UNC for WSL). */ -export type ClaudeAccountFolder = { configDir: string; readPath: string } +function routerFor(target: ClaudeAccountSelectionTarget): ClaudeProfileRouter | undefined { + return target.runtime === 'wsl' ? undefined : getClaudeProfileRouter() +} -/** Routes Claude launches on this host and its WSL distros to the selected account folder. */ -export class ClaudeRuntimeAuthService { - readonly router: ClaudeProfileRouter +export class ClaudeRuntimeAuthService extends ClaudeRuntimeAuthSync { private readonly wslRouter?: ClaudeWslProfileRouter - private readonly dataRoot = getAppEnvironment().getPath('userData') - private mutationQueue: Promise = Promise.resolve() - - constructor(private readonly store: Pick) { - const args = { getSettings: () => store.getSettings(), dataRoot: this.dataRoot } - this.router = new ClaudeProfileRouter(args) - installClaudeProfileRouter(this.router) - this.wslRouter = process.platform === 'win32' ? new ClaudeWslProfileRouter(args) : undefined - void this.publishAll().catch((error: unknown) => { - console.warn('[claude-runtime-auth] Failed to publish the Claude account selection:', error) - }) + + constructor(store: Store) { + super(store) + if (claudeProfileRoutingEnabled()) { + const args = { + getSettings: () => store.getSettings(), + dataRoot: getAppEnvironment().getPath('userData') + } + installClaudeProfileRouter(new ClaudeProfileRouter(args)) + this.wslRouter = process.platform === 'win32' ? new ClaudeWslProfileRouter(args) : undefined + } + this.initializeLastSyncedState() + void this.safeSyncForCurrentSelection() } - prepareForClaudeLaunch( + async prepareForClaudeLaunch( target?: ClaudeAccountSelectionTarget ): Promise { - const wsl = this.wslRouteFor(target) - return wsl ? wsl.router.prepareLaunch(wsl.distro) : this.router.prepareLaunch() + const effectiveTarget = target ?? this.getDefaultAccountSelectionTarget() + const wsl = this.wslRouteFor(effectiveTarget) + if (wsl) { + return wsl.router.prepareLaunch(wsl.distro) + } + const router = routerFor(effectiveTarget) + if (router) { + return router.prepareLaunch() + } + await this.syncForCurrentSelection(effectiveTarget) + return this.getPreparation(effectiveTarget) } async prepareForRateLimitFetch( target?: ClaudeAccountSelectionTarget ): Promise { - const wsl = this.wslRouteFor(target) - try { - return await (wsl ? wsl.router.preparation(wsl.distro) : this.router.preparation()) - } catch (error) { - // Why not thrown: one usage cycle polls every provider, so one account must not stop it. - return { - configDir: '', - envPatch: {}, - stripAuthEnv: true, - provenance: 'profile:unavailable', - usageError: error instanceof Error ? error.message : String(error) - } + const effectiveTarget = target ?? this.getDefaultAccountSelectionTarget() + const wsl = this.wslRouteFor(effectiveTarget) + if (wsl) { + return wsl.router.preparation(wsl.distro) + } + const router = routerFor(effectiveTarget) + if (router) { + return router.preparation() } + await this.syncForCurrentSelection(effectiveTarget) + return this.getPreparation(effectiveTarget) } - /** Rewrites the which-account file for `target` after its selection changed. */ async syncForCurrentSelection(target?: ClaudeAccountSelectionTarget): Promise { await this.serializeMutation(async () => { - const wsl = this.wslRouteFor(target) + const effectiveTarget = target ?? this.getDefaultAccountSelectionTarget() + const wsl = this.wslRouteFor(effectiveTarget) + const router = routerFor(effectiveTarget) if (wsl) { await this.publishWsl(wsl.router, wsl.distro) + } else if (router) { + router.publish() } else { - this.router.publish() + await this.doSyncForCurrentSelection(effectiveTarget) } }) } - /** Startup and rollback republish only running distros: neither may boot a stopped one. */ - async publishAll(): Promise { - await this.serializeMutation(async () => { - this.router.publish() - const router = this.wslRouter - if (router) { - const distros = await router.runningDistros() - await Promise.all(distros.map((distro) => this.publishWsl(router, distro))) - } + /** Null when the target is not a WSL distro routed by account folders. */ + private wslRouteFor( + target: ClaudeAccountSelectionTarget + ): { router: ClaudeWslProfileRouter; distro: string } | null { + const distro = + target.runtime === 'wsl' ? this.resolveWslDefaultTarget(target).wslDistro?.trim() : null + return this.wslRouter && distro ? { router: this.wslRouter, distro } : null + } + + // Why never thrown: a guest Orca cannot reach also cannot run a pane, and a deleted distro must + // not block removing its accounts. The next select, or a start while it runs, rewrites it. + private async publishWsl(router: ClaudeWslProfileRouter, distro: string): Promise { + await router.publish(distro).catch((error: unknown) => { + console.warn(`[claude-profile] Could not update the Claude account in WSL ${distro}:`, error) }) } - /** Creates and sets up an account's folder so Claude can sign in to it. */ - async prepareAccountFolder( - accountId: string, - target: ClaudeAccountSelectionTarget - ): Promise { - const wsl = this.wslRouteFor(target) - if (!wsl) { - const home = await this.router.prepareAccount(accountId) - return { configDir: home, readPath: home } + /** Startup and rollback republish only running distros: neither may boot a stopped one. */ + private async publishRunningWslDistros(): Promise { + const router = this.wslRouter + if (router) { + const distros = await router.runningDistros() + await Promise.all(distros.map((distro) => this.publishWsl(router, distro))) } - const home = await wsl.router.prepareAccount(wsl.distro, accountId) - return { configDir: home, readPath: toWindowsWslPath(home, wsl.distro) } } - async removeAccountFolder( - accountId: string, - target: ClaudeAccountSelectionTarget - ): Promise { - const wsl = this.wslRouteFor(target) - if (!wsl) { - await this.router.removeAccount(accountId) - return - } - // Why never thrown: a deleted distro must not block removing its accounts. - await wsl.router.removeAccount(wsl.distro, accountId).catch((error: unknown) => { - console.warn( - `[claude-profile] Could not delete the Claude account folder in WSL ${wsl.distro}:`, - error - ) + async forceMaterializeCurrentSelectionForRollback(): Promise { + await this.serializeMutation(async () => { + const router = getClaudeProfileRouter() + if (router) { + router.publish() + await this.publishRunningWslDistros() + return + } + const settings = this.store.getSettings() + if (!settings.activeClaudeManagedAccountId) { + const previousAccount = this.getActiveAccount( + settings.claudeManagedAccounts, + this.lastSyncedAccountId + ) + await this.restoreSystemDefaultSnapshot( + previousAccount ? await this.readManagedCredentials(previousAccount) : null, + previousAccount ? await this.readManagedOauthAccount(previousAccount) : undefined + ) + this.lastSyncedAccountId = null + return + } + await this.doSyncForCurrentSelection() }) } - /** System default's folder: skills and plugins install there and are linked into accounts. */ getRuntimeConfigDir(target?: ClaudeAccountSelectionTarget): string { - const effective = this.resolveTarget(target) - const home = - effective.runtime === 'wsl' && effective.wslDistro ? getWslHome(effective.wslDistro) : null - return home ? join(home, '.claude') : this.router.systemDefaultHome() + return this.getPreparation(target).configDir } - // Why never thrown: a guest Orca cannot reach also cannot run a pane, and a deleted distro must - // not block removing its accounts. The next select, or a start while it runs, rewrites it. - private async publishWsl(router: ClaudeWslProfileRouter, distro: string): Promise { - await router.publish(distro).catch((error: unknown) => { - console.warn(`[claude-profile] Could not update the Claude account in WSL ${distro}:`, error) - }) - } - - /** Null when the target is not a WSL distro this host can route. */ - private wslRouteFor( - target?: ClaudeAccountSelectionTarget - ): { router: ClaudeWslProfileRouter; distro: string } | null { - const effective = this.resolveTarget(target) - const distro = effective.runtime === 'wsl' ? effective.wslDistro?.trim() : null - return this.wslRouter && distro ? { router: this.wslRouter, distro } : null + private initializeLastSyncedState(): void { + const settings = this.store.getSettings() + this.lastSyncedAccountId = getSelectedClaudeAccountIdForTarget(settings, { runtime: 'host' }) } - /** A WSL target that names no distro means the default distro, as Claude launches it there. */ - private resolveTarget(target?: ClaudeAccountSelectionTarget): ClaudeAccountSelectionTarget { - const effective = - target ?? - // Why: stale cross-platform WSL pins must stay on the host off Windows. - (process.platform === 'win32' - ? resolveLocalAccountRuntimeTarget(this.store.getSettings()) - : { runtime: 'host' as const }) - if (effective.runtime !== 'wsl' || effective.wslDistro?.trim()) { - return effective + private async safeSyncForCurrentSelection(): Promise { + try { + const router = getClaudeProfileRouter() + if (!router) { + await this.syncForCurrentSelection() + return + } + // Why serialized: an account change during startup must not be overwritten by this older read. + await this.serializeMutation(async () => { + router.publish() + await this.publishRunningWslDistros() + }) + } catch (error) { + console.warn('[claude-runtime-auth] Failed to sync runtime auth state:', error) } - const defaultDistro = getDefaultWslDistro() - return defaultDistro ? { runtime: 'wsl', wslDistro: defaultDistro } : effective } private serializeMutation(fn: () => Promise): Promise { @@ -158,4 +166,14 @@ export class ClaudeRuntimeAuthService { this.mutationQueue = next.catch(() => {}) return next } + + // Why: re-auth/add-account write fresh managed tokens; skip the next read-back so stale runtime tokens can't overwrite them. + clearLastWrittenCredentialsJson( + accountId = this.store.getSettings().activeClaudeManagedAccountId + ): void { + if (accountId === this.store.getSettings().activeClaudeManagedAccountId) { + this.lastWrittenCredentialsJson = null + } + this.skipNextReadBackForAccountId = accountId + } } diff --git a/src/main/claude-accounts/runtime-auth-shared-credentials-fixture.ts b/src/main/claude-accounts/runtime-auth-shared-credentials-fixture.ts new file mode 100644 index 000000000000..8cb00420dc65 --- /dev/null +++ b/src/main/claude-accounts/runtime-auth-shared-credentials-fixture.ts @@ -0,0 +1,51 @@ +import { + createClaudeAccount, + createClaudeCredentialsJson, + createManagedClaudeAuth, + createSettings, + createStore, + setPlatform, + testState +} from './runtime-auth-service-test-harness' +import { writeFileSync } from 'node:fs' +import { join } from 'node:path' + +export const sharedFields = { + mcpOAuth: { figma: { accessToken: 'mcp-access', refreshToken: 'mcp-refresh' } }, + mcpOAuthClientConfig: { figma: { clientId: 'figma-client' } }, + mcpXaaIdp: { token: 'idp-token' }, + mcpXaaIdpConfig: { issuer: 'idp-issuer' }, + pluginSecrets: { plugin: 'secret' } +} + +export function withSharedFields( + credentials: string, + fields: Record = sharedFields +): string { + return JSON.stringify({ ...JSON.parse(credentials), ...fields }) +} + +export async function createSharedCredentialRuntime(platform: NodeJS.Platform = 'darwin') { + setPlatform(platform) + const runtimePath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const system = createClaudeCredentialsJson('system@example.com', 'system') + const first = createClaudeCredentialsJson('first@example.com', 'first') + const second = createClaudeCredentialsJson('second@example.com', 'second') + const firstPath = createManagedClaudeAuth(testState.userDataDir, 'first', first) + const secondPath = createManagedClaudeAuth(testState.userDataDir, 'second', second) + writeFileSync(runtimePath, withSharedFields(system)) + testState.scopedKeychainCredentials = withSharedFields(system) + testState.legacyKeychainCredentials = withSharedFields(system) + const settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('first', firstPath, { email: 'first@example.com' }), + createClaudeAccount('second', secondPath, { email: 'second@example.com' }) + ] + }) + const store = createStore(settings) + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime auth uses only getSettings/updateSettings from this store mock. + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + return { service, settings, runtimePath, first, second, system, firstPath, secondPath } +} diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-credential-identity.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-credential-identity.ts new file mode 100644 index 000000000000..d029ae0d6992 --- /dev/null +++ b/src/main/claude-accounts/runtime-auth/runtime-auth-credential-identity.ts @@ -0,0 +1,209 @@ +import { ClaudeRuntimeAuthFileStorage } from './runtime-auth-file-storage' +import { stripSharedClaudeCredentialFields } from '../shared-credential-fields' +import type { + ClaudeAuthIdentity, + ClaudeReadBackMatch, + ClaudeRefreshTokenComparison +} from './runtime-auth-types' + +export class ClaudeRuntimeAuthCredentialIdentity extends ClaudeRuntimeAuthFileStorage { + protected accountCredentialFieldsEqual(left: string | null, right: string | null): boolean { + if (left === right) { + return true + } + if (left === null || right === null) { + return false + } + try { + const leftAccount = this.asRecord(JSON.parse(stripSharedClaudeCredentialFields(left))) + const rightAccount = this.asRecord(JSON.parse(stripSharedClaudeCredentialFields(right))) + return ( + leftAccount !== null && + rightAccount !== null && + this.jsonValuesEqual(leftAccount, rightAccount) + ) + } catch { + return false + } + } + + protected readIdentityFromCredentials(credentialsJson: string): ClaudeAuthIdentity | null { + let parsed: Record + try { + parsed = JSON.parse(credentialsJson) as Record + } catch { + return null + } + const oauth = this.asRecord(parsed.claudeAiOauth) + return { + accountUuid: this.normalizeField( + this.readString(oauth, 'accountUuid') ?? this.readString(oauth, 'accountId') + ), + email: this.normalizeField(this.readString(oauth, 'email')), + organizationUuid: this.normalizeField( + this.readString(oauth, 'organizationUuid') ?? this.readString(oauth, 'organizationId') + ) + } + } + + protected isValidCredentialsJsonObject(credentialsJson: string): boolean { + try { + const parsed = this.asRecord(JSON.parse(credentialsJson)) + const oauth = this.asRecord(parsed?.claudeAiOauth) + return this.normalizeField(this.readString(oauth, 'accessToken')) !== null + } catch { + return false + } + } + + protected runtimeCredentialsAreFresher( + runtimeCredentialsJson: string, + managedCredentialsJson: string + ): boolean { + const runtimeFreshness = this.readFreshnessFromCredentials(runtimeCredentialsJson) + const managedFreshness = this.readFreshnessFromCredentials(managedCredentialsJson) + return ( + runtimeFreshness !== null && managedFreshness !== null && runtimeFreshness > managedFreshness + ) + } + + protected runtimeCredentialsAreOlder( + runtimeCredentialsJson: string, + managedCredentialsJson: string + ): boolean { + const runtimeFreshness = this.readFreshnessFromCredentials(runtimeCredentialsJson) + const managedFreshness = this.readFreshnessFromCredentials(managedCredentialsJson) + return ( + runtimeFreshness !== null && managedFreshness !== null && runtimeFreshness < managedFreshness + ) + } + + protected chooseFreshestReadBackCandidate( + candidates: { + credentialsJson: string + match: Extract + }[] + ): { + credentialsJson: string + match: Extract + } { + return candidates.reduce((freshest, candidate) => { + const candidateFreshness = this.readFreshnessFromCredentials(candidate.credentialsJson) + const freshestFreshness = this.readFreshnessFromCredentials(freshest.credentialsJson) + if ( + candidateFreshness !== null && + (freshestFreshness === null || candidateFreshness > freshestFreshness) + ) { + return candidate + } + return freshest + }) + } + + protected readFreshnessFromCredentials(credentialsJson: string): number | null { + let parsed: Record + try { + parsed = JSON.parse(credentialsJson) as Record + } catch { + return null + } + const oauth = this.asRecord(parsed.claudeAiOauth) + return ( + this.readNumber(oauth, 'expiresAt') ?? + this.readNumber(oauth, 'expires_at') ?? + this.readNumber(oauth, 'expiry') ?? + this.readNumber(oauth, 'expires') + ) + } + + protected compareRefreshTokens( + runtimeCredentialsJson: string, + managedCredentialsJson: string + ): ClaudeRefreshTokenComparison { + const runtimeRefreshToken = this.readRefreshTokenFromCredentials(runtimeCredentialsJson) + const managedRefreshToken = this.readRefreshTokenFromCredentials(managedCredentialsJson) + if (!runtimeRefreshToken || !managedRefreshToken) { + return 'missing' + } + return runtimeRefreshToken === managedRefreshToken ? 'same' : 'different' + } + + protected readRefreshTokenFromCredentials(credentialsJson: string): string | null { + try { + const parsed = JSON.parse(credentialsJson) as Record + const oauth = this.asRecord(parsed.claudeAiOauth) + return this.normalizeField(this.readString(oauth, 'refreshToken')) + } catch { + return null + } + } + + protected readIdentityFromOauthAccount(oauthAccount: unknown): ClaudeAuthIdentity { + const oauth = this.asRecord(oauthAccount) + return { + accountUuid: this.normalizeField( + this.readString(oauth, 'accountUuid') ?? this.readString(oauth, 'accountId') + ), + email: this.normalizeField( + this.readString(oauth, 'emailAddress') ?? this.readString(oauth, 'email') + ), + organizationUuid: this.normalizeField( + this.readString(oauth, 'organizationUuid') ?? this.readString(oauth, 'organizationId') + ) + } + } + + protected asRecord(value: unknown): Record | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null + } + return value as Record + } + + protected readString(value: Record | null, key: string): string | null { + const candidate = value?.[key] + return typeof candidate === 'string' ? candidate : null + } + + protected readNumber(value: Record | null, key: string): number | null { + const candidate = value?.[key] + if (typeof candidate === 'number' && Number.isFinite(candidate)) { + return candidate + } + if (typeof candidate === 'string') { + const parsed = Number(candidate) + return Number.isFinite(parsed) ? parsed : null + } + return null + } + + protected normalizeField(value: string | null | undefined): string | null { + if (!value) { + return null + } + const trimmed = value.trim() + return trimmed === '' ? null : trimmed + } + + protected jsonValuesEqual(left: unknown, right: unknown): boolean { + return ( + JSON.stringify(this.sortJsonValue(left ?? null)) === + JSON.stringify(this.sortJsonValue(right ?? null)) + ) + } + + protected sortJsonValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((item) => this.sortJsonValue(item)) + } + const record = this.asRecord(value) + if (!record) { + return value + } + return Object.fromEntries( + Object.entries(record) + .sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey)) + .map(([key, nestedValue]) => [key, this.sortJsonValue(nestedValue)]) + ) + } +} diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-credential-matching.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-credential-matching.ts new file mode 100644 index 000000000000..390ec57a8b2f --- /dev/null +++ b/src/main/claude-accounts/runtime-auth/runtime-auth-credential-matching.ts @@ -0,0 +1,152 @@ +import type { ClaudeManagedAccount } from '../../../shared/managed-account-types' +import { ClaudeRuntimeAuthRuntimeState } from './runtime-auth-runtime-state' +import type { ClaudeReadBackMatch } from './runtime-auth-types' + +export class ClaudeRuntimeAuthCredentialMatching extends ClaudeRuntimeAuthRuntimeState { + protected async findManagedAccountForRuntimeCredentials( + runtimeCredentialsJson: string, + runtimeOauthAccount: unknown + ): Promise { + const matches: { account: ClaudeManagedAccount; managedCredentialsJson: string }[] = [] + let unverifiableCount = 0 + for (const account of this.store.getSettings().claudeManagedAccounts) { + const managedCredentialsJson = await this.readManagedCredentials(account) + if (!managedCredentialsJson) { + continue + } + const match = this.runtimeCredentialsMatchAccount( + runtimeCredentialsJson, + runtimeOauthAccount, + account, + managedCredentialsJson, + await this.readManagedOauthAccount(account) + ) + if (match === 'match') { + matches.push({ account, managedCredentialsJson }) + } else if (match === 'unverifiable') { + unverifiableCount += 1 + } + } + + if (matches.length === 1 && unverifiableCount === 0) { + return { kind: 'matched', ...matches[0] } + } + return { kind: matches.length === 0 && unverifiableCount === 0 ? 'none' : 'ambiguous' } + } + + protected runtimeCredentialsMatchAccount( + runtimeCredentialsJson: string, + runtimeOauthAccount: unknown, + account: ClaudeManagedAccount, + managedCredentialsJson: string, + managedOauthAccount: unknown + ): 'match' | 'mismatch' | 'unverifiable' { + const identity = this.readIdentityFromCredentials(runtimeCredentialsJson) + if (!identity) { + return 'mismatch' + } + const managedIdentity = this.readIdentityFromCredentials(managedCredentialsJson) + const managedOauthIdentity = this.readIdentityFromOauthAccount(managedOauthAccount) + const runtimeOauthIdentity = this.readIdentityFromOauthAccount(runtimeOauthAccount) + const credentialOauthConflict = + (identity.accountUuid && + runtimeOauthIdentity.accountUuid && + identity.accountUuid !== runtimeOauthIdentity.accountUuid) || + (identity.email && + runtimeOauthIdentity.email && + identity.email !== runtimeOauthIdentity.email) || + (identity.organizationUuid && + runtimeOauthIdentity.organizationUuid && + identity.organizationUuid !== runtimeOauthIdentity.organizationUuid) + if (credentialOauthConflict) { + return 'mismatch' + } + + // Why: mirrors the Codex runtime-home guard; don't persist shared runtime creds into the managed account if another login rewrote them. + const selectedOrganizationUuid = this.normalizeField( + account.organizationUuid ?? + managedIdentity?.organizationUuid ?? + managedOauthIdentity.organizationUuid + ) + const oauthAccountMatches = + Boolean(managedOauthIdentity.accountUuid) && + managedOauthIdentity.accountUuid === runtimeOauthIdentity.accountUuid && + Boolean(runtimeOauthIdentity.email || runtimeOauthIdentity.organizationUuid) + const runtimeEmail = identity.email ?? runtimeOauthIdentity.email + const runtimeOrganizationUuid = + identity.organizationUuid ?? runtimeOauthIdentity.organizationUuid + const refreshTokenComparison = this.compareRefreshTokens( + runtimeCredentialsJson, + managedCredentialsJson + ) + if (!runtimeEmail) { + if (refreshTokenComparison === 'same') { + return 'match' + } + if (identity.organizationUuid) { + if (selectedOrganizationUuid && selectedOrganizationUuid !== identity.organizationUuid) { + return 'mismatch' + } + return 'unverifiable' + } + if (oauthAccountMatches) { + return 'match' + } + if (!runtimeOrganizationUuid && refreshTokenComparison === 'different') { + return 'mismatch' + } + return 'unverifiable' + } + if (account.email && this.normalizeField(account.email) !== runtimeEmail) { + return 'mismatch' + } + if (selectedOrganizationUuid && !runtimeOrganizationUuid) { + return refreshTokenComparison === 'same' || oauthAccountMatches ? 'match' : 'unverifiable' + } + if ( + selectedOrganizationUuid && + runtimeOrganizationUuid && + selectedOrganizationUuid !== runtimeOrganizationUuid + ) { + return 'mismatch' + } + if (!selectedOrganizationUuid && runtimeOrganizationUuid) { + return refreshTokenComparison === 'same' ? 'match' : 'unverifiable' + } + + return 'match' + } + + protected liveRuntimeCredentialsCanUpdateActiveAccount( + runtimeCredentialsJson: string, + account: ClaudeManagedAccount, + managedCredentialsJson: string, + managedOauthAccount: unknown + ): boolean { + const match = this.runtimeCredentialsMatchAccount( + runtimeCredentialsJson, + this.readRuntimeOauthAccount(), + account, + managedCredentialsJson, + managedOauthAccount + ) + if (match === 'match') { + return true + } + const identity = this.readIdentityFromCredentials(runtimeCredentialsJson) + const managedIdentity = this.readIdentityFromCredentials(managedCredentialsJson) + const managedOauthIdentity = this.readIdentityFromOauthAccount(managedOauthAccount) + const runtimeOauthIdentity = this.readIdentityFromOauthAccount(this.readRuntimeOauthAccount()) + const selectedOrganizationUuid = this.normalizeField( + account.organizationUuid ?? + managedIdentity?.organizationUuid ?? + managedOauthIdentity.organizationUuid + ) + return ( + match === 'unverifiable' && + Boolean(selectedOrganizationUuid) && + (identity?.organizationUuid ?? runtimeOauthIdentity.organizationUuid) === + selectedOrganizationUuid + ) + } +} diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-file-storage.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-file-storage.ts new file mode 100644 index 000000000000..8aa33a88c609 --- /dev/null +++ b/src/main/claude-accounts/runtime-auth/runtime-auth-file-storage.ts @@ -0,0 +1,82 @@ +import { chmodSync, existsSync, mkdirSync, readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { app } from 'electron' +import { writeFileAtomically } from '../../codex-accounts/fs-utils' +import { ClaudeRuntimeAuthState } from './runtime-auth-state' + +export class ClaudeRuntimeAuthFileStorage extends ClaudeRuntimeAuthState { + protected writeRuntimeCredentials(contents: string): void { + const credentialsPath = this.pathResolver.getRuntimePaths().credentialsPath + mkdirSync(dirname(credentialsPath), { recursive: true }) + // Why: skip unchanged rewrites to dodge Windows EPERM contention (#1507); re-verify the file since another Claude may have rewritten it. + if ( + this.lastWrittenCredentialsJson === contents && + this.fileContentsEqual(credentialsPath, contents) + ) { + this.ensureOwnerOnlyMode(credentialsPath) + return + } + if (this.fileContentsEqual(credentialsPath, contents)) { + this.ensureOwnerOnlyMode(credentialsPath) + this.lastWrittenCredentialsJson = contents + return + } + writeFileAtomically(credentialsPath, contents, { mode: 0o600 }) + this.lastWrittenCredentialsJson = contents + } + + protected writeJson(targetPath: string, value: unknown): void { + const serialized = `${JSON.stringify(value, null, 2)}\n` + mkdirSync(dirname(targetPath), { recursive: true }) + // Why: same Windows contention reason as writeRuntimeCredentials. + if (this.fileContentsEqual(targetPath, serialized)) { + return + } + writeFileAtomically(targetPath, serialized, { mode: 0o600 }) + } + + protected fileContentsEqual(targetPath: string, contents: string): boolean { + try { + return existsSync(targetPath) && readFileSync(targetPath, 'utf-8') === contents + } catch { + return false + } + } + + protected ensureOwnerOnlyMode(targetPath: string): void { + if (process.platform === 'win32') { + return + } + try { + chmodSync(targetPath, 0o600) + } catch { + /* Best effort: the next atomic write will set the restrictive mode. */ + } + } + + protected readJsonObject(targetPath: string): Record | null { + if (!existsSync(targetPath)) { + return {} + } + try { + const parsed = JSON.parse(readFileSync(targetPath, 'utf-8')) as unknown + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed as Record + } + } catch { + // Why: invalid config is unknown external state; return null so we don't erase user or Claude-owned settings. + return null + } + return null + } + + protected getRuntimeMetadataDir(): string { + const metadataDir = join(app.getPath('userData'), 'claude-runtime-auth') + mkdirSync(metadataDir, { recursive: true }) + return metadataDir + } + + protected getSystemDefaultSnapshotPath(): string { + return join(this.getRuntimeMetadataDir(), 'system-default-auth.json') + } +} diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-keychain-snapshots.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-keychain-snapshots.ts new file mode 100644 index 000000000000..ef4c54acab83 --- /dev/null +++ b/src/main/claude-accounts/runtime-auth/runtime-auth-keychain-snapshots.ts @@ -0,0 +1,135 @@ +import { + readActiveClaudeKeychainCredentials, + readActiveClaudeKeychainCredentialsStrict +} from '../keychain' +import { ClaudeRuntimeAuthManagedCredentials } from './runtime-auth-managed-credentials' +import type { + ClaudeKeychainReadResult, + ClaudeKeychainSnapshotValue, + ClaudeSystemDefaultSnapshot +} from './runtime-auth-types' + +export class ClaudeRuntimeAuthKeychainSnapshots extends ClaudeRuntimeAuthManagedCredentials { + protected isSystemDefaultSnapshot(value: unknown): value is ClaudeSystemDefaultSnapshot { + const snapshot = this.asRecord(value) + return ( + snapshot !== null && + Object.hasOwn(snapshot, 'credentialsJson') && + this.isOptionalNullableString(snapshot.credentialsJson) && + this.isOptionalNullableString(snapshot.keychainCredentialsJson) && + this.isOptionalNullableString(snapshot.scopedKeychainCredentialsJson) && + this.isOptionalNullableString(snapshot.legacyKeychainCredentialsJson) && + this.isOptionalBoolean(snapshot.scopedKeychainCredentialsCaptured) && + this.isOptionalBoolean(snapshot.legacyKeychainCredentialsCaptured) && + this.hasValidKeychainSnapshotValue(snapshot, 'scoped') && + this.hasValidKeychainSnapshotValue(snapshot, 'legacy') && + (snapshot.capturedAt === undefined || typeof snapshot.capturedAt === 'number') + ) + } + + protected isOptionalNullableString(value: unknown): boolean { + return value === undefined || value === null || typeof value === 'string' + } + + protected isOptionalBoolean(value: unknown): boolean { + return value === undefined || typeof value === 'boolean' + } + + protected snapshotKeychainCredentials( + credentialsJson: string | null, + previousSnapshot: ClaudeSystemDefaultSnapshot | null | undefined, + service: 'scoped' | 'legacy', + managedCredentialsJson: string | undefined + ): string | null { + if ( + managedCredentialsJson && + this.accountCredentialFieldsEqual(credentialsJson, managedCredentialsJson) && + previousSnapshot + ) { + const previousValue = this.readKeychainSnapshotValue(previousSnapshot, service) + if (previousValue.status === 'captured') { + return previousValue.credentialsJson + } + } + return credentialsJson + } + + protected hasValidKeychainSnapshotValue( + snapshot: Record, + service: 'scoped' | 'legacy' + ): boolean { + const capturedKey = + service === 'scoped' + ? 'scopedKeychainCredentialsCaptured' + : 'legacyKeychainCredentialsCaptured' + if (snapshot[capturedKey] === false) { + return true + } + const credentialsKey = + service === 'scoped' ? 'scopedKeychainCredentialsJson' : 'legacyKeychainCredentialsJson' + return ( + Object.hasOwn(snapshot, credentialsKey) || Object.hasOwn(snapshot, 'keychainCredentialsJson') + ) + } + + protected readKeychainSnapshotValue( + snapshot: ClaudeSystemDefaultSnapshot | null, + service: 'scoped' | 'legacy' + ): ClaudeKeychainSnapshotValue { + if (!snapshot) { + return { status: 'captured', credentialsJson: null } + } + const capturedKey = + service === 'scoped' + ? 'scopedKeychainCredentialsCaptured' + : 'legacyKeychainCredentialsCaptured' + if (snapshot[capturedKey] === false) { + return { status: 'unknown' } + } + const credentialsKey = + service === 'scoped' ? 'scopedKeychainCredentialsJson' : 'legacyKeychainCredentialsJson' + if (Object.hasOwn(snapshot, credentialsKey)) { + return { + status: 'captured', + credentialsJson: snapshot[credentialsKey] ?? null + } + } + return { status: 'captured', credentialsJson: snapshot.keychainCredentialsJson } + } + + protected async readAggregateClaudeKeychainCredentialsBestEffort( + configDir: string + ): Promise { + try { + return await readActiveClaudeKeychainCredentials(configDir) + } catch (error) { + console.warn('[claude-runtime-auth] Failed to read Claude Keychain credentials:', error) + return null + } + } + + protected async readActiveClaudeKeychainCredentialsBestEffort( + configDir?: string + ): Promise { + try { + return await readActiveClaudeKeychainCredentialsStrict(configDir) + } catch (error) { + console.warn('[claude-runtime-auth] Failed to read Claude Keychain credentials:', error) + return null + } + } + + protected async readActiveClaudeKeychainCredentialsForSnapshot( + configDir?: string + ): Promise { + try { + return { + status: 'captured', + credentialsJson: await readActiveClaudeKeychainCredentialsStrict(configDir) + } + } catch (error) { + console.warn('[claude-runtime-auth] Failed to read Claude Keychain credentials:', error) + return { status: 'failed' } + } + } +} diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-managed-credentials.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-managed-credentials.ts new file mode 100644 index 000000000000..3a93f63d4dd6 --- /dev/null +++ b/src/main/claude-accounts/runtime-auth/runtime-auth-managed-credentials.ts @@ -0,0 +1,152 @@ +import { existsSync } from 'node:fs' +import type { ClaudeManagedAccount } from '../../../shared/managed-account-types' +import { parseWslUncPath } from '../../../shared/wsl-paths' +import { toWindowsWslPath } from '../../wsl' +import { runWslProcess } from '../../wsl/wsl-runner' +import { + readClaudeManagedAuthFile, + resolveOwnedClaudeManagedAuthPath, + writeClaudeManagedAuthFile +} from '../managed-auth-path' +import { isOauthTokenExpiring, refreshClaudeOauthCredentials } from '../oauth-refresh' +import { + readManagedClaudeKeychainCredentials, + writeManagedClaudeKeychainCredentials +} from '../keychain' +import { ClaudeRuntimeAuthCredentialIdentity } from './runtime-auth-credential-identity' +import { stripSharedClaudeCredentialFields } from '../shared-credential-fields' + +const OWNERSHIP_PROBE_TIMEOUT = 'orca-wsl-ownership-probe-timeout' + +function shellQuote(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'` +} + +export class ClaudeRuntimeAuthManagedCredentials extends ClaudeRuntimeAuthCredentialIdentity { + protected async readManagedCredentials(account: ClaudeManagedAccount): Promise { + const managedAuthPath = await this.getOwnedManagedAuthPath(account) + if (!managedAuthPath) { + return null + } + if (process.platform === 'darwin') { + const credentials = await readManagedClaudeKeychainCredentials(account.id) + return credentials === null ? null : stripSharedClaudeCredentialFields(credentials) + } + const credentials = readClaudeManagedAuthFile(managedAuthPath, '.credentials.json') + return credentials === null || account.managedAuthRuntime === 'wsl' + ? credentials + : stripSharedClaudeCredentialFields(credentials) + } + + protected async writeManagedCredentials( + account: ClaudeManagedAccount, + credentialsJson: string + ): Promise { + const managedAuthPath = await this.getOwnedManagedAuthPath(account) + if (!managedAuthPath) { + throw new Error('Managed Claude auth storage is not owned by Orca.') + } + if (account.managedAuthRuntime !== 'wsl') { + credentialsJson = stripSharedClaudeCredentialFields(credentialsJson) + } + if (process.platform === 'darwin') { + await writeManagedClaudeKeychainCredentials(account.id, credentialsJson) + return + } + writeClaudeManagedAuthFile(managedAuthPath, '.credentials.json', credentialsJson) + } + + /** + * Proactively refresh an account's OAuth token and persist the rotation to + * managed storage. Returns the refreshed credentials JSON, or null when no + * refresh happened (token valid, no refresh token, or network failure). + * + * Caller guarantees this account isn't the live/active one and runs inside the + * serialized mutation queue, so the single-use refresh token can't rotate concurrently. + */ + protected async refreshManagedAccountTokenIfNeeded( + account: ClaudeManagedAccount, + credentialsJson: string + ): Promise { + if (!isOauthTokenExpiring(credentialsJson)) { + return null + } + const refreshed = await refreshClaudeOauthCredentials(credentialsJson) + if (!refreshed || !this.isValidCredentialsJsonObject(refreshed)) { + return null + } + try { + await this.writeManagedCredentials(account, refreshed) + } catch (error) { + console.warn('[claude-runtime-auth] Failed to persist refreshed Claude token:', error) + return null + } + return refreshed + } + + protected async readManagedOauthAccount(account: ClaudeManagedAccount): Promise { + const managedAuthPath = await this.getOwnedManagedAuthPath(account) + if (!managedAuthPath) { + return null + } + try { + const contents = readClaudeManagedAuthFile(managedAuthPath, 'oauth-account.json') + return contents ? (JSON.parse(contents) as unknown) : null + } catch { + return null + } + } + + protected async getOwnedManagedAuthPath(account: ClaudeManagedAccount): Promise { + const wslInfo = parseWslUncPath(account.managedAuthPath) + if (wslInfo) { + if ( + !wslInfo.linuxPath.includes('/.local/share/orca/claude-accounts/') || + !wslInfo.linuxPath.endsWith('/auth') + ) { + return null + } + if (process.platform === 'win32') { + try { + const owned = await runWslProcess({ + distro: wslInfo.distro, + loginPath: 'none', + shell: 'bash', + script: [ + 'set -euo pipefail', + `candidate=${shellQuote(wslInfo.linuxPath)}`, + 'managed_root="${HOME%/}/.local/share/orca/claude-accounts"', + 'candidate_real=$(readlink -f -- "$candidate")', + 'managed_root_real=$(readlink -f -- "$managed_root")', + 'test -f "$candidate_real/.orca-managed-claude-auth"', + `test "$(cat "$candidate_real/.orca-managed-claude-auth")" = ${shellQuote(account.id)}`, + 'case "$candidate_real" in "$managed_root_real"/*/auth) printf "%s\\n" "$candidate_real" ;; *) exit 35 ;; esac' + ].join('\n'), + timeoutMs: 5000 + }) + if (owned.timedOut) { + throw new Error(OWNERSHIP_PROBE_TIMEOUT) + } + if (owned.code !== 0) { + return null + } + const canonicalLinuxPath = owned.stdout.trim() + return canonicalLinuxPath ? toWindowsWslPath(canonicalLinuxPath, wslInfo.distro) : null + } catch (error) { + // Why rethrow a timeout: null means "not owned by Orca", and the + // caller persists that -- clearing the user's account selection. A + // slow distro must not decide ownership. Swallowing it here is what + // made the previous guard dead code. + if (error instanceof Error && error.message === OWNERSHIP_PROBE_TIMEOUT) { + throw error + } + return null + } + } + return existsSync(account.managedAuthPath) ? account.managedAuthPath : null + } + return resolveOwnedClaudeManagedAuthPath(account.id, account.managedAuthPath, { + adoptLegacyMarker: true + }) + } +} diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-preparation.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-preparation.ts new file mode 100644 index 000000000000..dabcd9d472f3 --- /dev/null +++ b/src/main/claude-accounts/runtime-auth/runtime-auth-preparation.ts @@ -0,0 +1,119 @@ +import { join } from 'node:path' +import type { ClaudeManagedAccount } from '../../../shared/managed-account-types' +import { resolveLocalAccountRuntimeTarget } from '../../../shared/local-account-runtime' +import { parseWslUncPath } from '../../../shared/wsl-paths' +import { shouldStripClaudeAuthEnvForAccount } from '../environment' +import { getDefaultWslDistro, getWslHome } from '../../wsl' +import { + getSelectedClaudeAccountIdForTarget, + normalizeClaudeAccountSelectionTarget, + type ClaudeAccountSelectionTarget +} from '../runtime-selection' +import { ClaudeRuntimeAuthSnapshotRestore } from './runtime-auth-snapshot-restore' +import type { ClaudeRuntimeAuthPreparation } from './runtime-auth-types' + +export class ClaudeRuntimeAuthPreparationService extends ClaudeRuntimeAuthSnapshotRestore { + protected getPreparation(target?: ClaudeAccountSelectionTarget): ClaudeRuntimeAuthPreparation { + const settings = this.store.getSettings() + const paths = this.pathResolver.getRuntimePaths() + const normalizedTarget = this.resolveWslDefaultTarget( + target ?? this.getDefaultAccountSelectionTarget(settings) + ) + const activeAccountId = getSelectedClaudeAccountIdForTarget(settings, normalizedTarget) + const activeAccount = this.getActiveAccount(settings.claudeManagedAccounts, activeAccountId) + if ( + normalizeClaudeAccountSelectionTarget(normalizedTarget).runtime === 'wsl' && + activeAccount?.managedAuthRuntime === 'wsl' && + activeAccount.wslLinuxAuthPath + ) { + return { + configDir: activeAccount.managedAuthPath, + runtime: 'wsl', + wslDistro: activeAccount.wslDistro ?? null, + wslLinuxConfigDir: activeAccount.wslLinuxAuthPath, + envPatch: { CLAUDE_CONFIG_DIR: activeAccount.wslLinuxAuthPath }, + stripAuthEnv: true, + provenance: `managed:${activeAccount.id}:wsl:${activeAccount.wslDistro ?? ''}` + } + } + if (normalizeClaudeAccountSelectionTarget(normalizedTarget).runtime === 'wsl') { + const distro = + normalizeClaudeAccountSelectionTarget(normalizedTarget).wslDistro ?? getDefaultWslDistro() + const wslHome = distro ? getWslHome(distro) : null + const wslHomeInfo = wslHome ? parseWslUncPath(wslHome) : null + if (distro && wslHome && wslHomeInfo) { + const windowsConfigDir = join(wslHome, '.claude') + const linuxConfigDir = `${wslHomeInfo.linuxPath.replace(/\/$/, '')}/.claude` + return { + configDir: windowsConfigDir, + runtime: 'wsl', + wslDistro: distro, + wslLinuxConfigDir: linuxConfigDir, + envPatch: {}, + stripAuthEnv: true, + provenance: `wsl:${distro}:system` + } + } + return { + configDir: paths.configDir, + runtime: 'wsl', + wslDistro: normalizeClaudeAccountSelectionTarget(normalizedTarget).wslDistro, + wslLinuxConfigDir: null, + envPatch: {}, + stripAuthEnv: true, + provenance: `wsl:${normalizeClaudeAccountSelectionTarget(normalizedTarget).wslDistro ?? '__default__'}:system` + } + } + return { + configDir: paths.configDir, + runtime: 'host', + wslDistro: null, + wslLinuxConfigDir: null, + envPatch: paths.envPatch, + stripAuthEnv: shouldStripClaudeAuthEnvForAccount( + settings.claudeManagedAccounts, + activeAccountId + ), + managedRefreshDeferredByLivePty: Boolean( + activeAccountId && + activeAccount?.managedAuthRuntime !== 'wsl' && + this.managedRefreshDeferredByLivePtyAccountId === activeAccountId + ), + provenance: + activeAccountId && activeAccount?.managedAuthRuntime !== 'wsl' + ? `managed:${activeAccountId}` + : 'system' + } + } + + protected getActiveAccount( + accounts: ClaudeManagedAccount[], + activeAccountId: string | null + ): ClaudeManagedAccount | null { + if (!activeAccountId) { + return null + } + return accounts.find((account) => account.id === activeAccountId) ?? null + } + + protected getDefaultAccountSelectionTarget( + settings = this.store.getSettings() + ): ClaudeAccountSelectionTarget { + // Why: Windows auth follows the resolved account runtime; stale cross-platform WSL pins must stay local-host. + const resolved = resolveLocalAccountRuntimeTarget(settings) + if (process.platform === 'win32' && resolved.runtime === 'wsl') { + return { runtime: 'wsl', wslDistro: resolved.wslDistro } + } + return { runtime: 'host' } + } + + protected resolveWslDefaultTarget( + target?: ClaudeAccountSelectionTarget + ): ClaudeAccountSelectionTarget { + if (target?.runtime !== 'wsl' || target.wslDistro?.trim()) { + return target ?? { runtime: 'host' } + } + const defaultDistro = getDefaultWslDistro() + return defaultDistro ? { runtime: 'wsl', wslDistro: defaultDistro } : target + } +} diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-readback.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-readback.ts new file mode 100644 index 000000000000..848ee093dfbc --- /dev/null +++ b/src/main/claude-accounts/runtime-auth/runtime-auth-readback.ts @@ -0,0 +1,163 @@ +import { existsSync, readFileSync } from 'node:fs' +import { writeActiveClaudeKeychainCredentialsForRuntime } from '../keychain' +import { ClaudeRuntimeAuthCredentialMatching } from './runtime-auth-credential-matching' +import type { + ClaudeReadBackMatch, + ClaudeReadBackResult, + ClaudeRuntimeCredentialCandidate +} from './runtime-auth-types' + +export class ClaudeRuntimeAuthReadback extends ClaudeRuntimeAuthCredentialMatching { + protected async readBackRefreshedTokens( + baselineCredentialsJson: string, + options: { updateLastWrittenCredentialsJson: boolean } + ): Promise { + try { + const candidates = + await this.readRuntimeCredentialCandidatesForReadBack(baselineCredentialsJson) + if (candidates.length === 0) { + return { status: 'unchanged' } + } + const changedCandidates = + this.lastWrittenCredentialsJson === null + ? candidates + : candidates.filter( + (candidate) => + !this.accountCredentialFieldsEqual( + candidate.credentialsJson, + this.lastWrittenCredentialsJson + ) + ) + if (changedCandidates.length === 0) { + return { status: 'unchanged' } + } + + const acceptedCandidates: { + credentialsJson: string + match: Extract + }[] = [] + const ambiguousCandidates: string[] = [] + let sawAmbiguousCandidate = false + let sawValidChangedCandidate = false + for (const runtimeContents of changedCandidates) { + if (!this.isValidCredentialsJsonObject(runtimeContents.credentialsJson)) { + continue + } + sawValidChangedCandidate = true + const match = await this.findManagedAccountForRuntimeCredentials( + runtimeContents.credentialsJson, + runtimeContents.runtimeOauthAccount + ) + if (match.kind === 'ambiguous') { + sawAmbiguousCandidate = true + ambiguousCandidates.push(runtimeContents.credentialsJson) + continue + } + if (match.kind !== 'matched') { + continue + } + // Why: on cold start we can't tell a fresh CLI refresh from stale runtime creds; adopt only when expiry or a rotated refresh token proves runtime is newer than managed. + if (this.lastWrittenCredentialsJson === null) { + const fresher = this.runtimeCredentialsAreFresher( + runtimeContents.credentialsJson, + match.managedCredentialsJson + ) + const refreshTokenRotated = + this.compareRefreshTokens( + runtimeContents.credentialsJson, + match.managedCredentialsJson + ) === 'different' + const older = this.runtimeCredentialsAreOlder( + runtimeContents.credentialsJson, + match.managedCredentialsJson + ) + if (!fresher && !(refreshTokenRotated && !older)) { + continue + } + } else if ( + this.runtimeCredentialsAreOlder( + runtimeContents.credentialsJson, + match.managedCredentialsJson + ) + ) { + continue + } + acceptedCandidates.push({ credentialsJson: runtimeContents.credentialsJson, match }) + } + if (acceptedCandidates.length === 0) { + if (sawAmbiguousCandidate) { + console.warn('[claude-runtime-auth] Refusing ambiguous Claude auth read-back') + } + return { + status: 'rejected', + runtimeCredentialsChanged: true, + hasValidChangedRuntimeCredentials: sawValidChangedCandidate, + runtimeCredentialsJson: + ambiguousCandidates.length === 1 ? ambiguousCandidates[0] : undefined + } + } + const { credentialsJson: runtimeContents, match } = + this.chooseFreshestReadBackCandidate(acceptedCandidates) + + await this.writeManagedCredentials(match.account, runtimeContents) + if (options.updateLastWrittenCredentialsJson) { + const merged = await this.mergeLiveRuntimeSharedCredentials(runtimeContents) + this.writeRuntimeCredentials(merged) + if (process.platform === 'darwin') { + const paths = this.pathResolver.getRuntimePaths() + await writeActiveClaudeKeychainCredentialsForRuntime(merged, paths.configDir) + } + this.lastWrittenSharedCredentialsJson = merged + } + return { status: 'persisted' } + } catch (error) { + // Why: read-back is best-effort; a transient fs error must not block forward sync (worst case: one more stale-token cycle). + console.warn('[claude-runtime-auth] Failed to read back refreshed tokens:', error) + return { + status: 'rejected', + runtimeCredentialsChanged: + this.runtimeCredentialsChangedSinceLastWrite(baselineCredentialsJson), + // Why: an fs error hides whether a live session's refresh is present, so err toward preserving runtime state. + hasValidChangedRuntimeCredentials: true + } + } + } + + protected async readRuntimeCredentialCandidatesForReadBack( + baselineCredentialsJson: string + ): Promise { + const paths = this.pathResolver.getRuntimePaths() + const fileCredentials = existsSync(paths.credentialsPath) + ? readFileSync(paths.credentialsPath, 'utf-8') + : null + const runtimeOauthAccount = this.readRuntimeOauthAccount() + const candidates: ClaudeRuntimeCredentialCandidate[] = [] + const pushCandidate = (credentialsJson: string | null): void => { + if ( + credentialsJson && + !candidates.some((candidate) => candidate.credentialsJson === credentialsJson) + ) { + candidates.push({ credentialsJson, runtimeOauthAccount }) + } + } + if (process.platform === 'darwin') { + const scopedKeychainCredentials = await this.readActiveClaudeKeychainCredentialsBestEffort( + paths.configDir + ) + const legacyKeychainCredentials = await this.readActiveClaudeKeychainCredentialsBestEffort() + if (this.lastWrittenCredentialsJson === null) { + pushCandidate(scopedKeychainCredentials) + pushCandidate(legacyKeychainCredentials) + pushCandidate(fileCredentials) + return candidates.filter( + (candidate) => + !this.accountCredentialFieldsEqual(candidate.credentialsJson, baselineCredentialsJson) + ) + } + pushCandidate(scopedKeychainCredentials) + pushCandidate(legacyKeychainCredentials) + } + pushCandidate(fileCredentials) + return candidates + } +} diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-runtime-state.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-runtime-state.ts new file mode 100644 index 000000000000..40d4d45395a2 --- /dev/null +++ b/src/main/claude-accounts/runtime-auth/runtime-auth-runtime-state.ts @@ -0,0 +1,238 @@ +import { existsSync, readFileSync, rmSync } from 'node:fs' +import type { ClaudeManagedAccount } from '../../../shared/managed-account-types' +import { + deleteActiveClaudeKeychainCredentialsStrict, + readActiveClaudeKeychainCredentialsStrict, + writeActiveClaudeKeychainCredentials +} from '../keychain' +import { + mergeSharedClaudeCredentialFields, + reconcileSharedClaudeCredentialFields +} from '../shared-credential-fields' +import { ClaudeRuntimeAuthKeychainSnapshots } from './runtime-auth-keychain-snapshots' +import { + RUNTIME_OAUTH_ACCOUNT_PARSE_ERROR, + type ClaudeKeychainSnapshotValue +} from './runtime-auth-types' + +export class ClaudeRuntimeAuthRuntimeState extends ClaudeRuntimeAuthKeychainSnapshots { + protected async mergeLiveRuntimeSharedCredentials(credentialsJson: string): Promise { + const paths = this.pathResolver.getRuntimePaths() + const candidates: string[] = [] + if (process.platform === 'darwin') { + // A failed read must stop the switch before any shared tokens are overwritten. + const scoped = await readActiveClaudeKeychainCredentialsStrict(paths.configDir) + const legacy = await readActiveClaudeKeychainCredentialsStrict() + if (scoped !== null) { + candidates.push(scoped) + } + if (legacy !== null) { + candidates.push(legacy) + } + } + const file = this.readRuntimeCredentialsFile() + if (file !== null) { + candidates.push(file) + } + const shared = reconcileSharedClaudeCredentialFields( + candidates, + this.lastWrittenSharedCredentialsJson + ) + return mergeSharedClaudeCredentialFields(credentialsJson, shared) + } + + protected readRuntimeCredentialsFile(): string | null { + const credentialsPath = this.pathResolver.getRuntimePaths().credentialsPath + return existsSync(credentialsPath) ? readFileSync(credentialsPath, 'utf-8') : null + } + + protected runtimeCredentialsBelongToAccount( + credentialsJson: string | null, + account: ClaudeManagedAccount, + managedOauthAccount: unknown + ): boolean { + if (!credentialsJson) { + return false + } + const identity = this.readIdentityFromCredentials(credentialsJson) + if ( + !identity?.email || + (account.email && this.normalizeField(account.email) !== identity.email) + ) { + return false + } + const oauthIdentity = this.readIdentityFromOauthAccount(managedOauthAccount) + const selectedOrganizationUuid = this.normalizeField( + account.organizationUuid ?? oauthIdentity.organizationUuid + ) + if (selectedOrganizationUuid) { + return identity.organizationUuid === selectedOrganizationUuid + } + return !identity.organizationUuid + } + + protected clearLastWrittenRuntimeState(): void { + this.lastWrittenCredentialsJson = null + this.lastWrittenOauthAccount = null + this.hasLastWrittenOauthAccount = false + this.hasMaterializedRuntimeAuth = false + } + + protected hasUnchangedRuntimeCredentials( + previouslyWrittenCredentialsJson: string | null + ): boolean { + if (previouslyWrittenCredentialsJson === null) { + return false + } + const paths = this.pathResolver.getRuntimePaths() + const currentCredentialsJson = existsSync(paths.credentialsPath) + ? readFileSync(paths.credentialsPath, 'utf-8') + : null + return this.accountCredentialFieldsEqual( + currentCredentialsJson, + previouslyWrittenCredentialsJson + ) + } + + protected runtimeCredentialsChangedSinceLastWrite(baselineCredentialsJson: string): boolean { + const paths = this.pathResolver.getRuntimePaths() + try { + const currentCredentialsJson = existsSync(paths.credentialsPath) + ? readFileSync(paths.credentialsPath, 'utf-8') + : null + return ( + currentCredentialsJson !== null && + currentCredentialsJson !== (this.lastWrittenCredentialsJson ?? baselineCredentialsJson) + ) + } catch { + return false + } + } + + protected restoreRuntimeCredentials( + credentialsJson: string | null, + sharedCredentialsJson?: string + ): void { + const paths = this.pathResolver.getRuntimePaths() + const restored = mergeSharedClaudeCredentialFields( + credentialsJson ?? '{}', + sharedCredentialsJson ?? this.readRuntimeCredentialsFile() + ) + if (credentialsJson !== null || restored !== '{}') { + this.writeRuntimeCredentials(restored) + } else { + rmSync(paths.credentialsPath, { force: true }) + } + } + + protected restoreRuntimeOauthAccountIfOwned( + oauthAccount: unknown, + ownedOauthAccount: unknown, + options: { allowCredentialSurfaceOwnership: boolean } + ): void { + const currentOauthAccount = this.readRuntimeOauthAccount() + if (currentOauthAccount === RUNTIME_OAUTH_ACCOUNT_PARSE_ERROR) { + return + } + if (options.allowCredentialSurfaceOwnership) { + this.writeRuntimeOauthAccount(oauthAccount) + return + } + if ( + (ownedOauthAccount === null || ownedOauthAccount === undefined) && + !options.allowCredentialSurfaceOwnership + ) { + return + } + if (!this.jsonValuesEqual(currentOauthAccount, ownedOauthAccount)) { + return + } + this.writeRuntimeOauthAccount(oauthAccount) + } + + protected async hasUnchangedActiveClaudeKeychainCredentials( + snapshotValue: ClaudeKeychainSnapshotValue, + previouslyWrittenCredentialsJson: string | null, + configDir?: string + ): Promise { + if (snapshotValue.status === 'unknown') { + return false + } + const currentCredentialsJson = + await this.readActiveClaudeKeychainCredentialsBestEffort(configDir) + return ( + previouslyWrittenCredentialsJson !== null && + this.accountCredentialFieldsEqual(currentCredentialsJson, previouslyWrittenCredentialsJson) + ) + } + + protected async restoreActiveClaudeKeychainCredentials( + credentialsJson: string | null, + configDir?: string, + sharedCredentialsJson?: string + ): Promise { + const live = + sharedCredentialsJson ?? (await readActiveClaudeKeychainCredentialsStrict(configDir)) + const restored = mergeSharedClaudeCredentialFields(credentialsJson ?? '{}', live) + await (credentialsJson !== null || restored !== '{}' + ? writeActiveClaudeKeychainCredentials(restored, configDir) + : deleteActiveClaudeKeychainCredentialsStrict(configDir)) + } + + protected async hasActiveKeychainCredentialsForAccount( + account: ClaudeManagedAccount, + managedOauthAccount: unknown, + configDir?: string + ): Promise { + const currentCredentialsJson = + await this.readActiveClaudeKeychainCredentialsBestEffort(configDir) + return this.runtimeCredentialsBelongToAccount( + currentCredentialsJson, + account, + managedOauthAccount + ) + } + + protected readRuntimeOauthAccount(): unknown { + const configPath = this.pathResolver.getRuntimePaths().configPath + if (!existsSync(configPath)) { + return null + } + try { + const parsed = JSON.parse(readFileSync(configPath, 'utf-8')) as unknown + const record = this.asRecord(parsed) + if (!record) { + return RUNTIME_OAUTH_ACCOUNT_PARSE_ERROR + } + return record.oauthAccount ?? null + } catch { + return RUNTIME_OAUTH_ACCOUNT_PARSE_ERROR + } + } + + protected runtimeOauthAccountMatches(managedOauthAccount: unknown): boolean { + if (managedOauthAccount === null || managedOauthAccount === undefined) { + return false + } + const currentOauthAccount = this.readRuntimeOauthAccount() + if (currentOauthAccount === RUNTIME_OAUTH_ACCOUNT_PARSE_ERROR) { + return false + } + return this.jsonValuesEqual(currentOauthAccount, managedOauthAccount) + } + + protected writeRuntimeOauthAccount(oauthAccount: unknown): boolean { + const configPath = this.pathResolver.getRuntimePaths().configPath + const existing = this.readJsonObject(configPath) + if (existing === null) { + return false + } + if (oauthAccount === null || oauthAccount === undefined) { + delete existing.oauthAccount + } else { + existing.oauthAccount = oauthAccount + } + this.writeJson(configPath, existing) + return true + } +} diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-snapshot-capture.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-snapshot-capture.ts new file mode 100644 index 000000000000..e184ec4e7517 --- /dev/null +++ b/src/main/claude-accounts/runtime-auth/runtime-auth-snapshot-capture.ts @@ -0,0 +1,119 @@ +import { existsSync, readFileSync, rmSync } from 'node:fs' +import { ClaudeRuntimeAuthReadback } from './runtime-auth-readback' +import { + RUNTIME_OAUTH_ACCOUNT_PARSE_ERROR, + type ClaudeSystemDefaultSnapshot +} from './runtime-auth-types' + +export class ClaudeRuntimeAuthSnapshotCapture extends ClaudeRuntimeAuthReadback { + protected async captureSystemDefaultSnapshotForManagedEntry( + runtimeCredentialsJson: string | null, + managedCredentialsJson: string + ): Promise { + const snapshotPath = this.getSystemDefaultSnapshotPath() + const existingSnapshot = this.readSystemDefaultSnapshot(snapshotPath) + if (!this.accountCredentialFieldsEqual(runtimeCredentialsJson, managedCredentialsJson)) { + await this.captureSystemDefaultSnapshot({ + force: true, + previousSnapshot: existingSnapshot, + managedCredentialsJson + }) + return + } + if (existingSnapshot) { + await this.captureSystemDefaultSnapshot({ + force: true, + credentialsJsonOverride: existingSnapshot.credentialsJson, + previousSnapshot: existingSnapshot, + managedCredentialsJson + }) + return + } + await this.captureSystemDefaultSnapshot({ force: false }) + } + + protected async captureSystemDefaultSnapshot(options: { + force: boolean + credentialsJsonOverride?: string | null + previousSnapshot?: ClaudeSystemDefaultSnapshot | null + managedCredentialsJson?: string + }): Promise { + const snapshotPath = this.getSystemDefaultSnapshotPath() + if (!options.force && existsSync(snapshotPath)) { + return + } + + const paths = this.pathResolver.getRuntimePaths() + const credentialsJson = + options.credentialsJsonOverride !== undefined + ? options.credentialsJsonOverride + : existsSync(paths.credentialsPath) + ? readFileSync(paths.credentialsPath, 'utf-8') + : null + const keychainCredentialsJson = await this.readAggregateClaudeKeychainCredentialsBestEffort( + paths.configDir + ) + const scopedKeychainCredentials = + process.platform === 'darwin' + ? await this.readActiveClaudeKeychainCredentialsForSnapshot(paths.configDir) + : ({ status: 'captured', credentialsJson: null } as const) + const legacyKeychainCredentialsJson = + process.platform === 'darwin' + ? await this.readActiveClaudeKeychainCredentialsForSnapshot() + : ({ status: 'captured', credentialsJson: null } as const) + if ( + scopedKeychainCredentials.status === 'failed' || + legacyKeychainCredentialsJson.status === 'failed' + ) { + throw new Error('Cannot capture current Claude Keychain credentials') + } + const scopedKeychainCredentialsJson = + scopedKeychainCredentials.status === 'captured' + ? this.snapshotKeychainCredentials( + scopedKeychainCredentials.credentialsJson, + options.previousSnapshot, + 'scoped', + options.managedCredentialsJson + ) + : undefined + const legacyKeychainSnapshotJson = + legacyKeychainCredentialsJson.status === 'captured' + ? this.snapshotKeychainCredentials( + legacyKeychainCredentialsJson.credentialsJson, + options.previousSnapshot, + 'legacy', + options.managedCredentialsJson + ) + : undefined + const configOauthAccount = this.readRuntimeOauthAccount() + const snapshot: ClaudeSystemDefaultSnapshot = { + credentialsJson, + configOauthAccount: + configOauthAccount === RUNTIME_OAUTH_ACCOUNT_PARSE_ERROR ? null : configOauthAccount, + keychainCredentialsJson, + scopedKeychainCredentialsJson, + legacyKeychainCredentialsJson: legacyKeychainSnapshotJson, + scopedKeychainCredentialsCaptured: scopedKeychainCredentials.status === 'captured', + legacyKeychainCredentialsCaptured: legacyKeychainCredentialsJson.status === 'captured', + capturedAt: Date.now() + } + this.writeJson(snapshotPath, snapshot) + } + + protected readSystemDefaultSnapshot(snapshotPath: string): ClaudeSystemDefaultSnapshot | null { + if (!existsSync(snapshotPath)) { + return null + } + try { + const parsed = JSON.parse(readFileSync(snapshotPath, 'utf-8')) as unknown + if (this.isSystemDefaultSnapshot(parsed)) { + return parsed + } + throw new Error('Invalid Claude system-default auth snapshot shape') + } catch (error) { + console.warn('[claude-runtime-auth] Ignoring invalid system-default auth snapshot:', error) + rmSync(snapshotPath, { force: true }) + return null + } + } +} diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-snapshot-restore.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-snapshot-restore.ts new file mode 100644 index 000000000000..bb2158d9464b --- /dev/null +++ b/src/main/claude-accounts/runtime-auth/runtime-auth-snapshot-restore.ts @@ -0,0 +1,239 @@ +import type { ClaudeManagedAccount } from '../../../shared/managed-account-types' +import { ClaudeRuntimeAuthSnapshotCapture } from './runtime-auth-snapshot-capture' +import type { ClaudeKeychainSnapshotValue } from './runtime-auth-types' + +export class ClaudeRuntimeAuthSnapshotRestore extends ClaudeRuntimeAuthSnapshotCapture { + protected async restoreSystemDefaultSnapshot( + ownedCredentialsJson?: string | null, + ownedOauthAccount?: unknown + ): Promise { + const snapshotPath = this.getSystemDefaultSnapshotPath() + const paths = this.pathResolver.getRuntimePaths() + const previouslyWrittenCredentialsJson = + this.lastWrittenCredentialsJson ?? ownedCredentialsJson ?? null + const snapshot = this.readSystemDefaultSnapshot(snapshotPath) + + const fileCredentialsOwned = this.hasUnchangedRuntimeCredentials( + previouslyWrittenCredentialsJson + ) + let hasCredentialSurfaceOwnership = fileCredentialsOwned + // Why: prove ownership before mutating anything, and restore OAuth first so a failure leaves the credential proof intact for retry. + this.lastWrittenCredentialsJson = previouslyWrittenCredentialsJson + let scopedSnapshot: ClaudeKeychainSnapshotValue | null = null + let legacySnapshot: ClaudeKeychainSnapshotValue | null = null + let scopedKeychainOwned = false + let legacyKeychainOwned = false + if (process.platform === 'darwin') { + scopedSnapshot = this.readKeychainSnapshotValue(snapshot, 'scoped') + legacySnapshot = this.readKeychainSnapshotValue(snapshot, 'legacy') + scopedKeychainOwned = await this.hasUnchangedActiveClaudeKeychainCredentials( + scopedSnapshot, + previouslyWrittenCredentialsJson, + paths.configDir + ) + legacyKeychainOwned = await this.hasUnchangedActiveClaudeKeychainCredentials( + legacySnapshot, + previouslyWrittenCredentialsJson + ) + hasCredentialSurfaceOwnership = + fileCredentialsOwned || scopedKeychainOwned || legacyKeychainOwned + } + const sharedCredentialsJson = hasCredentialSurfaceOwnership + ? await this.mergeLiveRuntimeSharedCredentials('{}') + : undefined + this.restoreRuntimeOauthAccountIfOwned( + snapshot?.configOauthAccount ?? null, + this.getOwnedRuntimeOauthBaseline(ownedOauthAccount, hasCredentialSurfaceOwnership), + { allowCredentialSurfaceOwnership: hasCredentialSurfaceOwnership } + ) + if (fileCredentialsOwned) { + this.restoreRuntimeCredentials(snapshot?.credentialsJson ?? null, sharedCredentialsJson) + } + if (process.platform === 'darwin') { + if (scopedSnapshot?.status === 'captured' && scopedKeychainOwned) { + await this.restoreActiveClaudeKeychainCredentials( + scopedSnapshot.credentialsJson, + paths.configDir, + sharedCredentialsJson + ) + } + if (legacySnapshot?.status === 'captured' && legacyKeychainOwned) { + await this.restoreActiveClaudeKeychainCredentials( + legacySnapshot.credentialsJson, + undefined, + sharedCredentialsJson + ) + } + } + this.recordRestoredSharedCredentials( + sharedCredentialsJson, + fileCredentialsOwned, + scopedSnapshot?.status === 'captured' && scopedKeychainOwned, + legacySnapshot?.status === 'captured' && legacyKeychainOwned + ) + this.lastWrittenCredentialsJson = null + this.lastWrittenOauthAccount = null + this.hasLastWrittenOauthAccount = false + this.hasMaterializedRuntimeAuth = false + } + + protected getOwnedRuntimeOauthBaseline( + ownedOauthAccount: unknown, + hasCredentialSurfaceOwnership: boolean + ): unknown { + if (this.hasLastWrittenOauthAccount) { + return this.lastWrittenOauthAccount + } + // Why: managed metadata hints identity but isn't proof Orca wrote .claude.json; use only after a credential surface proves ownership. + if (hasCredentialSurfaceOwnership && ownedOauthAccount !== undefined) { + return ownedOauthAccount + } + return null + } + + private recordRestoredSharedCredentials( + credentialsJson: string | undefined, + fileRestored: boolean, + scopedRestored: boolean, + legacyRestored: boolean + ): void { + if ( + credentialsJson !== undefined && + fileRestored && + (process.platform !== 'darwin' || (scopedRestored && legacyRestored)) + ) { + this.lastWrittenSharedCredentialsJson = credentialsJson + } + } + + protected async clearRuntimeAuthForAccount( + account: ClaudeManagedAccount, + managedOauthAccount: unknown + ): Promise { + const paths = this.pathResolver.getRuntimePaths() + const fileCredentialsOwned = this.runtimeCredentialsBelongToAccount( + this.readRuntimeCredentialsFile(), + account, + managedOauthAccount + ) + let scopedKeychainOwned = false + let legacyKeychainOwned = false + if (process.platform === 'darwin') { + scopedKeychainOwned = await this.hasActiveKeychainCredentialsForAccount( + account, + managedOauthAccount, + paths.configDir + ) + legacyKeychainOwned = await this.hasActiveKeychainCredentialsForAccount( + account, + managedOauthAccount + ) + } + const hasCredentialSurfaceOwnership = + fileCredentialsOwned || scopedKeychainOwned || legacyKeychainOwned + const sharedCredentialsJson = hasCredentialSurfaceOwnership + ? await this.mergeLiveRuntimeSharedCredentials('{}') + : undefined + this.restoreRuntimeOauthAccountIfOwned( + null, + this.getOwnedRuntimeOauthBaseline(managedOauthAccount, hasCredentialSurfaceOwnership), + { + allowCredentialSurfaceOwnership: hasCredentialSurfaceOwnership + } + ) + if (fileCredentialsOwned) { + this.restoreRuntimeCredentials(null, sharedCredentialsJson) + } + if (process.platform === 'darwin') { + if (scopedKeychainOwned) { + await this.restoreActiveClaudeKeychainCredentials( + null, + paths.configDir, + sharedCredentialsJson + ) + } + if (legacyKeychainOwned) { + await this.restoreActiveClaudeKeychainCredentials(null, undefined, sharedCredentialsJson) + } + } + this.recordRestoredSharedCredentials( + sharedCredentialsJson, + fileCredentialsOwned, + scopedKeychainOwned, + legacyKeychainOwned + ) + } + + protected async restoreSystemDefaultSnapshotForMissingManagedCredentials( + account: ClaudeManagedAccount, + managedOauthAccount: unknown + ): Promise { + const snapshot = this.readSystemDefaultSnapshot(this.getSystemDefaultSnapshotPath()) + if (!snapshot) { + await this.clearRuntimeAuthForAccount(account, managedOauthAccount) + this.clearLastWrittenRuntimeState() + return + } + const paths = this.pathResolver.getRuntimePaths() + const fileCredentialsOwned = this.runtimeCredentialsBelongToAccount( + this.readRuntimeCredentialsFile(), + account, + managedOauthAccount + ) + let scopedSnapshot: ClaudeKeychainSnapshotValue | null = null + let legacySnapshot: ClaudeKeychainSnapshotValue | null = null + let scopedKeychainOwned = false + let legacyKeychainOwned = false + if (process.platform === 'darwin') { + scopedSnapshot = this.readKeychainSnapshotValue(snapshot, 'scoped') + legacySnapshot = this.readKeychainSnapshotValue(snapshot, 'legacy') + scopedKeychainOwned = await this.hasActiveKeychainCredentialsForAccount( + account, + managedOauthAccount, + paths.configDir + ) + legacyKeychainOwned = await this.hasActiveKeychainCredentialsForAccount( + account, + managedOauthAccount + ) + } + const hasCredentialSurfaceOwnership = + fileCredentialsOwned || scopedKeychainOwned || legacyKeychainOwned + const sharedCredentialsJson = hasCredentialSurfaceOwnership + ? await this.mergeLiveRuntimeSharedCredentials('{}') + : undefined + this.restoreRuntimeOauthAccountIfOwned( + snapshot.configOauthAccount, + this.getOwnedRuntimeOauthBaseline(managedOauthAccount, hasCredentialSurfaceOwnership), + { + allowCredentialSurfaceOwnership: hasCredentialSurfaceOwnership + } + ) + if (fileCredentialsOwned) { + this.restoreRuntimeCredentials(snapshot.credentialsJson, sharedCredentialsJson) + } + if (process.platform === 'darwin') { + if (scopedSnapshot?.status === 'captured' && scopedKeychainOwned) { + await this.restoreActiveClaudeKeychainCredentials( + scopedSnapshot.credentialsJson, + paths.configDir, + sharedCredentialsJson + ) + } + if (legacySnapshot?.status === 'captured' && legacyKeychainOwned) { + await this.restoreActiveClaudeKeychainCredentials( + legacySnapshot.credentialsJson, + undefined, + sharedCredentialsJson + ) + } + } + this.recordRestoredSharedCredentials( + sharedCredentialsJson, + fileCredentialsOwned, + scopedSnapshot?.status === 'captured' && scopedKeychainOwned, + legacySnapshot?.status === 'captured' && legacyKeychainOwned + ) + this.clearLastWrittenRuntimeState() + } +} diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-state.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-state.ts new file mode 100644 index 000000000000..6d6e5e47a489 --- /dev/null +++ b/src/main/claude-accounts/runtime-auth/runtime-auth-state.ts @@ -0,0 +1,19 @@ +import type { Store } from '../../persistence' +import { ClaudeRuntimePathResolver } from '../runtime-paths' + +export class ClaudeRuntimeAuthState { + protected readonly pathResolver = new ClaudeRuntimePathResolver() + protected mutationQueue: Promise = Promise.resolve() + protected lastSyncedAccountId: string | null = null + // Why: creds Orca last wrote to the shared file; a mismatch on managed→default transition means an external login overwrote it, so adopt it as the new default. + protected lastWrittenCredentialsJson: string | null = null + // Connector revocations require a baseline that reached every runtime store, not a partial file write. + protected lastWrittenSharedCredentialsJson: string | null = null + protected hasMaterializedRuntimeAuth = false + protected hasLastWrittenOauthAccount = false + protected lastWrittenOauthAccount: unknown = null + protected skipNextReadBackForAccountId: string | null = null + protected managedRefreshDeferredByLivePtyAccountId: string | null = null + + protected constructor(protected readonly store: Store) {} +} diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-sync.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-sync.ts new file mode 100644 index 000000000000..23e9112d7ed7 --- /dev/null +++ b/src/main/claude-accounts/runtime-auth/runtime-auth-sync.ts @@ -0,0 +1,289 @@ +import { existsSync, readFileSync } from 'node:fs' +import { + getSelectedClaudeAccountIdForTarget, + normalizeClaudeAccountSelectionTarget, + normalizeClaudeRuntimeSelection, + setSelectedClaudeAccountIdForTarget, + type ClaudeAccountSelectionTarget +} from '../runtime-selection' +import { hasLiveClaudePtys } from '../live-pty-gate' +import { isOauthTokenExpiring } from '../oauth-refresh' +import { writeActiveClaudeKeychainCredentialsForRuntime } from '../keychain' +import { ClaudeRuntimeAuthPreparationService } from './runtime-auth-preparation' + +export class ClaudeRuntimeAuthSync extends ClaudeRuntimeAuthPreparationService { + protected async doSyncForCurrentSelection(target?: ClaudeAccountSelectionTarget): Promise { + const settings = this.store.getSettings() + const effectiveTarget = this.resolveWslDefaultTarget(target) + const normalizedTarget = normalizeClaudeAccountSelectionTarget(effectiveTarget) + const activeAccountId = getSelectedClaudeAccountIdForTarget(settings, normalizedTarget) + const activeAccount = this.getActiveAccount(settings.claudeManagedAccounts, activeAccountId) + const previousAccount = this.getActiveAccount( + settings.claudeManagedAccounts, + this.lastSyncedAccountId + ) + this.managedRefreshDeferredByLivePtyAccountId = null + const previousManagedCredentialsJson = previousAccount + ? await this.readManagedCredentials(previousAccount) + : null + const previousManagedOauthAccount = previousAccount + ? await this.readManagedOauthAccount(previousAccount) + : null + if (previousAccount && previousAccount.id !== activeAccount?.id) { + if (previousManagedCredentialsJson) { + const outgoingReadBackResult = await this.readBackRefreshedTokens( + previousManagedCredentialsJson, + { + updateLastWrittenCredentialsJson: true + } + ) + if ( + outgoingReadBackResult.status === 'rejected' && + outgoingReadBackResult.runtimeCredentialsChanged && + hasLiveClaudePtys() + ) { + if ( + outgoingReadBackResult.runtimeCredentialsJson && + this.liveRuntimeCredentialsCanUpdateActiveAccount( + outgoingReadBackResult.runtimeCredentialsJson, + previousAccount, + previousManagedCredentialsJson, + previousManagedOauthAccount + ) + ) { + // Why: switching away while Claude is live must preserve verified token refreshes before replacing shared runtime credentials. + await this.writeManagedCredentials( + previousAccount, + outgoingReadBackResult.runtimeCredentialsJson + ) + } else { + // Why: the runtime blob may lack identity proof for a live-session refresh; skip persisting it, but still let new terminals move to the account. + console.warn( + '[claude-runtime-auth] Skipping unverified live Claude auth read-back while switching accounts' + ) + } + } + } + } + if (!activeAccount) { + if (activeAccountId) { + const nextSelection = setSelectedClaudeAccountIdForTarget( + normalizeClaudeRuntimeSelection(settings), + null, + normalizedTarget + ) + this.store.updateSettings({ + activeClaudeManagedAccountId: + normalizedTarget.runtime === 'host' ? null : settings.activeClaudeManagedAccountId, + activeClaudeManagedAccountIdsByRuntime: nextSelection + }) + } + if (normalizedTarget.runtime === 'wsl') { + return + } + if (this.lastSyncedAccountId !== null) { + await (previousAccount + ? this.restoreSystemDefaultSnapshot( + previousManagedCredentialsJson, + previousManagedOauthAccount + ) + : this.restoreSystemDefaultSnapshot(this.lastWrittenCredentialsJson, undefined)) + this.lastSyncedAccountId = null + } + return + } + + if (activeAccount.managedAuthRuntime === 'wsl') { + if (!(await this.getOwnedManagedAuthPath(activeAccount))) { + console.warn( + '[claude-runtime-auth] Active WSL managed account is not owned by Orca, restoring system default' + ) + const nextSelection = setSelectedClaudeAccountIdForTarget( + normalizeClaudeRuntimeSelection(settings), + null, + normalizedTarget + ) + this.store.updateSettings({ + activeClaudeManagedAccountId: + normalizedTarget.runtime === 'host' ? null : settings.activeClaudeManagedAccountId, + activeClaudeManagedAccountIdsByRuntime: nextSelection + }) + return + } + const credentialsJson = await this.readManagedCredentials(activeAccount) + if (!credentialsJson || !this.isValidCredentialsJsonObject(credentialsJson)) { + console.warn( + '[claude-runtime-auth] Active WSL managed account is missing or has invalid credentials, restoring system default' + ) + const nextSelection = setSelectedClaudeAccountIdForTarget( + normalizeClaudeRuntimeSelection(settings), + null, + normalizedTarget + ) + this.store.updateSettings({ + activeClaudeManagedAccountId: + normalizedTarget.runtime === 'host' ? null : settings.activeClaudeManagedAccountId, + activeClaudeManagedAccountIdsByRuntime: nextSelection + }) + return + } + // Why: WSL managed accounts are isolated by their Linux CLAUDE_CONFIG_DIR; materializing into Windows ~/.claude would mix two auth stores. + this.clearLastWrittenRuntimeState() + return + } + + if (!(await this.getOwnedManagedAuthPath(activeAccount))) { + console.warn( + '[claude-runtime-auth] Active managed account is not owned by Orca, restoring system default' + ) + if (this.lastSyncedAccountId !== null) { + if ( + previousAccount && + (previousAccount.id !== activeAccount.id || + this.hasMaterializedRuntimeAuth || + this.runtimeOauthAccountMatches(await this.readManagedOauthAccount(previousAccount))) + ) { + await this.restoreSystemDefaultSnapshotForMissingManagedCredentials( + previousAccount, + previousManagedOauthAccount + ) + } else if (!previousAccount && this.hasMaterializedRuntimeAuth) { + await this.restoreSystemDefaultSnapshot(this.lastWrittenCredentialsJson, undefined) + } + } + this.store.updateSettings({ activeClaudeManagedAccountId: null }) + this.lastSyncedAccountId = null + return + } + + let credentialsJson = await this.readManagedCredentials(activeAccount) + if (!credentialsJson || !this.isValidCredentialsJsonObject(credentialsJson)) { + console.warn( + '[claude-runtime-auth] Active managed account is missing or has invalid credentials, restoring system default' + ) + if (this.lastSyncedAccountId !== null) { + if ( + previousAccount && + (previousAccount.id !== activeAccount.id || + this.hasMaterializedRuntimeAuth || + this.runtimeOauthAccountMatches(previousManagedOauthAccount)) + ) { + await this.restoreSystemDefaultSnapshotForMissingManagedCredentials( + previousAccount, + previousManagedOauthAccount + ) + } else if (!previousAccount && this.hasMaterializedRuntimeAuth) { + await this.restoreSystemDefaultSnapshot(this.lastWrittenCredentialsJson, undefined) + } + } + this.store.updateSettings({ activeClaudeManagedAccountId: null }) + this.lastSyncedAccountId = null + return + } + + if (this.lastSyncedAccountId === null) { + const paths = this.pathResolver.getRuntimePaths() + const runtimeCredentialsJson = existsSync(paths.credentialsPath) + ? readFileSync(paths.credentialsPath, 'utf-8') + : null + await this.captureSystemDefaultSnapshotForManagedEntry( + runtimeCredentialsJson, + credentialsJson + ) + } + + // Why: the CLI writes refreshed tokens to .credentials.json; if runtime differs from our last write, preserve them to managed storage before overwriting. + if (this.lastSyncedAccountId === activeAccount.id) { + if (this.skipNextReadBackForAccountId === activeAccount.id) { + this.skipNextReadBackForAccountId = null + } else { + const readBackResult = await this.readBackRefreshedTokens(credentialsJson, { + updateLastWrittenCredentialsJson: true + }) + if (readBackResult.status === 'persisted') { + const updatedCredentialsJson = await this.readManagedCredentials(activeAccount) + if (updatedCredentialsJson && this.isValidCredentialsJsonObject(updatedCredentialsJson)) { + credentialsJson = updatedCredentialsJson + } + } else if ( + readBackResult.status === 'rejected' && + readBackResult.runtimeCredentialsChanged && + // Why: a live Claude that lost a refresh race can wipe its runtime blob (empty tokens); preserving that would log out every new session. + readBackResult.hasValidChangedRuntimeCredentials && + hasLiveClaudePtys() + ) { + if ( + readBackResult.runtimeCredentialsJson && + this.liveRuntimeCredentialsCanUpdateActiveAccount( + readBackResult.runtimeCredentialsJson, + activeAccount, + credentialsJson, + await this.readManagedOauthAccount(activeAccount) + ) + ) { + // Why: this Claude launched under the active managed account, but persistence still needs positive account proof. + await this.writeManagedCredentials(activeAccount, readBackResult.runtimeCredentialsJson) + credentialsJson = readBackResult.runtimeCredentialsJson + } else { + // Why: while Claude runs, an unknown refresh may belong to a live session; rewriting stale managed auth logs it out. + console.warn( + '[claude-runtime-auth] Preserving changed Claude runtime credentials while live Claude terminals are running' + ) + this.lastSyncedAccountId = activeAccount.id + this.hasMaterializedRuntimeAuth = true + return + } + } + } + } + + if (this.lastSyncedAccountId !== activeAccount.id) { + this.skipNextReadBackForAccountId = null + } + + // Why: rotate+persist the single-use token to managed storage before materializing (else runtime gets a stale token that fails invalid_grant); skip while a live PTY owns the creds since refreshing would double-rotate it (invalidating one copy) — read-back preserves its refresh instead. + const liveClaudePtys = hasLiveClaudePtys() + if (liveClaudePtys && isOauthTokenExpiring(credentialsJson)) { + this.managedRefreshDeferredByLivePtyAccountId = activeAccount.id + } + if (!liveClaudePtys) { + const refreshed = await this.refreshManagedAccountTokenIfNeeded( + activeAccount, + credentialsJson + ) + if (refreshed) { + credentialsJson = refreshed + } + } + + const paths = this.pathResolver.getRuntimePaths() + const runtimeCredentialsJson = await this.mergeLiveRuntimeSharedCredentials(credentialsJson) + this.writeRuntimeCredentials(runtimeCredentialsJson) + if (process.platform === 'darwin') { + // Why: Claude Code 2.1+ reads the scoped service, older builds the legacy unsuffixed one; runtime switching must satisfy both. + try { + await writeActiveClaudeKeychainCredentialsForRuntime( + runtimeCredentialsJson, + paths.configDir + ) + } catch (error) { + await this.restoreSystemDefaultSnapshot( + credentialsJson, + await this.readManagedOauthAccount(activeAccount) + ) + throw error + } + } + this.lastWrittenSharedCredentialsJson = runtimeCredentialsJson + const managedOauthAccount = await this.readManagedOauthAccount(activeAccount) + if (this.writeRuntimeOauthAccount(managedOauthAccount)) { + this.lastWrittenOauthAccount = managedOauthAccount + this.hasLastWrittenOauthAccount = true + } else { + this.lastWrittenOauthAccount = null + this.hasLastWrittenOauthAccount = false + } + this.lastSyncedAccountId = activeAccount.id + this.hasMaterializedRuntimeAuth = true + } +} diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-types.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-types.ts index a6a099dad194..77f0394e5719 100644 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-types.ts +++ b/src/main/claude-accounts/runtime-auth/runtime-auth-types.ts @@ -1,3 +1,4 @@ +import type { ClaudeManagedAccount } from '../../../shared/managed-account-types' import type { ClaudeEnvPatch } from '../environment' export type ClaudeRuntimeAuthPreparation = { @@ -7,7 +8,48 @@ export type ClaudeRuntimeAuthPreparation = { wslLinuxConfigDir?: string | null envPatch: ClaudeEnvPatch stripAuthEnv: boolean + managedRefreshDeferredByLivePty?: boolean provenance: string - /** Usage only: why the selected account cannot be read; usage polling must not throw for it. */ - usageError?: string } + +export type ClaudeSystemDefaultSnapshot = { + credentialsJson: string | null + configOauthAccount: unknown + keychainCredentialsJson: string | null + scopedKeychainCredentialsJson?: string | null + legacyKeychainCredentialsJson?: string | null + scopedKeychainCredentialsCaptured?: boolean + legacyKeychainCredentialsCaptured?: boolean + capturedAt: number +} + +export type ClaudeAuthIdentity = { + accountUuid: string | null + email: string | null + organizationUuid: string | null +} + +export type ClaudeReadBackResult = + | { status: 'unchanged' | 'persisted' } + | { + status: 'rejected' + runtimeCredentialsChanged: boolean + hasValidChangedRuntimeCredentials: boolean + runtimeCredentialsJson?: string + } +export type ClaudeReadBackMatch = + | { kind: 'matched'; account: ClaudeManagedAccount; managedCredentialsJson: string } + | { kind: 'none' | 'ambiguous' } +export type ClaudeKeychainReadResult = + | { status: 'captured'; credentialsJson: string | null } + | { status: 'failed' } +export type ClaudeKeychainSnapshotValue = + | { status: 'captured'; credentialsJson: string | null } + | { status: 'unknown' } +export type ClaudeRefreshTokenComparison = 'same' | 'different' | 'missing' +export type ClaudeRuntimeCredentialCandidate = { + credentialsJson: string + runtimeOauthAccount: unknown +} + +export const RUNTIME_OAUTH_ACCOUNT_PARSE_ERROR = Symbol('runtime-oauth-account-parse-error') diff --git a/src/main/claude-accounts/runtime-paths.test.ts b/src/main/claude-accounts/runtime-paths.test.ts index aa2f168b6eac..cd1607e5a319 100644 --- a/src/main/claude-accounts/runtime-paths.test.ts +++ b/src/main/claude-accounts/runtime-paths.test.ts @@ -79,6 +79,12 @@ describe('ClaudeRuntimePathResolver', () => { expect(paths.envPatch).toEqual({ CLAUDE_CONFIG_DIR: inherited }) }) + it('resolves credentials next to the config directory', () => { + const paths = new ClaudeRuntimePathResolver().getRuntimePaths() + + expect(paths.credentialsPath).toBe(join(testState.fakeHomeDir, '.claude', '.credentials.json')) + }) + it('falls back to the home config file when no colocated config exists', () => { const paths = new ClaudeRuntimePathResolver().getRuntimePaths() diff --git a/src/main/claude-accounts/runtime-paths.ts b/src/main/claude-accounts/runtime-paths.ts index c164df91d9c6..d9ff5701f262 100644 --- a/src/main/claude-accounts/runtime-paths.ts +++ b/src/main/claude-accounts/runtime-paths.ts @@ -5,6 +5,7 @@ import type { ClaudeEnvPatch } from './environment' export type ClaudeRuntimePaths = { configDir: string + credentialsPath: string configPath: string envPatch: ClaudeEnvPatch } @@ -17,6 +18,7 @@ export class ClaudeRuntimePathResolver { return { configDir, + credentialsPath: join(configDir, '.credentials.json'), configPath: this.resolveConfigPath(configDir, inheritedConfigDir), envPatch: inheritedConfigDir ? { CLAUDE_CONFIG_DIR: configDir } : {} } diff --git a/src/main/claude-accounts/service.ts b/src/main/claude-accounts/service.ts index 9d64480cfd4a..d1c79eaf17d5 100644 --- a/src/main/claude-accounts/service.ts +++ b/src/main/claude-accounts/service.ts @@ -1,39 +1,73 @@ -import type { - ClaudeAccountSignIn, - ClaudeRateLimitAccountsState, - ClaudeSignInRequest -} from '../../shared/managed-account-types' +import type { ClaudeRateLimitAccountsState } from '../../shared/managed-account-types' +import type { Store } from '../persistence' +import type { RateLimitService } from '../rate-limits/service' import { ClaudeAccountRegistration } from './claude-account-registration' +import { ClaudeAccountSelection } from './claude-account-selection' import { - ClaudeAccountSelection, - type ClaudeAccountRuntime, - type ClaudeAccountStore, - type ClaudeAccountUsage -} from './claude-account-selection' -import { runClaudeCommandProcess } from './claude-command-process' + captureClaudeAuthFromConfigDir, + captureClaudeAuthFromExistingConfigDir, + readCapturedClaudeCredentials, + type CapturedClaudeAuth +} from './claude-auth-capture' +import { + runClaudeCommandProcess, + type ClaudeCommandConfig, + type ClaudeCommandOptions +} from './claude-command-process' import { runClaudeLoginSession } from './claude-login-session' +import { + ClaudeManagedAuthStorage, + type ClaudeManagedAuthLocation +} from './claude-managed-auth-storage' import type { ClaudeRuntimeAuthService } from './runtime-auth-service' import type { ClaudeAccountSelectionTarget } from './runtime-selection' +export type ClaudeAccountAddTarget = { + runtime?: 'host' | 'wsl' + wslDistro?: string | null +} + +export type ClaudeAccountImportOptions = ClaudeAccountAddTarget & { + previousLegacyCredentialsSha256?: string | null +} + export class ClaudeAccountService { private mutationQueue: Promise = Promise.resolve() private cancelPendingClaudeLogin: (() => boolean) | null = null + private readonly storage = new ClaudeManagedAuthStorage() private readonly selection: ClaudeAccountSelection private readonly registration: ClaudeAccountRegistration constructor( - store: ClaudeAccountStore, - rateLimits: ClaudeAccountUsage, - private readonly runtimeAuth: ClaudeAccountRuntime & - Pick, - private readonly runLoginCommand = runClaudeCommandProcess + store: Store, + rateLimits: RateLimitService, + private readonly runtimeAuth: ClaudeRuntimeAuthService ) { - this.selection = new ClaudeAccountSelection(store, rateLimits, runtimeAuth) + this.selection = new ClaudeAccountSelection(store, rateLimits, runtimeAuth, (accountId, path) => + this.safeRemoveManagedAuth(accountId, path) + ) this.registration = new ClaudeAccountRegistration({ store, rateLimits, runtimeAuth, - selection: this.selection + selection: this.selection, + createManagedAuth: (accountId, target) => this.storage.create(accountId, target), + assertManagedAuth: (path, accountId) => this.storage.assertOwned(path, accountId), + removeManagedAuth: (accountId, path) => this.safeRemoveManagedAuth(accountId, path), + writeManagedAuth: (accountId, path, captured) => + this.writeManagedAuth(accountId, path, captured), + writeCredentials: (accountId, path, value) => + this.storage.writeCredentials(accountId, path, value), + writeOauth: (accountId, path, value) => + this.storage.writeOauthAccount(accountId, path, value), + readSnapshot: (accountId, path) => this.storage.readSnapshot(accountId, path), + restoreCredentials: (accountId, path, snapshot) => + this.storage.restoreCredentials(accountId, path, snapshot), + restoreOauth: (accountId, path, snapshot) => + this.storage.restoreOauth(accountId, path, snapshot), + login: (location) => this.runClaudeLoginAndCapture(location), + captureExisting: (configDir, previousDigest) => + this.captureFromExistingConfigDir(configDir, previousDigest) }) } @@ -41,45 +75,34 @@ export class ClaudeAccountService { return this.selection.list() } - /** A hidden `claude auth login` opens the browser and writes into the new account's folder. */ - addAccount(target: ClaudeAccountSelectionTarget = {}): Promise { - return this.signInHidden({ runtime: target.runtime, wslDistro: target.wslDistro }) - } - - reauthenticateAccount(accountId: string): Promise { - return this.signInHidden({ accountId }) - } - - cancelPendingLogin(): boolean { - return this.cancelPendingClaudeLogin?.() ?? false - } - - beginSignIn(request: ClaudeSignInRequest = {}): Promise { - return this.serializeMutation(() => this.registration.begin(request)) + async addAccount(target?: ClaudeAccountAddTarget): Promise { + this.supersedePendingLogin() + return this.serializeMutation(() => this.registration.add(target)) } - finishSignIn( - signIn: Omit + async addAccountFromConfigDir( + configDir: string, + options?: ClaudeAccountImportOptions ): Promise { - return this.serializeMutation(() => this.registration.finish(signIn)) + return this.serializeMutation(() => this.registration.addFromConfigDir(configDir, options)) } - /** Deletes the folder of a sign-in that never became an account; a saved account's is kept. */ - cancelSignIn(signIn: Omit): Promise { - return this.serializeMutation(() => this.registration.cancel(signIn)) + async reauthenticateAccount(accountId: string): Promise { + this.supersedePendingLogin() + return this.serializeMutation(() => this.registration.reauthenticate(accountId)) } - removeAccount(accountId: string): Promise { + async removeAccount(accountId: string): Promise { this.supersedePendingLogin() return this.serializeMutation(() => this.selection.remove(accountId)) } - selectAccount(accountId: string | null): Promise { + async selectAccount(accountId: string | null): Promise { this.supersedePendingLogin() return this.serializeMutation(() => this.selection.select(accountId)) } - selectAccountForTarget( + async selectAccountForTarget( accountId: string | null, target?: ClaudeAccountSelectionTarget ): Promise { @@ -87,26 +110,13 @@ export class ClaudeAccountService { return this.serializeMutation(() => this.selection.select(accountId, target)) } - getRuntimeConfigDir(target?: ClaudeAccountSelectionTarget): string { - return this.runtimeAuth.getRuntimeConfigDir(target) - } - - private signInHidden(request: ClaudeSignInRequest): Promise { - this.supersedePendingLogin() - return this.serializeMutation(() => - this.registration.signIn(request, (folder) => - runClaudeLoginSession(folder, { - runCommand: this.runLoginCommand, - setCancel: (cancel) => { - this.cancelPendingClaudeLogin = cancel - } - }) - ) - ) + cancelPendingLogin(): boolean { + return this.cancelPendingClaudeLogin?.() ?? false } - // Why before the queue, not inside it: the abandoned login owns the queue slot every later - // account action waits for. Only user-driven actions supersede it. + // Why before the queue, not inside it: the abandoned login owns the queue slot + // every later account action waits for. Called from the four the user drives, + // never from serializeMutation, which background work also uses. private supersedePendingLogin(): void { if (this.cancelPendingLogin()) { console.info( @@ -115,9 +125,91 @@ export class ClaudeAccountService { } } + getRuntimeConfigDir(target?: ClaudeAccountSelectionTarget): string { + return this.runtimeAuth.getRuntimeConfigDir(target) + } + private serializeMutation(operation: () => Promise): Promise { const next = this.mutationQueue.then(operation, operation) this.mutationQueue = next.catch(() => {}) return next } + + private runClaudeLoginAndCapture( + location: ClaudeManagedAuthLocation = { + managedAuthPath: '', + managedAuthRuntime: 'host', + wslDistro: null, + wslLinuxAuthPath: null + } + ): Promise { + return runClaudeLoginSession(location, { + runCommand: (args, config, timeoutMs, options) => + this.runClaudeCommand(args, config, timeoutMs, options), + capture: (configDir, status, previousLegacy) => + this.captureAuthFromConfigDir(configDir, status, previousLegacy), + setCancel: (cancel) => { + this.cancelPendingClaudeLogin = cancel + } + }) + } + + private runClaudeCommand( + args: string[], + configDir: ClaudeCommandConfig, + timeoutMs: number, + options?: ClaudeCommandOptions + ): Promise { + return runClaudeCommandProcess(args, configDir, timeoutMs, options) + } + + private captureFromExistingConfigDir( + configDir: string, + previousLegacyCredentialsSha256?: string | null + ): Promise { + return captureClaudeAuthFromExistingConfigDir( + configDir, + previousLegacyCredentialsSha256, + (args, config, timeoutMs, options) => this.runClaudeCommand(args, config, timeoutMs, options) + ) + } + + private captureAuthFromConfigDir( + configDir: string, + statusOutput: string, + previousLegacyKeychain: string | null, + previousLegacyCredentialsSha256?: string | null + ): Promise { + return captureClaudeAuthFromConfigDir( + configDir, + statusOutput, + previousLegacyKeychain, + previousLegacyCredentialsSha256, + (path, previous, digest) => this.readCapturedCredentials(path, previous, digest) + ) + } + + private readCapturedCredentials( + configDir: string, + previousLegacyKeychain: string | null, + previousLegacyCredentialsSha256?: string | null + ): Promise { + return readCapturedClaudeCredentials( + configDir, + previousLegacyKeychain, + previousLegacyCredentialsSha256 + ) + } + + private writeManagedAuth( + accountId: string, + managedAuthPath: string, + captured: CapturedClaudeAuth + ): Promise { + return this.storage.writeAuth(accountId, managedAuthPath, captured) + } + + private safeRemoveManagedAuth(accountId: string, path: string): Promise { + return this.storage.remove(accountId, path) + } } diff --git a/src/main/claude-accounts/shared-credential-fields.test.ts b/src/main/claude-accounts/shared-credential-fields.test.ts new file mode 100644 index 000000000000..097e1725d128 --- /dev/null +++ b/src/main/claude-accounts/shared-credential-fields.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from 'vitest' +import { + mergeSharedClaudeCredentialFields, + SHARED_CLAUDE_CREDENTIAL_KEYS +} from './shared-credential-fields' + +describe('mergeSharedClaudeCredentialFields', () => { + it('merges the live credential shared fields into the target credential', () => { + const target = JSON.stringify({ claudeAiOauth: { accessToken: 'target-token' } }) + const live = JSON.stringify({ + claudeAiOauth: { accessToken: 'live-token' }, + mcpOAuth: { conn1: 'v1' }, + pluginSecrets: { s: 1 } + }) + const result = JSON.parse(mergeSharedClaudeCredentialFields(target, live)) + expect(result.claudeAiOauth).toEqual({ accessToken: 'target-token' }) + expect(result.mcpOAuth).toEqual({ conn1: 'v1' }) + expect(result.pluginSecrets).toEqual({ s: 1 }) + }) + + it('is absence-authoritative: a shared key missing on live is not carried from the target', () => { + const target = JSON.stringify({ + claudeAiOauth: { accessToken: 'target-token' }, + mcpOAuth: { stale: 'rotated-out' } + }) + const live = JSON.stringify({ claudeAiOauth: { accessToken: 'live-token' } }) + const result = JSON.parse(mergeSharedClaudeCredentialFields(target, live)) + expect(result.mcpOAuth).toBeUndefined() + }) + + it('leaves account-scoped sibling keys on the target untouched', () => { + const target = JSON.stringify({ + claudeAiOauth: { accessToken: 'target-token' }, + trustedDeviceToken: 'target-device-token' + }) + const live = JSON.stringify({ + claudeAiOauth: { accessToken: 'live-token' }, + mcpOAuth: { conn1: 'v1' } + }) + const result = JSON.parse(mergeSharedClaudeCredentialFields(target, live)) + expect(result.trustedDeviceToken).toBe('target-device-token') + expect(result.mcpOAuth).toEqual({ conn1: 'v1' }) + }) + + it('returns the target unchanged when there is no live credential to merge from', () => { + const target = JSON.stringify({ claudeAiOauth: { accessToken: 'target-token' } }) + expect(mergeSharedClaudeCredentialFields(target, null)).toBe(target) + }) + + it('returns the target unchanged when the live value is not a JSON object (e.g. a raw managed API key)', () => { + const target = JSON.stringify({ claudeAiOauth: { accessToken: 'target-token' } }) + expect(mergeSharedClaudeCredentialFields(target, 'sk-ant-api-not-json')).toBe(target) + }) + + it('returns the target unchanged when the target itself is not a Claude OAuth credential object (managed API key)', () => { + const target = 'sk-ant-api-a-raw-managed-key' + const live = JSON.stringify({ claudeAiOauth: {}, mcpOAuth: { conn1: 'v1' } }) + expect(mergeSharedClaudeCredentialFields(target, live)).toBe(target) + }) + + it('returns the target unchanged when the target JSON is malformed', () => { + const target = '{not valid json' + const live = JSON.stringify({ claudeAiOauth: {}, mcpOAuth: { conn1: 'v1' } }) + expect(mergeSharedClaudeCredentialFields(target, live)).toBe(target) + }) + + it('returns the target unchanged when claudeAiOauth is null rather than an object', () => { + const target = JSON.stringify({ claudeAiOauth: null }) + const live = JSON.stringify({ claudeAiOauth: {}, mcpOAuth: { conn1: 'v1' } }) + expect(mergeSharedClaudeCredentialFields(target, live)).toBe(target) + }) + + it('returns the target unchanged when claudeAiOauth is a primitive rather than an object', () => { + const target = JSON.stringify({ claudeAiOauth: 'not-an-object' }) + const live = JSON.stringify({ claudeAiOauth: {}, mcpOAuth: { conn1: 'v1' } }) + expect(mergeSharedClaudeCredentialFields(target, live)).toBe(target) + }) + + it('returns the target byte-for-byte unchanged when neither side has any shared key (no-op merge)', () => { + // Why: a no-op reformat (e.g. dropped trailing newline) reads as an external refresh downstream. + const target = `${JSON.stringify({ + claudeAiOauth: { accessToken: 'target-token', refreshToken: 'target-refresh' } + })}\n` + const live = JSON.stringify({ claudeAiOauth: { accessToken: 'live-token' } }) + expect(mergeSharedClaudeCredentialFields(target, live)).toBe(target) + }) + + it('covers every documented shared key, not just mcpOAuth', () => { + // Why: pins the actual key names, so removing one from the constant fails this test. + expect(SHARED_CLAUDE_CREDENTIAL_KEYS).toEqual([ + 'mcpOAuth', + 'mcpOAuthClientConfig', + 'mcpXaaIdp', + 'mcpXaaIdpConfig', + 'pluginSecrets' + ]) + const target = JSON.stringify({ claudeAiOauth: {} }) + const liveObj: Record = { claudeAiOauth: {} } + for (const key of SHARED_CLAUDE_CREDENTIAL_KEYS) { + liveObj[key] = { present: true } + } + const result = JSON.parse(mergeSharedClaudeCredentialFields(target, JSON.stringify(liveObj))) + for (const key of SHARED_CLAUDE_CREDENTIAL_KEYS) { + expect(result[key]).toEqual({ present: true }) + } + }) + + it('returns the target byte-for-byte unchanged when an existing shared key keeps its value (interleaved order)', () => { + // Why: rebuilding via key-order iteration used to move an existing shared key to the + // end even when its value did not change, causing a spurious formatting-only rewrite. + const target = `${JSON.stringify({ + claudeAiOauth: { accessToken: 'target-token' }, + mcpOAuth: { conn1: 'v1' }, + trustedDeviceToken: 'target-device-token' + })}\n` + const live = JSON.stringify({ + claudeAiOauth: { accessToken: 'live-token' }, + mcpOAuth: { conn1: 'v1' } + }) + expect(mergeSharedClaudeCredentialFields(target, live)).toBe(target) + }) + + it('still updates an interleaved shared key in place when its live value actually differs', () => { + const target = JSON.stringify({ + claudeAiOauth: { accessToken: 'target-token' }, + mcpOAuth: { conn1: 'stale' }, + trustedDeviceToken: 'target-device-token' + }) + const live = JSON.stringify({ + claudeAiOauth: { accessToken: 'live-token' }, + mcpOAuth: { conn1: 'fresh' } + }) + const result = JSON.parse(mergeSharedClaudeCredentialFields(target, live)) + expect(result.mcpOAuth).toEqual({ conn1: 'fresh' }) + expect(result.trustedDeviceToken).toBe('target-device-token') + }) +}) diff --git a/src/main/claude-accounts/shared-credential-fields.ts b/src/main/claude-accounts/shared-credential-fields.ts new file mode 100644 index 000000000000..e0014f25c4b8 --- /dev/null +++ b/src/main/claude-accounts/shared-credential-fields.ts @@ -0,0 +1,166 @@ +import { isDeepStrictEqual } from 'node:util' + +export const SHARED_CLAUDE_CREDENTIAL_KEYS = [ + 'mcpOAuth', + 'mcpOAuthClientConfig', + 'mcpXaaIdp', + 'mcpXaaIdpConfig', + 'pluginSecrets' +] as const + +function parseCredentialObject(credentialsJson: string | null): Record | null { + if (!credentialsJson) { + return null + } + let parsed: unknown + try { + parsed = JSON.parse(credentialsJson) + } catch { + return null + } + return isCredentialObject(parsed) ? parsed : null +} + +function isCredentialObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +export function stripSharedClaudeCredentialFields(credentialsJson: string): string { + const credential = parseCredentialObject(credentialsJson) + if (!credential) { + return credentialsJson + } + let changed = false + for (const key of SHARED_CLAUDE_CREDENTIAL_KEYS) { + if (Object.hasOwn(credential, key)) { + delete credential[key] + changed = true + } + } + return changed ? JSON.stringify(credential) : credentialsJson +} + +// Shared connector state follows the live runtime, including revocations, rather than frozen account snapshots. +export function mergeSharedClaudeCredentialFields( + targetCredentialsJson: string, + liveCredentialsJson: string | null +): string { + const target = parseCredentialObject(targetCredentialsJson) + const live = parseCredentialObject(liveCredentialsJson) + if ( + !target || + !live || + (Object.hasOwn(target, 'claudeAiOauth') && !isCredentialObject(target.claudeAiOauth)) + ) { + return targetCredentialsJson + } + + let changed = false + const merged: Record = { ...target } + for (const key of SHARED_CLAUDE_CREDENTIAL_KEYS) { + const targetHasKey = Object.hasOwn(target, key) + if (Object.hasOwn(live, key)) { + if (!targetHasKey || JSON.stringify(live[key]) !== JSON.stringify(target[key])) { + merged[key] = live[key] + changed = true + } + } else if (targetHasKey) { + delete merged[key] + changed = true + } + } + return changed ? JSON.stringify(merged) : targetCredentialsJson +} + +type CredentialField = { present: boolean; value: unknown } + +function credentialField(record: Record, key: string): CredentialField { + const present = Object.hasOwn(record, key) + return { present, value: present ? record[key] : undefined } +} + +function resolveCredentialField( + candidates: CredentialField[], + baseline: CredentialField | null +): CredentialField { + const changes = candidates.filter((candidate) => + baseline === null ? candidate.present : !isDeepStrictEqual(candidate, baseline) + ) + const first = changes[0] + if (first && changes.some((candidate) => !isDeepStrictEqual(candidate, first))) { + throw new Error( + 'Cannot switch Claude accounts: live connector credentials conflict; existing authorizations were preserved' + ) + } + return first ?? baseline ?? { present: false, value: undefined } +} + +function resolveServerGrants( + sources: Record[], + baseline: Record | null, + key: string +): CredentialField | null { + const fields = sources.map((source) => credentialField(source, key)) + const previous = baseline === null ? null : credentialField(baseline, key) + if ( + fields.some((field) => field.present && !isCredentialObject(field.value)) || + (previous?.present && !isCredentialObject(previous.value)) + ) { + return null + } + const maps = fields.map((field) => (isCredentialObject(field.value) ? field.value : {})) + const previousMap = + previous === null ? null : isCredentialObject(previous.value) ? previous.value : {} + const names = new Set([ + ...maps.flatMap((map) => Object.keys(map)), + ...Object.keys(previousMap ?? {}) + ]) + const entries: [string, unknown][] = [] + for (const name of names) { + // Keep a server's access/refresh token pair atomic while combining independent server updates. + const grant = resolveCredentialField( + maps.map((map) => credentialField(map, name)), + previousMap === null ? null : credentialField(previousMap, name) + ) + if (grant.present) { + entries.push([name, grant.value]) + } + } + const present = + entries.length > 0 || + (fields.some((field) => field.present) && + !(baseline !== null && fields.some((field) => !field.present))) + return { present, value: present ? Object.fromEntries(entries) : undefined } +} + +export function reconcileSharedClaudeCredentialFields( + liveCredentials: string[], + lastWrittenCredentials: string | null +): string { + const sources = liveCredentials.map((credentials) => { + const parsed = parseCredentialObject(credentials) + if (!parsed) { + throw new Error('Cannot preserve malformed Claude runtime credentials') + } + return parsed + }) + if (sources.length === 0) { + return '{}' + } + const baseline = parseCredentialObject(lastWrittenCredentials) + const entries: [string, unknown][] = [] + for (const key of SHARED_CLAUDE_CREDENTIAL_KEYS) { + const field = + (key === 'mcpOAuth' || key === 'mcpOAuthClientConfig' + ? resolveServerGrants(sources, baseline, key) + : null) ?? + resolveCredentialField( + sources.map((source) => credentialField(source, key)), + baseline === null ? null : credentialField(baseline, key) + ) + if (field.present) { + entries.push([key, field.value]) + } + } + return JSON.stringify(Object.fromEntries(entries)) +} diff --git a/src/main/claude-accounts/shared-credential-reconciliation.test.ts b/src/main/claude-accounts/shared-credential-reconciliation.test.ts new file mode 100644 index 000000000000..f772b5634c99 --- /dev/null +++ b/src/main/claude-accounts/shared-credential-reconciliation.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from 'vitest' +import { reconcileSharedClaudeCredentialFields } from './shared-credential-fields' + +const original = { accessToken: 'access', refreshToken: 'refresh' } +const rotated = { accessToken: 'rotated-access', refreshToken: 'rotated-refresh' } +const baseline = JSON.stringify({ + mcpOAuth: { figma: original }, + pluginSecrets: { plugin: 'secret' } +}) + +describe('live Claude connector reconciliation', () => { + it('combines independent server grants on startup without importing account identity', () => { + const sources = [ + JSON.stringify({ claudeAiOauth: { accessToken: 'account' }, mcpOAuth: { figma: original } }), + JSON.stringify({ + mcpOAuth: { notion: rotated }, + mcpOAuthClientConfig: { notion: { clientId: 'client' } } + }), + '{}' + ] + const expected = { + mcpOAuth: { figma: original, notion: rotated }, + mcpOAuthClientConfig: { notion: { clientId: 'client' } } + } + expect(JSON.parse(reconcileSharedClaudeCredentialFields(sources, null))).toEqual(expected) + expect(JSON.parse(reconcileSharedClaudeCredentialFields(sources.toReversed(), null))).toEqual( + expected + ) + }) + + it('combines independent rotations and additions against the previous write', () => { + const sources = [ + JSON.stringify({ mcpOAuth: { figma: rotated }, pluginSecrets: { plugin: 'secret' } }), + JSON.stringify({ + mcpOAuth: { figma: original, notion: original }, + pluginSecrets: { plugin: 'new-secret' } + }), + baseline + ] + const expected = { + mcpOAuth: { figma: rotated, notion: original }, + pluginSecrets: { plugin: 'new-secret' } + } + expect(JSON.parse(reconcileSharedClaudeCredentialFields(sources, baseline))).toEqual(expected) + expect( + JSON.parse(reconcileSharedClaudeCredentialFields(sources.toReversed(), baseline)) + ).toEqual(expected) + }) + + it('keeps a revocation while another store adds an unrelated server', () => { + const sources = [ + JSON.stringify({ mcpOAuth: {}, pluginSecrets: { plugin: 'secret' } }), + JSON.stringify({ + mcpOAuth: { figma: original, notion: rotated }, + pluginSecrets: { plugin: 'secret' } + }), + baseline + ] + expect(JSON.parse(reconcileSharedClaudeCredentialFields(sources, baseline))).toEqual({ + mcpOAuth: { notion: rotated }, + pluginSecrets: { plugin: 'secret' } + }) + }) + + it.each([null, baseline])( + 'rejects conflicting server token pairs with baseline %s', + (previous) => { + const sources = [ + JSON.stringify({ mcpOAuth: { figma: rotated } }), + JSON.stringify({ + mcpOAuth: { figma: { accessToken: 'other-access', refreshToken: 'other-refresh' } } + }) + ] + expect(() => reconcileSharedClaudeCredentialFields(sources, previous)).toThrow( + 'live connector credentials conflict' + ) + expect(() => reconcileSharedClaudeCredentialFields(sources.toReversed(), previous)).toThrow( + 'live connector credentials conflict' + ) + } + ) + + it('does not recombine access and refresh tokens from different writes', () => { + const sources = [ + JSON.stringify({ mcpOAuth: { figma: { ...original, accessToken: 'new-access' } } }), + JSON.stringify({ mcpOAuth: { figma: { ...original, refreshToken: 'new-refresh' } } }) + ] + expect(() => reconcileSharedClaudeCredentialFields(sources, baseline)).toThrow( + 'live connector credentials conflict' + ) + }) + + it('does not infer MCP freshness from Claude account-token expiry', () => { + const sources = [ + JSON.stringify({ claudeAiOauth: { expiresAt: 1 }, mcpOAuth: { figma: original } }), + JSON.stringify({ claudeAiOauth: { expiresAt: 9999999999999 }, mcpOAuth: { figma: rotated } }) + ] + expect(() => reconcileSharedClaudeCredentialFields(sources, null)).toThrow( + 'live connector credentials conflict' + ) + }) + + it('rejects conflicting non-server fields instead of combining their secrets', () => { + expect(() => + reconcileSharedClaudeCredentialFields( + [ + JSON.stringify({ pluginSecrets: { plugin: 'one' } }), + JSON.stringify({ pluginSecrets: { plugin: 'two' } }) + ], + null + ) + ).toThrow('live connector credentials conflict') + }) + + it('does not resurrect the last-written grants when every live store is missing', () => { + expect(reconcileSharedClaudeCredentialFields([], baseline)).toBe('{}') + }) + + it('treats an explicit null as an authoritative field removal after a known write', () => { + expect( + JSON.parse( + reconcileSharedClaudeCredentialFields( + [JSON.stringify({ mcpOAuth: null, pluginSecrets: { plugin: 'secret' } }), baseline], + baseline + ) + ) + ).toEqual({ mcpOAuth: null, pluginSecrets: { plugin: 'secret' } }) + }) +}) diff --git a/src/main/claude/claude-model-catalog-probe.ts b/src/main/claude/claude-model-catalog-probe.ts index 960a78e0236c..7a316bd0f40c 100644 --- a/src/main/claude/claude-model-catalog-probe.ts +++ b/src/main/claude/claude-model-catalog-probe.ts @@ -16,6 +16,7 @@ export type ClaudeModelCatalogProbeDeps = Pick< ClaudeStructuredLaunchResolverDeps, 'resolveCommand' | 'resolveEnv' | 'resolveInheritedEnv' | 'resolveAuthPolicy' > & { + authSwitchSettleTimeoutMs?: number /** Test seams; production runs the one-shot listing child. */ discover?: typeof discoverModelsLocal spawnAgent?: typeof spawnSourceControlAgent diff --git a/src/main/claude/claude-stream-json-connection.test.ts b/src/main/claude/claude-stream-json-connection.test.ts index 276328b009cd..e26e02263031 100644 --- a/src/main/claude/claude-stream-json-connection.test.ts +++ b/src/main/claude/claude-stream-json-connection.test.ts @@ -5,6 +5,7 @@ import { join } from 'node:path' import { providerDiagnosticOf } from '../../shared/agent-session-failure' import { afterEach, describe, expect, it, vi } from 'vitest' import { spawnProcess, type SpawnedProcess } from '../../shared/child-process/run-process' +import { hasLiveClaudePtys } from '../claude-accounts/live-pty-gate' import type { ProcessSpec } from '../../shared/child-process/process-spec' import { query, type CanUseTool, type Options } from '@anthropic-ai/claude-agent-sdk' import { @@ -760,3 +761,85 @@ describe('Claude stream-json connection', () => { await expect(connection.close()).resolves.toBe(true) }, 20_000) }) + +// A structured Claude child owns the account's credentials while it runs, exactly as +// a Claude PTY does. The gate is what makes runtime-auth-sync defer the managed OAuth +// refresh instead of rotating the single-use token out from under a live session, and +// structured sessions used to be invisible to it. +describe('the managed-auth live gate', () => { + it('holds while a structured child runs and releases when it ends', async () => { + // The gate is a process-wide singleton and a sibling test's release lands on its + // child's 'close' event, which can settle after that test's close() resolved. + await until(() => (hasLiveClaudePtys() ? null : true), 'a drained auth gate') + const scenario = scriptScenario([ + { emit: { type: 'system', subtype: 'init', session_id: SESSION_ID, uuid: 'init-1' } }, + { wait: HOLD_OPEN } + ]) + const connection = await open(launchFor(scenario)) + + expect(hasLiveClaudePtys()).toBe(true) + + await connection.close() + + await until(() => (hasLiveClaudePtys() ? null : true), 'the auth gate to drain') + expect(hasLiveClaudePtys()).toBe(false) + }, 30_000) + + it('releases when the child dies on its own rather than through close()', async () => { + await until(() => (hasLiveClaudePtys() ? null : true), 'a drained auth gate') + const scenario = scriptScenario([ + { emit: { type: 'system', subtype: 'init', session_id: SESSION_ID, uuid: 'init-1' } }, + { wait: HOLD_OPEN } + ]) + await open(launchFor(scenario)) + expect(hasLiveClaudePtys()).toBe(true) + + spawnedChildren.at(-1)?.kill('SIGKILL') + + await until(() => (hasLiveClaudePtys() ? null : true), 'the auth gate to drain') + expect(hasLiveClaudePtys()).toBe(false) + }, 30_000) + + // The gate entry is deliberately unpersisted, so confirmSeededClaudeLivePtys can never + // reconcile a stray one: a leak here defers the managed OAuth refresh for the life of + // the process. Entering the gate only after the release handlers are attached makes + // that unreachable regardless of what the setup in between does. + it('leaks no gate entry when setup throws between spawn and handler attachment', async () => { + await until(() => (hasLiveClaudePtys() ? null : true), 'a drained auth gate') + const scenario = scriptScenario([ + { emit: { type: 'system', subtype: 'init', session_id: SESSION_ID, uuid: 'init-1' } }, + { wait: HOLD_OPEN } + ]) + let started: SpawnedProcess | null = null + + try { + await expect( + openClaudeStreamJsonConnection(launchFor(scenario), {}, (spec) => { + const child = spawnProcess(spec) + started = child + const attach = child.stderr.on.bind(child.stderr) + // Measured attach order: the SDK binds stderr 'data' from inside query(), + // before the child is even assigned. The SECOND bind is this connection's own + // armTreeOnOutput — the first statement that runs after the child exists and + // before its 'exit'/'close' release handlers. Throwing on the first is + // vacuous: it escapes before any gate entry could have happened. + let dataAttaches = 0 + child.stderr.on = ((event: string, listener: (...args: unknown[]) => void) => { + if (event === 'data') { + dataAttaches += 1 + if (dataAttaches === 2) { + throw new Error('stderr listener attach failed') + } + } + return attach(event, listener) + }) as typeof child.stderr.on + return child + }) + ).rejects.toThrow('stderr listener attach failed') + + expect(hasLiveClaudePtys()).toBe(false) + } finally { + ;(started as SpawnedProcess | null)?.kill('SIGKILL') + } + }, 30_000) +}) diff --git a/src/main/claude/claude-stream-json-connection.ts b/src/main/claude/claude-stream-json-connection.ts index e270110fc6c2..8e178cb720a7 100644 --- a/src/main/claude/claude-stream-json-connection.ts +++ b/src/main/claude/claude-stream-json-connection.ts @@ -1,7 +1,12 @@ +import { randomUUID } from 'node:crypto' import { providerDiagnostic, withProviderDiagnostic } from '../../shared/agent-session-failure' import type * as ClaudeAgentSdk from '@anthropic-ai/claude-agent-sdk' import type { CanUseTool, OnUserDialog, SDKUserMessage } from '@anthropic-ai/claude-agent-sdk' import { spawnProcess } from '../../shared/child-process/run-process' +import { + markClaudeStructuredChildExited, + markClaudeStructuredChildSpawned +} from '../claude-accounts/live-pty-gate' import { buildClaudeChildProcessEnv } from './claude-child-process-environment' import { withoutInheritedClaudeConfigDir } from './claude-config-dir-pin' import { @@ -153,6 +158,12 @@ export async function openClaudeStreamJsonConnection( if (!child || !managed) { throw new Error('the claude agent SDK returned without spawning a child') } + // This child owns the account's credentials for as long as it runs, exactly as a + // Claude PTY does — hold the OAuth-refresh gate so a managed refresh cannot rotate + // the single-use token out from under it mid-turn. Entered below, once a release + // path exists. + const authGateKey = randomUUID() + const releaseAuthGate = (): void => markClaudeStructuredChildExited(authGateKey) let exitStatus: ExitStatus | null = null let closing = false let terminalError: Error | null = null @@ -251,6 +262,7 @@ export async function openClaudeStreamJsonConnection( managed.onExit((exit) => { exitStatus = exit + releaseAuthGate() handleUnexpectedEnd() }) child.on('error', (error) => { @@ -260,6 +272,7 @@ export async function openClaudeStreamJsonConnection( handleUnexpectedEnd(error) }) child.on('close', () => { + releaseAuthGate() handleUnexpectedEnd() }) child.stdin.on('error', (error) => { @@ -268,6 +281,13 @@ export async function openClaudeStreamJsonConnection( handleUnexpectedEnd(error) } }) + // Why here and not at spawn: a structured gate entry is deliberately unpersisted, so + // confirmSeededClaudeLivePtys can never reconcile a stray one and a leak defers the + // managed OAuth refresh for the life of the process. Entering only after 'exit' and + // 'close' are attached makes that unreachable — any later throw still leaves a + // listener that releases. Nothing between spawn and here can yield, so the child + // cannot end before the gate is entered. + markClaudeStructuredChildSpawned(authGateKey) const send: ClaudeStreamJsonConnection['send'] = (message, beforeDispatch) => { if ( diff --git a/src/main/claude/claude-structured-auth-parity.test.ts b/src/main/claude/claude-structured-auth-parity.test.ts index 2a22fae853ae..227fb117914c 100644 --- a/src/main/claude/claude-structured-auth-parity.test.ts +++ b/src/main/claude/claude-structured-auth-parity.test.ts @@ -1,8 +1,20 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionRecord } from '../../shared/agent-session-record' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' -import { CLAUDE_AUTH_ENV_CONFLICT_MESSAGE } from '../claude-accounts/environment' +import { beginClaudeAuthSwitch, endClaudeAuthSwitch } from '../claude-accounts/live-pty-gate' +import { + CLAUDE_AUTH_ENV_CONFLICT_MESSAGE, + CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE +} from '../claude-accounts/environment' import { createClaudeStructuredLaunchResolver } from './claude-structured-launch-resolution' +import { ClaudeStructuredSessionAdapter } from './claude-structured-session-adapter' +import { + PROVIDER_SESSION_ID, + adapterFor, + fakeClaude, + identityFor +} from './claude-structured-session-test-support' +import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const SESSION_ID = 'orca-session-auth' const IDENTITY = { sessionId: SESSION_ID } as Parameters< @@ -27,6 +39,7 @@ function record(): AgentSessionRecord { function resolverFor(options: { stripAuthEnv: boolean overlay?: Record + authSwitchSettleTimeoutMs?: number }): ReturnType { return createClaudeStructuredLaunchResolver({ resolveLaunchArgs: () => [], @@ -34,10 +47,41 @@ function resolverFor(options: { resolveWorkspacePath: async (id) => `/repos/${id}`, resolveCommand: () => '/usr/local/bin/claude', resolveAuthPolicy: () => ({ stripAuthEnv: options.stripAuthEnv }), + authSwitchSettleTimeoutMs: options.authSwitchSettleTimeoutMs ?? 20, ...(options.overlay ? { resolveEnv: () => options.overlay as Record } : {}) }) } +/** + * An adapter driven by the REAL launch resolver, not the stub in the shared test + * support — the stub has no auth guard at all, so a teardown-window test built on it + * would pass whatever the guard did. + */ +function realResolverAdapter( + claude: ReturnType, + authSwitchSettleTimeoutMs: number +): ClaudeStructuredSessionAdapter { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resolver reads only the chain head's handle from this partial record. + const resumable = { + ...record(), + providerHandleChain: [{ handle: claudeProviderHandle(PROVIDER_SESSION_ID, null) }] + } as unknown as AgentSessionRecord + return new ClaudeStructuredSessionAdapter({ + resolveLaunch: createClaudeStructuredLaunchResolver({ + resolveLaunchArgs: () => [], + store: { getRecord: () => resumable, pinLaunchDirectory: vi.fn() }, + resolveWorkspacePath: async (id) => `/repos/${id}`, + resolveCommand: () => '/usr/local/bin/claude', + resolveAuthPolicy: () => ({ stripAuthEnv: false }), + authSwitchSettleTimeoutMs + }), + openConnection: claude.openConnection, + readProcessStartTime: async () => 1_700_000_000_000, + now: () => 1_700_000_000_500, + persistHandle: async () => {} + }) +} + function withAmbientAuth(value: string, run: () => Promise): Promise { const restore = process.env.ANTHROPIC_API_KEY process.env.ANTHROPIC_API_KEY = value @@ -51,6 +95,10 @@ function withAmbientAuth(value: string, run: () => Promise): Promise { } describe('claude structured auth parity with the terminal preflight', () => { + afterEach(() => { + endClaudeAuthSwitch() + }) + // Task 1 — the terminal preflight refuses this at spawn-env.ts:25 and // runtime/spawn-preflight.ts:139; the structured path used to let the override win. it('refuses an explicit Anthropic auth override while a managed account is pinned', async () => { @@ -113,4 +161,91 @@ describe('claude structured auth parity with the terminal preflight', () => { expect(launch.env?.ANTHROPIC_API_KEY).toBeUndefined() }) }) + + // Task 3 — the terminal preflight guards this at four sites; the structured path had none. + it('refuses launch resolution when an account switch never settles', async () => { + beginClaudeAuthSwitch() + + await expect( + resolverFor({ stripAuthEnv: true, authSwitchSettleTimeoutMs: 20 })({ identity: IDENTITY }) + ).rejects.toMatchObject({ + message: CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE, + reason: 'accountSwitchInProgress' + }) + }) + + it('waits a settling account switch out rather than refusing a resolved launch', async () => { + beginClaudeAuthSwitch() + setTimeout(() => endClaudeAuthSwitch(), 20) + + const launch = await resolverFor({ + stripAuthEnv: true, + authSwitchSettleTimeoutMs: 5_000 + })({ identity: IDENTITY }) + + expect(launch.claudeConfigDir).toBe('/home/work/.claude') + }) + + it('refuses an acquire before it tears the previous session down', async () => { + const claude = fakeClaude() + const adapter = adapterFor(claude) + beginClaudeAuthSwitch() + + await expect( + adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + ).rejects.toMatchObject({ + message: CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE, + reason: 'accountSwitchInProgress' + }) + // Nothing was spawned, so the refusal must not have opened a connection. + expect(claude.connections).toHaveLength(0) + }) + + // The teardown between the entry guard and launch resolution closes the live child + // and proves its tree — seconds, not milliseconds. A switch that begins inside it + // has already cost the user their session, so refusing there produces exactly the + // outcome the entry guard advertises against: a dead chat and no replacement. + it('replaces the session when a switch begins inside the acquire teardown', async () => { + const claude = fakeClaude() + const adapter = realResolverAdapter(claude, 5_000) + await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + const live = claude.connections[0]! + const closeWithSwitch = live.close + live.close = async () => { + beginClaudeAuthSwitch() + setTimeout(() => endClaudeAuthSwitch(), 20) + return closeWithSwitch() + } + + await expect( + adapter.acquire({ identity: identityFor(), fence: 8, spawnToken: 'spawn-10' }) + ).resolves.toMatchObject({ process: { spawnToken: 'spawn-10' } }) + expect(live.closed).toBe(true) + // The replacement child exists: the user's chat came back. + expect(claude.connections).toHaveLength(2) + expect(claude.connections[1]!.closed).toBe(false) + await adapter.closeAll() + }) + + it('still refuses a mid-teardown switch that never settles, leaving nothing half-open', async () => { + const claude = fakeClaude() + const adapter = realResolverAdapter(claude, 20) + await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + const live = claude.connections[0]! + const closeWithSwitch = live.close + live.close = async () => { + beginClaudeAuthSwitch() + return closeWithSwitch() + } + + await expect( + adapter.acquire({ identity: identityFor(), fence: 8, spawnToken: 'spawn-10' }) + ).rejects.toMatchObject({ + message: CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE, + reason: 'accountSwitchInProgress' + }) + // No replacement child was opened, so nothing is left running unowned. + expect(claude.connections).toHaveLength(1) + await adapter.closeAll() + }) }) diff --git a/src/main/claude/claude-structured-effort-default-at-rest.test.ts b/src/main/claude/claude-structured-effort-default-at-rest.test.ts index 2d14a9e0c047..dcdad5b868bf 100644 --- a/src/main/claude/claude-structured-effort-default-at-rest.test.ts +++ b/src/main/claude/claude-structured-effort-default-at-rest.test.ts @@ -131,8 +131,6 @@ function restingRecord(options: Record): AgentSessionRecord { provider: 'claude', accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: ACCOUNT_HOME }, location: { wslDistro: null }, - // Why released: a chat at rest has no live claim, so its next start reads the current account. - lease: { claimStatus: 'released' }, options } as unknown as AgentSessionRecord } diff --git a/src/main/claude/claude-structured-launch-account-routing.test.ts b/src/main/claude/claude-structured-launch-account-routing.test.ts index 0d7babfb8f5d..031559a44387 100644 --- a/src/main/claude/claude-structured-launch-account-routing.test.ts +++ b/src/main/claude/claude-structured-launch-account-routing.test.ts @@ -4,8 +4,8 @@ import { join, resolve as resolvePath } from 'node:path' import { afterEach, expect, it, vi } from 'vitest' import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' -import { CLAUDE_PROFILE_SETUP_FAILED_MESSAGE } from '../../shared/claude-profile-routing' import { + CLAUDE_PROFILE_MISSING_MESSAGE, ClaudeProfileRouter, type ClaudeProfileRouterSettings } from '../claude-accounts/claude-profile-router' @@ -32,10 +32,7 @@ it('launches each acquisition under the current selection, not the account it wa getSettings: () => settings, dataRoot: root, userHome: join(root, 'personal'), - env: { CLAUDE_CONFIG_DIR: resolvePath('/user/own') }, - runSetup: async () => { - throw new Error('setup refused') - } + env: { CLAUDE_CONFIG_DIR: resolvePath('/user/own') } }) installClaudeProfileRouter(router) const record = { @@ -59,12 +56,7 @@ it('launches each acquisition under the current selection, not the account it wa providerHandle: claudeProviderHandle('unused', null) } - // A missing folder is set up first; typed when that fails, so the chat names the situation. - await expect(resolve({ identity })).rejects.toMatchObject({ - name: 'AgentSessionPreSpawnError', - reason: 'claudeAccountSetupFailed', - message: CLAUDE_PROFILE_SETUP_FAILED_MESSAGE - }) + await expect(resolve({ identity })).rejects.toThrow(CLAUDE_PROFILE_MISSING_MESSAGE) const home = join(root, 'claude-profiles', 'a', 'home') mkdirSync(home, { recursive: true }) diff --git a/src/main/claude/claude-structured-launch-home.ts b/src/main/claude/claude-structured-launch-home.ts index 14afad9b5c43..0df1b2f599c1 100644 --- a/src/main/claude/claude-structured-launch-home.ts +++ b/src/main/claude/claude-structured-launch-home.ts @@ -32,7 +32,7 @@ export async function resolveClaudeStructuredLaunchHome( } /** Whether Claude wrote a transcript for this id under the given config folder. */ -async function claudeTranscriptExists(input: { +export async function claudeTranscriptExists(input: { providerSessionId: string claudeConfigDir: string }): Promise { diff --git a/src/main/claude/claude-structured-launch-resolution.test.ts b/src/main/claude/claude-structured-launch-resolution.test.ts index 19067d65a4c5..f6d52ddb7ebe 100644 --- a/src/main/claude/claude-structured-launch-resolution.test.ts +++ b/src/main/claude/claude-structured-launch-resolution.test.ts @@ -5,6 +5,9 @@ import { describe, expect, it, vi } from 'vitest' import type { AgentSessionRecord } from '../../shared/agent-session-record' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants' +import { AgentSessionPreSpawnError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { claudeStructuredAuthPolicyForSettings } from '../claude-accounts/claude-structured-auth-policy' +import type { ClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support' import { CLAUDE_DEFAULT_SETTING_SOURCES, CLAUDE_SESSION_STATE_EVENTS_ENV, @@ -15,6 +18,7 @@ import { } from './claude-structured-launch-resolution' import { claudeStructuredPermissionModeForSettings } from './claude-structured-permission-mode' import { CLAUDE_THINKING_DISPLAY_FLAG, type ClaudeCliFlag } from './claude-cli-flag-support' +import { beginClaudeAuthSwitch, endClaudeAuthSwitch } from '../claude-accounts/live-pty-gate' import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const SESSION_ID = 'orca-session-1' @@ -76,6 +80,33 @@ function resolverFor( }) } +function managedAccount(id: string, managedAuthRuntime: 'host' | 'wsl') { + return { + id, + email: `${id}@example.com`, + managedAuthPath: `/managed/${id}`, + managedAuthRuntime, + authMethod: 'subscription-oauth' as const, + createdAt: 0, + updatedAt: 0, + lastAuthenticatedAt: 0 + } +} + +const HOST_SELECTED: ClaudeManagedAccountGateSettings = { + claudeManagedAccounts: [managedAccount('host-1', 'host')], + activeClaudeManagedAccountId: 'host-1', + activeClaudeManagedAccountIdsByRuntime: { host: 'host-1', wsl: {} } +} + +/** The normalized steady state of a Windows user whose only Claude account is WSL-managed: the + * prune drops the WSL account out of the host slot and persists that. */ +const WSL_ONLY_NORMALIZED: ClaudeManagedAccountGateSettings = { + claudeManagedAccounts: [managedAccount('wsl-1', 'wsl')], + activeClaudeManagedAccountId: null, + activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'wsl-1' } } +} + const RESUMABLE = record({ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resolver reads only each link's handle, so the link's other fields stay unset. providerHandleChain: [ @@ -550,11 +581,66 @@ describe('claude structured launch resolution', () => { }) ).rejects.toThrow(/CLAUDE_CONFIG_DIR/) }) + + /** The account state can change while a session lives, and a reacquire after an unexpected child + * exit re-resolves the launch. Without the gate here, that reacquire spawns under whatever the + * account state has become. */ + describe('managed-account gate on every acquisition', () => { + function resolverWithGate(read: () => ClaudeManagedAccountGateSettings | null) { + return createClaudeStructuredLaunchResolver({ + resolveLaunchArgs: () => [], + store: { getRecord: () => RESUMABLE, pinLaunchDirectory: vi.fn() }, + resolveWorkspacePath: async (id) => `/repos/${id}`, + resolveCommand: () => '/usr/local/bin/claude', + // Derived, not a literal: the gate and the policy must read the SAME account state, so a + // hardcoded value could assert a pairing production cannot produce. + resolveAuthPolicy: () => { + const settings = read() + if (!settings) { + throw new Error('the gate refuses before the auth policy is computed') + } + return claudeStructuredAuthPolicyForSettings(settings) + }, + readManagedAccountGate: read + }) + } + + it('refuses a reacquire once the account state becomes the refused shape', async () => { + let gate: ClaudeManagedAccountGateSettings | null = HOST_SELECTED + const resolve = resolverWithGate(() => gate) + + // Created while supported: the launch resolves and would spawn. + await expect(resolve({ identity: identityAt('leaf-current') })).resolves.toMatchObject({ + providerSessionId: 'provider-current' + }) + + gate = WSL_ONLY_NORMALIZED + + // Reacquire after the account state changed: refused before anything spawns, naming the + // account shape a person can change. + const refused = resolve({ identity: identityAt('leaf-current') }) + await expect(refused).rejects.toBeInstanceOf(AgentSessionPreSpawnError) + await expect(refused).rejects.toMatchObject({ reason: 'managedAccountUnsupported' }) + }) + + it('fails closed when the account state cannot be read, naming no situation', async () => { + const refused = resolverWithGate(() => null)({ identity: identityAt('leaf-current') }) + await expect(refused).rejects.toBeInstanceOf(AgentSessionPreSpawnError) + await expect(refused).rejects.toMatchObject({ reason: undefined }) + }) + + it('keeps resolving when no gate is wired, so other embedders are unaffected', async () => { + await expect( + resolverFor(RESUMABLE)({ identity: identityAt('leaf-current') }) + ).resolves.toMatchObject({ providerSessionId: 'provider-current' }) + }) + }) }) describe('readable Claude thinking', () => { const launchWith = ( cliFlags?: ClaudeStructuredLaunchResolverDeps['cliFlags'], + authSwitchSettleTimeoutMs?: number, command = '/usr/local/bin/claude', launchArgs: string[] = [] ) => @@ -566,7 +652,8 @@ describe('readable Claude thinking', () => { resolveAuthPolicy: () => ({ stripAuthEnv: false }), resolveEnv: () => ({ PROJECT_SHIM: '1', ANTHROPIC_API_KEY: 'sk-user' }), hasTranscript: async () => false, - ...(cliFlags ? { cliFlags } : {}) + ...(cliFlags ? { cliFlags } : {}), + ...(authSwitchSettleTimeoutMs === undefined ? {} : { authSwitchSettleTimeoutMs }) })({ identity: IDENTITY }) // Whether the CLI's directory holds a `node` decides if the runtime pairing puts that directory @@ -589,7 +676,7 @@ describe('readable Claude thinking', () => { if (sibling) { makeExecutable(join(binDir, process.platform === 'win32' ? 'node.cmd' : 'node')) } - const launch = await launchWith({ supports }, command) + const launch = await launchWith({ supports }, undefined, command) const asked = supports.mock.calls[0]?.[1] expect(asked).toMatchObject({ command, cwd: '/repos/workspace-1' }) const segments = (env: Record | undefined) => @@ -621,6 +708,7 @@ describe('readable Claude thinking', () => { const launch = await launchWith( { supports: async (flag) => flag === CLAUDE_THINKING_DISPLAY_FLAG }, undefined, + undefined, ['--effort', 'high', '--thinking-display', 'omitted'] ) expect(launch.options.extraArgs).toEqual({ @@ -629,4 +717,21 @@ describe('readable Claude thinking', () => { 'thinking-display': 'summarized' }) }) + + it('still rechecks an account switch that began while the probe ran', async () => { + try { + const launch = launchWith( + { + supports: async () => { + beginClaudeAuthSwitch() + return false + } + }, + 10 + ) + await expect(launch).rejects.toMatchObject({ reason: 'accountSwitchInProgress' }) + } finally { + endClaudeAuthSwitch() + } + }) }) diff --git a/src/main/claude/claude-structured-launch-resolution.ts b/src/main/claude/claude-structured-launch-resolution.ts index 8914481044bd..7e52332fe54e 100644 --- a/src/main/claude/claude-structured-launch-resolution.ts +++ b/src/main/claude/claude-structured-launch-resolution.ts @@ -14,10 +14,20 @@ import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { structuredSessionChildIdentityEnv } from '../runtime/structured-session-child-identity-env' import { CLAUDE_AUTH_ENV_CONFLICT_MESSAGE, + CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE, hasClaudeAuthEnvConflict } from '../claude-accounts/environment' import type { ClaudeStructuredAuthPolicy } from '../claude-accounts/claude-structured-auth-policy' +import { + CLAUDE_AUTH_SWITCH_SETTLE_TIMEOUT_MS, + whenClaudeAuthSwitchSettles +} from '../claude-accounts/live-pty-gate' import { AgentSessionPreSpawnError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { + hasWslBoundClaudeAccount, + structuredClaudeMatchesActiveManagedAccount, + type ClaudeManagedAccountGateSettings +} from '../native-chat/claude-structured-managed-account-support' import { claudeChildEnv, claudeProbeEnv, @@ -131,6 +141,10 @@ export type ClaudeStructuredLaunchResolverDeps = { resolveAuthPolicy: () => Promise | ClaudeStructuredAuthPolicy /** The user's Agent Permissions setting, re-read per acquisition. Absent means prompting. */ resolvePermissionMode?: () => Promise | PermissionMode + /** How long an in-flight account switch may hold a launch before it is refused. */ + authSwitchSettleTimeoutMs?: number + /** Account state for the managed-account gate; null when it cannot be read, which refuses. */ + readManagedAccountGate?: () => ClaudeManagedAccountGateSettings | null /** The host's chat attachment store: files a client attached live there, outside the workspace, * and the agent reads them without asking. */ attachmentDirectory?: string @@ -159,13 +173,19 @@ export type ClaudeStructuredInvocation = { * drift there heals on the next refresh. */ export async function resolveClaudeStructuredInvocation( - deps: ClaudeEnvDeps & Pick, + deps: ClaudeEnvDeps & + Pick & { + authSwitchSettleTimeoutMs?: number + }, decorateEnv: (env: Record) => Record = (env) => env, /** Already resolved by a caller that needed them earlier; read again otherwise. */ resolvedSources?: ClaudeChildEnvSources ): Promise { const sources = resolvedSources ?? (await resolveClaudeChildEnvSources(deps)) const auth = await deps.resolveAuthPolicy() + // A switch can begin while the policy and overlay resolve, exactly as it can + // during the terminal preflight's prepareClaudeAuth — recheck after the awaits. + await assertClaudeAuthSwitchSettled(deps.authSwitchSettleTimeoutMs) // Under a managed account the pinned credential is the only auth this launch may // use, so an explicit override is refused rather than silently beating the pin. if (auth.stripAuthEnv && hasClaudeAuthEnvConflict(sources.overlay)) { @@ -180,10 +200,29 @@ export async function resolveClaudeStructuredInvocation( } } +/** + * Wait a running account switch out, and refuse only if it never settles. + * + * Launch resolution is reached from `acquireClaudeSession` *after* the old child has + * been closed and proved, so a plain refusal here would leave the user with a dead + * chat and no replacement — the very harm the acquire-entry guard exists to prevent. + * The entry guard still refuses outright, because nothing has been torn down yet. + */ +export async function assertClaudeAuthSwitchSettled( + timeoutMs = CLAUDE_AUTH_SWITCH_SETTLE_TIMEOUT_MS +): Promise { + if (!(await whenClaudeAuthSwitchSettles(timeoutMs))) { + throw new AgentSessionPreSpawnError(new Error(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE), { + reason: 'accountSwitchInProgress' + }) + } +} + export function createClaudeStructuredLaunchResolver( deps: ClaudeStructuredLaunchResolverDeps ): (input: { identity: AgentSessionJournalIdentity }) => Promise { return async ({ identity }) => { + await assertClaudeAuthSwitchSettled(deps.authSwitchSettleTimeoutMs) const record = deps.store.getRecord(identity.sessionId) if (!record) { throw new Error(`no durable agent-session record for ${identity.sessionId}`) @@ -204,7 +243,18 @@ export function createClaudeStructuredLaunchResolver( if (accountHome.variable !== pinned) { throw new Error(`claude sessions pin ${pinned}, not ${accountHome.variable}`) } + // Every acquisition, not just the first: the account state can change under a live session, and + // a reacquire after an unexpected exit would otherwise spawn under whatever it has become. + // Codex has no gate here — it resolves its account on a different path. const router = getClaudeProfileRouter() + const gate = router ? undefined : deps.readManagedAccountGate?.() + if (gate !== undefined && !structuredClaudeMatchesActiveManagedAccount(gate)) { + // Unreadable account state names no situation a person can act on, so only the log reads it. + throw new AgentSessionPreSpawnError( + 'structured Claude is not offered under the active managed Claude account', + gate && hasWslBoundClaudeAccount(gate) ? { reason: 'managedAccountUnsupported' } : {} + ) + } // A Claude record's chain holds only Claude handles; the attach admission refuses anything else. const head = agentSessionProviderHandleChainHead(record.providerHandleChain)?.handle ?? null if ( @@ -234,6 +284,21 @@ export function createClaudeStructuredLaunchResolver( const permission = claudeStructuredPermissionOptions( (await deps.resolvePermissionMode?.()) ?? 'default' ) + // A start that failed before its first turn wrote no transcript, and `--resume` of an absent + // one exits; launch that id fresh instead. With a transcript, `--session-id` would collide. + const leafUuid = head ? claudeProviderHandleLeafUuid(head) : null + const resumes = async (claudeConfigDir: string): Promise => + head !== null && + (await claudeLaunchResumesTranscript({ + router, + leafUuid, + providerSessionId, + claudeConfigDir, + hasTranscript: deps.hasTranscript + })) + // Why: without a router the home is fixed, so check it before the recheck that must stay last. + const resumedWithoutRouter = router ? undefined : await resumes(accountHome.path) + // Last: it rechecks the account switch, which may have begun during any await above. const { command, env, account } = await resolveClaudeStructuredInvocation( deps, (base) => @@ -252,18 +317,7 @@ export function createClaudeStructuredLaunchResolver( sources ) const launchHome = await resolveClaudeStructuredLaunchHome(router, env, accountHome.path) - // A start that failed before its first turn wrote no transcript, and `--resume` of an absent - // one exits; launch that id fresh instead. With a transcript, `--session-id` would collide. - const leafUuid = head ? claudeProviderHandleLeafUuid(head) : null - const resumesTranscript = - head !== null && - (await claudeLaunchResumesTranscript({ - router, - leafUuid, - providerSessionId, - claudeConfigDir: launchHome, - hasTranscript: deps.hasTranscript - })) + const resumesTranscript = resumedWithoutRouter ?? (await resumes(launchHome)) return { pathToClaudeCodeExecutable: command, account, diff --git a/src/main/claude/claude-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts index 418aeba798c6..e13c1aa0047d 100644 --- a/src/main/claude/claude-structured-session-acquisition.ts +++ b/src/main/claude/claude-structured-session-acquisition.ts @@ -1,7 +1,10 @@ -import type { - AgentSessionAcquisition, - StructuredAgentSessionAcquireInput +import { + AgentSessionPreSpawnError, + type AgentSessionAcquisition, + type StructuredAgentSessionAcquireInput } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE } from '../claude-accounts/environment' +import { isClaudeAuthSwitchInProgress } from '../claude-accounts/live-pty-gate' import { openClaudeStreamJsonConnection } from './claude-stream-json-connection' import { buildClaudePermissionCallbacks } from './claude-structured-inbound-control' import { resolveClaudeReplayTurn } from './claude-replay-turn-resolution' @@ -59,6 +62,13 @@ export async function acquireClaudeSession({ exits: Map callbacks: ClaudeAcquireCallbacks }): Promise { + // A managed-account switch is mid-swap of the pinned credential home; refuse here, + // before this acquisition cancels the previous attempt and closes the live session. + if (isClaudeAuthSwitchInProgress()) { + throw new AgentSessionPreSpawnError(new Error(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE), { + reason: 'accountSwitchInProgress' + }) + } const sessionId = input.identity.sessionId const prompts = new ClaudePromptRegistry() const { previous, attempt } = acquisitions.start(sessionId, prompts) diff --git a/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts b/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts index 53e3d13744f7..ad7c1edabc83 100644 --- a/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts +++ b/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts @@ -3,7 +3,7 @@ import { PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION } from './types' describe('foreground-confirmation daemon protocol', () => { it('rejects daemons from before the fresh-confirmation RPC', () => { - expect(PROTOCOL_VERSION).toBe(42) + expect(PROTOCOL_VERSION).toBe(43) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(19) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(22) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(23) diff --git a/src/main/daemon/daemon-init-provider-installation.test.ts b/src/main/daemon/daemon-init-provider-installation.test.ts index 2de5fd5be84e..423ee9ee34f2 100644 --- a/src/main/daemon/daemon-init-provider-installation.test.ts +++ b/src/main/daemon/daemon-init-provider-installation.test.ts @@ -125,6 +125,26 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { expect(setLocalPtyProviderMock).not.toHaveBeenCalled() }) + it('prunes seeded Claude live-PTY ids against daemon sessions after init', async () => { + const mod = await importFresh() + // Why: live-pty-gate is intentionally unmocked — import from the same fresh registry so gate state matches daemon-init's. + const gate = await import('../claude-accounts/live-pty-gate') + defaultListSessionsSessions.push({ sessionId: 'claude-alive' }) + gate.seedLiveClaudePtysFromPersistence(['claude-alive', 'claude-dead']) + try { + await mod.initDaemonPtyProvider() + + expect(gate.hasLiveClaudePtys()).toBe(true) + + gate.markClaudePtyExited('claude-alive') + // Why: proves 'claude-dead' was released by the daemon reconcile — the surviving session held the gate alone. + expect(gate.hasLiveClaudePtys()).toBe(false) + } finally { + gate.markClaudePtyExited('claude-alive') + gate.markClaudePtyExited('claude-dead') + } + }) + it('does not install a late daemon provider after startup fallback aborts the init attempt', async () => { const mod = await importFresh() let resolveEnsureRunning!: (value: { socketPath: string; tokenPath: string }) => void diff --git a/src/main/daemon/daemon-protocol-version.ts b/src/main/daemon/daemon-protocol-version.ts index 8967d0cae513..540b84945493 100644 --- a/src/main/daemon/daemon-protocol-version.ts +++ b/src/main/daemon/daemon-protocol-version.ts @@ -1,9 +1,8 @@ // Why: daemons survive app updates, so wire behavior must be version-gated. -// v42 gives every pane the `claude` function that follows the selected Claude account; v41 stages -// long startup commands as sourced scripts; v40 rolled the #25130/#24636 shell-wrapper changes and -// the wider agent list into a fresh daemon; older owners stay attachable. -export const PROTOCOL_VERSION = 42 -export const CLAUDE_ACCOUNT_FUNCTION_DAEMON_PROTOCOL_VERSION = 42 +// v43 drops v42's `claude` account function (reverted #24434); v41 stages long startup commands as +// sourced scripts; v40 rolled the #25130/#24636 shell-wrapper changes and the wider agent list into +// a fresh daemon; older owners stay attachable. +export const PROTOCOL_VERSION = 43 // v39 gives plain fish panes Orca's codex function through XDG_DATA_DIRS. export const CODEX_FISH_SHELL_FUNCTION_DAEMON_PROTOCOL_VERSION = 39 // Why: older daemons reject `setColorQueryReplyColors` as an unknown request type. @@ -38,7 +37,7 @@ export const CLEAN_DISCONNECT_PROTOCOL_VERSION = 24 export const MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION = 29 export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, - 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41 + 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42 ] as const export function supportsColorQueryReplyColors(protocolVersion: number): boolean { diff --git a/src/main/daemon/daemon-provider-init.ts b/src/main/daemon/daemon-provider-init.ts index dd2d2f951368..9d9a1b5c3e11 100644 --- a/src/main/daemon/daemon-provider-init.ts +++ b/src/main/daemon/daemon-provider-init.ts @@ -1,4 +1,8 @@ import { getLocalPtyProvider, rebindLocalProviderListeners } from '../ipc/pty' +import { + confirmSeededClaudeLivePtys, + hasSeededUnconfirmedClaudePtys +} from '../claude-accounts/live-pty-gate' import { isStartupDiagnosticsEnabled, logStartupDiagnostic } from '../startup/startup-diagnostics' import { checkDaemonHealth } from './daemon-health' import { collectPinnedDaemonVersions, pruneOldDaemonHosts } from './daemon-host-relocation' @@ -159,6 +163,7 @@ export async function initDaemonPtyProvider( if (process.platform === 'darwin' && newSpawner.getHandle()?.adopted) { void reportDaemonAdoption(runtimeDir, info.socketPath, info.tokenPath, newAdapter) } + await reconcileSeededClaudeLivePtys(routedAdapter) } // Why off the init path: this is measurement of an adopted daemon (#17696), and neither its probes nor their failure may delay or fail startup. @@ -185,3 +190,29 @@ async function reportDaemonAdoption( // Best-effort measurement only. } } + +// Why: release gate ids only for daemon-confirmed-dead sessions; keep seeds on listing failure since releasing early can rotate a live CLI's refresh token. +async function reconcileSeededClaudeLivePtys(provider: DaemonProvider): Promise { + if (!hasSeededUnconfirmedClaudePtys()) { + return + } + try { + const adapters = + provider instanceof DaemonPtyRouter || provider instanceof DegradedDaemonPtyProvider + ? provider.getAllAdapters() + : [provider] + const results = await Promise.allSettled(adapters.map((entry) => entry.listSessions())) + if (results.some((result) => result.status === 'rejected')) { + console.warn('[daemon] Keeping seeded Claude live-PTY gate — session listing failed') + return + } + confirmSeededClaudeLivePtys( + results.flatMap((result) => + result.status === 'fulfilled' ? result.value.map((session) => session.sessionId) : [] + ) + ) + } catch (error) { + // Why: gate bookkeeping must never fail daemon init; stale seeds only defer a usage refresh until next restart. + console.warn('[daemon] Failed to reconcile seeded Claude live-PTY gate:', error) + } +} diff --git a/src/main/daemon/daemon-provider-state.ts b/src/main/daemon/daemon-provider-state.ts index 7599ee9cd03c..30eff6691f99 100644 --- a/src/main/daemon/daemon-provider-state.ts +++ b/src/main/daemon/daemon-provider-state.ts @@ -3,7 +3,7 @@ import { setLocalPtyProvider } from '../ipc/pty' import { DegradedDaemonPtyProvider } from './degraded-daemon-pty-provider' import { getDaemonRuntimeDir as getRuntimeDir } from './daemon-launch-paths' import { parseDaemonPidFile, type ParsedDaemonPid } from './daemon-pid-file-parse' -import { getLegacyDaemonAdapters, type DaemonProvider } from './daemon-provider-routing' +import type { DaemonProvider } from './daemon-provider-routing' import { DaemonPtyRouter } from './daemon-pty-router' import { getDaemonPidPath, getDaemonSocketPath, getDaemonTokenPath } from './daemon-spawner' import type { DaemonSpawner } from './daemon-spawner' @@ -88,18 +88,6 @@ export function getDaemonProvider(): DaemonProvider | null { return adapter } -/** True for a terminal on a daemon kept alive from before `protocolVersion`; never for an SSH pane. */ -export function isTerminalFromBeforeDaemonProtocol( - ptyId: string, - protocolVersion: number -): boolean { - return adapter - ? getLegacyDaemonAdapters(adapter).some( - (legacy) => legacy.protocolVersion < protocolVersion && legacy.hasPty(ptyId) - ) - : false -} - // Why: computed from the pid record on demand (not cached at adoption) so the Settings // remedy surface always reflects the daemon actually serving terminals right now. export async function getCurrentDaemonMacTccAttributionHealth(): Promise { diff --git a/src/main/daemon/pty-subprocess/shell-launch-plan.ts b/src/main/daemon/pty-subprocess/shell-launch-plan.ts index e3c1638ff1fe..e29fff5330ab 100644 --- a/src/main/daemon/pty-subprocess/shell-launch-plan.ts +++ b/src/main/daemon/pty-subprocess/shell-launch-plan.ts @@ -34,10 +34,6 @@ import { type RecognizedAgentProcess } from '../../../shared/agent-process-recognition' import { ORCA_HERMES_STARTUP_QUERY_ENV } from '../../../shared/hermes-startup-query' -import { - CLAUDE_INJECTED_CONFIG_DIR_ENV, - CLAUDE_PROFILE_POINTER_ENV -} from '../../../shared/claude-profile-routing' import { WINDOWS_GIT_BASH_SHELL } from '../../../shared/windows-terminal-shell' import { getShellLaunchConfig, resolvePtyShellPath } from '../shell-ready' import { resolveWslSessionContext } from '../wsl-session-context' @@ -173,8 +169,8 @@ export function createPtyShellLaunchPlan( if (env.CLAUDE_CONFIG_DIR) { addWslEnvKeys(env, ['CLAUDE_CONFIG_DIR']) } - if (env[CLAUDE_PROFILE_POINTER_ENV]) { - addWslEnvKeys(env, [CLAUDE_PROFILE_POINTER_ENV, CLAUDE_INJECTED_CONFIG_DIR_ENV]) + if (env.ORCA_CLAUDE_PROFILE_POINTER) { + addWslEnvKeys(env, ['ORCA_CLAUDE_PROFILE_POINTER', 'ORCA_CLAUDE_INJECTED_CONFIG_DIR']) } if (env[ORCA_HERMES_STARTUP_QUERY_ENV] !== undefined) { addWslEnvKeys(env, [ORCA_HERMES_STARTUP_QUERY_ENV]) diff --git a/src/main/ipc/claude-accounts.ts b/src/main/ipc/claude-accounts.ts index 181164a46468..f88f094e46b3 100644 --- a/src/main/ipc/claude-accounts.ts +++ b/src/main/ipc/claude-accounts.ts @@ -1,10 +1,10 @@ import { ipcMain } from 'electron' -import type { ClaudeAccountService } from '../claude-accounts/service' +import type { ClaudeAccountAddTarget, ClaudeAccountService } from '../claude-accounts/service' import type { ClaudeAccountSelectionTarget } from '../claude-accounts/runtime-selection' export function registerClaudeAccountHandlers(claudeAccounts: ClaudeAccountService): void { ipcMain.handle('claudeAccounts:list', () => claudeAccounts.listAccounts()) - ipcMain.handle('claudeAccounts:add', (_event, args?: ClaudeAccountSelectionTarget) => + ipcMain.handle('claudeAccounts:add', (_event, args?: ClaudeAccountAddTarget) => claudeAccounts.addAccount(args) ) ipcMain.handle('claudeAccounts:cancelPendingLogin', () => claudeAccounts.cancelPendingLogin()) @@ -16,9 +16,11 @@ export function registerClaudeAccountHandlers(claudeAccounts: ClaudeAccountServi ) ipcMain.handle( 'claudeAccounts:select', - (_event, args: { accountId: string | null } & ClaudeAccountSelectionTarget) => - args.runtime - ? claudeAccounts.selectAccountForTarget(args.accountId, args) - : claudeAccounts.selectAccount(args.accountId) + (_event, args: { accountId: string | null } & ClaudeAccountSelectionTarget) => { + if (!args.runtime) { + return claudeAccounts.selectAccount(args.accountId) + } + return claudeAccounts.selectAccountForTarget(args.accountId, args) + } ) } diff --git a/src/main/ipc/pty-listener-teardown-and-orphans.test.ts b/src/main/ipc/pty-listener-teardown-and-orphans.test.ts index 470582f35615..7e4178f35d3e 100644 --- a/src/main/ipc/pty-listener-teardown-and-orphans.test.ts +++ b/src/main/ipc/pty-listener-teardown-and-orphans.test.ts @@ -8,6 +8,7 @@ import { } from './pty-ipc-mock-registry' import { posixOnlyIt, makeDisposable } from './pty-ipc-test-constants' import { setupPtyIpcSuite } from './pty-ipc-test-harness' +import * as livePtyGate from '../claude-accounts/live-pty-gate' import { registerPtyHandlers, setLocalPtyProvider, getLocalPtyProvider } from './pty' import { join } from 'node:path' // Why resolved rather than hardcoded: the wrapper tree is content-addressed. @@ -366,6 +367,7 @@ describe('registerPtyHandlers', () => { } spawnMock.mockReturnValue(proc) const isRecoveryReloadInFlight = vi.fn(() => true) + const markClaudePtyExitedSpy = vi.spyOn(livePtyGate, 'markClaudePtyExited') registerPtyHandlers( mainWindow as never, @@ -394,8 +396,11 @@ describe('registerPtyHandlers', () => { expect(killSpy).not.toHaveBeenCalled() expect(runtime.onPtyExit).not.toHaveBeenCalled() + expect(markClaudePtyExitedSpy).not.toHaveBeenCalled() const listed = await getLocalPtyProvider().listProcesses() expect(listed.some((info) => info.id === spawnResult.id)).toBe(true) + + markClaudePtyExitedSpy.mockRestore() }) // Why: guard against over-suppression — with no recovery reload in flight the sweep MUST still reclaim genuinely orphaned local PTYs. it('still sweeps orphaned local PTYs when no recovery reload is in flight', async () => { 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 36c02a0f2346..d530a9cd1947 100644 --- a/src/main/ipc/pty-spawn-env-terminal-basics.test.ts +++ b/src/main/ipc/pty-spawn-env-terminal-basics.test.ts @@ -1,13 +1,14 @@ import { withFreshOmpLaunch } from '../../shared/omp-fresh-launch' import { describe, expect, it, vi } from 'vitest' import { piBuildPtyEnvMock, spawnMock } from './pty-ipc-mock-registry' -import { TEST_CODEX_HOME } from './pty-ipc-test-constants' +import { TEST_CODEX_HOME, makeDisposable } from './pty-ipc-test-constants' import { setupPtyIpcSuite } from './pty-ipc-test-harness' import { LocalPtyProvider } from '../providers/local-pty-provider' import { __resetPersistedWindowsPathCacheForTests } from '../pty/windows-environment-path' import { __setWindowsPathRegistryLoaderForTests } from '../pty/windows-path-registry-reader' +import { hasLiveClaudePtys, markClaudePtySpawned } from '../claude-accounts/live-pty-gate' import { wslHookRelayManager } from '../agent-hooks/wsl-hook-relay-manager' -import { registerPtyHandlers, buildPtyHostEnv } from './pty' +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' @@ -319,6 +320,49 @@ describe('registerPtyHandlers', () => { id: expect.any(String) }) }) + it('marks local Claude launches live until the PTY is killed', async () => { + let exitCb: ((info: { exitCode: number }) => void) | undefined + spawnMock.mockReturnValue({ + onData: vi.fn(() => makeDisposable()), + onExit: vi.fn((cb: (info: { exitCode: number }) => void) => { + exitCb = cb + return makeDisposable() + }), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(() => exitCb?.({ exitCode: -1 })), + process: 'zsh', + pid: 12345 + }) + const prepareClaudeAuth = vi.fn(async () => ({ + configDir: '/tmp/claude', + envPatch: {}, + stripAuthEnv: false, + provenance: 'managed:account-1' + })) + registerPtyHandlers(mainWindow as never, undefined, undefined, undefined, prepareClaudeAuth) + + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + command: 'claude' + })) as { id: string } + + expect(prepareClaudeAuth).toHaveBeenCalledTimes(1) + expect(hasLiveClaudePtys()).toBe(true) + + await handlers.get('pty:kill')!(null, { id: spawnResult.id }) + + expect(hasLiveClaudePtys()).toBe(false) + }) + it('clears Claude live-PTY tracking from shared provider teardown', () => { + markClaudePtySpawned('ssh-claude-pty') + expect(hasLiveClaudePtys()).toBe(true) + + clearProviderPtyState('ssh-claude-pty') + + expect(hasLiveClaudePtys()).toBe(false) + }) it('defaults LANG to en_US.UTF-8 when not inherited from process.env', async () => { const env = await spawnAndGetEnv(undefined, { LANG: undefined }) expect(env.LANG).toBe('en_US.UTF-8') diff --git a/src/main/ipc/pty/ipc/claude-old-terminal.test.ts b/src/main/ipc/pty/ipc/claude-old-terminal.test.ts deleted file mode 100644 index 6d8a3e16e00a..000000000000 --- a/src/main/ipc/pty/ipc/claude-old-terminal.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { beforeEach, expect, it, vi } from 'vitest' - -type Handler = (event: unknown, args: { id: unknown }) => Promise - -const mocks = vi.hoisted(() => ({ - handlers: new Map(), - olderPtyIds: new Set(), - startup: vi.fn<() => Promise | undefined>() -})) -vi.mock('../../pty-host-bindings', () => ({ - getPtyIpc: () => ({ - handle: (channel: string, handler: Handler) => mocks.handlers.set(channel, handler) - }) -})) -vi.mock('../../../daemon/daemon-provider-state', () => ({ - isTerminalFromBeforeDaemonProtocol: (id: string, protocolVersion: number) => - protocolVersion === 42 && mocks.olderPtyIds.has(id) -})) - -import { installPtyClaudeOldTerminalIpcHandler } from './claude-old-terminal' - -beforeEach(() => { - mocks.handlers.clear() - mocks.olderPtyIds = new Set(['old-1']) - installPtyClaudeOldTerminalIpcHandler({ getLocalPtyProviderStartupPromise: mocks.startup }) -}) - -it('answers true only for a pane owned by a daemon from before per-account Claude folders', async () => { - const ask = mocks.handlers.get('pty:openedBeforeClaudeAccounts')! - expect(await ask({}, { id: 'old-1' })).toBe(true) - // A pane on this build's daemon, or an SSH pane, which no local daemon owns. - expect(await ask({}, { id: 'new-1' })).toBe(false) - expect(await ask({}, { id: 'ssh:conn-1:pty-1' })).toBe(false) - expect(await ask({}, { id: 42 })).toBe(false) - expect(mocks.startup).toHaveBeenCalled() -}) diff --git a/src/main/ipc/pty/ipc/claude-old-terminal.ts b/src/main/ipc/pty/ipc/claude-old-terminal.ts deleted file mode 100644 index 385800241cbd..000000000000 --- a/src/main/ipc/pty/ipc/claude-old-terminal.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { getPtyIpc } from '../../pty-host-bindings' -import { CLAUDE_ACCOUNT_FUNCTION_DAEMON_PROTOCOL_VERSION } from '../../../daemon/daemon-protocol-version' -import { isTerminalFromBeforeDaemonProtocol } from '../../../daemon/daemon-provider-state' - -type Deps = { getLocalPtyProviderStartupPromise: () => Promise | undefined } - -/** Whether a pane's daemon predates the claude function that follows the selected account. */ -export function installPtyClaudeOldTerminalIpcHandler(deps: Deps): void { - getPtyIpc().handle( - 'pty:openedBeforeClaudeAccounts', - async (_event, args: { id: string }): Promise => { - if (typeof args?.id !== 'string') { - return false - } - // Why: the pre-swap provider does not own restored daemon ids. - await deps.getLocalPtyProviderStartupPromise() - return isTerminalFromBeforeDaemonProtocol( - args.id, - CLAUDE_ACCOUNT_FUNCTION_DAEMON_PROTOCOL_VERSION - ) - } - ) -} diff --git a/src/main/ipc/pty/ipc/spawn-commit.ts b/src/main/ipc/pty/ipc/spawn-commit.ts index ffcc5d158bb3..24df59c1201c 100644 --- a/src/main/ipc/pty/ipc/spawn-commit.ts +++ b/src/main/ipc/pty/ipc/spawn-commit.ts @@ -1,5 +1,6 @@ import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id' import { agentHookServer } from '../../../agent-hooks/server' +import { markClaudePtySpawned } from '../../../claude-accounts/live-pty-gate' import { registerPty } from '../../../memory/pty-registry' import type { PtySpawnResult } from '../../../providers/types' import { clearMigrationUnsupportedPtysForPaneKey } from '../../../agent-hooks/migration-unsupported-pty-state' @@ -124,6 +125,9 @@ async function commitReservedPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise { const args = ctx.args + if (ctx.isClaudeLaunch && isClaudeAuthSwitchInProgress()) { + throw new Error(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE) + } if (ctx.claudeAuth?.stripAuthEnv && hasClaudeAuthEnvConflict(args.env)) { throw new Error(CLAUDE_AUTH_ENV_CONFLICT_MESSAGE) } diff --git a/src/main/ipc/pty/ipc/spawn-preflight.ts b/src/main/ipc/pty/ipc/spawn-preflight.ts index 4b8d71c04417..b6a50c43bbbf 100644 --- a/src/main/ipc/pty/ipc/spawn-preflight.ts +++ b/src/main/ipc/pty/ipc/spawn-preflight.ts @@ -4,6 +4,8 @@ import { resolveLocalWindowsTerminalRuntimeOptions } from '../../../../shared/local-windows-terminal-runtime' import { isWslUncPath, toWindowsWslPath } from '../../../../shared/wsl-paths' +import { isClaudeAuthSwitchInProgress } from '../../../claude-accounts/live-pty-gate' +import { CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE } from '../../../claude-accounts/environment' import { mintPtySessionId } from '../../../daemon/pty-session-id' import { resolveWslSessionContext } from '../../../daemon/wsl-session-context' import { LocalPtyProvider } from '../../../providers/local-pty-provider' @@ -192,6 +194,9 @@ export async function preparePtyIpcSpawnPreflight(ctx: PtyIpcSpawnState): Promis } ctx.isClaudeLaunch = !ctx.preAdoptedStablePane && !args.connectionId && isClaudeLaunchCommand(args.command) + if (ctx.isClaudeLaunch && isClaudeAuthSwitchInProgress()) { + throw new Error(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE) + } ctx.terminalRuntimeOptions = process.platform === 'win32' && !args.connectionId ? resolveLocalWindowsTerminalRuntimeOptions({ diff --git a/src/main/ipc/pty/provider/bind-listeners.ts b/src/main/ipc/pty/provider/bind-listeners.ts index ec13ad0d36e2..31bacec82f7f 100644 --- a/src/main/ipc/pty/provider/bind-listeners.ts +++ b/src/main/ipc/pty/provider/bind-listeners.ts @@ -1,3 +1,4 @@ +import { markClaudePtyExited } from '../../../claude-accounts/live-pty-gate' import { LocalPtyProvider } from '../../../providers/local-pty-provider' import { isCurrentPtyExit, ptyOwnership } from './ownership-state' import { @@ -91,6 +92,7 @@ export function bindProviderListeners(session: PtyIpcSession): void { if (!isLocalProvider) { clearProviderPtyState(payload.id) ptyOwnership.delete(payload.id) + markClaudePtyExited(payload.id) if (syntheticExit) { session.runtime?.markPtyStopRequested(payload.id) } diff --git a/src/main/ipc/pty/provider/liveness.ts b/src/main/ipc/pty/provider/liveness.ts index 5c4265bdc6ea..8a362b74aaf2 100644 --- a/src/main/ipc/pty/provider/liveness.ts +++ b/src/main/ipc/pty/provider/liveness.ts @@ -10,6 +10,7 @@ import { isSshPtyProvenExitedOnRelayError } from '../../../providers/ssh-pty-errors' import type { IPtyProvider } from '../../../providers/types' +import { markClaudePtyExited } from '../../../claude-accounts/live-pty-gate' import { ptyIncarnationById, ptyOwnership } from './ownership-state' import { getRelayPtyId } from './registry' import { @@ -178,5 +179,6 @@ export function finishPtyShutdown( store?.markSshRemotePtyLease(connectionId, getRelayPtyId(connectionId, id), 'terminated') } ptyOwnership.delete(id) + markClaudePtyExited(id) return incarnationId } diff --git a/src/main/ipc/pty/provider/local-configure.ts b/src/main/ipc/pty/provider/local-configure.ts index eeb5fef028c5..7a494522ec01 100644 --- a/src/main/ipc/pty/provider/local-configure.ts +++ b/src/main/ipc/pty/provider/local-configure.ts @@ -10,6 +10,7 @@ import { stampWslOrchestrationCompatibilityHost } from '../../../pty/wsl-orca-env' import type { CodexAccountSelectionTarget } from '../../../codex-accounts/runtime-selection' +import { markClaudePtyExited } from '../../../claude-accounts/live-pty-gate' import { buildPtyHostEnv } from '../host-env/assembly' import { getCompatibleSelectedCodexHomePath, @@ -125,6 +126,7 @@ export function configureLocalPtyProvider(args: { } clearProviderPtyState(id) ptyOwnership.delete(id) + markClaudePtyExited(id) runtime?.onPtyExit(id, code, incarnationId, { providerExitObserved: true, ...(cause ? { cause } : {}) diff --git a/src/main/ipc/pty/provider/state-cleanup.ts b/src/main/ipc/pty/provider/state-cleanup.ts index aa99d191abc0..f70b07dbff85 100644 --- a/src/main/ipc/pty/provider/state-cleanup.ts +++ b/src/main/ipc/pty/provider/state-cleanup.ts @@ -1,5 +1,6 @@ import { advertisedUrlWatcher } from '../../../ports/advertised-url-watcher' import { unregisterPty } from '../../../memory/pty-registry' +import { markClaudePtyExited } from '../../../claude-accounts/live-pty-gate' import { forgetCodexPaneAccount } from '../../../codex/codex-pane-account-registry' import { openCodeHookService } from '../../../opencode/hook-service' import { piTitlebarExtensionService } from '../../../pi/titlebar-extension-service' @@ -48,6 +49,8 @@ export function clearProviderPtyState( // new teardown path forgets to remove one provider's overlay/hook state. openCodeHookService.clearPty(id) piTitlebarExtensionService.clearPty(id) + // Why: SSH exit/teardown paths bypass pty.ts's local onExit but still must release Claude account-switch guards. + markClaudePtyExited(id) ptySizes.delete(id) ptyIncarnationById.delete(id) lastInputAtByPty.delete(id) diff --git a/src/main/ipc/pty/register-handlers.ts b/src/main/ipc/pty/register-handlers.ts index 5e823ac535ca..7f29e895e0bc 100644 --- a/src/main/ipc/pty/register-handlers.ts +++ b/src/main/ipc/pty/register-handlers.ts @@ -13,7 +13,6 @@ import { finishPtyShutdown } from './provider/liveness' import type { GetSelectedCodexHomePath, PrepareClaudeAuth } from './host-env/types' import { installPtyInspectIpcHandlers } from './ipc/inspect' import { installPtyCodexSharedServerIpcHandler } from './ipc/codex-shared-server' -import { installPtyClaudeOldTerminalIpcHandler } from './ipc/claude-old-terminal' import { installPtyKillIpcHandler, stopReplacedPanePty, @@ -125,7 +124,6 @@ export function registerPtyHandlers( ipcMain.removeHandler('pty:isCodexOnSharedServer') ipcMain.removeHandler('pty:disableCodexSharedServerAutoStart') ipcMain.removeHandler('pty:stopCodexSharedServer') - ipcMain.removeHandler('pty:openedBeforeClaudeAccounts') ipcMain.removeHandler('pty:getCwd') ipcMain.removeHandler('pty:getSize') ipcMain.removeHandler('pty:getAuthoritativeBufferSnapshotCapabilities') @@ -288,6 +286,5 @@ export function registerPtyHandlers( installPtyResizeVisibilityIpc(session) installPtyInspectIpcHandlers({ getLocalPtyProviderStartupPromise }) installPtyCodexSharedServerIpcHandler({ getLocalPtyProviderStartupPromise }) - installPtyClaudeOldTerminalIpcHandler({ getLocalPtyProviderStartupPromise }) installPtyKillIpcHandler(killDeps) } diff --git a/src/main/ipc/pty/runtime/spawn-commit.ts b/src/main/ipc/pty/runtime/spawn-commit.ts index e6e301aa8afa..c38637371c36 100644 --- a/src/main/ipc/pty/runtime/spawn-commit.ts +++ b/src/main/ipc/pty/runtime/spawn-commit.ts @@ -7,6 +7,7 @@ import { recordCodexPaneAccountForSpawn, codexReattachedHomeRouteField } from '../host-env/codex-home' +import { markClaudePtySpawned } from '../../../claude-accounts/live-pty-gate' import { registerPty } from '../../../memory/pty-registry' import { rememberPaneKeyForPty } from '../pane/key-state' import { @@ -239,6 +240,9 @@ async function commitReservedRuntimePtySpawn(ctx: RuntimePtySpawnState) { if (!ctx.stablePaneOwner) { ctx.deps.runtime?.noteTerminalSpawnCommand?.(ctx.result.id, ctx.launchCommand ?? null) } + if (ctx.isClaudeLaunch && !ctx.stablePaneOwner) { + markClaudePtySpawned(ctx.result.id) + } if (args.telemetry && !ctx.stablePaneOwner) { recordPtySpawnTelemetry(args.telemetry) } diff --git a/src/main/ipc/pty/runtime/spawn-preflight-requested-shell.test.ts b/src/main/ipc/pty/runtime/spawn-preflight-requested-shell.test.ts index 88db8879630e..cfbace932ab8 100644 --- a/src/main/ipc/pty/runtime/spawn-preflight-requested-shell.test.ts +++ b/src/main/ipc/pty/runtime/spawn-preflight-requested-shell.test.ts @@ -96,7 +96,6 @@ describe('runtime pty spawn preflight: Claude account routing in a WSL pane', () new ClaudeProfileRouter({ getSettings: () => getDefaultSettings('/tmp'), dataRoot: '/data/orca', - env: {}, runSetup }) ) diff --git a/src/main/ipc/pty/runtime/spawn-preflight.ts b/src/main/ipc/pty/runtime/spawn-preflight.ts index 729cf74af786..c82b0656a377 100644 --- a/src/main/ipc/pty/runtime/spawn-preflight.ts +++ b/src/main/ipc/pty/runtime/spawn-preflight.ts @@ -23,8 +23,10 @@ import { } from '../host-env/fresh-spawn-routing' import { stripRemotePaneEnvWhenHooksDisabled } from '../provider/liveness' import { isTuiAgent } from '../../../../shared/tui-agent-config' +import { isClaudeAuthSwitchInProgress } from '../../../claude-accounts/live-pty-gate' import { CLAUDE_AUTH_ENV_CONFLICT_MESSAGE, + CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE, hasClaudeAuthEnvConflict } from '../../../claude-accounts/environment' import { @@ -68,6 +70,9 @@ export async function prepareRuntimePtySpawn( } ctx.isClaudeLaunch = !ctx.preAdoptedStablePane && !args.connectionId && isClaudeLaunchCommand(args.command) + if (ctx.isClaudeLaunch && isClaudeAuthSwitchInProgress()) { + throw new Error(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE) + } // Why: runtime-created terminals carry no renderer-computed projectRuntime; resolve from worktreeId to honor the project's Windows runtime. // `args.shellOverride` is the per-request pick (`terminal create --shell`), read here the way // the renderer twin (ipc/spawn-preflight.ts) reads a tab's override. Without it a runtime create @@ -146,6 +151,9 @@ export async function prepareRuntimePtySpawn( ctx.isClaudeLaunch && ctx.deps.prepareClaudeAuth ? await ctx.deps.prepareClaudeAuth(ctx.codexSelectionTarget) : null + if (ctx.isClaudeLaunch && isClaudeAuthSwitchInProgress()) { + throw new Error(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE) + } if (ctx.claudeAuth?.stripAuthEnv && hasClaudeAuthEnvConflict(args.env)) { throw new Error(CLAUDE_AUTH_ENV_CONFLICT_MESSAGE) } diff --git a/src/main/macos-keychain/generic-password.test.ts b/src/main/macos-keychain/generic-password.test.ts index 604fd8b00bd5..38ec1b05a723 100644 --- a/src/main/macos-keychain/generic-password.test.ts +++ b/src/main/macos-keychain/generic-password.test.ts @@ -4,7 +4,12 @@ const execFileMock = vi.hoisted(() => vi.fn()) vi.mock('node:child_process', () => ({ execFile: execFileMock })) -import { isKeychainNotFoundError, readKeychainPassword } from './generic-password' +import { + deleteKeychainPassword, + isKeychainNotFoundError, + readKeychainPassword, + writeKeychainPassword +} from './generic-password' const originalPlatform = process.platform @@ -66,10 +71,27 @@ describe('readKeychainPassword', () => { it('never shells out off macOS', async () => { setPlatform('win32') await expect(readKeychainPassword('svc', 'acct')).resolves.toBeNull() + await writeKeychainPassword('svc', 'acct', 'value') + await deleteKeychainPassword('svc', 'acct') expect(execFileMock).not.toHaveBeenCalled() }) }) +describe('deleteKeychainPassword', () => { + it('swallows a missing item', async () => { + fail(Object.assign(new Error('could not be found'), { code: 44 })) + await expect(deleteKeychainPassword('svc', 'acct')).resolves.toBeUndefined() + }) + + it('surfaces an access failure only when the caller asks for it', async () => { + fail(new Error('User interaction is not allowed')) + await expect(deleteKeychainPassword('svc', 'acct')).resolves.toBeUndefined() + await expect( + deleteKeychainPassword('svc', 'acct', { failOnAccessError: true }) + ).rejects.toThrow('User interaction is not allowed') + }) +}) + describe('isKeychainNotFoundError', () => { it('recognizes the security(1) not-found signals', () => { expect(isKeychainNotFoundError({ code: 44 })).toBe(true) diff --git a/src/main/macos-keychain/generic-password.ts b/src/main/macos-keychain/generic-password.ts index 1616008f303b..8a805edd809b 100644 --- a/src/main/macos-keychain/generic-password.ts +++ b/src/main/macos-keychain/generic-password.ts @@ -79,6 +79,20 @@ export function execSecurityCommand(args: string[]): Promise { + return execSecurityCommand(args).then(undefined, (error: unknown) => { + if (options?.ignoreNotFound && isKeychainNotFoundError(error)) { + return + } + if (!options?.ignoreFailure) { + throw error + } + }) +} + export async function readKeychainPassword( service: string, account: string @@ -106,3 +120,28 @@ export async function readKeychainPassword( throw error } } + +export async function writeKeychainPassword( + service: string, + account: string, + contents: string +): Promise { + if (process.platform !== 'darwin') { + return + } + await execSecurity(['add-generic-password', '-U', '-s', service, '-a', account, '-w', contents]) +} + +export async function deleteKeychainPassword( + service: string, + account: string, + options?: { failOnAccessError?: boolean } +): Promise { + if (process.platform !== 'darwin') { + return + } + await execSecurity(['delete-generic-password', '-s', service, '-a', account], { + ignoreNotFound: true, + ignoreFailure: !options?.failOnAccessError + }) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index 52d530c65ec0..3ead999cfbda 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -136,8 +136,6 @@ export type AgentSessionPreSpawnReason = Extract< | 'managedAccountUnsupported' | 'launchFolderMissing' | 'historyInOtherAccount' - | 'claudeAccountFolderMissing' - | 'claudeAccountSetupFailed' | 'agentCommandNotRunnable' > diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts index 5c66814475a4..fde97b1b09a9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts @@ -33,8 +33,6 @@ const TYPED_START_REFUSALS = [ 'managedAccountUnsupported', 'launchFolderMissing', 'historyInOtherAccount', - 'claudeAccountFolderMissing', - 'claudeAccountSetupFailed', 'agentCommandNotRunnable' ] as const satisfies readonly (AgentSessionFailureKind & AgentSessionRefusalReason<'agent_session_operation_invalid'>)[] diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts index 0a04d6cddfa5..a0f996c80b12 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts @@ -2,10 +2,6 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' -import { - CLAUDE_PROFILE_MISSING_MESSAGE, - CLAUDE_PROFILE_SETUP_FAILED_MESSAGE -} from '../../../shared/claude-profile-routing' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { mapRuntimeError } from '../../runtime/rpc/errors' @@ -173,18 +169,6 @@ describe('a create that fails before any process spawns', () => { 'historyInOtherAccount', "This chat's history is in another Claude account. Switch back to that account to continue it." ], - [ - 'a selected Claude account whose folder is gone', - CLAUDE_PROFILE_MISSING_MESSAGE, - 'claudeAccountFolderMissing', - CLAUDE_PROFILE_MISSING_MESSAGE - ], - [ - 'a selected Claude account that could not be set up', - CLAUDE_PROFILE_SETUP_FAILED_MESSAGE, - 'claudeAccountSetupFailed', - CLAUDE_PROFILE_SETUP_FAILED_MESSAGE - ], [ 'a Command setting that names no runnable program', 'the claude Command setting is not a runnable program', diff --git a/src/main/native-chat/claude-structured-managed-account-support.test.ts b/src/main/native-chat/claude-structured-managed-account-support.test.ts new file mode 100644 index 000000000000..8d6aa55783f4 --- /dev/null +++ b/src/main/native-chat/claude-structured-managed-account-support.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from 'vitest' +import { getSelectedClaudeAccountIdForTarget } from '../claude-accounts/runtime-selection' +import { + structuredClaudeMatchesActiveManagedAccount, + type ClaudeManagedAccountGateSettings +} from './claude-structured-managed-account-support' + +function account(id: string, managedAuthRuntime: 'host' | 'wsl') { + return { + id, + email: `${id}@example.com`, + managedAuthPath: `/managed/${id}`, + managedAuthRuntime, + authMethod: 'subscription-oauth' as const, + createdAt: 0, + updatedAt: 0, + lastAuthenticatedAt: 0 + } +} + +function settings( + overrides: Partial +): ClaudeManagedAccountGateSettings { + return { claudeManagedAccounts: [], activeClaudeManagedAccountId: null, ...overrides } +} + +describe('structuredClaudeMatchesActiveManagedAccount', () => { + it('allows an unmanaged install, where nothing claims an identity', () => { + expect(structuredClaudeMatchesActiveManagedAccount(settings({}))).toBe(true) + }) + + it('allows a selected host account, which the runtime syncs into the ambient config', () => { + expect( + structuredClaudeMatchesActiveManagedAccount( + settings({ + claudeManagedAccounts: [account('host-1', 'host')], + activeClaudeManagedAccountIdsByRuntime: { host: 'host-1', wsl: {} } + }) + ) + ).toBe(true) + }) + + it('refuses a WSL-only managed account, which never reaches the ambient config', () => { + expect( + structuredClaudeMatchesActiveManagedAccount( + settings({ + claudeManagedAccounts: [account('wsl-1', 'wsl')], + activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'wsl-1' } } + }) + ) + ).toBe(false) + }) + + it('refuses when a host selection names an account that is WSL-bound or missing', () => { + expect( + structuredClaudeMatchesActiveManagedAccount( + settings({ + claudeManagedAccounts: [account('wsl-1', 'wsl')], + activeClaudeManagedAccountIdsByRuntime: { host: 'wsl-1', wsl: {} } + }) + ) + ).toBe(false) + expect( + structuredClaudeMatchesActiveManagedAccount( + settings({ + claudeManagedAccounts: [account('host-1', 'host')], + activeClaudeManagedAccountIdsByRuntime: { host: 'gone', wsl: {} } + }) + ) + ).toBe(false) + }) + + /** Absent and empty are the same answer: this user has no managed Claude accounts, so nothing + * claims an identity and the ambient path is legitimate. Only settings that cannot be READ are + * unknown. Treating a missing key as unknown strands profiles that simply never wrote it — the + * auth policy's own predicate takes `(accounts ?? [])` for exactly this reason. */ + it('treats an absent account list the same as an empty one', () => { + expect( + structuredClaudeMatchesActiveManagedAccount(settings({ claudeManagedAccounts: [] })) + ).toBe(true) + expect( + structuredClaudeMatchesActiveManagedAccount({ + activeClaudeManagedAccountId: null + } as unknown as ClaudeManagedAccountGateSettings) + ).toBe(true) + }) + + it('fails closed when the settings cannot be read at all', () => { + expect(structuredClaudeMatchesActiveManagedAccount(null)).toBe(false) + expect(structuredClaudeMatchesActiveManagedAccount(undefined)).toBe(false) + }) + + /** THE discriminator, and the whole of this rule. With nothing selected for the host runtime the + * settings alone cannot distinguish honest deselection from the WSL-only steady state, because + * `pruneInvalidClaudeRuntimeSelection` empties the host slot in the second case and persists it. + * So the presence of ANY WSL-bound account decides. Simplifying this to "none active -> + * supported" re-opens the auth-identity misrepresentation this gate exists to prevent. */ + it('splits none-active on whether a WSL-bound account exists at all', () => { + const noneActive = (accounts: ReturnType[]) => + structuredClaudeMatchesActiveManagedAccount( + settings({ + claudeManagedAccounts: accounts, + activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: {} } + }) + ) + + expect(noneActive([account('host-1', 'host')])).toBe(true) + expect(noneActive([account('host-1', 'host'), account('host-2', 'host')])).toBe(true) + expect(noneActive([account('wsl-1', 'wsl')])).toBe(false) + // Mixed list still refuses: the WSL account is present and nothing is selected. + expect(noneActive([account('host-1', 'host'), account('wsl-1', 'wsl')])).toBe(false) + }) + + /** The gate and the auth policy must resolve the SAME account. A legacy settings blob carries the + * selection only in the flat `activeClaudeManagedAccountId`, which is where the accessor's + * fall-through lives — reading the runtime map directly silently disagrees with the policy. */ + it('resolves the same account as the auth policy on a legacy flat selection', () => { + const legacy = settings({ + claudeManagedAccounts: [account('host-1', 'host')], + activeClaudeManagedAccountId: 'host-1' + }) + + expect(getSelectedClaudeAccountIdForTarget(legacy, { runtime: 'host' })).toBe('host-1') + expect(structuredClaudeMatchesActiveManagedAccount(legacy)).toBe(true) + }) + + it('agrees with the auth policy that a legacy flat WSL selection is refused', () => { + const legacy = settings({ + claudeManagedAccounts: [account('wsl-1', 'wsl')], + activeClaudeManagedAccountId: 'wsl-1' + }) + + expect(getSelectedClaudeAccountIdForTarget(legacy, { runtime: 'host' })).toBe('wsl-1') + expect(structuredClaudeMatchesActiveManagedAccount(legacy)).toBe(false) + }) +}) diff --git a/src/main/native-chat/claude-structured-managed-account-support.ts b/src/main/native-chat/claude-structured-managed-account-support.ts new file mode 100644 index 000000000000..3b42c44c443f --- /dev/null +++ b/src/main/native-chat/claude-structured-managed-account-support.ts @@ -0,0 +1,68 @@ +import type { GlobalSettings } from '../../shared/global-settings-types' +import { getSelectedClaudeAccountIdForTarget } from '../claude-accounts/runtime-selection' + +export type ClaudeManagedAccountGateSettings = Pick< + GlobalSettings, + | 'claudeManagedAccounts' + | 'activeClaudeManagedAccountId' + | 'activeClaudeManagedAccountIdsByRuntime' +> + +/** + * A structured Claude session launches against the ambient Claude config, which the account service + * keeps in sync with the selected HOST account. A WSL-bound managed account lives inside the distro + * and is never synced there, so such a session would authenticate as whatever the ambient identity + * happens to be while the UI names the WSL account — the user is told one identity and given + * another. Refuse the structured path there and let the terminal-backed one, which resolves the + * account per runtime, handle that account shape. + * + * Reads the selection through the same accessor the auth policy uses. Resolving it any other way + * lets the two disagree, and a session admitted by this gate would then run under a policy computed + * from a different account than the one approved here. + * + * Unknown answers refuse, and only genuinely unknown ones: settings that cannot be read at all, or + * an active selection this cannot resolve. An install with no managed accounts — the list empty or + * never written — claims no identity and is fine. + */ +export function structuredClaudeMatchesActiveManagedAccount( + settings: ClaudeManagedAccountGateSettings | null | undefined +): boolean { + if (!settings) { + return false + } + // Absent is the same answer as empty — this user has no managed Claude accounts, so nothing + // claims an identity and ambient auth is the truth. Only settings that cannot be READ are + // unknown, and those refuse above. The auth policy reads the list the same way. + const accounts = settings.claudeManagedAccounts ?? [] + if (accounts.length === 0) { + return true + } + const activeHostId = getSelectedClaudeAccountIdForTarget(settings, { runtime: 'host' }) + if (!activeHostId) { + // Nothing selected for the host runtime is two different states that the settings cannot tell + // apart after the fact: honest deselection, where ambient auth is the truth and the UI names no + // identity, and the WSL-only case, where the prune emptied the host slot and persisted null + // while the UI still names the WSL account. The presence of any WSL-bound account decides. + return !hasWslBoundClaudeAccount(settings) + } + const active = accounts.find((candidate) => candidate.id === activeHostId) + return active ? active.managedAuthRuntime !== 'wsl' : false +} + +/** Whether any managed Claude account lives in WSL, the account shape the gate refuses over. */ +export function hasWslBoundClaudeAccount(settings: ClaudeManagedAccountGateSettings): boolean { + return (settings.claudeManagedAccounts ?? []).some( + (candidate) => candidate.managedAuthRuntime === 'wsl' + ) +} + +/** Reads the gate's settings, answering null when they cannot be read so callers refuse. */ +export function readClaudeManagedAccountGateSettings( + getSettings: () => ClaudeManagedAccountGateSettings +): ClaudeManagedAccountGateSettings | null { + try { + return getSettings() + } catch { + return null + } +} diff --git a/src/main/native-chat/structured-agent-session-create-support.test.ts b/src/main/native-chat/structured-agent-session-create-support.test.ts index a0b61c5ceadc..61dfd787920b 100644 --- a/src/main/native-chat/structured-agent-session-create-support.test.ts +++ b/src/main/native-chat/structured-agent-session-create-support.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' +import type { ClaudeManagedAccountGateSettings } from './claude-structured-managed-account-support' import { resolveStructuredAgentSessionCreateSupport } from './structured-agent-session-create-support' const LOCAL: AgentSessionExecutionLocation = { @@ -9,11 +10,64 @@ const LOCAL: AgentSessionExecutionLocation = { workspaceKind: 'git-worktree' } +function managedAccount(id: string, managedAuthRuntime: 'host' | 'wsl') { + return { + id, + email: `${id}@example.com`, + managedAuthPath: `/managed/${id}`, + managedAuthRuntime, + authMethod: 'subscription-oauth' as const, + createdAt: 0, + updatedAt: 0, + lastAuthenticatedAt: 0 + } +} + +const HOST_SELECTED: ClaudeManagedAccountGateSettings = { + claudeManagedAccounts: [managedAccount('host-1', 'host')], + activeClaudeManagedAccountId: 'host-1', + activeClaudeManagedAccountIdsByRuntime: { host: 'host-1', wsl: {} } +} + +const WSL_ONLY: ClaudeManagedAccountGateSettings = { + claudeManagedAccounts: [managedAccount('wsl-1', 'wsl')], + activeClaudeManagedAccountId: null, + activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'wsl-1' } } +} + +function support( + overrides: Partial[0]> = {} +) { + return resolveStructuredAgentSessionCreateSupport({ + agent: 'claude', + location: LOCAL, + adapterSupportsCreate: true, + getSettings: () => HOST_SELECTED, + ...overrides + }) +} + describe('resolveStructuredAgentSessionCreateSupport', () => { - it('supports what the adapter supports', () => { + it('supports Claude under a selected host account', () => { + expect(support()).toEqual({ supported: true }) + }) + + it('refuses Claude under a WSL-only managed account', () => { + expect(support({ getSettings: () => WSL_ONLY })).toEqual({ supported: false, reason: 'wsl' }) + }) + + it('fails closed for Claude when the settings throw', () => { expect( - resolveStructuredAgentSessionCreateSupport({ location: LOCAL, adapterSupportsCreate: true }) - ).toEqual({ supported: true }) + support({ + getSettings: () => { + throw new Error('no store') + } + }) + ).toEqual({ supported: false, reason: 'wsl' }) + }) + + it('leaves Codex to the adapter answer under the same WSL-only account', () => { + expect(support({ agent: 'codex', getSettings: () => WSL_ONLY })).toEqual({ supported: true }) }) it.each([ @@ -21,8 +75,22 @@ describe('resolveStructuredAgentSessionCreateSupport', () => { ['wsl workspace', { ...LOCAL, wslDistro: 'Ubuntu' }, 'wsl'], ['unsupported agent', LOCAL, 'agent'] ] as const)('keeps the adapter refusal reason for %s', (_name, location, reason) => { - expect( - resolveStructuredAgentSessionCreateSupport({ location, adapterSupportsCreate: false }) - ).toEqual({ supported: false, reason }) + expect(support({ adapterSupportsCreate: false, location })).toEqual({ + supported: false, + reason + }) + }) + + // Command values never change the selected chat surface. + it.each([ + ['claude', 'claude-wrapper'], + ['codex', 'codex-nightly'], + ['claude', 'npx claude'], + ['codex', 'wrapper --arg'], + ['claude', '/missing/claude'], + ['codex', './codex'] + ] as const)('supports %s when this host sets launch command %s', (agent, command) => { + const settings = { ...HOST_SELECTED, agentCmdOverrides: { [agent]: command } } + expect(support({ agent, getSettings: () => settings })).toEqual({ supported: true }) }) }) diff --git a/src/main/native-chat/structured-agent-session-create-support.ts b/src/main/native-chat/structured-agent-session-create-support.ts index 37a1fc033866..dc2b40ac1e65 100644 --- a/src/main/native-chat/structured-agent-session-create-support.ts +++ b/src/main/native-chat/structured-agent-session-create-support.ts @@ -1,16 +1,27 @@ import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' import type { StructuredAgentId } from '../../shared/agent-session-provider-handle' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import { + readClaudeManagedAccountGateSettings, + structuredClaudeMatchesActiveManagedAccount, + type ClaudeManagedAccountGateSettings +} from './claude-structured-managed-account-support' export type StructuredAgentSessionCreateSupport = { supported: boolean reason?: 'agent' | 'remote' | 'wsl' } -/** The create-support verdict: the adapter's location answer, with the reason a refusal names. */ +/** + * The create-support verdict, kept out of the runtime class file because that file is `@ts-nocheck` + * — a call site there is not typechecked, so an auth-identity decision written inline would compile + * however wrong it was. The runtime hands over the two facts it owns and this decides. + */ export function resolveStructuredAgentSessionCreateSupport(input: { + agent: StructuredAgentId location: AgentSessionExecutionLocation adapterSupportsCreate: boolean + getSettings: () => ClaudeManagedAccountGateSettings }): StructuredAgentSessionCreateSupport { if (!input.adapterSupportsCreate) { return { @@ -23,12 +34,26 @@ export function resolveStructuredAgentSessionCreateSupport(input: { : 'agent' } } + // Claude only: Codex resolves its account on a different path, so its answer is untouched here. + // `wsl` is the closest existing reason — the cause is a WSL-bound account rather than a WSL + // workspace — and no client reads the field, so it stays as-is. + if ( + input.agent === 'claude' && + !structuredClaudeMatchesActiveManagedAccount( + readClaudeManagedAccountGateSettings(input.getSettings) + ) + ) { + return { supported: false, reason: 'wsl' } + } return { supported: true } } -/** Which create-support check said no: where the chat would run, or the installed agent (its - * binary and version on this host). */ -export type StructuredAgentSessionCreateSupportCheck = 'location' | 'installed-agent' +/** Which create-support check said no: where the chat would run, the installed agent (its binary + * and version on this host), or Claude's managed-account binding. */ +export type StructuredAgentSessionCreateSupportCheck = + | 'location' + | 'installed-agent' + | 'managed-account' /** One main-log line per create-support verdict that sends a launch to the terminal; names the * check and never a path or environment value. */ diff --git a/src/main/persistence-worktree-lineage-and-backups.test.ts b/src/main/persistence-worktree-lineage-and-backups.test.ts index a9890f661644..1f750ff1503c 100644 --- a/src/main/persistence-worktree-lineage-and-backups.test.ts +++ b/src/main/persistence-worktree-lineage-and-backups.test.ts @@ -277,6 +277,67 @@ describe('Store', () => { }) }) + describe('claudeLivePtySessionIds', () => { + it('persists added ids across reloads and removes them durably', async () => { + const store = await createStore() + + store.addClaudeLivePtySessionId('claude-session-1') + store.addClaudeLivePtySessionId('claude-session-2') + store.addClaudeLivePtySessionId('claude-session-1') + + expect(store.getClaudeLivePtySessionIds()).toEqual(['claude-session-1', 'claude-session-2']) + + const reloaded = await createStore() + expect(reloaded.getClaudeLivePtySessionIds()).toEqual([ + 'claude-session-1', + 'claude-session-2' + ]) + + reloaded.removeClaudeLivePtySessionId('claude-session-1') + reloaded.flush() + + const reloadedAgain = await createStore() + expect(reloadedAgain.getClaudeLivePtySessionIds()).toEqual(['claude-session-2']) + }) + + it('drops malformed persisted entries on load', async () => { + writeDataFile({ + schemaVersion: 1, + claudeLivePtySessionIds: ['valid-id', '', 42, null, 'valid-id', 'x'.repeat(513)] + }) + + const store = await createStore() + + expect(store.getClaudeLivePtySessionIds()).toEqual(['valid-id']) + }) + + it('keeps the newest ids when an oversized persisted list is loaded', async () => { + writeDataFile({ + schemaVersion: 1, + claudeLivePtySessionIds: Array.from({ length: 205 }, (_, index) => `claude-${index}`) + }) + + const store = await createStore() + + const ids = store.getClaudeLivePtySessionIds() + expect(ids).toHaveLength(200) + expect(ids[0]).toBe('claude-5') + expect(ids[199]).toBe('claude-204') + }) + + it('caps the persisted id list', async () => { + const store = await createStore() + for (let index = 0; index < 205; index += 1) { + store.addClaudeLivePtySessionId(`claude-session-${index}`) + } + + const ids = store.getClaudeLivePtySessionIds() + expect(ids).toHaveLength(200) + expect(ids[0]).toBe('claude-session-5') + expect(ids[199]).toBe('claude-session-204') + }) + }) + describe('retired JSON backups', () => { it.each(['sync', 'async'] as const)( 'leaves legacy JSON and backups unchanged during %s SQL writes', diff --git a/src/main/persistence/leasing-ssh-ptys/ssh-target-state.ts b/src/main/persistence/leasing-ssh-ptys/ssh-target-state.ts index 7f72f2117aba..2f0ccdc8165f 100644 --- a/src/main/persistence/leasing-ssh-ptys/ssh-target-state.ts +++ b/src/main/persistence/leasing-ssh-ptys/ssh-target-state.ts @@ -2,6 +2,7 @@ import type { PersistedState } from '../../../shared/persisted-state-types' import type { RemovedSshTargetTombstone, SshTarget } from '../../../shared/ssh-types' import type { ProtectedSecretPersistence } from '../../protected-secret-persistence' import { sshPtyOwnerLeaseSecretSlot } from '../../protected-secret-persistence' +import { MAX_CLAUDE_LIVE_PTY_SESSION_IDS } from '../restoring-sessions/pane-alias-normalization' import { MAX_REMOVED_SSH_TARGET_TOMBSTONES, capRemovedSshTargetTombstones, @@ -18,6 +19,7 @@ export type SshTargetStateOperations = { state: PersistedState protectedSecrets: Pick scheduleSave: () => void + flush: () => void } export function getSshTargets(state: PersistedState): SshTarget[] { @@ -90,6 +92,41 @@ export function removeSshTarget(operations: SshTargetStateOperations, id: string operations.scheduleSave() } +export function getClaudeLivePtySessionIds(state: PersistedState): string[] { + return [...(state.claudeLivePtySessionIds ?? [])] +} + +export function addClaudeLivePtySessionId( + operations: SshTargetStateOperations, + sessionId: string +): void { + if (sessionId.length === 0 || sessionId.length > 512) { + return + } + const ids = operations.state.claudeLivePtySessionIds ?? [] + if (ids.includes(sessionId)) { + return + } + // Why: drop oldest at the cap — stale ids get pruned against the daemon at startup, so only recency matters. + operations.state.claudeLivePtySessionIds = [...ids, sessionId].slice( + -MAX_CLAUDE_LIVE_PTY_SESSION_IDS + ) + // Why: flush sync so a force-quit right after a Claude spawn still seeds the live-PTY gate next launch. + operations.flush() +} + +export function removeClaudeLivePtySessionId( + operations: SshTargetStateOperations, + sessionId: string +): void { + const ids = operations.state.claudeLivePtySessionIds ?? [] + if (!ids.includes(sessionId)) { + return + } + operations.state.claudeLivePtySessionIds = ids.filter((id) => id !== sessionId) + operations.scheduleSave() +} + export function getDeletedSshConfigAliases(state: PersistedState): string[] { return [...(state.deletedSshConfigAliases ?? [])] } diff --git a/src/main/persistence/loading-store/normalize-loaded-profile-state.ts b/src/main/persistence/loading-store/normalize-loaded-profile-state.ts index 7045f922de88..1a3ba807c5ab 100644 --- a/src/main/persistence/loading-store/normalize-loaded-profile-state.ts +++ b/src/main/persistence/loading-store/normalize-loaded-profile-state.ts @@ -12,6 +12,7 @@ import { normalizeSshTarget } from '../leasing-ssh-ptys/ssh-normalization' import { + normalizeClaudeLivePtySessionIds, normalizeLegacyPaneKeyAliasEntries, normalizeMigrationUnsupportedPtyEntries } from '../restoring-sessions/pane-alias-normalization' @@ -101,6 +102,7 @@ export function normalizeLoadedProfileState( .map(normalizeSshRemotePtyLease) .filter((lease): lease is SshRemotePtyLease => lease !== null), sshPtyConsumerRecoveries: parsed.sshPtyConsumerRecoveries, + claudeLivePtySessionIds: normalizeClaudeLivePtySessionIds(parsed.claudeLivePtySessionIds), migrationUnsupportedPtyEntries: normalizeMigrationUnsupportedPtyEntries( parsed.migrationUnsupportedPtyEntries ), diff --git a/src/main/persistence/loading-store/profile-state-direct-flush.test.ts b/src/main/persistence/loading-store/profile-state-direct-flush.test.ts index 9e2bed18bc01..9cf3d1ec2d1c 100644 --- a/src/main/persistence/loading-store/profile-state-direct-flush.test.ts +++ b/src/main/persistence/loading-store/profile-state-direct-flush.test.ts @@ -93,6 +93,16 @@ describe('SQLite durability barriers with another selective write pending', () = }) }) + it('commits Claude live-PTY admission before returning', () => { + const state = fixture() + state.pendSession() + state.store.addClaudeLivePtySessionId('claude-session') + expect(state.read('claudeLivePtySessionIds')).toMatchObject({ + kind: 'value', + value: ['claude-session'] + }) + }) + it('commits SSH lease admission before returning', () => { const state = fixture() state.pendSession() diff --git a/src/main/persistence/loading-store/ssh-profile-operations.ts b/src/main/persistence/loading-store/ssh-profile-operations.ts index ad12108129ef..ce81cea373b8 100644 --- a/src/main/persistence/loading-store/ssh-profile-operations.ts +++ b/src/main/persistence/loading-store/ssh-profile-operations.ts @@ -1,14 +1,17 @@ import type { RemovedSshTargetTombstone, SshTarget } from '../../../shared/ssh-types' import { type SshTargetStateOperations, + addClaudeLivePtySessionId as addClaudeLivePtySessionIdOperation, addDeletedSshConfigAlias as addDeletedSshConfigAliasOperation, addRemovedSshTargetTombstone as addRemovedSshTargetTombstoneOperation, addSshTarget as addSshTargetOperation, clearDeletedSshConfigAliases as clearDeletedSshConfigAliasesOperation, + getClaudeLivePtySessionIds as getClaudeLivePtySessionIdsOperation, getDeletedSshConfigAliases as getDeletedSshConfigAliasesOperation, getRemovedSshTargetTombstones as getRemovedSshTargetTombstonesOperation, getSshTarget as getSshTargetOperation, getSshTargets as getSshTargetsOperation, + removeClaudeLivePtySessionId as removeClaudeLivePtySessionIdOperation, removeDeletedSshConfigAlias as removeDeletedSshConfigAliasOperation, removeRemovedSshTargetTombstone as removeRemovedSshTargetTombstoneOperation, releaseRemovedSshTargetTombstone as releaseRemovedSshTargetTombstoneOperation, @@ -23,17 +26,22 @@ import { allocateSshTargetGeneration as allocateSshTargetGenerationOperation } f import type { StoreRuntimeState } from './store-runtime-state' import type { WriteSchedulingOperations } from './write-scheduling' +import type { WriteFlushBarrierOperations } from './write-flush-barriers' import type { RepoLifecycleOperations } from './repo-lifecycle-operations' import { syncProjectHostSetupCompatibilityState } from './repo-lifecycle-operations' import { scheduleSave } from './write-scheduling' import { forgetSshConnectionGeneration } from '../../ssh/ssh-connection-generation' -type SshProfileOperationsRuntime = Pick +type SshProfileOperationsRuntime = Pick< + StoreRuntimeState, + 'dirtyProfileStateDomains' | 'protectedSecrets' | 'state' +> const sshProfileOperationsContext = Symbol('SshProfileOperations') type SshProfileOperationsContext = { runtime: SshProfileOperationsRuntime scheduling: WriteSchedulingOperations + flushBarriers: WriteFlushBarrierOperations repos: RepoLifecycleOperations } @@ -43,9 +51,10 @@ export class SshProfileOperations { constructor( runtime: SshProfileOperationsRuntime, scheduling: WriteSchedulingOperations, + flushBarriers: WriteFlushBarrierOperations, repos: RepoLifecycleOperations ) { - this[sshProfileOperationsContext] = { runtime, scheduling, repos } + this[sshProfileOperationsContext] = { runtime, scheduling, flushBarriers, repos } } getSshTargets(): SshTarget[] { @@ -79,6 +88,18 @@ export class SshProfileOperations { ) } + getClaudeLivePtySessionIds(): string[] { + return getClaudeLivePtySessionIdsOperation(this[sshProfileOperationsContext].runtime.state) + } + + addClaudeLivePtySessionId(sessionId: string): void { + addClaudeLivePtySessionIdOperation(getSshTargetStateOperations(this), sessionId) + } + + removeClaudeLivePtySessionId(sessionId: string): void { + removeClaudeLivePtySessionIdOperation(getSshTargetStateOperations(this), sessionId) + } + getDeletedSshConfigAliases(): string[] { return getDeletedSshConfigAliasesOperation(this[sshProfileOperationsContext].runtime.state) } @@ -127,7 +148,13 @@ export function getSshTargetStateOperations(owner: SshProfileOperations): SshTar return { state: owner[sshProfileOperationsContext].runtime.state, protectedSecrets: owner[sshProfileOperationsContext].runtime.protectedSecrets, - scheduleSave: () => scheduleSave(owner[sshProfileOperationsContext].scheduling) + scheduleSave: () => scheduleSave(owner[sshProfileOperationsContext].scheduling), + flush: () => { + owner[sshProfileOperationsContext].runtime.dirtyProfileStateDomains?.add( + 'claudeLivePtySessionIds' + ) + owner[sshProfileOperationsContext].flushBarriers.flush() + } } } diff --git a/src/main/persistence/loading-store/store-domain-composition.ts b/src/main/persistence/loading-store/store-domain-composition.ts index cf3063ad37c8..4ba2e58881ac 100644 --- a/src/main/persistence/loading-store/store-domain-composition.ts +++ b/src/main/persistence/loading-store/store-domain-composition.ts @@ -183,7 +183,7 @@ export function createStoreDomains(runtime: StoreRuntimeState): StoreDomains { const mobileTabSelections = new MobileTabSelectionPersistence(runtime, scheduling) const sparsePresets = new SparsePresetPersistence(runtime, scheduling) const ptyBindings = new PtyBindingPersistenceOperations(runtime, sessions) - const sshProfiles = new SshProfileOperations(runtime, scheduling, repos) + const sshProfiles = new SshProfileOperations(runtime, scheduling, flushBarriers, repos) const retiredWorktreeNames = new RetiredWorktreeNamePersistence(runtime, scheduling) const sshLeases = new SshLeaseRecoveryOperations( runtime, diff --git a/src/main/persistence/restoring-sessions/pane-alias-normalization.ts b/src/main/persistence/restoring-sessions/pane-alias-normalization.ts index 6b9dca94c923..c7e88c480217 100644 --- a/src/main/persistence/restoring-sessions/pane-alias-normalization.ts +++ b/src/main/persistence/restoring-sessions/pane-alias-normalization.ts @@ -40,9 +40,33 @@ export function legacyMigrationUnsupportedRowsToAliasEntries( return aliasEntries } +// Why: bounds a corrupt/bloated persisted list — the gate only needs the few Claude sessions a daemon can keep alive. +export const MAX_CLAUDE_LIVE_PTY_SESSION_IDS = 200 + // Why: bound removed-SSH-target history so remove/re-add churn can't grow the file unbounded. export const MAX_REMOVED_SSH_TARGET_TOMBSTONES = 50 +export function normalizeClaudeLivePtySessionIds(value: unknown): string[] { + if (!Array.isArray(value)) { + return [] + } + // Why: scan newest-first so the cap keeps the most recent ids, matching addClaudeLivePtySessionId's eviction policy. + const ids: string[] = [] + for (let index = value.length - 1; index >= 0; index -= 1) { + const entry = value[index] + if (typeof entry !== 'string' || entry.length === 0 || entry.length > 512) { + continue + } + if (!ids.includes(entry)) { + ids.push(entry) + } + if (ids.length >= MAX_CLAUDE_LIVE_PTY_SESSION_IDS) { + break + } + } + return ids.toReversed() +} + export function normalizeMigrationUnsupportedPtyEntries( value: unknown ): MigrationUnsupportedPtyEntry[] { diff --git a/src/main/providers/local-pty-windows-spawn-environment.ts b/src/main/providers/local-pty-windows-spawn-environment.ts index a1f194e9af0c..63ad18883dc9 100644 --- a/src/main/providers/local-pty-windows-spawn-environment.ts +++ b/src/main/providers/local-pty-windows-spawn-environment.ts @@ -1,8 +1,4 @@ import { win32 as pathWin32 } from 'node:path' -import { - CLAUDE_INJECTED_CONFIG_DIR_ENV, - CLAUDE_PROFILE_POINTER_ENV -} from '../../shared/claude-profile-routing' import { ORCA_HERMES_STARTUP_QUERY_ENV } from '../../shared/hermes-startup-query' import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-wsl-env' import { addWslEnvKeys } from '../wsl-env' @@ -59,8 +55,8 @@ export function finalizeWindowsLocalPtySpawnEnvironment(args: { // Why: managed WSL Claude passes a Linux CLAUDE_CONFIG_DIR through wsl.exe; non-default vars need WSLENV import. addWslEnvKeys(env, ['CLAUDE_CONFIG_DIR']) } - if (env[CLAUDE_PROFILE_POINTER_ENV]) { - addWslEnvKeys(env, [CLAUDE_PROFILE_POINTER_ENV, CLAUDE_INJECTED_CONFIG_DIR_ENV]) + if (env.ORCA_CLAUDE_PROFILE_POINTER) { + addWslEnvKeys(env, ['ORCA_CLAUDE_PROFILE_POINTER', 'ORCA_CLAUDE_INJECTED_CONFIG_DIR']) } if (env[ORCA_HERMES_STARTUP_QUERY_ENV] !== undefined) { // Why: wsl.exe drops custom Windows env vars; the startup wrapper needs this imported inside WSL. diff --git a/src/main/providers/windows-shell-args.test.ts b/src/main/providers/windows-shell-args.test.ts index e6e573263ab9..38352a1b6d48 100644 --- a/src/main/providers/windows-shell-args.test.ts +++ b/src/main/providers/windows-shell-args.test.ts @@ -308,7 +308,7 @@ describe('resolveWindowsShellLaunchArgs', () => { expect(result.startupCommandDeliveredInShellArgs).toBeUndefined() }) - it('gives a plain Git Bash tab the same rcfile as one with a startup command', () => { + it('keeps a plain Git Bash tab a login shell and wraps one with a startup command', () => { const plain = resolveWindowsShellLaunchArgs( 'C:\\Program Files\\Git\\bin\\bash.exe', 'C:\\Users\\alice', @@ -322,12 +322,14 @@ describe('resolveWindowsShellLaunchArgs', () => { "codex 'fix the bug'" ) - expect(plain.shellArgs).toEqual(launched.shellArgs) - // Why: only the rcfile carries the account-following claude function and codex --no-daemon wrapper. - const rcfile = readFileSync(getGitBashRcfilePath(plain.shellArgs[1]), 'utf8') - expect(rcfile).toContain('function claude {') - expect(rcfile).toContain('set -- --no-daemon "$@"') - expect(rcfile).toContain('source "$HOME/.bash_profile"') + expect(plain.shellArgs).toEqual([ + '-c', + 'chcp.com 65001 >/dev/null 2>&1; exec "$BASH" --login -i' + ]) + // Why: without a preflight, only the rcfile carries the codex --no-daemon wrapper. + expect(readFileSync(getGitBashRcfilePath(launched.shellArgs[1]), 'utf8')).toContain( + 'set -- --no-daemon "$@"' + ) expect(launched.startupCommandDeliveredInShellArgs).toBeUndefined() }) diff --git a/src/main/providers/windows-shell-args.ts b/src/main/providers/windows-shell-args.ts index b905a3fbb302..c5ceade00a44 100644 --- a/src/main/providers/windows-shell-args.ts +++ b/src/main/providers/windows-shell-args.ts @@ -29,9 +29,12 @@ const CMD_CODEX_LAUNCH_PREFLIGHT = `if defined ORCA_CODEX_LAUNCH_PREFLIGHT call // `&&`) keeps startup working even if chcp.com is missing. const GIT_BASH_UTF8_LOGIN_COMMAND = 'chcp.com 65001 >/dev/null 2>&1; exec "$BASH" --login -i' -// Why every pane gets the rcfile: it defines the claude function that follows the selected -// account (and the codex wrapper), as bash panes elsewhere get; it sources the login chain itself. -function getGitBashLaunchCommand(): string { +// Why the rcfile for a startup command: it defines the codex wrapper Orca's launches need. +function getGitBashLaunchCommand(useWrapper: boolean): string { + if (!useWrapper) { + return GIT_BASH_UTF8_LOGIN_COMMAND + } + ensureShellReadyWrappersAt() const wrapperArgs = getBashWrapperLaunchArgs() if (!wrapperArgs) { @@ -214,7 +217,10 @@ export function resolveWindowsShellLaunchArgs( if (isWindowsGitBashShellPath(shellPath)) { return { - shellArgs: ['-c', getGitBashLaunchCommand()], + shellArgs: [ + '-c', + getGitBashLaunchCommand(Boolean(codexLaunchPreflightCommand) || Boolean(startupCommand)) + ], effectiveCwd: nativeCwd, validationCwd: nativeCwd } diff --git a/src/main/rate-limits/claude-active-usage-fetch.ts b/src/main/rate-limits/claude-active-usage-fetch.ts index df9867efd3b4..f285ba13ce93 100644 --- a/src/main/rate-limits/claude-active-usage-fetch.ts +++ b/src/main/rate-limits/claude-active-usage-fetch.ts @@ -1,86 +1,248 @@ import type { ProviderRateLimits } from '../../shared/rate-limit-types' +import { withMacTailscaleDnsHint } from '../network/macos-tailscale-dns-diagnostic' +import { completeClaudeOAuthUsageSuccess, fetchClaudeUsageViaCli } from './claude-cli-usage-fetch' import { readClaudeOAuthCredentials, resolveClaudeOAuthCredentialReadOptions } from './claude-oauth-credentials' +import { + canRetryClaudeOAuthWithLegacyKeychain, + makeClaudeUsageClassificationError, + makeLiveClaudeUsageDeferredResult, + repairClaudeCredentialsThenRetryOAuth, + retryClaudeOAuthWithLegacyKeychain, + shouldDeferClaudeUsageForLiveSession +} from './claude-oauth-recovery' import { fetchClaudeOAuthUsage } from './claude-oauth-usage-request' -import { classifyClaudeOAuthUsageError } from './claude-usage-error-classification' -import { OAuthUsageError } from './claude-oauth-usage-error' +import { + classifyClaudeCredentialAbsence, + classifyClaudeOAuthUsageError +} from './claude-usage-error-classification' import type { ClaudeRateLimitFetchOptions } from './claude-usage-fetch-options' -import { abortedClaudeRateLimitResult, makeClaudeUsageResult } from './claude-usage-result' -import { CLAUDE_PROFILE_MISSING_MESSAGE } from '../../shared/claude-profile-routing' +import { resolveClaudeUsageRefreshPlan } from './claude-usage-refresh-plan' +import { + abortedClaudeRateLimitResult, + makeClaudeUsageResult, + metadataForClaudeUsageAttempt, + recordClaudeUsageAttempt, + warnClaudeUsageFetchFailure +} from './claude-usage-result' -/** Usage observes the account; only a user-started Claude process may refresh its login. */ export async function fetchActiveClaudeRateLimits( options?: ClaudeRateLimitFetchOptions ): Promise { - const usageError = options?.authPreparation?.usageError - if (usageError) { - return makeClaudeUsageResult('error', usageError, { - failureKind: - usageError === CLAUDE_PROFILE_MISSING_MESSAGE ? 'missing-credentials' : 'usage-unavailable', - attemptedSources: [] - }) - } if (options?.signal?.aborted) { return abortedClaudeRateLimitResult() } - const credentials = await readClaudeOAuthCredentials( + const attempts = { attemptedSources: [] } + const allowCliFallback = options?.allowPtyFallback !== false + const plan = resolveClaudeUsageRefreshPlan({ + authPreparation: options?.authPreparation, + allowCliFallback + }) + + if (options?.authPreparation?.runtime === 'wsl' && !options.authPreparation.wslLinuxConfigDir) { + return makeClaudeUsageResult( + 'error', + `WSL Claude config unavailable for ${options.authPreparation.wslDistro ?? 'default distro'}`, + { + attemptedSources: [], + failureKind: 'cli-unavailable', + authProvenance: options.authPreparation.provenance + } + ) + } + + const oauthCredentials = await readClaudeOAuthCredentials( resolveClaudeOAuthCredentialReadOptions(options?.authPreparation) ) - const metadata = { - credentialSource: credentials.source, - authProvenance: options?.authPreparation?.provenance ?? 'system' + if (options?.signal?.aborted) { + return abortedClaudeRateLimitResult() + } + + if (plan.steps.some((step) => step.source === 'oauth') && oauthCredentials.token) { + recordClaudeUsageAttempt(attempts, 'oauth') + try { + const oauthLimits = await fetchClaudeOAuthUsage(oauthCredentials.token, options?.signal) + if (options?.signal?.aborted) { + return abortedClaudeRateLimitResult() + } + return await completeClaudeOAuthUsageSuccess({ + oauthLimits, + oauthCredentials, + attempts, + options + }) + } catch (error) { + warnClaudeUsageFetchFailure(options?.authPreparation, oauthCredentials, error) + const classification = classifyClaudeOAuthUsageError(error) + + if ( + canRetryClaudeOAuthWithLegacyKeychain({ + classification, + oauthCredentials, + authPreparation: options?.authPreparation + }) + ) { + const legacyResult = await retryClaudeOAuthWithLegacyKeychain({ + failedToken: oauthCredentials.token, + attempts, + options + }) + if (legacyResult) { + return legacyResult + } + } + + if (shouldDeferClaudeUsageForLiveSession(options?.authPreparation, classification)) { + return makeLiveClaudeUsageDeferredResult({ + attempts, + oauthCredentials, + authPreparation: options?.authPreparation + }) + } + + if (classification.shouldAttemptDelegatedRefresh && allowCliFallback) { + const repaired = await repairClaudeCredentialsThenRetryOAuth({ + options, + attempts, + oauthCredentials + }) + if (repaired) { + return repaired + } + } + + if (classification.shouldAttemptCliFallback && allowCliFallback) { + try { + return await fetchClaudeUsageViaCli({ + authPreparation: options?.authPreparation, + oauthCredentials, + attempts, + networkProxySettings: options?.networkProxySettings, + signal: options?.signal + }) + } catch (ptyError) { + warnClaudeUsageFetchFailure(options?.authPreparation, oauthCredentials, ptyError) + } + } + + return makeClaudeUsageClassificationError({ + error, + classification, + attempts, + oauthCredentials, + authPreparation: options?.authPreparation + }) + } } - // Why: System Default with no Claude login is an API-key or non-Claude user, not a signed-out account. + + const credentialClassification = classifyClaudeCredentialAbsence({ + hasRefreshableCredentials: oauthCredentials.hasRefreshableCredentials, + keychainUnavailable: oauthCredentials.keychainUnavailable, + managedRefreshDeferredByLivePty: options?.authPreparation?.managedRefreshDeferredByLivePty + }) + + if (shouldDeferClaudeUsageForLiveSession(options?.authPreparation, credentialClassification)) { + return makeLiveClaudeUsageDeferredResult({ + attempts, + oauthCredentials, + authPreparation: options?.authPreparation + }) + } + if ( - !credentials.token && - !credentials.unavailable && - !credentials.hasRefreshableCredentials && - metadata.authProvenance === 'system' + oauthCredentials.hasRefreshableCredentials && + credentialClassification.shouldAttemptDelegatedRefresh && + allowCliFallback ) { - return makeClaudeUsageResult('unavailable', 'No subscription plan — API key billing', { - ...metadata, - failureKind: 'missing-credentials', - attemptedSources: [] + const repaired = await repairClaudeCredentialsThenRetryOAuth({ + options, + attempts, + oauthCredentials }) + if (repaired) { + return repaired + } } - if (!credentials.token) { - return makeClaudeUsageResult( - 'error', - credentials.unavailable - ? 'Claude usage is unavailable.' - : 'Sign in again to use this account.', - { - ...metadata, - failureKind: credentials.unavailable ? 'keychain-unavailable' : 'missing-credentials', - attemptedSources: [] - } - ) + + if ( + (oauthCredentials.token || + oauthCredentials.hasRefreshableCredentials || + oauthCredentials.keychainUnavailable) && + credentialClassification.shouldAttemptCliFallback && + allowCliFallback + ) { + try { + return await fetchClaudeUsageViaCli({ + authPreparation: options?.authPreparation, + oauthCredentials, + attempts, + networkProxySettings: options?.networkProxySettings, + signal: options?.signal + }) + } catch (error) { + warnClaudeUsageFetchFailure(options?.authPreparation, oauthCredentials, error) + return makeClaudeUsageResult( + 'error', + withMacTailscaleDnsHint(error instanceof Error ? error.message : 'Unknown error'), + { + ...metadataForClaudeUsageAttempt({ + attemptedSources: attempts.attemptedSources, + oauthCredentials, + authPreparation: options?.authPreparation, + failureKind: + credentialClassification.failureKind === 'keychain-unavailable' + ? 'keychain-unavailable' + : 'cli-unavailable' + }) + } + ) + } } - try { - return await fetchClaudeOAuthUsage(credentials.token, options?.signal) - } catch (error) { - const { failureKind } = classifyClaudeOAuthUsageError(error) - // Why: the 429 wait gates the poll, and both messages tell the user why usage is missing. - const retryAfterMs = error instanceof OAuthUsageError ? error.retryAfterMs : null - return makeClaudeUsageResult( - 'error', - failureKind === 'stale-token' - ? // Why: Claude refreshes its own login when it next runs; System Default has no "account". - metadata.authProvenance === 'system' - ? 'Claude usage updates the next time Claude runs.' - : 'Claude usage has expired. Start Claude in this account to refresh it.' - : (failureKind === 'rate-limited' || failureKind === 'missing-scope') && - error instanceof OAuthUsageError - ? error.message - : 'Claude usage is unavailable.', - { - ...metadata, - failureKind, - attemptedSources: ['oauth'], - ...(retryAfterMs ? { retryAtMs: Date.now() + retryAfterMs } : {}) - } - ) + + if (oauthCredentials.keychainUnavailable) { + return makeClaudeUsageResult('error', 'Claude Keychain credentials unavailable', { + ...metadataForClaudeUsageAttempt({ + attemptedSources: attempts.attemptedSources, + oauthCredentials, + authPreparation: options?.authPreparation, + failureKind: 'keychain-unavailable' + }) + }) } + + if (oauthCredentials.hasRefreshableCredentials) { + return makeClaudeUsageResult('error', 'Claude OAuth access token unavailable', { + ...metadataForClaudeUsageAttempt({ + attemptedSources: attempts.attemptedSources, + oauthCredentials, + authPreparation: options?.authPreparation, + failureKind: credentialClassification.failureKind + }) + }) + } + + if (allowCliFallback && plan.steps.some((step) => step.source === 'cli')) { + try { + return await fetchClaudeUsageViaCli({ + authPreparation: options?.authPreparation, + oauthCredentials, + attempts, + networkProxySettings: options?.networkProxySettings, + signal: options?.signal + }) + } catch (error) { + warnClaudeUsageFetchFailure(options?.authPreparation, oauthCredentials, error) + } + } + + return makeClaudeUsageResult('unavailable', 'No subscription plan — API key billing', { + ...metadataForClaudeUsageAttempt({ + attemptedSources: attempts.attemptedSources, + oauthCredentials, + authPreparation: options?.authPreparation, + failureKind: 'missing-credentials' + }) + }) } diff --git a/src/main/rate-limits/claude-cli-usage-fetch.ts b/src/main/rate-limits/claude-cli-usage-fetch.ts new file mode 100644 index 000000000000..70d264a4e68f --- /dev/null +++ b/src/main/rate-limits/claude-cli-usage-fetch.ts @@ -0,0 +1,117 @@ +import type { ProviderRateLimits, UsageRateLimitFailureKind } from '../../shared/rate-limit-types' +import type { NetworkProxySettings } from '../../shared/network-proxy' +import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' +import { fetchViaPty } from './claude-pty' +import type { ClaudeOAuthCredentialReadResult } from './claude-oauth-credentials' +import type { ClaudeRateLimitFetchOptions } from './claude-usage-fetch-options' +import { + abortedClaudeRateLimitResult, + canSupplementClaudeOAuthUsage, + isManagedClaudeAuth, + mergeClaudeUsageWindows, + metadataForClaudeUsageAttempt, + recordClaudeUsageAttempt, + type ClaudeUsageAttemptState, + warnClaudeUsageFetchFailure, + withClaudeUsageMetadata +} from './claude-usage-result' + +function classifyClaudeCliUsageFailure( + limits: ProviderRateLimits +): UsageRateLimitFailureKind | undefined { + if (!limits.error) { + return undefined + } + if (/rate limited/i.test(limits.error)) { + return 'rate-limited' + } + if (/plan usage is unavailable|usage is unavailable/i.test(limits.error)) { + return 'usage-unavailable' + } + return 'cli-unavailable' +} + +export async function fetchClaudeUsageViaCli(input: { + authPreparation?: ClaudeRuntimeAuthPreparation + oauthCredentials: ClaudeOAuthCredentialReadResult + attempts: ClaudeUsageAttemptState + networkProxySettings?: NetworkProxySettings + signal?: AbortSignal +}): Promise { + recordClaudeUsageAttempt(input.attempts, 'cli') + const limits = await fetchViaPty({ + authPreparation: input.authPreparation, + networkProxySettings: input.networkProxySettings, + signal: input.signal + }) + return withClaudeUsageMetadata( + limits, + metadataForClaudeUsageAttempt({ + attemptedSources: input.attempts.attemptedSources, + oauthCredentials: input.oauthCredentials, + authPreparation: input.authPreparation, + source: 'cli', + failureKind: classifyClaudeCliUsageFailure(limits) + }) + ) +} + +async function supplementOAuthUsageFromCli(input: { + oauthLimits: ProviderRateLimits + authPreparation?: ClaudeRuntimeAuthPreparation + oauthCredentials: ClaudeOAuthCredentialReadResult + attempts: ClaudeUsageAttemptState + allowUsagePanelSupplement: boolean + networkProxySettings?: NetworkProxySettings + signal?: AbortSignal +}): Promise { + if (input.signal?.aborted || !canSupplementClaudeOAuthUsage(input)) { + return input.oauthLimits + } + try { + return mergeClaudeUsageWindows( + input.oauthLimits, + await fetchClaudeUsageViaCli({ + authPreparation: input.authPreparation, + oauthCredentials: input.oauthCredentials, + attempts: input.attempts, + networkProxySettings: input.networkProxySettings, + signal: input.signal + }) + ) + } catch (error) { + warnClaudeUsageFetchFailure(input.authPreparation, input.oauthCredentials, error) + return input.oauthLimits + } +} + +export async function completeClaudeOAuthUsageSuccess(input: { + oauthLimits: ProviderRateLimits + oauthCredentials: ClaudeOAuthCredentialReadResult + attempts: ClaudeUsageAttemptState + options?: ClaudeRateLimitFetchOptions +}): Promise { + const limits = await supplementOAuthUsageFromCli({ + oauthLimits: input.oauthLimits, + authPreparation: input.options?.authPreparation, + oauthCredentials: input.oauthCredentials, + attempts: input.attempts, + networkProxySettings: input.options?.networkProxySettings, + allowUsagePanelSupplement: + input.options?.allowUsagePanelSupplement ?? + isManagedClaudeAuth(input.options?.authPreparation), + signal: input.options?.signal + }) + if (input.options?.signal?.aborted) { + return abortedClaudeRateLimitResult() + } + return withClaudeUsageMetadata( + limits, + metadataForClaudeUsageAttempt({ + attemptedSources: input.attempts.attemptedSources, + oauthCredentials: input.oauthCredentials, + authPreparation: input.options?.authPreparation, + source: 'oauth' + }) + ) +} diff --git a/src/main/rate-limits/claude-fetcher-cli-fallback.test.ts b/src/main/rate-limits/claude-fetcher-cli-fallback.test.ts new file mode 100644 index 000000000000..428a29106941 --- /dev/null +++ b/src/main/rate-limits/claude-fetcher-cli-fallback.test.ts @@ -0,0 +1,490 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { fetchClaudeRateLimits } from './claude-fetcher' +import { primeClaudeFetcherMocks, restorePlatform } from './claude-fetcher-test-harness' +import { fetchViaPty } from './claude-pty' +import { + readActiveClaudeKeychainCredentials, + readActiveClaudeKeychainCredentialsStrict +} from '../claude-accounts/keychain' +import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' + +const { netFetchMock, readFileMock, resolveProxyMock, setProxyMock, appGetPathMock } = vi.hoisted( + () => ({ + netFetchMock: vi.fn(), + readFileMock: vi.fn(), + resolveProxyMock: vi.fn(), + setProxyMock: vi.fn(), + appGetPathMock: vi.fn() + }) +) + +vi.mock('node:fs/promises', () => ({ + readFile: readFileMock +})) + +vi.mock('electron', () => ({ + app: { + getPath: appGetPathMock + }, + net: { + fetch: netFetchMock + }, + session: { + defaultSession: { + resolveProxy: resolveProxyMock, + setProxy: setProxyMock + } + } +})) + +vi.mock('./claude-pty', () => ({ + fetchViaPty: vi.fn() +})) + +vi.mock('../claude-accounts/keychain', () => ({ + deleteActiveClaudeKeychainCredentialsStrict: vi.fn(), + readActiveClaudeKeychainCredentials: vi.fn(), + readActiveClaudeKeychainCredentialsStrict: vi.fn(), + readManagedClaudeKeychainCredentials: vi.fn(), + writeActiveClaudeKeychainCredentials: vi.fn(), + writeManagedClaudeKeychainCredentials: vi.fn() +})) + +describe('fetchClaudeRateLimits', () => { + beforeEach(() => { + primeClaudeFetcherMocks({ + netFetchMock, + readFileMock, + resolveProxyMock, + setProxyMock, + appGetPathMock + }) + }) + + afterEach(() => { + restorePlatform() + }) + + it('does not mask OAuth usage rate limits with the PTY fallback', async () => { + const configDir = '/Users/test/.claude' + const authPreparation: ClaudeRuntimeAuthPreparation = { + configDir, + envPatch: { CLAUDE_CONFIG_DIR: configDir }, + stripAuthEnv: false, + provenance: 'system' + } + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( + JSON.stringify({ + claudeAiOauth: { + accessToken: 'expired-oauth-token', + refreshToken: 'refresh-token', + expiresAt: Date.now() - 60_000 + } + }) + ) + netFetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + error: { + type: 'rate_limit_error', + message: 'Rate limited. Please try again later.' + } + }), + { status: 429, headers: { 'retry-after': '3000' } } + ) + ) + + const before = Date.now() + const result = await fetchClaudeRateLimits({ authPreparation }) + expect(result).toMatchObject({ + provider: 'claude', + status: 'error', + error: 'Claude usage is rate limited right now.', + usageMetadata: expect.objectContaining({ failureKind: 'rate-limited' }) + }) + expect(result.usageMetadata?.retryAtMs).toBeGreaterThanOrEqual(before + 3000 * 1000) + expect(result.usageMetadata?.retryAtMs).toBeLessThanOrEqual(Date.now() + 3000 * 1000) + + expect(netFetchMock).toHaveBeenCalledWith( + 'https://api.anthropic.com/api/oauth/usage', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer expired-oauth-token' + }) + }) + ) + expect(fetchViaPty).not.toHaveBeenCalled() + }) + + it('omits retryAtMs when a 429 has no Retry-After header', async () => { + const configDir = '/Users/test/.claude' + const authPreparation: ClaudeRuntimeAuthPreparation = { + configDir, + envPatch: { CLAUDE_CONFIG_DIR: configDir }, + stripAuthEnv: false, + provenance: 'system' + } + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( + JSON.stringify({ + claudeAiOauth: { + accessToken: 'expired-oauth-token', + refreshToken: 'refresh-token', + expiresAt: Date.now() - 60_000 + } + }) + ) + netFetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + error: { + type: 'rate_limit_error', + message: 'Rate limited. Please try again later.' + } + }), + { status: 429 } + ) + ) + + const result = await fetchClaudeRateLimits({ authPreparation }) + expect(result.status).toBe('error') + expect(result.usageMetadata?.retryAtMs).toBeUndefined() + expect(fetchViaPty).not.toHaveBeenCalled() + }) + + it('uses CLI fallback for OAuth auth failures when automatic repair is safe', async () => { + const configDir = '/Users/test/.claude' + const authPreparation: ClaudeRuntimeAuthPreparation = { + configDir, + envPatch: { CLAUDE_CONFIG_DIR: configDir }, + stripAuthEnv: false, + provenance: 'system' + } + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( + JSON.stringify({ + claudeAiOauth: { + accessToken: 'stale-oauth-token', + refreshToken: 'refresh-token', + expiresAt: Date.now() - 60_000 + } + }) + ) + netFetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + error: { + type: 'authentication_error', + message: 'Invalid OAuth token.' + } + }), + { status: 401 } + ) + ) + + await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ + provider: 'claude', + status: 'ok', + session: { usedPercent: 56 }, + usageMetadata: { + source: 'cli', + attemptedSources: ['oauth', 'cli'] + } + }) + + expect(fetchViaPty).toHaveBeenCalledWith({ authPreparation }) + }) + + it('re-reads credentials and retries OAuth once after CLI repair', async () => { + const configDir = '/Users/test/.claude' + const authPreparation: ClaudeRuntimeAuthPreparation = { + configDir, + envPatch: { CLAUDE_CONFIG_DIR: configDir }, + stripAuthEnv: false, + provenance: 'system' + } + vi.mocked(readActiveClaudeKeychainCredentialsStrict) + .mockResolvedValueOnce( + JSON.stringify({ + claudeAiOauth: { + accessToken: 'stale-oauth-token', + refreshToken: 'refresh-token', + expiresAt: Date.now() - 60_000 + } + }) + ) + // Legacy item absent — the stale-scoped legacy fallback must not preempt + // CLI repair in this scenario. + .mockResolvedValueOnce(null) + .mockResolvedValueOnce( + JSON.stringify({ + claudeAiOauth: { + accessToken: 'repaired-oauth-token', + refreshToken: 'refresh-token-2', + expiresAt: Date.now() + 60_000 + } + }) + ) + netFetchMock + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + error: { + type: 'authentication_error', + message: 'Invalid OAuth token.' + } + }), + { status: 401 } + ) + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + five_hour: { utilization: 14 }, + seven_day: { utilization: 27 } + }), + { status: 200 } + ) + ) + + await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ + provider: 'claude', + status: 'ok', + session: { usedPercent: 14 }, + weekly: { usedPercent: 27 }, + usageMetadata: { + source: 'oauth', + attemptedSources: ['oauth', 'cli'], + credentialSource: 'scoped-keychain' + } + }) + + expect(fetchViaPty).toHaveBeenCalledWith({ authPreparation }) + expect(netFetchMock).toHaveBeenNthCalledWith( + 2, + 'https://api.anthropic.com/api/oauth/usage', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer repaired-oauth-token' + }) + }) + ) + }) + + it('explains auth failures when a live Claude terminal owns managed refresh', async () => { + const configDir = '/Users/test/.claude' + const authPreparation: ClaudeRuntimeAuthPreparation = { + configDir, + envPatch: { CLAUDE_CONFIG_DIR: configDir }, + stripAuthEnv: false, + managedRefreshDeferredByLivePty: true, + provenance: 'managed:account-1' + } + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( + JSON.stringify({ + claudeAiOauth: { + accessToken: 'stale-oauth-token', + refreshToken: 'refresh-token', + expiresAt: Date.now() - 60_000 + } + }) + ) + netFetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + error: { + type: 'authentication_error', + message: 'Invalid OAuth token.' + } + }), + { status: 401 } + ) + ) + + await expect( + fetchClaudeRateLimits({ authPreparation, allowPtyFallback: false }) + ).resolves.toMatchObject({ + provider: 'claude', + status: 'error', + error: + 'Claude usage refresh is waiting for the live Claude terminal to rotate its credentials.' + }) + + expect(fetchViaPty).not.toHaveBeenCalled() + }) + + it('does not start CLI fallback when live Claude owns managed refresh and no token is readable', async () => { + const configDir = '/Users/test/.claude' + const authPreparation: ClaudeRuntimeAuthPreparation = { + configDir, + envPatch: { CLAUDE_CONFIG_DIR: configDir }, + stripAuthEnv: false, + managedRefreshDeferredByLivePty: true, + provenance: 'managed:account-1' + } + + await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ + provider: 'claude', + status: 'error', + error: + 'Claude usage refresh is waiting for the live Claude terminal to rotate its credentials.', + usageMetadata: { + failureKind: 'deferred-by-live-session', + deferredByLiveClaudeSession: true, + attemptedSources: [] + } + }) + + expect(fetchViaPty).not.toHaveBeenCalled() + }) + + it('does not start the PTY fallback when disabled for background fetches', async () => { + const configDir = '/Users/test/.claude' + const authPreparation: ClaudeRuntimeAuthPreparation = { + configDir, + envPatch: { CLAUDE_CONFIG_DIR: configDir }, + stripAuthEnv: false, + provenance: 'system' + } + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( + JSON.stringify({ + claudeAiOauth: { + accessToken: 'oauth-token', + refreshToken: 'refresh-token', + expiresAt: Date.now() + 60_000 + } + }) + ) + netFetchMock.mockResolvedValueOnce(new Response('temporary failure', { status: 500 })) + + await expect( + fetchClaudeRateLimits({ authPreparation, allowPtyFallback: false }) + ).resolves.toMatchObject({ + provider: 'claude', + status: 'error', + error: 'OAuth API returned 500' + }) + + expect(fetchViaPty).not.toHaveBeenCalled() + }) + + it('does not start the PTY fallback for refresh-only credentials when disabled', async () => { + const configDir = '/Users/test/.claude' + const authPreparation: ClaudeRuntimeAuthPreparation = { + configDir, + envPatch: { CLAUDE_CONFIG_DIR: configDir }, + stripAuthEnv: false, + provenance: 'system' + } + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( + JSON.stringify({ + claudeAiOauth: { + refreshToken: 'refresh-token', + expiresAt: Date.now() - 60_000 + } + }) + ) + + await expect( + fetchClaudeRateLimits({ authPreparation, allowPtyFallback: false }) + ).resolves.toMatchObject({ + provider: 'claude', + status: 'error', + error: 'Claude OAuth access token unavailable' + }) + + expect(fetchViaPty).not.toHaveBeenCalled() + }) + + it('falls back to CLI when OAuth credentials are missing in automatic mode', async () => { + const authPreparation: ClaudeRuntimeAuthPreparation = { + configDir: '/Users/test/.claude', + envPatch: {}, + stripAuthEnv: false, + provenance: 'system' + } + + await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ + provider: 'claude', + status: 'ok', + session: { usedPercent: 56 }, + usageMetadata: { + source: 'cli', + attemptedSources: ['cli'], + credentialSource: 'none' + } + }) + + expect(fetchViaPty).toHaveBeenCalledWith({ authPreparation }) + }) + + it('marks CLI plan usage shell results as usage unavailable', async () => { + const authPreparation: ClaudeRuntimeAuthPreparation = { + configDir: '/Users/test/.claude', + envPatch: {}, + stripAuthEnv: false, + provenance: 'system' + } + vi.mocked(fetchViaPty).mockResolvedValueOnce({ + provider: 'claude', + session: null, + weekly: null, + updatedAt: 1, + error: 'Claude plan usage is unavailable for this Claude CLI session.', + status: 'error' + }) + + await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ + provider: 'claude', + status: 'error', + error: 'Claude plan usage is unavailable for this Claude CLI session.', + usageMetadata: { + source: 'cli', + attemptedSources: ['cli'], + failureKind: 'usage-unavailable' + } + }) + }) + + it('surfaces Keychain read failures as structured usage metadata when CLI fallback is disabled', async () => { + vi.mocked(readActiveClaudeKeychainCredentials).mockRejectedValueOnce( + new Error('security timed out after 3000ms') + ) + + await expect(fetchClaudeRateLimits({ allowPtyFallback: false })).resolves.toMatchObject({ + provider: 'claude', + status: 'error', + error: 'Claude Keychain credentials unavailable', + usageMetadata: { + failureKind: 'keychain-unavailable', + attemptedSources: [], + credentialSource: 'none' + } + }) + + expect(fetchViaPty).not.toHaveBeenCalled() + }) + + it('uses CLI fallback when Keychain is unavailable in automatic mode', async () => { + const authPreparation: ClaudeRuntimeAuthPreparation = { + configDir: '/Users/test/.claude', + envPatch: {}, + stripAuthEnv: false, + provenance: 'system' + } + vi.mocked(readActiveClaudeKeychainCredentials).mockRejectedValueOnce( + new Error('security timed out after 3000ms') + ) + + await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ + provider: 'claude', + status: 'ok', + session: { usedPercent: 56 }, + usageMetadata: { + source: 'cli', + attemptedSources: ['cli'], + credentialSource: 'none' + } + }) + + expect(fetchViaPty).toHaveBeenCalledWith({ authPreparation }) + }) +}) diff --git a/src/main/rate-limits/claude-oauth-usage-extra-usage.test.ts b/src/main/rate-limits/claude-fetcher-extra-usage.test.ts similarity index 66% rename from src/main/rate-limits/claude-oauth-usage-extra-usage.test.ts rename to src/main/rate-limits/claude-fetcher-extra-usage.test.ts index 9911343080c4..fb56512bd8c6 100644 --- a/src/main/rate-limits/claude-oauth-usage-extra-usage.test.ts +++ b/src/main/rate-limits/claude-fetcher-extra-usage.test.ts @@ -1,28 +1,94 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { fetchClaudeOAuthUsage } from './claude-oauth-usage-request' +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest' +import { fetchClaudeRateLimits } from './claude-fetcher' +import { primeClaudeFetcherMocks, restorePlatform } from './claude-fetcher-test-harness' +import { readActiveClaudeKeychainCredentialsStrict } from '../claude-accounts/keychain' +import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' -const { netFetchMock } = vi.hoisted(() => ({ netFetchMock: vi.fn() })) +const { netFetchMock, readFileMock, resolveProxyMock, setProxyMock, appGetPathMock } = vi.hoisted( + () => ({ + netFetchMock: vi.fn(), + readFileMock: vi.fn(), + resolveProxyMock: vi.fn(), + setProxyMock: vi.fn(), + appGetPathMock: vi.fn() + }) +) + +vi.mock('node:fs/promises', () => ({ + readFile: readFileMock +})) vi.mock('electron', () => ({ - net: { fetch: netFetchMock }, - session: { defaultSession: {} } + app: { + getPath: appGetPathMock + }, + net: { + fetch: netFetchMock + }, + session: { + defaultSession: { + resolveProxy: resolveProxyMock, + setProxy: setProxyMock + } + } +})) + +vi.mock('./claude-pty', () => ({ + fetchViaPty: vi.fn() })) -vi.mock('../network/proxy-settings', () => ({ - ensureElectronProxyFromEnvironment: vi.fn(async () => {}) +vi.mock('../claude-accounts/keychain', () => ({ + deleteActiveClaudeKeychainCredentialsStrict: vi.fn(), + readActiveClaudeKeychainCredentials: vi.fn(), + readActiveClaudeKeychainCredentialsStrict: vi.fn(), + readManagedClaudeKeychainCredentials: vi.fn(), + writeActiveClaudeKeychainCredentials: vi.fn(), + writeManagedClaudeKeychainCredentials: vi.fn() })) +function oauthPrep(): ClaudeRuntimeAuthPreparation { + return { + configDir: '/Users/test/.claude', + envPatch: { CLAUDE_CONFIG_DIR: '/Users/test/.claude' }, + stripAuthEnv: false, + provenance: 'system' + } +} + async function fetchUsageResponse(body: string) { + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( + JSON.stringify({ + claudeAiOauth: { accessToken: 'oauth-token', expiresAt: Date.now() + 60_000 } + }) + ) netFetchMock.mockResolvedValueOnce(new Response(body, { status: 200 })) - return fetchClaudeOAuthUsage('oauth-token') + return fetchClaudeRateLimits({ authPreparation: oauthPrep() }) } -describe('Claude OAuth usage extra usage', () => { +describe('fetchClaudeRateLimits extra usage', () => { beforeEach(() => { - vi.clearAllMocks() + primeClaudeFetcherMocks({ + netFetchMock, + readFileMock, + resolveProxyMock, + setProxyMock, + appGetPathMock + }) + }) + + afterEach(() => { + restorePlatform() }) it('maps the usage-credits spend object into a capped balance in major units', async () => { + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( + JSON.stringify({ + claudeAiOauth: { + accessToken: 'oauth-token', + expiresAt: Date.now() + 60_000 + } + }) + ) netFetchMock.mockResolvedValueOnce( new Response( JSON.stringify({ @@ -39,7 +105,7 @@ describe('Claude OAuth usage extra usage', () => { ) ) - await expect(fetchClaudeOAuthUsage('oauth-token')).resolves.toMatchObject({ + await expect(fetchClaudeRateLimits({ authPreparation: oauthPrep() })).resolves.toMatchObject({ provider: 'claude', status: 'ok', extraUsage: { @@ -56,6 +122,14 @@ describe('Claude OAuth usage extra usage', () => { }) it('keeps the usage-credits cap visible without inventing an out-of-credits balance', async () => { + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( + JSON.stringify({ + claudeAiOauth: { + accessToken: 'oauth-token', + expiresAt: Date.now() + 60_000 + } + }) + ) netFetchMock.mockResolvedValueOnce( new Response( JSON.stringify({ @@ -73,7 +147,7 @@ describe('Claude OAuth usage extra usage', () => { ) ) - await expect(fetchClaudeOAuthUsage('oauth-token')).resolves.toMatchObject({ + await expect(fetchClaudeRateLimits({ authPreparation: oauthPrep() })).resolves.toMatchObject({ provider: 'claude', status: 'ok', extraUsage: { @@ -89,6 +163,14 @@ describe('Claude OAuth usage extra usage', () => { }) it('keeps legacy extra_usage spend data without inventing a missing balance', async () => { + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( + JSON.stringify({ + claudeAiOauth: { + accessToken: 'oauth-token', + expiresAt: Date.now() + 60_000 + } + }) + ) netFetchMock.mockResolvedValueOnce( new Response( JSON.stringify({ @@ -106,7 +188,7 @@ describe('Claude OAuth usage extra usage', () => { ) ) - await expect(fetchClaudeOAuthUsage('oauth-token')).resolves.toMatchObject({ + await expect(fetchClaudeRateLimits({ authPreparation: oauthPrep() })).resolves.toMatchObject({ provider: 'claude', status: 'ok', extraUsage: { diff --git a/src/main/rate-limits/claude-fetcher-fable-usage.test.ts b/src/main/rate-limits/claude-fetcher-fable-usage.test.ts new file mode 100644 index 000000000000..2a84cc4f2af6 --- /dev/null +++ b/src/main/rate-limits/claude-fetcher-fable-usage.test.ts @@ -0,0 +1,362 @@ +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest' +import { fetchClaudeRateLimits } from './claude-fetcher' +import { primeClaudeFetcherMocks, restorePlatform } from './claude-fetcher-test-harness' +import { fetchViaPty } from './claude-pty' +import { readActiveClaudeKeychainCredentialsStrict } from '../claude-accounts/keychain' +import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' + +const { netFetchMock, readFileMock, resolveProxyMock, setProxyMock, appGetPathMock } = vi.hoisted( + () => ({ + netFetchMock: vi.fn(), + readFileMock: vi.fn(), + resolveProxyMock: vi.fn(), + setProxyMock: vi.fn(), + appGetPathMock: vi.fn() + }) +) + +vi.mock('node:fs/promises', () => ({ + readFile: readFileMock +})) + +vi.mock('electron', () => ({ + app: { + getPath: appGetPathMock + }, + net: { + fetch: netFetchMock + }, + session: { + defaultSession: { + resolveProxy: resolveProxyMock, + setProxy: setProxyMock + } + } +})) + +vi.mock('./claude-pty', () => ({ + fetchViaPty: vi.fn() +})) + +vi.mock('../claude-accounts/keychain', () => ({ + deleteActiveClaudeKeychainCredentialsStrict: vi.fn(), + readActiveClaudeKeychainCredentials: vi.fn(), + readActiveClaudeKeychainCredentialsStrict: vi.fn(), + readManagedClaudeKeychainCredentials: vi.fn(), + writeActiveClaudeKeychainCredentials: vi.fn(), + writeManagedClaudeKeychainCredentials: vi.fn() +})) + +describe('fetchClaudeRateLimits', () => { + beforeEach(() => { + primeClaudeFetcherMocks({ + netFetchMock, + readFileMock, + resolveProxyMock, + setProxyMock, + appGetPathMock + }) + }) + + afterEach(() => { + restorePlatform() + }) + + it('accepts Claude Code statusline-style rate limit window fields', async () => { + const configDir = '/Users/test/.claude' + const authPreparation: ClaudeRuntimeAuthPreparation = { + configDir, + envPatch: { CLAUDE_CONFIG_DIR: configDir }, + stripAuthEnv: false, + provenance: 'system' + } + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( + JSON.stringify({ + claudeAiOauth: { + accessToken: 'oauth-token', + expiresAt: Date.now() + 60_000 + } + }) + ) + netFetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + five_hour: { used_percentage: 23.5, resets_at: 1770000000 }, + seven_day: { used_percentage: 41.2, resets_at: 1770604800 }, + fable_weekly: { used_percentage: 12.3, resets_at: 1770691200 } + }), + { status: 200 } + ) + ) + + await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ + provider: 'claude', + status: 'ok', + session: { usedPercent: 23.5, resetsAt: 1770000000000 }, + weekly: { usedPercent: 41.2, resetsAt: 1770604800000 }, + fableWeekly: { usedPercent: 12.3, resetsAt: 1770691200000 } + }) + }) + + it('maps active Fable usage from the scoped OAuth limits array without a PTY read', async () => { + const configDir = '/Users/test/.claude' + const authPreparation: ClaudeRuntimeAuthPreparation = { + configDir, + envPatch: { CLAUDE_CONFIG_DIR: configDir }, + stripAuthEnv: false, + provenance: 'managed:account-1' + } + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( + JSON.stringify({ claudeAiOauth: { accessToken: 'oauth-token' } }) + ) + netFetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + five_hour: { utilization: 36 }, + seven_day: { utilization: 73 }, + fable_weekly: { utilization: 12 }, + limits: [ + { kind: 'weekly_scoped', percent: 55, scope: null }, + { + kind: 'weekly_scoped', + percent: 100, + resets_at: '2026-07-17T20:00:00.099908+00:00', + is_active: true, + scope: { model: { display_name: 'Fable' } } + } + ] + }), + { status: 200 } + ) + ) + + await expect( + fetchClaudeRateLimits({ authPreparation, allowUsagePanelSupplement: true }) + ).resolves.toMatchObject({ + provider: 'claude', + status: 'ok', + fableWeekly: { + usedPercent: 100, + resetsAt: Date.parse('2026-07-17T20:00:00.099908+00:00') + }, + usageMetadata: { attemptedSources: ['oauth'] } + }) + expect(fetchViaPty).not.toHaveBeenCalled() + }) + + it('surfaces inactive scoped Fable usage over the legacy OAuth fallback', async () => { + const configDir = '/Users/test/.claude' + const authPreparation: ClaudeRuntimeAuthPreparation = { + configDir, + envPatch: { CLAUDE_CONFIG_DIR: configDir }, + stripAuthEnv: false, + provenance: 'system' + } + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( + JSON.stringify({ claudeAiOauth: { accessToken: 'oauth-token' } }) + ) + netFetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + five_hour: { utilization: 11 }, + seven_day: { utilization: 22 }, + fable_weekly: { utilization: 33 }, + limits: [ + { + kind: 'weekly_scoped', + percent: 90, + is_active: false, + scope: { model: { display_name: 'fable' } } + } + ] + }), + { status: 200 } + ) + ) + + await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ + fableWeekly: { usedPercent: 90 } + }) + }) + + it('surfaces an inactive scoped Fable entry when no legacy Fable field exists (#8979)', async () => { + const configDir = '/Users/test/.claude' + const authPreparation: ClaudeRuntimeAuthPreparation = { + configDir, + envPatch: { CLAUDE_CONFIG_DIR: configDir }, + stripAuthEnv: false, + provenance: 'system' + } + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( + JSON.stringify({ claudeAiOauth: { accessToken: 'oauth-token' } }) + ) + netFetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + five_hour: { utilization: 11 }, + seven_day: { utilization: 22 }, + limits: [ + { + kind: 'weekly_scoped', + percent: 64, + resets_at: '2026-07-24T20:00:00+00:00', + is_active: false, + scope: { model: { display_name: 'Fable' } } + } + ] + }), + { status: 200 } + ) + ) + + await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ + provider: 'claude', + status: 'ok', + fableWeekly: { + usedPercent: 64, + resetsAt: Date.parse('2026-07-24T20:00:00+00:00') + } + }) + }) + + it('supplements managed-account OAuth usage with Fable from the CLI usage panel', async () => { + const configDir = '/Users/test/.claude' + const authPreparation: ClaudeRuntimeAuthPreparation = { + configDir, + envPatch: { CLAUDE_CONFIG_DIR: configDir }, + stripAuthEnv: false, + provenance: 'managed:account-1' + } + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( + JSON.stringify({ + claudeAiOauth: { + accessToken: 'oauth-token', + expiresAt: Date.now() + 60_000 + } + }) + ) + netFetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + five_hour: { used_percentage: 23.5, resets_at: 1770000000 }, + seven_day: { used_percentage: 41.2, resets_at: 1770604800 } + }), + { status: 200 } + ) + ) + vi.mocked(fetchViaPty).mockResolvedValueOnce({ + provider: 'claude', + session: { usedPercent: 91, windowMinutes: 300, resetsAt: null, resetDescription: null }, + weekly: null, + fableWeekly: { + usedPercent: 12.3, + windowMinutes: 10080, + resetsAt: null, + resetDescription: '3d 2h' + }, + updatedAt: 1, + error: null, + status: 'ok' + }) + + await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ + provider: 'claude', + status: 'ok', + session: { usedPercent: 23.5, resetsAt: 1770000000000 }, + weekly: { usedPercent: 41.2, resetsAt: 1770604800000 }, + fableWeekly: { usedPercent: 12.3, resetDescription: '3d 2h' }, + usageMetadata: { + source: 'oauth', + attemptedSources: ['oauth', 'cli'] + } + }) + expect(fetchViaPty).toHaveBeenCalledWith({ authPreparation }) + }) + + it('supplements system OAuth usage when the service explicitly allows usage-panel reads', async () => { + const configDir = '/Users/test/.claude' + const authPreparation: ClaudeRuntimeAuthPreparation = { + configDir, + envPatch: { CLAUDE_CONFIG_DIR: configDir }, + stripAuthEnv: false, + provenance: 'system' + } + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( + JSON.stringify({ + claudeAiOauth: { + accessToken: 'oauth-token', + expiresAt: Date.now() + 60_000 + } + }) + ) + vi.mocked(fetchViaPty).mockResolvedValueOnce({ + provider: 'claude', + session: null, + weekly: null, + fableWeekly: { + usedPercent: 58, + windowMinutes: 10080, + resetsAt: null, + resetDescription: '4d' + }, + updatedAt: 1, + error: null, + status: 'ok' + }) + + await expect( + fetchClaudeRateLimits({ + authPreparation, + allowPtyFallback: false, + allowUsagePanelSupplement: true + }) + ).resolves.toMatchObject({ + provider: 'claude', + status: 'ok', + session: { usedPercent: 12 }, + weekly: { usedPercent: 34 }, + fableWeekly: { usedPercent: 58, resetDescription: '4d' }, + usageMetadata: { + source: 'oauth', + attemptedSources: ['oauth', 'cli'] + } + }) + expect(fetchViaPty).toHaveBeenCalledWith({ authPreparation }) + }) + + it('ignores bare Fable OAuth usage because the window length is ambiguous', async () => { + const configDir = '/Users/test/.claude' + const authPreparation: ClaudeRuntimeAuthPreparation = { + configDir, + envPatch: { CLAUDE_CONFIG_DIR: configDir }, + stripAuthEnv: false, + provenance: 'system' + } + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( + JSON.stringify({ + claudeAiOauth: { + accessToken: 'oauth-token', + expiresAt: Date.now() + 60_000 + } + }) + ) + netFetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + five_hour: { utilization: 11 }, + seven_day: { utilization: 22 }, + fable: { utilization: 33 } + }), + { status: 200 } + ) + ) + + await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ + provider: 'claude', + status: 'ok', + session: { usedPercent: 11 }, + weekly: { usedPercent: 22 }, + fableWeekly: null + }) + }) +}) diff --git a/src/main/rate-limits/claude-fetcher-keychain-credentials.test.ts b/src/main/rate-limits/claude-fetcher-keychain-credentials.test.ts new file mode 100644 index 000000000000..9a75d3826e5c --- /dev/null +++ b/src/main/rate-limits/claude-fetcher-keychain-credentials.test.ts @@ -0,0 +1,491 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { join } from 'node:path' +import { fetchClaudeRateLimits } from './claude-fetcher' +import { primeClaudeFetcherMocks, restorePlatform } from './claude-fetcher-test-harness' +import { fetchViaPty } from './claude-pty' +import { + readActiveClaudeKeychainCredentials, + readActiveClaudeKeychainCredentialsStrict +} from '../claude-accounts/keychain' +import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' + +const { netFetchMock, readFileMock, resolveProxyMock, setProxyMock, appGetPathMock } = vi.hoisted( + () => ({ + netFetchMock: vi.fn(), + readFileMock: vi.fn(), + resolveProxyMock: vi.fn(), + setProxyMock: vi.fn(), + appGetPathMock: vi.fn() + }) +) + +vi.mock('node:fs/promises', () => ({ + readFile: readFileMock +})) + +vi.mock('electron', () => ({ + app: { + getPath: appGetPathMock + }, + net: { + fetch: netFetchMock + }, + session: { + defaultSession: { + resolveProxy: resolveProxyMock, + setProxy: setProxyMock + } + } +})) + +vi.mock('./claude-pty', () => ({ + fetchViaPty: vi.fn() +})) + +vi.mock('../claude-accounts/keychain', () => ({ + deleteActiveClaudeKeychainCredentialsStrict: vi.fn(), + readActiveClaudeKeychainCredentials: vi.fn(), + readActiveClaudeKeychainCredentialsStrict: vi.fn(), + readManagedClaudeKeychainCredentials: vi.fn(), + writeActiveClaudeKeychainCredentials: vi.fn(), + writeManagedClaudeKeychainCredentials: vi.fn() +})) + +describe('fetchClaudeRateLimits', () => { + beforeEach(() => { + primeClaudeFetcherMocks({ + netFetchMock, + readFileMock, + resolveProxyMock, + setProxyMock, + appGetPathMock + }) + }) + + afterEach(() => { + restorePlatform() + }) + + it('does not read host credentials when WSL config resolution fails', async () => { + await expect( + fetchClaudeRateLimits({ + authPreparation: { + configDir: '/Users/test/.claude', + runtime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxConfigDir: null, + envPatch: {}, + stripAuthEnv: true, + provenance: 'wsl:Ubuntu:system' + } + }) + ).resolves.toMatchObject({ + provider: 'claude', + status: 'error', + error: 'WSL Claude config unavailable for Ubuntu' + }) + + expect(readFileMock).not.toHaveBeenCalled() + expect(readActiveClaudeKeychainCredentialsStrict).not.toHaveBeenCalled() + expect(fetchViaPty).not.toHaveBeenCalled() + }) + + it('reads scoped Keychain credentials when the Claude config dir is explicit', async () => { + const configDir = '/Users/test/.claude' + const authPreparation: ClaudeRuntimeAuthPreparation = { + configDir, + envPatch: { CLAUDE_CONFIG_DIR: configDir }, + stripAuthEnv: false, + provenance: 'system' + } + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( + JSON.stringify({ + claudeAiOauth: { + accessToken: 'oauth-token', + expiresAt: Date.now() + 60_000 + } + }) + ) + readFileMock.mockResolvedValue( + JSON.stringify({ + claudeAiOauth: { + accessToken: 'file-oauth-token', + expiresAt: Date.now() + 60_000 + } + }) + ) + + await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ + provider: 'claude', + status: 'ok', + session: { usedPercent: 12 }, + weekly: { usedPercent: 34 } + }) + + expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenCalledWith(configDir) + expect(netFetchMock).toHaveBeenCalledWith( + 'https://api.anthropic.com/api/oauth/usage', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer oauth-token', + 'User-Agent': 'claude-code/2.1.0' + }) + }) + ) + }) + + it('falls back to the legacy keychain token when the scoped token is rejected as stale', async () => { + const configDir = '/Users/test/.claude' + const authPreparation: ClaudeRuntimeAuthPreparation = { + configDir, + envPatch: { CLAUDE_CONFIG_DIR: configDir }, + stripAuthEnv: false, + provenance: 'system' + } + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockImplementation(async (dir) => + dir + ? JSON.stringify({ + claudeAiOauth: { accessToken: 'stale-scoped-token', refreshToken: 'refresh-1' } + }) + : JSON.stringify({ claudeAiOauth: { accessToken: 'fresh-legacy-token' } }) + ) + netFetchMock.mockImplementation(async (_url: string, init?: RequestInit) => { + const auth = (init?.headers as Record | undefined)?.Authorization + return auth === 'Bearer fresh-legacy-token' + ? new Response( + JSON.stringify({ five_hour: { utilization: 12 }, seven_day: { utilization: 34 } }), + { status: 200 } + ) + : new Response( + JSON.stringify({ error: { message: 'Invalid authentication credentials' } }), + { status: 401 } + ) + }) + + await expect( + fetchClaudeRateLimits({ authPreparation, allowPtyFallback: false }) + ).resolves.toMatchObject({ + provider: 'claude', + status: 'ok', + session: { usedPercent: 12 }, + weekly: { usedPercent: 34 } + }) + + expect(netFetchMock).toHaveBeenCalledTimes(2) + expect(fetchViaPty).not.toHaveBeenCalled() + }) + + it('prefers a legacy access token over scoped refresh-only credentials', async () => { + const configDir = '/Users/test/.claude' + const authPreparation: ClaudeRuntimeAuthPreparation = { + configDir, + envPatch: { CLAUDE_CONFIG_DIR: configDir }, + stripAuthEnv: false, + provenance: 'system' + } + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockImplementation(async (dir) => + dir + ? JSON.stringify({ claudeAiOauth: { refreshToken: 'refresh-only' } }) + : JSON.stringify({ claudeAiOauth: { accessToken: 'fresh-legacy-token' } }) + ) + + await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ + provider: 'claude', + status: 'ok', + session: { usedPercent: 12 } + }) + + expect(netFetchMock).toHaveBeenCalledTimes(1) + expect(netFetchMock).toHaveBeenCalledWith( + 'https://api.anthropic.com/api/oauth/usage', + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: 'Bearer fresh-legacy-token' }) + }) + ) + }) + + it('does not retry with the legacy keychain for managed account credentials', async () => { + const configDir = '/Users/test/managed-account' + const authPreparation: ClaudeRuntimeAuthPreparation = { + configDir, + envPatch: { CLAUDE_CONFIG_DIR: configDir }, + stripAuthEnv: true, + provenance: 'managed:account-1' + } + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockImplementation(async (dir) => + dir ? JSON.stringify({ claudeAiOauth: { accessToken: 'stale-managed-token' } }) : null + ) + netFetchMock.mockResolvedValue( + new Response(JSON.stringify({ error: { message: 'Invalid authentication credentials' } }), { + status: 401 + }) + ) + + await expect( + fetchClaudeRateLimits({ authPreparation, allowPtyFallback: false }) + ).resolves.toMatchObject({ + provider: 'claude', + status: 'error' + }) + + expect(netFetchMock).toHaveBeenCalledTimes(1) + expect(readActiveClaudeKeychainCredentialsStrict).not.toHaveBeenCalledWith(undefined) + }) + + it('does not retry with the legacy keychain for WSL targets', async () => { + // Why: a WSL target's stale credentials must never be answered with the + // host user's legacy macOS Keychain account. + const configDir = '\\\\wsl$\\Ubuntu\\home\\test\\.claude' + const authPreparation: ClaudeRuntimeAuthPreparation = { + configDir, + runtime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxConfigDir: '/home/test/.claude', + envPatch: { CLAUDE_CONFIG_DIR: '/home/test/.claude' }, + stripAuthEnv: false, + provenance: 'system' + } + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockImplementation(async (dir) => + dir + ? JSON.stringify({ claudeAiOauth: { accessToken: 'stale-wsl-token' } }) + : JSON.stringify({ claudeAiOauth: { accessToken: 'fresh-legacy-token' } }) + ) + netFetchMock.mockResolvedValue( + new Response(JSON.stringify({ error: { message: 'Invalid authentication credentials' } }), { + status: 401 + }) + ) + + await expect( + fetchClaudeRateLimits({ authPreparation, allowPtyFallback: false }) + ).resolves.toMatchObject({ + provider: 'claude', + status: 'error' + }) + + expect(netFetchMock).toHaveBeenCalledTimes(1) + expect(readActiveClaudeKeychainCredentialsStrict).not.toHaveBeenCalledWith(undefined) + }) + + it('skips the legacy retry when the legacy item mirrors the failed scoped token', async () => { + // Why: Claude's usage endpoint has a tight request budget; retrying the + // identical token would double the request for a guaranteed second 401. + const configDir = '/Users/test/.claude' + const authPreparation: ClaudeRuntimeAuthPreparation = { + configDir, + envPatch: { CLAUDE_CONFIG_DIR: configDir }, + stripAuthEnv: false, + provenance: 'system' + } + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValue( + JSON.stringify({ claudeAiOauth: { accessToken: 'mirrored-token' } }) + ) + netFetchMock.mockResolvedValue( + new Response(JSON.stringify({ error: { message: 'Invalid authentication credentials' } }), { + status: 401 + }) + ) + + await expect( + fetchClaudeRateLimits({ authPreparation, allowPtyFallback: false }) + ).resolves.toMatchObject({ + provider: 'claude', + status: 'error' + }) + + expect(netFetchMock).toHaveBeenCalledTimes(1) + expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenCalledWith(undefined) + }) + + it('falls back to legacy Keychain credentials for host system default without an explicit config dir', async () => { + const configDir = '/Users/test/.claude' + const authPreparation: ClaudeRuntimeAuthPreparation = { + configDir, + runtime: 'host', + envPatch: {}, + stripAuthEnv: false, + provenance: 'system' + } + vi.mocked(readActiveClaudeKeychainCredentialsStrict) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce( + JSON.stringify({ + claudeAiOauth: { + accessToken: 'legacy-oauth-token', + expiresAt: Date.now() + 60_000 + } + }) + ) + + await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ + provider: 'claude', + status: 'ok', + session: { usedPercent: 12 }, + weekly: { usedPercent: 34 } + }) + + expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenNthCalledWith(1, configDir) + expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenNthCalledWith(2, undefined) + expect(readActiveClaudeKeychainCredentials).not.toHaveBeenCalled() + expect(netFetchMock).toHaveBeenCalledWith( + 'https://api.anthropic.com/api/oauth/usage', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer legacy-oauth-token' + }) + }) + ) + }) + + it('reads scoped Keychain credentials for host system default without an explicit config dir', async () => { + const configDir = '/Users/test/.claude' + const authPreparation: ClaudeRuntimeAuthPreparation = { + configDir, + runtime: 'host', + envPatch: {}, + stripAuthEnv: false, + provenance: 'system' + } + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( + JSON.stringify({ + claudeAiOauth: { + accessToken: 'scoped-oauth-token', + expiresAt: Date.now() + 60_000 + } + }) + ) + + await expect( + fetchClaudeRateLimits({ authPreparation, allowPtyFallback: false }) + ).resolves.toMatchObject({ + provider: 'claude', + status: 'ok', + session: { usedPercent: 12 }, + weekly: { usedPercent: 34 } + }) + + expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenCalledWith(configDir) + expect(readActiveClaudeKeychainCredentials).not.toHaveBeenCalled() + expect(fetchViaPty).not.toHaveBeenCalled() + expect(netFetchMock).toHaveBeenCalledWith( + 'https://api.anthropic.com/api/oauth/usage', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer scoped-oauth-token' + }) + }) + ) + }) + + it('falls back to the credentials file when Keychain access fails', async () => { + const configDir = '/Users/test/.claude' + const authPreparation: ClaudeRuntimeAuthPreparation = { + configDir, + envPatch: { CLAUDE_CONFIG_DIR: configDir }, + stripAuthEnv: false, + provenance: 'system' + } + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockRejectedValue( + new Error('Keychain locked') + ) + readFileMock.mockResolvedValue( + JSON.stringify({ + claudeAiOauth: { + accessToken: 'file-oauth-token', + expiresAt: Date.now() + 60_000 + } + }) + ) + + await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ + provider: 'claude', + status: 'ok' + }) + + expect(readFileMock).toHaveBeenCalledWith( + join('/Users/test/.claude', '.credentials.json'), + 'utf-8' + ) + expect(netFetchMock).toHaveBeenCalledWith( + 'https://api.anthropic.com/api/oauth/usage', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer file-oauth-token' + }) + }) + ) + }) + + it('falls back to legacy Keychain when scoped credentials are unusable', async () => { + const configDir = '/Users/test/.claude' + const authPreparation: ClaudeRuntimeAuthPreparation = { + configDir, + envPatch: { CLAUDE_CONFIG_DIR: configDir }, + stripAuthEnv: false, + provenance: 'system' + } + vi.mocked(readActiveClaudeKeychainCredentialsStrict) + .mockResolvedValueOnce('{not-json') + .mockResolvedValueOnce( + JSON.stringify({ + claudeAiOauth: { + accessToken: 'legacy-oauth-token', + expiresAt: Date.now() + 60_000 + } + }) + ) + + await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ + provider: 'claude', + status: 'ok' + }) + + expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenNthCalledWith(1, configDir) + expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenNthCalledWith(2, undefined) + expect(netFetchMock).toHaveBeenCalledWith( + 'https://api.anthropic.com/api/oauth/usage', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer legacy-oauth-token' + }) + }) + ) + }) + + it('tries OAuth usage even when local credential metadata is expired', async () => { + const configDir = '/Users/test/.claude' + const authPreparation: ClaudeRuntimeAuthPreparation = { + configDir, + envPatch: { CLAUDE_CONFIG_DIR: configDir }, + stripAuthEnv: false, + provenance: 'system' + } + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( + JSON.stringify({ + claudeAiOauth: { + accessToken: 'expired-oauth-token', + refreshToken: 'refresh-token', + expiresAt: Date.now() - 60_000 + } + }) + ) + + await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ + provider: 'claude', + status: 'ok', + session: { usedPercent: 12 } + }) + + expect(netFetchMock).toHaveBeenNthCalledWith( + 1, + 'https://api.anthropic.com/api/oauth/usage', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer expired-oauth-token' + }) + }) + ) + expect(readFileMock).not.toHaveBeenCalled() + expect(fetchViaPty).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/rate-limits/claude-fetcher-managed-account-usage.test.ts b/src/main/rate-limits/claude-fetcher-managed-account-usage.test.ts new file mode 100644 index 000000000000..c01bcb00f6eb --- /dev/null +++ b/src/main/rate-limits/claude-fetcher-managed-account-usage.test.ts @@ -0,0 +1,425 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fetchManagedAccountUsage } from './claude-fetcher' +import { + primeClaudeFetcherMocks, + restorePlatform, + setPlatform +} from './claude-fetcher-test-harness' +import { fetchViaPty } from './claude-pty' +import { + deleteActiveClaudeKeychainCredentialsStrict, + readManagedClaudeKeychainCredentials, + writeActiveClaudeKeychainCredentials, + writeManagedClaudeKeychainCredentials +} from '../claude-accounts/keychain' + +const { netFetchMock, readFileMock, resolveProxyMock, setProxyMock, appGetPathMock } = vi.hoisted( + () => ({ + netFetchMock: vi.fn(), + readFileMock: vi.fn(), + resolveProxyMock: vi.fn(), + setProxyMock: vi.fn(), + appGetPathMock: vi.fn() + }) +) + +vi.mock('node:fs/promises', () => ({ + readFile: readFileMock +})) + +vi.mock('electron', () => ({ + app: { + getPath: appGetPathMock + }, + net: { + fetch: netFetchMock + }, + session: { + defaultSession: { + resolveProxy: resolveProxyMock, + setProxy: setProxyMock + } + } +})) + +vi.mock('./claude-pty', () => ({ + fetchViaPty: vi.fn() +})) + +vi.mock('../claude-accounts/keychain', () => ({ + deleteActiveClaudeKeychainCredentialsStrict: vi.fn(), + readActiveClaudeKeychainCredentials: vi.fn(), + readActiveClaudeKeychainCredentialsStrict: vi.fn(), + readManagedClaudeKeychainCredentials: vi.fn(), + writeActiveClaudeKeychainCredentials: vi.fn(), + writeManagedClaudeKeychainCredentials: vi.fn() +})) + +describe('fetchClaudeRateLimits', () => { + let tempDir: string | null = null + + beforeEach(() => { + tempDir = null + primeClaudeFetcherMocks({ + netFetchMock, + readFileMock, + resolveProxyMock, + setProxyMock, + appGetPathMock + }) + }) + + afterEach(() => { + restorePlatform() + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }) + } + }) + + it('does not read inactive managed credentials from unowned auth paths', async () => { + setPlatform('linux') + tempDir = mkdtempSync(join(tmpdir(), 'orca-claude-fetcher-')) + appGetPathMock.mockReturnValue(tempDir) + const unownedAuthPath = join(tempDir, 'unowned', 'auth') + mkdirSync(unownedAuthPath, { recursive: true }) + writeFileSync(join(unownedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') + writeFileSync( + join(unownedAuthPath, '.credentials.json'), + JSON.stringify({ + claudeAiOauth: { + accessToken: 'unowned-token', + expiresAt: Date.now() + 60_000 + } + }), + 'utf-8' + ) + + await expect( + fetchManagedAccountUsage({ id: 'account-1', managedAuthPath: unownedAuthPath }) + ).resolves.toMatchObject({ + provider: 'claude', + status: 'error', + error: 'No credentials' + }) + + expect(netFetchMock).not.toHaveBeenCalled() + expect(readFileMock).not.toHaveBeenCalled() + }) + + it('supplements inactive managed account OAuth usage with Fable from its usage panel', async () => { + setPlatform('linux') + tempDir = mkdtempSync(join(tmpdir(), 'orca-claude-fetcher-')) + appGetPathMock.mockReturnValue(tempDir) + const ownedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') + mkdirSync(ownedAuthPath, { recursive: true }) + writeFileSync(join(ownedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') + const canonicalAuthPath = realpathSync(ownedAuthPath) + writeFileSync( + join(ownedAuthPath, '.credentials.json'), + JSON.stringify({ + claudeAiOauth: { + accessToken: 'inactive-token', + expiresAt: Date.now() + 60_000 + } + }), + 'utf-8' + ) + vi.mocked(fetchViaPty).mockResolvedValueOnce({ + provider: 'claude', + session: null, + weekly: null, + fableWeekly: { + usedPercent: 42, + windowMinutes: 10080, + resetsAt: null, + resetDescription: '2d' + }, + updatedAt: 1, + error: null, + status: 'ok' + }) + + await expect( + fetchManagedAccountUsage( + { id: 'account-1', managedAuthPath: ownedAuthPath }, + { allowUsagePanelSupplement: true } + ) + ).resolves.toMatchObject({ + provider: 'claude', + status: 'ok', + session: { usedPercent: 12 }, + weekly: { usedPercent: 34 }, + fableWeekly: { usedPercent: 42, resetDescription: '2d' } + }) + expect(fetchViaPty).toHaveBeenCalledWith({ + authPreparation: expect.objectContaining({ + configDir: canonicalAuthPath, + envPatch: { CLAUDE_CONFIG_DIR: canonicalAuthPath }, + provenance: 'managed:account-1:inactive-preview', + stripAuthEnv: true + }) + }) + }) + + it('stages macOS inactive account credentials in a scoped Keychain for Fable preview', async () => { + setPlatform('darwin') + tempDir = mkdtempSync(join(tmpdir(), 'orca-claude-fetcher-')) + appGetPathMock.mockReturnValue(tempDir) + const ownedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') + const credentialsJson = JSON.stringify({ + claudeAiOauth: { + accessToken: 'managed-keychain-token', + expiresAt: Date.now() + 60_000 + } + }) + mkdirSync(ownedAuthPath, { recursive: true }) + writeFileSync(join(ownedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') + const canonicalAuthPath = realpathSync(ownedAuthPath) + vi.mocked(readManagedClaudeKeychainCredentials).mockResolvedValueOnce(credentialsJson) + vi.mocked(fetchViaPty).mockResolvedValueOnce({ + provider: 'claude', + session: { + usedPercent: 12, + windowMinutes: 300, + resetsAt: null, + resetDescription: null + }, + weekly: { + usedPercent: 34, + windowMinutes: 10080, + resetsAt: null, + resetDescription: null + }, + fableWeekly: { + usedPercent: 58, + windowMinutes: 10080, + resetsAt: null, + resetDescription: '3d' + }, + updatedAt: 1, + error: null, + status: 'ok' + }) + + const result = await fetchManagedAccountUsage( + { id: 'account-1', managedAuthPath: ownedAuthPath }, + { allowUsagePanelSupplement: true } + ) + + expect(result.fableWeekly).toMatchObject({ usedPercent: 58, resetDescription: '3d' }) + expect(writeActiveClaudeKeychainCredentials).toHaveBeenCalledWith( + credentialsJson, + canonicalAuthPath + ) + expect(deleteActiveClaudeKeychainCredentialsStrict).toHaveBeenCalledWith(canonicalAuthPath) + }) + + it('cleans up scoped Keychain credentials when the inactive preview fails', async () => { + setPlatform('darwin') + tempDir = mkdtempSync(join(tmpdir(), 'orca-claude-fetcher-')) + appGetPathMock.mockReturnValue(tempDir) + const ownedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') + mkdirSync(ownedAuthPath, { recursive: true }) + writeFileSync(join(ownedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') + const canonicalAuthPath = realpathSync(ownedAuthPath) + vi.mocked(readManagedClaudeKeychainCredentials).mockResolvedValueOnce( + JSON.stringify({ claudeAiOauth: { accessToken: 'managed-keychain-token' } }) + ) + vi.mocked(fetchViaPty).mockRejectedValueOnce(new Error('preview failed')) + + await expect( + fetchManagedAccountUsage( + { id: 'account-1', managedAuthPath: ownedAuthPath }, + { allowUsagePanelSupplement: true } + ) + ).resolves.toMatchObject({ + provider: 'claude', + status: 'ok', + session: { usedPercent: 12 }, + weekly: { usedPercent: 34 }, + fableWeekly: null + }) + + expect(deleteActiveClaudeKeychainCredentialsStrict).toHaveBeenCalledWith(canonicalAuthPath) + }) + + it('stages refreshed macOS inactive account credentials before Fable preview', async () => { + setPlatform('darwin') + tempDir = mkdtempSync(join(tmpdir(), 'orca-claude-fetcher-')) + appGetPathMock.mockReturnValue(tempDir) + const ownedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') + const staleCredentialsJson = JSON.stringify({ + claudeAiOauth: { + accessToken: 'stale-access', + refreshToken: 'stale-refresh', + expiresAt: Date.now() - 60_000 + } + }) + mkdirSync(ownedAuthPath, { recursive: true }) + writeFileSync(join(ownedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') + vi.mocked(readManagedClaudeKeychainCredentials).mockResolvedValueOnce(staleCredentialsJson) + netFetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + access_token: 'fresh-access', + expires_in: 3600, + refresh_token: 'fresh-refresh' + }), + { status: 200 } + ) + ) + netFetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ five_hour: { utilization: 12 }, seven_day: { utilization: 34 } }), + { + status: 200 + } + ) + ) + vi.mocked(fetchViaPty).mockResolvedValueOnce({ + provider: 'claude', + session: { + usedPercent: 12, + windowMinutes: 300, + resetsAt: null, + resetDescription: null + }, + weekly: { + usedPercent: 34, + windowMinutes: 10080, + resetsAt: null, + resetDescription: null + }, + fableWeekly: { + usedPercent: 58, + windowMinutes: 10080, + resetsAt: null, + resetDescription: '3d' + }, + updatedAt: 1, + error: null, + status: 'ok' + }) + + const result = await fetchManagedAccountUsage( + { id: 'account-1', managedAuthPath: ownedAuthPath }, + { allowUsagePanelSupplement: true } + ) + + const stagedCredentialsJson = vi.mocked(writeActiveClaudeKeychainCredentials).mock.calls[0]?.[0] + expect(result.fableWeekly).toMatchObject({ usedPercent: 58, resetDescription: '3d' }) + expect(JSON.parse(stagedCredentialsJson ?? '{}')).toMatchObject({ + claudeAiOauth: { + accessToken: 'fresh-access', + refreshToken: 'fresh-refresh' + } + }) + expect(writeManagedClaudeKeychainCredentials).toHaveBeenCalledWith( + 'account-1', + stagedCredentialsJson + ) + }) + + it('does not merge macOS inactive Fable preview when usage windows belong to another account', async () => { + setPlatform('darwin') + tempDir = mkdtempSync(join(tmpdir(), 'orca-claude-fetcher-')) + appGetPathMock.mockReturnValue(tempDir) + const ownedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') + mkdirSync(ownedAuthPath, { recursive: true }) + writeFileSync(join(ownedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') + vi.mocked(readManagedClaudeKeychainCredentials).mockResolvedValueOnce( + JSON.stringify({ + claudeAiOauth: { + accessToken: 'managed-keychain-token', + expiresAt: Date.now() + 60_000 + } + }) + ) + vi.mocked(fetchViaPty).mockResolvedValueOnce({ + provider: 'claude', + session: { + usedPercent: 91, + windowMinutes: 300, + resetsAt: null, + resetDescription: null + }, + weekly: { + usedPercent: 3, + windowMinutes: 10080, + resetsAt: null, + resetDescription: null + }, + fableWeekly: { + usedPercent: 58, + windowMinutes: 10080, + resetsAt: null, + resetDescription: '3d' + }, + updatedAt: 1, + error: null, + status: 'ok' + }) + + const result = await fetchManagedAccountUsage( + { id: 'account-1', managedAuthPath: ownedAuthPath }, + { allowUsagePanelSupplement: true } + ) + + expect(result.fableWeekly).toBeNull() + }) + + it('refreshes and persists an expiring inactive account before fetching usage', async () => { + setPlatform('linux') + tempDir = mkdtempSync(join(tmpdir(), 'orca-claude-fetcher-')) + appGetPathMock.mockReturnValue(tempDir) + const ownedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') + mkdirSync(ownedAuthPath, { recursive: true }) + writeFileSync(join(ownedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') + const credentialsPath = join(ownedAuthPath, '.credentials.json') + writeFileSync( + credentialsPath, + JSON.stringify({ + claudeAiOauth: { + accessToken: 'stale-access', + refreshToken: 'stale-refresh', + expiresAt: Date.now() - 60_000 + } + }), + 'utf-8' + ) + + // First net.fetch call is the OAuth refresh (token endpoint); second is the + // usage fetch with the refreshed access token. + netFetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + access_token: 'fresh-access', + expires_in: 3600, + refresh_token: 'fresh-refresh' + }) + }) + netFetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ five_hour: { utilization: 12 }, seven_day: { utilization: 34 } }) + }) + + const result = await fetchManagedAccountUsage({ + id: 'account-1', + managedAuthPath: ownedAuthPath + }) + + expect(result.status).toBe('ok') + // Rotated token persisted back to managed storage. + const persisted = JSON.parse(readFileSync(credentialsPath, 'utf-8')) + expect(persisted.claudeAiOauth.accessToken).toBe('fresh-access') + expect(persisted.claudeAiOauth.refreshToken).toBe('fresh-refresh') + // Usage fetch used the fresh access token. + const usageCall = netFetchMock.mock.calls.find(([url]) => + String(url).includes('/api/oauth/usage') + ) + expect(usageCall?.[1]?.headers?.Authorization).toBe('Bearer fresh-access') + }) +}) diff --git a/src/main/rate-limits/claude-fetcher-test-harness.ts b/src/main/rate-limits/claude-fetcher-test-harness.ts new file mode 100644 index 000000000000..f679d8388337 --- /dev/null +++ b/src/main/rate-limits/claude-fetcher-test-harness.ts @@ -0,0 +1,67 @@ +import { vi } from 'vitest' +import type { Mock } from 'vitest' +import { fetchViaPty } from './claude-pty' +import { + deleteActiveClaudeKeychainCredentialsStrict, + readActiveClaudeKeychainCredentials, + readActiveClaudeKeychainCredentialsStrict, + readManagedClaudeKeychainCredentials, + writeActiveClaudeKeychainCredentials, + writeManagedClaudeKeychainCredentials +} from '../claude-accounts/keychain' + +/** Electron/fs mocks each claude-fetcher test file declares via its own `vi.mock` factories. */ +export type ClaudeFetcherHoistedMocks = { + netFetchMock: Mock + readFileMock: Mock + resolveProxyMock: Mock + setProxyMock: Mock + appGetPathMock: Mock +} + +const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + +export function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { + configurable: true, + value: platform + }) +} + +export function restorePlatform(): void { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } +} + +/** Default happy-path state: darwin, no keychain credentials, OAuth usage 12/34, PTY session 56. */ +export function primeClaudeFetcherMocks(mocks: ClaudeFetcherHoistedMocks): void { + setPlatform('darwin') + vi.clearAllMocks() + mocks.readFileMock.mockRejectedValue(new Error('missing file')) + vi.mocked(readActiveClaudeKeychainCredentials).mockResolvedValue(null) + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValue(null) + vi.mocked(readManagedClaudeKeychainCredentials).mockResolvedValue(null) + vi.mocked(writeActiveClaudeKeychainCredentials).mockResolvedValue() + vi.mocked(deleteActiveClaudeKeychainCredentialsStrict).mockResolvedValue() + vi.mocked(writeManagedClaudeKeychainCredentials).mockResolvedValue() + mocks.appGetPathMock.mockReturnValue('/tmp/orca-claude-fetcher-test') + mocks.resolveProxyMock.mockResolvedValue('DIRECT') + mocks.netFetchMock.mockResolvedValue( + new Response( + JSON.stringify({ + five_hour: { utilization: 12 }, + seven_day: { utilization: 34 } + }), + { status: 200 } + ) + ) + vi.mocked(fetchViaPty).mockResolvedValue({ + provider: 'claude', + session: { usedPercent: 56, windowMinutes: 300, resetsAt: null, resetDescription: null }, + weekly: null, + updatedAt: 1, + error: null, + status: 'ok' + }) +} diff --git a/src/main/rate-limits/claude-fetcher.ts b/src/main/rate-limits/claude-fetcher.ts index 9b03a2eb9713..42427766a26f 100644 --- a/src/main/rate-limits/claude-fetcher.ts +++ b/src/main/rate-limits/claude-fetcher.ts @@ -1,9 +1,7 @@ import type { ProviderRateLimits } from '../../shared/rate-limit-types' import { fetchActiveClaudeRateLimits } from './claude-active-usage-fetch' -import { - fetchInactiveClaudeAccountUsage, - type InactiveClaudeAccount -} from './claude-managed-account-usage' +import type { InactiveClaudeAccount } from './claude-managed-account-credentials' +import { fetchInactiveClaudeAccountUsage } from './claude-managed-account-usage' import type { ClaudeManagedAccountUsageOptions, ClaudeRateLimitFetchOptions diff --git a/src/main/rate-limits/claude-managed-account-credentials.ts b/src/main/rate-limits/claude-managed-account-credentials.ts new file mode 100644 index 000000000000..71d505510cf1 --- /dev/null +++ b/src/main/rate-limits/claude-managed-account-credentials.ts @@ -0,0 +1,127 @@ +import { existsSync, lstatSync, readFileSync } from 'node:fs' +import path from 'node:path' +import { parseWslUncPath } from '../../shared/wsl-paths' +import { + deleteActiveClaudeKeychainCredentialsStrict, + readActiveClaudeKeychainCredentialsStrict, + readManagedClaudeKeychainCredentials, + writeActiveClaudeKeychainCredentials, + writeManagedClaudeKeychainCredentials +} from '../claude-accounts/keychain' +import { + readClaudeManagedAuthFile, + resolveOwnedClaudeManagedAuthPath, + writeClaudeManagedAuthFile +} from '../claude-accounts/managed-auth-path' + +export type InactiveClaudeAccount = { + id: string + managedAuthPath: string + managedAuthRuntime?: 'host' | 'wsl' + wslDistro?: string | null + wslLinuxAuthPath?: string | null +} + +export type ClaudeManagedCredentialsLocation = + | { kind: 'keychain'; accountId: string; managedAuthPath: string } + | { kind: 'file'; managedAuthPath: string } + +export function resolveClaudeManagedCredentialsLocation( + account: InactiveClaudeAccount +): ClaudeManagedCredentialsLocation | null { + if (account.managedAuthRuntime === 'wsl') { + const managedAuthPath = resolveOwnedWslClaudeManagedAuthPath(account) + return managedAuthPath ? { kind: 'file', managedAuthPath } : null + } + const managedAuthPath = resolveOwnedClaudeManagedAuthPath(account.id, account.managedAuthPath, { + adoptLegacyMarker: true + }) + if (!managedAuthPath) { + return null + } + return process.platform === 'darwin' + ? { kind: 'keychain', accountId: account.id, managedAuthPath } + : { kind: 'file', managedAuthPath } +} + +export async function readClaudeManagedCredentialsJson( + location: ClaudeManagedCredentialsLocation +): Promise { + try { + return location.kind === 'keychain' + ? await readManagedClaudeKeychainCredentials(location.accountId) + : readClaudeManagedAuthFile(location.managedAuthPath, '.credentials.json') + } catch { + return null + } +} + +export async function writeClaudeManagedCredentialsJson( + location: ClaudeManagedCredentialsLocation, + credentialsJson: string +): Promise { + if (location.kind === 'keychain') { + await writeManagedClaudeKeychainCredentials(location.accountId, credentialsJson) + } else { + writeClaudeManagedAuthFile(location.managedAuthPath, '.credentials.json', credentialsJson) + } +} + +function resolveOwnedWslClaudeManagedAuthPath(account: InactiveClaudeAccount): string | null { + if (process.platform !== 'win32') { + return null + } + const wslInfo = parseWslUncPath(account.managedAuthPath) + if (!wslInfo || (account.wslDistro && wslInfo.distro !== account.wslDistro)) { + return null + } + const linuxPath = account.wslLinuxAuthPath ?? wslInfo.linuxPath + if ( + !linuxPath.includes('/.local/share/orca/claude-accounts/') || + !linuxPath.endsWith(`/${account.id}/auth`) + ) { + return null + } + try { + const markerPath = path.join(account.managedAuthPath, '.orca-managed-claude-auth') + if ( + !existsSync(markerPath) || + lstatSync(markerPath).isSymbolicLink() || + readFileSync(markerPath, 'utf-8').trim() !== account.id + ) { + return null + } + return account.managedAuthPath + } catch { + return null + } +} + +export async function withClaudeManagedPreviewKeychainCredentials( + location: ClaudeManagedCredentialsLocation, + credentialsJson: string, + operation: () => Promise +): Promise { + if (location.kind !== 'keychain') { + return operation() + } + await writeActiveClaudeKeychainCredentials(credentialsJson, location.managedAuthPath) + try { + return await operation() + } finally { + await deleteActiveClaudeKeychainCredentialsStrict(location.managedAuthPath).catch(() => {}) + } +} + +export async function readStagedClaudeManagedPreviewCredentials( + location: ClaudeManagedCredentialsLocation +): Promise { + if (location.kind !== 'keychain') { + return null + } + try { + return await readActiveClaudeKeychainCredentialsStrict(location.managedAuthPath) + } catch { + return null + } +} diff --git a/src/main/rate-limits/claude-managed-account-usage.ts b/src/main/rate-limits/claude-managed-account-usage.ts index b2e419f8bb0e..ddf7b3df7508 100644 --- a/src/main/rate-limits/claude-managed-account-usage.ts +++ b/src/main/rate-limits/claude-managed-account-usage.ts @@ -1,66 +1,104 @@ import type { ProviderRateLimits } from '../../shared/rate-limit-types' -import { getClaudeProfileRouter } from '../claude-accounts/claude-profile-installed-router' -import { savedWslClaudeAccountHome } from '../claude-accounts/claude-profile-wsl-paths' -import { toWindowsWslPath } from '../../shared/wsl-paths' +import { + isOauthTokenExpiring, + refreshClaudeOauthCredentials +} from '../claude-accounts/oauth-refresh' +import { + readClaudeManagedCredentialsJson, + resolveClaudeManagedCredentialsLocation, + writeClaudeManagedCredentialsJson, + type InactiveClaudeAccount +} from './claude-managed-account-credentials' +import { fetchClaudeManagedUsagePanelSupplement } from './claude-managed-usage-panel' +import { parseClaudeOAuthCredentialsJson } from './claude-oauth-credentials' +import { fetchClaudeOAuthUsage } from './claude-oauth-usage-request' import type { ClaudeManagedAccountUsageOptions } from './claude-usage-fetch-options' -import { fetchActiveClaudeRateLimits } from './claude-active-usage-fetch' -import { claudeUsageUnavailable, makeClaudeUsageResult } from './claude-usage-result' -import { filterPathsToRunningWslDistrosAsync } from '../wsl-running-path-filter' +import { + abortedClaudeRateLimitResult, + canSupplementClaudeOAuthUsage, + mergeClaudeUsageWindows, + warnClaudeUsageFetchFailure +} from './claude-usage-result' -/** Usage reads the account's own folder; a host account's comes from its id, never a stored path. */ -export type InactiveClaudeAccount = { - id: string - managedAuthRuntime?: 'host' | 'wsl' - wslDistro?: string | null - wslLinuxAuthPath?: string | null +function noClaudeManagedCredentialsResult(): ProviderRateLimits { + return { + provider: 'claude', + session: null, + weekly: null, + updatedAt: Date.now(), + error: 'No credentials', + status: 'error' + } } export async function fetchInactiveClaudeAccountUsage( account: InactiveClaudeAccount, options: ClaudeManagedAccountUsageOptions = {} ): Promise { - const configDir = - account.managedAuthRuntime === 'wsl' - ? savedWslClaudeAccountHome(account) - : (getClaudeProfileRouter()?.accountHome(account.id) ?? null) - if (!configDir || (account.managedAuthRuntime === 'wsl' && !account.wslDistro)) { - return makeClaudeUsageResult('error', 'Sign in again to use this account.', { - failureKind: 'missing-credentials', - attemptedSources: [] - }) + if (options.signal?.aborted) { + return abortedClaudeRateLimitResult() } - const home = - account.managedAuthRuntime === 'wsl' && account.wslDistro - ? toWindowsWslPath(configDir, account.wslDistro) - : configDir - // Why: a stopped distro parks a UNC read for minutes, and the inactive loop is sequential. - if ( - account.managedAuthRuntime === 'wsl' && - (await filterPathsToRunningWslDistrosAsync([home], { requireConfirmed: true })).length === 0 - ) { - return claudeUsageUnavailable() + const location = resolveClaudeManagedCredentialsLocation(account) + let credentialsJson = location ? await readClaudeManagedCredentialsJson(location) : null + if (options.signal?.aborted) { + return abortedClaudeRateLimitResult() + } + if (!location || !credentialsJson) { + return noClaudeManagedCredentialsResult() } - const read = fetchActiveClaudeRateLimits({ - signal: options.signal, - authPreparation: { - configDir: home, - runtime: account.managedAuthRuntime, - wslDistro: account.wslDistro, - envPatch: { CLAUDE_CONFIG_DIR: configDir }, - stripAuthEnv: true, - provenance: `profile:${account.id}` + + let token = parseClaudeOAuthCredentialsJson(credentialsJson, 'credentials-file').token + if (isOauthTokenExpiring(credentialsJson)) { + const refreshed = await refreshClaudeOauthCredentials(credentialsJson) + if (options.signal?.aborted) { + return abortedClaudeRateLimitResult() + } + if (refreshed) { + try { + await writeClaudeManagedCredentialsJson(location, refreshed) + } catch { + // Keep the refreshed token for this fetch; a later poll can persist it. + } + credentialsJson = refreshed + token = parseClaudeOAuthCredentialsJson(refreshed, 'credentials-file').token } - }) - if (account.managedAuthRuntime !== 'wsl') { - return read } - const timeout = AbortSignal.timeout(INACTIVE_WSL_READ_TIMEOUT_MS) - return Promise.race([ - read, - new Promise((resolve) => - timeout.addEventListener('abort', () => resolve(claudeUsageUnavailable()), { once: true }) + + if (!token) { + return noClaudeManagedCredentialsResult() + } + const oauthLimits = await fetchClaudeOAuthUsage(token, options.signal) + if (options.signal?.aborted) { + return abortedClaudeRateLimitResult() + } + if ( + !canSupplementClaudeOAuthUsage({ + oauthLimits, + authPreparation: undefined, + allowUsagePanelSupplement: options.allowUsagePanelSupplement === true + }) + ) { + return oauthLimits + } + + try { + return mergeClaudeUsageWindows( + oauthLimits, + await fetchClaudeManagedUsagePanelSupplement({ + account, + location, + credentialsJson, + oauthLimits, + networkProxySettings: options.networkProxySettings, + signal: options.signal + }) + ) + } catch (error) { + warnClaudeUsageFetchFailure( + undefined, + parseClaudeOAuthCredentialsJson(credentialsJson, 'credentials-file'), + error ) - ]) + return oauthLimits + } } - -const INACTIVE_WSL_READ_TIMEOUT_MS = 10_000 diff --git a/src/main/rate-limits/claude-managed-usage-panel.ts b/src/main/rate-limits/claude-managed-usage-panel.ts new file mode 100644 index 000000000000..15c1e90169c7 --- /dev/null +++ b/src/main/rate-limits/claude-managed-usage-panel.ts @@ -0,0 +1,113 @@ +import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limit-types' +import type { NetworkProxySettings } from '../../shared/network-proxy' +import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' +import { fetchViaPty } from './claude-pty' +import { + readStagedClaudeManagedPreviewCredentials, + withClaudeManagedPreviewKeychainCredentials, + writeClaudeManagedCredentialsJson, + type ClaudeManagedCredentialsLocation, + type InactiveClaudeAccount +} from './claude-managed-account-credentials' + +function getManagedUsagePanelAuthPreparation( + account: InactiveClaudeAccount, + location: ClaudeManagedCredentialsLocation +): ClaudeRuntimeAuthPreparation | null { + if (process.platform === 'win32') { + return null + } + if (account.managedAuthRuntime === 'wsl') { + if (!account.wslLinuxAuthPath || !account.wslDistro) { + return null + } + return { + configDir: location.managedAuthPath, + runtime: 'wsl', + wslDistro: account.wslDistro, + wslLinuxConfigDir: account.wslLinuxAuthPath, + envPatch: { CLAUDE_CONFIG_DIR: account.wslLinuxAuthPath }, + stripAuthEnv: true, + provenance: `managed:${account.id}:inactive-preview` + } + } + return { + configDir: location.managedAuthPath, + runtime: 'host', + wslDistro: null, + wslLinuxConfigDir: null, + envPatch: { CLAUDE_CONFIG_DIR: location.managedAuthPath }, + stripAuthEnv: true, + provenance: `managed:${account.id}:inactive-preview` + } +} + +function windowsAgree(left: RateLimitWindow | null, right: RateLimitWindow | null): boolean { + return Boolean(left && right && Math.abs(left.usedPercent - right.usedPercent) <= 1) +} + +function canTrustManagedUsagePanelSupplement( + oauthLimits: ProviderRateLimits, + cliLimits: ProviderRateLimits, + requireMatchingOAuthWindow: boolean +): boolean { + if (!requireMatchingOAuthWindow) { + return true + } + const sharedWindowMatches = [ + oauthLimits.session && cliLimits.session + ? windowsAgree(oauthLimits.session, cliLimits.session) + : null, + oauthLimits.weekly && cliLimits.weekly + ? windowsAgree(oauthLimits.weekly, cliLimits.weekly) + : null + ].filter((match): match is boolean => match !== null) + // Why: older Claude builds may ignore the scoped Keychain and expose the active account. + return sharedWindowMatches.length > 0 && sharedWindowMatches.every(Boolean) +} + +export async function fetchClaudeManagedUsagePanelSupplement(input: { + account: InactiveClaudeAccount + location: ClaudeManagedCredentialsLocation + credentialsJson: string + oauthLimits: ProviderRateLimits + networkProxySettings?: NetworkProxySettings + signal?: AbortSignal +}): Promise { + if (input.signal?.aborted) { + return null + } + const authPreparation = getManagedUsagePanelAuthPreparation(input.account, input.location) + if (!authPreparation) { + return null + } + + return withClaudeManagedPreviewKeychainCredentials( + input.location, + input.credentialsJson, + async () => { + const cliLimits = await fetchViaPty({ + authPreparation, + networkProxySettings: input.networkProxySettings, + signal: input.signal + }) + if (input.signal?.aborted) { + return null + } + if ( + !canTrustManagedUsagePanelSupplement( + input.oauthLimits, + cliLimits, + input.location.kind === 'keychain' + ) + ) { + return null + } + const refreshed = await readStagedClaudeManagedPreviewCredentials(input.location) + if (refreshed && refreshed !== input.credentialsJson) { + await writeClaudeManagedCredentialsJson(input.location, refreshed) + } + return cliLimits + } + ) +} diff --git a/src/main/rate-limits/claude-oauth-credentials.ts b/src/main/rate-limits/claude-oauth-credentials.ts index 7fa4d04fbb92..3e14fb2909e7 100644 --- a/src/main/rate-limits/claude-oauth-credentials.ts +++ b/src/main/rate-limits/claude-oauth-credentials.ts @@ -1,137 +1,160 @@ import { readFile } from 'node:fs/promises' import { homedir } from 'node:os' import path from 'node:path' -import { z } from 'zod' -import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' -import { readActiveClaudeKeychainCredentialsStrict } from '../claude-accounts/keychain' -import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth/runtime-auth-types' -import { readUserClaudeConfigDir } from '../claude-accounts/claude-profile-paths' -import { getClaudeProfileRouter } from '../claude-accounts/claude-profile-installed-router' +import { + readActiveClaudeKeychainCredentials, + readActiveClaudeKeychainCredentialsStrict +} from '../claude-accounts/keychain' +import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' + +type ClaudeCredentials = { + claudeAiOauth?: { + accessToken?: string + refreshToken?: string + expiresAt?: number + } +} export type ClaudeOAuthCredentialSource = | 'scoped-keychain' | 'legacy-keychain' | 'credentials-file' | 'none' + export type ClaudeOAuthCredentialReadResult = { token: string | null hasRefreshableCredentials: boolean source: ClaudeOAuthCredentialSource keychainUnavailable?: boolean - unavailable?: boolean } + type ClaudeOAuthCredentialReadOptions = { credentialsFileConfigDir?: string keychainConfigDir?: string - /** System Default only: a managed profile never reads another login's item. */ - unsuffixedKeychainFallback?: boolean } -const schema = z.object({ - claudeAiOauth: z - .object({ - accessToken: z.string().optional(), - refreshToken: z.string().optional() - }) - .optional() -}) + export function parseClaudeOAuthCredentialsJson( raw: string, source: ClaudeOAuthCredentialSource ): ClaudeOAuthCredentialReadResult { try { - const oauth = schema.parse(JSON.parse(raw)).claudeAiOauth - // Why: expiresAt is not authoritative for the usage endpoint; let the server decide. - return { - token: oauth?.accessToken || null, - hasRefreshableCredentials: Boolean(oauth?.refreshToken), - source + const oauth = (JSON.parse(raw) as ClaudeCredentials)?.claudeAiOauth + const hasRefreshableCredentials = + typeof oauth?.refreshToken === 'string' && oauth.refreshToken.trim() !== '' + if (!oauth?.accessToken || typeof oauth.accessToken !== 'string') { + return { token: null, hasRefreshableCredentials, source } } + // Why: expiresAt is not authoritative for the usage endpoint; let the server decide. + return { token: oauth.accessToken, hasRefreshableCredentials, source } } catch { - return { ...emptyClaudeOAuthCredentialReadResult(), unavailable: true } + return emptyClaudeOAuthCredentialReadResult() } } + export function emptyClaudeOAuthCredentialReadResult(): ClaudeOAuthCredentialReadResult { return { token: null, hasRefreshableCredentials: false, source: 'none' } } + +function unavailableKeychainResult(): ClaudeOAuthCredentialReadResult { + return { + token: null, + hasRefreshableCredentials: false, + source: 'none', + keychainUnavailable: true + } +} + +async function readFromKeychain(configDir?: string): Promise { + if (process.platform !== 'darwin') { + return emptyClaudeOAuthCredentialReadResult() + } + + if (configDir) { + const scoped = await readClaudeCredentialsFromStrictKeychain(configDir, 'scoped-keychain') + if (scoped.token) { + return scoped + } + + const legacy = await readClaudeCredentialsFromStrictKeychain(undefined, 'legacy-keychain') + // Why: a real access token must not be shadowed by scoped refresh-only credentials. + if (legacy.token) { + return legacy + } + if (scoped.hasRefreshableCredentials) { + return scoped + } + if (legacy.hasRefreshableCredentials) { + return legacy + } + return scoped.keychainUnavailable || legacy.keychainUnavailable + ? unavailableKeychainResult() + : legacy + } + + try { + const credentials = await readActiveClaudeKeychainCredentials(configDir) + return credentials + ? parseClaudeOAuthCredentialsJson(credentials, 'legacy-keychain') + : emptyClaudeOAuthCredentialReadResult() + } catch { + return unavailableKeychainResult() + } +} + export async function readClaudeCredentialsFromStrictKeychain( configDir: string | undefined, source: ClaudeOAuthCredentialSource ): Promise { try { - const contents = await readActiveClaudeKeychainCredentialsStrict(configDir) - return contents - ? parseClaudeOAuthCredentialsJson(contents, source) + const credentials = await readActiveClaudeKeychainCredentialsStrict(configDir) + return credentials + ? parseClaudeOAuthCredentialsJson(credentials, source) : emptyClaudeOAuthCredentialReadResult() } catch { - return { - ...emptyClaudeOAuthCredentialReadResult(), - unavailable: true, - keychainUnavailable: true - } + return unavailableKeychainResult() } } -async function readClaudeKeychainCredentials( - options?: ClaudeOAuthCredentialReadOptions + +async function readFromCredentialsFile( + configDir?: string ): Promise { - if (!options?.keychainConfigDir) { - return readClaudeCredentialsFromStrictKeychain(undefined, 'legacy-keychain') - } - const scoped = await readClaudeCredentialsFromStrictKeychain( - options.keychainConfigDir, - 'scoped-keychain' + const credentialPath = path.join( + configDir ?? path.join(homedir(), '.claude'), + '.credentials.json' ) - if (scoped.token || !options.unsuffixedKeychainFallback) { - return scoped + try { + return parseClaudeOAuthCredentialsJson( + await readFile(credentialPath, 'utf-8'), + 'credentials-file' + ) + } catch { + return emptyClaudeOAuthCredentialReadResult() } - const legacy = await readClaudeCredentialsFromStrictKeychain(undefined, 'legacy-keychain') - // Why: as before profiles, a token wins over an unreadable item. - const candidates = [scoped, legacy] - return ( - candidates.find((candidate) => candidate.token) ?? - candidates.find((candidate) => candidate.unavailable) ?? - legacy - ) } + export async function readClaudeOAuthCredentials( options?: ClaudeOAuthCredentialReadOptions ): Promise { - const keychain = - process.platform === 'darwin' - ? await readClaudeKeychainCredentials(options) - : emptyClaudeOAuthCredentialReadResult() - if (keychain.token) { + const keychain = await readFromKeychain(options?.keychainConfigDir) + if (keychain.token || keychain.hasRefreshableCredentials) { return keychain } - try { - return parseClaudeOAuthCredentialsJson( - await readFile( - path.join( - options?.credentialsFileConfigDir ?? path.join(homedir(), '.claude'), - '.credentials.json' - ), - 'utf8' - ), - 'credentials-file' - ) - } catch (error) { - return isDefinitiveAbsence(error) ? keychain : { ...keychain, unavailable: true } + + const file = await readFromCredentialsFile(options?.credentialsFileConfigDir) + if (file.token || file.hasRefreshableCredentials) { + return file } + return keychain.keychainUnavailable ? keychain : emptyClaudeOAuthCredentialReadResult() } + export function resolveClaudeOAuthCredentialReadOptions( authPreparation?: ClaudeRuntimeAuthPreparation ): ClaudeOAuthCredentialReadOptions | undefined { if (!authPreparation) { return undefined } - const accountDir = authPreparation.envPatch.CLAUDE_CONFIG_DIR return { credentialsFileConfigDir: authPreparation.configDir, - // Why the user's own folder, login shell first: Claude names System default's item from it. - keychainConfigDir: - accountDir ?? - getClaudeProfileRouter()?.userConfigDir() ?? - readUserClaudeConfigDir(process.env), - // An unsuffixed lookup is exclusively System default, never a fallback from an account. - unsuffixedKeychainFallback: !accountDir + keychainConfigDir: authPreparation.configDir } } diff --git a/src/main/rate-limits/claude-oauth-recovery.ts b/src/main/rate-limits/claude-oauth-recovery.ts new file mode 100644 index 000000000000..58c6bf91cb37 --- /dev/null +++ b/src/main/rate-limits/claude-oauth-recovery.ts @@ -0,0 +1,172 @@ +import type { ProviderRateLimits } from '../../shared/rate-limit-types' +import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' +import { withMacTailscaleDnsHint } from '../network/macos-tailscale-dns-diagnostic' +import { completeClaudeOAuthUsageSuccess, fetchClaudeUsageViaCli } from './claude-cli-usage-fetch' +import { + readClaudeCredentialsFromStrictKeychain, + readClaudeOAuthCredentials, + resolveClaudeOAuthCredentialReadOptions, + type ClaudeOAuthCredentialReadResult +} from './claude-oauth-credentials' +import { OAuthUsageError } from './claude-oauth-usage-error' +import { fetchClaudeOAuthUsage } from './claude-oauth-usage-request' +import type { ClaudeUsageErrorClassification } from './claude-usage-error-classification' +import type { ClaudeRateLimitFetchOptions } from './claude-usage-fetch-options' +import { + abortedClaudeRateLimitResult, + isManagedClaudeAuth, + makeClaudeUsageResult, + mergeClaudeUsageWindows, + metadataForClaudeUsageAttempt, + recordClaudeUsageAttempt, + type ClaudeUsageAttemptState, + warnClaudeUsageFetchFailure, + withClaudeUsageMetadata +} from './claude-usage-result' + +const LIVE_REFRESH_DEFERRED_MESSAGE = + 'Claude usage refresh is waiting for the live Claude terminal to rotate its credentials.' + +export function canRetryClaudeOAuthWithLegacyKeychain(input: { + classification: ClaudeUsageErrorClassification + oauthCredentials: ClaudeOAuthCredentialReadResult + authPreparation?: ClaudeRuntimeAuthPreparation +}): boolean { + return ( + input.classification.failureKind === 'stale-token' && + input.oauthCredentials.source === 'scoped-keychain' && + (input.authPreparation?.runtime ?? 'host') === 'host' && + !isManagedClaudeAuth(input.authPreparation) + ) +} + +export async function retryClaudeOAuthWithLegacyKeychain(input: { + failedToken: string | null + attempts: ClaudeUsageAttemptState + options?: ClaudeRateLimitFetchOptions +}): Promise { + const legacy = await readClaudeCredentialsFromStrictKeychain(undefined, 'legacy-keychain') + if (!legacy.token || legacy.token === input.failedToken) { + return null + } + if (input.options?.signal?.aborted) { + return abortedClaudeRateLimitResult() + } + try { + const limits = await fetchClaudeOAuthUsage(legacy.token, input.options?.signal) + if (input.options?.signal?.aborted) { + return abortedClaudeRateLimitResult() + } + return await completeClaudeOAuthUsageSuccess({ + oauthLimits: limits, + oauthCredentials: legacy, + attempts: input.attempts, + options: input.options + }) + } catch (error) { + warnClaudeUsageFetchFailure(input.options?.authPreparation, legacy, error) + return null + } +} + +export function shouldDeferClaudeUsageForLiveSession( + authPreparation: ClaudeRuntimeAuthPreparation | undefined, + classification: ClaudeUsageErrorClassification +): boolean { + return Boolean( + authPreparation?.managedRefreshDeferredByLivePty && + (classification.failureKind === 'stale-token' || + classification.failureKind === 'refreshable-credentials-without-token' || + classification.failureKind === 'deferred-by-live-session') + ) +} + +export function makeLiveClaudeUsageDeferredResult(input: { + attempts: ClaudeUsageAttemptState + oauthCredentials: ClaudeOAuthCredentialReadResult + authPreparation?: ClaudeRuntimeAuthPreparation +}): ProviderRateLimits { + return makeClaudeUsageResult('error', LIVE_REFRESH_DEFERRED_MESSAGE, { + ...metadataForClaudeUsageAttempt({ + attemptedSources: input.attempts.attemptedSources, + oauthCredentials: input.oauthCredentials, + authPreparation: input.authPreparation, + failureKind: 'deferred-by-live-session', + deferredByLiveClaudeSession: true + }) + }) +} + +export function makeClaudeUsageClassificationError(input: { + error: unknown + classification: ClaudeUsageErrorClassification + attempts: ClaudeUsageAttemptState + oauthCredentials: ClaudeOAuthCredentialReadResult + authPreparation?: ClaudeRuntimeAuthPreparation +}): ProviderRateLimits { + const message = + input.error instanceof Error ? input.error.message : String(input.error || 'Unknown error') + const retryAfterMs = input.error instanceof OAuthUsageError ? input.error.retryAfterMs : null + return makeClaudeUsageResult('error', withMacTailscaleDnsHint(message), { + ...metadataForClaudeUsageAttempt({ + attemptedSources: input.attempts.attemptedSources, + oauthCredentials: input.oauthCredentials, + authPreparation: input.authPreparation, + failureKind: input.classification.failureKind, + retryAtMs: retryAfterMs ? Date.now() + retryAfterMs : undefined + }) + }) +} + +export async function repairClaudeCredentialsThenRetryOAuth(input: { + options?: ClaudeRateLimitFetchOptions + attempts: ClaudeUsageAttemptState + oauthCredentials: ClaudeOAuthCredentialReadResult +}): Promise { + if (input.options?.signal?.aborted) { + return abortedClaudeRateLimitResult() + } + let cliResult: ProviderRateLimits | null = null + try { + cliResult = await fetchClaudeUsageViaCli({ + authPreparation: input.options?.authPreparation, + oauthCredentials: input.oauthCredentials, + attempts: input.attempts, + networkProxySettings: input.options?.networkProxySettings, + signal: input.options?.signal + }) + } catch (error) { + warnClaudeUsageFetchFailure(input.options?.authPreparation, input.oauthCredentials, error) + } + + if (input.options?.signal?.aborted) { + return abortedClaudeRateLimitResult() + } + const refreshed = await readClaudeOAuthCredentials( + resolveClaudeOAuthCredentialReadOptions(input.options?.authPreparation) + ) + if (input.options?.signal?.aborted) { + return abortedClaudeRateLimitResult() + } + if (refreshed.token) { + recordClaudeUsageAttempt(input.attempts, 'oauth') + try { + const retry = await fetchClaudeOAuthUsage(refreshed.token, input.options?.signal) + if (input.options?.signal?.aborted) { + return abortedClaudeRateLimitResult() + } + return withClaudeUsageMetadata( + mergeClaudeUsageWindows(retry, cliResult), + metadataForClaudeUsageAttempt({ + attemptedSources: input.attempts.attemptedSources, + oauthCredentials: refreshed, + authPreparation: input.options?.authPreparation, + source: 'oauth' + }) + ) + } catch (error) { + warnClaudeUsageFetchFailure(input.options?.authPreparation, refreshed, error) + } + } + return cliResult +} diff --git a/src/main/rate-limits/claude-profile-usage.test.ts b/src/main/rate-limits/claude-profile-usage.test.ts deleted file mode 100644 index 36816ea655ae..000000000000 --- a/src/main/rate-limits/claude-profile-usage.test.ts +++ /dev/null @@ -1,271 +0,0 @@ -import type * as Keychain from '../claude-accounts/keychain' -import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, expect, it, vi } from 'vitest' -const calls = vi.hoisted(() => ({ keychain: vi.fn(), usage: vi.fn(), keychainService: vi.fn() })) -vi.mock('../macos-keychain/generic-password', () => ({ - readKeychainPassword: calls.keychainService -})) -vi.mock('../claude-accounts/keychain', () => ({ - readActiveClaudeKeychainCredentialsStrict: calls.keychain -})) -const profileRouter = vi.hoisted((): { userConfigDir?: string } => ({})) -vi.mock('../claude-accounts/claude-profile-installed-router', () => ({ - getClaudeProfileRouter: () => - profileRouter.userConfigDir ? { userConfigDir: () => profileRouter.userConfigDir } : undefined -})) -vi.mock('./claude-oauth-usage-request', () => ({ fetchClaudeOAuthUsage: calls.usage })) -const runningDistros = vi.hoisted(() => ({ - filter: vi.fn(async (paths: readonly string[]) => [...paths]) -})) -vi.mock('../wsl-running-path-filter', () => ({ - filterPathsToRunningWslDistrosAsync: runningDistros.filter -})) -vi.mock('../../shared/child-process/run-process', () => ({ - spawnProcess: () => { - throw new Error('Usage must not launch a process') - }, - runProcess: () => { - throw new Error('Usage must not launch a process') - } -})) -import { fetchActiveClaudeRateLimits } from './claude-active-usage-fetch' -import { - readClaudeOAuthCredentials, - resolveClaudeOAuthCredentialReadOptions -} from './claude-oauth-credentials' -import { OAuthUsageError } from './claude-oauth-usage-error' -import { CLAUDE_PROFILE_MISSING_MESSAGE } from '../../shared/claude-profile-routing' -const roots: string[] = [] -afterEach(() => { - roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true })) - vi.unstubAllGlobals() - vi.clearAllMocks() -}) -function profile() { - const home = mkdtempSync(join(tmpdir(), 'claude-usage-')) - roots.push(home) - calls.keychain.mockResolvedValue(null) - const options = { - authPreparation: { - configDir: home, - envPatch: { CLAUDE_CONFIG_DIR: home }, - stripAuthEnv: true, - provenance: 'profile:fake' - } - } - return { home, options } -} -function systemDefault() { - const home = mkdtempSync(join(tmpdir(), 'claude-usage-default-')) - roots.push(home) - calls.keychain.mockResolvedValue(null) - return { - home, - options: { - authPreparation: { configDir: home, envPatch: {}, stripAuthEnv: false, provenance: 'system' } - } - } -} -it('lets the server decide a locally expired token and never refreshes it', async () => { - const f = profile() - const file = join(f.home, '.credentials.json') - writeFileSync( - file, - JSON.stringify({ - claudeAiOauth: { accessToken: 'expired-fake', refreshToken: 'must-not-be-used', expiresAt: 1 } - }) - ) - vi.stubGlobal('fetch', () => { - throw new Error('No refresh endpoint') - }) - calls.usage.mockRejectedValue(new OAuthUsageError('Invalid OAuth token.', 401, true, null)) - expect(await fetchActiveClaudeRateLimits(f.options)).toMatchObject({ - status: 'error', - error: 'Claude usage has expired. Start Claude in this account to refresh it.', - usageMetadata: { failureKind: 'stale-token' } - }) - expect(calls.usage.mock.calls.map(([token]) => token)).toEqual(['expired-fake']) - const system = systemDefault() - writeFileSync( - join(system.home, '.credentials.json'), - JSON.stringify({ claudeAiOauth: { accessToken: 'expired-default', expiresAt: 1 } }) - ) - calls.usage.mockResolvedValueOnce({ provider: 'claude', status: 'ok' }) - expect(await fetchActiveClaudeRateLimits(system.options)).toMatchObject({ status: 'ok' }) - expect(await fetchActiveClaudeRateLimits(system.options)).toMatchObject({ - error: 'Claude usage updates the next time Claude runs.', - usageMetadata: { failureKind: 'stale-token' } - }) -}) -it('reads only the requested scoped Keychain, with no unsuffixed fallback', async () => { - const f = profile() - await readClaudeOAuthCredentials({ credentialsFileConfigDir: f.home, keychainConfigDir: f.home }) - if (process.platform === 'darwin') { - expect(calls.keychain.mock.calls).toEqual([[f.home]]) - } else { - expect(calls.keychain).not.toHaveBeenCalled() - } -}) -it('distinguishes missing, malformed and inaccessible credential observations', async () => { - const f = profile() - expect(await fetchActiveClaudeRateLimits(f.options)).toMatchObject({ - usageMetadata: { failureKind: 'missing-credentials' } - }) - writeFileSync(join(f.home, '.credentials.json'), '{') - expect(await fetchActiveClaudeRateLimits(f.options)).toMatchObject({ - usageMetadata: { failureKind: 'keychain-unavailable' } - }) -}) -it('reports an unreadable selected account before any credential read and requests no hidden recovery', async () => { - const f = profile() - expect( - await fetchActiveClaudeRateLimits({ - authPreparation: { ...f.options.authPreparation, usageError: CLAUDE_PROFILE_MISSING_MESSAGE } - }) - ).toMatchObject({ status: 'error', usageMetadata: { failureKind: 'missing-credentials' } }) - expect(calls.keychain).not.toHaveBeenCalled() - writeFileSync( - join(f.home, '.credentials.json'), - JSON.stringify({ claudeAiOauth: { accessToken: 'fake' } }) - ) - calls.usage.mockRejectedValue(new Error('network unavailable')) - expect(await fetchActiveClaudeRateLimits(f.options)).toMatchObject({ status: 'error' }) - expect(calls.usage).toHaveBeenCalledTimes(1) -}) - -it('strict Keychain lookup never falls back to the unsuffixed service', async () => { - const f = profile() - calls.keychainService.mockResolvedValue(null) - const actual = await vi.importActual('../claude-accounts/keychain') - await actual.readActiveClaudeKeychainCredentialsStrict(f.home) - expect(calls.keychainService).toHaveBeenCalled() - for (const [service] of calls.keychainService.mock.calls) { - expect(service).toMatch(/^Claude Code-credentials-[0-9a-f]{8}$/) - } - calls.keychainService.mockClear() - calls.keychainService.mockRejectedValue(new Error('locked')) - await expect(actual.readActiveClaudeKeychainCredentialsStrict(f.home)).rejects.toThrow('locked') - expect(calls.keychainService).toHaveBeenCalledTimes(1) -}) -it('treats an unreadable credential file as unavailable, not a missing login', async () => { - const f = profile() - mkdirSync(join(f.home, '.credentials.json')) - expect(await fetchActiveClaudeRateLimits(f.options)).toMatchObject({ - usageMetadata: { failureKind: 'keychain-unavailable' } - }) -}) -it('hides Claude usage for System Default with no Claude login, instead of asking to sign in again', async () => { - const f = systemDefault() - expect(await fetchActiveClaudeRateLimits(f.options)).toMatchObject({ - status: 'unavailable', - usageMetadata: { failureKind: 'missing-credentials' } - }) - expect(await fetchActiveClaudeRateLimits(profile().options)).toMatchObject({ - status: 'error', - error: 'Sign in again to use this account.' - }) -}) -it("keeps a 429's Retry-After and rate-limit message so polling waits it out", async () => { - const f = profile() - writeFileSync( - join(f.home, '.credentials.json'), - JSON.stringify({ claudeAiOauth: { accessToken: 'fake' } }) - ) - const message = 'Claude usage is rate limited right now.' - calls.usage.mockRejectedValueOnce(new OAuthUsageError(message, 429, true, 3_000_000)) - const before = Date.now() - const limited = await fetchActiveClaudeRateLimits(f.options) - expect(limited).toMatchObject({ - status: 'error', - error: message, - usageMetadata: { failureKind: 'rate-limited' } - }) - expect(limited.usageMetadata?.retryAtMs).toBeGreaterThanOrEqual(before + 3_000_000) - expect(limited.usageMetadata?.retryAtMs).toBeLessThanOrEqual(Date.now() + 3_000_000) - calls.usage.mockRejectedValueOnce(new OAuthUsageError(message, 429, true, null)) - expect((await fetchActiveClaudeRateLimits(f.options)).usageMetadata?.retryAtMs).toBeUndefined() -}) -it("reads System default's own CLAUDE_CONFIG_DIR Keychain item before the unsuffixed one", async () => { - const root = mkdtempSync(join(tmpdir(), 'claude-usage-inherited-')) - roots.push(root) - const inherited = join(root, 'own-claude-config') - vi.stubEnv('CLAUDE_CONFIG_DIR', inherited) - try { - calls.keychain.mockResolvedValue(null) - await readClaudeOAuthCredentials( - resolveClaudeOAuthCredentialReadOptions({ - configDir: inherited, - envPatch: {}, - stripAuthEnv: false, - provenance: 'system' - }) - ) - expect(calls.keychain.mock.calls).toEqual( - process.platform === 'darwin' ? [[inherited], [undefined]] : [] - ) - } finally { - vi.unstubAllEnvs() - } - const managed = profile() - calls.keychain.mockClear() - await fetchActiveClaudeRateLimits(managed.options) - expect(calls.keychain.mock.calls).toEqual(process.platform === 'darwin' ? [[managed.home]] : []) -}) -it("names System default's Keychain item from the login shell's CLAUDE_CONFIG_DIR a Dock launch lacks", () => { - vi.stubEnv('CLAUDE_CONFIG_DIR', '') - profileRouter.userConfigDir = '/shell/claude-config' - try { - expect( - resolveClaudeOAuthCredentialReadOptions({ - configDir: '/shell/claude-config', - envPatch: {}, - stripAuthEnv: false, - provenance: 'system' - })?.keychainConfigDir - ).toBe('/shell/claude-config') - } finally { - profileRouter.userConfigDir = undefined - vi.unstubAllEnvs() - } -}) -it('reports a host problem as unavailable usage, never as a signed-out account', async () => { - const f = profile() - expect( - await fetchActiveClaudeRateLimits({ - authPreparation: { - ...f.options.authPreparation, - usageError: 'Could not read the home folder of WSL distro Ubuntu.' - } - }) - ).toMatchObject({ status: 'error', usageMetadata: { failureKind: 'usage-unavailable' } }) -}) -it('asks an inactive account from an older Orca to sign in again, reading nothing', async () => { - const { fetchInactiveClaudeAccountUsage } = await import('./claude-managed-account-usage') - expect( - await fetchInactiveClaudeAccountUsage({ - id: 'acct', - managedAuthRuntime: 'wsl', - wslDistro: 'Ubuntu', - wslLinuxAuthPath: '/home/u/.local/share/orca/claude-accounts/acct/auth' - }) - ).toMatchObject({ usageMetadata: { failureKind: 'missing-credentials' } }) - expect(calls.keychain).not.toHaveBeenCalled() -}) -it('does not read an inactive WSL account through a stopped distro', async () => { - const { fetchInactiveClaudeAccountUsage } = await import('./claude-managed-account-usage') - runningDistros.filter.mockResolvedValueOnce([]) - const result = await fetchInactiveClaudeAccountUsage({ - id: 'acct', - managedAuthRuntime: 'wsl', - wslDistro: 'Ubuntu', - wslLinuxAuthPath: '/home/u/.local/share/orca/claude-profiles/acct/home' - }) - expect(runningDistros.filter).toHaveBeenCalledWith( - ['\\\\wsl.localhost\\Ubuntu\\home\\u\\.local\\share\\orca\\claude-profiles\\acct\\home'], - { requireConfirmed: true } - ) - expect(result).toMatchObject({ usageMetadata: { failureKind: 'usage-unavailable' } }) - expect(calls.keychain).not.toHaveBeenCalled() -}) diff --git a/src/main/rate-limits/claude-pty-reset-parser.ts b/src/main/rate-limits/claude-pty-reset-parser.ts new file mode 100644 index 000000000000..43137a8cea54 --- /dev/null +++ b/src/main/rate-limits/claude-pty-reset-parser.ts @@ -0,0 +1,307 @@ +import type { RateLimitWindow } from '../../shared/rate-limit-types' +import { buildWallClockTimestamp } from './time-zone-wall-clock' + +const RESET_LINE_RE = /resets?\s+(?:at\s+|in\s+)?(.+)/i +const MONTH_PATTERN = + 'jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|jun(?:e)?|jul(?:y)?|aug(?:ust)?|sep(?:t(?:ember)?)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?' +const MONTH_DAY_COMPACT_RE = new RegExp( + `\\b(${MONTH_PATTERN})(\\d{1,2})(?=\\s*at\\s*\\d|\\D|$)`, + 'i' +) +const MONTH_DAY_TIME_RE = new RegExp( + `\\b(${MONTH_PATTERN})\\.?\\s+(\\d{1,2})(?:,?\\s*(?:at\\s+)?)?(\\d{1,2})(?::(\\d{2}))?\\s*(am|pm)\\b`, + 'i' +) +const WEEKDAY_TIME_RE = + /\b(sun(?:day)?|mon(?:day)?|tue(?:sday)?|wed(?:nesday)?|thu(?:rsday)?|fri(?:day)?|sat(?:urday)?)\.?\s+(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)\b/i +const TIME_ONLY_RE = /\b(\d{1,2})(?::(\d{2}))?\s*(am|pm)\b/i +// Why: newer Codex CLIs print 24-hour reset times ("10:21 on 28 Jul") with no am/pm. +const TIME_24H_RE = /\b(\d{1,2}):(\d{2})\b/ +const DAY_MONTH_RE = new RegExp(`\\b(?:on\\s+)?(\\d{1,2})\\s+(${MONTH_PATTERN})\\b`, 'i') +const RELATIVE_RESET_RE = /^(?:\s*\d+\s*(?:d(?:ays?)?|h(?:ours?|rs?)?|m(?:in(?:ute)?s?)?)\s*)+$/i +const RELATIVE_RESET_TOKEN_RE = /(\d+)\s*(d(?:ays?)?|h(?:ours?|rs?)?|m(?:in(?:ute)?s?)?)/gi +const IANA_TIME_ZONE_RE = /\(([^()]*)\)?\s*$/ + +export type ClaudePtyResetMetadata = Pick + +const MONTH_INDEX_BY_NAME: Record = { + jan: 0, + january: 0, + feb: 1, + february: 1, + mar: 2, + march: 2, + apr: 3, + april: 3, + may: 4, + jun: 5, + june: 5, + jul: 6, + july: 6, + aug: 7, + august: 7, + sep: 8, + sept: 8, + september: 8, + oct: 9, + october: 9, + nov: 10, + november: 10, + dec: 11, + december: 11 +} + +const WEEKDAY_INDEX_BY_NAME: Record = { + sun: 0, + sunday: 0, + mon: 1, + monday: 1, + tue: 2, + tuesday: 2, + wed: 3, + wednesday: 3, + thu: 4, + thursday: 4, + fri: 5, + friday: 5, + sat: 6, + saturday: 6 +} + +export function extractClaudePtyResetMetadata( + lines: string[], + matchesLabel: (line: string) => boolean, + isSectionLabel: (line: string) => boolean +): ClaudePtyResetMetadata { + for (let i = 0; i < lines.length; i++) { + if (!matchesLabel(lines[i])) { + continue + } + for (let j = i; j < Math.min(i + 14, lines.length); j++) { + if (j > i && isSectionLabel(lines[j])) { + break + } + const m = RESET_LINE_RE.exec(lines[j]) + if (m) { + const resetDescription = normalizeResetDescription(m[1]) + return { + resetsAt: parseResetTimestamp(resetDescription), + resetDescription + } + } + } + } + return { resetsAt: null, resetDescription: null } +} + +function normalizeResetDescription(raw: string): string { + // Why: Claude's TUI occasionally drops spaces around the Fable reset date + // when copied from the PTY buffer, but the value still encodes a real reset. + return ( + raw + .trim() + // Why: PTY captures keep trailing box-border glyphs from framed status panels. + .replace(/[)\s│]+$/, '') + .replace(/\s+/g, ' ') + .replace(MONTH_DAY_COMPACT_RE, '$1 $2') + .replace(/(\d{1,2})\s*at\s*(\d{1,2}(?::\d{2})?\s*(?:am|pm))/i, '$1 at $2') + .replace(/(\d)(am|pm)\(/gi, '$1$2 (') + ) +} + +function parseResetTimestamp(resetDescription: string | null): number | null { + if (!resetDescription) { + return null + } + + return ( + parseRelativeResetTimestamp(resetDescription) ?? + parseMonthDayResetTimestamp(resetDescription) ?? + parseWeekdayResetTimestamp(resetDescription) ?? + parseTimeOnlyResetTimestamp(resetDescription) ?? + parseTwentyFourHourResetTimestamp(resetDescription) + ) +} + +function parseTwentyFourHourResetTimestamp(resetDescription: string): number | null { + const resetText = stripResetTimeZone(resetDescription) + const timeMatch = TIME_24H_RE.exec(resetText) + if (!timeMatch) { + return null + } + const hour = Number(timeMatch[1]) + const minute = Number(timeMatch[2]) + if (!isValidClockTime(hour, minute)) { + return null + } + + const dayMonthMatch = DAY_MONTH_RE.exec(resetText) + if (dayMonthMatch) { + const day = Number(dayMonthMatch[1]) + const monthIndex = MONTH_INDEX_BY_NAME[dayMonthMatch[2].toLowerCase()] + if (monthIndex === undefined || day < 1 || day > 31) { + return null + } + const now = new Date() + const timeZone = extractResetTimeZone(resetDescription) + let timestamp = buildWallClockTimestamp( + { year: now.getFullYear(), monthIndex, day, hour, minute }, + timeZone + ) + if (timestamp !== null && timestamp <= Date.now()) { + timestamp = buildWallClockTimestamp( + { year: now.getFullYear() + 1, monthIndex, day, hour, minute }, + timeZone + ) + } + return timestamp + } + + const candidate = new Date() + candidate.setHours(hour, minute, 0, 0) + if (candidate.getTime() <= Date.now()) { + candidate.setDate(candidate.getDate() + 1) + } + return candidate.getTime() +} + +function parseRelativeResetTimestamp(resetDescription: string): number | null { + if (!RELATIVE_RESET_RE.test(resetDescription)) { + return null + } + + let durationMs = 0 + for (const match of resetDescription.matchAll(RELATIVE_RESET_TOKEN_RE)) { + const amount = Number(match[1]) + const unit = match[2].toLowerCase()[0] + if (!Number.isFinite(amount)) { + continue + } + if (unit === 'd') { + durationMs += amount * 24 * 60 * 60_000 + } else if (unit === 'h') { + durationMs += amount * 60 * 60_000 + } else if (unit === 'm') { + durationMs += amount * 60_000 + } + } + return durationMs > 0 ? Date.now() + durationMs : null +} + +function parseMonthDayResetTimestamp(resetDescription: string): number | null { + const resetText = stripResetTimeZone(resetDescription) + const match = MONTH_DAY_TIME_RE.exec(resetText) + if (!match) { + return null + } + + const monthIndex = MONTH_INDEX_BY_NAME[match[1].toLowerCase()] + if (monthIndex === undefined) { + return null + } + + const day = Number(match[2]) + const hour = parseHour(match[3], match[5]) + const minute = Number(match[4] ?? 0) + if (!isValidClockTime(hour, minute)) { + return null + } + + const now = new Date() + const timeZone = extractResetTimeZone(resetDescription) + let timestamp = buildWallClockTimestamp( + { year: now.getFullYear(), monthIndex, day, hour, minute }, + timeZone + ) + if (timestamp !== null && timestamp <= Date.now()) { + timestamp = buildWallClockTimestamp( + { year: now.getFullYear() + 1, monthIndex, day, hour, minute }, + timeZone + ) + } + return timestamp +} + +function parseWeekdayResetTimestamp(resetDescription: string): number | null { + const resetText = stripResetTimeZone(resetDescription) + const match = WEEKDAY_TIME_RE.exec(resetText) + if (!match) { + return null + } + + const weekdayIndex = WEEKDAY_INDEX_BY_NAME[match[1].toLowerCase()] + if (weekdayIndex === undefined) { + return null + } + + const now = new Date() + const hour = parseHour(match[2], match[4]) + const minute = Number(match[3] ?? 0) + if (!isValidClockTime(hour, minute)) { + return null + } + + const candidate = new Date(now) + const daysUntil = (weekdayIndex - now.getDay() + 7) % 7 + candidate.setDate(now.getDate() + daysUntil) + candidate.setHours(hour, minute, 0, 0) + if (candidate.getTime() <= Date.now()) { + candidate.setDate(candidate.getDate() + 7) + } + return candidate.getTime() +} + +function parseTimeOnlyResetTimestamp(resetDescription: string): number | null { + const resetText = stripResetTimeZone(resetDescription) + const match = TIME_ONLY_RE.exec(resetText) + if (!match) { + return null + } + + const hour = parseHour(match[1], match[3]) + const minute = Number(match[2] ?? 0) + if (!isValidClockTime(hour, minute)) { + return null + } + + const candidate = new Date() + candidate.setHours(hour, minute, 0, 0) + if (candidate.getTime() <= Date.now()) { + candidate.setDate(candidate.getDate() + 1) + } + return candidate.getTime() +} + +function parseHour(hourText: string, periodText: string): number { + const hour = Number(hourText) + const period = periodText.toLowerCase() + if (hour < 1 || hour > 12) { + return Number.NaN + } + if (period === 'am') { + return hour === 12 ? 0 : hour + } + return hour === 12 ? 12 : hour + 12 +} + +function isValidClockTime(hour: number, minute: number): boolean { + return Number.isInteger(hour) && hour >= 0 && hour < 24 && minute >= 0 && minute < 60 +} + +function stripResetTimeZone(resetDescription: string): string { + return resetDescription.replace(/\s*\([^)]*\)?\s*$/, '').trim() +} + +function extractResetTimeZone(resetDescription: string): string | null { + const match = IANA_TIME_ZONE_RE.exec(resetDescription) + if (!match?.[1]) { + return null + } + const timeZone = match[1].trim() + try { + new Intl.DateTimeFormat('en-US', { timeZone }) + return timeZone + } catch { + return null + } +} diff --git a/src/main/rate-limits/claude-pty-stop-markers.ts b/src/main/rate-limits/claude-pty-stop-markers.ts new file mode 100644 index 000000000000..2bba56c69112 --- /dev/null +++ b/src/main/rate-limits/claude-pty-stop-markers.ts @@ -0,0 +1,14 @@ +// Why: these substrings indicate the /usage TUI panel has finished rendering. +export const CLAUDE_USAGE_STOP_SUBSTRINGS = [ + 'Current week (all models)', + 'Current week (Opus)', + 'Current week (Sonnet only)', + 'Current week (Sonnet)', + 'Weekly limits', + 'Weekly limit', + 'Weekly usage', + '7-day', + 'Current session', + 'Failed to load usage data', + 'failed to load usage data' +] diff --git a/src/main/rate-limits/claude-pty-usage-parser.ts b/src/main/rate-limits/claude-pty-usage-parser.ts new file mode 100644 index 000000000000..3328d9471600 --- /dev/null +++ b/src/main/rate-limits/claude-pty-usage-parser.ts @@ -0,0 +1,166 @@ +import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limit-types' +import { extractClaudePtyResetMetadata } from './claude-pty-reset-parser' + +// Why: these patterns match the Claude CLI's /usage TUI panel output. +// "Current session" shows a percent like "62% used" or "62% left". +// Weekly labels have varied between "Current week" and "Weekly limits". +const SESSION_RE = /current\s*session/i +const WEEKLY_RE = /(?:current\s*week|weekly\s*(?:limits?|usage|rate\s*limits?)|7\s*[- ]?\s*day)/i +const FABLE_WORD_RE = /\bfable\b/i +const FABLE_LABEL_RE = /^\s*fable\s*$/i +// Why: derive from WEEKLY_RE so a future weekly-wording change stays in one place +// instead of silently reopening the Fable-weekly parsing gap this fix closed. +const FABLE_WEEKLY_LABEL_RE = new RegExp( + `${WEEKLY_RE.source}\\s*(?:\\([^)]*\\bfable\\b[^)]*\\)|[-:]?\\s*\\bfable\\b)`, + 'i' +) +const PERCENT_RE = /(\d{1,3})(?:\.\d+)?\s*%\s*(used|consumed|left|remaining|available)/i +const ESC = String.fromCharCode(27) +const BEL = String.fromCharCode(7) +const OSC_SEQUENCE_RE = new RegExp(`${ESC}\\][^${BEL}]*(?:${BEL}|${ESC}\\\\)`, 'g') +const CSI_SEQUENCE_RE = new RegExp(`${ESC}\\[[0-9;?]*[ -/]*[@-~]`, 'g') + +export function stripTerminalControlSequences(output: string): string { + return output.replace(OSC_SEQUENCE_RE, '').replace(CSI_SEQUENCE_RE, '') +} + +/** + * Extract percent-left from lines following a label match. + * Scans up to 12 lines after the label to find the associated percent. + */ +function matchesWeeklyLabel(line: string): boolean { + return WEEKLY_RE.test(line) && !FABLE_WORD_RE.test(line) +} + +function matchesFableBoundary(line: string): boolean { + return FABLE_LABEL_RE.test(line) || (FABLE_WORD_RE.test(line) && WEEKLY_RE.test(line)) +} + +function matchesFableUsageLabel(line: string): boolean { + // Why: broad Fable-weekly copy should stop nearby scans, but only a real + // Fable usage heading should produce the distinct Fable meter. + return FABLE_LABEL_RE.test(line) || FABLE_WEEKLY_LABEL_RE.test(line) +} + +function isSectionLabel(line: string): boolean { + return SESSION_RE.test(line) || matchesWeeklyLabel(line) || matchesFableBoundary(line) +} + +function extractPercentAfterLabel( + lines: string[], + matchesLabel: (line: string) => boolean +): number | null { + for (let i = 0; i < lines.length; i++) { + if (!matchesLabel(lines[i])) { + continue + } + // Scan next 12 lines for a percent + for (let j = i; j < Math.min(i + 12, lines.length); j++) { + if (j > i && isSectionLabel(lines[j])) { + break + } + const m = PERCENT_RE.exec(lines[j]) + if (m) { + const pct = Number.parseFloat(m[1]) + const word = m[2].toLowerCase() + const isUsed = word === 'used' || word === 'consumed' + return isUsed ? pct : 100 - pct + } + } + } + return null +} + +export function parseClaudePtyUsage(output: string): { + session: RateLimitWindow | null + weekly: RateLimitWindow | null + fableWeekly: RateLimitWindow | null +} { + const lines = output.split(/\r\n|\n|\r/) + + const sessionPct = extractPercentAfterLabel(lines, (line) => SESSION_RE.test(line)) + const weeklyPct = extractPercentAfterLabel(lines, matchesWeeklyLabel) + const fableWeeklyPct = extractPercentAfterLabel(lines, matchesFableUsageLabel) + const sessionReset = extractClaudePtyResetMetadata( + lines, + (line) => SESSION_RE.test(line), + isSectionLabel + ) + const weeklyReset = extractClaudePtyResetMetadata(lines, matchesWeeklyLabel, isSectionLabel) + const fableWeeklyReset = extractClaudePtyResetMetadata( + lines, + matchesFableUsageLabel, + isSectionLabel + ) + + const session: RateLimitWindow | null = + sessionPct !== null + ? { + usedPercent: Math.min(100, Math.max(0, sessionPct)), + windowMinutes: 300, + resetsAt: sessionReset.resetsAt, + resetDescription: sessionReset.resetDescription + } + : null + + const weekly: RateLimitWindow | null = + weeklyPct !== null + ? { + usedPercent: Math.min(100, Math.max(0, weeklyPct)), + windowMinutes: 10080, + resetsAt: weeklyReset.resetsAt, + resetDescription: weeklyReset.resetDescription + } + : null + + const fableWeekly: RateLimitWindow | null = + fableWeeklyPct !== null + ? { + usedPercent: Math.min(100, Math.max(0, fableWeeklyPct)), + windowMinutes: 10080, + resetsAt: fableWeeklyReset.resetsAt, + resetDescription: fableWeeklyReset.resetDescription + } + : null + + return { session, weekly, fableWeekly } +} + +const RATE_LIMITED_RE = /rate limited\.?\s+please try again later/i +const LOAD_FAILED_RE = /failed to load usage data/i +const CLAUDE_21_USAGE_TABS_RE = /settings?\s+status?\s+config\s+usage\s+stats/i +const CLAUDE_21_SESSION_STATS_RE = /total\s*cost|total\s*duration|usage:\s*\d+\s*input/i + +export function isClaude21UsagePanel(output: string): boolean { + return CLAUDE_21_USAGE_TABS_RE.test(output) || CLAUDE_21_SESSION_STATS_RE.test(output) +} + +export function describeClaudeUsageFailure(output: string): string { + if (RATE_LIMITED_RE.test(output)) { + return 'Claude usage is rate limited right now.' + } + + if (LOAD_FAILED_RE.test(output)) { + return 'Claude usage is unavailable right now.' + } + + if (CLAUDE_21_USAGE_TABS_RE.test(output) || CLAUDE_21_SESSION_STATS_RE.test(output)) { + return 'Claude plan usage is unavailable for this Claude CLI session.' + } + + // Why: parser failures are an implementation detail of Orca's PTY fallback. + // The UI should explain the user-visible outcome, not leak internal parsing + // mechanics that the user cannot act on. + return 'Claude usage is unavailable right now.' +} + +export function abortedClaudeUsageResult(): ProviderRateLimits { + return { + provider: 'claude', + session: null, + weekly: null, + updatedAt: Date.now(), + error: 'Rate-limit fetch aborted', + status: 'error' + } +} diff --git a/src/main/rate-limits/claude-pty.test.ts b/src/main/rate-limits/claude-pty.test.ts new file mode 100644 index 000000000000..f2fd976821b6 --- /dev/null +++ b/src/main/rate-limits/claude-pty.test.ts @@ -0,0 +1,554 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { resolveClaudeCommandMock, spawnMock } = vi.hoisted(() => ({ + resolveClaudeCommandMock: vi.fn(), + spawnMock: vi.fn() +})) + +vi.mock('../codex-cli/command', () => ({ + resolveClaudeCommand: resolveClaudeCommandMock +})) + +vi.mock('node-pty', () => ({ + spawn: spawnMock +})) + +import { fetchViaPty } from './claude-pty' +import { getActiveHiddenRateLimitPtyCount } from './hidden-pty-cleanup' + +function makeDisposable() { + return { dispose: vi.fn() } +} + +type MockTerm = { + onData: ReturnType + onExit: ReturnType + write: ReturnType + kill: ReturnType +} + +function makeMockTerm(): MockTerm & { + emitData: (data: string) => void + emitExit: () => void +} { + let dataHandler: ((data: string) => void) | null = null + let exitHandler: (() => void) | null = null + return { + onData: vi.fn((handler: (data: string) => void) => { + dataHandler = handler + return makeDisposable() + }), + onExit: vi.fn((handler: () => void) => { + exitHandler = handler + return makeDisposable() + }), + write: vi.fn(), + kill: vi.fn(), + emitData: (data: string) => dataHandler?.(data), + emitExit: () => exitHandler?.() + } +} + +describe('fetchViaPty', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() + resolveClaudeCommandMock.mockReturnValue('claude') + }) + + it('disposes node-pty listeners before killing the hidden PTY on timeout', async () => { + const onDataDisposable = makeDisposable() + const onExitDisposable = makeDisposable() + const killMock = vi.fn() + + spawnMock.mockReturnValue({ + onData: vi.fn(() => onDataDisposable), + onExit: vi.fn(() => onExitDisposable), + write: vi.fn(), + kill: killMock + }) + + const resultPromise = fetchViaPty() + await vi.advanceTimersByTimeAsync(25_000) + await resultPromise + + expect(onDataDisposable.dispose.mock.invocationCallOrder[0]).toBeLessThan( + killMock.mock.invocationCallOrder[0] + ) + expect(onExitDisposable.dispose.mock.invocationCallOrder[0]).toBeLessThan( + killMock.mock.invocationCallOrder[0] + ) + }) + + it('injects the configured proxy into the hidden PTY spawn env', async () => { + const term = makeMockTerm() + spawnMock.mockReturnValue(term) + + const resultPromise = fetchViaPty({ + networkProxySettings: { httpProxyUrl: 'http://127.0.0.1:7890' } + }) + await vi.advanceTimersByTimeAsync(0) + + expect(spawnMock).toHaveBeenCalled() + const spawnEnv = spawnMock.mock.calls[0]?.[2]?.env as Record + expect(spawnEnv.HTTPS_PROXY).toBe('http://127.0.0.1:7890') + expect(spawnEnv.HTTP_PROXY).toBe('http://127.0.0.1:7890') + + term.emitExit() + await resultPromise + }) + + it('spawns the hidden usage PTY in a bounded non-root cwd', async () => { + const term = makeMockTerm() + spawnMock.mockReturnValue(term) + + const resultPromise = fetchViaPty() + await vi.advanceTimersByTimeAsync(0) + + const spawnCwd = spawnMock.mock.calls[0]?.[2]?.cwd as string + expect(spawnCwd).toContain('rate-limit-pty-cwd') + expect(spawnCwd).not.toBe('/') + expect(spawnCwd).not.toMatch(/^[A-Za-z]:(?:[\\/])?$/) + + term.emitExit() + await resultPromise + }) + + it('leaves the spawn env proxy untouched when no proxy is configured', async () => { + const term = makeMockTerm() + spawnMock.mockReturnValue(term) + const inheritedHttpsProxy = process.env.HTTPS_PROXY + + const resultPromise = fetchViaPty() + await vi.advanceTimersByTimeAsync(0) + + const spawnEnv = spawnMock.mock.calls[0]?.[2]?.env as Record + expect(spawnEnv.HTTPS_PROXY).toBe(inheritedHttpsProxy) + + term.emitExit() + await resultPromise + }) + + it('exports the configured proxy inside the WSL launch command', async () => { + // Why: Windows-side spawn env does not cross into the distro, so the proxy + // must be exported in the bash command for the inner claude to see it. + const term = makeMockTerm() + spawnMock.mockReturnValue(term) + + const resultPromise = fetchViaPty({ + networkProxySettings: { httpProxyUrl: 'http://127.0.0.1:7890' }, + authPreparation: { + configDir: '/home/u/.claude', + runtime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxConfigDir: '/home/u/.claude', + envPatch: { CLAUDE_CONFIG_DIR: '/home/u/.claude' }, + stripAuthEnv: false, + provenance: 'system' + } + }) + await vi.advanceTimersByTimeAsync(0) + + const [spawnFile, spawnArgs] = spawnMock.mock.calls[0] as [string, string[]] + expect(spawnFile).toBe('wsl.exe') + const bashCommand = spawnArgs.at(-1) as string + expect(bashCommand).toContain('mkdir -p "$orca_rate_limit_cwd"') + expect(bashCommand).toContain('cd "$orca_rate_limit_cwd"') + expect(bashCommand).toContain("export HTTPS_PROXY='http://127.0.0.1:7890'") + expect(bashCommand).toContain('exec claude') + + term.emitExit() + await resultPromise + }) + + it('kills and unregisters the hidden PTY when the fetch signal aborts', async () => { + const term = makeMockTerm() + spawnMock.mockReturnValue(term) + const controller = new AbortController() + const killMock = term.kill + + const resultPromise = fetchViaPty({ signal: controller.signal }) + await vi.advanceTimersByTimeAsync(0) + + expect(getActiveHiddenRateLimitPtyCount()).toBe(1) + + controller.abort() + + await expect(resultPromise).resolves.toMatchObject({ + provider: 'claude', + status: 'error', + error: 'Rate-limit fetch aborted' + }) + expect(killMock).toHaveBeenCalledTimes(1) + expect(getActiveHiddenRateLimitPtyCount()).toBe(0) + }) + + it('clears the startup delay timer when the hidden PTY exits early', async () => { + const term = makeMockTerm() + spawnMock.mockReturnValue(term) + + const resultPromise = fetchViaPty() + await vi.advanceTimersByTimeAsync(0) + expect(spawnMock).toHaveBeenCalled() + expect(vi.getTimerCount()).toBeGreaterThan(0) + + term.emitExit() + + await expect(resultPromise).resolves.toMatchObject({ + provider: 'claude', + status: 'error' + }) + expect(vi.getTimerCount()).toBe(0) + }) + + it('clears pending settle timers when the hidden PTY exits after usage output starts', async () => { + const term = makeMockTerm() + spawnMock.mockReturnValue(term) + + const resultPromise = fetchViaPty() + await vi.advanceTimersByTimeAsync(2_000) + term.emitData('Current session\r12% used\r') + expect(vi.getTimerCount()).toBeGreaterThan(0) + + term.emitExit() + + await expect(resultPromise).resolves.toMatchObject({ + provider: 'claude', + status: 'ok', + session: { + usedPercent: 12 + } + }) + expect(vi.getTimerCount()).toBe(0) + }) + + it('treats Claude 2.1 tabbed /usage session stats as rendered but unavailable', async () => { + const term = makeMockTerm() + spawnMock.mockReturnValue(term) + + const resultPromise = fetchViaPty() + + await vi.advanceTimersByTimeAsync(2_000) + expect(term.write).toHaveBeenCalledWith('/usage\r') + + term.emitData(` + Settings Status Config Usage Stats + + Session + Total cost: $0.0000 + Usage: 0 input, 0 output, 0 cache read, 0 cache write + `) + + await vi.advanceTimersByTimeAsync(8_000) + + await expect(resultPromise).resolves.toMatchObject({ + provider: 'claude', + status: 'error', + session: null, + weekly: null, + error: 'Claude plan usage is unavailable for this Claude CLI session.' + }) + expect(term.write).not.toHaveBeenCalledWith('\x1b[D\x1b[D') + }) + + it('keeps waiting for plan windows after the Claude 2.1 usage shell renders', async () => { + const term = makeMockTerm() + spawnMock.mockReturnValue(term) + + const resultPromise = fetchViaPty() + + await vi.advanceTimersByTimeAsync(2_000) + term.emitData(` + Settings Status Config Usage Stats + Session + Total cost: $0.0000 + `) + + await vi.advanceTimersByTimeAsync(1_000) + term.emitData('Current session\r12% used\rResets 4:00pm\rCurrent week (all models)\r34% used\r') + await vi.advanceTimersByTimeAsync(2_000) + + await expect(resultPromise).resolves.toMatchObject({ + provider: 'claude', + status: 'ok', + session: { + usedPercent: 12, + resetDescription: '4:00pm' + }, + weekly: { + usedPercent: 34 + }, + error: null + }) + }) + + it('parses the newer Claude weekly limits wording for Fable usage', async () => { + const term = makeMockTerm() + spawnMock.mockReturnValue(term) + vi.setSystemTime(new Date(2026, 6, 3, 20, 0)) + + const resultPromise = fetchViaPty() + + await vi.advanceTimersByTimeAsync(2_000) + term.emitData(` + Plan usage limits + + Current session + 18% remaining + Resets in 2h 10m + + Weekly limits + Fable + 42% consumed + Resets in 3d 2h + `) + await vi.advanceTimersByTimeAsync(2_000) + + const result = await resultPromise + + expect(result).toMatchObject({ + provider: 'claude', + status: 'ok', + session: { + usedPercent: 82, + resetsAt: Date.now() + 2 * 60 * 60_000 + 10 * 60_000, + resetDescription: '2h 10m' + }, + weekly: null, + fableWeekly: { + usedPercent: 42, + resetsAt: Date.now() + 3 * 24 * 60 * 60_000 + 2 * 60 * 60_000, + resetDescription: '3d 2h' + }, + error: null + }) + }) + + it('parses generic weekly and Fable weekly limits as separate windows', async () => { + const term = makeMockTerm() + spawnMock.mockReturnValue(term) + + const resultPromise = fetchViaPty() + + await vi.advanceTimersByTimeAsync(2_000) + term.emitData(` + Plan usage limits + + Current session + 18% remaining + Resets in 2h 10m + + Current week (all models) + 84% left + Resets in 5d 4h + + Fable + 42% consumed + Resets in 3d 2h + `) + await vi.advanceTimersByTimeAsync(2_000) + + await expect(resultPromise).resolves.toMatchObject({ + provider: 'claude', + status: 'ok', + session: { + usedPercent: 82, + resetDescription: '2h 10m' + }, + weekly: { + usedPercent: 16, + resetDescription: '5d 4h' + }, + fableWeekly: { + usedPercent: 42, + resetDescription: '3d 2h' + }, + error: null + }) + }) + + it('parses Claude current-week Fable usage as a distinct weekly window', async () => { + const term = makeMockTerm() + spawnMock.mockReturnValue(term) + vi.setSystemTime(new Date(2026, 6, 3, 20, 0)) + + const resultPromise = fetchViaPty() + + await vi.advanceTimersByTimeAsync(2_000) + term.emitData(` + Plan usage limits + + Current session + 8% used + Resets 3:39am + + Current week (all models) + 33% used + Resets Jul 10 at 12:59pm + + Current week (Fable) + 62% used + Resets Jul10at12:59pm(America/Los_Angeles) + `) + await vi.advanceTimersByTimeAsync(2_000) + + await expect(resultPromise).resolves.toMatchObject({ + provider: 'claude', + status: 'ok', + session: { + usedPercent: 8, + resetDescription: '3:39am' + }, + weekly: { + usedPercent: 33, + resetsAt: new Date(2026, 6, 10, 12, 59).getTime(), + resetDescription: 'Jul 10 at 12:59pm' + }, + fableWeekly: { + usedPercent: 62, + resetsAt: Date.parse('2026-07-10T19:59:00.000Z'), + resetDescription: 'Jul 10 at 12:59pm (America/Los_Angeles' + }, + error: null + }) + }) + + it('does not let an incomplete Fable section consume later usage sections', async () => { + const term = makeMockTerm() + spawnMock.mockReturnValue(term) + + const resultPromise = fetchViaPty() + + await vi.advanceTimersByTimeAsync(2_000) + term.emitData(` + Plan usage limits + + Fable + Usage unavailable + + Current session + 12% used + + Current week (all models) + 84% left + `) + await vi.advanceTimersByTimeAsync(2_000) + + await expect(resultPromise).resolves.toMatchObject({ + provider: 'claude', + status: 'ok', + session: { + usedPercent: 12 + }, + weekly: { + usedPercent: 16 + }, + fableWeekly: null, + error: null + }) + }) + + it('does not treat inline Fable weekly text as a parsed usage label', async () => { + const term = makeMockTerm() + spawnMock.mockReturnValue(term) + + const resultPromise = fetchViaPty() + + await vi.advanceTimersByTimeAsync(2_000) + term.emitData(` + Plan usage limits + + Current session + Fable weekly usage + 42% consumed + + Current week (all models) + 84% left + `) + await vi.advanceTimersByTimeAsync(2_000) + + await expect(resultPromise).resolves.toMatchObject({ + provider: 'claude', + status: 'ok', + session: null, + weekly: { + usedPercent: 16 + }, + fableWeekly: null, + error: null + }) + }) + + it('keeps waiting after a bare Fable heading until another usage section renders', async () => { + const term = makeMockTerm() + spawnMock.mockReturnValue(term) + let settled = false + + const resultPromise = fetchViaPty().finally(() => { + settled = true + }) + + await vi.advanceTimersByTimeAsync(2_000) + term.emitData(` + Plan usage limits + + Fable + `) + await vi.advanceTimersByTimeAsync(2_000) + + expect(settled).toBe(false) + + term.emitData(` + 42% consumed + + Current session + 12% used + `) + await vi.advanceTimersByTimeAsync(2_000) + + await expect(resultPromise).resolves.toMatchObject({ + provider: 'claude', + status: 'ok', + session: { + usedPercent: 12 + }, + fableWeekly: { + usedPercent: 42 + }, + error: null + }) + }) + + it('parses 7-day weekly labels without the old Current week heading', async () => { + const term = makeMockTerm() + spawnMock.mockReturnValue(term) + + const resultPromise = fetchViaPty() + + await vi.advanceTimersByTimeAsync(2_000) + term.emitData(` + Usage + + Current session + 12% used + + 7-day + 84% left + Resets Wed at 9:05 PM + `) + await vi.advanceTimersByTimeAsync(2_000) + + await expect(resultPromise).resolves.toMatchObject({ + provider: 'claude', + status: 'ok', + session: { + usedPercent: 12 + }, + weekly: { + usedPercent: 16, + resetDescription: 'Wed at 9:05 PM' + }, + error: null + }) + }) +}) diff --git a/src/main/rate-limits/claude-pty.ts b/src/main/rate-limits/claude-pty.ts new file mode 100644 index 000000000000..cbaedbd17f9d --- /dev/null +++ b/src/main/rate-limits/claude-pty.ts @@ -0,0 +1,354 @@ +import type { ProviderRateLimits } from '../../shared/rate-limit-types' +import { buildConfiguredProxyEnv, type NetworkProxySettings } from '../../shared/network-proxy' +import { resolveClaudeCommand } from '../codex-cli/command' +// Why: import from the shared module, not the codex-cli re-export, so a test that +// mocks '../codex-cli/command' does not have to restate this pure helper. +import { withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution' +import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' +import { applyClaudeEnvPatch } from '../claude-accounts/environment' +import { withMacTailscaleDnsHint } from '../network/macos-tailscale-dns-diagnostic' +import { cleanupHiddenRateLimitPty, registerHiddenRateLimitPty } from './hidden-pty-cleanup' +import { + getHiddenRateLimitWslCwdSetupCommands, + resolveHiddenRateLimitPtyCwd +} from './hidden-rate-limit-pty-cwd' +import { + abortedClaudeUsageResult, + describeClaudeUsageFailure, + isClaude21UsagePanel, + parseClaudePtyUsage, + stripTerminalControlSequences +} from './claude-pty-usage-parser' +import { quoteHiddenRateLimitShellValue } from './hidden-rate-limit-shell' +import { CLAUDE_USAGE_STOP_SUBSTRINGS } from './claude-pty-stop-markers' + +const PTY_TIMEOUT_MS = 25_000 +const MAX_OUTPUT_LENGTH = 100_000 // 100KB buffer limit + +// --------------------------------------------------------------------------- +// PTY fallback — spawn interactive `claude`, send `/usage`, parse the TUI +// --------------------------------------------------------------------------- + +// Why: prompt detection is unreliable because the Claude CLI v2.x renders +// a status bar and TUI elements that push the `❯` prompt out of any +// reasonable detection window. Instead we wait a fixed 2s after spawning +// for the CLI to initialize, then send `/usage\r` directly. Command +// palette prompts ("Show plan usage limits") are auto-confirmed with Enter. +const COMMAND_PALETTE_RE = /show plan|usage limits/i +const TRUST_PROMPT_RE = /do you trust|trust the files|safety check/i +const STARTUP_DELAY_MS = 2_000 +const SETTLE_AFTER_STOP_MS = 2_000 +const SETTLE_AFTER_CLAUDE_21_USAGE_MS = 8_000 + +export async function fetchViaPty(options?: { + authPreparation?: ClaudeRuntimeAuthPreparation + networkProxySettings?: NetworkProxySettings + signal?: AbortSignal +}): Promise { + if (options?.signal?.aborted) { + return abortedClaudeUsageResult() + } + const pty = await import('node-pty') + if (options?.signal?.aborted) { + return abortedClaudeUsageResult() + } + + return new Promise((resolve) => { + let output = '' + let resolved = false + let sentUsage = false + let stopDetected = false + let claude21UsageDetected = false + let startupDelayTimer: ReturnType | null = null + let stopSettleTimer: ReturnType | null = null + let claude21UsageSettleTimer: ReturnType | null = null + + const claudeCommand = resolveClaudeCommand() + + // Why: node-pty cannot spawn .cmd/.bat batch scripts directly on Windows — + // those need cmd.exe as an interpreter. Always route through cmd.exe on win32 + // and ensure the command path is properly quoted if it contains spaces. + const isWin32 = process.platform === 'win32' + const spawnEnv = applyClaudeEnvPatch( + { ...process.env, TERM: 'xterm-256color' } as Record, + options?.authPreparation?.envPatch ?? {}, + { stripAuthEnv: options?.authPreparation?.stripAuthEnv ?? false } + ) + // Why: this hidden usage PTY spawns `claude` directly, not the user's shell + // wrapper, so without the configured proxy it would reach api.anthropic.com + // from the app's own IP — bypassing the proxy the user set for Claude and + // risking rate-limit/geo signals on the account. Falls back to {} when unset. + const proxyEnv = buildConfiguredProxyEnv(options?.networkProxySettings) + Object.assign(spawnEnv, proxyEnv) + const authPreparation = options?.authPreparation + const wslConfig = + authPreparation?.runtime === 'wsl' && + authPreparation.wslDistro && + authPreparation.wslLinuxConfigDir + ? { + distro: authPreparation.wslDistro, + linuxConfigDir: authPreparation.wslLinuxConfigDir + } + : null + const spawnFile = wslConfig ? 'wsl.exe' : isWin32 ? 'cmd.exe' : claudeCommand + const spawnArgs = wslConfig + ? [ + '-d', + wslConfig.distro, + '--exec', + 'bash', + '-lc', + // Why: Windows-side env does not cross into the distro without WSLENV, + // so export the configured proxy inside the command for the inner claude. + [ + // Why: hidden usage probes must not inherit a root-like WSL cwd; + // keep Claude discovery bounded to a tiny temp directory. + ...getHiddenRateLimitWslCwdSetupCommands(), + `export CLAUDE_CONFIG_DIR=${quoteHiddenRateLimitShellValue(wslConfig.linuxConfigDir)}`, + ...Object.entries(proxyEnv).map( + ([key, value]) => `export ${key}=${quoteHiddenRateLimitShellValue(value)}` + ), + 'exec claude' + ].join(' && ') + ] + : isWin32 + ? ['/c', `"${claudeCommand}"`] + : [] + + const term = pty.spawn(spawnFile, spawnArgs, { + name: 'xterm-256color', + cols: 120, + rows: 40, + // Why: hidden usage PTYs must not inherit the process cwd (e.g. / or a + // drive root), which can trigger unbounded file discovery. + cwd: resolveHiddenRateLimitPtyCwd(), + env: withCliRuntimeOnPath(claudeCommand, spawnEnv) + }) + const termDisposables: { dispose: () => void }[] = [registerHiddenRateLimitPty(term)] + let enterInterval: ReturnType | null = null + let timeout: ReturnType | null = null + + function clearFollowupTimers(): void { + if (startupDelayTimer) { + clearTimeout(startupDelayTimer) + startupDelayTimer = null + } + if (stopSettleTimer) { + clearTimeout(stopSettleTimer) + stopSettleTimer = null + } + if (claude21UsageSettleTimer) { + clearTimeout(claude21UsageSettleTimer) + claude21UsageSettleTimer = null + } + if (enterInterval) { + clearInterval(enterInterval) + enterInterval = null + } + } + + function settleAborted(): void { + if (resolved) { + return + } + resolved = true + if (timeout) { + clearTimeout(timeout) + timeout = null + } + clearFollowupTimers() + cleanupHiddenRateLimitPty(term, termDisposables, { kill: true }) + resolve(abortedClaudeUsageResult()) + } + + if (options?.signal) { + if (options.signal.aborted) { + settleAborted() + return + } + options.signal.addEventListener('abort', settleAborted, { once: true }) + termDisposables.push({ + dispose: () => options.signal?.removeEventListener('abort', settleAborted) + }) + } + + timeout = setTimeout(() => { + if (!resolved) { + resolved = true + clearFollowupTimers() + cleanupHiddenRateLimitPty(term, termDisposables, { kill: true }) + // Even on timeout, try to parse whatever we collected + const clean = stripTerminalControlSequences(output) + const { session, weekly, fableWeekly } = parseClaudePtyUsage(clean) + if (session || weekly || fableWeekly) { + resolve({ + provider: 'claude', + session, + weekly, + fableWeekly, + updatedAt: Date.now(), + error: null, + status: 'ok' + }) + } else { + resolve({ + provider: 'claude', + session: null, + weekly: null, + updatedAt: Date.now(), + error: withMacTailscaleDnsHint( + isClaude21UsagePanel(clean) + ? describeClaudeUsageFailure(clean) + : 'PTY timeout — /usage panel did not render', + clean + ), + status: 'error' + }) + } + } + }, PTY_TIMEOUT_MS) + + // Why: the Claude TUI may have scrollable panels or prompts. + // Sending Enter every 0.8s advances through them. + function startEnterPresses(): void { + if (enterInterval) { + return + } + enterInterval = setInterval(() => { + if (!resolved && !stopDetected) { + term.write('\r') + } + }, 800) + } + + function finalize(): void { + if (resolved) { + return + } + resolved = true + if (timeout) { + clearTimeout(timeout) + timeout = null + } + clearFollowupTimers() + cleanupHiddenRateLimitPty(term, termDisposables, { kill: true }) + + const clean = stripTerminalControlSequences(output) + const { session, weekly, fableWeekly } = parseClaudePtyUsage(clean) + + if (!session && !weekly && !fableWeekly) { + resolve({ + provider: 'claude', + session: null, + weekly: null, + updatedAt: Date.now(), + error: withMacTailscaleDnsHint(describeClaudeUsageFailure(clean), clean), + status: 'error' + }) + } else { + resolve({ + provider: 'claude', + session, + weekly, + fableWeekly, + updatedAt: Date.now(), + error: null, + status: 'ok' + }) + } + } + + // Why: wait 2s for the CLI to initialize, then send `/usage\r` + // directly without detecting the prompt character (see comment above). + startupDelayTimer = setTimeout(() => { + startupDelayTimer = null + if (resolved) { + return + } + sentUsage = true + term.write('/usage\r') + startEnterPresses() + }, STARTUP_DELAY_MS) + + const onDataDisposable = term.onData((data) => { + output += data + // Why: prevent memory exhaustion if the CLI process floods output + if (output.length > MAX_OUTPUT_LENGTH) { + output = output.slice(-MAX_OUTPUT_LENGTH) + } + + const cleanChunk = stripTerminalControlSequences(data) + + // Why: the Claude CLI may prompt for first-run setup (trust files, + // workspace directory). Auto-accept so we can reach /usage. + if (TRUST_PROMPT_RE.test(cleanChunk)) { + term.write('y\r') + return + } + + // Why: Claude CLI v2.x may show a command palette when `/usage` is + // entered, listing options like "Show plan usage limits". Auto-confirm + // by sending Enter when these prompts appear. + if (sentUsage && COMMAND_PALETTE_RE.test(cleanChunk)) { + term.write('\r') + } + + // Check if we've hit a stop substring indicating the panel rendered + if (sentUsage && !stopDetected) { + const clean = stripTerminalControlSequences(output) + if (!claude21UsageDetected && isClaude21UsagePanel(clean)) { + claude21UsageDetected = true + if (enterInterval) { + clearInterval(enterInterval) + enterInterval = null + } + // Why: Claude 2.1 may render session stats without subscription + // plan windows. Give async usage loading a grace period, then finish + // with a user-facing unavailable state instead of a false PTY timeout. + claude21UsageSettleTimer = setTimeout(finalize, SETTLE_AFTER_CLAUDE_21_USAGE_MS) + } + for (const sub of CLAUDE_USAGE_STOP_SUBSTRINGS) { + if (clean.includes(sub)) { + stopDetected = true + // Why: 2.0s settle time after detecting the stop substring + // allows the full panel to finish rendering. + stopSettleTimer = setTimeout(finalize, SETTLE_AFTER_STOP_MS) + break + } + } + } + }) + if (onDataDisposable) { + termDisposables.push(onDataDisposable) + } + + const onExitDisposable = term.onExit(() => { + cleanupHiddenRateLimitPty(term, termDisposables, { kill: false }) + clearFollowupTimers() + if (!resolved) { + resolved = true + if (timeout) { + clearTimeout(timeout) + timeout = null + } + const clean = stripTerminalControlSequences(output) + const { session, weekly, fableWeekly } = parseClaudePtyUsage(clean) + resolve({ + provider: 'claude', + session, + weekly, + fableWeekly, + updatedAt: Date.now(), + error: + session || weekly || fableWeekly + ? null + : withMacTailscaleDnsHint('CLI exited before /usage rendered', clean), + status: session || weekly || fableWeekly ? 'ok' : 'error' + }) + } + }) + if (onExitDisposable) { + termDisposables.push(onExitDisposable) + } + }) +} diff --git a/src/main/rate-limits/claude-usage-error-classification.test.ts b/src/main/rate-limits/claude-usage-error-classification.test.ts index 5e60c281674f..9371479cfba5 100644 --- a/src/main/rate-limits/claude-usage-error-classification.test.ts +++ b/src/main/rate-limits/claude-usage-error-classification.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest' -import { classifyClaudeOAuthUsageError } from './claude-usage-error-classification' +import { + classifyClaudeCredentialAbsence, + classifyClaudeOAuthUsageError +} from './claude-usage-error-classification' import { OAuthUsageError } from './claude-oauth-usage-error' describe('classifyClaudeOAuthUsageError', () => { @@ -44,3 +47,38 @@ describe('classifyClaudeOAuthUsageError', () => { }) }) }) + +describe('classifyClaudeCredentialAbsence', () => { + it('classifies refresh-only credentials as repairable', () => { + expect(classifyClaudeCredentialAbsence({ hasRefreshableCredentials: true })).toMatchObject({ + failureKind: 'refreshable-credentials-without-token', + shouldAttemptDelegatedRefresh: true, + shouldAttemptCliFallback: true + }) + }) + + it('classifies Keychain read failures separately from missing credentials', () => { + expect( + classifyClaudeCredentialAbsence({ + hasRefreshableCredentials: false, + keychainUnavailable: true + }) + ).toMatchObject({ + failureKind: 'keychain-unavailable', + shouldAttemptCliFallback: true, + shouldAttemptDelegatedRefresh: false + }) + }) + + it('classifies live Claude ownership as a deferred state', () => { + expect( + classifyClaudeCredentialAbsence({ + hasRefreshableCredentials: true, + managedRefreshDeferredByLivePty: true + }) + ).toMatchObject({ + failureKind: 'deferred-by-live-session', + terminal: true + }) + }) +}) diff --git a/src/main/rate-limits/claude-usage-error-classification.ts b/src/main/rate-limits/claude-usage-error-classification.ts index 440d1155d243..71c88c242bd8 100644 --- a/src/main/rate-limits/claude-usage-error-classification.ts +++ b/src/main/rate-limits/claude-usage-error-classification.ts @@ -39,6 +39,23 @@ export function classifyClaudeOAuthUsageError(error: unknown): ClaudeUsageErrorC return fallbackOnly('unknown') } +export function classifyClaudeCredentialAbsence(input: { + hasRefreshableCredentials: boolean + keychainUnavailable?: boolean + managedRefreshDeferredByLivePty?: boolean +}): ClaudeUsageErrorClassification { + if (input.managedRefreshDeferredByLivePty) { + return terminal('deferred-by-live-session') + } + if (input.keychainUnavailable) { + return fallbackOnly('keychain-unavailable') + } + if (input.hasRefreshableCredentials) { + return recoverableAuth('refreshable-credentials-without-token') + } + return terminal('missing-credentials') +} + function recoverableAuth(failureKind: UsageRateLimitFailureKind): ClaudeUsageErrorClassification { return { failureKind, diff --git a/src/main/rate-limits/claude-usage-refresh-plan.test.ts b/src/main/rate-limits/claude-usage-refresh-plan.test.ts new file mode 100644 index 000000000000..dced9e7815c7 --- /dev/null +++ b/src/main/rate-limits/claude-usage-refresh-plan.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { resolveClaudeUsageRefreshPlan } from './claude-usage-refresh-plan' + +describe('resolveClaudeUsageRefreshPlan', () => { + it('uses OAuth then CLI app auto ordering when CLI fallback is available', () => { + expect(resolveClaudeUsageRefreshPlan({ allowCliFallback: true }).steps).toEqual([ + { source: 'oauth', reason: 'app-auto-preferred-oauth' }, + { source: 'cli', reason: 'app-auto-fallback-cli' } + ]) + }) + + it('keeps web deferred and internal-only for the first parity pass', () => { + expect(resolveClaudeUsageRefreshPlan({ allowCliFallback: true }).webDeferred).toBe(true) + }) + + it('omits CLI fallback for unresolved WSL Claude config', () => { + expect( + resolveClaudeUsageRefreshPlan({ + allowCliFallback: true, + authPreparation: { + configDir: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.claude', + runtime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxConfigDir: null, + envPatch: {}, + stripAuthEnv: true, + provenance: 'wsl:Ubuntu:system' + } + }).steps + ).toEqual([{ source: 'oauth', reason: 'app-auto-preferred-oauth' }]) + }) +}) diff --git a/src/main/rate-limits/claude-usage-refresh-plan.ts b/src/main/rate-limits/claude-usage-refresh-plan.ts new file mode 100644 index 000000000000..06144a15715b --- /dev/null +++ b/src/main/rate-limits/claude-usage-refresh-plan.ts @@ -0,0 +1,47 @@ +import type { UsageRateLimitSource } from '../../shared/rate-limit-types' +import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' + +export type ClaudeUsageRefreshStep = { + source: UsageRateLimitSource + reason: 'app-auto-preferred-oauth' | 'app-auto-fallback-cli' | 'future-web-fallback-deferred' +} + +export type ClaudeUsageRefreshPlanInput = { + authPreparation?: ClaudeRuntimeAuthPreparation + allowCliFallback: boolean +} + +export type ClaudeUsageRefreshPlan = { + steps: ClaudeUsageRefreshStep[] + webDeferred: boolean +} + +export function resolveClaudeUsageRefreshPlan( + input: ClaudeUsageRefreshPlanInput +): ClaudeUsageRefreshPlan { + const steps: ClaudeUsageRefreshStep[] = [ + { + source: 'oauth', + reason: 'app-auto-preferred-oauth' + } + ] + + if (input.allowCliFallback && isCliPlausiblyAvailable(input.authPreparation)) { + steps.push({ + source: 'cli', + reason: 'app-auto-fallback-cli' + }) + } + + return { + steps, + webDeferred: true + } +} + +function isCliPlausiblyAvailable(authPreparation?: ClaudeRuntimeAuthPreparation): boolean { + if (authPreparation?.runtime === 'wsl') { + return Boolean(authPreparation.wslDistro && authPreparation.wslLinuxConfigDir) + } + return true +} diff --git a/src/main/rate-limits/claude-usage-result.ts b/src/main/rate-limits/claude-usage-result.ts index e1863be724d3..a29cb38d4aad 100644 --- a/src/main/rate-limits/claude-usage-result.ts +++ b/src/main/rate-limits/claude-usage-result.ts @@ -1,4 +1,16 @@ -import type { ProviderRateLimits, UsageRateLimitMetadata } from '../../shared/rate-limit-types' +import type { + ProviderRateLimits, + UsageRateLimitFailureKind, + UsageRateLimitMetadata, + UsageRateLimitSource +} from '../../shared/rate-limit-types' +import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' +import type { ClaudeOAuthCredentialReadResult } from './claude-oauth-credentials' +import { OAuthUsageError } from './claude-oauth-usage-error' + +export type ClaudeUsageAttemptState = { + attemptedSources: UsageRateLimitSource[] +} export function abortedClaudeRateLimitResult(): ProviderRateLimits { return { @@ -11,6 +23,30 @@ export function abortedClaudeRateLimitResult(): ProviderRateLimits { } } +export function recordClaudeUsageAttempt( + state: ClaudeUsageAttemptState, + source: UsageRateLimitSource +): UsageRateLimitSource[] { + if (!state.attemptedSources.includes(source)) { + state.attemptedSources.push(source) + } + return state.attemptedSources +} + +export function withClaudeUsageMetadata( + limits: ProviderRateLimits, + metadata: UsageRateLimitMetadata +): ProviderRateLimits { + return { + ...limits, + usageMetadata: { + ...limits.usageMetadata, + ...metadata, + attemptedSources: metadata.attemptedSources ?? limits.usageMetadata?.attemptedSources + } + } +} + export function makeClaudeUsageResult( status: ProviderRateLimits['status'], error: string | null, @@ -27,10 +63,76 @@ export function makeClaudeUsageResult( } } -// Why plain: the profile/host reason is shown on the account row, not as raw text in usage. -export function claudeUsageUnavailable(): ProviderRateLimits { - return makeClaudeUsageResult('error', 'Claude usage is unavailable right now.', { - failureKind: 'usage-unavailable', - attemptedSources: [] +export function metadataForClaudeUsageAttempt(input: { + attemptedSources: UsageRateLimitSource[] + oauthCredentials: ClaudeOAuthCredentialReadResult + authPreparation?: ClaudeRuntimeAuthPreparation + source?: UsageRateLimitSource + failureKind?: UsageRateLimitFailureKind + deferredByLiveClaudeSession?: boolean + retryAtMs?: number +}): UsageRateLimitMetadata { + return { + source: input.source, + attemptedSources: [...input.attemptedSources], + failureKind: input.failureKind, + credentialSource: input.oauthCredentials.source, + authProvenance: input.authPreparation?.provenance ?? 'system', + deferredByLiveClaudeSession: input.deferredByLiveClaudeSession, + retryAtMs: input.retryAtMs + } +} + +export function warnClaudeUsageFetchFailure( + authPreparation: ClaudeRuntimeAuthPreparation | undefined, + credentials: ClaudeOAuthCredentialReadResult, + error: unknown +): void { + console.warn('[claude-rate-limits] Claude usage refresh failed', { + provenance: authPreparation?.provenance ?? 'system', + runtime: authPreparation?.runtime ?? 'host', + wslDistro: authPreparation?.wslDistro ?? null, + hasExplicitClaudeConfigDir: Boolean(authPreparation?.envPatch.CLAUDE_CONFIG_DIR), + credentialSource: credentials.source, + keychainUnavailable: credentials.keychainUnavailable, + hasRefreshableCredentials: credentials.hasRefreshableCredentials, + status: error instanceof OAuthUsageError ? error.status : null, + message: error instanceof Error ? error.message : String(error) }) } + +export function mergeClaudeUsageWindows( + primary: ProviderRateLimits, + supplement: ProviderRateLimits | null +): ProviderRateLimits { + if (!supplement) { + return primary + } + return { + ...primary, + session: primary.session ?? supplement.session, + weekly: primary.weekly ?? supplement.weekly, + fableWeekly: primary.fableWeekly ?? supplement.fableWeekly ?? null, + // Only OAuth reports the extra-usage cap; keep it when the CLI supplements windows. + extraUsage: primary.extraUsage ?? supplement.extraUsage ?? null + } +} + +export function canSupplementClaudeOAuthUsage(input: { + oauthLimits: ProviderRateLimits + authPreparation?: ClaudeRuntimeAuthPreparation + allowUsagePanelSupplement: boolean +}): boolean { + return Boolean( + input.allowUsagePanelSupplement && + !input.authPreparation?.managedRefreshDeferredByLivePty && + !input.oauthLimits.fableWeekly && + (input.oauthLimits.session || input.oauthLimits.weekly) + ) +} + +export function isManagedClaudeAuth( + authPreparation: ClaudeRuntimeAuthPreparation | undefined +): boolean { + return authPreparation?.provenance.startsWith('managed:') === true +} diff --git a/src/main/rate-limits/hidden-pty-cleanup.test.ts b/src/main/rate-limits/hidden-pty-cleanup.test.ts new file mode 100644 index 000000000000..43e8453fa0fd --- /dev/null +++ b/src/main/rate-limits/hidden-pty-cleanup.test.ts @@ -0,0 +1,135 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + cleanupHiddenRateLimitPty, + getActiveHiddenRateLimitPtyCount, + registerHiddenRateLimitPty +} from './hidden-pty-cleanup' + +function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { + configurable: true, + value: platform + }) +} + +describe('cleanupHiddenRateLimitPty', () => { + const originalPlatform = process.platform + + afterEach(() => { + setPlatform(originalPlatform) + vi.clearAllMocks() + }) + + it('disposes listeners, kills the child, then destroys the PTY fd on POSIX', () => { + setPlatform('darwin') + const dataDisposable = { dispose: vi.fn() } + const exitDisposable = { dispose: vi.fn() } + const killMock = vi.fn() + const term = { + kill: killMock, + destroy: vi.fn() + } + + cleanupHiddenRateLimitPty(term, [dataDisposable, exitDisposable], { kill: true }) + + expect(dataDisposable.dispose.mock.invocationCallOrder[0]).toBeLessThan( + killMock.mock.invocationCallOrder[0] + ) + expect(exitDisposable.dispose.mock.invocationCallOrder[0]).toBeLessThan( + killMock.mock.invocationCallOrder[0] + ) + expect(killMock.mock.invocationCallOrder[0]).toBeLessThan( + term.destroy.mock.invocationCallOrder[0] + ) + }) + + it('releases the PTY fd without killing again after natural exit', () => { + setPlatform('darwin') + const killMock = vi.fn() + const term = { + kill: killMock, + destroy: vi.fn() + } + + cleanupHiddenRateLimitPty(term, [], { kill: false }) + + expect(killMock).not.toHaveBeenCalled() + expect(term.destroy).toHaveBeenCalledTimes(1) + }) + + it('neutralizes POSIX destroy-time SIGHUP after the intentional kill', () => { + setPlatform('linux') + const killMock = vi.fn() + const term = { + kill: killMock, + destroy: vi.fn(() => { + term.kill('SIGHUP') + }) + } + + cleanupHiddenRateLimitPty(term, [], { kill: true }) + + expect(killMock).toHaveBeenCalledTimes(1) + expect(killMock).toHaveBeenCalledWith() + }) + + it('does not destroy after an intentional Windows kill because destroy kills again', () => { + setPlatform('win32') + const term = { + kill: vi.fn(), + destroy: vi.fn(() => { + term.kill() + }) + } + + cleanupHiddenRateLimitPty(term, [], { kill: true }) + + expect(term.kill).toHaveBeenCalledTimes(1) + expect(term.destroy).not.toHaveBeenCalled() + }) + + it('destroys a Windows PTY after natural exit so ConPTY cleanup still runs', () => { + setPlatform('win32') + const term = { + kill: vi.fn(), + destroy: vi.fn(() => { + term.kill() + }) + } + + cleanupHiddenRateLimitPty(term, [], { kill: false }) + + expect(term.kill).toHaveBeenCalledTimes(1) + expect(term.destroy).toHaveBeenCalledTimes(1) + }) + + it('removes registered hidden PTYs when cleanup kills them', () => { + setPlatform('darwin') + const term = { + kill: vi.fn(), + destroy: vi.fn() + } + const registration = registerHiddenRateLimitPty(term) + + expect(getActiveHiddenRateLimitPtyCount()).toBe(1) + + cleanupHiddenRateLimitPty(term, [registration], { kill: true }) + + expect(getActiveHiddenRateLimitPtyCount()).toBe(0) + }) + + it('removes registered hidden PTYs after natural exit cleanup', () => { + setPlatform('darwin') + const term = { + kill: vi.fn(), + destroy: vi.fn() + } + const registration = registerHiddenRateLimitPty(term) + + expect(getActiveHiddenRateLimitPtyCount()).toBe(1) + + cleanupHiddenRateLimitPty(term, [registration], { kill: false }) + + expect(getActiveHiddenRateLimitPtyCount()).toBe(0) + }) +}) diff --git a/src/main/rate-limits/hidden-pty-cleanup.ts b/src/main/rate-limits/hidden-pty-cleanup.ts new file mode 100644 index 000000000000..068b937ee844 --- /dev/null +++ b/src/main/rate-limits/hidden-pty-cleanup.ts @@ -0,0 +1,58 @@ +type HiddenPty = { + kill: (signal?: string) => void + destroy?: () => void +} + +type Disposable = { + dispose: () => void +} + +const activeHiddenRateLimitPtys = new Set() + +export function registerHiddenRateLimitPty(term: HiddenPty): Disposable { + activeHiddenRateLimitPtys.add(term) + return { + dispose: () => { + activeHiddenRateLimitPtys.delete(term) + } + } +} + +export function getActiveHiddenRateLimitPtyCount(): number { + return activeHiddenRateLimitPtys.size +} + +export function cleanupHiddenRateLimitPty( + term: HiddenPty, + disposables: Disposable[], + options: { kill: boolean } +): void { + for (const disposable of disposables.splice(0)) { + disposable.dispose() + } + + if (options.kill) { + try { + term.kill() + } catch { + /* already exited */ + } + + // Why: node-pty WindowsTerminal.destroy() calls kill() again, which can + // close the same ConPTY handle twice after an intentional termination. + if (process.platform === 'win32') { + return + } + } + + // Why: node-pty destroy releases the master PTY fd; on POSIX, neutralize + // the post-close SIGHUP hook after exit/kill to avoid pid reuse. + if (process.platform !== 'win32') { + term.kill = () => {} + } + try { + term.destroy?.() + } catch { + /* already torn down */ + } +} diff --git a/src/main/rate-limits/service-account-target-selection.test.ts b/src/main/rate-limits/service-account-target-selection.test.ts index 4366417dc3e7..27e93208c1a1 100644 --- a/src/main/rate-limits/service-account-target-selection.test.ts +++ b/src/main/rate-limits/service-account-target-selection.test.ts @@ -551,7 +551,9 @@ describe('RateLimitService', () => { it('does not cache host Claude usage under an outgoing WSL account', async () => { const service = new RateLimitService() - service.setInactiveClaudeAccountsResolver(() => [{ id: 'wsl-account-1' }]) + service.setInactiveClaudeAccountsResolver(() => [ + { id: 'wsl-account-1', managedAuthPath: '/tmp/account-1/auth' } + ]) service.setClaudeAuthPreparationResolver(async (target) => ({ configDir: target?.runtime === 'wsl' diff --git a/src/main/rate-limits/service-inactive-account-previews.test.ts b/src/main/rate-limits/service-inactive-account-previews.test.ts index 469b51e4cf1c..cae71c569e71 100644 --- a/src/main/rate-limits/service-inactive-account-previews.test.ts +++ b/src/main/rate-limits/service-inactive-account-previews.test.ts @@ -78,7 +78,7 @@ describe('RateLimitService', () => { it('aborts inactive Claude preview fetches on stop', async () => { const service = new RateLimitService() - const account = { id: 'account-1' } + const account = { id: 'account-1', managedAuthPath: '/tmp/account-1/auth' } const capturedSignals: { claude?: AbortSignal } = {} service.setInactiveClaudeAccountsResolver(() => [account]) vi.mocked(fetchManagedAccountUsage).mockImplementation( @@ -212,7 +212,7 @@ describe('RateLimitService', () => { it('allows usage-panel Fable supplements for inactive Claude account previews', async () => { const service = new RateLimitService() - const account = { id: 'account-1' } + const account = { id: 'account-1', managedAuthPath: '/tmp/account-1/auth' } service.setInactiveClaudeAccountsResolver(() => [account]) vi.mocked(fetchManagedAccountUsage).mockResolvedValueOnce(okProvider('claude', 33, Date.now())) @@ -230,7 +230,9 @@ describe('RateLimitService', () => { it('does not start overlapping inactive Claude preview fetches', async () => { const service = new RateLimitService() const accountFetch = deferred() - service.setInactiveClaudeAccountsResolver(() => [{ id: 'account-1' }]) + service.setInactiveClaudeAccountsResolver(() => [ + { id: 'account-1', managedAuthPath: '/tmp/account-1/auth' } + ]) vi.mocked(fetchManagedAccountUsage).mockReturnValueOnce(accountFetch.promise) const firstFetch = service.fetchInactiveClaudeAccountsOnOpen() @@ -399,7 +401,7 @@ describe('RateLimitService', () => { it('does not recache an inactive Claude account removed during fetch-on-open', async () => { const service = new RateLimitService() const accountFetch = deferred() - let inactiveAccounts = [{ id: 'account-1' }] + let inactiveAccounts = [{ id: 'account-1', managedAuthPath: '/tmp/account-1/auth' }] service.setInactiveClaudeAccountsResolver(() => inactiveAccounts) service.setClaudeAuthPreparationResolver(async () => ({ configDir: '/tmp/.claude', @@ -418,7 +420,7 @@ describe('RateLimitService', () => { ]) service.evictInactiveClaudeCache('account-1') - inactiveAccounts = [{ id: 'account-1' }] + inactiveAccounts = [{ id: 'account-1', managedAuthPath: '/tmp/account-1/auth' }] await service.refreshForClaudeAccountChange('account-1') expect(service.getState().inactiveClaudeAccounts[0]?.accountId).toBe('account-1') @@ -433,7 +435,9 @@ describe('RateLimitService', () => { it('does not overwrite inactive Claude cache from a stale same-id fetch', async () => { const service = new RateLimitService() const accountFetch = deferred() - service.setInactiveClaudeAccountsResolver(() => [{ id: 'account-1' }]) + service.setInactiveClaudeAccountsResolver(() => [ + { id: 'account-1', managedAuthPath: '/tmp/account-1/auth' } + ]) service.setClaudeAuthPreparationResolver(async () => ({ configDir: '/tmp/.claude', envPatch: {}, diff --git a/src/main/rate-limits/service-live-claude-usage.test.ts b/src/main/rate-limits/service-live-claude-usage.test.ts index f6cbc7006200..8c35ab77acc7 100644 --- a/src/main/rate-limits/service-live-claude-usage.test.ts +++ b/src/main/rate-limits/service-live-claude-usage.test.ts @@ -14,12 +14,6 @@ import { resetRateLimitProviderMocks } from './rate-limit-service-test-harness' -const profileRouter = vi.hoisted((): { userConfigDir?: string } => ({})) -vi.mock('../claude-accounts/claude-profile-installed-router', () => ({ - getClaudeProfileRouter: () => - profileRouter.userConfigDir ? { userConfigDir: () => profileRouter.userConfigDir } : undefined -})) - vi.mock('./claude-fetcher', () => ({ fetchClaudeRateLimits: vi.fn(), fetchManagedAccountUsage: vi.fn() @@ -286,60 +280,6 @@ describe('RateLimitService', () => { } }) - it('attributes statusline posts to System Default when it inherits CLAUDE_CONFIG_DIR', async () => { - vi.useFakeTimers() - try { - vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 18)) - mockFreshBackgroundProviderFetches() - vi.stubEnv('CLAUDE_CONFIG_DIR', '/own/claude-config') - const service = new RateLimitService() - service.setClaudeAuthPreparationResolver(async () => ({ - configDir: '/own/claude-config', - envPatch: {}, - stripAuthEnv: false, - provenance: 'system' - })) - await service.refresh() - service.ingestLiveClaudeRateLimits({ - configDir: '/own/claude-config', - fiveHour: { used_percentage: 44 }, - sevenDay: null - }) - expect(service.getState().claude?.session?.usedPercent).toBe(44) - } finally { - vi.unstubAllEnvs() - vi.useRealTimers() - } - }) - - it("attributes System Default posts to the login shell's CLAUDE_CONFIG_DIR a Dock launch lacks", async () => { - vi.useFakeTimers() - try { - vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 18)) - mockFreshBackgroundProviderFetches() - vi.stubEnv('CLAUDE_CONFIG_DIR', '') - profileRouter.userConfigDir = '/shell/claude-config' - const service = new RateLimitService() - service.setClaudeAuthPreparationResolver(async () => ({ - configDir: '/shell/claude-config', - envPatch: {}, - stripAuthEnv: false, - provenance: 'system' - })) - await service.refresh() - service.ingestLiveClaudeRateLimits({ - configDir: '/shell/claude-config', - fiveHour: { used_percentage: 44 }, - sevenDay: null - }) - expect(service.getState().claude?.session?.usedPercent).toBe(44) - } finally { - profileRouter.userConfigDir = undefined - vi.unstubAllEnvs() - vi.useRealTimers() - } - }) - it('keeps the other window when a statusline post carries only one', async () => { vi.useFakeTimers() try { @@ -537,4 +477,51 @@ describe('RateLimitService', () => { vi.useRealTimers() } }) + + describe('refreshAfterClaudeLivePtysDrained', () => { + function deferredClaudeResult(): ProviderRateLimits { + return { + ...errorProvider('claude', 'Waiting for Claude session'), + usageMetadata: { + failureKind: 'deferred-by-live-session', + deferredByLiveClaudeSession: true + } + } + } + + it('refetches Claude usage when the current result was deferred by a live session', async () => { + const service = new RateLimitService() + vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(deferredClaudeResult()) + await service.refresh() + expect(service.getState().claude?.usageMetadata?.deferredByLiveClaudeSession).toBe(true) + vi.mocked(fetchClaudeRateLimits).mockClear() + vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now())) + + await service.refreshAfterClaudeLivePtysDrained() + + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1) + expect(service.getState().claude?.status).toBe('ok') + }) + + it('does not refetch when the current Claude result was not deferred', async () => { + const service = new RateLimitService() + vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce( + errorProvider('claude', 'Token expired') + ) + await service.refresh() + vi.mocked(fetchClaudeRateLimits).mockClear() + + await service.refreshAfterClaudeLivePtysDrained() + + expect(fetchClaudeRateLimits).not.toHaveBeenCalled() + }) + + it('does not refetch when there is no Claude state yet', async () => { + const service = new RateLimitService() + + await service.refreshAfterClaudeLivePtysDrained() + + expect(fetchClaudeRateLimits).not.toHaveBeenCalled() + }) + }) }) diff --git a/src/main/rate-limits/service/service-account-refresh.ts b/src/main/rate-limits/service/service-account-refresh.ts index 944c09be2479..6aeacd99583b 100644 --- a/src/main/rate-limits/service/service-account-refresh.ts +++ b/src/main/rate-limits/service/service-account-refresh.ts @@ -191,4 +191,15 @@ export abstract class RateLimitServiceAccountRefresh extends RateLimitServiceIna await this.fetchClaudeOnly({ force: true }) return this.getState() } + + async refreshAfterClaudeLivePtysDrained(): Promise { + // Why: "Waiting for Claude session" can only recover once no live claude + // owns the credentials. Refetch on the last PTY exit instead of leaving + // the stale terminal error up until the failure backoff elapses. + if (!this.state.claude?.usageMetadata?.deferredByLiveClaudeSession) { + return + } + this.activeFailureStreakByProvider.claude = 0 + await this.fetchClaudeOnly({ force: true }) + } } diff --git a/src/main/rate-limits/service/service-fetch-policy.ts b/src/main/rate-limits/service/service-fetch-policy.ts index 907f0512f007..cfd4a6e9d58e 100644 --- a/src/main/rate-limits/service/service-fetch-policy.ts +++ b/src/main/rate-limits/service/service-fetch-policy.ts @@ -10,8 +10,6 @@ import { type ProviderRateLimits } from './service-types' import { mapClaudeUsageWindow } from '../claude-usage-window' -import { readUserClaudeConfigDir } from '../../claude-accounts/claude-profile-paths' -import { getClaudeProfileRouter } from '../../claude-accounts/claude-profile-installed-router' export abstract class RateLimitServiceFetchPolicy extends RateLimitServiceFetchTargets { protected getMiniMaxCredentialError(message: string): ProviderRateLimits { @@ -93,12 +91,7 @@ export abstract class RateLimitServiceFetchPolicy extends RateLimitServiceFetchT return } this.lastClaudeAuthSnapshot = { - // Why the user's own folder: System default passes it through rather than injecting it. - configDir: normalizeClaudeConfigDir( - authPreparation?.envPatch.CLAUDE_CONFIG_DIR ?? - getClaudeProfileRouter()?.userConfigDir() ?? - readUserClaudeConfigDir(process.env) - ), + configDir: normalizeClaudeConfigDir(authPreparation?.envPatch.CLAUDE_CONFIG_DIR), provenance: authPreparation?.provenance ?? 'system' } } diff --git a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts index c7e3c1d79a12..7fd0dec42ad8 100644 --- a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts +++ b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts @@ -41,7 +41,8 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR agent, worktreeSelector, location: await this.resolveStructuredAgentSessionLocation(worktreeSelector), - runtime: this + runtime: this, + getSettings: () => this.requireStore().getSettings() }) } diff --git a/src/main/runtime/orca-runtime-get-worktree-ps.ts b/src/main/runtime/orca-runtime-get-worktree-ps.ts index 4fb79e83cd4b..1698676a3cb1 100644 --- a/src/main/runtime/orca-runtime-get-worktree-ps.ts +++ b/src/main/runtime/orca-runtime-get-worktree-ps.ts @@ -260,6 +260,8 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStartTuiIdleVis resolveAgentCommandSettings: () => this.requireStore().getSettings(), resolveCodexAccountKind: (home) => resolveStructuredCodexAccountKind(home, this.requireStore().getSettings()), + // Same gate and same settings as agentSession.createSupport, re-read on every acquisition. + getClaudeManagedAccountGateSettings: () => this.requireStore().getSettings(), resolveAgentAccountHome: (agent) => this.resolveStructuredAgentAccountHome(agent), ...(this.prepareCodexCatalogProbeHomeFn ? { prepareCodexCatalogProbeHome: this.prepareCodexCatalogProbeHomeFn } diff --git a/src/main/runtime/orca-runtime-structured-claude-account-gate.test.ts b/src/main/runtime/orca-runtime-structured-claude-account-gate.test.ts new file mode 100644 index 000000000000..64b451e9ea90 --- /dev/null +++ b/src/main/runtime/orca-runtime-structured-claude-account-gate.test.ts @@ -0,0 +1,112 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' +import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' +import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import type { ClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support' + +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') } +})) + +function managedAccount(id: string, managedAuthRuntime: 'host' | 'wsl') { + return { + id, + email: `${id}@example.com`, + managedAuthPath: `/managed/${id}`, + managedAuthRuntime, + authMethod: 'subscription-oauth' as const, + createdAt: 0, + updatedAt: 0, + lastAuthenticatedAt: 0 + } +} + +const WSL_ONLY: ClaudeManagedAccountGateSettings = { + claudeManagedAccounts: [managedAccount('wsl-1', 'wsl')], + activeClaudeManagedAccountId: null, + activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'wsl-1' } } +} + +/** Registered Claude accounts with none selected: ambient auth, and the UI names no host identity, + * so this must reach structured rather than silently falling back to a terminal session. */ +const ACCOUNTS_PRESENT_NONE_ACTIVE: ClaudeManagedAccountGateSettings = { + claudeManagedAccounts: [managedAccount('host-1', 'host'), managedAccount('host-2', 'host')], + activeClaudeManagedAccountId: null, + activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: {} } +} + +const HOST_SELECTED: ClaudeManagedAccountGateSettings = { + claudeManagedAccounts: [managedAccount('host-1', 'host')], + activeClaudeManagedAccountId: 'host-1', + activeClaudeManagedAccountIdsByRuntime: { host: 'host-1', wsl: {} } +} + +function runtimeWithAccounts(claude: ClaudeManagedAccountGateSettings | null): OrcaRuntimeService { + // No store at all is the unreadable-settings case the gate must fail closed on. + const runtime = claude + ? new OrcaRuntimeService({ getSettings: () => claude } as never) + : new OrcaRuntimeService() + const internal = runtime as unknown as { + resolveStructuredAgentSessionLocation: (selector: string) => Promise + ensureStructuredAgentSessionHost: () => Promise + } + internal.resolveStructuredAgentSessionLocation = vi.fn(async () => ({ + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' as const + })) + // The adapter's own location answer is irrelevant here; pin it supported so only the account + // gate can refuse. + internal.ensureStructuredAgentSessionHost = vi.fn(async () => {}) + setStructuredAgentSessionHost({ + supportsCreate: () => true + } as unknown as StructuredAgentSessionHost) + return runtime +} + +afterEach(() => { + setStructuredAgentSessionHost(null) +}) + +describe('structured Claude managed-account gate', () => { + it('refuses Claude under a WSL-only managed account', async () => { + const runtime = runtimeWithAccounts(WSL_ONLY) + await expect( + runtime.getStructuredAgentSessionCreateSupport('id:workspace-1', 'claude') + ).resolves.toMatchObject({ supported: false }) + }) + + it('supports Claude when accounts are registered but none is selected', async () => { + const runtime = runtimeWithAccounts(ACCOUNTS_PRESENT_NONE_ACTIVE) + await expect( + runtime.getStructuredAgentSessionCreateSupport('id:workspace-1', 'claude') + ).resolves.toMatchObject({ supported: true }) + }) + + it('still supports Claude under a selected host managed account', async () => { + const runtime = runtimeWithAccounts(HOST_SELECTED) + await expect( + runtime.getStructuredAgentSessionCreateSupport('id:workspace-1', 'claude') + ).resolves.toMatchObject({ supported: true }) + }) + + it('fails closed for Claude when the account runtime cannot be determined', async () => { + const runtime = runtimeWithAccounts(null) + await expect( + runtime.getStructuredAgentSessionCreateSupport('id:workspace-1', 'claude') + ).resolves.toMatchObject({ supported: false }) + }) + + /** The gate is Claude's alone: Codex resolves its account separately and this lane must not + * change any Codex answer. */ + it('leaves Codex supported under the same WSL-only Claude account', async () => { + const runtime = runtimeWithAccounts(WSL_ONLY) + await expect( + runtime.getStructuredAgentSessionCreateSupport('id:workspace-1', 'codex') + ).resolves.toMatchObject({ supported: true }) + }) +}) diff --git a/src/main/runtime/orca-runtime-structured-claude-gate-wiring.test.ts b/src/main/runtime/orca-runtime-structured-claude-gate-wiring.test.ts new file mode 100644 index 000000000000..0d9b12f7c528 --- /dev/null +++ b/src/main/runtime/orca-runtime-structured-claude-gate-wiring.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it, vi } from 'vitest' + +const installed = vi.hoisted(() => ({ deps: null as Record | null })) + +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') } +})) + +vi.mock('./structured-agent-session-runtime', () => ({ + ensureStructuredAgentSessionHost: vi.fn(async (deps: Record) => { + installed.deps = deps + }) +})) + +import { OrcaRuntimeService } from './orca-runtime' +import { + readClaudeManagedAccountGateSettings, + type ClaudeManagedAccountGateSettings +} from '../native-chat/claude-structured-managed-account-support' + +const SETTINGS = { + claudeManagedAccounts: [], + activeClaudeManagedAccountId: null, + agentDefaultEnv: {}, + agentDefaultArgs: {} +} as unknown as ClaudeManagedAccountGateSettings + +function gateSettingsGetter(): (() => ClaudeManagedAccountGateSettings) | undefined { + const deps: Record = installed.deps ?? {} + const get = deps['getClaudeManagedAccountGateSettings'] + return typeof get === 'function' ? (get as () => ClaudeManagedAccountGateSettings) : undefined +} + +/** The runtime class this wiring lives on does not typecheck its own `this` calls, so a broken or + * missing gate hookup compiles clean. Pin it behaviourally instead. */ +describe('structured Claude managed-account gate wiring', () => { + it('hands the host a gate reader that resolves the live settings', async () => { + installed.deps = null + const runtime = new OrcaRuntimeService({ getSettings: () => SETTINGS } as never) + + await runtime.ensureStructuredAgentSessionHost() + + const get = gateSettingsGetter() + expect(typeof get).toBe('function') + expect(get?.()).toBe(SETTINGS) + }) + + /** The installer composes this getter with the fail-closed reader, which is the shape the + * resolver consumes; pin that composition end to end. */ + it('composes into a null answer instead of throwing when settings cannot be read', async () => { + installed.deps = null + const runtime = new OrcaRuntimeService() + + await runtime.ensureStructuredAgentSessionHost() + + const get = gateSettingsGetter() + expect(typeof get).toBe('function') + expect(() => get?.()).toThrow() + expect(readClaudeManagedAccountGateSettings(get!)).toBeNull() + }) +}) diff --git a/src/main/runtime/rpc/methods/accounts.test.ts b/src/main/runtime/rpc/methods/accounts.test.ts index c10d70b7ad99..b8473d00e014 100644 --- a/src/main/runtime/rpc/methods/accounts.test.ts +++ b/src/main/runtime/rpc/methods/accounts.test.ts @@ -15,6 +15,20 @@ function method(name: string) { describe('account RPC methods', () => { it.each([ + { + methodName: 'accounts.addClaudeFromConfigDir', + params: { + configDir: join(tmpdir(), 'claude-login'), + previousLegacyCredentialsSha256: 'a'.repeat(64) + }, + runtimeMethod: 'addClaudeAccountFromConfigDir', + expectedSource: join(tmpdir(), 'claude-login'), + expectedOptions: { + runtime: undefined, + wslDistro: null, + previousLegacyCredentialsSha256: 'a'.repeat(64) + } + }, { methodName: 'accounts.addCodexFromHome', params: { sourceHome: join(tmpdir(), 'codex-login') }, @@ -36,37 +50,29 @@ describe('account RPC methods', () => { }) it.each([ - ['accounts.addCodexFromHome', { sourceHome: join(tmpdir(), 'codex-login') }, /only available/], + ['accounts.addClaudeFromConfigDir', { configDir: join(tmpdir(), 'claude-login') }], + ['accounts.addCodexFromHome', { sourceHome: join(tmpdir(), 'codex-login') }], [ 'accounts.addDataFromHome', - { provider: 'opencode', sourceDataHome: join(tmpdir(), 'login'), label: 'Work' }, - /only available/ - ], - ['accounts.beginClaudeSignIn', {}, /Sign in on the Orca execution host/], - ['accounts.finishClaudeSignIn', { accountId: 'a' }, /Sign in on the Orca execution host/] - ] as const)('rejects paired-device calls to %s', async (methodName, params, refusal) => { + { provider: 'opencode', sourceDataHome: join(tmpdir(), 'login'), label: 'Work' } + ] + ])('rejects paired-device calls to %s', async (methodName, params) => { const runtime = { - addCodexAccountFromHome: vi.fn(), - addDataAccountFromHome: vi.fn(), - beginClaudeSignIn: vi.fn(), - finishClaudeSignIn: vi.fn() - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: refused calls never reach the runtime; only these stubs are asserted. - const runtimeService = runtime as unknown as OrcaRuntimeService + addClaudeAccountFromConfigDir: vi.fn(), + addCodexAccountFromHome: vi.fn() + } as unknown as OrcaRuntimeService const addMethod = method(methodName) if (isStreamingMethod(addMethod)) { throw new Error(`${methodName} must be a request method`) } for (const clientKind of ['mobile', 'runtime'] as const) { - await expect( - addMethod.handler(params, { runtime: runtimeService, clientKind }) - ).rejects.toThrow(refusal) + await expect(addMethod.handler(params, { runtime, clientKind })).rejects.toThrow( + /only available on the Orca host runtime/ + ) } + expect(runtime.addClaudeAccountFromConfigDir).not.toHaveBeenCalled() expect(runtime.addCodexAccountFromHome).not.toHaveBeenCalled() - expect(runtime.addDataAccountFromHome).not.toHaveBeenCalled() - expect(runtime.beginClaudeSignIn).not.toHaveBeenCalled() - expect(runtime.finishClaudeSignIn).not.toHaveBeenCalled() }) it('keeps explicit account-list refreshes on the forced refresh lane', async () => { @@ -221,14 +227,3 @@ describe('account RPC methods', () => { await running }) }) - -it('rejects the retired Claude import even for a local socket, before touching its supplied path', async () => { - const old = method('accounts.addClaudeFromConfigDir') - if (isStreamingMethod(old)) { - throw new Error('Unexpected stream') - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Rejection happens before the handler accesses its context. - await expect(old.handler({ configDir: '/never-read' }, {} as never)).rejects.toThrow( - 'Update the Orca CLI to add accounts' - ) -}) diff --git a/src/main/runtime/rpc/methods/accounts.ts b/src/main/runtime/rpc/methods/accounts.ts index 7c97c36ac405..10c39aacd2f8 100644 --- a/src/main/runtime/rpc/methods/accounts.ts +++ b/src/main/runtime/rpc/methods/accounts.ts @@ -4,8 +4,6 @@ import { SelectDataAccountParams, RemoveDataAccountParams, AccountsUnsubscribeParams, - BeginClaudeSignInParams, - FinishClaudeSignInParams, AddClaudeFromConfigDirParams, AddCodexFromHomeParams, ConsumeCodexResetCreditParams, @@ -21,42 +19,15 @@ import { // registerSubscriptionCleanup's existing-key eviction path. let accountsSubscriptionSeq = 0 +// Why: bridges the desktop ClaudeAccountService / CodexAccountService / +// RateLimitService into the WebSocket / local-socket RPC. Read + switch + +// remove for all clients; interactive add/re-auth flows spawn `claude login` +// / `codex login` PTYs that need a desktop browser, so they intentionally +// remain desktop-only. `accounts.addClaudeFromConfigDir` is the exception: it +// captures an already-authenticated CLAUDE_CONFIG_DIR (no PTY) so the local +// `orca account add` CLI can register accounts on a headless host; it is gated +// to the local runtime connection, never a mobile device token. See #1438. export const ACCOUNT_METHODS = [ - // Why local only: sign-in runs `claude auth login` in a terminal on the execution host. - defineMethod({ - name: 'accounts.beginClaudeSignIn', - permission: 'accounts-admin', - params: BeginClaudeSignInParams, - handler: async (params, { runtime, clientKind }) => { - if (clientKind !== undefined) { - throw new Error('Sign in on the Orca execution host.') - } - return runtime.beginClaudeSignIn(params) - } - }), - defineMethod({ - name: 'accounts.finishClaudeSignIn', - permission: 'accounts-admin', - params: FinishClaudeSignInParams, - handler: async (params, { runtime, clientKind }) => { - if (clientKind !== undefined) { - throw new Error('Sign in on the Orca execution host.') - } - return runtime.finishClaudeSignIn(params) - } - }), - defineMethod({ - name: 'accounts.cancelClaudeSignIn', - permission: 'accounts-admin', - params: FinishClaudeSignInParams, - handler: async (params, { runtime, clientKind }) => { - if (clientKind !== undefined) { - throw new Error('Sign in on the Orca execution host.') - } - await runtime.cancelClaudeSignIn(params) - return {} - } - }), defineMethod({ name: 'accounts.listData', permission: 'workspace', @@ -147,10 +118,17 @@ export const ACCOUNT_METHODS = [ name: 'accounts.addClaudeFromConfigDir', permission: 'accounts-admin', params: AddClaudeFromConfigDirParams, - handler: async () => { - throw new Error( - 'Update the Orca CLI to add accounts. Importing Claude logins is no longer supported.' - ) + handler: async (params, { runtime, clientKind }) => { + // Why: capturing a host filesystem path is local-socket-only; paired + // mobile and remote-runtime tokens must never read host credential paths. + if (clientKind !== undefined) { + throw new Error('Adding Claude accounts is only available on the Orca host runtime.') + } + return runtime.addClaudeAccountFromConfigDir(params.configDir, { + runtime: params.runtime, + wslDistro: params.wslDistro ?? null, + previousLegacyCredentialsSha256: params.previousLegacyCredentialsSha256 + }) } }), defineMethod({ diff --git a/src/main/runtime/runtime-account-controller.ts b/src/main/runtime/runtime-account-controller.ts index 248adf2c18f1..c824301e5a5f 100644 --- a/src/main/runtime/runtime-account-controller.ts +++ b/src/main/runtime/runtime-account-controller.ts @@ -2,9 +2,7 @@ import type { ClaudeAccountService } from '../claude-accounts/service' import { hasAppEnvironment } from '../../shared/app-environment' import { getManagedDataAccountService } from '../managed-data-accounts/service' import type { - ClaudeAccountSignIn, ClaudeRateLimitAccountsState, - ClaudeSignInRequest, CodexRateLimitAccountsState, ManagedDataAccountProvider, ManagedDataAccountsState @@ -166,18 +164,15 @@ export class RuntimeAccountController { return this.requireServices().claudeAccounts.removeAccount(accountId) } - beginClaudeSignIn(request: ClaudeSignInRequest): Promise { - return this.requireServices().claudeAccounts.beginSignIn(request) - } - - finishClaudeSignIn( - signIn: Omit + addClaudeFromConfigDir( + configDir: string, + options?: { + runtime?: 'host' | 'wsl' + wslDistro?: string | null + previousLegacyCredentialsSha256?: string | null + } ): Promise { - return this.requireServices().claudeAccounts.finishSignIn(signIn) - } - - cancelClaudeSignIn(signIn: Omit): Promise { - return this.requireServices().claudeAccounts.cancelSignIn(signIn) + return this.requireServices().claudeAccounts.addAccountFromConfigDir(configDir, options) } removeCodex(accountId: string): Promise { diff --git a/src/main/runtime/runtime-service-command-surface.ts b/src/main/runtime/runtime-service-command-surface.ts index 966976e1a946..c62fc799dd40 100644 --- a/src/main/runtime/runtime-service-command-surface.ts +++ b/src/main/runtime/runtime-service-command-surface.ts @@ -54,9 +54,7 @@ export type RuntimeServiceCommandSurface = { selectCodexAccountForTarget: RuntimeAccountController['selectCodexForTarget'] consumeCodexRateLimitResetCredit: RuntimeAccountController['consumeCodexResetCredit'] removeClaudeAccount: RuntimeAccountController['removeClaude'] - beginClaudeSignIn: RuntimeAccountController['beginClaudeSignIn'] - finishClaudeSignIn: RuntimeAccountController['finishClaudeSignIn'] - cancelClaudeSignIn: RuntimeAccountController['cancelClaudeSignIn'] + addClaudeAccountFromConfigDir: RuntimeAccountController['addClaudeFromConfigDir'] removeCodexAccount: RuntimeAccountController['removeCodex'] addCodexAccountFromHome: RuntimeAccountController['addCodexFromHome'] addDataAccountFromHome: RuntimeAccountController['addDataFromHome'] @@ -156,9 +154,7 @@ export function installRuntimeServiceCommandSurface( selectCodexAccountForTarget: accounts.selectCodexForTarget.bind(accounts), consumeCodexRateLimitResetCredit: accounts.consumeCodexResetCredit.bind(accounts), removeClaudeAccount: accounts.removeClaude.bind(accounts), - beginClaudeSignIn: accounts.beginClaudeSignIn.bind(accounts), - finishClaudeSignIn: accounts.finishClaudeSignIn.bind(accounts), - cancelClaudeSignIn: accounts.cancelClaudeSignIn.bind(accounts), + addClaudeAccountFromConfigDir: accounts.addClaudeFromConfigDir.bind(accounts), removeCodexAccount: accounts.removeCodex.bind(accounts), addCodexAccountFromHome: accounts.addCodexFromHome.bind(accounts), addDataAccountFromHome: accounts.addDataFromHome.bind(accounts), diff --git a/src/main/runtime/structured-agent-create-support-opencode-rig.test.ts b/src/main/runtime/structured-agent-create-support-opencode-rig.test.ts index 97f258120726..aecff69f1785 100644 --- a/src/main/runtime/structured-agent-create-support-opencode-rig.test.ts +++ b/src/main/runtime/structured-agent-create-support-opencode-rig.test.ts @@ -66,7 +66,8 @@ function createSupport(settings: ReturnType) { runtime: { requireStore: () => ({ getSettings: () => settings }), resolveRuntimeFileTarget: async () => ({ worktree: { path: join(root, 'proj') } }) - } + }, + getSettings: () => ({ claudeManagedAccounts: [], activeClaudeManagedAccountId: null }) }) } diff --git a/src/main/runtime/structured-agent-launch-support.ts b/src/main/runtime/structured-agent-launch-support.ts index 5f81b2c91c5b..14a60f024d13 100644 --- a/src/main/runtime/structured-agent-launch-support.ts +++ b/src/main/runtime/structured-agent-launch-support.ts @@ -4,6 +4,7 @@ import type { GlobalSettings } from '../../shared/global-settings-types' import { nativeChatShellEnvironmentPolicy } from '../../shared/native-chat-shell-environment' import { isTuiAgent } from '../../shared/tui-agent-config' import { resolveTuiAgentLaunchEnv } from '../../shared/tui-agent-launch-defaults' +import type { ClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support' import { resolveStructuredAgentSessionCreateSupport, warnStructuredAgentSessionCreateUnsupported, @@ -59,12 +60,13 @@ export async function structuredAgentSupportsLaunch( /** `agentSession.createSupport` on this host: every refusal it can know before spawning. The * agent's location rule and installed-agent check from its registration, without installing the - * host. A refusal logs which check said no. */ + * host, then Claude's managed-account gate. A refusal logs which check said no. */ export async function resolveHostStructuredAgentCreateSupport(input: { agent: StructuredAgentId worktreeSelector: string location: AgentSessionExecutionLocation runtime: LaunchSupportRuntime + getSettings: () => ClaudeManagedAccountGateSettings }): Promise { const { agent, location } = input const supportsLocation = @@ -73,13 +75,15 @@ export async function resolveHostStructuredAgentCreateSupport(input: { supportsLocation && (await structuredAgentSupportsLaunch(agent, input.worktreeSelector, input.runtime)) const support = resolveStructuredAgentSessionCreateSupport({ + agent, location, - adapterSupportsCreate: supportsLaunch + adapterSupportsCreate: supportsLaunch, + getSettings: input.getSettings }) warnStructuredAgentSessionCreateUnsupported( agent, support, - supportsLocation ? 'installed-agent' : 'location' + !supportsLocation ? 'location' : !supportsLaunch ? 'installed-agent' : 'managed-account' ) return support } diff --git a/src/main/runtime/structured-agent-runtime-registrations.ts b/src/main/runtime/structured-agent-runtime-registrations.ts index 20cc9ee2523c..deed5bc01341 100644 --- a/src/main/runtime/structured-agent-runtime-registrations.ts +++ b/src/main/runtime/structured-agent-runtime-registrations.ts @@ -21,6 +21,7 @@ import { import type { StructuredAgentCommandSettings } from '../native-chat/structured-agent-command-resolution' import type { StructuredAgentDefinition } from '../native-chat/agent-session-wire/structured-agent-definition' import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import { readClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support' import { agentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' import { agentSessionAttachmentStoreRoot } from '../native-chat/agent-session-attachments/agent-session-attachment-references' import { @@ -171,6 +172,12 @@ function createClaudeAdapter( ...(deps.resolveClaudePermissionMode ? { resolveClaudePermissionMode: deps.resolveClaudePermissionMode } : {}), + ...(deps.getClaudeManagedAccountGateSettings + ? { + readClaudeManagedAccountGate: () => + readClaudeManagedAccountGateSettings(deps.getClaudeManagedAccountGateSettings!) + } + : {}), attachmentDirectory: agentSessionAttachmentStoreRoot(deps.stateDirectory), onLifecycleEvent: context.deliverLifecycle, logger: deps.logger, diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index efcdab0f22cb..8853d7428f20 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -34,6 +34,7 @@ import { import { StructuredAgentSessionAdapterRouter } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router' import { StructuredAgentRegistry } from '../native-chat/agent-session-wire/structured-agent-registry' import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' +import type { ClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support' import { installAgentSessionAttachments, stopAgentSessionAttachments @@ -133,6 +134,8 @@ export type StructuredAgentSessionRuntimeDeps = { resolveAgentLaunchEnv?: (agent: string) => Record /** The settings a per-agent Command override is read from, for the same agents. */ resolveAgentCommandSettings?: () => StructuredAgentCommandSettings + /** Raw settings getter; the reader that fails closed around it is built here, in checked code. */ + getClaudeManagedAccountGateSettings?: () => ClaudeManagedAccountGateSettings resolveEnvironment?: () => Promise /** Which login-shell variables Codex and Claude children inherit; absent inherits all. */ resolveShellEnvironmentPolicy?: () => NativeChatShellEnvironmentPolicy diff --git a/src/main/runtime/structured-claude-runtime-adapter.ts b/src/main/runtime/structured-claude-runtime-adapter.ts index d9abf1cf3885..269fd8423149 100644 --- a/src/main/runtime/structured-claude-runtime-adapter.ts +++ b/src/main/runtime/structured-claude-runtime-adapter.ts @@ -15,6 +15,7 @@ import { recordAgentSessionProviderHandle, reviseAgentSessionProviderResumePoint } from './agent-session-provider-handle-transition' +import type { ClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support' import type { AgentSessionRecordStore } from './agent-session-record-store' import { ClaudeAtRestCommandCatalog } from '../claude/claude-at-rest-commands' import { openClaudeStreamJsonConnection } from '../claude/claude-stream-json-connection' @@ -39,6 +40,7 @@ export type StructuredClaudeRuntimeAdapterDeps = { resolveClaudeAuthPolicy: () => Promise | ClaudeStructuredAuthPolicy /** The user's Agent Permissions setting for Claude; absent means prompting. */ resolveClaudePermissionMode?: () => Promise | PermissionMode + readClaudeManagedAccountGate?: () => ClaudeManagedAccountGateSettings | null /** Where the host stores chat attachments; granted to the agent as a readable directory. */ attachmentDirectory?: string openClaudeConnection?: ClaudeStructuredSessionAdapterDeps['openConnection'] @@ -115,6 +117,9 @@ export function createStructuredClaudeRuntimeAdapter( ...(deps.resolveClaudePermissionMode ? { resolvePermissionMode: deps.resolveClaudePermissionMode } : {}), + ...(deps.readClaudeManagedAccountGate + ? { readManagedAccountGate: deps.readClaudeManagedAccountGate } + : {}), ...(deps.attachmentDirectory ? { attachmentDirectory: deps.attachmentDirectory } : {}), ...(deps.claudeCliFlags ? { cliFlags: deps.claudeCliFlags } : {}), ...(deps.prepareVisuals ? { prepareVisuals: deps.prepareVisuals } : {}) diff --git a/src/main/shell-wrapper-generated-file-snapshot.test.ts b/src/main/shell-wrapper-generated-file-snapshot.test.ts index edc24c7a44a5..9e86ebdd15ca 100644 --- a/src/main/shell-wrapper-generated-file-snapshot.test.ts +++ b/src/main/shell-wrapper-generated-file-snapshot.test.ts @@ -68,8 +68,6 @@ async function expectWrapperFiles(transport: string, root: string): Promise !activeIds.has(account.id)) .map((account) => ({ id: account.id, + managedAuthPath: account.managedAuthPath, managedAuthRuntime: account.managedAuthRuntime, wslDistro: account.wslDistro, wslLinuxAuthPath: account.wslLinuxAuthPath diff --git a/src/main/startup/main-process-ready-foundation.ts b/src/main/startup/main-process-ready-foundation.ts index 015a7eba6544..0b6ac644a25e 100644 --- a/src/main/startup/main-process-ready-foundation.ts +++ b/src/main/startup/main-process-ready-foundation.ts @@ -24,6 +24,11 @@ import { setDefaultWslDistroOverride } from '../git/runner' import { wslHookRelayManager } from '../agent-hooks/wsl-hook-relay-manager' +import { + attachClaudeLivePtyPersistence, + onLiveClaudePtysDrained, + seedLiveClaudePtysFromPersistence +} from '../claude-accounts/live-pty-gate' import { applyAppIcon } from '../app-icon' import { shouldSuppressDevEducation, @@ -260,6 +265,21 @@ export async function initializeReadyFoundation(): Promise { } } }) + // Why: run before ClaudeRuntimeAuthService's constructor sync — a surviving daemon Claude CLI holds the single-use refresh token; early refresh rotates it out mid-session. + attachClaudeLivePtyPersistence(store) + // Why: while a live claude defers the managed OAuth refresh, usage shows + // "Waiting for Claude session"; refetch when the last live PTY exits so the + // error clears immediately instead of after the failure backoff. + onLiveClaudePtysDrained(() => { + void state.rateLimits?.refreshAfterClaudeLivePtysDrained() + }) + const persistedClaudePtyIds = store.getClaudeLivePtySessionIds() + seedLiveClaudePtysFromPersistence(persistedClaudePtyIds) + if (persistedClaudePtyIds.length > 0) { + console.log( + `[claude-live-pty] Seeded ${persistedClaudePtyIds.length} persisted Claude session id(s) into the refresh gate` + ) + } applyAppIcon(store.getSettings().appIcon) if (shouldSuppressDevEducation({ isDev: is.dev })) { suppressDevEducationForStore(store) diff --git a/src/main/startup/main-process-ready-identity-write.test.ts b/src/main/startup/main-process-ready-identity-write.test.ts index 19bb07714e92..4d2285e3b8e4 100644 --- a/src/main/startup/main-process-ready-identity-write.test.ts +++ b/src/main/startup/main-process-ready-identity-write.test.ts @@ -73,6 +73,9 @@ vi.mock('../persistence', () => ({ return {} } onSettingsChanged() {} + getClaudeLivePtySessionIds() { + return [] + } getSshTargets() { return [] } @@ -84,6 +87,7 @@ vi.mock('../persistence/profile-state/profile-state-startup-authority', () => ({ store: { getSettings: () => ({}), onSettingsChanged: () => {}, + getClaudeLivePtySessionIds: () => [], getSshTargets: () => [] } }) @@ -142,6 +146,11 @@ vi.mock('../git/runner', () => ({ vi.mock('../agent-hooks/wsl-hook-relay-manager', () => ({ wslHookRelayManager: { setManagedHookSettingsResolver: vi.fn() } })) +vi.mock('../claude-accounts/live-pty-gate', () => ({ + attachClaudeLivePtyPersistence: vi.fn(), + onLiveClaudePtysDrained: vi.fn(), + seedLiveClaudePtysFromPersistence: vi.fn() +})) vi.mock('../app-icon', () => ({ applyAppIcon: vi.fn() })) vi.mock('./dev-education-suppression', () => ({ shouldSuppressDevEducation: () => false, diff --git a/src/main/startup/main-window-core-services.test.ts b/src/main/startup/main-window-core-services.test.ts index aedb8dfccada..ef986e43ab87 100644 --- a/src/main/startup/main-window-core-services.test.ts +++ b/src/main/startup/main-window-core-services.test.ts @@ -112,6 +112,7 @@ describe('main window profile-state update preparation', () => { expect(preserveAgentAuthBeforeRestartMock).toHaveBeenCalledWith({ codexRuntimeHome: state.codexRuntimeHome, + claudeRuntimeAuth: state.claudeRuntimeAuth, store }) expect(store.writeLatestProfileStateJsonExportAsync).not.toHaveBeenCalled() diff --git a/src/main/startup/main-window-core-services.ts b/src/main/startup/main-window-core-services.ts index 96c8c65c18ea..8f1cac48de6e 100644 --- a/src/main/startup/main-window-core-services.ts +++ b/src/main/startup/main-window-core-services.ts @@ -91,7 +91,11 @@ export function attachMainWindowCoreServices( onBeforeRelaunch: async () => { state.isQuitting = true state.desktopRelayService?.fenceAndCloseNow() - await preserveAgentAuthBeforeRestart({ codexRuntimeHome, store }) + await preserveAgentAuthBeforeRestart({ + codexRuntimeHome, + claudeRuntimeAuth, + store + }) }, onOrcaProfileAuthMutation: () => state.desktopRelayService?.authMutated(), // Sign-out is the one fence a paired phone can be told about; quit and @@ -127,7 +131,7 @@ export function attachMainWindowCoreServices( onCodexHomePtySpawned: handleCodexHomePtySpawned, onPtyExit: handlePtyExit, onBeforeUpdateQuit: async () => { - await preserveAgentAuthBeforeRestart({ codexRuntimeHome, store }) + await preserveAgentAuthBeforeRestart({ codexRuntimeHome, claudeRuntimeAuth, store }) await store.flushPendingOrThrowAsync({ fullCheckpoint: true }) }, onBeforeUpdateQuitFailure: 'abort', diff --git a/src/preload/api/pty-api.ts b/src/preload/api/pty-api.ts index cbcf78a4abe2..229ef1a5414c 100644 --- a/src/preload/api/pty-api.ts +++ b/src/preload/api/pty-api.ts @@ -145,8 +145,6 @@ export type PtyApi = { /** Runs the fix with the pane's own Codex; true only once verified. Local panes only. */ disableCodexSharedServerAutoStart: (id: string) => Promise stopCodexSharedServer: (id: string) => Promise - /** A local pane whose daemon predates per-account Claude folders; false for any other pane. */ - openedBeforeClaudeAccounts: (id: string) => Promise getCwd: (id: string) => Promise getSize: (id: string) => Promise<{ cols: number; rows: number } | null> listSessions: (scope?: PtySessionListScope) => Promise diff --git a/src/preload/api/pty-bridge-stream-and-serialization.ts b/src/preload/api/pty-bridge-stream-and-serialization.ts index 3d72ac5e518f..e0b2e7f2bf67 100644 --- a/src/preload/api/pty-bridge-stream-and-serialization.ts +++ b/src/preload/api/pty-bridge-stream-and-serialization.ts @@ -23,8 +23,6 @@ export const ptyStreamAndSerializationApi = { ipcRenderer.invoke('pty:disableCodexSharedServerAutoStart', { id }), stopCodexSharedServer: (id: string): Promise => ipcRenderer.invoke('pty:stopCodexSharedServer', { id }), - openedBeforeClaudeAccounts: (id: string): Promise => - ipcRenderer.invoke('pty:openedBeforeClaudeAccounts', { id }), getCwd: (id: string): Promise => ipcRenderer.invoke('pty:getCwd', { id }), getSize: (id: string): Promise<{ cols: number; rows: number } | null> => ipcRenderer.invoke('pty:getSize', { id }), 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 9f833364ab15..bab4c09e1e81 100644 --- a/src/renderer/src/app-shell/use-app-shell-services.ts +++ b/src/renderer/src/app-shell/use-app-shell-services.ts @@ -22,7 +22,6 @@ import { useTerminalViewerColorPublication } from './use-terminal-viewer-color-p 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' -import { useClaudeAccountSignInNotice } from '../lib/claude-account-sign-in-notice' import { useVisibleReviewRefreshReporting } from './use-visible-review-refresh-reporting' import { useVisibleHostedReviewRefresh } from './use-visible-hosted-review-refresh' import { useProfileStateSaveDelayNotice } from './use-profile-state-save-delay-notice' @@ -65,5 +64,4 @@ export function useAppShellServices(): void { useBrowserIdentityMigrationNotice() useCodexTerminalServerIsolationNotice() useCodexSharedSettingsNotice() - useClaudeAccountSignInNotice() } diff --git a/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx b/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx index 47856cdaae84..1bd21e9ec013 100644 --- a/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx @@ -15,12 +15,6 @@ import { useNativeChatOrcaStopView } from './native-chat-orca-stop-context' import { nativeChatOrcaStopRowText } from './native-chat-orca-stop-words' import { AGENT_SESSION_ORCA_STOP_PRESENTATION } from '../../../../shared/agent-session-orca-stop' import { ProviderFrameRow } from './NativeChatTranscriptChrome' -import { Button } from '@/components/ui/button' -import { - isClaudeSignInFailureKind, - nativeChatClaudeSignInLabel, - useNativeChatClaudeSignInView -} from './native-chat-claude-sign-in' const HOST_STATUS_WORDS: Record string> = { 'history-repaired': () => @@ -45,7 +39,6 @@ export function NativeChatNoticeRow({ allowFileUriLinks?: boolean }): React.JSX.Element { const orcaStopView = useNativeChatOrcaStopView() - const claudeSignIn = useNativeChatClaudeSignInView() if (block.presentation === 'compaction') { const label = translate('components.native-chat.notices.compaction', 'Context compacted') return ( @@ -130,17 +123,6 @@ export function NativeChatNoticeRow({ {Icon ? : null}

    {text}

    - {claudeSignIn && isClaudeSignInFailureKind(block.failure?.kind) ? ( - - ) : null} {block.providerFrame ? ( @@ -136,10 +130,6 @@ export function NativeChatStructuredSession( isWorking: controller.isWorking, composer: { clearError: () => reportComposerError(null) } }) - const startFailures = useStructuredAgentSessionStartFailureFacts( - controller.journalItems, - props.agent === 'claude' - ) const deliveryNotices = useStructuredAgentSessionDeliveryNotices({ pending: controller.pending, submissions: controller.submissions, @@ -215,18 +205,11 @@ export function NativeChatStructuredSession( launchFailure: provisionalLaunch.lifecycle === 'failed' ? provisionalLaunch.failure : null, journalItems: controller.journalItems }) - const claudeSignIn = useNativeChatClaudeSignIn({ - agent: props.agent, - target: props.target, - failure: provisionalLaunch.failure, - failureRows: startFailures.filter((fact) => isClaudeSignInFailureKind(fact.kind)).length - }) const notices = structuredSessionNotices({ launch, agentLabel, sessionError, composerError: composerError ?? continuation.continueError, - claudeSignIn, availability }) if (hostNotice) { @@ -262,31 +245,29 @@ export function NativeChatStructuredSession( ) : ( - - - + )} diff --git a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts index 9e308368ba38..87c5f88accfe 100644 --- a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts +++ b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts @@ -116,16 +116,6 @@ const PIECES: Record - translate( - 'components.native-chat.failureWords.claudeAccountFolderMissing', - COPY.claudeAccountFolderMissing - ), - claudeAccountSetupFailed: () => - translate( - 'components.native-chat.failureWords.claudeAccountSetupFailed', - COPY.claudeAccountSetupFailed - ), agentCommandNotRunnable: (values) => translate( 'components.native-chat.failureWords.agentCommandNotRunnable', diff --git a/src/renderer/src/components/native-chat/native-chat-claude-sign-in.test.tsx b/src/renderer/src/components/native-chat/native-chat-claude-sign-in.test.tsx deleted file mode 100644 index c59e44c4b877..000000000000 --- a/src/renderer/src/components/native-chat/native-chat-claude-sign-in.test.tsx +++ /dev/null @@ -1,116 +0,0 @@ -// @vitest-environment happy-dom -import '@testing-library/jest-dom/vitest' -import { act, cleanup, fireEvent, render, renderHook, screen } from '@testing-library/react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { getDefaultSettings } from '../../../../shared/constants' -import { projectStructuredItemsToNativeChat } from '../../../../shared/structured-agent-session-projection' -import type { AgentJournalStatusItem } from '../../../../shared/agent-session-journal-types' -import { agentSessionFailureWords } from '../../../../shared/agent-session-failure-words' -import type { AgentSessionRefusalReason } from '../../../../shared/agent-session-refusal-details' -import { useAppStore } from '@/store' -import { MessageRow } from './NativeChatMessageRow' -import { - NativeChatClaudeSignInContext, - useNativeChatClaudeSignIn, - type NativeChatClaudeSignIn -} from './native-chat-claude-sign-in' -import { structuredSessionNotices } from './native-chat-structured-session-notices' - -vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) - -const reauthenticate = vi.fn(() => Promise.resolve({ accounts: [], activeAccountId: 'a' })) -const LOCAL = { kind: 'local' } as const -const ROW_SIGN_IN = { signingIn: false, signIn: vi.fn() } - -beforeEach(() => { - vi.clearAllMocks() - useAppStore.setState({ - settings: { ...getDefaultSettings('/home/me'), activeClaudeManagedAccountId: 'a' }, - fetchSettings: vi.fn(() => Promise.resolve()) - }) - Object.defineProperty(window, 'api', { - configurable: true, - value: { claudeAccounts: { reauthenticate } } - }) -}) -afterEach(cleanup) - -function launchNotice( - reason: AgentSessionRefusalReason<'agent_session_operation_invalid'>, - claudeSignIn: NativeChatClaudeSignIn | null -) { - return structuredSessionNotices({ - launch: { - lifecycle: 'failed', - failure: { kind: 'refused', code: 'agent_session_operation_invalid', details: { reason } }, - retry: vi.fn() - }, - agentLabel: 'Claude', - sessionError: null, - composerError: null, - claudeSignIn - })[0] -} - -describe('a Claude chat that failed for want of a sign-in', () => { - it('offers Sign in on the start failure, then Retry once signed in', async () => { - const failure = {} - const { result, rerender } = renderHook( - (rows: number) => - useNativeChatClaudeSignIn({ agent: 'claude', target: LOCAL, failure, failureRows: rows }), - { initialProps: 0 } - ) - const notice = launchNotice('notSignedIn', result.current) - expect(notice?.text).toContain("Claude isn't signed in.") - expect(notice?.action?.label).toBe('Sign in') - await act(async () => notice?.action?.onClick()) - expect(reauthenticate).toHaveBeenCalledWith({ accountId: 'a' }) - expect(result.current).toBeNull() - expect(launchNotice('notSignedIn', result.current)?.action?.label).toBe('Retry') - // A new failure after the sign-in offers it again. - rerender(1) - expect(result.current).not.toBeNull() - }) - - it('offers Sign in for a missing account folder, and Retry for any other failure', () => { - const signIn = { signingIn: false, signIn: vi.fn() } - expect(launchNotice('claudeAccountFolderMissing', signIn)?.action?.label).toBe('Sign in') - expect(launchNotice('claudeAccountSetupFailed', signIn)?.action?.label).toBe('Retry') - }) - - it('offers nothing for Codex, a remote chat, or System default', () => { - const args = { target: LOCAL, failure: null, failureRows: 0 } - expect( - renderHook(() => useNativeChatClaudeSignIn({ ...args, agent: 'codex' })).result.current - ).toBeNull() - const remote = { kind: 'environment', environmentId: 'env-1' } as const - expect( - renderHook(() => useNativeChatClaudeSignIn({ ...args, agent: 'claude', target: remote })) - .result.current - ).toBeNull() - useAppStore.setState({ - settings: { ...getDefaultSettings('/home/me'), activeClaudeManagedAccountId: null } - }) - expect( - renderHook(() => useNativeChatClaudeSignIn({ ...args, agent: 'claude' })).result.current - ).toBeNull() - }) - - it('puts Sign in on the transcript row that says so', () => { - const body: AgentJournalStatusItem = { - kind: 'status', - tone: 'error', - ...agentSessionFailureWords({ kind: 'notSignedIn' }, { surface: 'row', provider: 'claude' }) - } - const [message] = projectStructuredItemsToNativeChat([ - { itemId: 'start-failure', sequence: 1, revision: 1, observedAt: 1, body } - ]) - render( - - - - ) - fireEvent.click(screen.getByRole('button', { name: 'Sign in' })) - expect(ROW_SIGN_IN.signIn).toHaveBeenCalled() - }) -}) diff --git a/src/renderer/src/components/native-chat/native-chat-claude-sign-in.ts b/src/renderer/src/components/native-chat/native-chat-claude-sign-in.ts deleted file mode 100644 index 64fbc7aa3b4a..000000000000 --- a/src/renderer/src/components/native-chat/native-chat-claude-sign-in.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react' -import { translate } from '@/i18n/i18n' -import { useAppStore } from '@/store' -import { signInToClaudeAccount } from '@/lib/claude-account-sign-in' -import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' - -/** The chat failures a sign-in to the selected Claude account fixes. */ -const SIGN_IN_FAILURE_KINDS: ReadonlySet = new Set([ - 'notSignedIn', - 'claudeAccountFolderMissing' -]) - -export function isClaudeSignInFailureKind(kind: string | undefined): boolean { - return kind !== undefined && SIGN_IN_FAILURE_KINDS.has(kind) -} - -/** Settings' Sign in again for the selected account, offered where a chat failed for want of it. */ -export type NativeChatClaudeSignIn = { signingIn: boolean; signIn: () => void } - -export const NativeChatClaudeSignInContext = createContext(null) - -export function useNativeChatClaudeSignInView(): NativeChatClaudeSignIn | null { - return useContext(NativeChatClaudeSignInContext) -} - -export function nativeChatClaudeSignInLabel(signIn: NativeChatClaudeSignIn): string { - return signIn.signingIn - ? translate('components.native-chat.claudeSignIn.signingIn', 'Signing in…') - : translate('accounts.claude.signIn', 'Sign in') -} - -/** Null unless a local Claude chat runs under a selected account, and after a sign-in until a new - * failure (a different `failure`, or another `failureRows`), so a resend is what follows it. */ -export function useNativeChatClaudeSignIn(input: { - agent: string - target: RuntimeClientTarget - failure: unknown - failureRows: number -}): NativeChatClaudeSignIn | null { - // Why local only: the hidden sign-in runs on this device, and Claude chats run on its host. - const accountId = useAppStore((s) => - input.agent === 'claude' && input.target.kind === 'local' - ? (s.settings?.activeClaudeManagedAccountIdsByRuntime?.host ?? - s.settings?.activeClaudeManagedAccountId ?? - null) - : null - ) - const [signingIn, setSigningIn] = useState(false) - // Why a ref: the notice and a transcript row both sign in, and state lags a double press. - const signingInRef = useRef(false) - const [signedInFor, setSignedInFor] = useState<{ failure: unknown; rows: number } | null>(null) - const { failure, failureRows } = input - const signIn = useCallback(() => { - if (!accountId || signingInRef.current) { - return - } - signingInRef.current = true - setSigningIn(true) - void signInToClaudeAccount(accountId) - .then((signedIn) => { - if (signedIn) { - setSignedInFor({ failure, rows: failureRows }) - } - }) - .finally(() => { - signingInRef.current = false - setSigningIn(false) - }) - }, [accountId, failure, failureRows]) - const signedInSinceFailure = - signedInFor !== null && signedInFor.failure === failure && signedInFor.rows === failureRows - // Why memoized: rows read it through context, and a chat re-renders on every streamed token. - return useMemo( - () => (accountId && !signedInSinceFailure ? { signingIn, signIn } : null), - [accountId, signedInSinceFailure, signingIn, signIn] - ) -} diff --git a/src/renderer/src/components/native-chat/native-chat-structured-session-notices.ts b/src/renderer/src/components/native-chat/native-chat-structured-session-notices.ts index cce5d915e939..b12ae667de84 100644 --- a/src/renderer/src/components/native-chat/native-chat-structured-session-notices.ts +++ b/src/renderer/src/components/native-chat/native-chat-structured-session-notices.ts @@ -4,11 +4,6 @@ import { agentSessionRefusalCauseParts } from '../../../../shared/agent-session- import type { AgentSessionWriteRefusal } from '../../../../shared/agent-session-write-failure' import { joinSentences } from '../../../../shared/sentence-joining' import { agentSessionWriteNoticeText } from './agent-session-write-notice-text' -import { - isClaudeSignInFailureKind, - nativeChatClaudeSignInLabel, - type NativeChatClaudeSignIn -} from './native-chat-claude-sign-in' import type { NativeChatComposerNotice, NativeChatComposerNoticeContent @@ -19,8 +14,7 @@ function nativeChatLaunchNotice({ lifecycle, failure = null, agentLabel, - onRetry, - claudeSignIn = null + onRetry }: { lifecycle: StructuredAgentSessionLaunchLifecycle | null /** The host's refusal behind the failed start; its message is never shown. */ @@ -28,8 +22,6 @@ function nativeChatLaunchNotice({ /** Names the agent in a start failure's words. */ agentLabel?: string onRetry: () => void - /** Offered instead of Retry until it signs in, when the start failed for want of a sign-in. */ - claudeSignIn?: NativeChatClaudeSignIn | null }): NativeChatComposerNotice | null { if (lifecycle !== 'failed' && lifecycle !== 'visibility-unknown') { return null @@ -57,17 +49,10 @@ function nativeChatLaunchNotice({ key: 'launch', kind: 'error', text: cause ? (saysStartFailure ? cause : joinSentences([message, cause])) : message, - action: - claudeSignIn && isClaudeSignInFailureKind(failure?.details?.reason) - ? { - label: nativeChatClaudeSignInLabel(claudeSignIn), - onClick: claudeSignIn.signIn, - disabled: claudeSignIn.signingIn - } - : { - label: translate('auto.components.native.chat.NativeChatLaunchRetry.retry', 'Retry'), - onClick: onRetry - } + action: { + label: translate('auto.components.native.chat.NativeChatLaunchRetry.retry', 'Retry'), + onClick: onRetry + } } } @@ -77,7 +62,6 @@ export function structuredSessionNotices({ agentLabel, sessionError, composerError, - claudeSignIn = null, availability = null }: { launch: { @@ -88,7 +72,6 @@ export function structuredSessionNotices({ agentLabel: string sessionError: string | null composerError: (NativeChatComposerNoticeContent & { onDismiss: () => void }) | null - claudeSignIn?: NativeChatClaudeSignIn | null /** Why the host says no chat can start here, from `useNativeChatAvailabilityNotice`. */ availability?: NativeChatComposerNotice | null }): NativeChatComposerNotice[] { @@ -96,8 +79,7 @@ export function structuredSessionNotices({ lifecycle: launch.lifecycle, failure: launch.failure, agentLabel, - onRetry: launch.retry, - claudeSignIn + onRetry: launch.retry }) return [ ...(availability ? [availability] : []), diff --git a/src/renderer/src/components/settings/accounts-pane-account-actions.ts b/src/renderer/src/components/settings/accounts-pane-account-actions.ts index 1f0099876d27..98ba3cac3f99 100644 --- a/src/renderer/src/components/settings/accounts-pane-account-actions.ts +++ b/src/renderer/src/components/settings/accounts-pane-account-actions.ts @@ -176,25 +176,24 @@ export function createClaudeAccountActionRunner( await syncClaudeAccounts(next) recordFeatureInteraction('claude-account-switching') const nextActiveAccountId = getProviderAccountActiveIdForView(next, actionRuntime) - // Why: switching reaches the next `claude` started in any terminal, so nothing restarts. - if (action === 'adding' || previousActiveAccountId !== nextActiveAccountId) { + const shouldPromptRestart = + action === 'adding' || + previousActiveAccountId !== nextActiveAccountId || + (action.startsWith('reauth:') && + nextActiveAccountId !== null && + action === `reauth:${nextActiveAccountId}`) + if (shouldPromptRestart) { toast.info( translate('auto.components.settings.AccountsPane.f921d32606', 'Claude account updated.'), { - description: - previousActiveAccountId === nextActiveAccountId - ? translate( - 'accounts.claude.added', - 'Account added. Select it to use it for the next Claude you start.' - ) - : translate( - 'accounts.claude.nextLaunch', - '{{value0}} → {{value1}}. The next Claude you start uses it. Running sessions keep their account.', - { - value0: getClaudeAccountLabel(claudeAccounts, previousActiveAccountId), - value1: getClaudeAccountLabel(next, nextActiveAccountId) - } - ) + description: translate( + 'auto.components.settings.AccountsPane.b15ce90870', + '{{value0}} -> {{value1}}. Restart live Claude terminals before continuing old sessions.', + { + value0: getClaudeAccountLabel(claudeAccounts, previousActiveAccountId), + value1: getClaudeAccountLabel(next, nextActiveAccountId) + } + ) } ) } diff --git a/src/renderer/src/components/settings/accounts-pane-claude-section.test.tsx b/src/renderer/src/components/settings/accounts-pane-claude-section.test.tsx deleted file mode 100644 index dfa13023815f..000000000000 --- a/src/renderer/src/components/settings/accounts-pane-claude-section.test.tsx +++ /dev/null @@ -1,156 +0,0 @@ -import React from 'react' -import { renderToStaticMarkup } from 'react-dom/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { getDefaultSettings } from '../../../../shared/constants' -import type { GlobalSettings } from '../../../../shared/global-settings-types' -import type { - ClaudeManagedAccountSummary, - ClaudeRateLimitAccountsState -} from '../../../../shared/managed-account-types' -import { i18n } from '../../i18n/i18n' -import { useAppStore } from '../../store' -import { renderClaudeAccountsSection } from './accounts-pane-claude-section' -import type { AccountsPaneSectionModel } from './accounts-pane-types' - -function account( - id: string, - email: string, - overrides: Partial = {} -): ClaudeManagedAccountSummary { - return { - id, - email, - managedAuthRuntime: 'host', - wslDistro: null, - authMethod: 'subscription-oauth', - organizationUuid: null, - organizationName: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1, - ...overrides - } -} - -function render( - state: Partial = {}, - settings: Partial = {} -): string { - const claudeAccounts: ClaudeRateLimitAccountsState = { - accounts: [], - activeAccountId: null, - ...state - } - const accountRuntime = { runtime: 'host' as const, wslDistro: null, label: 'This device' } - const model: AccountsPaneSectionModel = { - // Unused by the Claude section; present only so the model is the real type. - updateSettings: vi.fn(), - searchQuery: '', - recordFeatureInteraction: vi.fn(), - wslSupportedPlatform: false, - wslAvailable: false, - wslDistros: [], - localAccountRuntime: accountRuntime, - localAccountRuntimeSentenceLabel: 'this device', - remoteServerName: null, - codexAccounts: { accounts: [], activeAccountId: null }, - codexAction: 'idle', - visibleCodexAccounts: [], - systemCodexActive: true, - systemCodexNeedsSignIn: false, - systemCodexMissingSignIn: false, - systemCodexIdentity: undefined, - activeCodexAuthWarning: null, - activeCodexAccountId: null, - codexConfigSync: null, - codexConfigSyncWarning: null, - codexRateLimits: null, - codexRateLimitTarget: { runtime: 'host', wslDistro: null }, - setRemoveCodexTarget: vi.fn(), - runCodexAccountAction: vi.fn(async () => {}), - recordOpenCodeSettingEdit: vi.fn(), - miniMaxRateLimits: null, - miniMaxApiKeyDraft: '', - setMiniMaxApiKeyDraft: vi.fn(), - miniMaxApiKeyConfigured: false, - miniMaxApiKeyProtection: null, - saveMiniMaxApiKey: vi.fn(async () => {}), - clearMiniMaxApiKey: vi.fn(async () => {}), - miniMaxCookieDraft: '', - setMiniMaxCookieDraft: vi.fn(), - miniMaxConfigured: false, - miniMaxCookieProtection: null, - miniMaxCredentialBusy: false, - saveMiniMaxCookie: vi.fn(async () => {}), - clearMiniMaxCookie: vi.fn(async () => {}), - accountRuntime, - accountRuntimeSentenceLabel: 'this device', - accountRuntimeUnavailable: false, - accountVisibilityOptions: { remoteOwner: false, ownerPlatform: 'darwin' }, - claudeAccounts, - claudeAction: 'idle', - isRemoteAccountScope: false, - remoteAccountScopeNotice: null, - runClaudeAccountAction: vi.fn(async () => {}), - setRemoveClaudeTarget: vi.fn(), - settings: { ...getDefaultSettings('/tmp'), ...settings }, - systemClaudeActive: claudeAccounts.activeAccountId === null, - visibleClaudeAccounts: claudeAccounts.accounts, - wslCapabilitiesLoading: false - } - return renderToStaticMarkup(React.createElement(() => renderClaudeAccountsSection(model))) -} - -function selectButtons(markup: string): string[] { - return [ - ...markup.matchAll(/') - // Saving your own login as an account too is normal: no copy evidence, no warning. - expect(render(saved)).not.toContain('An earlier Orca version') - expect( - render( - { ...saved, systemDefaultMayBeCopied: true }, - { claudeCopiedSystemDefaultNoticeDismissed: true } - ) - ).not.toContain('An earlier Orca version') - const own = render({ - ...saved, - systemDefaultEmail: 'me@example.test', - systemDefaultMayBeCopied: true - }) - expect(own).toContain('System default: me@example.test') - expect(own).not.toContain('An earlier Orca version') - }) -}) diff --git a/src/renderer/src/components/settings/accounts-pane-claude-section.tsx b/src/renderer/src/components/settings/accounts-pane-claude-section.tsx index 29b03daa2921..e99758f0db54 100644 --- a/src/renderer/src/components/settings/accounts-pane-claude-section.tsx +++ b/src/renderer/src/components/settings/accounts-pane-claude-section.tsx @@ -27,21 +27,9 @@ export function renderClaudeAccountsSection(model: AccountsPaneSectionModel): Re setRemoveClaudeTarget, settings, systemClaudeActive, - updateSettings, visibleClaudeAccounts, wslCapabilitiesLoading } = model - // Why host only: the host reads its own System default, never a WSL distro's. - const systemDefaultEmail = - accountRuntime.runtime === 'host' ? claudeAccounts.systemDefaultEmail : undefined - // Why the copy evidence: saving your own login as an account too is normal and needs no warning. - const systemDefaultIsSaved = - !!systemDefaultEmail && - claudeAccounts.systemDefaultMayBeCopied === true && - !settings.claudeCopiedSystemDefaultNoticeDismissed && - claudeAccounts.accounts.some( - (account) => account.email.toLowerCase() === systemDefaultEmail.toLowerCase() - ) return (
    @@ -60,8 +48,8 @@ export function renderClaudeAccountsSection(model: AccountsPaneSectionModel): Re
    - {systemDefaultEmail - ? translate( - 'accounts.claude.systemDefaultNamed', - 'System default: {{value0}}', - { - value0: systemDefaultEmail - } - ) - : translate( - 'auto.components.settings.AccountsPane.f2a265f8c7', - 'System default' - )} + {translate('auto.components.settings.AccountsPane.f2a265f8c7', 'System default')} {systemClaudeActive ? (
    - {systemDefaultIsSaved ? ( -
    -

    - {translate( - 'accounts.claude.systemDefaultIsSaved', - 'System default is signed in as {{value0}}, which is also a saved account. An earlier Orca version may have copied that login there. If it is not your own login, select System default and run claude /login.', - { value0: systemDefaultEmail } - )} -

    - -
    - ) : null} {visibleClaudeAccounts.length === 0 ? (
    {isRemoteAccountScope @@ -249,7 +208,7 @@ export function renderClaudeAccountsSection(model: AccountsPaneSectionModel): Re accountRuntimeView ) }} - disabled={isBusy || account.needsSignIn} + disabled={isBusy} className="flex min-w-0 flex-1 flex-col gap-0.5 text-left disabled:cursor-default" >
    @@ -273,14 +232,9 @@ export function renderClaudeAccountsSection(model: AccountsPaneSectionModel): Re ) : null}
    - {account.needsSignIn - ? translate( - 'accounts.claude.signInRequired', - 'Sign in again to use this account' - ) - : account.organizationName - ? `${account.organizationName} · ${formatAccountTimestamp(account.lastAuthenticatedAt)}` - : formatAccountTimestamp(account.lastAuthenticatedAt)} + {account.organizationName + ? `${account.organizationName} · ${formatAccountTimestamp(account.lastAuthenticatedAt)}` + : formatAccountTimestamp(account.lastAuthenticatedAt)}
    @@ -306,7 +260,10 @@ export function renderClaudeAccountsSection(model: AccountsPaneSectionModel): Re ) : ( )} - {translate('accounts.claude.signInAgain', 'Sign in again')} + {translate( + 'auto.components.settings.AccountsPane.8a0f870153', + 'Re-authenticate' + )}
    - {signInId ? ( - void handleSignIn(signInId)} - /> - ) : target.hint ? ( - - {target.hint} - - ) : null} {inactiveUsage?.isFetching && !inactiveUsage.rateLimits ? ( - ) : inactiveUsage?.rateLimits && - // Why: the sign-in note above already says this; skip the repeat. - !(signInId && isUnavailableInactiveUsage(inactiveUsage.rateLimits)) ? ( + ) : inactiveUsage?.rateLimits ? (
    {translate( - 'accounts.claude.profileSwitching', - 'Switching applies to the next Claude you start in any tab. Running sessions keep their account.' + 'auto.components.status.bar.StatusBar.8295903d17', + 'Restart live Claude terminals before continuing old conversations after switching.' )}
    diff --git a/src/renderer/src/components/status-bar/InlineProviderUsage.tsx b/src/renderer/src/components/status-bar/InlineProviderUsage.tsx index 18170fd7f809..6e0ac21c023f 100644 --- a/src/renderer/src/components/status-bar/InlineProviderUsage.tsx +++ b/src/renderer/src/components/status-bar/InlineProviderUsage.tsx @@ -89,22 +89,18 @@ export function InlineUsageSignInAction({ isSigningIn, disabled, onSignInPointerDown, - onSignIn, - reason + onSignIn }: { isFetching: boolean isSigningIn: boolean disabled: boolean onSignInPointerDown?: () => void onSignIn: () => void - /** Why it needs a sign-in; defaults to the usage it would show. */ - reason?: string }): React.JSX.Element { return (
    - {reason ?? - translate('auto.components.status.bar.StatusBar.f19a63e7cd', 'Sign in to see usage')} + {translate('auto.components.status.bar.StatusBar.f19a63e7cd', 'Sign in to see usage')} - } - onDismiss={() => { - dismissedPtyIds.add(ptyId) - setDismissed(true) - }} - /> - ) : null} - { - if (!open) { - setDialogOpen(false) - } - }} - onOpenNewTerminal={() => { - // Why: a new terminal takes focus; only when none opens does focus return to this pane. - if (openTerminalBesideTab(tabId)) { - skipReturnFocus() - } - setDialogOpen(false) - }} - /> - - ) -} diff --git a/src/renderer/src/components/terminal-pane/CodexOldTerminalDialog.tsx b/src/renderer/src/components/terminal-pane/CodexOldTerminalDialog.tsx new file mode 100644 index 000000000000..60238ca38323 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/CodexOldTerminalDialog.tsx @@ -0,0 +1,54 @@ +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { translate } from '@/i18n/i18n' + +/** Learn more for a terminal opened before Orca gave each Codex its own server. */ +export function CodexOldTerminalDialog({ + open, + onOpenChange, + onOpenNewTerminal +}: { + open: boolean + onOpenChange: (open: boolean) => void + onOpenNewTerminal: () => void +}): React.JSX.Element { + return ( + + + + + {translate( + 'terminal.codexSharedServerBanner.oldTerminalDialogTitle', + 'Why this Codex shares a server' + )} + + + {translate( + 'terminal.codexSharedServerBanner.oldTerminalDialogRisk', + 'Codex sessions that share a server can end together, and agent status can be wrong.' + )} + + +

    + {translate( + 'terminal.codexSharedServerBanner.oldTerminalDialogCause', + 'Older terminals still share a Codex server. Open a new terminal to run Codex on a separate server.' + )} +

    + {/* Why no global fix: a new terminal already runs Codex on its own server here. */} + + + +
    +
    + ) +} diff --git a/src/renderer/src/components/terminal-pane/CodexSharedServerBanner.tsx b/src/renderer/src/components/terminal-pane/CodexSharedServerBanner.tsx index 01a50ea27b00..942647054650 100644 --- a/src/renderer/src/components/terminal-pane/CodexSharedServerBanner.tsx +++ b/src/renderer/src/components/terminal-pane/CodexSharedServerBanner.tsx @@ -1,15 +1,18 @@ -import { useEffect, useState } from 'react' +import { useEffect, useLayoutEffect, useRef, useState } from 'react' +import { TriangleAlert } from 'lucide-react' import { Button } from '@/components/ui/button' import { translate } from '@/i18n/i18n' import { useAppStore } from '@/store' import { isCodexSharedServerWarningEnabled } from '../../../../shared/codex-terminal-server-isolation' import type { CodexSharedServerStatus } from '../../../../shared/codex-shared-server-command' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import { makePaneKey } from '../../../../shared/stable-pane-id' +import { createFloatingWorkspaceTerminalTab } from '@/lib/floating-workspace-tab-creation' +import { revealFloatingWorkspacePanel } from '@/lib/floating-workspace-panel-reveal' +import { activateAndRevealWorkspace } from '@/lib/worktree-activation' import { useModalReturnFocus } from '@/hooks/useModalReturnFocus' -import { OldTerminalDialog } from './OldTerminalDialog' +import { CodexOldTerminalDialog } from './CodexOldTerminalDialog' import { CodexSharedServerFixDialog } from './CodexSharedServerFixDialog' -import { openTerminalBesideTab } from './open-terminal-beside-tab' -import { PaneBannerLearnMore, PaneTopWarningBanner } from './PaneTopWarningBanner' import { retireCodexTerminalServerIsolationNotice } from './codex-terminal-server-isolation-notice' // Why a ladder: Codex joins or starts the server a few seconds after its process appears. @@ -67,6 +70,53 @@ function usePaneCodexSharedServerStatus( return status } +/** Opens a new terminal where this tab lives; a new terminal's shell has Orca's codex wrapper. */ +function openTerminalBesideTab(terminalTabId: string): boolean { + const state = useAppStore.getState() + const tab = Object.values(state.unifiedTabsByWorktree) + .flat() + .find( + (candidate) => candidate.contentType === 'terminal' && candidate.entityId === terminalTabId + ) + if (!tab) { + return false + } + if (tab.worktreeId === FLOATING_TERMINAL_WORKTREE_ID) { + revealFloatingWorkspacePanel(state) + void createFloatingWorkspaceTerminalTab(state) + return true + } + // Why always: Activity can show this pane behind its own view even when its workspace is active. + if (activateAndRevealWorkspace(tab.worktreeId) === false) { + return false + } + void useAppStore.getState().openNewTerminalTabInActiveWorkspace(tab.groupId) + return true +} + +/** Reserves the banner's height at the top of its pane so the terminal refits below it. */ +function useReservePaneTopSpace(): React.RefObject { + const ref = useRef(null) + useLayoutEffect(() => { + const banner = ref.current + const pane = banner?.parentElement + if (!banner || !pane) { + return + } + const reserve = (): void => { + pane.style.setProperty('--orca-pane-top-banner-height', `${banner.offsetHeight}px`) + } + reserve() + const observer = new ResizeObserver(reserve) + observer.observe(banner) + return () => { + observer.disconnect() + pane.style.removeProperty('--orca-pane-top-banner-height') + } + }, []) + return ref +} + export function CodexSharedServerBanner({ ptyId, tabId, @@ -115,7 +165,7 @@ export function CodexSharedServerBanner({ 'terminal.codexSharedServerBanner.openedBeforeUpdateBody', 'This terminal was opened before Orca started giving each Codex its own server.' )}{' '} - showDialog('oldTerminal')} /> + showDialog('oldTerminal')} /> ), primaryAction: ( @@ -125,7 +175,7 @@ export function CodexSharedServerBanner({ size="xs" onClick={() => openTerminalBesideTab(tabId)} > - {translate('terminal.paneTopBanner.openNewTerminal', 'Open new terminal')} + {translate('terminal.codexSharedServerBanner.openNewTerminal', 'Open new terminal')} ) } @@ -136,7 +186,7 @@ export function CodexSharedServerBanner({ 'terminal.codexSharedServerBanner.body', 'Sessions may end unexpectedly, and agent status may be wrong.' )}{' '} - showDialog('fix')} /> + showDialog('fix')} /> ), primaryAction: ( @@ -153,11 +203,7 @@ export function CodexSharedServerBanner({ return ( <> {status ? ( - { @@ -167,23 +213,10 @@ export function CodexSharedServerBanner({ onDontShowAgain={() => void useAppStore.getState().updateSettings({ codexSharedServerWarning: false }) } - onShown={retireCodexTerminalServerIsolationNotice} /> ) : null} - { // Why: a new terminal takes focus; only when none opens does focus return to this pane. @@ -202,3 +235,68 @@ export function CodexSharedServerBanner({ ) } + +function LearnMoreLink({ onClick }: { onClick: () => void }): React.JSX.Element { + return ( + + ) +} + +function CodexSharedServerBannerFrame({ + body, + primaryAction, + onDismiss, + onDontShowAgain +}: { + body: React.ReactNode + primaryAction: React.ReactNode + onDismiss: () => void + onDontShowAgain: () => void +}): React.JSX.Element { + const ref = useReservePaneTopSpace() + useEffect(retireCodexTerminalServerIsolationNotice, []) + + return ( +
    + {/* Why a container query: split panes are narrow, so actions drop below the text there. + Narrow and wide variants never share a property, so an unlayered utility cannot override them. */} +
    +
    +
    +
    + {primaryAction} + + +
    +
    +
    + ) +} diff --git a/src/renderer/src/components/terminal-pane/OldTerminalDialog.tsx b/src/renderer/src/components/terminal-pane/OldTerminalDialog.tsx deleted file mode 100644 index 9665e4b5c3e8..000000000000 --- a/src/renderer/src/components/terminal-pane/OldTerminalDialog.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { Button } from '@/components/ui/button' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle -} from '@/components/ui/dialog' -import { translate } from '@/i18n/i18n' - -/** Learn more for a terminal opened before an Orca update its shell does not have. */ -export function OldTerminalDialog({ - open, - onOpenChange, - onOpenNewTerminal, - title, - description, - cause -}: { - open: boolean - onOpenChange: (open: boolean) => void - onOpenNewTerminal: () => void - title: string - description: string - cause: string -}): React.JSX.Element { - return ( - - - - {title} - {description} - -

    {cause}

    - {/* Why no global fix: a new terminal already has the update. */} - - - -
    -
    - ) -} diff --git a/src/renderer/src/components/terminal-pane/PaneTopWarningBanner.tsx b/src/renderer/src/components/terminal-pane/PaneTopWarningBanner.tsx deleted file mode 100644 index 50eb7b4179cb..000000000000 --- a/src/renderer/src/components/terminal-pane/PaneTopWarningBanner.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import { useEffect, useLayoutEffect, useRef } from 'react' -import { TriangleAlert } from 'lucide-react' -import { Button } from '@/components/ui/button' -import { translate } from '@/i18n/i18n' - -/** Reserves the banner's height at the top of its pane so the terminal refits below it. */ -function useReservePaneTopSpace(): React.RefObject { - const ref = useRef(null) - useLayoutEffect(() => { - const banner = ref.current - const pane = banner?.parentElement - if (!banner || !pane) { - return - } - const reserve = (): void => { - pane.style.setProperty('--orca-pane-top-banner-height', `${banner.offsetHeight}px`) - } - reserve() - const observer = new ResizeObserver(reserve) - observer.observe(banner) - return () => { - observer.disconnect() - pane.style.removeProperty('--orca-pane-top-banner-height') - } - }, []) - return ref -} - -export function PaneBannerLearnMore({ onClick }: { onClick: () => void }): React.JSX.Element { - return ( - - ) -} - -/** The warning strip across the top of a terminal pane, with its actions and Dismiss. */ -export function PaneTopWarningBanner({ - title, - body, - primaryAction, - onDismiss, - onDontShowAgain, - onShown -}: { - title: string - body: React.ReactNode - primaryAction: React.ReactNode - onDismiss: () => void - onDontShowAgain?: () => void - onShown?: () => void -}): React.JSX.Element { - const ref = useReservePaneTopSpace() - useEffect(() => { - onShown?.() - }, [onShown]) - - return ( -
    - {/* Why a container query: split panes are narrow, so actions drop below the text there. - Narrow and wide variants never share a property, so an unlayered utility cannot override them. */} -
    -
    -
    -
    - {primaryAction} - {onDontShowAgain ? ( - - ) : null} - -
    -
    -
    - ) -} diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneRuntimePortals.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneRuntimePortals.tsx index 570702c3b1a0..ee003a527b15 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneRuntimePortals.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneRuntimePortals.tsx @@ -2,7 +2,6 @@ import { createPortal } from 'react-dom' import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' import CodexRestartChip from '../CodexRestartChip' import { CodexSharedServerBanner } from './CodexSharedServerBanner' -import { ClaudeOldTerminalBanner } from './ClaudeOldTerminalBanner' import { TerminalSshReconnectOverlay } from './TerminalSshReconnectOverlay' import { TerminalRemoteRuntimeReconnectBanner } from './TerminalRemoteRuntimeReconnectBanner' import { TerminalProcessExitOverlay } from './TerminalProcessExitOverlay' @@ -56,12 +55,6 @@ export function TerminalPaneCodexRestartPortals({ tabId={tabId} leafId={pane.leafId} /> - , pane.container, `codex-restart-${pane.id}` diff --git a/src/renderer/src/components/terminal-pane/claude-old-terminal-banner.test.tsx b/src/renderer/src/components/terminal-pane/claude-old-terminal-banner.test.tsx deleted file mode 100644 index cbd317a21835..000000000000 --- a/src/renderer/src/components/terminal-pane/claude-old-terminal-banner.test.tsx +++ /dev/null @@ -1,154 +0,0 @@ -// @vitest-environment happy-dom - -import { act } from 'react' -import { createRoot, type Root } from 'react-dom/client' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { useAppStore } from '@/store' -import type { GlobalSettings } from '../../../../shared/global-settings-types' -import { getDefaultSettings } from '../../../../shared/constants' -import type { ClaudeManagedAccount } from '../../../../shared/managed-account-types' -import { makePaneKey } from '../../../../shared/stable-pane-id' -import { ClaudeOldTerminalBanner } from './ClaudeOldTerminalBanner' - -const routing = vi.hoisted(() => ({ activateAndRevealWorkspace: vi.fn(() => ({})) })) -vi.mock('@/lib/worktree-activation', () => ({ - activateAndRevealWorkspace: routing.activateAndRevealWorkspace -})) - -globalThis.IS_REACT_ACT_ENVIRONMENT = true - -const TAB_ID = 'tab-1' -const LEAF_ID = '11111111-1111-4111-8111-111111111111' -const PANE_KEY = makePaneKey(TAB_ID, LEAF_ID) -const TITLE = "This terminal was opened before Orca's Claude account update" -const ACCOUNT: ClaudeManagedAccount = { - id: 'a', - email: 'a@example.test', - authMethod: 'subscription-oauth', - managedAuthPath: '/unused', - createdAt: 0, - updatedAt: 0, - lastAuthenticatedAt: 0 -} -let paneElement: HTMLDivElement -let root: Root -let openedBeforeClaudeAccounts: ReturnType Promise>> -let nextPtyId = 0 -let ptyId: string - -class ResizeObserverStub { - observe(): void {} - disconnect(): void {} -} - -function setState(settings: Partial, agent: 'claude' | 'codex' = 'claude'): void { - useAppStore.setState({ - settings: { ...getDefaultSettings('/home/me'), ...settings }, - paneForegroundAgentByPaneKey: { [PANE_KEY]: { agent, shellForeground: false } } - }) -} - -const SELECTED = { claudeManagedAccounts: [ACCOUNT], activeClaudeManagedAccountId: 'a' } - -async function renderBanner(): Promise { - await act(async () => { - root.render() - }) -} - -function button(label: string): HTMLButtonElement { - const match = Array.from(document.querySelectorAll('button')).find( - (candidate) => candidate.textContent?.trim() === label - ) - if (!match) { - throw new Error(`missing ${label} button`) - } - return match -} - -beforeEach(() => { - vi.stubGlobal('ResizeObserver', ResizeObserverStub) - useAppStore.setState(useAppStore.getInitialState(), true) - ptyId = `pty-${(nextPtyId += 1)}` - paneElement = document.createElement('div') - document.body.appendChild(paneElement) - root = createRoot(paneElement) - openedBeforeClaudeAccounts = vi.fn(() => Promise.resolve(true)) - Object.defineProperty(window, 'api', { - configurable: true, - value: { pty: { openedBeforeClaudeAccounts } } - }) -}) - -afterEach(() => { - act(() => root.unmount()) - paneElement.remove() - vi.unstubAllGlobals() - useAppStore.setState(useAppStore.getInitialState(), true) -}) - -describe('ClaudeOldTerminalBanner', () => { - it('says Claude in an older terminal uses System default, with a new terminal and Learn more', async () => { - setState(SELECTED) - await renderBanner() - expect(openedBeforeClaudeAccounts).toHaveBeenCalledWith(ptyId) - expect(paneElement.textContent).toContain(TITLE) - expect(paneElement.textContent).toContain("so claude here uses System default's login.") - button('Open new terminal') - expect(() => button("Don't show again")).toThrow() - - await act(async () => button('Learn more').click()) - expect(document.body.textContent).toContain('Open a new terminal to use the selected account.') - }) - - it('opens a new terminal beside the tab from the banner', async () => { - const openNewTerminalTabInActiveWorkspace = vi.fn(() => Promise.resolve()) - setState(SELECTED) - useAppStore.setState({ - unifiedTabsByWorktree: { - 'wt-1': [ - { - id: 'unified-1', - entityId: TAB_ID, - groupId: 'group-2', - worktreeId: 'wt-1', - contentType: 'terminal', - label: 'Terminal', - customLabel: null, - color: null, - sortOrder: 0, - createdAt: 0 - } - ] - }, - openNewTerminalTabInActiveWorkspace - }) - await renderBanner() - await act(async () => button('Open new terminal').click()) - expect(openNewTerminalTabInActiveWorkspace).toHaveBeenCalledWith('group-2') - }) - - it('stays hidden for a terminal from this version, an SSH pane, or one after Dismiss', async () => { - setState(SELECTED) - openedBeforeClaudeAccounts.mockResolvedValue(false) - await renderBanner() - expect(paneElement.textContent).toBe('') - - openedBeforeClaudeAccounts.mockResolvedValue(true) - ptyId = `pty-${(nextPtyId += 1)}` - await renderBanner() - await act(async () => button('Dismiss').click()) - expect(paneElement.textContent).toBe('') - }) - - it('never asks without Claude in the pane or a selected account', async () => { - setState(SELECTED, 'codex') - await renderBanner() - setState({ claudeManagedAccounts: [ACCOUNT], activeClaudeManagedAccountId: null }) - await renderBanner() - setState({ claudeManagedAccounts: [], activeClaudeManagedAccountId: 'a' }) - await renderBanner() - expect(openedBeforeClaudeAccounts).not.toHaveBeenCalled() - expect(paneElement.textContent).toBe('') - }) -}) diff --git a/src/renderer/src/components/terminal-pane/open-terminal-beside-tab.ts b/src/renderer/src/components/terminal-pane/open-terminal-beside-tab.ts deleted file mode 100644 index a63aafa10b17..000000000000 --- a/src/renderer/src/components/terminal-pane/open-terminal-beside-tab.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { useAppStore } from '@/store' -import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' -import { createFloatingWorkspaceTerminalTab } from '@/lib/floating-workspace-tab-creation' -import { revealFloatingWorkspacePanel } from '@/lib/floating-workspace-panel-reveal' -import { activateAndRevealWorkspace } from '@/lib/worktree-activation' - -/** Opens a new terminal where this tab lives; a new terminal's shell has Orca's agent functions. */ -export function openTerminalBesideTab(terminalTabId: string): boolean { - const state = useAppStore.getState() - const tab = Object.values(state.unifiedTabsByWorktree) - .flat() - .find( - (candidate) => candidate.contentType === 'terminal' && candidate.entityId === terminalTabId - ) - if (!tab) { - return false - } - if (tab.worktreeId === FLOATING_TERMINAL_WORKTREE_ID) { - revealFloatingWorkspacePanel(state) - void createFloatingWorkspaceTerminalTab(state) - return true - } - // Why always: Activity can show this pane behind its own view even when its workspace is active. - if (activateAndRevealWorkspace(tab.worktreeId) === false) { - return false - } - void useAppStore.getState().openNewTerminalTabInActiveWorkspace(tab.groupId) - return true -} diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 43e1931d0560..2332d1aa4afc 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2876,8 +2876,6 @@ "chooseClaudeAccount": "Choose or add one in Claude Accounts settings.", "chooseClaudeAccountThenRunCommand": "Choose or add one in Claude Accounts settings, then run /{{command}} again.", "chooseClaudeAccountThenSend": "Choose or add one in Claude Accounts settings, then send your message again.", - "claudeAccountFolderMissing": "The selected Claude account's folder is missing. Sign in to it again or choose another account.", - "claudeAccountSetupFailed": "The selected Claude account could not be set up. Try again or choose another account.", "claudeManagedNotSignedIn": "This Claude account isn't signed in. Sign in again in Claude Accounts settings.", "claudeSystemNotSignedIn": "Claude isn't signed in. Run `claude` and sign in with /login, or choose an account in Claude Accounts settings.", "cliMissing": "{{agent}} wasn't found on the computer running this chat. Install it, or check its Command in Settings → Agents.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 81cf2f502962..09cf619f4935 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -323,10 +323,7 @@ "fileExplorer": "file explorer" }, "followSymlinkedDirectories": "Follow symlinked directories", - "followSymlinkedDirectoriesDescription": "Search linked folders in Quick Open and expand local linked folders in File Explorer.", - "accounts": { - "claudeRemoveFolder": "Orca deletes this account's Claude folder and its sign-in from this computer. Chat history shared with System default stays. If it is selected, the next Claude you start uses System default." - } + "followSymlinkedDirectoriesDescription": "Search linked folders in Quick Open and expand local linked folders in File Explorer." }, "menu": { "checkForUpdates": "Check for Updates...", @@ -3948,6 +3945,7 @@ "54e8d6bb2d": "Fable", "d79c3362c4": "5h", "a79c64f87e": "Fable", + "8295903d17": "Restart live Claude terminals before continuing old conversations after switching.", "c98ea88392": "No other accounts", "9332ba8684": "Switch to", "d450654fa2": "Claude Account", @@ -4210,16 +4208,6 @@ "message": "Run cursor-agent login in a terminal on the computer running Orca, then retry usage." }, "plan": "Plan" - }, - "claude": { - "waiting": { - "label": "Updates when Claude runs", - "message": "Claude usage updates the next time Claude runs." - }, - "expired": { - "label": "Sign-in expired", - "message": "Claude usage has expired. Start Claude in this account to refresh it." - } } }, "SshTargetStatusRow": { @@ -6742,6 +6730,7 @@ "AccountsPane": { "c2d2751587": "Remove Account", "dbb9626ed1": "Cancel", + "854ebbcc45": "Orca will delete the managed Claude auth for this saved account. If it is currently active, Orca falls back to the system default Claude login.", "63843e37e2": "Remove Claude Account?", "380a7736cc": "Removing this account permanently deletes its managed Codex home, including all Codex session history and MCP logins stored inside. This cannot be undone. If the account is currently active, Orca falls back to the system default Codex login.", "0d47394635": "Remove Codex Account?", @@ -6793,10 +6782,12 @@ "3fe7862418": "No managed Claude accounts for {{value0}}. Orca will use that environment's system default Claude login until you add one here.", "3455cf43fa": "Claude login.", "fcc4093fc1": "Use your current {{value0}} Codex login.", + "79e484c3b2": "Optional account switcher for the shared Claude auth files.", "8bbfd74556": "Claude Accounts", "72b36ea174": "Optional. Orca can use your normal Claude login; add accounts only if you want quick switching without moving chat sessions.", "26ef4b55be": "Claude", "2743cdc0af": "Claude account update failed.", + "b15ce90870": "{{value0}} -> {{value1}}. Restart live Claude terminals before continuing old sessions.", "f921d32606": "Claude account updated.", "5bf8764953": "Codex account update failed.", "9baf45d071": "This device", @@ -15139,11 +15130,11 @@ "d90d2e1f6d": "Track session and weekly usage.", "8919321417": "Codex sign-in failed.", "c7b90c140b": "Codex account added.", + "4e71d72912": "Claude sign-in failed.", "9ddeb558f9": "Claude account added.", "29d0653961": "Sign in", "945865332e": "Signing in", - "accountStatusUnknown": "Account status unknown", - "4e71d72912": "Claude sign-in failed." + "accountStatusUnknown": "Account status unknown" }, "UsagePage": { "64265cb295": "29% used 5h", @@ -18032,8 +18023,6 @@ "managedAccountUnsupported": "While a Claude account is added in WSL, Claude chats need a Windows Claude account.", "launchFolderMissing": "The folder this chat ran in no longer exists. Restore it to continue this chat.", "historyInOtherAccount": "This chat's history is in another Claude account. Switch back to that account to continue it.", - "claudeAccountFolderMissing": "The selected Claude account's folder is missing. Sign in to it again or choose another account.", - "claudeAccountSetupFailed": "The selected Claude account could not be set up. Try again or choose another account.", "agentCommandNotRunnable": "{{agent}}'s Command in Settings → Agents must be a program path or name Orca can find, with no arguments or variables. Change it or reset it.", "chooseClaudeAccount": "Choose or add one in Claude Accounts settings.", "chooseClaudeAccountThenRunCommand": "Choose or add one in Claude Accounts settings, then run /{{command}} again.", @@ -18656,9 +18645,6 @@ "continue": "Continue", "failed": "Couldn't continue this chat. Try again, or send a message.", "explanation": "Continue, and the agent first checks whether its last step finished." - }, - "claudeSignIn": { - "signingIn": "Signing in…" } }, "tab": { @@ -19361,6 +19347,9 @@ "oldTerminalDialogRisk": "Codex sessions that share a server can end together, and agent status can be wrong.", "oldTerminalDialogCause": "Older terminals still share a Codex server. Open a new terminal to run Codex on a separate server.", "fix": "Fix", + "openNewTerminal": "Open new terminal", + "dontShowAgain": "Don't show again", + "dismiss": "Dismiss", "dialogTitle": "Give each Codex tab its own server", "dialogDescription": "Codex sessions started directly in a terminal share one background server. Orca keeps the Codex sessions it starts separate. When sessions share a server, closing one can end the others, and agent status can be wrong.", "copy": "Copy", @@ -19371,6 +19360,7 @@ "step2Title": "Stop the running shared server", "undo": "To undo, run codex features enable daemon_auto_start.", "step2Warning": "Closes any open Codex sessions that share the server", + "learnMore": "Learn more", "turnOff": "Turn off", "turningOff": "Turning off…", "turnedOff": "Turned off", @@ -19386,18 +19376,6 @@ }, "paneMove": { "failed": "Couldn't move the pane to a new tab." - }, - "claudeOldTerminalBanner": { - "title": "This terminal was opened before Orca's Claude account update, so claude here uses System default's login.", - "dialogTitle": "Why claude here doesn't use the selected account", - "dialogDescription": "Orca now keeps each Claude account in its own folder, and claude in a new terminal follows the account you select.", - "dialogCause": "Terminals opened before this update don't, so claude here signs in with System default's login. Open a new terminal to use the selected account." - }, - "paneTopBanner": { - "openNewTerminal": "Open new terminal", - "learnMore": "Learn more", - "dontShowAgain": "Don't show again", - "dismiss": "Dismiss" } }, "fileExplorer": { @@ -19488,24 +19466,6 @@ "accounts": "accounts" } }, - "claude": { - "added": "Account added. Select it to use it for the next Claude you start.", - "nextLaunch": "{{value0}} → {{value1}}. The next Claude you start uses it. Running sessions keep their account.", - "profileSwitching": "Switching applies to the next Claude you start in any tab. Running sessions keep their account.", - "systemDefaultNamed": "System default: {{value0}}", - "systemDefaultIsSaved": "System default is signed in as {{value0}}, which is also a saved account. An earlier Orca version may have copied that login there. If it is not your own login, select System default and run claude /login.", - "systemDefaultIsSavedDismiss": "Dismiss", - "signInRequired": "Sign in again to use this account", - "signInAgain": "Sign in again", - "usageSignInLabel": "Sign in again", - "usageSignInMessage": "The selected Claude account needs you to sign in again. Open Settings > AI Provider Accounts, or choose System default.", - "signIn": "Sign in", - "signInNotice": { - "title": "Finish setting up your Claude accounts", - "description": "Sign in to each saved account once and you're ready to switch anytime.", - "action": "Sign in to {{value0}}" - } - }, "managedData": { "description": "Add accounts by running this command in a terminal on the Orca host. Selection applies to new explicit agent launches on that host; direct SSH relay and Windows-hosted WSL launches use their own credentials.", "copied": "Copied", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 76bbfc173fc2..9007cb4c45d4 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -3742,6 +3742,7 @@ "54e8d6bb2d": "Fable", "d79c3362c4": "5h", "a79c64f87e": "Fable", + "8295903d17": "Reinicia los terminales Claude activos antes de continuar conversaciones antiguas después de cambiar.", "c98ea88392": "Ninguna otra cuenta", "9332ba8684": "Cambiar a", "d450654fa2": "Cuenta de Claude", @@ -6507,6 +6508,7 @@ "AccountsPane": { "c2d2751587": "Eliminar cuenta", "dbb9626ed1": "Cancelar", + "854ebbcc45": "Orca eliminará la autenticación de Claude gestionada para esta cuenta guardada. Si está activa actualmente, Orca volverá al login predeterminado de Claude del sistema.", "63843e37e2": "¿Eliminar la cuenta de Claude?", "380a7736cc": "Eliminar esta cuenta borra permanentemente su home de Codex gestionado, incluido todo el historial de sesiones de Codex y los inicios de sesión de MCP almacenados dentro. Esto no se puede deshacer. Si la cuenta está activa, Orca vuelve al login predeterminado de Codex del sistema.", "0d47394635": "¿Eliminar cuenta de Codex?", @@ -6554,10 +6556,12 @@ "3fe7862418": "No hay cuentas de Claude gestionadas para {{value0}}. Orca usará el login predeterminado de Claude del sistema de ese entorno hasta que agregues una aquí.", "3455cf43fa": "Login de Claude.", "fcc4093fc1": "Usa tu login actual de Codex de {{value0}}.", + "79e484c3b2": "Switcher de cuenta opcional para los archivos de autenticación compartidos de Claude.", "8bbfd74556": "Cuentas de Claude", "72b36ea174": "Opcional. Orca puede usar tu login normal de Claude; agrega cuentas solo si quieres cambiar rápido sin mover sesiones de chat.", "26ef4b55be": "Claude", "2743cdc0af": "Error al actualizar la cuenta de Claude.", + "b15ce90870": "{{value0}} -> {{value1}}. Reinicia los terminales Claude activos antes de continuar sesiones antiguas.", "f921d32606": "Cuenta Claude actualizada.", "5bf8764953": "Error al actualizar la cuenta del Codex.", "9baf45d071": "Este dispositivo", @@ -14900,6 +14904,7 @@ "d90d2e1f6d": "Controla el uso por sesión y semanal.", "8919321417": "Error al iniciar sesión en Codex.", "c7b90c140b": "Cuenta de Codex agregada.", + "4e71d72912": "Error al iniciar sesión en Claude.", "9ddeb558f9": "Cuenta de Claude agregada.", "29d0653961": "Iniciar sesión", "945865332e": "Iniciando sesión", @@ -17800,8 +17805,6 @@ "managedAccountUnsupported": "Mientras haya una cuenta de Claude agregada en WSL, los chats de Claude necesitan una cuenta de Claude de Windows.", "launchFolderMissing": "La carpeta en la que se ejecutaba este chat ya no existe. Restáurela para continuar este chat.", "historyInOtherAccount": "El historial de este chat está en otra cuenta de Claude. Vuelva a esa cuenta para continuarlo.", - "claudeAccountFolderMissing": "Falta la carpeta de la cuenta de Claude seleccionada. Vuelva a iniciar sesión en ella o elija otra cuenta.", - "claudeAccountSetupFailed": "No se pudo configurar la cuenta de Claude seleccionada. Vuelva a intentarlo o elija otra cuenta.", "agentCommandNotRunnable": "{{agent}}: el Comando en Ajustes → Agentes debe ser la ruta o el nombre de un programa que Orca pueda encontrar, sin argumentos ni variables. Cámbielo o restablézcalo con Reiniciar.", "chooseClaudeAccount": "Elija o agregue una en la configuración de Cuentas de Claude.", "chooseClaudeAccountThenRunCommand": "Elija o agregue una en la configuración de Cuentas de Claude y vuelva a ejecutar /{{command}}.", @@ -19349,6 +19352,7 @@ }, "codexSharedServerBanner": { "fix": "Solucionar", + "dismiss": "Descartar", "copied": "Copiado", "done": "Hecho", "cancel": "Cancelar", @@ -19359,6 +19363,8 @@ "oldTerminalDialogTitle": "Por qué este Codex comparte un servidor", "oldTerminalDialogRisk": "Las sesiones de Codex que comparten un servidor pueden terminar juntas, y el estado del agente puede ser incorrecto.", "oldTerminalDialogCause": "Los terminales antiguos aún comparten un servidor de Codex. Abre un terminal nuevo para ejecutar Codex en un servidor aparte.", + "openNewTerminal": "Abrir terminal nuevo", + "dontShowAgain": "No volver a mostrar", "dialogTitle": "Dar a cada pestaña de Codex su propio servidor", "dialogDescription": "Las sesiones de Codex iniciadas directamente en un terminal comparten un servidor en segundo plano. Orca mantiene separadas las sesiones de Codex que inicia. Cuando las sesiones comparten un servidor, cerrar una puede terminar con las demás, y el estado del agente puede ser incorrecto.", "copy": "Copiar", @@ -19367,6 +19373,7 @@ "step2Title": "Detener el servidor compartido en ejecución", "undo": "Para deshacerlo, ejecuta codex features enable daemon_auto_start.", "step2Warning": "Cierra cualquier sesión de Codex abierta que comparta el servidor", + "learnMore": "Más información", "turnOff": "Desactivar", "turningOff": "Desactivando…", "turnedOff": "Desactivado", @@ -19389,12 +19396,6 @@ }, "paneMove": { "failed": "No se pudo mover el panel a una pestaña nueva." - }, - "paneTopBanner": { - "openNewTerminal": "Abrir terminal nuevo", - "learnMore": "Más información", - "dontShowAgain": "No volver a mostrar", - "dismiss": "Descartar" } }, "quickOpen": { diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 99821d665151..76da9f0fdfef 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -3839,6 +3839,7 @@ "54e8d6bb2d": "Fable", "d79c3362c4": "5 h", "a79c64f87e": "Fable", + "8295903d17": "Redémarrez les terminaux Claude actifs avant de reprendre d'anciennes conversations après un changement de compte.", "c98ea88392": "Aucun autre compte", "9332ba8684": "Basculer vers", "d450654fa2": "Compte Claude", @@ -6612,6 +6613,7 @@ "AccountsPane": { "c2d2751587": "Supprimer le compte", "dbb9626ed1": "Annuler", + "854ebbcc45": "Orca supprimera l'authentification Claude gérée pour ce compte enregistré. S'il est actuellement actif, Orca revient à la connexion Claude par défaut du système.", "63843e37e2": "Supprimer le compte Claude ?", "380a7736cc": "La suppression de ce compte efface définitivement son home Codex géré, y compris tout l'historique de sessions Codex et les connexions MCP stockés dedans. Cette action est irréversible. Si le compte est actuellement actif, Orca revient à la connexion Codex par défaut du système.", "0d47394635": "Supprimer le compte Codex ?", @@ -6659,10 +6661,12 @@ "3fe7862418": "Aucun compte Claude géré pour {{value0}}. Orca utilisera la connexion Claude par défaut du système de cet environnement jusqu'à ce que vous en ajoutiez un ici.", "3455cf43fa": " Claude.", "fcc4093fc1": "Utilisez votre connexion Codex {{value0}} actuelle.", + "79e484c3b2": "Sélecteur de comptes facultatif pour les fichiers d'authentification Claude partagés.", "8bbfd74556": "Comptes Claude", "72b36ea174": "Facultatif. Orca peut utiliser votre connexion Claude habituelle ; ajoutez des comptes seulement si vous voulez changer rapidement sans déplacer les sessions de chat.", "26ef4b55be": "Claude", "2743cdc0af": "Échec de la mise à jour du compte Claude.", + "b15ce90870": "{{value0}} -> {{value1}}. Redémarrez les terminaux Claude actifs avant de poursuivre les anciennes sessions.", "f921d32606": "Compte Claude mis à jour.", "5bf8764953": "Échec de la mise à jour du compte Codex.", "9baf45d071": "Cet appareil", @@ -15005,6 +15009,7 @@ "d90d2e1f6d": "Suivez l'utilisation par session et par semaine.", "8919321417": "Échec de la connexion Codex.", "c7b90c140b": "Compte Codex ajouté.", + "4e71d72912": "Échec de la connexion Claude.", "9ddeb558f9": "Compte Claude ajouté.", "29d0653961": "Se connecter", "945865332e": "Connexion en cours", @@ -17901,8 +17906,6 @@ "managedAccountUnsupported": "Tant qu'un compte Claude est ajouté dans WSL, les chats Claude nécessitent un compte Claude Windows.", "launchFolderMissing": "Le dossier dans lequel ce chat s'exécutait n'existe plus. Restaurez-le pour poursuivre ce chat.", "historyInOtherAccount": "L'historique de ce chat se trouve dans un autre compte Claude. Revenez à ce compte pour le poursuivre.", - "claudeAccountFolderMissing": "Le dossier du compte Claude sélectionné est introuvable. Reconnectez-vous à ce compte ou choisissez-en un autre.", - "claudeAccountSetupFailed": "Le compte Claude sélectionné n'a pas pu être configuré. Réessayez ou choisissez un autre compte.", "agentCommandNotRunnable": "{{agent}} : la Commande dans Paramètres → Agents doit être le chemin ou le nom d'un programme qu'Orca peut trouver, sans arguments ni variables. Modifiez-la ou réinitialisez-la.", "chooseClaudeAccount": "Choisissez-en un ou ajoutez-en un dans les paramètres Comptes Claude.", "chooseClaudeAccountThenRunCommand": "Choisissez-en un ou ajoutez-en un dans les paramètres Comptes Claude, puis relancez /{{command}}.", @@ -19396,6 +19399,9 @@ "oldTerminalDialogRisk": "Les sessions Codex qui partagent un serveur peuvent se terminer ensemble et le statut de l'agent peut être erroné.", "oldTerminalDialogCause": "Les terminaux plus anciens partagent toujours un serveur Codex. Ouvrez un nouveau terminal pour exécuter Codex sur un serveur distinct.", "fix": "Réparer", + "openNewTerminal": "Ouvrir un nouveau terminal", + "dontShowAgain": "Ne plus montrer", + "dismiss": "Rejeter", "dialogTitle": "Donnez à chaque onglet du Codex son propre serveur", "dialogDescription": "Les sessions Codex démarrées directement dans un terminal partagent un serveur en arrière-plan. Orca conserve les sessions Codex qu'il démarre séparément. Lorsque des sessions partagent un serveur, la fermeture de l'une peut mettre fin aux autres et le statut de l'agent peut être erroné.", "copy": "Copie", @@ -19406,6 +19412,7 @@ "step2Title": "Arrêtez le serveur partagé en cours d'exécution", "undo": "Pour annuler, exécutez codex features enable daemon_auto_start.", "step2Warning": "Ferme toutes les sessions Codex ouvertes qui partagent le serveur", + "learnMore": "Apprendre encore plus", "turnOff": "Éteindre", "turningOff": "Éteindre…", "turnedOff": "Éteint", @@ -19421,12 +19428,6 @@ }, "paneMove": { "failed": "Impossible de déplacer le volet vers un nouvel onglet." - }, - "paneTopBanner": { - "openNewTerminal": "Ouvrir un nouveau terminal", - "learnMore": "Apprendre encore plus", - "dontShowAgain": "Ne plus montrer", - "dismiss": "Rejeter" } }, "fileSearch": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 830d40bd3aef..7a3b7b5fdc98 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -3742,6 +3742,7 @@ "54e8d6bb2d": "Fable", "d79c3362c4": "5時間", "a79c64f87e": "Fable", + "8295903d17": "切り替え後に古い会話を続ける前に、ライブ Claude ターミナルを再起動します。", "c98ea88392": "他のアカウントはありません", "9332ba8684": "切り替え先", "d450654fa2": "Claude アカウント", @@ -6492,6 +6493,7 @@ "AccountsPane": { "c2d2751587": "アカウントの削除", "dbb9626ed1": "キャンセル", + "854ebbcc45": "Orca は、この保存されたアカウントの管理対象 Claude 認証を削除します。現在アクティブな場合、Orca はシステムのデフォルトの Claude ログインに戻ります。", "63843e37e2": "Claude アカウントを削除しますか?", "380a7736cc": "このアカウントを削除すると、管理対象の Codex ホームと、その中に保存されているすべての Codex セッション履歴および MCP ログインが完全に削除されます。この操作は元に戻せません。アカウントが現在アクティブな場合、Orca はシステムのデフォルトの Codex ログインに戻ります。", "0d47394635": "Codex アカウントを削除しますか?", @@ -6539,10 +6541,12 @@ "3fe7862418": "{{value0}} の管理対象 Claude アカウントはありません。ここで追加するまで、Orca はその環境のシステムデフォルトの Claude ログインを使用します。", "3455cf43fa": "Claude のログイン。", "fcc4093fc1": "現在の {{value0}} Codex ログインを使用します。", + "79e484c3b2": "共有 Claude 認証ファイル用のオプションのアカウントスイッチャー。", "8bbfd74556": "Claude アカウント", "72b36ea174": "任意。Orca は通常の Claude ログインを使用できます。チャットセッションを移さずにすばやく切り替えたい場合にのみ、アカウントを追加してください。", "26ef4b55be": "Claude", "2743cdc0af": "Claude アカウントの更新に失敗しました。", + "b15ce90870": "{{value0}} -> {{value1}}。古いセッションを続行する前に、ライブ Claude ターミナルを再起動します。", "f921d32606": "Claude アカウントが更新されました。", "5bf8764953": "Codex アカウントの更新に失敗しました。", "9baf45d071": "このデバイス", @@ -14900,6 +14904,7 @@ "d90d2e1f6d": "セッションと毎週の使用状況を追跡します。", "8919321417": "Codex のサインインに失敗しました。", "c7b90c140b": "Codex アカウントが追加されました。", + "4e71d72912": "Claude のサインインに失敗しました。", "9ddeb558f9": "Claude アカウントが追加されました。", "29d0653961": "サインイン", "945865332e": "サインインする", @@ -17835,8 +17840,6 @@ "managedAccountUnsupported": "WSL に Claude アカウントが追加されている間、Claude チャットには Windows の Claude アカウントが必要です。", "launchFolderMissing": "このチャットが実行されていたフォルダーは存在しなくなりました。このチャットを続けるには、フォルダーを復元してください。", "historyInOtherAccount": "このチャットの履歴は別の Claude アカウントにあります。続けるには、そのアカウントに切り替えてください。", - "claudeAccountFolderMissing": "選択した Claude アカウントのフォルダーが見つかりません。もう一度サインインするか、別のアカウントを選択してください。", - "claudeAccountSetupFailed": "選択した Claude アカウントを設定できませんでした。もう一度試すか、別のアカウントを選択してください。", "agentCommandNotRunnable": "[設定] → [Agent] にある {{agent}} の [コマンド] には、Orca が見つけられるプログラムのパスまたは名前を、引数や変数なしで指定する必要があります。変更するか、[リセット] してください。", "chooseClaudeAccount": "Claude アカウントの設定で選択または追加してください。", "chooseClaudeAccountThenRunCommand": "Claude アカウントの設定で選択または追加してから、/{{command}} をもう一度実行してください。", @@ -19396,6 +19399,9 @@ "oldTerminalDialogRisk": "サーバーを共有する Codex セッションが同時に終了する可能性があり、Agent のステータスが間違っている可能性があります。", "oldTerminalDialogCause": "古いターミナルは依然として Codex サーバーを共有しています。新規ターミナルを開いて、別のサーバーで Codex を実行します。", "fix": "修理", + "openNewTerminal": "新規ターミナルを開く", + "dontShowAgain": "二度と表示しないでください", + "dismiss": "閉じる", "dialogTitle": "各 Codex タブに独自のサーバーを与える", "dialogDescription": "ターミナルで直接開始された Codex セッションは、1 つのバックグラウンドサーバーを共有します。 Orca は、開始する Codex セッションを個別に保持します。セッションがサーバーを共有している場合、1 つを閉じると他のセッションも終了し、Agent のステータスが不正になる可能性があります。", "copy": "コピー", @@ -19406,6 +19412,7 @@ "step2Title": "稼働中の共有サーバーを停止します", "undo": "元に戻すには、codex features enable daemon_auto_start を実行します。", "step2Warning": "サーバーを共有している開いている Codex セッションをすべて閉じます。", + "learnMore": "詳しく見る", "turnOff": "消す", "turningOff": "オフにしています…", "turnedOff": "オフになっています", @@ -19421,12 +19428,6 @@ }, "paneMove": { "failed": "ペインを新規タブに移動できませんでした。" - }, - "paneTopBanner": { - "openNewTerminal": "新規ターミナルを開く", - "learnMore": "詳しく見る", - "dontShowAgain": "二度と表示しないでください", - "dismiss": "閉じる" } }, "fileSearch": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 355225c9e462..db78d9b861b2 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -3742,6 +3742,7 @@ "54e8d6bb2d": "Fable", "d79c3362c4": "5h", "a79c64f87e": "Fable", + "8295903d17": "전환 후 이전 대화를 계속하려면 활성 Claude terminal을 다시 시작하세요.", "c98ea88392": "다른 계정 없음", "9332ba8684": "다음으로 전환", "d450654fa2": "Claude 계정", @@ -6492,6 +6493,7 @@ "AccountsPane": { "c2d2751587": "계정 삭제", "dbb9626ed1": "취소", + "854ebbcc45": "Orca는 저장된 계정에 대해 관리되는 Claude 인증을 삭제합니다. 현재 활성화된 경우 Orca는 시스템 기본 Claude 로그인으로 대체됩니다.", "63843e37e2": "Claude 계정을 삭제하시겠습니까?", "380a7736cc": "이 계정을 제거하면 관리되는 Codex 홈과 그 안에 저장된 모든 Codex 세션 기록 및 MCP 로그인이 영구적으로 삭제됩니다. 이 작업은 실행 취소할 수 없습니다. 계정이 현재 활성 상태인 경우 Orca는 시스템 기본 Codex 로그인으로 전환됩니다.", "0d47394635": "Codex 계정을 제거하시겠습니까?", @@ -6539,10 +6541,12 @@ "3fe7862418": "{{value0}}에 대한 관리된 Claude 계정이 없습니다. Orca는 여기에 추가할 때까지 해당 환경의 시스템 기본 Claude 로그인을 사용합니다.", "3455cf43fa": "Claude 로그인.", "fcc4093fc1": "현재 {{value0}} Codex 로그인을 사용하세요.", + "79e484c3b2": "공유 Claude 인증 파일에 대한 선택적 계정 전환기입니다.", "8bbfd74556": "Claude 계정", "72b36ea174": "선택 사항. Orca는 일반적인 Claude 로그인을 사용할 수 있습니다. 채팅 세션을 이동하지 않고 빠르게 전환하려는 경우에만 계정을 추가하세요.", "26ef4b55be": "Claude", "2743cdc0af": "Claude 계정 업데이트에 실패했습니다.", + "b15ce90870": "{{value0}} -> {{value1}}. 이전 세션을 계속하기 전에 실행 중인 Claude terminals을 다시 시작하세요.", "f921d32606": "Claude 계정이 업데이트되었습니다.", "5bf8764953": "Codex 계정 업데이트에 실패했습니다.", "9baf45d071": "이 기기", @@ -14900,6 +14904,7 @@ "d90d2e1f6d": "세션 및 주간 사용량을 추적합니다.", "8919321417": "Codex 로그인에 실패했습니다.", "c7b90c140b": "Codex 계정이 추가되었습니다.", + "4e71d72912": "Claude 로그인에 실패했습니다.", "9ddeb558f9": "Claude 계정이 추가되었습니다.", "29d0653961": "로그인", "945865332e": "로그인 중", @@ -17835,8 +17840,6 @@ "managedAccountUnsupported": "WSL에 Claude 계정이 추가되어 있는 동안에는 Claude 채팅에 Windows Claude 계정이 필요합니다.", "launchFolderMissing": "이 채팅이 실행되던 폴더가 더 이상 존재하지 않습니다. 이 채팅을 계속하려면 폴더를 복원하세요.", "historyInOtherAccount": "이 채팅의 기록은 다른 Claude 계정에 있습니다. 계속하려면 해당 계정으로 다시 전환하세요.", - "claudeAccountFolderMissing": "선택한 Claude 계정의 폴더가 없습니다. 다시 로그인하거나 다른 계정을 선택하세요.", - "claudeAccountSetupFailed": "선택한 Claude 계정을 설정할 수 없습니다. 다시 시도하거나 다른 계정을 선택하세요.", "agentCommandNotRunnable": "{{agent}}의 설정 → 에이전트 → 명령은 Orca가 찾을 수 있는 프로그램 경로나 이름이어야 하며, 인수나 변수를 포함할 수 없습니다. 변경하거나 재설정하세요.", "chooseClaudeAccount": "Claude 계정 설정에서 선택하거나 추가하세요.", "chooseClaudeAccountThenRunCommand": "Claude 계정 설정에서 선택하거나 추가한 다음 /{{command}}을(를) 다시 실행하세요.", @@ -19396,6 +19399,9 @@ "oldTerminalDialogRisk": "서버를 공유하는 Codex 세션은 함께 종료될 수 있으며 에이전트 상태가 잘못될 수 있습니다.", "oldTerminalDialogCause": "이전 터미널은 여전히 Codex 서버를 공유합니다. 새 터미널을 열어 별도의 서버에서 Codex를 실행합니다.", "fix": "자물쇠", + "openNewTerminal": "새 터미널 열기", + "dontShowAgain": "다시 보지 않기", + "dismiss": "닫기", "dialogTitle": "각 Codex 탭에 자체 서버를 제공합니다", "dialogDescription": "Codex 세션은 터미널 공유 백그라운드 서버에서 직접 시작되었습니다. ORCA는 시작되는 Codex 세션을 별도로 유지합니다. 세션이 서버를 공유할 때 서버를 닫으면 다른 세션이 종료될 수 있으며 에이전트 상태가 잘못될 수 있습니다.", "copy": "복사", @@ -19406,6 +19412,7 @@ "step2Title": "실행 중인 공유 서버 중지", "undo": "되돌리려면 codex features enable daemon_auto_start를 실행하세요.", "step2Warning": "서버를 공유하는 열려 있는 Codex 세션을 닫습니다.", + "learnMore": "자세히 알아보기", "turnOff": "끄기", "turningOff": "끄는 중...", "turnedOff": "껐어요", @@ -19421,12 +19428,6 @@ }, "paneMove": { "failed": "분할 창을 새 탭으로 이동할 수 없습니다." - }, - "paneTopBanner": { - "openNewTerminal": "새 터미널 열기", - "learnMore": "자세히 알아보기", - "dontShowAgain": "다시 보지 않기", - "dismiss": "닫기" } }, "fileSearch": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index ecadc83a4c58..d55c9d9cc9ea 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -3742,6 +3742,7 @@ "54e8d6bb2d": "Fable", "d79c3362c4": "5小时", "a79c64f87e": "Fable", + "8295903d17": "切换后继续旧对话之前,请重新启动实时 Claude 终端。", "c98ea88392": "没有其他账户", "9332ba8684": "切换到", "d450654fa2": "Claude 账户", @@ -6492,6 +6493,7 @@ "AccountsPane": { "c2d2751587": "删除账户", "dbb9626ed1": "取消", + "854ebbcc45": "Orca 将删除此已保存账户的托管 Claude 身份验证。如果当前处于活动状态,Orca 将回退到系统默认的 Claude 登录名。", "63843e37e2": "删除 Claude 账户?", "380a7736cc": "移除此账户会永久删除其托管的 Codex 主目录,包括其中存储的所有 Codex 会话历史记录和 MCP 登录信息。此操作无法撤销。如果该账户当前处于活动状态,Orca 将回退到系统默认的 Codex 登录。", "0d47394635": "删除 Codex 账户?", @@ -6539,10 +6541,12 @@ "3fe7862418": "{{value0}} 没有受管理的 Claude 账户。Orca 将使用该环境的系统默认 Claude 登录,直到您在此处添加一个。", "3455cf43fa": "Claude 登录。", "fcc4093fc1": "使用您当前 {{value0}} Codex 登录名。", + "79e484c3b2": "用于共享 Claude 身份验证文件的可选账户切换器。", "8bbfd74556": "Claude 账户", "72b36ea174": "可选。Orca 可使用系统默认 Claude 登录;仅当你需要快速切换且不想迁移聊天会话时才添加账户。", "26ef4b55be": "Claude", "2743cdc0af": "Claude 账户更新失败。", + "b15ce90870": "{{value0}} -> {{value1}}。在继续旧会话之前重新启动实时 Claude 终端。", "f921d32606": "Claude 账户已更新。", "5bf8764953": "Codex 账户更新失败。", "9baf45d071": "当前设备", @@ -14900,6 +14904,7 @@ "d90d2e1f6d": "跟踪会话和每周使用情况。", "8919321417": "Codex 登录失败。", "c7b90c140b": "添加了 Codex 账户。", + "4e71d72912": "Claude 登录失败。", "9ddeb558f9": "已添加 Claude 账户。", "29d0653961": "登入", "945865332e": "登录", @@ -17800,8 +17805,6 @@ "managedAccountUnsupported": "当 WSL 中添加了 Claude 账户时,Claude 聊天需要 Windows Claude 账户。", "launchFolderMissing": "此聊天运行所在的文件夹已不存在。请恢复该文件夹以继续此聊天。", "historyInOtherAccount": "此聊天的历史记录位于另一个 Claude 账户中。请切换回该账户以继续此聊天。", - "claudeAccountFolderMissing": "所选 Claude 账户的文件夹不存在。请重新登录该账户或选择其他账户。", - "claudeAccountSetupFailed": "无法设置所选 Claude 账户。请重试或选择其他账户。", "agentCommandNotRunnable": "{{agent}} 在设置 → 智能体中的命令必须是 Orca 能找到的程序路径或名称,且不能包含参数或变量。请修改或重置。", "chooseClaudeAccount": "请在 Claude 账户设置中选择或添加一个。", "chooseClaudeAccountThenRunCommand": "请在 Claude 账户设置中选择或添加一个,然后重新运行 /{{command}}。", @@ -19396,6 +19399,9 @@ "oldTerminalDialogRisk": "共享服务器的 Codex 会话可能会一起结束,并且智能体状态可能会错误。", "oldTerminalDialogCause": "较旧的终端仍然共享 Codex 服务器。打开一个新终端以在单独的服务器上运行 Codex。", "fix": "使固定", + "openNewTerminal": "打开新终端", + "dontShowAgain": "不再显示", + "dismiss": "关闭", "dialogTitle": "为每个 Codex 选项卡提供自己的服务器", "dialogDescription": "Codex 会话直接在共享一台后台服务器的终端中启动。 Orca 将其启动的 Codex 会话分开保存。当会话共享一台服务器时,关闭一个服务器可能会结束其他服务器,并且智能体状态可能会错误。", "copy": "复制", @@ -19406,6 +19412,7 @@ "step2Title": "停止正在运行的共享服务器", "undo": "要撤消,请运行 codex features enable daemon_auto_start。", "step2Warning": "关闭共享服务器的所有打开的 Codex 会话", + "learnMore": "了解更多", "turnOff": "关", "turningOff": "正在关闭…", "turnedOff": "已关闭", @@ -19421,12 +19428,6 @@ }, "paneMove": { "failed": "无法将窗格移动到新选项卡。" - }, - "paneTopBanner": { - "openNewTerminal": "打开新终端", - "learnMore": "了解更多", - "dontShowAgain": "不再显示", - "dismiss": "关闭" } }, "fileSearch": { diff --git a/src/renderer/src/lib/claude-account-sign-in-notice.test.ts b/src/renderer/src/lib/claude-account-sign-in-notice.test.ts deleted file mode 100644 index d4515b47a60f..000000000000 --- a/src/renderer/src/lib/claude-account-sign-in-notice.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -// @vitest-environment happy-dom - -import { act } from 'react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { - ClaudeManagedAccountSummary, - ClaudeRateLimitAccountsState -} from '../../../shared/managed-account-types' -import { - mountHook, - noticeTestStore, - setNoticeState, - unmountHooks -} from '../components/terminal-pane/codex-notice-test-harness' -import { useClaudeAccountSignInNotice } from './claude-account-sign-in-notice' - -const { toastInfo, toastError } = vi.hoisted(() => ({ toastInfo: vi.fn(), toastError: vi.fn() })) -vi.mock('sonner', () => ({ toast: { info: toastInfo, error: toastError } })) -vi.mock('@/store', () => import('../components/terminal-pane/codex-notice-test-harness')) - -function account(id: string, needsSignIn = false): ClaudeManagedAccountSummary { - return { - id, - email: `${id}@example.test`, - authMethod: 'subscription-oauth', - createdAt: 0, - updatedAt: 0, - lastAuthenticatedAt: 0, - ...(needsSignIn ? { needsSignIn: true as const } : {}) - } -} - -let state: ClaudeRateLimitAccountsState -const list = vi.fn(() => Promise.resolve(state)) -const reauthenticate = vi.fn(() => Promise.resolve(state)) -const fetchSettings = vi.fn(() => Promise.resolve()) - -async function settle(): Promise { - await act(async () => { - await Promise.resolve() - }) -} - -beforeEach(() => { - vi.clearAllMocks() - state = { - accounts: [account('a', true), account('b', true)], - activeAccountId: 'b', - activeAccountIdsByRuntime: { host: 'b', wsl: {} } - } - vi.stubGlobal('api', { claudeAccounts: { list, reauthenticate } }) - noticeTestStore.setState( - { - claudeAccountSignInNoticeSeen: true, - markClaudeAccountSignInNoticeSeen: () => - noticeTestStore.setState({ claudeAccountSignInNoticeSeen: true }), - fetchSettings - }, - true - ) -}) - -afterEach(() => { - unmountHooks() - vi.unstubAllGlobals() -}) - -describe('useClaudeAccountSignInNotice', () => { - it('waits for the persisted flag, then shows once with Sign in for the selected account', async () => { - await mountHook(useClaudeAccountSignInNotice) - expect(list).not.toHaveBeenCalled() - - await setNoticeState({ claudeAccountSignInNoticeSeen: false }) - await settle() - expect(noticeTestStore.getState().claudeAccountSignInNoticeSeen).toBe(true) - expect(toastInfo).toHaveBeenCalledTimes(1) - const [title, options] = toastInfo.mock.calls[0] - expect(title).toBe('Finish setting up your Claude accounts') - expect(options).toMatchObject({ - description: "Sign in to each saved account once and you're ready to switch anytime.", - duration: Infinity, - action: { label: 'Sign in to b@example.test' } - }) - - await act(async () => options.action.onClick()) - await settle() - expect(reauthenticate).toHaveBeenCalledWith({ accountId: 'b' }) - expect(fetchSettings).toHaveBeenCalled() - }) - - it('signs in to the first account needing it when the selected one does not', async () => { - state.accounts = [account('a'), account('b', true)] - state.activeAccountIdsByRuntime = { host: 'a', wsl: {} } - await mountHook(useClaudeAccountSignInNotice) - await setNoticeState({ claudeAccountSignInNoticeSeen: false }) - await settle() - await act(async () => toastInfo.mock.calls[0][1].action.onClick()) - await settle() - expect(reauthenticate).toHaveBeenCalledWith({ accountId: 'b' }) - }) - - it('never shows without an account that needs a sign-in, and never asks again', async () => { - state.accounts = [account('a')] - await mountHook(useClaudeAccountSignInNotice) - await setNoticeState({ claudeAccountSignInNoticeSeen: false }) - await settle() - expect(toastInfo).not.toHaveBeenCalled() - expect(noticeTestStore.getState().claudeAccountSignInNoticeSeen).toBe(true) - expect(list).toHaveBeenCalledTimes(1) - }) -}) diff --git a/src/renderer/src/lib/claude-account-sign-in-notice.ts b/src/renderer/src/lib/claude-account-sign-in-notice.ts deleted file mode 100644 index f1892652aa92..000000000000 --- a/src/renderer/src/lib/claude-account-sign-in-notice.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { useEffect } from 'react' -import { toast } from 'sonner' -import { translate } from '@/i18n/i18n' -import { useAppStore } from '@/store' -import type { ClaudeManagedAccountSummary } from '../../../shared/managed-account-types' -import { isPairedWebClientWindow } from '@/lib/desktop-window-chrome' -import { claudeAccountToSignIn, signInToClaudeAccount } from './claude-account-sign-in' - -const NOTICE_TOAST_ID = 'claude-account-sign-in-notice' - -function showClaudeAccountSignInNotice(account: ClaudeManagedAccountSummary): void { - toast.info( - translate('accounts.claude.signInNotice.title', 'Finish setting up your Claude accounts'), - { - // Why a stable id: a late sync that resets the flag can't stack a second toast. - id: NOTICE_TOAST_ID, - description: translate( - 'accounts.claude.signInNotice.description', - "Sign in to each saved account once and you're ready to switch anytime." - ), - // Why no timeout: it is marked seen before showing, so an auto-close would lose it for good. - duration: Infinity, - action: { - label: translate('accounts.claude.signInNotice.action', 'Sign in to {{value0}}', { - value0: account.email - }), - onClick: () => void signInToClaudeAccount(account.id) - } - } - ) -} - -/** Once, on the first launch after the update: only when a saved account still needs a sign-in. */ -export function useClaudeAccountSignInNotice(): void { - // Why no hydration check: the flag defaults to true until the persisted value arrives. - const seen = useAppStore((s) => s.claudeAccountSignInNoticeSeen) - - useEffect(() => { - // Why skip paired web clients: the accounts are the host's, whose own window shows this. - if (seen || isPairedWebClientWindow()) { - return - } - let cancelled = false - void window.api.claudeAccounts - .list() - .then((state) => { - if (cancelled) { - return - } - // Why mark either way: the update happened once; a later sign-out is not this news. - useAppStore.getState().markClaudeAccountSignInNoticeSeen() - const accountId = claudeAccountToSignIn(state) - const account = state.accounts.find((candidate) => candidate.id === accountId) - if (account) { - showClaudeAccountSignInNotice(account) - } - }) - .catch((error: unknown) => console.warn('[claude-accounts] Sign-in notice skipped:', error)) - return () => { - cancelled = true - } - }, [seen]) -} diff --git a/src/renderer/src/lib/claude-account-sign-in.ts b/src/renderer/src/lib/claude-account-sign-in.ts deleted file mode 100644 index fb17da8744c9..000000000000 --- a/src/renderer/src/lib/claude-account-sign-in.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { toast } from 'sonner' -import { translate } from '@/i18n/i18n' -import { useAppStore } from '@/store' -import type { ClaudeRateLimitAccountsState } from '../../../shared/managed-account-types' -import { - getClaudeAccountErrorDescription, - isClaudeAccountCancellation -} from '@/components/settings/accounts-pane-action-errors' - -/** The selected account when it needs a sign-in, else the first saved account that does. */ -export function claudeAccountToSignIn(state: ClaudeRateLimitAccountsState): string | null { - const needing = state.accounts.filter((account) => account.needsSignIn) - const selected = state.activeAccountIdsByRuntime?.host ?? state.activeAccountId - return (needing.find((account) => account.id === selected) ?? needing[0])?.id ?? null -} - -/** Settings' Sign in again: a hidden `claude auth login` into the account's own folder. True once - * signed in; a failure says why in a toast, and a cancel says nothing. */ -export async function signInToClaudeAccount(accountId: string): Promise { - try { - await window.api.claudeAccounts.reauthenticate({ accountId }) - // Why: the account rows other surfaces read come from settings, which the sign-in rewrote. - await useAppStore.getState().fetchSettings() - return true - } catch (error) { - if (!isClaudeAccountCancellation(error)) { - toast.error( - translate( - 'auto.components.settings.AccountsPane.2743cdc0af', - 'Claude account update failed.' - ), - { description: getClaudeAccountErrorDescription(error) } - ) - } - return false - } -} 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 96d80d064d3c..a69a18999c52 100644 --- a/src/renderer/src/store/slices/ui-notice-dismissals.test.ts +++ b/src/renderer/src/store/slices/ui-notice-dismissals.test.ts @@ -264,19 +264,6 @@ describe('createUISlice Codex shared-settings notice', () => { }) }) -describe('createUISlice Claude account sign-in notice', () => { - it('counts as seen until hydration, then follows the persisted flag, and persists marking', () => { - vi.stubGlobal('window', { api: { ui: { set: vi.fn(() => Promise.resolve()) } } }) - const store = createUIStore() - expect(store.getState().claudeAccountSignInNoticeSeen).toBe(true) - store.getState().hydratePersistedUI(makePersistedUI({})) - expect(store.getState().claudeAccountSignInNoticeSeen).toBe(false) - store.getState().markClaudeAccountSignInNoticeSeen() - expect(store.getState().claudeAccountSignInNoticeSeen).toBe(true) - expect(window.api.ui.set).toHaveBeenCalledWith({ claudeAccountSignInNoticeSeen: 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 d8ea7fe00046..6ec88e1e4f36 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 @@ -118,6 +118,4 @@ export type UISliceContextual = { markCodexTerminalServerIsolationNoticeSeen: () => void codexSharedSettingsNoticeSeen: boolean markCodexSharedSettingsNoticeSeen: () => void - claudeAccountSignInNoticeSeen: boolean - markClaudeAccountSignInNoticeSeen: () => 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 9b13b85a7142..9df5868597d3 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 @@ -245,7 +245,6 @@ export function createUiHydrationActions(set: UISliceSet, _get: UISliceGet): Par codexTerminalServerIsolationNoticeSeen: ui.codexTerminalServerIsolationNoticeSeen === true, codexSharedSettingsNoticeSeen: ui.codexSharedSettingsNoticeSeen === true, - claudeAccountSignInNoticeSeen: ui.claudeAccountSignInNoticeSeen === 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 41f3cf4f35b0..1e1c25573a27 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 @@ -157,15 +157,6 @@ export function createUiTrustActions(set: UISliceSet, _get: UISliceGet): Partial } window.api.ui.set({ codexSharedSettingsNoticeSeen: true }).catch(console.error) return { codexSharedSettingsNoticeSeen: true } - }), - claudeAccountSignInNoticeSeen: true, - markClaudeAccountSignInNoticeSeen: () => - set((s) => { - if (s.claudeAccountSignInNoticeSeen) { - return s - } - window.api.ui.set({ claudeAccountSignInNoticeSeen: true }).catch(console.error) - return { claudeAccountSignInNoticeSeen: true } }) } } diff --git a/src/renderer/src/web/preload-api/web-terminal-api.ts b/src/renderer/src/web/preload-api/web-terminal-api.ts index 97212f496e1c..fcd16fab1754 100644 --- a/src/renderer/src/web/preload-api/web-terminal-api.ts +++ b/src/renderer/src/web/preload-api/web-terminal-api.ts @@ -44,7 +44,6 @@ export function createPtyApi(): NonNullable['pty']> { isCodexOnSharedServer: () => Promise.resolve({ joined: false }), disableCodexSharedServerAutoStart: () => Promise.resolve(false), stopCodexSharedServer: () => Promise.resolve(false), - openedBeforeClaudeAccounts: () => Promise.resolve(false), getCwd: () => Promise.resolve('~'), getSize: () => Promise.resolve(null), listSessions: () => Promise.resolve([]), diff --git a/src/shared/agent-session-failure-copy.ts b/src/shared/agent-session-failure-copy.ts index 113bea70764e..cd51a03f9133 100644 --- a/src/shared/agent-session-failure-copy.ts +++ b/src/shared/agent-session-failure-copy.ts @@ -1,11 +1,6 @@ // The pieces every failure sentence is made of, in English. The host fills them in as they are; // desktop translates each piece whole with this as its fallback, so the two never differ. -import { - CLAUDE_PROFILE_MISSING_MESSAGE, - CLAUDE_PROFILE_SETUP_FAILED_MESSAGE -} from './claude-profile-routing' - /** Sentences a refusal notice shows too, so a chat says them one way. */ export const TERMINAL_AGENT_HOLDS_CHAT = 'This chat is still open in a terminal agent.' export const QUIT_TERMINAL_AGENT = 'Quit that agent to continue the chat here.' @@ -53,8 +48,6 @@ export const AGENT_SESSION_FAILURE_COPY = { 'The folder this chat ran in no longer exists. Restore it to continue this chat.', historyInOtherAccount: "This chat's history is in another Claude account. Switch back to that account to continue it.", - claudeAccountFolderMissing: CLAUDE_PROFILE_MISSING_MESSAGE, - claudeAccountSetupFailed: CLAUDE_PROFILE_SETUP_FAILED_MESSAGE, agentCommandNotRunnable: "{{agent}}'s Command in Settings → Agents must be a program path or name Orca can find, with no arguments or variables. Change it or reset it.", chooseClaudeAccount: 'Choose or add one in Claude Accounts settings.', diff --git a/src/shared/agent-session-failure-words.ts b/src/shared/agent-session-failure-words.ts index 03cb2edae4d0..ca219c0c6f35 100644 --- a/src/shared/agent-session-failure-words.ts +++ b/src/shared/agent-session-failure-words.ts @@ -214,8 +214,6 @@ const FAILURE_SENTENCES = { accountSwitchInProgress: (_context, _fact, _surface, say) => say('accountSwitchInProgress'), launchFolderMissing: (_context, _fact, _surface, say) => say('launchFolderMissing'), historyInOtherAccount: (_context, _fact, _surface, say) => say('historyInOtherAccount'), - claudeAccountFolderMissing: (_context, _fact, _surface, say) => say('claudeAccountFolderMissing'), - claudeAccountSetupFailed: (_context, _fact, _surface, say) => say('claudeAccountSetupFailed'), agentCommandNotRunnable: (context, _fact, _surface, say) => say('agentCommandNotRunnable', agent(say, context)), managedAccountUnsupported: (context, _fact, _surface, say) => diff --git a/src/shared/agent-session-failure.ts b/src/shared/agent-session-failure.ts index ac4f9ae6cfd9..9f43c47e9bfe 100644 --- a/src/shared/agent-session-failure.ts +++ b/src/shared/agent-session-failure.ts @@ -34,8 +34,6 @@ export const AGENT_SESSION_FAILURE_KINDS = [ 'managedAccountUnsupported', 'launchFolderMissing', 'historyInOtherAccount', - 'claudeAccountFolderMissing', - 'claudeAccountSetupFailed', 'agentCommandNotRunnable', 'providerExited', 'restartFailed', diff --git a/src/shared/agent-session-refusal-details.ts b/src/shared/agent-session-refusal-details.ts index ae52282631a8..3c1fde945665 100644 --- a/src/shared/agent-session-refusal-details.ts +++ b/src/shared/agent-session-refusal-details.ts @@ -66,10 +66,6 @@ export const AGENT_SESSION_REFUSAL_REASONS = { 'launchFolderMissing', /** The chat's transcript is in a Claude account other than the selected one. */ 'historyInOtherAccount', - /** The selected Claude account's folder is gone. */ - 'claudeAccountFolderMissing', - /** The selected Claude account's folder could not be set up. */ - 'claudeAccountSetupFailed', /** Settings → Agents → Command names no program this host can run. */ 'agentCommandNotRunnable', /** The agent started, then Orca could not open the chat's conversation for it. */ diff --git a/src/shared/agent-session-refusal-reason-words.ts b/src/shared/agent-session-refusal-reason-words.ts index 03c5c1c04dd2..0f947a850917 100644 --- a/src/shared/agent-session-refusal-reason-words.ts +++ b/src/shared/agent-session-refusal-reason-words.ts @@ -101,8 +101,6 @@ const REASON_WORDS = { managedAccountUnsupported: { fact: 'managedAccountUnsupported', action: 'actFirst' }, launchFolderMissing: { fact: 'launchFolderMissing', action: 'actFirst' }, historyInOtherAccount: { fact: 'historyInOtherAccount', action: 'actFirst' }, - claudeAccountFolderMissing: { fact: 'claudeAccountFolderMissing', action: 'actFirst' }, - claudeAccountSetupFailed: { fact: 'claudeAccountSetupFailed', action: 'retry' }, agentCommandNotRunnable: { fact: 'agentCommandNotRunnable', action: 'actFirst' }, attachFailed: codeWords('retry') }, diff --git a/src/shared/claude-profile-activation-contract.test.ts b/src/shared/claude-profile-activation-contract.test.ts deleted file mode 100644 index 17cc204304b4..000000000000 --- a/src/shared/claude-profile-activation-contract.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { expect, it } from 'vitest' -import { CLAUDE_SIGN_IN_RUNTIME_CAPABILITY, RUNTIME_CAPABILITIES } from './protocol-version' -import { - buildWslInteractiveLoginShellCommand, - buildWslLoginShellCommand -} from './wsl-login-shell-command' -import { getFishClaudeShellFunction } from './claude-shell-function' - -it('advertises the Claude sign-in the CLI needs before it starts a login', () => { - expect([...RUNTIME_CAPABILITIES]).toContain(CLAUDE_SIGN_IN_RUNTIME_CAPABILITY) -}) - -it('gives a WSL fish pane the account function, and leaves other WSL commands alone', () => { - const pane = buildWslInteractiveLoginShellCommand() - expect(pane).toContain('"$_orca_wsl_shell" -l -C') - expect(getFishClaudeShellFunction()).toContain('ORCA_CLAUDE_PROFILE_POINTER') - expect(buildWslLoginShellCommand('true')).not.toContain('fish') -}) diff --git a/src/shared/claude-profile-routing.ts b/src/shared/claude-profile-routing.ts index 6b1294f236f7..cf4f915bddaf 100644 --- a/src/shared/claude-profile-routing.ts +++ b/src/shared/claude-profile-routing.ts @@ -1,14 +1,7 @@ +/** Step 4 enables this only after the credential writers have been removed. */ +export function claudeProfileRoutingEnabled(): boolean { + return false +} + /** The pane's path to the which-account file the `claude` shell function re-reads on every launch. */ export const CLAUDE_PROFILE_POINTER_ENV = 'ORCA_CLAUDE_PROFILE_POINTER' - -/** Set beside every CLAUDE_CONFIG_DIR Orca injects, so its own value never reads as the user's. */ -export const CLAUDE_INJECTED_CONFIG_DIR_ENV = 'ORCA_CLAUDE_INJECTED_CONFIG_DIR' - -/** The user's own CLAUDE_CONFIG_DIR an injected value replaced; System default restores it. */ -export const CLAUDE_USER_CONFIG_DIR_ENV = 'ORCA_CLAUDE_USER_CONFIG_DIR' - -export const CLAUDE_PROFILE_MISSING_MESSAGE = - "The selected Claude account's folder is missing. Sign in to it again or choose another account." - -export const CLAUDE_PROFILE_SETUP_FAILED_MESSAGE = - 'The selected Claude account could not be set up. Try again or choose another account.' diff --git a/src/shared/claude-shell-function.test.ts b/src/shared/claude-shell-function.test.ts index f45c1f884642..dee5b3dc5f57 100644 --- a/src/shared/claude-shell-function.test.ts +++ b/src/shared/claude-shell-function.test.ts @@ -1,16 +1,14 @@ +import type * as ProfileRouting from './claude-profile-routing' import { spawnSync } from 'node:child_process' -import { - chmodSync, - existsSync, - mkdirSync, - mkdtempSync, - rmSync, - statSync, - writeFileSync -} from 'node:fs' +import { chmodSync, existsSync, 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 { afterEach, describe, expect, it, vi } from 'vitest' +const gate = vi.hoisted(() => ({ enabled: true })) +vi.mock('./claude-profile-routing', async (original) => ({ + ...(await original()), + claudeProfileRoutingEnabled: () => gate.enabled +})) import { getPosixClaudeShellFunction, getFishClaudeShellFunction, @@ -22,6 +20,7 @@ const FISH = ['/opt/homebrew/bin/fish', '/usr/local/bin/fish', '/usr/bin/fish']. const SHELLS = [...POSIX_SHELLS, ...(FISH ? [FISH] : [])] const roots: string[] = [] afterEach(() => { + gate.enabled = true roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true })) }) function fixture() { @@ -64,8 +63,6 @@ function fixture() { { encoding: 'utf8', cwd: root } ) expect(result.status).not.toBe(97) - // Orca prints nothing into a terminal; the fake claude writes only to stdout. - expect(result.stderr).toBe('') return result } return { a, b, pointer, run } @@ -94,22 +91,6 @@ describe.each(SHELLS)('the claude function in %s', (shell) => { expect(f.run(shell, injected(f.a)).stdout).toBe('HOME=default KEY=fake TWIN=none\n') }) - it('restores the user’s own value Orca replaced when switched back to System default', () => { - const f = fixture() - const own = ['ORCA_CLAUDE_USER_CONFIG_DIR=/user/own'] - writeFileSync(f.pointer, f.a) - const account = f.run(shell, [...injected(f.a), ...own]) - expect(account.stdout).toBe(`HOME=${f.a} KEY=none TWIN=${f.a}\n`) - writeFileSync(f.pointer, '') - const restored = f.run(shell, [...injected(f.a), ...own]) - expect(restored.stdout).toBe('HOME=/user/own KEY=fake TWIN=none\n') - // An rc export still wins, and a value the user unset stays unset. - const rc = ['CLAUDE_CONFIG_DIR=/rc/own', `ORCA_CLAUDE_INJECTED_CONFIG_DIR=${f.a}`, ...own] - expect(f.run(shell, rc).stdout).toBe(`HOME=/rc/own KEY=fake TWIN=${f.a}\n`) - const unset = f.run(shell, [`ORCA_CLAUDE_INJECTED_CONFIG_DIR=${f.a}`, ...own]) - expect(unset.stdout).toBe('HOME=default KEY=fake TWIN=none\n') - }) - it('reads a WSL pane’s home-relative pointer against $HOME', () => { const f = fixture() writeFileSync(f.pointer, f.a) @@ -117,37 +98,16 @@ describe.each(SHELLS)('the claude function in %s', (shell) => { expect(relative.stdout).toBe(`HOME=${f.a} KEY=none TWIN=${f.a}\n`) }) - it('creates a missing account folder and runs Claude in it, printing nothing', () => { - const f = fixture() - // A pre-update account: nothing of its folder exists yet. - const fresh = join(f.a, 'claude-profiles', 'id', 'home') - writeFileSync(f.pointer, fresh) - const result = f.run(shell) - expect(result.stdout).toBe(`HOME=${fresh} KEY=none TWIN=${fresh}\n`) - expect(result.status).toBe(23) - expect(existsSync(fresh)).toBe(true) - // Same mode as Orca's own setup: the folder will hold a login. - expect(statSync(fresh).mode & 0o777).toBe(0o700) - }) - - it('still runs Claude only in the selected folder when that folder cannot be created', () => { - const f = fixture() - const blocked = join(f.a, 'file', 'home') - writeFileSync(join(f.a, 'file'), '') - writeFileSync(f.pointer, blocked) - expect(f.run(shell).stdout).toBe(`HOME=${blocked} KEY=none TWIN=${blocked}\n`) - }) - - it('runs nothing for a pointer that names no absolute folder', () => { + it('refuses a selected account whose folder is missing', () => { const f = fixture() - writeFileSync(f.pointer, 'not-a-folder') + writeFileSync(f.pointer, join(f.a, 'gone')) const result = f.run(shell) expect(result.status).toBe(1) expect(result.stdout).toBe('') - expect(existsSync(join(f.a, '..', 'not-a-folder'))).toBe(false) + expect(result.stderr).toContain('folder is missing') }) - it('lets the user’s own CLAUDE_CONFIG_DIR win silently when an account is selected', () => { + it('lets the user’s own CLAUDE_CONFIG_DIR win and says so when an account is selected', () => { const f = fixture() const own = f.run(shell, ['CLAUDE_CONFIG_DIR=/user/own']) expect(own.stdout).toBe('HOME=/user/own KEY=fake TWIN=none\n') @@ -155,10 +115,19 @@ describe.each(SHELLS)('the claude function in %s', (shell) => { writeFileSync(f.pointer, f.a) const overridden = f.run(shell, ['CLAUDE_CONFIG_DIR=/user/own']) expect(overridden.stdout).toBe('HOME=/user/own KEY=fake TWIN=none\n') - expect(overridden.stderr).toBe('') + expect(overridden.stderr).toContain('not used here') }) }) +it('emits nothing while routing is off', () => { + gate.enabled = false + expect([ + getPosixClaudeShellFunction(), + getFishClaudeShellFunction(), + getPowerShellClaudeShellFunction() + ]).toEqual(['', '', '']) +}) + it('restores PowerShell process env without creating empty variables', () => { const script = getPowerShellClaudeShellFunction() expect(script.startsWith('\n$orcaClaudeCommand = Get-Command claude')).toBe(true) @@ -167,32 +136,8 @@ it('restores PowerShell process env without creating empty variables', () => { expect(script).not.toMatch(/SetEnvironmentVariable\([^)]*,\s*(\$null|''|"")\s*,/) }) -it('prints no Orca text from the PowerShell function and creates a missing account folder', () => { - const script = getPowerShellClaudeShellFunction() - expect(script).not.toMatch(/Write-(Host|Output|Warning)|\bthrow\b|Orca:/) - // The shell's or Claude's own failure (claude missing, a throwing claude.ps1) still surfaces. - expect(script.match(/Write-Error/g)).toHaveLength(1) - expect(script).toContain( - '} catch { $global:LASTEXITCODE = 1; Write-Error $_ -ErrorAction Continue }' - ) - expect(script).toContain( - 'if (-not [IO.Directory]::Exists($orcaClaudeHome)) { $null = New-Item -ItemType Directory -Path $orcaClaudeHome -Force -ErrorAction SilentlyContinue }' - ) - expect(script).toContain('-not [IO.Path]::IsPathRooted($orcaClaudeHome)') -}) - it('trims the PowerShell pointer read as POSIX command substitution does', () => { expect(getPowerShellClaudeShellFunction()).toContain( '[IO.File]::ReadAllText($env:ORCA_CLAUDE_PROFILE_POINTER).TrimEnd()' ) }) - -it('restores the user’s own value on PowerShell System default rather than removing it', () => { - const script = getPowerShellClaudeShellFunction() - expect(script).toContain( - 'if ($env:CLAUDE_CONFIG_DIR -and $env:ORCA_CLAUDE_USER_CONFIG_DIR) { $env:CLAUDE_CONFIG_DIR = $env:ORCA_CLAUDE_USER_CONFIG_DIR }' - ) - expect(script).not.toContain( - 'Remove-Item Env:CLAUDE_CONFIG_DIR, Env:ORCA_CLAUDE_INJECTED_CONFIG_DIR' - ) -}) diff --git a/src/shared/claude-shell-function.ts b/src/shared/claude-shell-function.ts index 2cb7307b2573..abb3b97b3685 100644 --- a/src/shared/claude-shell-function.ts +++ b/src/shared/claude-shell-function.ts @@ -1,19 +1,24 @@ +import { claudeProfileRoutingEnabled } from './claude-profile-routing' const authHeaderWords = 'authorization|x-api-key|api-key|bearer' const posixAuthHeaderPattern = authHeaderWords .split('|') .map((word) => `*${word.replace(/[a-z]/g, (letter) => `[${letter}${letter.toUpperCase()}]`)}*`) .join('|') +const OVERRIDE_NOTE = + 'Orca: CLAUDE_CONFIG_DIR is set in this shell, so the Claude account selected in Orca is not used here.' +const MISSING_NOTE = + "Orca: the selected Claude account's folder is missing. Sign in to it again or choose another account." /** * `claude` re-reads the which-account file on every launch, so a switch reaches open terminals * (superset's wrapper rule). Defined only in a pane Orca routed (pointer env set) where `claude` is - * a real executable. A missing or empty file is System default, which restores the user's own value - * Orca's replaced; a CLAUDE_CONFIG_DIR the user set, as opposed to Orca's twin-marked value, wins. - * Prints nothing: an account folder with no login is created, and Claude's own first run signs in - * there. A pointer that names no absolute folder (never written by Orca) runs nothing, since any - * fallback would be another account. + * a real executable. A missing or empty file is System default; a CLAUDE_CONFIG_DIR the user set, + * as opposed to Orca's twin-marked value, wins. */ export function getPosixClaudeShellFunction(): string { + if (!claudeProfileRoutingEnabled()) { + return '' + } return `__orca_claude_binary="$(unalias claude 2>/dev/null || :; command -v claude 2>/dev/null || :)" if [[ -n "\${ORCA_CLAUDE_PROFILE_POINTER:-}" && -n "\${__orca_claude_binary:-}" && -x "\${__orca_claude_binary}" ]]; then function claude { @@ -22,18 +27,13 @@ if [[ -n "\${ORCA_CLAUDE_PROFILE_POINTER:-}" && -n "\${__orca_claude_binary:-}" case "$__orca_claude_pointer" in '~/'*) __orca_claude_pointer="\${HOME:-}/\${__orca_claude_pointer#??}" ;; esac __orca_claude_home="$(cat "$__orca_claude_pointer" 2>/dev/null || :)" if [ -n "\${CLAUDE_CONFIG_DIR:-}" ] && [ "$CLAUDE_CONFIG_DIR" != "\${ORCA_CLAUDE_INJECTED_CONFIG_DIR:-}" ]; then + [ -z "$__orca_claude_home" ] || [ "$__orca_claude_home" = "$CLAUDE_CONFIG_DIR" ] || printf '%s\\n' '${OVERRIDE_NOTE}' >&2 command claude "$@"; return fi - # Why: Orca's value replaced the user's own at spawn, so System default restores theirs. - if [ -z "$__orca_claude_home" ] && [ -n "\${CLAUDE_CONFIG_DIR:-}" ] && [ -n "\${ORCA_CLAUDE_USER_CONFIG_DIR:-}" ]; then - ( unset ORCA_CLAUDE_INJECTED_CONFIG_DIR; export CLAUDE_CONFIG_DIR="$ORCA_CLAUDE_USER_CONFIG_DIR"; command claude "$@" ); return - fi if [ -z "$__orca_claude_home" ]; then ( unset CLAUDE_CONFIG_DIR ORCA_CLAUDE_INJECTED_CONFIG_DIR; command claude "$@" ); return fi - case "$__orca_claude_home" in /*|[A-Za-z]:*) ;; *) return 1 ;; esac - # Why -m 700: matches the folder Orca's setup creates; a credentials folder stays private. - [ -d "$__orca_claude_home" ] || mkdir -p -m 700 -- "$__orca_claude_home" 2>/dev/null + if [ ! -d "$__orca_claude_home" ]; then printf '%s\\n' "${MISSING_NOTE}" >&2; return 1; fi ( unset ANTHROPIC_API_KEY ANTHROPIC_AUTH_TOKEN CLAUDE_CODE_OAUTH_TOKEN AWS_BEARER_TOKEN_BEDROCK; case "\${ANTHROPIC_CUSTOM_HEADERS:-}" in ${posixAuthHeaderPattern}) unset ANTHROPIC_CUSTOM_HEADERS ;; esac; export CLAUDE_CONFIG_DIR="$__orca_claude_home" ORCA_CLAUDE_INJECTED_CONFIG_DIR="$__orca_claude_home"; command claude "$@" ) } fi @@ -43,6 +43,9 @@ unset __orca_claude_binary /** Leading newline: the codex fragment it follows ends without one. */ export function getFishClaudeShellFunction(): string { + if (!claudeProfileRoutingEnabled()) { + return '' + } return ` set -l __orca_claude_type (type -t claude 2>/dev/null) if test -n "$ORCA_CLAUDE_PROFILE_POINTER"; and test "$__orca_claude_type" = file @@ -51,21 +54,19 @@ if test -n "$ORCA_CLAUDE_PROFILE_POINTER"; and test "$__orca_claude_type" = file set -l pointer (string replace -r '^~/' "$HOME/" -- "$ORCA_CLAUDE_PROFILE_POINTER") set -l profile (cat "$pointer" 2>/dev/null) if test -n "$CLAUDE_CONFIG_DIR"; and test "$CLAUDE_CONFIG_DIR" != "$ORCA_CLAUDE_INJECTED_CONFIG_DIR" + if test -n "$profile"; and test "$profile" != "$CLAUDE_CONFIG_DIR" + echo '${OVERRIDE_NOTE}' >&2 + end command claude $argv return $status end - if test -z "$profile"; and test -n "$CLAUDE_CONFIG_DIR"; and test -n "$ORCA_CLAUDE_USER_CONFIG_DIR" - env -u ORCA_CLAUDE_INJECTED_CONFIG_DIR CLAUDE_CONFIG_DIR="$ORCA_CLAUDE_USER_CONFIG_DIR" claude $argv - return $status - end if test -z "$profile" env -u CLAUDE_CONFIG_DIR -u ORCA_CLAUDE_INJECTED_CONFIG_DIR claude $argv return $status end - if not string match -qr '^(/|[A-Za-z]:)' -- "$profile" - return 1 + if not test -d "$profile" + echo "${MISSING_NOTE}" >&2; return 1 end - test -d "$profile"; or mkdir -p -m 700 -- "$profile" 2>/dev/null set -l headers if string match -irq '${authHeaderWords}' -- "$ANTHROPIC_CUSTOM_HEADERS" set headers -u ANTHROPIC_CUSTOM_HEADERS @@ -79,6 +80,9 @@ set -e __orca_claude_type /** Leading newline: the codex fragment it follows ends without one. */ export function getPowerShellClaudeShellFunction(): string { + if (!claudeProfileRoutingEnabled()) { + return '' + } return ` $orcaClaudeCommand = Get-Command claude -ErrorAction SilentlyContinue | Select-Object -First 1 if ($env:ORCA_CLAUDE_PROFILE_POINTER -and $orcaClaudeCommand -and @@ -93,16 +97,12 @@ function Global:claude { $orcaClaudeHome = [IO.File]::ReadAllText($env:ORCA_CLAUDE_PROFILE_POINTER).TrimEnd() } if ($env:CLAUDE_CONFIG_DIR -and $env:CLAUDE_CONFIG_DIR -ne $env:ORCA_CLAUDE_INJECTED_CONFIG_DIR) { - # The user's own value wins. + if ($orcaClaudeHome -and $orcaClaudeHome -ne $env:CLAUDE_CONFIG_DIR) { [Console]::Error.WriteLine('${OVERRIDE_NOTE}') } } elseif (-not $orcaClaudeHome) { - Remove-Item Env:ORCA_CLAUDE_INJECTED_CONFIG_DIR -ErrorAction SilentlyContinue - if ($env:CLAUDE_CONFIG_DIR -and $env:ORCA_CLAUDE_USER_CONFIG_DIR) { $env:CLAUDE_CONFIG_DIR = $env:ORCA_CLAUDE_USER_CONFIG_DIR } - else { Remove-Item Env:CLAUDE_CONFIG_DIR -ErrorAction SilentlyContinue } - } elseif (-not [IO.Path]::IsPathRooted($orcaClaudeHome)) { - $global:LASTEXITCODE = 1 - return + Remove-Item Env:CLAUDE_CONFIG_DIR, Env:ORCA_CLAUDE_INJECTED_CONFIG_DIR -ErrorAction SilentlyContinue + } elseif (-not [IO.Directory]::Exists($orcaClaudeHome)) { + throw "${MISSING_NOTE}" } else { - if (-not [IO.Directory]::Exists($orcaClaudeHome)) { $null = New-Item -ItemType Directory -Path $orcaClaudeHome -Force -ErrorAction SilentlyContinue } foreach ($name in $names) { if ($name -ne 'ANTHROPIC_CUSTOM_HEADERS' -or $env:ANTHROPIC_CUSTOM_HEADERS -match '${authHeaderWords}') { Remove-Item -LiteralPath "Env:$name" -ErrorAction SilentlyContinue } } diff --git a/src/shared/constants.ts b/src/shared/constants.ts index a25939149b4b..2b3b82b5a37c 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -175,6 +175,7 @@ export function getDefaultPersistedState(homedir: string): PersistedState { deletedSshConfigAliases: [], sshRemotePtyLeases: [], sshPtyConsumerRecoveries: [], + claudeLivePtySessionIds: [], migrationUnsupportedPtyEntries: [], legacyPaneKeyAliasEntries: [], automations: [], diff --git a/src/shared/global-settings-types.ts b/src/shared/global-settings-types.ts index a8661a51689d..433e1f928ecb 100644 --- a/src/shared/global-settings-types.ts +++ b/src/shared/global-settings-types.ts @@ -320,8 +320,6 @@ export type GlobalSettings = NativeChatGlobalSettings & { claudeManagedAccounts: ClaudeManagedAccount[] activeClaudeManagedAccountId: string | null activeClaudeManagedAccountIdsByRuntime?: ClaudeManagedAccountRuntimeSelection - /** Dismissed the "System default may hold a copied login" notice in Settings > Accounts. */ - claudeCopiedSystemDefaultNoticeDismissed?: boolean /** Per-worktree shell history so ArrowUp doesn't surface other worktrees' commands (a HISTFILE for * bash/zsh, a `fish_history` session name for fish). Defaults to true. */ terminalScopeHistoryByWorktree: boolean diff --git a/src/shared/local-build-compatibility-contract.json b/src/shared/local-build-compatibility-contract.json index 626bd5c2b986..86423fffa64b 100644 --- a/src/shared/local-build-compatibility-contract.json +++ b/src/shared/local-build-compatibility-contract.json @@ -3,9 +3,9 @@ "appId": "com.stablyai.orca", "stateSchemaVersion": 1, "readableStateSchemaVersions": [1], - "daemonProtocolVersion": 42, + "daemonProtocolVersion": 43, "attachableDaemonProtocolVersions": [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, - 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42 + 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43 ] } diff --git a/src/shared/local-build-compatibility-contract.ts b/src/shared/local-build-compatibility-contract.ts index e7e6c827ea37..cd9a4734bbf1 100644 --- a/src/shared/local-build-compatibility-contract.ts +++ b/src/shared/local-build-compatibility-contract.ts @@ -3,9 +3,9 @@ export const LOCAL_BUILD_COMPATIBILITY_CONTRACT = { appId: 'com.stablyai.orca', stateSchemaVersion: 1, readableStateSchemaVersions: [1], - daemonProtocolVersion: 42, + daemonProtocolVersion: 43, attachableDaemonProtocolVersions: [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, - 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42 + 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43 ] } as const diff --git a/src/shared/managed-account-types.ts b/src/shared/managed-account-types.ts index 6f9cc642ce7e..a4f7c272ac62 100644 --- a/src/shared/managed-account-types.ts +++ b/src/shared/managed-account-types.ts @@ -95,33 +95,12 @@ export type ClaudeManagedAccountSummary = { createdAt: number updatedAt: number lastAuthenticatedAt: number - /** Its folder holds no login yet, e.g. an account saved by an older Orca; it needs a sign-in. */ - needsSignIn?: true -} - -/** A sign-in to a new account folder, or to a saved account's own when `accountId` is given. */ -export type ClaudeSignInRequest = { - accountId?: string - runtime?: 'host' | 'wsl' - wslDistro?: string | null -} - -/** A sign-in in progress: `configDir` is the CLAUDE_CONFIG_DIR `claude auth login` runs against. */ -export type ClaudeAccountSignIn = { - accountId: string - configDir: string - runtime?: 'host' | 'wsl' - wslDistro?: string | null } export type ClaudeRateLimitAccountsState = { accounts: ClaudeManagedAccountSummary[] activeAccountId: string | null activeAccountIdsByRuntime?: ClaudeManagedAccountRuntimeSelection - /** The login System default holds, when the host can read it and accounts exist. */ - systemDefaultEmail?: string - /** An older Orca's copy-based switching once wrote an account's login into System default. */ - systemDefaultMayBeCopied?: boolean } export type ClaudeManagedAccountRuntimeSelection = { diff --git a/src/shared/persisted-state-types.ts b/src/shared/persisted-state-types.ts index b874b17f87af..c1f1b6de7275 100644 --- a/src/shared/persisted-state-types.ts +++ b/src/shared/persisted-state-types.ts @@ -108,6 +108,8 @@ export type PersistedState = { sshRemotePtyLeases: SshRemotePtyLease[] /** Main-owned authenticated relay recovery records; never expose through renderer settings APIs. */ sshPtyConsumerRecoveries?: SshPtyConsumerRecovery[] + /** Live local Claude daemon session ids; seeds the live-PTY gate so early OAuth refresh can't rotate the single-use refresh token out from under a running daemon. */ + claudeLivePtySessionIds?: string[] migrationUnsupportedPtyEntries: MigrationUnsupportedPtyEntry[] legacyPaneKeyAliasEntries: LegacyPaneKeyAliasEntry[] automations: Automation[] diff --git a/src/shared/persisted-ui-state-types.ts b/src/shared/persisted-ui-state-types.ts index 12e27144908d..ecd514295333 100644 --- a/src/shared/persisted-ui-state-types.ts +++ b/src/shared/persisted-ui-state-types.ts @@ -179,8 +179,6 @@ export type PersistedUIState = { codexTerminalServerIsolationNoticeSeen?: boolean /** Windows one-shot toast for Codex moving onto ~/.codex; set when shown, so absent means not yet seen. */ codexSharedSettingsNoticeSeen?: boolean - /** One-shot toast asking for one sign-in to each Claude account saved before per-account folders. */ - claudeAccountSignInNoticeSeen?: boolean /** URL for new browser tabs; null = blank tab. */ browserDefaultUrl?: string | null browserDefaultSearchEngine?: 'google' | 'duckduckgo' | 'bing' | 'kagi' | null diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index cac746323b86..4dd12da66ea8 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -165,8 +165,6 @@ export const WORKTREE_ARCHIVE_FAILURE_BLOCKING_RUNTIME_CAPABILITY = 'worktree.archive-failure-blocking.v1' as const export const CODEX_RESET_CREDIT_RUNTIME_CAPABILITY = 'accounts.codex-reset-credit.v1' as const export const ACCOUNT_IMPORT_RUNTIME_CAPABILITY = 'accounts.import-host-credentials.v1' as const -// Why: `orca account add claude` signs in to an account folder the host creates first. -export const CLAUDE_SIGN_IN_RUNTIME_CAPABILITY = 'accounts.claude-sign-in.v1' as const export const ANTIGRAVITY_ACCOUNTS_RUNTIME_CAPABILITY = 'accounts.antigravity-native.v1' as const export const DATA_ACCOUNT_RUNTIME_CAPABILITY = 'accounts.managed-data-profiles.v1' as const // Why: older hosts cannot reconcile terminal.create's mutation after losing the reply, so clients may only retry unknown outcomes when advertised. @@ -418,7 +416,6 @@ export const RUNTIME_CAPABILITIES = [ WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY, WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY, ACCOUNT_IMPORT_RUNTIME_CAPABILITY, - CLAUDE_SIGN_IN_RUNTIME_CAPABILITY, ANTIGRAVITY_ACCOUNTS_RUNTIME_CAPABILITY, DATA_ACCOUNT_RUNTIME_CAPABILITY, CODEX_RESET_CREDIT_RUNTIME_CAPABILITY, diff --git a/src/shared/rate-limit-types.ts b/src/shared/rate-limit-types.ts index 7b30956f47c4..0a9abf64ab13 100644 --- a/src/shared/rate-limit-types.ts +++ b/src/shared/rate-limit-types.ts @@ -67,6 +67,7 @@ export type UsageRateLimitMetadata = { failureKind?: UsageRateLimitFailureKind credentialSource?: string authProvenance?: string + deferredByLiveClaudeSession?: boolean lastSuccessfulSource?: UsageRateLimitSource /** Unix ms timestamp before which usage refetches should not be attempted (from HTTP Retry-After). */ retryAtMs?: number diff --git a/src/shared/rpc-contract/accounts-params.ts b/src/shared/rpc-contract/accounts-params.ts index 15bd686a79c8..7fa31ee6b804 100644 --- a/src/shared/rpc-contract/accounts-params.ts +++ b/src/shared/rpc-contract/accounts-params.ts @@ -93,16 +93,3 @@ export const AccountsUnsubscribeParams = z.object({ .transform((value) => (typeof value === 'string' && value.length > 0 ? value : '')) .pipe(z.string().min(1, 'Missing subscriptionId')) }) - -const ClaudeSignInTarget = { - runtime: z.enum(['host', 'wsl']).optional(), - wslDistro: z.string().nullish() -} - -export const BeginClaudeSignInParams = z - .object({ accountId: z.string().min(1).optional(), ...ClaudeSignInTarget }) - .strict() - -export const FinishClaudeSignInParams = z - .object({ accountId: z.string().min(1), ...ClaudeSignInTarget }) - .strict() diff --git a/src/shared/rpc-contract/client-ui-params.ts b/src/shared/rpc-contract/client-ui-params.ts index b787d523768b..9f41e92674e4 100644 --- a/src/shared/rpc-contract/client-ui-params.ts +++ b/src/shared/rpc-contract/client-ui-params.ts @@ -244,7 +244,6 @@ export const UiUpdateFields = z usageEmptyStateDismissed: z.boolean().optional(), codexTerminalServerIsolationNoticeSeen: z.boolean().optional(), codexSharedSettingsNoticeSeen: z.boolean().optional(), - claudeAccountSignInNoticeSeen: z.boolean().optional(), petVisible: z.boolean().optional(), petId: z.string().optional(), customPets: UnknownRecordArray.optional(), diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index 27c4ee41ded5..ce0481c1f171 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -31,9 +31,7 @@ import { AddClaudeFromConfigDirParams, AddCodexFromHomeParams, AddDataAccountParams, - BeginClaudeSignInParams, ConsumeCodexResetCreditParams, - FinishClaudeSignInParams, ListAccountsParams, RemoveAccountParams, RemoveDataAccountParams, @@ -592,10 +590,7 @@ export const RPC_PARAMS_BY_METHOD = { 'accounts.antigravityList': AntigravityAccountTargetParams, 'accounts.antigravityRemove': AntigravityAccountMutationParams, 'accounts.antigravitySelect': AntigravityAccountMutationParams, - 'accounts.beginClaudeSignIn': BeginClaudeSignInParams, - 'accounts.cancelClaudeSignIn': FinishClaudeSignInParams, 'accounts.consumeCodexResetCredit': ConsumeCodexResetCreditParams, - 'accounts.finishClaudeSignIn': FinishClaudeSignInParams, 'accounts.list': ListAccountsParams, 'accounts.listData': null, 'accounts.removeClaude': RemoveAccountParams, diff --git a/src/shared/structured-agent-session-dispatch-rejection.ts b/src/shared/structured-agent-session-dispatch-rejection.ts index 800c0e8b439a..1b8bf6990233 100644 --- a/src/shared/structured-agent-session-dispatch-rejection.ts +++ b/src/shared/structured-agent-session-dispatch-rejection.ts @@ -93,8 +93,6 @@ const KIND_CATEGORY = { managedAccountUnsupported: 'startFailed', launchFolderMissing: 'startFailed', historyInOtherAccount: 'startFailed', - claudeAccountFolderMissing: 'startFailed', - claudeAccountSetupFailed: 'startFailed', agentCommandNotRunnable: 'startFailed', restartFailed: 'startFailed', providerRejected: 'content', @@ -124,8 +122,6 @@ const KIND_VERDICT = { managedAccountUnsupported: 'failure', launchFolderMissing: 'failure', historyInOtherAccount: 'failure', - claudeAccountFolderMissing: 'failure', - claudeAccountSetupFailed: 'failure', agentCommandNotRunnable: 'failure', restartFailed: 'failure', providerRejected: 'failure', diff --git a/src/shared/wsl-login-shell-command.ts b/src/shared/wsl-login-shell-command.ts index fded4982e089..fd0f0b505c98 100644 --- a/src/shared/wsl-login-shell-command.ts +++ b/src/shared/wsl-login-shell-command.ts @@ -1,4 +1,3 @@ -import { getFishClaudeShellFunction } from './claude-shell-function' export function quotePosixShell(value: string): string { return `'${value.replace(/'/g, "'\\''")}'` } @@ -131,10 +130,6 @@ export function buildWslInteractiveLoginShellCommand(): string { ' exec "$_orca_wsl_shell" --rcfile "${_orca_shell_ready_root}/bash/rcfile"', ' fi', ' ;;', - // Why: a WSL fish pane gets no shell-ready wrapper or XDG handoff, so this is its only `claude`. - ' fish)', - ` exec "$_orca_wsl_shell" -l -C ${quotePosixShell(getFishClaudeShellFunction())}`, - ' ;;', ' zsh)', ' if [ -n "${_orca_shell_ready_root:-}" ] && [ -d "${_orca_shell_ready_root}/zsh" ]; then', ' export ZDOTDIR="${_orca_shell_ready_root}/zsh"', From 22974c5809d18d8e84ccb9f3ad2e420c3d8ca00a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 8 Oct 2026 11:46:55 -0700 Subject: [PATCH 15/90] Default experimental native chat to structured chat (#22933) * refactor(native-chat): make structured chat the only Chat UI runtime Retire the experimentalStructuredNativeChat opt-in. Chat UI on is now the one gate for structured chat: new agent tabs that default to Chat UI start a structured session wherever the launch is supported, and unsupported launches keep falling back to terminal-backed chat. - Shared isNativeChatEnabled predicate replaces prefersStructuredNativeChatByDefault; host admission, renderer routing, orchestration mode, startup projection, tab sync and the resume offer all read it. - The retired key is stripped on load and on save, dropped from the settings type, defaults, the published client-settings projection and telemetry. - Settings no longer offer the structured toggle; resume-after-restart and shell-environment rows show under the Chat UI default view. - Startup structured-tab projection moves beside its sync hook so both gates read the same predicate. * docs(native-chat): describe structured chat as the Chat UI runtime New local Claude and Codex chats no longer sit on a PTY, so the page stops saying the terminal is always the source of truth and scopes the chat/terminal toggle to terminal-backed chats. * fix(native-chat): stop promising a Windows fallback in the Chat UI scope note A host refusal after the route picks structured chat still ends as a failed launch. * fix(native-chat): keep open structured chats reachable when Chat UI is off Turning Chat UI off hid open Claude/Codex chat tabs while their provider processes kept running, because the host and the desktop mirror gated serving existing sessions on the same setting that decides how new launches open. The host now asks two separate questions. Creating a new structured session (agentSession.create, createSupport, ensure) needs the negotiated capability plus the Chat UI setting. Serving a session that already exists (listing, reading, sending, resuming, closing, and the mobile projection and restore) needs the capability only. The desktop mirror, startup restore and resume-on-restart offer no longer read the setting, and the toggle-off teardown and the mobile re-publish on setting change are removed as dead. * fix(native-chat): run the local structured tab sync only where chats may exist The previous commit started the every-worktree session-tab census and subscription, the startup restore, and the resume-offer host read for every desktop user. Gate them on a derived fact instead: Chat UI is on, or the local host holds at least one structured session record. With Chat UI off nothing can create a session, so the answer can only go from yes to no. The host answers from its installed record store, or from whether the store file exists when the host is not installed, so a machine that never had a structured chat pays one existence check and never starts the host. Chat UI turning on starts the sync; turning it off never stops it. * fix(native-chat): answer an empty restart offer without starting the chat host With every Chat UI user now on structured chat, the launch-time resume offer read built the host even on machines with no stored chat, which on Linux scans every process environment. No store on disk means no offer, so the listing answers empty without installing the host. * fix(native-chat): release the structured tab sync when it never subscribed A failed capability probe reads as unsupported, so the sync returned without subscribing while still holding the one-sync slot. Nothing could start another, and chat tabs stopped live-updating until relaunch. The start now reports whether it subscribed, and a sync that did not releases the slot so the next Chat UI change retries. * fix(native-chat): let the host answer the restart offer whatever Chat UI says The renderer asked main whether a structured store existed before reading the restart offer, so it would not start the chat host for users without one. The host now answers an empty offer without starting itself, which made that probe an extra round trip. In paired web it was also wrong: the probe is hard-coded to no and the gate read the browser's own Chat UI setting, so a paired host's chats were never offered back once that was off. * refactor(native-chat): drop the structured tab sync generation fence The fence existed so a Chat UI toggle-off could retire in-flight inventory replies before it cleared the mirror. That teardown is gone: turning Chat UI off keeps open chats, and only a test reset still bumped the generation, so every production check compared equal. The sync's own disposal still fences its subscription, and the startup restore latch and publication cursors stay. * fix(native-chat): retry the structured tab sync when the capability probe fails A failed runtime status probe maps to an empty capability list, which the sync read as "unsupported" and gave up on. The release only let a later Chat UI change try again, so one transient startup failure left chat tabs without live updates for the whole session. The sync now tells an unknown answer apart from a host that answered without the surface, re-asks unknown answers on its existing subscribe backoff, and stops only on a definite no. * refactor(native-chat): begin single-switch structured launch routing * Make Chat UI the single structured launch default * Remove retired chat settings copy from localization catalogs * Keep locale checks aligned with the single Chat UI switch * Keep explicit chats accessible and older client defaults terminal * Align newly landed Settings tests with the Chat UI switch * Expect terminal fallback on older paired structured hosts * Correct terminal fallback launch expectation * Update tests that still expected the retired chat settings * Keep chat composer options off terminal launches and restore route coverage Chat composer model and effort no longer become CLI flags on terminal launches, since a new launch can no longer open in the terminal chat view; structured launches seed their own options. Restores dropped route coverage, including that an explicit resume-in-new-chat request stays available while Chat UI is off. * Replace the stale remembered-options test with terminal fallback coverage The test expected chat composer model and effort on a codex launch with Chat UI on, but its mocked host has no structured capability, so that launch is the terminal fallback that must not carry them. It now checks the fallback with Chat UI on and off, with shipped and stored arguments; structured creates are covered where the seed is applied. * Lower the terminal pane listener budget to the measured 15 The explicit-chat change retired the per-pane Chat UI setting read, so each mounted pane opens one fewer store listener. Pin the budget to the measured count and count that read among the saved listeners. --- .../agent-launch-executor.test.ts | 11 +- .../agent-launch/agent-launch-executor.ts | 8 +- src/main/agent-launch/agent-launch-mode.ts | 4 +- .../agent-launch/agent-launch-view-mode.ts | 39 --- src/main/ipc/runtime.test.ts | 2 +- .../terminal-settings-migrations.ts | 6 + .../normalize-loaded-global-settings.test.ts | 13 + ...ude-structured-session-integration.test.ts | 2 +- .../paired-settings.spec.ts | 13 + .../agent-launch-caller-renders.test.ts | 2 +- .../agent-launch-floating-workspace.test.ts | 2 +- .../methods/agent-launch-instant-tab.test.ts | 23 +- .../rpc/methods/agent-launch-replay.test.ts | 4 +- .../methods/agent-launch-tab-publication.ts | 8 +- .../rpc/methods/agent-launch.test-fixture.ts | 4 +- .../runtime/rpc/methods/agent-launch.test.ts | 4 +- .../runtime/rpc/methods/client-ui.test.ts | 3 +- .../orchestration-worker-mode-opacity.test.ts | 4 +- ...ration-worker-start-mode-selection.test.ts | 9 +- .../orchestration-worker-start-mode.test.ts | 8 +- ...ation-worker-start-receipt-wording.test.ts | 4 +- ...tion-worker-start-registered-agent.test.ts | 6 +- ...chestration-worker-support-unknown.test.ts | 4 +- ...ion-tab-agent-capability-mutations.test.ts | 6 +- .../session-tabs-structured-restore.test.ts | 2 +- .../session-tabs-subscribe-admission.test.ts | 2 +- ...structured-agent-session-admission.test.ts | 4 +- ...ured-agent-session-adoption-replay.test.ts | 4 +- .../structured-agent-session-at-rest.test.ts | 4 +- .../structured-agent-session-hold.test.ts | 16 +- .../structured-agent-session-policy.ts | 5 +- ...ed-agent-session-precommit-refusal.test.ts | 2 +- ...ructured-agent-session-rpc.test-fixture.ts | 2 +- .../methods/structured-agent-session.test.ts | 2 +- .../methods/structured-chat-tab-table.test.ts | 4 +- src/main/runtime/runtime-client-settings.ts | 12 +- src/main/runtime/runtime-store-contract.ts | 2 - ...ed-agent-runtime-registrations-acp.test.ts | 10 +- ...ructured-agent-session-integration.test.ts | 2 +- ...tiveChatResumeOnRestartModal.list.test.tsx | 2 +- .../NativeChatResumeOnRestartModal.test.tsx | 14 +- ...ative-chat-launch-resume-decision.test.tsx | 1 - .../native-chat-resume-modal.test-support.tsx | 2 +- .../native-chat-availability.test.ts | 27 +- .../native-chat/native-chat-availability.ts | 5 - ...native-chat-launch-session-options.test.ts | 54 ---- .../native-chat-launch-session-options.ts | 34 --- .../use-native-chat-toggle-shortcut.ts | 1 - .../onboarding-folder-agent-startup.test.ts | 54 ++-- .../SourceControlAgentActionDialog.test.tsx | 8 +- ...t-session-resume-in-chat-workspace.test.ts | 4 +- .../ai-vault-session-resume-in-chat.ts | 6 +- ...ol-launch-agent-args-applicability.test.ts | 2 +- .../settings/AppearancePane.test.tsx | 3 +- ...hatSettingsSection.inline-visuals.test.tsx | 4 +- .../ChatSettingsSection.navigation.test.tsx | 4 +- .../settings/ChatSettingsSection.test.tsx | 14 +- .../settings/ChatSettingsSection.tsx | 2 +- .../settings/ExperimentalPane.test.tsx | 226 +---------------- .../NativeChatExperimentalSetting.test.tsx | 53 +--- .../NativeChatExperimentalSetting.tsx | 106 +------- .../settings/SettingsSidebar.test.tsx | 4 +- .../settings-persistence-test-fixture.ts | 2 +- .../components/sidebar/NonGitFolderDialog.tsx | 4 +- .../sidebar/folder-workspace-agent-startup.ts | 5 - .../folder-workspace-composer-submit.test.ts | 21 +- .../folder-workspace-composer-submit.ts | 12 +- .../NativeChatResumeStatusSegment.test.tsx | 10 +- .../components/tab-bar/TabBarItemRow.test.tsx | 1 - .../tab-bar/tab-agent-types-by-tab-id.test.ts | 17 +- .../tab-bar/tab-agent-types-by-tab-id.ts | 24 +- .../tab-bar/tab-bar-item-surface.tsx | 5 +- .../tab-bar/use-tab-bar-runtime-model.ts | 2 +- ...terminal-chat-ownership-lifecycle.test.tsx | 13 +- ...al-pane-store-subscription-budget.test.tsx | 7 +- .../use-terminal-pane-chat-state.ts | 10 +- .../composer-state/composer-submit-model.ts | 4 +- .../composer-submit-orchestration.ts | 1 - .../folder-submit-orchestration.ts | 31 +-- .../quick-creation-execution.ts | 8 +- .../composer-state/quick-startup-plan.ts | 25 -- .../composer-state/quick-submit-action.ts | 3 +- ...c-events-terminal-create-scenario-types.ts | 1 - ...ipc-events-terminal-create-test-harness.ts | 1 - .../terminal-presentation-ipc-bridge.ts | 13 +- .../ipc-events/terminal-request-ipc-bridge.ts | 12 +- .../settings-navigation-build-options.ts | 2 +- .../settings-navigation-interface-sections.ts | 4 +- ...Events-browser-certificate-failure.test.ts | 1 - ...seIpcEvents-ssh-disconnect-cleanup.test.ts | 3 +- ...pcEvents-terminal-create-surfacing.test.ts | 11 +- .../useSettingsNavigationMetadata.test.ts | 4 +- .../hooks/useSettingsNavigationMetadata.ts | 12 +- .../src/i18n/en-runtime-required.json | 5 +- src/renderer/src/i18n/locales/en.json | 15 +- src/renderer/src/i18n/locales/es.json | 12 - src/renderer/src/i18n/locales/fr.json | 12 - src/renderer/src/i18n/locales/ja.json | 12 - src/renderer/src/i18n/locales/ko.json | 12 - src/renderer/src/i18n/locales/zh.json | 12 - .../src/i18n/native-chat-locales.test.ts | 18 +- .../agent-launch-caller-permissions.test.ts | 76 +++--- .../lib/agent-launch-caller-routing.test.ts | 4 +- ...t-launch-route-connection-fallback.test.ts | 4 +- .../src/lib/agent-launch-route-input.test.ts | 26 +- .../src/lib/agent-launch-route-input.ts | 4 +- ...ent-launch-route-registered-agents.test.ts | 24 +- .../src/lib/agent-launch-routing.test.ts | 240 ++++++++---------- src/renderer/src/lib/agent-launch-routing.ts | 61 +---- .../src/lib/agent-launch-through-host.ts | 4 +- .../src/lib/agent-session-launch-plan.test.ts | 6 +- .../src/lib/agent-session-launch-plan.ts | 2 +- ...empty-workspace-default-agent-chat.test.ts | 5 +- .../lib/empty-workspace-default-agent-chat.ts | 7 +- ...aunch-agent-in-new-tab-host-agents.test.ts | 12 +- .../launch-agent-in-new-tab-placement.test.ts | 16 +- .../src/lib/launch-agent-in-new-tab.test.ts | 70 ++--- .../src/lib/launch-agent-in-new-tab.ts | 40 +-- ...launch-agent-structured-chat-guard.test.ts | 33 +-- ...nch-work-item-direct-agent-routing.test.ts | 2 +- .../launch-work-item-direct-agent-routing.ts | 4 - .../lib/launch-work-item-direct-agent.test.ts | 45 +--- .../src/lib/launch-work-item-direct-agent.ts | 27 +- ...unch-work-item-direct-route-preparation.ts | 3 +- .../src/lib/launch-work-item-direct.test.ts | 6 - .../src/lib/launch-work-item-direct.ts | 7 +- .../lib/native-chat-initial-view-mode.test.ts | 169 ------------ .../src/lib/native-chat-initial-view-mode.ts | 6 - ...ve-chat-launch-draft-mirrorability.test.ts | 27 +- .../lib/native-chat-launch-prompt-delivery.ts | 1 + .../src/lib/onboarding-folder-agent-launch.ts | 7 +- .../lib/onboarding-folder-agent-startup.ts | 16 +- ...eb-runtime-worktree-terminal-after-wake.ts | 19 +- .../worktree-activation-agent-startup.test.ts | 44 ++-- .../lib/worktree-activation-store-contract.ts | 2 +- .../lib/worktree-creation-agent-seeds.test.ts | 90 +------ .../src/lib/worktree-creation-agent-seeds.ts | 45 ---- .../src/lib/worktree-creation-flow-startup.ts | 1 - ...ree-creation-flow-stranded-surface.test.ts | 3 +- .../src/lib/worktree-creation-flow.test.ts | 17 +- .../src/lib/worktree-default-terminal-tabs.ts | 24 +- .../worktree-draft-startup-view-mode.test.ts | 15 +- .../lib/worktree-draft-startup-view-mode.ts | 22 +- .../lib/worktree-initial-terminal-seeding.ts | 24 +- .../src/lib/worktree-startup-payload.ts | 29 --- .../runtime/host-structured-agents-sync.ts | 3 +- .../runtime/host-structured-agents.test.ts | 8 +- .../runtime/local-structured-chats.test.ts | 4 +- .../src/runtime/local-structured-chats.ts | 9 +- ...-structured-session-tabs-sync-hook.test.ts | 2 +- .../src/store/repos/repo-add-actions.ts | 6 +- src/shared/default-global-settings.ts | 2 - src/shared/global-settings-types.ts | 6 +- src/shared/native-chat-initial-view-mode.ts | 73 ------ .../native-chat-launch-draft-mirrorability.ts | 6 +- ...tructured-native-chat-launch-route.test.ts | 33 +-- .../structured-native-chat-launch-route.ts | 22 +- src/shared/telemetry-events.test.ts | 14 +- src/shared/telemetry-property-schemas.ts | 4 +- ...sion-paired-structured-launch.unit.test.ts | 8 +- ...native-chat-routing-authority.unit.test.ts | 3 +- 161 files changed, 637 insertions(+), 2114 deletions(-) delete mode 100644 src/main/agent-launch/agent-launch-view-mode.ts delete mode 100644 src/renderer/src/components/native-chat/native-chat-launch-session-options.test.ts delete mode 100644 src/renderer/src/components/native-chat/native-chat-launch-session-options.ts delete mode 100644 src/renderer/src/lib/native-chat-initial-view-mode.test.ts delete mode 100644 src/renderer/src/lib/native-chat-initial-view-mode.ts create mode 100644 src/renderer/src/lib/native-chat-launch-prompt-delivery.ts delete mode 100644 src/shared/native-chat-initial-view-mode.ts diff --git a/src/main/agent-launch/agent-launch-executor.test.ts b/src/main/agent-launch/agent-launch-executor.test.ts index c27d84cf0379..b917dff661bf 100644 --- a/src/main/agent-launch/agent-launch-executor.test.ts +++ b/src/main/agent-launch/agent-launch-executor.test.ts @@ -15,9 +15,7 @@ import type { AgentLaunchIntent, AgentLaunchResult } from '../../shared/agent-la import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants' const STRUCTURED_PREFERENCE = { - experimentalNativeChat: true, - experimentalStructuredNativeChat: true, - openAgentTabsInChatByDefault: true + experimentalNativeChat: true } function harness(options: { @@ -732,7 +730,7 @@ describe('the surface is published as the launch stands, before its prompt is de describe('a new local worktree whose startup terminal did not come up', () => { it('opens its agent in the view a local workspace allows, as an existing one would', async () => { const h = harness({ - settings: { experimentalNativeChat: true, openAgentTabsInChatByDefault: true } + settings: { experimentalNativeChat: true } }) h.createWorktree.mockImplementationOnce(async () => ({ worktreeId: 'wt-new', @@ -742,8 +740,7 @@ describe('a new local worktree whose startup terminal did not come up', () => { await h.run({ ...CREATE_INTENT, agent: 'opencode' }) - expect(h.createTerminalAgent).toHaveBeenCalledWith( - expect.objectContaining({ viewMode: 'chat' }) - ) + expect(h.createStructuredSession).toHaveBeenCalled() + expect(h.createTerminalAgent).not.toHaveBeenCalled() }) }) diff --git a/src/main/agent-launch/agent-launch-executor.ts b/src/main/agent-launch/agent-launch-executor.ts index 642e59914148..f33e85b92b08 100644 --- a/src/main/agent-launch/agent-launch-executor.ts +++ b/src/main/agent-launch/agent-launch-executor.ts @@ -47,7 +47,6 @@ import { type WorkspaceLaunchKind } from '../../shared/workspace-launch-kind' import type { OrcaRuntimeService } from '../runtime/orca-runtime' -import { deriveAgentLaunchTerminalViewMode } from './agent-launch-view-mode' import { isDefinitiveAgentSessionCreateRefusal } from '../../shared/agent-session-definitive-refusal' import { decideAgentLaunchMode, @@ -353,12 +352,7 @@ async function createTerminalSurface( agent: intent.agent, ...(startupPrompt ? { startupPrompt } : {}), ...terminalLaunchInputs(intent), - viewMode: deriveAgentLaunchTerminalViewMode({ - settings: readAgentLaunchModeSettings(execution.runtime), - agent: intent.agent, - ...(intent.prompt ? { prompt: intent.prompt } : {}), - connectionId: workspace.connectionId - }) + viewMode: 'terminal' }) return { outcome: { diff --git a/src/main/agent-launch/agent-launch-mode.ts b/src/main/agent-launch/agent-launch-mode.ts index be3837f97b6b..523ad1fe0e81 100644 --- a/src/main/agent-launch/agent-launch-mode.ts +++ b/src/main/agent-launch/agent-launch-mode.ts @@ -26,7 +26,7 @@ import type { import { RUNTIME_CAPABILITIES } from '../../shared/protocol-version' import { STRUCTURED_AGENT_RUNTIME_REGISTRATIONS } from '../runtime/structured-agent-runtime-registrations' import { - prefersStructuredNativeChatByDefault, + isNativeChatEnabled, resolveStructuredNativeChatSupport, type NativeChatDefaultSettings, type StructuredNativeChatBlocker @@ -137,7 +137,7 @@ export function decideAgentLaunchMode(args: { }): AgentLaunchModeReceipt { const { placement, settings } = args const vocabulary = args.vocabulary ?? DEFAULT_LAUNCH_VOCABULARY - if (!prefersStructuredNativeChatByDefault(settings)) { + if (!isNativeChatEnabled(settings)) { return { mode: 'terminal', preferred: 'terminal', diff --git a/src/main/agent-launch/agent-launch-view-mode.ts b/src/main/agent-launch/agent-launch-view-mode.ts deleted file mode 100644 index f7e6d565b38b..000000000000 --- a/src/main/agent-launch/agent-launch-view-mode.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * Which view a terminal agent tab opens in — the terminal itself or its chat view — decided on the - * host by the rule the window applies to a tab it creates, so a launch from the phone, the CLI or the - * window opens the same way. Never a caller field. - */ - -import type { AgentLaunchPrompt } from '../../shared/agent-launch-intent' -import type { GlobalSettings } from '../../shared/global-settings-types' -import { decideInitialAgentTabViewMode } from '../../shared/native-chat-initial-view-mode' -import { isNativeChatTranscriptLocalReadable } from '../../shared/native-chat-transcript-readability' -import type { TuiAgent } from '../../shared/tui-agent' - -export type AgentLaunchTerminalViewMode = 'terminal' | 'chat' - -/** Always explicit: an absent view lets the window re-derive it without the prompt's delivery. */ -export function deriveAgentLaunchTerminalViewMode(args: { - settings: Partial< - Pick - > | null - agent: TuiAgent - prompt?: AgentLaunchPrompt - /** The workspace's SSH connection; `null` is local. */ - connectionId: string | null | undefined -}): AgentLaunchTerminalViewMode { - const viewMode = decideInitialAgentTabViewMode({ - experimentalNativeChat: args.settings?.experimentalNativeChat, - openAgentTabsInChatByDefault: args.settings?.openAgentTabsInChatByDefault, - agent: args.agent, - ...(args.prompt - ? { - promptDelivery: args.prompt.delivery === 'draft' ? 'draft' : 'auto-submit', - launchDraftText: args.prompt.text - } - : {}), - nativeChatTranscriptIsLocalReadable: - args.connectionId !== undefined && isNativeChatTranscriptLocalReadable(args.connectionId) - }) - return viewMode === 'chat' ? 'chat' : 'terminal' -} diff --git a/src/main/ipc/runtime.test.ts b/src/main/ipc/runtime.test.ts index ece4a272f9b9..8820e7c93c43 100644 --- a/src/main/ipc/runtime.test.ts +++ b/src/main/ipc/runtime.test.ts @@ -172,7 +172,7 @@ describe('registerRuntimeHandlers', () => { } const runtime = { getRuntimeId: vi.fn().mockReturnValue('runtime-1'), - getClientSettings: vi.fn(() => ({ experimentalStructuredNativeChat: true })), + getClientSettings: vi.fn(() => ({ experimentalNativeChat: true })), restoreStructuredAgentSessionTabs: vi.fn(async () => undefined), listMobileSessionTabs: vi.fn(async () => ({ worktree: 'workspace-1', diff --git a/src/main/persistence/applying-settings/terminal-settings-migrations.ts b/src/main/persistence/applying-settings/terminal-settings-migrations.ts index 8f2cc0dc5ca5..3ed0b25f84b3 100644 --- a/src/main/persistence/applying-settings/terminal-settings-migrations.ts +++ b/src/main/persistence/applying-settings/terminal-settings-migrations.ts @@ -64,6 +64,8 @@ type RetiredGlobalSettings = { experimentalManagedServers?: unknown // Why: #22551 kept this key in settings; it now lives in a main-owned store and must never ride along. opencodeGoApiKey?: unknown + experimentalStructuredNativeChat?: unknown + openAgentTabsInChatByDefault?: unknown } export function stripRetiredGlobalSettings( @@ -75,6 +77,8 @@ export function stripRetiredGlobalSettings( showAgentsSidebar: _legacyShowAgentsSidebar, experimentalManagedServers: _retiredManagedServersExperiment, opencodeGoApiKey: _legacyOpenCodeGoApiKey, + experimentalStructuredNativeChat: _legacyStructuredNativeChat, + openAgentTabsInChatByDefault: _legacyChatDefaultView, ...rest } = (settings ?? {}) as Partial & RetiredGlobalSettings void _legacyScrollbackBytes @@ -82,6 +86,8 @@ export function stripRetiredGlobalSettings( void _legacyShowAgentsSidebar void _retiredManagedServersExperiment void _legacyOpenCodeGoApiKey + void _legacyStructuredNativeChat + void _legacyChatDefaultView return rest } diff --git a/src/main/persistence/loading-store/normalize-loaded-global-settings.test.ts b/src/main/persistence/loading-store/normalize-loaded-global-settings.test.ts index 7cbce85fe9b0..0db64a75fb83 100644 --- a/src/main/persistence/loading-store/normalize-loaded-global-settings.test.ts +++ b/src/main/persistence/loading-store/normalize-loaded-global-settings.test.ts @@ -45,6 +45,19 @@ describe('retired managed servers experiment', () => { }) }) +describe('retired chat default selectors', () => { + it('keeps Chat UI on while dropping both older keys from a saved profile', () => { + const normalized = normalizeLegacyProfile({ + experimentalNativeChat: true, + experimentalStructuredNativeChat: false, + openAgentTabsInChatByDefault: false + }) + expect(normalized.experimentalNativeChat).toBe(true) + expect(normalized).not.toHaveProperty('experimentalStructuredNativeChat') + expect(normalized).not.toHaveProperty('openAgentTabsInChatByDefault') + }) +}) + describe('structured chat shell environment settings', () => { it('keeps a valid saved list and an explicit opt-out', () => { const normalized = normalizeLegacyProfile({ diff --git a/src/main/runtime/claude-structured-session-integration.test.ts b/src/main/runtime/claude-structured-session-integration.test.ts index 7dc6629a566e..b3acbb0548d7 100644 --- a/src/main/runtime/claude-structured-session-integration.test.ts +++ b/src/main/runtime/claude-structured-session-integration.test.ts @@ -227,7 +227,7 @@ beforeEach(async () => { hookServer = new AgentHookServer() const runtime = { getRuntimeId: () => 'runtime-1', - getClientSettings: () => ({ experimentalStructuredNativeChat: true }), + getClientSettings: () => ({ experimentalNativeChat: true }), getStructuredAgentSessionCreateSupport: async () => ({ supported: true }), resolveStructuredAgentSessionCreateIntent: async (input: { envelope: unknown }) => ({ ...ensureParams(1), diff --git a/src/main/runtime/orca-runtime-tests/paired-settings.spec.ts b/src/main/runtime/orca-runtime-tests/paired-settings.spec.ts index a823e7d45912..9f9909c7ec4d 100644 --- a/src/main/runtime/orca-runtime-tests/paired-settings.spec.ts +++ b/src/main/runtime/orca-runtime-tests/paired-settings.spec.ts @@ -6,8 +6,21 @@ import { electronMocks } from '../orca-runtime-test-mocks.spec' import { deferred, store } from '../orca-runtime-test-fixtures.spec' +import { RuntimeClientSettingsController } from '../runtime-client-settings' describe('OrcaRuntimeService', () => { + it.each([true, false])('projects the single Chat UI switch to older clients (%s)', (enabled) => { + const controller = new RuntimeClientSettingsController({ + getSettings: () => ({ ...store.getSettings(), experimentalNativeChat: enabled }), + updateSettings: () => undefined + }) + expect(controller.get()).toMatchObject({ + experimentalNativeChat: enabled, + experimentalStructuredNativeChat: enabled, + openAgentTabsInChatByDefault: false + }) + }) + it('projects runtime-backed settings to paired clients', () => { const terminalQuickCommands = [ { diff --git a/src/main/runtime/rpc/methods/agent-launch-caller-renders.test.ts b/src/main/runtime/rpc/methods/agent-launch-caller-renders.test.ts index 9ad8705529b6..cfc81c0d4ab9 100644 --- a/src/main/runtime/rpc/methods/agent-launch-caller-renders.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-caller-renders.test.ts @@ -91,7 +91,7 @@ describe('a launch the caller cannot show as a chat', () => { describe('Grok with the structured-chat setting off', () => { it('opens as a terminal for every caller, as Claude and Codex do', async () => { - const off = { ...STRUCTURED_PREFERENCE, experimentalStructuredNativeChat: false } + const off = { ...STRUCTURED_PREFERENCE, experimentalNativeChat: false } for (const agent of ['grok', 'claude']) { const { result, runtime } = await launchInto(agent, {}, off) expect(result.outcome).toMatchObject({ kind: 'terminal' }) diff --git a/src/main/runtime/rpc/methods/agent-launch-floating-workspace.test.ts b/src/main/runtime/rpc/methods/agent-launch-floating-workspace.test.ts index f51e53369eae..b235bac0e08b 100644 --- a/src/main/runtime/rpc/methods/agent-launch-floating-workspace.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-floating-workspace.test.ts @@ -91,7 +91,7 @@ describe('agent.launch with the real floating workspace resolver', () => { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the launch reads only these preferences and optional agentCmdOverrides; no other settings consumer runs because terminal creation is stubbed. { ...STRUCTURED_PREFERENCE, - openAgentTabsInChatByDefault: structuredPreference + experimentalNativeChat: structuredPreference } as ReturnType ) const scope = vi.spyOn(runtime, 'showTerminalWorkspaceLaunchScope') diff --git a/src/main/runtime/rpc/methods/agent-launch-instant-tab.test.ts b/src/main/runtime/rpc/methods/agent-launch-instant-tab.test.ts index 75a801d75983..17e9ffdaef29 100644 --- a/src/main/runtime/rpc/methods/agent-launch-instant-tab.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-instant-tab.test.ts @@ -780,25 +780,34 @@ describe('whose view moves', () => { }) describe('the view the tab opens in', () => { - const CHAT_VIEW = { experimentalNativeChat: true, openAgentTabsInChatByDefault: true } + const TERMINAL_VIEW = { experimentalNativeChat: false } - it('is derived on the host, the same for the shown tab and the spawn', async () => { - const runtime = hostWithWindow({ settings: CHAT_VIEW }) + it('keeps a terminal launch in the same view for the shown tab and spawn', async () => { + const runtime = hostWithWindow({ settings: TERMINAL_VIEW }) await replayLaunch(runtime, {}, CLI) - expect(runtime.published[0]?.viewMode).toBe('chat') - expect(terminalOptions(runtime)).toMatchObject({ viewMode: 'chat' }) + expect(runtime.published[0]?.viewMode).toBe('terminal') + expect(terminalOptions(runtime)).toMatchObject({ viewMode: 'terminal' }) }) - it('stays the terminal for a draft the chat view cannot mirror', async () => { - const runtime = hostWithWindow({ settings: CHAT_VIEW }) + it('keeps a terminal draft in terminal view regardless of mirrorability', async () => { + const runtime = hostWithWindow({ settings: TERMINAL_VIEW }) await replayLaunch(runtime, { prompt: { text: ' ', delivery: 'draft' } }, CLI) expect(runtime.published[0]?.viewMode).toBe('terminal') expect(terminalOptions(runtime)).toMatchObject({ viewMode: 'terminal' }) }) + + it('publishes a terminal fallback in terminal view while Chat UI is on', async () => { + const runtime = hostWithWindow({ settings: { experimentalNativeChat: true } }) + + await replayLaunch(runtime, { agent: 'gemini' }, CLI) + + expect(runtime.published[0]?.viewMode).toBe('terminal') + expect(terminalOptions(runtime)).toMatchObject({ viewMode: 'terminal' }) + }) }) it('hands the window the prompt, so a pane whose agent did not start can offer to copy it', async () => { diff --git a/src/main/runtime/rpc/methods/agent-launch-replay.test.ts b/src/main/runtime/rpc/methods/agent-launch-replay.test.ts index 7d18b2d2f7c4..712b55adb3f9 100644 --- a/src/main/runtime/rpc/methods/agent-launch-replay.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-replay.test.ts @@ -267,9 +267,7 @@ describe('a replay answers from the record', () => { // now say the user prefers a terminal; the recorded one still says what actually ran. const movedSettings = runtimeStub({ settings: { - experimentalNativeChat: false, - experimentalStructuredNativeChat: false, - openAgentTabsInChatByDefault: false + experimentalNativeChat: false } }) const replayed = await launch(params, movedSettings, PAIRED_CLIENT) diff --git a/src/main/runtime/rpc/methods/agent-launch-tab-publication.ts b/src/main/runtime/rpc/methods/agent-launch-tab-publication.ts index fd4d29bd5476..ba48fada691c 100644 --- a/src/main/runtime/rpc/methods/agent-launch-tab-publication.ts +++ b/src/main/runtime/rpc/methods/agent-launch-tab-publication.ts @@ -43,7 +43,6 @@ import { decideAgentLaunchMode, readAgentLaunchModeSettings } from '../../../agent-launch/agent-launch-mode' -import { deriveAgentLaunchTerminalViewMode } from '../../../agent-launch/agent-launch-view-mode' import type { RpcContext } from '../core' import type { AgentLaunchParams } from './agent-launch-schemas' import { agentLaunchOperationCallerKey } from './agent-launch-replay' @@ -199,12 +198,7 @@ export async function publishAgentLaunchTabEarly( tabId: pane.tabId, leafId: pane.leafId, launchAgent: params.agent, - viewMode: deriveAgentLaunchTerminalViewMode({ - settings, - agent: params.agent, - ...(params.prompt ? { prompt: params.prompt } : {}), - connectionId: workspace.connectionId - }), + viewMode: 'terminal', ...(params.placement ? { placement: params.placement } : {}), viewer: agentLaunchTabViewerRule(context, params.presentation), ...(params.prompt?.text ? { prompt: params.prompt.text } : {}), diff --git a/src/main/runtime/rpc/methods/agent-launch.test-fixture.ts b/src/main/runtime/rpc/methods/agent-launch.test-fixture.ts index d41f250ad969..348cfed86d9f 100644 --- a/src/main/runtime/rpc/methods/agent-launch.test-fixture.ts +++ b/src/main/runtime/rpc/methods/agent-launch.test-fixture.ts @@ -17,9 +17,7 @@ import type { } from '../../../../shared/agent-launch-tab-publication' export const STRUCTURED_PREFERENCE = { - experimentalNativeChat: true, - experimentalStructuredNativeChat: true, - openAgentTabsInChatByDefault: true + experimentalNativeChat: true } export type AgentLaunchRuntimeStubOptions = { diff --git a/src/main/runtime/rpc/methods/agent-launch.test.ts b/src/main/runtime/rpc/methods/agent-launch.test.ts index 581a86115602..5bc9b3c98ab6 100644 --- a/src/main/runtime/rpc/methods/agent-launch.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch.test.ts @@ -548,8 +548,8 @@ describe('the terminal factory', () => { expect(runtime.createTerminal).toHaveBeenCalledWith('id:wt-new', { startupAgent: 'claude', - // The host derives the tab's first view by the window's rule; chat view is on by default here. - viewMode: 'chat', + // A refused structured session opens its fallback tab as a terminal. + viewMode: 'terminal', // A paired device's launch never moves the desktop window. surfaceOwner: false, onPtySpawnDispatched: expect.any(Function) diff --git a/src/main/runtime/rpc/methods/client-ui.test.ts b/src/main/runtime/rpc/methods/client-ui.test.ts index cf47bc564546..0894fb7d7de5 100644 --- a/src/main/runtime/rpc/methods/client-ui.test.ts +++ b/src/main/runtime/rpc/methods/client-ui.test.ts @@ -32,7 +32,6 @@ describe('client UI RPC methods', () => { visibleTaskProviders: ['github', 'gitlab'], defaultRepoSelection: ['repo-1'], defaultLinearTeamSelection: ['team-1'], - experimentalStructuredNativeChat: true, compactWorktreeCards: true, minimaxGroupId: 'group-42', minimaxUsageModels: 'general,abab6.5', @@ -71,7 +70,7 @@ describe('client UI RPC methods', () => { const dispatcher = new RpcDispatcher({ runtime, methods: CLIENT_UI_METHODS }) const response = await dispatcher.dispatch( - makeRequest('settings.update', { experimentalStructuredNativeChat: true }) + makeRequest('settings.update', { experimentalNativeChat: true }) ) expect(response).toMatchObject({ diff --git a/src/main/runtime/rpc/methods/orchestration-worker-mode-opacity.test.ts b/src/main/runtime/rpc/methods/orchestration-worker-mode-opacity.test.ts index 7852626c0a18..6724ce93e22d 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-mode-opacity.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-mode-opacity.test.ts @@ -60,13 +60,11 @@ vi.mock('./orchestration-structured-worker-session', async (importOriginal) => ( const STRUCTURED_DEFAULT = { experimentalNativeChat: true, - openAgentTabsInChatByDefault: true, - experimentalStructuredNativeChat: true, agentCmdOverrides: {}, agentDefaultArgs: {}, agentDefaultEnv: {} } -const TERMINAL_DEFAULT = { ...STRUCTURED_DEFAULT, experimentalStructuredNativeChat: false } +const TERMINAL_DEFAULT = { ...STRUCTURED_DEFAULT, experimentalNativeChat: false } /** A coordinator that IS a structured session: registry identity plus a live durable record. */ function installStructuredCoordinator(handle: string, sessionId: string): string { diff --git a/src/main/runtime/rpc/methods/orchestration-worker-start-mode-selection.test.ts b/src/main/runtime/rpc/methods/orchestration-worker-start-mode-selection.test.ts index 271071d59704..ecdf276d2dcc 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-start-mode-selection.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-start-mode-selection.test.ts @@ -40,8 +40,6 @@ vi.mock('./orchestration-structured-worker-session', async (importOriginal) => ( const STRUCTURED_DEFAULT = { experimentalNativeChat: true, - openAgentTabsInChatByDefault: true, - experimentalStructuredNativeChat: true, agentCmdOverrides: {}, agentDefaultArgs: {}, agentDefaultEnv: {} @@ -171,10 +169,7 @@ describe('worker-start honours the settings default', () => { }) it('starts a terminal agent worker when it is not', async () => { - const result = await startWorker({ - ...STRUCTURED_DEFAULT, - experimentalStructuredNativeChat: false - }) + const result = await startWorker({ experimentalNativeChat: false }) expect(result).toMatchObject({ state: 'ready', @@ -253,7 +248,7 @@ describe('worker-start honours the settings default', () => { const created = mockWorktreeCreation() const result = await startWorker( - { ...STRUCTURED_DEFAULT, experimentalStructuredNativeChat: false }, + { ...STRUCTURED_DEFAULT, experimentalNativeChat: false }, { worktree: 'new-child', name: 'worker-child' } ) diff --git a/src/main/runtime/rpc/methods/orchestration-worker-start-mode.test.ts b/src/main/runtime/rpc/methods/orchestration-worker-start-mode.test.ts index aee3bfefb6d0..e11bcb25758b 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-start-mode.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-start-mode.test.ts @@ -14,9 +14,7 @@ import { } from './orchestration-worker-start-mode' const STRUCTURED_DEFAULT = { - experimentalNativeChat: true, - openAgentTabsInChatByDefault: true, - experimentalStructuredNativeChat: true + experimentalNativeChat: true } function decide( @@ -42,8 +40,8 @@ describe('worker start mode from the user default', () => { it.each([ ['native chat off', { ...STRUCTURED_DEFAULT, experimentalNativeChat: false }], - ['chat-by-default off', { ...STRUCTURED_DEFAULT, openAgentTabsInChatByDefault: false }], - ['structured off', { ...STRUCTURED_DEFAULT, experimentalStructuredNativeChat: false }], + ['chat-by-default off', { ...STRUCTURED_DEFAULT, experimentalNativeChat: false }], + ['structured off', { ...STRUCTURED_DEFAULT, experimentalNativeChat: false }], ['no settings at all', null] ])('starts a terminal worker when %s', (_name, settings) => { expect(decide({ settings })).toMatchObject({ diff --git a/src/main/runtime/rpc/methods/orchestration-worker-start-receipt-wording.test.ts b/src/main/runtime/rpc/methods/orchestration-worker-start-receipt-wording.test.ts index 4648880586b7..b950ad51eabb 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-start-receipt-wording.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-start-receipt-wording.test.ts @@ -21,9 +21,7 @@ import { } from './orchestration-worker-start-mode' const STRUCTURED_PREFERENCE = { - experimentalNativeChat: true, - experimentalStructuredNativeChat: true, - openAgentTabsInChatByDefault: true + experimentalNativeChat: true } as const function structuredReceipt(): WorkerStartModeReceipt { diff --git a/src/main/runtime/rpc/methods/orchestration-worker-start-registered-agent.test.ts b/src/main/runtime/rpc/methods/orchestration-worker-start-registered-agent.test.ts index 175f650c7a1f..9d24f3dad1e6 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-start-registered-agent.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-start-registered-agent.test.ts @@ -11,9 +11,7 @@ import { import { createStructuredWorkerSessionForWorktree } from './orchestration/worker/worker-topology' const STRUCTURED_PREFERENCE = { - experimentalNativeChat: true, - experimentalStructuredNativeChat: true, - openAgentTabsInChatByDefault: true + experimentalNativeChat: true } as const describe('a Grok worker and the structured-chat setting', () => { @@ -56,7 +54,7 @@ describe('a Grok worker and the structured-chat setting', () => { expect( decideWorkerStartMode({ params: { agent }, - settings: { ...STRUCTURED_PREFERENCE, experimentalStructuredNativeChat: false } + settings: { experimentalNativeChat: false } }) ).toMatchObject({ mode: 'terminal', preferred: 'terminal', reason: 'user_default' }) } diff --git a/src/main/runtime/rpc/methods/orchestration-worker-support-unknown.test.ts b/src/main/runtime/rpc/methods/orchestration-worker-support-unknown.test.ts index 1adca072f20e..7bcd483e8c0b 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-support-unknown.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-support-unknown.test.ts @@ -7,9 +7,7 @@ import { const mode = decideWorkerStartMode({ params: { agent: 'claude' }, settings: { - experimentalNativeChat: true, - experimentalStructuredNativeChat: true, - openAgentTabsInChatByDefault: true + experimentalNativeChat: true } }) diff --git a/src/main/runtime/rpc/methods/session-tab-agent-capability-mutations.test.ts b/src/main/runtime/rpc/methods/session-tab-agent-capability-mutations.test.ts index f0792e20a471..1996789733de 100644 --- a/src/main/runtime/rpc/methods/session-tab-agent-capability-mutations.test.ts +++ b/src/main/runtime/rpc/methods/session-tab-agent-capability-mutations.test.ts @@ -128,9 +128,10 @@ describe('session tab structured capability mutations', () => { async (method) => { const snapshot = agentSnapshot() const closeMobileSessionTab = vi.fn().mockResolvedValue({ closed: true }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Dispatcher uses only the methods supplied by this test double. const runtime = { getRuntimeId: () => 'test-runtime', - getClientSettings: vi.fn(() => ({ experimentalStructuredNativeChat: true })), + getClientSettings: vi.fn(() => ({ experimentalNativeChat: true })), listMobileSessionTabs: vi.fn().mockResolvedValue(snapshot), closeMobileSessionTab } as unknown as OrcaRuntimeService @@ -177,13 +178,14 @@ function createFixture( moveMobileSessionTab: vi.fn().mockResolvedValue({ moved: true }), setMobileSessionTabProps: vi.fn().mockResolvedValue({ updated: true }) } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Dispatcher uses only the methods supplied by this test double. const runtime = { getRuntimeId: () => 'test-runtime', listMobileSessionTabs: vi.fn().mockResolvedValue(snapshot), getClientSettings: () => ({ // Why: defaults on, so a fixture that says nothing about the setting exercises capability // gating alone; callers opt into the off case explicitly. - experimentalStructuredNativeChat: options.structuredNativeChatEnabled !== false + experimentalNativeChat: options.structuredNativeChatEnabled !== false }), ...calls } as unknown as OrcaRuntimeService diff --git a/src/main/runtime/rpc/methods/session-tabs-structured-restore.test.ts b/src/main/runtime/rpc/methods/session-tabs-structured-restore.test.ts index 0bfc94f3663d..d28a76b74cfb 100644 --- a/src/main/runtime/rpc/methods/session-tabs-structured-restore.test.ts +++ b/src/main/runtime/rpc/methods/session-tabs-structured-restore.test.ts @@ -17,7 +17,7 @@ function makeRuntime(): OrcaRuntimeService { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only the runtime members these RPCs read are staged. return { getRuntimeId: () => 'test-runtime', - getClientSettings: vi.fn(() => ({ experimentalStructuredNativeChat: false })), + getClientSettings: vi.fn(() => ({ experimentalNativeChat: false })), restoreStructuredAgentSessionTabs: vi.fn(), listMobileSessionTabs: vi.fn().mockResolvedValue(visibleSnapshot()) } as unknown as OrcaRuntimeService diff --git a/src/main/runtime/rpc/methods/session-tabs-subscribe-admission.test.ts b/src/main/runtime/rpc/methods/session-tabs-subscribe-admission.test.ts index 6f874b226330..f683d5219d4b 100644 --- a/src/main/runtime/rpc/methods/session-tabs-subscribe-admission.test.ts +++ b/src/main/runtime/rpc/methods/session-tabs-subscribe-admission.test.ts @@ -36,7 +36,7 @@ function makeHost(options: { structuredChat?: boolean } = {}) { const runtime = { getRuntimeId: () => 'test-runtime', getClientSettings: () => ({ - experimentalStructuredNativeChat: options.structuredChat === true + experimentalNativeChat: options.structuredChat === true }), restoreStructuredAgentSessionTabs: restore, listMobileSessionTabs, diff --git a/src/main/runtime/rpc/methods/structured-agent-session-admission.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-admission.test.ts index dc6fd761b1c2..94f936b8050a 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-admission.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-admission.test.ts @@ -31,8 +31,8 @@ afterEach(() => { clearStructuredHostStub() }) -const SETTING_OFF = { getClientSettings: () => ({ experimentalStructuredNativeChat: false }) } -const SETTING_ON = { getClientSettings: () => ({ experimentalStructuredNativeChat: true }) } +const SETTING_OFF = { getClientSettings: () => ({ experimentalNativeChat: false }) } +const SETTING_ON = { getClientSettings: () => ({ experimentalNativeChat: true }) } // A client that picks each launch's mode itself, as the desktop does. const MODE_CHOOSING_CLIENT = { ...STRUCTURED_CLIENT, diff --git a/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts index db540d3bbd73..4a82cb904752 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts @@ -144,7 +144,6 @@ describe('committed adopting create RPC replay', () => { const runtime = new OrcaRuntimeService( { getSettings: () => ({ - experimentalStructuredNativeChat: true, agentDefaultEnv: { codex: {} } }) } as never, @@ -153,8 +152,9 @@ describe('committed adopting create RPC replay', () => { ) // The structured surface is settings-gated for every caller, not just mobile; this test // probes durable-identity replay, which only runs once the gate admits the call. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The RPC gate reads only this setting from the test double. vi.spyOn(runtime, 'getClientSettings').mockReturnValue({ - experimentalStructuredNativeChat: true + experimentalNativeChat: true } as ReturnType) vi.spyOn(runtime, 'getStructuredAgentSessionCreateSupport').mockResolvedValue({ supported: true diff --git a/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts index 1e5bb7e6bc37..436e7020d0db 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts @@ -81,9 +81,7 @@ beforeEach(async () => { vi.spyOn(runtime, 'getClientSettings').mockImplementation( () => // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the RPC gate reads only this one setting. - ({ experimentalStructuredNativeChat: true }) as ReturnType< - OrcaRuntimeService['getClientSettings'] - > + ({ experimentalNativeChat: true }) as ReturnType ) dispatcher = new RpcDispatcher({ runtime, methods: STRUCTURED_AGENT_SESSION_METHODS }) }) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts index 549b0a92dd26..25467e57248d 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts @@ -32,6 +32,8 @@ import { openTestJournalHostDatabase } from '../../../native-chat/agent-session- import { createStructuredAgentSessionLogger } from '../../../native-chat/agent-session-wire/structured-agent-session-logger' import { codexProviderHandle } from '../../../../shared/agent-session-provider-handle-encoding' import { NO_STRUCTURED_AGENTS } from '../../../native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' +import { getDefaultPersistedState } from '../../../../shared/constants' +import { RuntimeClientSettingsController } from '../../runtime-client-settings' const CONNECTION = 'connection-1' const CLIENT = { @@ -109,12 +111,14 @@ beforeEach(async () => { setStructuredAgentSessionHost(host) runtime = new OrcaRuntimeService() // The structured surface is settings-gated for every caller, in-process included. - vi.spyOn(runtime, 'getClientSettings').mockImplementation( - () => - ({ experimentalStructuredNativeChat: structuredNativeChatEnabled }) as ReturnType< - OrcaRuntimeService['getClientSettings'] - > - ) + const clientSettings = new RuntimeClientSettingsController({ + getSettings: () => ({ + ...getDefaultPersistedState(root).settings, + experimentalNativeChat: structuredNativeChatEnabled + }), + updateSettings: () => undefined + }).get() + vi.spyOn(runtime, 'getClientSettings').mockImplementation(() => clientSettings) dispatcher = new RpcDispatcher({ runtime, methods: STRUCTURED_AGENT_SESSION_METHODS }) expect(await host.attach({ callerKey: 'client-1' }, hostTestAttachParams(null))).toMatchObject({ ok: true diff --git a/src/main/runtime/rpc/methods/structured-agent-session-policy.ts b/src/main/runtime/rpc/methods/structured-agent-session-policy.ts index 4295dee842f0..a920a2c4192f 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-policy.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-policy.ts @@ -6,12 +6,13 @@ import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, type RuntimeCapability } from '../../../../shared/protocol-version' +import { isNativeChatEnabled } from '../../../../shared/structured-native-chat-launch-route' import type { OrcaRuntimeService } from '../../orca-runtime' import type { RpcContext } from '../core' /** * One rule for every caller: can this client read structured sessions? The host's own - * `experimentalStructuredNativeChat` is not consulted. It is the host user's launch preference, + * The host's Chat UI setting is not consulted. It is the host user's launch preference, * and whether a new agent is a chat is decided by whoever launches it, so a paired client's * sessions stay reachable whatever the host's setting says. The negotiated capability is a wire * term, asked of remote clients only: in-process callers are the host's own build. @@ -91,7 +92,7 @@ export function isStructuredNativeChatEnabled( runtime: Pick ): boolean { try { - return runtime.getClientSettings().experimentalStructuredNativeChat === true + return isNativeChatEnabled(runtime.getClientSettings()) } catch { return false } diff --git a/src/main/runtime/rpc/methods/structured-agent-session-precommit-refusal.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-precommit-refusal.test.ts index 970374824d1f..e837a7efdb05 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-precommit-refusal.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-precommit-refusal.test.ts @@ -79,7 +79,7 @@ async function create( getRuntimeId: () => 'runtime-1', // The structured surface is settings-gated for every caller; these fixtures probe the // pre-commit boundary, which only runs once the gate admits the call. - getClientSettings: () => ({ experimentalStructuredNativeChat: true }), + getClientSettings: () => ({ experimentalNativeChat: true }), registerSubscriptionCleanup: vi.fn(), cleanupSubscription: vi.fn(), cleanupSubscriptionsByPrefix: vi.fn(), diff --git a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts index c4b3f62700b6..a24d21e69382 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts @@ -248,7 +248,7 @@ export function dispatcher( }) const runtime = { getRuntimeId: () => 'runtime-1', - getClientSettings: () => ({ experimentalStructuredNativeChat: true }), + getClientSettings: () => ({ experimentalNativeChat: true }), registerSubscriptionCleanup: vi.fn(), cleanupSubscription: vi.fn(), cleanupSubscriptionsByPrefix: vi.fn(), diff --git a/src/main/runtime/rpc/methods/structured-agent-session.test.ts b/src/main/runtime/rpc/methods/structured-agent-session.test.ts index 4bbd415cd025..e287472bb6ce 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.test.ts @@ -312,7 +312,7 @@ describe('capability gating', () => { it('serves a capable mobile client whatever the host structured-chat setting says', async () => { const response = await call('agentSession.send', sendParams(), STRUCTURED_MOBILE_CLIENT, { - getClientSettings: () => ({ experimentalStructuredNativeChat: false }) + getClientSettings: () => ({ experimentalNativeChat: false }) }) expect(response).toMatchObject({ ok: true }) expect(hostCalls.send).toHaveBeenCalledTimes(1) diff --git a/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts b/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts index 7c8eb63c32e3..97feae48ff06 100644 --- a/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts +++ b/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts @@ -187,9 +187,7 @@ beforeEach(async () => { runtime = new OrcaRuntimeService() vi.spyOn(runtime, 'getClientSettings').mockReturnValue( // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the structured-chat policy reads only this one setting on these paths. - { experimentalStructuredNativeChat: true } as ReturnType< - OrcaRuntimeService['getClientSettings'] - > + { experimentalNativeChat: true } as ReturnType ) dispatcher = new RpcDispatcher({ runtime, diff --git a/src/main/runtime/runtime-client-settings.ts b/src/main/runtime/runtime-client-settings.ts index a312fc66dbe5..5ca04d127e38 100644 --- a/src/main/runtime/runtime-client-settings.ts +++ b/src/main/runtime/runtime-client-settings.ts @@ -42,8 +42,6 @@ export type RuntimeClientSettings = Pick< | 'githubProjects' | 'experimentalNewWorktreeCardStyle' | 'experimentalNativeChat' - | 'openAgentTabsInChatByDefault' - | 'experimentalStructuredNativeChat' | 'compactWorktreeCards' | 'minimaxGroupId' | 'minimaxUsageModels' @@ -55,6 +53,9 @@ export type RuntimeClientSettings = Pick< | 'agentSkillSharingEnabled' | 'machineName' > & { + /** Older clients still read these; neither is persisted or accepted as an update. */ + openAgentTabsInChatByDefault?: boolean + experimentalStructuredNativeChat?: boolean hostSettingOverrides: RuntimeHostDisplayLabelOverrides sourceControlAi: RuntimeClientSourceControlAi } @@ -125,11 +126,10 @@ export class RuntimeClientSettingsController { : null, githubProjects: settings.githubProjects, experimentalNewWorktreeCardStyle: settings.experimentalNewWorktreeCardStyle === true, - // The three that decide whether a new agent tab -- and so an orchestration worker -- is a - // structured chat session rather than a terminal agent. experimentalNativeChat: settings.experimentalNativeChat === true, - openAgentTabsInChatByDefault: settings.openAgentTabsInChatByDefault === true, - experimentalStructuredNativeChat: settings.experimentalStructuredNativeChat === true, + // Older clients use this key to fall back to terminal-backed chat. Keep their default terminal. + openAgentTabsInChatByDefault: false, + experimentalStructuredNativeChat: settings.experimentalNativeChat === true, compactWorktreeCards: settings.compactWorktreeCards === true, minimaxGroupId: settings.minimaxGroupId ?? '', minimaxUsageModels: settings.minimaxUsageModels ?? 'general', diff --git a/src/main/runtime/runtime-store-contract.ts b/src/main/runtime/runtime-store-contract.ts index b25168334c67..0c5c24f4cb24 100644 --- a/src/main/runtime/runtime-store-contract.ts +++ b/src/main/runtime/runtime-store-contract.ts @@ -96,8 +96,6 @@ export type RuntimeStore = { agentStatusHooksEnabled?: GlobalSettings['agentStatusHooksEnabled'] terminalCopyTrimsGutter?: GlobalSettings['terminalCopyTrimsGutter'] experimentalNativeChat?: GlobalSettings['experimentalNativeChat'] - openAgentTabsInChatByDefault?: GlobalSettings['openAgentTabsInChatByDefault'] - experimentalStructuredNativeChat?: GlobalSettings['experimentalStructuredNativeChat'] defaultTaskSource?: GlobalSettings['defaultTaskSource'] defaultTaskViewPreset?: GlobalSettings['defaultTaskViewPreset'] visibleTaskProviders?: GlobalSettings['visibleTaskProviders'] diff --git a/src/main/runtime/structured-agent-runtime-registrations-acp.test.ts b/src/main/runtime/structured-agent-runtime-registrations-acp.test.ts index 43b11bdee1cf..c3892d5e6556 100644 --- a/src/main/runtime/structured-agent-runtime-registrations-acp.test.ts +++ b/src/main/runtime/structured-agent-runtime-registrations-acp.test.ts @@ -55,19 +55,17 @@ describe('ACP agents in the runtime registrations', () => { expect(registration?.supportsLaunch).toBeTypeOf('function') }) - it('opens OMP as a structured chat only with the updated structured chat setting on', () => { + it('opens OMP as a structured chat with Chat UI on', () => { const settings = { - experimentalNativeChat: true, - openAgentTabsInChatByDefault: true, - experimentalStructuredNativeChat: true + experimentalNativeChat: true } const placement = { agent: 'omp', workspaceKind: 'git-worktree' } as const expect(decideAgentLaunchMode({ placement, settings }).mode).toBe('structured') - // Off: OMP keeps the terminal-backed chat it has always had. + // Off: new OMP launches use the terminal. expect( decideAgentLaunchMode({ placement, - settings: { ...settings, experimentalStructuredNativeChat: false } + settings: { experimentalNativeChat: false } }) ).toMatchObject({ mode: 'terminal', reason: 'user_default' }) }) diff --git a/src/main/runtime/structured-agent-session-integration.test.ts b/src/main/runtime/structured-agent-session-integration.test.ts index 92510aa3241a..2842c2b280fd 100644 --- a/src/main/runtime/structured-agent-session-integration.test.ts +++ b/src/main/runtime/structured-agent-session-integration.test.ts @@ -327,7 +327,7 @@ beforeEach(async () => { shellEnvironmentPolicy = { inheritAll: true, names: [] } const runtime = { getRuntimeId: () => 'runtime-1', - getClientSettings: () => ({ experimentalStructuredNativeChat: true }), + getClientSettings: () => ({ experimentalNativeChat: true }), getStructuredAgentSessionCreateSupport: async () => ({ supported: true }), resolveStructuredAgentSessionCreateIntent: async () => { const { diff --git a/src/renderer/src/components/NativeChatResumeOnRestartModal.list.test.tsx b/src/renderer/src/components/NativeChatResumeOnRestartModal.list.test.tsx index 1d778c697462..eed135ccce05 100644 --- a/src/renderer/src/components/NativeChatResumeOnRestartModal.list.test.tsx +++ b/src/renderer/src/components/NativeChatResumeOnRestartModal.list.test.tsx @@ -48,7 +48,7 @@ beforeEach(() => { consumeNativeChatResumeOnRestartDialogRequest() useAppStore.setState(useAppStore.getInitialState(), true) useAppStore.setState({ - settings: { ...getDefaultSettings(''), experimentalStructuredNativeChat: true } + settings: { ...getDefaultSettings(''), experimentalNativeChat: true } }) container = document.createElement('div') document.body.append(container) diff --git a/src/renderer/src/components/NativeChatResumeOnRestartModal.test.tsx b/src/renderer/src/components/NativeChatResumeOnRestartModal.test.tsx index 5c4190870203..5cae482fa95e 100644 --- a/src/renderer/src/components/NativeChatResumeOnRestartModal.test.tsx +++ b/src/renderer/src/components/NativeChatResumeOnRestartModal.test.tsx @@ -317,7 +317,7 @@ it('resumes and continues once when the launch begins opted in', async () => { useAppStore.setState({ settings: { ...getDefaultSettings(''), - experimentalStructuredNativeChat: true, + experimentalNativeChat: true, nativeChatResumeWorkOnRestart: true } }) @@ -333,12 +333,8 @@ it('resumes and continues once when the launch begins opted in', async () => { await act(async () => useAppStore.getState().updateSettings({ nativeChatResumeWorkOnRestart: true }) ) - await act(async () => - useAppStore.getState().updateSettings({ experimentalStructuredNativeChat: false }) - ) - await act(async () => - useAppStore.getState().updateSettings({ experimentalStructuredNativeChat: true }) - ) + await act(async () => useAppStore.getState().updateSettings({ experimentalNativeChat: false })) + await act(async () => useAppStore.getState().updateSettings({ experimentalNativeChat: true })) // One bulk action, with its remaining list in the reply. expect(calls()).toEqual([ ['agentSession.restartResumable', undefined], @@ -355,7 +351,7 @@ it('shows an opted-in launch resume in the status bar while it runs', async () = useAppStore.setState({ settings: { ...getDefaultSettings(''), - experimentalStructuredNativeChat: true, + experimentalNativeChat: true, nativeChatResumeWorkOnRestart: true } }) @@ -376,7 +372,7 @@ it('reports chats an opted-in launch could not carry on in one toast and the sta useAppStore.setState({ settings: { ...getDefaultSettings(''), - experimentalStructuredNativeChat: true, + experimentalNativeChat: true, nativeChatResumeWorkOnRestart: true } }) diff --git a/src/renderer/src/components/native-chat-launch-resume-decision.test.tsx b/src/renderer/src/components/native-chat-launch-resume-decision.test.tsx index 51ef0de7a6da..a6c506fd2925 100644 --- a/src/renderer/src/components/native-chat-launch-resume-decision.test.tsx +++ b/src/renderer/src/components/native-chat-launch-resume-decision.test.tsx @@ -54,7 +54,6 @@ function settings(autoResume: boolean | undefined): void { useAppStore.setState({ settings: { ...getDefaultSettings(''), - experimentalStructuredNativeChat: true, ...(autoResume === undefined ? {} : { nativeChatResumeWorkOnRestart: autoResume }) } }) diff --git a/src/renderer/src/components/native-chat-resume-modal.test-support.tsx b/src/renderer/src/components/native-chat-resume-modal.test-support.tsx index 0bbc9dcdf310..3fc6d9f70218 100644 --- a/src/renderer/src/components/native-chat-resume-modal.test-support.tsx +++ b/src/renderer/src/components/native-chat-resume-modal.test-support.tsx @@ -211,7 +211,7 @@ export function createResumeModalFixture(rpc: Mock, statusStream: Re vi.mocked(toast).mockClear() useAppStore.setState(useAppStore.getInitialState(), true) useAppStore.setState({ - settings: { ...getDefaultSettings(''), experimentalStructuredNativeChat: true }, + settings: { ...getDefaultSettings(''), experimentalNativeChat: true }, updateSettings: async (changes) => { useAppStore.setState((state) => ({ settings: { ...getDefaultSettings(''), ...state.settings, ...changes } diff --git a/src/renderer/src/components/native-chat/native-chat-availability.test.ts b/src/renderer/src/components/native-chat/native-chat-availability.test.ts index 737d7d4a0774..d26c1bac03e9 100644 --- a/src/renderer/src/components/native-chat/native-chat-availability.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-availability.test.ts @@ -6,7 +6,6 @@ describe('canToggleNativeChat', () => { it('allows a terminal launched with a supported coding agent', () => { expect( canToggleNativeChat({ - experimentalNativeChatEnabled: true, contentType: 'terminal', launchAgent: 'claude' }) @@ -16,7 +15,6 @@ describe('canToggleNativeChat', () => { it('allows a terminal with a live detected supported agent but no launchAgent', () => { expect( canToggleNativeChat({ - experimentalNativeChatEnabled: true, contentType: 'terminal', launchAgent: null, detectedAgent: 'codex' @@ -27,7 +25,6 @@ describe('canToggleNativeChat', () => { it('allows a terminal with a resolved title/foreground supported agent before hooks arrive', () => { expect( canToggleNativeChat({ - experimentalNativeChatEnabled: true, contentType: 'terminal', launchAgent: null, resolvedAgent: 'claude' @@ -38,7 +35,6 @@ describe('canToggleNativeChat', () => { it('allows the OpenClaude variant', () => { expect( canToggleNativeChat({ - experimentalNativeChatEnabled: true, contentType: 'terminal', launchAgent: 'openclaude' }) @@ -48,7 +44,6 @@ describe('canToggleNativeChat', () => { it('allows an existing chat view to toggle back after live signals are gone', () => { expect( canToggleNativeChat({ - experimentalNativeChatEnabled: true, contentType: 'terminal', launchAgent: null, isChatViewMode: true @@ -59,7 +54,6 @@ describe('canToggleNativeChat', () => { it('accepts local Grok once native chat can parse its transcript', () => { expect( canToggleNativeChat({ - experimentalNativeChatEnabled: true, contentType: 'terminal', launchAgent: 'grok', nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable(null) @@ -70,7 +64,6 @@ describe('canToggleNativeChat', () => { it('accepts runtime-owned Grok because Model B reads the transcript locally', () => { expect( canToggleNativeChat({ - experimentalNativeChatEnabled: true, contentType: 'terminal', launchAgent: 'grok', nativeChatTranscriptIsLocalReadable: @@ -82,7 +75,6 @@ describe('canToggleNativeChat', () => { it('rejects Model-A SSH Grok when its transcript is remote-only', () => { expect( canToggleNativeChat({ - experimentalNativeChatEnabled: true, contentType: 'terminal', launchAgent: 'grok', nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable('ssh-target-1') @@ -95,7 +87,6 @@ describe('canToggleNativeChat', () => { it('rejects Model-A SSH omp but accepts it local and runtime-owned', () => { const forConnection = (connectionId: string | null): boolean => canToggleNativeChat({ - experimentalNativeChatEnabled: true, contentType: 'terminal', launchAgent: 'omp', nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable(connectionId) @@ -108,7 +99,6 @@ describe('canToggleNativeChat', () => { it('lets an existing Model-A SSH Grok chat toggle back to terminal', () => { expect( canToggleNativeChat({ - experimentalNativeChatEnabled: true, contentType: 'terminal', launchAgent: 'grok', nativeChatTranscriptIsLocalReadable: false, @@ -120,7 +110,6 @@ describe('canToggleNativeChat', () => { it.each(['gemini'] as const)('rejects unsupported agent %s detected live', (agent) => { expect( canToggleNativeChat({ - experimentalNativeChatEnabled: true, contentType: 'terminal', launchAgent: null, detectedAgent: agent @@ -131,7 +120,6 @@ describe('canToggleNativeChat', () => { it('accepts Grok when resolved from the title', () => { expect( canToggleNativeChat({ - experimentalNativeChatEnabled: true, contentType: 'terminal', launchAgent: null, resolvedAgent: 'grok', @@ -143,7 +131,6 @@ describe('canToggleNativeChat', () => { it('rejects a stale supported title when live detection found an unsupported agent', () => { expect( canToggleNativeChat({ - experimentalNativeChatEnabled: true, contentType: 'terminal', launchAgent: null, detectedAgent: 'gemini', @@ -155,7 +142,6 @@ describe('canToggleNativeChat', () => { it('rejects stale launch metadata when live detection found an unsupported agent', () => { expect( canToggleNativeChat({ - experimentalNativeChatEnabled: true, contentType: 'terminal', launchAgent: 'codex', detectedAgent: 'gemini' @@ -166,7 +152,6 @@ describe('canToggleNativeChat', () => { it('rejects a stale supported title when launch metadata names an unsupported agent', () => { expect( canToggleNativeChat({ - experimentalNativeChatEnabled: true, contentType: 'terminal', launchAgent: 'gemini', resolvedAgent: 'claude' @@ -174,20 +159,18 @@ describe('canToggleNativeChat', () => { ).toBe(false) }) - it('rejects otherwise eligible terminals while the experimental flag is off', () => { + it('allows an explicit chat toggle even when Chat UI is off', () => { expect( canToggleNativeChat({ - experimentalNativeChatEnabled: false, contentType: 'terminal', launchAgent: 'claude' }) - ).toBe(false) + ).toBe(true) }) it('rejects a plain shell terminal with no agent', () => { expect( canToggleNativeChat({ - experimentalNativeChatEnabled: true, contentType: 'terminal', launchAgent: null, detectedAgent: null @@ -196,15 +179,12 @@ describe('canToggleNativeChat', () => { }) it('rejects a plain shell terminal with everything omitted', () => { - expect( - canToggleNativeChat({ experimentalNativeChatEnabled: true, contentType: 'terminal' }) - ).toBe(false) + expect(canToggleNativeChat({ contentType: 'terminal' })).toBe(false) }) it('rejects an editor tab even if a supported agent hint were somehow present', () => { expect( canToggleNativeChat({ - experimentalNativeChatEnabled: true, contentType: 'editor', launchAgent: 'codex', detectedAgent: 'codex' @@ -215,7 +195,6 @@ describe('canToggleNativeChat', () => { it('rejects a browser tab', () => { expect( canToggleNativeChat({ - experimentalNativeChatEnabled: true, contentType: 'browser', detectedAgent: 'claude' }) diff --git a/src/renderer/src/components/native-chat/native-chat-availability.ts b/src/renderer/src/components/native-chat/native-chat-availability.ts index 3cad7fc157fb..40366dbdd75e 100644 --- a/src/renderer/src/components/native-chat/native-chat-availability.ts +++ b/src/renderer/src/components/native-chat/native-chat-availability.ts @@ -13,8 +13,6 @@ export { isNativeChatSupportedAgent } * unit-testable; call sites resolve `launchAgent`/`detectedAgent` from the * terminal tab + agent-status before calling. */ export type NativeChatAvailabilityInput = { - /** Feature flag: hidden unless enabled from Settings > Experimental. */ - experimentalNativeChatEnabled?: boolean contentType: Tab['contentType'] /** The coding-agent Orca launched in this terminal, if any (from TerminalTab). */ launchAgent?: TuiAgent | null @@ -40,9 +38,6 @@ export type NativeChatAvailabilityInput = { * launch metadata is next, and title resolution only fills the pre-hook gap for * manually-started Claude/Codex/Grok sessions. */ export function canToggleNativeChat(input: NativeChatAvailabilityInput): boolean { - if (input.experimentalNativeChatEnabled !== true) { - return false - } if (input.contentType !== 'terminal') { return false } diff --git a/src/renderer/src/components/native-chat/native-chat-launch-session-options.test.ts b/src/renderer/src/components/native-chat/native-chat-launch-session-options.test.ts deleted file mode 100644 index 551be10ab0cd..000000000000 --- a/src/renderer/src/components/native-chat/native-chat-launch-session-options.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { resolveInitialNativeChatSessionOptions } from './native-chat-launch-session-options' - -const settings = { - experimentalNativeChat: true, - openAgentTabsInChatByDefault: true, - nativeChatSessionOptions: { - codex: { - model: 'gpt-5.2-codex', - valuesByModel: { 'gpt-5.2-codex': { effort: 'medium' } } - } - } -} - -describe('resolveInitialNativeChatSessionOptions', () => { - it('omits native-chat preferences from terminal-default launches', () => { - expect( - resolveInitialNativeChatSessionOptions( - { ...settings, openAgentTabsInChatByDefault: false }, - { agent: 'codex' } - ) - ).toBeUndefined() - }) - - it('applies native-chat preferences when the launch resolves to chat', () => { - expect(resolveInitialNativeChatSessionOptions(settings, { agent: 'codex' })).toEqual({ - model: 'gpt-5.2-codex', - effort: 'medium' - }) - }) - - it('omits preferences when a draft forces the initial view back to terminal', () => { - expect( - resolveInitialNativeChatSessionOptions(settings, { - agent: 'codex', - promptDelivery: 'draft', - launchDraftText: 'one\u2028two' - }) - ).toBeUndefined() - }) - - it('omits preferences when a remote transcript forces the initial view to terminal', () => { - const grokSettings = { - ...settings, - nativeChatSessionOptions: { grok: { model: 'grok-4.5' } } - } - expect( - resolveInitialNativeChatSessionOptions(grokSettings, { - agent: 'grok', - nativeChatTranscriptIsLocalReadable: false - }) - ).toBeUndefined() - }) -}) diff --git a/src/renderer/src/components/native-chat/native-chat-launch-session-options.ts b/src/renderer/src/components/native-chat/native-chat-launch-session-options.ts deleted file mode 100644 index 2271d4bd1d8b..000000000000 --- a/src/renderer/src/components/native-chat/native-chat-launch-session-options.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { GlobalSettings } from '../../../../shared/global-settings-types' -import type { TuiAgent } from '../../../../shared/tui-agent' -import type { SessionOptionValue } from '../../../../shared/native-chat-session-options' -import { - decideInitialAgentTabViewMode, - type NativeChatLaunchPromptDelivery -} from '@/lib/native-chat-initial-view-mode' -import { resolveNativeChatLaunchSessionOptions } from './native-chat-session-option-enrichment' - -type NativeChatLaunchSettings = Pick< - GlobalSettings, - 'experimentalNativeChat' | 'openAgentTabsInChatByDefault' | 'nativeChatSessionOptions' -> - -export type InitialNativeChatSessionOptionsArgs = { - agent: TuiAgent - promptDelivery?: NativeChatLaunchPromptDelivery - launchDraftText?: string - nativeChatTranscriptIsLocalReadable?: boolean -} - -export function resolveInitialNativeChatSessionOptions( - settings: NativeChatLaunchSettings | null | undefined, - args: InitialNativeChatSessionOptionsArgs -): Record | undefined { - const viewMode = decideInitialAgentTabViewMode({ - experimentalNativeChat: settings?.experimentalNativeChat, - openAgentTabsInChatByDefault: settings?.openAgentTabsInChatByDefault, - ...args - }) - return viewMode === 'chat' - ? resolveNativeChatLaunchSessionOptions(settings?.nativeChatSessionOptions, args.agent) - : undefined -} diff --git a/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.ts b/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.ts index 6cc94609361e..bb1cff3ddcfb 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.ts @@ -88,7 +88,6 @@ export function useNativeChatToggleShortcut(worktreeId: string, isWorktreeActive : null if ( !canToggleNativeChat({ - experimentalNativeChatEnabled: state.settings?.experimentalNativeChat === true, contentType: 'terminal', launchAgent: detectedAgent || !tabWideFallbackSafe ? null : terminalTab?.launchAgent, detectedAgent, diff --git a/src/renderer/src/components/onboarding/onboarding-folder-agent-startup.test.ts b/src/renderer/src/components/onboarding/onboarding-folder-agent-startup.test.ts index d57028e882c8..23cd8c0fe2e1 100644 --- a/src/renderer/src/components/onboarding/onboarding-folder-agent-startup.test.ts +++ b/src/renderer/src/components/onboarding/onboarding-folder-agent-startup.test.ts @@ -41,44 +41,26 @@ describe('buildOnboardingFolderAgentStartup', () => { expect(startup).toBeUndefined() }) - it('omits native-chat preferences from terminal-default folder launches', () => { - const startup = buildOnboardingFolderAgentStartup({ - ...getDefaultSettings('/tmp/orca-workspaces'), - defaultTuiAgent: 'codex', - experimentalNativeChat: true, - openAgentTabsInChatByDefault: false, - nativeChatSessionOptions: { - codex: { - model: 'gpt-5.2-codex', - valuesByModel: { 'gpt-5.2-codex': { effort: 'medium' } } - } - } - }) - - expect(startup?.command).not.toContain("'-m'") - expect(startup?.sessionOptions).toBeUndefined() - }) - - it('applies native-chat preferences to chat-default folder launches', () => { - const startup = buildOnboardingFolderAgentStartup({ - ...getDefaultSettings('/tmp/orca-workspaces'), - defaultTuiAgent: 'codex', - experimentalNativeChat: true, - openAgentTabsInChatByDefault: true, - nativeChatSessionOptions: { - codex: { - model: 'gpt-5.2-codex', - valuesByModel: { 'gpt-5.2-codex': { effort: 'medium' } } + it.each([false, true])( + 'keeps Chat UI model preferences out of the terminal folder launch (Chat UI %s)', + (experimentalNativeChat) => { + const startup = buildOnboardingFolderAgentStartup({ + ...getDefaultSettings('/tmp/orca-workspaces'), + defaultTuiAgent: 'codex', + experimentalNativeChat, + nativeChatSessionOptions: { + codex: { + model: 'gpt-5.2-codex', + valuesByModel: { 'gpt-5.2-codex': { effort: 'medium' } } + } } - } - }) + }) - expect(startup?.command).toContain("'-m' 'gpt-5.2-codex'") - expect(startup?.sessionOptions).toEqual({ - model: 'gpt-5.2-codex', - effort: 'medium' - }) - }) + expect(startup?.command).not.toContain("'-m'") + expect(startup?.command).not.toContain('model_reasoning_effort=') + expect(startup).not.toHaveProperty('sessionOptions') + } + ) it('does not infer an agent from auto mode', () => { const startup = buildOnboardingFolderAgentStartup({ diff --git a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.test.tsx b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.test.tsx index a5592bb9fc0a..caafca96b009 100644 --- a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.test.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.test.tsx @@ -234,9 +234,7 @@ describe('SourceControlAgentActionDialog', () => { resetStore( { ...settingsWithGlobalRecipe(null), - experimentalNativeChat: true, - experimentalStructuredNativeChat: true, - openAgentTabsInChatByDefault: true + experimentalNativeChat: true }, [repoWithSavedRecipe('--model saved', 'build-box', 'ssh:build-box')] ) @@ -264,9 +262,7 @@ describe('SourceControlAgentActionDialog', () => { resetStore( { ...settingsWithGlobalRecipe(recipe), - experimentalNativeChat: true, - experimentalStructuredNativeChat: true, - openAgentTabsInChatByDefault: true + experimentalNativeChat: true }, [] ) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-workspace.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-workspace.test.ts index 59276f03d92b..d8387ddb90c0 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-workspace.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-workspace.test.ts @@ -42,9 +42,7 @@ function resumeInChat() { activeWorkspaceId: PAIRED_WORKSPACE, targetState: { folderWorkspaces: [], projectGroups: [], repos: [], worktreesByRepo: {} }, settings: { - experimentalNativeChat: true, - experimentalStructuredNativeChat: true, - openAgentTabsInChatByDefault: true + experimentalNativeChat: true } }) } diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat.ts index 6468ca5ef006..ec18262ed098 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat.ts @@ -65,9 +65,9 @@ export function resolveAiVaultSessionResumeInChatEligibility(args: { > & { structuredSession?: AiVaultSession['structuredSession'] } targetWorkspaceId: string | null targetWorkspacePath: string | null - /** The route the same (workspace, agent) pair would take for a fresh chat. Reused rather than - * re-derived: it already encodes the settings flag, host capability, platform refusals and the - * WSL/repair refusal, and a second copy of those conditions would drift from it. */ + /** Whether the same (workspace, agent) pair can host a structured chat; the Chat UI switch does + * not apply. Reused rather than re-derived: it already encodes host capability, platform refusals + * and the WSL/repair refusal, and a second copy of those conditions would drift from it. */ structuredRouteAvailable: boolean }): AiVaultResumeInChatEligibility { const { session } = args diff --git a/src/renderer/src/components/right-sidebar/source-control-launch-agent-args-applicability.test.ts b/src/renderer/src/components/right-sidebar/source-control-launch-agent-args-applicability.test.ts index 44e844bc4d66..dfbc7b9c4225 100644 --- a/src/renderer/src/components/right-sidebar/source-control-launch-agent-args-applicability.test.ts +++ b/src/renderer/src/components/right-sidebar/source-control-launch-agent-args-applicability.test.ts @@ -39,7 +39,7 @@ describe('sourceControlLaunchAppliesAgentArgs', () => { it('keeps arguments for a chat-by-default user whose launch falls back to a terminal', () => { // A remote host, an agent without a structured session, or a floating workspace all land here. - mocks.resolveAgentSessionLaunchRoute.mockReturnValue('legacy-native-chat') + mocks.resolveAgentSessionLaunchRoute.mockReturnValue('terminal-tui') expect( sourceControlLaunchAppliesAgentArgs({ agent: 'codex', diff --git a/src/renderer/src/components/settings/AppearancePane.test.tsx b/src/renderer/src/components/settings/AppearancePane.test.tsx index 488b90472be0..62152c3244b9 100644 --- a/src/renderer/src/components/settings/AppearancePane.test.tsx +++ b/src/renderer/src/components/settings/AppearancePane.test.tsx @@ -247,8 +247,7 @@ describe('AppearancePane', () => { it('keeps chat appearance controls out of the Appearance pane', async () => { mocks.state.settingsSearchQuery = '' const container = await renderAppearancePane({ - ...getDefaultSettings('/tmp'), - experimentalStructuredNativeChat: true + ...getDefaultSettings('/tmp') }) expect(appearanceSectionToggle(container, 'chat')).toBeUndefined() expect(container.textContent).not.toContain('Reset chat appearance') diff --git a/src/renderer/src/components/settings/ChatSettingsSection.inline-visuals.test.tsx b/src/renderer/src/components/settings/ChatSettingsSection.inline-visuals.test.tsx index b7853aa2f250..0f023f1ae052 100644 --- a/src/renderer/src/components/settings/ChatSettingsSection.inline-visuals.test.tsx +++ b/src/renderer/src/components/settings/ChatSettingsSection.inline-visuals.test.tsx @@ -29,7 +29,7 @@ beforeEach(() => { function renderChat(nativeChatInlineVisuals: boolean | undefined) { const settings = { ...getDefaultSettings('/tmp'), - experimentalStructuredNativeChat: true, + experimentalNativeChat: true, nativeChatInlineVisuals } state.settings = settings @@ -81,7 +81,7 @@ describe('Inline visuals on the Chat settings page', () => { isMac: false, isWindows: false, isWebClient: false, - experimentalStructuredNativeChat: true, + nativeChatEnabled: true, repos: [] }) const results = buildCmdJSettingsResults(sections) diff --git a/src/renderer/src/components/settings/ChatSettingsSection.navigation.test.tsx b/src/renderer/src/components/settings/ChatSettingsSection.navigation.test.tsx index 4a065f0881ba..a274675bc0a0 100644 --- a/src/renderer/src/components/settings/ChatSettingsSection.navigation.test.tsx +++ b/src/renderer/src/components/settings/ChatSettingsSection.navigation.test.tsx @@ -34,12 +34,12 @@ function NavigationHarness({ targetSection?: string }) { const [activeSectionId, setActiveSectionId] = useState(initialSection) - const settings = { ...getDefaultSettings('/tmp'), experimentalStructuredNativeChat: enabled } + const settings = { ...getDefaultSettings('/tmp'), experimentalNativeChat: enabled } const sections = buildSettingsNavigationMetadata({ isMac: false, isWindows: false, isWebClient: false, - experimentalStructuredNativeChat: enabled, + nativeChatEnabled: enabled, repos: [] }) const pendingNavSectionRef = useRef('chat') diff --git a/src/renderer/src/components/settings/ChatSettingsSection.test.tsx b/src/renderer/src/components/settings/ChatSettingsSection.test.tsx index 9e5716452527..701f06526dbd 100644 --- a/src/renderer/src/components/settings/ChatSettingsSection.test.tsx +++ b/src/renderer/src/components/settings/ChatSettingsSection.test.tsx @@ -34,7 +34,7 @@ function renderChat( showDesktopOnlySettings = true, hasUnsavedChatPromptChanges = false ) { - const settings = { ...getDefaultSettings('/tmp'), experimentalStructuredNativeChat: enabled } + const settings = { ...getDefaultSettings('/tmp'), experimentalNativeChat: enabled } state.settings = settings const updateSettings = vi.fn(async (updates: Partial) => { if (state.settings) { @@ -73,7 +73,7 @@ describe('Chat settings page', () => { isMac: false, isWindows: false, isWebClient: true, - experimentalStructuredNativeChat: true, + nativeChatEnabled: true, repos: [] }) const results = buildCmdJSettingsResults(sections).filter((entry) => entry.sectionId === 'chat') @@ -141,7 +141,7 @@ describe('Chat settings page', () => { rerender( {}} searchEntries={[]} @@ -166,7 +166,7 @@ describe('Chat settings page', () => { isMac: true, isWindows: false, isWebClient: false, - experimentalStructuredNativeChat: true, + nativeChatEnabled: true, repos: [] }) const results = buildCmdJSettingsResults(sections) @@ -191,7 +191,7 @@ describe('Chat settings page', () => { isMac: false, isWindows: true, isWebClient: false, - experimentalStructuredNativeChat: true, + nativeChatEnabled: true, repos: [] }) const result = buildCmdJSettingsResults(sections).find( @@ -231,7 +231,7 @@ describe('Chat settings page', () => { isMac: true, isWindows: false, isWebClient: false, - experimentalStructuredNativeChat: true, + nativeChatEnabled: true, repos: [] }) const result = buildCmdJSettingsResults(sections).find( @@ -264,7 +264,7 @@ describe('Chat settings page', () => { isMac: true, isWindows: false, isWebClient: false, - experimentalStructuredNativeChat: true, + nativeChatEnabled: true, repos: [] }) const result = buildCmdJSettingsResults(sections).find( diff --git a/src/renderer/src/components/settings/ChatSettingsSection.tsx b/src/renderer/src/components/settings/ChatSettingsSection.tsx index 62d83dafa056..0eb46c38a4c0 100644 --- a/src/renderer/src/components/settings/ChatSettingsSection.tsx +++ b/src/renderer/src/components/settings/ChatSettingsSection.tsx @@ -34,7 +34,7 @@ export function ChatSettingsSection({ isMounted: boolean }): React.JSX.Element | null { const query = useAppStore((state) => state.settingsSearchQuery) - if (settings.experimentalStructuredNativeChat !== true) { + if (settings.experimentalNativeChat !== true) { return null } const title = translate('settings.appearance.chat.title', 'Chat') diff --git a/src/renderer/src/components/settings/ExperimentalPane.test.tsx b/src/renderer/src/components/settings/ExperimentalPane.test.tsx index 66b795329f11..93b2d2f2565c 100644 --- a/src/renderer/src/components/settings/ExperimentalPane.test.tsx +++ b/src/renderer/src/components/settings/ExperimentalPane.test.tsx @@ -223,56 +223,27 @@ describe('ExperimentalPane', () => { expect(markup).toContain('aria-checked="true"') }) - it('shows the structured-native-chat child setting only when Chat UI is the default view', async () => { + it('offers one Chat UI switch and no default-view selector', async () => { const updateSettings = vi.fn() - const disabledSettings = getDefaultSettings('/tmp') - const disabledMarkup = renderToStaticMarkup( - - ) - expect(disabledMarkup).toContain('Chat UI') - expect(disabledMarkup).not.toContain('Use updated structured native chat') - expect(disabledMarkup).not.toContain('Default view') - - const terminalDefault = { - ...getDefaultSettings('/tmp'), - experimentalNativeChat: true, - experimentalStructuredNativeChat: false, - openAgentTabsInChatByDefault: false - } - const terminalRender = await renderExperimentalPane({ - updateSettings, - settings: terminalDefault - }) - - // The default-view control is a sibling of the Chat UI toggle, never replaced by the opt-in. - expect(terminalRender.container.textContent).toContain('Default view') - expect( - terminalRender.container.querySelector('[data-slot="native-chat-default-view-select"]') - ).not.toBeNull() - // Structured chat has no entry path under Terminal chat, so its opt-in is not offered. - expect(terminalRender.container.textContent).not.toContain('Use updated structured native chat') - terminalRender.root.unmount() - const { root, container } = await renderExperimentalPane({ updateSettings, - settings: { ...terminalDefault, openAgentTabsInChatByDefault: true } + settings: getDefaultSettings('/tmp') }) - - expect(container.textContent).toContain('Use updated structured native chat') - // The one setting governs both providers, so its copy must not name only Codex. - expect(container.textContent).toContain('Open new agents as structured chats where supported.') - // The setting picks what new agents open as; existing chats are left alone. - expect(container.textContent).toContain('Chats that already exist stay as they are.') - // Paired Orca servers run structured chats too; only WSL and SSH stay on terminal chat. - expect(container.textContent).toContain( - 'Runs on this machine and on paired Orca servers running a version that supports it; older servers keep terminal chat. WSL and SSH hosts continue to use terminal chat.' + expect(container.textContent).toContain('Chat UI') + expect(container.textContent).not.toContain('Default view') + expect(container.textContent).not.toContain('Use updated structured native chat') + const switchControl = container.querySelector( + '#experimental-native-chat button[aria-label="Toggle Chat UI"]' ) - expect(container.textContent).toContain('Default view') + expect(switchControl).not.toBeNull() + await act(async () => { + switchControl?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + expect(updateSettings).toHaveBeenCalledWith({ experimentalNativeChat: true }) root.unmount() }) - // Those settings govern the chats this machine holds, which keep running with the setting off. - it('shows the structured chat settings while this machine holds chats, whatever the setting', async () => { + it('shows structured chat controls for chats this machine still holds while Chat UI is off', async () => { Object.defineProperty(window, 'api', { configurable: true, value: { @@ -285,12 +256,11 @@ describe('ExperimentalPane', () => { try { const { root, container } = await renderExperimentalPane({ updateSettings: vi.fn(), - settings: { ...getDefaultSettings('/tmp'), experimentalNativeChat: true } + settings: getDefaultSettings('/tmp') }) await act(async () => { await Promise.resolve() }) - expect(container.textContent).toContain('Resume working chats automatically after a restart') root.unmount() } finally { @@ -299,174 +269,6 @@ describe('ExperimentalPane', () => { } }) - it('hides the structured chat settings on a machine that holds none with the setting off', async () => { - const { root, container } = await renderExperimentalPane({ - updateSettings: vi.fn(), - settings: { ...getDefaultSettings('/tmp'), experimentalNativeChat: true } - }) - - expect(container.textContent).not.toContain( - 'Resume working chats automatically after a restart' - ) - root.unmount() - }) - - it('hides a stale structured opt-in under Terminal chat without clearing it', async () => { - const updateSettings = vi.fn() - const settings = { - ...getDefaultSettings('/tmp'), - experimentalNativeChat: true, - experimentalStructuredNativeChat: true, - openAgentTabsInChatByDefault: true - } - const { root, container } = await renderExperimentalPane({ updateSettings, settings }) - - expect(container.textContent).toContain('Use updated structured native chat') - - const terminalChatOption = Array.from( - container.querySelectorAll('[data-slot="select-item"]') - ).find((button) => button.getAttribute('data-value') === 'terminal-chat') - if (!terminalChatOption) { - throw new Error('Terminal chat default-view option was not rendered') - } - - await act(async () => { - terminalChatOption.dispatchEvent(new MouseEvent('click', { bubbles: true })) - }) - - // Switching the default view must not clobber the persisted opt-in — only hide its control. - expect(updateSettings).toHaveBeenCalledWith({ openAgentTabsInChatByDefault: false }) - expect(updateSettings).toHaveBeenCalledTimes(1) - root.unmount() - - const hidden = await renderExperimentalPane({ - updateSettings, - settings: { ...settings, openAgentTabsInChatByDefault: false } - }) - - expect(hidden.container.textContent).not.toContain('Use updated structured native chat') - hidden.root.unmount() - - // Returning to Chat UI restores the control still switched on. - const restored = await renderExperimentalPane({ updateSettings, settings }) - const structuredSwitch = restored.container.querySelector( - '#experimental-native-chat button[role="switch"][aria-label="Toggle updated structured native chat"]' - ) - expect(structuredSwitch?.getAttribute('aria-checked')).toBe('true') - restored.root.unmount() - }) - - it('shows Chat UI default-mode as a child setting only when Chat UI is enabled', async () => { - const updateSettings = vi.fn() - const disabledSettings = getDefaultSettings('/tmp') - const disabledMarkup = renderToStaticMarkup( - - ) - expect(disabledMarkup).toContain('Chat UI') - expect(disabledMarkup).not.toContain('Default view') - - const settings = { - ...getDefaultSettings('/tmp'), - experimentalNativeChat: true, - openAgentTabsInChatByDefault: false - } - const { root, container } = await renderExperimentalPane({ updateSettings, settings }) - - expect(container.textContent).toContain('Default view') - expect(container.textContent).toContain('Terminal chat') - expect(container.textContent).toContain('Chat UI') - expect( - container - .querySelector('[data-slot="native-chat-default-view-select"]') - ?.getAttribute('data-value') - ).toBe('terminal-chat') - - const nativeChatOption = Array.from( - container.querySelectorAll('[data-slot="select-item"]') - ).find((button) => button.getAttribute('data-value') === 'native-chat') - if (!nativeChatOption) { - throw new Error('Chat UI default-view option was not rendered') - } - - await act(async () => { - nativeChatOption.dispatchEvent(new MouseEvent('click', { bubbles: true })) - }) - - expect(updateSettings).toHaveBeenCalledWith({ openAgentTabsInChatByDefault: true }) - - root.unmount() - - const nativeSettings = { - ...settings, - openAgentTabsInChatByDefault: true - } - const secondRender = await renderExperimentalPane({ - updateSettings, - settings: nativeSettings - }) - - expect( - secondRender.container - .querySelector('[data-slot="native-chat-default-view-select"]') - ?.getAttribute('data-value') - ).toBe('native-chat') - - const terminalChatOption = Array.from( - secondRender.container.querySelectorAll('[data-slot="select-item"]') - ).find((button) => button.getAttribute('data-value') === 'terminal-chat') - if (!terminalChatOption) { - throw new Error('Terminal chat default-view option was not rendered') - } - - await act(async () => { - terminalChatOption.dispatchEvent(new MouseEvent('click', { bubbles: true })) - }) - - expect(updateSettings).toHaveBeenCalledWith({ openAgentTabsInChatByDefault: false }) - - secondRender.root.unmount() - }) - - // The two controls are nested, but each still writes only its own key. - it('never writes one Chat UI child setting while changing the other', async () => { - const updateSettings = vi.fn() - const settings = { - ...getDefaultSettings('/tmp'), - experimentalNativeChat: true, - experimentalStructuredNativeChat: false, - openAgentTabsInChatByDefault: true - } - const { root, container } = await renderExperimentalPane({ updateSettings, settings }) - - const structuredSwitch = container.querySelector( - '#experimental-native-chat button[role="switch"][aria-label="Toggle updated structured native chat"]' - ) - if (!structuredSwitch) { - throw new Error('Structured native chat switch was not rendered') - } - - await act(async () => { - structuredSwitch.dispatchEvent(new MouseEvent('click', { bubbles: true })) - }) - - expect(updateSettings).toHaveBeenCalledWith({ experimentalStructuredNativeChat: true }) - - const terminalChatOption = Array.from( - container.querySelectorAll('[data-slot="select-item"]') - ).find((button) => button.getAttribute('data-value') === 'terminal-chat') - if (!terminalChatOption) { - throw new Error('Terminal chat default-view option was not rendered') - } - - await act(async () => { - terminalChatOption.dispatchEvent(new MouseEvent('click', { bubbles: true })) - }) - - expect(updateSettings).toHaveBeenCalledWith({ openAgentTabsInChatByDefault: false }) - expect(updateSettings).toHaveBeenCalledTimes(2) - root.unmount() - }) - it('renders the agent sleep idle duration as configurable minutes', async () => { const updateSettings = vi.fn() const settings = { diff --git a/src/renderer/src/components/settings/NativeChatExperimentalSetting.test.tsx b/src/renderer/src/components/settings/NativeChatExperimentalSetting.test.tsx index 8e95b25a45c6..6f26ae16d122 100644 --- a/src/renderer/src/components/settings/NativeChatExperimentalSetting.test.tsx +++ b/src/renderer/src/components/settings/NativeChatExperimentalSetting.test.tsx @@ -45,37 +45,19 @@ function listedNames(container: HTMLElement): string[] { } describe('NativeChatExperimentalSetting shell environment', () => { - it('shows only when Chat UI, the Chat UI default view, and structured chat are all on', () => { - for (const experimentalNativeChat of [false, true]) { - for (const openAgentTabsInChatByDefault of [false, true]) { - for (const experimentalStructuredNativeChat of [false, true]) { - const { container, unmount } = renderSetting({ - experimentalNativeChat, - openAgentTabsInChatByDefault, - experimentalStructuredNativeChat - }) - const expected = - experimentalNativeChat && - openAgentTabsInChatByDefault && - experimentalStructuredNativeChat - expect( - container.querySelector(SHELL_ENV_TOGGLE) !== null, - JSON.stringify({ - experimentalNativeChat, - openAgentTabsInChatByDefault, - experimentalStructuredNativeChat - }) - ).toBe(expected) - unmount() - } - } - } + it('shows structured controls when Chat UI is on', () => { + const enabled = renderSetting({ experimentalNativeChat: true }) + expect(enabled.container.querySelector(SHELL_ENV_TOGGLE)).not.toBeNull() + expect(enabled.container.textContent).not.toContain('Default view') + enabled.unmount() + + const disabled = renderSetting({ experimentalNativeChat: false }) + expect(disabled.container.querySelector(SHELL_ENV_TOGGLE)).toBeNull() + disabled.unmount() }) const structuredOn = { - experimentalNativeChat: true, - openAgentTabsInChatByDefault: true, - experimentalStructuredNativeChat: true + experimentalNativeChat: true } const chooseNames = { ...structuredOn, nativeChatInheritShellEnvironment: false } @@ -215,9 +197,7 @@ describe('NativeChatExperimentalSetting shell environment', () => { describe('NativeChatExperimentalSetting queue follow-ups', () => { const QUEUE_TOGGLE = '[aria-label="Toggle queue follow-ups"]' const structuredOn = { - experimentalNativeChat: true, - openAgentTabsInChatByDefault: true, - experimentalStructuredNativeChat: true + experimentalNativeChat: true } it('shows the switch, with copy naming the image exception, when the host queues messages', () => { @@ -249,12 +229,9 @@ describe('NativeChatExperimentalSetting queue follow-ups', () => { expect(container.querySelector(QUEUE_TOGGLE)).not.toBeNull() }) - it('stays hidden outside structured chat even when the host queues messages', () => { + it('hides the switch with Chat UI off when no structured chats are held', () => { setLocalRuntimeCapabilitiesForTests([AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY]) - const { container } = renderSetting({ - ...structuredOn, - experimentalStructuredNativeChat: false - }) + const { container } = renderSetting({ experimentalNativeChat: false }) expect(container.querySelector(QUEUE_TOGGLE)).toBeNull() }) }) @@ -262,9 +239,7 @@ describe('NativeChatExperimentalSetting queue follow-ups', () => { describe('NativeChatExperimentalSetting inline visuals', () => { it('leaves Inline visuals on the Chat page even while structured chat is enabled', () => { const { queryByRole, getByRole } = renderSetting({ - experimentalNativeChat: true, - openAgentTabsInChatByDefault: true, - experimentalStructuredNativeChat: true + experimentalNativeChat: true }) expect(getByRole('switch', { name: 'Toggle automatic resume after a restart' })).toBeTruthy() expect(queryByRole('switch', { name: 'Toggle inline visuals' })).toBeNull() diff --git a/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx b/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx index fae3362eb747..34c5de50a653 100644 --- a/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx +++ b/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx @@ -2,7 +2,6 @@ import type { GlobalSettings } from '../../../../shared/global-settings-types' import { useLocalStructuredAgentSessionsHeld } from '@/runtime/local-structured-chats' import { translate } from '@/i18n/i18n' import { Label } from '../ui/label' -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' import { NativeChatQueueFollowUpsSetting } from './NativeChatQueueFollowUpsSetting' import { NativeChatShellEnvironmentSetting } from './NativeChatShellEnvironmentSetting' import { NativeChatSupportedAgents } from './NativeChatSupportedAgents' @@ -10,8 +9,6 @@ import { SearchableSetting } from './SearchableSetting' import { SettingsSwitch } from './SettingsFormControls' import { getExperimentalSearchEntry } from './experimental-search' -type NativeChatDefaultView = 'terminal-chat' | 'native-chat' - type NativeChatExperimentalSettingProps = { settings: GlobalSettings updateSettings: (updates: Partial) => void @@ -22,22 +19,16 @@ export function NativeChatExperimentalSetting({ updateSettings }: NativeChatExperimentalSettingProps): React.JSX.Element { const nativeChatEnabled = settings.experimentalNativeChat === true - const structuredNativeChatEnabled = settings.experimentalStructuredNativeChat === true const resumeOnRestartEnabled = settings.nativeChatResumeWorkOnRestart === true - const defaultView: NativeChatDefaultView = - settings.openAgentTabsInChatByDefault === true ? 'native-chat' : 'terminal-chat' - // Structured-only settings; terminal-backed chat never reads them. They govern the chats this - // machine holds too, which keep running whatever the setting says. const holdsStructuredChats = useLocalStructuredAgentSessionsHeld() - const structuredChatActive = - (defaultView === 'native-chat' && structuredNativeChatEnabled) || holdsStructuredChats + const structuredChatActive = nativeChatEnabled || holdsStructuredChats return ( {translate( 'auto.components.settings.ExperimentalPane.nativeChat.copy', - 'Enables the experimental Chat UI for newly created supported local sessions. Existing terminal sessions keep the terminal chat path while we tune transcript fidelity, streaming, and parity.' + 'Supported new agents open in structured chat. Other agents open in the terminal; existing chats stay available.' )}

    @@ -69,97 +60,8 @@ export function NativeChatExperimentalSetting({ } />
    - {nativeChatEnabled ? ( + {structuredChatActive ? (
    -
    -
    - -

    - {translate( - 'auto.components.settings.ExperimentalPane.nativeChat.defaultCopy', - 'Choose how new supported agent terminal tabs open.' - )} -

    -
    - -
    - - {/* Structured chat rides the Chat UI default view; it has no entry path under Terminal - chat. Hidden only — the opt-in keeps its persisted value for when Chat UI returns. */} - {defaultView === 'native-chat' ? ( -
    -
    - -

    - {translate( - 'auto.components.settings.ExperimentalPane.nativeChat.structuredCopy', - 'Open new agents as structured chats where supported. Off opens them in the terminal-backed chat. Chats that already exist stay as they are.' - )} -

    -

    - {translate( - 'auto.components.settings.ExperimentalPane.nativeChat.structuredScope', - 'Runs on this machine and on paired Orca servers running a version that supports it; older servers keep terminal chat. WSL and SSH hosts continue to use terminal chat.' - )} -

    -
    - - updateSettings({ - experimentalStructuredNativeChat: !structuredNativeChatEnabled - }) - } - /> -
    - ) : null} - {/* Only structured sessions have a resume cursor to continue from. */} {structuredChatActive ? (
    diff --git a/src/renderer/src/components/settings/SettingsSidebar.test.tsx b/src/renderer/src/components/settings/SettingsSidebar.test.tsx index 8d7a12ef5555..122c641097f7 100644 --- a/src/renderer/src/components/settings/SettingsSidebar.test.tsx +++ b/src/renderer/src/components/settings/SettingsSidebar.test.tsx @@ -135,12 +135,12 @@ describe('SettingsSidebar', () => { }) it.each([false, true])('renders Chat navigation only with the opt-in enabled (%s)', (enabled) => { - const settings = { ...getDefaultSettings('/tmp'), experimentalStructuredNativeChat: enabled } + const settings = { ...getDefaultSettings('/tmp'), experimentalNativeChat: enabled } const sections = buildSettingsNavigationMetadata({ isMac: false, isWindows: false, isWebClient: false, - experimentalStructuredNativeChat: enabled, + nativeChatEnabled: enabled, repos: [] }).filter((section) => section.group === 'interface') const container = document.createElement('div') diff --git a/src/renderer/src/components/settings/settings-persistence-test-fixture.ts b/src/renderer/src/components/settings/settings-persistence-test-fixture.ts index 181e87361727..d6cd5d8e2666 100644 --- a/src/renderer/src/components/settings/settings-persistence-test-fixture.ts +++ b/src/renderer/src/components/settings/settings-persistence-test-fixture.ts @@ -17,7 +17,7 @@ beforeEach(() => { useAppStore.setState({ settings: { ...getDefaultSettings('/synthetic'), - experimentalStructuredNativeChat: true, + experimentalNativeChat: true, sourceControlAi: normalizeSourceControlAiSettings({ ...normalizeSourceControlAiSettings(undefined), enabled: false, diff --git a/src/renderer/src/components/sidebar/NonGitFolderDialog.tsx b/src/renderer/src/components/sidebar/NonGitFolderDialog.tsx index 024cab700f22..8ae03f120d87 100644 --- a/src/renderer/src/components/sidebar/NonGitFolderDialog.tsx +++ b/src/renderer/src/components/sidebar/NonGitFolderDialog.tsx @@ -14,7 +14,6 @@ import { resolveDismissedOnboardingFolderAgentLaunch, revealOnboardingFolderWithAgentLaunch } from '@/lib/onboarding-folder-agent-launch' -import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' import { markOnboardingProjectAdded } from '@/lib/onboarding-project-checklist' import { translate } from '@/i18n/i18n' import { upsertAddedRepoWithProjectHostSetup } from './add-repo-store-upsert' @@ -92,8 +91,7 @@ const NonGitFolderDialog = React.memo(function NonGitFolderDialog() { store: useAppStore.getState(), onboarding, hasExistingProject: hadProjectBeforeAdd, - executionHostId: ownerOptions.executionHostId ?? connectionId, - nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable(connectionId) + executionHostId: ownerOptions.executionHostId ?? connectionId }) await revealOnboardingFolderWithAgentLaunch({ worktreeId: folderWorktree.id, diff --git a/src/renderer/src/components/sidebar/folder-workspace-agent-startup.ts b/src/renderer/src/components/sidebar/folder-workspace-agent-startup.ts index d3746a38e826..bffc6debf1f0 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-agent-startup.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-agent-startup.ts @@ -9,7 +9,6 @@ import { isWindowsAbsolutePathLike } from '../../../../shared/cross-platform-pat import type { ProjectGroup } from '../../../../shared/project-group-types' import type { TuiAgent } from '../../../../shared/tui-agent' import type { AgentStartupShell } from '../../../../shared/tui-agent-startup-shell' -import type { SessionOptionValue } from '../../../../shared/native-chat-session-options' import { isWslUncPath } from '../../../../shared/wsl-paths' export function getFolderWorkspaceAgentLaunchPlatform( @@ -38,7 +37,6 @@ export function buildFolderWorkspaceLinkedStartupPlan(args: { agentCmdOverrides: Record | undefined agentArgs?: string | null agentEnv?: Record - sessionOptions?: Record platform: NodeJS.Platform shell?: AgentStartupShell isRemote: boolean @@ -51,7 +49,6 @@ export function buildFolderWorkspaceLinkedStartupPlan(args: { cmdOverrides: args.agentCmdOverrides ?? {}, agentArgs: args.agentArgs, agentEnv: args.agentEnv, - sessionOptions: args.sessionOptions, platform: args.platform, shell: args.shell, isRemote: args.isRemote @@ -64,7 +61,6 @@ export function buildFolderWorkspaceLinkedStartupPlan(args: { expectedProcess: draftLaunchPlan.expectedProcess, followupPrompt: null, launchConfig: draftLaunchPlan.launchConfig, - ...(draftLaunchPlan.sessionOptions ? { sessionOptions: draftLaunchPlan.sessionOptions } : {}), ...(draftLaunchPlan.startupCommandDelivery ? { startupCommandDelivery: draftLaunchPlan.startupCommandDelivery } : {}), @@ -79,7 +75,6 @@ export function buildFolderWorkspaceLinkedStartupPlan(args: { cmdOverrides: args.agentCmdOverrides ?? {}, agentArgs: args.agentArgs, agentEnv: args.agentEnv, - sessionOptions: args.sessionOptions, platform: args.platform, shell: args.shell, isRemote: args.isRemote, diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts index 6976647ed8b9..2e09dee878e8 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts @@ -11,8 +11,6 @@ const mocks = vi.hoisted(() => ({ ensureAgentStartupInTerminal: vi.fn() })) -// Why: importOriginal keeps the real resolveStartupLaunchDraftText, so the -// invariant test below exercises the shipped gate instead of a copy of it. vi.mock('@/lib/worktree-activation', async (importOriginal) => { const actual = await importOriginal>() return { ...actual, activateAndRevealFolderWorkspace: mocks.activateAndRevealFolderWorkspace } @@ -27,8 +25,6 @@ vi.mock('@/lib/new-workspace', async (importOriginal) => { }) import { useAppStore } from '@/store' -import { decideInitialAgentTabViewMode } from '@/lib/native-chat-initial-view-mode' -import { resolveStartupLaunchDraftText } from '@/lib/worktree-startup-payload' import { getFolderWorkspaceAgentLaunchPlatform, submitFolderWorkspaceCreate @@ -804,9 +800,8 @@ describe('folder-workspace draft: seeded set == chat-opening set', () => { vi.restoreAllMocks() }) - // Why: `claude` takes its draft on argv, so `startupPlan.draftPrompt` stays - // undefined; `codex` gets a startup paste and sets it. Both must reach the - // view-mode gate, and both must agree with what the composer actually holds. + // Claude takes its draft on argv; Codex receives a startup paste. Both keep + // the draft available for an explicit switch to terminal chat. it.each([ ['argv-prefill', 'claude' as const, '', true], ['argv-prefill multi-line', 'claude' as const, 'Reproduce on Windows first', true], @@ -828,20 +823,8 @@ describe('folder-workspace draft: seeded set == chat-opening set', () => { const startup = mocks.activateAndRevealFolderWorkspace.mock.calls[0]?.[1]?.startup const seeded = useAppStore.getState().nativeChatLaunchDraftByTabId['tab-1'] != null - const draftText = resolveStartupLaunchDraftText(startup) - const opensInChat = - decideInitialAgentTabViewMode({ - experimentalNativeChat: true, - openAgentTabsInChatByDefault: true, - agent: quickAgent, - ...(draftText != null - ? { promptDelivery: 'draft' as const, launchDraftText: draftText } - : {}) - }) === 'chat' - // The draft always reaches the TUI, whichever way it is delivered. expect(`${startup?.command ?? ''}${startup?.draftPrompt ?? ''}`).toContain(ISSUE_URL) expect(seeded).toBe(expectMirrored) - expect(opensInChat).toBe(expectMirrored) }) }) diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts index 40d65b759e22..a2df33dd4bd7 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts @@ -10,7 +10,6 @@ import type { ProjectGroup } from '../../../../shared/project-group-types' import type { TuiAgent } from '../../../../shared/tui-agent' import { resolveLocalWindowsAgentStartupShell } from '../../../../shared/windows-terminal-shell' import type { LaunchSource } from '../../../../shared/telemetry-events' -import type { SessionOptionValue } from '../../../../shared/native-chat-session-options' import type { TaskSourceContext } from '../../../../shared/task-source-context' import { folderWorkspaceKey } from '../../../../shared/workspace-scope' import { @@ -56,7 +55,6 @@ type SubmitFolderWorkspaceCreateParams = { agentCmdOverrides: Record | undefined agentArgs?: string | null agentEnv?: Record - sessionOptions?: Record terminalWindowsShell?: string | null isRemote?: boolean launchSource?: LaunchSource @@ -77,7 +75,6 @@ export async function submitFolderWorkspaceCreate({ agentCmdOverrides, agentArgs, agentEnv, - sessionOptions, terminalWindowsShell, launchSource = 'sidebar', runtimeEnvironmentId = null, @@ -108,7 +105,6 @@ export async function submitFolderWorkspaceCreate({ agentCmdOverrides, agentArgs, agentEnv, - sessionOptions, platform: launchPlatform, shell: launchShell, isRemote: launchIsRemote @@ -120,7 +116,6 @@ export async function submitFolderWorkspaceCreate({ cmdOverrides: agentCmdOverrides ?? {}, agentArgs, agentEnv, - sessionOptions, platform: launchPlatform, shell: launchShell, isRemote: launchIsRemote, @@ -141,8 +136,7 @@ export async function submitFolderWorkspaceCreate({ executionHostId: getNewWorkspaceProjectGroupHostId(projectGroup) }, prompt: launchDraftPrompt ?? note, - promptDelivery: launchDraftPrompt ? 'draft' : 'auto-submit', - initialSessionOptions: startupPlan?.sessionOptions + promptDelivery: launchDraftPrompt ? 'draft' : 'auto-submit' }) : null const structuredLaunch = plan?.route === 'structured-native-chat' @@ -183,11 +177,7 @@ export async function submitFolderWorkspaceCreate({ launchConfig: startupPlan.launchConfig, ...(startupPlan.launchToken ? { launchToken: startupPlan.launchToken } : {}), launchAgent: quickAgent, - ...(startupPlan.sessionOptions ? { sessionOptions: startupPlan.sessionOptions } : {}), ...(startupPlan.draftPrompt ? { draftPrompt: startupPlan.draftPrompt } : {}), - // Why: view-mode only. The argv-prefill plan sets no draftPrompt, so - // without this the tab opens in chat with nothing mirrored into it. - ...(launchDraftPrompt ? { launchDraftText: launchDraftPrompt } : {}), ...(startupPlan.startupCommandDelivery ? { startupCommandDelivery: startupPlan.startupCommandDelivery } : {}), diff --git a/src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.test.tsx b/src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.test.tsx index fc5fefce1505..108f51e17da8 100644 --- a/src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.test.tsx +++ b/src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.test.tsx @@ -72,7 +72,7 @@ describe('NativeChatResumeStatusSegment', () => { consumeNativeChatResumeOnRestartDialogRequest() useAppStore.setState({ ...useAppStore.getInitialState(), - settings: { ...getDefaultSettings(''), experimentalStructuredNativeChat: true } + settings: { ...getDefaultSettings(''), experimentalNativeChat: true } }) }) @@ -186,7 +186,7 @@ describe('NativeChatResumeStatusSegment', () => { it('hides when no chat exists and the setting is off, or the host offers nothing', async () => { rpc.mockResolvedValue({ sessions: candidates }) useAppStore.setState({ - settings: { ...getDefaultSettings(''), experimentalStructuredNativeChat: false } + settings: { ...getDefaultSettings(''), experimentalNativeChat: false } }) await mount() expect(screen.queryByRole('button')).toBeNull() @@ -196,7 +196,7 @@ describe('NativeChatResumeStatusSegment', () => { cleanup() rpc.mockResolvedValue({ sessions: [] }) useAppStore.setState({ - settings: { ...getDefaultSettings(''), experimentalStructuredNativeChat: true } + settings: { ...getDefaultSettings(''), experimentalNativeChat: true } }) await mount() expect(screen.queryByRole('button')).toBeNull() @@ -206,7 +206,7 @@ describe('NativeChatResumeStatusSegment', () => { it('offers to continue the chats this machine holds while the setting is off', async () => { rpc.mockResolvedValue({ sessions: candidates }) useAppStore.setState({ - settings: { ...getDefaultSettings(''), experimentalStructuredNativeChat: false } + settings: { ...getDefaultSettings(''), experimentalNativeChat: false } }) stageLocalHost(true) await mount() @@ -219,7 +219,7 @@ describe('NativeChatResumeStatusSegment', () => { it("does not ask this machine for an offer over a paired server's chats", async () => { rpc.mockResolvedValue({ sessions: candidates }) useAppStore.setState({ - settings: { ...getDefaultSettings(''), experimentalStructuredNativeChat: false }, + settings: { ...getDefaultSettings(''), experimentalNativeChat: false }, unifiedTabsByWorktree: { 'wt-1': [ { diff --git a/src/renderer/src/components/tab-bar/TabBarItemRow.test.tsx b/src/renderer/src/components/tab-bar/TabBarItemRow.test.tsx index a095d4e2e6fa..90fbfc385f46 100644 --- a/src/renderer/src/components/tab-bar/TabBarItemRow.test.tsx +++ b/src/renderer/src/components/tab-bar/TabBarItemRow.test.tsx @@ -161,7 +161,6 @@ function Strip({ resolvedGroupId: 'group-1', generatedTabTitlesEnabled, unifiedTabByVisibleId: new Map(), - nativeChatEnabled: false, tabAgentTypesByTabId: {}, nativeChatTabWideFallbackUnsafeTabsById: {}, nativeChatTranscriptIsLocalReadable: false, diff --git a/src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.test.ts b/src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.test.ts index 38d6f2e267fd..6b6b72727f2e 100644 --- a/src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.test.ts +++ b/src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.test.ts @@ -307,7 +307,7 @@ describe('selectTabAgentTypesByTabId', () => { expect(select({ settings: { experimentalNativeChat: true } })).toBe(first) }) - it('releases enabled inputs on disable and rescans them after re-enabling', () => { + it('keeps agent evidence available for explicit chat toggles while the default is off', () => { const onStatusEntryVisited = vi.fn() const onAgentTypeLayoutVisited = vi.fn() const onUnsafeLayoutVisited = vi.fn() @@ -323,16 +323,17 @@ describe('selectTabAgentTypesByTabId', () => { } const first = select(state) - select({ ...state, settings: { experimentalNativeChat: false } }) + const disabled = select({ ...state, settings: { experimentalNativeChat: false } }) const afterReenable = select(state) + expect(disabled.tabAgentTypesByTabId).toEqual({ 'tab-1': 'claude' }) expect(afterReenable).not.toBe(first) - expect(onStatusEntryVisited).toHaveBeenCalledTimes(2) - expect(onAgentTypeLayoutVisited).toHaveBeenCalledTimes(2) - expect(onUnsafeLayoutVisited).toHaveBeenCalledTimes(2) + expect(onStatusEntryVisited).toHaveBeenCalledTimes(1) + expect(onAgentTypeLayoutVisited).toHaveBeenCalledTimes(1) + expect(onUnsafeLayoutVisited).toHaveBeenCalledTimes(1) }) - it('production selector skips all map scans while native chat is disabled', () => { + it('production selector scans once and reuses agent evidence with Chat UI off', () => { let statusEnumerations = 0 let layoutEnumerations = 0 const statuses = new Proxy( @@ -363,8 +364,8 @@ describe('selectTabAgentTypesByTabId', () => { for (let consumer = 0; consumer < 100; consumer++) { expect(selectTabBarAgentProjections(disabledState)).toBe(disabled) } - expect(statusEnumerations).toBe(0) - expect(layoutEnumerations).toBe(0) + expect(statusEnumerations).toBe(1) + expect(layoutEnumerations).toBe(2) const enabledState = { ...disabledState, diff --git a/src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.ts b/src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.ts index 420949992d8d..cf4ae8cc07bb 100644 --- a/src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.ts +++ b/src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.ts @@ -27,12 +27,6 @@ const EMPTY_AGENT_STATUS_BY_PANE_KEY: Record = Object. const EMPTY_TERMINAL_LAYOUTS_BY_TAB_ID: Record = Object.freeze({}) const EMPTY_TAB_AGENT_TYPES_BY_TAB_ID: Record = Object.freeze({}) const EMPTY_UNSAFE_TABS_BY_ID: Record = Object.freeze({}) -const DISABLED_TAB_BAR_AGENT_PROJECTIONS: TabBarAgentProjections = Object.freeze({ - nativeChatEnabled: false, - tabAgentTypesByTabId: EMPTY_TAB_AGENT_TYPES_BY_TAB_ID, - nativeChatTabWideFallbackUnsafeTabsById: EMPTY_UNSAFE_TABS_BY_ID -}) - function reuseRecordIfEqual( previous: Record | undefined, next: Record @@ -142,18 +136,7 @@ export function createTabBarAgentProjectionSelector( let cachedEnabledResult: TabBarAgentProjections | null = null return (state) => { - if (state.settings?.experimentalNativeChat !== true) { - if (cachedEnabledResult) { - cachedAgentStatusByPaneKey = null - cachedAgentTypeLayoutsByTabId = null - cachedAgentTypesByTabId = EMPTY_TAB_AGENT_TYPES_BY_TAB_ID - cachedUnsafeLayoutsByTabId = null - cachedUnsafeTabsById = EMPTY_UNSAFE_TABS_BY_ID - cachedEnabledResult = null - } - return DISABLED_TAB_BAR_AGENT_PROJECTIONS - } - + const nativeChatEnabled = state.settings?.experimentalNativeChat === true const statuses = state.agentStatusByPaneKey ?? EMPTY_AGENT_STATUS_BY_PANE_KEY const layouts = state.terminalLayoutsByTabId ?? EMPTY_TERMINAL_LAYOUTS_BY_TAB_ID if (statuses !== cachedAgentStatusByPaneKey || layouts !== cachedAgentTypeLayoutsByTabId) { @@ -172,13 +155,14 @@ export function createTabBarAgentProjectionSelector( cachedUnsafeLayoutsByTabId = layouts } if ( - cachedEnabledResult?.tabAgentTypesByTabId === cachedAgentTypesByTabId && + cachedEnabledResult?.nativeChatEnabled === nativeChatEnabled && + cachedEnabledResult.tabAgentTypesByTabId === cachedAgentTypesByTabId && cachedEnabledResult.nativeChatTabWideFallbackUnsafeTabsById === cachedUnsafeTabsById ) { return cachedEnabledResult } cachedEnabledResult = { - nativeChatEnabled: true, + nativeChatEnabled, tabAgentTypesByTabId: cachedAgentTypesByTabId, nativeChatTabWideFallbackUnsafeTabsById: cachedUnsafeTabsById } diff --git a/src/renderer/src/components/tab-bar/tab-bar-item-surface.tsx b/src/renderer/src/components/tab-bar/tab-bar-item-surface.tsx index 7cd3c69cd35b..48d7f0421f51 100644 --- a/src/renderer/src/components/tab-bar/tab-bar-item-surface.tsx +++ b/src/renderer/src/components/tab-bar/tab-bar-item-surface.tsx @@ -28,7 +28,6 @@ export type TabBarItemSurfaceRuntime = Pick< | 'resolvedGroupId' | 'generatedTabTitlesEnabled' | 'unifiedTabByVisibleId' - | 'nativeChatEnabled' | 'tabAgentTypesByTabId' | 'nativeChatTabWideFallbackUnsafeTabsById' | 'nativeChatTranscriptIsLocalReadable' @@ -66,7 +65,6 @@ export function renderTabBarItems({ resolvedGroupId, generatedTabTitlesEnabled, unifiedTabByVisibleId, - nativeChatEnabled, tabAgentTypesByTabId, nativeChatTabWideFallbackUnsafeTabsById, nativeChatTranscriptIsLocalReadable, @@ -114,7 +112,6 @@ export function renderTabBarItems({ canToggleViewMode = unifiedTabForItem !== undefined && canToggleNativeChat({ - experimentalNativeChatEnabled: nativeChatEnabled, contentType: 'terminal', launchAgent: tabWideFallbackSafe ? terminalTab.launchAgent : null, detectedAgent, @@ -122,7 +119,7 @@ export function renderTabBarItems({ nativeChatTranscriptIsLocalReadable, isChatViewMode: unifiedTabForItem.viewMode === 'chat' }) - isChatView = nativeChatEnabled && unifiedTabForItem?.viewMode === 'chat' + isChatView = unifiedTabForItem?.viewMode === 'chat' viewModeTabId = unifiedTabForItem?.id } return ( diff --git a/src/renderer/src/components/tab-bar/use-tab-bar-runtime-model.ts b/src/renderer/src/components/tab-bar/use-tab-bar-runtime-model.ts index 3436b2200f2b..792093937a69 100644 --- a/src/renderer/src/components/tab-bar/use-tab-bar-runtime-model.ts +++ b/src/renderer/src/components/tab-bar/use-tab-bar-runtime-model.ts @@ -249,7 +249,7 @@ export function useTabBarRuntimeModel({ ) // Why: tab-wide launch/title hints are safe only before split; gate the view-mode toggle to the active leaf's agent. const toggleTabViewMode = useAppStore((s) => s.toggleTabViewMode) - // Why: every retained TabBar observes the same hot maps; one feature-gated selector shares their projections. + // Why: every retained TabBar observes the same hot maps; one selector shares their projections. const { nativeChatEnabled, tabAgentTypesByTabId, nativeChatTabWideFallbackUnsafeTabsById } = useAppStore(useShallow(selectTabBarAgentProjections)) const nativeChatTranscriptIsLocalReadable = useAppStore((s) => diff --git a/src/renderer/src/components/terminal-pane/terminal-chat-ownership-lifecycle.test.tsx b/src/renderer/src/components/terminal-pane/terminal-chat-ownership-lifecycle.test.tsx index 350c27667fa5..ae024a5886eb 100644 --- a/src/renderer/src/components/terminal-pane/terminal-chat-ownership-lifecycle.test.tsx +++ b/src/renderer/src/components/terminal-pane/terminal-chat-ownership-lifecycle.test.tsx @@ -136,6 +136,7 @@ function useFixture(fixture: ReturnType, initialOwner: strin beforeEach(() => { mocks.state.settings.experimentalNativeChat = true + mocks.state.runtimePaneTitlesByTabId.tab = { 1: 'codex', 2: 'codex' } mocks.state.setTabViewMode('tab', 'chat') mocks.state.setTabLayout('tab', { root: { @@ -166,9 +167,10 @@ describe('terminal chat ownership lifecycle', () => { expect(mocks.state.terminalLayoutsByTabId.tab.chatLeafId).toBeUndefined() }) - it('preserves the saved owner when this client disables native chat', () => { + it('keeps a saved legacy chat and its explicit toggle available with Chat UI off', () => { const push = vi.fn() const fixture = makeFixture(undefined, { push }) + mocks.state.runtimePaneTitlesByTabId.tab = { 1: 'bash', 2: 'codex' } mocks.state.terminalLayoutsByTabId.tab.ptyIdsByLeafId = { [LEFT]: 'remote:host:terminal', [RIGHT]: 'remote:host:chat' @@ -177,14 +179,21 @@ describe('terminal chat ownership lifecycle', () => { mocks.state.settings.experimentalNativeChat = false hook.rerender() hook.result.current.persistLayoutSnapshot() - expect(hook.result.current.effectiveChatViewMode).toBe(false) + expect(hook.result.current.effectiveChatViewMode).toBe(true) expect(mocks.state.unifiedTabsByWorktree.wt[0].viewMode).toBe('chat') expect(mocks.state.terminalLayoutsByTabId.tab.chatLeafId).toBe(RIGHT) + expect(hook.result.current.canToggleChatForLeaf(RIGHT)).toBe(true) + expect(hook.result.current.canToggleChatForLeaf(LEFT)).toBe(false) expect(push).toHaveBeenLastCalledWith( expect.objectContaining({ layout: expect.objectContaining({ chatLeafId: RIGHT }) }) ) + act(() => hook.result.current.toggleNativeChatForLeaf(RIGHT)) + expect(mocks.state.unifiedTabsByWorktree.wt[0].viewMode).toBe('terminal') + expect(hook.result.current.canToggleChatForLeaf(RIGHT)).toBe(true) + act(() => hook.result.current.toggleNativeChatForLeaf(RIGHT)) + expect(mocks.state.unifiedTabsByWorktree.wt[0].viewMode).toBe('chat') }) it('clears ownership when the tab-bar turns chat off, then targets the active sibling', () => { diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-store-subscription-budget.test.tsx b/src/renderer/src/components/terminal-pane/terminal-pane-store-subscription-budget.test.tsx index 9228ceaa5c79..741519aaab52 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-store-subscription-budget.test.tsx +++ b/src/renderer/src/components/terminal-pane/terminal-pane-store-subscription-budget.test.tsx @@ -26,7 +26,7 @@ import { * visit per store publication for every retained tab in the app — read the doc * above before you do. */ -const TERMINAL_PANE_LISTENER_BUDGET = 16 +const TERMINAL_PANE_LISTENER_BUDGET = 15 /** What the same mount cost before the stable-action and unified-tab folds: one listener per * bound action (each new stable action raises this by one, never the budget) plus the folded reads. */ const PRE_FOLD_LISTENERS_PER_PANE = 50 @@ -105,8 +105,9 @@ describe('TerminalPane store subscription budget', () => { expect(perPane).toBe(TERMINAL_PANE_LISTENER_BUDGET) expect(perPane).toBeLessThan(PRE_FOLD_LISTENERS_PER_PANE) - // 29 stable actions, four duplicate unified-tab reads, one dead dispatch-status read. - expect(PRE_FOLD_LISTENERS_PER_PANE - perPane).toBe(TERMINAL_PANE_STORE_ACTION_KEYS.length + 5) + // 29 stable actions, four duplicate unified-tab reads, one dead dispatch-status read, + // one retired per-pane Chat UI setting read. + expect(PRE_FOLD_LISTENERS_PER_PANE - perPane).toBe(TERMINAL_PANE_STORE_ACTION_KEYS.length + 6) unmount() expect(listenerCount()).toBe(baseline) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-chat-state.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-chat-state.ts index 06c3b979f633..400397f14f49 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-chat-state.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-chat-state.ts @@ -47,8 +47,7 @@ export function useTerminalPaneChatState(controller: TerminalPaneTitleController selectUnifiedTerminalTabFields(store.unifiedTabsByWorktree, worktreeId, tabId) ) ) - const nativeChatEnabled = useAppStore((store) => store.settings?.experimentalNativeChat === true) - const effectiveChatViewMode = nativeChatEnabled && isChatViewMode + const effectiveChatViewMode = isChatViewMode const runtimePaneTitlesByPaneId = useAppStore( useShallow((store) => store.runtimePaneTitlesByTabId[tabId] ?? {}) ) @@ -129,7 +128,6 @@ export function useTerminalPaneChatState(controller: TerminalPaneTitleController leafIds: getNativeChatLeafIds() }) return canToggleNativeChat({ - experimentalNativeChatEnabled: nativeChatEnabled, contentType: 'terminal', launchAgent: detectedAgent ? null : launchAgent, detectedAgent, @@ -139,7 +137,6 @@ export function useTerminalPaneChatState(controller: TerminalPaneTitleController }, [ tabAgentTypeByLeaf, - nativeChatEnabled, nativeChatTranscriptIsLocalReadable, terminalTab?.launchAgent, getNativeChatLeafIds, @@ -208,9 +205,9 @@ export function useTerminalPaneChatState(controller: TerminalPaneTitleController (leafId: string | null): boolean => { // Scope the "always allow toggling back" rule to the leaf showing chat; must not make an unsupported sibling look eligible. const isChatViewForLeaf = effectiveChatViewMode && leafId !== null && chatLeafId === leafId - return (nativeChatEnabled && isChatViewForLeaf) || isChatEligibleForLeaf(leafId) + return isChatViewForLeaf || isChatEligibleForLeaf(leafId) }, - [chatLeafId, effectiveChatViewMode, isChatEligibleForLeaf, nativeChatEnabled] + [chatLeafId, effectiveChatViewMode, isChatEligibleForLeaf] ) const toggleNativeChatForLeaf = useCallback( (leafId: string) => { @@ -261,7 +258,6 @@ export function useTerminalPaneChatState(controller: TerminalPaneTitleController clearCodexRestartNotice, unifiedTabId, isChatViewMode, - nativeChatEnabled, effectiveChatViewMode, unifiedTabLabel, isTabPinned, diff --git a/src/renderer/src/hooks/composer-state/composer-submit-model.ts b/src/renderer/src/hooks/composer-state/composer-submit-model.ts index 351e5a23c070..490c543de4b7 100644 --- a/src/renderer/src/hooks/composer-state/composer-submit-model.ts +++ b/src/renderer/src/hooks/composer-state/composer-submit-model.ts @@ -1,7 +1,6 @@ import type { TuiAgent } from '../../../../shared/tui-agent' import type { GitPushTarget } from '../../../../shared/worktree/types' import type { SetupDecision } from '../../../../shared/worktree/create-types' -import type { Repo } from '../../../../shared/repo-types' import type { WorkspaceIntentName } from '../../../../shared/workspace-name' import type { LinkedWorkItemSummary } from '@/lib/new-workspace' import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation' @@ -47,8 +46,7 @@ export type ComposerSubmitModel = { requestedAgent: TuiAgent | null, workspaceNameSeed: string, workspaceRunContext: WorktreeCreationRequest['workspaceRunContext'], - repoId: string, - selectedRepo: Repo + repoId: string ) => Promise prepareQuickSubmit: ( resolution: PendingSmartGitHubSubmitResolution, diff --git a/src/renderer/src/hooks/composer-state/composer-submit-orchestration.ts b/src/renderer/src/hooks/composer-state/composer-submit-orchestration.ts index d1f3d0cb9ed6..ed9e512d664e 100644 --- a/src/renderer/src/hooks/composer-state/composer-submit-orchestration.ts +++ b/src/renderer/src/hooks/composer-state/composer-submit-orchestration.ts @@ -21,7 +21,6 @@ export function useComposerSubmitOrchestration( disabledTuiAgents: target.workspaceIdentityState.disabledTuiAgents, folderCreateDisabled: source.composerNavigationActions.folderCreateDisabled, folderSourceRepos: target.runtimeTargetSelection.folderSourceRepos, - folderTargetConnectionId: target.runtimeTargetSelection.folderTargetConnectionId, folderTargetIsRemote: target.runtimeTargetSelection.folderTargetIsRemote, folderTargetRuntimeEnvironmentId: target.runtimeTargetSelection.folderTargetRuntimeEnvironmentId, diff --git a/src/renderer/src/hooks/composer-state/folder-submit-orchestration.ts b/src/renderer/src/hooks/composer-state/folder-submit-orchestration.ts index e35abc70b1f9..036177a4a73c 100644 --- a/src/renderer/src/hooks/composer-state/folder-submit-orchestration.ts +++ b/src/renderer/src/hooks/composer-state/folder-submit-orchestration.ts @@ -8,7 +8,6 @@ type FolderSubmitOrchestrationInput = Pick< | 'disabledTuiAgents' | 'folderCreateDisabled' | 'folderSourceRepos' - | 'folderTargetConnectionId' | 'folderTargetIsRemote' | 'folderTargetRuntimeEnvironmentId' | 'isSubmissionCancelled' @@ -31,16 +30,11 @@ import { useCallback } from 'react' import type { TuiAgent } from '../../../../shared/tui-agent' import { settleComposerSubmit } from '@/lib/composer-submit-cancellation' import { isTuiAgentEnabled } from '../../../../shared/tui-agent-selection' -import { - resolveFolderWorkspaceLaunchDraft, - submitFolderWorkspaceCreate -} from '@/components/sidebar/folder-workspace-composer-submit' +import { submitFolderWorkspaceCreate } from '@/components/sidebar/folder-workspace-composer-submit' import { resolveTuiAgentLaunchArgs, resolveTuiAgentLaunchEnv } from '../../../../shared/tui-agent-launch-defaults' -import { resolveInitialNativeChatSessionOptions } from '@/components/native-chat/native-chat-launch-session-options' -import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' import { translate } from '@/i18n/i18n' import { formatWorkspaceCreateError, @@ -56,7 +50,6 @@ export function useFolderSubmitOrchestration(input: FolderSubmitOrchestrationInp disabledTuiAgents, folderCreateDisabled, folderSourceRepos, - folderTargetConnectionId, folderTargetIsRemote, folderTargetRuntimeEnvironmentId, isSubmissionCancelled, @@ -107,10 +100,6 @@ export function useFolderSubmitOrchestration(input: FolderSubmitOrchestrationInp if (isSubmissionCancelled()) { return } - const folderLaunchDraftText = - agent && submitLinkedWorkItem - ? resolveFolderWorkspaceLaunchDraft(submitLinkedWorkItem, note) - : null const folderWorkspaceCreated = await submitFolderWorkspaceCreate({ projectGroup: selectedProjectGroup, name: smartGitHubMetadata?.workspaceName ?? name, @@ -125,23 +114,6 @@ export function useFolderSubmitOrchestration(input: FolderSubmitOrchestrationInp ? resolveTuiAgentLaunchArgs(agent, settings?.agentDefaultArgs) : undefined, agentEnv: agent ? resolveTuiAgentLaunchEnv(agent, settings?.agentDefaultEnv) : undefined, - sessionOptions: agent - ? resolveInitialNativeChatSessionOptions( - { - experimentalNativeChat: settings?.experimentalNativeChat, - openAgentTabsInChatByDefault: settings?.openAgentTabsInChatByDefault, - nativeChatSessionOptions: settings?.nativeChatSessionOptions - }, - { - agent, - ...(folderLaunchDraftText - ? { promptDelivery: 'draft' as const, launchDraftText: folderLaunchDraftText } - : {}), - nativeChatTranscriptIsLocalReadable: - isNativeChatTranscriptLocalReadable(folderTargetConnectionId) - } - ) - : undefined, terminalWindowsShell: settings?.terminalWindowsShell, isRemote: folderTargetIsRemote, launchSource: telemetrySource === 'onboarding' ? 'onboarding' : 'new_workspace_composer', @@ -188,7 +160,6 @@ export function useFolderSubmitOrchestration(input: FolderSubmitOrchestrationInp canResolveFolderSmartGitHubSubmit, disabledTuiAgents, folderCreateDisabled, - folderTargetConnectionId, folderTargetIsRemote, folderTargetRuntimeEnvironmentId, folderSourceRepos.length, diff --git a/src/renderer/src/hooks/composer-state/quick-creation-execution.ts b/src/renderer/src/hooks/composer-state/quick-creation-execution.ts index 554571dc6314..c2b5119c8bc0 100644 --- a/src/renderer/src/hooks/composer-state/quick-creation-execution.ts +++ b/src/renderer/src/hooks/composer-state/quick-creation-execution.ts @@ -33,7 +33,6 @@ type QuickCreationExecutionInput = Pick< > import { useCallback } from 'react' -import type { Repo } from '../../../../shared/repo-types' import type { TuiAgent } from '../../../../shared/tui-agent' import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation' import { useAppStore } from '@/store' @@ -85,8 +84,7 @@ export function useQuickCreationExecution(input: QuickCreationExecutionInput) { requestedAgent: TuiAgent | null, workspaceNameSeed: string, workspaceRunContext: WorktreeCreationRequest['workspaceRunContext'], - repoId: string, - selectedRepo: Repo + repoId: string ): Promise => { const prepared = await prepareQuickSubmit( smartGitHubResolution, @@ -134,7 +132,6 @@ export function useQuickCreationExecution(input: QuickCreationExecutionInput) { prompt: quickPrompt, draftPrompt: quickDraftPrompt, settings, - repoConnectionId: selectedRepo.connectionId, platform: selectedRepoAgentLaunchPlatform, shell: selectedRepoStartupShell, isRemote: selectedRepoIsRemote, @@ -188,8 +185,7 @@ export function useQuickCreationExecution(input: QuickCreationExecutionInput) { : (workspaceRunContext?.hostId ?? selectedRepoExecutionHostId ?? undefined) }, prompt: quickDraftPrompt ?? quickPrompt, - promptDelivery, - initialSessionOptions: startupPlan?.sessionOptions + promptDelivery }) : 'terminal-tui' const structuredLaunch = agentLaunchRoute === 'structured-native-chat' diff --git a/src/renderer/src/hooks/composer-state/quick-startup-plan.ts b/src/renderer/src/hooks/composer-state/quick-startup-plan.ts index 486550197814..f1f3ab33576e 100644 --- a/src/renderer/src/hooks/composer-state/quick-startup-plan.ts +++ b/src/renderer/src/hooks/composer-state/quick-startup-plan.ts @@ -9,8 +9,6 @@ import { resolveTuiAgentLaunchArgs, resolveTuiAgentLaunchEnv } from '../../../../shared/tui-agent-launch-defaults' -import { resolveInitialNativeChatSessionOptions } from '@/components/native-chat/native-chat-launch-session-options' -import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' import { tuiAgentToAgentKind } from '@/lib/telemetry' export type QuickComposerStartupInput = { @@ -18,7 +16,6 @@ export type QuickComposerStartupInput = { prompt: string draftPrompt: string | null | undefined settings: GlobalSettings | null | undefined - repoConnectionId: string | null | undefined platform: NodeJS.Platform shell: AgentStartupShell | null | undefined isRemote: boolean @@ -33,25 +30,6 @@ export type QuickComposerStartup = { export function buildQuickComposerStartup(input: QuickComposerStartupInput): QuickComposerStartup { const { agent, draftPrompt, prompt, settings } = input - const sessionOptions = - agent === null - ? undefined - : resolveInitialNativeChatSessionOptions( - { - experimentalNativeChat: settings?.experimentalNativeChat, - openAgentTabsInChatByDefault: settings?.openAgentTabsInChatByDefault, - nativeChatSessionOptions: settings?.nativeChatSessionOptions - }, - { - agent, - ...(draftPrompt - ? { promptDelivery: 'draft' as const, launchDraftText: draftPrompt } - : {}), - nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable( - input.repoConnectionId - ) - } - ) const draftLaunchPlan = agent === null || !draftPrompt ? null @@ -61,7 +39,6 @@ export function buildQuickComposerStartup(input: QuickComposerStartupInput): Qui cmdOverrides: settings?.agentCmdOverrides ?? {}, agentArgs: resolveTuiAgentLaunchArgs(agent, settings?.agentDefaultArgs), agentEnv: resolveTuiAgentLaunchEnv(agent, settings?.agentDefaultEnv), - sessionOptions, platform: input.platform, shell: input.shell ?? undefined, isRemote: input.isRemote @@ -74,7 +51,6 @@ export function buildQuickComposerStartup(input: QuickComposerStartupInput): Qui expectedProcess: draftLaunchPlan.expectedProcess, followupPrompt: null, launchConfig: draftLaunchPlan.launchConfig, - ...(draftLaunchPlan.sessionOptions ? { sessionOptions: draftLaunchPlan.sessionOptions } : {}), ...(draftLaunchPlan.startupCommandDelivery ? { startupCommandDelivery: draftLaunchPlan.startupCommandDelivery } : {}), @@ -87,7 +63,6 @@ export function buildQuickComposerStartup(input: QuickComposerStartupInput): Qui cmdOverrides: settings?.agentCmdOverrides ?? {}, agentArgs: resolveTuiAgentLaunchArgs(agent, settings?.agentDefaultArgs), agentEnv: resolveTuiAgentLaunchEnv(agent, settings?.agentDefaultEnv), - sessionOptions, platform: input.platform, shell: input.shell ?? undefined, isRemote: input.isRemote, diff --git a/src/renderer/src/hooks/composer-state/quick-submit-action.ts b/src/renderer/src/hooks/composer-state/quick-submit-action.ts index 1ce90eb3f90c..3bdce721bfd1 100644 --- a/src/renderer/src/hooks/composer-state/quick-submit-action.ts +++ b/src/renderer/src/hooks/composer-state/quick-submit-action.ts @@ -143,8 +143,7 @@ export function useQuickSubmitAction(input: QuickSubmitActionInput) { requestedAgent, workspaceNameSeed, workspaceRunContext, - repoId, - selectedRepo + repoId ) } catch (error) { if (isSubmissionCancelled()) { diff --git a/src/renderer/src/hooks/ipc-events-terminal-create-scenario-types.ts b/src/renderer/src/hooks/ipc-events-terminal-create-scenario-types.ts index af3ef297368d..12ec35eadc6f 100644 --- a/src/renderer/src/hooks/ipc-events-terminal-create-scenario-types.ts +++ b/src/renderer/src/hooks/ipc-events-terminal-create-scenario-types.ts @@ -118,7 +118,6 @@ export type TerminalCreateSurfacingStore = { settings: { terminalFontSize: number experimentalNativeChat: boolean - openAgentTabsInChatByDefault: boolean activeRuntimeEnvironmentId: string | undefined } } diff --git a/src/renderer/src/hooks/ipc-events-terminal-create-test-harness.ts b/src/renderer/src/hooks/ipc-events-terminal-create-test-harness.ts index 0b44fbfbf5c0..67e5931cfa9e 100644 --- a/src/renderer/src/hooks/ipc-events-terminal-create-test-harness.ts +++ b/src/renderer/src/hooks/ipc-events-terminal-create-test-harness.ts @@ -104,7 +104,6 @@ export async function setupTerminalCreateSurfacing( settings: { terminalFontSize: 13, experimentalNativeChat: false, - openAgentTabsInChatByDefault: false, activeRuntimeEnvironmentId: undefined as string | undefined } } diff --git a/src/renderer/src/hooks/ipc-events/terminal-presentation-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/terminal-presentation-ipc-bridge.ts index e978865a34ee..09da5ff7ff7b 100644 --- a/src/renderer/src/hooks/ipc-events/terminal-presentation-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/terminal-presentation-ipc-bridge.ts @@ -7,9 +7,6 @@ import { SPLIT_TERMINAL_PANE_EVENT } from '@/constants/terminal' import type { SplitTerminalPaneDetail } from '@/constants/terminal' import { singlePaneLayoutSnapshot } from '@/store/slices/terminal-helpers' import { verifyTerminalRevealIdentity } from '@/lib/terminal-reveal-identity' -import { initialAgentTabViewModeProps } from '@/lib/native-chat-initial-view-mode' -import { getConnectionIdFromState } from '@/lib/connection-context' -import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' import { tryMakePaneKey } from './agent-status-routing' import { wasAgentLaunchPaneClosedByUser } from '@/lib/agent-launch-pane-closes' import { useAppStore } from '../../store' @@ -104,15 +101,7 @@ export function registerTerminalPresentationIpcBridge(unsubs: (() => void)[]): v ? { launchAgent, // Why: a paired client resolved explicit mode before PTY materialization; only omitted mode uses host defaults. - ...(viewMode - ? { viewMode } - : initialAgentTabViewModeProps(store.settings, { - agent: launchAgent, - nativeChatTranscriptIsLocalReadable: - isNativeChatTranscriptLocalReadable( - getConnectionIdFromState(store, worktreeId) - ) - })) + ...(viewMode ? { viewMode } : {}) } : {}), ...(cwd ? { startupCwd: cwd } : {}), diff --git a/src/renderer/src/hooks/ipc-events/terminal-request-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/terminal-request-ipc-bridge.ts index 6aa784fa746a..35f9cdcc79a0 100644 --- a/src/renderer/src/hooks/ipc-events/terminal-request-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/terminal-request-ipc-bridge.ts @@ -1,7 +1,4 @@ import { requestBackgroundTerminalWorktreeMount } from '@/components/terminal/background-terminal-worktree-mount' -import { getConnectionIdFromState } from '@/lib/connection-context' -import { initialAgentTabViewModeProps } from '@/lib/native-chat-initial-view-mode' -import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' import { resolveTerminalWorktreeRoute } from '@/lib/terminal-worktree-route' import { insertUnifiedTabAfterAnchor } from '@/lib/unified-tab-anchor-insertion' import { translate } from '@/i18n/i18n' @@ -56,14 +53,7 @@ export function registerTerminalRequestIpcBridge(unsubs: (() => void)[]): void { ? { ...(shouldActivate ? {} : { activate: false, recordInteraction: false }), launchAgent: data.launchAgent, - ...(data.viewMode - ? { viewMode: data.viewMode } - : initialAgentTabViewModeProps(store.settings, { - agent: data.launchAgent, - nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable( - getConnectionIdFromState(store, worktreeId) - ) - })), + ...(data.viewMode ? { viewMode: data.viewMode } : {}), ...(data.cwd ? { startupCwd: data.cwd } : {}) } : shouldActivate diff --git a/src/renderer/src/hooks/settings-navigation-build-options.ts b/src/renderer/src/hooks/settings-navigation-build-options.ts index 9ea40e53593f..6e501d90ab64 100644 --- a/src/renderer/src/hooks/settings-navigation-build-options.ts +++ b/src/renderer/src/hooks/settings-navigation-build-options.ts @@ -7,7 +7,7 @@ export type SettingsNavigationBuildOptions = { isLocalWindowsHost: boolean isWindowsTerminalHost: boolean isWebClient: boolean - experimentalStructuredNativeChat: boolean + nativeChatEnabled: boolean managedBrowserCreationEnabled: boolean mobileEmulatorCreationEnabled: boolean isDev: boolean diff --git a/src/renderer/src/hooks/settings-navigation-interface-sections.ts b/src/renderer/src/hooks/settings-navigation-interface-sections.ts index 8adfb8b0c8fd..a40064076d55 100644 --- a/src/renderer/src/hooks/settings-navigation-interface-sections.ts +++ b/src/renderer/src/hooks/settings-navigation-interface-sections.ts @@ -15,7 +15,7 @@ export function buildInterfaceSettingsSections({ isMac, isWindows, isWebClient, - experimentalStructuredNativeChat, + nativeChatEnabled, managedBrowserCreationEnabled, mobileEmulatorCreationEnabled }: SettingsNavigationBuildOptions): SettingsNavSection[] { @@ -36,7 +36,7 @@ export function buildInterfaceSettingsSections({ }), group: 'interface' }, - ...(experimentalStructuredNativeChat + ...(nativeChatEnabled ? [ { id: 'chat', diff --git a/src/renderer/src/hooks/useIpcEvents-browser-certificate-failure.test.ts b/src/renderer/src/hooks/useIpcEvents-browser-certificate-failure.test.ts index cc40c801169e..ef9d571bf6e3 100644 --- a/src/renderer/src/hooks/useIpcEvents-browser-certificate-failure.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-browser-certificate-failure.test.ts @@ -85,7 +85,6 @@ function storeStateWithRuntime( settings: { terminalFontSize: 13, experimentalNativeChat: false, - openAgentTabsInChatByDefault: false, activeRuntimeEnvironmentId: 'env-a' } }) diff --git a/src/renderer/src/hooks/useIpcEvents-ssh-disconnect-cleanup.test.ts b/src/renderer/src/hooks/useIpcEvents-ssh-disconnect-cleanup.test.ts index 83e8c1108f11..3a2f64e7ced6 100644 --- a/src/renderer/src/hooks/useIpcEvents-ssh-disconnect-cleanup.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-ssh-disconnect-cleanup.test.ts @@ -75,8 +75,7 @@ describe('useIpcEvents updater integration', () => { sshTargetLabels: new Map([['conn-1', 'Remote']]), settings: { terminalFontSize: 13, - experimentalNativeChat: false, - openAgentTabsInChatByDefault: false + experimentalNativeChat: false } } vi.doMock('react', async () => { diff --git a/src/renderer/src/hooks/useIpcEvents-terminal-create-surfacing.test.ts b/src/renderer/src/hooks/useIpcEvents-terminal-create-surfacing.test.ts index 6bc2373f83ca..63c3c9122e5e 100644 --- a/src/renderer/src/hooks/useIpcEvents-terminal-create-surfacing.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-terminal-create-surfacing.test.ts @@ -210,8 +210,7 @@ describe('useIpcEvents updater integration', () => { focusTerminalTabSurface.mockClear() storeState.settings = { ...storeState.settings, - experimentalNativeChat: true, - openAgentTabsInChatByDefault: true + experimentalNativeChat: true } requestTerminalCreateListenerRef.current({ requestId: 'req-renderer-backed', @@ -232,7 +231,6 @@ describe('useIpcEvents updater integration', () => { activate: false, recordInteraction: false, launchAgent: 'codex', - viewMode: 'chat', startupCwd: '/repo/packages/app' }) expect(setActiveView).not.toHaveBeenCalled() @@ -481,8 +479,7 @@ describe('useIpcEvents updater integration', () => { expect(createTab).toHaveBeenCalledWith('wt-2', undefined, undefined, { initialPtyId: 'pty-bg', activate: false, - launchAgent: 'codex', - viewMode: 'chat' + launchAgent: 'codex' }) expect(setActiveView).not.toHaveBeenCalled() expect(setActiveWorktree).not.toHaveBeenCalled() @@ -517,7 +514,7 @@ describe('useIpcEvents updater integration', () => { }) createTab.mockClear() - storeState.settings.openAgentTabsInChatByDefault = false + storeState.settings.experimentalNativeChat = false createTerminalListenerRef.current({ worktreeId: 'wt-2', ptyId: 'pty-explicit-chat', @@ -530,7 +527,7 @@ describe('useIpcEvents updater integration', () => { launchAgent: 'codex', viewMode: 'chat' }) - storeState.settings.openAgentTabsInChatByDefault = true + storeState.settings.experimentalNativeChat = true createTab.mockClear() setActiveView.mockClear() diff --git a/src/renderer/src/hooks/useSettingsNavigationMetadata.test.ts b/src/renderer/src/hooks/useSettingsNavigationMetadata.test.ts index 1f8df823650c..842993d49064 100644 --- a/src/renderer/src/hooks/useSettingsNavigationMetadata.test.ts +++ b/src/renderer/src/hooks/useSettingsNavigationMetadata.test.ts @@ -40,7 +40,7 @@ describe('settings navigation metadata', () => { isMac: false, isWindows: false, isWebClient: false, - experimentalStructuredNativeChat: enabled, + nativeChatEnabled: enabled, repos: [] }) expect(sections.some((section) => section.id === 'chat')).toBe(false) @@ -60,7 +60,7 @@ describe('settings navigation metadata', () => { isMac: false, isWindows: false, isWebClient: false, - experimentalStructuredNativeChat: true, + nativeChatEnabled: true, repos: [] }) const appearanceIndex = sections.findIndex((section) => section.id === 'appearance') diff --git a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts index e7ae73e1b2a1..bda7a5d407c8 100644 --- a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts +++ b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts @@ -41,7 +41,7 @@ export function buildSettingsNavigationMetadata({ isLocalWindowsHost = isWindows, isWindowsTerminalHost = isWindows, isWebClient, - experimentalStructuredNativeChat = false, + nativeChatEnabled = false, managedBrowserCreationEnabled = !isWebClient, mobileEmulatorCreationEnabled = !isWebClient, isDev = import.meta.env.DEV, @@ -54,7 +54,7 @@ export function buildSettingsNavigationMetadata({ isLocalWindowsHost?: boolean isWindowsTerminalHost?: boolean isWebClient: boolean - experimentalStructuredNativeChat?: boolean + nativeChatEnabled?: boolean managedBrowserCreationEnabled?: boolean mobileEmulatorCreationEnabled?: boolean isDev?: boolean @@ -82,7 +82,7 @@ export function buildSettingsNavigationMetadata({ isLocalWindowsHost, isWindowsTerminalHost, isWebClient, - experimentalStructuredNativeChat, + nativeChatEnabled, managedBrowserCreationEnabled, mobileEmulatorCreationEnabled, isDev, @@ -122,7 +122,7 @@ export function useSettingsNavigationMetadata(): SettingsNavSection[] { ] as const }) ) - const experimentalStructuredNativeChat = settings?.experimentalStructuredNativeChat === true + const nativeChatEnabled = settings?.experimentalNativeChat === true const isMac = isMacUserAgent() const isWindows = isWindowsUserAgent() const isWebClient = isWebClientLocation() @@ -165,7 +165,7 @@ export function useSettingsNavigationMetadata(): SettingsNavSection[] { isLocalWindowsHost, isWindowsTerminalHost, isWebClient, - experimentalStructuredNativeChat, + nativeChatEnabled, managedBrowserCreationEnabled, mobileEmulatorCreationEnabled, isDev: import.meta.env.DEV, @@ -183,7 +183,7 @@ export function useSettingsNavigationMetadata(): SettingsNavSection[] { isLocalWindowsHost, isWindowsTerminalHost, isWebClient, - experimentalStructuredNativeChat, + nativeChatEnabled, managedBrowserCreationEnabled, mobileEmulatorCreationEnabled, isLinearConnected, diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 2332d1aa4afc..f47ca0c96a2b 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -1704,7 +1704,10 @@ "d23ae13990": "worktree", "f082788cfe": "links", "fa72e71f05": "agents", - "fe5688b761": "sidebar" + "fe5688b761": "sidebar", + "nativeChat": { + "description": "Open supported new agents in Chat UI." + } } }, "general": { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 09cf619f4935..d56b5ad2b560 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -7419,18 +7419,9 @@ "0e89a574ae": "Floating animated pet in the bottom-right corner.", "nativeChat": { "title": "Chat UI", - "description": "Preview the desktop chat surface for supported agent terminal sessions.", - "copy": "Enables the experimental Chat UI for newly created supported local sessions. Existing terminal sessions keep the terminal chat path while we tune transcript fidelity, streaming, and parity.", + "description": "Open supported new agents in Chat UI.", + "copy": "Supported new agents open in structured chat. Other agents open in the terminal; existing chats stay available.", "toggleLabel": "Toggle Chat UI", - "defaultTitle": "Default view", - "defaultCopy": "Choose how new supported agent terminal tabs open.", - "defaultViewLabel": "Default Chat UI view", - "defaultViewTerminal": "Terminal chat", - "defaultViewNative": "Chat UI", - "structuredTitle": "Use updated structured native chat", - "structuredCopy": "Open new agents as structured chats where supported. Off opens them in the terminal-backed chat. Chats that already exist stay as they are.", - "structuredScope": "Runs on this machine and on paired Orca servers running a version that supports it; older servers keep terminal chat. WSL and SSH hosts continue to use terminal chat.", - "structuredToggleLabel": "Toggle updated structured native chat", "resumeTitle": "Resume working chats automatically after a restart", "resumeCopy": "When Orca quits or installs an update, chats that were working are automatically resumed when Orca is reopened.", "resumeToggleLabel": "Toggle automatic resume after a restart", @@ -9939,7 +9930,7 @@ "87d99e634b": "Pet", "nativeChat": { "title": "Chat UI", - "description": "Preview the desktop chat surface for supported agent terminal sessions.", + "description": "Open supported new agents in Chat UI.", "grok": "grok", "native": "native", "chat": "chat", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 9007cb4c45d4..a914e04dbc26 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -7197,14 +7197,7 @@ }, "nativeChat": { "title": "Chat UI", - "description": "Previsualiza la interfaz de chat de escritorio para sesiones de terminal de agentes compatibles.", - "copy": "Añade una vista de Chat UI a la que puedes cambiar desde paneles de terminal de agentes compatibles. Es experimental mientras ajustamos la fidelidad de las transcripciones, la transmisión y la paridad con el terminal.", "toggleLabel": "Alternar Chat UI", - "defaultTitle": "Vista predeterminada", - "defaultCopy": "Elige cómo se abren las nuevas pestañas de terminal de agentes compatibles.", - "defaultViewLabel": "Vista predeterminada de Chat UI", - "defaultViewTerminal": "Chat en terminal", - "defaultViewNative": "Chat UI", "shellEnvNamesLabel": "Variables que se pasan desde tu shell", "shellEnvNamePlaceholder": "Nombre de la variable", "shellEnvNameAdd": "Agregar", @@ -7215,10 +7208,6 @@ "shellEnvCopy": "Los chats de Codex y Claude empiezan con todas las variables que exporta tu shell de inicio de sesión, igual que un terminal. Desactívalo para elegir cuáles reciben.", "shellEnvToggleLabel": "Alternar el uso del entorno de tu shell", "resumeCopy": "Cuando Orca se cierra o instala una actualización, los chats que estaban trabajando se reanudan automáticamente al volver a abrir Orca.", - "structuredTitle": "Utilice el chat nativo estructurado actualizado", - "structuredCopy": "Abre nuevos agentes como chats estructurados cuando sea posible. Si está desactivado, los abre en el chat respaldado por la terminal. Los chats que ya existen permanecen como están.", - "structuredScope": "Se ejecuta en esta máquina y en servidores Orca emparejados que ejecutan una versión que lo admita; los servidores más antiguos mantienen el chat de terminal. Los hosts WSL y SSH continúan usando el chat de terminal.", - "structuredToggleLabel": "Alternar chat nativo estructurado actualizado", "resumeTitle": "Reanudar los chats de trabajo automáticamente después de reiniciar", "resumeToggleLabel": "Alternar reanudación automática después de un reinicio" }, @@ -9666,7 +9655,6 @@ }, "nativeChat": { "title": "Chat UI", - "description": "Previsualiza la interfaz de chat de escritorio para sesiones de terminal de agentes compatibles.", "grok": "grok", "queue": "cola", "native": "nativo", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 76da9f0fdfef..7bf8eb683a59 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -7302,14 +7302,7 @@ "0e89a574ae": "Compagnon animé flottant dans le coin inférieur droit.", "nativeChat": { "title": "Chat UI", - "description": "Prévisualisez la surface de chat de bureau pour les sessions de terminal d'agents prises en charge.", - "copy": "Ajoute une vue Chat UI accessible depuis les volets de terminal d'agents pris en charge. Expérimental pendant que nous peaufinons la fidélité de la transcription, le streaming et la parité avec le terminal.", "toggleLabel": "Activer Chat UI", - "defaultTitle": "Vue par défaut", - "defaultCopy": "Choisissez comment s'ouvrent les nouveaux onglets de terminal d'agents pris en charge.", - "defaultViewLabel": "Vue Chat UI par défaut", - "defaultViewTerminal": "Chat dans le terminal", - "defaultViewNative": "Chat UI", "shellEnvNamesLabel": "Variables à transmettre depuis votre shell", "shellEnvNamePlaceholder": "Nom de la variable", "shellEnvNameAdd": "Ajouter", @@ -7319,10 +7312,6 @@ "shellEnvTitle": "Utiliser l'environnement de votre shell", "shellEnvCopy": "Les chats Codex et Claude démarrent avec toutes les variables exportées par votre shell de connexion, comme un terminal. Désactivez pour choisir celles qu'ils reçoivent.", "shellEnvToggleLabel": "Activer l'utilisation de l'environnement de votre shell", - "structuredTitle": "Utiliser le chat natif structuré mis à jour", - "structuredCopy": "Ouvre les nouveaux agents sous forme de discussions structurées lorsque c'est possible. Désactivé, ils s'ouvrent dans le chat basé sur le terminal. Les discussions existantes restent telles quelles.", - "structuredScope": "Fonctionne sur cette machine et sur les serveurs Orca appairés dont la version le prend en charge ; les serveurs plus anciens conservent le chat du terminal. WSL et les hôtes SSH continuent d'utiliser le chat du terminal.", - "structuredToggleLabel": "Basculer le chat natif structuré mis à jour", "resumeTitle": "Reprendre automatiquement les discussions de travail après un redémarrage", "resumeCopy": "Lorsqu'Orca quitte ou installe une mise à jour, les discussions en cours reprennent automatiquement à la réouverture d'Orca.", "resumeToggleLabel": "Activer la reprise automatique après un redémarrage" @@ -9813,7 +9802,6 @@ "87d99e634b": "Compagnon", "nativeChat": { "title": "Chat UI", - "description": "Prévisualisez la surface de chat de bureau pour les sessions de terminal d'agents prises en charge.", "grok": "grok", "native": "indigène", "chat": "chat", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 7a3b7b5fdc98..5096ff9c7f65 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -7182,14 +7182,7 @@ }, "nativeChat": { "title": "Chat UI", - "description": "対応 Agent のターミナルセッション用デスクトップチャット画面をプレビューします。", - "copy": "対応 Agent のターミナルペインから切り替えられる Chat UI ビューを追加します。トランスクリプトの忠実度、ストリーミング、ターミナルとの同等性を調整しているため、試験運用中です。", "toggleLabel": "Chat UI の切り替え", - "defaultTitle": "デフォルトのビュー", - "defaultCopy": "対応 Agent の新規ターミナルタブを開く方法を選択します。", - "defaultViewLabel": "デフォルトの Chat UI ビュー", - "defaultViewTerminal": "ターミナルチャット", - "defaultViewNative": "Chat UI", "shellEnvNamesLabel": "シェルから渡す変数", "shellEnvNamePlaceholder": "変数名", "shellEnvNameAdd": "追加", @@ -7199,10 +7192,6 @@ "shellEnvTitle": "シェル環境を使用する", "shellEnvCopy": "Codex と Claude のチャットは、ターミナルと同じく、ログインシェルがエクスポートするすべての変数を引き継いで開始します。オフにすると、渡す変数を選択できます。", "shellEnvToggleLabel": "シェル環境の使用の切り替え", - "structuredTitle": "更新された構造化ネイティブチャットを使用する", - "structuredCopy": "対応している場合、新規の Agent を構造化チャットとして開きます。オフにすると、ターミナルを使用したチャットで開きます。既存のチャットはそのまま残ります。", - "structuredScope": "このマシンと、対応するバージョンを実行しているペアリング済みの Orca サーバーで動作します。古いサーバーはターミナルチャットのままです。WSL および SSH ホストは引き続きターミナルチャットを使用します。", - "structuredToggleLabel": "更新された構造化ネイティブチャットの切り替え", "resumeTitle": "再起動後に作業中のチャットを自動的に再開する", "resumeCopy": "Orca が終了するかアップデートをインストールすると、Orca が再度開かれたときに作業中だったチャットが自動的に再開されます。", "resumeToggleLabel": "再起動後の自動再開の切り替え" @@ -9688,7 +9677,6 @@ }, "nativeChat": { "title": "Chat UI", - "description": "対応 Agent のターミナルセッション用デスクトップチャット画面をプレビューします。", "grok": "grok", "native": "ネイティブ", "chat": "チャット", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index db78d9b861b2..3b8312655bea 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -7182,14 +7182,7 @@ }, "nativeChat": { "title": "Chat UI", - "description": "지원되는 에이전트 터미널 세션의 데스크톱 채팅 화면을 미리 봅니다.", - "copy": "지원되는 에이전트 터미널 창에서 전환할 수 있는 Chat UI 보기를 추가합니다. 대화 기록 충실도, 스트리밍, 터미널 동작 일치를 조정하는 동안 실험적으로 제공됩니다.", "toggleLabel": "Chat UI 전환", - "defaultTitle": "기본 보기", - "defaultCopy": "지원되는 에이전트의 새 터미널 탭을 여는 방식을 선택합니다.", - "defaultViewLabel": "기본 Chat UI 보기", - "defaultViewTerminal": "Terminal 채팅", - "defaultViewNative": "Chat UI", "shellEnvNamesLabel": "셸에서 전달할 변수", "shellEnvNamePlaceholder": "변수 이름", "shellEnvNameAdd": "추가", @@ -7199,10 +7192,6 @@ "shellEnvTitle": "셸 환경 사용", "shellEnvCopy": "Codex 및 Claude 채팅은 터미널과 마찬가지로 로그인 셸이 내보내는 모든 변수를 가지고 시작합니다. 끄면 전달할 변수를 선택할 수 있습니다.", "shellEnvToggleLabel": "셸 환경 사용 전환", - "structuredTitle": "업데이트된 구조화된 기본 채팅 사용", - "structuredCopy": "지원되는 경우 새 에이전트를 구조화된 채팅으로 엽니다. 끄면 터미널 기반 채팅으로 엽니다. 이미 있는 채팅은 그대로 유지됩니다.", - "structuredScope": "이 컴퓨터와 이를 지원하는 버전을 실행하는 페어링된 Orca 서버에서 실행됩니다. 이전 서버는 터미널 채팅을 유지합니다. WSL 및 SSH 호스트는 계속해서 터미널 채팅을 사용합니다.", - "structuredToggleLabel": "업데이트된 구조화된 기본 채팅 전환", "resumeTitle": "다시 시작한 후 작업 중인 채팅을 자동으로 재개합니다.", "resumeCopy": "Orca가 종료되거나 업데이트를 설치하면 Orca가 다시 열릴 때 작업 중이던 채팅이 자동으로 재개됩니다.", "resumeToggleLabel": "다시 시작한 후 자동 재개 전환" @@ -9651,7 +9640,6 @@ }, "nativeChat": { "title": "Chat UI", - "description": "지원되는 에이전트 터미널 세션의 데스크톱 채팅 화면을 미리 봅니다.", "grok": "grok", "native": "네이티브", "chat": "채팅", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index d55c9d9cc9ea..4df85264d1d8 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -7182,14 +7182,7 @@ }, "nativeChat": { "title": "Chat UI", - "description": "预览受支持的智能体终端会话的桌面聊天界面。", - "copy": "添加可从受支持的智能体终端窗格切换的 Chat UI 视图。在我们调整记录保真度、流式传输和终端一致性期间,此功能仍处于实验阶段。", "toggleLabel": "切换 Chat UI", - "defaultTitle": "默认视图", - "defaultCopy": "选择如何打开受支持智能体的新终端标签页。", - "defaultViewLabel": "默认 Chat UI 视图", - "defaultViewTerminal": "终端聊天", - "defaultViewNative": "Chat UI", "shellEnvNamesLabel": "从 shell 传递的变量", "shellEnvNamePlaceholder": "变量名", "shellEnvNameAdd": "添加", @@ -7199,10 +7192,6 @@ "shellEnvTitle": "使用 shell 环境", "shellEnvCopy": "Codex 和 Claude 聊天启动时会带上登录 shell 导出的所有变量,与终端一致。关闭后可选择传递哪些变量。", "shellEnvToggleLabel": "切换使用 shell 环境", - "structuredTitle": "使用更新的结构化本机聊天", - "structuredCopy": "在支持的情况下,将新的智能体作为结构化聊天打开。关闭时会在基于终端的聊天中打开。已有的聊天保持不变。", - "structuredScope": "在本机和运行支持该功能版本的已配对 Orca 服务器上运行;较旧的服务器保留终端聊天。WSL 和 SSH 主机继续使用终端聊天。", - "structuredToggleLabel": "切换更新的结构化本机聊天", "resumeTitle": "重启后自动恢复工作聊天", "resumeCopy": "当 Orca 退出或安装更新时,重新打开 Orca 后,正在工作的聊天会自动恢复。", "resumeToggleLabel": "重启后切换自动恢复" @@ -9651,7 +9640,6 @@ }, "nativeChat": { "title": "Chat UI", - "description": "预览受支持的智能体终端会话的桌面聊天界面。", "grok": "grok", "native": "原生", "chat": "聊天", diff --git a/src/renderer/src/i18n/native-chat-locales.test.ts b/src/renderer/src/i18n/native-chat-locales.test.ts index fc1f5e71e046..023498741503 100644 --- a/src/renderer/src/i18n/native-chat-locales.test.ts +++ b/src/renderer/src/i18n/native-chat-locales.test.ts @@ -45,19 +45,17 @@ describe('native chat locale copy', () => { }) it.each(Object.entries(localizedCatalogs))( - '%s keeps provider-neutral copy localized', + '%s falls back to current Chat UI copy until it is translated', (_code, catalog) => { const setting = catalog.auto.components.settings.ExperimentalPane.nativeChat const search = catalog.auto.components.settings.experimental.search.nativeChat - for (const [localized, english] of [ - [setting.description, englishSetting.description], - [setting.copy, englishSetting.copy], - [setting.defaultCopy, englishSetting.defaultCopy], - [search.description, englishSearch.description] - ]) { - expect(localized.trim()).not.toBe('') - expect(localized).not.toBe(english) - } + expect(englishSetting.description).toContain('Chat UI') + expect(englishSetting.copy).toContain('structured chat') + expect(englishSearch.description).toContain('Chat UI') + expect(setting).not.toHaveProperty('description') + expect(setting).not.toHaveProperty('copy') + expect(setting).not.toHaveProperty('defaultCopy') + expect(search).not.toHaveProperty('description') expect(search.grok).toBe('grok') const composer = catalog.components['native-chat'].composer for (const key of [ diff --git a/src/renderer/src/lib/agent-launch-caller-permissions.test.ts b/src/renderer/src/lib/agent-launch-caller-permissions.test.ts index dfc416260f84..b6654a2b36a8 100644 --- a/src/renderer/src/lib/agent-launch-caller-permissions.test.ts +++ b/src/renderer/src/lib/agent-launch-caller-permissions.test.ts @@ -243,49 +243,41 @@ describe('agent launch caller arguments and permission bypass', () => { }) }) - it('applies a remembered model and effort to the launch command', async () => { - store.settings = { - ...store.settings, - experimentalNativeChat: true, - openAgentTabsInChatByDefault: true, - nativeChatSessionOptions: { - codex: { model: 'gpt-5.2-codex', valuesByModel: { 'gpt-5.2-codex': { effort: 'medium' } } } + // Why: chat composer picks seed only structured creates (launch-structured-agent-session.test.ts, + // orca-runtime-structured-agent-session-create-intent.test.ts); a terminal fallback never gets them. + it.each([ + [true, undefined, CODEX_BYPASS], + [false, undefined, CODEX_BYPASS], + [true, '--search', '--search'], + [false, '--search', '--search'] + ])( + 'keeps remembered model and effort off a terminal fallback (Chat UI %s, stored args %s)', + async (chatUi, storedArgs, expectedArgs) => { + store.settings = { + ...store.settings, + experimentalNativeChat: chatUi, + ...(storedArgs === undefined ? {} : { agentDefaultArgs: { codex: storedArgs } }), + nativeChatSessionOptions: { + codex: { + model: 'gpt-5.2-codex', + valuesByModel: { 'gpt-5.2-codex': { effort: 'medium' } } + } + } } - } - const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') - - const result = launchAgentInNewTab({ - requestId: 'request-9', - agent: 'codex', - worktreeId: 'wt-1' - }) - - expect(result?.startupPlan?.sessionOptions).toEqual({ - model: 'gpt-5.2-codex', - effort: 'medium' - }) - expect(queuedStartupCommand(store)).toContain("'-m' 'gpt-5.2-codex'") - expect(queuedStartupCommand(store)).toContain("'-c' 'model_reasoning_effort=medium'") - // The remembered options ride beside the bypass default rather than replacing it. - expect(queuedStartupCommand(store)).toContain(CODEX_BYPASS) - }) + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') - it('keeps remembered session options out of a plain terminal launch', async () => { - store.settings = { - ...store.settings, - nativeChatSessionOptions: { - codex: { model: 'gpt-5.2-codex', valuesByModel: { 'gpt-5.2-codex': { effort: 'medium' } } } - } + const result = launchAgentInNewTab({ + requestId: 'request-9', + agent: 'codex', + worktreeId: 'wt-1' + }) + + // No local structured capability, so even with Chat UI on this is the terminal fallback. + expect(result?.startupPlan?.sessionOptions).toBeUndefined() + const command = queuedStartupCommand(store) + expect(command).not.toContain("'-m'") + expect(command).not.toContain('model_reasoning_effort') + expect(command).toBe(`codex '${expectedArgs}'`) } - const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') - - const result = launchAgentInNewTab({ - requestId: 'request-10', - agent: 'codex', - worktreeId: 'wt-1' - }) - - expect(result?.startupPlan?.sessionOptions).toBeUndefined() - expect(queuedStartupCommand(store)).not.toContain("'-m'") - }) + ) }) diff --git a/src/renderer/src/lib/agent-launch-caller-routing.test.ts b/src/renderer/src/lib/agent-launch-caller-routing.test.ts index fb79b9c746a1..034c8a2e99a2 100644 --- a/src/renderer/src/lib/agent-launch-caller-routing.test.ts +++ b/src/renderer/src/lib/agent-launch-caller-routing.test.ts @@ -78,9 +78,7 @@ function serverReports(capabilities: readonly string[] | null): void { } const CHAT_DEFAULT_SETTINGS = { - experimentalNativeChat: true, - experimentalStructuredNativeChat: true, - openAgentTabsInChatByDefault: true + experimentalNativeChat: true } const cases = callerProfileCases() diff --git a/src/renderer/src/lib/agent-launch-route-connection-fallback.test.ts b/src/renderer/src/lib/agent-launch-route-connection-fallback.test.ts index c87c594c069d..7c8e51591266 100644 --- a/src/renderer/src/lib/agent-launch-route-connection-fallback.test.ts +++ b/src/renderer/src/lib/agent-launch-route-connection-fallback.test.ts @@ -6,7 +6,7 @@ import { planAgentSessionLaunch } from './agent-session-launch-plan' // Deliberately un-mocked: the defect this pins lives in the owner resolution itself, so a suite // that stages `getConnectionIdFromState` cannot catch it. Grok is the agent the answer routes on — // only agents whose hook discloses no transcript path read the local-readability input at all. -const NATIVE_CHAT_SETTINGS = { experimentalNativeChat: true, openAgentTabsInChatByDefault: true } +const NATIVE_CHAT_SETTINGS = { experimentalNativeChat: true } const WORKTREE_ID = 'repo-1::/repo/wt-1' const WORKSPACE = { kind: 'git-worktree', worktreeId: WORKTREE_ID, repoId: 'repo-1' } as const @@ -44,7 +44,7 @@ describe('launch route transcript readability', () => { expect( planAgentSessionLaunch(store, { requestId: 'request-1', agent: 'grok', workspace: WORKSPACE }) .route - ).toBe('legacy-native-chat') + ).toBe('terminal-tui') }) it('keeps a remote repo off native chat through the same fallback', () => { diff --git a/src/renderer/src/lib/agent-launch-route-input.test.ts b/src/renderer/src/lib/agent-launch-route-input.test.ts index 3903f814c75a..e5e87f6f1376 100644 --- a/src/renderer/src/lib/agent-launch-route-input.test.ts +++ b/src/renderer/src/lib/agent-launch-route-input.test.ts @@ -59,8 +59,6 @@ const structuredFeasibleFor = (appStore: AgentLaunchRouteStore, args: AgentLaunc const STRUCTURED_SETTINGS = { experimentalNativeChat: true, - openAgentTabsInChatByDefault: true, - experimentalStructuredNativeChat: true, agentCmdOverrides: {}, agentDefaultArgs: {}, agentDefaultEnv: {} @@ -103,8 +101,7 @@ describe('buildAgentLaunchRouteInput', () => { agent: 'codex', workspace: { kind: 'git-worktree', worktreeId: 'wt-1' }, prompt: 'fix the flaky test', - promptDelivery: 'auto-submit', - initialSessionOptions: { model: 'gpt-5.4' } + promptDelivery: 'auto-submit' }) expect(input).toEqual({ agent: 'codex', @@ -116,8 +113,7 @@ describe('buildAgentLaunchRouteInput', () => { promptDelivery: 'auto-submit', launchText: 'fix the flaky test', nativeChatTranscriptIsLocalReadable: true, - startsOutsideWorkspaceRoot: false, - initialSessionOptions: { model: 'gpt-5.4' } + startsOutsideWorkspaceRoot: false }) expect(mocks.getExecutionHostIdForWorktree).toHaveBeenCalledWith(appStore, 'wt-1') expect(mocks.getLocalProjectExecutionRuntimeContext).toHaveBeenCalledWith(appStore, 'wt-1') @@ -127,7 +123,7 @@ describe('buildAgentLaunchRouteInput', () => { agent: 'codex', workspace: { kind: 'git-worktree', worktreeId: 'wt-1' } }) - ).toBe('legacy-native-chat') + ).toBe('terminal-tui') }) it('never consults the local project runtime for a worktree on an SSH connection', () => { @@ -174,7 +170,7 @@ describe('buildAgentLaunchRouteInput', () => { prompt: 'issue body', promptDelivery: 'draft' }) - ).toBe('legacy-native-chat') + ).toBe('terminal-tui') }) it.each([ @@ -301,7 +297,7 @@ describe('buildAgentLaunchRouteInput', () => { }) // Grok reads its transcript off local disk, so it is the agent the readability answer routes on. - const NATIVE_CHAT_SETTINGS = { experimentalNativeChat: true, openAgentTabsInChatByDefault: true } + const NATIVE_CHAT_SETTINGS = { experimentalNativeChat: true } const UNLANDED_WORKSPACE = { kind: 'git-worktree', worktreeId: 'repo-1::/repo/wt-1', @@ -322,7 +318,7 @@ describe('buildAgentLaunchRouteInput', () => { .nativeChatTranscriptIsLocalReadable ).toBe(true) expect(routeFor(appStore, { agent: 'grok', workspace: UNLANDED_WORKSPACE })).toBe( - 'legacy-native-chat' + 'terminal-tui' ) }) @@ -346,7 +342,7 @@ describe('buildAgentLaunchRouteInput', () => { worktreesByRepo: {} } as unknown as AgentLaunchRouteStore expect(routeFor(appStore, { agent: 'grok', workspace: UNLANDED_WORKSPACE })).toBe( - 'legacy-native-chat' + 'terminal-tui' ) }) @@ -393,14 +389,14 @@ describe('buildAgentLaunchRouteInput', () => { it('treats a server that has not reported its status as unknown', () => { expect(buildAgentLaunchRouteInput(store(), args).hostCapabilities).toBeNull() - expect(routeFor(store(), args)).toBe('legacy-native-chat') + expect(routeFor(store(), args)).toBe('terminal-tui') }) // A released server advertises structured sessions but admits them only with its own chat // setting on; a chat opened there could never start. it('keeps the terminal on a server that predates client-chosen launch modes', () => { expect(routeFor(pairedStore([STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]), args)).toBe( - 'legacy-native-chat' + 'terminal-tui' ) }) @@ -409,7 +405,7 @@ describe('buildAgentLaunchRouteInput', () => { it('keeps the host terminal for a browser client', () => { mocks.isWebClientLocation.mockReturnValue(true) try { - expect(routeFor(pairedStore(CURRENT_SERVER), args)).toBe('legacy-native-chat') + expect(routeFor(pairedStore(CURRENT_SERVER), args)).toBe('terminal-tui') } finally { mocks.isWebClientLocation.mockReturnValue(false) } @@ -448,7 +444,7 @@ describe('buildAgentLaunchRouteInput', () => { agent: 'claude', workspace: { kind: 'git-worktree', worktreeId: 'wt-1' } }) - ).toBe('legacy-native-chat') + ).toBe('terminal-tui') }) }) diff --git a/src/renderer/src/lib/agent-launch-route-input.ts b/src/renderer/src/lib/agent-launch-route-input.ts index 6ac1121ae248..316d0f7aeb4b 100644 --- a/src/renderer/src/lib/agent-launch-route-input.ts +++ b/src/renderer/src/lib/agent-launch-route-input.ts @@ -19,7 +19,7 @@ import { getLocalProjectExecutionRuntimeContext, getLocalRepoProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' -import type { NativeChatLaunchPromptDelivery } from '@/lib/native-chat-initial-view-mode' +import type { NativeChatLaunchPromptDelivery } from '@/lib/native-chat-launch-prompt-delivery' import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' import { readLocalRuntimeCapabilitiesOrUnknown } from '@/runtime/local-runtime-capabilities' @@ -63,7 +63,6 @@ export type AgentLaunchRouteArgs = { promptDelivery?: NativeChatLaunchPromptDelivery /** A working directory only a terminal can apply; a structured session runs in its workspace. */ tuiCustomization?: { cwd?: string | null } - initialSessionOptions?: Readonly> } export { workspaceKindForWorktreeId } @@ -172,7 +171,6 @@ export function buildAgentLaunchRouteInput( resolveFolderWorkspacePath: (folderWorkspaceId) => store.folderWorkspaces?.find((entry) => entry.id === folderWorkspaceId)?.folderPath }), - initialSessionOptions: args.initialSessionOptions, ...hostStructuredAgentsInput(store, executionHostId) } } diff --git a/src/renderer/src/lib/agent-launch-route-registered-agents.test.ts b/src/renderer/src/lib/agent-launch-route-registered-agents.test.ts index 0a8c0bf005d8..8b319c2e9a6e 100644 --- a/src/renderer/src/lib/agent-launch-route-registered-agents.test.ts +++ b/src/renderer/src/lib/agent-launch-route-registered-agents.test.ts @@ -47,9 +47,7 @@ const LISTS_GROK = { ] } const SETTINGS = { - experimentalNativeChat: true, - openAgentTabsInChatByDefault: true, - experimentalStructuredNativeChat: true + experimentalNativeChat: true } const LOCAL: ProspectiveWorkspace = { kind: 'git-worktree', repoId: 'repo-1' } const PAIRED: ProspectiveWorkspace = { kind: 'git-worktree', runtimeEnvironmentId: 'env-1' } @@ -121,12 +119,12 @@ describe('structured launch of a host-registered agent', () => { expect(routeFor(store(), 'gemini')).not.toBe('structured-native-chat') }) - it('keeps the experimental chat setting authoritative', async () => { + it('keeps the Chat UI switch authoritative', async () => { await loadHostStructuredAgents('local', REGISTERED, null) - expect( - routeFor(store({ settings: { experimentalStructuredNativeChat: false } }), 'grok') - ).not.toBe('structured-native-chat') + expect(routeFor(store({ settings: { experimentalNativeChat: false } }), 'grok')).not.toBe( + 'structured-native-chat' + ) }) it("reads a paired host's list only from the runtime that gave it", async () => { @@ -172,9 +170,9 @@ describe('OpenCode with its Command and environment set (live QA run 5)', () => mocks.callRuntimeRpc.mockResolvedValue(LISTS_OPENCODE) // What run 5 saw: the terminal-backed "OpenCode" chat, and no version check on the host. - expect(routeFor(withOverrides(), 'opencode')).toBe('legacy-native-chat') + expect(routeFor(withOverrides(), 'opencode')).toBe('terminal-tui') expect(warn).toHaveBeenCalledWith( - '[agent-launch-route] opencode opens legacy-native-chat: agent-without-structured-session ' + + '[agent-launch-route] opencode opens terminal-tui: agent-without-structured-session ' + '(host local, host agents not learned, host capabilities known)' ) await vi.waitFor(() => expect(mocks.callRuntimeRpc).toHaveBeenCalledTimes(2)) @@ -184,12 +182,10 @@ describe('OpenCode with its Command and environment set (live QA run 5)', () => ) }) - it('names the settings default when new agent tabs do not open as chats', () => { - expect(routeFor(store({ settings: { openAgentTabsInChatByDefault: false } }), 'opencode')).toBe( + it('keeps new agent tabs in terminals while Chat UI is off', () => { + expect(routeFor(store({ settings: { experimentalNativeChat: false } }), 'opencode')).toBe( 'terminal-tui' ) - expect(warn).toHaveBeenCalledWith( - expect.stringContaining('opencode opens terminal-tui: new-tabs-default-to-terminal') - ) + expect(warn).not.toHaveBeenCalled() }) }) diff --git a/src/renderer/src/lib/agent-launch-routing.test.ts b/src/renderer/src/lib/agent-launch-routing.test.ts index 985ff7a7a021..f2b42c96f8c4 100644 --- a/src/renderer/src/lib/agent-launch-routing.test.ts +++ b/src/renderer/src/lib/agent-launch-routing.test.ts @@ -2,16 +2,13 @@ import { describe, expect, it } from 'vitest' import { RUNTIME_CAPABILITIES, STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY, + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../../shared/electron-remote-runtime-client-capabilities' import { resolveAgentLaunchRoute, structuredAgentLaunchSupported } from './agent-launch-routing' -const settings = { - experimentalNativeChat: true, - experimentalStructuredNativeChat: true, - openAgentTabsInChatByDefault: true -} +const settings = { experimentalNativeChat: true } function route(overrides: Partial[0]> = {}) { return resolveAgentLaunchRoute({ @@ -20,130 +17,45 @@ function route(overrides: Partial[0]> executionHostId: 'local', hostCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY], workspaceKind: 'git-worktree', - nativeChatTranscriptIsLocalReadable: true, ...overrides }) } -describe('resolveAgentLaunchRoute', () => { - it.each(['claude', 'codex'] as const)( - 'routes a supported local %s launch to structured native chat', - (agent) => { - expect(route({ agent })).toBe('structured-native-chat') - expect(route({ agent, initialSessionOptions: { model: 'gpt-5.6-sol' } })).toBe( - 'structured-native-chat' - ) - expect( - route({ agent, launchText: 'explain this change', promptDelivery: 'auto-submit' }) - ).toBe('structured-native-chat') - } - ) - - /** Windows eligibility is no client-side platform guess for either provider: the route lets the - * launch through and the executing host settles it with agentSession.createSupport at create - * time. A stale caller still passing the removed `platform` input must not flip Codex off the - * structured route — the field is gone, not reinterpreted. */ - it.each(['claude', 'codex'] as const)( - 'routes %s to structured even when the caller claims a win32 client platform', - (agent) => { - expect(route({ agent, ...({ platform: 'win32' } as object) })).toBe('structured-native-chat') - } - ) - - it('routes editable drafts to the structured chat composer', () => { - expect(route({ launchText: 'reviewable context', promptDelivery: 'draft' })).toBe( +describe('new agent launch routing', () => { + it.each(['claude', 'codex'] as const)('opens supported local %s in structured chat', (agent) => { + expect(route({ agent })).toBe('structured-native-chat') + expect(route({ agent, launchText: 'draft', promptDelivery: 'draft' })).toBe( 'structured-native-chat' ) }) - // Why: the terminal mirror gate caps a draft at forty lines because a TUI cannot clear more; - // the structured composer has no such limit and must be chosen before that gate runs. + it('uses Chat UI alone even when an old selector is false or absent', () => { + const oldSelectorOff = { experimentalNativeChat: true, openAgentTabsInChatByDefault: false } + expect(route({ settings: oldSelectorOff })).toBe('structured-native-chat') + expect(route({ settings: { experimentalNativeChat: true } })).toBe('structured-native-chat') + }) + + // Why: a TUI caps a mirrored draft at forty lines; the structured composer has no such limit. it('routes a draft longer than the terminal mirror cap to structured chat', () => { const sixtyLineDraft = Array.from({ length: 60 }, (_, i) => `line ${i + 1}`).join('\n') expect(route({ launchText: sixtyLineDraft, promptDelivery: 'draft' })).toBe( 'structured-native-chat' ) - expect( - route({ - launchText: sixtyLineDraft, - promptDelivery: 'draft', - settings: { ...settings, experimentalStructuredNativeChat: false } - }) - ).toBe('terminal-tui') - }) - - it('preserves toggle-off and terminal-default behavior', () => { - expect(route({ settings: { ...settings, experimentalStructuredNativeChat: false } })).toBe( - 'legacy-native-chat' - ) - expect(route({ settings: { ...settings, openAgentTabsInChatByDefault: false } })).toBe( - 'terminal-tui' - ) - expect(route({ settings: { ...settings, experimentalNativeChat: false } })).toBe('terminal-tui') }) - it('fails closed for missing capability, unsupported providers, and explicit TUI options', () => { - expect(route({ hostCapabilities: [] })).toBe('legacy-native-chat') - expect(route({ hostCapabilities: null })).toBe('legacy-native-chat') - // openclaude and grok render native chat but have no structured adapter. - expect(route({ agent: 'openclaude' })).toBe('legacy-native-chat') - expect(route({ agent: 'grok' })).toBe('legacy-native-chat') - expect(route({ startsOutsideWorkspaceRoot: true })).toBe('legacy-native-chat') + it('opens the terminal when Chat UI is off', () => { + expect(route({ settings: { experimentalNativeChat: false } })).toBe('terminal-tui') + expect(route({ settings: null })).toBe('terminal-tui') }) - it('keeps an SSH workspace terminal-backed, since no Orca runtime runs there', () => { - expect(route({ executionHostId: 'ssh:host-a' })).toBe('legacy-native-chat') - }) - - // The lists the two sides really advertise, not hand-written ones: dropping the launch-mode - // capability from either would quietly turn every paired-server launch into a terminal. - it('opens a chat on a current paired server with the lists both sides advertise', () => { - expect( - route({ - executionHostId: 'runtime:environment-a', - hostCapabilities: RUNTIME_CAPABILITIES, - clientCapabilities: ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES - }) - ).toBe('structured-native-chat') - }) - - it('routes a paired server by its own capabilities', () => { - const structured = [ - STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, - STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY - ] - const server = { - executionHostId: 'runtime:environment-a', - hostCapabilities: structured, - clientCapabilities: structured - } - expect(route(server)).toBe('structured-native-chat') - // A client that never told the server it reads structured sessions keeps the host terminal. - expect(route({ ...server, clientCapabilities: [] })).toBe('legacy-native-chat') - expect(route({ ...server, clientCapabilities: undefined })).toBe('legacy-native-chat') - // A released server admits chats only with its own setting on, so it keeps the terminal. - expect( - route({ executionHostId: 'runtime:environment-a', clientCapabilities: structured }) - ).toBe('legacy-native-chat') - // The server has not answered yet, or answered without structured sessions. - expect(route({ executionHostId: 'runtime:environment-a', hostCapabilities: null })).toBe( - 'legacy-native-chat' - ) - expect(route({ executionHostId: 'runtime:environment-a', hostCapabilities: [] })).toBe( - 'legacy-native-chat' - ) - }) - - // Floating joined this list: its configured directory resolves like any other workspace, so a - // session can be filed under it. Workspace kind no longer downgrades a launch on its own. - it.each(['git-worktree', 'folder', 'floating'] as const)( - 'resolves a structured session for a local %s', - (workspaceKind) => { - expect(route({ workspaceKind })).toBe('structured-native-chat') - } - ) - - it('keeps WSL and repair-required launches terminal-backed', () => { + it('keeps unsupported and unverified launches in terminal UI', () => { + expect(route({ hostCapabilities: [] })).toBe('terminal-tui') + expect(route({ hostCapabilities: null })).toBe('terminal-tui') + expect(route({ agent: 'openclaude' })).toBe('terminal-tui') + // Grok has no built-in structured adapter; only a host agent list admits it. + expect(route({ agent: 'grok' })).toBe('terminal-tui') + expect(route({ startsOutsideWorkspaceRoot: true })).toBe('terminal-tui') + expect(route({ executionHostId: 'ssh:host-a' })).toBe('terminal-tui') expect( route({ projectRuntime: { @@ -158,7 +70,7 @@ describe('resolveAgentLaunchRoute', () => { } } }) - ).toBe('legacy-native-chat') + ).toBe('terminal-tui') expect( route({ projectRuntime: { @@ -172,31 +84,89 @@ describe('resolveAgentLaunchRoute', () => { } } }) - ).toBe('legacy-native-chat') + ).toBe('terminal-tui') + }) + + it('uses the host agent list for providers beyond Claude and Codex', () => { + expect( + route({ + agent: 'grok', + hostCapabilities: [ + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY + ], + hostStructuredAgents: ['grok'] + }) + ).toBe('structured-native-chat') + }) + + it.each(['git-worktree', 'folder', 'floating'] as const)( + 'allows a supported local %s workspace', + (workspaceKind) => expect(route({ workspaceKind })).toBe('structured-native-chat') + ) + + it('honors current paired-host capabilities and falls back on older hosts', () => { + expect( + route({ + executionHostId: 'runtime:environment-a', + hostCapabilities: RUNTIME_CAPABILITIES, + clientCapabilities: ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES + }) + ).toBe('structured-native-chat') + const negotiated = [ + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY + ] + expect( + route({ + executionHostId: 'runtime:environment-a', + hostCapabilities: negotiated, + clientCapabilities: negotiated + }) + ).toBe('structured-native-chat') + expect( + route({ + executionHostId: 'runtime:environment-a', + hostCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY], + clientCapabilities: negotiated + }) + ).toBe('terminal-tui') + const server = { + executionHostId: 'runtime:environment-a', + hostCapabilities: negotiated, + clientCapabilities: negotiated + } + // A client that never told the server it reads structured sessions keeps the host terminal. + expect(route({ ...server, clientCapabilities: [] })).toBe('terminal-tui') + expect(route({ ...server, clientCapabilities: undefined })).toBe('terminal-tui') + // The server has not answered yet, or answered without structured sessions. + expect(route({ ...server, hostCapabilities: null })).toBe('terminal-tui') + expect(route({ ...server, hostCapabilities: [] })).toBe('terminal-tui') }) }) describe('explicit structured chat requests', () => { - it.each(['claude', 'codex'] as const)( - 'supports %s history resume when new tabs default to terminal', - (agent) => { - const input = { - agent, - settings: { ...settings, openAgentTabsInChatByDefault: false }, - executionHostId: 'local', - platform: 'darwin' as const, - hostCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY], - workspaceKind: 'folder' as const - } - expect(resolveAgentLaunchRoute(input)).toBe('terminal-tui') - expect(structuredAgentLaunchSupported(input)).toBe(true) - expect(structuredAgentLaunchSupported({ ...input, hostCapabilities: [] })).toBe(false) - expect( - structuredAgentLaunchSupported({ - ...input, - settings: { ...input.settings, experimentalStructuredNativeChat: false } - }) - ).toBe(false) + it.each(['claude', 'codex'] as const)('supports %s history resume on a capable host', (agent) => { + const input = { + agent, + settings, + executionHostId: 'local', + hostCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY], + workspaceKind: 'folder' as const } - ) + expect(structuredAgentLaunchSupported(input)).toBe(true) + expect(structuredAgentLaunchSupported({ ...input, hostCapabilities: [] })).toBe(false) + }) + + it('still permits an explicit structured history request while Chat UI is off', () => { + const input = { + agent: 'codex' as const, + settings: { experimentalNativeChat: false }, + executionHostId: 'local', + hostCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] + } + expect(resolveAgentLaunchRoute(input)).toBe('terminal-tui') + expect(structuredAgentLaunchSupported(input)).toBe(true) + expect(structuredAgentLaunchSupported({ ...input, settings: null })).toBe(true) + }) }) diff --git a/src/renderer/src/lib/agent-launch-routing.ts b/src/renderer/src/lib/agent-launch-routing.ts index 93bc37ecb520..700b04dc424c 100644 --- a/src/renderer/src/lib/agent-launch-routing.ts +++ b/src/renderer/src/lib/agent-launch-routing.ts @@ -1,30 +1,19 @@ import type { GlobalSettings } from '../../../shared/global-settings-types' import type { ProjectExecutionRuntimeResolution } from '../../../shared/project-execution-runtime' import { - prefersStructuredNativeChatByDefault, + isNativeChatEnabled, resolveStructuredNativeChatSupport, type StructuredNativeChatBlocker } from '../../../shared/structured-native-chat-launch-route' import type { TuiAgent } from '../../../shared/tui-agent' import type { WorkspaceLaunchKind } from '../../../shared/workspace-launch-kind' -import { - decideInitialAgentTabViewMode, - type NativeChatLaunchPromptDelivery -} from '@/lib/native-chat-initial-view-mode' +import type { NativeChatLaunchPromptDelivery } from '@/lib/native-chat-launch-prompt-delivery' -export type AgentLaunchRoute = 'structured-native-chat' | 'legacy-native-chat' | 'terminal-tui' +export type AgentLaunchRoute = 'structured-native-chat' | 'terminal-tui' export type AgentLaunchRoutingInput = { agent: TuiAgent - settings: - | Pick< - GlobalSettings, - | 'experimentalNativeChat' - | 'experimentalStructuredNativeChat' - | 'openAgentTabsInChatByDefault' - > - | null - | undefined + settings: Pick | null | undefined executionHostId: string /** Capabilities of the target host; `null` = not yet established. */ hostCapabilities: readonly string[] | null @@ -36,43 +25,21 @@ export type AgentLaunchRoutingInput = { launchText?: string nativeChatTranscriptIsLocalReadable?: boolean startsOutsideWorkspaceRoot?: boolean - initialSessionOptions?: Readonly> /** The agents the target host listed as structured; absent until it has. */ hostStructuredAgents?: readonly string[] } export function resolveAgentLaunchRoute(input: AgentLaunchRoutingInput): AgentLaunchRoute { - // Why: structured eligibility is decided before the view-mode decider. That decider applies the - // terminal mirror gate (a TUI cannot clear more than forty lines of prefilled draft), which has - // no meaning for a session that seeds the composer store directly. Its other gates are already - // implied here: the structured resolver admits only agents the host runs as chats, and - // only hosts with an Orca runtime, and a structured session reads its journal over RPC rather - // than the transcript file, so local transcript readability does not apply either. - if ( - prefersStructuredNativeChatByDefault(input.settings) && - structuredAgentLaunchSupported(input) - ) { - return 'structured-native-chat' - } - const initialViewMode = decideInitialAgentTabViewMode({ - experimentalNativeChat: input.settings?.experimentalNativeChat, - openAgentTabsInChatByDefault: input.settings?.openAgentTabsInChatByDefault, - agent: input.agent, - promptDelivery: input.promptDelivery, - launchDraftText: input.launchText, - nativeChatTranscriptIsLocalReadable: input.nativeChatTranscriptIsLocalReadable - }) - return initialViewMode === 'chat' ? 'legacy-native-chat' : 'terminal-tui' + return isNativeChatEnabled(input.settings) && structuredAgentLaunchSupported(input) + ? 'structured-native-chat' + : 'terminal-tui' } -// Explicit chat requests do not depend on the default view mode for new tabs. +// Explicit chat requests (resume history in a new chat) do not depend on the Chat UI switch. export function structuredAgentLaunchSupported( input: Omit ): boolean { - return ( - input.settings?.experimentalStructuredNativeChat === true && - structuredAgentLaunchSupport(input).supported - ) + return structuredAgentLaunchSupport(input).supported } function structuredAgentLaunchSupport(input: Omit) { @@ -93,16 +60,10 @@ function structuredAgentLaunchSupport(input: Omit { }) it('opens nothing structured on a route that is not', async () => { - mocks.resolveAgentLaunchRoute.mockReturnValue('legacy-native-chat') + mocks.resolveAgentLaunchRoute.mockReturnValue('terminal-tui') const plan = planAgentSessionLaunch(store, { requestId: 'request-4b', agent: 'grok', @@ -186,7 +186,7 @@ describe('structuredAgentSessionLaunchFeasible', () => { it.each([true, false])('answers from feasibility alone (supported=%s)', (supported) => { mocks.structuredAgentLaunchSupported.mockReturnValue(supported) - const settings = { experimentalStructuredNativeChat: true } as never + const settings = { experimentalNativeChat: true } expect( structuredAgentSessionLaunchFeasible(store, { @@ -200,7 +200,7 @@ describe('structuredAgentSessionLaunchFeasible', () => { }) it('builds the input from the named settings, not the store copy', () => { - const settings = { experimentalStructuredNativeChat: true } as never + const settings = { experimentalNativeChat: true } structuredAgentSessionLaunchFeasible(store, { agent: 'codex', workspace: { kind: 'git-worktree', worktreeId: 'wt-1' }, diff --git a/src/renderer/src/lib/agent-session-launch-plan.ts b/src/renderer/src/lib/agent-session-launch-plan.ts index 25a247ca4e39..0df9b006f80b 100644 --- a/src/renderer/src/lib/agent-session-launch-plan.ts +++ b/src/renderer/src/lib/agent-session-launch-plan.ts @@ -17,7 +17,7 @@ import { type AgentLaunchRoute, type AgentLaunchRoutingInput } from '@/lib/agent-launch-routing' -import type { NativeChatLaunchPromptDelivery } from '@/lib/native-chat-initial-view-mode' +import type { NativeChatLaunchPromptDelivery } from '@/lib/native-chat-launch-prompt-delivery' import { beginStructuredAgentLaunchSettlement, type StructuredAgentLaunchHandle, diff --git a/src/renderer/src/lib/empty-workspace-default-agent-chat.test.ts b/src/renderer/src/lib/empty-workspace-default-agent-chat.test.ts index 78ba58e4eaf8..0ae25d2652a2 100644 --- a/src/renderer/src/lib/empty-workspace-default-agent-chat.test.ts +++ b/src/renderer/src/lib/empty-workspace-default-agent-chat.test.ts @@ -34,7 +34,6 @@ function seedSettings(settings: Partial): void { settings: { ...getDefaultSettings('/tmp'), experimentalNativeChat: true, - openAgentTabsInChatByDefault: true, defaultTuiAgent: 'codex', disabledTuiAgents: [], ...settings @@ -82,7 +81,7 @@ describe('openDefaultAgentChatInEmptyWorkspace', () => { }) it('does nothing unless new agent tabs open as chat', () => { - seedSettings({ openAgentTabsInChatByDefault: false }) + seedSettings({ experimentalNativeChat: false }) expect(openDefaultAgentChatInEmptyWorkspace('wt-1', mocks.seedShell)).toBeNull() expect(mocks.launchAgentInNewTab).not.toHaveBeenCalled() @@ -135,7 +134,7 @@ describe('agent detection for the default chat', () => { useAppStore.setState({ remoteDetectedAgentIds: { 'conn-1': [] } }) expect(emptyWorkspaceDefaultChatAwaitsDetection('wt-1')).toBe(false) - seedSettings({ openAgentTabsInChatByDefault: false }) + seedSettings({ experimentalNativeChat: false }) useAppStore.setState({ remoteDetectedAgentIds: {} }) expect(emptyWorkspaceDefaultChatAwaitsDetection('wt-1')).toBe(false) }) diff --git a/src/renderer/src/lib/empty-workspace-default-agent-chat.ts b/src/renderer/src/lib/empty-workspace-default-agent-chat.ts index 79612829b713..ad447daa4809 100644 --- a/src/renderer/src/lib/empty-workspace-default-agent-chat.ts +++ b/src/renderer/src/lib/empty-workspace-default-agent-chat.ts @@ -1,4 +1,4 @@ -import { agentTabsDefaultToNativeChat } from '../../../shared/structured-native-chat-launch-route' +import { isNativeChatEnabled } from '../../../shared/structured-native-chat-launch-route' import { pickTuiAgent } from '../../../shared/tui-agent-selection' import type { TuiAgent } from '../../../shared/tui-agent' import { withTimeout } from '../../../shared/promise-timeout-fallback' @@ -26,10 +26,7 @@ function defaultChatDetectionTarget( worktreeId: string ): AgentDetectionTarget | undefined { // Why: a 'blank' default means the user wants workspaces to open without an agent. - if ( - !agentTabsDefaultToNativeChat(state.settings) || - state.settings?.defaultTuiAgent === 'blank' - ) { + if (!isNativeChatEnabled(state.settings) || state.settings?.defaultTuiAgent === 'blank') { return undefined } // Why: an unresolved owner is an unknown host, not the local machine. diff --git a/src/renderer/src/lib/launch-agent-in-new-tab-host-agents.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab-host-agents.test.ts index 9e23b70db3c6..6616205f6897 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab-host-agents.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab-host-agents.test.ts @@ -12,9 +12,7 @@ const REGISTERED: RuntimeCapability[] = [ STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY ] const SETTINGS = { - experimentalNativeChat: true, - openAgentTabsInChatByDefault: true, - experimentalStructuredNativeChat: true + experimentalNativeChat: true } const mocks = vi.hoisted(() => { @@ -141,7 +139,7 @@ describe('a new tab whose chat route waits on the host agent list', () => { await expect(result?.structuredSettlement).resolves.toEqual({ kind: 'terminal' }) expect(relaunch.mock.calls[0]?.[0]).toMatchObject({ - agentSessionLaunchPlan: { route: 'legacy-native-chat' } + agentSessionLaunchPlan: { route: 'terminal-tui' } }) }) @@ -157,11 +155,11 @@ describe('a new tab whose chat route waits on the host agent list', () => { }) expect( routeNewTabLaunch( - storeWithSettings({ ...SETTINGS, experimentalStructuredNativeChat: false }), + storeWithSettings({ ...SETTINGS, experimentalNativeChat: false }), args, request ) - ).toMatchObject({ plan: { route: 'legacy-native-chat' } }) + ).toMatchObject({ plan: { route: 'terminal-tui' } }) }) }) @@ -237,7 +235,7 @@ describe("a new tab during startup, before this computer's runtime answered", () 'structured-native-chat' ) expect(console.warn).toHaveBeenCalledWith( - expect.stringContaining('claude opens legacy-native-chat: runtime-capability-unknown') + expect.stringContaining('claude opens terminal-tui: runtime-capability-unknown') ) }) }) diff --git a/src/renderer/src/lib/launch-agent-in-new-tab-placement.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab-placement.test.ts index a2c203ac25ab..c150741b7ac0 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab-placement.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab-placement.test.ts @@ -16,8 +16,6 @@ type PlacementSettings = { agentDefaultEnv: Record> activeRuntimeEnvironmentId: string | null experimentalNativeChat?: boolean - experimentalStructuredNativeChat?: boolean - openAgentTabsInChatByDefault?: boolean nativeChatSessionOptions?: Record< string, { model?: string; valuesByModel?: Record> } @@ -108,11 +106,9 @@ describe('launchAgentInNewTab terminal tab activation', () => { } ) - it('honours the chat default in a floating launch and scopes its surface to the floating workspace', async () => { + it('scopes a floating terminal fallback to the floating workspace with Chat UI on', async () => { store.settings = placementSettings({ experimentalNativeChat: true, - experimentalStructuredNativeChat: true, - openAgentTabsInChatByDefault: true, nativeChatSessionOptions: { codex: { model: 'gpt-5.2-codex', @@ -134,18 +130,14 @@ describe('launchAgentInNewTab terminal tab activation', () => { undefined, { launchAgent: 'codex', - viewMode: 'chat' + quickCommandLabel: undefined } ) expect(mockSetActiveTabType).toHaveBeenCalledExactlyOnceWith( 'terminal', FLOATING_TERMINAL_WORKTREE_ID ) - // Why: the panel hosts the chat pane itself, so the launch carries the user's model/effort - // preferences the same way a main-window launch does. - expect(mockSeedNativeChatAppliedSessionOptions).toHaveBeenCalledWith('tab-1', 'codex', { - model: 'gpt-5.2-codex', - effort: 'medium' - }) + // Chat UI model preferences belong to structured chats, not a terminal fallback. + expect(mockQueueTabStartupCommand.mock.calls[0]?.[1]).not.toHaveProperty('sessionOptions') }) }) diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts index e9ad2fba3731..fa5ff2a1aecc 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts @@ -34,8 +34,6 @@ const store = { activeRuntimeEnvironmentId: string | null terminalWindowsShell?: string experimentalNativeChat?: boolean - experimentalStructuredNativeChat?: boolean - openAgentTabsInChatByDefault?: boolean nativeChatSessionOptions?: Record< string, { model?: string; valuesByModel?: Record> } @@ -241,9 +239,7 @@ describe('launchAgentInNewTab', () => { agentDefaultArgs: {}, agentDefaultEnv: {}, activeRuntimeEnvironmentId: null, - experimentalNativeChat: true, - experimentalStructuredNativeChat: true, - openAgentTabsInChatByDefault: true + experimentalNativeChat: true } const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') @@ -257,7 +253,7 @@ describe('launchAgentInNewTab', () => { expect(mockCreateTab).toHaveBeenCalledWith('wt-1', undefined, undefined, { launchAgent: 'codex', - viewMode: 'chat' + quickCommandLabel: undefined }) expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( 'tab-1', @@ -280,9 +276,7 @@ describe('launchAgentInNewTab', () => { agentDefaultArgs: {}, agentDefaultEnv: {}, activeRuntimeEnvironmentId: null, - experimentalNativeChat: true, - experimentalStructuredNativeChat: true, - openAgentTabsInChatByDefault: true + experimentalNativeChat: true } const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') @@ -296,8 +290,7 @@ describe('launchAgentInNewTab', () => { expect(mockCreateTab).toHaveBeenCalledWith('wt-1', undefined, undefined, { launchAgent: 'grok', - quickCommandLabel: undefined, - viewMode: 'chat' + quickCommandLabel: undefined }) expect(mockSeedNativeChatLaunchPrompt).toHaveBeenCalledWith({ tabId: 'tab-1', @@ -313,9 +306,7 @@ describe('launchAgentInNewTab', () => { agentDefaultArgs: {}, agentDefaultEnv: {}, activeRuntimeEnvironmentId: null, - experimentalNativeChat: true, - experimentalStructuredNativeChat: true, - openAgentTabsInChatByDefault: true + experimentalNativeChat: true } store.repos = [{ id: 'repo-1', connectionId: 'ssh-target-1', path: '/repo' }] const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') @@ -334,9 +325,7 @@ describe('launchAgentInNewTab', () => { agentDefaultArgs: {}, agentDefaultEnv: {}, activeRuntimeEnvironmentId: null, - experimentalNativeChat: true, - experimentalStructuredNativeChat: true, - openAgentTabsInChatByDefault: true + experimentalNativeChat: true } const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') @@ -357,18 +346,16 @@ describe('launchAgentInNewTab', () => { text: 'https://github.com/o/r/issues/12' }) ) - expect(mockCreateTab.mock.calls[0]?.[3]).toHaveProperty('viewMode', 'chat') + expect(mockCreateTab.mock.calls[0]?.[3]).not.toHaveProperty('viewMode') }) - it('mirrors a multi-line draft into chat and opens the tab there', async () => { + it('mirrors a multi-line draft into chat without opening the tab there', async () => { store.settings = { agentCmdOverrides: {}, agentDefaultArgs: {}, agentDefaultEnv: {}, activeRuntimeEnvironmentId: null, - experimentalNativeChat: true, - experimentalStructuredNativeChat: true, - openAgentTabsInChatByDefault: true + experimentalNativeChat: true } const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') @@ -384,7 +371,7 @@ describe('launchAgentInNewTab', () => { expect(mockSeedNativeChatLaunchDraft).toHaveBeenCalledWith( expect.objectContaining({ tabId: 'tab-1', agent: 'claude', text: prompt }) ) - expect(mockCreateTab.mock.calls[0]?.[3]).toHaveProperty('viewMode', 'chat') + expect(mockCreateTab.mock.calls[0]?.[3]).not.toHaveProperty('viewMode') }) it('passes quick command labels only to locally-created agent tabs', async () => { @@ -409,9 +396,7 @@ describe('launchAgentInNewTab', () => { agentDefaultArgs: { codex: '--profile team' }, agentDefaultEnv: {}, activeRuntimeEnvironmentId: null, - experimentalNativeChat: true, - experimentalStructuredNativeChat: true, - openAgentTabsInChatByDefault: false, + experimentalNativeChat: false, nativeChatSessionOptions: { codex: { model: 'gpt-5.2-codex', @@ -437,15 +422,13 @@ describe('launchAgentInNewTab', () => { expect(launch.sessionOptions).toBeUndefined() }) - it('applies native-chat model preferences to Quick Commands opened in chat', async () => { + it('keeps Chat UI model preferences out of a terminal fallback launch', async () => { store.settings = { agentCmdOverrides: {}, agentDefaultArgs: {}, agentDefaultEnv: {}, activeRuntimeEnvironmentId: null, experimentalNativeChat: true, - experimentalStructuredNativeChat: true, - openAgentTabsInChatByDefault: true, nativeChatSessionOptions: { codex: { model: 'gpt-5.2-codex', @@ -465,19 +448,20 @@ describe('launchAgentInNewTab', () => { }) const launch = mockQueueTabStartupCommand.mock.calls[0]?.[1] - expect(launch.command).toContain("'-m' 'gpt-5.2-codex'") - expect(launch.command).toContain("'-c' 'model_reasoning_effort=medium'") - expect(launch.sessionOptions).toEqual({ model: 'gpt-5.2-codex', effort: 'medium' }) + expect(launch.command).not.toContain("'-m'") + expect(launch.command).not.toContain('model_reasoning_effort=') + expect(launch.sessionOptions).toBeUndefined() expect(mockCreateTab).toHaveBeenCalledWith( 'wt-1', undefined, undefined, - expect.objectContaining({ viewMode: 'chat' }) + expect.objectContaining({ launchAgent: 'codex' }) ) + expect(mockCreateTab.mock.calls[0]?.[3]).not.toHaveProperty('viewMode') expect(mockSetTabViewMode).not.toHaveBeenCalled() }) - it('preserves paired-host draft delivery and supported launch preferences', async () => { + it('preserves paired-host draft delivery without Chat UI launch preferences', async () => { mockIsWebRuntimeSessionActive.mockReturnValue(true) store.settings = { agentCmdOverrides: {}, @@ -485,8 +469,6 @@ describe('launchAgentInNewTab', () => { agentDefaultEnv: {}, activeRuntimeEnvironmentId: 'web-runtime', experimentalNativeChat: true, - experimentalStructuredNativeChat: true, - openAgentTabsInChatByDefault: true, nativeChatSessionOptions: { claude: { model: 'opus', @@ -513,25 +495,25 @@ describe('launchAgentInNewTab', () => { prompt: 'review before sending', promptDelivery: 'draft', agentArgs: '--permission-mode plan', - launchPreferences: { model: 'opus', effort: 'high' }, agent: 'claude', launchDraft: 'review before sending' }) ) + expect( + mockCreateWebRuntimeAgentSessionTerminalWithLaunchDraft.mock.calls[0]?.[0] + ).not.toHaveProperty('launchPreferences') expect(mockCreateWebRuntimeSessionTerminal).not.toHaveBeenCalled() expect(mockCreateTab).not.toHaveBeenCalled() }) - it('propagates the default chat mode to paired web runtime launches', async () => { + it('propagates terminal UI to paired web runtime fallbacks', async () => { mockIsWebRuntimeSessionActive.mockReturnValue(true) store.settings = { agentCmdOverrides: {}, agentDefaultArgs: {}, agentDefaultEnv: {}, activeRuntimeEnvironmentId: 'web-runtime', - experimentalNativeChat: true, - experimentalStructuredNativeChat: true, - openAgentTabsInChatByDefault: true + experimentalNativeChat: true } const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') @@ -543,7 +525,7 @@ describe('launchAgentInNewTab', () => { environmentId: 'web-runtime', agentSessionKind: 'fresh', agent: 'codex', - viewMode: 'chat' + viewMode: 'terminal' }) ) }) @@ -555,9 +537,7 @@ describe('launchAgentInNewTab', () => { agentDefaultArgs: {}, agentDefaultEnv: {}, activeRuntimeEnvironmentId: 'web-runtime', - experimentalNativeChat: true, - experimentalStructuredNativeChat: true, - openAgentTabsInChatByDefault: false + experimentalNativeChat: true } const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index 97e5b1071e95..16047919930d 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -9,8 +9,6 @@ import { launchNewTabPromptThroughHost, newTabPromptLaunchesThroughHost } from '@/lib/launch-agent-new-tab-host-route' -import { initialAgentTabViewModeProps } from '@/lib/native-chat-initial-view-mode' -import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { isWebRuntimeSessionActive } from '@/runtime/web-runtime-session' import { launchAgentInWebHostTab } from '@/lib/launch-agent-web-host-tab' @@ -22,8 +20,6 @@ import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' import type { TuiAgent } from '../../../shared/tui-agent' import type { LaunchSource } from '../../../shared/telemetry-events' import { resolveAgentLaunchExecutionContext } from '@/lib/launch-agent-execution-context' -import { resolveInitialNativeChatSessionOptions } from '@/components/native-chat/native-chat-launch-session-options' -import { seedNativeChatAppliedSessionOptions } from '@/components/native-chat/native-chat-session-option-cache' import { launchStructuredAgentFromNewTab } from '@/lib/launch-agent-in-new-tab-structured-route' import type { StructuredAgentLaunchSettlement } from '@/lib/structured-agent-launch-settlement' import type { StructuredLaunchTerminal } from '@/lib/structured-agent-session-launch-admission' @@ -134,11 +130,13 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent activate } = args const store = useAppStore.getState() - const { worktreeSshConnectionId, resolvedLaunchPlatform, isRemote, queuedShell } = - resolveAgentLaunchExecutionContext(store, { + const { resolvedLaunchPlatform, isRemote, queuedShell } = resolveAgentLaunchExecutionContext( + store, + { worktreeId, ...(launchPlatform ? { launchPlatform } : {}) - }) + } + ) const cmdOverrides = store.settings?.agentCmdOverrides ?? {} const effectiveAgentArgs = agentArgs !== undefined @@ -149,17 +147,9 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent const hasPrompt = trimmedPrompt.length > 0 const isFollowupPath = TUI_AGENT_CONFIG[agent].promptInjectionMode === 'stdin-after-start' const workspaceKind = workspaceKindForWorktreeId(worktreeId) - // Why: the remote host can't infer this client's draft/default view choice, so decide it here for paired tabs too. - const viewModePromptDelivery = + // Why: a followup-path agent gets its prompt pasted unsubmitted after start, so route it as a draft. + const routePromptDelivery = hasPrompt && isFollowupPath && promptDelivery === 'auto-submit' ? 'draft' : promptDelivery - const initialViewModeOptions = { - agent, - promptDelivery: viewModePromptDelivery, - launchDraftText: trimmedPrompt, - nativeChatTranscriptIsLocalReadable: - isNativeChatTranscriptLocalReadable(worktreeSshConnectionId) - } - const initialViewModeProps = initialAgentTabViewModeProps(store.settings, initialViewModeOptions) const startupPlanBase = { agent, cmdOverrides, @@ -167,8 +157,7 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent shell: queuedShell, isRemote, agentArgs: effectiveAgentArgs, - agentEnv, - sessionOptions: resolveInitialNativeChatSessionOptions(store.settings, initialViewModeOptions) + agentEnv } const { startupPlan, pasteDraftAfterLaunch, submitPastedPrompt } = planLaunchAgentStartupPrompt({ base: startupPlanBase, @@ -188,9 +177,8 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent agent, workspace: { kind: workspaceKind, worktreeId }, prompt: trimmedPrompt, - promptDelivery: viewModePromptDelivery, + promptDelivery: routePromptDelivery, tuiCustomization: { cwd: initialCwd }, - initialSessionOptions: startupPlan.sessionOptions, onPromptDelivered, ...(args.promptKeptByCaller ? { promptKeptByCaller: true as const } : {}) }) @@ -236,7 +224,7 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent agentArgs, // Why: omission means terminal locally, but would let a paired host apply // its own default; send the client's resolved terminal choice explicitly. - viewMode: initialViewModeProps.viewMode ?? 'terminal', + viewMode: 'terminal', onPromptDelivered }) return { @@ -263,12 +251,10 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent prompt: trimmedPrompt, ...(agentArgs !== undefined ? { agentArgs } : {}), ...(initialCwd?.trim() ? { cwd: initialCwd } : {}), - ...(startupPlan.sessionOptions ? { sessionOptions: startupPlan.sessionOptions } : {}), // The same source main's window stamps on its own launches. launchSource: launchSource ?? 'tab_bar_quick_launch', quickCommandLabel, ...(pendingActivationSpawn ? { pendingActivationSpawn: true } : {}), - ...(initialViewModeProps.viewMode ? { viewMode: initialViewModeProps.viewMode } : {}), pasteContent: pasteDraftAfterLaunch, submit: submitPastedPrompt, ...(onPromptDelivered ? { onPromptDelivered } : {}), @@ -282,15 +268,12 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent } } // Why: queue startup BEFORE TerminalPane mounts — it snapshots pendingStartupByTabId in useState on first render. - // Why: followup path pastes an unsubmitted draft, so gate the initial chat view like a draft launch, not auto-submit. const tab = store.createTab(worktreeId, groupId, undefined, { launchAgent: agent, quickCommandLabel, ...(pendingActivationSpawn ? { pendingActivationSpawn: true } : {}), - ...(activate === false ? { activate: false } : {}), - ...initialViewModeProps + ...(activate === false ? { activate: false } : {}) }) - seedNativeChatAppliedSessionOptions(tab.id, agent, startupPlan.sessionOptions) if (initialCwd?.trim()) { // Why: queue before mount so local, WSL, and SSH continuations preserve their subdirectory. store.queueTabInitialCwd(tab.id, initialCwd) @@ -301,7 +284,6 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent launchConfig: startupPlan.launchConfig, launchAgent: agent, ...(agentArgs !== undefined ? { agentArgsOverride: agentArgs } : {}), - ...(startupPlan.sessionOptions ? { sessionOptions: startupPlan.sessionOptions } : {}), ...(startupPlan.startupCommandDelivery ? { startupCommandDelivery: startupPlan.startupCommandDelivery } : {}), diff --git a/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts b/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts index 2d909918b444..c1fcc5b5b074 100644 --- a/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts +++ b/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts @@ -82,8 +82,6 @@ const store = { agentDefaultEnv: {}, activeRuntimeEnvironmentId: null, experimentalNativeChat: true, - experimentalStructuredNativeChat: true, - openAgentTabsInChatByDefault: true, nativeChatSessionOptions: undefined as | Record< string, @@ -178,9 +176,8 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({ : mockCallStructuredAgentSession(target, method, params) })) -/** Structured adoption creates the tab in terminal mode and flips it to chat once - * Codex is ready; the bridge stamps `viewMode: 'chat'` on the tab up front. That - * difference is the only observable signal that the availability guard ran. */ +/** Structured adoption creates a terminal tab and flips it to chat once Codex is ready. + * The absence of an initial chat mode shows that the availability guard ran. */ describe('structured chat adoption guard on the launch path', () => { beforeEach(() => { vi.clearAllMocks() @@ -247,7 +244,7 @@ describe('structured chat adoption guard on the launch path', () => { mockCreateSupport.mockResolvedValue({ supported: true }) mockToastError.mockReset() hostCapabilities = STRUCTURED_HOST_CAPABILITIES - store.settings.openAgentTabsInChatByDefault = true + store.settings.experimentalNativeChat = true store.settings.nativeChatSessionOptions = undefined }) @@ -409,7 +406,7 @@ describe('structured chat adoption guard on the launch path', () => { /** The toggle is hidden under Terminal chat but its persisted value survives, so the launch * path must re-check the default view rather than trust a stale opt-in. */ it('ignores a stale structured opt-in while the default view is Terminal chat', async () => { - store.settings.openAgentTabsInChatByDefault = false + store.settings.experimentalNativeChat = false const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') const result = launchAgentInNewTab({ @@ -675,10 +672,13 @@ describe('structured chat adoption guard on the launch path', () => { launchAgentInNewTab({ requestId: 'request-18', agent: 'codex', worktreeId: 'wt-1' }) - expect(mockCreateTab).toHaveBeenCalledWith('wt-1', undefined, undefined, { - launchAgent: 'codex', - viewMode: 'chat' - }) + expect(mockCreateTab).toHaveBeenCalledWith( + 'wt-1', + undefined, + undefined, + expect.objectContaining({ launchAgent: 'codex' }) + ) + expect(mockCreateTab.mock.calls[0]?.[3]).not.toHaveProperty('viewMode') expect(mockWaitForAgentReady).not.toHaveBeenCalled() }) @@ -688,10 +688,13 @@ describe('structured chat adoption guard on the launch path', () => { launchAgentInNewTab({ requestId: 'request-19', agent: 'codex', worktreeId: 'wt-1' }) - expect(mockCreateTab).toHaveBeenCalledWith('wt-1', undefined, undefined, { - launchAgent: 'codex', - viewMode: 'chat' - }) + expect(mockCreateTab).toHaveBeenCalledWith( + 'wt-1', + undefined, + undefined, + expect.objectContaining({ launchAgent: 'codex' }) + ) + expect(mockCreateTab.mock.calls[0]?.[3]).not.toHaveProperty('viewMode') expect(mockWaitForAgentReady).not.toHaveBeenCalled() }) }) diff --git a/src/renderer/src/lib/launch-work-item-direct-agent-routing.test.ts b/src/renderer/src/lib/launch-work-item-direct-agent-routing.test.ts index f92407a8fa82..8cb614a293d7 100644 --- a/src/renderer/src/lib/launch-work-item-direct-agent-routing.test.ts +++ b/src/renderer/src/lib/launch-work-item-direct-agent-routing.test.ts @@ -72,7 +72,7 @@ describe('beginDirectWorkItemStructuredLaunch', () => { beginDirectWorkItemStructuredLaunch({ plan: adoptAgentSessionLaunchVerdict({ ...structuredPlan, - route: 'legacy-native-chat' + route: 'terminal-tui' }), primaryTabId: null, beforeOpen: vi.fn() diff --git a/src/renderer/src/lib/launch-work-item-direct-agent-routing.ts b/src/renderer/src/lib/launch-work-item-direct-agent-routing.ts index b1a0ac294fc2..40e04c61e5c8 100644 --- a/src/renderer/src/lib/launch-work-item-direct-agent-routing.ts +++ b/src/renderer/src/lib/launch-work-item-direct-agent-routing.ts @@ -4,7 +4,6 @@ import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' import { isTuiAgentEnabled, pickTuiAgent } from '../../../shared/tui-agent-selection' import { buildDirectWorkItemAgentStartupPlan } from '@/lib/launch-work-item-direct-agent' import type { AgentSessionLaunchPlan } from '@/lib/agent-session-launch-plan' -import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' import { resolveSourceControlLaunchPlatform } from '@/lib/source-control-launch-platform' import { beginStructuredAgentSessionProvisionalLaunch } from '@/lib/structured-agent-session-provisional-tab' import type { DeclinedStructuredLaunchTerminalOptions } from '@/lib/structured-agent-session-launch-admission' @@ -34,9 +33,6 @@ export function buildDirectWorkItemStartup(args: { promptDelivery: args.promptDelivery, settings: args.settings, launchPlatform, - nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable( - args.launchConnectionId - ), // Why: SSH hosts run the plain `orca` shim, so the Linux-only `orca-ide` rename is not applied. isRemote: typeof args.launchConnectionId === 'string' }) diff --git a/src/renderer/src/lib/launch-work-item-direct-agent.test.ts b/src/renderer/src/lib/launch-work-item-direct-agent.test.ts index b5358ec6b3a5..3b9fd6146359 100644 --- a/src/renderer/src/lib/launch-work-item-direct-agent.test.ts +++ b/src/renderer/src/lib/launch-work-item-direct-agent.test.ts @@ -46,9 +46,7 @@ describe('buildDirectWorkItemStartupOpts', () => { }) }) - it('carries launchDraftText for a natively-prefilled draft launch', () => { - // Why: the draft is already inside launchCommand, so draftPrompt stays unset - // and launchDraftText is the only signal the view-mode gate can read. + it('keeps a natively-prefilled draft in the terminal command', () => { const plan: AgentStartupPlan = { agent: 'claude', launchCommand: "claude --prefill 'https://github.com/o/r/issues/12'", @@ -57,15 +55,10 @@ describe('buildDirectWorkItemStartupOpts', () => { launchConfig: { agentArgs: '', agentEnv: {} } } - const opts = buildDirectWorkItemStartupOpts( - 'claude', - plan, - 'task_page', - 'https://github.com/o/r/issues/12' - ) + const opts = buildDirectWorkItemStartupOpts('claude', plan, 'task_page') expect(opts.startup?.draftPrompt).toBeUndefined() - expect(opts.startup?.launchDraftText).toBe('https://github.com/o/r/issues/12') + expect(opts.startup?.command).toContain('https://github.com/o/r/issues/12') }) }) @@ -83,36 +76,19 @@ const settings = { } describe('buildDirectWorkItemAgentStartupPlan', () => { - it('omits native-chat preferences when the new workspace opens in terminal mode', () => { + it('keeps Chat UI model preferences out of the terminal startup', () => { const result = buildDirectWorkItemAgentStartupPlan({ agent: 'codex', draftContent: 'Review issue 42', promptDelivery: 'draft', - settings: { ...settings, openAgentTabsInChatByDefault: false }, - launchPlatform: 'darwin', - nativeChatTranscriptIsLocalReadable: true + settings, + launchPlatform: 'darwin' }) expect(result.startupPlan?.launchCommand).not.toContain("'-m'") + expect(result.startupPlan?.launchCommand).not.toContain('model_reasoning_effort=') expect(result.startupPlan?.sessionOptions).toBeUndefined() }) - - it('applies native-chat preferences when the new workspace opens in chat', () => { - const result = buildDirectWorkItemAgentStartupPlan({ - agent: 'codex', - draftContent: 'Review issue 42', - promptDelivery: 'draft', - settings: { ...settings, openAgentTabsInChatByDefault: true }, - launchPlatform: 'darwin', - nativeChatTranscriptIsLocalReadable: true - }) - - expect(result.startupPlan?.launchCommand).toContain("'-m' 'gpt-5.2-codex'") - expect(result.startupPlan?.sessionOptions).toEqual({ - model: 'gpt-5.2-codex', - effort: 'medium' - }) - }) }) describe('notifyDirectWorkItemAgentStartTimeout', () => { @@ -141,7 +117,6 @@ describe('notifyDirectWorkItemAgentStartTimeout', () => { describe('buildDirectWorkItemAgentStartupPlan global arguments fallback', () => { const withGlobalArgs = { ...settings, - openAgentTabsInChatByDefault: false, agentDefaultArgs: { codex: '--sandbox danger-full-access' } } @@ -151,8 +126,7 @@ describe('buildDirectWorkItemAgentStartupPlan global arguments fallback', () => draftContent: 'Fix the broken checks', promptDelivery: 'draft', settings: withGlobalArgs, - launchPlatform: 'darwin', - nativeChatTranscriptIsLocalReadable: true + launchPlatform: 'darwin' }) expect(result.startupPlan?.launchCommand).toContain("'--sandbox' 'danger-full-access'") @@ -165,8 +139,7 @@ describe('buildDirectWorkItemAgentStartupPlan global arguments fallback', () => draftContent: 'Fix the broken checks', promptDelivery: 'draft', settings: withGlobalArgs, - launchPlatform: 'darwin', - nativeChatTranscriptIsLocalReadable: true + launchPlatform: 'darwin' }) expect(result.startupPlan?.launchCommand).toContain("'--model' 'gpt-5'") diff --git a/src/renderer/src/lib/launch-work-item-direct-agent.ts b/src/renderer/src/lib/launch-work-item-direct-agent.ts index e5595c0674be..b9175034e7b3 100644 --- a/src/renderer/src/lib/launch-work-item-direct-agent.ts +++ b/src/renderer/src/lib/launch-work-item-direct-agent.ts @@ -15,8 +15,6 @@ import { resolveTuiAgentLaunchEnv } from '../../../shared/tui-agent-launch-defaults' import { translate } from '@/i18n/i18n' -import { resolveInitialNativeChatSessionOptions } from '@/components/native-chat/native-chat-launch-session-options' -import type { PersistedNativeChatSessionOptions } from '../../../shared/native-chat-session-options' export function buildDirectWorkItemAgentStartupPlan(args: { agent: TuiAgent | null @@ -28,14 +26,10 @@ export function buildDirectWorkItemAgentStartupPlan(args: { agentCmdOverrides?: Partial> agentDefaultArgs?: Partial> agentDefaultEnv?: Partial>> - experimentalNativeChat?: boolean - openAgentTabsInChatByDefault?: boolean - nativeChatSessionOptions?: PersistedNativeChatSessionOptions } | null | undefined launchPlatform: NodeJS.Platform - nativeChatTranscriptIsLocalReadable?: boolean /** Why: SSH remotes deploy the CLI shim as plain `orca`, so the Linux-only * `orca-ide` rename must not be applied for remote launches. */ isRemote?: boolean @@ -53,13 +47,6 @@ export function buildDirectWorkItemAgentStartupPlan(args: { ? resolveTuiAgentLaunchArgs(args.agent, args.settings?.agentDefaultArgs) : args.agentArgs const effectiveAgentEnv = resolveTuiAgentLaunchEnv(args.agent, args.settings?.agentDefaultEnv) - const sessionOptions = resolveInitialNativeChatSessionOptions(args.settings, { - agent: args.agent, - ...(args.promptDelivery === 'draft' - ? { promptDelivery: 'draft' as const, launchDraftText: args.draftContent } - : {}), - nativeChatTranscriptIsLocalReadable: args.nativeChatTranscriptIsLocalReadable - }) const draftLaunchPlan = args.promptDelivery === 'submit-after-ready' ? null @@ -70,8 +57,7 @@ export function buildDirectWorkItemAgentStartupPlan(args: { platform: args.launchPlatform, isRemote: args.isRemote, agentArgs: effectiveAgentArgs, - agentEnv: effectiveAgentEnv, - sessionOptions + agentEnv: effectiveAgentEnv }) if (draftLaunchPlan) { @@ -82,9 +68,6 @@ export function buildDirectWorkItemAgentStartupPlan(args: { expectedProcess: draftLaunchPlan.expectedProcess, followupPrompt: null, launchConfig: draftLaunchPlan.launchConfig, - ...(draftLaunchPlan.sessionOptions - ? { sessionOptions: draftLaunchPlan.sessionOptions } - : {}), ...(draftLaunchPlan.startupCommandDelivery ? { startupCommandDelivery: draftLaunchPlan.startupCommandDelivery } : {}), @@ -103,7 +86,6 @@ export function buildDirectWorkItemAgentStartupPlan(args: { isRemote: args.isRemote, agentArgs: effectiveAgentArgs, agentEnv: effectiveAgentEnv, - sessionOptions, allowEmptyPromptLaunch: true }) if (startupPlan && args.promptDelivery === 'draft') { @@ -119,10 +101,7 @@ export function buildDirectWorkItemAgentStartupPlan(args: { export function buildDirectWorkItemStartupOpts( agent: TuiAgent | null, plan: AgentStartupPlan | null, - launchSource: LaunchSource, - /** Unsent launch context, for the view-mode decision only. Set it for every - * draft launch — a natively-prefilled plan carries no `draftPrompt`. */ - launchDraftText?: string + launchSource: LaunchSource ): { startup?: { command: string @@ -130,7 +109,6 @@ export function buildDirectWorkItemStartupOpts( launchConfig?: SleepingAgentLaunchConfig launchAgent?: TuiAgent draftPrompt?: string - launchDraftText?: string sessionOptions?: AgentStartupPlan['sessionOptions'] startupCommandDelivery?: StartupCommandDelivery telemetry?: AgentStartedTelemetry @@ -151,7 +129,6 @@ export function buildDirectWorkItemStartupOpts( ...(plan.sessionOptions ? { sessionOptions: plan.sessionOptions } : {}), ...(agent ? { launchAgent: agent } : {}), ...(plan.draftPrompt ? { draftPrompt: plan.draftPrompt } : {}), - ...(launchDraftText ? { launchDraftText } : {}), ...(plan.startupCommandDelivery ? { startupCommandDelivery: plan.startupCommandDelivery } : {}), diff --git a/src/renderer/src/lib/launch-work-item-direct-route-preparation.ts b/src/renderer/src/lib/launch-work-item-direct-route-preparation.ts index 3dddd4276b45..a57c1318062e 100644 --- a/src/renderer/src/lib/launch-work-item-direct-route-preparation.ts +++ b/src/renderer/src/lib/launch-work-item-direct-route-preparation.ts @@ -100,8 +100,7 @@ export async function prepareDirectWorkItemAgentLaunch(args: { agent: effectiveAgent, workspace: { kind: 'git-worktree', worktreeId: args.worktreeId, repoId: args.repoId }, prompt: args.draftContent, - promptDelivery: args.promptDelivery, - initialSessionOptions: startupPlan?.sessionOptions + promptDelivery: args.promptDelivery }) const structuredLaunch = plan?.route === 'structured-native-chat' diff --git a/src/renderer/src/lib/launch-work-item-direct.test.ts b/src/renderer/src/lib/launch-work-item-direct.test.ts index 92e625db5290..333c4ea3b4fe 100644 --- a/src/renderer/src/lib/launch-work-item-direct.test.ts +++ b/src/renderer/src/lib/launch-work-item-direct.test.ts @@ -410,7 +410,6 @@ describe('launchWorkItemDirect', () => { cmdOverrides: {}, agentArgs: '--dangerously-skip-permissions', agentEnv: {}, - sessionOptions: undefined, platform: 'win32', isRemote: false }) @@ -465,11 +464,8 @@ describe('launchWorkItemDirect', () => { }) expect(mocks.seedNativeChatLaunchPrompt).not.toHaveBeenCalled() // Why: the draft is inside `--prefill`, so the plan sets no draftPrompt. - // launchDraftText is the only thing that lets the view-mode gate see a - // draft here — without it this tab opens in chat unconditionally. const startup = mocks.activateAndRevealWorktree.mock.calls.at(-1)?.[1]?.startup expect(startup?.draftPrompt).toBeUndefined() - expect(startup?.launchDraftText).toBe('https://github.com/acme/repo/issues/12') }) it('seeds the chat-composer launch draft for a multi-line Linear draft launch', async () => { @@ -672,7 +668,6 @@ describe('launchWorkItemDirect', () => { cmdOverrides: {}, agentArgs: '--yolo', agentEnv: {}, - sessionOptions: undefined, platform: 'linux', isRemote: true }) @@ -682,7 +677,6 @@ describe('launchWorkItemDirect', () => { cmdOverrides: {}, agentArgs: '--yolo', agentEnv: {}, - sessionOptions: undefined, platform: 'linux', isRemote: true, allowEmptyPromptLaunch: true diff --git a/src/renderer/src/lib/launch-work-item-direct.ts b/src/renderer/src/lib/launch-work-item-direct.ts index dba04c50c1f8..debf385c3be8 100644 --- a/src/renderer/src/lib/launch-work-item-direct.ts +++ b/src/renderer/src/lib/launch-work-item-direct.ts @@ -242,12 +242,7 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom defaultTabs: result.defaultTabs, ...(launchPreparation.structuredLaunch ? { providesInitialSurface: true } - : buildDirectWorkItemStartupOpts( - effectiveAgent, - startupPlan, - launchSource, - promptDelivery === 'draft' ? draftContent : undefined - )) + : buildDirectWorkItemStartupOpts(effectiveAgent, startupPlan, launchSource)) }) return activationHolder.value !== false } diff --git a/src/renderer/src/lib/native-chat-initial-view-mode.test.ts b/src/renderer/src/lib/native-chat-initial-view-mode.test.ts deleted file mode 100644 index 52385894c034..000000000000 --- a/src/renderer/src/lib/native-chat-initial-view-mode.test.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { describe, it, expect } from 'vitest' -import type { Tab } from '../../../shared/tab-types' -import { - decideInitialAgentTabViewMode, - initialAgentTabViewModeProps -} from './native-chat-initial-view-mode' -import { isNativeChatTranscriptLocalReadable } from './native-chat-transcript-readability' - -describe('decideInitialAgentTabViewMode', () => { - it("returns 'chat' when native chat and the opt-in default setting are on", () => { - expect( - decideInitialAgentTabViewMode({ - experimentalNativeChat: true, - openAgentTabsInChatByDefault: true, - agent: 'codex' - }) - ).toBe('chat') - }) - - it('returns undefined when native chat is disabled', () => { - expect( - decideInitialAgentTabViewMode({ - experimentalNativeChat: false, - openAgentTabsInChatByDefault: true, - agent: 'codex' - }) - ).toBeUndefined() - }) - - it('returns undefined when the default-chat setting is off', () => { - expect( - decideInitialAgentTabViewMode({ - experimentalNativeChat: true, - openAgentTabsInChatByDefault: false, - agent: 'codex' - }) - ).toBeUndefined() - }) - - it('returns undefined when the setting is missing (legacy settings)', () => { - expect( - decideInitialAgentTabViewMode({ - experimentalNativeChat: true, - openAgentTabsInChatByDefault: undefined, - agent: 'codex' - }) - ).toBeUndefined() - }) - - it.each(['gemini'] as const)('keeps unsupported agent %s in terminal view', (agent) => { - expect( - decideInitialAgentTabViewMode({ - experimentalNativeChat: true, - openAgentTabsInChatByDefault: true, - agent - }) - ).toBeUndefined() - }) - - it.each([ - ['local', null], - ['runtime-owned', 'runtime-ssh-env-1'] - ] as const)('opens %s Grok in chat when configured', (_host, connectionId) => { - expect( - decideInitialAgentTabViewMode({ - experimentalNativeChat: true, - openAgentTabsInChatByDefault: true, - agent: 'grok', - nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable(connectionId) - }) - ).toBe('chat') - }) - - it('keeps Model-A SSH omp in the terminal view but opens it locally', () => { - const forConnection = (connectionId: string | null): Tab['viewMode'] => - decideInitialAgentTabViewMode({ - experimentalNativeChat: true, - openAgentTabsInChatByDefault: true, - agent: 'omp', - nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable(connectionId) - }) - expect(forConnection('ssh-target-1')).toBeUndefined() - expect(forConnection(null)).toBe('chat') - }) - - it('keeps Model-A SSH Grok in the terminal view', () => { - expect( - decideInitialAgentTabViewMode({ - experimentalNativeChat: true, - openAgentTabsInChatByDefault: true, - agent: 'grok', - nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable('ssh-target-1') - }) - ).toBeUndefined() - expect( - initialAgentTabViewModeProps( - { - experimentalNativeChat: true, - openAgentTabsInChatByDefault: true - }, - { agent: 'grok', nativeChatTranscriptIsLocalReadable: false } - ) - ).toEqual({}) - }) - - it('opens a mirrorable draft launch in chat', () => { - expect( - decideInitialAgentTabViewMode({ - experimentalNativeChat: true, - openAgentTabsInChatByDefault: true, - agent: 'claude', - promptDelivery: 'draft', - launchDraftText: 'https://github.com/o/r/issues/12' - }) - ).toBe('chat') - }) - - it.each([ - ['multi-line', 'Reproduce first\n\nhttps://github.com/o/r/issues/12'], - ['trailing-newline', 'https://github.com/o/r/issues/12\n'] - ])('opens a %s draft in chat with its mirrored composer text', (_label, launchDraftText) => { - expect( - decideInitialAgentTabViewMode({ - experimentalNativeChat: true, - openAgentTabsInChatByDefault: true, - agent: 'claude', - promptDelivery: 'draft', - launchDraftText - }) - ).toBe('chat') - }) - - it.each([ - ['Unicode-line-separator', 'one\u2028two'], - ['blank', ' '], - ['absent', undefined] - ])('keeps a %s draft in the terminal, where its text actually is', (_label, launchDraftText) => { - expect( - decideInitialAgentTabViewMode({ - experimentalNativeChat: true, - openAgentTabsInChatByDefault: true, - agent: 'claude', - promptDelivery: 'draft', - ...(launchDraftText === undefined ? {} : { launchDraftText }) - }) - ).toBeUndefined() - }) - - it('returns tab creation props only when chat should be the initial mode', () => { - expect( - initialAgentTabViewModeProps( - { - experimentalNativeChat: true, - openAgentTabsInChatByDefault: true - }, - { agent: 'claude' } - ) - ).toEqual({ viewMode: 'chat' }) - expect( - initialAgentTabViewModeProps( - { - experimentalNativeChat: false, - openAgentTabsInChatByDefault: true - }, - { agent: 'claude' } - ) - ).toEqual({}) - }) -}) diff --git a/src/renderer/src/lib/native-chat-initial-view-mode.ts b/src/renderer/src/lib/native-chat-initial-view-mode.ts deleted file mode 100644 index abc631dc6fa7..000000000000 --- a/src/renderer/src/lib/native-chat-initial-view-mode.ts +++ /dev/null @@ -1,6 +0,0 @@ -// The host derives an agent.launch tab's view by this same rule, so it lives in shared. -export { - decideInitialAgentTabViewMode, - initialAgentTabViewModeProps, - type NativeChatLaunchPromptDelivery -} from '../../../shared/native-chat-initial-view-mode' diff --git a/src/renderer/src/lib/native-chat-launch-draft-mirrorability.test.ts b/src/renderer/src/lib/native-chat-launch-draft-mirrorability.test.ts index be8b09061706..279cadacd673 100644 --- a/src/renderer/src/lib/native-chat-launch-draft-mirrorability.test.ts +++ b/src/renderer/src/lib/native-chat-launch-draft-mirrorability.test.ts @@ -12,7 +12,6 @@ vi.mock('@/store', () => ({ import { seedNativeChatLaunchDraftForAgentTab } from './agent-launch-prompt-delivery' import { canMirrorLaunchDraftToNativeChat } from './native-chat-launch-draft-mirrorability' -import { decideInitialAgentTabViewMode } from './native-chat-initial-view-mode' import { AGENT_TUI_CLEAR_MAX_LINES } from '../../../shared/agent-tui-input-clear' const maxLineDraft = Array.from({ length: AGENT_TUI_CLEAR_MAX_LINES }, () => 'line').join('\n') @@ -39,18 +38,6 @@ const DRAFT_TEXTS = [ '\n' ] -function opensInChat(text: string): boolean { - return ( - decideInitialAgentTabViewMode({ - experimentalNativeChat: true, - openAgentTabsInChatByDefault: true, - agent: 'claude', - promptDelivery: 'draft', - launchDraftText: text - }) === 'chat' - ) -} - function seedsTheComposer(text: string): boolean { mocks.seedNativeChatLaunchDraft.mockClear() seedNativeChatLaunchDraftForAgentTab({ tabId: 'tab-1', agent: 'claude', text }) @@ -62,10 +49,9 @@ describe('launch draft mirrorability', () => { vi.clearAllMocks() }) - it.each(DRAFT_TEXTS)('both sides follow the predicate: %j', (text) => { + it.each(DRAFT_TEXTS)('seeds only mirrorable explicit chat drafts: %j', (text) => { const expected = canMirrorLaunchDraftToNativeChat(text) expect(seedsTheComposer(text)).toBe(expected) - expect(opensInChat(text)).toBe(expected) }) it('accepts CR/LF drafts and rejects unsupported Unicode line separators', () => { @@ -84,8 +70,6 @@ describe('launch draft mirrorability', () => { }) it('withholds the mirror from agents without a native-chat renderer', () => { - // The view mode already returns undefined for these, so the sets still - // agree — but only the seeding side enforces it. expect(seedsTheComposer('https://github.com/o/r/issues/12')).toBe(true) mocks.seedNativeChatLaunchDraft.mockClear() seedNativeChatLaunchDraftForAgentTab({ @@ -94,14 +78,5 @@ describe('launch draft mirrorability', () => { text: 'https://github.com/o/r/issues/12' }) expect(mocks.seedNativeChatLaunchDraft).not.toHaveBeenCalled() - expect( - decideInitialAgentTabViewMode({ - experimentalNativeChat: true, - openAgentTabsInChatByDefault: true, - agent: 'gemini', - promptDelivery: 'draft', - launchDraftText: 'https://github.com/o/r/issues/12' - }) - ).toBeUndefined() }) }) diff --git a/src/renderer/src/lib/native-chat-launch-prompt-delivery.ts b/src/renderer/src/lib/native-chat-launch-prompt-delivery.ts new file mode 100644 index 000000000000..9650c5d06200 --- /dev/null +++ b/src/renderer/src/lib/native-chat-launch-prompt-delivery.ts @@ -0,0 +1 @@ +export type NativeChatLaunchPromptDelivery = 'auto-submit' | 'draft' | 'submit-after-ready' diff --git a/src/renderer/src/lib/onboarding-folder-agent-launch.ts b/src/renderer/src/lib/onboarding-folder-agent-launch.ts index 3ae37582360a..7f18ee3444e6 100644 --- a/src/renderer/src/lib/onboarding-folder-agent-launch.ts +++ b/src/renderer/src/lib/onboarding-folder-agent-launch.ts @@ -28,13 +28,11 @@ export function resolveDismissedOnboardingFolderAgentLaunch(args: { onboarding: OnboardingState | null hasExistingProject: boolean executionHostId: string - nativeChatTranscriptIsLocalReadable?: boolean }): OnboardingFolderAgentLaunch { const startup = buildDismissedOnboardingFolderAgentStartup( args.store.settings ?? null, args.onboarding, - args.hasExistingProject, - args.nativeChatTranscriptIsLocalReadable + args.hasExistingProject ) const agent = startup?.launchAgent ?? null if (!startup || !agent) { @@ -44,8 +42,7 @@ export function resolveDismissedOnboardingFolderAgentLaunch(args: { const plan = planAgentSessionLaunch(args.store, { requestId: newAgentLaunchRequestId(), agent, - workspace: { kind: 'folder', executionHostId: args.executionHostId }, - initialSessionOptions: startup.sessionOptions + workspace: { kind: 'folder', executionHostId: args.executionHostId } }) return { agent, diff --git a/src/renderer/src/lib/onboarding-folder-agent-startup.ts b/src/renderer/src/lib/onboarding-folder-agent-startup.ts index 5dbc893ecbda..6c2e1d8bea5a 100644 --- a/src/renderer/src/lib/onboarding-folder-agent-startup.ts +++ b/src/renderer/src/lib/onboarding-folder-agent-startup.ts @@ -11,8 +11,6 @@ import type { SleepingAgentLaunchConfig } from '../../../shared/agent-session-re import type { GlobalSettings } from '../../../shared/global-settings-types' import type { OnboardingState } from '../../../shared/onboarding-state-types' import type { TuiAgent } from '../../../shared/tui-agent' -import { resolveInitialNativeChatSessionOptions } from '@/components/native-chat/native-chat-launch-session-options' -import type { SessionOptionValue } from '../../../shared/native-chat-session-options' export type OnboardingFolderAgentStartup = { command: string @@ -20,7 +18,6 @@ export type OnboardingFolderAgentStartup = { launchConfig?: SleepingAgentLaunchConfig launchAgent?: TuiAgent startupCommandDelivery?: StartupCommandDelivery - sessionOptions?: Record telemetry: AgentStartedTelemetry } @@ -32,8 +29,7 @@ function getClientPlatform(): NodeJS.Platform { } export function buildOnboardingFolderAgentStartup( - settings: GlobalSettings | null, - nativeChatTranscriptIsLocalReadable = true + settings: GlobalSettings | null ): OnboardingFolderAgentStartup | undefined { const agent = settings?.defaultTuiAgent if ( @@ -51,10 +47,6 @@ export function buildOnboardingFolderAgentStartup( cmdOverrides: settings.agentCmdOverrides ?? {}, agentArgs: resolveTuiAgentLaunchArgs(agent, settings.agentDefaultArgs), agentEnv: resolveTuiAgentLaunchEnv(agent, settings.agentDefaultEnv), - sessionOptions: resolveInitialNativeChatSessionOptions(settings, { - agent, - nativeChatTranscriptIsLocalReadable - }), platform: getClientPlatform(), allowEmptyPromptLaunch: true }) @@ -67,7 +59,6 @@ export function buildOnboardingFolderAgentStartup( ...(startupPlan.env ? { env: startupPlan.env } : {}), launchConfig: startupPlan.launchConfig, launchAgent: agent, - ...(startupPlan.sessionOptions ? { sessionOptions: startupPlan.sessionOptions } : {}), ...(startupPlan.startupCommandDelivery ? { startupCommandDelivery: startupPlan.startupCommandDelivery } : {}), @@ -94,11 +85,10 @@ export function shouldSeedFolderAgentAfterDismissedOnboarding( export function buildDismissedOnboardingFolderAgentStartup( settings: GlobalSettings | null, onboarding: OnboardingState | null, - hasExistingProject: boolean, - nativeChatTranscriptIsLocalReadable = true + hasExistingProject: boolean ): OnboardingFolderAgentStartup | undefined { if (!shouldSeedFolderAgentAfterDismissedOnboarding(onboarding, hasExistingProject)) { return undefined } - return buildOnboardingFolderAgentStartup(settings, nativeChatTranscriptIsLocalReadable) + return buildOnboardingFolderAgentStartup(settings) } diff --git a/src/renderer/src/lib/web-runtime-worktree-terminal-after-wake.ts b/src/renderer/src/lib/web-runtime-worktree-terminal-after-wake.ts index c577e143d635..eb2db4c8938a 100644 --- a/src/renderer/src/lib/web-runtime-worktree-terminal-after-wake.ts +++ b/src/renderer/src/lib/web-runtime-worktree-terminal-after-wake.ts @@ -11,15 +11,8 @@ import { endWebRuntimeWakeTerminalRespawn } from '@/runtime/web-runtime-wake-terminal-respawn' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' -import { - draftViewModeProps, - resolveStartupLaunchDraftText, - type WorktreeStartupPayload -} from '@/lib/worktree-startup-payload' +import type { WorktreeStartupPayload } from '@/lib/worktree-startup-payload' import type { TuiAgent } from '../../../shared/tui-agent' -import { initialAgentTabViewModeProps } from '@/lib/native-chat-initial-view-mode' -import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' -import { getConnectionId } from '@/lib/connection-context' import { toast } from 'sonner' import { shouldAutoCreateInitialTerminal } from '@/components/terminal/initial-terminal' @@ -94,20 +87,10 @@ export function ensureWebRuntimeWorktreeTerminalAfterWake( } const startup = opts?.startup - const viewModeProps = launchAgent - ? initialAgentTabViewModeProps(state.settings, { - agent: launchAgent, - ...draftViewModeProps(resolveStartupLaunchDraftText(startup)), - nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable( - getConnectionId(worktreeId) - ) - }) - : {} // Why: sleep keeps tab rows but terminal.stop clears host PTYs, while a failed create receipt leaves a selected agent with no host surface. void createWebRuntimeSessionTerminal({ worktreeId, environmentId: runtimeEnvironmentId, - ...viewModeProps, ...(startup ? { command: startup.command, diff --git a/src/renderer/src/lib/worktree-activation-agent-startup.test.ts b/src/renderer/src/lib/worktree-activation-agent-startup.test.ts index 5f450b999789..ca6595d0f43e 100644 --- a/src/renderer/src/lib/worktree-activation-agent-startup.test.ts +++ b/src/renderer/src/lib/worktree-activation-agent-startup.test.ts @@ -33,8 +33,7 @@ describe('ensureWorktreeHasInitialTerminal', () => { it('opens new agent workspace terminals in native chat when configured', () => { const store = createMockStore({ settings: { - experimentalNativeChat: true, - openAgentTabsInChatByDefault: true + experimentalNativeChat: true } }) @@ -51,8 +50,7 @@ describe('ensureWorktreeHasInitialTerminal', () => { expect(store.createTab).toHaveBeenCalledWith('wt-1', undefined, undefined, { pendingActivationSpawn: true, - launchAgent: 'claude', - viewMode: 'chat' + launchAgent: 'claude' }) expect(store.queueTabStartupCommand).toHaveBeenCalledWith('tab-1', { command: 'claude', @@ -61,14 +59,13 @@ describe('ensureWorktreeHasInitialTerminal', () => { }) it.each([ - ['mirrorable', 'https://github.com/o/r/issues/12', { viewMode: 'chat' }], - ['multi-line', 'Review this\n\nhttps://github.com/o/r/issues/12', { viewMode: 'chat' }], + ['mirrorable', 'https://github.com/o/r/issues/12', {}], + ['multi-line', 'Review this\n\nhttps://github.com/o/r/issues/12', {}], ['unsupported-separator', 'Review this\u2028https://github.com/o/r/issues/12', {}] ])('opens a %s draft startup payload accordingly', (_label, draftPrompt, expectedViewMode) => { const store = createMockStore({ settings: { - experimentalNativeChat: true, - openAgentTabsInChatByDefault: true + experimentalNativeChat: true } }) @@ -91,20 +88,17 @@ describe('ensureWorktreeHasInitialTerminal', () => { }) }) - // An argv-prefill launch carries the draft inside `command` and sets NO - // draftPrompt, so gating on draftPrompt alone lets it open in chat with - // nothing mirrored — an empty composer beside a filled TUI input. + // An argv-prefill launch carries its draft inside the terminal command. it.each([ - ['mirrorable', 'https://github.com/o/r/issues/12', { viewMode: 'chat' }], - ['multi-line', 'Review this\n\nhttps://github.com/o/r/issues/12', { viewMode: 'chat' }], + ['mirrorable', 'https://github.com/o/r/issues/12', {}], + ['multi-line', 'Review this\n\nhttps://github.com/o/r/issues/12', {}], ['unsupported-separator', 'Review this\u2028https://github.com/o/r/issues/12', {}] ])( - 'gates a %s argv-prefill draft on launchDraftText alone', + 'keeps a %s argv-prefill draft in the terminal', (_label, launchDraftText, expectedViewMode) => { const store = createMockStore({ settings: { - experimentalNativeChat: true, - openAgentTabsInChatByDefault: true + experimentalNativeChat: true } }) @@ -113,8 +107,7 @@ describe('ensureWorktreeHasInitialTerminal', () => { 'wt-1', { command: `claude --prefill '${launchDraftText}'`, - launchAgent: 'claude', - launchDraftText + launchAgent: 'claude' }, undefined, undefined @@ -128,14 +121,13 @@ describe('ensureWorktreeHasInitialTerminal', () => { } ) - it('opens the startup default tab in native chat when configured', () => { + it('opens the startup default tab in the terminal', () => { let createdIndex = 0 const createTab = vi.fn(() => ({ id: `tab-${++createdIndex}` })) const store = createMockStore({ createTab, settings: { - experimentalNativeChat: true, - openAgentTabsInChatByDefault: true + experimentalNativeChat: true } }) @@ -151,14 +143,13 @@ describe('ensureWorktreeHasInitialTerminal', () => { expect(createTab).toHaveBeenNthCalledWith(1, 'wt-1', undefined, undefined, { pendingActivationSpawn: true, recordInteraction: false, - launchAgent: 'claude', - viewMode: 'chat' + launchAgent: 'claude' }) }) it.each([ - ['mirrorable', 'https://github.com/o/r/issues/12', { viewMode: 'chat' }], - ['multi-line', 'Review this\n\nhttps://github.com/o/r/issues/12', { viewMode: 'chat' }], + ['mirrorable', 'https://github.com/o/r/issues/12', {}], + ['multi-line', 'Review this\n\nhttps://github.com/o/r/issues/12', {}], ['unsupported-separator', 'Review this\u2028https://github.com/o/r/issues/12', {}] ])( 'opens a %s draft startup default tab accordingly', @@ -168,8 +159,7 @@ describe('ensureWorktreeHasInitialTerminal', () => { const store = createMockStore({ createTab, settings: { - experimentalNativeChat: true, - openAgentTabsInChatByDefault: true + experimentalNativeChat: true } }) diff --git a/src/renderer/src/lib/worktree-activation-store-contract.ts b/src/renderer/src/lib/worktree-activation-store-contract.ts index 66de89e85e35..8d1f3aa9151f 100644 --- a/src/renderer/src/lib/worktree-activation-store-contract.ts +++ b/src/renderer/src/lib/worktree-activation-store-contract.ts @@ -59,7 +59,7 @@ export type WorktreeActivationStore = Partial & { startup: { command: string; env?: Record } ) => void queueTabInitialCwd: (tabId: string, cwd: string) => void - settings?: Pick | null + settings?: Pick | null } export type InitialTerminalOptions = { diff --git a/src/renderer/src/lib/worktree-creation-agent-seeds.test.ts b/src/renderer/src/lib/worktree-creation-agent-seeds.test.ts index e052b75f7d48..ab2bd9e780dc 100644 --- a/src/renderer/src/lib/worktree-creation-agent-seeds.test.ts +++ b/src/renderer/src/lib/worktree-creation-agent-seeds.test.ts @@ -91,8 +91,7 @@ beforeEach(() => { useAppStore.setState({ settings: { ...initialSettings, - experimentalNativeChat: true, - openAgentTabsInChatByDefault: true + experimentalNativeChat: true } }) }) @@ -131,9 +130,8 @@ describe('seedAgentTabStateAfterWorktreeCreate', () => { ) }) - it('moves a backend-spawned non-mirrorable draft out of an inherited chat view', () => { + it('preserves an explicit chat view while seeding a backend draft', () => { setTabs([{ id: 'agent-tab', launchAgent: 'claude', viewMode: 'chat' }]) - seedAgentTabStateAfterWorktreeCreate({ request: { ...request, launchDraftPrompt: 'note\u2028https://github.com/o/r/issues/12' }, worktreeId: 'wt-1', @@ -141,83 +139,23 @@ describe('seedAgentTabStateAfterWorktreeCreate', () => { startupTerminalTabId: 'agent-tab', backendSpawned: true }) - - expect(tabViewMode('agent-tab')).toBe('terminal') + expect(tabViewMode('agent-tab')).toBe('chat') }) - it('opens a backend-spawned mirrorable draft in chat after host reconciliation', async () => { + it('does not turn a backend terminal draft into terminal-backed chat', () => { setTabs([{ id: 'agent-tab', launchAgent: 'claude', viewMode: 'terminal' }]) - const runtimeCall = vi.fn(async ({ method }: { method: string }) => { - if (method === 'agentSession.handoffStatus') { - return { - id: 'status', - ok: true, - result: { owner: 'native', direction: null, phase: 'idle' }, - _meta: { runtimeId: 'runtime-1' } - } - } - throw new Error(`Unexpected runtime method: ${method}`) - }) - const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window') - Object.defineProperty(globalThis, 'window', { - configurable: true, - value: { api: { runtime: { call: runtimeCall } } } - }) - useAppStore.setState({ - terminalLayoutsByTabId: { - 'agent-tab': { - activeLeafId: 'leaf-1', - ptyIdsByLeafId: { 'leaf-1': 'pty-1' } - } as never - }, - agentStatusByPaneKey: { - 'agent-tab:leaf-1': { - agentType: 'claude', - providerSession: { id: 'claude-session-1' } - } as never - } - }) - - try { - seedAgentTabStateAfterWorktreeCreate({ - request, - worktreeId: 'wt-1', - primaryTabId: 'agent-tab', - startupTerminalTabId: 'agent-tab', - backendSpawned: true - }) - - await vi.waitFor(() => expect(tabViewMode('agent-tab')).toBe('chat')) - } finally { - if (previousWindow) { - Object.defineProperty(globalThis, 'window', previousWindow) - } else { - Reflect.deleteProperty(globalThis, 'window') - } - } - }) - - it('still opens a local omp draft in chat, despite the local-transcript gate', () => { - // Why: omp discloses no hook transcript path, so it joins Grok in requiring a - // locally readable sessions root. This call site must therefore SUPPLY that - // readability flag for omp too — gating on Grok alone left it undefined and - // parked every omp draft in the terminal view, local workspace or not. - setTabs([{ id: 'agent-tab', launchAgent: 'omp', viewMode: 'terminal' }]) - seedAgentTabStateAfterWorktreeCreate({ - request: { ...request, agent: 'omp' as const }, + request, worktreeId: 'wt-1', primaryTabId: 'agent-tab', startupTerminalTabId: 'agent-tab', backendSpawned: true }) - - expect(tabViewMode('agent-tab')).toBe('chat') + expect(tabViewMode('agent-tab')).toBe('terminal') }) - it('keys a raw backend tab id and updates the host before its tab mirror lands', async () => { + it('seeds a raw backend tab id without setting a default view on the host', () => { setTabs([], 'runtime-1') - seedAgentTabStateAfterWorktreeCreate({ request, worktreeId: 'wt-1', @@ -225,20 +163,8 @@ describe('seedAgentTabStateAfterWorktreeCreate', () => { startupTerminalTabId: 'host-agent-tab', backendSpawned: true }) - - expect(seededTabIds()).toEqual(['web-terminal-host-agent-tab']) - await vi.waitFor(() => - expect(mocks.setWebRuntimeTabProps).toHaveBeenCalledWith({ - worktreeId: 'wt-1', - tabId: 'web-terminal-host-agent-tab', - viewMode: 'chat' - }) - ) - setTabs( - [{ id: 'web-terminal-host-agent-tab', launchAgent: 'claude', viewMode: 'chat' }], - 'runtime-1' - ) expect(seededTabIds()).toEqual(['web-terminal-host-agent-tab']) + expect(mocks.setWebRuntimeTabProps).not.toHaveBeenCalled() }) it('seeds the launchAgent-stamped tab when the renderer owns startup', () => { diff --git a/src/renderer/src/lib/worktree-creation-agent-seeds.ts b/src/renderer/src/lib/worktree-creation-agent-seeds.ts index 6abf478bd282..e884cb38d7a8 100644 --- a/src/renderer/src/lib/worktree-creation-agent-seeds.ts +++ b/src/renderer/src/lib/worktree-creation-agent-seeds.ts @@ -2,10 +2,6 @@ import { useAppStore } from '@/store' import { seedNativeChatAppliedSessionOptions } from '@/components/native-chat/native-chat-session-option-cache' import { seedNativeChatLaunchDraftForAgentTab } from '@/lib/agent-launch-prompt-delivery' import { queueHookCommandsForFirstWorktreeTab } from '@/lib/hook-command-delayed-delivery' -import { decideInitialAgentTabViewMode } from '@/lib/native-chat-initial-view-mode' -import { getConnectionIdFromState } from '@/lib/connection-context' -import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' -import { nativeChatRequiresLocalTranscript } from '@/lib/native-chat-supported-agent' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { toWebTerminalSurfaceTabId } from '@/runtime/web-terminal-surface-id' import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation' @@ -45,46 +41,6 @@ function resolveLaunchAgentTabId( return stamped ?? args.primaryTabId ?? args.startupTerminalTabId ?? null } -function applyBackendSpawnedDraftViewMode(args: { - state: AppStoreSnapshot - request: SeedRequest - agent: TuiAgent - tabId: string - worktreeId: string - backendSpawned: boolean -}): void { - const { state, request, agent, tabId, worktreeId, backendSpawned } = args - if (!backendSpawned || !request.launchDraftPrompt) { - return - } - const desiredViewMode = - decideInitialAgentTabViewMode({ - experimentalNativeChat: state.settings?.experimentalNativeChat, - openAgentTabsInChatByDefault: state.settings?.openAgentTabsInChatByDefault, - agent, - promptDelivery: 'draft', - launchDraftText: request.launchDraftPrompt, - ...(nativeChatRequiresLocalTranscript(agent) - ? { - nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable( - getConnectionIdFromState(state, worktreeId) - ) - } - : {}) - }) ?? 'terminal' - const tab = state.unifiedTabsByWorktree?.[worktreeId]?.find((tab) => tab.id === tabId) - if (!tab && getRuntimeEnvironmentIdForWorktree(state, worktreeId)) { - void import('@/runtime/web-runtime-session').then(({ setWebRuntimeTabProps }) => - setWebRuntimeTabProps({ worktreeId, tabId, viewMode: desiredViewMode }) - ) - return - } - const currentViewMode = tab?.viewMode ?? 'terminal' - if (currentViewMode !== desiredViewMode) { - state.setTabViewMode(tabId, desiredViewMode) - } -} - function applyAgentTabSeeds(args: { state: AppStoreSnapshot request: SeedRequest @@ -94,7 +50,6 @@ function applyAgentTabSeeds(args: { backendSpawned: boolean }): void { const { request, agent, tabId } = args - applyBackendSpawnedDraftViewMode(args) seedNativeChatAppliedSessionOptions(tabId, agent, request.startupPlan?.sessionOptions) // Why: draft launch context reaches only the TUI input; seed the // chat-composer copy so it isn't invisible in the chat view. diff --git a/src/renderer/src/lib/worktree-creation-flow-startup.ts b/src/renderer/src/lib/worktree-creation-flow-startup.ts index 4418cb21ed3a..539c566a800e 100644 --- a/src/renderer/src/lib/worktree-creation-flow-startup.ts +++ b/src/renderer/src/lib/worktree-creation-flow-startup.ts @@ -25,7 +25,6 @@ export function buildWorktreeCreationStartupOpt( ...(plan.draftPrompt ? { draftPrompt: plan.draftPrompt } : {}), // Why: view-mode only. An argv-prefill plan sets no draftPrompt, so this is // the sole signal that this launch starts with unsent context in the TUI. - ...(request.launchDraftPrompt ? { launchDraftText: request.launchDraftPrompt } : {}), ...(plan.startupCommandDelivery ? { startupCommandDelivery: plan.startupCommandDelivery } : {}), // Why: command-code shows its prompt in the tab status before the first // hook fires, so the prompt is threaded through here. diff --git a/src/renderer/src/lib/worktree-creation-flow-stranded-surface.test.ts b/src/renderer/src/lib/worktree-creation-flow-stranded-surface.test.ts index 7a678434a4ea..70df0b299203 100644 --- a/src/renderer/src/lib/worktree-creation-flow-stranded-surface.test.ts +++ b/src/renderer/src/lib/worktree-creation-flow-stranded-surface.test.ts @@ -17,8 +17,7 @@ type TestActiveView = 'terminal' | 'tasks' const store = { settings: { activeRuntimeEnvironmentId: null as string | null, - experimentalNativeChat: undefined as boolean | undefined, - openAgentTabsInChatByDefault: undefined as boolean | undefined + experimentalNativeChat: undefined }, activeView: 'terminal' as TestActiveView, activePendingCreationId: 'creation-1' as string | null, diff --git a/src/renderer/src/lib/worktree-creation-flow.test.ts b/src/renderer/src/lib/worktree-creation-flow.test.ts index 92dea7e39910..f719025dd5ba 100644 --- a/src/renderer/src/lib/worktree-creation-flow.test.ts +++ b/src/renderer/src/lib/worktree-creation-flow.test.ts @@ -13,8 +13,8 @@ type TestActiveView = 'terminal' | 'tasks' const store = { settings: { activeRuntimeEnvironmentId: null as string | null, - experimentalNativeChat: undefined as boolean | undefined, - openAgentTabsInChatByDefault: undefined as boolean | undefined + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The fixture mutates this setting across test cases. + experimentalNativeChat: undefined as boolean | undefined }, activeView: 'terminal' as TestActiveView, activePendingCreationId: 'creation-1' as string | null, @@ -100,7 +100,6 @@ beforeEach(() => { vi.clearAllMocks() store.settings.activeRuntimeEnvironmentId = null store.settings.experimentalNativeChat = undefined - store.settings.openAgentTabsInChatByDefault = undefined store.activeView = 'terminal' store.activePendingCreationId = 'creation-1' store.repos = [] @@ -682,11 +681,10 @@ describe('staged background worktree creation', () => { }) it.each([ - ['mirrorable local Grok', 'grok', 'https://github.com/o/r/issues/12', 'chat'], - ['multi-line Claude', 'claude', 'note\nhttps://github.com/o/r/issues/12', 'chat'] + ['mirrorable local Grok', 'grok', 'https://github.com/o/r/issues/12', 'terminal'], + ['multi-line Claude', 'claude', 'note\nhttps://github.com/o/r/issues/12', 'terminal'] ] as const)('passes %s draft mode to backend startup', async (_label, agent, draft, viewMode) => { store.settings.experimentalNativeChat = true - store.settings.openAgentTabsInChatByDefault = true store.repos = [{ id: 'repo-1', connectionId: null }] continueBackgroundWorktreeCreation( 'creation-1', @@ -713,10 +711,7 @@ describe('staged background worktree creation', () => { }) }) - it('carries launchDraftText into activation for an argv-prefill launch', async () => { - // Why: the draft rides inside `launchCommand` here, so the plan sets no - // draftPrompt — without launchDraftText the initial view-mode decision - // never sees a draft and opens chat on an unmirrorable one. + it('keeps an argv-prefill draft inside the terminal command', async () => { store.activeView = 'terminal' store.activePendingCreationId = 'creation-1' store.createWorktree.mockResolvedValueOnce({ @@ -742,7 +737,7 @@ describe('staged background worktree creation', () => { await vi.waitFor(() => expect(activateAndRevealWorktree).toHaveBeenCalled()) const startup = vi.mocked(activateAndRevealWorktree).mock.calls[0]?.[1]?.startup expect(startup?.draftPrompt).toBeUndefined() - expect(startup?.launchDraftText).toBe('https://github.com/o/r/issues/12') + expect(startup?.command).toContain('https://github.com/o/r/issues/12') }) it('does not seed a launch draft without draft launch context', async () => { diff --git a/src/renderer/src/lib/worktree-default-terminal-tabs.ts b/src/renderer/src/lib/worktree-default-terminal-tabs.ts index 5a9d48723f02..7104318ff010 100644 --- a/src/renderer/src/lib/worktree-default-terminal-tabs.ts +++ b/src/renderer/src/lib/worktree-default-terminal-tabs.ts @@ -3,19 +3,12 @@ import type { WorktreeSetupLaunch } from '../../../shared/worktree/launch-types' import { agentKindToTuiAgent } from '../../../shared/agent-kind' -import { initialAgentTabViewModeProps } from './native-chat-initial-view-mode' -import { getConnectionId } from '@/lib/connection-context' -import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' import { seedNativeChatAppliedSessionOptions } from '@/components/native-chat/native-chat-session-option-cache' import type { InitialTerminalOptions, WorktreeActivationStore } from '@/lib/worktree-activation-store-contract' -import { - draftViewModeProps, - resolveStartupLaunchDraftText, - type WorktreeStartupPayload -} from '@/lib/worktree-startup-payload' +import type { WorktreeStartupPayload } from '@/lib/worktree-startup-payload' import { queueSetupAndIssueCommands, type IssueCommandLaunch @@ -51,20 +44,7 @@ export function applyDefaultTerminalTabs( const tab = store.createTab(worktreeId, undefined, undefined, { pendingActivationSpawn: true, recordInteraction: false, - ...(launchAgent - ? { - launchAgent, - ...initialAgentTabViewModeProps(store.settings ?? null, { - agent: launchAgent, - ...draftViewModeProps( - isStartupTab ? resolveStartupLaunchDraftText(startup) : undefined - ), - nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable( - getConnectionId(worktreeId) - ) - }) - } - : {}), + ...(launchAgent ? { launchAgent } : {}), ...(opts?.activateCreatedTabs === false ? { activate: false } : {}) }) if (index === 0) { diff --git a/src/renderer/src/lib/worktree-draft-startup-view-mode.test.ts b/src/renderer/src/lib/worktree-draft-startup-view-mode.test.ts index 71d895c80afb..fcccaff94965 100644 --- a/src/renderer/src/lib/worktree-draft-startup-view-mode.test.ts +++ b/src/renderer/src/lib/worktree-draft-startup-view-mode.test.ts @@ -30,8 +30,7 @@ beforeEach(() => { useAppStore.setState({ settings: { ...initialSettings, - experimentalNativeChat: true, - openAgentTabsInChatByDefault: true + experimentalNativeChat: true } }) }) @@ -41,13 +40,9 @@ afterEach(() => { }) describe('resolveBackendDraftStartup', () => { - // Why: omp discloses no hook transcript path, so it joins Grok in requiring a - // locally readable sessions root. This call site must SUPPLY that flag for omp - // too — gating on Grok alone left it undefined and parked every omp draft in - // the terminal view, local workspace or not. - it('opens a local omp draft in chat', () => { + it('keeps a local omp terminal startup in terminal view', () => { setRepoConnection(null) - expect(viewModeFor('omp')).toBe('chat') + expect(viewModeFor('omp')).toBe('terminal') }) it('keeps a Model-A SSH omp draft in the terminal view', () => { @@ -55,8 +50,8 @@ describe('resolveBackendDraftStartup', () => { expect(viewModeFor('omp')).toBe('terminal') }) - it('opens a runtime-owned SSH omp draft in chat, which reads the transcript locally', () => { + it('keeps a runtime-owned SSH omp terminal startup in terminal view', () => { setRepoConnection('runtime-ssh-env-1') - expect(viewModeFor('omp')).toBe('chat') + expect(viewModeFor('omp')).toBe('terminal') }) }) diff --git a/src/renderer/src/lib/worktree-draft-startup-view-mode.ts b/src/renderer/src/lib/worktree-draft-startup-view-mode.ts index 0c11ea02c808..a04d0f66858a 100644 --- a/src/renderer/src/lib/worktree-draft-startup-view-mode.ts +++ b/src/renderer/src/lib/worktree-draft-startup-view-mode.ts @@ -1,7 +1,3 @@ -import { useAppStore } from '@/store' -import { decideInitialAgentTabViewMode } from '@/lib/native-chat-initial-view-mode' -import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' -import { nativeChatRequiresLocalTranscript } from '@/lib/native-chat-supported-agent' import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation' export function resolveBackendDraftStartup( @@ -10,21 +6,5 @@ export function resolveBackendDraftStartup( if (!request.startup || !request.agent || !request.launchDraftPrompt) { return request.startup } - const state = useAppStore.getState() - const repo = state.repos.find((entry) => entry.id === request.repoId) - const connectionId = repo ? (repo.connectionId ?? null) : undefined - const viewMode = - decideInitialAgentTabViewMode({ - experimentalNativeChat: state.settings?.experimentalNativeChat, - openAgentTabsInChatByDefault: state.settings?.openAgentTabsInChatByDefault, - agent: request.agent, - promptDelivery: 'draft', - launchDraftText: request.launchDraftPrompt, - ...(nativeChatRequiresLocalTranscript(request.agent) - ? { - nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable(connectionId) - } - : {}) - }) ?? 'terminal' - return { ...request.startup, viewMode } + return { ...request.startup, viewMode: 'terminal' } } diff --git a/src/renderer/src/lib/worktree-initial-terminal-seeding.ts b/src/renderer/src/lib/worktree-initial-terminal-seeding.ts index 3bb5a28c4200..026fbb58f5c3 100644 --- a/src/renderer/src/lib/worktree-initial-terminal-seeding.ts +++ b/src/renderer/src/lib/worktree-initial-terminal-seeding.ts @@ -11,19 +11,12 @@ import { agentKindToTuiAgent } from '../../../shared/agent-kind' import { useAppStore } from '@/store' import { queueHookCommandsForFirstWorktreeTab } from '@/lib/hook-command-delayed-delivery' import { resolveWorkspaceTerminalHostAuthority } from '@/lib/workspace-terminal-host-authority' -import { initialAgentTabViewModeProps } from './native-chat-initial-view-mode' -import { getConnectionId } from '@/lib/connection-context' -import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' import { seedNativeChatAppliedSessionOptions } from '@/components/native-chat/native-chat-session-option-cache' import type { InitialTerminalOptions, WorktreeActivationStore } from '@/lib/worktree-activation-store-contract' -import { - draftViewModeProps, - resolveStartupLaunchDraftText, - type WorktreeStartupPayload -} from '@/lib/worktree-startup-payload' +import type { WorktreeStartupPayload } from '@/lib/worktree-startup-payload' import { queueSetupAndIssueCommands, type IssueCommandLaunch @@ -259,20 +252,7 @@ export function ensureWorktreeHasInitialTerminal( : undefined) const terminalTab = store.createTab(worktreeId, undefined, undefined, { pendingActivationSpawn: true, - ...(launchAgent - ? { - launchAgent, - ...initialAgentTabViewModeProps(store.settings ?? null, { - agent: launchAgent, - // Why: argv-prefill launches carry the draft in `command` and set no - // draftPrompt, so gating on draftPrompt alone misses them entirely. - ...draftViewModeProps(resolveStartupLaunchDraftText(sequencedStartup)), - nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable( - getConnectionId(worktreeId) - ) - }) - } - : {}), + ...(launchAgent ? { launchAgent } : {}), ...(opts?.activateCreatedTabs === false ? { activate: false } : {}) }) if (opts?.activateCreatedTabs !== false) { diff --git a/src/renderer/src/lib/worktree-startup-payload.ts b/src/renderer/src/lib/worktree-startup-payload.ts index afb29b980dde..77084b0a91fe 100644 --- a/src/renderer/src/lib/worktree-startup-payload.ts +++ b/src/renderer/src/lib/worktree-startup-payload.ts @@ -20,37 +20,8 @@ export type WorktreeStartupPayload = { launchToken?: string launchAgent?: TuiAgent draftPrompt?: string - /** - * The unsent launch context, for the initial view-mode decision ONLY. - * - * Deliberately separate from `draftPrompt`, which drives the bracketed paste - * in pty-connection: an argv-prefill launch already carries the draft inside - * `command`, so reusing `draftPrompt` here would paste it a second time. - * Set this on every draft launch; set `draftPrompt` only for paste delivery. - */ - launchDraftText?: string startupCommandDelivery?: StartupCommandDelivery initialAgentStatus?: { agent: TuiAgent; prompt: string } sessionOptions?: Record telemetry?: AgentStartedTelemetry } - -/** - * The unsent launch context a startup payload carries, whichever way the agent - * receives it: argv prefill sets only `launchDraftText`, post-ready paste sets - * `draftPrompt`. Gating on `draftPrompt` alone silently misses every - * argv-prefill launch. - */ -export function resolveStartupLaunchDraftText( - startup: Pick | undefined -): string | undefined { - return startup?.draftPrompt ?? startup?.launchDraftText -} - -/** Shared by both tab-creation sites so the draft gate can't drift between them. */ -export function draftViewModeProps(draftText: string | undefined): { - promptDelivery?: 'draft' - launchDraftText?: string -} { - return draftText == null ? {} : { promptDelivery: 'draft', launchDraftText: draftText } -} diff --git a/src/renderer/src/runtime/host-structured-agents-sync.ts b/src/renderer/src/runtime/host-structured-agents-sync.ts index 79db61d7e5a6..c30394980d6c 100644 --- a/src/renderer/src/runtime/host-structured-agents-sync.ts +++ b/src/renderer/src/runtime/host-structured-agents-sync.ts @@ -1,5 +1,6 @@ import { useEffect } from 'react' import { LOCAL_EXECUTION_HOST_ID, toRuntimeExecutionHostId } from '../../../shared/execution-host' +import { isNativeChatEnabled } from '../../../shared/structured-native-chat-launch-route' import { lastVerifiedRuntimeStatus } from '../../../shared/runtime-host-status' import { useAppStore } from '@/store' import type { AppState } from '@/store/types' @@ -17,7 +18,7 @@ import { localStructuredChatsInUse } from './local-structured-chats' // Why gated: a host older than this build installs its structured host to answer, which a profile // that never uses structured chat must not pay for; only with chats in use can a launch route to one. function structuredChatSettingOn(state: Pick): boolean { - return state.settings?.experimentalStructuredNativeChat === true + return isNativeChatEnabled(state.settings) } async function syncLocalHost(settings: AppState['settings']): Promise { diff --git a/src/renderer/src/runtime/host-structured-agents.test.ts b/src/renderer/src/runtime/host-structured-agents.test.ts index 63ec2c2dd88a..4ef1092f2c32 100644 --- a/src/renderer/src/runtime/host-structured-agents.test.ts +++ b/src/renderer/src/runtime/host-structured-agents.test.ts @@ -10,10 +10,10 @@ import type { RuntimeCapability } from '../../../shared/protocol-version' type Statuses = Map type StoreState = { runtimeStatusByEnvironmentId: Statuses - settings: { experimentalStructuredNativeChat: boolean } | null + settings: { experimentalNativeChat: boolean } | null } -const ON = { experimentalStructuredNativeChat: true } +const ON = { experimentalNativeChat: true } const mocks = vi.hoisted(() => { const state: StoreState = { runtimeStatusByEnvironmentId: new Map(), settings: null } @@ -36,7 +36,7 @@ vi.mock('./local-runtime-capabilities', () => ({ })) vi.mock('./local-structured-chats', () => ({ localStructuredChatsInUse: async (settings: StoreState['settings']) => - settings?.experimentalStructuredNativeChat === true + settings?.experimentalNativeChat === true })) vi.mock('@/store', () => ({ useAppStore: { @@ -168,7 +168,7 @@ describe('host structured agents', () => { // Asking installs a host's structured store; a profile with structured chat off never pays it. it('asks no host until structured chat is turned on', async () => { - mocks.state = { ...mocks.state, settings: { experimentalStructuredNativeChat: false } } + mocks.state = { ...mocks.state, settings: { experimentalNativeChat: false } } uninstall = installHostStructuredAgentsSync() setStatuses(new Map([['env-1', pairedStatus('rt-1', REGISTERED)]])) await Promise.resolve() diff --git a/src/renderer/src/runtime/local-structured-chats.test.ts b/src/renderer/src/runtime/local-structured-chats.test.ts index 24a2a481c457..35212d551e70 100644 --- a/src/renderer/src/runtime/local-structured-chats.test.ts +++ b/src/renderer/src/runtime/local-structured-chats.test.ts @@ -13,8 +13,8 @@ import { restoreLocalStructuredChatsAtStartup } from './local-structured-chats' -const SETTING_OFF = { experimentalStructuredNativeChat: false } -const SETTING_ON = { experimentalStructuredNativeChat: true } +const SETTING_OFF = { experimentalNativeChat: false } +const SETTING_ON = { experimentalNativeChat: true } beforeEach(() => { mocks.held = false diff --git a/src/renderer/src/runtime/local-structured-chats.ts b/src/renderer/src/runtime/local-structured-chats.ts index dad01c24361b..54aec040aeb0 100644 --- a/src/renderer/src/runtime/local-structured-chats.ts +++ b/src/renderer/src/runtime/local-structured-chats.ts @@ -1,5 +1,6 @@ import { useSyncExternalStore } from 'react' import type { GlobalSettings } from '../../../shared/global-settings-types' +import { isNativeChatEnabled } from '../../../shared/structured-native-chat-launch-route' import { isWebClientLocation } from '@/lib/web-client-location' import { useAppStore } from '@/store' import { restoreLocalStructuredSessionTabsOnce } from './local-structured-session-tabs-sync/inventory-refresh' @@ -70,17 +71,17 @@ export function useLocalStructuredAgentSessionsHeld(): boolean { ) } -type StructuredChatSettings = Pick | null +type StructuredChatSettings = Pick | null function chatsInUse(settings: StructuredChatSettings | undefined, holds: boolean): boolean { - return !isWebClientLocation() && (settings?.experimentalStructuredNativeChat === true || holds) + return !isWebClientLocation() && (isNativeChatEnabled(settings) || holds) } /** Structured chats can exist on this machine: the setting launches them, or the runtime holds some. */ export function useLocalStructuredChatsInUse(): boolean { const holds = useLocalStructuredAgentSessionsHeld() - const setting = useAppStore((state) => state.settings?.experimentalStructuredNativeChat === true) - return chatsInUse({ experimentalStructuredNativeChat: setting }, holds) + const setting = useAppStore((state) => isNativeChatEnabled(state.settings)) + return chatsInUse({ experimentalNativeChat: setting }, holds) } /** The same answer for one-shot startup work, asked of the host rather than read from the renderer. */ diff --git a/src/renderer/src/runtime/local-structured-session-tabs-sync-hook.test.ts b/src/renderer/src/runtime/local-structured-session-tabs-sync-hook.test.ts index 454012c23d6d..dca9dabc1afd 100644 --- a/src/renderer/src/runtime/local-structured-session-tabs-sync-hook.test.ts +++ b/src/renderer/src/runtime/local-structured-session-tabs-sync-hook.test.ts @@ -21,7 +21,7 @@ import { resetLocalStructuredChatsForTests } from './local-structured-chats' function setStructuredChat(enabled: boolean): void { useAppStore.setState({ - settings: { ...useAppStore.getState().settings!, experimentalStructuredNativeChat: enabled } + settings: { ...useAppStore.getState().settings!, experimentalNativeChat: enabled } }) } diff --git a/src/renderer/src/store/repos/repo-add-actions.ts b/src/renderer/src/store/repos/repo-add-actions.ts index a4afef1890c5..95d81bcb86e2 100644 --- a/src/renderer/src/store/repos/repo-add-actions.ts +++ b/src/renderer/src/store/repos/repo-add-actions.ts @@ -5,7 +5,6 @@ import type { Repo } from '../../../../shared/repo-types' import { isGitRepoKind } from '../../../../shared/repo-kind' import { getRepoHostIdentity } from '../slices/repo-host-identity' import { callRuntimeRpc, getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client' -import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' import { markOnboardingProjectAdded } from '@/lib/onboarding-project-checklist' import { translate } from '@/i18n/i18n' import { @@ -194,10 +193,7 @@ export function createRepoAddActions( store: get(), onboarding, hasExistingProject: hadProjectBeforeAdd, - executionHostId: executionHostId ?? LOCAL_EXECUTION_HOST_ID, - nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable( - repo.connectionId - ) + executionHostId: executionHostId ?? LOCAL_EXECUTION_HOST_ID }) await revealOnboardingFolderWithAgentLaunch({ worktreeId: folderWorktree.id, diff --git a/src/shared/default-global-settings.ts b/src/shared/default-global-settings.ts index be9ee19ab216..39e42cfdc763 100644 --- a/src/shared/default-global-settings.ts +++ b/src/shared/default-global-settings.ts @@ -132,9 +132,7 @@ export function buildDefaultSettings(args: { terminalLinkActionPopoverEnabled: true, terminalLinkClickBehavior: 'actions', terminalUrlMiddleClickBehavior: 'open', - openAgentTabsInChatByDefault: false, experimentalNativeChat: false, - experimentalStructuredNativeChat: false, nativeChatResumeWorkOnRestart: false, nativeChatQueueFollowUps: true, nativeChatInlineVisuals: true, diff --git a/src/shared/global-settings-types.ts b/src/shared/global-settings-types.ts index 433e1f928ecb..a456af45b833 100644 --- a/src/shared/global-settings-types.ts +++ b/src/shared/global-settings-types.ts @@ -216,12 +216,8 @@ export type GlobalSettings = NativeChatGlobalSettings & { terminalLinkClickBehavior?: 'actions' | 'open' | 'none' /** Middle mouse URL behavior; defaults to opening the primary routed destination. */ terminalUrlMiddleClickBehavior?: 'open' | 'actions' | 'none' - /** Opt-in: open new coding-agent tabs in native chat instead of the raw terminal; optional for legacy settings. */ - openAgentTabsInChatByDefault?: boolean - /** Experimental native chat surface for Claude/Codex sessions; off by default. */ + /** New supported agent launches use structured Chat UI; off defaults to terminal UI. */ experimentalNativeChat?: boolean - /** Opt-in updated structured runtime; off keeps the existing PTY-backed native chat path. */ - experimentalStructuredNativeChat?: boolean /** Opt-in: resume working structured chats automatically on the next launch. Off still offers * the list, so the user sees exactly what would run before anything spends tokens. */ nativeChatResumeWorkOnRestart?: boolean diff --git a/src/shared/native-chat-initial-view-mode.ts b/src/shared/native-chat-initial-view-mode.ts deleted file mode 100644 index 42cdb4e44b89..000000000000 --- a/src/shared/native-chat-initial-view-mode.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { GlobalSettings } from './global-settings-types' -import { agentTabsDefaultToNativeChat } from './structured-native-chat-launch-route' -import type { Tab } from './tab-types' -import type { TuiAgent } from './tui-agent' -import { canMirrorLaunchDraftToNativeChat } from './native-chat-launch-draft-mirrorability' -import { - isNativeChatSupportedAgent, - nativeChatRequiresLocalTranscript -} from './native-chat-agent-support' - -export type NativeChatLaunchPromptDelivery = 'auto-submit' | 'draft' | 'submit-after-ready' - -/** - * Decide the initial `viewMode` for a newly launched agent tab from the - * opt-in `openAgentTabsInChatByDefault` setting. - * - * Returns `'chat'` only when the setting is explicitly on and the launched - * agent has a native-chat renderer. A draft launch opens in chat only when its - * unsent context can be mirrored into the composer — gated on the same - * predicate as seeding so the view never opens empty beside a filled TUI input. - */ -export function decideInitialAgentTabViewMode(args: { - experimentalNativeChat?: boolean - openAgentTabsInChatByDefault?: boolean - agent?: TuiAgent | null - promptDelivery?: NativeChatLaunchPromptDelivery - /** The unsent launch context, when `promptDelivery` is `'draft'`. */ - launchDraftText?: string - nativeChatTranscriptIsLocalReadable?: boolean -}): Tab['viewMode'] { - if (!agentTabsDefaultToNativeChat(args)) { - return undefined - } - if (!isNativeChatSupportedAgent(args.agent)) { - return undefined - } - if ( - nativeChatRequiresLocalTranscript(args.agent) && - args.nativeChatTranscriptIsLocalReadable !== true - ) { - return undefined - } - if ( - args.promptDelivery === 'draft' && - !canMirrorLaunchDraftToNativeChat(args.launchDraftText ?? '') - ) { - return undefined - } - return 'chat' -} - -export function initialAgentTabViewModeProps( - settings: - | Pick - | null - | undefined, - options: { - agent?: TuiAgent | null - promptDelivery?: NativeChatLaunchPromptDelivery - launchDraftText?: string - nativeChatTranscriptIsLocalReadable?: boolean - } = {} -): { viewMode?: Tab['viewMode'] } { - const viewMode = decideInitialAgentTabViewMode({ - experimentalNativeChat: settings?.experimentalNativeChat, - openAgentTabsInChatByDefault: settings?.openAgentTabsInChatByDefault, - agent: options.agent, - promptDelivery: options.promptDelivery, - launchDraftText: options.launchDraftText, - nativeChatTranscriptIsLocalReadable: options.nativeChatTranscriptIsLocalReadable - }) - return viewMode ? { viewMode } : {} -} diff --git a/src/shared/native-chat-launch-draft-mirrorability.ts b/src/shared/native-chat-launch-draft-mirrorability.ts index a66584cd8464..d27d51f5ff21 100644 --- a/src/shared/native-chat-launch-draft-mirrorability.ts +++ b/src/shared/native-chat-launch-draft-mirrorability.ts @@ -4,10 +4,8 @@ import { AGENT_TUI_CLEAR_MAX_LINES, countAgentTuiInputLines } from './agent-tui- * Single source of truth for whether unsent launch context can be mirrored from * the agent's TUI input into the native-chat composer. * - * Both the seeding path (`seedNativeChatLaunchDraftForAgentTab`) and the - * initial-view-mode decision (`decideInitialAgentTabViewMode`) gate on this one - * predicate, so a draft launch can never open in chat with a composer that - * chat then refuses to fill. + * Explicit terminal chat view can mirror a launch draft only when the TUI input + * can be cleared within the same bound. * * CR/LF drafts are safe within the bounded TUI-clear budget. Unicode line * separators and drafts beyond that budget remain terminal-only. diff --git a/src/shared/structured-native-chat-launch-route.test.ts b/src/shared/structured-native-chat-launch-route.test.ts index 2f7d01017f13..c0c00e39c151 100644 --- a/src/shared/structured-native-chat-launch-route.test.ts +++ b/src/shared/structured-native-chat-launch-route.test.ts @@ -9,17 +9,12 @@ import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from './protocol-version' import { - agentTabsDefaultToNativeChat, - prefersStructuredNativeChatByDefault, + isNativeChatEnabled, resolveStructuredNativeChatSupport, type StructuredNativeChatSupportInput } from './structured-native-chat-launch-route' -const ON = { - experimentalNativeChat: true, - openAgentTabsInChatByDefault: true, - experimentalStructuredNativeChat: true -} +const ON = { experimentalNativeChat: true } function support(overrides: Partial = {}) { return resolveStructuredNativeChatSupport({ @@ -31,26 +26,16 @@ function support(overrides: Partial = {}) { }) } -describe('the settings default', () => { - it('needs all three toggles for structured, and the first two for native chat', () => { - expect(prefersStructuredNativeChatByDefault(ON)).toBe(true) - expect(prefersStructuredNativeChatByDefault({ ...ON, experimentalNativeChat: false })).toBe( - false - ) - expect( - prefersStructuredNativeChatByDefault({ ...ON, openAgentTabsInChatByDefault: false }) - ).toBe(false) - expect( - prefersStructuredNativeChatByDefault({ ...ON, experimentalStructuredNativeChat: false }) - ).toBe(false) - expect(agentTabsDefaultToNativeChat({ ...ON, experimentalStructuredNativeChat: false })).toBe( - true - ) +describe('the Chat UI switch', () => { + it('selects structured chat from the one persisted setting', () => { + const oldSelectorOff = { experimentalNativeChat: true, openAgentTabsInChatByDefault: false } + expect(isNativeChatEnabled(ON)).toBe(true) + expect(isNativeChatEnabled({ experimentalNativeChat: false })).toBe(false) + expect(isNativeChatEnabled(oldSelectorOff)).toBe(true) }) it.each([null, undefined, {}])('reads %s as no preference', (settings) => { - expect(prefersStructuredNativeChatByDefault(settings)).toBe(false) - expect(agentTabsDefaultToNativeChat(settings)).toBe(false) + expect(isNativeChatEnabled(settings)).toBe(false) }) }) diff --git a/src/shared/structured-native-chat-launch-route.ts b/src/shared/structured-native-chat-launch-route.ts index f92bac17e847..284737c987c9 100644 --- a/src/shared/structured-native-chat-launch-route.ts +++ b/src/shared/structured-native-chat-launch-route.ts @@ -20,10 +20,7 @@ import { import type { TuiAgent } from './tui-agent' import type { WorkspaceLaunchKind } from './workspace-launch-kind' -export type NativeChatDefaultSettings = Pick< - GlobalSettings, - 'experimentalNativeChat' | 'experimentalStructuredNativeChat' | 'openAgentTabsInChatByDefault' -> +export type NativeChatDefaultSettings = Pick /** Why a launch that the user's default asked to be structured cannot be. */ export type StructuredNativeChatBlocker = @@ -64,22 +61,11 @@ export type StructuredNativeChatSupportInput = { hostStructuredAgents?: readonly string[] } -/** The user's default for a new agent tab: native chat rather than the raw TUI. */ -export function agentTabsDefaultToNativeChat( +/** The single Chat UI switch selects structured chat for supported new launches. */ +export function isNativeChatEnabled( settings: Partial | null | undefined ): boolean { - return ( - settings?.experimentalNativeChat === true && settings?.openAgentTabsInChatByDefault === true - ) -} - -/** ...and specifically a structured native chat session rather than a terminal rendered as chat. */ -export function prefersStructuredNativeChatByDefault( - settings: Partial | null | undefined -): boolean { - return ( - agentTabsDefaultToNativeChat(settings) && settings?.experimentalStructuredNativeChat === true - ) + return settings?.experimentalNativeChat === true } function clientChoosesStructuredLaunches(capabilities: readonly string[] | undefined): boolean { diff --git a/src/shared/telemetry-events.test.ts b/src/shared/telemetry-events.test.ts index 13dca1c08d14..053f79dcb272 100644 --- a/src/shared/telemetry-events.test.ts +++ b/src/shared/telemetry-events.test.ts @@ -537,15 +537,25 @@ describe('workspace_create_failed schema', () => { }) describe('settings_changed schema', () => { - it('accepts structured native chat as a boolean adoption signal', () => { + it('accepts the Chat UI switch as a boolean adoption signal', () => { expect( eventSchemas.settings_changed.safeParse({ - setting_key: 'experimentalStructuredNativeChat', + setting_key: 'experimentalNativeChat', value_kind: 'bool' }).success ).toBe(true) }) + it.each(['experimentalStructuredNativeChat', 'openAgentTabsInChatByDefault'])( + 'rejects the retired %s key', + (settingKey) => { + expect( + eventSchemas.settings_changed.safeParse({ setting_key: settingKey, value_kind: 'bool' }) + .success + ).toBe(false) + } + ) + it('rejects non-whitelisted setting keys', () => { const parsed = eventSchemas.settings_changed.safeParse({ setting_key: 'telemetryOptIn', // deliberately excluded from the whitelist diff --git a/src/shared/telemetry-property-schemas.ts b/src/shared/telemetry-property-schemas.ts index 1b91ead78c30..754c141b2141 100644 --- a/src/shared/telemetry-property-schemas.ts +++ b/src/shared/telemetry-property-schemas.ts @@ -204,14 +204,12 @@ export const SETTINGS_CHANGED_WHITELIST = [ 'experimentalMobile', 'experimentalPet', 'experimentalNativeChat', - 'experimentalStructuredNativeChat', 'experimentalActivity', 'experimentalAgentDashboardPopout', 'experimentalTerminalAttention', 'experimentalAgentHibernation', 'experimentalEphemeralVms', - 'geminiCliOAuthEnabled', - 'openAgentTabsInChatByDefault' + 'geminiCliOAuthEnabled' ] as const satisfies readonly BooleanGlobalSettingsKey[] export const settingsChangedKeySchema = z.enum(SETTINGS_CHANGED_WHITELIST) export type SettingsChangedKey = z.infer diff --git a/tests/e2e/cross-version-wire/cross-version-paired-structured-launch.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-paired-structured-launch.unit.test.ts index 545d79c80dee..4bc014a98865 100644 --- a/tests/e2e/cross-version-wire/cross-version-paired-structured-launch.unit.test.ts +++ b/tests/e2e/cross-version-wire/cross-version-paired-structured-launch.unit.test.ts @@ -13,11 +13,7 @@ import { importReleaseCheckoutModule, materializeReleaseCheckout } from './relea // string would follow along in unit tests but silently change what this desktop does with that server. // This release advertises structured chat but predates client-chosen launch modes. const LEGACY_PAIRED_STRUCTURED_LAUNCH_RELEASE_REF = 'v1.4.219' -const SETTINGS = { - experimentalNativeChat: true, - experimentalStructuredNativeChat: true, - openAgentTabsInChatByDefault: true -} +const SETTINGS = { experimentalNativeChat: true } let legacyHostCapabilities: readonly string[] @@ -56,7 +52,7 @@ describe(`a current desktop paired with a ${LEGACY_PAIRED_STRUCTURED_LAUNCH_RELE supported: false, blocker: 'runtime-capability' }) - expect(resolveAgentLaunchRoute({ ...legacy, settings: SETTINGS })).toBe('legacy-native-chat') + expect(resolveAgentLaunchRoute({ ...legacy, settings: SETTINGS })).toBe('terminal-tui') // The same handshake against today's server: the refusal above is the old server's doing. expect( resolveAgentLaunchRoute({ diff --git a/tests/e2e/structured-native-chat-routing-authority.unit.test.ts b/tests/e2e/structured-native-chat-routing-authority.unit.test.ts index dd2211d08cd0..304ff3492bf5 100644 --- a/tests/e2e/structured-native-chat-routing-authority.unit.test.ts +++ b/tests/e2e/structured-native-chat-routing-authority.unit.test.ts @@ -118,8 +118,7 @@ describe('shared feasibility owns every caller decision', () => { promptDelivery, hostCapabilities: RUNTIME_CAPABILITIES, startsOutsideWorkspaceRoot: true, - workspaceKind: 'folder', - initialSessionOptions: { model: 'model-1', effort: 'high' } + workspaceKind: 'folder' } predicate.mockReturnValue({ supported: true }) expect(resolveAgentLaunchRoute(input)).toBe('structured-native-chat') From 4b13ab6892a85bde2c769469b8ba1410a1001a6a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 8 Oct 2026 11:56:11 -0700 Subject: [PATCH 16/90] fix(lint): move the macOS input-source probe out of app.ts (#26643) #26447 pushed src/main/ipc/app.ts one line over the 300-line limit, so main's oxlint is red and merges from main fail the commit hook. Move the keyboard input-source probe and its bounded command runner, unchanged, into macos-keyboard-input-source.ts beside the other macOS keyboard probes. --- src/main/ipc/app.ts | 153 +------------------- src/main/ipc/macos-keyboard-input-source.ts | 152 +++++++++++++++++++ 2 files changed, 153 insertions(+), 152 deletions(-) create mode 100644 src/main/ipc/macos-keyboard-input-source.ts diff --git a/src/main/ipc/app.ts b/src/main/ipc/app.ts index 637edad4dd35..3e9d9517dad1 100644 --- a/src/main/ipc/app.ts +++ b/src/main/ipc/app.ts @@ -1,4 +1,3 @@ -import { spawn } from 'node:child_process' import { existsSync } from 'node:fs' import path from 'node:path' import { pathToFileURL } from 'node:url' @@ -27,15 +26,7 @@ import { registerRendererShutdownCheckpointHandler } from './renderer-shutdown-c import { readMacKeyboardLayoutSnapshot } from './macos-keyboard-layout-snapshot' import { registerMacKeyboardLayoutChangeNotifications } from './macos-keyboard-layout-change-notifications' import { isProfileStateSaveDelayed } from '../startup/profile-state-save-delay' - -const KEYBOARD_INPUT_SOURCE_TIMEOUT_MS = 500 -const MAC_HITOOLBOX_DOMAIN = 'com.apple.HIToolbox' -// Why: defaults export reads live prefs (on-disk plist lags cfprefsd); xml1 dodges plutil's json abort on macOS 15 input-source arrays; absolute paths so a minimal PATH can't shadow the tools. -const MAC_SELECTED_INPUT_SOURCES_JSON_COMMAND = [ - `/usr/bin/defaults export ${MAC_HITOOLBOX_DOMAIN} -`, - '/usr/bin/plutil -extract AppleSelectedInputSources xml1 -o - -', - '/usr/bin/plutil -convert json -o - -' -].join(' | ') +import { readCommandStdout, readKeyboardInputSourceId } from './macos-keyboard-input-source' type RegisterAppHandlersOptions = { onBeforeRelaunch?: () => void | Promise @@ -112,148 +103,6 @@ function resolveDevFeatureWallAssetDir(): string { return candidates.find((candidate) => existsSync(candidate)) ?? candidates[0] } -function readCommandStdout( - command: string, - args: string[], - timeoutMessage: string -): Promise { - return new Promise((resolve, reject) => { - let settled = false - let child: ReturnType | undefined - - // Why: killing only the shell orphans pipeline stages; detached spawn lets one negative-pid SIGKILL reap the whole group. - const killTree = (): void => { - if (!child?.pid) { - return - } - try { - process.kill(-child.pid, 'SIGKILL') - } catch { - child.kill() - } - } - - // Why: short timeout so a wedged macOS probe never hangs; this timer owns the process-group kill. - const timer = setTimeout(() => { - if (settled) { - return - } - settled = true - killTree() - reject(new Error(timeoutMessage)) - }, KEYBOARD_INPUT_SOURCE_TIMEOUT_MS) - - const settle = (callback: () => void): void => { - if (settled) { - return - } - settled = true - clearTimeout(timer) - callback() - } - - try { - child = spawn(command, args, { detached: true, stdio: ['ignore', 'pipe', 'ignore'] }) - let stdout = '' - child.stdout?.setEncoding('utf8') - child.stdout?.on('data', (chunk: string) => { - stdout += chunk - }) - const failWith = (error: Error): void => { - killTree() - settle(() => reject(error)) - } - // Why: an unhandled Readable 'error' would crash the main process; treat stdout errors like spawn errors. - child.stdout?.on('error', failWith) - child.on('error', failWith) - child.on('close', (code, signal) => { - settle(() => - code === 0 - ? resolve(stdout) - : reject( - new Error( - `${command} exited with ${signal ? `signal ${signal}` : `code ${code ?? 'unknown'}`}` - ) - ) - ) - }) - } catch (error) { - settle(() => reject(error)) - } - }) -} - -type SelectedKeyboardInputSource = { kind: 'inputSource'; id: string } | { kind: 'keyboardLayout' } - -function readSelectedInputSourceFromJson(stdout: string): SelectedKeyboardInputSource | null { - let records: unknown - try { - records = JSON.parse(stdout) - } catch { - return null - } - if (!Array.isArray(records)) { - return null - } - - let hasSelectedKeyboardLayout = false - for (const record of records.slice().toReversed()) { - if (!record || typeof record !== 'object') { - continue - } - const kind = - 'InputSourceKind' in record && typeof record.InputSourceKind === 'string' - ? record.InputSourceKind.trim().toLowerCase() - : '' - if (kind === 'keyboard layout') { - hasSelectedKeyboardLayout = true - continue - } - if (kind.includes('non keyboard')) { - continue - } - if (kind !== 'input mode' && kind !== 'keyboard input method') { - return null - } - const inputMode = 'Input Mode' in record ? record['Input Mode'] : undefined - const bundleId = 'Bundle ID' in record ? record['Bundle ID'] : undefined - const id = typeof inputMode === 'string' && inputMode.trim() ? inputMode : bundleId - if (typeof id === 'string' && id.trim()) { - return { kind: 'inputSource', id: id.trim() } - } - return null - } - return hasSelectedKeyboardLayout ? { kind: 'keyboardLayout' } : null -} - -async function readSelectedKeyboardInputSource(): Promise { - try { - const stdout = await readCommandStdout( - '/bin/sh', - ['-c', MAC_SELECTED_INPUT_SOURCES_JSON_COMMAND], - 'Selected keyboard input source probe timed out' - ) - return readSelectedInputSourceFromJson(stdout) - } catch { - return null - } -} - -async function readKeyboardInputSourceId(): Promise { - const selectedInputSource = await readSelectedKeyboardInputSource() - if (selectedInputSource?.kind === 'inputSource') { - return selectedInputSource.id - } - // An IME can use ABC underneath; the backing layout alone cannot identify the selected source. - return selectedInputSource?.kind === 'keyboardLayout' - ? readCommandStdout( - '/usr/bin/defaults', - ['read', MAC_HITOOLBOX_DOMAIN, 'AppleCurrentKeyboardLayoutInputSourceID'], - 'Keyboard layout input source probe timed out' - ) - : null -} - export function registerAppHandlers(store: Store, options: RegisterAppHandlersOptions = {}): void { registerRendererShutdownCheckpointHandler(store) registerMacKeyboardLayoutChangeNotifications() diff --git a/src/main/ipc/macos-keyboard-input-source.ts b/src/main/ipc/macos-keyboard-input-source.ts new file mode 100644 index 000000000000..41a1ad5a4e94 --- /dev/null +++ b/src/main/ipc/macos-keyboard-input-source.ts @@ -0,0 +1,152 @@ +import { spawn } from 'node:child_process' + +const KEYBOARD_INPUT_SOURCE_TIMEOUT_MS = 500 +const MAC_HITOOLBOX_DOMAIN = 'com.apple.HIToolbox' +// Why: defaults export reads live prefs (on-disk plist lags cfprefsd); xml1 dodges plutil's json abort on macOS 15 input-source arrays; absolute paths so a minimal PATH can't shadow the tools. +const MAC_SELECTED_INPUT_SOURCES_JSON_COMMAND = [ + `/usr/bin/defaults export ${MAC_HITOOLBOX_DOMAIN} -`, + '/usr/bin/plutil -extract AppleSelectedInputSources xml1 -o - -', + '/usr/bin/plutil -convert json -o - -' +].join(' | ') + +export function readCommandStdout( + command: string, + args: string[], + timeoutMessage: string +): Promise { + return new Promise((resolve, reject) => { + let settled = false + let child: ReturnType | undefined + + // Why: killing only the shell orphans pipeline stages; detached spawn lets one negative-pid SIGKILL reap the whole group. + const killTree = (): void => { + if (!child?.pid) { + return + } + try { + process.kill(-child.pid, 'SIGKILL') + } catch { + child.kill() + } + } + + // Why: short timeout so a wedged macOS probe never hangs; this timer owns the process-group kill. + const timer = setTimeout(() => { + if (settled) { + return + } + settled = true + killTree() + reject(new Error(timeoutMessage)) + }, KEYBOARD_INPUT_SOURCE_TIMEOUT_MS) + + const settle = (callback: () => void): void => { + if (settled) { + return + } + settled = true + clearTimeout(timer) + callback() + } + + try { + child = spawn(command, args, { detached: true, stdio: ['ignore', 'pipe', 'ignore'] }) + let stdout = '' + child.stdout?.setEncoding('utf8') + child.stdout?.on('data', (chunk: string) => { + stdout += chunk + }) + const failWith = (error: Error): void => { + killTree() + settle(() => reject(error)) + } + // Why: an unhandled Readable 'error' would crash the main process; treat stdout errors like spawn errors. + child.stdout?.on('error', failWith) + child.on('error', failWith) + child.on('close', (code, signal) => { + settle(() => + code === 0 + ? resolve(stdout) + : reject( + new Error( + `${command} exited with ${signal ? `signal ${signal}` : `code ${code ?? 'unknown'}`}` + ) + ) + ) + }) + } catch (error) { + settle(() => reject(error)) + } + }) +} + +type SelectedKeyboardInputSource = { kind: 'inputSource'; id: string } | { kind: 'keyboardLayout' } + +function readSelectedInputSourceFromJson(stdout: string): SelectedKeyboardInputSource | null { + let records: unknown + try { + records = JSON.parse(stdout) + } catch { + return null + } + if (!Array.isArray(records)) { + return null + } + + let hasSelectedKeyboardLayout = false + for (const record of records.slice().toReversed()) { + if (!record || typeof record !== 'object') { + continue + } + const kind = + 'InputSourceKind' in record && typeof record.InputSourceKind === 'string' + ? record.InputSourceKind.trim().toLowerCase() + : '' + if (kind === 'keyboard layout') { + hasSelectedKeyboardLayout = true + continue + } + if (kind.includes('non keyboard')) { + continue + } + if (kind !== 'input mode' && kind !== 'keyboard input method') { + return null + } + const inputMode = 'Input Mode' in record ? record['Input Mode'] : undefined + const bundleId = 'Bundle ID' in record ? record['Bundle ID'] : undefined + const id = typeof inputMode === 'string' && inputMode.trim() ? inputMode : bundleId + if (typeof id === 'string' && id.trim()) { + return { kind: 'inputSource', id: id.trim() } + } + return null + } + return hasSelectedKeyboardLayout ? { kind: 'keyboardLayout' } : null +} + +async function readSelectedKeyboardInputSource(): Promise { + try { + const stdout = await readCommandStdout( + '/bin/sh', + ['-c', MAC_SELECTED_INPUT_SOURCES_JSON_COMMAND], + 'Selected keyboard input source probe timed out' + ) + return readSelectedInputSourceFromJson(stdout) + } catch { + return null + } +} + +export async function readKeyboardInputSourceId(): Promise { + const selectedInputSource = await readSelectedKeyboardInputSource() + if (selectedInputSource?.kind === 'inputSource') { + return selectedInputSource.id + } + // An IME can use ABC underneath; the backing layout alone cannot identify the selected source. + return selectedInputSource?.kind === 'keyboardLayout' + ? readCommandStdout( + '/usr/bin/defaults', + ['read', MAC_HITOOLBOX_DOMAIN, 'AppleCurrentKeyboardLayoutInputSourceID'], + 'Keyboard layout input source probe timed out' + ) + : null +} From 04ac6d6cb24a0ba32dd0e53484043c83bc429c7d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 8 Oct 2026 11:57:51 -0700 Subject: [PATCH 17/90] Hold messages in the host until a chat's agent has finished starting (#26416) * feat(native-chat): host-owned startup attempt and hold-and-deliver barrier The host now mints one startup attempt before any adapter acquires (lease identity, spawn token, pinned launch context, cancellation, a 60 s deadline from the mint that activity never renews, option intent, and the event and child-work sinks), and the shared delivery loop hands nothing to a child still proving its start. A send is still accepted and journaled at once; readiness (the normalized `started` settlement) wakes delivery, which re-checks the child, fence, close and Stop before handover. A start that fails, crashes, or misses its deadline leaves held messages definitely unsent; a dispatch that throws after handover is `unknown`, never inferred unsent from the child's phase. * fix(native-chat): time agent starts from spawn by progress, and release Claude on initialize Review round 1 on the host-owned startup attempt. - The startup clock now starts when the process exists and fails a start only after 60 s with no stdout frame or stderr line, or 10 minutes after spawn however chatty it is. Every agent's child reports output through the one managed-process spawn, so a slow migration or history replay is no longer killed mid-way. Each start's spawn-to-outcome time is recorded as an `agentSession.startup` span for tuning. - Claude reports `started` on its initialize answer; the settings read follows and is applied and persisted as a later `options-reported` update, so messages no longer wait on it. - A started child's reported options are persisted in their own step after the held messages are handed over, and only if no pick changed the record's options since. - An attempt now ends whenever its child ends or an expiry stopped it. - Removed `optionsAsOf`, which was not an option revision, and comments that described the old publish-and-write-at-once behaviour. * fix(native-chat): keep the startup attempt's contract free of host and process code The adapter contract imported the attempt type from the module that also runs its clock and imports the host types, which pulled the model catalog and process-scan code into the mobile test typecheck. The attempt's shape and limits now live in a dependency-free contract module. * fix(native-chat): never let a stale option report undo a later pick A report of the child's options is now persisted only if no pick, no model-not-found heal and no newer report reached the chat since its read began. The host keeps one option revision per chat; the startup attempt hands the adapter a reader for it, and each report carries the revision it was read at. This replaces the record-snapshot check `started` had and covers the settings report, which had none, so a settings read answered just after a pick can no longer write the older model back. Also: the option read a ready child does inside its start now ends with the start (limit, Stop or close); a start that turns ready as the limit fires is recorded as ready; the startup-attempt host tests run under the Node SQLite runtime; and the retired-model test follows the new startup order. * fix(native-chat): require the option revision and order reports by arrival The host can no longer mint a startup attempt without the conversation's option revision, so no host path can stamp a report at a stale zero and drop every report after the first pick. Adapter tests may still omit it. A report is now dropped only when a pick or a value the child showed it cannot run came after its read. Reports are ordered among themselves by when the host takes them, and only the newest admitted one is persisted. The settings read's stamp therefore holds however late the host takes the start's report, instead of relying on that report being handled synchronously before the read begins. Tests: a settings read stamped before the host took the start's report is kept; a report read after a failed pick is persisted; the minted attempt's revision reader answers the host's current revision. --- .../scripts/vitest-sqlite-runtime-files.mjs | 2 + src/main/acp/acp-agent-connection.ts | 8 +- src/main/acp/acp-structured-acquire.ts | 1 + .../claude/claude-agent-sdk-process-spawn.ts | 6 +- .../claude/claude-stream-json-connection.ts | 4 +- .../claude-structured-applied-options.test.ts | 9 +- .../claude/claude-structured-init-proof.ts | 55 ++ .../claude/claude-structured-real-cli.test.ts | 61 ++ .../claude-structured-session-acquisition.ts | 11 +- .../claude-structured-session-adapter.test.ts | 2 +- ...aude-structured-session-journal-control.ts | 2 +- ...claude-structured-session-startup-state.ts | 5 +- .../claude-structured-session-startup.test.ts | 36 +- .../claude-structured-session-startup.ts | 269 ++++---- .../claude/claude-structured-session-state.ts | 4 +- .../codex-app-server-connection-types.ts | 2 + src/main/codex/codex-app-server-connection.ts | 3 +- .../codex/codex-structured-session-acquire.ts | 1 + src/main/jsonl-rpc/agent-connection.ts | 5 +- ...-agent-session-accept-then-deliver.test.ts | 28 +- ...structured-agent-session-acquire-aborts.ts | 8 +- ...-agent-session-acquisition-options.test.ts | 12 + ...-session-acquisition-runtime-stamp.test.ts | 1 + .../structured-agent-session-acquisition.ts | 46 +- .../structured-agent-session-adapter.ts | 49 +- ...tured-agent-session-adopted-import.test.ts | 1 + .../structured-agent-session-agent-start.ts | 15 +- ...structured-agent-session-attach-failure.ts | 36 + .../structured-agent-session-attach-flow.ts | 32 +- ...ured-agent-session-attach-orchestration.ts | 76 +-- .../structured-agent-session-child-exit.ts | 4 + ...ent-session-claude-hung-start-stop.test.ts | 41 +- .../structured-agent-session-command-turn.ts | 23 +- ...d-agent-session-conversation-close.test.ts | 12 +- ...red-agent-session-conversation-lifetime.ts | 33 +- ...ctured-agent-session-delivery-loop.test.ts | 2 +- .../structured-agent-session-delivery-loop.ts | 68 +- ...ructured-agent-session-drivability.test.ts | 1 + ...tured-agent-session-ended-child-failure.ts | 41 ++ ...structured-agent-session-event-recovery.ts | 7 +- ...structured-agent-session-host-mutations.ts | 2 + ...ctured-agent-session-host-runtime-state.ts | 27 +- .../structured-agent-session-host-types.ts | 4 + .../structured-agent-session-host.ts | 8 +- ...ctured-agent-session-mutation-admission.ts | 1 - ...ructured-agent-session-mutation-context.ts | 4 +- ...red-agent-session-mutation-turn-context.ts | 1 - ...ructured-agent-session-option-revisions.ts | 46 ++ ...ed-agent-session-owed-work-release.test.ts | 30 +- ...ent-session-pre-spawn-first-answer.test.ts | 1 + ...nt-session-processless-reservation.test.ts | 5 + ...gent-session-provider-child-record.test.ts | 57 +- ...d-agent-session-provider-history-window.ts | 28 + ...t-session-provider-started-catalog.test.ts | 3 +- ...ructured-agent-session-provider-started.ts | 162 +++-- ...session-queued-message-rig.test-fixture.ts | 32 +- ...session-send-restarts-failed-start.test.ts | 61 +- ...agent-session-start-failure-writer.test.ts | 8 +- ...-agent-session-startup-attempt-contract.ts | 43 ++ ...agent-session-startup-attempt-host.test.ts | 621 ++++++++++++++++++ ...sion-startup-attempt-registrations.test.ts | 192 ++++++ ...ured-agent-session-startup-attempt.test.ts | 250 +++++++ ...tructured-agent-session-startup-attempt.ts | 318 +++++++++ ...d-agent-session-stop-event-binding.test.ts | 3 +- ...tructured-agent-session-stop-event.test.ts | 9 +- .../structured-agent-session-turns.ts | 20 +- ...ured-agent-session-unrun-send-hold.test.ts | 37 +- .../agent-session-instrumentation.ts | 19 +- src/main/pi/rpc-session.ts | 1 + .../managed-provider-process-output.test.ts | 58 ++ .../managed-provider-process.ts | 23 + ...ude-structured-retired-saved-model.test.ts | 16 +- ...ude-structured-send-during-startup.test.ts | 40 +- ...-send-restart-dies-before-dispatch.test.ts | 9 +- ...tured-startup-fault-is-not-an-exit.test.ts | 8 +- .../structured-agent-session-at-rest.test.ts | 2 +- ...ured-agent-session-failure-logging.test.ts | 3 +- .../structured-claude-runtime-adapter.ts | 6 +- 78 files changed, 2643 insertions(+), 537 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-ended-child-failure.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-option-revisions.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-provider-history-window.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-contract.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-registrations.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt.ts create mode 100644 src/main/provider-process/managed-provider-process-output.test.ts diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index 64fd1a0181aa..a8b2112a1481 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -127,6 +127,8 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-shutdown-cut-row.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-registrations.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child-work.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child.test.ts', diff --git a/src/main/acp/acp-agent-connection.ts b/src/main/acp/acp-agent-connection.ts index d4f7eaf2ec25..b7a20520941c 100644 --- a/src/main/acp/acp-agent-connection.ts +++ b/src/main/acp/acp-agent-connection.ts @@ -13,6 +13,8 @@ export type AcpAgentConnectionOptions = Omit & peer?: Omit /** Process exit evidence, including expected closes and processless spawn failures. */ onExit?: (error: Error, context: { expected: boolean; exit: ProviderProcessExit }) => void + /** Any stdout or stderr chunk from the child. */ + onOutput?: () => void } /** The execution host owns the child and protocol lifetime; the adapter owns turns and Stop. */ @@ -37,7 +39,11 @@ export class AcpAgentConnection extends AcpSessionRuntime { ) { // Validate before spawning so invalid limits cannot leave an unowned child. const peer = resolveAcpPeerOptions({ ...options.peer, closeOnInputEnd: false }) - const managed = spawnManagedProviderProcess(launch, { spawnImpl, site: 'acp-agent-teardown' }) + const managed = spawnManagedProviderProcess(launch, { + spawnImpl, + site: 'acp-agent-teardown', + ...(options.onOutput ? { onOutput: options.onOutput } : {}) + }) const lifecycle: { closing: boolean; error?: Error } = { closing: false } const diagnose = (message: string): void => { try { diff --git a/src/main/acp/acp-structured-acquire.ts b/src/main/acp/acp-structured-acquire.ts index b9c8b41d209f..45097b006ee6 100644 --- a/src/main/acp/acp-structured-acquire.ts +++ b/src/main/acp/acp-structured-acquire.ts @@ -128,6 +128,7 @@ export async function acquireAcpStructuredSession(input: { }, { clientInfo: { name: 'orca', version: '1' }, + ...(acquire.onOutput ? { onOutput: acquire.onOutput } : {}), onPermission: (request, context) => { if (!session?.turns.acceptsRequests) { // No prompt of Orca's runs (a turn the agent began itself included), or a Stop or steer diff --git a/src/main/claude/claude-agent-sdk-process-spawn.ts b/src/main/claude/claude-agent-sdk-process-spawn.ts index 23bbe5950a0c..c117ee09b665 100644 --- a/src/main/claude/claude-agent-sdk-process-spawn.ts +++ b/src/main/claude/claude-agent-sdk-process-spawn.ts @@ -46,7 +46,8 @@ function definedEnv(env: Record): Record void ): ClaudeCodeProcessSpawn { let managed: ManagedProviderProcess | null = null return { @@ -64,7 +65,8 @@ export function createClaudeCodeProcessSpawn( inheritedEnv: definedEnv(options.env), site: 'claude-stream-json-teardown', policy: (supervised) => claudeChildClosePolicy(supervised, platform), - acceptClose: claudeChildCloseProven + acceptClose: claudeChildCloseProven, + ...(onOutput ? { onOutput } : {}) } ) // The SDK drains stderr only for its own local spawn; the managed process drains it here. diff --git a/src/main/claude/claude-stream-json-connection.ts b/src/main/claude/claude-stream-json-connection.ts index 8e178cb720a7..5f39b7acb48f 100644 --- a/src/main/claude/claude-stream-json-connection.ts +++ b/src/main/claude/claude-stream-json-connection.ts @@ -76,6 +76,8 @@ export type ClaudeStreamJsonConnectionHandlers = { /** The root process exited, reported once. `expected`: a close had begun, so it is that close's * end, even one that ran out of its own escalation first and came back unproven. */ onExit?: (error: Error, exit?: { expected: boolean }) => void + /** Any stdout or stderr chunk from the child. */ + onOutput?: () => void } /** @@ -132,7 +134,7 @@ export async function openClaudeStreamJsonConnection( queryImpl?: typeof ClaudeAgentSdk.query ): Promise { const { query } = await loadClaudeAgentSdk() - const spawner = createClaudeCodeProcessSpawn(spawnImpl) + const spawner = createClaudeCodeProcessSpawn(spawnImpl, process.platform, handlers.onOutput) const inbox = createClaudeUserMessageQueue() const session = (queryImpl ?? query)({ prompt: inbox.messages, diff --git a/src/main/claude/claude-structured-applied-options.test.ts b/src/main/claude/claude-structured-applied-options.test.ts index 1c3d0be57742..f4043c1fd489 100644 --- a/src/main/claude/claude-structured-applied-options.test.ts +++ b/src/main/claude/claude-structured-applied-options.test.ts @@ -125,8 +125,9 @@ describe('Claude model before the first turn', () => { const events: ClaudeStructuredSessionEvent[] = [] await acquired(startedWithoutATurn(SETTINGS.settingsHaiku), {}, events) - const started = events.find((event) => event.type === 'started') - expect(started).toMatchObject({ reportedOptions: { model: 'haiku' } }) + // The settings read follows `started`; what it shows is persisted as the later report. + const reported = events.find((event) => event.type === 'options-reported') + expect(reported).toMatchObject({ reportedOptions: { model: 'haiku' } }) }) it('shows the applied effort but never persists it as the session options', async () => { @@ -139,6 +140,10 @@ describe('Claude model before the first turn', () => { expect(started?.type === 'started' ? started.reportedOptions : null).not.toHaveProperty( 'effort' ) + const reported = events.find((event) => event.type === 'options-reported') + expect( + reported?.type === 'options-reported' ? reported.reportedOptions : null + ).not.toHaveProperty('effort') const { current } = await adapter.readOptions({ sessionId: 'session-1', fence: 7 }) expect(current.effort).toBe('medium') }) diff --git a/src/main/claude/claude-structured-init-proof.ts b/src/main/claude/claude-structured-init-proof.ts index f2754f4c1f0d..1a30d1c5e185 100644 --- a/src/main/claude/claude-structured-init-proof.ts +++ b/src/main/claude/claude-structured-init-proof.ts @@ -108,3 +108,58 @@ export function claudeAuthDiagnostic( settingSources: CLAUDE_DEFAULT_SETTING_SOURCES } } + +/** The CLI's own frame naming the session it runs (system/init or a SessionStart hook). Only a + * SessionStart hook sends one before the first turn, so startup takes it when it came and never + * waits for it. */ +export type ClaudeInitProof = { + promise: Promise + resolve: (init: ClaudeInitObservation) => void + reject: (error: Error) => void + /** A frame named another provider session. */ + refuse: () => void + /** The proof seen so far, or null; throws when it was refused or the child failed first. */ + seen: () => ClaudeInitObservation | null + /** Set once startup has read the proof: a later refusal ends the session. */ + onRefusal: ((error: Error) => void) | null +} + +export function createClaudeInitProof(): ClaudeInitProof { + let resolvePromise = (_init: ClaudeInitObservation): void => {} + let rejectPromise = (_error: Error): void => {} + const promise = new Promise((resolve, reject) => { + resolvePromise = resolve + rejectPromise = reject + }) + void promise.catch(() => {}) + let outcome: { init: ClaudeInitObservation } | { error: Error } | null = null + const reject = (error: Error): void => { + outcome ??= { error } + rejectPromise(error) + } + const proof: ClaudeInitProof = { + promise, + resolve: (init) => { + outcome ??= { init } + resolvePromise(init) + }, + reject, + refuse: () => { + // A session already proven by its own frame keeps that proof. + if (outcome && 'init' in outcome) { + return + } + const error = new Error('claude provider session expected') + reject(error) + proof.onRefusal?.(error) + }, + seen: () => { + if (outcome && 'error' in outcome) { + throw outcome.error + } + return outcome?.init ?? null + }, + onRefusal: null + } + return proof +} diff --git a/src/main/claude/claude-structured-real-cli.test.ts b/src/main/claude/claude-structured-real-cli.test.ts index 0e9345537bb1..96a8c7a1803d 100644 --- a/src/main/claude/claude-structured-real-cli.test.ts +++ b/src/main/claude/claude-structured-real-cli.test.ts @@ -338,6 +338,67 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => { 90_000 ) + // The host hands a Claude child nothing until `started`, which now follows the initialize answer + // alone. Measures what that wait costs against the live binary, so the startup limits are tuned + // from data: spawn to `started` with nothing sent, then a send to its first output. + it.skipIf(!realClaudeAuthenticated)( + 'reports `started` with nothing sent, then answers a send handed over after it', + async () => { + const providerSessionId = randomUUID() + const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), '.claude') + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = realAdapter( + providerSessionId, + claudeConfigDir, + events, + process.cwd(), + undefined, + realClaudeLaunchHome().env, + false + ) + const until = async (done: () => boolean, timeoutMs: number): Promise => { + const deadline = Date.now() + timeoutMs + while (!done() && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 20)) + } + return done() + } + try { + const acquireAt = Date.now() + await adapter.acquire({ + identity: identity(providerSessionId), + fence: 1, + spawnToken: 'real-cli-started-timing' + }) + const publishedAt = Date.now() + expect(await until(() => events.some((event) => event.type === 'started'), 30_000)).toBe( + true + ) + const startedAt = Date.now() + const before = events.length + await adapter.dispatch({ + sessionId: 'real-cli-handshake', + clientMessageId: 'real-cli-started-timing-1', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Say ok' }] }, + fence: 1 + }) + expect( + await until(() => events.slice(before).some((event) => event.type === 'message'), 60_000) + ).toBe(true) + const firstOutputAt = Date.now() + // Reported for tuning; the assertions above are the contract. + console.info( + `[real-cli startup timing] acquire->published ${publishedAt - acquireAt} ms, ` + + `published->started ${startedAt - publishedAt} ms, ` + + `dispatch->first output ${firstOutputAt - startedAt} ms` + ) + } finally { + await adapter.closeAll() + } + }, + 120_000 + ) + // The contract a chat's first message depends on: saved options ride the launch, and the // message is written as soon as the child is published, before the CLI answers initialize. it.skipIf(!realClaudeAuthenticated)( diff --git a/src/main/claude/claude-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts index e13c1aa0047d..c10b8d626075 100644 --- a/src/main/claude/claude-structured-session-acquisition.ts +++ b/src/main/claude/claude-structured-session-acquisition.ts @@ -11,6 +11,7 @@ import { resolveClaudeReplayTurn } from './claude-replay-turn-resolution' import { claudeSessionStateEndsTurn } from './claude-session-state-turn-over' import { settleClaudeTurnEndWaiters } from './claude-request-end-wait' import { + createClaudeInitProof, readClaudeCapabilities, readClaudeFrameString, readClaudeInit @@ -22,11 +23,11 @@ import { adoptClaudeStructuredSpawnOptions } from './claude-structured-spawn-opt import { createClaudeSessionJournalTranslator } from './claude-structured-journal-translation' import { observeClaudeFastModeFacts } from './claude-structured-session-options' import { - createClaudeInitProof, readClaudeStartupFacts, settleClaudeSessionStartup } from './claude-structured-session-startup' import { createClaudeSessionPublication } from './claude-structured-session-publication' +import { readClaudeStructuredSessionSettings } from './claude-structured-session-acquisition-options' import { mintClaudeAcquisitionGeneration, type ClaudeAcquisitionRegistry, @@ -193,6 +194,7 @@ export async function acquireClaudeSession({ onMessage, canUseTool, onUserDialog, + ...(input.onOutput ? { onOutput: input.onOutput } : {}), onFault: (error) => { childEnded ??= error initProof.reject(error) @@ -286,8 +288,12 @@ export async function acquireClaudeSession({ requestTimeoutMs: deps.requestTimeoutMs, emit }), + readSettings: () => readClaudeStructuredSessionSettings(connection, deps.requestTimeoutMs), isCurrent: () => sessions.get(sessionId) === session, fault: (error) => callbacks.handleExit(sessionId, attempt, error), + diagnose: (diagnostic) => emit({ type: 'auth-diagnostic', sessionId, diagnostic }), + // The host's minted attempt always carries it; only adapter tests that pass less omit it. + optionRevision: () => input.optionRevision?.() ?? 0, report: (event) => emit({ ...event, @@ -304,8 +310,7 @@ export async function acquireClaudeSession({ exits.get(sessionId)?.error ?? new Error('claude session ended before acquisition returned') ) } - // The start reads its facts only after publish, so the child is `starting` until `started` - // says otherwise; it already takes input. + // The start reads its facts only after publish, so the child is `starting` until `started`. return { ...publication.acquisition, providerChildPhase: 'starting' } } catch (error) { unbindReadingControl?.() diff --git a/src/main/claude/claude-structured-session-adapter.test.ts b/src/main/claude/claude-structured-session-adapter.test.ts index cc59dfde3017..0897917718da 100644 --- a/src/main/claude/claude-structured-session-adapter.test.ts +++ b/src/main/claude/claude-structured-session-adapter.test.ts @@ -130,7 +130,7 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { ).toEqual([]) // Left out, not refused: the record takes the provider's own Fast, as before. expect(adapter.readOptionRestoreFailures('session-1')).toEqual([]) - expect(events.find((event) => event.type === 'started')).toMatchObject({ + expect(events.find((event) => event.type === 'options-reported')).toMatchObject({ reportedOptions: { fastMode: false } }) }) diff --git a/src/main/claude/claude-structured-session-journal-control.ts b/src/main/claude/claude-structured-session-journal-control.ts index 9e3ee0289cf4..796e75c35f95 100644 --- a/src/main/claude/claude-structured-session-journal-control.ts +++ b/src/main/claude/claude-structured-session-journal-control.ts @@ -5,7 +5,7 @@ import { import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import type { ClaudeStreamJsonConnection } from './claude-stream-json-connection' import type { ClaudeJournalTranslator } from './claude-journal-translator-contract' -import type { ClaudeInitProof } from './claude-structured-session-startup' +import type { ClaudeInitProof } from './claude-structured-init-proof' import type { ClaudeAcquisitionAttempt, ClaudeAcquireCallbacks diff --git a/src/main/claude/claude-structured-session-startup-state.ts b/src/main/claude/claude-structured-session-startup-state.ts index adb363339ddd..2ece1890fe82 100644 --- a/src/main/claude/claude-structured-session-startup-state.ts +++ b/src/main/claude/claude-structured-session-startup-state.ts @@ -1,7 +1,6 @@ // Where a Claude start stands. A session is published once its child is spawned, launched with the -// chat's saved options, before the CLI has answered initialize. Messages are written to it at once -// (the CLI queues them behind its start); only an option write, a control request initialize must -// answer first, waits for startup to land. +// chat's saved options, before the CLI has answered initialize. The host hands it no message until +// `started`; an option write, a control request initialize must answer first, waits here too. import type { SubmissionRejectionFact } from '../../shared/agent-session-failure' import { providerStartupFailureFact } from '../native-chat/agent-session-wire/structured-agent-session-failure-text' diff --git a/src/main/claude/claude-structured-session-startup.test.ts b/src/main/claude/claude-structured-session-startup.test.ts index 58a81a2ca6f4..0c01bcd51f0f 100644 --- a/src/main/claude/claude-structured-session-startup.test.ts +++ b/src/main/claude/claude-structured-session-startup.test.ts @@ -69,6 +69,38 @@ describe('Claude structured session publishes before the CLI answers initialize' await adapter.closeAll() }) + it('reports `started` on the initialize answer, never waiting on the settings read', async () => { + const SLOW_SETTINGS_MS = 25_000 + const claude = fakeClaude({ + initDelayMs: SLOW_INIT_MS, + // The fake answers `get_settings` with whatever this resolves to, when it resolves. + settings: new Promise((resolve) => + setTimeout( + () => resolve({ effective: { fastMode: true }, applied: { model: 'claude-haiku-4-5' } }), + SLOW_INIT_MS + SLOW_SETTINGS_MS + ) + ) + }) + const { adapter, events } = startingAdapter(claude) + await adapter.acquire(ACQUIRE) + + await vi.advanceTimersByTimeAsync(SLOW_INIT_MS) + + expect(claude.connections[0].calls.map((call) => call.subtype)).toContain('get_settings') + expect(events.filter((event) => event.type === 'started')).toHaveLength(1) + expect(events.some((event) => event.type === 'options-reported')).toBe(false) + expect(events.some((event) => event.type === 'auth-diagnostic')).toBe(false) + + await vi.advanceTimersByTimeAsync(SLOW_SETTINGS_MS) + await claudeStartupSettled(adapter, 'session-1') + + expect(events.find((event) => event.type === 'options-reported')).toMatchObject({ + reportedOptions: { model: 'claude-haiku-4-5', fastMode: true } + }) + expect(events.some((event) => event.type === 'auth-diagnostic')).toBe(true) + await adapter.closeAll() + }) + it('reports `started` with the options its child was launched with, having written none', async () => { const claude = fakeClaude({ initDelayMs: SLOW_INIT_MS, initModel: 'claude-opus-9' }) const { adapter, events } = startingAdapter(claude) @@ -87,7 +119,9 @@ describe('Claude structured session publishes before the CLI answers initialize' acquisitionGeneration: expect.any(String), // What the child was launched with, carried so the host never asks the CLI again. reportedOptions: expect.objectContaining({ model: 'opus' }), - restoreSkippedOptions: [] + restoreSkippedOptions: [], + // No host revision handed in: nothing has moved it. + optionRevision: 0 }) expect(claude.connections[0].calls.map(({ subtype }) => subtype)).toEqual([ 'initialize', diff --git a/src/main/claude/claude-structured-session-startup.ts b/src/main/claude/claude-structured-session-startup.ts index 423338b90344..a40d22c25c00 100644 --- a/src/main/claude/claude-structured-session-startup.ts +++ b/src/main/claude/claude-structured-session-startup.ts @@ -1,7 +1,8 @@ -// What Claude reports at initialize, read in the background once the session is published. None of -// it gates the create or a message: the child was launched with the chat's saved options and takes -// input at once. Every way the start can fail (exit, auth, a foreign session id) faults the -// published session through its exit path; a CLI that never answers is ended by a Stop or a close. +// What Claude reports at initialize, read once the session is published. The initialize answer is +// the start: `started` follows it, and the host hands the child nothing before. The settings read +// after it is optional and never delays that; what it reports is applied as a later options update. +// Every way the start can fail (exit, auth, a foreign session id) faults the published session +// through its exit path; one that stops making progress is ended by the host's startup limit. import type { AgentSessionAccountKind } from '../../shared/agent-session-availability' import type { @@ -15,104 +16,61 @@ import { claudeInitializationAuthError, readClaudeCapabilities, readClaudeModels, - type ClaudeInitObservation + type ClaudeInitObservation, + type ClaudeInitProof } from './claude-structured-init-proof' import { claudeStructuredSessionPublicationOptions, - prepareClaudeStructuredSessionAcquisitionOptions, - readClaudeStructuredSessionSettings + prepareClaudeStructuredSessionAcquisitionOptions } from './claude-structured-session-acquisition-options' import { claudeRetiredOptions } from './claude-structured-retired-model' import { claudeStructuredSessionOptionsFrom, observeClaudeSettingsApplied, + readClaudeFastModeFacts, readClaudeSettingsEffort } from './claude-structured-session-options' import { failClaudeStartup, type ClaudeSessionStartup } from './claude-structured-session-startup-state' -import type { ClaudeSession, ClaudeStructuredSessionEvent } from './claude-structured-session-state' +import type { + ClaudeAuthDiagnostic, + ClaudeSession, + ClaudeStructuredSessionEvent +} from './claude-structured-session-state' import { admitClaudeStartFastMode, applyClaudeStartFastMode } from './claude-structured-start-fast-mode' -/** The CLI's own frame naming the session it runs (system/init or a SessionStart hook). Only a - * SessionStart hook sends one before the first turn, so startup takes it when it came and never - * waits for it. */ -export type ClaudeInitProof = { - promise: Promise - resolve: (init: ClaudeInitObservation) => void - reject: (error: Error) => void - /** A frame named another provider session. */ - refuse: () => void - /** The proof seen so far, or null; throws when it was refused or the child failed first. */ - seen: () => ClaudeInitObservation | null - /** Set once startup has read the proof: a later refusal ends the session. */ - onRefusal: ((error: Error) => void) | null -} - -export function createClaudeInitProof(): ClaudeInitProof { - let resolvePromise = (_init: ClaudeInitObservation): void => {} - let rejectPromise = (_error: Error): void => {} - const promise = new Promise((resolve, reject) => { - resolvePromise = resolve - rejectPromise = reject - }) - void promise.catch(() => {}) - let outcome: { init: ClaudeInitObservation } | { error: Error } | null = null - const reject = (error: Error): void => { - outcome ??= { error } - rejectPromise(error) - } - const proof: ClaudeInitProof = { - promise, - resolve: (init) => { - outcome ??= { init } - resolvePromise(init) - }, - reject, - refuse: () => { - // A session already proven by its own frame keeps that proof. - if (outcome && 'init' in outcome) { - return - } - const error = new Error('claude provider session expected') - reject(error) - proof.onRefusal?.(error) - }, - seen: () => { - if (outcome && 'error' in outcome) { - throw outcome.error - } - return outcome?.init ?? null - }, - onRefusal: null - } - return proof -} - export type StructuredAgentSessionStartedOptions = Pick< StructuredAgentSessionStartedEvent, - 'reportedOptions' | 'restoreSkippedOptions' | 'retiredOptions' + 'reportedOptions' | 'restoreSkippedOptions' | 'retiredOptions' | 'optionRevision' > /** A lifecycle event the start reports, before the session's identity is stamped on it. */ export type ClaudeStartupReport = - | ({ type: 'started' } & StructuredAgentSessionStartedOptions) + | ({ + type: 'started' | 'options-reported' + } & StructuredAgentSessionStartedOptions) | Pick -export type ClaudeStartupFacts = { +/** What the initialize answer proved: enough to start. */ +export type ClaudeInitializeFacts = { init: ClaudeInitObservation | null initProof: ClaudeInitProof initialization: unknown - settings: unknown - prepared: ReturnType resumesTranscript: boolean requestTimeoutMs: number | undefined } +/** The initialize facts and the optional settings read that followed them. */ +export type ClaudeStartupFacts = ClaudeInitializeFacts & { + settings: unknown + prepared: ReturnType +} + /** Settles on the CLI's initialize answer, or on its exit or a refused proof. */ export async function readClaudeStartupFacts(input: { connection: ClaudeStreamJsonConnection @@ -124,7 +82,7 @@ export async function readClaudeStartupFacts(input: { account?: AgentSessionAccountKind requestTimeoutMs: number | undefined emit: (event: ClaudeStructuredSessionEvent) => void -}): Promise { +}): Promise { // The CLI's first answer has no request deadline of its own; the reads after it do. const initialization = await Promise.race([ input.connection.initializationResult().then((result) => { @@ -145,39 +103,50 @@ export async function readClaudeStartupFacts(input: { sessionId: input.sessionId, models: readClaudeModels(initialization) }) - input.initProof.seen() - const settings = await readClaudeStructuredSessionSettings( - input.connection, - input.requestTimeoutMs - ) - // Read again: a frame naming another session may have come during the settings read. - const init = input.initProof.seen() - input.emit({ - type: 'auth-diagnostic', - sessionId: input.sessionId, - diagnostic: claudeAuthDiagnostic(initialization, init, settings) - }) return { - init, + init: input.initProof.seen(), initProof: input.initProof, initialization, - settings, - prepared: prepareClaudeStructuredSessionAcquisitionOptions({ settings, initialization }), resumesTranscript: input.resumesTranscript, requestTimeoutMs: input.requestTimeoutMs } } -function applyClaudeStartupFacts(session: ClaudeSession, facts: ClaudeStartupFacts): void { - const { init, initialization, settings, prepared } = facts - const effort = readClaudeSettingsEffort(settings) - const published = claudeStructuredSessionPublicationOptions(prepared) +function applyClaudeInitializeFacts(session: ClaudeSession, facts: ClaudeInitializeFacts): void { + const { init, initialization } = facts // A turn's own init frame may already have reported the running model. if (init?.model && session.reportedOptions.model === undefined) { session.reportedOptions.model = init.model session.reportedModelMutation = session.optionMutationSequence } + const fastModeFacts = readClaudeFastModeFacts(initialization) + session.fastModeState ??= fastModeFacts.state + session.fastModeDisabledReason ??= fastModeFacts.disabledReason + session.capabilities = readClaudeCapabilities(session.capabilities, initialization, init?.message) + // A catalog frame that streamed in after publish is newer than the initialize answer. + if (session.commands.commands === undefined) { + session.commands = new ClaudeSlashCommandCatalog(init?.message, initialization) + } + session.events?.publish() +} + +/** Settings read after `started`. Values a write made since the read began own are left alone. */ +function applyClaudeSettingsFacts( + session: ClaudeSession, + facts: ClaudeStartupFacts, + writtenSinceRead: boolean +): void { + const { settings, prepared } = facts + const effort = readClaudeSettingsEffort(settings) + const published = claudeStructuredSessionPublicationOptions(prepared) observeClaudeSettingsApplied(session, settings) + if (published.fastModePerSessionOptIn !== null) { + session.fastModePerSessionOptIn = published.fastModePerSessionOptIn + } + if (writtenSinceRead) { + session.events?.publish() + return + } // The readback vouches for a value the child was launched with only when it reports that value. const agrees = (key: string, reported: string): boolean => !session.options.has(key) || session.options.get(key) === reported @@ -198,16 +167,6 @@ function applyClaudeStartupFacts(session: ClaudeSession, facts: ClaudeStartupFac session.confirmedOptions.add('fastMode') } } - if (published.fastModePerSessionOptIn !== null) { - session.fastModePerSessionOptIn = published.fastModePerSessionOptIn - } - session.fastModeState ??= published.fastModeState - session.fastModeDisabledReason ??= published.fastModeDisabledReason - session.capabilities = readClaudeCapabilities(session.capabilities, initialization, init?.message) - // A catalog frame that streamed in after publish is newer than the initialize answer. - if (session.commands.commands === undefined) { - session.commands = new ClaudeSlashCommandCatalog(init?.message, initialization) - } session.events?.publish() } @@ -215,9 +174,10 @@ function applyClaudeStartupFacts(session: ClaudeSession, facts: ClaudeStartupFac * it would pin an effort nobody chose on every reopen, past a later settings change. */ function claudeStartedReportedOptions( session: ClaudeSession, - catalog: unknown[] + catalog: unknown[], + readMutationSequence = session.optionMutationSequence ): StructuredAgentSessionStartedOptions['reportedOptions'] { - const { current } = claudeStructuredSessionOptionsFrom(session, catalog) + const { current } = claudeStructuredSessionOptionsFrom(session, catalog, readMutationSequence) if (session.options.has('effort') || session.reportedOptions.effort !== undefined) { return current } @@ -225,15 +185,21 @@ function claudeStartedReportedOptions( return persisted } -/** Applies startup facts to the published session, which already takes input. Any failure faults - * the session so the user sees why it never started. */ +/** Applies the initialize answer and reports `started`, so the host hands the child what it holds; + * then reads the optional settings and reports what they add. A failure before `started` faults + * the session so the user sees why it never started; the settings read can fail nothing. */ export async function settleClaudeSessionStartup(input: { session: ClaudeSession - facts: Promise + facts: Promise + /** Never rejects: an unreadable settings answer reads as null. */ + readSettings: () => Promise isCurrent: () => boolean fault: (error: Error) => void + diagnose: (diagnostic: ClaudeAuthDiagnostic) => void + /** The host's option revision now, stamped on each report as its read begins. */ + optionRevision: () => number /** `started` once startup has proven, with what the child now reports, snapshotted from memory; - * then any saved option the child showed it cannot run. */ + * then what the settings add, and any saved option the child showed it cannot run. */ report: (event: ClaudeStartupReport) => void }): Promise { const { session } = input @@ -244,41 +210,36 @@ export async function settleClaudeSessionStartup(input: { failClaudeStartup(session, new Error('claude session closed before startup completed')) return true } + let initialized: ClaudeInitializeFacts try { - const facts = await input.facts + initialized = await input.facts if (superseded()) { return } // From here no read of the proof is pending, so a frame naming another session ends it. - facts.initProof.onRefusal = (error) => { + initialized.initProof.onRefusal = (error) => { failClaudeStartup(session, error) if (input.isCurrent()) { input.fault(error) } } - applyClaudeStartupFacts(session, facts) - const startFastMode = admitClaudeStartFastMode(session, facts) - if (!superseded()) { - input.report({ - type: 'started', - // `list_models` is answered from this same initialize result, so nothing is re-read. - reportedOptions: claudeStartedReportedOptions( - session, - readClaudeModels(facts.initialization) - ), - restoreSkippedOptions: [...session.restoreSkippedOptions], - ...claudeRetiredOptions(session) - }) - if (session.startup.state === 'pending') { - session.startup.state = 'proven' - } - if (startFastMode !== null) { - void applyClaudeStartFastMode(session, facts, startFastMode, (event) => { - if (input.isCurrent()) { - input.report(event) - } - }) - } + applyClaudeInitializeFacts(session, initialized) + input.report({ + type: 'started', + // `list_models` is answered from this same initialize result, so nothing is re-read. A + // saved Fast the launch left out is decided once settings are read, so this read settles + // none of it (an older sequence never rewrites an option). + reportedOptions: claudeStartedReportedOptions( + session, + readClaudeModels(initialized.initialization), + session.fastModeAtStart ? session.optionMutationSequence - 1 : undefined + ), + restoreSkippedOptions: [...session.restoreSkippedOptions], + ...claudeRetiredOptions(session), + optionRevision: input.optionRevision() + }) + if (session.startup.state === 'pending') { + session.startup.state = 'proven' } } catch (caught) { const error = caught instanceof Error ? caught : new Error(String(caught)) @@ -288,5 +249,55 @@ export async function settleClaudeSessionStartup(input: { if (!endedElsewhere && input.isCurrent()) { input.fault(error) } + return + } + await settleClaudeStartupSettings(input, initialized) +} + +async function settleClaudeStartupSettings( + input: Parameters[0], + initialized: ClaudeInitializeFacts +): Promise { + const { session } = input + const sequence = session.optionMutationSequence + // Only a pick moves it, so this stamp holds however late the host takes `started`. + const optionRevision = input.optionRevision() + const settings = await input.readSettings() + if (!input.isCurrent()) { + return + } + let init: ClaudeInitObservation | null + try { + // Read again: a frame naming the session may have come since; a refusal is already ending it. + init = initialized.initProof.seen() + } catch { + return + } + const facts: ClaudeStartupFacts = { + ...initialized, + init, + settings, + prepared: prepareClaudeStructuredSessionAcquisitionOptions({ + settings, + initialization: initialized.initialization + }) + } + input.diagnose(claudeAuthDiagnostic(facts.initialization, facts.init, settings)) + const writtenSinceRead = sequence !== session.optionMutationSequence + applyClaudeSettingsFacts(session, facts, writtenSinceRead) + const startFastMode = writtenSinceRead ? null : admitClaudeStartFastMode(session, facts) + input.report({ + type: 'options-reported', + reportedOptions: claudeStartedReportedOptions(session, readClaudeModels(facts.initialization)), + restoreSkippedOptions: [...session.restoreSkippedOptions], + ...claudeRetiredOptions(session), + optionRevision + }) + if (startFastMode !== null) { + void applyClaudeStartFastMode(session, facts, startFastMode, (event) => { + if (input.isCurrent()) { + input.report(event) + } + }) } } diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index e327e2316219..096a3a4c6724 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -7,6 +7,7 @@ import type { } from '../../shared/agent-session-journal-types' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import type { + StructuredAgentSessionOptionsReportedEvent, StructuredAgentSessionOptionsSkippedEvent, StructuredAgentSessionStartedEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' @@ -68,8 +69,9 @@ export type ClaudeStructuredSessionEvent = fence: number } | { type: 'auth-diagnostic'; sessionId: string; diagnostic: ClaudeAuthDiagnostic } - /** Startup facts applied; the child already took any message written to it. */ + /** The CLI answered initialize: the host may hand the child input from here. */ | StructuredAgentSessionStartedEvent + | StructuredAgentSessionOptionsReportedEvent | StructuredAgentSessionOptionsSkippedEvent | { type: 'ended' diff --git a/src/main/codex/codex-app-server-connection-types.ts b/src/main/codex/codex-app-server-connection-types.ts index bf33bc5d0b80..9e50a5233133 100644 --- a/src/main/codex/codex-app-server-connection-types.ts +++ b/src/main/codex/codex-app-server-connection-types.ts @@ -13,6 +13,8 @@ export type CodexAppServerConnectionHandlers = { onExit?: (error: Error, exit?: { expected: boolean }) => void /** Awaited once the child has a pid and before the handshake; a rejection reaps the child. */ onSpawned?: (pid: number) => Promise + /** Any stdout or stderr chunk from the child. */ + onOutput?: () => void } export type CodexAppServerConnection = { diff --git a/src/main/codex/codex-app-server-connection.ts b/src/main/codex/codex-app-server-connection.ts index e144f1314224..4efcbaf30ba3 100644 --- a/src/main/codex/codex-app-server-connection.ts +++ b/src/main/codex/codex-app-server-connection.ts @@ -47,7 +47,8 @@ export async function openCodexAppServerConnection( ): Promise { const managed = spawnManagedProviderProcess(launch, { spawnImpl, - site: 'codex-app-server-teardown' + site: 'codex-app-server-teardown', + ...(handlers.onOutput ? { onOutput: handlers.onOutput } : {}) }) const { child, terminateTree: terminateProcessTree } = managed diff --git a/src/main/codex/codex-structured-session-acquire.ts b/src/main/codex/codex-structured-session-acquire.ts index 1dbdde663b41..5171e92a1a0f 100644 --- a/src/main/codex/codex-structured-session-acquire.ts +++ b/src/main/codex/codex-structured-session-acquire.ts @@ -166,6 +166,7 @@ export async function acquireCodexStructuredSession(input: { Buffer.byteLength(JSON.stringify(payload ?? null), 'utf8') ), onSpawned: spawnIdentity.onSpawned, + ...(acquireInput.onOutput ? { onOutput: acquireInput.onOutput } : {}), onExit: (error, exit) => { try { handleCodexSessionExit({ diff --git a/src/main/jsonl-rpc/agent-connection.ts b/src/main/jsonl-rpc/agent-connection.ts index 65bd6cfc9ad3..89be90dc6e4c 100644 --- a/src/main/jsonl-rpc/agent-connection.ts +++ b/src/main/jsonl-rpc/agent-connection.ts @@ -17,6 +17,8 @@ import { resolveJsonlRpcPeerOptions, type JsonlRpcPeerOptions } from './peer-lim export type JsonlRpcAgentConnectionOptions = JsonlRpcPeerHandlers & { peer?: JsonlRpcPeerOptions onExit?: (error: Error, context: { expected: boolean; exit: ProviderProcessExit }) => void + /** Any stdout or stderr chunk from the child. */ + onOutput?: () => void } /** The owning runtime keeps process exit evidence separate from transport closure. */ @@ -35,7 +37,8 @@ export class JsonlRpcAgentConnection { const peerOptions = resolveJsonlRpcPeerOptions(options.peer) this.managed = spawnManagedProviderProcess(launch, { spawnImpl, - site: 'jsonl-rpc-agent-teardown' + site: 'jsonl-rpc-agent-teardown', + ...(options.onOutput ? { onOutput: options.onOutput } : {}) }) const managed = this.managed this.peer = new JsonlRpcPeer( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts index 263de102f532..29a033a4e7b9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts @@ -53,6 +53,8 @@ import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router- const CALLER = { callerKey: 'client-1' } +const childPhase = () => host.collaboratorsForTests().sessions.get(SESSION)?.child?.phase + function eventually(assertion: () => void | Promise): Promise { return vi.waitFor(assertion, { timeout: 10_000 }) } @@ -624,11 +626,9 @@ describe('a child that exits before its message is handed over', () => { }) }) -describe('a start that fails after it was handed messages', () => { +describe('a start that fails while messages wait on it', () => { it('keeps one row in the words its rejected messages carry', async () => { - // A starting child takes the first at once; the second waits until that turn opens, which it - // never does. Neither is answered. - dispatch.mockImplementation(async () => ({ state: 'admitted' as const })) + // Both are held for a starting child that never proves its start. acquire.mockImplementation(async (input) => ({ ...(await spawnChild(input)), providerChildPhase: 'starting' as const @@ -638,7 +638,7 @@ describe('a start that fails after it was handed messages', () => { const first = await accept('first') const second = await accept('second') - await eventually(() => expect(dispatch).toHaveBeenCalledTimes(1)) + await eventually(() => expect(childPhase()).toBe('starting')) await host.handleAdapterEvent({ type: 'ended', sessionId: SESSION, @@ -653,7 +653,7 @@ describe('a start that fails after it was handed messages', () => { }) await host.flushStreamedEvents(SESSION) - // The held message is rejected by the delivery loop's next step, after the start's own. + // Both are rejected by the delivery loop's next step, never having been handed over. await eventually(async () => expect((await submission(second))?.dispatchState).toBe('rejected')) const rows = (await host.journalSnapshot(SESSION)).items.filter((item) => item.itemId.includes('start-failure') @@ -671,6 +671,7 @@ describe('a start that fails after it was handed messages', () => { rejection: row.kind === 'status' ? row.failure : null }) } + expect(dispatch).not.toHaveBeenCalled() }) }) @@ -724,7 +725,7 @@ describe('Stop withdraws what is queued', () => { expect(dispatch).not.toHaveBeenCalled() }) - it('stops a child still proving its start, rejecting what it was handed, and the next send starts another (W17c)', async () => { + it('stops a child still proving its start, withdrawing what was held for it, and the next send starts another (W17c)', async () => { const closeSession = vi.fn(async () => true) adapterExtras = { closeSession } await host.close(SESSION, 'evict') @@ -733,10 +734,10 @@ describe('Stop withdraws what is queued', () => { ...(await spawnChild(input)), providerChildPhase: 'starting' as const })) - // Written to the starting child at once; it never answers. - dispatch.mockResolvedValueOnce({ state: 'admitted' }) + // Held for the starting child, which never proves its start. const id = await accept('hello') - await eventually(async () => expect((await submission(id))?.handedOverAt).toBeDefined()) + await eventually(() => expect(childPhase()).toBe('starting')) + expect((await submission(id))?.handedOverAt).toBeUndefined() expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) @@ -747,7 +748,7 @@ describe('Stop withdraws what is queued', () => { expect(closeSession).toHaveBeenCalled() const next = await accept('after stop') await eventually(async () => expect((await submission(next))?.dispatchState).toBe('accepted')) - expect(dispatch).toHaveBeenCalledTimes(2) + expect(dispatch).toHaveBeenCalledTimes(1) }) }) @@ -827,10 +828,7 @@ describe('a close that stops the child and then a wind-down step fails', () => { evict: () => host.close(SESSION, 'evict') } satisfies Partial Promise>> - it.each([ - { end: 'evict', starting: true, kind: 'chatClosed', verdict: null }, - { end: 'evict', starting: false, kind: 'chatClosed', verdict: null } - ] as const)( + it.each([{ end: 'evict', starting: true, kind: 'chatClosed', verdict: null }] as const)( 'rejects what is queued as $kind after a $end (during startup: $starting)', async ({ end, starting, kind, verdict }) => { const started = deferred() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquire-aborts.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquire-aborts.ts index a3eabd09f0e5..9fdc5e9a72b4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-acquire-aborts.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquire-aborts.ts @@ -1,7 +1,7 @@ // The acquire each session has in flight, owned by the host that runs it, or another provider wait // its queue is on (an option write). The queue runs one step at a time, so a session has at most -// one; a close, a Stop admitted now, or quit aborts it from outside the queue instead of waiting -// behind a provider that may never answer. +// one; a close, a Stop admitted now, quit, or the startup limit aborts it from outside the queue +// instead of waiting behind a provider that may never answer. export class StructuredAgentSessionAcquireAborts { private readonly inFlight = new Map() @@ -26,8 +26,8 @@ export class StructuredAgentSessionAcquireAborts { } /** A no-op when the session has nothing in flight. */ - abort(sessionId: string, reason: string): void { - this.inFlight.get(sessionId)?.abort(new Error(reason)) + abort(sessionId: string, reason: string | Error): void { + this.inFlight.get(sessionId)?.abort(typeof reason === 'string' ? new Error(reason) : reason) } /** Quit: every start under way stops, and so does any the attach drain still runs. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts index ffb50f0ef99b..495b7c629989 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts @@ -166,6 +166,7 @@ describe('structured session acquisition options', () => { probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', + optionRevision: () => 0, params: attachParams(CREATE_OPERATION, null), now: () => NOW, onAttached: (attached) => { @@ -205,6 +206,7 @@ describe('structured session acquisition options', () => { probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', + optionRevision: () => 0, params: attachParams(RESUME_OPERATION, releasedFence), now: () => NOW + 1, onAttached: () => {} @@ -238,6 +240,7 @@ describe('structured session acquisition options', () => { probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', + optionRevision: () => 0, params: attachParams(CREATE_OPERATION, null, options), now: () => NOW, recordPhase, @@ -269,6 +272,7 @@ describe('structured session acquisition options', () => { probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', + optionRevision: () => 0, params: attachParams(CREATE_OPERATION, null, options), now: () => NOW, onAttached: () => {} @@ -301,6 +305,7 @@ describe('structured session acquisition options', () => { probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', + optionRevision: () => 0, params: attachParams(CREATE_OPERATION, null), now: () => NOW, onAttached: () => {} @@ -339,6 +344,7 @@ describe('structured session acquisition options', () => { probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', + optionRevision: () => 0, params: attachParams(RESUME_OPERATION, releasedFence), now: () => NOW + 1, onAttached: () => {} @@ -377,6 +383,7 @@ describe('structured session acquisition options', () => { probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', + optionRevision: () => 0, params: attachParams(CREATE_OPERATION, null, { model: 'gpt-standard', fastMode: 'true' @@ -413,6 +420,7 @@ describe('structured session acquisition options', () => { probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', + optionRevision: () => 0, params: attachParams(CREATE_OPERATION, null, { model: 'gpt-saved', effort: 'medium' }), now: () => NOW, onAttached: () => {} @@ -449,6 +457,7 @@ describe('structured session acquisition options', () => { probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', + optionRevision: () => 0, params: attachParams(CREATE_OPERATION, null, options), now: () => NOW, onAttached: () => {} @@ -485,6 +494,7 @@ describe('structured session acquisition options', () => { probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', + optionRevision: () => 0, params: attachParams(CREATE_OPERATION, null), now: () => NOW, onAttached: () => {} @@ -580,6 +590,7 @@ describe('structured session acquisition options', () => { probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', + optionRevision: () => 0, params: attachParams(operationId, fence), now: () => NOW, onAttached: () => {} @@ -680,6 +691,7 @@ describe('the tab a create reserves', () => { probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', + optionRevision: () => 0, // Beside the fingerprinted fields, like `options`: which tab shows the chat is not which // conversation this attaches to. params: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-runtime-stamp.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-runtime-stamp.test.ts index 90c94306db1d..d47f8650abca 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-runtime-stamp.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-runtime-stamp.test.ts @@ -114,6 +114,7 @@ it('starts a chat whose recorded owner carries the runtime stamp', async () => { probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', + optionRevision: () => 0, params: attachParams(), now: () => NOW, onAttached: () => {} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts index 70019d6da3e5..6b87efc267d1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts @@ -1,4 +1,5 @@ import { isDeepStrictEqual } from 'node:util' +import { waitForPromiseWithSignal } from '../../../shared/abort-signal-reason' import type { AgentSessionProcessIdentity, AgentSessionRecord @@ -13,6 +14,7 @@ import { journalIdentityFor } from './structured-agent-session-attach' import type { AttachFlowInput } from './structured-agent-session-attach-flow' import { readNativeSessionOptions } from './structured-agent-session-option-restoration' import { withAgentSessionCreatePhase } from '../../observability/agent-session-instrumentation' +import { mintStructuredAgentSessionStartupAttempt } from './structured-agent-session-startup-attempt' /** The same process, whatever Orca runtime the store stamped on its record (`runtime`): that stamp * is about who holds the process, not which process it is. */ @@ -48,16 +50,22 @@ export async function acquireOwner( } catch (error) { throw new AgentSessionPreSpawnError(error) } - const acquired = await input.adapter.acquire({ + const attempt = mintStructuredAgentSessionStartupAttempt({ + record, identity: journalIdentityFor(record, input.params), - fence, // Retries must recover the original reservation, not mint a second child. spawnToken, - ...(record.options ? { options: record.options } : {}), ...(input.eventSink ? { events: input.eventSink } : {}), - ...(input.recordPhase ? { recordPhase: input.recordPhase } : {}), ...(input.acquireSignal ? { signal: input.acquireSignal } : {}), + optionRevision: input.optionRevision + }) + const progress = input.onStartupAttempt?.(attempt) + const acquired = await input.adapter.acquire({ + ...attempt, + ...(progress ? { onOutput: progress.output } : {}), + ...(input.recordPhase ? { recordPhase: input.recordPhase } : {}), onSpawned: async (process) => { + progress?.spawned() record = await input.store.commitProcessIdentity({ sessionId: record.sessionId, fence, @@ -72,20 +80,22 @@ export async function acquireOwner( const options = providerChildPhase === 'starting' ? undefined - : await withAgentSessionCreatePhase('restore_options', input.recordPhase, async () => - input.adapter.readAcquisitionOptions - ? input.adapter.readAcquisitionOptions({ - sessionId: record.sessionId, - fence, - ...(record.options ? { priorOptions: record.options } : {}) - }) - : readNativeSessionOptions({ - adapter: input.adapter, - sessionId: record.sessionId, - fence, - ...(record.options ? { priorOptions: record.options } : {}) - }) - ) + : await withAgentSessionCreatePhase('restore_options', input.recordPhase, async () => { + const read = { + sessionId: record.sessionId, + fence, + ...(record.options ? { priorOptions: record.options } : {}) + } + // Still inside the start, so the limit or a Stop ends a read the provider never answers. + return waitForPromiseWithSignal( + Promise.resolve( + input.adapter.readAcquisitionOptions + ? input.adapter.readAcquisitionOptions(read) + : readNativeSessionOptions({ adapter: input.adapter, ...read }) + ), + input.acquireSignal + ) + }) if (record.lease.ownerProcess === null) { await input.store.commitProcessIdentity({ sessionId: record.sessionId, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index 3ead999cfbda..d753547d57ee 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -47,7 +47,7 @@ export type { import type { AgentJournalDispatchRejection } from '../../../shared/agent-session-failure-words' import type { AgentSessionPromptResponse } from '../../../shared/agent-session-question-answer' import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler' -import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' +import type { StructuredAgentSessionStartupAttempt } from './structured-agent-session-startup-attempt-contract' import type { AgentSessionCreatePhaseRecorder } from '../../observability/agent-session-instrumentation' export class AgentSessionAcquisitionRefusal extends Error { @@ -124,7 +124,9 @@ export type AgentSessionAcquisition = { /** Host-local identity for this exact provider child, distinct even when the durable fence is * reused by a superseding acquisition. */ acquisitionGeneration?: string - /** Absent means `ready`: the adapter proved startup before answering. */ + /** `starting`: published at spawn, proven later by a `started` event. Absent means `ready`, an + * acquire that ran the whole handshake before answering; that bridge ends once every adapter + * publishes at spawn. Either way the host holds input until the child is `ready`. */ providerChildPhase?: StructuredAgentSessionProviderChildPhase } @@ -227,6 +229,9 @@ export type StructuredAgentSessionStartedEvent = { restoreSkippedOptions: readonly string[] /** Values the child showed it cannot run: a report naming the same value is not persisted. */ retiredOptions?: Readonly> + /** The attempt's `optionRevision()` as the read behind this report began: a pick the host took + * since makes it out of date. An earlier report never does, whenever the host took it. */ + optionRevision: number } /** A running child showed saved options it cannot run, as a model the provider reports missing. @@ -240,32 +245,36 @@ export type StructuredAgentSessionOptionsSkippedEvent = { options: Readonly> } +/** What a child that already proved its start reports later, such as an optional read that came + * after `started`: persisted as the start's report is. Never delays a start. */ +export type StructuredAgentSessionOptionsReportedEvent = Omit< + StructuredAgentSessionStartedEvent, + 'type' +> & { type: 'options-reported' } + export type StructuredAgentSessionLifecycleEvent = | StructuredAgentSessionEndedEvent | StructuredAgentSessionStartedEvent + | StructuredAgentSessionOptionsReportedEvent | StructuredAgentSessionOptionsSkippedEvent /** Whether the provider child behind an acquisition has proven its start. A publish-first - * acquire hands over a `starting` child, which already takes input, and the `started` lifecycle - * event flips it. */ + * acquire hands over a `starting` child, which the host gives no input until the `started` + * lifecycle event flips it. */ export type StructuredAgentSessionProviderChildPhase = 'starting' | 'ready' -export type StructuredAgentSessionAcquireInput = { - identity: AgentSessionJournalIdentity - fence: number - spawnToken: string - options?: Readonly> - /** Provider events may begin before acquisition returns. */ - events?: StructuredAgentSessionEventSink - recordPhase?: AgentSessionCreatePhaseRecorder - /** Durably records the child's identity the moment it exists, before any handshake, so a crash - * mid-start leaves an owner recovery can stop. The acquisition's `process` must match it. */ - onSpawned?: (process: AgentSessionProcessIdentity) => Promise - /** Aborted by a close, or by a Stop admitted now, that must not wait behind this acquire: the - * adapter stops what it started and the acquire fails. An adapter whose acquire never waits on - * the provider's handshake may ignore it. */ - signal?: AbortSignal -} +/** The host's startup attempt (`structured-agent-session-startup-attempt`), which the host always + * passes whole. Only the fields every caller has are required, so an adapter test may pass less. */ +export type StructuredAgentSessionAcquireInput = Pick< + StructuredAgentSessionStartupAttempt, + 'identity' | 'fence' | 'spawnToken' +> & + Partial> & { + recordPhase?: AgentSessionCreatePhaseRecorder + /** Durably records the child's identity the moment it exists, before any handshake, so a crash + * mid-start leaves an owner recovery can stop. The acquisition's `process` must match it. */ + onSpawned?: (process: AgentSessionProcessIdentity) => Promise + } export type StructuredAgentSessionSetOptionInput = { sessionId: string diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts index 93bfc64bae2b..79d5c5d44923 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts @@ -137,6 +137,7 @@ async function attach( probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', + optionRevision: () => 0, params: attachParams(transcriptPath), now: () => NOW, onAttached diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts index 9046e40dbbe8..2c180c121b33 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts @@ -52,6 +52,8 @@ export type StructuredAgentSessionResumeOutcome = diagnostic?: ProviderDiagnostic /** The host's own close, Stop or quit aborted the start, so it failed nothing it was for. */ aborted?: true + /** The start went silent or hit its ceiling before it proved itself: Orca stopped it. */ + expired?: true argumentProblem?: AgentSessionArgumentProblem } @@ -168,6 +170,7 @@ async function startStructuredAgentSessionAgent( let attached: AgentSessionMutationResult let acquisitionError: unknown let aborted = false + let expired = false try { attached = await attachStructuredAgentSessionUnderSerialize(context, callerKey, params, { ...(startedFor === undefined ? {} : { startedFor }), @@ -176,6 +179,9 @@ async function startStructuredAgentSessionAgent( }, onAborted: () => { aborted = true + }, + onStartupExpired: () => { + expired = true } }) } catch (error) { @@ -190,17 +196,19 @@ async function startStructuredAgentSessionAgent( error ) if (settled) { - return withDiagnostic(settled.refusal, error, aborted) + return withDiagnostic(settled.refusal, error, { aborted, expired }) } throw error } - return attached.ok ? { ok: true } : withDiagnostic(attached.refusal, acquisitionError, aborted) + return attached.ok + ? { ok: true } + : withDiagnostic(attached.refusal, acquisitionError, { aborted, expired }) } function withDiagnostic( refusal: AgentSessionWireRefusal, error: unknown, - aborted: boolean + { aborted, expired }: { aborted: boolean; expired: boolean } ): StructuredAgentSessionResumeOutcome { const diagnostic = providerDiagnosticOf(error) const argumentProblem = argumentProblemOf(error) @@ -209,6 +217,7 @@ function withDiagnostic( refusal, ...(diagnostic ? { diagnostic } : {}), ...(aborted ? { aborted: true as const } : {}), + ...(expired ? { expired: true as const } : {}), ...(argumentProblem ? { argumentProblem } : {}) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-failure.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-failure.ts index 361c8f73aac8..06e325fefd37 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-failure.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-failure.ts @@ -5,6 +5,10 @@ import { AgentSessionAcquisitionRootExitObservedError } from './structured-agent-session-adapter' import { rethrowAfterAgentSessionAcquisitionCleanup } from './structured-agent-session-provider-exit-proof' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' +import type { StructuredAgentSessionStopVerdict } from './structured-agent-session-host-types' +import { endProviderChild } from './structured-agent-session-provider-child' export async function settlePostAcquisitionAttachFailure( input: AttachFlowInput, @@ -49,3 +53,35 @@ export async function settlePostAcquisitionAttachFailure( } throw cleanupError } + +export function endStructuredAgentSessionReleasedChild( + context: StructuredAgentSessionAttachContext, + sessionId: string, + cause: unknown, + verdict: StructuredAgentSessionStopVerdict +): void { + const session = context.sessions.get(sessionId) + const child = session?.child + if (child) { + context.runtimeState.startupAttempts.childEnded(sessionId, child) + } + if ( + !session || + !child || + !endProviderChild(session, { + generation: child.generation, + fence: child.fence, + cause: 'attach-failed', + reason: cause instanceof Error ? cause.message : String(cause), + // Orca failed to attach; the provider said nothing. + failure: agentSessionFailureFact('hostFault'), + duringStartup: child.phase === 'starting', + ...verdict + }) + ) { + return + } + context.runtimeState.currentEventSink(sessionId)?.close() + context.runtimeState.discardEventSink(sessionId) + context.publishStatus?.(sessionId) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts index 9c287da62e2c..1563146f7e4a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts @@ -16,7 +16,6 @@ import type { AgentSessionMutationResult } from '../../../shared/agent-session-wire' import { agentSessionLeaseAdmitsWriter } from '../../../shared/agent-session-lease-adjudication' -import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { admitAttachOrRefuse, @@ -47,6 +46,9 @@ import { type AgentSessionCreatePhaseRecorder } from '../../observability/agent-session-instrumentation' import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler' +import { readProviderHistoryWindow } from './structured-agent-session-provider-history-window' +import type { StructuredAgentSessionStartupAttempt } from './structured-agent-session-startup-attempt-contract' +import type { StructuredAgentSessionStartupProgress } from './structured-agent-session-startup-attempt' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' @@ -63,6 +65,13 @@ export type AttachFlowInput = { recordPhase?: AgentSessionCreatePhaseRecorder /** Aborted when a close, or a Stop admitted now, must not wait behind this attach's acquire. */ acquireSignal?: AbortSignal + /** The attempt this attach's acquire runs under, minted before the adapter is called; what it + * answers learns of the child's spawn and output. */ + onStartupAttempt?: ( + attempt: StructuredAgentSessionStartupAttempt + ) => StructuredAgentSessionStartupProgress + /** The conversation's option revision, which the child's reports are stamped with. */ + optionRevision: () => number /** Publishes the journal before clients can send against the new owner. `acquiredOwner` is * true only when this attach spawned the provider child, so a re-attach to a live one is not * mistaken for a cold acquire. */ @@ -279,27 +288,6 @@ export async function performAttach( } } -async function readProviderHistoryWindow(input: { - adapter: StructuredAgentSessionAdapter - identity: AgentSessionJournalIdentity - accountHome: AgentSessionRecord['accountHome'] - ownerAlreadyAdmitted: boolean -}): Promise { - const read = input.adapter.providerHistoryWindow - if (!read) { - return null - } - let history: ProviderHistoryWindow | null - try { - history = await read({ identity: input.identity, accountHome: input.accountHome }) - } catch { - return null - } - // A lease that was already live may belong to a provider child this process - // has not indexed yet. Preserve the safe unknown outcome in that case. - return history && input.ownerAlreadyAdmitted ? { ...history, turnInFlight: true } : history -} - async function settleUnsupportedReservation( input: AttachFlowInput, record: AgentSessionRecord diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts index 6b6bb8006fa3..cd419133389c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts @@ -1,4 +1,3 @@ -import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { recoverStructuredRewind } from './structured-rewind-recovery' // The host's attach, lifted out of the host class. // @@ -16,6 +15,7 @@ import type { } from '../../../shared/agent-session-wire' import type { AgentSessionAttachParams } from './structured-agent-session-attach' import { performAttach, type AttachFlowInput } from './structured-agent-session-attach-flow' +import { endStructuredAgentSessionReleasedChild } from './structured-agent-session-attach-failure' import { stampFailedCreateOwnerVerdict } from './structured-agent-session-failed-create-refusal' import { pinnedAgentSessionLaunchArgs, @@ -25,18 +25,16 @@ import { refuseAgentSessionMutation } from './structured-agent-session-mutation- import { settleStaleStructuredAgentSessionState } from './structured-agent-session-dead-generation-settlement' import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' -import type { - StructuredAgentSessionProviderChild, - StructuredAgentSessionStopVerdict -} from './structured-agent-session-host-types' +import type { StructuredAgentSessionProviderChild } from './structured-agent-session-host-types' import { - endProviderChild, indexProviderChild, structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' import type { DeferredStructuredAgentSessionEventSink } from './structured-agent-session-event-sink' import { noteStructuredAgentSessionProviderStarted } from './structured-agent-session-provider-started' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { isStructuredAgentSessionStartupExpired } from './structured-agent-session-startup-attempt' +import type { StructuredAgentSessionStartupAttempt } from './structured-agent-session-startup-attempt-contract' import { addAgentSessionCreatePhaseAttributes, withAgentSessionCreatePhase, @@ -52,6 +50,8 @@ export type StructuredAgentSessionAttachOptions = { startedFor?: string /** A close, an admitted Stop or quit aborted this attach: its refusal is that abort's. */ onAborted?: () => void + /** The startup limit aborted this attach's acquire: it failed, nobody stopped it. */ + onStartupExpired?: () => void } /** @@ -115,7 +115,9 @@ async function runAttach( return await runAttachUnderAbort(context, callerKey, params, options, acquire.signal) } finally { acquire.end() - if (acquire.signal.aborted) { + if (isStructuredAgentSessionStartupExpired(acquire.signal.reason)) { + options.onStartupExpired?.() + } else if (acquire.signal.aborted) { options.onAborted?.() } } @@ -162,10 +164,12 @@ async function runAttachUnderAbort( // attach makes the child and its sink the session's; any other exit closes the sink with // whatever the child queued, and leaves the conversation's child as it was. const attemptSink = context.runtimeState.mintEventSink(sessionId) - const attempt: { candidate: AttachCandidate | null; committed: boolean } = { - candidate: null, - committed: false - } + const attempt: { + candidate: AttachCandidate | null + committed: boolean + startup: StructuredAgentSessionStartupAttempt | null + } = { candidate: null, committed: false, startup: null } + const { startupAttempts } = context.runtimeState try { const attached = await performAttach({ store: context.deps.store, @@ -194,6 +198,11 @@ async function runAttachUnderAbort( now: () => context.now(), recordPhase, acquireSignal, + onStartupAttempt: (startup) => { + attempt.startup = startup + return startupAttempts.track(sessionId, startup) + }, + optionRevision: () => context.runtimeState.optionRevisions.current(sessionId), ...(options.onAcquisitionFailed ? { onAcquisitionFailed: options.onAcquisitionFailed } : {}), openConversation: async (record) => { const conversation = await context.openConversation(record.sessionId, { @@ -206,7 +215,7 @@ async function runAttachUnderAbort( }, // The cleanup released the acquisition, which for a re-attach is the live child itself. onAcquisitionReleased: (cause, verdict) => - endReleasedChild(context, sessionId, cause, verdict), + endStructuredAgentSessionReleasedChild(context, sessionId, cause, verdict), onAttached: async (attached, acquisitionGeneration, acquiredOwner, providerChildPhase) => { const fence = structuredAgentSessionConversationFence(context.deps.store, sessionId) const current = context.sessions.get(sessionId)?.child ?? null @@ -231,7 +240,6 @@ async function runAttachUnderAbort( ) attempt.candidate = { sink: eventSink, - provedStart: acquiredOwner && providerChildPhase === 'ready', child: { generation: acquisitionGeneration ?? current?.generation ?? null, fence, @@ -261,8 +269,12 @@ async function runAttachUnderAbort( context.runtimeState.adoptEventSink(sessionId, candidate.sink) attempt.committed = candidate.sink === attemptSink indexProviderChild(conversation, candidate.child) - if (candidate.provedStart) { - noteStructuredAgentSessionProviderStarted(context.deps, sessionId) + if (attempt.startup) { + startupAttempts.published(sessionId, attempt.startup.attemptId, candidate.child) + // Only an acquire proves a start; a re-attach to a live child proved nothing. + if (candidate.child.phase === 'ready') { + noteStructuredAgentSessionProviderStarted(context.deps, sessionId) + } } context.publishStatus?.(sessionId) } @@ -270,6 +282,9 @@ async function runAttachUnderAbort( } finally { if (!attempt.committed) { attemptSink.close() + if (attempt.startup) { + startupAttempts.abandon(sessionId, attempt.startup.attemptId) + } } } } @@ -277,37 +292,6 @@ async function runAttachUnderAbort( type AttachCandidate = { child: StructuredAgentSessionProviderChild sink: DeferredStructuredAgentSessionEventSink - /** This attach acquired a child that proved its start; a re-attach to a live one proved nothing. */ - provedStart: boolean -} - -function endReleasedChild( - context: StructuredAgentSessionAttachContext, - sessionId: string, - cause: unknown, - verdict: StructuredAgentSessionStopVerdict -): void { - const session = context.sessions.get(sessionId) - const child = session?.child - if ( - !session || - !child || - !endProviderChild(session, { - generation: child.generation, - fence: child.fence, - cause: 'attach-failed', - reason: cause instanceof Error ? cause.message : String(cause), - // Orca failed to attach; the provider said nothing. - failure: agentSessionFailureFact('hostFault'), - duringStartup: child.phase === 'starting', - ...verdict - }) - ) { - return - } - context.runtimeState.currentEventSink(sessionId)?.close() - context.runtimeState.discardEventSink(sessionId) - context.publishStatus?.(sessionId) } /** Binds the sink to the journal and waits for the barrier the host publishes diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts index a983753e2a99..e9ac5b09bd9e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts @@ -30,6 +30,7 @@ import { import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' import { evictStructuredAgentSession } from './structured-agent-session-eviction' import type { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' +import type { StructuredAgentSessionStartupAttempts } from './structured-agent-session-startup-attempt' /** How the child's root went: the close this host asked for, or a death of its own. */ export type StructuredAgentSessionChildExit = { @@ -58,6 +59,8 @@ export type StructuredAgentSessionChildExitContext< publishStatus?: (sessionId: string) => void /** The delivery loop hands over whatever is queued once the child is off the record. */ wakeDelivery?: (sessionId: string) => void + /** An ended child's start, if still open, has nothing left to time. */ + startupAttempts?: Pick serialize: (sessionId: string, task: () => Promise) => Promise now: () => number logger: StructuredAgentSessionLogger @@ -142,6 +145,7 @@ export async function endExitedStructuredAgentSessionChildUnderSerialize< : ('chatClosed' as const) : undefined const endChild = (): void => { + context.startupAttempts?.childEnded(sessionId, child) endProviderChild(session, { generation: child.generation, fence: child.fence, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-hung-start-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-hung-start-stop.test.ts index 595e18177541..347de6cf6643 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-hung-start-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-hung-start-stop.test.ts @@ -1,5 +1,6 @@ -// A Claude CLI that never answers initialize still takes the chat's message at once, and the -// chat's Stop still ends it. On the shipping adapter and host, against a CLI that echoes nothing. +// A Claude CLI that never answers initialize is handed nothing: the host accepts the chat's message +// at once and holds it until the start proves itself, and the chat's Stop still ends the start. On +// the shipping adapter and host, against a CLI that echoes nothing. import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -130,7 +131,15 @@ async function readsWorking(): Promise { ) } -it('writes the message to a start that never answers, and a Stop ends it: stopped, nothing working', async () => { +/** Whether the CLI was written `hello`, once the delivery loop has settled what it does with it. */ +async function helloWritten(): Promise { + await vi.waitFor(() => + expect(host.collaboratorsForTests().conversationDelivery.loop.isRunning(SESSION)).toBe(false) + ) + return claude.connections[0]!.sent.some((message) => JSON.stringify(message).includes('hello')) +} + +it('holds the message from a start that never answers, and a Stop ends it: stopped, nothing working', async () => { expect(host['sessions'].get(SESSION)?.child?.phase).toBe('starting') const body = hostTestMessage('hello') const sent = await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) @@ -140,10 +149,8 @@ it('writes the message to a start that never answers, and a Stop ends it: stoppe const id = sent.value.clientMessageId const [connection] = claude.connections - // Handed over and written while initialize is still unanswered. - await vi.waitFor(() => - expect(connection!.sent.some((message) => JSON.stringify(message).includes('hello'))).toBe(true) - ) + // Accepted, and held while initialize is still unanswered. + expect(await helloWritten()).toBe(false) expect(connection!.calls).toEqual([{ subtype: 'initialize' }]) expect(host['sessions'].get(SESSION)?.child?.phase).toBe('starting') expect(await readsWorking()).toBe(true) @@ -153,7 +160,7 @@ it('writes the message to a start that never answers, and a Stop ends it: stoppe expect(stopped).toMatchObject({ ok: true, value: { cancelled: true } }) await vi.waitFor(() => expect(host['sessions'].get(SESSION)?.child).toBeNull()) expect(connection!.closeCount).toBeGreaterThan(0) - // Stopped, not in doubt: the child it was written to is gone and never echoed it. + // Withdrawn, not in doubt: it was never handed over. await vi.waitFor(async () => expect( (await host.journalSnapshot(SESSION)).submissions.find( @@ -164,7 +171,7 @@ it('writes the message to a start that never answers, and a Stop ends it: stoppe expect(await readsWorking()).toBe(false) }) -async function sendHello(): Promise { +async function sendHello(written: boolean): Promise { const body = hostTestMessage('hello') // A person's send, which a close keeps as a held card. const sent = await host.send(CALLER, { @@ -175,11 +182,7 @@ async function sendHello(): Promise { if (!sent.ok) { throw new Error('send refused') } - await vi.waitFor(() => - expect( - claude.connections[0]!.sent.some((message) => JSON.stringify(message).includes('hello')) - ).toBe(true) - ) + await vi.waitFor(async () => expect(await helloWritten()).toBe(written)) return sent.value.clientMessageId } @@ -189,12 +192,12 @@ async function submission(id: string) { ) } -// A CLI that never answered initialize ran nothing it was handed: a close settles it as it does a -// queued send, keeping the person's words as a held card. +// A CLI that never answered initialize was handed nothing: a close settles the held message as it +// does any queued send, keeping the person's words as a held card. it.each(['user-close', 'evict'] as const)( - 'keeps a message written to a start that never answered as a held card when a %s ends it', + 'keeps a message held behind a start that never answered as a held card when a %s ends it', async (cause) => { - const id = await sendHello() + const id = await sendHello(false) await host.close(SESSION, cause) @@ -213,7 +216,7 @@ it.each(['user-close', 'evict'] as const)( it('leaves a message in doubt when the start answered and the chat closes before the echo', async () => { answerInitialize() await adapter['sessions'].get(SESSION)?.startup.settled - const id = await sendHello() + const id = await sendHello(true) await host.close(SESSION, 'user-close') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts index e0130423d5e2..5a1bbeab5120 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts @@ -39,11 +39,9 @@ import type { JournalLifecycleMutationInput } from '../agent-session-journal/jou import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { AgentSessionCommandAdmission, - StructuredAgentSessionAdapter, - StructuredAgentSessionProviderChildPhase + StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import type { StructuredAgentRegistry } from './structured-agent-registry' -import { structuredAgentSessionStartFailure } from './structured-agent-session-failure-text' import { conversationCommandBlocked } from './structured-conversation-command-admission' export const STRUCTURED_AGENT_SESSION_COMPACT_COMMAND = 'compact' @@ -155,7 +153,6 @@ export type StructuredAgentSessionCommandHandoverContext = { fence: number adapter: StructuredAgentSessionAdapter agents: StructuredAgentRegistry - providerChildPhase?: () => StructuredAgentSessionProviderChildPhase | undefined /** Who a failure the handover meets names, as the start's own row does. */ failureTextContext?: AgentSessionFailureWordsContext record: () => AgentSessionRecord | null @@ -210,19 +207,11 @@ export async function handOverStructuredAgentSessionCommand( command: { clientMessageId, ...turn, running } }) } catch (error) { - // A child still starting throws only for a start that failed before the write, so the command - // provably did not run. Any other throw is a lost reply: the command may have run. - const unsent = - ctx.providerChildPhase?.() === 'starting' - ? { - state: 'rejected' as const, - ...structuredAgentSessionStartFailure({ error }, ctx.failureTextContext) - } - : { - state: 'unknown' as const, - reason: error instanceof Error ? error.message : String(error) - } - await settleUnsentCommand(ctx, clientMessageId, unsent) + // Handed over only to a child that proved its start, so a throw is a lost reply: it may have run. + await settleUnsentCommand(ctx, clientMessageId, { + state: 'unknown', + reason: error instanceof Error ? error.message : String(error) + }) return } if (admission.state === 'rejected') { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts index e19127fde7cd..4e6b575ef1f1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts @@ -221,22 +221,23 @@ describe('the sweep and the lease (P2-20)', () => { }) describe('a start that never finishes (P2-15)', () => { - it('is stopped by the sweep, and the message it was handed carries its one row and rejection', async () => { + it('is stopped by the sweep, and the message held for it carries its one row and rejection', async () => { const stopReason = 'Codex never finished starting, so Orca stopped it.' const spawn = rig.adapter.acquire.getMockImplementation()! rig.adapter.acquire.mockImplementationOnce(async (input) => ({ ...(await spawn(input)), providerChildPhase: 'starting' as const })) - // Written to the starting child at once; it never answers. - rig.adapter.dispatch.mockResolvedValueOnce({ state: 'admitted' }) + // Held for the starting child, which never proves its start. const reader = collectSubscriber() const attached = await rig.host.attach(CALLER, hostTestAttachParams(null)) expect(attached.ok).toBe(true) await rig.host.subscribe({ id: 'reader', sessionId: SESSION, emit: reader.emit }) const sent = await rig.host.send(CALLER, restTestSend('stuck behind the start', fence())) expect(sent.ok).toBe(true) - await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledOnce()) + await vi.waitFor(() => + expect(rig.host.collaboratorsForTests().sessions.get(SESSION)?.child?.phase).toBe('starting') + ) rig.clock.now += IDLE_MS + 1 await sweepOnce(rig.host) @@ -259,7 +260,8 @@ describe('a start that never finishes (P2-15)', () => { const again = await rig.host.send(CALLER, restTestSend('try again', fence())) expect(again.ok).toBe(true) - await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(2), COLD_START) + expect(rig.adapter.dispatch).not.toHaveBeenCalled() + await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(1), COLD_START) await expect(sweepOnce(rig.host)).resolves.toBeUndefined() }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts index 1100c0abf81b..431ca9e761de 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts @@ -25,6 +25,8 @@ import type { StructuredAgentSessionHostSession } from './structured-agent-sessi import { StructuredAgentSessionIdleSweep } from './structured-agent-session-idle-sweep' import { AGENT_SESSION_NOT_ATTACHED } from './structured-agent-session-mutation-admission' import { deferredStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { sameProviderChild } from './structured-agent-session-provider-child' +import type { StructuredAgentSessionExpiredStartup } from './structured-agent-session-startup-attempt' export type StructuredAgentSessionConversationLifetime = ReturnType< typeof createStructuredAgentSessionConversationLifetime @@ -57,7 +59,7 @@ export function createStructuredAgentSessionConversationLifetime(host: { if (await releaseLeaseOfEndedStructuredAgentSessionChild(host.context(), sessionId)) { return false } - return closeStructuredAgentSessionConversationUnderSerialize( + const closed = await closeStructuredAgentSessionConversationUnderSerialize( { sessions, closeStatus: (id) => { @@ -69,6 +71,10 @@ export function createStructuredAgentSessionConversationLifetime(host: { sessionId, atRest ) + if (closed) { + host.context().runtimeState.optionRevisions.forget(sessionId) + } + return closed } const idleSweep = new StructuredAgentSessionIdleSweep({ @@ -100,10 +106,35 @@ export function createStructuredAgentSessionConversationLifetime(host: { return { idleSweep, stopAgent, + /** A host stop of the child the expired attempt published, if it is still that one and still + * starting: its end fails what is queued, as the idle sweep's stop of a start does. */ + expireStartup: ({ sessionId, child: expired }: StructuredAgentSessionExpiredStartup): void => { + void serialize(sessionId, async () => { + const child = sessions.get(sessionId)?.child + if ( + !expired || + !child || + child.phase !== 'starting' || + !sameProviderChild(child, expired) + ) { + return + } + await stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId, { + cause: 'host-stop' + }) + }).catch((error: unknown) => + deps().logger.warn('stopping an agent past its startup limit failed', { + scope: 'startup-limit', + sessionId, + error + }) + ) + }, /** Quit has begun: nothing opens a conversation or sweeps one after this. */ dispose: (): void => { disposed = true idleSweep.dispose() + host.context().runtimeState.startupAttempts.dispose() }, /** * The only way any code reaches a session. An open conversation answers without the lock, so diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.test.ts index 242daf74faf6..61f596359165 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' -import { structuredAgentSessionEndedChildFailure } from './structured-agent-session-delivery-loop' +import { structuredAgentSessionEndedChildFailure } from './structured-agent-session-ended-child-failure' import type { StructuredAgentSessionChildEndCause, StructuredAgentSessionEndedChild diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts index 380742f0e235..1858eaeddf8d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts @@ -25,8 +25,6 @@ import { } from './structured-agent-session-failure-text' import type { StructuredAgentSessionResumeOutcome } from './structured-agent-session-agent-start' import type { - StructuredAgentSessionChildEndCause, - StructuredAgentSessionEndedChild, StructuredAgentSessionHostSession, StructuredAgentSessionProviderChildIdentity } from './structured-agent-session-host-types' @@ -40,6 +38,7 @@ import { structuredAgentSessionNextHandover } from './structured-agent-session-o import { structuredAgentSessionCommandRunning } from './structured-agent-session-command-turn' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' import { holdRestartedStructuredAgentSessionSends } from './structured-agent-session-host-lifetime' +import { structuredAgentSessionEndedChildFailure } from './structured-agent-session-ended-child-failure' export type StructuredAgentSessionDeliveryLoopDeps = { sessions: ReadonlyMap @@ -85,6 +84,7 @@ type StartFailure = { startKey: string | null; cause: StructuredAgentSessionStar export class StructuredAgentSessionDeliveryLoop { private readonly running = new Set() + private readonly runs = new Map>() private disposed = false constructor(private readonly deps: StructuredAgentSessionDeliveryLoopDeps) {} @@ -99,13 +99,23 @@ export class StructuredAgentSessionDeliveryLoop { } /** From inside the session's serialize, after a message was accepted or the conversation - * opened. A loop already running re-reads the journal on its next step. */ - wake(sessionId: string): void { - if (this.disposed || this.running.has(sessionId)) { - return + * opened. A loop already running re-reads the journal on its next step. Settles once the loop + * has handed over all it can; never rejects. */ + wake(sessionId: string): Promise { + if (this.disposed) { + return Promise.resolve() + } + if (this.running.has(sessionId)) { + return this.runs.get(sessionId) ?? Promise.resolve() } this.running.add(sessionId) - void this.run(sessionId) + const run = this.run(sessionId).finally(() => { + if (this.runs.get(sessionId) === run) { + this.runs.delete(sessionId) + } + }) + this.runs.set(sessionId, run) + return run } private async run(sessionId: string): Promise { @@ -218,7 +228,6 @@ export class StructuredAgentSessionDeliveryLoop { child && awaited && child.generation === awaited.generation && child.fence === awaited.fence ? child : null - // A child still starting takes input: the provider queues it behind its own start. if (!awaitedChild) { // The child started for this message is gone or replaced by another. const ended = awaitedChild ? undefined : session.lastEndedChild @@ -238,7 +247,9 @@ export class StructuredAgentSessionDeliveryLoop { // Never steer into a turn a person's Stop is ending: the message runs after it, as its own // turn, and the turn's end is a commit that wakes the loop again. Read here, at the handover, // because a Stop can take the lane between the step that judged the send and this one. - if (this.deps.stopping(sessionId)) { + // Nor hand anything to a child still proving its start: nothing is written until it is + // `ready`, whose settlement wakes the loop, so a start that fails leaves the message unsent. + if (this.deps.stopping(sessionId) || awaitedChild.phase === 'starting') { return this.stop(sessionId) } // None while a turn is still opening: joining it would record the message ahead of it. @@ -253,7 +264,6 @@ export class StructuredAgentSessionDeliveryLoop { fence: awaitedChild.fence, adapter: this.deps.adapter, agents: this.deps.agents, - providerChildPhase: () => this.deps.sessions.get(sessionId)?.child?.phase, failureTextContext: this.deps.failureTextContext(sessionId), record: () => this.deps.record(sessionId), childWork: () => this.deps.readChildWork(sessionId), @@ -267,8 +277,11 @@ export class StructuredAgentSessionDeliveryLoop { /** A start the session refused, as the failure every queued message it was for is rejected with. */ private refusedStart( sessionId: string, - { refusal, diagnostic, argumentProblem }: RefusedStart + { refusal, diagnostic, argumentProblem, expired }: RefusedStart ): StartFailure { + if (expired) { + return { startKey: null, cause: { failure: agentSessionFailureFact('hostStopped') } } + } // A conversation no agent ever ran, such as a cleared chat's, failed to start, not restart. const newSession = this.deps.record(sessionId)?.providerHandleChain.length === 0 return { @@ -345,36 +358,3 @@ function startThatFailedWhileQueued( const cause = structuredAgentSessionEndedChildFailure(ended) return cause ? { startKey: ended.generation, cause } : null } - -function providerEndFailure( - ended: StructuredAgentSessionEndedChild -): StructuredAgentSessionStartFailureCause { - if (ended.duringStartup) { - return { exit: ended.failure } - } - return { failure: ended.failure ?? agentSessionFailureFact('providerExited') } -} - -// Every end cause, so a new one does not compile until it says whether it fails what is queued. -const ENDED_CHILD_FAILURE = { - 'user-stop': () => null, - // The user closing this chat closes what was queued before it; see `closeWhatTheUserClosed`. - 'user-close': () => null, - // The host stopping the child is Orca's cause, never the provider's: a start that never finished. - 'host-stop': () => ({ failure: agentSessionFailureFact('hostStopped') }), - exit: providerEndFailure, - // The attach records its own fault as the end's failure. - 'attach-failed': providerEndFailure, - // Reached only when an eviction's stop landed and a later step failed, leaving the conversation. - evict: providerEndFailure -} satisfies Record< - StructuredAgentSessionChildEndCause, - (ended: StructuredAgentSessionEndedChild) => StructuredAgentSessionStartFailureCause | null -> - -/** Why a queued message the child never took is rejected; null when its end fails nothing. */ -export function structuredAgentSessionEndedChildFailure( - ended: StructuredAgentSessionEndedChild -): StructuredAgentSessionStartFailureCause | null { - return ENDED_CHILD_FAILURE[ended.cause](ended) -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-drivability.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-drivability.test.ts index bbcadedabfef..2814638e0413 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-drivability.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-drivability.test.ts @@ -143,6 +143,7 @@ async function attach(input: { probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', + optionRevision: () => 0, params: input.params, now: () => NOW, onAttached: async (attached) => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-ended-child-failure.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-ended-child-failure.ts new file mode 100644 index 000000000000..2ae9a10a4631 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-ended-child-failure.ts @@ -0,0 +1,41 @@ +// Why a queued message the child never took is rejected, by how that child ended. + +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import type { StructuredAgentSessionStartFailureCause } from './structured-agent-session-failure-text' +import type { + StructuredAgentSessionChildEndCause, + StructuredAgentSessionEndedChild +} from './structured-agent-session-host-types' + +function providerEndFailure( + ended: StructuredAgentSessionEndedChild +): StructuredAgentSessionStartFailureCause { + if (ended.duringStartup) { + return { exit: ended.failure } + } + return { failure: ended.failure ?? agentSessionFailureFact('providerExited') } +} + +// Every end cause, so a new one does not compile until it says whether it fails what is queued. +const ENDED_CHILD_FAILURE = { + 'user-stop': () => null, + // The user closing this chat closes what was queued before it; see `closeWhatTheUserClosed`. + 'user-close': () => null, + // The host stopping the child is Orca's cause, never the provider's: a start that never finished. + 'host-stop': () => ({ failure: agentSessionFailureFact('hostStopped') }), + exit: providerEndFailure, + // The attach records its own fault as the end's failure. + 'attach-failed': providerEndFailure, + // Reached only when an eviction's stop landed and a later step failed, leaving the conversation. + evict: providerEndFailure +} satisfies Record< + StructuredAgentSessionChildEndCause, + (ended: StructuredAgentSessionEndedChild) => StructuredAgentSessionStartFailureCause | null +> + +/** Why a queued message the child never took is rejected; null when its end fails nothing. */ +export function structuredAgentSessionEndedChildFailure( + ended: StructuredAgentSessionEndedChild +): StructuredAgentSessionStartFailureCause | null { + return ENDED_CHILD_FAILURE[ended.cause](ended) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts index bf4c74bd3086..51894a9ff8d7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts @@ -10,6 +10,7 @@ import type { } from './structured-agent-session-host-types' import type { StructuredAgentSessionSinkBarrier } from './structured-agent-session-event-sink' import { + settleStructuredAgentSessionOptionsReported, settleStructuredAgentSessionOptionsSkipped, settleStructuredAgentSessionProviderStarted } from './structured-agent-session-provider-started' @@ -35,7 +36,7 @@ export class StructuredAgentSessionEventRecovery { serialize: (sessionId: string, task: () => Promise) => Promise now: () => number runtimeState: StructuredAgentSessionHostRuntimeState - wakeDelivery: (sessionId: string) => void + wakeDelivery: (sessionId: string) => Promise } ) {} @@ -44,6 +45,7 @@ export class StructuredAgentSessionEventRecovery { return { ...this.context, logger: deps.logger, + startupAttempts: runtimeState.startupAttempts, holdUnrunSends: async (sessionId, fence, cause) => { const journal = this.context.sessions.get(sessionId)?.journal if (journal) { @@ -111,6 +113,9 @@ export class StructuredAgentSessionEventRecovery { if (event.type === 'started') { return settleStructuredAgentSessionProviderStarted(this.context, event) } + if (event.type === 'options-reported') { + return settleStructuredAgentSessionOptionsReported(this.context, event) + } if (event.type === 'options-skipped') { return settleStructuredAgentSessionOptionsSkipped(this.context, event) } 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 5a0537e5a5ac..cb9cd10fea68 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 @@ -199,6 +199,8 @@ export async function setStructuredAgentSessionOption( return await performSetOption(ctx, params, wait.signal) } finally { wait.end() + // Whatever the write's outcome, the child may hold what it asked for now. + context.optionRevisions.advance(params.envelope.sessionId) } } }, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts index cc5be644cc13..d604b805eced 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts @@ -12,11 +12,20 @@ import { heldProviderChildReader, type ProviderChildSessions } from './structured-agent-session-provider-child' +import { StructuredAgentSessionOptionRevisions } from './structured-agent-session-option-revisions' import { resolveStructuredSessionRecovery } from './structured-agent-session-recovery-resolution' +import { recordAgentSessionStartup } from '../../observability/agent-session-instrumentation' +import { + StructuredAgentSessionStartupAttempts, + StructuredAgentSessionStartupExpiredError, + type StructuredAgentSessionExpiredStartup +} from './structured-agent-session-startup-attempt' export class StructuredAgentSessionHostRuntimeState { private readonly eventSinks = new Map() readonly acquireAborts = new StructuredAgentSessionAcquireAborts() + readonly startupAttempts: StructuredAgentSessionStartupAttempts + readonly optionRevisions = new StructuredAgentSessionOptionRevisions() private readonly leaseRenewer: StructuredAgentSessionLeaseRenewer private readonly onEventSinkFailure?: (sessionId: string, error: unknown) => void @@ -24,9 +33,25 @@ export class StructuredAgentSessionHostRuntimeState { private readonly deps: StructuredAgentSessionHostDeps, /** Required: a child held here renews its lease without a PID probe. */ sessions: ProviderChildSessions, - onEventSinkFailure?: (sessionId: string, error: unknown) => void + onEventSinkFailure?: (sessionId: string, error: unknown) => void, + /** A published child's start passed its startup limit; one still acquiring is aborted here. */ + onStartupExpired?: (expired: StructuredAgentSessionExpiredStartup) => void ) { this.onEventSinkFailure = onEventSinkFailure + this.startupAttempts = new StructuredAgentSessionStartupAttempts({ + ...(deps.startupLimits ? { limits: deps.startupLimits } : {}), + settled: recordAgentSessionStartup, + expire: (expired) => { + if (expired.child === null) { + this.acquireAborts.abort( + expired.sessionId, + new StructuredAgentSessionStartupExpiredError() + ) + return + } + onStartupExpired?.(expired) + } + }) this.leaseRenewer = new StructuredAgentSessionLeaseRenewer({ store: deps.store, probe: (record) => this.probeRecord(record), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index 34fe554d31cd..d6372878b2bd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -25,6 +25,7 @@ import type { AgentModelCatalogService } from '../agent-model-catalog/agent-mode import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' import type { StructuredAgentId } from '../../../shared/agent-session-provider-handle' import type { StructuredAgentRegistry } from './structured-agent-registry' +import type { StructuredAgentSessionStartupLimits } from './structured-agent-session-startup-attempt-contract' export type StructuredAgentSessionCaller = { callerKey: string } @@ -143,6 +144,9 @@ export type StructuredAgentSessionHostDeps = { now?: () => number /** The idle sweep's period and window. Tests drive these; production takes the defaults. */ idleSweep?: { intervalMs?: number; idleMs?: number } + /** How long a start may stay silent, and its ceiling. Tests drive these; production takes the + * host constants. */ + startupLimits?: Partial /** Whether an orchestration dispatch still owns this session's worker; absent answers no. */ hasOpenDispatch?: (record: AgentSessionRecord) => boolean /** A chat tab left the screen: closed, or its workspace removed. Advisory; a throw is logged. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 9a033b5f831b..37ad45d465af 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -104,8 +104,11 @@ export class StructuredAgentSessionHost { (sessionId) => this.lifetime.conversation(sessionId), this.clientDelivery.readChildWork ) - this.runtimeState = new StructuredAgentSessionHostRuntimeState(deps, this.sessions, (id, e) => - this.eventRecovery.recoverAfterSinkFailure(id, e) + this.runtimeState = new StructuredAgentSessionHostRuntimeState( + deps, + this.sessions, + (id, e) => this.eventRecovery.recoverAfterSinkFailure(id, e), + (expired) => this.lifetime.expireStartup(expired) ) this.reconcileLeases = createRestartReconciler({ store: deps.store, @@ -292,6 +295,7 @@ export class StructuredAgentSessionHost { stopAgent: (sessionId, ending) => this.lifetime.stopAgent(sessionId, ending), wakeQueuedDrain: (sessionId) => this.queued.drain.schedule(sessionId), acquireAborts: this.runtimeState.acquireAborts, + optionRevisions: this.runtimeState.optionRevisions, now: () => this.now() } } 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 3cb9762cf39f..3fac8f4d8709 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 @@ -89,7 +89,6 @@ export type AgentSessionMutationRequest = { record: AgentSessionRecord ) => Promise publish: (journal: AgentSessionJournal) => void - providerChildPhase?: AgentSessionTurnContext['providerChildPhase'] now: () => number } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts index 099b356cb5e3..1f543cbf19f0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts @@ -14,6 +14,7 @@ import { import type { MutationPlan } from './structured-agent-session-mutation-plans' import type { StructuredAgentSessionStopEnding } from './structured-agent-session-host-lifetime' import type { StructuredAgentSessionAcquireAborts } from './structured-agent-session-acquire-aborts' +import type { StructuredAgentSessionOptionRevisions } from './structured-agent-session-option-revisions' import type { StructuredAgentSessionCaller, StructuredAgentSessionHostDeps, @@ -48,6 +49,8 @@ export type StructuredAgentSessionMutationContext = { /** The provider wait each session's serialize is on (a start, an option write), which a caller * outside that serialize aborts. */ acquireAborts: Pick + /** Moved by every pick a running child takes, so a report it read before is never persisted. */ + optionRevisions: Pick now: () => number } @@ -71,7 +74,6 @@ export function mutateStructuredAgentSession( journal: () => context.sessions.get(envelope.sessionId)?.journal, prepareSession, publish: (journal) => context.publish(envelope.sessionId, journal), - providerChildPhase: () => context.sessions.get(envelope.sessionId)?.child?.phase, now: () => context.now() }) ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-turn-context.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-turn-context.ts index cae4399cb654..776937662eb0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-turn-context.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-turn-context.ts @@ -32,7 +32,6 @@ export function mutationTurnContext( .then(() => undefined), resolvedBy: request.callerKey, publish: () => request.publish(journal), - ...(request.providerChildPhase ? { providerChildPhase: request.providerChildPhase } : {}), now: () => request.now() } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-option-revisions.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-option-revisions.ts new file mode 100644 index 000000000000..0c20a84c1f59 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-option-revisions.ts @@ -0,0 +1,46 @@ +// The order of what a conversation's record learns about its child's options. +// +// A pick and a child's report both write the record's options, but a report describes the child as +// of its read, which can predate a pick still in flight, a saved value the child showed it cannot +// run, or a newer report. A pick or such a value moves the revision; a report carries the revision +// it was read at and is admitted only if nothing moved it since. Reports are ordered among +// themselves by arrival, so a read's stamp never depends on when an earlier report was taken, and +// the newest admitted report is the only one persisted. + +export class StructuredAgentSessionOptionRevisions { + private readonly revisions = new Map() + private readonly newestReports = new Map() + private reports = 0 + + current(sessionId: string): number { + return this.revisions.get(sessionId) ?? 0 + } + + /** What any report read before now says is out of date. */ + advance(sessionId: string): number { + const next = this.current(sessionId) + 1 + this.revisions.set(sessionId, next) + return next + } + + /** A report read at `readAt` becomes the newest word, or null when a pick came since its read. */ + admitReport(sessionId: string, readAt: number): number | null { + if (readAt !== this.current(sessionId)) { + return null + } + const admitted = ++this.reports + this.newestReports.set(sessionId, admitted) + return admitted + } + + /** Whether the report admitted as `admitted`, read at `readAt`, is still the newest word. */ + isNewest(sessionId: string, readAt: number, admitted: number): boolean { + return readAt === this.current(sessionId) && this.newestReports.get(sessionId) === admitted + } + + /** The conversation closed with no child: nothing it reported is still to be persisted. */ + forget(sessionId: string): void { + this.revisions.delete(sessionId) + this.newestReports.delete(sessionId) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts index c906785c5418..6d188fb3b385 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts @@ -175,15 +175,21 @@ function waitOutSeveralSweeps(): Promise { return new Promise((resolve) => setTimeout(resolve, SWEEP_MS * 20)) } +function deliverySettled(): Promise { + return vi.waitFor(() => + expect(host.collaboratorsForTests().conversationDelivery.loop.isRunning(SESSION)).toBe(false) + ) +} + describe('a Claude chat whose CLI is still starting', () => { - it('writes a message at once, and keeps the agent inside the idle window while startup is still out', async () => { + it('holds a message until startup lands, and keeps the agent inside the idle window meanwhile', async () => { await attachStarting() const sent = await send('sent while starting') - // Written before initialize answers: the CLI queues it behind its own start. - await untilSent(claude.connections[0]) - expect(claude.connections[0].sent).toEqual([expect.objectContaining({ type: 'user' })]) - await vi.waitFor(async () => expect(await dispatchState(sent)).toBe('accepted')) + // Held, never written, while initialize is unanswered. + await deliverySettled() + expect(claude.connections[0].sent).toEqual([]) + expect(await dispatchState(sent)).toBe('pending') clock += IDLE_MS - 1 await waitOutSeveralSweeps() @@ -192,10 +198,13 @@ describe('a Claude chat whose CLI is still starting', () => { landInit() await claudeStartupSettled(adapter, SESSION) + await untilSent(claude.connections[0]) + expect(claude.connections[0].sent).toEqual([expect.objectContaining({ type: 'user' })]) + await vi.waitFor(async () => expect(await dispatchState(sent)).toBe('accepted')) expect(host.hasSession(SESSION)).toBe(true) }) - it('gives a message written while starting a full idle window after startup lands to open its turn', async () => { + it('gives a message held while starting a full idle window after startup lands to open its turn', async () => { await attachStarting() const connection = claude.connections[0] // Claude echoes a prompt only when it starts that turn, which a loaded machine delays. @@ -203,16 +212,19 @@ describe('a Claude chat whose CLI is still starting', () => { connection.sent.push(message) } const held = await send('sent while starting') - await untilSent(connection) - expect(connection.sent).toEqual([expect.objectContaining({ type: 'user' })]) + await deliverySettled() + expect(connection.sent).toEqual([]) clock += IDLE_MS - 1 await waitOutSeveralSweeps() expect(host.hasSession(SESSION)).toBe(true) - // Startup lands just before the window closes; the agent stays while its message waits. + // Startup lands just before the window closes and the message is handed over; the agent stays + // while its message waits. landInit() await claudeStartupSettled(adapter, SESSION) await Promise.all(lifecycle) + await untilSent(connection) + expect(connection.sent).toEqual([expect.objectContaining({ type: 'user' })]) clock += IDLE_MS - 1 await waitOutSeveralSweeps() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts index a0f996c80b12..24451889ec60 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts @@ -96,6 +96,7 @@ async function firstAnswerAndReplay(thrown: AgentSessionPreSpawnError) { probe: { outcome: 'reservation-unused' as const } }, callerKey: 'client-1', + optionRevision: () => 0, params: createParams(), now: () => NOW, onAttached: () => {} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts index 543abc1e2537..bebf68bd2f34 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts @@ -97,6 +97,7 @@ describe('processless structured session reservation', () => { probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', + optionRevision: () => 0, params: attachParams(), now: () => NOW, onAttached: () => {} @@ -145,6 +146,7 @@ describe('processless structured session reservation', () => { probe: { outcome: 'reservation-unused' as const } }, callerKey: 'client-1', + optionRevision: () => 0, params: attachParams(), now: () => NOW, onAttached: () => {} @@ -184,6 +186,7 @@ describe('processless structured session reservation', () => { probe: { outcome: 'reservation-unused' as const } }, callerKey: 'client-1', + optionRevision: () => 0, params: attachParams(), now: () => NOW, onAttached: () => {} @@ -237,6 +240,7 @@ describe('processless structured session reservation', () => { probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', + optionRevision: () => 0, params: attachParams(), now: () => NOW, onAttached: () => {} @@ -306,6 +310,7 @@ describe('processless structured session reservation', () => { probe: { outcome: 'reservation-unused' as const } }, callerKey: 'client-1', + optionRevision: () => 0, params: attachParams(), now: () => NOW, onAttached: () => {} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts index 8c98a685c7d2..c7a9997117e3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts @@ -215,6 +215,19 @@ function currentChild() { return { sessionId: SESSION, fence: child.fence, acquisitionGeneration: child.generation } } +/** The current child proves its start, as a publish-first provider's `started` event does. */ +function prove(): Promise { + const { acquisitionGeneration, ...child } = currentChild() + return host.handleAdapterEvent({ + type: 'started', + ...child, + acquisitionGeneration, + reportedOptions: { model: 'default' }, + restoreSkippedOptions: [], + optionRevision: host.collaboratorsForTests().runtimeState.optionRevisions.current(SESSION) + }) +} + function exit(child: ReturnType, reason: string, startupUnproven?: true) { return host.handleAdapterEvent({ type: 'ended', @@ -278,14 +291,13 @@ describe('Stop on a child still proving its start', () => { adapterExtras = { closeSession: vi.fn(async () => true) } await restartHost() acquire.mockImplementationOnce(spawnStartingChild) - // Written to the starting child at once; it never answers. - dispatch.mockResolvedValueOnce({ state: 'admitted' }) + // Held for the starting child, which never proves its start. const first = await accept('hello') const journal = conversation()?.journal const events = await subscribe() const frames = watchStatus() await eventually(() => expect(conversation()?.child?.phase).toBe('starting')) - await eventually(async () => expect((await submission(first))?.handedOverAt).toBeDefined()) + expect((await submission(first))?.handedOverAt).toBeUndefined() expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) @@ -306,7 +318,7 @@ describe('Stop on a child still proving its start', () => { const next = await accept('after stop') await eventually(async () => expect((await submission(next))?.dispatchState).toBe('accepted')) expect(conversation()?.journal).toBe(journal) - expect(dispatch.mock.calls.map(([input]) => input.clientMessageId)).toEqual([first, next]) + expect(dispatch.mock.calls.map(([input]) => input.clientMessageId)).toEqual([next]) // The reader opened before the Stop saw the next message delivered on the same stream. expect( events.some( @@ -383,16 +395,14 @@ describe('a published child that dies while it proves its start', () => { const EXIT = START_EXIT const TEXT = START_TEXT - it('leaves one error row keyed by the start, and every message it was handed rejected with it (R2)', async () => { + it('leaves one error row keyed by the start, and every message held for it rejected with it (R2)', async () => { await restartHost() acquire.mockImplementation(spawnStartingChild) - // The first is written to the starting child at once; it never answers, so its turn never - // opens and the second waits behind it. - dispatch.mockImplementation(async () => ({ state: 'admitted' as const })) + // Both are held for the starting child, which never proves its start. const first = await accept('first') const events = await subscribe() const second = await accept('second') - await eventually(() => expect(dispatch).toHaveBeenCalledTimes(1)) + await eventually(() => expect(conversation()?.child?.phase).toBe('starting')) const child = currentChild() await exit(child, EXIT, true) @@ -415,6 +425,7 @@ describe('a published child that dies while it proves its start', () => { } expect(rejectedIn(events, second)).toBe(true) expect(acquire).toHaveBeenCalledTimes(2) + expect(dispatch).not.toHaveBeenCalled() }) }) @@ -508,6 +519,7 @@ describe('a start another operation made that dies while a sent message waits on await exited await held const id = params.envelope.clientOperationId + await prove() await eventually(async () => expect((await submission(id))?.dispatchState).not.toBe('pending')) expect((await submission(id))?.dispatchState).toBe('accepted') @@ -564,14 +576,13 @@ describe('a child that ends before its message is handed over', () => { }) describe('another child indexed after a Stop ended the starting one', () => { - it('hands the next message to the child now there, still starting (R2)', async () => { + it('hands the next message to the child now there once it proves its start (R2)', async () => { adapterExtras = { closeSession: vi.fn(async () => true) } await restartHost() acquire.mockImplementation(spawnStartingChild) - // The starting child takes the first message at once and never answers it. - dispatch.mockResolvedValueOnce({ state: 'admitted' }) + // Held for the starting child, which never proves its start. const first = await accept('first') - await eventually(() => expect(dispatch).toHaveBeenCalledTimes(1)) + await eventually(() => expect(conversation()?.child?.phase).toBe('starting')) const stopped = currentChild() expect(await stop()).toMatchObject({ ok: true }) // An operation that needs the agent starts its own child before the second message is sent. @@ -581,15 +592,19 @@ describe('another child indexed after a Stop ended the starting one', () => { const replacement = currentChild() expect(replacement.acquisitionGeneration).not.toBe(stopped.acquisitionGeneration) const secondId = await accept('second') + await settleLoop() + expect((await submission(secondId))?.handedOverAt).toBeUndefined() + + await prove() await eventually(async () => expect((await submission(secondId))?.dispatchState).toBe('accepted') ) expect(conversation()?.child).toMatchObject({ generation: replacement.acquisitionGeneration, - phase: 'starting' + phase: 'ready' }) - expect(dispatch.mock.calls.map(([input]) => input.clientMessageId)).toEqual([first, secondId]) + expect(dispatch.mock.calls.map(([input]) => input.clientMessageId)).toEqual([secondId]) expect(acquire).toHaveBeenCalledTimes(3) expect(await submission(first)).toMatchObject({ reason: DISPATCH_REJECTED_CANCELLED }) }) @@ -703,15 +718,14 @@ async function settleLoop(): Promise { } describe('how a stopped child ends the start its loop was waiting on', () => { - /** A starting child that took the first message and never answered it, then the stop below; - * the message sent after the stop is the next delivery's. */ + /** A starting child the first message is held for, then the stop below; the message sent after + * the stop is the next delivery's. */ async function stoppedWhileStarting(stop: () => Promise) { adapterExtras = { closeSession: vi.fn(async () => true) } await restartHost() acquire.mockImplementationOnce(spawnStartingChild) - dispatch.mockResolvedValueOnce({ state: 'admitted' }) await accept('first') - await eventually(() => expect(dispatch).toHaveBeenCalledTimes(1)) + await eventually(() => expect(conversation()?.child?.phase).toBe('starting')) await stop() return accept('second') } @@ -865,9 +879,8 @@ describe('how a stopped child ends the start its loop was waiting on', () => { adapterExtras = { closeSession: vi.fn(async () => true) } await restartHost() acquire.mockImplementationOnce(spawnStartingChild) - dispatch.mockResolvedValueOnce({ state: 'admitted' }) const first = await accept('first') - await eventually(() => expect(dispatch).toHaveBeenCalledTimes(1)) + await eventually(() => expect(conversation()?.child?.phase).toBe('starting')) await host['serialize'](SESSION, () => stopStructuredAgentSessionAgentUnderSerialize(host['lifetimeContext'](), SESSION, { cause: 'host-stop', @@ -875,7 +888,7 @@ describe('how a stopped child ends the start its loop was waiting on', () => { }) ) - // What the stopped start was handed fails with it, said once; a later send starts afresh. + // What was held for the stopped start fails with it, said once; a later send starts afresh. await eventually(async () => expect((await submission(first))?.dispatchState).toBe('rejected')) // The sentence is the constructor's, not the reason the stop was given. const text = 'Codex never finished starting, so Orca stopped it.' diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-history-window.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-history-window.ts new file mode 100644 index 000000000000..4d090e3ef066 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-history-window.ts @@ -0,0 +1,28 @@ +// What the provider recorded after the journal's last committed item, sampled before an attach +// acquires a new child: once one is running, an absent prompt no longer proves non-delivery. + +import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' + +export async function readProviderHistoryWindow(input: { + adapter: StructuredAgentSessionAdapter + identity: AgentSessionJournalIdentity + accountHome: AgentSessionRecord['accountHome'] + ownerAlreadyAdmitted: boolean +}): Promise { + const read = input.adapter.providerHistoryWindow + if (!read) { + return null + } + let history: ProviderHistoryWindow | null + try { + history = await read({ identity: input.identity, accountHome: input.accountHome }) + } catch { + return null + } + // A lease that was already live may belong to a provider child this process + // has not indexed yet. Preserve the safe unknown outcome in that case. + return history && input.ownerAlreadyAdmitted ? { ...history, turnInFlight: true } : history +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started-catalog.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started-catalog.test.ts index 112c84665f67..5c59dbf0b3f8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started-catalog.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started-catalog.test.ts @@ -102,7 +102,8 @@ it('a published child tells the catalog only once its start is proven', async () fence: store.getRecord(SESSION)?.lease.runtimeFence ?? 0, acquisitionGeneration: 'generation-1', reportedOptions: { model: 'sonnet' }, - restoreSkippedOptions: [] + restoreSkippedOptions: [], + optionRevision: 0 }) expect(providerStarted).toHaveBeenCalledTimes(1) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts index 324d8447bee8..7aa18579ade9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts @@ -1,16 +1,19 @@ // The host's half of a provider child proving its start. // // A publish-first acquire hands the host a child that has answered nothing yet, so the record -// keeps only the saved options the reservation carried. This is where the host learns the start -// landed, flips the session to `ready`, and persists what the child now reports as fact through -// the same record write a user's option change takes. Bookkeeping never gates the user: a failed -// write is reported and the session stays usable. +// keeps only the saved options the reservation carried, and the delivery loop hands it nothing. +// This is where the host learns the start landed: the child turns `ready`, its startup attempt +// ends, and the loop wakes to hand over what was queued meanwhile. Only once that handover is done +// is what the child reports persisted, in a step of its own, so bookkeeping never sits between a +// ready child and the user's first message; a failed write is reported, never thrown. Every report +// is persisted only if no pick or later report came after its read (`option-revisions`). // -// This runs under the session's own serialized step, which its close and sends wait on, so it -// asks the provider nothing: the event carries what the child proved. +// These run under the session's own serialized steps, which its close and sends wait on, so they +// ask the provider nothing: the event carries what the child proved. import { agentSessionLeaseAdmitsWriter } from '../../../shared/agent-session-lease-adjudication' import type { + StructuredAgentSessionOptionsReportedEvent, StructuredAgentSessionOptionsSkippedEvent, StructuredAgentSessionStartedEvent } from './structured-agent-session-adapter' @@ -19,7 +22,12 @@ import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import { nativeSessionOptionsFromReport } from './structured-agent-session-option-restoration' -import { markProviderChildStarted } from './structured-agent-session-provider-child' +import { + markProviderChildStarted, + sameProviderChild +} from './structured-agent-session-provider-child' +import type { StructuredAgentSessionOptionRevisions } from './structured-agent-session-option-revisions' +import type { StructuredAgentSessionStartupAttempts } from './structured-agent-session-startup-attempt' export type StructuredAgentSessionProviderStartedContext = { deps: StructuredAgentSessionHostDeps @@ -27,37 +35,45 @@ export type StructuredAgentSessionProviderStartedContext = { serialize: (sessionId: string, task: () => Promise) => Promise now: () => number publishStatus?: (sessionId: string) => void + runtimeState: { + startupAttempts: Pick + optionRevisions: Pick< + StructuredAgentSessionOptionRevisions, + 'admitReport' | 'advance' | 'isNewest' + > + } + /** The barrier lifts: what was accepted while the child started is handed over now. Settles once + * the loop has handed over all it can. */ + wakeDelivery: (sessionId: string) => Promise } -export function settleStructuredAgentSessionProviderStarted( +type ReportedOptions = Omit + +export async function settleStructuredAgentSessionProviderStarted( context: StructuredAgentSessionProviderStartedContext, event: StructuredAgentSessionStartedEvent ): Promise { + // On arrival, before any wait: reports are admitted in the order the child made them. + const admitted = admitReportedOptions(context, event) // Serialized behind the attach that published this child, so the lease it proved is committed. - return context.serialize(event.sessionId, async () => { + const started = await context.serialize(event.sessionId, async () => { const session = context.sessions.get(event.sessionId) - if ( - !session || - !markProviderChildStarted(session, { - generation: event.acquisitionGeneration, - fence: event.fence - }) - ) { - return + const child = { generation: event.acquisitionGeneration, fence: event.fence } + // A stale child's proof starts nothing: the barrier stays on the child the host holds. + if (!session || !markProviderChildStarted(session, child)) { + return null } + context.runtimeState.startupAttempts.ready(event.sessionId, child) + const delivered = context.wakeDelivery(event.sessionId) noteStructuredAgentSessionProviderStarted(context.deps, event.sessionId) - try { - await persistStartedOptions(context, event) - } catch (error) { - context.deps.logger.warn('recording what a started provider reported failed', { - scope: 'provider-started-options', - sessionId: event.sessionId, - error - }) - } finally { - context.publishStatus?.(event.sessionId) - } + context.publishStatus?.(event.sessionId) + return { delivered } }) + if (!started) { + return + } + // Not awaited: the adapter's next event may be what the handover itself waits on. + void started.delivered.then(() => persistReportedOptions(context, event, admitted)) } /** A proven start shows the agent's program exists and may mean a sign-in was fixed, so the @@ -72,30 +88,76 @@ export function noteStructuredAgentSessionProviderStarted( } } -async function persistStartedOptions( +/** What a ready child reports later, such as a read that came after its start: persisted as the + * start's report is, never ahead of a send. */ +export function settleStructuredAgentSessionOptionsReported( context: StructuredAgentSessionProviderStartedContext, - event: StructuredAgentSessionStartedEvent + event: StructuredAgentSessionOptionsReportedEvent ): Promise { - const { store } = context.deps - const record = store.getRecord(event.sessionId) - if ( - !record || - record.lease.runtimeFence !== event.fence || - !agentSessionLeaseAdmitsWriter(record.lease) - ) { - return + return persistReportedOptions(context, event, admitReportedOptions(context, event)) +} + +function admitReportedOptions( + context: StructuredAgentSessionProviderStartedContext, + event: ReportedOptions +): number | null { + return context.runtimeState.optionRevisions.admitReport(event.sessionId, event.optionRevision) +} + +/** Non-fatal, and only for the child that reported, while its report is still the newest word: a + * pick or a later report since it was admitted is newer than what it read. */ +function persistReportedOptions( + context: StructuredAgentSessionProviderStartedContext, + event: ReportedOptions, + admitted: number | null +): Promise { + if (admitted === null) { + return Promise.resolve() } - await store.replaceSessionOptions({ - sessionId: event.sessionId, - fence: event.fence, - options: nativeSessionOptionsFromReport({ - reported: event.reportedOptions, - restoreSkipped: event.restoreSkippedOptions, - ...(event.retiredOptions ? { retired: event.retiredOptions } : {}), - ...(record.options ? { priorOptions: record.options } : {}) - }), - now: context.now() - }) + return context + .serialize(event.sessionId, async () => { + const child = context.sessions.get(event.sessionId)?.child + const record = context.deps.store.getRecord(event.sessionId) + if ( + !child || + !sameProviderChild(child, { + generation: event.acquisitionGeneration, + fence: event.fence + }) || + !record || + record.lease.runtimeFence !== event.fence || + !agentSessionLeaseAdmitsWriter(record.lease) || + !context.runtimeState.optionRevisions.isNewest( + event.sessionId, + event.optionRevision, + admitted + ) + ) { + return + } + try { + await context.deps.store.replaceSessionOptions({ + sessionId: event.sessionId, + fence: event.fence, + options: nativeSessionOptionsFromReport({ + reported: event.reportedOptions, + restoreSkipped: event.restoreSkippedOptions, + ...(event.retiredOptions ? { retired: event.retiredOptions } : {}), + ...(record.options ? { priorOptions: record.options } : {}) + }), + now: context.now() + }) + } finally { + context.publishStatus?.(event.sessionId) + } + }) + .catch((error: unknown) => { + context.deps.logger.warn('recording what a started provider reported failed', { + scope: 'provider-started-options', + sessionId: event.sessionId, + error + }) + }) } /** A running child showed saved options it cannot run: the record drops them, as a start that @@ -104,6 +166,8 @@ export function settleStructuredAgentSessionOptionsSkipped( context: StructuredAgentSessionProviderStartedContext, event: StructuredAgentSessionOptionsSkippedEvent ): Promise { + // A report read before the child showed this is out of date: it would write the value back. + context.runtimeState.optionRevisions.advance(event.sessionId) return context.serialize(event.sessionId, async () => { const { store } = context.deps const record = store.getRecord(event.sessionId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts index fd3294ddc7d6..90f2a96acba3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts @@ -169,11 +169,17 @@ export async function createQueuedMessageTestRig( })) } - /** A first send that keeps the session working until the test settles it. */ + /** A first send that keeps the session working until the test settles it. A starting child + * proves its start first, as the host hands it nothing before. */ async function workingSend(): Promise { const { id, result } = send('work on this') await result - await eventually(async () => expect((await submission(id))?.handedOverAt).toBeDefined()) + await eventually(async () => { + if (host.collaboratorsForTests().sessions.get(SESSION)?.child?.phase === 'starting') { + await emitStarted() + } + expect((await submission(id))?.handedOverAt).toBeDefined() + }) return id } @@ -241,6 +247,27 @@ export async function createQueuedMessageTestRig( }) } + /** The starting child proves its start, as a publish-first provider's `started` event does: the + * host hands it what it held. */ + async function proveStart(): Promise { + await eventually(() => + expect(host.collaboratorsForTests().sessions.get(SESSION)?.child?.phase).toBe('starting') + ) + await emitStarted() + } + + function emitStarted(): Promise { + return host.handleAdapterEvent({ + type: 'started', + sessionId: SESSION, + fence: store.getRecord(SESSION)!.lease.runtimeFence, + acquisitionGeneration: 'generation-1', + reportedOptions: { model: 'default' }, + restoreSkippedOptions: [], + optionRevision: host.collaboratorsForTests().runtimeState.optionRevisions.current(SESSION) + }) + } + async function dispose(): Promise { await host.flushAllStreamedEvents() await rm(root, { recursive: true, force: true }) @@ -278,6 +305,7 @@ export async function createQueuedMessageTestRig( queuePause, restartOffers, resume, + proveStart, dispose } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts index 592f82653750..fb2f9beae728 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts @@ -3,9 +3,9 @@ // child back: the host accepts the message, and its delivery restarts the child and hands the // message to the new owner, instead of parking it behind a lease nothing would ever re-acquire. // -// A child is published before it has proven its start, and it owns the send from that moment: the -// message is handed to it. When the child exits first, the exit settlement rejects the message and -// writes the cause into the chat, once, and the next send is a fresh restart. +// A child is published before it has proven its start, and the host holds the send until it does, +// then hands it over. When the child exits first, the message was never handed over: it is rejected +// with the cause, written into the chat once, and the next send is a fresh restart. import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -64,7 +64,8 @@ function sendEnvelope( } } -/** A send is accepted at once and handed to the child delivery finds or starts. */ +/** A send is accepted at once; delivery finds or starts its child and holds it until that child + * proves its start. */ async function send( text: string, fence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 @@ -77,10 +78,16 @@ async function send( value: { submission: { dispatchState: 'pending', handoverRecorded: true } } }) const clientMessageId = sent.ok ? sent.value.clientMessageId : '' + await eventually(() => + expect(host.collaboratorsForTests().conversationDelivery.loop.isRunning(SESSION)).toBe(false) + ) + return clientMessageId +} + +async function handedOver(clientMessageId: string): Promise { await eventually(async () => expect((await submission(clientMessageId))?.handedOverAt).toBeDefined() ) - return clientMessageId } /** The child of the current acquisition, as the adapter would identify it in a lifecycle event. */ @@ -97,7 +104,8 @@ function proveStarted(): Promise { type: 'started', ...currentChild(), reportedOptions: { model: 'sonnet' }, - restoreSkippedOptions: [] + restoreSkippedOptions: [], + optionRevision: host.collaboratorsForTests().runtimeState.optionRevisions.current(SESSION) }) } @@ -184,27 +192,31 @@ describe('a send into a published session whose child ended before startup', () expect(acquire).toHaveBeenCalledOnce() }) - it('restarts the child and admits the message against it before it has proven its start', async () => { + it('restarts the child and holds the message for it until it proves its start', async () => { const releasedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 const first = await send('hello again', releasedFence) - // Accepted at the lost owner's fence and handed to the child delivery started, once. + // Accepted at the lost owner's fence, held for the child delivery started. expect(acquire).toHaveBeenCalledTimes(2) expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('live') - expect(dispatch).toHaveBeenCalledOnce() + expect(dispatch).not.toHaveBeenCalled() const current = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 expect(current).toBeGreaterThan(releasedFence) - // Once proven, the next send meets a live owner and restarts nothing. The child echoes the first - // message, as a live one does, so the next is not held behind a turn still opening. + // Proven, the child is handed the message, once. await proveStarted() + await handedOver(first) + expect(dispatch).toHaveBeenCalledOnce() + + // The next send meets a live owner and restarts nothing. The child echoes the first message, + // as a live one does, so the next is not held behind a turn still opening. await host.settleLateDispatch({ sessionId: SESSION, clientMessageId: first, providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 0 } }) - await send('and again', current) + await handedOver(await send('and again', current)) expect(acquire).toHaveBeenCalledTimes(2) expect(dispatch).toHaveBeenCalledTimes(2) }) @@ -216,8 +228,10 @@ describe('a send into a published session whose child ended before startup', () const held = await send('still not signed in', releasedFence) await exitBeforeProof() - // The child never proved its start, so it accepted nothing: the exit rejects the message this - // host admitted, so nothing pins the session and Retry stays offered, and one row names the cause. + // The child never proved its start, so it was handed nothing: the message is rejected, so + // nothing pins the session and Retry stays offered, and one row names the cause. + await eventually(async () => expect((await submission(held))?.dispatchState).toBe('rejected')) + expect((await submission(held))?.handedOverAt).toBeUndefined() expect(await submission(held)).toMatchObject({ dispatchState: 'rejected', reason: 'Codex stopped before it finished starting. Send your message to try again.', @@ -245,23 +259,27 @@ describe('a send into a published session whose child ended before startup', () // One spawn per user action: nothing restarted it a second time. expect(acquire).toHaveBeenCalledTimes(2) - // Retry is a fresh action: it restarts once and is handed to the new child. - await send('signed in now') + // Retry is a fresh action: it restarts once and is handed to the new child once it proves itself. + const retry = await send('signed in now') expect(acquire).toHaveBeenCalledTimes(3) - expect(dispatch).toHaveBeenCalledTimes(2) + await proveStarted() + await handedOver(retry) + expect(dispatch).toHaveBeenCalledOnce() expect((await journalStatuses()).slice(rowsBefore)).toHaveLength(1) }) }) describe('a send while the child of the first start is still proving itself', () => { - it('is handed to the starting child, and nothing restarts it', async () => { - await send('hello') + it('is held for the starting child, handed over once it proves its start, and restarts nothing', async () => { + const id = await send('hello') - expect(dispatch).toHaveBeenCalledOnce() + expect(dispatch).not.toHaveBeenCalled() expect(acquire).toHaveBeenCalledOnce() await proveStarted() + await handedOver(id) + expect(dispatch).toHaveBeenCalledOnce() expect(acquire).toHaveBeenCalledOnce() expect(await journalStatuses()).toEqual([]) }) @@ -272,6 +290,7 @@ describe('a send while the child of the first start is still proving itself', () await exitBeforeProof() + await eventually(async () => expect((await submission(held))?.dispatchState).toBe('rejected')) expect(await submission(held)).toMatchObject({ dispatchState: 'rejected', reason: 'Codex stopped before it finished starting. Send your message to try again.', @@ -288,7 +307,7 @@ describe('a send while the child of the first start is still proving itself', () it('leaves a send against a proven child alone', async () => { await proveStarted() - await send('hello') + await handedOver(await send('hello')) expect(acquire).toHaveBeenCalledOnce() expect(dispatch).toHaveBeenCalledOnce() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts index 6cef8ef9a1f1..1d2c47beb5f4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts @@ -175,10 +175,11 @@ afterEach(async () => { }) describe('a start that fails before it proves itself', () => { - it("writes one row in the exit's words for a message the child was handed", async () => { + it("writes one row in the exit's words for a message held for the starting child", async () => { const sent = await send('hello') - // A starting child takes the message at once. - await eventually(() => expect(dispatch).toHaveBeenCalledOnce()) + await eventually(() => + expect(host.collaboratorsForTests().sessions.get(SESSION)?.child?.phase).toBe('starting') + ) await exitBeforeProof() await eventually(async () => @@ -188,6 +189,7 @@ describe('a start that fails before it proves itself', () => { expect(await startRows()).toEqual([EXIT_TEXT]) expect(publishedStartRows()).toEqual([EXIT_TEXT]) + expect(dispatch).not.toHaveBeenCalled() }) it('leaves the row to the loop when the exit lands while the message still waits', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-contract.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-contract.ts new file mode 100644 index 000000000000..ec239cf3a304 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-contract.ts @@ -0,0 +1,43 @@ +// The startup attempt's shape and limits, apart from its clock: the adapter contract reaches this +// module, so it must not pull in the host or Node-only process code. + +import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentChildWorkEvidence } from '../../../shared/agent-status-child-work-evidence' +import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' + +/** A spawned child with no stdout frame or stderr line for this long has stopped starting. */ +export const STRUCTURED_AGENT_SESSION_STARTUP_SILENCE_MS = 60_000 +/** No start, however chatty, outlives this from its spawn. */ +export const STRUCTURED_AGENT_SESSION_STARTUP_CEILING_MS = 10 * 60_000 + +export type StructuredAgentSessionStartupLimits = { silenceMs: number; ceilingMs: number } + +export const STRUCTURED_AGENT_SESSION_STARTUP_LIMITS: StructuredAgentSessionStartupLimits = { + silenceMs: STRUCTURED_AGENT_SESSION_STARTUP_SILENCE_MS, + ceilingMs: STRUCTURED_AGENT_SESSION_STARTUP_CEILING_MS +} + +export type StructuredAgentSessionStartupAttempt = { + /** The host's generation for this start; never reused, even when the lease fence is. */ + readonly attemptId: string + readonly identity: AgentSessionJournalIdentity + readonly fence: number + readonly spawnToken: string + /** Where and as whom the record pins the start; an adapter never re-resolves either. */ + readonly launch: Pick + /** The saved options the start launches with, as intent; never a catalog guess. */ + readonly options?: Readonly> + /** Provider events may begin before acquisition returns. */ + readonly events?: StructuredAgentSessionEventSink + /** Background work this start's child reports, scoped to the attempt. Unset until the host routes + * child work by attempt; adapters report through their registration until then. */ + readonly childWork?: (evidence: AgentChildWorkEvidence[]) => void + /** Aborted by a close, a Stop admitted now, quit, or the startup limit: the adapter stops what it + * started and the acquire fails. */ + readonly signal?: AbortSignal + /** Any stdout frame or stderr line from the child: proof the start is still moving. */ + readonly onOutput?: () => void + /** The conversation's option revision now; a report stamps it as its read begins. */ + readonly optionRevision: () => number +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts new file mode 100644 index 000000000000..7efd39d0072a --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts @@ -0,0 +1,621 @@ +// The host's startup attempt and its hold on delivery, against the real host, store and journal +// with a scripted adapter. A send to a chat whose agent is still starting is accepted at once and +// handed over only once the start proves itself; every way a start ends settles what was held. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { DISPATCH_REJECTED_CANCELLED } from '../../../shared/structured-agent-session-dispatch-rejection' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import type { + AgentSessionAcquisition, + StructuredAgentSessionAcquireInput, + StructuredAgentSessionAdapter +} from './structured-agent-session-adapter' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import type { StructuredAgentSessionStartupLimits } from './structured-agent-session-startup-attempt-contract' + +const CALLER = { callerKey: 'client-1' } + +const eventually = (assertion: () => void | Promise): Promise => + vi.waitFor(assertion, { timeout: 10_000 }) + +let root: string +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost +let acquire: Mock +let dispatch: Mock +let closeSession: Mock> +let setOption: Mock +let readAcquisitionOptions: StructuredAgentSessionAdapter['readAcquisitionOptions'] +let startupLimits: Partial | undefined +const providerStarted = vi.fn() + +function acquisition(input: StructuredAgentSessionAcquireInput): AgentSessionAcquisition { + return { + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken: input.spawnToken + }, + acquisitionGeneration: `generation-${acquire.mock.calls.length}`, + link: { + linkId: `link-${input.fence}`, + handle: codexProviderHandle(THREAD), + origin: store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', + mintedAtFence: input.fence, + observedAt: NOW + } + } +} + +/** An acquire that publishes at spawn: the child proves its start later, with `started`. */ +const publishFirst: StructuredAgentSessionAdapter['acquire'] = async (input) => ({ + ...acquisition(input), + providerChildPhase: 'starting' +}) + +async function startHost(): Promise { + host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, + logger: createStructuredAgentSessionLogger(), + store, + adapter: { + acquire, + dispatch, + closeSession, + releaseAcquisition: vi.fn(async () => true), + cancelTurn: vi.fn(async () => ({ cancelled: false })), + answerPrompt: vi.fn(async () => undefined), + setOption, + ...(readAcquisitionOptions ? { readAcquisitionOptions } : {}) + }, + modelCatalog: { read: vi.fn(), providerStarted }, + journalDatabase: openTestJournalHostDatabase(root), + claimKeyId: 'key-1', + mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, + idleSweep: { intervalMs: 60 * 60_000, idleMs: 60 * 60_000 }, + ...(startupLimits === undefined ? {} : { startupLimits }), + now: () => NOW + }) +} + +/** A fresh host with no child, so the next send starts one through `acquire`. */ +async function restartWith(next: StructuredAgentSessionAdapter['acquire']): Promise { + await host.close(SESSION, 'evict') + await host.flushAllStreamedEvents() + acquire.mockImplementation(next) + closeSession.mockClear() + await startHost() +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-startup-attempt-')) + resetHostTestOperationIds() + startupLimits = undefined + providerStarted.mockReset() + acquire = vi.fn(async (input) => acquisition(input)) + dispatch = vi.fn(async () => ({ + state: 'accepted' as const, + providerIdentity: { + provider: 'codex' as const, + threadId: THREAD, + turnId: `turn-${dispatch.mock.calls.length}`, + ordinal: dispatch.mock.calls.length + } + })) + closeSession = vi.fn(async () => true) + setOption = vi.fn(async () => undefined) + readAcquisitionOptions = undefined + store = await openTestAgentSessionRecordStore(root) + await startHost() + expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) +}) + +afterEach(async () => { + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +function sendParams(text: string, clientOperationId = hostTestOperationId()) { + const body = hostTestMessage(text) + return { + envelope: { + sessionId: SESSION, + clientOperationId, + expectedRuntimeFence: 1, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields: { body } + }) + }, + body + } +} + +async function accept(text: string, clientOperationId?: string): Promise { + const params = sendParams(text, clientOperationId) + expect(await host.send(CALLER, params)).toMatchObject({ + ok: true, + value: { submission: { dispatchState: 'pending', handoverRecorded: true } } + }) + return params.envelope.clientOperationId +} + +async function submission(id: string): Promise { + return (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === id + ) +} + +/** The revision the attempt's `optionRevision()` answers now, as a report's read would stamp. */ +function optionRevisionNow(): number { + const [input] = acquire.mock.calls.at(-1)! + return input.optionRevision!() +} + +function startedEvent(generation = `generation-${acquire.mock.calls.length}`) { + return { + type: 'started' as const, + sessionId: SESSION, + fence: store.getRecord(SESSION)!.lease.runtimeFence, + acquisitionGeneration: generation, + reportedOptions: { model: 'default' }, + restoreSkippedOptions: [], + optionRevision: optionRevisionNow() + } +} + +function pick(value: string) { + const fields = { key: 'model', value } + return host.setOption(CALLER, { + envelope: { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: store.getRecord(SESSION)!.lease.runtimeFence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.setOption', + sessionId: SESSION, + fields + }) + }, + ...fields + }) +} + +/** The send's start has published its child, still starting; the host holds the send. */ +async function heldBehindStart(text: string): Promise { + const id = await accept(text) + await eventually(async () => + expect((await host.readStatusSummary(SESSION))?.hostExecutionPhase).toBe('starting') + ) + return id +} + +function stop() { + return host.cancel(CALLER, { + envelope: { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: null, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.cancel', + sessionId: SESSION, + fields: { turnId: 'turn-none' } + }) + }, + turnId: 'turn-none' + }) +} + +const HOST_STOPPED = agentSessionFailureWords(agentSessionFailureFact('hostStopped'), { + agentName: 'Codex', + surface: 'rejection' +}) + +describe('a send while the agent starts', () => { + it('is accepted at once and handed over only once the start proves itself', async () => { + await restartWith(publishFirst) + const id = await heldBehindStart('hello') + + expect(await submission(id)).toMatchObject({ dispatchState: 'pending' }) + expect((await submission(id))?.handedOverAt).toBeUndefined() + expect(dispatch).not.toHaveBeenCalled() + + await host.handleAdapterEvent(startedEvent()) + + await eventually(async () => expect((await submission(id))?.dispatchState).toBe('accepted')) + expect(dispatch).toHaveBeenCalledTimes(1) + }) + + it('hands every message held behind the start over, in order, once', async () => { + await restartWith(publishFirst) + const first = await heldBehindStart('first') + const second = await accept('second') + // The same id again is the same message: it is recorded and handed over once. + await accept('first', first) + + await host.handleAdapterEvent(startedEvent()) + + await eventually(async () => expect((await submission(second))?.dispatchState).toBe('accepted')) + expect(dispatch.mock.calls.map(([input]) => input.clientMessageId)).toEqual([first, second]) + }) + + it('ignores a start proof from a child it no longer holds', async () => { + await restartWith(publishFirst) + const id = await heldBehindStart('hello') + + await host.handleAdapterEvent(startedEvent('generation-stale')) + await host.handleAdapterEvent({ ...startedEvent(), fence: startedEvent().fence + 1 }) + await host.flushStreamedEvents(SESSION) + + expect(dispatch).not.toHaveBeenCalled() + expect((await host.readStatusSummary(SESSION))?.hostExecutionPhase).toBe('starting') + await host.handleAdapterEvent(startedEvent()) + await eventually(async () => expect((await submission(id))?.dispatchState).toBe('accepted')) + }) + + it('tells the model catalog once the start proves itself, never for a child it no longer holds', async () => { + await restartWith(publishFirst) + providerStarted.mockClear() + const id = await heldBehindStart('hello') + expect(providerStarted).not.toHaveBeenCalled() + + await host.handleAdapterEvent(startedEvent('generation-stale')) + await host.flushStreamedEvents(SESSION) + expect(providerStarted).not.toHaveBeenCalled() + + await host.handleAdapterEvent(startedEvent()) + await eventually(async () => expect((await submission(id))?.dispatchState).toBe('accepted')) + expect(providerStarted).toHaveBeenCalledTimes(1) + expect(providerStarted.mock.calls[0]?.[0]).toMatchObject({ sessionId: SESSION }) + }) +}) + +describe('a start that ends before it proves itself', () => { + it('settles what it held as never sent when the agent exits during startup', async () => { + await restartWith(publishFirst) + const id = await heldBehindStart('hello') + + await host.handleAdapterEvent({ + type: 'ended', + sessionId: SESSION, + fence: store.getRecord(SESSION)!.lease.runtimeFence, + acquisitionGeneration: `generation-${acquire.mock.calls.length}`, + reason: 'exited with code 1', + cause: 'unexpected-exit', + startupUnproven: true + }) + + await eventually(async () => expect((await submission(id))?.dispatchState).toBe('rejected')) + expect((await submission(id))?.handedOverAt).toBeUndefined() + expect(dispatch).not.toHaveBeenCalled() + expect(startupAttemptOpen()).toBe(false) + }) + + it('withdraws what it held on a Stop, without waiting on the handshake', async () => { + await restartWith(publishFirst) + const id = await heldBehindStart('hello') + + expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + + expect(await submission(id)).toMatchObject({ + dispatchState: 'rejected', + reason: DISPATCH_REJECTED_CANCELLED + }) + expect((await submission(id))?.handedOverAt).toBeUndefined() + expect(closeSession).toHaveBeenCalled() + expect(dispatch).not.toHaveBeenCalled() + expect(startupAttemptOpen()).toBe(false) + }) + + it('keeps what it held for the chat on a close, without waiting on the handshake', async () => { + await restartWith(publishFirst) + const id = await heldBehindStart('hello') + + await host.close(SESSION, 'user-close') + + await host.revealSession(SESSION) + expect(await submission(id)).toMatchObject({ + dispatchState: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact('chatClosed'), { surface: 'rejection' }) + }) + expect((await submission(id))?.handedOverAt).toBeUndefined() + expect(dispatch).not.toHaveBeenCalled() + expect(startupAttemptOpen()).toBe(false) + }) + + it('stops a published start that goes silent and fails what it held', async () => { + startupLimits = { silenceMs: 50 } + await restartWith(publishFirst) + const id = await heldBehindStart('hello') + + await eventually(async () => expect((await submission(id))?.dispatchState).toBe('rejected')) + expect(await submission(id)).toMatchObject(HOST_STOPPED) + expect((await submission(id))?.handedOverAt).toBeUndefined() + expect(closeSession).toHaveBeenCalled() + expect(dispatch).not.toHaveBeenCalled() + expect(startupAttemptOpen()).toBe(false) + }) + + it('keeps a slow start that is still talking, and stops one that never readies at the ceiling', async () => { + startupLimits = { silenceMs: 50, ceilingMs: 400 } + const chatter: ReturnType[] = [] + await restartWith(async (input) => { + await input.onSpawned?.(acquisition(input).process) + chatter.push(setInterval(() => input.onOutput?.(), 10)) + return { ...acquisition(input), providerChildPhase: 'starting' } + }) + try { + const id = await heldBehindStart('hello') + await new Promise((resolve) => setTimeout(resolve, 200)) + expect(closeSession).not.toHaveBeenCalled() + expect(startupAttemptOpen()).toBe(true) + + await eventually(async () => expect((await submission(id))?.dispatchState).toBe('rejected')) + expect(await submission(id)).toMatchObject(HOST_STOPPED) + expect(dispatch).not.toHaveBeenCalled() + } finally { + chatter.forEach(clearInterval) + } + }) + + it('aborts an acquire still in its handshake once it goes silent and fails what it held', async () => { + startupLimits = { silenceMs: 50 } + await restartWith( + (input) => + new Promise((_resolve, reject) => { + void input.onSpawned?.(acquisition(input).process) + input.signal?.addEventListener('abort', () => reject(input.signal?.reason)) + }) + ) + const id = await accept('hello') + + await eventually(async () => expect((await submission(id))?.dispatchState).toBe('rejected')) + expect(await submission(id)).toMatchObject(HOST_STOPPED) + expect(dispatch).not.toHaveBeenCalled() + await eventually(() => expect(startupAttemptOpen()).toBe(false)) + }) + + it('ends a ready child’s option read that never answers, and fails what it held', async () => { + startupLimits = { silenceMs: 50 } + readAcquisitionOptions = () => new Promise(() => {}) + await restartWith(async (input) => { + await input.onSpawned?.(acquisition(input).process) + return acquisition(input) + }) + const id = await accept('hello') + + await eventually(async () => expect((await submission(id))?.dispatchState).toBe('rejected')) + expect(await submission(id)).toMatchObject(HOST_STOPPED) + expect(dispatch).not.toHaveBeenCalled() + await eventually(() => expect(startupAttemptOpen()).toBe(false)) + }) + + it('ends a ready child inside its start when the chat closes before its options are read', async () => { + let land = (): void => {} + const landed = new Promise((resolve) => { + land = resolve + }) + await restartWith(async (input) => { + await landed + return acquisition(input) + }) + const id = await accept('hello') + await eventually(() => expect(acquire).toHaveBeenCalled()) + + const closing = host.close(SESSION, 'evict') + land() + await expect(closing).resolves.toBeUndefined() + + await eventually(async () => expect((await submission(id))?.dispatchState).toBe('rejected')) + expect((await submission(id))?.rejection).toMatchObject({ kind: 'chatClosed' }) + expect(dispatch).not.toHaveBeenCalled() + }) + + it('is not ended by the limit once it proved itself', async () => { + startupLimits = { silenceMs: 50 } + await restartWith(publishFirst) + const id = await heldBehindStart('hello') + await host.handleAdapterEvent(startedEvent()) + await eventually(async () => expect((await submission(id))?.dispatchState).toBe('accepted')) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(closeSession).not.toHaveBeenCalled() + expect((await host.readStatusSummary(SESSION))?.hostExecutionPhase).toBe('ready') + expect(startupAttemptOpen()).toBe(false) + }) +}) + +describe('what a started child reports', () => { + it('is persisted only after what was held is handed over', async () => { + await restartWith(publishFirst) + await heldBehindStart('hello') + const order: string[] = [] + dispatch.mockImplementationOnce(async (input) => { + order.push('dispatch') + return { + state: 'accepted' as const, + providerIdentity: { + provider: 'codex' as const, + threadId: THREAD, + turnId: `turn-${input.clientMessageId}`, + ordinal: 1 + } + } + }) + const replace = store.replaceSessionOptions.bind(store) + vi.spyOn(store, 'replaceSessionOptions').mockImplementation(async (args) => { + order.push('persist') + return replace(args) + }) + + await host.handleAdapterEvent({ ...startedEvent(), reportedOptions: { model: 'reported' } }) + + await eventually(() => expect(order).toEqual(['dispatch', 'persist'])) + expect(store.getRecord(SESSION)?.options).toMatchObject({ model: 'reported' }) + }) + + it('never overwrites a pick made after the child reported', async () => { + await restartWith(publishFirst) + await heldBehindStart('hello') + const report = { ...startedEvent(), reportedOptions: { model: 'reported' } } + dispatch.mockImplementationOnce(async () => { + // A pick lands while the first handover is still running. + void pick('picked') + return { state: 'admitted' as const } + }) + + await host.handleAdapterEvent(report) + await eventually(() => expect(store.getRecord(SESSION)?.options).toEqual({ model: 'picked' })) + await new Promise((resolve) => setTimeout(resolve, 50)) + await host.flushStreamedEvents(SESSION) + + expect(store.getRecord(SESSION)?.options).toEqual({ model: 'picked' }) + }) + + it('never lets a settings read made before a pick undo it, even queued behind the pick', async () => { + await restartWith(publishFirst) + const id = await heldBehindStart('hello') + await host.handleAdapterEvent(startedEvent()) + await eventually(async () => expect((await submission(id))?.dispatchState).toBe('accepted')) + await eventually(() => expect(store.getRecord(SESSION)?.options).toEqual({ model: 'default' })) + // The settings read starts, then the user picks while it is out. + const readAt = optionRevisionNow() + let landPick = (): void => {} + setOption.mockImplementationOnce( + () => + new Promise((resolve) => { + landPick = () => resolve(undefined) + }) + ) + const picked = pick('picked') + await eventually(() => expect(setOption).toHaveBeenCalled()) + + // The read answers with what the child ran before the pick, while the pick is still in flight. + const reported = host.handleAdapterEvent({ + ...startedEvent(), + type: 'options-reported', + reportedOptions: { model: 'default' }, + optionRevision: readAt + }) + landPick() + expect(await picked).toMatchObject({ ok: true }) + await reported + await host.flushStreamedEvents(SESSION) + + expect(store.getRecord(SESSION)?.options).toEqual({ model: 'picked' }) + }) + + it('never lands the start’s report after a newer settings report', async () => { + await restartWith(publishFirst) + await heldBehindStart('hello') + let landHandover = (): void => {} + dispatch.mockImplementationOnce( + () => + new Promise((resolve) => { + landHandover = () => resolve({ state: 'admitted' as const }) + }) + ) + // The start's report waits on the handover; the settings report that follows it does not. + await host.handleAdapterEvent({ ...startedEvent(), reportedOptions: { model: 'at-start' } }) + await eventually(() => expect(dispatch).toHaveBeenCalled()) + const reported = host.handleAdapterEvent({ + ...startedEvent(), + type: 'options-reported', + reportedOptions: { model: 'from-settings' } + }) + + landHandover() + await reported + await new Promise((resolve) => setTimeout(resolve, 50)) + await host.flushStreamedEvents(SESSION) + + expect(store.getRecord(SESSION)?.options).toEqual({ model: 'from-settings' }) + }) + + it('keeps a settings read stamped before the host took the start’s report', async () => { + await restartWith(publishFirst) + await heldBehindStart('hello') + // Both reads are stamped before the host takes either report, as a delivery that lags would. + const started = { ...startedEvent(), reportedOptions: { model: 'at-start' } } + const settings = { + ...started, + type: 'options-reported' as const, + reportedOptions: { model: 'from-settings' } + } + + await host.handleAdapterEvent(started) + await host.handleAdapterEvent(settings) + await new Promise((resolve) => setTimeout(resolve, 50)) + await host.flushStreamedEvents(SESSION) + + expect(store.getRecord(SESSION)?.options).toEqual({ model: 'from-settings' }) + }) + + it('takes a report read after a pick that failed', async () => { + await restartWith(publishFirst) + const id = await heldBehindStart('hello') + await host.handleAdapterEvent(startedEvent()) + await eventually(async () => expect((await submission(id))?.dispatchState).toBe('accepted')) + await eventually(() => expect(store.getRecord(SESSION)?.options).toEqual({ model: 'default' })) + setOption.mockRejectedValueOnce(new Error('the provider refused the model')) + await expect(pick('refused')).rejects.toThrow('the provider refused the model') + + await host.handleAdapterEvent({ + ...startedEvent(), + type: 'options-reported', + reportedOptions: { model: 'from-settings' } + }) + + await eventually(() => + expect(store.getRecord(SESSION)?.options).toEqual({ model: 'from-settings' }) + ) + }) +}) + +function startupAttemptOpen(): boolean { + return host.collaboratorsForTests().runtimeState.startupAttempts.isOpen(SESSION) +} + +describe('the attempt an acquire runs under', () => { + it('is minted by the host with the lease, the pinned launch and its progress report', async () => { + await restartWith(publishFirst) + await heldBehindStart('hello') + + const record = store.getRecord(SESSION)! + const [input] = acquire.mock.calls.at(-1)! + expect(input).toMatchObject({ + fence: record.lease.runtimeFence, + launch: { location: record.location, accountHome: record.accountHome } + }) + expect(input.onOutput).toEqual(expect.any(Function)) + expect(input.attemptId).toEqual(expect.any(String)) + expect(input.signal).toBeInstanceOf(AbortSignal) + expect(input.optionRevision!()).toBe( + host.collaboratorsForTests().runtimeState.optionRevisions.current(SESSION) + ) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-registrations.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-registrations.test.ts new file mode 100644 index 000000000000..64c681c9f9ed --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-registrations.test.ts @@ -0,0 +1,192 @@ +// Every agent this build registers starts under the host's one startup attempt and its hold on +// delivery. Iterates the runtime's real registration list, so an agent registered later is covered +// without being named here. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi, type Mock } from 'vitest' +import type { AgentSessionAccountHome } from '../../../shared/agent-session-account-home' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import type { AgentSessionProviderHandle } from '../../../shared/agent-session-provider-handle' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import { STRUCTURED_AGENT_RUNTIME_REGISTRATIONS } from '../../runtime/structured-agent-runtime-registrations' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import type { StructuredAgentDefinition } from './structured-agent-definition' +import { StructuredAgentRegistry } from './structured-agent-registry' +import type { + StructuredAgentSessionAcquireInput, + StructuredAgentSessionAdapter +} from './structured-agent-session-adapter' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' + +const CALLER = { callerKey: 'client-1' } + +// Temporary, removed when every adapter publishes at spawn: adapters whose own acquire already does, +// with the test that drives the real one. Every other registration still runs its handshake inside +// acquire (the bridge) until then. +const REAL_ADAPTER_PUBLISHES_AT_SPAWN: Readonly> = { + claude: 'structured-agent-session-claude-hung-start-stop.test.ts' +} + +let root: string | null = null + +afterEach(async () => { + if (root) { + await rm(root, { recursive: true, force: true }) + root = null + } +}) + +function accountHome(definition: StructuredAgentDefinition): AgentSessionAccountHome { + return definition.accountLocatorKind === 'opencode' + ? { kind: 'opencode', locator: { kind: 'unmanaged' } } + : { variable: definition.accountHomeVariable, path: `/home/dev/.${definition.agent}` } +} + +function handle(definition: StructuredAgentDefinition): AgentSessionProviderHandle { + return { transport: definition.handleTransport, agent: definition.agent, nativeId: 'native-1' } +} + +/** A host driving `definition` over a scripted adapter that publishes its child at spawn. */ +async function hostFor(definition: StructuredAgentDefinition): Promise<{ + host: StructuredAgentSessionHost + store: AgentSessionRecordStore + acquire: Mock + dispatch: Mock +}> { + root = await mkdtemp(join(tmpdir(), 'orca-startup-registrations-')) + resetHostTestOperationIds() + const store = await openTestAgentSessionRecordStore(root) + const acquire: Mock = vi.fn( + async (input: StructuredAgentSessionAcquireInput) => ({ + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken: input.spawnToken + }, + acquisitionGeneration: `generation-${acquire.mock.calls.length}`, + providerChildPhase: 'starting' as const, + link: { + linkId: `link-${input.fence}`, + handle: handle(definition), + origin: 'created' as const, + mintedAtFence: input.fence, + observedAt: NOW + } + }) + ) + const dispatch: Mock = vi.fn(async () => ({ + state: 'admitted' as const + })) + const adapter: StructuredAgentSessionAdapter = { + acquire, + dispatch, + closeSession: vi.fn(async () => true), + releaseAcquisition: vi.fn(async () => true), + cancelTurn: vi.fn(async () => ({ cancelled: false })), + answerPrompt: vi.fn(async () => undefined), + setOption: vi.fn(async () => undefined) + } + const declared: StructuredAgentDefinition = { + ...definition, + // The double implements none of the optional capabilities' methods. + capabilities: { ...definition.capabilities, rewind: false, compact: false, threadGoal: false } + } + const host = new StructuredAgentSessionHost({ + agents: new StructuredAgentRegistry([{ definition: declared, adapter }]), + logger: createStructuredAgentSessionLogger(), + store, + adapter, + journalDatabase: openTestJournalHostDatabase(root), + claimKeyId: 'key-1', + mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, + now: () => NOW + }) + return { host, store, acquire, dispatch } +} + +describe.each( + STRUCTURED_AGENT_RUNTIME_REGISTRATIONS.map( + ({ definition }) => [definition.agent, definition] as const + ) +)('%s starts under the host attempt', (agent, definition) => { + it('acquires under a host-minted attempt and is handed nothing until it proves its start', async () => { + const { host, store, acquire, dispatch } = await hostFor(definition) + try { + const attached = await host.attach( + CALLER, + hostTestAttachParams(null, { + provider: agent, + agent, + accountHome: accountHome(definition), + // A new chat: the provider mints its handle at acquire. + providerHandle: undefined + }) + ) + expect(attached, JSON.stringify(attached)).toMatchObject({ ok: true }) + const record = store.getRecord(SESSION)! + expect(acquire.mock.calls[0]?.[0]).toMatchObject({ + attemptId: expect.any(String), + fence: record.lease.runtimeFence, + launch: { location: record.location, accountHome: record.accountHome }, + onOutput: expect.any(Function) + }) + + const body = hostTestMessage('hello') + const sent = await host.send(CALLER, { + envelope: { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: record.lease.runtimeFence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields: { body } + }) + }, + body + }) + expect(sent).toMatchObject({ ok: true }) + await vi.waitFor(() => + expect(host.collaboratorsForTests().conversationDelivery.loop.isRunning(SESSION)).toBe( + false + ) + ) + expect(dispatch).not.toHaveBeenCalled() + + await host.handleAdapterEvent({ + type: 'started', + sessionId: SESSION, + fence: record.lease.runtimeFence, + acquisitionGeneration: 'generation-1', + reportedOptions: { model: 'default' }, + restoreSkippedOptions: [], + optionRevision: host.collaboratorsForTests().runtimeState.optionRevisions.current(SESSION) + }) + + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) + } finally { + await host.flushAllStreamedEvents() + } + }) +}) + +describe.each( + STRUCTURED_AGENT_RUNTIME_REGISTRATIONS.map(({ definition }) => definition.agent).filter( + (agent) => !REAL_ADAPTER_PUBLISHES_AT_SPAWN[agent] + ) +)('%s with its own adapter', () => { + it.todo('publishes at spawn instead of finishing its handshake first') +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt.test.ts new file mode 100644 index 000000000000..ddf04b4b0e73 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt.test.ts @@ -0,0 +1,250 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { + STRUCTURED_AGENT_SESSION_STARTUP_CEILING_MS as CEILING_MS, + STRUCTURED_AGENT_SESSION_STARTUP_SILENCE_MS as SILENCE_MS +} from './structured-agent-session-startup-attempt-contract' +import { + StructuredAgentSessionStartupAttempts, + mintStructuredAgentSessionStartupAttempt, + type StructuredAgentSessionExpiredStartup, + type StructuredAgentSessionStartupSettled +} from './structured-agent-session-startup-attempt' + +const SESSION = 'session-alpha' +const CHILD = { generation: 'generation-1', fence: 3 } + +let expired: StructuredAgentSessionExpiredStartup[] +let settled: StructuredAgentSessionStartupSettled[] +let attempts: StructuredAgentSessionStartupAttempts + +function mint() { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the mint reads only the lease fence, location, account home and options this literal sets. + const record = { + lease: { runtimeFence: CHILD.fence }, + location: { executionHostId: 'local', workspaceId: 'workspace-1' }, + accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' } + } as unknown as AgentSessionRecord + return mintStructuredAgentSessionStartupAttempt({ + record, + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: null + }, + spawnToken: 'spawn-1', + optionRevision: () => 0 + }) +} + +/** Output every `everyMs` for `forMs`. */ +function chatter(output: () => void, forMs: number, everyMs = 10_000): void { + for (let elapsed = 0; elapsed < forMs; elapsed += everyMs) { + vi.advanceTimersByTime(everyMs) + output() + } +} + +beforeEach(() => { + vi.useFakeTimers() + expired = [] + settled = [] + attempts = new StructuredAgentSessionStartupAttempts({ + expire: (startup) => expired.push(startup), + settled: (startup) => settled.push(startup) + }) +}) + +afterEach(() => { + attempts.dispose() + vi.useRealTimers() +}) + +describe('the startup clock', () => { + it('does not run before the spawn: resolving a launch is not starting', () => { + attempts.track(SESSION, mint()) + + vi.advanceTimersByTime(CEILING_MS * 2) + + expect(expired).toEqual([]) + }) + + it('fails a spawned agent that says nothing for the silence limit, naming the acquire', () => { + const attempt = mint() + attempts.track(SESSION, attempt).spawned() + + vi.advanceTimersByTime(SILENCE_MS - 1) + expect(expired).toEqual([]) + vi.advanceTimersByTime(1) + + expect(expired).toEqual([{ sessionId: SESSION, attemptId: attempt.attemptId, child: null }]) + vi.advanceTimersByTime(CEILING_MS) + expect(expired).toHaveLength(1) + // Measured at the limit, recorded once the aborted acquire settles. + expect(settled).toEqual([]) + attempts.abandon(SESSION, attempt.attemptId) + expect(settled).toEqual([{ agent: 'codex', outcome: 'silent', durationMs: SILENCE_MS }]) + }) + + it('records a start that turned ready as the limit fired as ready', () => { + const attempt = mint() + attempts.track(SESSION, attempt).spawned() + vi.advanceTimersByTime(SILENCE_MS) + expect(expired).toHaveLength(1) + + attempts.published(SESSION, attempt.attemptId, { ...CHILD, phase: 'ready' }) + + expect(settled).toEqual([{ agent: 'codex', outcome: 'ready', durationMs: SILENCE_MS }]) + }) + + it('keeps a slow start that is still talking past the silence limit', () => { + const attempt = mint() + const progress = attempts.track(SESSION, attempt) + progress.spawned() + attempts.published(SESSION, attempt.attemptId, { ...CHILD, phase: 'starting' }) + + chatter(progress.output, SILENCE_MS * 3) + attempts.ready(SESSION, CHILD) + + expect(expired).toEqual([]) + expect(settled).toEqual([{ agent: 'codex', outcome: 'ready', durationMs: SILENCE_MS * 3 }]) + expect(attempts.isOpen(SESSION)).toBe(false) + }) + + it('times silence from the last output, not the spawn', () => { + const progress = attempts.track(SESSION, mint()) + progress.spawned() + vi.advanceTimersByTime(SILENCE_MS - 1_000) + progress.output() + + vi.advanceTimersByTime(SILENCE_MS - 1) + expect(expired).toEqual([]) + vi.advanceTimersByTime(1) + + expect(expired).toHaveLength(1) + }) + + it('fails a chatty agent that never becomes ready at the ceiling', () => { + const attempt = mint() + const progress = attempts.track(SESSION, attempt) + progress.spawned() + attempts.published(SESSION, attempt.attemptId, { ...CHILD, phase: 'starting' }) + + chatter(progress.output, CEILING_MS - 10_000) + expect(expired).toEqual([]) + chatter(progress.output, 10_000) + + expect(expired).toEqual([{ sessionId: SESSION, attemptId: attempt.attemptId, child: CHILD }]) + expect(settled).toEqual([{ agent: 'codex', outcome: 'ceiling', durationMs: CEILING_MS }]) + }) + + it('starts on output or publication when no spawn was reported', () => { + const byOutput = mint() + attempts.track(SESSION, byOutput).output() + const byPublish = mint() + attempts.track('session-beta', byPublish) + attempts.published('session-beta', byPublish.attemptId, { ...CHILD, phase: 'starting' }) + + vi.advanceTimersByTime(SILENCE_MS) + + expect(expired.map((startup) => startup.attemptId).sort()).toEqual( + [byOutput.attemptId, byPublish.attemptId].sort() + ) + }) +}) + +describe('an attempt ends', () => { + it('with the start its child proved, and ignores a stale child’s proof', () => { + const attempt = mint() + attempts.track(SESSION, attempt).spawned() + attempts.published(SESSION, attempt.attemptId, { ...CHILD, phase: 'starting' }) + + attempts.ready(SESSION, { ...CHILD, generation: 'generation-0' }) + expect(attempts.isOpen(SESSION)).toBe(true) + attempts.ready(SESSION, CHILD) + vi.advanceTimersByTime(CEILING_MS) + + expect(expired).toEqual([]) + expect(attempts.isOpen(SESSION)).toBe(false) + }) + + it('with its child, however the child ended, leaving no entry or timer', () => { + const attempt = mint() + attempts.track(SESSION, attempt).spawned() + attempts.published(SESSION, attempt.attemptId, { ...CHILD, phase: 'starting' }) + + attempts.childEnded(SESSION, { ...CHILD, generation: 'generation-0' }) + expect(attempts.isOpen(SESSION)).toBe(true) + attempts.childEnded(SESSION, CHILD) + + expect(attempts.isOpen(SESSION)).toBe(false) + expect(vi.getTimerCount()).toBe(0) + expect(settled.map((startup) => startup.outcome)).toEqual(['ended']) + }) + + it('once an expiry has stopped its published child', () => { + const attempt = mint() + attempts.track(SESSION, attempt).spawned() + attempts.published(SESSION, attempt.attemptId, { ...CHILD, phase: 'starting' }) + + vi.advanceTimersByTime(SILENCE_MS) + + expect(expired).toHaveLength(1) + expect(attempts.isOpen(SESSION)).toBe(false) + expect(vi.getTimerCount()).toBe(0) + }) + + it('when an acquire the expiry aborted is abandoned, or publishes late and is expired then', () => { + const abandoned = mint() + attempts.track(SESSION, abandoned).spawned() + vi.advanceTimersByTime(SILENCE_MS) + expect(attempts.isOpen(SESSION)).toBe(true) + attempts.abandon(SESSION, abandoned.attemptId) + expect(attempts.isOpen(SESSION)).toBe(false) + + const late = mint() + attempts.track(SESSION, late).spawned() + vi.advanceTimersByTime(SILENCE_MS) + attempts.published(SESSION, late.attemptId, { ...CHILD, phase: 'starting' }) + + expect(expired.map((startup) => startup.child)).toEqual([null, null, CHILD]) + expect(attempts.isOpen(SESSION)).toBe(false) + expect(vi.getTimerCount()).toBe(0) + // Each start is measured once, at the limit that ended it. + expect(settled.map((startup) => startup.outcome)).toEqual(['silent', 'silent']) + }) + + it('with an acquire that answered ready, or an attach that failed', () => { + const ready = mint() + attempts.track(SESSION, ready).spawned() + attempts.published(SESSION, ready.attemptId, { ...CHILD, phase: 'ready' }) + const failed = mint() + attempts.track('session-beta', failed).spawned() + attempts.abandon('session-beta', failed.attemptId) + + vi.advanceTimersByTime(CEILING_MS) + + expect(expired).toEqual([]) + expect(vi.getTimerCount()).toBe(0) + }) + + it('when the next attempt replaces it, and every clock stops at quit', () => { + const first = mint() + const firstProgress = attempts.track(SESSION, first) + firstProgress.spawned() + const second = mint() + attempts.track(SESSION, second).spawned() + // The replaced attempt's reports reach nothing. + firstProgress.output() + attempts.abandon(SESSION, first.attemptId) + expect(attempts.isOpen(SESSION)).toBe(true) + + attempts.dispose() + vi.advanceTimersByTime(CEILING_MS) + + expect(expired).toEqual([]) + expect(attempts.isOpen(SESSION)).toBe(false) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt.ts new file mode 100644 index 000000000000..efecd7f77376 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt.ts @@ -0,0 +1,318 @@ +// One start of a conversation's provider child, minted by the host before any adapter acquires. +// +// Whatever a start means as policy belongs to the host and is the same for every agent: which +// conversation and lease it is for, what cancels it, when it gives up, and when its child may take +// input. An adapter only runs its protocol inside the attempt it is handed. +// +// The clock starts when the process exists, never before (resolving a launch is not starting it). +// A start that says nothing for a minute is wedged; one still talking is given up only at the +// ceiling, so a slow migration or history replay is not killed mid-way while every start still +// ends. Expiry re-derives what to end from the host's own state, so a timer that outlives its +// attempt ends nothing. + +import { randomUUID } from 'node:crypto' +import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' +import type { StructuredAgentSessionProviderChildIdentity } from './structured-agent-session-host-types' +import { sameProviderChild } from './structured-agent-session-provider-child' +import { + STRUCTURED_AGENT_SESSION_STARTUP_LIMITS, + type StructuredAgentSessionStartupAttempt, + type StructuredAgentSessionStartupLimits +} from './structured-agent-session-startup-attempt-contract' + +export function mintStructuredAgentSessionStartupAttempt(input: { + record: AgentSessionRecord + identity: AgentSessionJournalIdentity + spawnToken: string + events?: StructuredAgentSessionEventSink + signal?: AbortSignal + optionRevision: () => number +}): StructuredAgentSessionStartupAttempt { + const { record } = input + return { + attemptId: randomUUID(), + identity: input.identity, + fence: record.lease.runtimeFence, + spawnToken: input.spawnToken, + launch: { + location: record.location, + accountHome: record.accountHome, + ...(record.launchDirectory === undefined ? {} : { launchDirectory: record.launchDirectory }) + }, + ...(record.options ? { options: record.options } : {}), + ...(input.events ? { events: input.events } : {}), + ...(input.signal ? { signal: input.signal } : {}), + optionRevision: input.optionRevision + } +} + +/** The startup limit's own reason, so an expired start is told from a close, Stop or quit. */ +export class StructuredAgentSessionStartupExpiredError extends Error { + constructor() { + super('the agent stopped making progress before it finished starting') + this.name = 'StructuredAgentSessionStartupExpiredError' + } +} + +export function isStructuredAgentSessionStartupExpired(reason: unknown): boolean { + return reason instanceof Error && reason.name === 'StructuredAgentSessionStartupExpiredError' +} + +/** Where an expired attempt stood: still inside its acquire, or published as a starting child. */ +export type StructuredAgentSessionExpiredStartup = { + sessionId: string + attemptId: string + /** Null while the acquire has not returned. */ + child: StructuredAgentSessionProviderChildIdentity | null +} + +/** How one spawned start ended, for tuning the limits from what agents really take. */ +export type StructuredAgentSessionStartupSettled = { + agent: AgentSessionJournalIdentity['agent'] + outcome: 'ready' | 'silent' | 'ceiling' | 'ended' + /** From spawn to the outcome. */ + durationMs: number +} + +/** What the host learns about a start while its acquire runs. */ +export type StructuredAgentSessionStartupProgress = { + /** The process exists: the clock starts. */ + spawned: () => void + /** Any output; output before a spawn report also starts the clock. */ + output: () => void +} + +type Tracked = { + attempt: StructuredAgentSessionStartupAttempt + child: StructuredAgentSessionProviderChildIdentity | null + spawnedAt: number | null + lastOutputAt: number + timer: ReturnType | null + expired: boolean + /** The limit's measure, held until an acquire it aborted settles: one that is ready anyway + * reports that instead. */ + expiry: StructuredAgentSessionStartupSettled | null +} + +/** Each session's open attempt and its startup clock. A session starts one child at a time, so a + * new attempt replaces any older one. */ +export class StructuredAgentSessionStartupAttempts { + private readonly open = new Map() + private disposed = false + private readonly limits: StructuredAgentSessionStartupLimits + private readonly now: () => number + + constructor( + private readonly deps: { + /** Runs outside the session's queue: an acquire may hold it for the whole handshake. */ + expire: (expired: StructuredAgentSessionExpiredStartup) => void + /** Never throws back into the clock. */ + settled?: (settled: StructuredAgentSessionStartupSettled) => void + limits?: Partial + /** Real elapsed time: the host's own `now` may be pinned. */ + now?: () => number + } + ) { + this.limits = { ...STRUCTURED_AGENT_SESSION_STARTUP_LIMITS, ...deps.limits } + this.now = deps.now ?? Date.now + } + + track( + sessionId: string, + attempt: StructuredAgentSessionStartupAttempt + ): StructuredAgentSessionStartupProgress { + this.end(sessionId) + const tracked: Tracked = { + attempt, + child: null, + spawnedAt: null, + lastOutputAt: 0, + timer: null, + expired: false, + expiry: null + } + this.open.set(sessionId, tracked) + const current = (): boolean => this.open.get(sessionId) === tracked && !this.disposed + return { + spawned: () => { + if (current()) { + this.startClock(sessionId, tracked) + } + }, + output: () => { + if (!current()) { + return + } + if (tracked.spawnedAt === null) { + this.startClock(sessionId, tracked) + return + } + // Read when the timer fires, so a chatty child costs no timer churn. + tracked.lastOutputAt = this.now() + } + } + } + + /** The acquire returned: a `starting` child stays on the clock until it proves its start, and + * one published after the limit passed is expired now. */ + published( + sessionId: string, + attemptId: string, + child: StructuredAgentSessionProviderChildIdentity & { phase: 'starting' | 'ready' } + ): void { + const tracked = this.open.get(sessionId) + if (tracked?.attempt.attemptId !== attemptId) { + return + } + if (child.phase === 'ready') { + this.settle(sessionId, tracked, 'ready') + return + } + tracked.child = { generation: child.generation, fence: child.fence } + if (tracked.expired) { + this.expire(sessionId, tracked, null) + return + } + // A child published without a spawn report has existed since at least now. + if (tracked.spawnedAt === null && !this.disposed) { + this.startClock(sessionId, tracked) + } + } + + /** The child proved its start: its attempt is over. A stale child's proof ends nothing. */ + ready(sessionId: string, child: StructuredAgentSessionProviderChildIdentity): void { + const tracked = this.open.get(sessionId) + if (tracked?.child && sameProviderChild(tracked.child, child)) { + this.settle(sessionId, tracked, 'ready') + } + } + + /** The child ended (exit, crash, Stop, close): nothing is left for its attempt to time. */ + childEnded(sessionId: string, child: StructuredAgentSessionProviderChildIdentity): void { + const tracked = this.open.get(sessionId) + if (tracked?.child && sameProviderChild(tracked.child, child)) { + this.settle(sessionId, tracked, 'ended') + } + } + + /** The attempt failed, or its attach did: nothing it started is left to time out. */ + abandon(sessionId: string, attemptId: string): void { + const tracked = this.open.get(sessionId) + if (tracked?.attempt.attemptId === attemptId) { + this.settle(sessionId, tracked, 'ended') + } + } + + /** Whether the session still has an attempt open; for tests and diagnostics. */ + isOpen(sessionId: string): boolean { + return this.open.has(sessionId) + } + + /** Quit: no limit fires after this. */ + dispose(): void { + this.disposed = true + for (const tracked of this.open.values()) { + if (tracked.timer) { + clearTimeout(tracked.timer) + } + } + this.open.clear() + } + + private startClock(sessionId: string, tracked: Tracked): void { + if (tracked.spawnedAt !== null) { + return + } + tracked.spawnedAt = this.now() + tracked.lastOutputAt = tracked.spawnedAt + this.schedule(sessionId, tracked) + } + + private schedule(sessionId: string, tracked: Tracked): void { + if (tracked.spawnedAt === null) { + return + } + const due = Math.min( + tracked.lastOutputAt + this.limits.silenceMs, + tracked.spawnedAt + this.limits.ceilingMs + ) + tracked.timer = setTimeout(() => this.check(sessionId, tracked), Math.max(0, due - this.now())) + // A start's clock must never be the reason a process stays alive at quit. + tracked.timer.unref?.() + } + + private check(sessionId: string, tracked: Tracked): void { + tracked.timer = null + if (this.open.get(sessionId) !== tracked || this.disposed || tracked.spawnedAt === null) { + return + } + const now = this.now() + if (now - tracked.spawnedAt >= this.limits.ceilingMs) { + this.expire(sessionId, tracked, 'ceiling') + } else if (now - tracked.lastOutputAt >= this.limits.silenceMs) { + this.expire(sessionId, tracked, 'silent') + } else { + this.schedule(sessionId, tracked) + } + } + + /** `reason` null: a start whose limit already passed inside its acquire was published now. */ + private expire(sessionId: string, tracked: Tracked, reason: 'silent' | 'ceiling' | null): void { + if (this.open.get(sessionId) !== tracked || this.disposed) { + return + } + if (reason) { + tracked.expiry = this.measure(tracked, reason) + } + tracked.expired = true + const { child } = tracked + // A published child's stop is under way and ends nothing a newer attempt owns; one still + // acquiring stays tracked until its aborted acquire is abandoned or published. + if (child) { + this.report(tracked.expiry) + this.end(sessionId) + } + this.deps.expire({ sessionId, attemptId: tracked.attempt.attemptId, child }) + } + + private settle(sessionId: string, tracked: Tracked, outcome: 'ready' | 'ended'): void { + this.report( + tracked.expired && outcome === 'ended' ? tracked.expiry : this.measure(tracked, outcome) + ) + this.end(sessionId) + } + + private measure( + tracked: Tracked, + outcome: StructuredAgentSessionStartupSettled['outcome'] + ): StructuredAgentSessionStartupSettled | null { + return tracked.spawnedAt === null + ? null + : { + agent: tracked.attempt.identity.agent, + outcome, + durationMs: Math.max(0, this.now() - tracked.spawnedAt) + } + } + + private report(settled: StructuredAgentSessionStartupSettled | null): void { + if (!settled || this.disposed) { + return + } + try { + this.deps.settled?.(settled) + } catch { + // Measurement is bookkeeping: it never decides a start. + } + } + + private end(sessionId: string): void { + const tracked = this.open.get(sessionId) + if (tracked?.timer) { + clearTimeout(tracked.timer) + } + this.open.delete(sessionId) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts index 9c9722de00bb..c09d2eba456c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts @@ -108,7 +108,7 @@ async function queuedDraft(text: string): Promise { * behind the start, which the Stop holds. */ async function stopOfStart(options: { held?: true } = {}): Promise { rig = await createQueuedMessageTestRig({ starting: true, restartable: true }) - // Handed over at once to a child that never proves its start; nothing echoes it. + // Held for a child that never proves its start, so it is never handed over. rig.send('work on this') await eventually(() => expect(childPhase()).toBe('starting')) const held = options.held ? await queuedDraft('queued behind the start') : undefined @@ -124,6 +124,7 @@ async function mailTurn(): Promise { const handedOver = rig.dispatch.mock.calls.length const mail = rig.send('mail for the worker') await mail.result + await rig.proveStart() await eventually(() => expect(rig.dispatch).toHaveBeenCalledTimes(handedOver + 1)) await turnOpenedBy(mail.id) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts index 1e29b5ad1113..ea0dae59199e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts @@ -126,9 +126,13 @@ describe("a Stop's event", () => { it('at an agent still starting, reaches the journal before the start is ended, and holds a card queued before it', async () => { rig = await createQueuedMessageTestRig({ starting: true, restartable: true }) - // A starting agent takes the send at once; it never proves its start. + // Held for a starting agent that never proves its start. rig.send('work on this') - await eventually(() => expect(rig.dispatch).toHaveBeenCalledTimes(1)) + await eventually(() => + expect(rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.child?.phase).toBe( + 'starting' + ) + ) const held = await queuedDraft('queued while it starts') let atEnd: JournalStopEvent[] | undefined rig.closeSession.mockImplementationOnce(async () => { @@ -140,6 +144,7 @@ describe("a Stop's event", () => { expect(await rig.handoff(held)).toBeUndefined() expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) expect(rig.cancelTurn).not.toHaveBeenCalled() + expect(rig.dispatch).not.toHaveBeenCalled() expect(rig.closeSession).toHaveBeenCalledTimes(1) expect(atEnd).toEqual([ { reason: 'user-stop', caller: QUEUED_RIG_CALLER.callerKey, at: expect.any(Number) } 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 200ce5a338ab..17ccba7938ef 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 @@ -28,10 +28,8 @@ import type { AgentSessionJournal } from '../agent-session-journal/journal-store import type { JournalOperationReceipt } from '../agent-session-journal/journal-row-writer' import type { AgentSessionDispatchOutcome, - StructuredAgentSessionAdapter, - StructuredAgentSessionProviderChildPhase + StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { structuredAgentSessionStartFailure } from './structured-agent-session-failure-text' import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' import { handOverStructuredAgentSessionCommand, @@ -67,8 +65,6 @@ export type AgentSessionTurnContext = { /** Republishes state kept outside the journal, such as the record's options or rewind phase. * Journal appends reach readers on their own. */ publish: () => void - /** What the host holds about the child this dispatch is for, read at the moment it is needed. */ - 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`). */ @@ -98,10 +94,10 @@ export function agentSessionAttachmentExpiredRefusal(): { ) } -/** A thrown adapter error is indistinguishable from a lost reply, so it settles as `unknown` - * rather than as a rejection — unless the child had not proven its start. A dispatch to such a - * child throws only when the start failed before the write (a write's own failure is an outcome, - * not a throw), so it is provably unwritten and is rejected with the cause the adapter gave. */ +/** A thrown adapter error is indistinguishable from a lost reply, so it settles as `unknown`, never + * replayed. Only a child that proved its start is handed anything, so a start that fails leaves its + * messages unsent instead (the delivery loop's barrier); an adapter that knows a write never + * happened answers `rejected`. */ async function dispatchSafely( ctx: AgentSessionHandoverContext, clientMessageId: string, @@ -117,12 +113,6 @@ async function dispatchSafely( requestedAt }) } catch (error) { - if (ctx.providerChildPhase?.() === 'starting') { - return { - state: 'rejected', - ...structuredAgentSessionStartFailure({ error }, ctx.failureTextContext) - } - } return { state: 'unknown', reason: error instanceof Error ? error.message : String(error) } } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unrun-send-hold.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unrun-send-hold.test.ts index fe6a29cdcae6..e4de581bf067 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unrun-send-hold.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unrun-send-hold.test.ts @@ -1,9 +1,8 @@ -// A message handed to an agent whose start never answers provably never ran: its CLI takes no -// message before it answers initialize. So when the chat ends then, the message is settled as a +// A message sent while the agent's start never answers provably never ran: the host hands a child +// nothing until it proves its start. So when the chat ends then, the message is settled as a // queued one is for the same end (`journal-unsent-send-hold.ts`): a quit or a close keeps a // person's words as an ordinary card that waits for the chat's next turn, a person's Stop -// withdraws it. Against the real host, store and -// journal, with an agent that stays starting. +// withdraws it. Against the real host, store and journal, with an agent that stays starting. import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' @@ -46,20 +45,23 @@ afterEach(async () => { await rig.dispose() }) -/** Sent to the starting agent and handed over to it, never echoed. */ -async function handedToHungStart(text: string): Promise { +/** Sent while the agent starts, and held: never handed over. */ +async function heldBehindHungStart(text: string): Promise { // No child, so this send starts one that never answers. await rig.host.close(SESSION, 'evict') const sent = rig.send(text) await sent.result - await eventually(async () => expect((await rig.submission(sent.id))?.handedOverAt).toBeDefined()) - expect(rig.host.collaboratorsForTests().sessions.get(SESSION)?.child?.phase).toBe('starting') + await eventually(() => + expect(rig.host.collaboratorsForTests().sessions.get(SESSION)?.child?.phase).toBe('starting') + ) + expect((await rig.submission(sent.id))?.handedOverAt).toBeUndefined() + expect(rig.dispatch).not.toHaveBeenCalled() return sent.id } -describe('a message handed to a start that never answered, then the chat ends', () => { +describe('a message held behind a start that never answered, then the chat ends', () => { it('is a card after a quit, rejected as a restart, held by the reopen until a turn', async () => { - const id = await handedToHungStart('kept through quit') + const id = await heldBehindHungStart('kept through quit') await rig.quitRestartHostProcess() expect(await rig.submission(id)).toMatchObject({ @@ -76,7 +78,7 @@ describe('a message handed to a start that never answered, then the chat ends', expect(await rig.restartOffers()).toEqual([]) }) - it('is offered for resume after a quit once the start answered, as the control', async () => { + it('is offered for resume after a quit once the start proved itself, as the control', async () => { await rig.dispose() rig = await createQueuedMessageTestRig({ restartable: true, @@ -84,7 +86,14 @@ describe('a message handed to a start that never answered, then the chat ends', stopEndsSession: true, recoveryCapsule: true }) - const id = await handedToHungStart('may have run') + await rig.host.close(SESSION, 'evict') + const sent = rig.send('may have run') + await sent.result + await rig.proveStart() + await eventually(async () => + expect((await rig.submission(sent.id))?.handedOverAt).toBeDefined() + ) + const id = sent.id await rig.quitRestartHostProcess() expect(await rig.restartOffers()).toEqual([ @@ -95,7 +104,7 @@ describe('a message handed to a start that never answered, then the chat ends', it.each(['user-close', 'evict'] as const)( 'is a card after a %s, rejected as closed, held by the reopen until a turn', async (cause) => { - const id = await handedToHungStart('kept at close') + const id = await heldBehindHungStart('kept at close') await rig.host.close(SESSION, cause) rig.crashRestartHostProcess() @@ -110,7 +119,7 @@ describe('a message handed to a start that never answered, then the chat ends', ) it("is withdrawn by a person's Stop, as a queued one is, and kept as no card", async () => { - const id = await handedToHungStart('stopped') + const id = await heldBehindHungStart('stopped') expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: true } }) diff --git a/src/main/observability/agent-session-instrumentation.ts b/src/main/observability/agent-session-instrumentation.ts index 693d96a28c20..980f0d61c054 100644 --- a/src/main/observability/agent-session-instrumentation.ts +++ b/src/main/observability/agent-session-instrumentation.ts @@ -1,4 +1,4 @@ -import { withSpan, type ActiveSpan } from './tracer' +import { startSpan, withSpan, type ActiveSpan } from './tracer' export type AgentSessionCreatePhase = | 'reconcile_leases' @@ -24,6 +24,23 @@ export async function withAgentSessionSpan(fn: (span: ActiveSpan) => Promise< return withSpan('agentSession.create', fn, { attributes: { kind: 'agent-session' } }) } +/** One spawned start's outcome and its spawn-to-outcome time, so the startup limits are tuned from + * what each agent really takes. Agent id and numbers only. */ +export function recordAgentSessionStartup(startup: { + agent: string + outcome: string + durationMs: number +}): void { + startSpan('agentSession.startup', { + attributes: { + kind: 'agent-session', + 'agent_session.agent': startup.agent, + 'agent_session.startup.outcome': startup.outcome, + 'agent_session.startup.spawn_to_outcome_ms': Math.round(startup.durationMs) + } + }).end() +} + export async function withAgentSessionCreatePhase( phase: AgentSessionCreatePhase, record: AgentSessionCreatePhaseRecorder | undefined, diff --git a/src/main/pi/rpc-session.ts b/src/main/pi/rpc-session.ts index 7796ae3a1024..ec6427a3f0c9 100644 --- a/src/main/pi/rpc-session.ts +++ b/src/main/pi/rpc-session.ts @@ -117,6 +117,7 @@ export class PiRpcSession { this.connection = ( deps.openConnection ?? ((spec, handlers) => new JsonlRpcAgentConnection(spec, handlers)) )(launch, { + ...(input.onOutput ? { onOutput: input.onOutput } : {}), onRecord: (frame) => frame.type === 'extension_ui_request' ? this.dialogs.receive(frame) diff --git a/src/main/provider-process/managed-provider-process-output.test.ts b/src/main/provider-process/managed-provider-process-output.test.ts new file mode 100644 index 000000000000..1973d83918dc --- /dev/null +++ b/src/main/provider-process/managed-provider-process-output.test.ts @@ -0,0 +1,58 @@ +import { EventEmitter } from 'node:events' +import { PassThrough } from 'node:stream' +import { describe, expect, it, vi } from 'vitest' +import type { spawnProcess } from '../../shared/child-process/run-process' +import { spawnManagedProviderProcess } from './managed-provider-process' + +function launchWithOutput(onOutput: () => void) { + const child = Object.assign(new EventEmitter(), { + pid: 9_999_999, + stdin: new PassThrough(), + stdout: new PassThrough(), + stderr: new PassThrough(), + kill: vi.fn(() => true) + }) + const managed = spawnManagedProviderProcess( + { command: 'fixture-provider', args: [] }, + { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The managed lifecycle reads only events, pid, streams and kill from this fixture. + spawnImpl: () => child as unknown as ReturnType, + platform: 'win32', + site: 'fixture-provider-teardown', + onOutput + } + ) + return { child, managed } +} + +const tick = (): Promise => new Promise((resolve) => setImmediate(resolve)) + +describe('managed provider process output', () => { + it('reports stderr and stdout chunks as output', async () => { + const onOutput = vi.fn() + const { child } = launchWithOutput(onOutput) + child.stdout.on('data', () => {}) + await tick() + + child.stderr.write('migrating\n') + child.stdout.write('{"id":1}\n') + await tick() + + expect(onOutput).toHaveBeenCalledTimes(2) + }) + + it('never starts stdout before its reader subscribes, so a late reader loses nothing', async () => { + const onOutput = vi.fn() + const { child } = launchWithOutput(onOutput) + child.stdout.write('early frame\n') + await tick() + expect(onOutput).not.toHaveBeenCalled() + + const read: string[] = [] + child.stdout.setEncoding('utf8').on('data', (chunk: string) => read.push(chunk)) + await tick() + + expect(read.join('')).toBe('early frame\n') + expect(onOutput).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/provider-process/managed-provider-process.ts b/src/main/provider-process/managed-provider-process.ts index cdee919d242e..c73b0b8ccb06 100644 --- a/src/main/provider-process/managed-provider-process.ts +++ b/src/main/provider-process/managed-provider-process.ts @@ -38,6 +38,8 @@ type ManagedProviderProcessOptions = { inheritedEnv?: NodeJS.ProcessEnv /** Defaults to "the root is gone". */ acceptClose?: (result: ProviderProcessCloseResult) => boolean + /** Any stdout or stderr chunk: the child is doing something. */ + onOutput?: () => void } export type ManagedProviderProcess = { @@ -97,7 +99,11 @@ export function spawnManagedProviderProcess( // An undrained stderr pipe blocks the child once it fills. child.stderr.setEncoding('utf8').on('data', (chunk: string) => { stderrTail = (stderrTail + chunk).slice(-STDERR_TAIL_MAX_CHARS) + options.onOutput?.() }) + if (options.onOutput) { + observeReaderOutput(child.stdout, options.onOutput) + } let resolveExit = (): void => {} const exitPromise = new Promise((resolve) => { resolveExit = resolve @@ -183,3 +189,20 @@ export function spawnManagedProviderProcess( } } } + +/** Watches stdout only once its reader subscribes: a listener of our own would start the stream + * flowing and drop whatever arrived before a reader that subscribes late. */ +function observeReaderOutput( + stdout: Pick, + onOutput: () => void +): void { + const onSubscribe = (event: string | symbol): void => { + if (event !== 'data' && event !== 'readable') { + return + } + stdout.removeListener('newListener', onSubscribe) + // After the reader's own listener lands; no chunk can arrive before a microtask runs. + queueMicrotask(() => stdout.on('data', onOutput)) + } + stdout.on('newListener', onSubscribe) +} diff --git a/src/main/runtime/claude-structured-retired-saved-model.test.ts b/src/main/runtime/claude-structured-retired-saved-model.test.ts index 138a240984dc..d4743996f63a 100644 --- a/src/main/runtime/claude-structured-retired-saved-model.test.ts +++ b/src/main/runtime/claude-structured-retired-saved-model.test.ts @@ -109,20 +109,21 @@ describe("a Claude chat whose saved model the CLI says doesn't exist", () => { expect(record()?.options?.model).toBe('opus') }) - // A slow settings readback lets the turn's error land first; `started` then reports the CLI's - // own model, which is still the retired one, and must not write it back. - it('does not let a start that lands after the heal write the retired model back', async () => { + // A slow settings readback lets the turn's error land first; the report that follows it still + // names the CLI's own model, which is the retired one, and must not write it back. + it('does not let a settings report that lands after the heal write the retired model back', async () => { claude.behave(SESSION, { sendsNoStartFrame: true, startupSettingsReadHangs: true, controlTimeoutMs: 300 }) const host = await claude.install() + const reports = vi.spyOn(host, 'handleAdapterEvent') await expect( host.attach(CALLER, claude.attachParams(SESSION, null, { options: { model: RETIRED } })) ).resolves.toMatchObject({ ok: true }) await vi.waitFor(() => - expect(host.collaboratorsForTests().sessions.get(SESSION)?.child?.phase).toBe('starting') + expect(host.collaboratorsForTests().sessions.get(SESSION)?.child?.phase).toBe('ready') ) claude.child(SESSION).handlers.onMessage?.({ type: 'system', @@ -131,11 +132,16 @@ describe("a Claude chat whose saved model the CLI says doesn't exist", () => { model: RETIRED }) claude.child(SESSION).handlers.onMessage?.(modelNotFoundReply('reply-1')) + await vi.waitFor(() => + expect(host.deps.store.getRecord(SESSION)?.options).not.toHaveProperty('model') + ) + // The settings read gives up at its deadline and reports what the child showed. await vi.waitFor( - () => expect(host.collaboratorsForTests().sessions.get(SESSION)?.child?.phase).toBe('ready'), + () => expect(reports.mock.calls.map(([event]) => event.type)).toContain('options-reported'), { timeout: 5_000 } ) + await host.flushStreamedEvents(SESSION) expect(host.deps.store.getRecord(SESSION)?.options).not.toHaveProperty('model') }) }) diff --git a/src/main/runtime/claude-structured-send-during-startup.test.ts b/src/main/runtime/claude-structured-send-during-startup.test.ts index a50615fbed4a..494d012de462 100644 --- a/src/main/runtime/claude-structured-send-during-startup.test.ts +++ b/src/main/runtime/claude-structured-send-during-startup.test.ts @@ -1,10 +1,10 @@ // A Claude chat is published the moment its child spawns, before the CLI has answered initialize, // and launched with the chat's saved options. A send in that window — into a fresh start, or into -// the restart the delivery loop makes for a send after a start that failed — is written to the CLI -// at once, which takes it behind its own start. When the CLI dies before answering, the message is -// rejected with the CLI's own diagnostic, the chat shows the cause once, and nothing is left as a -// delivery nobody can confirm. Against the production runtime, adapter, record store and host, with -// only the CLI process scripted. +// the restart the delivery loop makes for a send after a start that failed — is accepted at once and +// held by the host until the start proves itself, then written once. When the CLI dies before +// answering, the never-written message is rejected with the CLI's own diagnostic, the chat shows the +// cause once, and nothing is left as a delivery nobody can confirm. Against the production runtime, +// adapter, record store and host, with only the CLI process scripted. import { afterEach, describe, expect, it, vi } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' @@ -57,6 +57,14 @@ function fence(host: StructuredAgentSessionHost): number { return host.deps.store.getRecord(SESSION)?.lease.runtimeFence ?? 0 } +/** Whether the CLI was written a message, once the delivery loop has settled what it does. */ +async function written(host: StructuredAgentSessionHost): Promise { + await vi.waitFor(() => + expect(host.collaboratorsForTests().conversationDelivery.loop.isRunning(SESSION)).toBe(false) + ) + return claude.child(SESSION).calls.includes('send') +} + async function statusRows(host: StructuredAgentSessionHost): Promise { return (await host.journalSnapshot(SESSION)).items.flatMap((item) => item.body.kind === 'status' ? [item.body.text] : [] @@ -80,7 +88,7 @@ async function failLatestStart(host: StructuredAgentSessionHost, count: number): } describe('a send into a Claude chat whose CLI keeps failing at startup', () => { - it('restarts once, rejects the message it wrote with the diagnostic when that start dies too, then delivers once the CLI is healthy', async () => { + it('restarts once, rejects the message held for it with the diagnostic when that start dies too, then delivers once the CLI is healthy', async () => { claude.behave(SESSION, { initHangs: true }) const host = await claude.install() await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ @@ -90,14 +98,14 @@ describe('a send into a Claude chat whose CLI keeps failing at startup', () => { expect(await statusRows(host)).toEqual([STARTUP_TEXT]) const releasedFence = fence(host) - // The delivery loop asks for the child back and writes the message to it at once; the CLI - // dies again before it answers initialize. + // The delivery loop asks for the child back and holds the message for it; the CLI dies again + // before it answers initialize, so it was never written. const held = await send(host, 'hello?') await vi.waitFor(() => expect(claude.children(SESSION)).toHaveLength(2)) - await vi.waitFor(() => expect(claude.child(SESSION).calls).toContain('send')) await vi.waitFor(() => expect(host.deps.store.getRecord(SESSION)?.lease.claimStatus).toBe('live') ) + expect(await written(host)).toBe(false) await failLatestStart(host, 2) // Rejected with the cause, not left in doubt; one row for this attempt names it. @@ -128,21 +136,22 @@ describe('a send into a Claude chat whose CLI keeps failing at startup', () => { }) describe('a send while the first Claude start is still answering initialize', () => { - it('is written to the CLI at once, before it answers', async () => { + it('is held from the CLI until it answers, then written once', async () => { claude.behave(SESSION, { initHangs: true }) const host = await claude.install() await host.attach(CALLER, claude.attachParams(SESSION, null)) const sent = await send(host, 'hello') - await vi.waitFor(() => expect(claude.child(SESSION).calls).toContain('send')) + expect(await written(host)).toBe(false) expect(host.collaboratorsForTests().sessions.get(SESSION)?.child?.phase).toBe('starting') - // The CLI answers: the start lands under the message already written, and nothing fails. + // The CLI answers: the start lands, and the held message is written to it, once. claude.child(SESSION).answerInit() await vi.waitFor(() => expect(host.collaboratorsForTests().sessions.get(SESSION)?.child?.phase).toBe('ready') ) + await vi.waitFor(() => expect(claude.child(SESSION).calls).toContain('send')) expect(claude.child(SESSION).calls.filter((call) => call === 'send')).toHaveLength(1) expect((await submission(host, sent))?.dispatchState).not.toBe('rejected') expect(claude.children(SESSION)).toHaveLength(1) @@ -151,18 +160,19 @@ describe('a send while the first Claude start is still answering initialize', () // Only a SessionStart hook makes the CLI name its session before the first turn, and that hook // comes from status hooks a user can turn off; the initialize answer alone starts the chat. - it('is written at once, and the start lands, with no start frame before the first turn', async () => { + it('lands its start and is written, with no start frame before the first turn', async () => { claude.behave(SESSION, { initHangs: true, sendsNoStartFrame: true }) const host = await claude.install() await host.attach(CALLER, claude.attachParams(SESSION, null)) await send(host, 'hello') - await vi.waitFor(() => expect(claude.child(SESSION).calls).toContain('send')) + expect(await written(host)).toBe(false) claude.child(SESSION).answerInit() await vi.waitFor(() => expect(host.collaboratorsForTests().sessions.get(SESSION)?.child?.phase).toBe('ready') ) + await vi.waitFor(() => expect(claude.child(SESSION).calls).toContain('send')) expect(claude.children(SESSION)).toHaveLength(1) expect(await statusRows(host)).toEqual([]) }) @@ -174,7 +184,7 @@ describe('a send while the first Claude start is still answering initialize', () const startedFence = fence(host) const held = await send(host, 'hello') - await vi.waitFor(() => expect(claude.child(SESSION).calls).toContain('send')) + expect(await written(host)).toBe(false) await failLatestStart(host, 1) await vi.waitFor(async () => diff --git a/src/main/runtime/claude-structured-send-restart-dies-before-dispatch.test.ts b/src/main/runtime/claude-structured-send-restart-dies-before-dispatch.test.ts index 3eb815bca50e..a05905e9599c 100644 --- a/src/main/runtime/claude-structured-send-restart-dies-before-dispatch.test.ts +++ b/src/main/runtime/claude-structured-send-restart-dies-before-dispatch.test.ts @@ -1,6 +1,6 @@ // A send is accepted into a chat whose Claude child is gone, and its delivery restarts the child -// and writes the message to it at once. When that child dies before it proves its start, it never -// ran the message, so the send settles `rejected` with the child's own diagnostic, never as a +// and holds the message until that child proves its start. When it dies first, it was never handed +// the message, so the send settles `rejected` with the child's own diagnostic, never as a // delivery nobody can confirm, and a client that was subscribed the whole time receives the // failure row and the rejected submission over the wire. Against the production runtime, adapter, // record store and host, with only the CLI scripted. @@ -141,7 +141,10 @@ describe('a send whose restarted Claude child dies before it proves its start', // Accepted: the answer comes before the restart it needs. expect(answered.get(sent)).toBe('pending') await eventually(() => expect(claude.children(SESSION)).toHaveLength(2)) - await eventually(() => expect(claude.child(SESSION).calls).toContain('send')) + await eventually(() => + expect(host.collaboratorsForTests().conversationDelivery.loop.isRunning(SESSION)).toBe(false) + ) + expect(claude.child(SESSION).calls).not.toContain('send') await failLatestStart(host, 2) // Provably not delivered, with the cause; not "unconfirmed". diff --git a/src/main/runtime/claude-structured-startup-fault-is-not-an-exit.test.ts b/src/main/runtime/claude-structured-startup-fault-is-not-an-exit.test.ts index 65215d609966..81b3b9c19535 100644 --- a/src/main/runtime/claude-structured-startup-fault-is-not-an-exit.test.ts +++ b/src/main/runtime/claude-structured-startup-fault-is-not-an-exit.test.ts @@ -94,14 +94,17 @@ describe('a Claude start that Orca fails while the CLI is still running', () => expect(await failureRows(host)).toEqual([]) }) - it('rejects a message it wrote as a start that could not happen when init names another session', async () => { + it('rejects a message held for it as a start that could not happen when init names another session', async () => { claude.behave(SESSION, { initHangs: true, initNamesForeignSession: true }) const host = await claude.install() await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ ok: true }) const held = await send(host, 'hello') - await vi.waitFor(() => expect(claude.child(SESSION).calls).toContain('send')) + await vi.waitFor(() => + expect(host.collaboratorsForTests().conversationDelivery.loop.isRunning(SESSION)).toBe(false) + ) + expect(claude.child(SESSION).calls).not.toContain('send') claude.child(SESSION).answerInit() await released(host) @@ -116,6 +119,7 @@ describe('a Claude start that Orca fails while the CLI is still running', () => expect(await failureRows(host)).toEqual([ { text: expect.stringMatching(/^Claude couldn't start\./), kind: 'startFailed' } ]) + expect(claude.child(SESSION).calls).not.toContain('send') }) it('still says Claude stopped when the CLI exits on its own before its start lands', async () => { diff --git a/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts index 436e7020d0db..1048e7ed03e2 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts @@ -471,7 +471,7 @@ describe('every close withdraws what is queued (P2-29)', () => { await restingChat() // The delivery loop has not reached its first start yet. const { loop } = rig.host.collaboratorsForTests().conversationDelivery - vi.spyOn(loop, 'wake').mockImplementation(() => undefined) + vi.spyOn(loop, 'wake').mockImplementation(() => Promise.resolve()) const reader: unknown[] = [] await rig.host.subscribe({ id: 'reader', diff --git a/src/main/runtime/structured-agent-session-failure-logging.test.ts b/src/main/runtime/structured-agent-session-failure-logging.test.ts index 9825a2b7aee6..e343a45ab211 100644 --- a/src/main/runtime/structured-agent-session-failure-logging.test.ts +++ b/src/main/runtime/structured-agent-session-failure-logging.test.ts @@ -111,7 +111,8 @@ describe('failures the desktop host used to drop', () => { fence: 1, acquisitionGeneration: 'generation-1', reportedOptions: { model: 'gpt-5' }, - restoreSkippedOptions: [] + restoreSkippedOptions: [], + optionRevision: 0 }) lifecycle.deliver({ type: 'ended', diff --git a/src/main/runtime/structured-claude-runtime-adapter.ts b/src/main/runtime/structured-claude-runtime-adapter.ts index 269fd8423149..4533ccc3e889 100644 --- a/src/main/runtime/structured-claude-runtime-adapter.ts +++ b/src/main/runtime/structured-claude-runtime-adapter.ts @@ -57,7 +57,11 @@ export type StructuredClaudeRuntimeAdapterDeps = { export function structuredClaudeLifecycleEvent( event: ClaudeStructuredSessionEvent ): StructuredAgentSessionLifecycleEvent | null { - if (event.type === 'started' || event.type === 'options-skipped') { + if ( + event.type === 'started' || + event.type === 'options-reported' || + event.type === 'options-skipped' + ) { return event } // Every exit of a child with an identity, expected or not: the host ends that child's record. From 5de53910aa1f97e413878ae18c4d8ff72eea1db7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 8 Oct 2026 12:29:51 -0700 Subject: [PATCH 18/90] fix(lint): move knownAgentIds out of the session host (#26648) #26416 pushed structured-agent-session-host.ts one line over the 300-line limit, so main's oxlint is red and merges from main fail the commit hook. Move the pure knownAgentIds logic into structured-agent-session-provider-support.ts beside the other agent-support checks. --- .../structured-agent-session-host.ts | 8 +------- .../structured-agent-session-provider-support.ts | 13 +++++++++++++ 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 37ad45d465af..64ed2f26d5fa 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -214,13 +214,7 @@ export class StructuredAgentSessionHost { /** Every agent this runtime registered: what `agentSession.agents` publishes. */ agentDefinitions = () => this.deps.agents.definitions() - /** Saved chats can outlive their registration; both vocabularies bound a client's audience. */ - knownAgentIds = (): readonly string[] => [ - ...new Set([ - ...this.deps.agents.definitions().map(({ agent }) => agent), - ...this.deps.store.listRecords().map(({ provider }) => provider) - ]) - ] + knownAgentIds = (): readonly string[] => providerSupport.knownAgentIds(this.deps) private readonly tabs = sessionTabs.createStructuredAgentSessionTabSurface( this, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-support.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-support.ts index 9215c4908445..aa5844ef0eea 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-support.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-support.ts @@ -65,3 +65,16 @@ export function hostCanStartRecord( agentDrivesSession(deps.agents, record) ) } + +/** Saved chats can outlive their registration; both vocabularies bound a client's audience. */ +export function knownAgentIds(deps: { + agents: Pick + store: { listRecords: () => readonly Pick[] } +}): readonly string[] { + return [ + ...new Set([ + ...deps.agents.definitions().map(({ agent }) => agent), + ...deps.store.listRecords().map(({ provider }) => provider) + ]) + ] +} From a062abffd2c781fda9907ebf8772fc283685cb3e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 8 Oct 2026 13:02:03 -0700 Subject: [PATCH 19/90] Stop native chat jumping when Codex starts a foreground command (#26503) * Keep foreground Codex commands out of the background strip * Run the command-strip host regression in Node * Exercise retained commands after their source turns end --- .../scripts/vitest-sqlite-runtime-files.mjs | 1 + .../codex-background-command-tracker.test.ts | 26 +++- .../codex/codex-background-command-tracker.ts | 75 +++++++-- .../codex-background-task-tracker.test.ts | 7 +- .../codex/codex-background-task-tracker.ts | 7 +- .../codex/codex-child-work-evidence.test.ts | 12 +- src/main/codex/codex-child-work-evidence.ts | 18 +-- .../codex-default-mode-subagents.test.ts | 5 +- ...codex-persistent-command-retention.test.ts | 7 +- .../structured-child-records-switch.test.ts | 23 +-- ...tructured-foreground-command-strip.test.ts | 147 ++++++++++++++++++ ...tructured-agent-session-transport-state.ts | 14 +- .../agent-status-child-work-view.test.ts | 24 +++ src/shared/agent-status-child-work-view.ts | 10 +- ...ured-session-background-tasks-view.test.ts | 43 +++++ ...tructured-session-background-tasks-view.ts | 47 ++++-- ...ctured-session-foreground-commands.test.ts | 99 ++++++++++++ .../structured-session-foreground-commands.ts | 64 ++++++++ 18 files changed, 549 insertions(+), 80 deletions(-) create mode 100644 src/main/runtime/structured-foreground-command-strip.test.ts create mode 100644 src/shared/structured-session-foreground-commands.test.ts create mode 100644 src/shared/structured-session-foreground-commands.ts diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index a8b2112a1481..66485a110158 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -203,6 +203,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/runtime/structured-agent-session-startup-reconcile.test.ts', 'src/main/runtime/structured-claude-attachment-grant.test.ts', 'src/main/runtime/structured-claude-pending-rewind.test.ts', + 'src/main/runtime/structured-foreground-command-strip.test.ts', 'src/main/ssh-expired-lease-pane-readoption.test.ts', 'src/main/ssh-reattach-pane-cardinality.test.ts', 'src/main/ssh/orcad-migration-cutover-coordinator.test.ts', diff --git a/src/main/codex/codex-background-command-tracker.test.ts b/src/main/codex/codex-background-command-tracker.test.ts index abccb4e4a35d..f720a92a44c9 100644 --- a/src/main/codex/codex-background-command-tracker.test.ts +++ b/src/main/codex/codex-background-command-tracker.test.ts @@ -35,6 +35,23 @@ function command(method: string, id = 'exec', threadId = 'root') { } describe('persistent command ownership', () => { + it('promotes only the launching turn and recognizes a start delivered after its end', () => { + const tracker = new CodexBackgroundCommandTracker('root') + tracker.observe(command('item/started')) + expect(tracker.tasks()).toEqual([]) + expect(tracker.endTurn('root', 'another-turn')).toEqual([]) + expect(tracker.endTurn('child', 'turn')).toEqual([]) + expect(tracker.tasks()).toEqual([]) + expect(tracker.endTurn('root', 'turn')).toHaveLength(1) + expect(tracker.tasks()).toHaveLength(1) + expect(tracker.endTurn('root', 'turn')).toEqual([]) + tracker.observe(command('item/started', 'late')) + expect(tracker.tasks()).toHaveLength(2) + tracker.clear() + tracker.observe(command('item/started', 'new')) + expect(tracker.tasks()).toEqual([]) + }) + it('preflights finite metadata capacity and admits work again after process completion', () => { const tracker = new CodexBackgroundCommandTracker('root', 700) const first = command('item/started', 'first') @@ -43,12 +60,12 @@ describe('persistent command ownership', () => { tracker.observe(first) expect(tracker.canObserve(second)).toBe(false) expect(() => tracker.observe(second)).toThrow('not admitted') - expect(tracker.tasks()).toHaveLength(1) + expect(tracker.threadCommands('root')).toHaveLength(1) expect(tracker.retainedMetadataBytes).toBeLessThanOrEqual(700) tracker.observe(command('item/completed', 'first')) expect(tracker.canObserve(second)).toBe(true) tracker.observe(second) - expect(tracker.tasks()).toHaveLength(1) + expect(tracker.threadCommands('root')).toHaveLength(1) expect(tracker.retainedMetadataBytes).toBeLessThanOrEqual(700) tracker.clear() expect(tracker.retainedMetadataBytes).toBe(0) @@ -73,10 +90,11 @@ describe('persistent command ownership', () => { deliver(command('item/started')) const originalKey = rows.find(({ body }) => body.kind === 'tool-call')?.key deliver(notification('turn/completed', { turn: { id: 'turn' } })) + tracker.endTurn('root', 'turn') expect(rows.filter(({ body }) => body.kind === 'tool-call').map(({ body }) => body)).toEqual([ expect.objectContaining({ state: 'running' }) ]) - expect(tracker.tasks()).toHaveLength(1) + expect(tracker.threadCommands('root')).toHaveLength(1) deliver( notification('item/commandExecution/outputDelta', { itemId: 'exec', delta: 'late output' }) ) @@ -102,6 +120,7 @@ describe('persistent command ownership', () => { }) ) expect(tracker.tasks(new Set(['child']))).toEqual([]) + tracker.endTurn('child', 'turn') expect(tracker.tasks()).toEqual([ { id: 'codex-command:thread:child:child-exec', kind: 'command', description: 'sleep 30' } ]) @@ -119,6 +138,7 @@ describe('persistent command ownership', () => { tracker.observe(command('item/started', `short-${index}`)) tracker.observe(command('item/completed', `short-${index}`)) } + tracker.endTurn('root', 'turn') expect(tracker.tasks()).toEqual([ { id: 'codex-command:primary:long-lived', kind: 'command', description: 'sleep 30' } ]) diff --git a/src/main/codex/codex-background-command-tracker.ts b/src/main/codex/codex-background-command-tracker.ts index e7d19e3bbccf..35027c823988 100644 --- a/src/main/codex/codex-background-command-tracker.ts +++ b/src/main/codex/codex-background-command-tracker.ts @@ -8,14 +8,28 @@ import { readRecord, readString } from './codex-item-field-readers' import { readCodexThreadItem } from './codex-structured-item-translation' import { MAX_CODEX_ITEM_STREAM_METADATA_BYTES } from './codex-item-stream-retention' import type { CodexAbandonedCommand } from './codex-prompt-registry' +import { readCodexTurnId } from './codex-structured-thread-facts' +import { codexPrimaryCommandTaskId } from '../../shared/structured-session-foreground-commands' const MAX_SETTLED_COMMANDS = 128 -type Command = { threadId: string; task: AgentSessionBackgroundTask; bytes: number } +type Command = { + threadId: string + turnId: string | null + backgrounded: boolean + task: AgentSessionBackgroundTask + bytes: number +} /** A command process starting, or ending: it exited, its thread closed, or the session ended. */ export type CodexBackgroundCommandChange = - | { type: 'started'; threadId: string; task: AgentSessionBackgroundTask } + | { + type: 'started' + threadId: string + turnId: string | null + backgrounded: boolean + task: AgentSessionBackgroundTask + } | { type: 'ended'; threadId: string; taskId: string } export class CodexBackgroundCommandTracker { @@ -62,7 +76,29 @@ export class CodexBackgroundCommandTracker { this.commands.set(key, command) this.liveBytes += command.bytes this.trimSettled() - return { type: 'started', threadId: command.threadId, task: command.task } + return this.liveChange(command) + } + + /** A surviving process becomes background work when the turn that launched it ends. */ + endTurn(threadId: string, turnId: string): CodexBackgroundCommandChange[] { + const backgrounded: CodexBackgroundCommandChange[] = [] + for (const command of this.commands.values()) { + if ( + command.threadId === threadId && + (command.turnId === turnId || command.turnId === null) && + !command.backgrounded + ) { + command.backgrounded = true + backgrounded.push(this.liveChange(command)) + } + } + this.rememberSettled(this.turnKey(threadId, turnId)) + return backgrounded + } + + private liveChange(command: Command): Extract { + const { threadId, turnId, backgrounded, task } = command + return { type: 'started', threadId, turnId, backgrounded, task } } /** The thread closed: Codex stops its processes first, so none of them can report an exit. */ @@ -82,7 +118,7 @@ export class CodexBackgroundCommandTracker { childLabel?: (threadId: string) => string | null ): AgentSessionBackgroundTask[] { return [...this.commands.values()] - .filter((command) => !coveredThreads?.has(command.threadId)) + .filter((command) => command.backgrounded && !coveredThreads?.has(command.threadId)) .map(({ threadId, task }) => { // The agent row carrying the child's name is gone by the time this row shows; // unqualified it reads as a bare shell string with no owner. Resolved on read so @@ -94,11 +130,11 @@ export class CodexBackgroundCommandTracker { }) } - /** The live commands one thread launched, as the strip would publish them. */ - threadTasks(threadId: string): AgentSessionBackgroundTask[] { + /** Live processes, including foreground tools that are absent from the strip. */ + threadCommands(threadId: string): CodexBackgroundCommandChange[] { return [...this.commands.values()] .filter((command) => command.threadId === threadId) - .map((command) => command.task) + .map((command) => this.liveChange(command)) } /** The session ended, and every command with it. */ @@ -124,15 +160,26 @@ export class CodexBackgroundCommandTracker { this.liveBytes -= existing.bytes this.commands.delete(key) } + this.rememberSettled(key) + return existing + ? { type: 'ended', threadId: existing.threadId, taskId: existing.task.id } + : null + } + + private turnKey(threadId: string, turnId: string): string { + return JSON.stringify(['turn', threadId, turnId]) + } + + private rememberSettled(key: string): void { + if (this.settled.has(key)) { + return + } const bytes = Buffer.byteLength(key, 'utf8') + 256 if (this.liveBytes + bytes <= this.maxMetadataBytes) { this.settled.set(key, bytes) this.settledBytes += bytes } this.trimSettled() - return existing - ? { type: 'ended', threadId: existing.threadId, taskId: existing.task.id } - : null } private trimSettled(): void { @@ -155,8 +202,7 @@ export class CodexBackgroundCommandTracker { if (event.method !== 'item/started' && event.method !== 'item/completed') { return null } - // Any command may outlive its turn; `source` says only how Codex launched it. A stdin write - // starts no process: it reaches one already tracked. + // Track every process; a stdin write reaches one already tracked. const item = readCodexThreadItem(readRecord(event.params).item) if (item?.type !== 'commandExecution' || item.source === 'unifiedExecInteraction') { return null @@ -166,12 +212,15 @@ export class CodexBackgroundCommandTracker { const description = boundCodexCommandDescription(readString(item, 'command') ?? '') .replace(/\s+/g, ' ') .trim() + const turnId = readCodexTurnId(event.params) const value = { threadId: event.threadId, + turnId, + backgrounded: turnId !== null && this.settled.has(this.turnKey(event.threadId, turnId)), task: { id: event.threadId === this.primaryThreadId - ? `codex-command:primary:${encodeURIComponent(item.id)}` + ? codexPrimaryCommandTaskId(item.id) : `codex-command:thread:${encodeURIComponent(event.threadId)}:${encodeURIComponent(item.id)}`, kind: 'command' as const, ...(description ? { description } : {}) diff --git a/src/main/codex/codex-background-task-tracker.test.ts b/src/main/codex/codex-background-task-tracker.test.ts index 6c32896140b3..7926131ae5b3 100644 --- a/src/main/codex/codex-background-task-tracker.test.ts +++ b/src/main/codex/codex-background-task-tracker.test.ts @@ -60,7 +60,7 @@ function command( threadId, params: { threadId, - turnId: PARENT_TURN, + turnId: threadId === PRIMARY ? PARENT_TURN : CHILD_TURN, item: { type: 'commandExecution', id: 'exec-1', @@ -227,13 +227,12 @@ describe('CodexBackgroundTaskTracker child execution ownership', () => { }) describe('CodexBackgroundTaskTracker command integration', () => { - it('keeps a primary shell visible from launch until its own completion', () => { + it('reveals a surviving primary shell after its own turn ends', () => { const tracker = new CodexBackgroundTaskTracker(PRIMARY) const shell = [{ id: 'codex-command:primary:exec-1', kind: 'command', description: 'sleep 90' }] tracker.observe(turn('turn/started', PRIMARY, PARENT_TURN)) tracker.observe(command()) - // Visible while the turn that launched it is still running. - expect(tracker.state?.tasks).toEqual(shell) + expect(tracker.state).toBeNull() tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN)) expect(tracker.state?.tasks).toEqual(shell) // Only the shell's own completion retires the row. diff --git a/src/main/codex/codex-background-task-tracker.ts b/src/main/codex/codex-background-task-tracker.ts index 9fd2880dc75e..a4462e624760 100644 --- a/src/main/codex/codex-background-task-tracker.ts +++ b/src/main/codex/codex-background-task-tracker.ts @@ -44,7 +44,7 @@ export class CodexBackgroundTaskTracker { ) { this.commands = new CodexBackgroundCommandTracker(primaryThreadId) this.childWork = new CodexChildWorkEvidence(primaryThreadId, executions, (threadId) => - this.commands.threadTasks(threadId) + this.commands.threadCommands(threadId) ) } @@ -64,15 +64,18 @@ export class CodexBackgroundTaskTracker { ): boolean { const itemEvent = event.method === 'item/started' || event.method === 'item/completed' const command = itemEvent ? this.commands.observe(event) : null + const frame = readCodexBackgroundTaskFrame(event, this.primaryThreadId) const commands = [ ...unapproved.flatMap((abandoned) => this.commands.endUnapproved(abandoned) ?? []), + ...(frame?.kind === 'turn' && frame.state !== 'working' + ? this.commands.endTurn(frame.threadId, frame.turnId) + : []), ...(event.method === 'thread/closed' ? this.commands.endThread(event.threadId) : command ? [command] : []) ] - const frame = readCodexBackgroundTaskFrame(event, this.primaryThreadId) if (frame?.kind === 'subagents') { for (const child of frame.children) { this.executions.register( diff --git a/src/main/codex/codex-child-work-evidence.test.ts b/src/main/codex/codex-child-work-evidence.test.ts index 5b1b1bc29b4f..c0526cdddca7 100644 --- a/src/main/codex/codex-child-work-evidence.test.ts +++ b/src/main/codex/codex-child-work-evidence.test.ts @@ -411,14 +411,20 @@ describe('Codex child-work evidence', () => { expect.objectContaining({ membership: 'live', description: 'npm run dev', - residency: 'background', + residency: 'foreground', parentChildWorkId: agent!.childWorkId, firstObservedAt: 1_040 }) ]) send(turn('turn/completed', CHILD, 'c1')) expect(byKind('agent')[0]).toMatchObject({ membership: 'settled', outcome: 'succeeded' }) - expect(byKind('command')).toEqual([expect.objectContaining({ membership: 'live' })]) + expect(byKind('command')).toEqual([ + expect.objectContaining({ + membership: 'live', + residency: 'background', + firstObservedAt: 1_040 + }) + ]) expect(display(agent!.childWorkId)).toBe('monitoring') send( item('item/completed', CHILD, 'c1', { @@ -553,7 +559,7 @@ describe('Codex child-work evidence', () => { expect(records()).toEqual([ expect.objectContaining({ kind: 'agent', membership: 'settled', outcome: 'succeeded' }) ]) - // One edge when each shell starts and one when it exits, as the strip republishes today. + // Raw status evidence keeps every start and exit, including foreground processes. expect( log .slice(before) diff --git a/src/main/codex/codex-child-work-evidence.ts b/src/main/codex/codex-child-work-evidence.ts index af946afeedc0..3b6d146b2e58 100644 --- a/src/main/codex/codex-child-work-evidence.ts +++ b/src/main/codex/codex-child-work-evidence.ts @@ -12,7 +12,6 @@ // Codex children outlive the turn that spawned them, so only a child's own turn, or the session, // ends it. -import type { AgentSessionBackgroundTask } from '../../shared/agent-session-wire' import type { AgentChildWorkEvidence, AgentChildWorkLiveObservation @@ -65,15 +64,18 @@ function ofTurn(fact: T | undefined, turnId return fact?.turnId === turnId ? fact : undefined } -function commandLive(task: AgentSessionBackgroundTask, ownerId: string | null) { +function commandLive( + command: Extract, + ownerId: string | null +) { + const { task } = command return (observedAt: number): AgentChildWorkEvidence => ({ type: 'live', observedAt, child: { handle: { idKind: 'task_id', id: task.id }, kind: 'command', - // Its own process, not a turn's: no turn ending may settle it. - residency: 'background', + residency: command.backgrounded ? 'background' : 'foreground', state: 'working', ...(task.description ? { description: task.description } : {}), ...(ownerId !== null ? { ownerId } : {}), @@ -89,7 +91,7 @@ export class CodexChildWorkEvidence { constructor( private readonly primaryThreadId: string, private readonly executions: CodexSubagentExecutions, - private readonly liveCommands: (threadId: string) => readonly AgentSessionBackgroundTask[] + private readonly liveCommands: (threadId: string) => readonly CodexBackgroundCommandChange[] ) {} /** After the tracker applied the frame: which command processes it saw start or stop, and the @@ -142,7 +144,7 @@ export class CodexChildWorkEvidence { for (const command of commands) { if (command.type === 'started') { const ownerId = command.threadId === this.primaryThreadId ? null : command.threadId - this.pending.push(commandLive(command.task, ownerId)) + this.pending.push(commandLive(command, ownerId)) continue } const { taskId } = command @@ -277,9 +279,7 @@ export class CodexChildWorkEvidence { /** Work a child launched before the host held its record was admitted with no owner; now that * the owner is recorded, say again whose it is. */ private requeueOwnedBy(threadId: string): void { - for (const task of this.liveCommands(threadId)) { - this.pending.push(commandLive(task, threadId)) - } + this.queueCommands(this.liveCommands(threadId)) for (const spawned of this.executions.workingChildren()) { const facts = this.facts.get(spawned.agentThreadId) if (spawned.spawnerThreadId === threadId && facts?.published !== undefined) { diff --git a/src/main/codex/codex-default-mode-subagents.test.ts b/src/main/codex/codex-default-mode-subagents.test.ts index 335f903efbbf..9f72f13512f2 100644 --- a/src/main/codex/codex-default-mode-subagents.test.ts +++ b/src/main/codex/codex-default-mode-subagents.test.ts @@ -503,7 +503,10 @@ describe('a helper whose spawn was never seen', () => { turn('turn/started', HELPER, HELPER_TURN), helperShell('item/started') ) - expect(run.strip()).toEqual([expect.objectContaining({ kind: 'command' })]) + expect(run.strip()).toEqual([]) + expect(run.commands()).toEqual([ + expect.objectContaining({ membership: 'live', residency: 'foreground' }) + ]) run.send(sendInput) // No spawn named it, so it reads as any unnamed subagent does. diff --git a/src/main/codex/codex-persistent-command-retention.test.ts b/src/main/codex/codex-persistent-command-retention.test.ts index 9da3e7792077..5c70b2bbcf60 100644 --- a/src/main/codex/codex-persistent-command-retention.test.ts +++ b/src/main/codex/codex-persistent-command-retention.test.ts @@ -110,11 +110,14 @@ describe('persistent command retention', () => { }).admission ).toEqual({ accepted: true }) } + expect(tracker.tasks()).toEqual([]) for (let thread = 0; thread < 7; thread += 1) { + const threadId = `thread-${thread}` + expect(tracker.threadCommands(threadId)).toHaveLength(64) expect( settleCodexJournalTurn({ sessionId: 'session', - threadId: `thread-${thread}`, + threadId, turnId: 'turn', turnLifecycle: null, completedAt: 1, @@ -125,6 +128,8 @@ describe('persistent command retention', () => { attributionFor: () => ({ turnScope: AGENT_JOURNAL_THREAD_SCOPE }) }) ).toEqual({ accepted: true }) + expect(tracker.endTurn(threadId, 'turn')).toHaveLength(64) + expect(tracker.tasks()).toHaveLength((thread + 1) * 64) } expect(items.activeItems.size).toBe(448) expect(items.streams.persistentCount).toBe(448) diff --git a/src/main/runtime/structured-child-records-switch.test.ts b/src/main/runtime/structured-child-records-switch.test.ts index 9e9eddb7f855..02079d90360a 100644 --- a/src/main/runtime/structured-child-records-switch.test.ts +++ b/src/main/runtime/structured-child-records-switch.test.ts @@ -211,7 +211,7 @@ describe('the chat strip and the session list read the same host child records', source: 'unifiedExecStartup', status: 'inProgress' }) - // Every command the child runs is a live record under it, whatever Codex tagged it. + // Foreground shell records keep status live; surfaces show the child's tool instead. both([ { kind: 'agent', @@ -219,20 +219,6 @@ describe('the chat strip and the session list read the same host child records', state: 'working', membership: 'live', tool: 'Bash: npm run dev' - }, - { - kind: 'command', - description: 'npm test', - state: 'working', - membership: 'live', - owner: 'review' - }, - { - kind: 'command', - description: 'npm run dev', - state: 'working', - membership: 'live', - owner: 'review' } ]) @@ -252,13 +238,6 @@ describe('the chat strip and the session list read the same host child records', state: 'working', membership: 'live', tool: 'Bash: npm run dev' - }, - { - kind: 'command', - description: 'npm run dev', - state: 'working', - membership: 'live', - owner: 'review' } ]) diff --git a/src/main/runtime/structured-foreground-command-strip.test.ts b/src/main/runtime/structured-foreground-command-strip.test.ts new file mode 100644 index 000000000000..f21054ef1b64 --- /dev/null +++ b/src/main/runtime/structured-foreground-command-strip.test.ts @@ -0,0 +1,147 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire' +import { AgentHookServer, _internals } from '../agent-hooks/server' +import { fakeCodex, THREAD_ID } from '../codex/codex-structured-session-adapter-fixture' +import { + HOST_TEST_SESSION as SESSION, + hostTestAttachParams +} from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import { + ensureStructuredAgentSessionHost, + stopStructuredAgentSessionRuntime +} from './structured-agent-session-runtime' + +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn() })) + +describe('foreground commands in the background-task channel', () => { + let root: string + let server: AgentHookServer + + beforeEach(async () => { + _internals.resetCachesForTests() + root = await mkdtemp(join(tmpdir(), 'orca-foreground-command-strip-')) + server = new AgentHookServer() + }) + + afterEach(async () => { + await stopStructuredAgentSessionRuntime() + await rm(root, { recursive: true, force: true }) + }) + + async function rig() { + const codex = fakeCodex({ + 'model/list': () => ({ + data: [ + { + model: 'gpt-test', + displayName: 'GPT Test', + hidden: false, + supportedReasoningEfforts: [], + defaultReasoningEffort: null, + isDefault: true + } + ], + nextCursor: null + }) + }) + const host = await ensureStructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), + stateDirectory: root, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async () => root, + resolveLaunchArgs: () => [], + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveCodexCommand: () => 'codex', + resolveEnvironment: async () => ({ PATH: process.env.PATH }), + openCodexConnection: codex.openConnection, + readProcessStartTime: async () => 1_700_000_000_000, + statusSink: { + publish: (summary, subject) => server.ingestStructuredStatus(summary, subject), + forget: (subject) => server.dropStructuredStatus(subject), + publishChildWork: (subject, evidence, provider) => + server.ingestStructuredChildWork(subject, evidence, provider), + readChildWork: (subject) => server.getStructuredChildWorkViews(subject) + } + }) + const params = hostTestAttachParams(null, { providerHandle: undefined }) + params.envelope.clientOperationId = `${Date.now()}-${'1'.padStart(32, '0')}` + const attached = await host.attach({ callerKey: 'strip-test' }, params) + expect(attached, JSON.stringify(attached)).toMatchObject({ ok: true }) + const rosters: (AgentSessionBackgroundTaskState | null)[] = [] + await host.subscribe({ + id: 'strip', + sessionId: SESSION, + emit: (event) => { + if ('backgroundTasks' in event && event.backgroundTasks !== undefined) { + rosters.push(event.backgroundTasks) + } + } + }) + const settle = async () => { + for (let tick = 0; tick < 5; tick += 1) { + await new Promise((resolve) => setTimeout(resolve, 0)) + } + } + const notify = async (method: string, params: Record) => { + codex.connections[0]?.handlers.onNotification?.(method, params) + await settle() + } + const turn = (method: string, id: string) => + notify(method, { threadId: THREAD_ID, turn: { id, status: 'completed' } }) + const command = (method: string, id: string, turnId = 'turn-1') => + notify(method, { + threadId: THREAD_ID, + turnId, + item: { + type: 'commandExecution', + id, + command: id === 'pwd' ? 'pwd' : 'git status --short --branch', + source: 'unifiedExecStartup', + status: method === 'item/completed' ? 'completed' : 'inProgress' + } + }) + return { host, rosters, turn, command } + } + + it('never publishes a strip between a quick command starting and completing', async () => { + const { host, rosters, turn, command } = await rig() + await turn('turn/started', 'turn-1') + for (const id of ['pwd', 'status']) { + await command('item/started', id) + const page = await host.history({ sessionId: SESSION, direction: 'tail' }) + expect(page.page.items).toContainEqual( + expect.objectContaining({ + body: expect.objectContaining({ kind: 'tool-call', callId: id }) + }) + ) + // Observe every publication, including the start frame before the completion arrives. + expect(rosters.every((roster) => roster === null)).toBe(true) + expect(page.page.backgroundTasks).toBeNull() + await command('item/completed', id) + } + await turn('turn/completed', 'turn-1') + expect(rosters.every((roster) => roster === null)).toBe(true) + }) + + it('shows a surviving command after its own turn ends and throughout the next turn', async () => { + const { rosters, turn, command } = await rig() + await turn('turn/started', 'turn-1') + await command('item/started', 'server') + await turn('turn/completed', 'turn-1') + expect(rosters.at(-1)?.tasks).toContainEqual( + expect.objectContaining({ id: 'codex-command:primary:server', kind: 'command' }) + ) + await turn('turn/started', 'turn-2') + await command('item/started', 'pwd', 'turn-2') + expect(rosters.at(-1)?.tasks?.map((task) => task.id)).toEqual(['codex-command:primary:server']) + await command('item/completed', 'pwd', 'turn-2') + await command('item/completed', 'server') + expect(rosters.at(-1)).toBeNull() + }) +}) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-transport-state.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-transport-state.ts index fbc3a3674e23..b956d721ffd4 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-transport-state.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-transport-state.ts @@ -5,6 +5,7 @@ import type { StructuredAgentSessionState } from '../../../../shared/structured- import type { StructuredAgentSubagentRoster } from '../../../../shared/structured-agent-session-subagent-roster' import { selectStructuredAgentTurnActivity } from '../../../../shared/native-chat-turn-activity' import { structuredSessionBackgroundTasksView } from '../../../../shared/structured-session-background-tasks-view' +import { structuredSessionForegroundCommands } from '../../../../shared/structured-session-foreground-commands' import { useStructuredAgentTurnTiming } from './use-structured-agent-turn-timing' const NO_JOURNAL_ITEMS: StructuredAgentSessionState['items'] = [] @@ -41,6 +42,14 @@ export function useStructuredAgentSessionTransportState( }, turnId ) + const backgroundTasks = useMemo(() => { + const roster = enabled ? state.backgroundTasks : null + return structuredSessionBackgroundTasksView( + roster, + turnId, + structuredSessionForegroundCommands(roster, { items: journalItems, latestTurn }) + ) + }, [enabled, journalItems, latestTurn, state.backgroundTasks, turnId]) return { journalItems, latestTurn, @@ -56,9 +65,6 @@ export function useStructuredAgentSessionTransportState( queuePause: (enabled ? state.queuePause : null) ?? null, /** Working only because the queue is about to send: nothing is in flight to stop yet. */ queueSendsNext, - backgroundTasks: structuredSessionBackgroundTasksView( - enabled ? state.backgroundTasks : null, - turnId - ) + backgroundTasks } } diff --git a/src/shared/agent-status-child-work-view.test.ts b/src/shared/agent-status-child-work-view.test.ts index da0de4e752cd..1deccf15989d 100644 --- a/src/shared/agent-status-child-work-view.test.ts +++ b/src/shared/agent-status-child-work-view.test.ts @@ -66,6 +66,30 @@ function alias( } describe('projectAgentChildWorkViews', () => { + it('keeps Codex foreground commands in status records but out of all surface views', () => { + const foreground = record('foreground', { + provider: 'codex', + kind: 'command', + residency: 'foreground' + }) + const records = [ + foreground, + record('background', { provider: 'codex', kind: 'command', residency: 'background' }), + record('claude', { kind: 'command', residency: 'foreground' }), + record('agent', { provider: 'codex', residency: 'foreground' }) + ] + expect(agentChildWorkLiveness([foreground])).toBe('monitoring') + expect(projectAgentChildWorkViews(records, []).map((view) => view.id)).toEqual([ + 'background', + 'claude', + 'agent' + ]) + expect( + projectAgentChildWorkViews([{ ...foreground, residency: 'background' }], []) + ).toHaveLength(1) + expect(foreground.residency).toBe('foreground') + }) + it('carries what a surface reads and drops host bookkeeping', () => { const [view] = projectAgentChildWorkViews( [ diff --git a/src/shared/agent-status-child-work-view.ts b/src/shared/agent-status-child-work-view.ts index bfcbcfc11d5a..86a316d410f2 100644 --- a/src/shared/agent-status-child-work-view.ts +++ b/src/shared/agent-status-child-work-view.ts @@ -108,14 +108,18 @@ function resolvedOwner( : undefined } -/** The one record-to-view projection; every surface and every legacy shape starts here. */ +/** Every surface starts here; foreground Codex tools stay in status records and tool rows. */ export function projectAgentChildWorkViews( records: readonly AgentChildWorkInput[], aliases: readonly AgentChildWorkViewAlias[] ): AgentChildWorkView[] { - const byId = new Map(records.map((record) => [record.childWorkId, record])) + const visible = records.filter( + (record) => + record.provider !== 'codex' || record.kind !== 'command' || record.residency !== 'foreground' + ) + const byId = new Map(visible.map((record) => [record.childWorkId, record])) const aliasesByChild = groupedBy(aliases, (alias) => alias.childWorkId) - return records.map((record) => { + return visible.map((record) => { const providerId = providerIdFor(record, aliasesByChild.get(record.childWorkId) ?? []) const owner = resolvedOwner(record, byId) return { diff --git a/src/shared/structured-session-background-tasks-view.test.ts b/src/shared/structured-session-background-tasks-view.test.ts index 285fcce3f1d3..5e76feaf81f2 100644 --- a/src/shared/structured-session-background-tasks-view.test.ts +++ b/src/shared/structured-session-background-tasks-view.test.ts @@ -17,6 +17,49 @@ function view(membership: 'live' | 'settled', kind: AgentChildWorkView['kind'] = } describe('structuredSessionBackgroundTasksView', () => { + it('does not mount the strip for a foreground command from an older host', () => { + const id = 'codex-command:primary:pwd' + const foreground = new Set([id]) + expect( + structuredSessionBackgroundTasksView( + { state: 'monitoring', tasks: [{ id, kind: 'command' }] }, + 'turn-1', + foreground + ) + ).toMatchObject({ show: false, isMonitoring: false, tasks: [] }) + }) + + it('removes the foreground command from child views and legacy tasks together', () => { + const id = 'codex-command:primary:pwd' + const shell = { ...view('live', 'command'), providerId: id } + const agent = view('live') + const tasks = [ + { id, kind: 'command' as const }, + { id: 'child-agent', kind: 'agent' as const } + ] + const shown = structuredSessionBackgroundTasksView( + { state: 'monitoring', tasks, children: [shell, agent] }, + 'turn-1', + new Set([id]) + ) + expect(shown).toMatchObject({ show: true, tasks: [tasks[1]], children: [agent] }) + }) + + it('keeps unrelated rosters and commands from earlier turns during the current turn', () => { + const tasks = [ + { id: 'codex-command:primary:server', kind: 'command' as const }, + { id: 'claude-shell', kind: 'command' as const }, + { id: 'codex-command:thread:child:pwd', kind: 'command' as const } + ] + const shown = structuredSessionBackgroundTasksView( + { state: 'monitoring', tasks }, + 'turn-2', + new Set(['codex-command:primary:pwd']) + ) + expect(shown.show).toBe(true) + expect(shown.tasks).toBe(tasks) + }) + it('shows finished children until the next turn, but they hold nothing open', () => { const finished = structuredSessionBackgroundTasksView( { state: 'monitoring', children: [view('settled')] }, diff --git a/src/shared/structured-session-background-tasks-view.ts b/src/shared/structured-session-background-tasks-view.ts index 14421548755e..9786239f6452 100644 --- a/src/shared/structured-session-background-tasks-view.ts +++ b/src/shared/structured-session-background-tasks-view.ts @@ -1,13 +1,7 @@ // The background-tasks strip's view of one session's wire state. // -// The strip reports work that is IN FLIGHT, whether or not it outlived a turn: -// a fan-out's children keep reporting long after the parent settles, and a -// foreground fan-out is running work while the turn is still open. It stays -// mounted through a running turn — turn state is not a filter on the rows, -// because the producers publish only tasks they still have live evidence for. -// A host that publishes its child records sends the running ones only, and no -// roster once none runs, so the strip hides. Only an idle session with live -// work lets the strip animate or speak for itself. +// Hosts select background work; newer clients also exclude their current turn's commands +// from an older host's roster. Child agents and earlier commands stay visible mid-turn. import type { AgentSessionBackgroundTask, @@ -17,7 +11,7 @@ import { agentChildWorkLiveness } from './agent-status-child-work-liveness' import type { AgentChildWorkView } from './agent-status-child-work-view' export type StructuredSessionBackgroundTasksView = { - /** The strip renders whenever the host reports rows — mid-turn included. */ + /** Background rows remain visible during a foreground turn. */ show: boolean /** Idle-only: gates the animated monitoring indicator and conversation * commands, never the strip itself. A running turn owns the voice. */ @@ -32,24 +26,47 @@ export type StructuredSessionBackgroundTasksView = { /** One shared empty list: a roster with no rows of a kind keeps the strip's memo on every render. */ const NO_TASKS: AgentSessionBackgroundTask[] = [] +const NO_FOREGROUND_COMMANDS: ReadonlySet = new Set() + +function withoutForeground( + rows: T[], + foreground: ReadonlySet, + id: (row: T) => string | undefined +): T[] { + const visible = (row: T) => !foreground.has(id(row) ?? '') + return rows.every(visible) ? rows : rows.filter(visible) +} export function structuredSessionBackgroundTasksView( backgroundTasks: AgentSessionBackgroundTaskState | null | undefined, - turnId: string | null + turnId: string | null, + foregroundCommands: ReadonlySet = NO_FOREGROUND_COMMANDS ): StructuredSessionBackgroundTasksView { const monitoring = backgroundTasks?.state === 'monitoring' // Decoded once where the frame entered the client's state, so its identity holds between frames. - const children = monitoring ? backgroundTasks.children : undefined + const children = + monitoring && backgroundTasks.children + ? withoutForeground(backgroundTasks.children, foregroundCommands, (child) => child.providerId) + : undefined + const tasks = withoutForeground( + backgroundTasks?.tasks ?? NO_TASKS, + foregroundCommands, + (task) => task.id + ) // Only running children arrive; a roster with none holds nothing open either way. // An older host's roster whose listed rows have all finished shows nothing that runs. - const onlyFinished = - !children && !backgroundTasks?.tasks?.length && Boolean(backgroundTasks?.settledTasks?.length) - const show = monitoring && !onlyFinished + const onlyFinished = !children && !tasks.length && Boolean(backgroundTasks?.settledTasks?.length) + const onlyForeground = + foregroundCommands.size > 0 && + !children?.length && + !tasks.length && + Boolean(backgroundTasks?.children?.length || backgroundTasks?.tasks?.length) + const show = monitoring && !onlyFinished && !onlyForeground const liveWork = children ? agentChildWorkLiveness(children) !== null : show return { show, isMonitoring: turnId === null && liveWork, - tasks: backgroundTasks?.tasks ?? NO_TASKS, + tasks, // Running work only, from any host: an older host's finished rows are not shown either. settledTasks: NO_TASKS, ...(children ? { children } : {}), diff --git a/src/shared/structured-session-foreground-commands.test.ts b/src/shared/structured-session-foreground-commands.test.ts new file mode 100644 index 000000000000..ccd303c02e17 --- /dev/null +++ b/src/shared/structured-session-foreground-commands.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest' +import type { AgentJournalRenderItem } from './agent-session-journal-types' +import type { AgentSessionBackgroundTaskState, AgentSessionLatestTurn } from './agent-session-wire' +import { structuredSessionForegroundCommands } from './structured-session-foreground-commands' + +const pwd = 'codex-command:primary:pwd' +const server = 'codex-command:primary:server' +const running = { + itemId: 'turn-row-2', + observedAt: 20, + turn: { turnId: 'turn-2', state: 'running' } +} satisfies AgentSessionLatestTurn + +function tool(callId: string, turnItemId: string): AgentJournalRenderItem { + return { + itemId: `tool-${callId}`, + sequence: 2, + revision: 1, + observedAt: 21, + turnScope: { kind: 'turn', turnItemId }, + body: { kind: 'tool-call', callId, name: 'shell', input: null, state: 'running' } + } +} + +const roster: AgentSessionBackgroundTaskState = { + state: 'monitoring', + tasks: [ + { id: pwd, kind: 'command' }, + { id: server, kind: 'command' }, + { id: 'codex-command:thread:child:exec', kind: 'command' }, + { id: 'claude-task', kind: 'command' } + ] +} + +describe('structuredSessionForegroundCommands', () => { + it('handles a roster arriving before its tool row, without hiding earlier work', () => { + expect([ + ...structuredSessionForegroundCommands(roster, { + items: [tool('server', 'turn-row-1')], + latestTurn: running + }) + ]).toEqual([pwd]) + expect([ + ...structuredSessionForegroundCommands(roster, { + items: [tool('server', 'turn-row-1'), tool('pwd', running.itemId)], + latestTurn: running + }) + ]).toEqual([pwd]) + }) + + it('keeps an earlier command whose journal row is outside the loaded page', () => { + const children = [ + { + id: 'child-server', + providerId: server, + kind: 'command' as const, + state: 'working' as const, + membership: 'live' as const, + firstObservedAt: 10, + observedAt: 21, + stoppable: false, + invocation: { invocationId: server, generation: 1 } + } + ] + expect([ + ...structuredSessionForegroundCommands( + { ...roster, children }, + { + items: [], + latestTurn: running + } + ) + ]).toEqual([pwd]) + }) + + it('reveals surviving commands when the source turn ends', () => { + expect( + structuredSessionForegroundCommands(roster, { + items: [], + latestTurn: { ...running, turn: { ...running.turn, state: 'completed' } } + }).size + ).toBe(0) + }) + + it('uses loaded turn rows when an older host has no latest-turn field', () => { + const turn: AgentJournalRenderItem = { + itemId: running.itemId, + sequence: 1, + revision: 1, + observedAt: 20, + body: { kind: 'turn', ...running.turn } + } + expect([ + ...structuredSessionForegroundCommands(roster, { + items: [turn, tool('server', 'turn-row-1')] + }) + ]).toEqual([pwd]) + }) +}) diff --git a/src/shared/structured-session-foreground-commands.ts b/src/shared/structured-session-foreground-commands.ts new file mode 100644 index 000000000000..1dd606315464 --- /dev/null +++ b/src/shared/structured-session-foreground-commands.ts @@ -0,0 +1,64 @@ +import type { AgentJournalRenderItem } from './agent-session-journal-types' +import { isRootAgentJournalItem } from './agent-session-journal-producer' +import type { AgentSessionBackgroundTaskState, AgentSessionLatestTurn } from './agent-session-wire' +import { + runningStructuredAgentSessionTurnId, + runningStructuredAgentSessionTurnScope +} from './structured-agent-session-live-turn' + +const PRIMARY_COMMAND_PREFIX = 'codex-command:primary:' + +export function codexPrimaryCommandTaskId(callId: string): string { + return `${PRIMARY_COMMAND_PREFIX}${encodeURIComponent(callId)}` +} + +/** Older hosts list foreground shells too; their journal still identifies which turn owns one. */ +export function structuredSessionForegroundCommands( + roster: AgentSessionBackgroundTaskState | null | undefined, + source: { items: readonly AgentJournalRenderItem[]; latestTurn?: AgentSessionLatestTurn | null } +): ReadonlySet { + const foreground = new Set() + const turnId = runningStructuredAgentSessionTurnId(source) + if (turnId === null || !roster) { + return foreground + } + const scope = runningStructuredAgentSessionTurnScope(source) + const owningTurnItems = new Map() + for (const item of source.items) { + if ( + isRootAgentJournalItem(item) && + item.body.kind === 'tool-call' && + item.body.callId && + item.turnScope?.kind === 'turn' + ) { + owningTurnItems.set(codexPrimaryCommandTaskId(item.body.callId), item.turnScope.turnItemId) + } + } + const firstObserved = new Map( + (roster.children ?? []).flatMap((child) => + child.providerId ? [[child.providerId, child.firstObservedAt] as const] : [] + ) + ) + const ids = new Set([ + ...(roster.tasks ?? []).map((task) => task.id), + ...(roster.children ?? []).flatMap((child) => child.providerId ?? []) + ]) + for (const id of ids) { + if (!id.startsWith(PRIMARY_COMMAND_PREFIX)) { + continue + } + const observedAt = firstObserved.get(id) + const owningTurnItem = owningTurnItems.get(id) + const earlierTurn = + owningTurnItem !== undefined && scope.kind === 'turn' + ? owningTurnItem !== scope.turnItemId + : observedAt !== undefined && + source.latestTurn != null && + observedAt < source.latestTurn.observedAt + // With no ownership yet, keep an older host's start frame in the tool row until the turn ends. + if (!earlierTurn) { + foreground.add(id) + } + } + return foreground +} From 47019ae458a654df84b6ab37656544c4d97ea3cb Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Thu, 8 Oct 2026 16:09:56 -0400 Subject: [PATCH 20/90] fix(editor): route zoom gestures and app zoom to the PDF viewer (#26652) * fix(editor): route zoom gestures and app zoom to the PDF viewer Ctrl-wheel / trackpad pinch over a PDF scrolled it instead of zooming, and Cmd/Ctrl +/-/0 changed the (invisible) editor font zoom because the main process intercepts zoom shortcuts before the PDF's keydown listener runs. - The pdf.js session owns a non-passive ctrl-wheel listener for its container and zooms to an absolute scale anchored at the pointer. - App zoom commands on an editor tab are first offered to the PDF in the command-owning (focused) editor pane; unclaimed commands keep editor font zoom. - Wheel, toolbar and shortcut zoom all record the persisted scale. Co-authored-by: Infinity_X * fix(editor): make pinch zoom track the trackpad more closely Halve the wheel-zoom divisor so pinch zoom in PDFs and images moves twice as fast, and halve the per-event delta cap so a single event still zooms less than 2x. Co-authored-by: Infinity_X * test(editor): type the PDF ownership test's document fixture * fix(editor): keep fast pinch bursts anchored to the pointer pdf.js refreshes its saved location on the next frame, so a second wheel event in the same frame zoomed around the old location and drifted. Call viewer.update() after the anchor scroll. Also make the ownership test's reset assertion depend on reset running. --------- Co-authored-by: Infinity_X --- .../src/components/editor/EditorPanel.tsx | 116 +++++++++--------- .../src/components/editor/PdfViewer.tsx | 58 +++++---- .../editor/editor-command-owner-context.ts | 4 + .../editor/image-viewer-zoom.test.ts | 4 +- .../components/editor/image-viewer-zoom.ts | 4 +- .../editor/pdf-scale-preference.test.ts | 62 +++++++++- .../components/editor/pdf-scale-preference.ts | 47 +++++++ .../components/editor/pdf-viewer-session.ts | 22 +++- .../editor/pdf-viewer-zoom-ownership.test.tsx | 77 ++++++++++++ .../editor/pdf-zoom-request.test.ts | 18 +++ .../src/components/editor/pdf-zoom-request.ts | 24 ++++ .../src/hooks/ipc-events/zoom-ipc-bridge.ts | 5 + src/renderer/src/hooks/zoom-routing.test.ts | 31 ++++- 13 files changed, 382 insertions(+), 90 deletions(-) create mode 100644 src/renderer/src/components/editor/editor-command-owner-context.ts create mode 100644 src/renderer/src/components/editor/pdf-viewer-zoom-ownership.test.tsx create mode 100644 src/renderer/src/components/editor/pdf-zoom-request.test.ts create mode 100644 src/renderer/src/components/editor/pdf-zoom-request.ts diff --git a/src/renderer/src/components/editor/EditorPanel.tsx b/src/renderer/src/components/editor/EditorPanel.tsx index de466cd70749..118b14a42bb6 100644 --- a/src/renderer/src/components/editor/EditorPanel.tsx +++ b/src/renderer/src/components/editor/EditorPanel.tsx @@ -9,6 +9,7 @@ import { exportActiveMarkdownToPdf } from './export-active-markdown' import type { EditorToggleValue } from './EditorViewToggle' import { EditorPanelShell } from './EditorPanelShell' import { DiffNavigationProvider } from './diff-navigation-context' +import { EditorCommandOwnerContext } from './editor-command-owner-context' import { canUseChangesModeForFile } from './editor-panel-file-mode' import { getEditorPanelRenderModel } from './editor-panel-render-model' import { useEditorCmdSaveRequest } from './useEditorCmdSaveRequest' @@ -317,61 +318,66 @@ function EditorPanelInner({ ) return ( - // Why: each split pane needs an isolated bridge between its diff editor and header controls. - - void handleCopyPath()} - onOpenDiffTargetFile={handleOpenDiffTargetFile} - onOpenPreviewToSide={handleOpenPreviewToSide} - onOpenMarkdownPreview={handleOpenMarkdownPreview} - onToggleSideBySide={() => setSideBySide((prev) => !prev)} - onEditorToggleChange={handleEditorToggleChange} - onToggleMarkdownTableOfContents={() => - setMarkdownTableOfContentsVisible( - markdownDocumentStateFileId, - !isMarkdownTableOfContentsVisible - ) - } - onToggleMarkdownFrontmatter={() => - setMarkdownFrontmatterVisible(markdownDocumentStateFileId, !isMarkdownFrontmatterVisible) - } - onExportMarkdownToPdf={() => - void exportActiveMarkdownToPdf({ fileId: activeFile.id, root: panelRef.current }) - } - createMarkdownArtifactRequest={ - activeMarkdownContent === null ? undefined : createActiveMarkdownArtifactRequest - } - onContentChange={handleContentChange} - onContentChangeForFile={handleContentChangeForFile} - onDirtyStateHint={handleDirtyStateHint} - onSave={handleSave} - onSaveForFile={handleSaveForFile} - onReloadContent={reloadContent} - onCloseMarkdownTableOfContents={() => - setMarkdownTableOfContentsVisible(markdownDocumentStateFileId, false) - } - onCloseRenameDialog={closeRenameDialog} - onRenameConfirm={handleRenameConfirm} - markdownAnnotationsEnabled={markdownAnnotationsEnabled && markdownReviewNotesEnabled} - /> - + + {/* Why: each split pane needs an isolated bridge between its diff editor and header controls. */} + + void handleCopyPath()} + onOpenDiffTargetFile={handleOpenDiffTargetFile} + onOpenPreviewToSide={handleOpenPreviewToSide} + onOpenMarkdownPreview={handleOpenMarkdownPreview} + onToggleSideBySide={() => setSideBySide((prev) => !prev)} + onEditorToggleChange={handleEditorToggleChange} + onToggleMarkdownTableOfContents={() => + setMarkdownTableOfContentsVisible( + markdownDocumentStateFileId, + !isMarkdownTableOfContentsVisible + ) + } + onToggleMarkdownFrontmatter={() => + setMarkdownFrontmatterVisible( + markdownDocumentStateFileId, + !isMarkdownFrontmatterVisible + ) + } + onExportMarkdownToPdf={() => + void exportActiveMarkdownToPdf({ fileId: activeFile.id, root: panelRef.current }) + } + createMarkdownArtifactRequest={ + activeMarkdownContent === null ? undefined : createActiveMarkdownArtifactRequest + } + onContentChange={handleContentChange} + onContentChangeForFile={handleContentChangeForFile} + onDirtyStateHint={handleDirtyStateHint} + onSave={handleSave} + onSaveForFile={handleSaveForFile} + onReloadContent={reloadContent} + onCloseMarkdownTableOfContents={() => + setMarkdownTableOfContentsVisible(markdownDocumentStateFileId, false) + } + onCloseRenameDialog={closeRenameDialog} + onRenameConfirm={handleRenameConfirm} + markdownAnnotationsEnabled={markdownAnnotationsEnabled && markdownReviewNotesEnabled} + /> + + ) } diff --git a/src/renderer/src/components/editor/PdfViewer.tsx b/src/renderer/src/components/editor/PdfViewer.tsx index b63ce8ef1ff1..1243f2664a2f 100644 --- a/src/renderer/src/components/editor/PdfViewer.tsx +++ b/src/renderer/src/components/editor/PdfViewer.tsx @@ -1,5 +1,5 @@ /* oxlint-disable react-doctor/no-adjust-state-on-prop-change -- Why: PDF loading drives pdf.js document/viewer instances and decode errors through an external worker lifecycle. */ -import { type JSX, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { type JSX, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' import { Image as ImageIcon, RotateCcw, Search, ZoomIn, ZoomOut } from 'lucide-react' import * as pdfjsLib from 'pdfjs-dist' import type { @@ -24,6 +24,8 @@ import { type PdfScalePreference } from './pdf-scale-preference' import { readPdfScalePreference, writePdfScalePreference } from './pdf-scale-preference-storage' +import { EditorCommandOwnerContext } from './editor-command-owner-context' +import { listenForPdfZoomRequests } from './pdf-zoom-request' pdfjsLib.GlobalWorkerOptions.workerSrc = workerUrl @@ -66,6 +68,7 @@ function PdfDocumentViewer({ const [scale, setScale] = useState(1) const keybindings = useAppStore((state) => state.keybindings) const findShortcutLabel = useShortcutLabel('editor.find') + const isCommandOwner = useContext(EditorCommandOwnerContext) const eventBusRef = useRef | null>(null) const findControllerRef = useRef | null>(null) const pdfViewerRef = useRef | null>(null) @@ -76,6 +79,18 @@ function PdfDocumentViewer({ const filename = useMemo(() => filePath.split(/[/\\]/).pop() || filePath, [filePath]) const cleanedContent = useMemo(() => content.replace(/\s/g, ''), [content]) + // Why: every zoom entry point (toolbar, app zoom command, wheel/pinch) must record the + // scale preference so the next content reload restores it (see scalePreferenceRef). + const recordScalePreference = useCallback( + (preference: PdfScalePreference) => { + scalePreferenceRef.current = preference + if (preferenceKey) { + writePdfScalePreference(preferenceKey, preference) + } + }, + [preferenceKey] + ) + useEffect(() => { const container = containerRef.current const viewerDiv = viewerDivRef.current @@ -97,7 +112,8 @@ function PdfDocumentViewer({ scrollCacheKey, scalePreference: scalePreferenceRef.current, scaleBounds: SCALE_BOUNDS, - onScaleChanging: setScale + onScaleChanging: setScale, + onWheelZoom: recordScalePreference }) eventBusRef.current = session.eventBus findControllerRef.current = session.findController @@ -119,7 +135,7 @@ function PdfDocumentViewer({ loaderRef.current = null loader.dispose() } - }, [filePath, preferenceKey, scrollCacheKey]) + }, [filePath, preferenceKey, recordScalePreference, scrollCacheKey]) useEffect(() => { loaderRef.current?.load(cleanedContent) @@ -134,8 +150,6 @@ function PdfDocumentViewer({ setFindOpen(false) }, []) - // Why: every zoom entry point (toolbar + keyboard) must record the scale - // preference so the next content reload restores it (see scalePreferenceRef). const stepZoom = useCallback( (direction: 'in' | 'out') => { const viewer = pdfViewerRef.current @@ -144,12 +158,9 @@ function PdfDocumentViewer({ } const next = stepPdfScalePreference(viewer.currentScale, direction, SCALE_BOUNDS) viewer.currentScale = next.scale - scalePreferenceRef.current = next.preference - if (preferenceKey) { - writePdfScalePreference(preferenceKey, next.preference) - } + recordScalePreference(next.preference) }, - [preferenceKey] + [recordScalePreference] ) const zoomIn = useCallback(() => stepZoom('in'), [stepZoom]) @@ -160,12 +171,18 @@ function PdfDocumentViewer({ if (!viewer) { return } - scalePreferenceRef.current = 'page-width' applyPdfScalePreference(viewer, 'page-width', SCALE_BOUNDS) - if (preferenceKey) { - writePdfScalePreference(preferenceKey, 'page-width') + recordScalePreference('page-width') + }, [recordScalePreference]) + + useEffect(() => { + if (!isCommandOwner) { + return } - }, [preferenceKey]) + return listenForPdfZoomRequests((direction) => + direction === 'reset' ? zoomReset() : stepZoom(direction) + ) + }, [isCommandOwner, stepZoom, zoomReset]) useEffect(() => { const handleKeyDown = (e: KeyboardEvent): void => { @@ -174,22 +191,11 @@ function PdfDocumentViewer({ e.preventDefault() e.stopPropagation() setFindOpen(true) - return - } - if (keybindingMatchesAction('zoom.in', e, platform, keybindings)) { - e.preventDefault() - zoomIn() - } else if (keybindingMatchesAction('zoom.out', e, platform, keybindings)) { - e.preventDefault() - zoomOut() - } else if (keybindingMatchesAction('zoom.reset', e, platform, keybindings)) { - e.preventDefault() - zoomReset() } } window.addEventListener('keydown', handleKeyDown, true) return () => window.removeEventListener('keydown', handleKeyDown, true) - }, [keybindings, zoomIn, zoomOut, zoomReset]) + }, [keybindings]) const zoomPercent = Math.round(scale * 100) diff --git a/src/renderer/src/components/editor/editor-command-owner-context.ts b/src/renderer/src/components/editor/editor-command-owner-context.ts new file mode 100644 index 000000000000..e78988a642c4 --- /dev/null +++ b/src/renderer/src/components/editor/editor-command-owner-context.ts @@ -0,0 +1,4 @@ +import { createContext } from 'react' + +// Why: split panes and hidden worktrees keep editors mounted; only the focused pane answers app commands. +export const EditorCommandOwnerContext = createContext(false) diff --git a/src/renderer/src/components/editor/image-viewer-zoom.test.ts b/src/renderer/src/components/editor/image-viewer-zoom.test.ts index 367c4f365e20..c67aa8899c3a 100644 --- a/src/renderer/src/components/editor/image-viewer-zoom.test.ts +++ b/src/renderer/src/components/editor/image-viewer-zoom.test.ts @@ -43,8 +43,8 @@ describe('image viewer zoom helpers', () => { }) it('bounds very large per-event zoom factors', () => { - expect(getPinchZoomFactor(-10_000, 0)).toBeCloseTo(getPinchZoomFactor(-200, 0)) - expect(getPinchZoomFactor(10_000, 0)).toBeCloseTo(getPinchZoomFactor(200, 0)) + expect(getPinchZoomFactor(-10_000, 0)).toBeCloseTo(getPinchZoomFactor(-100, 0)) + expect(getPinchZoomFactor(10_000, 0)).toBeCloseTo(getPinchZoomFactor(100, 0)) expect(getPinchZoomFactor(-10_000, 0)).toBeLessThan(2) expect(getPinchZoomFactor(10_000, 0)).toBeGreaterThan(0.5) }) diff --git a/src/renderer/src/components/editor/image-viewer-zoom.ts b/src/renderer/src/components/editor/image-viewer-zoom.ts index 3f6d2c30e085..f9896bf29da0 100644 --- a/src/renderer/src/components/editor/image-viewer-zoom.ts +++ b/src/renderer/src/components/editor/image-viewer-zoom.ts @@ -7,8 +7,8 @@ const DOM_DELTA_LINE = 1 const DOM_DELTA_PAGE = 2 const PIXELS_PER_LINE = 16 const PIXELS_PER_PAGE = 800 -const MAX_NORMALIZED_WHEEL_DELTA = 200 -const WHEEL_ZOOM_SENSITIVITY = 300 +const MAX_NORMALIZED_WHEEL_DELTA = 100 +const WHEEL_ZOOM_SENSITIVITY = 150 type ImageZoomWheelEventLike = { ctrlKey: boolean diff --git a/src/renderer/src/components/editor/pdf-scale-preference.test.ts b/src/renderer/src/components/editor/pdf-scale-preference.test.ts index 6d3ac4a75841..51ecc701c2dd 100644 --- a/src/renderer/src/components/editor/pdf-scale-preference.test.ts +++ b/src/renderer/src/components/editor/pdf-scale-preference.test.ts @@ -1,9 +1,11 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { applyPdfScalePreference, clampPdfScale, - stepPdfScalePreference + stepPdfScalePreference, + zoomPdfViewerWithWheel } from './pdf-scale-preference' +import { getPinchZoomFactor } from './image-viewer-zoom' const BOUNDS = { min: 0.25, max: 5, step: 1.25 } @@ -46,3 +48,59 @@ describe('stepPdfScalePreference', () => { expect(zoomedOut.scale).toBe(1) }) }) + +describe('zoomPdfViewerWithWheel', () => { + function wheel(deltaY: number, ctrlKey = true) { + return { ctrlKey, deltaY, deltaMode: 0, clientX: 300, clientY: 250, preventDefault: vi.fn() } + } + function viewerAt(scale: number) { + return { + currentScale: scale, + container: { + scrollLeft: 100, + scrollTop: 400, + getBoundingClientRect: () => ({ left: 200, top: 50 }) + }, + update: vi.fn() + } + } + + it('leaves plain wheel scrolling alone', () => { + const viewer = viewerAt(1) + const event = wheel(-30, false) + expect(zoomPdfViewerWithWheel(viewer, event, BOUNDS)).toBeNull() + expect(event.preventDefault).not.toHaveBeenCalled() + expect(viewer.currentScale).toBe(1) + }) + + it('keeps the content under the pointer in place', () => { + const viewer = viewerAt(1) + const event = wheel(-30) + expect(zoomPdfViewerWithWheel(viewer, event, BOUNDS)).toBe(viewer.currentScale) + expect(event.preventDefault).toHaveBeenCalled() + const growth = viewer.currentScale - 1 + expect(growth).toBeGreaterThan(0) + // Pointer sits 100px right of and 200px below the container's top-left. + expect(viewer.container.scrollLeft).toBeCloseTo(100 + 100 * growth) + expect(viewer.container.scrollTop).toBeCloseTo(400 + 200 * growth) + // The next same-frame event must anchor to this scroll position. + expect(viewer.update).toHaveBeenCalledOnce() + }) + + it('accumulates slow trackpad pinches instead of rounding them away', () => { + const viewer = viewerAt(0.5) + for (let i = 0; i < 30; i++) { + zoomPdfViewerWithWheel(viewer, wheel(-1), BOUNDS) + } + expect(viewer.currentScale).toBeCloseTo(0.5 * getPinchZoomFactor(-1, 0) ** 30) + }) + + it('claims the gesture but stops at the scale bounds', () => { + const viewer = viewerAt(BOUNDS.max) + const event = wheel(-30) + expect(zoomPdfViewerWithWheel(viewer, event, BOUNDS)).toBeNull() + expect(event.preventDefault).toHaveBeenCalled() + expect(viewer.container.scrollLeft).toBe(100) + expect(viewer.update).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/editor/pdf-scale-preference.ts b/src/renderer/src/components/editor/pdf-scale-preference.ts index abdba00c6a95..27f50254a28c 100644 --- a/src/renderer/src/components/editor/pdf-scale-preference.ts +++ b/src/renderer/src/components/editor/pdf-scale-preference.ts @@ -1,3 +1,5 @@ +import { getPinchZoomFactor, shouldHandleImageZoomWheel } from './image-viewer-zoom' + export type PdfScalePreference = 'page-width' | number export function clampPdfScale(scale: number, min: number, max: number): number { @@ -29,3 +31,48 @@ export function stepPdfScalePreference( : clampPdfScale(currentScale / bounds.step, bounds.min, bounds.max) return { scale: next, preference: next } } + +type PdfWheelZoomEvent = Pick< + WheelEvent, + 'ctrlKey' | 'deltaY' | 'deltaMode' | 'clientX' | 'clientY' | 'preventDefault' +> + +/** Ctrl-wheel / trackpad-pinch zoom that keeps the content under the pointer in place. */ +export function zoomPdfViewerWithWheel( + viewer: { + currentScale: number + container: { + scrollLeft: number + scrollTop: number + getBoundingClientRect: () => { left: number; top: number } + } + update: () => void + }, + event: PdfWheelZoomEvent, + bounds: { min: number; max: number } +): number | null { + if (!shouldHandleImageZoomWheel(event)) { + return null + } + event.preventDefault() + const previous = viewer.currentScale + const next = clampPdfScale( + previous * getPinchZoomFactor(event.deltaY, event.deltaMode), + bounds.min, + bounds.max + ) + if (next === previous) { + return null + } + const { container } = viewer + const rect = container.getBoundingClientRect() + // Why: an absolute scale avoids updateScale's 0.01 rounding, which swallows slow pinches. + viewer.currentScale = next + // Why: pdf.js keeps the viewport's top-left fixed, so shift by the pointer's grown offset. + const growth = next / previous - 1 + container.scrollLeft += (event.clientX - rect.left) * growth + container.scrollTop += (event.clientY - rect.top) * growth + // Why: pdf.js re-reads its location on the next frame; a faster pinch event would anchor to the old one. + viewer.update() + return next +} diff --git a/src/renderer/src/components/editor/pdf-viewer-session.ts b/src/renderer/src/components/editor/pdf-viewer-session.ts index dfa80fff68b7..52b14b3eefe3 100644 --- a/src/renderer/src/components/editor/pdf-viewer-session.ts +++ b/src/renderer/src/components/editor/pdf-viewer-session.ts @@ -5,7 +5,11 @@ import { PDFLinkService, PDFViewer as PdfJsViewer } from 'pdfjs-dist/web/pdf_viewer.mjs' -import { applyPdfScalePreference, type PdfScalePreference } from './pdf-scale-preference' +import { + applyPdfScalePreference, + zoomPdfViewerWithWheel, + type PdfScalePreference +} from './pdf-scale-preference' import { pdfViewPositionCache, setWithLRU } from '@/lib/scroll-cache' import { buildPdfScrollDestination, @@ -33,7 +37,8 @@ export function createPdfViewerSession({ scrollCacheKey, scalePreference, scaleBounds, - onScaleChanging + onScaleChanging, + onWheelZoom }: { container: HTMLDivElement viewerDiv: HTMLDivElement @@ -42,6 +47,7 @@ export function createPdfViewerSession({ scalePreference: PdfScalePreference scaleBounds: { min: number; max: number } onScaleChanging: (scale: number) => void + onWheelZoom: (scale: number) => void }): { viewer: InstanceType eventBus: InstanceType @@ -77,6 +83,18 @@ export function createPdfViewerSession({ } eventBus.on('scalechanging', handleScaleChanging) + // Why: Chromium reports trackpad pinch as ctrl-wheel; unclaimed, it only scrolls the PDF. + container.addEventListener( + 'wheel', + (event) => { + const scale = zoomPdfViewerWithWheel(viewer, event, scaleBounds) + if (scale !== null) { + onWheelZoom(scale) + } + }, + { passive: false, signal: abortController.signal } + ) + // Why: each displayed document owns its scroll recorder and cache key. const recorder = scrollCacheKey ? createPdfViewPositionRecorder({ diff --git a/src/renderer/src/components/editor/pdf-viewer-zoom-ownership.test.tsx b/src/renderer/src/components/editor/pdf-viewer-zoom-ownership.test.tsx new file mode 100644 index 000000000000..ff4c6340046d --- /dev/null +++ b/src/renderer/src/components/editor/pdf-viewer-zoom-ownership.test.tsx @@ -0,0 +1,77 @@ +// @vitest-environment happy-dom +import { act, cleanup, render } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import PdfViewer from './PdfViewer' +import { EditorCommandOwnerContext } from './editor-command-owner-context' +import { requestPdfZoom } from './pdf-zoom-request' +import { readPdfScalePreference } from './pdf-scale-preference-storage' + +type PdfDocumentFixture = { name: string } + +const viewers = vi.hoisted((): { currentScale: number; currentScaleValue: string }[] => []) + +vi.mock('pdfjs-dist', () => ({ GlobalWorkerOptions: {} })) +vi.mock('pdfjs-dist/web/pdf_viewer.css', () => ({})) +vi.mock('pdfjs-dist/build/pdf.worker.min.mjs?url', () => ({ default: '' })) +vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) +vi.mock('@/hooks/useShortcutLabel', () => ({ useShortcutLabel: () => '' })) +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: { keybindings: Record }) => unknown) => + selector({ keybindings: {} }) +})) +vi.mock('./pdf-document-loader', () => ({ + createPdfDocumentLoader: ({ display }: { display: (doc: PdfDocumentFixture) => () => void }) => ({ + load: () => display({ name: 'report.pdf' }), + dispose: () => {} + }) +})) +vi.mock('./pdf-viewer-session', () => ({ + createPdfViewerSession: () => { + const viewer = { currentScale: 1, currentScaleValue: 'page-width' } + viewers.push(viewer) + return { viewer, eventBus: null, findController: null, dispose: () => {} } + } +})) + +afterEach(() => { + cleanup() + viewers.length = 0 + localStorage.clear() +}) + +function renderPdf(isCommandOwner: boolean): void { + render( + + + + ) +} + +describe('PdfViewer app zoom ownership', () => { + it('zooms the PDF in the command-owning editor pane', () => { + renderPdf(true) + + let claimed = false + act(() => { + claimed = requestPdfZoom('in') + }) + + expect(claimed).toBe(true) + expect(viewers[0].currentScale).toBe(1.25) + expect(readPdfScalePreference('report')).toBe(1.25) + + viewers[0].currentScaleValue = '1.25' + act(() => { + requestPdfZoom('reset') + }) + expect(viewers[0].currentScaleValue).toBe('page-width') + expect(readPdfScalePreference('report')).toBe('page-width') + }) + + it('leaves zoom to the focused pane when this PDF is in an unfocused split or hidden worktree', () => { + renderPdf(false) + + expect(requestPdfZoom('in')).toBe(false) + expect(viewers[0].currentScale).toBe(1) + }) +}) diff --git a/src/renderer/src/components/editor/pdf-zoom-request.test.ts b/src/renderer/src/components/editor/pdf-zoom-request.test.ts new file mode 100644 index 000000000000..b8b33bf6876e --- /dev/null +++ b/src/renderer/src/components/editor/pdf-zoom-request.test.ts @@ -0,0 +1,18 @@ +// @vitest-environment happy-dom +import { describe, expect, it, vi } from 'vitest' +import { listenForPdfZoomRequests, requestPdfZoom } from './pdf-zoom-request' + +describe('requestPdfZoom', () => { + it('reports whether a PDF took the zoom command', () => { + expect(requestPdfZoom('in')).toBe(false) + + const onZoom = vi.fn() + const stop = listenForPdfZoomRequests(onZoom) + expect(requestPdfZoom('out')).toBe(true) + expect(onZoom).toHaveBeenCalledWith('out') + + stop() + expect(requestPdfZoom('reset')).toBe(false) + expect(onZoom).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/components/editor/pdf-zoom-request.ts b/src/renderer/src/components/editor/pdf-zoom-request.ts new file mode 100644 index 000000000000..5f46b78176a9 --- /dev/null +++ b/src/renderer/src/components/editor/pdf-zoom-request.ts @@ -0,0 +1,24 @@ +export type PdfZoomDirection = 'in' | 'out' | 'reset' + +const PDF_ZOOM_REQUEST_EVENT = 'orca:pdf-zoom-request' + +/** Offers an app zoom command to the focused pane's PDF; true when a PDF took it. */ +export function requestPdfZoom(direction: PdfZoomDirection): boolean { + const event = new CustomEvent(PDF_ZOOM_REQUEST_EVENT, { detail: direction, cancelable: true }) + window.dispatchEvent(event) + return event.defaultPrevented +} + +export function listenForPdfZoomRequests( + onZoom: (direction: PdfZoomDirection) => void +): () => void { + const listener = (event: Event): void => { + const direction: unknown = event instanceof CustomEvent ? event.detail : null + if (direction === 'in' || direction === 'out' || direction === 'reset') { + event.preventDefault() + onZoom(direction) + } + } + window.addEventListener(PDF_ZOOM_REQUEST_EVENT, listener) + return () => window.removeEventListener(PDF_ZOOM_REQUEST_EVENT, listener) +} diff --git a/src/renderer/src/hooks/ipc-events/zoom-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/zoom-ipc-bridge.ts index 6d2337909092..763c553fcfd9 100644 --- a/src/renderer/src/hooks/ipc-events/zoom-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/zoom-ipc-bridge.ts @@ -6,6 +6,7 @@ import { dispatchZoomLevelChanged } from '@/lib/zoom-events' import { stepUIZoomLevel } from '../../../../shared/ui-zoom-level' import { useAppStore } from '../../store' import { resolveZoomTarget } from '../resolve-zoom-target' +import { requestPdfZoom } from '@/components/editor/pdf-zoom-request' export function registerZoomIpcBridge(unsubs: (() => void)[]): void { // Zoom handling for menu accelerators and keyboard fallback paths. @@ -29,6 +30,10 @@ export function registerZoomIpcBridge(unsubs: (() => void)[]): void { return } if (target === 'editor') { + // Why: a PDF owns its page zoom; editor font zoom would change nothing visible. + if (requestPdfZoom(direction)) { + return + } const next = nextEditorFontZoomLevel(editorFontZoomLevel, direction) setEditorFontZoomLevel(next) void window.api.ui.set({ editorFontZoomLevel: next }) diff --git a/src/renderer/src/hooks/zoom-routing.test.ts b/src/renderer/src/hooks/zoom-routing.test.ts index 370e229fb01c..4ee5624e26b6 100644 --- a/src/renderer/src/hooks/zoom-routing.test.ts +++ b/src/renderer/src/hooks/zoom-routing.test.ts @@ -113,6 +113,7 @@ describe('registerZoomIpcBridge', () => { uiZoomLevel?: number nativeChatAppearance?: NativeChatAppearanceSettings editorFontZoomLevel?: number + pdfTakesZoom?: boolean } = {} ) { const { @@ -121,7 +122,8 @@ describe('registerZoomIpcBridge', () => { activeElement = makeTarget({ editorClosest: true, hasXtermClass: true }), uiZoomLevel = 0, editorFontZoomLevel = 0, - nativeChatAppearance + nativeChatAppearance, + pdfTakesZoom = false } = args const applyUIZoom = vi.fn() @@ -133,6 +135,8 @@ describe('registerZoomIpcBridge', () => { Object.assign(settings, updates) }) + const requestPdfZoom = vi.fn(() => pdfTakesZoom) + vi.doMock('@/components/editor/pdf-zoom-request', () => ({ requestPdfZoom })) vi.doMock('@/lib/ui-zoom', () => ({ applyUIZoom })) vi.doMock('@/lib/zoom-events', () => ({ dispatchZoomLevelChanged })) vi.doMock('../store', () => ({ @@ -176,6 +180,7 @@ describe('registerZoomIpcBridge', () => { } return { fire, + requestPdfZoom, applyUIZoom, dispatchZoomLevelChanged, setEditorFontZoomLevel, @@ -264,6 +269,30 @@ describe('registerZoomIpcBridge', () => { expect(zoom.applyUIZoom).not.toHaveBeenCalled() }) + it('hands editor-tab zoom to the PDF it shows instead of editor font zoom', async () => { + const zoom = await mountZoomBridge({ + activeTabType: 'editor', + activeElement: makeTarget({}), + pdfTakesZoom: true + }) + + zoom.fire('out') + + expect(zoom.requestPdfZoom).toHaveBeenCalledWith('out') + expect(zoom.setEditorFontZoomLevel).not.toHaveBeenCalled() + expect(zoom.dispatchZoomLevelChanged).not.toHaveBeenCalled() + expect(zoom.applyUIZoom).not.toHaveBeenCalled() + }) + + it('never offers app zoom outside editor tabs to a PDF', async () => { + const zoom = await mountZoomBridge({ activeTabType: 'browser', pdfTakesZoom: true }) + + zoom.fire('in') + + expect(zoom.requestPdfZoom).not.toHaveBeenCalled() + expect(zoom.applyUIZoom).toHaveBeenCalledWith(0.5) + }) + it('clamps app zoom at the supported maximum', async () => { const zoom = await mountZoomBridge({ uiZoomLevel: UI_ZOOM_MAX }) From 6ceb423599cc1663ed22e63d6cb13e793164e684 Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:11:09 +0000 Subject: [PATCH 21/90] fix(native-chat): return focus to the composer after a pick (#26574) Radix restores focus to the trigger on close, so the model and effort pills kept it. Escape and Tab still land there; only a choice moves on. --- .../native-chat/NativeChatComposerActions.tsx | 5 +- .../native-chat/NativeChatComposerField.tsx | 10 +- .../native-chat/NativeChatModelCombobox.tsx | 6 + ...SessionOptionPickers.focus-return.test.tsx | 167 ++++++++++++++++++ .../NativeChatSessionOptionPickers.tsx | 46 ++++- .../native-chat-picker-focus-return.ts | 26 +++ 6 files changed, 247 insertions(+), 13 deletions(-) create mode 100644 src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.focus-return.test.tsx create mode 100644 src/renderer/src/components/native-chat/native-chat-picker-focus-return.ts diff --git a/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx b/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx index 2bcea04f8773..7344937fec24 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx @@ -39,6 +39,7 @@ export type NativeChatComposerActionsProps = { sessionOptionsSurface: SessionOptionsSurface | null sessionOptionsSnapshot: SessionOptionDescriptor[] sessionOptionsPickerRequest?: NativeChatOptionPickerRequest | null + focusComposer?: () => void /** Present while the composer is in goal mode; the chip calls it to leave. */ onExitGoalMode?: () => void /** Absent until the session has reported or the transcript can estimate. */ @@ -67,7 +68,8 @@ export function NativeChatComposerActions({ sessionOptionsSnapshot, sessionOptionsPickerRequest, onExitGoalMode, - contextUsage + contextUsage, + focusComposer }: NativeChatComposerActionsProps): React.JSX.Element { const stops = primaryAction === 'stop' const resumes = primaryAction === 'resume' @@ -149,6 +151,7 @@ export function NativeChatComposerActions({ snapshot={sessionOptionsSnapshot} isWorking={isWorking} pickerRequest={sessionOptionsPickerRequest} + focusComposer={focusComposer} /> {contextUsage ? : null} diff --git a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx index b39d6548170e..d895611466f8 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx @@ -1,7 +1,7 @@ import { NativeChatPromptEditor } from './NativeChatPromptEditor' import type { NativeChatComposerInput } from './native-chat-composer-input' import type { ClipboardEventHandler, KeyboardEventHandler, RefObject } from 'react' -import { useLayoutEffect, useRef } from 'react' +import { useCallback, useLayoutEffect, useRef } from 'react' import { flushSync } from 'react-dom' import type { useImeEnterGestureOwnership } from '@/lib/ime-composition-keyboard-event' import { cn } from '@/lib/utils' @@ -219,6 +219,8 @@ export function NativeChatComposerField({ sendDisabled: sendButtonDisabled }) const { resume } = primary + // Stable so the memoized option pickers keep their identity. + const focusComposer = useCallback(() => textareaRef.current?.focus(), [textareaRef]) // The button disables while resuming, which drops its focus; typing is what comes next. const resumeQueue = (): void => { resume?.() @@ -378,16 +380,14 @@ export function NativeChatComposerField({ sessionOptionsSnapshot={sessionOptionsSnapshot} contextUsage={contextUsage} sessionOptionsPickerRequest={sessionOptionsPickerRequest} + focusComposer={focusComposer} onExitGoalMode={goalMode?.active ? goalMode.exit : undefined} />
    - textareaRef.current?.focus()} - /> + ) } diff --git a/src/renderer/src/components/native-chat/NativeChatModelCombobox.tsx b/src/renderer/src/components/native-chat/NativeChatModelCombobox.tsx index a6d1842cbf4f..03c768e28514 100644 --- a/src/renderer/src/components/native-chat/NativeChatModelCombobox.tsx +++ b/src/renderer/src/components/native-chat/NativeChatModelCombobox.tsx @@ -10,6 +10,7 @@ import { import { Popover, PopoverContent } from '@/components/ui/popover' import { translate } from '@/i18n/i18n' import type { SessionOptionSelectChoice } from '../../../../shared/native-chat-session-options' +import { useNativeChatPickerFocusReturn } from './native-chat-picker-focus-return' import { nativeChatSessionChoiceLabel } from './native-chat-session-option-labels' import { NativeChatSessionOptionChoiceBody } from './NativeChatSessionOptionChoiceBody' @@ -22,6 +23,7 @@ type NativeChatModelComboboxProps = { readOnly: boolean readOnlyReason: string | null onSelect: (value: string) => void + focusComposer?: () => void /** Renders the `PopoverTrigger` pill, which must take the key handler. */ renderTrigger: (onKeyDown: React.KeyboardEventHandler) => React.ReactNode } @@ -40,9 +42,11 @@ export function NativeChatModelCombobox({ readOnly, readOnlyReason, onSelect, + focusComposer, renderTrigger }: NativeChatModelComboboxProps): React.JSX.Element { const [open, setOpen] = useState(defaultOpen) + const focusReturn = useNativeChatPickerFocusReturn(focusComposer) const searchLabel = translate('components.native-chat.composer.searchModels', 'Search models…') return ( @@ -58,6 +62,7 @@ export function NativeChatModelCombobox({ side="top" collisionPadding={8} className="w-64" + onCloseAutoFocus={focusReturn.onCloseAutoFocus} // Why: the popover loops Tab inside itself, which would trap focus in the search field. onKeyDown={(event) => { if (event.key === 'Tab') { @@ -88,6 +93,7 @@ export function NativeChatModelCombobox({ disabled={readOnly} aria-current={current || undefined} onSelect={() => { + focusReturn.notePick() setOpen(false) onSelect(choice.value) }} diff --git a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.focus-return.test.tsx b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.focus-return.test.tsx new file mode 100644 index 000000000000..bbba4d02d6d3 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.focus-return.test.tsx @@ -0,0 +1,167 @@ +// @vitest-environment happy-dom + +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string, values?: Record) => + values + ? Object.entries(values).reduce( + (text, [name, value]) => text.replaceAll(`{{${name}}}`, String(value)), + fallback + ) + : fallback +})) + +import { TooltipProvider } from '@/components/ui/tooltip' +import type { SessionOptionDescriptor } from '../../../../shared/native-chat-session-options' +import { NativeChatSessionOptionPickers } from './NativeChatSessionOptionPickers' + +const settle = (): Promise => + act(async () => new Promise((resolve) => setTimeout(resolve, 30))) + +const surface = { + getSnapshot: vi.fn(() => []), + setOption: vi.fn(() => Promise.resolve({ snapshot: [] })), + invokeAction: vi.fn(() => Promise.resolve({ snapshot: [] })), + subscribe: vi.fn(() => vi.fn()) +} + +const model: SessionOptionDescriptor = { + id: 'model', + label: 'Model', + category: 'model', + kind: { + type: 'select', + currentValue: 'opus', + choices: [ + { value: 'opus', label: 'Opus 4.8' }, + { value: 'sonnet', label: 'Sonnet 5' } + ] + }, + valueSource: 'applied', + transport: 'catalog', + settable: true +} + +const effort: SessionOptionDescriptor = { + id: 'effort', + label: 'Effort', + category: 'thought_level', + kind: { + type: 'select', + currentValue: 'low', + choices: [ + { value: 'low', label: 'Low' }, + { value: 'high', label: 'High' } + ] + }, + valueSource: 'applied', + transport: 'catalog', + settable: true +} + +const fast: SessionOptionDescriptor = { + id: 'fastMode', + label: 'Fast mode', + category: 'mode', + kind: { type: 'boolean', currentValue: false }, + valueSource: 'applied', + transport: 'catalog', + settable: true +} + +/** The pickers beside a real message box, so focus has somewhere to come back to. */ +async function renderComposer(snapshot: SessionOptionDescriptor[]): Promise { + const messageBox = document.createElement('textarea') + render( + + messageBox.focus()} + /> + + ) + document.body.append(messageBox) + await settle() + return messageBox +} + +/** The options pill opens a menu; the model pill opens the search popover. */ +const pill = (surfaceKind: 'menu' | 'dialog'): HTMLElement => { + const found = screen + .getAllByRole('button') + .find((button) => button.getAttribute('aria-haspopup') === surfaceKind) + if (!found) { + throw new Error(`no pill opening a ${surfaceKind}`) + } + return found +} + +/** The menu opens on pointerdown, which happy-dom does not synthesize, so press Enter instead. */ +const openPill = async (surfaceKind: 'menu' | 'dialog'): Promise => { + const trigger = pill(surfaceKind) + if (surfaceKind === 'menu') { + fireEvent.keyDown(trigger, { key: 'Enter' }) + } else { + fireEvent.click(trigger) + } + await settle() + return trigger +} + +afterEach(() => { + cleanup() + document.body.replaceChildren() + vi.clearAllMocks() +}) + +describe('session option picker focus return', () => { + it('lands focus in the message box after an effort choice', async () => { + const messageBox = await renderComposer([model, effort]) + await openPill('menu') + + fireEvent.click(screen.getByRole('menuitemradio', { name: /High/ })) + await settle() + + expect(surface.setOption).toHaveBeenCalledWith('effort', 'high') + expect(document.activeElement).toBe(messageBox) + }) + + it('lands focus in the message box after a model choice', async () => { + const messageBox = await renderComposer([model, effort]) + await openPill('dialog') + + fireEvent.click(screen.getByRole('option', { name: /Sonnet 5/ })) + await settle() + + expect(surface.setOption).toHaveBeenCalledWith('model', 'sonnet') + expect(document.activeElement).toBe(messageBox) + }) + + it('leaves focus on the pill when the picker is dismissed instead of used', async () => { + await renderComposer([model, effort]) + const trigger = await openPill('menu') + + fireEvent.keyDown(document.activeElement ?? document.body, { key: 'Escape' }) + await settle() + + expect(surface.setOption).not.toHaveBeenCalled() + expect(document.activeElement).toBe(trigger) + }) + + it('keeps focus in the open menu when a toggle leaves it open', async () => { + await renderComposer([model, effort, fast]) + await openPill('menu') + + fireEvent.click(screen.getByRole('switch', { name: 'Fast mode' })) + await settle() + + expect(surface.setOption).toHaveBeenCalledWith('fastMode', true) + const menu = screen.getByRole('menu') + expect(menu).toBeTruthy() + expect(menu.contains(document.activeElement)).toBe(true) + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.tsx b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.tsx index 8accb8676311..36c4fa683cac 100644 --- a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.tsx +++ b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.tsx @@ -36,6 +36,7 @@ import { agentSessionThrownFailure } from '../../../../shared/agent-session-writ import { RuntimeRpcCallError } from '@/runtime/runtime-rpc-result' import { agentSessionWriteFailureText } from './agent-session-write-notice-text' import { NativeChatModelCombobox } from './NativeChatModelCombobox' +import { useNativeChatPickerFocusReturn } from './native-chat-picker-focus-return' import { NativeChatSessionOptionChoiceBody } from './NativeChatSessionOptionChoiceBody' export type NativeChatSessionOptionPickersProps = { @@ -43,6 +44,8 @@ export type NativeChatSessionOptionPickersProps = { snapshot: SessionOptionDescriptor[] isWorking: boolean pickerRequest?: NativeChatOptionPickerRequest | null + /** Where focus lands after a pick; see `useNativeChatPickerFocusReturn`. */ + focusComposer?: () => void } function PickerTooltipContent(props: { @@ -117,19 +120,27 @@ function DescriptorMenuRows(props: { pending: boolean setValue: (value: SessionOptionValue) => void invokeAction: () => void + /** Called by the rows whose choice closes the menu. */ + notePick: () => void }): React.JSX.Element { - const { descriptor, pending, setValue, invokeAction } = props + const { descriptor, pending, setValue, invokeAction, notePick } = props // Why: flip-only without a baseline is an action — never claim On/Off. if (descriptor.action?.type === 'toggle-command') { return ( - invokeAction()}> + { + notePick() + invokeAction() + }} + > {translate('components.native-chat.composer.toggleOption', 'Toggle {{value0}}', { value0: nativeChatSessionOptionLabel(descriptor).toLowerCase() })} ) } - // Why: agent-picker opens the TUI; it is not a set of radio choices. + // Why: agent-picker opens the TUI; it is not a set of radio choices, and that picker owns focus. if (descriptor.action?.type === 'agent-picker') { return ( invokeAction()}> @@ -167,7 +178,10 @@ function DescriptorMenuRows(props: { setValue(value)} + onValueChange={(value) => { + notePick() + setValue(value) + }} > {descriptor.kind.choices.map((choice) => ( (null) + const modelMenuFocus = useNativeChatPickerFocusReturn(focusComposer) + const optionsMenuFocus = useNativeChatPickerFocusReturn(focusComposer) const model = snapshot.find((descriptor) => descriptor.category === 'model') const options = sortNativeChatSessionOptions(snapshot) if (!surface || !model) { @@ -264,6 +281,7 @@ function NativeChatSessionOptionPickersInner({ readOnly={!model.settable || pendingId !== null} readOnlyReason={modelReason} onSelect={(value) => setOption(model, value)} + focusComposer={focusComposer} renderTrigger={(onKeyDown) => ( )} @@ -271,7 +289,13 @@ function NativeChatSessionOptionPickersInner({ ) : ( - + {modelReason && !model.settable ? ( {modelReason} ) : null} @@ -280,6 +304,7 @@ function NativeChatSessionOptionPickersInner({ pending={pendingId !== null} setValue={(value) => setOption(model, value)} invokeAction={() => invokeAction(model)} + notePick={modelMenuFocus.notePick} /> @@ -297,7 +322,13 @@ function NativeChatSessionOptionPickersInner({ disabledReason={optionsReason} dispatched={options.some(sessionOptionDispatchUnconfirmed)} /> - + {options.map((descriptor, index) => { const reason = nativeChatSessionOptionDisabledReason(descriptor.disabledReason) return ( @@ -316,6 +347,7 @@ function NativeChatSessionOptionPickersInner({ pending={pendingId !== null} setValue={(value) => setOption(descriptor, value)} invokeAction={() => invokeAction(descriptor)} + notePick={optionsMenuFocus.notePick} /> ) diff --git a/src/renderer/src/components/native-chat/native-chat-picker-focus-return.ts b/src/renderer/src/components/native-chat/native-chat-picker-focus-return.ts new file mode 100644 index 000000000000..9486b8d3f4c7 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-picker-focus-return.ts @@ -0,0 +1,26 @@ +import { useRef } from 'react' + +/** Radix restores focus to the trigger on close, which is right for a dismissal and wrong after a + * pick. `notePick` marks the choices that close the surface, so only those reach `focusComposer`. */ +export function useNativeChatPickerFocusReturn(focusComposer: (() => void) | undefined): { + notePick: () => void + onCloseAutoFocus: (event: Event) => void +} { + const picked = useRef(false) + return { + notePick: () => { + picked.current = true + }, + onCloseAutoFocus: (event: Event) => { + if (!picked.current) { + return + } + picked.current = false + if (!focusComposer) { + return + } + event.preventDefault() + focusComposer() + } + } +} From 23fd5e6869a9be207372b48169dd8a14c12edae0 Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:14:47 +0000 Subject: [PATCH 22/90] fix(browser): keep the annotation composer off the element it annotates (#26596) Placement only measured the gap below the target, so a tall target or a short pane sent the card to a side with no room and Radix slid it back over the selection. --- .../pending-browser-annotation-card.tsx | 84 +++++++++---------- .../browser-annotation-geometry.test.ts | 57 +++++++++++++ .../browser-annotation-geometry.ts | 74 +++++++++++++--- src/renderer/src/i18n/locales/en.json | 1 - src/renderer/src/i18n/locales/es.json | 1 - src/renderer/src/i18n/locales/fr.json | 1 - src/renderer/src/i18n/locales/ja.json | 1 - src/renderer/src/i18n/locales/ko.json | 1 - src/renderer/src/i18n/locales/zh.json | 1 - 9 files changed, 161 insertions(+), 60 deletions(-) diff --git a/src/renderer/src/components/browser-pane/annotate/pending-browser-annotation-card.tsx b/src/renderer/src/components/browser-pane/annotate/pending-browser-annotation-card.tsx index 47daea3b1dde..554ded9f019a 100644 --- a/src/renderer/src/components/browser-pane/annotate/pending-browser-annotation-card.tsx +++ b/src/renderer/src/components/browser-pane/annotate/pending-browser-annotation-card.tsx @@ -51,7 +51,7 @@ export function PendingBrowserAnnotationCard({ /> -
    -
    + {/* Selector lives in the tooltip: truncated to a popover width it reads as noise. */} +
    + + {payload.target.tagName} + + {payload.target.accessibility.accessibleName || payload.target.textSnippet || - payload.target.tagName} -
    -
    - {payload.target.selector} -
    + payload.target.selector} +
    + {automation.extraAgentArgs ? ( + + ) : null}
    diff --git a/src/renderer/src/components/automations/AutomationEditorDialog.tsx b/src/renderer/src/components/automations/AutomationEditorDialog.tsx index d6ca70851ffc..f285a4f9ca6e 100644 --- a/src/renderer/src/components/automations/AutomationEditorDialog.tsx +++ b/src/renderer/src/components/automations/AutomationEditorDialog.tsx @@ -38,6 +38,8 @@ export type AutomationDraft = { name: string prompt: string agentId: TuiAgent + /** Saved as-is; empty means none. */ + extraAgentArgs: string projectId: string workspaceMode: AutomationWorkspaceMode workspaceId: string diff --git a/src/renderer/src/components/automations/AutomationEditorSettingsSidebar.tsx b/src/renderer/src/components/automations/AutomationEditorSettingsSidebar.tsx index 21f57cd2ce95..ff73ddd116f1 100644 --- a/src/renderer/src/components/automations/AutomationEditorSettingsSidebar.tsx +++ b/src/renderer/src/components/automations/AutomationEditorSettingsSidebar.tsx @@ -20,6 +20,7 @@ import { AutomationSessionField } from './AutomationSessionField' import { AutomationSetupDecisionField } from './AutomationSetupDecisionField' import { AutomationWorkspaceField } from './AutomationWorkspaceField' import { AutomationDestinationField } from './AutomationDestinationField' +import { AutomationExtraAgentArgsField } from './AutomationExtraAgentArgsField' import type { AutomationCreateDestinationControl } from './use-automation-create-destination' import type { AutomationDraft } from './AutomationEditorDialog' @@ -109,6 +110,7 @@ export function AutomationEditorSettingsSidebar({ allowNarrowTrigger /> +
    diff --git a/src/renderer/src/components/automations/AutomationExtraAgentArgsField.tsx b/src/renderer/src/components/automations/AutomationExtraAgentArgsField.tsx new file mode 100644 index 000000000000..8d81b50f358c --- /dev/null +++ b/src/renderer/src/components/automations/AutomationExtraAgentArgsField.tsx @@ -0,0 +1,108 @@ +import { useId } from 'react' +import { Button } from '@/components/ui/button' +import { Textarea } from '@/components/ui/textarea' +import { translate } from '@/i18n/i18n' +import { + getExtraAgentArgsPlaceholder, + hasExtraAgentArgs, + supportsExtraAgentArgs +} from '../../../../shared/automation-extra-agent-args' +import { AUTOMATION_EDITOR_SECTION_LABEL_CLASS } from './automation-page-parts' +import { + draftExtraAgentArgsNeedFreshSession, + getDraftExtraAgentArgsError +} from './automation-draft-model' +import type { AutomationDraft } from './AutomationEditorDialog' + +type AutomationExtraAgentArgsFieldProps = { + draft: AutomationDraft + onDraftChange: (updater: (current: AutomationDraft) => AutomationDraft) => void +} + +export function AutomationExtraAgentArgsField({ + draft, + onDraftChange +}: AutomationExtraAgentArgsFieldProps): React.JSX.Element { + const supported = supportsExtraAgentArgs(draft.agentId) + const error = getDraftExtraAgentArgsError(draft) + const needsFreshSession = supported && draftExtraAgentArgsNeedFreshSession(draft) + const inputId = useId() + const messageId = useId() + return ( +
    +
    + {translate('auto.components.automations.extraAgentArgs.advanced', 'Advanced')} +
    +
    + +