diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 7fc5200404bd..43339372bcbc 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -16,7 +16,7 @@ Thanks for contributing to Orca. ## Local Setup -Install Node 24, pnpm, and the Bun version in [`config/bun-version`](../config/bun-version). +Install Node 24, pnpm, and the Bun version in [`config/.bun-version`](../config/.bun-version). `pnpm test` runs Vitest on Bun, with Node workers for runtime contracts such as SQLite, native PTYs, socket liveness, and V8 memory behavior. `pnpm test:node` runs the same suites entirely on Node. Builds and dependency installation still use Node and pnpm. diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index ce7b71eebf7c..de3c64d0c79d 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -201,7 +201,7 @@ jobs: if: needs.code_paths.outputs.static_analysis == 'true' uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version-file: config/bun-version + bun-version-file: config/.bun-version # Keep each check in its own log while sharing this runner. - name: Lint diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index efb236d0c0e6..c4ff51dd1e19 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -50,7 +50,7 @@ jobs: - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version-file: config/bun-version + bun-version-file: config/.bun-version - uses: actions/download-artifact@v8 continue-on-error: true diff --git a/cloud/dev/scripts/prepare-relay-production-capacity-canary.test.mjs b/cloud/dev/scripts/prepare-relay-production-capacity-canary.test.mjs index d643f2e21788..020cd6ae553a 100644 --- a/cloud/dev/scripts/prepare-relay-production-capacity-canary.test.mjs +++ b/cloud/dev/scripts/prepare-relay-production-capacity-canary.test.mjs @@ -97,13 +97,11 @@ describe('production Relay capacity cell admission', () => { it('admits the same-cap Asia and migration-only cells only under the same-cap allowlist', () => { for (const cellId of [ 'production-gce-c27', 'production-gce-c28', 'production-gce-c29', 'production-gce-c30', - 'production-gce-c31', + 'production-gce-c31', 'production-gce-c34', // Migration-only canaries: the US-only capacity rollout never touches them either. 'production-gce-c17', 'production-gce-c18', // US cells at the Asia shape: the 1,000-cap capacity rollout never touches them. - 'production-gce-c32', 'production-gce-c33', - // The migration-only Asia spare. - 'production-gce-c34' + 'production-gce-c32', 'production-gce-c33' ]) { const hostname = cellId.slice('production-gce-'.length) assert.deepEqual(parseProductionCapacityCellArguments([ diff --git a/cloud/dev/scripts/relay-production-same-cap-wave.mjs b/cloud/dev/scripts/relay-production-same-cap-wave.mjs index b03249937c8b..0b1a756615f6 100644 --- a/cloud/dev/scripts/relay-production-same-cap-wave.mjs +++ b/cloud/dev/scripts/relay-production-same-cap-wave.mjs @@ -4,10 +4,7 @@ import { requireSameEvidenceCode } from './relay-evidence-code-provenance.mjs' // Migration-only by policy: zero hosts and no reservation, so a wave rolls one without // displacing anybody. It enters and must leave migration-only, never general. -// C34 is an Asia spare that stays migration-only by policy. -export const SAME_CAP_MIGRATION_ONLY_CELLS = [ - 'production-gce-c17', 'production-gce-c18', 'production-gce-c34' -] +export const SAME_CAP_MIGRATION_ONLY_CELLS = ['production-gce-c17', 'production-gce-c18'] export const SAME_CAP_CELLS = [ 'production-gce-c7', 'production-gce-c8', 'production-gce-c9', 'production-gce-c10', @@ -15,7 +12,7 @@ export const SAME_CAP_CELLS = [ 'production-gce-c19', 'production-gce-c20', 'production-gce-c21', 'production-gce-c22', 'production-gce-c23', 'production-gce-c24', 'production-gce-c25', 'production-gce-c26', 'production-gce-c27', 'production-gce-c28', 'production-gce-c29', 'production-gce-c30', - 'production-gce-c31', 'production-gce-c32', 'production-gce-c33', + 'production-gce-c31', 'production-gce-c32', 'production-gce-c33', 'production-gce-c34', ...SAME_CAP_MIGRATION_ONLY_CELLS ] diff --git a/cloud/dev/scripts/relay-production-same-cap-wave.test.mjs b/cloud/dev/scripts/relay-production-same-cap-wave.test.mjs index 3d659bb67505..f292bc17a680 100644 --- a/cloud/dev/scripts/relay-production-same-cap-wave.test.mjs +++ b/cloud/dev/scripts/relay-production-same-cap-wave.test.mjs @@ -136,13 +136,11 @@ test('the wave workflow chains exactly ten serial cell jobs', () => { assert.doesNotMatch(dispatch, /\n cell_11:/) }) -test('lists the C34 spare as migration-only beside C17 and C18, and C30-C33 as general', () => { - assert.deepEqual( - SAME_CAP_MIGRATION_ONLY_CELLS, - ['production-gce-c17', 'production-gce-c18', 'production-gce-c34'] - ) +test('lists only C17 and C18 as migration-only now that C34 is promoted, and C30-C34 as general', () => { + assert.deepEqual(SAME_CAP_MIGRATION_ONLY_CELLS, ['production-gce-c17', 'production-gce-c18']) for (const cellId of [ - 'production-gce-c30', 'production-gce-c31', 'production-gce-c32', 'production-gce-c33' + 'production-gce-c30', 'production-gce-c31', 'production-gce-c32', 'production-gce-c33', + 'production-gce-c34' ]) { assert.equal(SAME_CAP_CELLS.includes(cellId), true, cellId) } @@ -207,6 +205,19 @@ test('rolls the migration-only cells but never mixes the two classes in one wave confirmation: `ROLL_RELAY_SAME_CAP ${targetDigest} ${asiaPromoted}`, canaryRunId: '42' }).cells, ['production-gce-c30', 'production-gce-c31']) + // C34 is general once its Asia canary promotes it, so a rollback restores it general. + assert.equal(entryAdmission('production-gce-c34'), 'general') + assert.equal(selectorWaveDelta('production-gce-c34'), 2) + const c34Mixed = 'production-gce-c34,production-gce-c17' + assert.throws(() => validateSameCapWave({ + mode: 'batch-apply', + cellIds: c34Mixed, + targetDigest, + rollbackDigest, + drainPaceWindowMs: '300000', + confirmation: `ROLL_RELAY_SAME_CAP ${targetDigest} ${c34Mixed}`, + canaryRunId: '42' + }), /all general or all migration-only/) const c31Mixed = 'production-gce-c31,production-gce-c18' assert.throws(() => validateSameCapWave({ mode: 'batch-apply', diff --git a/cloud/dev/scripts/relay-same-cap-script-census.test.mjs b/cloud/dev/scripts/relay-same-cap-script-census.test.mjs index 722d032d4036..90228f4c7499 100644 --- a/cloud/dev/scripts/relay-same-cap-script-census.test.mjs +++ b/cloud/dev/scripts/relay-same-cap-script-census.test.mjs @@ -279,10 +279,9 @@ describe('same-cap roll scripts accept every same-cap cell', () => { const trusted = SAME_CAP_CELLS.filter((cell) => REHOME_SOURCE_CELLS.has(cell)) // Only a declared rehome source may roll at a trusted protocol at all; the job refuses // the rest before it plans, and the next test covers them at protocol 0. - // The C34 spare is a rehome source that stays migration-only. assert.deepEqual( SAME_CAP_CELLS.filter((cell) => !REHOME_SOURCE_CELLS.has(cell)), - SAME_CAP_MIGRATION_ONLY_CELLS.filter((cell) => cell !== 'production-gce-c34') + SAME_CAP_MIGRATION_ONLY_CELLS ) for (const [cellId, protocol] of trusted.flatMap((cell) => [[cell, 1], [cell, 3]])) { const { cap, pool } = cellShape(cellId) diff --git a/cloud/dev/scripts/relay-same-cap-shadow-gate-verdict.mjs b/cloud/dev/scripts/relay-same-cap-shadow-gate-verdict.mjs index 23ba31a222b6..019b31616fbc 100644 --- a/cloud/dev/scripts/relay-same-cap-shadow-gate-verdict.mjs +++ b/cloud/dev/scripts/relay-same-cap-shadow-gate-verdict.mjs @@ -28,7 +28,8 @@ export const FLEET_POOL_CELL_IDS = [ 'production-gce-c28', 'production-gce-c29', 'production-gce-c30', - 'production-gce-c31' + 'production-gce-c31', + 'production-gce-c34' ] export const SHADOW_GATE_THRESHOLDS = { diff --git a/cloud/dev/scripts/relay-same-cap-shadow-gate.test.mjs b/cloud/dev/scripts/relay-same-cap-shadow-gate.test.mjs index adcf0edfebec..34ac47fec7fc 100644 --- a/cloud/dev/scripts/relay-same-cap-shadow-gate.test.mjs +++ b/cloud/dev/scripts/relay-same-cap-shadow-gate.test.mjs @@ -278,6 +278,7 @@ function productionLikeEntries() { ...metricSamples({ cellId: 'production-gce-c29', from: '2026-09-20T20:20:00Z', count: 20 }), ...metricSamples({ cellId: 'production-gce-c30', from: '2026-09-20T20:20:00Z', count: 20 }), ...metricSamples({ cellId: 'production-gce-c31', from: '2026-09-20T20:20:00Z', count: 20 }), + ...metricSamples({ cellId: 'production-gce-c34', from: '2026-09-20T20:20:00Z', count: 20 }), // One director instance's samples from ten minutes before the drain to the window's end. ...directorSamples({ from: '2026-09-20T19:50:00Z', count: 80, payload: {} }) ] @@ -339,10 +340,10 @@ function gcloudSeam(entries = productionLikeEntries()) { } } -test('reads every promoted asia-east2 cell as fleet pool, C31 included', () => { +test('reads every promoted asia-east2 cell as fleet pool, C34 included', () => { assert.deepEqual(FLEET_POOL_CELL_IDS, [ 'production-gce-c27', 'production-gce-c28', 'production-gce-c29', 'production-gce-c30', - 'production-gce-c31' + 'production-gce-c31', 'production-gce-c34' ]) }) @@ -363,6 +364,7 @@ test('a healthy roll reads as PASS and names the instance it proved serving', as 'fleetPool:production-gce-c29', 'fleetPool:production-gce-c30', 'fleetPool:production-gce-c31', + 'fleetPool:production-gce-c34', 'nonDrain503Budget' ]) assert.deepEqual(report.drain, { diff --git a/cloud/docs/relay-workflows.md b/cloud/docs/relay-workflows.md index c898079e43c8..c66785a254af 100644 --- a/cloud/docs/relay-workflows.md +++ b/cloud/docs/relay-workflows.md @@ -221,18 +221,12 @@ list because their pool is the US default of 10. C34 is a sixth Asia cell at the C31 shape in `asia-east2-c`, so the six Asia cells spread 2/2/2. It was its own topology wave, registered alone as migration-only, and the director was configured with -`cell-ids` set to C34, all on 2026-10-05. It launched as a migration-only spare and now has a -promotion wave of its own, with the same five-minute canary C30 and C31 ran. The canary lands on -C34 because it is the emptiest general Asia cell once promoted. Promotion compares the director's -serving digest and C34's runtime digest with the one `image-digest` input, so C34 must first be -rolled to the director's image. That roll is a same-cap wave that enters and leaves -migration-only, so it moves nobody. C34 stays in the same-cap migration-only list until its promotion -succeeds, because a same-cap job reads a cell's class from that list, not from the selector. It -then moves to the general list and the fleet pool list together, as its own reviewed change, -before any same-cap wave names C34 again. Between promotion and that change, do not run a same-cap -wave on C34; a rollback there would demote it. Do not name it in the multi-target -`promote-general-cell` or `retire-migration-cell` modes, which accept any migration-only cell. It -is a declared rehome source. +`cell-ids` set to C34, all on 2026-10-05. It launched as a migration-only spare and has a promotion +wave of its own, with the same five-minute canary C30 and C31 ran. Promotion compares the +director's serving digest and C34's runtime digest with the one `image-digest` input, so C34 was +first rolled to the director's image as a migration-only same-cap wave. Once its canary promoted +it, it moved to the same-cap general list and the fleet pool list together, so the same-cap job now +rolls it as a general cell and a rollback restores it general. It is a declared rehome source. Rollback returns Asia cells to migration-only; it does not destroy the network or use existing-only. The production topology dispatch remains unavailable until the diff --git a/cloud/infra/terraform/environments/production.tfvars b/cloud/infra/terraform/environments/production.tfvars index 2081472fa82b..6b9f1d408ce8 100644 --- a/cloud/infra/terraform/environments/production.tfvars +++ b/cloud/infra/terraform/environments/production.tfvars @@ -450,7 +450,7 @@ relay_gce_cells = { connection_hard_cap = 3000 connection_unobserved_bound = 60 } - # Asia spare: registered migration-only as a drain landing zone; c completes the 2/2/2 zone spread. + # Sixth Asia cell: launched as a migration-only spare, now general; c completes the 2/2/2 zone spread. "production-gce-c34" = { hostname = "c34" region = "asia-east2" diff --git a/config/bun-version b/config/.bun-version similarity index 100% rename from config/bun-version rename to config/.bun-version diff --git a/config/build-plugins/plain-node-entry-guard.ts b/config/build-plugins/plain-node-entry-guard.ts index 8c8c8f18020d..cc0ab39bc1d4 100644 --- a/config/build-plugins/plain-node-entry-guard.ts +++ b/config/build-plugins/plain-node-entry-guard.ts @@ -61,6 +61,7 @@ const WORKER_THREAD_ENTRY_NAMES = [ 'main-thread-hang-watchdog-entry', 'port-scan-command-worker-entry', 'usage-scan-worker-entry', + 'claude-profile-setup-worker-entry', 'profile-state-backup-worker-entry', 'profile-state-writer-worker-entry' ] as const diff --git a/config/scripts/build-relay.mjs b/config/scripts/build-relay.mjs index 83332aefa444..27e5103e76aa 100644 --- a/config/scripts/build-relay.mjs +++ b/config/scripts/build-relay.mjs @@ -25,6 +25,7 @@ import { RELAY_BUILD_PLATFORMS, RELAY_VERSION_FILENAME, RELAY_OPENCODE_SQLITE_READER_FILENAME, + WSL_CLAUDE_PROFILE_HELPER_FILENAME, relayOptionalArtifactFilenames, isWindowsRelayPlatform, relayArtifactFilenames @@ -331,6 +332,18 @@ for (const platform of RELAY_BUILD_PLATFORMS) { .slice(0, 12) writeFileSync(join(outDir, '.browser-network-version'), `${RELAY_VERSION}+${browserNetworkHash}`) console.log(`Built WSL browser network relay → ${outDir}/wsl-browser-network-relay.js`) + + // Why here, not the relay dirs: only the desktop runs it, inside WSL; SSH hosts never upload it. + await build({ + entryPoints: [join(ROOT, 'src/main/claude-accounts/claude-profile-wsl-entry.ts')], + bundle: true, + platform: 'node', + target: 'node18', + format: 'cjs', + outfile: join(outDir, WSL_CLAUDE_PROFILE_HELPER_FILENAME), + minify: true, + define: { 'process.env.NODE_ENV': '"production"' } + }) } console.log('Relay build complete.') diff --git a/config/scripts/check-runtime-electron-ratchet-graph.test.mjs b/config/scripts/check-runtime-electron-ratchet-graph.test.mjs new file mode 100644 index 000000000000..943b24fcd781 --- /dev/null +++ b/config/scripts/check-runtime-electron-ratchet-graph.test.mjs @@ -0,0 +1,114 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { collectElectronImporters } from './check-runtime-electron-ratchet.mjs' + +const roots = [] +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +function fixture(files) { + const root = mkdtempSync(path.join(tmpdir(), 'orca-electron-ratchet-graph-')) + roots.push(root) + for (const [file, source] of Object.entries(files)) { + const absolute = path.join(root, file) + mkdirSync(path.dirname(absolute), { recursive: true }) + writeFileSync(absolute, source) + } + return root +} + +async function inspectGraph(entries, { separateEntries = false, plugins = [] } = {}) { + let result + const inspect = { + name: 'inspect-electron-ratchet-graph', + setup(pluginBuild) { + if (separateEntries) { + delete pluginBuild.initialOptions.stdin + pluginBuild.initialOptions.entryPoints = entries + } + pluginBuild.onEnd((built) => { + result = built + }) + } + } + const importers = await collectElectronImporters(entries, { plugins: [...plugins, inspect] }) + return { importers, ...result } +} + +function fixtureInputs(result, root) { + return Object.fromEntries( + Object.entries(result.metafile.inputs) + .filter(([file]) => file.includes(path.basename(root))) + .sort(([left], [right]) => left.localeCompare(right)) + ) +} + +describe('the runtime Electron import graph', () => { + it('retains every original CommonJS input and Electron edge when sharing root dependencies', async () => { + const root = fixture({ + 'entry.mjs': ` + export { value } from './shared.cjs' + export { value as imported } from 'conditional-package' + export const load = () => import('./dynamic.mjs') + `, + 'future.cjs': ` + module.exports = { + value: require('conditional-package').value, + bare: () => require.resolve('electron'), + subpath: () => require.resolve('electron/main'), + addon: () => require('./missing.node'), + desktop: () => require('desktop-package') + } + `, + 'shared.cjs': 'exports.value = 42', + 'dynamic.mjs': "import 'electron/renderer'; export const value = 1", + 'node_modules/conditional-package/package.json': JSON.stringify({ + exports: { '.': { import: './import.mjs', require: './require.cjs' } } + }), + 'node_modules/conditional-package/import.mjs': "import 'electron'; export const value = 2", + 'node_modules/conditional-package/require.cjs': "require('electron/main'); exports.value = 3", + 'node_modules/desktop-package/package.json': '{"main":"index.cjs"}', + 'node_modules/desktop-package/index.cjs': "require('electron'); exports.value = 4" + }) + const injectElectron = { + name: 'inject-future-electron-import', + setup(pluginBuild) { + pluginBuild.onLoad({ filter: /future\.cjs$/ }, (args) => ({ + contents: `require('electron/utility')\n${readFileSync(args.path, 'utf8')}`, + loader: 'js' + })) + } + } + const entries = ['entry.mjs', 'future.cjs', 'shared.cjs'].map((file) => path.join(root, file)) + const original = await inspectGraph(entries, { + separateEntries: true, + plugins: [injectElectron] + }) + const shared = await inspectGraph(entries, { plugins: [injectElectron] }) + + expect(fixtureInputs(shared, root)).toEqual(fixtureInputs(original, root)) + expect(shared.importers).toEqual(original.importers) + expect(shared.importers.map((file) => file.slice(file.indexOf(path.basename(root))))).toEqual([ + `${path.basename(root)}/dynamic.mjs`, + `${path.basename(root)}/future.cjs`, + `${path.basename(root)}/node_modules/conditional-package/import.mjs`, + `${path.basename(root)}/node_modules/conditional-package/require.cjs`, + `${path.basename(root)}/node_modules/desktop-package/index.cjs` + ]) + const future = Object.entries(shared.metafile.inputs).find(([file]) => + file.endsWith('/future.cjs') + ) + expect(future[1].imports).toEqual( + expect.arrayContaining([ + expect.objectContaining({ path: 'electron', kind: 'require-resolve', external: true }), + expect.objectContaining({ path: 'electron/main', kind: 'require-resolve', external: true }), + expect.objectContaining({ path: './missing.node', external: true }) + ]) + ) + }) +}) diff --git a/config/scripts/check-runtime-electron-ratchet.mjs b/config/scripts/check-runtime-electron-ratchet.mjs index 98c5493b7e9f..8c20cb8e8c28 100644 --- a/config/scripts/check-runtime-electron-ratchet.mjs +++ b/config/scripts/check-runtime-electron-ratchet.mjs @@ -113,18 +113,24 @@ const externalNativeAddons = { // Why `plugins`: lets a test add an Electron import to a real file in memory, never on disk. export async function collectElectronImporters(entryPoints, { plugins = [] } = {}) { const result = await build({ - entryPoints, + // Export every entry through one bundle so shared dependencies are emitted once. + stdin: { + contents: entryPoints + .map( + (entry, index) => + `export * as entry_${index} from ${JSON.stringify(path.resolve(ROOT, entry))}` + ) + .join('\n'), + resolveDir: ROOT, + loader: 'ts', + sourcefile: 'runtime-electron-ratchet-entry.ts' + }, bundle: true, write: false, - // Why outdir with write:false: esbuild refuses multiple entry points without one, - // even though nothing is emitted — the metafile is all this reads. outdir: path.join(ROOT, 'runtime-electron-ratchet-metafile-only'), platform: 'node', target: 'node20', - // Why splitting: ~2.5k entry points each bundled whole held every copy of the graph in - // memory (2GB+), enough to take down a CI runner; shared chunks keep one copy. - format: 'esm', - splitting: true, + format: 'cjs', external: EXTERNAL, metafile: true, absWorkingDir: ROOT, diff --git a/config/scripts/check-runtime-electron-ratchet.test.mjs b/config/scripts/check-runtime-electron-ratchet.test.mjs index 2e46362390eb..9f9e81f0f40b 100644 --- a/config/scripts/check-runtime-electron-ratchet.test.mjs +++ b/config/scripts/check-runtime-electron-ratchet.test.mjs @@ -94,21 +94,61 @@ describe('structured chat coverage', () => { } ) - it('finds Electron through a package imported by each unwired future lane', async () => { + it.each([true, false])( + 'finds Electron through an unwired lane package (sideEffects=%s)', + async (sideEffects) => { + const root = fixture({ + ...requiredLanes, + 'src/main/acp/adapter.ts': "import 'acp-desktop-package'", + 'src/main/provider-process/worker.ts': "import 'provider-desktop-package'", + 'node_modules/acp-desktop-package/package.json': JSON.stringify({ + main: 'index.js', + sideEffects + }), + 'node_modules/acp-desktop-package/index.js': "require('electron')", + 'node_modules/provider-desktop-package/package.json': JSON.stringify({ + main: 'index.js', + sideEffects + }), + 'node_modules/provider-desktop-package/index.js': "require('electron')" + }) + const current = await collectElectronImporters(collectStructuredChatEntryPoints(root)) + expect(current.map((file) => file.split('/node_modules/').pop())).toEqual([ + 'acp-desktop-package/index.js', + 'provider-desktop-package/index.js' + ]) + } + ) + + it('bounds shared dependency output without dropping any entry point', async () => { + const entries = Array.from({ length: 40 }, (_, index) => `entry-${index}.ts`) const root = fixture({ - ...requiredLanes, - 'src/main/acp/adapter.ts': "import 'acp-desktop-package'", - 'src/main/provider-process/worker.ts': "import 'provider-desktop-package'", - 'node_modules/acp-desktop-package/package.json': '{"main":"index.js"}', - 'node_modules/acp-desktop-package/index.js': "require('electron')", - 'node_modules/provider-desktop-package/package.json': '{"main":"index.js"}', - 'node_modules/provider-desktop-package/index.js': "require('electron')" + 'shared.ts': `export const payload = ${JSON.stringify('x'.repeat(64 * 1024))}`, + ...Object.fromEntries( + entries.map((file) => [file, "import 'electron'; export { payload } from './shared'"]) + ) }) - const current = await collectElectronImporters(collectStructuredChatEntryPoints(root)) - expect(current.map((file) => file.split('/node_modules/').pop())).toEqual([ - 'acp-desktop-package/index.js', - 'provider-desktop-package/index.js' - ]) + let emittedBytes = 0 + const current = await collectElectronImporters( + entries.map((file) => path.join(root, file)), + { + plugins: [ + { + name: 'measure-audit-output', + setup(builder) { + builder.onEnd((result) => { + emittedBytes = result.outputFiles.reduce( + (bytes, file) => bytes + file.contents.byteLength, + 0 + ) + }) + } + } + ] + } + ) + expect(current.map((file) => path.basename(file)).sort()).toEqual(entries.sort()) + expect(emittedBytes).toBeLessThan(128 * 1024) }) }) diff --git a/config/scripts/run-vitest.mjs b/config/scripts/run-vitest.mjs index 21cb3ae4482c..35bb720259a9 100644 --- a/config/scripts/run-vitest.mjs +++ b/config/scripts/run-vitest.mjs @@ -19,6 +19,6 @@ try { process.exitCode = result.code ?? 1 } catch (error) { - console.error('Could not start Vitest. Install the Bun version in config/bun-version.', error) + console.error('Could not start Vitest. Install the Bun version in config/.bun-version.', error) process.exitCode = 1 } diff --git a/config/scripts/vitest-node-runtime-files.mjs b/config/scripts/vitest-node-runtime-files.mjs index e3c917105aa9..69e473c91d22 100644 --- a/config/scripts/vitest-node-runtime-files.mjs +++ b/config/scripts/vitest-node-runtime-files.mjs @@ -63,6 +63,7 @@ export const NODE_RUNTIME_INCLUDE = [ 'src/renderer/src/store/github/cache-persistence.test.ts', 'src/main/native-chat/agent-session-journal/**/*.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-conversation-name.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-status-first-input-identity.test.ts', 'src/main/native-chat/structured-chat-naming-command-first.test.ts', 'src/main/native-chat/structured-chat-naming-status-hook.test.ts', @@ -73,6 +74,8 @@ export const NODE_RUNTIME_INCLUDE = [ 'src/shared/remote-runtime-connect-bound.test.ts', 'src/shared/remote-runtime-subscription-connect-bound.test.ts', 'src/main/codex/codex-shared-server-probe.test.ts', + 'src/main/opencode/opencode-launch-model-context-supervision.test.ts', + 'src/main/hermes/hermes-config-remote-adapter.test.ts', 'src/relay/relay-reconnect-listener-peer-write-error.test.ts', 'src/relay/relay-primary-channel.test.ts', 'src/renderer/src/lib/monaco-languages/monarch-upstream-mdx-recursion.test.ts', @@ -80,6 +83,7 @@ export const NODE_RUNTIME_INCLUDE = [ 'src/main/runtime/structured-agent-session-startup-tab-restore.test.ts', 'src/shared/remote-runtime-shared-control-connection.test.ts', 'src/main/runtime/relay/relay-control-client.test.ts', + 'src/main/ipc/runtime-environment-preference.test.ts', 'src/main/ipc/runtime-environment-transport-routing-tailscale-hint.test.ts', 'src/renderer/src/components/emulator-pane/emulator-device-frame-visibility.test.tsx', 'src/main/claude/claude-agent-sdk-contract-pins.test.ts', diff --git a/config/scripts/vitest-sqlite-database-runtime-files.mjs b/config/scripts/vitest-sqlite-database-runtime-files.mjs index d34137bbe0b3..9098a6f566db 100644 --- a/config/scripts/vitest-sqlite-database-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-database-runtime-files.mjs @@ -29,7 +29,7 @@ export const SQLITE_DATABASE_RUNTIME_INCLUDE = [ 'src/main/runtime/claude-structured-exit-mid-response.test.ts', 'src/main/runtime/claude-structured-failed-start-resume.test.ts', 'src/main/runtime/claude-structured-resumed-start-failure.test.ts', - 'src/main/runtime/claude-structured-send-held-for-startup.test.ts', + 'src/main/runtime/claude-structured-send-during-startup.test.ts', 'src/main/runtime/claude-structured-send-restart-dies-before-dispatch.test.ts', 'src/main/runtime/claude-structured-session-integration.test.ts', 'src/main/runtime/claude-structured-startup-fault-is-not-an-exit.test.ts', @@ -119,6 +119,7 @@ export const SQLITE_DATABASE_RUNTIME_INCLUDE = [ 'src/main/runtime/orchestration/worker-dispatch-repair-safety.test.ts', 'src/main/runtime/orchestration/worker-dispatch-settlement.test.ts', 'src/main/runtime/orchestration/worker-start-unobserved-prompt-settlement.test.ts', + 'src/main/runtime/rpc/methods/agent-launch-instant-tab.test.ts', 'src/main/runtime/rpc/methods/agent-launch-restart-replay.test.ts', 'src/main/runtime/rpc/methods/native-chat-opencode-semantic-pages.test.ts', 'src/main/runtime/rpc/methods/native-chat-opencode-wire-page-budget.test.ts', diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index 3e6664e84c1f..0edefd1c8e2e 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -48,6 +48,8 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-hung-start-stop.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-opening-send-hold.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts', @@ -55,6 +57,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-note-end.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-turn-end.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-close-aborts-start.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-note-end.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts', @@ -75,7 +78,6 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts', - 'src/main/native-chat/agent-session-wire/structured-agent-session-import-mismatch.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-journal-corruption.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-late-proof-tool-ends.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts', @@ -104,10 +106,10 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-repeated-stop.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-resend-answer.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-resend-ledger-refusal.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-newer-orca.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts', - 'src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-rewind-live-send.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts', @@ -177,11 +179,11 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts', 'src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts', 'src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts', + 'src/main/runtime/structured-agent-session-codex-opening-send-hold.test.ts', 'src/main/runtime/structured-agent-session-codex-settlement-order.test.ts', 'src/main/runtime/structured-agent-session-integration-replay.test.ts', 'src/main/runtime/structured-agent-session-integration.test.ts', 'src/main/runtime/structured-agent-session-mobile-restore.test.ts', - 'src/main/runtime/structured-agent-session-record-import-install.test.ts', 'src/main/runtime/structured-agent-session-rollback-compatibility.test.ts', 'src/main/runtime/structured-agent-session-spawn-token-descendants.test.ts', 'src/main/runtime/structured-agent-session-startup-reconcile.test.ts', diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index e3279def3d89..be0414e96118 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -127,6 +127,7 @@ "../src/main/antigravity/hooks-json-bundle.ts", "../src/main/claude/hook-settings.ts", "../src/main/claude/hook-service.ts", + "../src/main/claude/claude-profile-hook-target.ts", "../src/main/claude/statusline-script.ts", "../src/main/claude/windows-hook-files.ts", "../src/main/claude-accounts/keychain.ts", diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index b9325c02a1a5..f895d9dc38a5 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 95m + + downloads: 96m @@ -15,7 +15,7 @@ downloads downloads - 95m - 95m + 96m + 96m diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 4cc56f7b1293..263268833ba1 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -257,6 +257,10 @@ export const electronViteConfig: UserConfig = { // corpora and read SQLite synchronously; a worker thread keeps that // off the main-process event loop. 'usage-scan-worker-entry': resolve('src/main/usage/usage-scan-worker-entry.ts'), + // Why: a first account setup can merge a large history tree with sync fs calls. + 'claude-profile-setup-worker-entry': resolve( + 'src/main/claude-accounts/claude-profile-setup-worker-entry.ts' + ), 'profile-state-backup-worker-entry': resolve( 'src/main/persistence/profile-state/profile-state-backup-worker-entry.ts' ), diff --git a/mobile/src/session/mobile-native-chat-merged-snapshot-parity.test.ts b/mobile/src/session/mobile-native-chat-merged-snapshot-parity.test.ts index 3762d57006e4..9d75bb02d7d6 100644 --- a/mobile/src/session/mobile-native-chat-merged-snapshot-parity.test.ts +++ b/mobile/src/session/mobile-native-chat-merged-snapshot-parity.test.ts @@ -13,10 +13,8 @@ import { describe, expect, it } from 'vitest' import type { AgentSessionSubscribeEvent } from '../../../src/shared/agent-session-wire' import { withNativeChatCutTurnNotices } from '../../../src/shared/native-chat-cut-turn-notice' import { nativeChatRowsInDrawOrder } from '../../../src/shared/native-chat-turn-grouping' -import { - nativeChatMessagesWaitingBehindLiveTurn, - nativeChatTurnMembership -} from '../../../src/shared/native-chat-turn-membership' +import { nativeChatTurnMembership } from '../../../src/shared/native-chat-turn-membership' +import { nativeChatMessagesWaitingBehindLiveTurn } from '../../../src/shared/native-chat-messages-waiting-behind-live-turn' import { activeStructuredAgentSessionTurnId } from '../../../src/shared/structured-agent-session-live-turn' import { isStructuredAgentSessionMainAgentWorking } from '../../../src/shared/structured-agent-session-main-agent-working' import { projectStructuredAgentSessionMessages } from '../../../src/shared/structured-agent-session-message-projection' diff --git a/mobile/src/session/use-mobile-native-chat-turn-disclosure-opening-send.test.tsx b/mobile/src/session/use-mobile-native-chat-turn-disclosure-opening-send.test.tsx new file mode 100644 index 000000000000..1c8e285179f1 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-turn-disclosure-opening-send.test.tsx @@ -0,0 +1,294 @@ +// On the phone, as on the desktop: a message sent while the turn ahead is still opening waits after +// that turn's live status, and the live status stays on the turn ahead, never on the waiting one. +// The phone keeps no outbox: its rows are the host's recorded ones, plus its own echo of a send the +// host accepted, until that send's row arrives. The list is built as the phone's view builds it. + +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it } from 'vitest' +import { agentSessionFailureFact } from '../../../src/shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../src/shared/agent-session-failure-words' +import { agentJournalSubmissionKey } from '../../../src/shared/agent-session-journal-item-key' +import type { + AgentJournalItemBody, + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../src/shared/agent-session-journal-types' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { projectStructuredAgentSessionMessages } from '../../../src/shared/structured-agent-session-message-projection' +import { + buildMobileNativeChatTransientData, + foldMobileNativeChatMessages, + type MobileNativeChatPendingItem +} from './mobile-native-chat-render-data' +import { useMobileNativeChatTurnDisclosure } from './use-mobile-native-chat-turn-disclosure' + +const NOW = 100_000 + +function item( + itemId: string, + sequence: number, + body: AgentJournalItemBody +): AgentJournalRenderItem { + return { + itemId, + revision: 0, + sequence, + observedAt: sequence, + body, + turnScope: { kind: 'thread' } + } +} + +const userMessage = (id: string, sequence: number) => + item(agentJournalSubmissionKey(id), sequence, { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: id }] + }) + +function submission( + clientMessageId: string, + overrides: Partial = {} +): AgentJournalSubmission { + return { + clientMessageId, + fence: 1, + payloadFingerprint: clientMessageId, + dispatchState: 'pending', + providerItemId: null, + reason: null, + submittedAt: NOW, + resolvedAt: null, + handoverRecorded: true, + ...overrides + } +} + +/** "first" handed over (row 5) with no turn record yet: its turn opens. */ +const openingItems = [userMessage('first', 5)] +const openingSubmissions = [submission('first', { acceptedSequence: 3, handedOverAt: NOW })] + +/** The first send's turn record, written as its turn opens; Codex names its own provider key. */ +const FIRST_TURN_PROVIDER_KEY = 'codex:thread:turn-first:0' +const firstTurnOpened = item('turn-first', 6, { + kind: 'turn', + turnId: 'turn-first', + state: 'running', + userItemId: FIRST_TURN_PROVIDER_KEY, + startedAt: NOW +}) + +type Disclosure = ReturnType + +function Harness(props: { + items: AgentJournalRenderItem[] + submissions: AgentJournalSubmission[] + stopping: boolean + pending: MobileNativeChatPendingItem[] + seen: (disclosure: Disclosure) => void +}): null { + // As the phone projects: no other rejected send drawn in place. + const messages: NativeChatMessage[] = projectStructuredAgentSessionMessages( + props.items, + [], + props.submissions, + { rejectedInPlace: false } + ) + const { data } = buildMobileNativeChatTransientData({ + messages, + folded: foldMobileNativeChatMessages(messages), + streaming: null, + pending: props.pending + }) + const disclosure = useMobileNativeChatTurnDisclosure({ + messages: data, + enabled: true, + isWorking: true, + workingStartedAt: NOW, + turnJournal: { items: props.items, submissions: props.submissions }, + stopping: props.stopping, + scopeKey: 'host\0worktree\0tab-a' + }) + props.seen(disclosure) + return null +} + +describe('a message sent while the turn ahead is still opening, on the phone', () => { + let renderer: ReactTestRenderer | null = null + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + function frame( + items: AgentJournalRenderItem[], + submissions: AgentJournalSubmission[], + stopping = false, + pending: MobileNativeChatPendingItem[] = [] + ): { listed: string[]; waiting: string[]; liveOn: string | undefined } { + let seen: Disclosure | undefined + act(() => { + const element = createElement(Harness, { + items, + submissions, + stopping, + pending, + seen: (disclosure) => { + seen = disclosure + } + }) + if (renderer) { + renderer.update(element) + } else { + renderer = create(element) + } + }) + if (!seen) { + throw new Error('expected the hook to render') + } + const disclosure = seen + const listed = disclosure.listMessages.map((message) => message.id) + const liveRow = disclosure.listMessages.findIndex( + (message, index) => disclosure.resolveRow(index, message).turnStatus !== null + ) + return { + listed, + waiting: disclosure.waitingRows.map(({ item: row }) => row.id), + liveOn: liveRow === -1 ? undefined : listed[liveRow] + } + } + + // Accepted at a row above the first send's handover, yet still drawn after its live status. + it('keeps a queued send waiting after the live status', () => { + expect( + frame( + [...openingItems, userMessage('second', 4)], + [...openingSubmissions, submission('second', { acceptedSequence: 4 })] + ) + ).toEqual({ + listed: [agentJournalSubmissionKey('first')], + waiting: [agentJournalSubmissionKey('second')], + liveOn: agentJournalSubmissionKey('first') + }) + }) + + // Its echo of a send the host accepted, before that send's row reaches the phone. + it('keeps its own echo of an accepted send waiting after the live status, Stopping or not', () => { + const echo = { + id: 'pending-1', + text: 'second', + baselineTailMessageId: agentJournalSubmissionKey('first') + } + for (const stopping of [false, true]) { + expect(frame(openingItems, openingSubmissions, stopping, [echo])).toEqual({ + listed: [agentJournalSubmissionKey('first')], + waiting: ['pending-1'], + liveOn: agentJournalSubmissionKey('first') + }) + } + // While Stopping, sent after a queued B the Stop holds. + expect( + frame( + [...openingItems, userMessage('b', 4)], + [...openingSubmissions, submission('b', { acceptedSequence: 4 })], + true, + [{ ...echo, baselineTailMessageId: agentJournalSubmissionKey('b') }] + ).waiting + ).toEqual([agentJournalSubmissionKey('b'), 'pending-1']) + }) + + // Taken back, B stays where it was sent with its stop row: listed after the live turn, no longer + // waiting. + it('lists B after the live turn when a Stop takes it back, never above the live status', () => { + const queued = [...openingItems, userMessage('b', 4)] + const stopping = frame( + queued, + [...openingSubmissions, submission('b', { acceptedSequence: 4 })], + true + ) + expect(stopping.waiting).toEqual([agentJournalSubmissionKey('b')]) + expect(stopping.listed).toEqual([agentJournalSubmissionKey('first')]) + + const withdrawn = submission('b', { + acceptedSequence: 4, + dispatchState: 'rejected', + resolvedAt: NOW + 200, + ...agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }) + }) + expect(frame(queued, [...openingSubmissions, withdrawn], true)).toEqual({ + listed: [ + agentJournalSubmissionKey('first'), + agentJournalSubmissionKey('b'), + `stopped-before-start:${agentJournalSubmissionKey('b')}` + ], + waiting: [], + liveOn: agentJournalSubmissionKey('first') + }) + }) + + const echoed = (sent: AgentJournalSubmission): AgentJournalSubmission => ({ + ...sent, + dispatchState: 'accepted', + providerItemId: FIRST_TURN_PROVIDER_KEY, + resolvedAt: NOW + 20 + }) + // Handed over as a steer once the turn opened (row 8). + const steered = [ + ...openingItems, + firstTurnOpened, + { ...userMessage('second', 8), turnScope: { kind: 'turn' as const, turnItemId: 'turn-first' } } + ] + + /** One frame per commit: "second" is listed in the turn it lands in, after "first", and the live + * status stays on "first". */ + function walkTurnOpening(steps: [AgentJournalRenderItem[], AgentJournalSubmission[]][]): void { + for (const step of steps) { + expect(frame(...step)).toEqual({ + listed: [agentJournalSubmissionKey('first'), agentJournalSubmissionKey('second')], + waiting: [], + liveOn: agentJournalSubmissionKey('first') + }) + } + } + + it('lists it in the turn ahead at every step of the turn opening, even with the steer echoed first', () => { + const [first] = openingSubmissions + if (!first) { + throw new Error('expected the first send') + } + const handedOver = submission('second', { acceptedSequence: 7, handedOverAt: NOW + 10 }) + walkTurnOpening([ + // Recorded after the turn record, not yet handed over. + [ + [...openingItems, firstTurnOpened, userMessage('second', 7)], + [first, submission('second', { acceptedSequence: 7 })] + ], + [steered, [first, handedOver]], + // Codex echoes the steer before the send that opened the turn, then that send. + [steered, [first, echoed(handedOver)]], + [steered, [echoed(first), echoed(handedOver)]] + ]) + }) + + // Two messages queued behind /compact, or sent while Stopping: "second" is accepted above the + // first send's handover, and the first send's turn record lands before "second" is handed over. + it('lists it in the turn ahead at every step when it was accepted above the first send', () => { + const [first] = openingSubmissions + if (!first) { + throw new Error('expected the first send') + } + const queuedAbove = [userMessage('second', 4), ...openingItems, firstTurnOpened] + const queued = submission('second', { acceptedSequence: 4 }) + const handedOver = { ...queued, handedOverAt: NOW + 10 } + walkTurnOpening([ + [queuedAbove, [first, queued]], + // The first send echoes while "second" is still queued. + [queuedAbove, [echoed(first), queued]], + [steered, [first, handedOver]], + [steered, [first, echoed(handedOver)]], + [steered, [echoed(first), echoed(handedOver)]] + ]) + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts b/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts index 81c76d1401c6..28bd7a7d4fa8 100644 --- a/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts +++ b/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts @@ -8,10 +8,10 @@ import { import type { NativeChatSettledTurns } from '../../../src/shared/native-chat-turn-status' import { isNativeChatRowInLiveWorkingTurn, - nativeChatMessagesWaitingBehindLiveTurn, nativeChatTurnMembership, type NativeChatTurnJournal } from '../../../src/shared/native-chat-turn-membership' +import { nativeChatMessagesWaitingBehindLiveTurn } from '../../../src/shared/native-chat-messages-waiting-behind-live-turn' import { nativeChatRowsInDrawOrder, nativeChatTurnBarRows @@ -135,7 +135,12 @@ export function useMobileNativeChatTurnDisclosure({ // A message waiting behind the live turn draws after that turn's live status, not in the list. const waiting = useMemo(() => { const ids = enabled - ? nativeChatMessagesWaitingBehindLiveTurn(rows, turnJournal?.items, stopping) + ? nativeChatMessagesWaitingBehindLiveTurn( + rows, + turnJournal?.items, + stopping, + turnJournal?.submissions + ) : null if (!ids?.size) { return { listMessages: rows, waitingRows: [], indexById: null } diff --git a/src/cli/handlers/worktree-setup-decision-recovery.ts b/src/cli/handlers/worktree-setup-decision-recovery.ts new file mode 100644 index 000000000000..27acc186b395 --- /dev/null +++ b/src/cli/handlers/worktree-setup-decision-recovery.ts @@ -0,0 +1,34 @@ +import { RuntimeClientError, RuntimeRpcFailureError } from '../runtime-client' + +// Why match the text: every host version refuses an undecided `ask` create with exactly this +// message and the generic runtime_error code, so it is the only stable signal. +const SETUP_DECISION_REQUIRED = 'Setup decision required for this repository' +const SETUP_DECISION_NEXT_STEP = + 'Pass --setup run to run the setup script, or --setup skip to create without it.' + +/** Why here, not on the host: desktop and phone choose setup in their own UI, so the host's + * shared refusal names no flag; only the CLI user answers it with --setup. */ +export async function withSetupDecisionRecovery(create: Promise): Promise { + try { + return await create + } catch (error) { + throw attachSetupDecisionRecovery(error) + } +} + +function attachSetupDecisionRecovery(error: unknown): unknown { + if (!(error instanceof RuntimeClientError) || error.message !== SETUP_DECISION_REQUIRED) { + return error + } + const data = { + ...(error.data && typeof error.data === 'object' ? error.data : {}), + nextSteps: [SETUP_DECISION_NEXT_STEP] + } + if (error instanceof RuntimeRpcFailureError) { + return new RuntimeRpcFailureError({ + ...error.response, + error: { ...error.response.error, data } + }) + } + return new RuntimeClientError(error.code, error.message, data) +} diff --git a/src/cli/handlers/worktree.ts b/src/cli/handlers/worktree.ts index e6cfd02db958..32f37e253bbe 100644 --- a/src/cli/handlers/worktree.ts +++ b/src/cli/handlers/worktree.ts @@ -39,6 +39,7 @@ import { import { getOptionalLinearIssueLinkFlag } from './worktree-linear-issue-link' import { getOptionalWorktreeUnreadFlag } from './worktree-unread-flag' import { getReviewTargetLinkFlags } from './worktree-review-link-flags' +import { withSetupDecisionRecovery } from './worktree-setup-decision-recovery' import { assertGitLabLinkFlagProjectsMatch } from './worktree-gitlab-link-context' function getEnvParentWorkspace(): string | undefined { @@ -210,37 +211,39 @@ export const WORKTREE_HANDLERS: Record = { const name = getRequiredStringFlag(flags, 'name') const repo = await getCreateRepoSelector(flags, cwdParentWorktree, client) await assertGitLabLinkFlagProjectsMatch(flags, client, { repo }) - const result = await client.call('worktree.create', { - repo, - name, - displayName: name, - displayNameKind: 'user', - baseBranch: getOptionalStringFlag(flags, 'base-branch'), - ...reviewLinks, - ...linearIssueLink, - comment: getOptionalStringFlag(flags, 'comment'), - runHooks: flags.get('run-hooks') === true, - activate, - // CLI activation targets its runtime's desktop, never unrelated paired viewers. - ...(activate ? { navigation: 'host' as const } : {}), - ...(setupDecision ? { setupDecision } : {}), - parentWorktree: explicitParentWorktree, - ...(explicitParentWorkspace ? { parentWorkspace: explicitParentWorkspace } : {}), - ...(envParentWorkspace ? { envParentWorkspace } : {}), - ...(cwdParentWorktree ? { cwdParentWorktree } : {}), - noParent, - callerTerminalHandle, - // Why: marks the workspace as CLI-created so the sidebar can badge and - // filter it. Sent on every `worktree create` — hand-typed or agent-run. - cliProvenanceRequest: callerTerminalHandle ? { callerTerminalHandle } : {}, - ...(startupAgent - ? { - startupAgent, - startupPrompt: getPresentStringFlag(flags, 'prompt', { allowEmpty: true }) ?? '', - launchSource: 'cli' - } - : {}) - }) + const result = await withSetupDecisionRecovery( + client.call('worktree.create', { + repo, + name, + displayName: name, + displayNameKind: 'user', + baseBranch: getOptionalStringFlag(flags, 'base-branch'), + ...reviewLinks, + ...linearIssueLink, + comment: getOptionalStringFlag(flags, 'comment'), + runHooks: flags.get('run-hooks') === true, + activate, + // CLI activation targets its runtime's desktop, never unrelated paired viewers. + ...(activate ? { navigation: 'host' as const } : {}), + ...(setupDecision ? { setupDecision } : {}), + parentWorktree: explicitParentWorktree, + ...(explicitParentWorkspace ? { parentWorkspace: explicitParentWorkspace } : {}), + ...(envParentWorkspace ? { envParentWorkspace } : {}), + ...(cwdParentWorktree ? { cwdParentWorktree } : {}), + noParent, + callerTerminalHandle, + // Why: marks the workspace as CLI-created so the sidebar can badge and + // filter it. Sent on every `worktree create` — hand-typed or agent-run. + cliProvenanceRequest: callerTerminalHandle ? { callerTerminalHandle } : {}, + ...(startupAgent + ? { + startupAgent, + startupPrompt: getPresentStringFlag(flags, 'prompt', { allowEmpty: true }) ?? '', + launchSource: 'cli' + } + : {}) + }) + ) printHookWarning(result.result, json) printLineageSummary(result.result, json) printResult(result, json, formatWorktreeShow) diff --git a/src/cli/index-worktree-create-target.test.ts b/src/cli/index-worktree-create-target.test.ts index dc7249ec5b48..5b643a02efc4 100644 --- a/src/cli/index-worktree-create-target.test.ts +++ b/src/cli/index-worktree-create-target.test.ts @@ -43,6 +43,7 @@ vi.mock('child_process', async () => { import { main } from './index' import { buildWorktree, okFixture, queueFixtures, worktreeListFixture } from './test-fixtures' import { pairRuntimeEnvironment, useWorktreeAwarenessEnvironment } from './index-test-harness' +import { RuntimeRpcFailureError } from './runtime-client' describe('orca cli worktree awareness', () => { useWorktreeAwarenessEnvironment({ @@ -303,4 +304,29 @@ describe('orca cli worktree awareness', () => { expect.objectContaining({ cliProvenanceRequest: {} }) ) }) + + it('tells the user which --setup flag answers an undecided ask repo', async () => { + callMock.mockRejectedValueOnce( + new RuntimeRpcFailureError({ + id: 'req_create', + ok: false, + error: { code: 'runtime_error', message: 'Setup decision required for this repository' }, + _meta: { runtimeId: 'runtime-1' } + }) + ) + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const priorExitCode = process.exitCode + + await main( + ['worktree', 'create', '--repo', 'id:repo-1', '--name', 'child', '--no-parent'], + '/tmp/repo' + ) + + expect(errSpy.mock.calls.flat().join('\n')).toBe( + 'Setup decision required for this repository\n' + + 'Next step: Pass --setup run to run the setup script, or --setup skip to create without it.' + ) + expect(process.exitCode).toBe(1) + process.exitCode = priorExitCode + }) }) diff --git a/src/cli/skills.test.ts b/src/cli/skills.test.ts index b30ddc100d5c..19b26037d923 100644 --- a/src/cli/skills.test.ts +++ b/src/cli/skills.test.ts @@ -794,7 +794,7 @@ describe('orca skills CLI', () => { it('maps detected agents onto the skills CLI namespace, not Orca ids', async () => { const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) - detectCommandsMock.mockReturnValue(new Set(['claude', 'cursor-agent', 'rovo'])) + detectCommandsMock.mockReturnValue(new Set(['claude', 'cursor-agent', 'acli'])) await main(['skills', 'install', '--skill', 'alpha', '--dry-run'], '/tmp/repo') diff --git a/src/main/acp/acp-dialects/acp-dialect.ts b/src/main/acp/acp-dialects/acp-dialect.ts index 3b38b9cc1bdd..596ddcb4049a 100644 --- a/src/main/acp/acp-dialects/acp-dialect.ts +++ b/src/main/acp/acp-dialects/acp-dialect.ts @@ -11,6 +11,10 @@ export type AcpRequestPresentation = { reply(response: AgentSessionPromptResponse | null): unknown } +/** A request the client answers at once, asking no one: the reply, and a plan it carried for the + * chat's existing plan row. */ +export type AcpRequestSettlement = { reply: unknown; plan?: string } + export type AcpBackgroundTaskUpdate = Pick & Partial> & { /** Used only until a provider description is known. */ @@ -45,6 +49,8 @@ export type AcpDialect = { notification?(method: string, params: unknown, at: number): AcpDialectNotification | undefined contextWindow?(models: unknown): number | undefined request?(method: string, params: unknown): AcpRequestPresentation | undefined + /** Requests answered at once instead of shown to the person. */ + settleRequest?(method: string, params: unknown): AcpRequestSettlement | undefined /** The provider's words in a `session/prompt` error answer, when its message is generic. */ promptErrorDetail?(error: AcpAgentError): string | undefined /** The row for a failed turn the provider gave no words for. */ diff --git a/src/main/acp/acp-dialects/grok-dialect.ts b/src/main/acp/acp-dialects/grok-dialect.ts index 09213c973894..609a9c313c5e 100644 --- a/src/main/acp/acp-dialects/grok-dialect.ts +++ b/src/main/acp/acp-dialects/grok-dialect.ts @@ -1,7 +1,7 @@ import { z } from 'zod' import { acpNotificationEnvelopeSchema } from '../acp-context-usage' import type { AcpDialect, AcpDialectNotification } from './acp-dialect' -import { grokRequest } from './grok-requests' +import { grokRequest, grokSettleRequest } from './grok-requests' import { grokBackgroundTaskNotification, grokToolBackgroundTasks } from './grok-background-tasks' const tokenCount = z.number().int().nonnegative() @@ -173,6 +173,7 @@ export const GROK_ACP_DIALECT: AcpDialect = { return parsed.success ? parsed.data['x.ai/tool'].name : undefined }, request: grokRequest, + settleRequest: grokSettleRequest, toolBackgroundTasks: grokToolBackgroundTasks, notification, contextWindow, diff --git a/src/main/acp/acp-dialects/grok-requests.ts b/src/main/acp/acp-dialects/grok-requests.ts index 26dbee0fae25..b86788120616 100644 --- a/src/main/acp/acp-dialects/grok-requests.ts +++ b/src/main/acp/acp-dialects/grok-requests.ts @@ -5,7 +5,7 @@ import { } from '../../../shared/agent-session-question-answer' import { AcpRpcError } from '../acp-errors' import { pendingAcpResolution } from '../acp-timeline-requests' -import type { AcpRequestPresentation } from './acp-dialect' +import type { AcpRequestPresentation, AcpRequestSettlement } from './acp-dialect' const questionRequestSchema = z.object({ sessionId: z.string(), @@ -98,47 +98,35 @@ function questionRequest(params: unknown): AcpRequestPresentation { } } -function planRequest(params: unknown): AcpRequestPresentation { +/** What Grok hears once Orca has shown its plan: no approval is given for the person. */ +const PLAN_SHOWN_FEEDBACK = + 'Orca showed your plan to the user. Stop here and wait for their feedback or a request to implement it in a later turn.' + +/** Grok's plan-mode exit: its plan goes to the chat's plan row and the gate is answered at once + * (`abandoned`), so the turn ends instead of waiting on an approval card. */ +function settlePlanRequest(params: unknown): AcpRequestSettlement { const parsed = planRequestSchema.safeParse(params) if (!parsed.success) { - throw new AcpRpcError(-32602, 'Invalid agent plan approval request') + throw new AcpRpcError(-32602, 'Invalid agent plan request') } return { - body: { - kind: 'approval', - title: 'Approve plan', - detail: null, - subject: { - kind: 'plan', - text: parsed.data.planContent ?? 'The agent exited plan mode without writing a plan.' - }, - options: [ - { id: 'approved', label: 'Approve plan' }, - { id: 'request_changes', label: 'Request changes' } - ], - resolution: pendingAcpResolution - }, - reply: (response) => { - if (response === null) { - return { outcome: 'abandoned' } - } - if ( - response.kind !== 'option' || - !['approved', 'request_changes'].includes(response.optionId) - ) { - throw new AcpRpcError(-32602, 'Plan answer must select an offered option') - } - return { outcome: response.optionId } - } + reply: { outcome: 'abandoned', feedback: PLAN_SHOWN_FEEDBACK }, + plan: parsed.data.planContent?.trim() || 'The agent exited plan mode without writing a plan.' } } +export function grokSettleRequest( + method: string, + params: unknown +): AcpRequestSettlement | undefined { + return ['_x.ai/exit_plan_mode', 'x.ai/exit_plan_mode'].includes(method) + ? settlePlanRequest(params) + : undefined +} + export function grokRequest(method: string, params: unknown): AcpRequestPresentation | undefined { if (['_x.ai/ask_user_question', 'x.ai/ask_user_question'].includes(method)) { return questionRequest(params) } - if (['_x.ai/exit_plan_mode', 'x.ai/exit_plan_mode'].includes(method)) { - return planRequest(params) - } return undefined } diff --git a/src/main/acp/acp-launch-specs.ts b/src/main/acp/acp-launch-specs.ts new file mode 100644 index 000000000000..2b705aa0dd7f --- /dev/null +++ b/src/main/acp/acp-launch-specs.ts @@ -0,0 +1,76 @@ +// How Orca starts each ACP agent, as data: adding an agent is one more row (plus a dialect when it +// speaks protocol extensions). Nothing outside `acp-dialects/` branches on an agent's name. + +import { join } from 'node:path' +import { + ORCA_SCRUB_SAFE_LAUNCH_ENV, + ORCA_SCRUB_SAFE_PANE_ENV +} from '../../shared/agent-hook-scrub-safe-env' +import { AGENT_HOOK_RUNTIME_ENV_KEYS } from '../ipc/pty/host-env/spawn-env-keys' +import type { AcpDialect } from './acp-dialects/acp-dialect' +import { GROK_ACP_DIALECT } from './acp-dialects/grok-dialect' + +export type AcpLaunchSpec = { + /** The Orca agent id (a `TuiAgent`), which names the agent's records and its catalog label. */ + agent: string + command: string + /** Built per launch: `fullAccess` is the Agent Permissions setting's bypass posture. */ + args(input: { fullAccess: boolean }): string[] + /** Overlaid on the child's environment. */ + env: Readonly> + dialect: AcpDialect + /** The agent's own sign-in command, for a person to run when it reports auth required. */ + loginCommand: readonly string[] + /** Which advertised sign-in method to use when the agent reports auth required, read from the + * environment it was launched with (on its own machine); none leaves it not signed in. */ + authMethod?(input: { + advertised: readonly string[] + env: Readonly> + }): string | undefined + /** Variable naming the agent's config directory, pinned as each record's account home. */ + accountHomeVariable: string + /** The config directory the agent uses when the variable is unset, under the user's home. */ + defaultAccountHome(homePath: string): string + /** Where the agent installs its own binary, searched after PATH. */ + installDirectories(accountHomePath: string): string[] +} + +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'], + env: {}, + dialect: GROK_ACP_DIALECT, + loginCommand: ['grok', 'login'], + // An API key in Grok's own environment, else the sign-in Grok already cached; never interactive. + authMethod: ({ advertised, env }) => + env.XAI_API_KEY?.trim() && advertised.includes('xai.api_key') + ? 'xai.api_key' + : advertised.includes('cached_token') + ? 'cached_token' + : undefined, + accountHomeVariable: 'GROK_HOME', + defaultAccountHome: (homePath) => join(homePath, '.grok'), + installDirectories: (accountHomePath) => [join(accountHomePath, 'bin')] +} + +export const ACP_LAUNCH_SPECS: readonly AcpLaunchSpec[] = [GROK_LAUNCH_SPEC] + +export function acpLaunchSpecFor(agent: string): AcpLaunchSpec | null { + return ACP_LAUNCH_SPECS.find((spec) => spec.agent === agent) ?? null +} + +/** + * A pane's identity in the inherited environment would let the agent's own Orca status hooks + * report for this session too; the structured session is its one status producer. + */ +export const ACP_CHILD_ENV_TO_DELETE: readonly string[] = [ + 'ORCA_PANE_KEY', + 'ORCA_TAB_ID', + 'ORCA_WORKTREE_ID', + 'ORCA_AGENT_LAUNCH_TOKEN', + ORCA_SCRUB_SAFE_PANE_ENV, + ORCA_SCRUB_SAFE_LAUNCH_ENV, + ...AGENT_HOOK_RUNTIME_ENV_KEYS +] diff --git a/src/main/acp/acp-prompt-turns.ts b/src/main/acp/acp-prompt-turns.ts index c9fe584dcabe..257ffc68523d 100644 --- a/src/main/acp/acp-prompt-turns.ts +++ b/src/main/acp/acp-prompt-turns.ts @@ -33,6 +33,15 @@ export class AcpPromptTurns { this.current = undefined } + /** Forgets a prompt the agent refused before its turn opened; false for any other. */ + refuse(clientMessageId: string): boolean { + if (this.current?.clientMessageId !== clientMessageId || this.current.opened) { + return false + } + this.current = undefined + return true + } + start(turn: string, at: number): ProviderTimelineEvent[] { const prompt = this.current if (prompt?.turn !== turn || prompt.opened) { diff --git a/src/main/acp/acp-session-reopen-failure.ts b/src/main/acp/acp-session-reopen-failure.ts new file mode 100644 index 000000000000..90c7442d24b6 --- /dev/null +++ b/src/main/acp/acp-session-reopen-failure.ts @@ -0,0 +1,65 @@ +// What takes over when an ACP agent cannot reopen the session a chat proved. A session this chat +// created and never exchanged a turn on, which the agent says it does not hold, is superseded: +// there was nothing to remember. Any other is replaced by a new session that names it, and the +// chat says once that the agent forgot: the row names the lost conversation, so a later start +// can tell whether it was ever written. + +import { agentSessionFailureFact } from '../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../shared/agent-session-failure-words' +import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' +import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' +import { AcpAuthRequiredError, AcpRpcError } from './acp-errors' +import type { AcpStructuredLaunch } from './acp-structured-launch-resolution' + +/** ACP's "resource not found": the agent holds no session under the id this chat proved. */ +const ACP_RESOURCE_NOT_FOUND = -32002 +export const ACP_REOPEN_FAILED = 'ACP agent could not reopen its saved session; starting a new one' + +/** The fields the new session's link carries; rethrows `error` when the start must fail instead. */ +export function acpReopenTakeover( + error: unknown, + resume: NonNullable, + start: { over: boolean; now: number; warn: (fields: { scope: string; error: unknown }) => void } +): Pick { + // Signed out, or the start is over (Close, Stop, a lost agent): a new session would not help. + if (error instanceof AcpAuthRequiredError || start.over) { + throw error + } + if ( + error instanceof AcpRpcError && + error.code === ACP_RESOURCE_NOT_FOUND && + resume.mayBeUnsaved() + ) { + return { supersedesKey: resume.key } + } + start.warn({ scope: 'acp-restore-failed', error }) + return { replaces: { key: resume.key, reason: 'restore-failed', replacedAt: start.now } } +} + +/** The provider item key of the row saying the conversation under chain key `lostKey` was lost. */ +export function acpSessionNotRestoredItem(lostKey: string): string { + return `session-not-restored:${lostKey}` +} + +/** The one row a lost conversation gets: the agent no longer remembers what came before. */ +export function acpSessionNotRestoredRow( + lostKey: string, + providerSessionId: string, + agentName: string +): ProviderTimelineEvent[] { + return [ + { + type: 'item.update', + item: acpSessionNotRestoredItem(lostKey), + body: { + kind: 'status', + tone: 'warning', + ...agentSessionFailureWords(agentSessionFailureFact('sessionNotRestored'), { + surface: 'row', + agentName + }) + }, + join: { thread: providerSessionId } + } + ] +} diff --git a/src/main/acp/acp-structured-acquire.ts b/src/main/acp/acp-structured-acquire.ts new file mode 100644 index 000000000000..cf7466f27d8f --- /dev/null +++ b/src/main/acp/acp-structured-acquire.ts @@ -0,0 +1,330 @@ +// Making a reservation real for an ACP agent: open its connection (which spawns and owns the +// process), record it before any handshake, initialize with the client's file system and terminals +// off, then reattach the session this chat proved with `session/load` (`session/resume` only for an +// agent that cannot load) or start a new one. A saved session the agent cannot reopen is replaced +// by a new one, with a warning row that any later start writes if this attach never did. The +// journal already holds a reattached chat, so whatever the agent sends while it reattaches is not +// written, except context usage. The handshake has no time bound: the acquire's abort signal +// (Close, Stop, quit) stops it at any point. + +import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' +import { TUI_AGENT_DISPLAY_NAMES } from '../../shared/tui-agent-display-names' +import { isTuiAgent } from '../../shared/tui-agent-config' +import { + AgentSessionAcquisitionRefusal, + AgentSessionPreSpawnError, + type AgentSessionAcquisition, + type StructuredAgentSessionAcquireInput +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { providerTimelineSink } from '../native-chat/agent-session-timeline/provider-timeline-plan' +import { + providerSpawnedProcessIdentity, + PROVIDER_SPAWN_TOKEN_ENV +} from '../provider-process/provider-spawned-process-identity' +import { structuredSessionChildIdentityEnv } from '../runtime/structured-session-child-identity-env' +import { AcpAuthRequiredError } from './acp-errors' +import { ACP_CHILD_ENV_TO_DELETE } from './acp-launch-specs' +import { + ACP_REOPEN_FAILED, + acpReopenTakeover, + acpSessionNotRestoredRow +} from './acp-session-reopen-failure' +import type { AcpSessionEvent } from './acp-session-runtime' +import type { AcpStructuredConnection } from './acp-structured-connection' +import { ACP_HANDLE_TRANSPORT } from './acp-structured-agent-definitions' +import { AcpStructuredLane } from './acp-structured-lane' +import type { AcpStructuredLaunch } from './acp-structured-launch-resolution' +import { AcpStructuredOptions, restoreAcpSessionOptions } from './acp-structured-options' +import { AcpStructuredPrompts } from './acp-structured-prompts' +import { + asReattachHistory, + routeAcpSessionEvent, + type AcpStructuredSession +} from './acp-structured-session' +import type { AcpStructuredSessionAdapterDeps } from './acp-structured-session-adapter-deps' +import { AcpStructuredTurns, type AcpStructuredTurnsDeps } from './acp-structured-turns' +import { RequestPermissionResponseSchema } from './generated/acp-protocol.generated' + +/** Frames an agent may send before its session exists; past this they are dropped. */ +const MAX_EARLY_FRAMES = 2_048 +export function acpAgentName(agent: string): string { + return isTuiAgent(agent) ? TUI_AGENT_DISPLAY_NAMES[agent] : agent +} + +export async function acquireAcpStructuredSession(input: { + acquire: StructuredAgentSessionAcquireInput + deps: AcpStructuredSessionAdapterDeps + generation: string + /** The acquire was aborted; checked until the spawn, after which `track` hands it the connection. */ + abandoned: () => boolean + /** Registers the connection so a close during the acquire can stop it. */ + track: (connection: AcpStructuredConnection) => void + /** The process's exit, observed while or after the session exists. */ + onExit: (session: AcpStructuredSession | null) => void + /** The protocol broke with the process perhaps still running. Null while starting: then the + * start itself fails. */ + onConnectionLost: (session: AcpStructuredSession | null, error: Error) => void + onSettled: AcpStructuredTurnsDeps['settle'] + forceClose: (sessionId: string) => void +}): Promise<{ acquisition: AgentSessionAcquisition; session: AcpStructuredSession }> { + const { acquire, deps, generation } = input + const { spec } = deps + const sessionId = acquire.identity.sessionId + const now = deps.now ?? Date.now + const events = acquire.events + const sink = events ? providerTimelineSink(events) : null + if (!sink) { + throw new AgentSessionPreSpawnError(new Error(`${spec.agent} chats need a journal sink`)) + } + const closedBeforeSpawn = () => { + if (input.abandoned()) { + throw new AgentSessionPreSpawnError( + new Error(`${acpAgentName(spec.agent)} was closed before it started`) + ) + } + } + closedBeforeSpawn() + const launch: AcpStructuredLaunch = await deps + .resolveLaunch({ identity: acquire.identity }) + .catch((error: unknown) => { + throw new AgentSessionPreSpawnError(error) + }) + closedBeforeSpawn() + let session: AcpStructuredSession | null = null + // A slot rather than a `let`: closures read it, and control-flow narrowing cannot see them write. + const slot: { lane: AcpStructuredLane | null; reattaching: boolean } = { + lane: null, + reattaching: false + } + const options = new AcpStructuredOptions() + // Only Orca's own prompt may ask the person, as with permissions: a turn the agent began itself + // has nobody waiting on it. What a settled request carries still shows in that turn. + const prompts = new AcpStructuredPrompts( + () => slot.lane, + () => session?.turns.acceptsRequests === true, + () => + session !== null && + (session.turns.acceptsRequests || + (!session.turns.running && !session.turns.stopped && session.lane.openTurnId !== null)) + ) + const early: (() => void)[] = [] + const whenLane = (deliver: () => void): void => { + if (slot.lane) { + deliver() + } else if (early.length < MAX_EARLY_FRAMES) { + early.push(deliver) + } + } + const connection = deps.connect( + { + command: launch.command, + args: launch.args, + cwd: launch.cwd, + env: { + ...structuredSessionChildIdentityEnv(sessionId, launch.env), + [PROVIDER_SPAWN_TOKEN_ENV]: acquire.spawnToken + }, + envToDelete: ACP_CHILD_ENV_TO_DELETE + }, + { + clientInfo: { name: 'orca', version: '1' }, + 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 + // is cutting it short: nobody is there to ask. + return { outcome: { outcome: 'cancelled' } } + } + if (launch.fullAccess) { + // Full access: Orca answers yes for the person, as the agent's own bypass flag would. + const allow = request.options.find((option) => option.kind === 'allow_once') + if (allow) { + return { outcome: { outcome: 'selected', optionId: allow.optionId } } + } + } + return prompts + .handle('session/request_permission', request, context) + .then((reply) => RequestPermissionResponseSchema.parse(reply)) + }, + onRequest: (method, params, context) => prompts.handle(method, params, context), + onExtensionNotification: (method, params) => + whenLane(() => + slot.lane?.apply( + slot.lane.translator.notification( + method, + slot.reattaching ? asReattachHistory(params) : params, + now() + ) + ) + ), + onDiagnostic: (message) => + deps.logger?.warn('ACP agent protocol diagnostic', { + scope: 'acp-diagnostic', + sessionId, + message + }), + // The protocol broke while the process may still run; the connection is already ending it. + // Its stdout ending alone is not this: the agent may still answer Stop's cancel or exit. + onClose: (error) => input.onConnectionLost(session, error), + // A crash usually ends stdout first; the session's end still reads the agent's last words at + // this proven exit. + onExit: () => input.onExit(session) + } + ) + // From here an abort closes this connection (the start's one canceller): whatever the agent left + // unanswered fails at once and the process ends, whether or not its exit is proven yet. + input.track(connection) + connection.subscribe((event: AcpSessionEvent) => + whenLane( + () => + slot.lane && + routeAcpSessionEvent({ lane: slot.lane, options }, event, now(), slot.reattaching) + ) + ) + const identity = providerSpawnedProcessIdentity( + acquire, + `${spec.agent} ACP agent`, + deps.readProcessStartTime + ) + /** `attaching`: the lane opens inside the attach window, before any frame queued so far. */ + const makeLane = (providerSessionId: string, attaching = false): AcpStructuredLane => { + const lane = new AcpStructuredLane({ + sink, + sessionId, + agent: spec.agent, + agentName: acpAgentName(spec.agent), + generation, + providerSessionId, + dialect: spec.dialect, + onInputAccepted: (clientMessageId) => session?.turns.accept(clientMessageId), + onFailed: () => input.forceClose(sessionId) + }) + slot.lane = lane + slot.reattaching = attaching + if (attaching) { + lane.translator.beginLoad() + } + for (const deliver of early.splice(0)) { + deliver() + } + return lane + } + await connection.spawned + const pid = connection.pid + if (pid === undefined) { + throw new AgentSessionPreSpawnError( + new Error(connection.stderrTail() || `${launch.command} could not be started`) + ) + } + await identity.onSpawned(pid) + const agentName = acpAgentName(spec.agent) + try { + const initialized = await connection.initialize() + // Chosen here, on the machine Grok runs on, from the environment it was launched with. + const authMethodId = spec.authMethod?.({ + advertised: (initialized.authMethods ?? []).map((method) => method.id), + env: launch.env + }) + const auth = authMethodId === undefined ? {} : { authMethodId } + const resume = launch.resume + let started: Awaited> | null = null + let liveLane: AcpStructuredLane | null = null + let takeover: ReturnType | null = null + if (resume) { + const attaching = makeLane(resume.sessionId, true) + liveLane = attaching + try { + started = await connection.start({ + cwd: launch.cwd, + mcpServers: [], + sessionId: resume.sessionId, + ...auth + }) + slot.reattaching = false + attaching.translator.finishLoad() + } catch (error) { + takeover = acpReopenTakeover(error, resume, { + over: connection.closed || acquire.signal?.aborted === true, + now: now(), + warn: (fields) => deps.logger?.warn(ACP_REOPEN_FAILED, { ...fields, sessionId }) + }) + // A new session takes the old one's place, with a new lane, so nothing of the failed + // attach's window outlives it. + } + } + if (!started || !liveLane) { + liveLane?.dispose() + slot.lane = null + started = await connection.start({ cwd: launch.cwd, mcpServers: [], ...auth }) + liveLane = makeLane(started.sessionId) + } + options.adoptSession(started.response) + liveLane.apply(liveLane.translator.contextModels(started.response.models, now())) + const restoreSkipped = await restoreAcpSessionOptions(connection, options, acquire.options) + const process = await identity.read(pid) + const link: AgentSessionProviderHandleLink = { + linkId: + deps.mintLinkId?.() ?? `${spec.agent}-${acquire.fence}-${started.sessionId}`.slice(0, 128), + handle: { transport: ACP_HANDLE_TRANSPORT, agent: spec.agent, nativeId: started.sessionId }, + origin: started.kind === 'new' ? 'created' : 'resumed', + mintedAtFence: acquire.fence, + observedAt: now(), + ...takeover + } + session = { + sessionId, + fence: acquire.fence, + acquisitionGeneration: generation, + spec, + connection, + lane: liveLane, + prompts, + options, + turns: new AcpStructuredTurns({ + connection, + withdrawRequests: () => prompts.withdrawAll(), + lane: liveLane, + agentName, + now, + settle: input.onSettled + }), + restoreSkipped, + closeRequested: false, + journalClosed: null, + ended: false, + exitObservedAt: null + } + const reading = liveLane + const unbind = events?.bindReadingControl?.({ + pauseReading: () => connection.pauseReading(), + resumeReading: () => { + connection.resumeReading() + reading.retry() + } + }) + if (unbind) { + session.unbindReadingControl = unbind + } + if (connection.exited || connection.closed) { + throw new Error(connection.stderrTail() || `${spec.command} exited while starting`) + } + // The chat says once per lost conversation that the agent forgot it, this start's loss included. + const lost = [ + ...(launch.resume?.unannouncedLosses() ?? []), + ...(takeover?.replaces ? [takeover.replaces.key] : []) + ] + for (const key of lost) { + liveLane.apply(acpSessionNotRestoredRow(key, started.sessionId, agentName)) + } + return { acquisition: { process, link, acquisitionGeneration: generation }, session } + } catch (error) { + session = null + slot.lane?.dispose() + if (error instanceof AcpAuthRequiredError) { + throw new AgentSessionAcquisitionRefusal( + `${spec.agent} reported that it is not signed in: ${error.message}`, + 'notSignedIn' + ) + } + throw error + } +} diff --git a/src/main/acp/acp-structured-adapter.test-support.ts b/src/main/acp/acp-structured-adapter.test-support.ts new file mode 100644 index 000000000000..e39ade6c7b97 --- /dev/null +++ b/src/main/acp/acp-structured-adapter.test-support.ts @@ -0,0 +1,296 @@ +// A scripted ACP agent behind the real adapter: a fake connection over in-memory stdio, the real +// protocol runtime, translator and assembler, and a real on-disk journal to read back. + +import { vi } from 'vitest' +import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' +import type { ProviderProcessExit } from '../provider-process/managed-provider-process' +import type { ProviderProcessLaunch } from '../provider-process/provider-process-launch' +import type { StructuredAgentSessionLifecycleEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { + openProviderTimelineRig, + SESSION, + type ProviderTimelineRig +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { acpLaunchSpecFor } from './acp-launch-specs' +import { AcpScriptedAgent, tick, type FakeFrame } from './acp-scripted-agent.test-support' +import type { AcpAgentConnectionOptions } from './acp-agent-connection' +import { AcpConnectionClosedError } from './acp-errors' +import { AcpSessionRuntime } from './acp-session-runtime' +import type { AcpStructuredConnection } from './acp-structured-connection' +import type { AcpStructuredLaunch } from './acp-structured-launch-resolution' +import { AcpStructuredSessionAdapter } from './acp-structured-session-adapter' +import type { AcpStructuredSessionAdapterDeps } from './acp-structured-session-adapter-deps' + +export const GROK = acpLaunchSpecFor('grok')! +export const PROVIDER_SESSION = 'acp-session-1' +export const PID = 4242 + +export const GROK_CONFIG_OPTIONS = [ + { + id: 'model', + name: 'Model', + category: 'model', + type: 'select', + currentValue: 'grok-4.7', + options: [ + { value: 'grok-4.7', name: 'Grok 4.7' }, + { value: 'grok-4.6', name: 'Grok 4.6' } + ] + }, + { + id: 'reasoning_effort', + name: 'Reasoning Effort', + category: 'thought_level', + type: 'select', + currentValue: 'high', + options: [ + { value: 'high', name: 'High' }, + { value: 'low', name: 'Low' } + ] + } +] + +/** A scripted agent behind the connection surface: the real protocol runtime over in-memory stdio, + * with the process lifecycle `AcpAgentConnection` gives it (stdout EOF is not exit; the exit closes + * the protocol with the agent's last words; a protocol failure while it runs is `onClose`). */ +export class FakeAcpChild extends AcpSessionRuntime implements AcpStructuredConnection { + readonly agent: AcpScriptedAgent + readonly pid: number | undefined = PID + readonly spawned = Promise.resolve() + stderr = '' + processTreeUnproven = false + private exitListeners: ((exit: ProviderProcessExit) => void)[] = [] + private gone = false + private closing = false + private lostWith: Error | undefined + closes = 0 + + constructor( + readonly launch: ProviderProcessLaunch, + private readonly connectionOptions: AcpAgentConnectionOptions, + agent = new AcpScriptedAgent() + ) { + const lifecycle: { child: FakeAcpChild | null } = { child: null } + super(agent.stdout, agent.stdin, { + ...connectionOptions, + peer: { ...connectionOptions.peer, closeOnInputEnd: false }, + onClose: (error) => lifecycle.child?.lost(error) + }) + lifecycle.child = this + this.agent = agent + } + + get exited() { + return this.gone + } + onExit(listener: (exit: ProviderProcessExit) => void): void { + if (this.gone) { + listener(FAKE_EXIT) + } else { + this.exitListeners.push(listener) + } + } + stderrTail(): string { + return this.stderr + } + pauseReading(): void { + this.agent.stdout.pause() + } + resumeReading(): void { + this.agent.stdout.resume() + } + /** What a close proves once the protocol is closed; replace it to leave the exit unproven. */ + proveClose = async (): Promise => { + this.exit() + return true + } + override close(error?: Error): Promise { + this.closes += 1 + this.closing ||= !this.gone + super.close(error) + return this.proveClose() + } + /** The agent process ends on its own (or Orca's close landed). */ + exit(): void { + if (this.gone) { + return + } + this.gone = true + const error = + this.lostWith ?? new AcpConnectionClosedError(this.stderr || `${this.launch.command} exited`) + super.close(error) + this.connectionOptions.onExit?.(error, { expected: this.closing, exit: FAKE_EXIT }) + for (const listener of this.exitListeners.splice(0)) { + listener(FAKE_EXIT) + } + } + private lost(error: Error): void { + this.lostWith ??= error + if (this.closing || this.gone) { + return + } + this.connectionOptions.onClose?.(error) + } +} + +const FAKE_EXIT: ProviderProcessExit = { code: 0, signal: null, processless: false } + +export type AcpAdapterRig = { + rig: ProviderTimelineRig + adapter: AcpStructuredSessionAdapter + child: () => FakeAcpChild + spawned: string[] + lifecycle: StructuredAgentSessionLifecycleEvent[] + settled: Parameters>[0][] + acquire(options?: { + fence?: number + onSpawned?: () => Promise + signal?: AbortSignal + }): ReturnType + /** Frames Orca wrote to the agent with this method. */ + sent(method: string): FakeFrame[] + /** Waits for the `index`th frame Orca writes with this method. */ + frame(method: string, index?: number): Promise + settle(): Promise +} + +export async function openAcpAdapterRig( + options: { + launch?: Partial + initialize?: Record + script?: (agent: AcpScriptedAgent) => void + deps?: Partial + } = {} +): Promise { + const rig = await openProviderTimelineRig() + let current: FakeAcpChild | null = null + const spawned: string[] = [] + const lifecycle: StructuredAgentSessionLifecycleEvent[] = [] + const settled: AcpAdapterRig['settled'] = [] + const adapter = new AcpStructuredSessionAdapter({ + spec: GROK, + resolveLaunch: async () => ({ + spec: GROK, + command: '/opt/grok/bin/grok', + args: GROK.args({ fullAccess: false }), + cwd: '/workspace/project', + env: { PATH: '/usr/bin', ORCA_PANE_KEY: 'tab-1:pane-1', ORCA_AGENT_HOOK_PORT: '1234' }, + fullAccess: false, + resume: null, + ...options.launch + }), + connect: (launch, connectionOptions) => { + spawned.push('spawn') + const child = new FakeAcpChild(launch, connectionOptions) + current = child + const { agent } = child + agent.on('initialize', (frame) => { + spawned.push('initialize') + agent.reply(frame, { + protocolVersion: 1, + agentCapabilities: { loadSession: true }, + ...options.initialize + }) + }) + const opened = { sessionId: PROVIDER_SESSION, configOptions: GROK_CONFIG_OPTIONS } + agent.on('session/new', (frame) => agent.reply(frame, opened)) + agent.on('session/load', (frame) => + agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS }) + ) + options.script?.(agent) + return child + }, + readProcessStartTime: async () => 1_700_000_000_000, + onEvent: (event) => lifecycle.push(event), + onDispatchSettledLate: (settlement) => settled.push(settlement), + mintGeneration: () => 'gen-acp', + now: () => 5_000, + ...options.deps + }) + const frames = () => current?.agent.frames ?? [] + return { + rig, + adapter, + child: () => { + if (!current) { + throw new Error('no ACP child spawned') + } + return current + }, + spawned, + lifecycle, + settled, + acquire: (acquireOptions = {}) => + adapter.acquire({ + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'grok', + providerHandle: null + }, + fence: acquireOptions.fence ?? 1, + spawnToken: 'spawn-1', + events: rig.eventSink, + ...(acquireOptions.signal ? { signal: acquireOptions.signal } : {}), + onSpawned: async () => { + spawned.push('onSpawned') + await acquireOptions.onSpawned?.() + } + }), + sent: (method) => frames().filter((frame) => frame.method === method), + frame: (method, index = 0) => + waitFor(() => { + const frame = frames().filter((entry) => entry.method === method)[index] + if (!frame) { + throw new Error(`no ${method} frame #${index} yet`) + } + return frame + }), + settle: async () => { + for (let round = 0; round < 5; round += 1) { + await tick() + } + await rig.rows() + } + } +} + +const HELLO: AgentJournalMessageItem = { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'hello' }] +} + +/** A person's send of `hello` under `clientMessageId`. */ +export function sendHello(rig: AcpAdapterRig, clientMessageId: string, fence = 1) { + return rig.adapter.dispatch({ sessionId: SESSION, clientMessageId, body: HELLO, fence }) +} + +/** One streamed reply chunk of the turn Grok runs under `promptId`. */ +export function replyChunk(promptId: string, text: string, meta: Record = {}) { + return { + sessionId: PROVIDER_SESSION, + update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text } }, + _meta: { promptId, ...meta } + } +} + +/** A shell command Grok runs inside the turn it runs under `promptId`. */ +export function shellCall(promptId: string, status: 'in_progress' | 'completed') { + return { + sessionId: PROVIDER_SESSION, + update: { + sessionUpdate: status === 'in_progress' ? 'tool_call' : 'tool_call_update', + toolCallId: 'sleep-1', + title: 'sleep 25; echo first-done', + kind: 'execute', + status + }, + _meta: { promptId } + } +} + +export function waitFor(assertion: () => T | Promise): Promise { + return vi.waitFor(assertion, { timeout: 2_000, interval: 5 }) +} diff --git a/src/main/acp/acp-structured-agent-definitions.ts b/src/main/acp/acp-structured-agent-definitions.ts new file mode 100644 index 000000000000..d51816db450e --- /dev/null +++ b/src/main/acp/acp-structured-agent-definitions.ts @@ -0,0 +1,39 @@ +import type { StructuredAgentDefinition } from '../native-chat/agent-session-wire/structured-agent-definition' +import type { AcpLaunchSpec } from './acp-launch-specs' + +/** Every ACP agent's handles live in one protocol's id space; the agent part keeps them apart. */ +export const ACP_HANDLE_TRANSPORT = 'acp' + +/** Session options an ACP agent takes: its model, and the model's reasoning effort. */ +const ACP_OPTION_KEYS: ReadonlySet = new Set(['model', 'effort']) + +export function isAcpStructuredOptionKey(key: string): boolean { + return ACP_OPTION_KEYS.has(key) +} + +/** What the host knows about an ACP agent before any of its sessions runs. */ +export function acpStructuredAgentDefinition(spec: AcpLaunchSpec): StructuredAgentDefinition { + return { + agent: spec.agent, + handleTransport: ACP_HANDLE_TRANSPORT, + accountHomeVariable: spec.accountHomeVariable, + capabilities: { + // ACP has no stable rewind, compact or goal method; a command arrives through `/` instead. + rewind: false, + compact: false, + threadGoal: false, + contextUsage: true, + // Off: Orca sends text prompts only until ACP image prompts have a path. + imagePrompts: false, + steering: 'queue', + // Orca answers every permission request the agent sends; the agent decides when it asks. + approvalEnforcement: 'orca' + }, + restingOptions: { + acceptsKey: isAcpStructuredOptionKey, + // The agent lists its models over the protocol once it runs; Orca keeps no list of its own. + fallbackModels: () => null, + effortDefaultsToModel: false + } + } +} diff --git a/src/main/acp/acp-structured-auth.test.ts b/src/main/acp/acp-structured-auth.test.ts new file mode 100644 index 000000000000..92f27513cd51 --- /dev/null +++ b/src/main/acp/acp-structured-auth.test.ts @@ -0,0 +1,109 @@ +// Grok signs in on the machine it runs on: on auth required, with the API key in its own launch +// environment, else the sign-in it already cached; with neither, the chat reports it signed out. + +import { afterEach, describe, expect, it } from 'vitest' +import { z } from 'zod' +import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { AgentSessionAcquisitionRefusal } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { AcpScriptedAgent } from './acp-scripted-agent.test-support' +import { + GROK_CONFIG_OPTIONS, + openAcpAdapterRig, + PROVIDER_SESSION +} from './acp-structured-adapter.test-support' + +afterEach(async () => { + await closeProviderTimelineRigs() +}) + +const METHOD = z.object({ methodId: z.string() }) + +const BOTH = [ + { id: 'xai.api_key', name: 'API key' }, + { id: 'cached_token', name: 'Cached sign-in' } +] + +/** Grok refuses to open a session until it is authenticated. */ +function requiresSignIn(agent: AcpScriptedAgent): void { + let signedIn = false + agent.on('authenticate', (frame) => { + signedIn = true + agent.reply(frame, {}) + }) + const opens = (method: string, result: unknown) => + agent.on(method, (frame) => + signedIn ? agent.reply(frame, result) : agent.fail(frame, -32000, 'Authentication required') + ) + opens('session/new', { sessionId: PROVIDER_SESSION, configOptions: GROK_CONFIG_OPTIONS }) + opens('session/load', { configOptions: GROK_CONFIG_OPTIONS }) +} + +async function signInWith(options: { + env: Record + authMethods: { id: string; name: string }[] + resume?: boolean +}) { + const rig = await openAcpAdapterRig({ + launch: { + env: options.env, + ...(options.resume + ? { + resume: { + sessionId: PROVIDER_SESSION, + key: 'acp-key', + mayBeUnsaved: () => false, + unannouncedLosses: () => [] + } + } + : {}) + }, + initialize: { authMethods: options.authMethods }, + script: requiresSignIn + }) + const acquired = await rig.acquire().catch((error: unknown) => error) + const methods = rig.sent('authenticate').map((frame) => METHOD.parse(frame.params).methodId) + return { rig, acquired, methods } +} + +describe('Grok sign-in on its own machine', () => { + it("uses the API key in Grok's launch environment when Grok offers that method", async () => { + const { acquired, methods } = await signInWith({ + env: { XAI_API_KEY: 'xai-1' }, + authMethods: BOTH + }) + expect(methods).toEqual(['xai.api_key']) + expect(acquired).toMatchObject({ link: { origin: 'created' } }) + }) + + it('uses the cached sign-in without a key, or when Grok does not offer the key method', async () => { + const noKey = await signInWith({ env: {}, authMethods: BOTH }) + expect(noKey.methods).toEqual(['cached_token']) + expect(noKey.acquired).toMatchObject({ link: { origin: 'created' } }) + const keyNotOffered = await signInWith({ + env: { XAI_API_KEY: 'xai-1' }, + authMethods: [{ id: 'cached_token', name: 'Cached sign-in' }] + }) + expect(keyNotOffered.methods).toEqual(['cached_token']) + }) + + it('reports Grok signed out when it offers neither, sending no sign-in', async () => { + const { acquired, methods } = await signInWith({ + env: { XAI_API_KEY: 'xai-1' }, + authMethods: [{ id: 'browser', name: 'Browser' }] + }) + expect(methods).toEqual([]) + expect(acquired).toBeInstanceOf(AgentSessionAcquisitionRefusal) + expect(acquired).toMatchObject({ reason: 'notSignedIn' }) + }) + + it('signs in the same way when it reopens a saved session', async () => { + const { rig, acquired, methods } = await signInWith({ + env: { XAI_API_KEY: 'xai-1' }, + authMethods: BOTH, + resume: true + }) + expect(methods).toEqual(['xai.api_key']) + expect(acquired).toMatchObject({ link: { origin: 'resumed' } }) + expect(rig.sent('session/new')).toEqual([]) + }) +}) diff --git a/src/main/acp/acp-structured-broken-stream.test.ts b/src/main/acp/acp-structured-broken-stream.test.ts new file mode 100644 index 000000000000..5b2d6bf1e275 --- /dev/null +++ b/src/main/acp/acp-structured-broken-stream.test.ts @@ -0,0 +1,162 @@ +// Real children (plain Node, never an agent CLI) behind the real connection. One stops talking +// mid-turn by closing its stdout while the process keeps running: nothing exited and Orca can still +// write to it, so, as in the common pattern, the turn runs on until a Stop or close ends the agent. +// The other exits while a process it started still holds its stdout open: the exit alone ends it. + +import { mkdtempSync, readFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import { + closeProviderTimelineRigs, + SESSION +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { + GROK_CONFIG_OPTIONS, + openAcpAdapterRig, + PROVIDER_SESSION, + sendHello +} from './acp-structured-adapter.test-support' +import { createAcpAgentConnection } from './acp-agent-connection' + +afterEach(async () => { + await closeProviderTimelineRigs() +}) + +// Answers the handshake and echoes one reply chunk for the prompt, then runs `onPrompt`. +const agentScript = (onPrompt: string): string => String.raw` + const fs = require('node:fs') + fs.writeFileSync(process.env.ORCA_TEST_PID_FILE, String(process.pid)) + const send = (frame) => process.stdout.write(JSON.stringify({ jsonrpc: '2.0', ...frame }) + '\n') + let buffered = '' + process.stdin.setEncoding('utf8').on('data', (chunk) => { + buffered += chunk + let newline + while ((newline = buffered.indexOf('\n')) !== -1) { + const frame = JSON.parse(buffered.slice(0, newline)) + buffered = buffered.slice(newline + 1) + if (frame.method === 'initialize') { + send({ id: frame.id, result: { protocolVersion: 1, agentCapabilities: {} } }) + } else if (frame.method === 'session/new') { + send({ id: frame.id, result: JSON.parse(process.env.ORCA_TEST_SESSION) }) + } else if (frame.method === 'session/prompt') { + const update = { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'partial' } } + send({ + method: 'session/update', + params: { sessionId: frame.params.sessionId, update, _meta: frame.params._meta } + }) + process.stdout.write('', () => { + ${onPrompt} + }) + } + } + }) + setInterval(() => {}, 60000) +` +const CLOSES_STDOUT_MID_TURN = agentScript('fs.closeSync(1)') +// Starts a process that inherits its stdout and outlives it, records that process, then exits. +const EXITS_HOLDING_STDOUT = agentScript(String.raw` + const held = require('node:child_process').spawn( + process.execPath, + ['-e', 'setInterval(() => {}, 60000)'], + { stdio: ['ignore', 'inherit', 'ignore'] } + ) + fs.writeFileSync(process.env.ORCA_TEST_PID_FILE + '.held', String(held.pid)) + process.exit(3) +`) + +function alive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch { + return false + } +} + +describe('ACP agent that closes its stdout but keeps running', () => { + it('leaves the turn running and the agent alive until Orca closes it', async () => { + const pidFile = join(mkdtempSync(join(tmpdir(), 'orca-acp-broken-stream-')), 'pid') + const rig = await openAcpAdapterRig({ + deps: { + connect: (_launch, options) => + createAcpAgentConnection( + { + command: process.execPath, + args: ['-e', CLOSES_STDOUT_MID_TURN], + cwd: process.cwd(), + env: { + ORCA_TEST_PID_FILE: pidFile, + ORCA_TEST_SESSION: JSON.stringify({ + sessionId: PROVIDER_SESSION, + configOptions: GROK_CONFIG_OPTIONS + }) + } + }, + options + ) + } + }) + onTestFinished(async () => { + await rig.adapter.closeAll().catch(() => {}) + }) + await rig.acquire() + await sendHello(rig, 'broken') + const turns = async () => + (await rig.rig.rows()).flatMap((row) => readAgentJournalTurn(row.body) ?? []) + await vi.waitFor( + async () => expect((await turns()).at(-1)).toMatchObject({ state: 'running' }), + { timeout: 10_000, interval: 20 } + ) + const pid = Number(readFileSync(pidFile, 'utf8')) + // Long enough for a closed stream to have reached the adapter. + await new Promise((resolve) => setTimeout(resolve, 500)) + expect((await turns()).at(-1)).toMatchObject({ state: 'running' }) + expect(rig.lifecycle).toEqual([]) + expect(alive(pid)).toBe(true) + await expect(rig.adapter.closeSession(SESSION)).resolves.toBe(true) + expect(alive(pid)).toBe(false) + expect(rig.lifecycle).toMatchObject([{ type: 'ended', cause: 'requested-close' }]) + }, 30_000) +}) + +describe('ACP agent that exits while a process it started holds its stdout open', () => { + it('ends the session at the exit, with the turn interrupted', async () => { + const pidFile = join(mkdtempSync(join(tmpdir(), 'orca-acp-held-stdout-')), 'pid') + const rig = await openAcpAdapterRig({ + deps: { + connect: (_launch, options) => + createAcpAgentConnection( + { + command: process.execPath, + args: ['-e', EXITS_HOLDING_STDOUT], + cwd: process.cwd(), + env: { + ORCA_TEST_PID_FILE: pidFile, + ORCA_TEST_SESSION: JSON.stringify({ + sessionId: PROVIDER_SESSION, + configOptions: GROK_CONFIG_OPTIONS + }) + } + }, + options + ) + } + }) + onTestFinished(async () => { + await rig.adapter.closeAll().catch(() => {}) + const held = Number(readFileSync(`${pidFile}.held`, 'utf8')) + if (alive(held)) { + process.kill(held) + } + }) + await rig.acquire() + await sendHello(rig, 'held') + await vi.waitFor(() => expect(rig.lifecycle).toHaveLength(1), { timeout: 10_000, interval: 20 }) + expect(rig.lifecycle[0]).toMatchObject({ type: 'ended', cause: 'unexpected-exit' }) + expect(alive(Number(readFileSync(pidFile, 'utf8')))).toBe(false) + const turns = (await rig.rig.rows()).flatMap((row) => readAgentJournalTurn(row.body) ?? []) + expect(turns.at(-1)).toMatchObject({ state: 'interrupted' }) + }, 30_000) +}) diff --git a/src/main/acp/acp-structured-connection.ts b/src/main/acp/acp-structured-connection.ts new file mode 100644 index 000000000000..1ac06b7620d8 --- /dev/null +++ b/src/main/acp/acp-structured-connection.ts @@ -0,0 +1,54 @@ +// The ACP agent as the adapter holds it: one connection that owns both the agent's process and its +// protocol (`createAcpAgentConnection`). Supervision, the process-tree teardown, Windows-safe +// spawning and stdio are the connection's; turns, Stop and steering are the adapter's. + +import type { AcpAgentConnection, AcpAgentConnectionOptions } from './acp-agent-connection' +import type { ProviderProcessLaunch } from '../provider-process/provider-process-launch' + +/** What the adapter uses of a connection; tests stand in a scripted agent behind the same surface. */ +export type AcpStructuredConnection = Pick< + AcpAgentConnection, + | 'initialize' + | 'start' + | 'prompt' + | 'cancel' + | 'setConfigOption' + | 'setModel' + | 'subscribe' + | 'closed' + | 'spawned' + | 'pid' + | 'exited' + | 'stderrTail' + | 'onExit' + | 'pauseReading' + | 'resumeReading' + | 'close' + | 'processTreeUnproven' +> + +export type ConnectAcpAgent = ( + launch: ProviderProcessLaunch, + options: AcpAgentConnectionOptions +) => AcpStructuredConnection + +/** Resolves once `connection`'s process exits, `signal` aborts, or `ms` pass, whichever is first. */ +export function waitForAcpExit( + connection: AcpStructuredConnection, + ms: number, + signal: AbortSignal +): Promise { + if (connection.exited || signal.aborted) { + return Promise.resolve() + } + return new Promise((resolve) => { + const done = (): void => { + clearTimeout(timer) + signal.removeEventListener('abort', done) + resolve() + } + const timer = setTimeout(done, ms) + signal.addEventListener('abort', done, { once: true }) + connection.onExit(done) + }) +} diff --git a/src/main/acp/acp-structured-electron-free.test.ts b/src/main/acp/acp-structured-electron-free.test.ts new file mode 100644 index 000000000000..0a63542a1997 --- /dev/null +++ b/src/main/acp/acp-structured-electron-free.test.ts @@ -0,0 +1,33 @@ +// The ACP lane runs in whatever process hosts the runtime, the headless one included, so nothing +// it reaches may import Electron. + +import { resolve } from 'node:path' +import { build } from 'esbuild' +import { describe, expect, it } from 'vitest' + +describe('ACP structured lane', () => { + it('reaches no Electron import from the adapter or its launch resolution', async () => { + const result = await build({ + entryPoints: [ + resolve(__dirname, 'acp-structured-session-adapter.ts'), + resolve(__dirname, 'acp-structured-launch-resolution.ts'), + resolve(__dirname, 'acp-agent-connection.ts') + ], + bundle: true, + write: false, + outdir: 'out', + platform: 'node', + format: 'esm', + target: 'node22', + packages: 'external', + metafile: true, + logLevel: 'silent' + }) + const imports = Object.values(result.metafile.outputs).flatMap((output) => + output.imports.map((entry) => entry.path) + ) + expect(imports.filter((path) => path === 'electron' || path.startsWith('electron/'))).toEqual( + [] + ) + }, 60_000) +}) diff --git a/src/main/acp/acp-structured-fixture-replay.test-support.ts b/src/main/acp/acp-structured-fixture-replay.test-support.ts new file mode 100644 index 000000000000..c4cf1605c797 --- /dev/null +++ b/src/main/acp/acp-structured-fixture-replay.test-support.ts @@ -0,0 +1,102 @@ +// Plays a recorded Grok session back as the agent behind the real adapter. Orca's own frames +// advance the recording: the agent's lines after each one are sent until the recording waits on +// Orca again. The recording's prompt ids and session id are rewritten to the ones this run uses, +// as the real agent echoes the prompt id it was sent. + +import { expect } from 'vitest' +import { z } from 'zod' +import type { AcpFixtureFrame } from './acp-timeline-fixture.test-support' +import type { AcpScriptedAgent, FakeFrame } from './acp-scripted-agent.test-support' +import { PROVIDER_SESSION } from './acp-structured-adapter.test-support' + +/** Recordings name their sessions `session-N`; a replay plays them all as this run's one. */ +const RECORDED_SESSION = /"session-\d+"/g +const promptMetaSchema = z.object({ _meta: z.object({ promptId: z.string() }) }) +const messageSchema = z.object({ + id: z.union([z.string(), z.number()]).optional(), + method: z.string().optional(), + params: z.unknown().optional(), + result: z.unknown().optional() +}) + +function promptIdOf(params: unknown): string | undefined { + return promptMetaSchema.safeParse(params).data?._meta.promptId +} + +export class GrokFixtureReplay { + private cursor = 0 + private agent: AcpScriptedAgent | null = null + private readonly promptIds = new Map() + private readonly requestIds = new Map() + + constructor(private readonly frames: AcpFixtureFrame[]) {} + + attach(agent: AcpScriptedAgent): void { + this.agent = agent + agent.on('session/prompt', (frame) => this.orcaSent('session/prompt', frame)) + agent.on('session/cancel', (frame) => this.orcaSent('session/cancel', frame)) + } + + /** What the recording waits for Orca to send next; null once it has played to the end. */ + get awaiting(): string | null { + const next = this.frames[this.cursor] + if (!next) { + return null + } + return next.direction === 'out' ? (next.message.method ?? 'reply') : 'agent' + } + + private orcaSent(method: string, frame: FakeFrame): void { + const recorded = this.frames[this.cursor] + expect(recorded?.direction).toBe('out') + expect(recorded?.message.method).toBe(method) + if (method === 'session/prompt' && recorded) { + const recordedId = promptIdOf(recorded.message.params) + const sentId = promptIdOf(frame.params) + if (recordedId && sentId) { + this.promptIds.set(recordedId, sentId) + } + this.requestIds.set(recorded.message.id ?? '', frame.id) + } + this.cursor += 1 + this.play() + } + + private play(): void { + const agent = this.agent + while (agent && this.frames[this.cursor]?.direction === 'in') { + const recorded = this.frames[this.cursor]! + this.cursor += 1 + const message = this.rewrite(recorded.message) + if (message.method !== undefined && message.id !== undefined) { + // The agent asks Orca something; the recording resumes once Orca answers it. + void agent.request(message.id, message.method, message.params).then(() => { + expect(this.frames[this.cursor]?.direction).toBe('out') + this.cursor += 1 + this.play() + }) + return + } + if (message.method !== undefined) { + agent.notify(message.method, message.params) + } else { + agent.send({ + jsonrpc: '2.0', + id: this.requestIds.get(message.id ?? '') ?? message.id, + result: message.result + }) + } + } + } + + private rewrite(message: AcpFixtureFrame['message']): AcpFixtureFrame['message'] { + let text = JSON.stringify(message).replaceAll( + RECORDED_SESSION, + JSON.stringify(PROVIDER_SESSION) + ) + for (const [recorded, sent] of this.promptIds) { + text = text.replaceAll(JSON.stringify(recorded), JSON.stringify(sent)) + } + return messageSchema.parse(JSON.parse(text)) + } +} diff --git a/src/main/acp/acp-structured-host-crash.test.ts b/src/main/acp/acp-structured-host-crash.test.ts new file mode 100644 index 000000000000..a656ac08de4c --- /dev/null +++ b/src/main/acp/acp-structured-host-crash.test.ts @@ -0,0 +1,120 @@ +// A Grok crash mid-reply, through the real host: the host's settlement of the proven exit writes one +// `provider-exit:` batch whose cut-short row reports on the turn the crash ended, carrying the +// adapter's failure with Grok's last words. Whether Grok's pipes closed before its exit was seen +// changes only what the chat shows in between. + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { readAgentSessionFailureFact } from '../../shared/agent-session-failure' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { PROVIDER_EXIT_ROW_PREFIX } from '../../shared/agent-session-stop-row-identity' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import type { JournalLifecycleBatchInput } from '../native-chat/agent-session-journal/journal-store-contracts' +import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { HOST_TEST_SESSION as SESSION } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { replyChunk, shellCall, waitFor } from './acp-structured-adapter.test-support' +import { openAttachedHostRig, promptIdOf, send } from './acp-structured-host.test-support' + +afterEach(async () => { + await closeProviderTimelineRigs() +}) + +const LAST_WORDS = 'qa-cell20-last-words' + +/** Grok mid-reply ("Oak") with a command running, every lifecycle batch the journal takes recorded. */ +async function midReply() { + const rig = await openAttachedHostRig() + const { host } = rig + await send(host, 'hello') + const prompt = await rig.rig.frame('session/prompt') + const child = rig.rig.child() + child.agent.notify('session/update', replyChunk(promptIdOf(prompt), 'Oak')) + child.agent.notify('session/update', shellCall(promptIdOf(prompt), 'in_progress')) + await rig.rig.settle() + await host.flushStreamedEvents(SESSION) + const journal = host.collaboratorsForTests().sessions.get(SESSION)!.journal + const batches: JournalLifecycleBatchInput[] = [] + const append = journal.appendLifecycleBatch.bind(journal) + vi.spyOn(journal, 'appendLifecycleBatch').mockImplementation((batch) => { + batches.push(batch) + return append(batch) + }) + child.stderr = LAST_WORDS + return { ...rig, child, batches } +} + +async function settledCrash(rig: Awaited>) { + const providerExits = () => + rig.batches.filter((batch) => + batch.settlementId.startsWith(`dead-generation:${PROVIDER_EXIT_ROW_PREFIX}`) + ) + await waitFor(() => expect(providerExits()).toHaveLength(1)) + const rows = await rig.rows() + const turn = rows.find((row) => readAgentJournalTurn(row.body) !== null) + const statuses = rows.filter((row) => row.body.kind === 'status') + return { batch: providerExits()[0]!, rows, turn, statuses } +} + +function expectOneTurnScopedCutShortRow(settled: Awaited>) { + const { turn, statuses, rows } = settled + expect(readAgentJournalTurn(turn?.body)).toMatchObject({ state: 'interrupted' }) + expect(statuses).toHaveLength(1) + const [row] = statuses + expect(decodeURIComponent(row?.itemId ?? '')).toContain(PROVIDER_EXIT_ROW_PREFIX) + expect(row?.turnScope).toEqual({ kind: 'turn', turnItemId: turn?.itemId }) + expect( + readAgentSessionFailureFact(row?.body.kind === 'status' ? row.body.failure : undefined) + ).toEqual({ kind: 'providerExited', detail: { text: LAST_WORDS, audience: 'person' } }) + expect(rows.find((item) => item.body.kind === 'tool-call')?.body).toMatchObject({ + state: 'failed', + endedAs: 'interrupted' + }) +} + +describe('a Grok crash with its exit seen first', () => { + it('ends the turn at the exit itself; the provider-exit batch only reports on it', async () => { + const rig = await midReply() + rig.child.exit() + const settled = await settledCrash(rig) + expectOneTurnScopedCutShortRow(settled) + // The child's own end landed first, interrupted at the exit and never `unverifiable`: the host + // has no turn or call of it left to revise. + expect( + settled.batch.mutations.map((mutation) => mutation.kind === 'item' && mutation.body.kind) + ).toEqual(['status']) + await rig.host.close(SESSION, 'user-close') + }) +}) + +describe('a Grok crash whose pipes close before its exit is seen', () => { + it('is unverifiable only until the exit is proven; the provider-exit batch then revises the turn', async () => { + const rig = await midReply() + // The child outlives its stream until Orca's close of it is proven. + let exitNow: () => void = () => undefined + rig.child.proveClose = () => + new Promise((resolve) => { + exitNow = () => { + rig.child.exit() + resolve(true) + } + }) + rig.child.agent.close() + await waitFor(async () => + expect((await rig.turns()).map((turn) => turn.state)).toEqual(['unverifiable']) + ) + expect((await rig.rows()).filter((row) => row.body.kind === 'status')).toEqual([]) + + exitNow() + const settled = await settledCrash(rig) + expectOneTurnScopedCutShortRow(settled) + // The turn's revision, its call's and the row land together. + const turnKey = settled.turn!.itemId + expect( + settled.batch.mutations.flatMap((mutation) => + mutation.kind === 'item' ? [agentJournalItemKey(mutation.identity)] : [] + ) + ).toEqual(expect.arrayContaining([settled.statuses[0]!.itemId, turnKey])) + // No later stale-session pass rewrites it: nothing is left for one. + expect(rig.batches.filter((batch) => batch.settlementId.includes('stale-session'))).toEqual([]) + await rig.host.close(SESSION, 'user-close') + }) +}) diff --git a/src/main/acp/acp-structured-host-lifecycle.test.ts b/src/main/acp/acp-structured-host-lifecycle.test.ts new file mode 100644 index 000000000000..a1486cd3263e --- /dev/null +++ b/src/main/acp/acp-structured-host-lifecycle.test.ts @@ -0,0 +1,287 @@ +// The ACP adapter behind the real host: the host's own order (a fresh sink per start, bound only +// once the start proved its owner; Close and Stop reaching a start from outside the session's +// queue), with a scripted Grok, the real record store and an on-disk journal. + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import { readAgentSessionFailureFact } from '../../shared/agent-session-failure' +import { withNativeChatCutTurnNotices } from '../../shared/native-chat-cut-turn-notice' +import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { + CALLER, + envelope +} from '../native-chat/agent-session-wire/structured-agent-session-host-test-harness' +import { HOST_TEST_SESSION as SESSION } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import type { AcpScriptedAgent } from './acp-scripted-agent.test-support' +import { + GROK_CONFIG_OPTIONS, + PROVIDER_SESSION, + replyChunk, + waitFor +} from './acp-structured-adapter.test-support' +import { + attachParams, + hello, + launch, + openAttachedHostRig, + openHostRig, + promptIdOf, + RESUMES, + send, + stop +} from './acp-structured-host.test-support' + +afterEach(async () => { + await closeProviderTimelineRigs() +}) + +/** On every reopen, Grok sends what its load may carry: the saved `reply` marked as replay, and a + * task the dead process left running, ended by the restart. */ +function loads(reply: string, count: { loads: number }) { + return (agent: AcpScriptedAgent) => + agent.on('session/load', (frame) => { + count.loads += 1 + agent.notify('session/update', replyChunk('prompt:m1', reply, { isReplay: true })) + agent.notify('x.ai/task_completed', { + sessionId: PROVIDER_SESSION, + update: { + sessionUpdate: 'task_completed', + task_snapshot: { task_id: 'task-orphan', command: 'sleep 600', signal: 'session_restart' } + } + }) + agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS }) + }) +} + +describe('reopening a Grok chat through the host', () => { + it('loads a chat the journal holds and writes its exchange once', async () => { + const count = { loads: 0 } + let resumed = false + const { host, fence, messages, exchange } = await openHostRig({ + initialize: RESUMES, + script: loads('hi', count), + deps: { resolveLaunch: launch(() => resumed) } + }) + expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) + resumed = true + await exchange('hi', true) + expect(await messages()).toEqual(['hello', 'hi']) + const before = (await host.history({ sessionId: SESSION, direction: 'tail' })).page.items + await host.close(SESSION, 'user-close') + expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + await host.flushStreamedEvents(SESSION) + expect(count.loads).toBe(1) + expect(await messages()).toEqual(['hello', 'hi']) + const after = (await host.history({ sessionId: SESSION, direction: 'tail' })).page.items + expect(after.filter((row) => row.itemId.includes('background-task'))).toEqual([]) + expect(after.filter((row) => readAgentJournalTurn(row.body))).toHaveLength( + before.filter((row) => readAgentJournalTurn(row.body)).length + ) + await host.close(SESSION, 'user-close') + }) + + it('shows a reply a crash cut off with the existing notice, never completed from what Grok saved', async () => { + let resumed = false + const { rig, host, fence, messages, exchange } = await openHostRig({ + initialize: RESUMES, + script: loads('complete saved reply', { loads: 0 }), + deps: { resolveLaunch: launch(() => resumed) } + }) + expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) + resumed = true + await exchange('complete', false) + // Grok dies mid-reply; the host ends its record, which moves the chat's fence. + const cutAt = fence() + rig.child().exit() + await waitFor(() => expect(fence()).toBeGreaterThan(cutAt)) + expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + await host.flushStreamedEvents(SESSION) + expect(await messages()).toEqual(['hello', 'complete']) + const rows = (await host.history({ sessionId: SESSION, direction: 'tail' })).page.items + expect(rows.flatMap((row) => readAgentJournalTurn(row.body)?.state ?? [])).not.toContain( + 'completed' + ) + // The transcript explains the cut the way a Claude or Codex chat's does. + const transcript = withNativeChatCutTurnNotices(rows, { agentName: 'Grok' }) + expect( + transcript.some( + (row) => + row.body.kind === 'status' && + (row.itemId.includes('cut-turn-notice') || + readAgentSessionFailureFact(row.body.failure)?.kind === 'providerExited') + ) + ).toBe(true) + await host.close(SESSION, 'user-close') + }) +}) + +describe('a Grok crash whose exit is not proven yet', () => { + it('rejects a send meanwhile as never sent, and a later Stop does not turn the crash into one', async () => { + const { rig, host, rows } = await openAttachedHostRig() + const child = rig.child() + await send(host, 'hello') + const prompt = await rig.frame('session/prompt') + child.agent.notify('session/update', replyChunk(promptIdOf(prompt), 'partial')) + await rig.settle() + let proves = false + child.proveClose = async () => { + if (proves) { + child.exit() + } + return proves + } + child.stderr = 'panic: out of memory' + // Both pipes close as the process dies, before its exit is seen. + child.agent.close() + await rig.settle() + const next = await send(host, 'next') + await waitFor(async () => + expect( + (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === next + ) + ).toMatchObject({ + dispatchState: 'rejected', + reason: 'Grok stopped before this message was sent.' + }) + ) + await stop(host) + proves = true + child.exit() + await rig.settle() + await host.flushStreamedEvents(SESSION) + const transcript = withNativeChatCutTurnNotices(await rows(), { agentName: 'Grok' }) + // The crash's own notice, as for any crash; not a Stop's. + expect( + transcript.flatMap((row) => + row.body.kind === 'status' + ? [{ text: row.body.text, failure: readAgentSessionFailureFact(row.body.failure)?.kind }] + : [] + ) + ).toEqual([ + { + text: 'Grok stopped while this response was in progress. You can continue in this conversation.', + failure: 'providerExited' + } + ]) + await host.close(SESSION, 'user-close') + }) +}) + +describe('closing or stopping a Grok chat while it starts', () => { + it('keeps a failed start whose child is not proven gone; a close leaves it, the next start asks it again', async () => { + const { rig, host } = await openHostRig({ + script: (agent) => agent.on('initialize', () => {}) + }) + const attaching = host.attach(CALLER, attachParams()).catch((error: unknown) => error) + await rig.frame('initialize') + const child = rig.child() + child.proveClose = vi.fn(async () => false) + await host.close(SESSION, 'user-close') + expect(await attaching).toMatchObject({ name: 'AgentSessionAcquisitionExitUnprovenError' }) + child.proveClose = vi.fn(async () => { + child.exit() + return true + }) + await host.close(SESSION, 'user-close') + expect(child.proveClose).not.toHaveBeenCalled() + expect(child.exited).toBe(false) + // As Claude's: the next start asks the child again before it spawns another. + await host.send(CALLER, { + envelope: envelope('agentSession.send', { body: hello }), + body: hello + }) + await waitFor(() => expect(child.exited).toBe(true)) + await waitFor(() => expect(rig.child()).not.toBe(child)) + await host.close(SESSION, 'user-close') + }) + + it('lets a Stop reach a child Grok never finished initializing', async () => { + const { rig, host } = await openHostRig({ + script: (agent) => agent.on('initialize', () => {}) + }) + const attaching = host.attach(CALLER, attachParams()) + await rig.frame('initialize') + const stopping = host.cancel(CALLER, { envelope: envelope('agentSession.cancel', {}) }) + // With no close behind it. + await waitFor(() => expect(rig.child().exited).toBe(true)) + expect((await attaching).ok).toBe(false) + expect(await stopping).toMatchObject({ ok: true }) + expect(rig.spawned.filter((step) => step === 'spawn')).toHaveLength(1) + }) + + it('cancels a queued send whose start a Stop ended, with no start failure in the chat', async () => { + let spawns = 0 + const { rig, host } = await openHostRig({ + script: (agent) => { + spawns += 1 + if (spawns > 1) { + agent.on('initialize', () => {}) + } + } + }) + expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) + // The chat closes; the next send has to start Grok again, and that start never answers. + await host.close(SESSION, 'user-close') + await host.send(CALLER, { + envelope: envelope('agentSession.send', { body: hello }), + body: hello + }) + await waitFor(() => expect(spawns).toBe(2)) + await rig.frame('initialize') + const restarted = rig.child() + expect( + await host.cancel(CALLER, { envelope: envelope('agentSession.cancel', {}) }) + ).toMatchObject({ ok: true, value: { cancelled: true } }) + expect(restarted.exited).toBe(true) + await host.flushStreamedEvents(SESSION) + const journal = host.collaboratorsForTests().sessions.get(SESSION)!.journal + expect(journal.submissions()).toMatchObject([ + { dispatchState: 'rejected', rejection: { kind: 'cancelled' } } + ]) + const rows = (await host.history({ sessionId: SESSION, direction: 'tail' })).page.items + expect(rows.filter((row) => row.body.kind === 'status')).toEqual([]) + await host.close(SESSION, 'user-close') + }) + + it('leaves a start alone for a Stop that names a turn of a child already gone', async () => { + const { rig, host } = await openHostRig({ + script: (agent) => agent.on('initialize', () => {}) + }) + const attaching = host.attach(CALLER, attachParams()) + await rig.frame('initialize') + const stopping = host.cancel(CALLER, { + envelope: envelope('agentSession.cancel', { turnId: 'turn-of-an-old-child' }), + turnId: 'turn-of-an-old-child' + }) + await rig.settle() + expect(rig.child().closes).toBe(0) + await host.close(SESSION, 'user-close') + await attaching + await stopping + }) + + it('never spawns a child for a start closed while its launch was still resolving', async () => { + let releaseLaunch: () => void = () => {} + const resolving = { started: false } + const { rig, host } = await openHostRig({ + deps: { + resolveLaunch: async () => { + resolving.started = true + await new Promise((resolve) => { + releaseLaunch = resolve + }) + return launch(() => false)() + } + } + }) + const attaching = host.attach(CALLER, attachParams()).catch((error: unknown) => error) + await waitFor(() => expect(resolving.started).toBe(true)) + const closing = host.close(SESSION, 'user-close') + releaseLaunch() + expect(await attaching).toMatchObject({ name: 'AgentSessionPreSpawnError' }) + await closing + expect(rig.spawned).toEqual([]) + expect(await rig.adapter.closeSession(SESSION)).toBe(true) + }) +}) diff --git a/src/main/acp/acp-structured-host-option-hang.test.ts b/src/main/acp/acp-structured-host-option-hang.test.ts new file mode 100644 index 000000000000..32f0a6df64f6 --- /dev/null +++ b/src/main/acp/acp-structured-host-option-hang.test.ts @@ -0,0 +1,88 @@ +// A model or effort pick Grok never answers, through the real host: it must not hold the chat's +// Stop or Close behind it, and on its own it fails at a bound instead of holding the chat forever. + +import { afterEach, describe, expect, it } from 'vitest' +import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { + CALLER, + envelope +} from '../native-chat/agent-session-wire/structured-agent-session-host-test-harness' +import { HOST_TEST_SESSION as SESSION } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { GROK_CONFIG_OPTIONS, waitFor } from './acp-structured-adapter.test-support' +import { framesOf, openAttachedHostRig, stop } from './acp-structured-host.test-support' +import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' + +afterEach(async () => { + await closeProviderTimelineRigs() +}) + +function pick(host: StructuredAgentSessionHost, value: string) { + const fields = { key: 'model', value } + return host.setOption(CALLER, { envelope: envelope('agentSession.setOption', fields), ...fields }) +} + +/** Whether `promise` settled within `ms`, and how. */ +async function within(promise: Promise, ms: number) { + let timer: ReturnType | undefined + const late = new Promise<'pending'>((resolve) => { + timer = setTimeout(() => resolve('pending'), ms) + }) + try { + return await Promise.race([ + promise.then( + (value) => ({ settled: 'resolved' as const, value }), + (error: unknown) => ({ settled: 'rejected' as const, value: error }) + ), + late + ]) + } finally { + clearTimeout(timer) + } +} + +const failed = (outcome: Awaited>) => + outcome !== 'pending' && + (outcome.settled === 'rejected' || + (typeof outcome.value === 'object' && outcome.value !== null && 'ok' in outcome.value + ? outcome.value.ok === false + : false)) + +describe('a Grok model pick Grok never answers', () => { + it('does not hold a Stop behind it; the pick fails', async () => { + const { rig, host } = await openAttachedHostRig() + const picking = pick(host, 'grok-4.6') + await waitFor(() => expect(framesOf(rig.child(), 'session/set_config_option')).toHaveLength(1)) + expect(await within(stop(host), 2_000)).toMatchObject({ settled: 'resolved' }) + expect(failed(await within(picking, 1_000))).toBe(true) + expect(await within(host.close(SESSION, 'user-close'), 2_000)).not.toBe('pending') + }) + + it('does not hold a Close behind it; the pick fails', async () => { + const { rig, host } = await openAttachedHostRig() + const picking = pick(host, 'grok-4.6') + await waitFor(() => expect(framesOf(rig.child(), 'session/set_config_option')).toHaveLength(1)) + expect(await within(host.close(SESSION, 'user-close'), 2_000)).toMatchObject({ + settled: 'resolved' + }) + expect(failed(await within(picking, 1_000))).toBe(true) + }) + + it('fails at its bound, frees the chat, and keeps what Grok answers late', async () => { + const { rig, host } = await openAttachedHostRig({ optionWriteTimeoutMs: 100 }) + const picking = pick(host, 'grok-4.6') + await waitFor(() => expect(framesOf(rig.child(), 'session/set_config_option')).toHaveLength(1)) + expect(failed(await within(picking, 2_000))).toBe(true) + // The chat's queue moved on: a later operation runs. + expect(await within(host.readOptions(SESSION), 1_000)).not.toBe('pending') + const [frame] = framesOf(rig.child(), 'session/set_config_option') + rig.child().agent.reply(frame!, { + configOptions: GROK_CONFIG_OPTIONS.map((option) => + option.id === 'model' ? { ...option, currentValue: 'grok-4.6' } : option + ) + }) + await waitFor(async () => + expect((await host.readOptions(SESSION)).current).toMatchObject({ model: 'grok-4.6' }) + ) + await host.close(SESSION, 'user-close') + }) +}) diff --git a/src/main/acp/acp-structured-host-restore-failed.test.ts b/src/main/acp/acp-structured-host-restore-failed.test.ts new file mode 100644 index 000000000000..bf6c7a363c8d --- /dev/null +++ b/src/main/acp/acp-structured-host-restore-failed.test.ts @@ -0,0 +1,303 @@ +// A Grok session Grok cannot reopen, through the real host, record store, journal and launch +// resolver: a fresh session continues the chat, the chain records what it replaced, and the chat +// says so once, unless nothing was ever exchanged on the lost session. A start that succeeds +// writes the row an earlier attach failure dropped. + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { z } from 'zod' +import { readAgentSessionFailureFact } from '../../shared/agent-session-failure' +import { + agentSessionProviderHandleChainHead, + agentSessionProviderHandleKey +} from '../../shared/agent-session-provider-handle' +import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { CALLER } from '../native-chat/agent-session-wire/structured-agent-session-host-test-harness' +import { HOST_TEST_SESSION as SESSION } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import type { AcpScriptedAgent } from './acp-scripted-agent.test-support' +import { GROK, GROK_CONFIG_OPTIONS, PROVIDER_SESSION } from './acp-structured-adapter.test-support' +import { ACP_HANDLE_TRANSPORT } from './acp-structured-agent-definitions' +import { replayJournal } from '../native-chat/agent-session-journal/journal-open' +import { AgentSessionJournal } from '../native-chat/agent-session-journal/journal-store' +import { createAcpStructuredLaunchResolver } from './acp-structured-launch-resolution' +import { + attachParams, + openHostRig, + promptIdOf, + RESUMES, + send +} from './acp-structured-host.test-support' + +afterEach(async () => { + await closeProviderTimelineRigs() +}) + +const FRESH = 'grok-fresh-session' +const FRESH_2 = 'grok-second-fresh-session' +const keyOf = (nativeId: string) => + agentSessionProviderHandleKey({ transport: ACP_HANDLE_TRANSPORT, agent: 'grok', nativeId }) + +type Lost = Map + +/** Grok opens sessions in turn and cannot reopen any session in `lost`; it reopens every other. */ +function sessionsLost(loads: string[], lost: Lost) { + const opened = [PROVIDER_SESSION, FRESH, FRESH_2] + return (agent: AcpScriptedAgent) => { + agent.on('session/new', (frame) => { + agent.reply(frame, { + sessionId: opened.shift() ?? 'grok-unexpected-session', + configOptions: GROK_CONFIG_OPTIONS + }) + }) + agent.on('session/load', (frame) => { + const { sessionId } = z.object({ sessionId: z.string() }).parse(frame.params) + loads.push(sessionId) + const failure = lost.get(sessionId) + if (failure) { + agent.fail(frame, failure.code, failure.message) + } else { + agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS }) + } + }) + } +} + +async function openRestoreRig(code: number, message: string) { + const loads: string[] = [] + const lost: Lost = new Map([[PROVIDER_SESSION, { code, message }]]) + const resolver: { resolve: ReturnType | null } = { + resolve: null + } + const opened = await openHostRig({ + initialize: RESUMES, + script: sessionsLost(loads, lost), + deps: { + resolveLaunch: (input) => { + if (!resolver.resolve) { + throw new Error('launch resolver not ready') + } + return resolver.resolve(input) + } + } + }) + const { rig, host, store, journalDatabase } = opened + resolver.resolve = createAcpStructuredLaunchResolver(GROK, { + store, + readJournal: (sessionId) => replayJournal(journalDatabase.db, sessionId), + resolveWorkspacePath: async () => '/workspace', + resolveEnvironment: async () => ({ PATH: '/usr/bin' }), + resolveCommand: () => '/fake/grok' + }) + const warnings = async () => { + await host.flushStreamedEvents(SESSION) + return (await host.history({ sessionId: SESSION, direction: 'tail' })).page.items.filter( + (row) => + row.body.kind === 'status' && + readAgentSessionFailureFact(row.body.failure)?.kind === 'sessionNotRestored' + ) + } + /** A send Grok answers in `providerSession`, ending its turn. */ + const exchange = async (text: string, reply: string, providerSession: string) => { + await send(host, text) + const prompt = await rig.frame('session/prompt') + expect(prompt.params).toMatchObject({ sessionId: providerSession }) + rig.child().agent.notify('session/update', { + sessionId: providerSession, + update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: reply } }, + _meta: { promptId: promptIdOf(prompt) } + }) + rig.child().agent.reply(prompt, { stopReason: 'end_turn' }) + await rig.settle() + await host.flushStreamedEvents(SESSION) + } + return { ...opened, loads, lost, warnings, exchange } +} + +describe('a saved Grok session Grok cannot reopen', () => { + it('continues the chat in a fresh session that the chain records and the next reopen loads', async () => { + const { host, store, fence, loads, warnings, exchange } = await openRestoreRig( + -32603, + 'session file is corrupt' + ) + + expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) + await host.close(SESSION, 'user-close') + expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + + expect(loads).toEqual([PROVIDER_SESSION]) + const chain = store.getRecord(SESSION)?.providerHandleChain ?? [] + expect(chain).toHaveLength(2) + expect(agentSessionProviderHandleChainHead(chain)).toMatchObject({ + origin: 'created', + handle: { nativeId: FRESH }, + replaces: { key: keyOf(PROVIDER_SESSION), reason: 'restore-failed' } + }) + const rows = await warnings() + expect(rows).toHaveLength(1) + expect(rows[0]?.body).toMatchObject({ + kind: 'status', + tone: 'warning', + text: "Grok couldn't reopen its earlier session, so this chat continues in a new one. Grok doesn't remember the earlier messages." + }) + + // The chat keeps working in the fresh session. + await exchange('still there?', 'yes', FRESH) + const messages = (await host.history({ sessionId: SESSION, direction: 'tail' })).page.items + .filter((row) => row.body.kind === 'message') + .map((row) => (row.body.kind === 'message' ? row.body.blocks : [])) + expect(messages.flat()).toEqual( + expect.arrayContaining([ + { type: 'text', text: 'still there?' }, + { type: 'text', text: 'yes' } + ]) + ) + + // The next reopen loads the fresh session, and says nothing new. + await host.close(SESSION, 'user-close') + expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + expect(loads).toEqual([PROVIDER_SESSION, FRESH]) + expect(store.getRecord(SESSION)?.providerHandleChain).toHaveLength(3) + expect(await warnings()).toHaveLength(1) + await host.close(SESSION, 'user-close') + }) +}) + +describe('a created Grok session Grok reports missing on the first reopen', () => { + it('replaces it and says so once when the chat exchanged a turn on it', async () => { + const { host, store, fence, loads, warnings, exchange, messages } = await openRestoreRig( + -32002, + 'Resource not found' + ) + expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) + await exchange('remember 42', 'noted', PROVIDER_SESSION) + await host.close(SESSION, 'user-close') + expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + + expect(loads).toEqual([PROVIDER_SESSION]) + const chain = store.getRecord(SESSION)?.providerHandleChain ?? [] + expect(chain).toHaveLength(2) + const head = agentSessionProviderHandleChainHead(chain) + expect(head).toMatchObject({ + origin: 'created', + handle: { nativeId: FRESH }, + replaces: { key: keyOf(PROVIDER_SESSION), reason: 'restore-failed' } + }) + expect(head?.supersedesKey).toBeUndefined() + expect(await warnings()).toHaveLength(1) + // The person still sees what Grok forgot. + expect(await messages()).toEqual(expect.arrayContaining(['remember 42', 'noted'])) + + // The next reopen loads the fresh session, and says nothing new. + await host.close(SESSION, 'user-close') + expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + expect(loads).toEqual([PROVIDER_SESSION, FRESH]) + expect(await warnings()).toHaveLength(1) + await host.close(SESSION, 'user-close') + }) + + it('supersedes it silently when nothing was exchanged on it', async () => { + const { host, store, fence, loads, warnings } = await openRestoreRig( + -32002, + 'Resource not found' + ) + expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) + await host.close(SESSION, 'user-close') + expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + + expect(loads).toEqual([PROVIDER_SESSION]) + const chain = store.getRecord(SESSION)?.providerHandleChain ?? [] + expect(chain).toHaveLength(1) + const head = agentSessionProviderHandleChainHead(chain) + expect(head).toMatchObject({ + origin: 'created', + handle: { nativeId: FRESH }, + supersedesKey: keyOf(PROVIDER_SESSION) + }) + expect(head?.replaces).toBeUndefined() + expect(await warnings()).toEqual([]) + await host.close(SESSION, 'user-close') + }) +}) + +describe('the warning row of a replacement whose attach failed', () => { + async function replaceThenFailAttach() { + const opened = await openRestoreRig(-32603, 'session file is corrupt') + const { host, store, fence, warnings } = opened + expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) + await host.close(SESSION, 'user-close') + // The journal's open fails after the fresh session's link is durable, before its row is written. + vi.spyOn(AgentSessionJournal.prototype, 'open').mockRejectedValueOnce( + new Error('journal path unavailable') + ) + await expect(host.attach(CALLER, attachParams(fence()))).rejects.toThrow( + 'journal path unavailable' + ) + expect( + agentSessionProviderHandleChainHead(store.getRecord(SESSION)?.providerHandleChain ?? []) + ).toMatchObject({ + handle: { nativeId: FRESH }, + replaces: { key: keyOf(PROVIDER_SESSION), reason: 'restore-failed' } + }) + expect(await warnings()).toEqual([]) + return opened + } + + it('is written by the next start that loads the fresh session, and only once', async () => { + const { host, fence, loads, warnings } = await replaceThenFailAttach() + + expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + expect(loads).toEqual([PROVIDER_SESSION, FRESH]) + expect(await warnings()).toHaveLength(1) + + await host.close(SESSION, 'user-close') + expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + expect(loads).toEqual([PROVIDER_SESSION, FRESH, FRESH]) + expect(await warnings()).toHaveLength(1) + await host.close(SESSION, 'user-close') + }) + + it('is written when Grok never saved the fresh session and a newer one supersedes it', async () => { + const { host, store, fence, loads, lost, warnings } = await replaceThenFailAttach() + lost.set(FRESH, { code: -32002, message: 'Resource not found' }) + + expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + expect(loads).toEqual([PROVIDER_SESSION, FRESH]) + const chain = store.getRecord(SESSION)?.providerHandleChain ?? [] + expect(chain).toHaveLength(2) + expect(agentSessionProviderHandleChainHead(chain)).toMatchObject({ + handle: { nativeId: FRESH_2 }, + supersedesKey: keyOf(FRESH), + replaces: { key: keyOf(PROVIDER_SESSION), reason: 'restore-failed' } + }) + expect(await warnings()).toHaveLength(1) + await host.close(SESSION, 'user-close') + expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + expect(loads).toEqual([PROVIDER_SESSION, FRESH, FRESH_2]) + expect(await warnings()).toHaveLength(1) + await host.close(SESSION, 'user-close') + }) +}) + +describe('a replacement Grok never saved, superseded after its row was written', () => { + it('keeps the one row: the conversation lost is still the first one', async () => { + const { host, fence, loads, lost, warnings } = await openRestoreRig( + -32603, + 'session file is corrupt' + ) + expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) + await host.close(SESSION, 'user-close') + expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + expect(await warnings()).toHaveLength(1) + await host.close(SESSION, 'user-close') + lost.set(FRESH, { code: -32002, message: 'Resource not found' }) + + expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + expect(loads).toEqual([PROVIDER_SESSION, FRESH]) + expect(await warnings()).toHaveLength(1) + // The newer session carries the same loss, so reopening it says nothing new. + await host.close(SESSION, 'user-close') + expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + expect(loads).toEqual([PROVIDER_SESSION, FRESH, FRESH_2]) + expect(await warnings()).toHaveLength(1) + await host.close(SESSION, 'user-close') + }) +}) diff --git a/src/main/acp/acp-structured-host-start-aborts.test.ts b/src/main/acp/acp-structured-host-start-aborts.test.ts new file mode 100644 index 000000000000..216a495e1bc9 --- /dev/null +++ b/src/main/acp/acp-structured-host-start-aborts.test.ts @@ -0,0 +1,209 @@ +// A Grok start the host aborts — at quit, by a close, or by an admitted Stop — through the one +// signal the host owns, with a scripted Grok, the real record store and an on-disk journal. + +import { afterEach, describe, expect, it } from 'vitest' +import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import { CALLER } from '../native-chat/agent-session-wire/structured-agent-session-host-test-harness' +import { HOST_TEST_SESSION as SESSION } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { GROK_CONFIG_OPTIONS, waitFor } from './acp-structured-adapter.test-support' +import { + attachParams, + launch, + openHostRig, + RESUMES, + send, + stop +} from './acp-structured-host.test-support' + +afterEach(async () => { + await closeProviderTimelineRigs() +}) + +const STALLED = Symbol('stalled') +const within = (promise: Promise, ms: number): Promise => + Promise.race([ + promise, + new Promise((resolve) => setTimeout(resolve, ms, STALLED)) + ]) + +/** Runs `atEnd` once, right before the host ends an attach's controller: after its commit. */ +function beforeAttachEnds(host: StructuredAgentSessionHost, atEnd: () => void): void { + const aborts = host.collaboratorsForTests().runtimeState.acquireAborts + const begin = aborts.begin.bind(aborts) + let fired = false + aborts.begin = (sessionId) => { + const started = begin(sessionId) + return { + signal: started.signal, + end: () => { + if (!fired) { + fired = true + atEnd() + } + started.end() + } + } + } +} + +/** Runs `during` once, while the attach is still probing the previous owner, before any launch. */ +function duringOwnerProbe(host: StructuredAgentSessionHost, during: () => void): void { + const state = host.collaboratorsForTests().runtimeState + const probe = state.probeOwner.bind(state) + let fired = false + state.probeOwner = async (sessionId) => { + if (!fired) { + fired = true + during() + } + return probe(sessionId) + } +} + +describe('a Grok start the host aborts', () => { + it('quits promptly while Grok never answers its handshake, and stops the child', async () => { + const { rig, host } = await openHostRig({ + script: (agent) => agent.on('initialize', () => {}) + }) + const attaching = host.attach(CALLER, attachParams()) + await rig.frame('initialize') + // The start has no bound of its own: the quit is what ends it. + expect(await within(host.flushAllStreamedEvents({ trigger: 'quit' }), 2_000)).not.toBe(STALLED) + expect((await attaching).ok).toBe(false) + expect(rig.child().exited).toBe(true) + await rig.adapter.closeAll() + }) + + it('closes a chat whose start already returned as a close Orca asked for, not a crash', async () => { + const { rig, host, store } = await openHostRig() + const ended: unknown[] = [] + const handle = host.handleAdapterEvent.bind(host) + host.handleAdapterEvent = (event) => { + if (event.type === 'ended') { + ended.push(event) + } + return handle(event) + } + let closing: Promise | undefined + beforeAttachEnds(host, () => { + closing = host.close(SESSION, 'user-close') + }) + expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) + await closing + expect(rig.child().exited).toBe(true) + expect(ended).toMatchObject([{ cause: 'requested-close' }]) + expect(store.getRecord(SESSION)?.lease.deathEvidence?.detail).not.toContain('exited') + }) + + it('never launches Grok for a chat closed while its attach was still reconciling', async () => { + const { rig, host } = await openHostRig({ + script: (agent) => agent.on('initialize', () => {}) + }) + let closing: Promise | undefined + duringOwnerProbe(host, () => { + closing = host.close(SESSION, 'user-close') + }) + expect( + await host.attach(CALLER, attachParams()).catch((error: unknown) => error) + ).toMatchObject({ name: 'AgentSessionPreSpawnError' }) + expect(await within(closing!, 2_000)).not.toBe(STALLED) + expect(rig.spawned).not.toContain('spawn') + }) + + it('never launches Grok for a start a Stop reached while its attach was still reconciling', async () => { + const { rig, host } = await openHostRig() + expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) + // The chat closes; the next send has to start Grok again. + await host.close(SESSION, 'user-close') + let stopping: ReturnType | undefined + duringOwnerProbe(host, () => { + stopping = stop(host) + }) + await send(host, 'hello') + await waitFor(() => expect(stopping).toBeDefined()) + expect(await stopping).toMatchObject({ ok: true, value: { cancelled: true } }) + await host.flushStreamedEvents(SESSION) + expect(rig.spawned.filter((step) => step === 'spawn')).toHaveLength(1) + const journal = host.collaboratorsForTests().sessions.get(SESSION)!.journal + expect(journal.submissions()).toMatchObject([ + { dispatchState: 'rejected', rejection: { kind: 'cancelled' } } + ]) + }) + + it('starts Grok afresh for a message sent right after a Stop reached a reconciling start', async () => { + const { rig, host } = await openClosedResumableChat() + let stopping: ReturnType | undefined + let second: Promise | undefined + duringOwnerProbe(host, () => { + stopping = stop(host) + // Accepted while the aborted start is still unwinding. + second = send(host, 'second') + }) + await send(host, 'hello') + await waitFor(() => expect(second).toBeDefined()) + expect(await stopping).toMatchObject({ ok: true, value: { cancelled: true } }) + await expectDeliveredAfterStop({ rig, host }, await second!) + expect(rig.spawned.filter((step) => step === 'spawn')).toHaveLength(2) + }) + + it('starts Grok afresh for a message sent right after a Stop ended a hung handshake', async () => { + const { rig, host, children } = await openClosedResumableChat({ hangsHandshake: 2 }) + const first = send(host, 'hello') + // The restart's own child, not the first one's. + await waitFor(() => expect(children()).toBe(2)) + await rig.frame('initialize') + const stopping = stop(host) + const second = send(host, 'second') + expect(await stopping).toMatchObject({ ok: true, value: { cancelled: true } }) + await first + await expectDeliveredAfterStop({ rig, host }, await second) + expect(children()).toBe(3) + }) +}) + +/** A Grok chat the user closed; each later start loads it. `hangsHandshake`: that child never + * answers `initialize`. */ +async function openClosedResumableChat(options: { hangsHandshake?: number } = {}) { + let children = 0 + let resumed = false + const rig = await openHostRig({ + initialize: RESUMES, + script: (agent) => { + if (++children === options.hangsHandshake) { + agent.on('initialize', () => {}) + } + agent.on('session/load', (frame) => + agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS }) + ) + }, + deps: { resolveLaunch: launch(() => resumed) } + }) + expect(await rig.host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) + resumed = true + await rig.host.close(SESSION, 'user-close') + return { ...rig, children: () => children } +} + +/** The stopped message reads cancelled; the one sent after it reached Grok, with no failure row. */ +async function expectDeliveredAfterStop( + { rig, host }: Pick>, 'rig' | 'host'>, + secondId: string +): Promise { + const prompt = await rig.frame('session/prompt') + expect(JSON.stringify(prompt.params)).toContain('second') + rig.child().agent.reply(prompt, { stopReason: 'end_turn' }) + const journal = host.collaboratorsForTests().sessions.get(SESSION)!.journal + await waitFor(() => + expect( + journal.submissions().find((entry) => entry.clientMessageId === secondId)?.dispatchState + ).toBe('accepted') + ) + expect(journal.submissions().map((entry) => entry.rejection?.kind ?? null)).toEqual([ + 'cancelled', + null + ]) + await host.flushStreamedEvents(SESSION) + const rows = (await host.history({ sessionId: SESSION, direction: 'tail' })).page.items + expect(rows.filter((row) => row.body.kind === 'status')).toEqual([]) +} diff --git a/src/main/acp/acp-structured-host-stop.test.ts b/src/main/acp/acp-structured-host-stop.test.ts new file mode 100644 index 000000000000..2e6677390d65 --- /dev/null +++ b/src/main/acp/acp-structured-host-stop.test.ts @@ -0,0 +1,184 @@ +// A Grok chat's Stop through the real host: it ends the process once Grok settles its turn, and the +// next send resumes the session. + +import { afterEach, describe, expect, it } from 'vitest' +import { withNativeChatCutTurnNotices } from '../../shared/native-chat-cut-turn-notice' +import { + closeProviderTimelineRigs, + providerTurnId +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { HOST_TEST_SESSION as SESSION } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { PROVIDER_SESSION, replyChunk, waitFor } from './acp-structured-adapter.test-support' +import { + framesOf, + openAttachedHostRig, + promptIdOf, + send, + stop +} from './acp-structured-host.test-support' + +afterEach(async () => { + await closeProviderTimelineRigs() +}) + +describe('a Grok chat Stop', () => { + it('ends Grok once it settles the turn, and the next send resumes with no notice', async () => { + const { rig, host, count, rows, turns, messages } = await openAttachedHostRig() + const first = rig.child() + await send(host, 'hello') + const prompt = await rig.frame('session/prompt') + first.agent.notify('session/update', replyChunk(promptIdOf(prompt), 'partial')) + // Grok takes a moment to wind the turn down; the host waits for it before ending the process. + const answered: { cancel: boolean; atClose: boolean | null } = { cancel: false, atClose: null } + first.agent.on('session/cancel', () => + setTimeout(() => { + answered.cancel = true + first.agent.reply(prompt, { stopReason: 'cancelled' }) + }, 50) + ) + const close = first.close.bind(first) + first.close = async () => { + answered.atClose ??= answered.cancel + return close() + } + await rig.settle() + await host.flushStreamedEvents(SESSION) + expect(await stop(host)).toMatchObject({ ok: true, value: { cancelled: true } }) + await waitFor(() => expect(first.exited).toBe(true)) + expect(framesOf(first, 'session/cancel')).toHaveLength(1) + expect(answered.atClose).toBe(true) + expect((await turns()).at(-1)).toMatchObject({ state: 'interrupted' }) + + await send(host, 'again') + await waitFor(() => expect(rig.child()).not.toBe(first)) + const second = rig.child() + await rig.frame('session/prompt') + expect(count.loads).toBe(1) + expect(framesOf(second, 'session/new')).toHaveLength(0) + expect(await messages()).toEqual(['hello', 'partial', 'again']) + // Only the Stop's own note: no exit or cut-reply notice for a Stop the person asked for. + const transcript = withNativeChatCutTurnNotices(await rows(), { agentName: 'Grok' }) + expect( + transcript.flatMap((row) => (row.body.kind === 'status' ? [row.body.text] : [])) + ).toEqual(['Cancellation requested.']) + await host.close(SESSION, 'user-close') + }) + + it('ends Grok at the grace when Grok never reads the cancel, never waiting on its write', async () => { + const { rig, host, turns } = await openAttachedHostRig({ stopGraceMs: 30 }) + const first = rig.child() + await send(host, 'hello') + const prompt = await rig.frame('session/prompt') + first.agent.notify('session/update', replyChunk(promptIdOf(prompt), 'partial')) + await rig.settle() + await host.flushStreamedEvents(SESSION) + // A write that never completes, as a full pipe to a Grok that stopped reading. + let cancels = 0 + first.cancel = () => { + cancels += 1 + return new Promise(() => {}) + } + expect(await stop(host)).toMatchObject({ ok: true, value: { cancelled: true } }) + await waitFor(() => expect(first.exited).toBe(true)) + expect(cancels).toBe(1) + expect((await turns()).at(-1)).toMatchObject({ state: 'interrupted' }) + }) + + it('ends Grok on a Stop of a turn it began itself, once that turn ends', async () => { + const { rig, host, turns } = await openAttachedHostRig() + const { agent } = rig.child() + agent.notify('session/update', replyChunk('task-completed-background-1', 'Working on it')) + agent.on('session/cancel', () => + agent.notify('x.ai/session_notification', { + sessionId: PROVIDER_SESSION, + update: { + sessionUpdate: 'turn_completed', + prompt_id: 'task-completed-background-1', + stop_reason: 'cancelled' + } + }) + ) + await rig.settle() + expect((await turns()).at(-1)).toMatchObject({ state: 'running' }) + expect(await stop(host)).toMatchObject({ ok: true, value: { cancelled: true } }) + await waitFor(() => expect(rig.child().exited).toBe(true)) + expect(framesOf(rig.child(), 'session/cancel')).toHaveLength(1) + expect((await turns()).at(-1)).toMatchObject({ state: 'interrupted' }) + await host.close(SESSION, 'user-close') + }) + + it('leaves no Grok behind whose background work could begin a turn after the Stop', async () => { + const { rig, host, rows, turns } = await openAttachedHostRig() + const first = rig.child() + await send(host, 'hello') + const prompt = await rig.frame('session/prompt') + first.agent.notify('session/update', replyChunk(promptIdOf(prompt), 'started a build')) + first.agent.notify('x.ai/task_backgrounded', { + sessionId: PROVIDER_SESSION, + update: { sessionUpdate: 'task_backgrounded', task_id: 'build-1', command: 'make all' } + }) + first.agent.on('session/cancel', () => first.agent.reply(prompt, { stopReason: 'cancelled' })) + await rig.settle() + await host.flushStreamedEvents(SESSION) + await stop(host) + await waitFor(() => expect(first.exited).toBe(true)) + // The task ended with Grok's process; Orca cannot say how, so its row says no more than that. + const task = (await rows()).find((row) => row.itemId.includes('background-task')) + expect(task?.body).toMatchObject({ + blocks: [ + { type: 'text', text: 'Background command "make all" stopped reporting' }, + { type: 'background-task', taskId: 'build-1', state: 'unverifiable' } + ] + }) + const settled = await turns() + // What a kept process would do once its backgrounded build finished. + first.agent.notify('session/update', replyChunk('task-completed-background-1', 'Build done')) + await rig.settle() + expect(await turns()).toEqual(settled) + expect(settled.map((turn) => turn.state)).not.toContain('running') + await host.close(SESSION, 'user-close') + }) +}) + +describe('a Grok chat Stop naming a turn that has ended', () => { + it('stops neither the turn running now nor Grok', async () => { + const { rig, host, turns } = await openAttachedHostRig() + const { agent } = rig.child() + await send(host, 'old') + const old = await rig.frame('session/prompt') + agent.notify('session/update', replyChunk(promptIdOf(old), 'done')) + agent.reply(old, { stopReason: 'end_turn' }) + await rig.settle() + await send(host, 'new') + const current = await rig.frame('session/prompt', 1) + agent.notify('session/update', replyChunk(promptIdOf(current), 'working')) + await rig.settle() + await host.flushStreamedEvents(SESSION) + const stopped = await stop(host, providerTurnId(promptIdOf(old), PROVIDER_SESSION)) + expect(stopped).toMatchObject({ ok: true, value: { cancelled: false } }) + await rig.settle() + expect(framesOf(rig.child(), 'session/cancel')).toHaveLength(0) + expect(rig.child().exited).toBe(false) + expect((await turns()).at(-1)).toMatchObject({ state: 'running' }) + await host.close(SESSION, 'user-close') + }) + + it('ends Grok in the gap before it echoes the next prompt, as a Claude Stop does', async () => { + const { rig, host } = await openAttachedHostRig() + const { agent } = rig.child() + await send(host, 'old') + const old = await rig.frame('session/prompt') + agent.notify('session/update', replyChunk(promptIdOf(old), 'done')) + agent.reply(old, { stopReason: 'end_turn' }) + await rig.settle() + await send(host, 'new') + const next = await rig.frame('session/prompt', 1) + agent.on('session/cancel', () => agent.reply(next, { stopReason: 'cancelled' })) + await host.flushStreamedEvents(SESSION) + const stopped = await stop(host, providerTurnId(promptIdOf(old), PROVIDER_SESSION)) + expect(stopped).toMatchObject({ ok: true, value: { cancelled: true } }) + await waitFor(() => expect(rig.child().exited).toBe(true)) + expect(framesOf(rig.child(), 'session/cancel')).toHaveLength(1) + await host.close(SESSION, 'user-close') + }) +}) diff --git a/src/main/acp/acp-structured-host.test-support.ts b/src/main/acp/acp-structured-host.test-support.ts new file mode 100644 index 000000000000..afc1c1c42401 --- /dev/null +++ b/src/main/acp/acp-structured-host.test-support.ts @@ -0,0 +1,200 @@ +// The ACP adapter behind the real host, with a scripted Grok, the real record store and an +// on-disk journal. + +import { expect } from 'vitest' +import { z } from 'zod' +import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' +import { agentSessionProviderHandleKey } from '../../shared/agent-session-provider-handle' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' +import { messageText } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import { StructuredAgentRegistry } from '../native-chat/agent-session-wire/structured-agent-registry' +import { + CALLER, + envelope, + hostTestState, + replaceHostTestState +} from '../native-chat/agent-session-wire/structured-agent-session-host-test-harness' +import { + HOST_TEST_NOW, + HOST_TEST_SESSION as SESSION, + hostTestAttachParams +} from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { AgentSessionRecoveryCapsule } from '../runtime/agent-session-recovery-capsule' +import { openTestAgentSessionRecordStore } from '../runtime/agent-session-record-store-test-harness' +import type { AcpScriptedAgent, FakeFrame } from './acp-scripted-agent.test-support' +import { + GROK, + GROK_CONFIG_OPTIONS, + openAcpAdapterRig, + PROVIDER_SESSION, + replyChunk, + type FakeAcpChild +} from './acp-structured-adapter.test-support' +import { + ACP_HANDLE_TRANSPORT, + acpStructuredAgentDefinition +} from './acp-structured-agent-definitions' +import type { AcpStructuredLaunch } from './acp-structured-launch-resolution' +import type { AcpStructuredSessionAdapterDeps } from './acp-structured-session-adapter-deps' + +export const hello: AgentJournalMessageItem = { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'hello' }] +} + +export const attachParams = (fence: number | null = null) => + hostTestAttachParams(fence, { + provider: 'grok', + agent: 'grok', + accountHome: { variable: 'GROK_HOME', path: '/grok' }, + providerHandle: undefined + }) + +/** A launch that resumes the provider session once the chat has one. */ +export function launch(resume: () => boolean): () => Promise { + return async () => ({ + spec: GROK, + command: '/fake/grok', + args: [], + cwd: '/workspace', + env: {}, + fullAccess: false, + resume: resume() + ? { + sessionId: PROVIDER_SESSION, + key: agentSessionProviderHandleKey({ + transport: ACP_HANDLE_TRANSPORT, + agent: 'grok', + nativeId: PROVIDER_SESSION + }), + mayBeUnsaved: () => false, + unannouncedLosses: () => [] + } + : null + }) +} + +export async function openHostRig( + options: { + script?: (agent: AcpScriptedAgent) => void + initialize?: Record + deps?: Partial + } = {} +) { + const state = hostTestState() + const store = await openTestAgentSessionRecordStore(state.root) + let generation = 0 + // As the runtime wires it: every exit and late send settlement the adapter observes reaches the host. + const hosted: { host: StructuredAgentSessionHost | null } = { host: null } + const journalDatabase = openTestJournalHostDatabase(state.root) + const rig = await openAcpAdapterRig({ + ...options, + deps: { + onEvent: (event) => void hosted.host?.handleAdapterEvent(event), + onDispatchSettledLate: (settlement) => void hosted.host?.settleLateDispatch(settlement), + now: () => HOST_TEST_NOW, + readProcessStartTime: async () => 1_700_000_000_000 + ++generation, + mintGeneration: () => `generation-${generation}`, + ...options.deps + } + }) + const host = new StructuredAgentSessionHost({ + agents: new StructuredAgentRegistry([ + { definition: acpStructuredAgentDefinition(GROK), adapter: rig.adapter } + ]), + logger: state.log.logger, + store, + adapter: rig.adapter, + journalDatabase, + recoveryCapsule: new AgentSessionRecoveryCapsule(state.root), + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + now: () => HOST_TEST_NOW + }) + hosted.host = host + replaceHostTestState({ store, host }) + const fence = () => store.getRecord(SESSION)?.lease.runtimeFence ?? 1 + const messages = async () => + (await host.history({ sessionId: SESSION, direction: 'tail' })).page.items + .filter((row) => row.body.kind === 'message') + .map((row) => messageText(row.body)) + /** Orca's send of `hello` as `m1`, and Grok's reply `text`, ended or left running. */ + const exchange = async (text: string, end: boolean) => { + const journal = host.collaboratorsForTests().sessions.get(SESSION)!.journal + await journal.appendItem({ provider: 'orca', clientMessageId: 'm1' }, hello, { + fence: fence(), + turnScope: { kind: 'thread' } + }) + await rig.adapter.dispatch({ + sessionId: SESSION, + clientMessageId: 'm1', + body: hello, + fence: fence() + }) + const prompt = await rig.frame('session/prompt') + rig.child().agent.notify('session/update', replyChunk('prompt:m1', text)) + if (end) { + rig.child().agent.reply(prompt, { stopReason: 'end_turn' }) + } + await rig.settle() + await host.flushStreamedEvents(SESSION) + } + return { rig, host, store, journalDatabase, fence, messages, exchange } +} + +/** Grok's capabilities: it loads and resumes sessions; Orca reopens with `session/load`. */ +export const RESUMES = { + agentCapabilities: { loadSession: true, sessionCapabilities: { resume: {} } } +} + +const promptMeta = z.object({ _meta: z.object({ promptId: z.string() }) }) +export const promptIdOf = (frame: FakeFrame): string => + promptMeta.parse(frame.params)._meta.promptId + +export function message(text: string): AgentJournalMessageItem { + return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } +} + +export async function send(host: StructuredAgentSessionHost, text: string): Promise { + const body = message(text) + const sent = await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + if (!sent.ok) { + throw new Error('send refused') + } + return sent.value.clientMessageId +} + +export function stop(host: StructuredAgentSessionHost, turnId?: string) { + return turnId === undefined + ? host.cancel(CALLER, { envelope: envelope('agentSession.cancel', {}) }) + : host.cancel(CALLER, { envelope: envelope('agentSession.cancel', { turnId }), turnId }) +} + +export const framesOf = (child: FakeAcpChild, method: string) => + child.agent.frames.filter((frame) => frame.method === method) + +/** A new Grok chat the host attached; Grok loads its session on a later start, counting each. */ +export async function openAttachedHostRig(deps: Partial = {}) { + const count = { loads: 0 } + let resumed = false + const rig = await openHostRig({ + initialize: RESUMES, + script: (agent) => + agent.on('session/load', (frame) => { + count.loads += 1 + agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS }) + }), + deps: { resolveLaunch: launch(() => resumed), ...deps } + }) + expect(await rig.host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) + resumed = true + const rows = async () => { + await rig.host.flushStreamedEvents(SESSION) + return (await rig.host.history({ sessionId: SESSION, direction: 'tail' })).page.items + } + const turns = async () => (await rows()).flatMap((row) => readAgentJournalTurn(row.body) ?? []) + return { ...rig, count, rows, turns } +} diff --git a/src/main/acp/acp-structured-lane.ts b/src/main/acp/acp-structured-lane.ts new file mode 100644 index 000000000000..958d20dc47ef --- /dev/null +++ b/src/main/acp/acp-structured-lane.ts @@ -0,0 +1,158 @@ +// One ACP child's path into the journal: the translator turns its traffic into grammar events, and +// the shared assembler writes them. Events apply strictly in order; one the sink refuses under +// backpressure holds every later one until the sink drains, so nothing is reordered or dropped. + +import { parseAgentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { + createProviderTimelineAssembler, + type ProviderTimelineAssembler +} from '../native-chat/agent-session-timeline/provider-timeline-assembler' +import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' +import { createLegacyProviderTimelineIdentityScheme } from '../native-chat/agent-session-timeline/provider-timeline-identity' +import type { ProviderTimelineSink } from '../native-chat/agent-session-timeline/provider-timeline-plan' +import type { AcpDialect } from './acp-dialects/acp-dialect' +import { AcpTimelineTranslator } from './acp-timeline-translator' + +/** How long a held event waits for the sink to say it drained before trying again on its own. */ +const BACKPRESSURE_RETRY_MS = 250 + +export type AcpStructuredLaneDeps = { + sink: ProviderTimelineSink + sessionId: string + agent: string + /** The agent's name as a person reads it, for the rows that name it. */ + agentName: string + generation: string + providerSessionId: string + dialect: AcpDialect + /** A send of Orca's reached the agent: the turn it opened has its first provider event. */ + onInputAccepted: (clientMessageId: string) => void + /** The sink refused for good; nothing more this child says can be journaled. */ + onFailed: (reason: string) => void +} + +export class AcpStructuredLane { + readonly translator: AcpTimelineTranslator + private readonly assembler: ProviderTimelineAssembler + private readonly backlog: ProviderTimelineEvent[] = [] + private readonly turnWatchers = new Set<() => void>() + private readonly requestIdentity: ReturnType + private retryTimer: ReturnType | null = null + private failed = false + private disposed = false + + constructor(private readonly deps: AcpStructuredLaneDeps) { + this.translator = new AcpTimelineTranslator({ + sessionId: deps.providerSessionId, + dialect: deps.dialect, + agentName: deps.agentName + }) + this.requestIdentity = createLegacyProviderTimelineIdentityScheme({ + agent: deps.agent, + sessionId: deps.sessionId + }) + this.assembler = createProviderTimelineAssembler({ + sink: deps.sink, + sessionId: deps.sessionId, + agent: deps.agent, + generation: deps.generation, + namespace: deps.providerSessionId + }) + } + + get openTurnId(): string | null { + return this.assembler.openTurnId + } + + apply(events: readonly ProviderTimelineEvent[]): void { + if (this.failed || this.disposed) { + return + } + this.backlog.push(...events) + this.drain() + } + + /** Resolves once `turnId` is no longer the open turn, or nothing more can be written. */ + whenTurnLeaves(turnId: string): Promise { + return new Promise((resolve) => { + const check = (): void => { + if (this.failed || this.disposed || this.openTurnId !== turnId) { + this.turnWatchers.delete(check) + resolve() + } + } + this.turnWatchers.add(check) + check() + }) + } + + /** The sink drained: whatever it held back goes now. */ + retry(): void { + this.drain() + } + + /** Whether the journal row `itemId` is the row of request `requestKey`, which the assembler + * writes under this acquisition's generation (any incarnation a reused key adds). */ + isRequestRow(itemId: string, requestKey: string): boolean { + const identity = parseAgentJournalItemKey(itemId) + if (identity?.provider !== 'legacy' || identity.sessionId !== this.deps.sessionId) { + return false + } + const first = this.requestIdentity.request({ + generation: this.deps.generation, + key: requestKey, + incarnation: 1 + }) + const prefix = first.provider === 'legacy' ? first.recordId : null + return identity.recordId === prefix || identity.recordId.startsWith(`${prefix}#`) + } + + flush(): void { + this.assembler.flush() + } + + dispose(): void { + this.disposed = true + this.clearRetry() + this.backlog.length = 0 + this.assembler.dispose() + this.notifyTurnWatchers() + } + + private drain(): void { + this.clearRetry() + while (this.backlog.length > 0 && !this.failed && !this.disposed) { + const event = this.backlog[0] + const { admission } = this.assembler.apply(event) + if (!admission.accepted) { + if (admission.reason === 'backpressure') { + this.retryTimer = setTimeout(() => this.drain(), BACKPRESSURE_RETRY_MS) + return + } + this.failed = true + this.backlog.length = 0 + this.notifyTurnWatchers() + this.deps.onFailed(admission.reason) + return + } + this.backlog.shift() + if (event.type === 'input.accepted') { + this.deps.onInputAccepted(event.clientMessageId) + } + } + this.notifyTurnWatchers() + } + + private notifyTurnWatchers(): void { + for (const check of this.turnWatchers) { + check() + } + } + + private clearRetry(): void { + if (this.retryTimer) { + clearTimeout(this.retryTimer) + this.retryTimer = null + } + } +} diff --git a/src/main/acp/acp-structured-launch-resolution.test.ts b/src/main/acp/acp-structured-launch-resolution.test.ts new file mode 100644 index 000000000000..0e7af326a604 --- /dev/null +++ b/src/main/acp/acp-structured-launch-resolution.test.ts @@ -0,0 +1,290 @@ +import { describe, expect, it } from 'vitest' +import { + agentSessionProviderHandleKey, + type AgentSessionProviderHandle +} from '../../shared/agent-session-provider-handle' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' +import type { JournalLoad } from '../native-chat/agent-session-journal/journal-open' +import { createJournalReducerState } from '../native-chat/agent-session-journal/journal-reducer' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { + createLegacyProviderTimelineIdentityScheme, + spellProviderTimelineKey +} from '../native-chat/agent-session-timeline/provider-timeline-identity' +import { createProviderSpawnSpec } from '../provider-process/provider-process-supervisor' +import { ACP_CHILD_ENV_TO_DELETE, acpLaunchSpecFor } from './acp-launch-specs' +import { acpSessionNotRestoredItem } from './acp-session-reopen-failure' +import { createAcpStructuredLaunchResolver } from './acp-structured-launch-resolution' + +const GROK = acpLaunchSpecFor('grok')! +const identity = { + sessionId: 'session-alpha-1', + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'grok', + providerHandle: null +} + +function grokRecord(overrides: Partial = {}): AgentSessionRecord { + return { + ...agentSessionRecordFixture(), + provider: 'grok', + providerHandleChain: [], + accountHome: { variable: 'GROK_HOME', path: '/home/user/.grok-work' }, + ...overrides + } +} + +/** A journal holding one turn of each named provider session. */ +function journalWithTurns(...providerSessions: string[]): JournalLoad { + const state = createJournalReducerState(identity.sessionId, 'epoch-1') + providerSessions.forEach((providerSession, index) => { + const turnId = spellProviderTimelineKey(providerSession, { + source: 'provider', + value: `prompt:m${index}` + }) + state.items.set(`turn-${index}`, { + itemId: `turn-${index}`, + revision: 1, + sequence: index + 2, + observedAt: 1, + body: { kind: 'turn', turnId, state: 'completed' } + }) + }) + return { state, newer: null, damage: null } +} + +function resolver( + record: AgentSessionRecord, + fullAccess = false, + readJournal: () => JournalLoad | null = () => null +) { + const searched: (string | null | undefined)[] = [] + return { + searched, + resolve: createAcpStructuredLaunchResolver(GROK, { + store: { getRecord: () => record }, + readJournal, + resolveWorkspacePath: async () => '/repo/worktree', + resolveEnvironment: async () => ({ PATH: '/usr/bin', HOME: '/home/user' }), + resolveLaunchEnv: () => ({ GROK_EXTRA: '1' }), + resolveFullAccess: () => fullAccess, + resolveCommand: (command, options) => { + searched.push(options?.pathEnv) + return `/resolved/${command}` + } + }) + } +} + +describe('ACP launch resolution', () => { + it('pins the record account home and searches the agent install dir', async () => { + const { resolve, searched } = resolver(grokRecord()) + const launch = await resolve({ identity }) + expect(launch).toMatchObject({ + command: '/resolved/grok', + args: ['agent', 'stdio'], + cwd: '/repo/worktree', + fullAccess: false, + resume: null + }) + expect(launch.env).toMatchObject({ + GROK_HOME: '/home/user/.grok-work', + GROK_EXTRA: '1' + }) + expect(searched[0]).toContain('/home/user/.grok-work/bin') + }) + + it('asks the agent to approve everything only under full access', async () => { + const launch = await resolver(grokRecord(), true).resolve({ identity }) + expect(launch.args).toContain('--always-approve') + }) + + it('resumes the chain head by its key, possibly unsaved only when this chat created it', async () => { + const link = (origin: 'created' | 'resumed') => ({ + linkId: `link-${origin}`, + origin, + mintedAtFence: 1, + observedAt: 1, + handle: { transport: 'acp', agent: 'grok', nativeId: 'acp-1' } + }) + const created = await resolver(grokRecord({ providerHandleChain: [link('created')] })).resolve({ + identity + }) + const handle: AgentSessionProviderHandle = { + transport: 'acp', + agent: 'grok', + nativeId: 'acp-1' + } + const key = agentSessionProviderHandleKey(handle) + expect(created.resume).toMatchObject({ sessionId: 'acp-1', key }) + expect(created.resume?.mayBeUnsaved()).toBe(true) + const resumed = await resolver( + grokRecord({ providerHandleChain: [link('created'), link('resumed')] }), + false, + () => journalWithTurns() + ).resolve({ identity }) + expect(resumed.resume).toMatchObject({ sessionId: 'acp-1', key }) + expect(resumed.resume?.mayBeUnsaved()).toBe(false) + }) + + it('counts a created session as possibly unsaved only while the journal proves no turn on it', async () => { + const created = grokRecord({ + providerHandleChain: [ + { + linkId: 'link-created', + origin: 'created', + mintedAtFence: 1, + observedAt: 1, + handle: { transport: 'acp', agent: 'grok', nativeId: 'acp-1' } + } + ] + }) + const unsaved = async (readJournal: () => JournalLoad | null) => + (await resolver(created, false, readJournal).resolve({ identity })).resume?.mayBeUnsaved() + const reads: string[] = [] + const launch = await resolver(created, false, () => { + reads.push('read') + return null + }).resolve({ identity }) + // Read only when asked: a reopen that works never replays the journal. + expect(reads).toEqual([]) + expect(launch.resume?.mayBeUnsaved()).toBe(true) + + expect(await unsaved(() => journalWithTurns())).toBe(true) + expect(await unsaved(() => journalWithTurns('acp-other'))).toBe(true) + expect(await unsaved(() => journalWithTurns('acp-other', 'acp-1'))).toBe(false) + // A journal that does not read whole, or at all, proves nothing. + expect( + await unsaved(() => ({ + ...journalWithTurns(), + damage: { sequence: 3, cause: 'sequence-gap' } + })) + ).toBe(false) + expect(await unsaved(() => ({ ...journalWithTurns(), newer: { sequence: 3 } }))).toBe(false) + expect( + await unsaved(() => { + throw new Error('journal_closed') + }) + ).toBe(false) + }) + + it('names the lost conversations whose warning row no session of the chat wrote', async () => { + const link = (nativeId: string, replaced?: string) => ({ + linkId: `link-${nativeId}`, + origin: 'created' as const, + mintedAtFence: 1, + observedAt: 1, + handle: { transport: 'acp', agent: 'grok', nativeId }, + ...(replaced + ? { + replaces: { + key: agentSessionProviderHandleKey({ + transport: 'acp', + agent: 'grok', + nativeId: replaced + }), + reason: 'restore-failed', + replacedAt: 1 + } + } + : {}) + }) + const lostKey = agentSessionProviderHandleKey({ + transport: 'acp', + agent: 'grok', + nativeId: 'acp-1' + }) + const replaced = grokRecord({ providerHandleChain: [link('acp-1'), link('acp-2', 'acp-1')] }) + /** A journal holding the row for `lostKey`, written while `providerSession` ran. */ + const withRow = (providerSession: string): JournalLoad => { + const load = journalWithTurns() + const itemId = agentJournalItemKey( + createLegacyProviderTimelineIdentityScheme({ + agent: 'grok', + sessionId: identity.sessionId + }).item({ + namespace: providerSession, + family: 'item', + key: { source: 'provider', value: acpSessionNotRestoredItem(lostKey) }, + thread: providerSession + }) + ) + load.state.items.set(itemId, { + itemId, + revision: 1, + sequence: 2, + observedAt: 1, + body: { kind: 'status', tone: 'warning', text: 'forgot' } + }) + return load + } + const losses = async (record: AgentSessionRecord, readJournal: () => JournalLoad | null) => + (await resolver(record, false, readJournal).resolve({ identity })).resume?.unannouncedLosses() + + const reads: string[] = [] + // A chain that lost nothing never reads the journal. + expect( + await losses(grokRecord({ providerHandleChain: [link('acp-1')] }), () => { + reads.push('read') + return null + }) + ).toEqual([]) + expect(reads).toEqual([]) + expect(await losses(replaced, () => null)).toEqual([lostKey]) + expect(await losses(replaced, () => journalWithTurns('acp-2'))).toEqual([lostKey]) + expect(await losses(replaced, () => withRow('acp-2'))).toEqual([]) + // A row the superseded replacement wrote still counts. + expect(await losses(replaced, () => withRow('acp-gone'))).toEqual([]) + // A journal that does not read whole proves nothing, so no row is written. + expect( + await losses(replaced, () => ({ + ...journalWithTurns(), + damage: { sequence: 3, cause: 'sequence-gap' } + })) + ).toEqual([]) + expect( + await losses(replaced, () => { + throw new Error('journal_closed') + }) + ).toEqual([]) + }) + + it('refuses a record pinned to another host or another agent', async () => { + const remote = grokRecord() + remote.location = { ...remote.location, executionHostId: 'ssh:box' } + await expect(resolver(remote).resolve({ identity })).rejects.toThrow(/run on this runtime/) + await expect(resolver(agentSessionRecordFixture()).resolve({ identity })).rejects.toThrow( + /claude session/ + ) + }) +}) + +describe('Grok status: the structured session is the only producer', () => { + it('strips every pane identity and hook endpoint an inherited environment carries', () => { + const inherited = { + PATH: '/usr/bin', + ORCA_PANE_KEY: 'tab-1:pane-1', + ORCA_AGENT_PANE: 'tab-1:pane-1', + ORCA_TAB_ID: 'tab-1', + ORCA_WORKTREE_ID: 'wt-1', + ORCA_AGENT_LAUNCH_TOKEN: 'launch-1', + ORCA_AGENT_HOOK_PORT: '4321', + ORCA_AGENT_HOOK_TOKEN: 'secret', + ORCA_AGENT_HOOK_ENDPOINT: '/tmp/endpoint' + } + const spec = createProviderSpawnSpec( + { + command: 'grok', + args: ['agent', 'stdio'], + env: { GROK_HOME: '/home/user/.grok' }, + envToDelete: ACP_CHILD_ENV_TO_DELETE + }, + inherited, + 'win32' + ) + expect(Object.keys(spec.env).filter((key) => key.startsWith('ORCA_'))).toEqual([]) + expect(spec.env).toMatchObject({ PATH: '/usr/bin', GROK_HOME: '/home/user/.grok' }) + }) +}) diff --git a/src/main/acp/acp-structured-launch-resolution.ts b/src/main/acp/acp-structured-launch-resolution.ts new file mode 100644 index 000000000000..dbe372495a0a --- /dev/null +++ b/src/main/acp/acp-structured-launch-resolution.ts @@ -0,0 +1,192 @@ +// How a durable session record becomes an ACP agent launch, on the machine this runtime runs on. +// +// Every input is read back from the record the store made durable, never from the call that +// triggered the acquire: the working directory, the account home, and the provider session a +// resume names are the ones this session proved. + +import { delimiter } from 'node:path' +import { homedir } from 'node:os' +import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' +import { parseAgentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { + agentSessionProviderHandleChainHead, + agentSessionProviderHandleKey, + type AgentSessionProviderHandleChain +} from '../../shared/agent-session-provider-handle' +import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import { resolveCliCommand } from '../../shared/node-cli-command-resolution' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import type { JournalLoad } from '../native-chat/agent-session-journal/journal-open' +import { + isProviderTimelineTurnInNamespace, + providerTimelineKeyPart, + spelledProviderTimelineItemKey +} from '../native-chat/agent-session-timeline/provider-timeline-identity' +import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' +import type { AcpLaunchSpec } from './acp-launch-specs' +import { acpSessionNotRestoredItem } from './acp-session-reopen-failure' + +export type AcpStructuredLaunch = { + spec: AcpLaunchSpec + command: string + args: string[] + cwd: string + env: Record + fullAccess: boolean + /** The provider session to load; null starts a new one. */ + resume: { + sessionId: string + /** The chain key of the session to load, which a fresh session names if it takes over. */ + key: string + /** Only a session this chat created and never exchanged a turn on may be one the agent never + * saved, and so be superseded. Read only when the agent cannot reopen it. */ + mayBeUnsaved: () => boolean + /** Keys of the conversations the chain says the agent lost whose warning row the chat does not + * hold, as when the attach that would have written it failed. Read once the start succeeds. */ + unannouncedLosses: () => string[] + } | null +} + +export type AcpStructuredLaunchResolverDeps = { + store: Pick + /** The chat's journal as it stands, read without opening it; null when it has none. */ + readJournal: (sessionId: string) => JournalLoad | null + resolveWorkspacePath: (workspaceId: string) => Promise + /** The shared base env; re-read per acquisition. */ + resolveEnvironment: () => Promise> + /** The user's per-agent environment overlay from settings. */ + resolveLaunchEnv?: (agent: string) => Record + /** The Agent Permissions setting's bypass posture for this agent, re-read per acquisition. */ + resolveFullAccess?: (agent: string) => boolean + resolveCommand?: typeof resolveCliCommand + homePath?: string +} + +export function createAcpStructuredLaunchResolver( + spec: AcpLaunchSpec, + deps: AcpStructuredLaunchResolverDeps +): (input: { identity: AgentSessionJournalIdentity }) => Promise { + return async ({ identity }) => { + const record = deps.store.getRecord(identity.sessionId) + if (!record) { + throw new Error(`no durable agent-session record for ${identity.sessionId}`) + } + if (record.provider !== spec.agent) { + throw new Error(`session ${identity.sessionId} is a ${record.provider} session`) + } + const { location, accountHome } = record + // A session pinned elsewhere belongs to that host's runtime; starting it here would put a + // second writer on the same provider session. + if (location.executionHostId !== LOCAL_EXECUTION_HOST_ID || location.wslDistro !== null) { + throw new Error( + `${spec.agent} structured sessions run on this runtime's host, not ${location.executionHostId}` + ) + } + if (accountHome.variable !== spec.accountHomeVariable) { + throw new Error( + `${spec.agent} sessions pin ${spec.accountHomeVariable}, not ${accountHome.variable}` + ) + } + const base = await deps.resolveEnvironment() + const env: Record = { + ...base, + ...deps.resolveLaunchEnv?.(spec.agent), + ...spec.env, + [spec.accountHomeVariable]: accountHome.path + } + const pathEnv = [env.PATH ?? env.Path, ...spec.installDirectories(accountHome.path)] + .filter((entry): entry is string => Boolean(entry)) + .join(delimiter) + const command = (deps.resolveCommand ?? resolveCliCommand)(spec.command, { + pathEnv, + homePath: env.HOME ?? env.USERPROFILE ?? deps.homePath ?? homedir() + }) + const fullAccess = deps.resolveFullAccess?.(spec.agent) ?? false + const head = agentSessionProviderHandleChainHead(record.providerHandleChain) + return { + spec, + command, + args: spec.args({ fullAccess }), + cwd: await deps.resolveWorkspacePath(location.workspaceId), + env, + fullAccess, + resume: head + ? { + sessionId: head.handle.nativeId, + key: agentSessionProviderHandleKey(head.handle), + mayBeUnsaved: () => + head.origin === 'created' && + nothingExchangedOn(deps.readJournal, identity.sessionId, head.handle.nativeId), + unannouncedLosses: () => + unannouncedLosses(deps.readJournal, identity.sessionId, record.providerHandleChain) + } + : null + } + } +} + +/** Proof the chat holds no turn of provider session `nativeId`; a journal that does not read whole, + * or does not read at all, proves nothing. */ +function nothingExchangedOn( + readJournal: AcpStructuredLaunchResolverDeps['readJournal'], + sessionId: string, + nativeId: string +): boolean { + let load: JournalLoad | null + try { + load = readJournal(sessionId) + } catch { + return false + } + if (!load) { + return true + } + if (load.damage || load.newer) { + return false + } + for (const item of load.state.items.values()) { + const turn = readAgentJournalTurn(item.body) + if (turn && isProviderTimelineTurnInNamespace(turn.turnId, nativeId)) { + return false + } + } + return true +} + +/** Derived on every start, never stored: a row an attach failure dropped is written by the next + * start that succeeds. A journal that does not read whole proves nothing, so none is written. */ +function unannouncedLosses( + readJournal: AcpStructuredLaunchResolverDeps['readJournal'], + sessionId: string, + chain: AgentSessionProviderHandleChain +): string[] { + const lost = chain.flatMap((link) => + link.replaces?.reason === 'restore-failed' ? [link.replaces.key] : [] + ) + if (lost.length === 0) { + return [] + } + let load: JournalLoad | null + try { + load = readJournal(sessionId) + } catch { + return [] + } + if (!load) { + return lost + } + if (load.damage || load.newer) { + return [] + } + const written = new Set() + for (const itemId of load.state.items.keys()) { + const identity = parseAgentJournalItemKey(itemId) + const spelled = + identity?.provider === 'legacy' ? spelledProviderTimelineItemKey(identity.recordId) : null + if (spelled !== null) { + written.add(spelled) + } + } + // Any session's row counts: one an unused replacement wrote stays when the agent supersedes it. + return lost.filter((key) => !written.has(providerTimelineKeyPart(acpSessionNotRestoredItem(key)))) +} diff --git a/src/main/acp/acp-structured-options.ts b/src/main/acp/acp-structured-options.ts new file mode 100644 index 000000000000..662c3c98c1a9 --- /dev/null +++ b/src/main/acp/acp-structured-options.ts @@ -0,0 +1,234 @@ +// What a running ACP agent reports about its own choices — config options (model, reasoning +// effort), the older model list, and its `/` commands — as Orca's option and command surfaces. +// Kept as the agent last reported it; nothing here is guessed or listed by Orca itself. + +import type { + AgentSessionModelOption, + AgentSessionOptionChoice, + AgentSessionOptionsResult, + AgentSessionSlashCommand +} from '../../shared/agent-session-wire' +import { isAcpStructuredOptionKey } from './acp-structured-agent-definitions' +import { AcpRpcError } from './acp-errors' +import type { AcpStructuredConnection } from './acp-structured-connection' +import { + SessionConfigSelectGroupSchema, + SessionConfigSelectOptionSchema, + type AvailableCommand, + type SessionConfigOption, + type SessionConfigSelectOption, + type SessionModelState +} from './generated/acp-protocol.generated' + +type SelectOption = Extract + +/** How an Orca option key reaches the agent: a config option it declared, or the model method. */ +export type AcpOptionWrite = + | { method: 'config'; configId: string; value: string } + | { method: 'model'; modelId: string } + +function selectChoices(option: SelectOption): SessionConfigSelectOption[] { + const choices: SessionConfigSelectOption[] = [] + for (const entry of option.options) { + const group = SessionConfigSelectGroupSchema.safeParse(entry) + if (group.success) { + choices.push(...group.data.options) + continue + } + const choice = SessionConfigSelectOptionSchema.safeParse(entry) + if (choice.success) { + choices.push(choice.data) + } + } + return choices +} + +function isSelect(option: SessionConfigOption): option is SelectOption { + return option.type === 'select' +} + +export class AcpStructuredOptions { + private configOptions: SessionConfigOption[] = [] + private models: SessionModelState | null = null + private commands: AgentSessionSlashCommand[] | undefined + + /** The session state a new, load or resume response reported. */ + adoptSession(response: { + configOptions?: SessionConfigOption[] | null + models?: SessionModelState | null + }): void { + this.configOptions = response.configOptions ?? [] + this.models = response.models ?? null + } + + adoptConfigOptions(configOptions: SessionConfigOption[] | null | undefined): void { + if (configOptions) { + this.configOptions = configOptions + } + } + + adoptModel(modelId: string): void { + if (this.models) { + this.models = { ...this.models, currentModelId: modelId } + } + } + + adoptCommands(commands: readonly AvailableCommand[]): void { + this.commands = commands.map((command) => ({ + name: command.name, + kind: 'command', + ...(command.description ? { description: command.description } : {}), + ...(command.input?.hint ? { argumentHint: command.input.hint } : {}) + })) + } + + readCommands(): AgentSessionSlashCommand[] | undefined { + return this.commands + } + + /** Where a pick of `key` goes; null when the agent offers nothing for it. */ + write(key: string, value: string): AcpOptionWrite | null { + if (key === 'model') { + const option = this.select('model') + if (option) { + return { method: 'config', configId: option.id, value } + } + return this.models ? { method: 'model', modelId: value } : null + } + if (key === 'effort') { + const option = this.select('thought_level') + return option ? { method: 'config', configId: option.id, value } : null + } + return null + } + + read(): Pick { + const modelOption = this.select('model') + const effortOption = this.select('thought_level') + const efforts: AgentSessionOptionChoice[] = effortOption + ? selectChoices(effortOption).map((choice) => ({ + value: choice.value, + label: choice.name, + ...(choice.description ? { description: choice.description } : {}) + })) + : [] + const currentModel = modelOption?.currentValue ?? this.models?.currentModelId ?? '' + const listed: { id: string; label: string; description?: string }[] = modelOption + ? selectChoices(modelOption).map((choice) => ({ + id: choice.value, + label: choice.name, + ...(choice.description ? { description: choice.description } : {}) + })) + : (this.models?.availableModels.map((model) => ({ + id: model.modelId, + label: model.name, + ...(model.description ? { description: model.description } : {}) + })) ?? []) + const models: AgentSessionModelOption[] = listed.map((model) => ({ + ...model, + isDefault: model.id === currentModel, + efforts, + ...(effortOption ? { defaultEffort: effortOption.currentValue } : {}) + })) + const confirmed = [...(currentModel ? ['model'] : []), ...(effortOption ? ['effort'] : [])] + return { + models, + current: { + model: currentModel, + ...(effortOption ? { effort: effortOption.currentValue } : {}), + confirmed + } + } + } + + /** The values the agent reports now, as the record's options would name them. */ + reported(): Readonly> { + const { current } = this.read() + return { + ...(current.model ? { model: current.model } : {}), + ...(current.effort ? { effort: current.effort } : {}) + } + } + + private select(category: string): SelectOption | null { + const option = this.configOptions.find( + (entry) => entry.category === category || (category === 'model' && entry.id === 'model') + ) + return option && isSelect(option) ? option : null + } +} + +/** Sends a pick and adopts the agent's answer, even one that lands after the wait gave up: that is + * still what the agent runs. The wait fails at `timeoutMs`, or once `signal` aborts. */ +export function writeAcpSessionOption( + connection: Pick, + options: AcpStructuredOptions, + write: AcpOptionWrite, + bound: { agent: string; timeoutMs: number; signal?: AbortSignal } +): Promise { + const { agent, timeoutMs, signal } = bound + if (signal?.aborted) { + return Promise.reject(new Error(`${agent} option write abandoned before it was sent`)) + } + const applied = + write.method === 'config' + ? connection + .setConfigOption(write.configId, write.value) + .then((result) => options.adoptConfigOptions(result.configOptions)) + : connection.setModel(write.modelId).then(() => options.adoptModel(write.modelId)) + return new Promise((resolve, reject) => { + const fail = (error: Error): void => { + settle() + reject(error) + } + const onAbort = (): void => fail(new Error(`${agent} option write abandoned`)) + const timer = setTimeout( + () => fail(new Error(`${agent} did not answer an option write within ${timeoutMs}ms`)), + timeoutMs + ) + const settle = (): void => { + clearTimeout(timer) + signal?.removeEventListener('abort', onAbort) + } + signal?.addEventListener('abort', onAbort, { once: true }) + applied.then(() => { + settle() + resolve() + }, fail) + }) +} + +/** Re-applies the chat's saved picks to the agent's new session; a pick it refuses is skipped and + * reported, never retried. */ +export async function restoreAcpSessionOptions( + connection: Pick, + options: AcpStructuredOptions, + saved: Readonly> | undefined +): Promise { + const skipped: string[] = [] + for (const [key, value] of Object.entries(saved ?? {})) { + const reported = options.reported() + if (!isAcpStructuredOptionKey(key) || reported[key] === value) { + continue + } + const write = options.write(key, value) + try { + if (write?.method === 'config') { + options.adoptConfigOptions( + (await connection.setConfigOption(write.configId, value)).configOptions + ) + } else if (write?.method === 'model') { + await connection.setModel(write.modelId) + options.adoptModel(write.modelId) + } else { + skipped.push(key) + } + } catch (error) { + if (!(error instanceof AcpRpcError)) { + throw error + } + skipped.push(key) + } + } + return skipped +} diff --git a/src/main/acp/acp-structured-prompts.ts b/src/main/acp/acp-structured-prompts.ts new file mode 100644 index 000000000000..7f9516e9827a --- /dev/null +++ b/src/main/acp/acp-structured-prompts.ts @@ -0,0 +1,170 @@ +// The agent's open requests to the user (permission, question, plan approval), each held as the +// JSON-RPC call it is until a person answers it, a Stop cancels it, or the agent withdraws it. +// An answer claims the request, commits the journal compare-and-set while the claim is held, and +// only then replies, so a second client loses the commit and the agent hears exactly one answer. +// Each request is this module's to answer: once the agent cancels it, or a Stop or steer withdraws +// what is open (`withdrawAll`), an unclaimed one is answered with the agent's own cancelled reply +// and a claimed one still sends the committed answer — except a permission the agent itself +// cancelled, which the protocol answers `cancelled` the moment it is cancelled. + +import type { AgentSessionPromptResponse } from '../../shared/agent-session-question-answer' +import { + AgentSessionPromptAnswerRejectedError, + AgentSessionPromptUnavailableError +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { AcpRequestPresentation } from './acp-dialects/acp-dialect' +import { AcpRpcError } from './acp-errors' +import type { AcpRequestContext } from './acp-json-rpc-peer' +import type { AcpStructuredLane } from './acp-structured-lane' + +type OpenRequest = { + key: string + presentation: AcpRequestPresentation + /** A permission, whose cancellation the protocol answers itself. */ + permission: boolean + signal: AbortSignal + claimed: boolean + /** A Stop or steer withdrew it while an answer was being saved: a failed save withdraws it. */ + withdrawing: boolean + settle: (reply: unknown) => void +} + +export class AcpStructuredPrompts { + private readonly open = new Map() + + constructor( + private readonly lane: () => AcpStructuredLane | null, + /** Whether a request arriving now belongs to a turn that may still ask the person. */ + private readonly admits: () => boolean, + /** Whether its turn may still show what a request answered at once carried. */ + private readonly shows: () => boolean + ) {} + + get size(): number { + return this.open.size + } + + /** Answers one agent request: a row the user can act on, or the translator's verdict on it. */ + handle(method: string, params: unknown, context: AcpRequestContext): Promise { + const lane = this.lane() + if (!lane || context.id === null) { + throw new AcpRpcError(-32603, 'ACP request arrived with no session to show it') + } + const translated = lane.translator.request(method, params, context.id) + if (translated.settled) { + // Answered at once; what it carried shows only while its turn may still show anything. + if (this.shows()) { + lane.apply(translated.events) + } + return Promise.resolve(translated.settled.reply) + } + if (translated.presentation && !this.admits()) { + // No prompt of Orca's runs, or a Stop or steer is cutting it short: the agent hears its own + // cancelled reply and no card opens. + return Promise.resolve(translated.presentation.reply(null)) + } + lane.apply(translated.events) + const opened = translated.events.find((event) => event.type === 'request.open') + if (!translated.presentation || opened?.type !== 'request.open') { + throw new AcpRpcError(-32601, `Unsupported ACP client method: ${method}`) + } + const key = opened.request + const presentation = translated.presentation + return new Promise((resolve) => { + const entry: OpenRequest = { + key, + presentation, + permission: method === 'session/request_permission', + signal: context.signal, + claimed: false, + withdrawing: false, + settle: (reply) => { + if (this.open.get(key) === entry) { + this.open.delete(key) + } + resolve(reply) + } + } + this.open.set(key, entry) + // The agent or a Stop gave up on it: its card can no longer be answered, and the agent hears + // its own cancelled reply. A claimed one finishes its answer. + context.signal.addEventListener( + 'abort', + () => { + if (this.open.get(key) === entry && !entry.claimed) { + this.withdraw(entry) + } + }, + { once: true } + ) + }) + } + + async answer(input: { + itemId: string + response: AgentSessionPromptResponse + commit: () => Promise + }): Promise { + const entry = this.find(input.itemId) + if (!entry) { + throw new AgentSessionPromptUnavailableError(input.itemId) + } + let reply: unknown + try { + reply = entry.presentation.reply(input.response) + } catch (error) { + throw new AgentSessionPromptAnswerRejectedError( + error instanceof Error ? error.message : String(error) + ) + } + entry.claimed = true + try { + await input.commit() + } catch (error) { + entry.claimed = false + if (entry.signal.aborted || entry.withdrawing) { + this.withdraw(entry) + } + throw error + } + entry.settle(reply) + if (entry.permission && entry.signal.aborted) { + // Cancelled while the answer was saved: the agent already heard `cancelled`, not this answer. + throw new Error(`the agent stopped waiting for the answer to ${input.itemId}`) + } + } + + /** A Stop or steer: the agent hears its own cancelled reply to everything no answer has claimed. */ + withdrawAll(): void { + for (const entry of this.open.values()) { + if (entry.claimed) { + entry.withdrawing = true + } else { + this.withdraw(entry) + } + } + } + + private withdraw(entry: OpenRequest): void { + entry.settle(entry.presentation.reply(null)) + this.lane()?.apply([{ type: 'request.withdrawn', request: entry.key }]) + } + + /** The child is gone: nobody can answer for it any more. */ + clear(): void { + this.open.clear() + } + + private find(itemId: string): OpenRequest | null { + const lane = this.lane() + if (!lane) { + return null + } + for (const entry of this.open.values()) { + if (!entry.claimed && lane.isRequestRow(itemId, entry.key)) { + return entry + } + } + return null + } +} diff --git a/src/main/acp/acp-structured-request-admission.test.ts b/src/main/acp/acp-structured-request-admission.test.ts new file mode 100644 index 000000000000..3bed54f87c9c --- /dev/null +++ b/src/main/acp/acp-structured-request-admission.test.ts @@ -0,0 +1,121 @@ +// What Grok asks reaches the person only while Orca's prompt runs and may still ask: a Stop or a +// steer withdraws what is open and refuses what arrives later with Grok's own cancelled reply, with +// no card, as does a turn Grok began itself; an answer the person already gave is still sent. + +import { afterEach, describe, expect, it } from 'vitest' +import { + closeProviderTimelineRigs, + SESSION +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { + openAcpAdapterRig, + PROVIDER_SESSION, + replyChunk, + sendHello +} from './acp-structured-adapter.test-support' + +afterEach(async () => { + await closeProviderTimelineRigs() +}) + +const question = (toolCallId: string) => ({ + sessionId: PROVIDER_SESSION, + toolCallId, + questions: [{ question: 'Which file?', options: [{ label: 'a.ts' }, { label: 'b.ts' }] }] +}) + +const permission = (toolCallId: string) => ({ + sessionId: PROVIDER_SESSION, + toolCall: { toolCallId, title: 'Write file' }, + options: [{ optionId: 'allow-once', name: 'Allow', kind: 'allow_once' }] +}) + +async function runningPrompt() { + const rig = await openAcpAdapterRig() + await rig.acquire() + await sendHello(rig, 'first') + await rig.frame('session/prompt') + rig.child().agent.notify('session/update', replyChunk('prompt:first', 'working')) + await rig.settle() + const cards = async () => + (await rig.rig.rows()).filter( + (row) => row.body.kind === 'question' || row.body.kind === 'approval' + ) + return { rig, agent: rig.child().agent, cards } +} + +describe('Grok requests and the turn they belong to', () => { + it('shows a question while its turn runs, and withdraws it on Stop with Grok own reply', async () => { + const { rig, agent, cards } = await runningPrompt() + const asked = agent.request(1, 'x.ai/ask_user_question', question('call-1')) + await rig.settle() + expect(await cards()).toHaveLength(1) + await rig.adapter.cancelTurn({ sessionId: SESSION, fence: 1 }) + expect(await asked).toMatchObject({ result: { outcome: 'cancelled' } }) + }) + + it('withdraws an open question when a steer cancels its turn', async () => { + const { rig, agent } = await runningPrompt() + const asked = agent.request(6, 'x.ai/ask_user_question', question('call-6')) + await rig.settle() + await sendHello(rig, 'steer') + expect(await asked).toMatchObject({ result: { outcome: 'cancelled' } }) + expect(rig.sent('session/cancel')).toHaveLength(1) + }) + + it('refuses a question that arrives after a Stop, opening no card', async () => { + const { rig, agent, cards } = await runningPrompt() + await rig.adapter.cancelTurn({ sessionId: SESSION, fence: 1 }) + const late = await agent.request(2, 'x.ai/ask_user_question', question('call-2')) + expect(late).toMatchObject({ result: { outcome: 'cancelled' } }) + expect(await cards()).toEqual([]) + }) + + it('refuses a permission or a question that arrives while a steer cuts the turn short', async () => { + const { rig, agent, cards } = await runningPrompt() + await sendHello(rig, 'steer') + await rig.settle() + expect(rig.sent('session/cancel')).toHaveLength(1) + expect( + await agent.request(3, 'session/request_permission', permission('call-3')) + ).toMatchObject({ result: { outcome: { outcome: 'cancelled' } } }) + expect(await agent.request(4, 'x.ai/ask_user_question', question('call-4'))).toMatchObject({ + result: { outcome: 'cancelled' } + }) + expect(await cards()).toEqual([]) + }) + + it('refuses a question during a turn Grok began itself, opening no card, yet shows its plan', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + const { agent } = rig.child() + agent.notify('session/update', replyChunk('task-completed-background-1', 'Build finished; now')) + await rig.settle() + expect(await agent.request(7, 'x.ai/ask_user_question', question('call-7'))).toMatchObject({ + result: { outcome: 'cancelled' } + }) + expect( + await agent.request(8, 'x.ai/exit_plan_mode', { + sessionId: PROVIDER_SESSION, + toolCallId: 'call-8', + planContent: '# Plan' + }) + ).toMatchObject({ result: { outcome: 'abandoned' } }) + await rig.settle() + const rows = await rig.rig.rows() + expect(rows.filter((row) => row.body.kind === 'question')).toEqual([]) + expect(rows.map((row) => row.body)).toContainEqual( + expect.objectContaining({ kind: 'status', presentation: 'plan-document', text: '# Plan' }) + ) + }) + + it('refuses a question when no turn is open', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + const { agent } = rig.child() + expect(await agent.request(5, 'x.ai/ask_user_question', question('call-5'))).toMatchObject({ + result: { outcome: 'cancelled' } + }) + expect((await rig.rig.rows()).filter((row) => row.body.kind === 'question')).toEqual([]) + }) +}) diff --git a/src/main/acp/acp-structured-session-adapter-deps.ts b/src/main/acp/acp-structured-session-adapter-deps.ts new file mode 100644 index 000000000000..3f59f4db3e4a --- /dev/null +++ b/src/main/acp/acp-structured-session-adapter-deps.ts @@ -0,0 +1,42 @@ +import type { StructuredAgentSessionLifecycleEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import type { + AgentJournalItemIdentity, + AgentSessionJournalIdentity +} from '../../shared/agent-session-journal-types' +import type { AgentJournalDispatchRejection } from '../../shared/agent-session-failure-words' +import type { AcpLaunchSpec } from './acp-launch-specs' +import type { ConnectAcpAgent } from './acp-structured-connection' +import type { AcpStructuredLaunch } from './acp-structured-launch-resolution' + +/** How long a Stop, counted from its cancel, lets the agent end its turn before the child goes. */ +export const ACP_STOP_GRACE_MS = 4_000 +/** How long a model or effort pick waits for the agent's answer, as Claude's and Codex's do. */ +export const ACP_OPTION_WRITE_TIMEOUT_MS = 30_000 + +export type AcpStructuredSessionAdapterDeps = { + spec: AcpLaunchSpec + resolveLaunch: (input: { identity: AgentSessionJournalIdentity }) => Promise + /** Starts the agent's process and owns its protocol: `createAcpAgentConnection` in production. */ + connect: ConnectAcpAgent + readProcessStartTime?: (pid: number) => Promise + /** Every exit, expected or not: the host ends that child's record. */ + onEvent?: (event: StructuredAgentSessionLifecycleEvent) => void + /** A send this adapter admitted, settled once the agent answered for it. */ + onDispatchSettledLate?: ( + settlement: { sessionId: string; clientMessageId: string } & ( + | { providerIdentity: AgentJournalItemIdentity } + | ({ state: 'rejected' } & AgentJournalDispatchRejection) + | { state: 'unknown'; reason: string } + ) + ) => void + logger?: StructuredAgentSessionLogger + now?: () => number + mintGeneration?: () => string + mintLinkId?: () => string + /** Bounds a Stop's wait for the agent to end its turn; past it the child is closed. */ + stopGraceMs?: number + /** Bounds a pick's wait for the agent's answer; past it the pick fails. */ + optionWriteTimeoutMs?: number + isWindowsProcessStartTimeAvailable?: () => boolean +} diff --git a/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts b/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts new file mode 100644 index 000000000000..bb845eb429fb --- /dev/null +++ b/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts @@ -0,0 +1,466 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import { + closeProviderTimelineRigs, + providerTurnId, + SESSION +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { StructuredAgentSessionTaskQueue } from '../native-chat/agent-session-wire/structured-agent-session-task-queue' +import { tick } from './acp-scripted-agent.test-support' +import { + GROK, + openAcpAdapterRig, + PROVIDER_SESSION, + replyChunk, + sendHello, + waitFor, + type AcpAdapterRig +} from './acp-structured-adapter.test-support' + +afterEach(async () => { + await closeProviderTimelineRigs() +}) + +async function journalTurns(rig: AcpAdapterRig) { + return (await rig.rig.rows()).flatMap((row) => readAgentJournalTurn(row.body) ?? []) +} + +const turnIdOf = (promptId: string) => providerTurnId(promptId, PROVIDER_SESSION) + +describe('ACP Stop names a turn', () => { + it('a Stop naming a turn that has ended stops nothing running now', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await sendHello(rig, 'old') + const old = await rig.frame('session/prompt') + rig.child().agent.notify('session/update', replyChunk('prompt:old', 'done')) + rig.child().agent.reply(old, { stopReason: 'end_turn' }) + await rig.settle() + await sendHello(rig, 'new') + const current = await rig.frame('session/prompt', 1) + rig.child().agent.notify('session/update', replyChunk('prompt:new', 'working')) + await rig.settle() + rig + .child() + .agent.on('session/cancel', () => + rig.child().agent.reply(current, { stopReason: 'cancelled' }) + ) + const live = turnIdOf('prompt:new') + await expect( + rig.adapter.cancelTurn({ + sessionId: SESSION, + fence: 1, + turnId: turnIdOf('prompt:old'), + resolveLiveTurnId: () => live + }) + ).resolves.toEqual({ cancelled: false, refusal: { turnNotRunning: true } }) + await rig.settle() + expect(rig.sent('session/cancel')).toHaveLength(0) + expect(rig.settled.map((settled) => settled.clientMessageId)).toEqual(['old', 'new']) + }) + + it('a Stop naming an ended turn during the gap before the next prompt opens stops that prompt', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await sendHello(rig, 'old') + const old = await rig.frame('session/prompt') + rig.child().agent.notify('session/update', replyChunk('prompt:old', 'done')) + rig.child().agent.reply(old, { stopReason: 'end_turn' }) + await rig.settle() + await sendHello(rig, 'new') + await rig.frame('session/prompt', 1) + await rig.settle() + // The journal shows no turn running: Grok has not echoed the new prompt yet, as with Claude. + await expect( + rig.adapter.cancelTurn({ + sessionId: SESSION, + fence: 1, + turnId: turnIdOf('prompt:old'), + resolveLiveTurnId: () => null + }) + ).resolves.toEqual({ cancelled: true }) + expect(rig.sent('session/cancel')).toHaveLength(1) + }) + + it('a Stop naming the turn running now stops it and withdraws the steer waiting on its cancel', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await sendHello(rig, 'new') + const current = await rig.frame('session/prompt') + rig.child().agent.notify('session/update', replyChunk('prompt:new', 'working')) + await sendHello(rig, 'follow-up') + await rig.settle() + rig + .child() + .agent.on('session/cancel', () => + rig.child().agent.reply(current, { stopReason: 'cancelled' }) + ) + const live = turnIdOf('prompt:new') + await expect( + rig.adapter.cancelTurn({ + sessionId: SESSION, + fence: 1, + turnId: live, + resolveLiveTurnId: () => live + }) + ).resolves.toEqual({ cancelled: true }) + // The steer's cancel, then the Stop's own bounded one. + expect(rig.sent('session/cancel')).toHaveLength(2) + expect(rig.settled).toEqual( + expect.arrayContaining([ + expect.objectContaining({ clientMessageId: 'follow-up', state: 'rejected' }) + ]) + ) + }) +}) + +describe('ACP Stop of a turn Grok began itself', () => { + it('cancels a turn opened by a background completion; the Stop waits for it to end', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + const { agent } = rig.child() + agent.notify('session/update', replyChunk('task-completed-background-1', 'Working on it')) + await rig.settle() + expect((await journalTurns(rig)).at(-1)).toMatchObject({ state: 'running' }) + agent.on('session/cancel', () => + agent.notify('x.ai/session_notification', { + sessionId: PROVIDER_SESSION, + update: { + sessionUpdate: 'turn_completed', + prompt_id: 'task-completed-background-1', + stop_reason: 'cancelled' + } + }) + ) + expect(rig.adapter.stopEndsSession()).toBe(true) + await expect(rig.adapter.cancelTurn({ sessionId: SESSION, fence: 1 })).resolves.toEqual({ + cancelled: true + }) + await rig.adapter.awaitStoppedRequestEnd(SESSION, Date.now()) + expect(rig.sent('session/cancel')).toHaveLength(1) + expect((await journalTurns(rig)).at(-1)).toMatchObject({ state: 'interrupted' }) + // The host ends the child next; the adapter does not end it on its own. + expect(rig.child().closes).toBe(0) + }) + + it("ends the Stop's wait at its grace when Grok never ends its own turn", async () => { + const rig = await openAcpAdapterRig({ deps: { stopGraceMs: 20 } }) + await rig.acquire() + rig.child().agent.notify('session/update', replyChunk('task-completed-background-1', 'Busy')) + await rig.settle() + await expect(rig.adapter.cancelTurn({ sessionId: SESSION, fence: 1 })).resolves.toEqual({ + cancelled: true + }) + await rig.adapter.awaitStoppedRequestEnd(SESSION, Date.now()) + expect((await journalTurns(rig)).at(-1)).toMatchObject({ state: 'running' }) + expect(rig.child().closes).toBe(0) + }) +}) + +describe('ACP connection loss', () => { + it('settles a send the agent never took as unknown when its stdin breaks, stops the child and tells the host', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await sendHello(rig, 'lost') + await rig.frame('session/prompt') + await sendHello(rig, 'held') + rig.child().agent.stdin.destroy() + await rig.settle() + await waitFor(() => + expect(rig.lifecycle).toMatchObject([ + { type: 'ended', cause: 'unexpected-exit', acquisitionGeneration: 'gen-acp' } + ]) + ) + expect(rig.settled).toEqual( + expect.arrayContaining([ + expect.objectContaining({ clientMessageId: 'lost', state: 'unknown' }), + expect.objectContaining({ clientMessageId: 'held', state: 'rejected' }) + ]) + ) + expect(rig.child().closes).toBe(1) + // Nothing was opened for a send the agent never echoed, so nothing claims it completed. + expect(await journalTurns(rig)).toEqual([]) + await expect(sendHello(rig, 'after')).rejects.toThrow(/no live grok child/) + }) + + it("ends a crash whose stdout closed before its exit at that exit, with Grok's last words", async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + rig.child().stderr = 'panic: out of memory' + rig.child().agent.stdout.end() + await rig.settle() + expect(rig.lifecycle).toEqual([]) + rig.child().exit() + await waitFor(() => expect(rig.lifecycle).toHaveLength(1)) + expect(rig.lifecycle[0]).toMatchObject({ + type: 'ended', + cause: 'unexpected-exit', + reason: 'grok ACP agent exited: panic: out of memory', + failure: { kind: 'providerExited', detail: { text: 'panic: out of memory' } } + }) + }) + + it('keeps a crash a crash when a stop Orca asks for lands before its unproven exit', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + const child = rig.child() + child.proveClose = async () => false + child.stderr = 'panic: late' + child.agent.close() + await rig.settle() + expect(rig.lifecycle).toEqual([]) + // Never left Orca, so it is not recorded as unconfirmed. + expect( + await rig.adapter.dispatch({ + sessionId: SESSION, + clientMessageId: 'after', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'after' }] }, + fence: 1 + }) + ).toEqual({ + state: 'rejected', + reason: 'Grok stopped before this message was sent.', + rejection: { kind: 'providerExited' } + }) + // The next start's stop of the old child is one Orca asked for; it does not rename the crash. + await expect(rig.acquire()).rejects.toMatchObject({ + name: 'AgentSessionAcquisitionExitUnprovenError' + }) + child.exit() + await waitFor(() => expect(rig.lifecycle).toHaveLength(1)) + expect(rig.lifecycle[0]).toMatchObject({ + cause: 'unexpected-exit', + reason: 'grok ACP agent exited: panic: late', + failure: { kind: 'providerExited', detail: { text: 'panic: late' } } + }) + }) + + it('leaves a running turn unverifiable, never completed, when its stdin breaks mid-turn', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await sendHello(rig, 'lost') + await rig.frame('session/prompt') + rig.child().agent.notify('session/update', replyChunk('prompt:lost', 'partial')) + await rig.settle() + rig.child().agent.stdin.destroy() + await waitFor(async () => + expect((await journalTurns(rig)).at(-1)).toMatchObject({ state: 'unverifiable' }) + ) + expect(rig.child().closes).toBe(1) + }) + + it('ends the turn as failed on an answer Orca cannot read, and sends the next message', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await sendHello(rig, 'garbled') + const prompt = await rig.frame('session/prompt') + rig.child().agent.notify('session/update', replyChunk('prompt:garbled', 'partial')) + // Usage without the totals the schema requires: the answer fails to parse. + rig.child().agent.reply(prompt, { + stopReason: 'end_turn', + usage: { inputTokens: 10, outputTokens: 2 } + }) + await rig.settle() + expect((await journalTurns(rig)).at(-1)).toMatchObject({ + state: 'completed', + outcome: 'failure' + }) + await sendHello(rig, 'next') + const next = await rig.frame('session/prompt', 1) + expect(rig.sent('session/cancel')).toEqual([]) + rig.child().agent.notify('session/update', replyChunk('prompt:next', 'ok')) + rig.child().agent.reply(next, { stopReason: 'end_turn' }) + await rig.settle() + expect(rig.settled.map((settled) => settled.clientMessageId)).toEqual(['garbled', 'next']) + expect(rig.child().closes).toBe(0) + expect(rig.lifecycle).toEqual([]) + }) + + it('leaves Grok running when it ends its stdout but can still be written to', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await sendHello(rig, 'quiet') + await rig.frame('session/prompt') + rig.child().agent.notify('session/update', replyChunk('prompt:quiet', 'partial')) + await rig.settle() + rig.child().agent.stdout.end() + await rig.settle() + expect((await journalTurns(rig)).at(-1)).toMatchObject({ state: 'running' }) + expect(rig.child().closes).toBe(0) + expect(rig.lifecycle).toEqual([]) + await expect(rig.adapter.closeSession(SESSION)).resolves.toBe(true) + expect(rig.lifecycle).toMatchObject([{ type: 'ended', cause: 'requested-close' }]) + }) +}) + +describe('ACP refusals', () => { + it('rejects a send the agent refused before starting its turn', async () => { + const rig = await openAcpAdapterRig({ + script: (agent) => + agent.on('session/prompt', (frame) => agent.fail(frame, -32602, 'unsupported input')) + }) + await rig.acquire() + await sendHello(rig, 'refused') + await waitFor(() => + expect(rig.settled).toMatchObject([ + { clientMessageId: 'refused', state: 'rejected', rejection: { kind: 'providerRejected' } } + ]) + ) + expect(await journalTurns(rig)).toEqual([]) + // The session takes the next send. + rig.child().agent.on('session/prompt', () => {}) + await sendHello(rig, 'next') + await rig.frame('session/prompt', 1) + }) + + it('ends a started turn as failed when the agent answers its prompt with an error', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await sendHello(rig, 'failing') + const prompt = await rig.frame('session/prompt') + rig.child().agent.notify('session/update', replyChunk('prompt:failing', 'partial')) + await rig.settle() + rig.child().agent.fail(prompt, -32603, 'model overloaded') + await waitFor(async () => + expect((await journalTurns(rig)).at(-1)).toMatchObject({ + state: 'completed', + outcome: 'failure' + }) + ) + expect(rig.settled).toMatchObject([{ clientMessageId: 'failing', providerIdentity: {} }]) + expect(rig.child().closes).toBe(0) + }) +}) + +describe('ACP startup that never answers', () => { + it('lets a close stop the child while the handshake is unanswered', async () => { + const rig = await openAcpAdapterRig({ script: (agent) => agent.on('initialize', () => {}) }) + const queue = new StructuredAgentSessionTaskQueue() + const start = new AbortController() + const acquiring = queue.serialize(SESSION, () => rig.acquire({ signal: start.signal })) + const failed = acquiring.catch((error: unknown) => error) + await rig.frame('initialize') + // What the host's close does outside the queue, then its queued stop. + start.abort() + const closing = queue.serialize(SESSION, () => rig.adapter.closeSession(SESSION)) + expect(rig.child().closes).toBe(1) + expect(await failed).toMatchObject({ message: 'Grok was closed while starting' }) + await expect(closing).resolves.toBe(true) + expect(rig.lifecycle).toEqual([]) + }) + + it('leaves the child of a start that returned alone when its signal aborts later', async () => { + const rig = await openAcpAdapterRig() + const start = new AbortController() + await rig.acquire({ signal: start.signal }) + // The host's close, landing while it still commits the attach, stops the child its own way. + start.abort(new Error('closed while starting')) + await rig.settle() + expect(rig.child().closes).toBe(0) + expect(rig.lifecycle).toEqual([]) + }) + + it('lets a handshake Grok never answers run on until the start is aborted', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + try { + const rig = await openAcpAdapterRig({ + script: (agent) => agent.on('session/new', () => {}) + }) + const start = new AbortController() + const failed = rig.acquire({ signal: start.signal }).catch((error: unknown) => error) + let settled = false + void failed.then(() => { + settled = true + }) + await rig.settle() + vi.advanceTimersByTime(30 * 60_000) + await rig.settle() + expect(settled).toBe(false) + expect(rig.child().closes).toBe(0) + start.abort() + expect(await failed).toBeInstanceOf(Error) + expect(rig.child().closes).toBeGreaterThan(0) + } finally { + vi.useRealTimers() + } + }) + + it('keeps the child of a failed start until its exit is proven, and starts no other meanwhile', async () => { + const rig = await openAcpAdapterRig({ + script: (agent) => agent.on('initialize', () => {}) + }) + const start = new AbortController() + const failed = rig.acquire({ signal: start.signal }).catch((error: unknown) => error) + await rig.frame('initialize') + const child = rig.child() + child.proveClose = vi.fn(async () => false) + start.abort() + expect(await failed).toMatchObject({ name: 'AgentSessionAcquisitionExitUnprovenError' }) + // Every later stop asks the child again, and none answers for it. + expect(await rig.adapter.closeSession(SESSION)).toBe(false) + await expect(rig.adapter.closeAll()).rejects.toThrow('could not be proven stopped') + expect(await rig.acquire().catch((error: unknown) => error)).toMatchObject({ + name: 'AgentSessionAcquisitionExitUnprovenError' + }) + expect(rig.spawned.filter((step) => step === 'spawn')).toHaveLength(1) + child.exit() + expect(await rig.adapter.closeSession(SESSION)).toBe(true) + }) + + it('spawns nothing for a start a close reached before its spawn', async () => { + let releaseLaunch: () => void = () => {} + const rig = await openAcpAdapterRig({ + deps: { + resolveLaunch: async () => { + await new Promise((resolve) => { + releaseLaunch = resolve + }) + return { + spec: GROK, + command: '/opt/grok/bin/grok', + args: [], + cwd: '/workspace/project', + env: {}, + fullAccess: false, + resume: null + } + } + } + }) + const start = new AbortController() + const failed = rig.acquire({ signal: start.signal }).catch((error: unknown) => error) + await tick() + start.abort() + releaseLaunch() + expect(await failed).toMatchObject({ name: 'AgentSessionPreSpawnError' }) + expect(rig.spawned).toEqual([]) + }) +}) + +describe('ACP session release', () => { + it('stops nothing for a chat it never started, as one an earlier Orca left', async () => { + const rig = await openAcpAdapterRig() + await expect(rig.adapter.releaseAcquisition({ sessionId: SESSION })).resolves.toBe(true) + expect(rig.spawned).toEqual([]) + expect(rig.lifecycle).toEqual([]) + }) + + it('keeps nothing of a chat once its child is proven closed', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + expect(rig.adapter.backgroundTaskStops(SESSION)).toBeDefined() + await rig.adapter.closeSession(SESSION) + // Both answer from the entry the adapter holds for a chat; none is left. + expect(rig.adapter.backgroundTaskStops(SESSION)).toBeUndefined() + expect(rig.adapter.readCommands(SESSION)).toBeUndefined() + }) + + it('keeps nothing of a chat whose child exited on its own', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + rig.child().exit() + await waitFor(() => expect(rig.lifecycle).toHaveLength(1)) + expect(rig.adapter.backgroundTaskStops(SESSION)).toBeUndefined() + await expect(rig.adapter.closeSession(SESSION)).resolves.toBe(true) + }) +}) diff --git a/src/main/acp/acp-structured-session-adapter-recordings.test.ts b/src/main/acp/acp-structured-session-adapter-recordings.test.ts new file mode 100644 index 000000000000..8b29e3fbb220 --- /dev/null +++ b/src/main/acp/acp-structured-session-adapter-recordings.test.ts @@ -0,0 +1,122 @@ +// The adapter against Grok's recorded traffic: each recording plays back as the agent, and the +// assertions read the journal a client would see. + +import { afterEach, describe, expect, it } from 'vitest' +import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import { + closeProviderTimelineRigs, + SESSION +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { + openAcpAdapterRig, + waitFor, + type AcpAdapterRig +} from './acp-structured-adapter.test-support' +import { GrokFixtureReplay } from './acp-structured-fixture-replay.test-support' +import { readAcpFixture } from './acp-timeline-fixture.test-support' + +afterEach(async () => { + await closeProviderTimelineRigs() +}) + +const ask: AgentJournalMessageItem = { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'Fixture prompt' }] +} + +async function replaying(name: string): Promise<{ rig: AcpAdapterRig; replay: GrokFixtureReplay }> { + const replay = new GrokFixtureReplay(await readAcpFixture(name)) + const rig = await openAcpAdapterRig({ script: (agent) => replay.attach(agent) }) + await rig.acquire() + return { rig, replay } +} + +async function turns(rig: AcpAdapterRig) { + return (await rig.rig.rows()).flatMap((row) => readAgentJournalTurn(row.body) ?? []) +} + +async function rowsOf(rig: AcpAdapterRig, kind: string) { + return (await rig.rig.rows()).filter((row) => row.body.kind === kind) +} + +function send(rig: AcpAdapterRig, clientMessageId: string) { + return rig.adapter.dispatch({ sessionId: SESSION, clientMessageId, body: ask, fence: 1 }) +} + +describe('Grok recordings through the adapter', () => { + it('s1: a read-only turn lands as one completed turn with its tools and reply', async () => { + const { rig, replay } = await replaying('s1-basic') + await send(rig, 'send-1') + await waitFor(() => expect(replay.awaiting).toBeNull()) + await waitFor(async () => + expect(await turns(rig)).toMatchObject([{ state: 'completed', outcome: 'success' }]) + ) + const tools = await rowsOf(rig, 'tool-call') + expect(tools.length).toBeGreaterThan(0) + expect( + tools.every((row) => row.body.kind === 'tool-call' && row.body.state === 'completed') + ).toBe(true) + expect( + (await rig.rig.rows()).some( + (row) => row.body.kind === 'message' && row.body.role === 'assistant' + ) + ).toBe(true) + expect(rig.settled).toMatchObject([{ clientMessageId: 'send-1', providerIdentity: {} }]) + }) + + it('s2: an allowed and then a rejected permission are each answered once, after the commit', async () => { + const { rig, replay } = await replaying('s2-permission') + const answer = async (optionId: string, index: number) => { + const approval = await waitFor(async () => { + const found = (await rowsOf(rig, 'approval'))[index] + if (!found || replay.awaiting !== 'reply') { + throw new Error('no pending approval yet') + } + return found + }) + await rig.adapter.answerPrompt({ + sessionId: SESSION, + itemId: approval.itemId, + kind: 'approval', + response: { kind: 'option', optionId }, + fence: 1, + commit: async () => {} + }) + } + await send(rig, 'send-1') + await answer('allow-once', 0) + await waitFor(async () => expect(await turns(rig)).toHaveLength(1)) + await waitFor(() => expect(replay.awaiting).toBe('session/prompt')) + await send(rig, 'send-2') + await answer('reject-once', 1) + await waitFor(() => expect(replay.awaiting).toBeNull()) + await waitFor(async () => + expect(await turns(rig)).toMatchObject([ + { state: 'completed', outcome: 'success' }, + { state: 'interrupted', outcome: 'cancellation' } + ]) + ) + }) + + it('s3: a Stop mid-tool ends the turn interrupted and the session takes the next prompt', async () => { + const { rig, replay } = await replaying('s3-cancel') + await send(rig, 'send-1') + await waitFor(() => expect(replay.awaiting).toBe('session/cancel')) + await expect(rig.adapter.cancelTurn({ sessionId: SESSION, fence: 1 })).resolves.toEqual({ + cancelled: true + }) + await waitFor(async () => + expect(await turns(rig)).toMatchObject([{ state: 'interrupted', outcome: 'cancellation' }]) + ) + // The recording left the shell call `in_progress`; the turn's end cuts it short. + const tool = (await rowsOf(rig, 'tool-call')).at(-1) + expect(tool?.body).toMatchObject({ kind: 'tool-call', state: 'failed', endedAs: 'interrupted' }) + await send(rig, 'send-2') + await waitFor(() => expect(replay.awaiting).toBeNull()) + await waitFor(async () => + expect((await turns(rig)).at(-1)).toMatchObject({ state: 'completed', outcome: 'success' }) + ) + }) +}) diff --git a/src/main/acp/acp-structured-session-adapter-resume.test.ts b/src/main/acp/acp-structured-session-adapter-resume.test.ts new file mode 100644 index 000000000000..1818beee8456 --- /dev/null +++ b/src/main/acp/acp-structured-session-adapter-resume.test.ts @@ -0,0 +1,419 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' +import { readAgentSessionFailureFact } from '../../shared/agent-session-failure' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import { + closeProviderTimelineRigs, + messageText, + SESSION +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { AgentSessionAcquisitionRefusal } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' +import type { AcpScriptedAgent } from './acp-scripted-agent.test-support' +import { ACP_REOPEN_FAILED } from './acp-session-reopen-failure' +import { + GROK_CONFIG_OPTIONS, + openAcpAdapterRig, + PROVIDER_SESSION, + replyChunk, + sendHello, + type AcpAdapterRig +} from './acp-structured-adapter.test-support' + +afterEach(async () => { + await closeProviderTimelineRigs() +}) + +const hello: AgentJournalMessageItem = { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'hello' }] +} + +/** Grok's own capabilities: it loads and resumes sessions; Orca reopens with `session/load`. */ +const RESUMES = { agentCapabilities: { loadSession: true, sessionCapabilities: { resume: {} } } } +const resume = { + launch: { + resume: { + sessionId: PROVIDER_SESSION, + key: 'acp:grok:saved', + mayBeUnsaved: () => false, + unannouncedLosses: () => [] + } + } +} + +/** Everything Grok may send while it reattaches: its saved exchange marked as replay, a task the + * dead process left running ended by the restart, and its context usage. */ +function sendsWhileAttaching(agent: AcpScriptedAgent, reply: string): void { + agent.notify('session/update', { + sessionId: PROVIDER_SESSION, + update: { sessionUpdate: 'user_message_chunk', content: { type: 'text', text: 'hello' } }, + _meta: { isReplay: true } + }) + agent.notify('session/update', replyChunk('prompt:m1', reply, { isReplay: true })) + agent.notify('x.ai/task_completed', { + sessionId: PROVIDER_SESSION, + update: { + sessionUpdate: 'task_completed', + task_snapshot: { task_id: 'task-orphan', command: 'sleep 600', signal: 'session_restart' } + } + }) + agent.notify('session/update', { + sessionId: PROVIDER_SESSION, + update: { sessionUpdate: 'usage_update', used: 4_000, size: 200_000 } + }) +} + +/** Orca's send of `hello` as `m1` and Grok's reply, ended or cut off. */ +async function exchange(rig: AcpAdapterRig, reply: string, end: boolean): Promise { + await rig.rig.eventSink.appendItem({ provider: 'orca', clientMessageId: 'm1' }, hello, { + turnScope: { kind: 'thread' } + }) + await sendHello(rig, 'm1') + const prompt = await rig.frame('session/prompt') + rig.child().agent.notify('session/update', replyChunk('prompt:m1', reply)) + if (end) { + rig.child().agent.reply(prompt, { stopReason: 'end_turn' }) + } + await rig.settle() +} + +const texts = async (rig: AcpAdapterRig) => + (await rig.rig.rows()).flatMap((row) => messageText(row.body) ?? []) + +const notRestoredRows = async (rig: AcpAdapterRig) => + (await rig.rig.rows()).filter( + (row) => + row.body.kind === 'status' && + readAgentSessionFailureFact(row.body.failure)?.kind === 'sessionNotRestored' + ) + +/** What Grok might send while it resumes with no replay mark: an old reply and its turn's end. */ +function sendsUnmarkedWhileAttaching(agent: AcpScriptedAgent): void { + agent.notify('session/update', replyChunk('prompt:old', 'stale text')) + agent.notify('x.ai/session_notification', { + sessionId: PROVIDER_SESSION, + update: { sessionUpdate: 'turn_completed', prompt_id: 'prompt:old', stop_reason: 'end_turn' } + }) +} + +describe('the reattach window', () => { + const resumesUnmarked = { + ...resume, + initialize: RESUMES, + script: (agent: AcpScriptedAgent) => + agent.on('session/load', (frame) => { + sendsUnmarkedWhileAttaching(agent) + agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS }) + }) + } + + it('writes nothing Grok sends while it loads, even unmarked as replay', async () => { + const rig = await openAcpAdapterRig(resumesUnmarked) + await rig.acquire() + await rig.settle() + expect(await rig.rig.rows()).toEqual([]) + }) + + it('leaves no turn from the window for a later Stop to find', async () => { + const rig = await openAcpAdapterRig({ + ...resumesUnmarked, + script: (agent: AcpScriptedAgent) => + agent.on('session/load', (frame) => { + // An old reply with no end and no replay mark. + agent.notify('session/update', replyChunk('prompt:old', 'stale text')) + agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS }) + }) + }) + await rig.acquire() + await rig.settle() + await expect(rig.adapter.cancelTurn({ sessionId: SESSION, fence: 1 })).resolves.toEqual({ + cancelled: false, + refusal: { turnNotRunning: true } + }) + expect(rig.child().closes).toBe(0) + }) +}) + +describe('the context meter across a reopen', () => { + it('keeps the last reading the journal holds; the load only refreshes the window', async () => { + const models = { + currentModelId: 'grok-4.7', + availableModels: [ + { modelId: 'grok-4.7', name: 'Grok 4.7', _meta: { totalContextTokens: 256_000 } } + ] + } + const rig = await openAcpAdapterRig({ + ...resume, + initialize: RESUMES, + script: (agent) => + // A load that replays no usage; its answer carries only the models. + agent.on('session/load', (frame) => + agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS, models }) + ) + }) + await rig.acquire() + await sendHello(rig, 'm1') + const prompt = await rig.frame('session/prompt') + const { agent } = rig.child() + agent.notify('session/update', replyChunk('prompt:m1', 'hi')) + agent.notify('session/update', { + sessionId: PROVIDER_SESSION, + update: { sessionUpdate: 'usage_update', used: 4_000, size: 200_000 } + }) + agent.reply(prompt, { stopReason: 'end_turn' }) + await rig.settle() + await rig.adapter.closeSession(SESSION) + await rig.acquire({ fence: 2 }) + await rig.settle() + const usage = (await rig.rig.rows()) + .flatMap((row) => readAgentJournalTurn(row.body) ?? []) + .at(-1)?.contextUsage + expect(usage).toMatchObject({ + window: { tokens: 256_000 }, + used: { usage: { inputTokens: 4_000 } } + }) + }) +}) + +describe('reattaching a Grok chat the journal holds', () => { + it('loads with session/load though Grok also resumes, and writes nothing it replays', async () => { + const rig = await openAcpAdapterRig({ + ...resume, + initialize: RESUMES, + script: (agent) => + agent.on('session/load', (frame) => { + sendsWhileAttaching(agent, 'hi') + agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS }) + }) + }) + await rig.acquire() + await exchange(rig, 'hi', true) + const before = (await rig.rig.rows()).map((row) => row.itemId) + await rig.adapter.closeSession(SESSION) + await rig.acquire({ fence: 2 }) + await rig.settle() + expect(rig.sent('session/load')).toHaveLength(1) + expect(rig.sent('session/resume')).toHaveLength(0) + // No second user bubble, no stale task row, and no turn opened by the load's own traffic. + expect((await rig.rig.rows()).map((row) => row.itemId)).toEqual(before) + expect(await texts(rig)).toEqual(['hello', 'hi']) + await expect(rig.adapter.cancelTurn({ sessionId: SESSION, fence: 2 })).resolves.toEqual({ + cancelled: false, + refusal: { turnNotRunning: true } + }) + }) + + it('loads an agent that only loads, writing none of what it replays', async () => { + const rig = await openAcpAdapterRig({ + ...resume, + script: (agent) => + agent.on('session/load', (frame) => { + sendsWhileAttaching(agent, 'hi') + agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS }) + }) + }) + await rig.acquire() + await exchange(rig, 'hi', true) + const before = await rig.rig.rows() + await rig.adapter.closeSession(SESSION) + await rig.acquire({ fence: 2 }) + await rig.settle() + expect(rig.sent('session/resume')).toHaveLength(0) + expect((await rig.rig.rows()).map((row) => row.itemId)).toEqual(before.map((row) => row.itemId)) + expect(await texts(rig)).toEqual(['hello', 'hi']) + }) + + it('leaves a reply cut off mid-turn as it was cut, never completed from what Grok saved', async () => { + const rig = await openAcpAdapterRig({ + ...resume, + script: (agent) => + agent.on('session/load', (frame) => { + sendsWhileAttaching(agent, 'complete saved reply') + agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS }) + }) + }) + await rig.acquire() + await exchange(rig, 'complete', false) + await rig.adapter.closeSession(SESSION) + await rig.acquire({ fence: 2 }) + await rig.settle() + const rows = await rig.rig.rows() + expect(await texts(rig)).toEqual(['hello', 'complete']) + expect(rows.flatMap((row) => readAgentJournalTurn(row.body)?.state ?? [])).toEqual([ + 'unverifiable' + ]) + }) + + it('writes nothing of the reattach into a sink the host binds only after the start', async () => { + const rig = await openAcpAdapterRig({ + ...resume, + initialize: RESUMES, + script: (agent) => + agent.on('session/load', (frame) => { + sendsWhileAttaching(agent, 'hi') + agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS }) + }) + }) + await rig.acquire() + await exchange(rig, 'hi', true) + await rig.adapter.closeSession(SESSION) + const before = await rig.rig.rows() + // The host's order: a fresh sink per start, bound to the journal once the start succeeded. + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging(SESSION)) + await rig.adapter.acquire({ + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'grok', + providerHandle: null + }, + fence: 2, + spawnToken: 'spawn-2', + events: deferred.sink + }) + deferred.bind({ journal: rig.rig.journal, fence: 2, publish: () => {} }) + expect(await deferred.drained()).toMatchObject({ ok: true }) + expect((await rig.rig.rows()).map((row) => row.itemId)).toEqual(before.map((row) => row.itemId)) + await rig.adapter.closeSession(SESSION) + deferred.close() + }) + + it('starts a new session in place of a created one that session/load reports missing', async () => { + const rig = await openAcpAdapterRig({ + launch: { + resume: { + sessionId: 'never-saved', + key: 'acp:grok:never-saved', + mayBeUnsaved: () => true, + unannouncedLosses: () => [] + } + }, + initialize: RESUMES, + script: (agent) => + agent.on('session/load', (frame) => agent.fail(frame, -32002, 'Resource not found')) + }) + const acquired = await rig.acquire() + expect(acquired.link).toMatchObject({ + origin: 'created', + handle: { nativeId: PROVIDER_SESSION }, + supersedesKey: 'acp:grok:never-saved' + }) + // The failed load left the translator taking prompts. + await exchange(rig, 'hi', true) + expect(await texts(rig)).toEqual(['hello', 'hi']) + // Nothing was forgotten: the agent never saved that session. + expect(await notRestoredRows(rig)).toEqual([]) + }) + + it.each([ + ['a saved session', false, -32603, 'session file is corrupt'], + ['a created session that fails for another reason', true, -32603, 'session file is corrupt'], + ['a saved session the agent reports missing', false, -32002, 'Resource not found'] + ])( + 'continues %s it cannot reopen in a new one, says so once, and keeps working', + async (_label, mayBeUnsaved, code, message) => { + const rig = await openAcpAdapterRig({ + launch: { + resume: { + sessionId: 'saved-1', + key: 'acp-key-saved-1', + mayBeUnsaved: () => mayBeUnsaved, + unannouncedLosses: () => [] + } + }, + initialize: RESUMES, + script: (agent) => agent.on('session/load', (frame) => agent.fail(frame, code, message)) + }) + const acquired = await rig.acquire() + expect(acquired.link).toMatchObject({ + origin: 'created', + handle: { nativeId: PROVIDER_SESSION }, + replaces: { key: 'acp-key-saved-1', reason: 'restore-failed', replacedAt: 5_000 } + }) + expect(acquired.link.supersedesKey).toBeUndefined() + expect(rig.sent('session/new')).toHaveLength(1) + const rows = await notRestoredRows(rig) + expect(rows).toHaveLength(1) + expect(rows[0]?.body).toMatchObject({ kind: 'status', tone: 'warning' }) + await exchange(rig, 'hi', true) + expect(await texts(rig)).toEqual(['hello', 'hi']) + expect(await notRestoredRows(rig)).toHaveLength(1) + } + ) + + it('refuses a signed-out agent at reopen rather than replacing its session', async () => { + const rig = await openAcpAdapterRig({ + launch: { + resume: { + sessionId: 'saved-1', + key: 'acp-key-saved-1', + mayBeUnsaved: () => false, + unannouncedLosses: () => [] + } + }, + initialize: RESUMES, + script: (agent) => + agent.on('session/load', (frame) => agent.fail(frame, -32000, 'Authentication required')) + }) + const failure = await rig.acquire().catch((error: unknown) => error) + expect(failure).toBeInstanceOf(AgentSessionAcquisitionRefusal) + expect(failure).toMatchObject({ reason: 'notSignedIn' }) + expect(rig.sent('session/new')).toEqual([]) + }) + + it('fails a start closed while the agent reopens rather than opening a new session', async () => { + const start = new AbortController() + const logger = { warn: vi.fn(), error: vi.fn() } + const rig = await openAcpAdapterRig({ + launch: { + resume: { + sessionId: 'saved-1', + key: 'acp-key-saved-1', + mayBeUnsaved: () => false, + unannouncedLosses: () => [] + } + }, + initialize: RESUMES, + deps: { logger }, + script: (agent) => + agent.on('session/load', (frame) => { + start.abort() + agent.fail(frame, -32603, 'session file is corrupt') + }) + }) + expect( + await rig.acquire({ signal: start.signal }).catch((error: unknown) => error) + ).toBeInstanceOf(Error) + expect(rig.sent('session/new')).toEqual([]) + expect(await notRestoredRows(rig)).toEqual([]) + // Nothing was replaced, so nothing says it was. + expect(logger.warn).not.toHaveBeenCalledWith(ACP_REOPEN_FAILED, expect.anything()) + }) + + it('keeps the slash commands Grok reports while it loads', async () => { + const rig = await openAcpAdapterRig({ + ...resume, + initialize: RESUMES, + script: (agent) => + agent.on('session/load', (frame) => { + agent.notify('session/update', { + sessionId: PROVIDER_SESSION, + update: { + sessionUpdate: 'available_commands_update', + availableCommands: [{ name: 'compact', description: 'Compress', input: null }] + } + }) + agent.reply(frame, { configOptions: [] }) + }) + }) + await rig.acquire() + await rig.settle() + expect(rig.adapter.readCommands(SESSION)).toEqual([ + { name: 'compact', kind: 'command', description: 'Compress' } + ]) + }) +}) diff --git a/src/main/acp/acp-structured-session-adapter-teardown.test.ts b/src/main/acp/acp-structured-session-adapter-teardown.test.ts new file mode 100644 index 000000000000..9d9e3022826e --- /dev/null +++ b/src/main/acp/acp-structured-session-adapter-teardown.test.ts @@ -0,0 +1,162 @@ +// A close, dispose or quit while a turn runs: the agent is asked to cancel it and given the Stop's +// grace to end it before its process goes, as a Stop does; an idle close goes at once. + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + closeProviderTimelineRigs, + SESSION +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { HOST_TEST_SESSION } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { tick, type FakeFrame } from './acp-scripted-agent.test-support' +import { + openAcpAdapterRig, + sendHello, + type AcpAdapterRig, + type FakeAcpChild +} from './acp-structured-adapter.test-support' +import { framesOf, openAttachedHostRig, send } from './acp-structured-host.test-support' +import { ACP_STOP_GRACE_MS } from './acp-structured-session-adapter-deps' + +afterEach(async () => { + vi.useRealTimers() + await closeProviderTimelineRigs() +}) + +/** What Orca had written, and whether the turn had ended, when it first asked the child to close. */ +function watchClose(child: FakeAcpChild, turn: { ended: boolean }) { + const atClose: { cancels: number | null; turnEnded: boolean | null } = { + cancels: null, + turnEnded: null + } + const close = child.close.bind(child) + child.close = async (error?: Error) => { + atClose.cancels ??= framesOf(child, 'session/cancel').length + atClose.turnEnded ??= turn.ended + return close(error) + } + return atClose +} + +/** The agent ends the cancelled turn a moment after Orca asks. */ +function answerCancel(child: FakeAcpChild, prompt: FakeFrame, turn: { ended: boolean }) { + child.agent.on('session/cancel', () => + setTimeout(() => { + turn.ended = true + child.agent.reply(prompt, { stopReason: 'cancelled' }) + }, 30) + ) +} + +async function runningPrompt(rig: AcpAdapterRig): Promise { + await rig.acquire() + await sendHello(rig, 'send-1') + return rig.frame('session/prompt') +} + +describe('ACP teardown of a running turn', () => { + it('a close cancels the turn first and ends the child once the agent ends it', async () => { + const rig = await openAcpAdapterRig() + const prompt = await runningPrompt(rig) + const turn = { ended: false } + answerCancel(rig.child(), prompt, turn) + const atClose = watchClose(rig.child(), turn) + await expect(rig.adapter.closeSession(SESSION)).resolves.toBe(true) + expect(atClose).toEqual({ cancels: 1, turnEnded: true }) + expect(rig.lifecycle).toMatchObject([{ type: 'ended', cause: 'requested-close' }]) + }) + + it('a dispose ends the child at the grace when the agent never ends its turn', async () => { + const rig = await openAcpAdapterRig() + await runningPrompt(rig) + const child = rig.child() + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + const closing = rig.adapter.disposeSession(SESSION) + await vi.advanceTimersByTimeAsync(ACP_STOP_GRACE_MS - 1) + await tick() + expect(framesOf(child, 'session/cancel')).toHaveLength(1) + expect(child.closes).toBe(0) + await vi.advanceTimersByTimeAsync(1) + await expect(closing).resolves.toBe(true) + expect(child.closes).toBe(1) + }) + + it('a quit cancels every running turn before it ends the children, within the grace', async () => { + const rig = await openAcpAdapterRig({ deps: { stopGraceMs: 20 } }) + await runningPrompt(rig) + const atClose = watchClose(rig.child(), { ended: false }) + await rig.adapter.closeAll() + expect(atClose).toEqual({ cancels: 1, turnEnded: false }) + expect(rig.child().closes).toBe(1) + }) + + it('a close still ends the child when the cancel cannot be written', async () => { + const rig = await openAcpAdapterRig({ deps: { stopGraceMs: 20 } }) + await runningPrompt(rig) + const cancel = vi.spyOn(rig.child(), 'cancel').mockRejectedValue(new Error('EPIPE')) + await expect(rig.adapter.closeSession(SESSION)).resolves.toBe(true) + expect(cancel).toHaveBeenCalledTimes(1) + expect(rig.child().closes).toBe(1) + }) + + it('a close after a Stop asks no second cancel and waits only what is left of its grace', async () => { + const rig = await openAcpAdapterRig({ deps: { stopGraceMs: 60_000 } }) + await runningPrompt(rig) + const child = rig.child() + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + await expect(rig.adapter.cancelTurn({ sessionId: SESSION, fence: 1 })).resolves.toEqual({ + cancelled: true + }) + await vi.advanceTimersByTimeAsync(20_000) + const closing = rig.adapter.closeSession(SESSION) + await vi.advanceTimersByTimeAsync(40_000 - 1) + expect(child.closes).toBe(0) + await vi.advanceTimersByTimeAsync(1) + await expect(closing).resolves.toBe(true) + expect(framesOf(child, 'session/cancel')).toHaveLength(1) + }) +}) + +describe('ACP teardown with nothing to cancel', () => { + it('an idle close writes no cancel and ends the child at once', async () => { + const rig = await openAcpAdapterRig({ deps: { stopGraceMs: 60_000 } }) + await rig.acquire() + await expect(rig.adapter.closeSession(SESSION)).resolves.toBe(true) + expect(framesOf(rig.child(), 'session/cancel')).toHaveLength(0) + expect(rig.child().closes).toBe(1) + }) + + it('a close after the connection broke writes no cancel and waits for nothing', async () => { + const rig = await openAcpAdapterRig({ deps: { stopGraceMs: 60_000 } }) + await runningPrompt(rig) + const child = rig.child() + const cancel = vi.spyOn(child, 'cancel') + let proves = false + child.proveClose = async () => { + if (proves) { + child.exit() + } + return proves + } + child.agent.stdin.destroy() + await vi.waitFor(() => expect(child.closes).toBe(1)) + proves = true + await expect(rig.adapter.closeSession(SESSION)).resolves.toBe(true) + expect(cancel).not.toHaveBeenCalled() + }) +}) + +describe('a Grok chat closed through the host while its turn runs', () => { + it('cancels the turn before ending the process, and the turn ends interrupted', async () => { + const { rig, host, turns } = await openAttachedHostRig() + const child = rig.child() + await send(host, 'hello') + const prompt = await rig.frame('session/prompt') + const turn = { ended: false } + answerCancel(child, prompt, turn) + const atClose = watchClose(child, turn) + await host.close(HOST_TEST_SESSION, 'user-close') + expect(atClose).toEqual({ cancels: 1, turnEnded: true }) + expect(child.exited).toBe(true) + expect((await turns()).at(-1)).toMatchObject({ state: 'interrupted' }) + }) +}) diff --git a/src/main/acp/acp-structured-session-adapter.test.ts b/src/main/acp/acp-structured-session-adapter.test.ts new file mode 100644 index 000000000000..3fad79da2362 --- /dev/null +++ b/src/main/acp/acp-structured-session-adapter.test.ts @@ -0,0 +1,463 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import { + closeProviderTimelineRigs, + SESSION +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { + AgentSessionAcquisitionExitProvenError, + AgentSessionAcquisitionRefusal, + AgentSessionAcquisitionRootExitObservedError +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { GENERIC_ACP_DIALECT } from './acp-dialects/acp-dialect' +import { ACP_CHILD_ENV_TO_DELETE } from './acp-launch-specs' +import { AGENT_HOOK_RUNTIME_ENV_KEYS } from '../ipc/pty/host-env/spawn-env-keys' +import { + GROK, + openAcpAdapterRig, + PID, + PROVIDER_SESSION, + waitFor, + type AcpAdapterRig +} from './acp-structured-adapter.test-support' +import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' + +afterEach(async () => { + await closeProviderTimelineRigs() +}) + +const hello: AgentJournalMessageItem = { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'hello' }] +} + +function send(rig: AcpAdapterRig, clientMessageId: string) { + return rig.adapter.dispatch({ sessionId: SESSION, clientMessageId, body: hello, fence: 1 }) +} + +function chunk(promptId: string, text: string) { + return { + sessionId: PROVIDER_SESSION, + update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text } }, + _meta: { promptId } + } +} + +describe('ACP structured session adapter: acquire', () => { + it('records the child before the handshake and starts a new session with client fs and terminals off', async () => { + const rig = await openAcpAdapterRig() + const acquired = await rig.acquire() + expect(rig.spawned).toEqual(['spawn', 'onSpawned', 'initialize']) + expect(acquired.process).toMatchObject({ pid: PID, spawnToken: 'spawn-1', hostId: 'local' }) + expect(acquired.link).toMatchObject({ + handle: { transport: 'acp', agent: 'grok', nativeId: PROVIDER_SESSION }, + origin: 'created', + mintedAtFence: 1 + }) + expect(rig.sent('initialize')[0]?.params).toMatchObject({ + clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false } + }) + expect(rig.sent('session/new')[0]?.params).toEqual({ + cwd: '/workspace/project', + mcpServers: [] + }) + }) + + it('launches without any pane identity, so the agent own status hooks stay silent', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + const launch = rig.child().launch + expect(launch.envToDelete).toEqual(expect.arrayContaining([...ACP_CHILD_ENV_TO_DELETE])) + expect(launch.envToDelete).toEqual( + expect.arrayContaining(['ORCA_PANE_KEY', 'ORCA_AGENT_PANE', ...AGENT_HOOK_RUNTIME_ENV_KEYS]) + ) + expect(launch.env).toMatchObject({ + ORCA_AGENT_SESSION_ID: SESSION, + ORCA_AGENT_SESSION_SPAWN_TOKEN: 'spawn-1' + }) + }) + + it('replaces a created session the agent never saved with a new one', async () => { + const rig = await openAcpAdapterRig({ + launch: { + resume: { + sessionId: 'lost', + key: 'old-key', + mayBeUnsaved: () => true, + unannouncedLosses: () => [] + } + }, + script: (agent) => + agent.on('session/load', (frame) => agent.fail(frame, -32002, 'Session not found')) + }) + const acquired = await rig.acquire({ fence: 2 }) + expect(acquired.link).toMatchObject({ origin: 'created', supersedesKey: 'old-key' }) + }) + + it('refuses a signed-out agent through the existing start-failure surface and stops its child', async () => { + const rig = await openAcpAdapterRig({ + script: (agent) => + agent.on('session/new', (frame) => agent.fail(frame, -32000, 'Authentication required')) + }) + const failure = await rig.acquire().catch((error: unknown) => error) + expect(failure).toBeInstanceOf(AgentSessionAcquisitionRefusal) + expect(failure).toMatchObject({ reason: 'notSignedIn' }) + expect(rig.child().closes).toBe(1) + }) +}) + +it('reports an agent that exits while starting with its own last words', async () => { + const rig = await openAcpAdapterRig({ + script: (agent) => + agent.on('session/new', () => { + const child = rig.child() + child.stderr = 'grok: config.toml is invalid' + child.exit() + }) + }) + const failure = await rig.acquire().catch((error: unknown) => error) + expect(failure).toBeInstanceOf(AgentSessionAcquisitionExitProvenError) + expect(failure).toMatchObject({ message: 'grok: config.toml is invalid' }) + expect(rig.lifecycle).toEqual([]) +}) + +it('reads those last words when the agent ends its stdout before its exit is seen', async () => { + const rig = await openAcpAdapterRig({ + script: (agent) => + agent.on('session/new', () => { + const child = rig.child() + child.stderr = 'grok: config.toml is invalid' + // On POSIX a dying process's stdout ends first; its exit is observed a moment later. + child.agent.stdout.end() + setTimeout(() => child.exit(), 5) + }) + }) + const failure = await rig.acquire().catch((error: unknown) => error) + expect(failure).toBeInstanceOf(AgentSessionAcquisitionExitProvenError) + expect(failure).toMatchObject({ message: 'grok: config.toml is invalid' }) +}) + +describe('ACP structured session adapter: turns', () => { + it("sends Grok's prompt identity only to an agent whose dialect echoes it", async () => { + const rig = await openAcpAdapterRig({ + deps: { spec: { ...GROK, dialect: GENERIC_ACP_DIALECT } } + }) + await rig.acquire() + await send(rig, 'send-1') + const prompt = await rig.frame('session/prompt') + expect(prompt.params).not.toHaveProperty('_meta') + }) + + it('sends a prompt under an injected id and settles it on the agent first event', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + expect(await send(rig, 'send-1')).toEqual({ state: 'admitted' }) + const prompt = await rig.frame('session/prompt') + expect(prompt.params).toMatchObject({ + prompt: [{ type: 'text', text: 'hello' }], + _meta: { promptId: 'prompt:send-1', requestId: 'prompt:send-1' } + }) + rig.child().agent.notify('session/update', chunk('prompt:send-1', 'Hi there')) + await waitFor(() => + expect(rig.settled).toEqual([ + { + sessionId: SESSION, + clientMessageId: 'send-1', + providerIdentity: { provider: 'orca', clientMessageId: 'send-1' } + } + ]) + ) + rig.child().agent.reply(prompt, { stopReason: 'end_turn' }) + await waitFor(async () => { + const turns = (await rig.rig.rows()).flatMap((row) => readAgentJournalTurn(row.body) ?? []) + expect(turns.at(-1)).toMatchObject({ state: 'completed', outcome: 'success' }) + }) + }) + + it('stops a running turn: open permissions answer cancelled, session/cancel goes out', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await send(rig, 'send-1') + const prompt = await rig.frame('session/prompt') + const { agent } = rig.child() + const permission = agent.request(7, 'session/request_permission', { + sessionId: PROVIDER_SESSION, + toolCall: { toolCallId: 'call-1', title: 'Write file' }, + options: [{ optionId: 'allow-once', name: 'Allow', kind: 'allow_once' }] + }) + agent.on('session/cancel', () => agent.reply(prompt, { stopReason: 'cancelled' })) + await rig.settle() + await expect(rig.adapter.cancelTurn({ sessionId: SESSION, fence: 1 })).resolves.toEqual({ + cancelled: true + }) + expect(await permission).toMatchObject({ result: { outcome: { outcome: 'cancelled' } } }) + expect(rig.sent('session/cancel')).toHaveLength(1) + await waitFor(async () => { + const turns = (await rig.rig.rows()).flatMap((row) => readAgentJournalTurn(row.body) ?? []) + expect(turns.at(-1)).toMatchObject({ state: 'interrupted' }) + }) + }) + + it('answers a Stop with no turn running as one the agent was not running', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await expect(rig.adapter.cancelTurn({ sessionId: SESSION, fence: 1 })).resolves.toEqual({ + cancelled: false, + refusal: { turnNotRunning: true } + }) + }) +}) + +describe('ACP structured session adapter: approvals', () => { + it('commits the journal answer before replying with the option the person chose', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await send(rig, 'send-1') + await rig.frame('session/prompt') + const { agent } = rig.child() + const reply = agent.request(3, 'session/request_permission', { + sessionId: PROVIDER_SESSION, + toolCall: { toolCallId: 'call-1', title: 'Write file' }, + options: [ + { optionId: 'allow-once', name: 'Allow', kind: 'allow_once' }, + { optionId: 'reject-once', name: 'Reject', kind: 'reject_once' } + ] + }) + const row = await waitFor(async () => { + const found = (await rig.rig.rows()).find((item) => item.body.kind === 'approval') + expect(found).toBeDefined() + return found! + }) + const order: string[] = [] + let answered = false + void reply.then(() => { + answered = true + order.push('reply') + }) + await rig.adapter.answerPrompt({ + sessionId: SESSION, + itemId: row.itemId, + kind: 'approval', + response: { kind: 'option', optionId: 'reject-once' }, + fence: 1, + commit: async () => { + order.push('commit') + expect(answered).toBe(false) + } + }) + expect(await reply).toMatchObject({ + result: { outcome: { outcome: 'selected', optionId: 'reject-once' } } + }) + expect(order).toEqual(['commit', 'reply']) + await expect( + rig.adapter.answerPrompt({ + sessionId: SESSION, + itemId: row.itemId, + kind: 'approval', + response: { kind: 'option', optionId: 'allow-once' }, + fence: 1, + commit: async () => {} + }) + ).rejects.toThrow(/no longer waiting/) + }) + + it('answers cancelled a permission Grok asks during a turn it began itself, with no card', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + const { agent } = rig.child() + agent.notify('session/update', chunk('task-completed-background-1', 'Build finished; now')) + await rig.settle() + const reply = await agent.request(3, 'session/request_permission', { + sessionId: PROVIDER_SESSION, + toolCall: { toolCallId: 'call-1', title: 'Write file' }, + options: [{ optionId: 'allow-once', name: 'Allow', kind: 'allow_once' }] + }) + expect(reply).toMatchObject({ result: { outcome: { outcome: 'cancelled' } } }) + expect((await rig.rig.rows()).some((row) => row.body.kind === 'approval')).toBe(false) + }) +}) + +describe('ACP structured session adapter: requests a Stop cancels', () => { + it("answers an open question with Grok's own cancelled reply once a Stop cancels it", async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await send(rig, 'send-1') + const prompt = await rig.frame('session/prompt') + const { agent } = rig.child() + const question = agent.request(5, '_x.ai/ask_user_question', { + sessionId: PROVIDER_SESSION, + toolCallId: 'call-2', + questions: [{ question: 'Which?', options: [{ label: 'A' }] }] + }) + await waitFor(async () => { + expect((await rig.rig.rows()).some((row) => row.body.kind === 'question')).toBe(true) + }) + agent.on('session/cancel', () => agent.reply(prompt, { stopReason: 'cancelled' })) + await expect(rig.adapter.cancelTurn({ sessionId: SESSION, fence: 1 })).resolves.toEqual({ + cancelled: true + }) + expect(await question).toMatchObject({ result: { outcome: 'cancelled' } }) + expect(agent.frames.filter((frame) => frame.id === 5)).toHaveLength(1) + }) + + it("shows Grok's plan in the plan row and answers its plan-mode exit at once, with no card", async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await send(rig, 'send-1') + await rig.frame('session/prompt') + const { agent } = rig.child() + const plan = await agent.request(7, '_x.ai/exit_plan_mode', { + sessionId: PROVIDER_SESSION, + toolCallId: 'call-3', + planContent: '# Plan\n- step' + }) + expect(plan).toMatchObject({ result: { outcome: 'abandoned' } }) + await rig.settle() + const rows = await rig.rig.rows() + expect(rows.filter((row) => row.body.kind === 'approval')).toEqual([]) + expect(rows.map((row) => row.body)).toContainEqual( + expect.objectContaining({ + kind: 'status', + presentation: 'plan-document', + text: '# Plan\n- step' + }) + ) + }) + + it('sends a permission answer whose save was under way when the Stop landed', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await send(rig, 'send-1') + const prompt = await rig.frame('session/prompt') + const { agent } = rig.child() + const permission = agent.request(6, 'session/request_permission', { + sessionId: PROVIDER_SESSION, + toolCall: { toolCallId: 'call-1', title: 'Write file' }, + options: [{ optionId: 'allow-once', name: 'Allow', kind: 'allow_once' }] + }) + const row = await waitFor(async () => { + const found = (await rig.rig.rows()).find((item) => item.body.kind === 'approval') + expect(found).toBeDefined() + return found! + }) + agent.on('session/cancel', () => agent.reply(prompt, { stopReason: 'cancelled' })) + const answering = rig.adapter.answerPrompt({ + sessionId: SESSION, + itemId: row.itemId, + kind: 'approval', + response: { kind: 'option', optionId: 'allow-once' }, + fence: 1, + // The Stop lands while the person's answer is being saved. + commit: () => rig.adapter.cancelTurn({ sessionId: SESSION, fence: 1 }).then(() => {}) + }) + // The Stop withdraws only what no answer has claimed. + await expect(answering).resolves.toBeUndefined() + expect(await permission).toMatchObject({ + result: { outcome: { outcome: 'selected', optionId: 'allow-once' } } + }) + expect(rig.sent('session/cancel')).toHaveLength(1) + }) +}) + +describe('ACP structured session adapter: options and commands', () => { + it('reads the agent own models and effort and writes a pick through set_config_option', async () => { + const rig = await openAcpAdapterRig({ + script: (agent) => + agent.on('session/set_config_option', (frame) => agent.reply(frame, { configOptions: [] })) + }) + await rig.acquire() + const options = await rig.adapter.readOptions({ sessionId: SESSION, fence: 1 }) + expect(options.current).toMatchObject({ model: 'grok-4.7', effort: 'high' }) + expect(options.models.map((model) => model.id)).toEqual(['grok-4.7', 'grok-4.6']) + expect(options.models[0]?.efforts.map((effort) => effort.value)).toEqual(['high', 'low']) + await rig.adapter.setOption({ sessionId: SESSION, key: 'model', value: 'grok-4.6', fence: 1 }) + expect(rig.sent('session/set_config_option')[0]?.params).toMatchObject({ + configId: 'model', + value: 'grok-4.6' + }) + await expect( + rig.adapter.setOption({ sessionId: SESSION, key: 'fastMode', value: 'true', fence: 1 }) + ).rejects.toThrow(/no session option/) + }) + + it('reports the agent own slash commands', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + expect(rig.adapter.readCommands(SESSION)).toBeUndefined() + rig.child().agent.notify('session/update', { + sessionId: PROVIDER_SESSION, + update: { + sessionUpdate: 'available_commands_update', + availableCommands: [ + { name: 'compact', description: 'Compress history', input: null }, + { name: 'always-approve', description: 'Toggle', input: { hint: 'on|off' } } + ] + } + }) + await waitFor(() => + expect(rig.adapter.readCommands(SESSION)).toEqual([ + { name: 'compact', kind: 'command', description: 'Compress history' }, + { name: 'always-approve', kind: 'command', description: 'Toggle', argumentHint: 'on|off' } + ]) + ) + }) +}) + +describe('ACP structured session adapter: close and exit', () => { + it('reports a close whose root exited but whose process tree was not proven gone', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + rig.child().processTreeUnproven = true + await expect(rig.adapter.closeSession(SESSION)).rejects.toBeInstanceOf( + AgentSessionAcquisitionRootExitObservedError + ) + expect(rig.lifecycle).toMatchObject([{ type: 'ended', cause: 'requested-close' }]) + }) + + it('closes with proof and reports a requested close', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await expect(rig.adapter.closeSession(SESSION)).resolves.toBe(true) + expect(rig.lifecycle).toMatchObject([ + { type: 'ended', sessionId: SESSION, cause: 'requested-close', fence: 1 } + ]) + await expect(rig.adapter.closeSession(SESSION)).resolves.toBe(true) + expect(rig.lifecycle).toHaveLength(1) + }) + + it('ends the session on an unexpected exit: held sends rejected, the running one unknown', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await send(rig, 'send-1') + await rig.frame('session/prompt') + await send(rig, 'send-2') + rig.child().stderr = 'grok: crashed' + rig.child().exit() + await waitFor(() => + expect(rig.lifecycle).toMatchObject([ + { + type: 'ended', + cause: 'unexpected-exit', + acquisitionGeneration: 'gen-acp', + failure: { kind: 'providerExited' } + } + ]) + ) + expect(rig.settled).toEqual( + expect.arrayContaining([ + expect.objectContaining({ clientMessageId: 'send-2', state: 'rejected' }), + expect.objectContaining({ clientMessageId: 'send-1', state: 'unknown' }) + ]) + ) + await expect(send(rig, 'send-3')).rejects.toThrow(/no live grok child/) + }) +}) + +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.env).toEqual({}) + }) +}) diff --git a/src/main/acp/acp-structured-session-adapter.ts b/src/main/acp/acp-structured-session-adapter.ts new file mode 100644 index 000000000000..b0dd9804b5ea --- /dev/null +++ b/src/main/acp/acp-structured-session-adapter.ts @@ -0,0 +1,341 @@ +// A structured chat over the Agent Client Protocol: one adapter per registered ACP agent, which +// the router drives like the Claude and Codex lanes. Rewind, compaction and goals are absent, so +// the chat hides them; everything else maps onto ACP methods. + +import { randomUUID } from 'node:crypto' +import { agentSessionFailureFact } from '../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../shared/agent-session-failure-words' +import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' +import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' +import { + AgentSessionAcquisitionExitProvenError, + AgentSessionAcquisitionRootExitObservedError, + AgentSessionAcquisitionExitUnprovenError, + AgentSessionPreSpawnError, + isAgentSessionPreSpawnError, + type AgentSessionAcquisition, + type AgentSessionDispatchOutcome, + type StructuredAgentSessionAcquireInput, + type StructuredAgentSessionAdapter, + type StructuredAgentSessionSetOptionInput +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { supportsSupervisedProviderChildLocation } from '../provider-process/supervised-provider-child-location' +import { withObservedProviderExit } from '../native-chat/agent-session-wire/structured-agent-session-failure-text' +import { acpAgentName, acquireAcpStructuredSession } from './acp-structured-acquire' +import { + closeAcpSessionJournal, + endAcpStructuredSession, + type AcpStructuredSession +} from './acp-structured-session' +import { AcpStructuredStarts, type AcpStartAttempt } from './acp-structured-starts' +import { waitForAcpExit } from './acp-structured-connection' +import { AcpConnectionClosedError } from './acp-errors' +import { acpPromptBlocks } from './acp-structured-turns' +import { awaitAcpTurnEnd, interruptAcpTurn, windDownAcpTurn } from './acp-structured-stop' +import { + ACP_OPTION_WRITE_TIMEOUT_MS, + ACP_STOP_GRACE_MS, + type AcpStructuredSessionAdapterDeps +} from './acp-structured-session-adapter-deps' +import { writeAcpSessionOption } from './acp-structured-options' + +export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapter { + /** Live children, and ones whose exit is not yet proven; a proven exit removes its entry. */ + private readonly sessions = new Map() + private readonly starts = new AcpStructuredStarts() + + constructor(private readonly deps: AcpStructuredSessionAdapterDeps) {} + + // The child runs on this runtime's own machine; Windows needs process start-time proof. + supportsLocation = (location: AgentSessionExecutionLocation): boolean => + supportsSupervisedProviderChildLocation(location, this.deps.isWindowsProcessStartTimeAvailable) + + async acquire(input: StructuredAgentSessionAcquireInput): Promise { + const sessionId = input.identity.sessionId + const attempt = this.starts.begin(input.signal) + try { + if (!(await this.stop(sessionId))) { + throw new AgentSessionAcquisitionExitUnprovenError( + new Error( + `the previous ${this.deps.spec.agent} child for ${sessionId} could not be stopped` + ) + ) + } + return await this.start(input, attempt) + } finally { + this.starts.end(attempt) + } + } + + private async start( + input: StructuredAgentSessionAcquireInput, + attempt: AcpStartAttempt + ): Promise { + const sessionId = input.identity.sessionId + const generation = this.deps.mintGeneration?.() ?? randomUUID() + try { + const { acquisition, session } = await acquireAcpStructuredSession({ + acquire: input, + deps: this.deps, + generation, + abandoned: () => attempt.signal.aborted, + track: (connection) => this.starts.track(attempt, connection), + onExit: (session) => { + if (session && this.sessions.get(sessionId) === session) { + this.finish(session, this.now()) + } + }, + onConnectionLost: (session, error) => this.connectionLost(session, error), + onSettled: (settlement) => this.deps.onDispatchSettledLate?.({ sessionId, ...settlement }), + forceClose: (id) => void this.forceCloseSession(id) + }) + if (attempt.signal.aborted) { + session.lane.dispose() + throw new Error('closed while starting') + } + this.sessions.set(sessionId, session) + return acquisition + } catch (error) { + const { connection } = attempt + if (connection && error instanceof AcpConnectionClosedError) { + // A protocol that broke before the exit was seen: wait (bounded) for that exit, so the + // failure carries the agent's last words, as a running session's end does. + await waitForAcpExit(connection, this.stopGraceMs(), attempt.signal) + } + // Checked before the close below, which would make any exit look like one Orca asked for. + const exitedOnItsOwn = connection?.exited === true && !attempt.signal.aborted + if (connection && !(await connection.close().catch(() => false))) { + // Kept, so the next start or quit closes this same process again; no second one spawns. + this.starts.retainFailed(sessionId, connection) + throw new AgentSessionAcquisitionExitUnprovenError(error) + } + if (attempt.signal.aborted) { + const closed = new Error( + `${acpAgentName(this.deps.spec.agent)} was closed while starting`, + { cause: error } + ) + throw connection ? closed : new AgentSessionPreSpawnError(closed) + } + if (connection && exitedOnItsOwn && !isAgentSessionPreSpawnError(error)) { + // The agent's own last words are what a person can act on. + throw new AgentSessionAcquisitionExitProvenError( + withObservedProviderExit( + new Error(connection.stderrTail() || String(error), { cause: error }) + ) + ) + } + throw error + } + } + + async dispatch(input: { + sessionId: string + clientMessageId: string + body: AgentJournalMessageItem + fence: number + requestedAt?: number + beforeDispatch?: () => Promise + }): Promise { + const lost = this.sessions.get(input.sessionId) + if (lost && lost.journalClosed !== null) { + // The connection broke and the exit is not yet proven: the message never left Orca. + return this.rejected(lost, 'providerExited') + } + const session = this.live(input.sessionId) + const prompt = acpPromptBlocks(input.body) + if (!prompt) { + return this.rejected(session, 'attachmentInvalid') + } + await input.beforeDispatch?.() + session.turns.dispatch({ + clientMessageId: input.clientMessageId, + prompt, + requestedAt: input.requestedAt ?? this.now() + }) + // The write is the admission; the agent's first event for the turn settles it. + return { state: 'admitted' } + } + + cancelTurn: StructuredAgentSessionAdapter['cancelTurn'] = async (input) => { + const session = this.live(input.sessionId) + // The Stop ends the child unless it is declined here. Claude's rule: a Stop naming an ended turn + // while another one is live stops nothing; in the gap before a follow-up's turn opens, which no + // client can name, it stops what is in flight. + const liveTurnId = input.resolveLiveTurnId?.() ?? session.lane.openTurnId + if (input.turnId !== undefined && liveTurnId !== null && input.turnId !== liveTurnId) { + return { cancelled: false, refusal: { turnNotRunning: true } } + } + if (!session.turns.running && session.lane.openTurnId === null && !session.turns.holdsSteers) { + return { cancelled: false, refusal: { turnNotRunning: true } } + } + // The agent may end its turn its own way; the host ends the process once that lands or the + // grace runs out (`awaitStoppedRequestEnd`), never waiting on the cancel's write. + interruptAcpTurn(session) + return { cancelled: true } + } + + // Stop is a session boundary for every ACP agent, as in the common pattern: a cancel ends only + // the running turn, and work the agent moved to the background could begin a turn of its own. + // The next send reloads the session. + stopEndsSession = (): boolean => true + + awaitStoppedRequestEnd = async (sessionId: string, stoppedAt: number): Promise => { + const session = this.sessions.get(sessionId) + if (session) { + await awaitAcpTurnEnd(session, stoppedAt, this.stopGraceMs()) + } + } + + answerPrompt: StructuredAgentSessionAdapter['answerPrompt'] = (input) => + this.live(input.sessionId).prompts.answer(input) + + async setOption( + input: StructuredAgentSessionSetOptionInput + ): Promise>> { + const session = this.live(input.sessionId) + const write = session.options.write(input.key, input.value) + if (!write) { + throw new Error(`${session.spec.agent} offers no session option named ${input.key}`) + } + // Bounded, and abandoned by a close or Stop: the session's queue waits on it. + await writeAcpSessionOption(session.connection, session.options, write, { + agent: session.spec.agent, + timeoutMs: this.deps.optionWriteTimeoutMs ?? ACP_OPTION_WRITE_TIMEOUT_MS, + ...(input.signal ? { signal: input.signal } : {}) + }) + return session.options.reported() + } + + readOptions = async (input: { sessionId: string; fence: number }) => + this.live(input.sessionId).options.read() + + readOptionRestoreFailures = (sessionId: string): readonly string[] => + this.sessions.get(sessionId)?.restoreSkipped ?? [] + + readCommands = (sessionId: string) => this.sessions.get(sessionId)?.options.readCommands() + + // ACP has no way to stop one background task the agent started. + backgroundTaskStops: NonNullable = ( + sessionId + ) => + this.sessions.has(sessionId) ? { supportsTaskStop: false, supportsStopAll: false } : undefined + + closeSession = (sessionId: string): Promise => this.close(sessionId) + disposeSession = (sessionId: string): Promise => this.close(sessionId) + releaseAcquisition = (input: { sessionId: string }): Promise => + this.close(input.sessionId) + /** After a sink failure: the exit is recovered as unexpected. */ + forceCloseSession = (sessionId: string): Promise => this.stop(sessionId, false) + + async closeAll(): Promise { + const ids = new Set([...this.sessions.keys(), ...this.starts.failedSessionIds()]) + // A start still under way is the host's to abort: its teardown does, before this runs. + const proven = await Promise.all([...ids].map((sessionId) => this.stop(sessionId, true))) + if (proven.includes(false)) { + throw new Error('an ACP agent child could not be proven stopped') + } + } + + /** A requested close: proven by the root's exit; a tree not proven gone is the caller's to report. */ + private async close(sessionId: string): Promise { + const connection = this.sessions.get(sessionId)?.connection + const closed = await this.stop(sessionId, true) + if (closed && connection?.processTreeUnproven) { + throw new AgentSessionAcquisitionRootExitObservedError( + new Error( + `${this.deps.spec.agent} ACP agent exited, but its process tree was not proven gone` + ) + ) + } + return closed + } + + /** True only once every child is proven gone, or when this adapter runs none for the session: + * a failed start's child, and the session's own. */ + private async stop(sessionId: string, requested = true): Promise { + const [failedStart, session] = await Promise.all([ + this.starts.stopFailed(sessionId), + this.stopSession(sessionId, requested) + ]) + return failedStart && session + } + + private async stopSession(sessionId: string, requested: boolean): Promise { + const session = this.sessions.get(sessionId) + if (!session || session.ended) { + return true + } + if (requested && session.journalClosed === null) { + // A close, dispose or quit ends a running turn as a Stop does before the child goes. + session.closeRequested = true + await windDownAcpTurn(session, this.stopGraceMs()) + if (session.ended) { + return true + } + } + // A connection loss already decided why the child ends; a later stop does not relabel it. + if (session.journalClosed === null) { + session.closeRequested ||= requested + session.lane.flush() + } + const proven = await session.connection.close() + if (proven) { + this.finish(session, session.exitObservedAt ?? this.now()) + } + return proven + } + + /** The child's exit is proven: the host hears it, and nothing of the child stays here. */ + private finish(session: AcpStructuredSession, observedAt: number): void { + endAcpStructuredSession(session, observedAt, this.deps.onEvent) + if (this.sessions.get(session.sessionId) === session) { + this.sessions.delete(session.sessionId) + } + } + + /** The connection broke while the child may still run: nothing more it says can be journaled, so + * the journal closes now and the child is stopped; the host hears `ended` once that is proven. */ + private connectionLost(session: AcpStructuredSession | null, error: Error): void { + if ( + !session || + this.sessions.get(session.sessionId) !== session || + session.journalClosed !== null + ) { + return + } + closeAcpSessionJournal( + session, + `${session.spec.agent} ACP connection closed: ${error.message || error.name}` + ) + void this.stop(session.sessionId, false) + } + + private rejected( + session: AcpStructuredSession, + kind: 'providerExited' | 'attachmentInvalid' + ): AgentSessionDispatchOutcome { + return { + state: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact(kind), { + surface: 'rejection', + agentName: acpAgentName(session.spec.agent) + }) + } + } + + private live(sessionId: string): AcpStructuredSession { + const session = this.sessions.get(sessionId) + if (!session || session.journalClosed !== null) { + throw new Error(`no live ${this.deps.spec.agent} child owns ${sessionId}`) + } + return session + } + + private stopGraceMs(): number { + return this.deps.stopGraceMs ?? ACP_STOP_GRACE_MS + } + + private now(): number { + return (this.deps.now ?? Date.now)() + } +} diff --git a/src/main/acp/acp-structured-session.ts b/src/main/acp/acp-structured-session.ts new file mode 100644 index 000000000000..6b9fef556e43 --- /dev/null +++ b/src/main/acp/acp-structured-session.ts @@ -0,0 +1,145 @@ +// One live ACP agent connection and what Orca keeps beside it, and how its traffic and its +// process's exit reach the journal and the host. + +import { agentSessionFailureFact, providerDiagnostic } from '../../shared/agent-session-failure' +import type { StructuredAgentSessionEndedEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { + UNVERIFIABLE_TURN_VERDICT, + type StructuredAgentSessionTurnVerdict +} from '../native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict' +import type { AcpLaunchSpec } from './acp-launch-specs' +import type { AcpSessionEvent } from './acp-session-runtime' +import type { AcpStructuredConnection } from './acp-structured-connection' +import type { AcpStructuredLane } from './acp-structured-lane' +import type { AcpStructuredOptions } from './acp-structured-options' +import type { AcpStructuredPrompts } from './acp-structured-prompts' +import type { AcpStructuredTurns } from './acp-structured-turns' + +export type AcpStructuredSession = { + sessionId: string + fence: number + acquisitionGeneration: string + spec: AcpLaunchSpec + /** The agent's process and its protocol, one owner. */ + connection: AcpStructuredConnection + lane: AcpStructuredLane + prompts: AcpStructuredPrompts + options: AcpStructuredOptions + turns: AcpStructuredTurns + /** Saved picks the agent refused when this child started. */ + restoreSkipped: readonly string[] + /** Orca asked this child to stop; its exit is then a requested close. */ + closeRequested: boolean + /** Why nothing this child says reaches the journal any more; null while it does. */ + journalClosed: string | null + /** Its exit is proven and the host was told. */ + ended: boolean + exitObservedAt: number | null + unbindReadingControl?: () => void +} + +/** Every agent frame goes through here once the lane exists: options and commands first, so a + * read right after the frame sees them, then the journal. `reattaching`: see `asReattachHistory`. */ +export function routeAcpSessionEvent( + session: Pick, + event: AcpSessionEvent, + at: number, + reattaching = false +): void { + if (event.kind === 'known') { + const { update } = event.notification + if (update.sessionUpdate === 'available_commands_update') { + session.options.adoptCommands(update.availableCommands) + } else if (update.sessionUpdate === 'config_option_update') { + session.options.adoptConfigOptions(update.configOptions) + } + } + const { translator } = session.lane + session.lane.apply( + reattaching + ? translator.notification( + 'session/update', + asReattachHistory(event.kind === 'known' ? event.notification : event.raw), + at + ) + : translator.sessionEvent(event, at) + ) +} + +/** While a chat reattaches, everything the agent sends is replayed history, whatever the agent + * marked: marked as replay, the translator drops it and keeps only its usage. */ +export function asReattachHistory(params: unknown): unknown { + if (typeof params !== 'object' || params === null || Array.isArray(params)) { + return params + } + const meta = '_meta' in params ? params._meta : undefined + const marked = typeof meta === 'object' && meta !== null && !Array.isArray(meta) ? meta : {} + return { ...params, _meta: { ...marked, isReplay: true } } +} + +/** + * Nothing more this child says reaches the journal, once: on its exit, or when its connection broke + * while it may still run. Open requests die, held sends never left Orca, the running send's fate is + * unknown, and the running turn ends as `verdict` says: `unverifiable` while the child may still + * run (the host's settlement of its proven exit revises it), interrupted at a proven exit. + */ +export function closeAcpSessionJournal( + session: AcpStructuredSession, + reason: string, + verdict: StructuredAgentSessionTurnVerdict = UNVERIFIABLE_TURN_VERDICT +): void { + if (session.journalClosed !== null) { + return + } + session.journalClosed = reason + session.prompts.clear() + session.turns.end(reason) + session.lane.apply([{ type: 'session.ended', verdict }]) + session.lane.flush() + session.lane.dispose() + session.unbindReadingControl?.() +} + +/** The child's proven exit, once: the journal closes if it has not, and the host hears `ended` so + * it releases the session. */ +export function endAcpStructuredSession( + session: AcpStructuredSession, + observedAt: number, + onEvent: ((event: StructuredAgentSessionEndedEvent) => void) | undefined +): void { + if (session.ended) { + return + } + session.ended = true + session.exitObservedAt = observedAt + const stderr = session.connection.stderrTail() + // Read at the proven exit: a crash usually ends the agent's stdout before its exit is observed, + // so the connection's close comes first, but the agent's own last words are what explain it. + const reason = session.closeRequested + ? `${session.spec.agent} ACP agent closed by Orca` + : session.journalClosed !== null && !stderr + ? session.journalClosed + : `${session.spec.agent} ACP agent exited${stderr ? `: ${stderr}` : ''}` + // A death of its own ended the running turn at the exit, as the host's exit settlement reads it + // (`exitedRootTurnScope`). A close Orca asked for leaves it `unverifiable`; that settlement revises it. + closeAcpSessionJournal( + session, + reason, + session.closeRequested + ? UNVERIFIABLE_TURN_VERDICT + : { state: 'interrupted', completedAt: observedAt } + ) + const detail = stderr ? providerDiagnostic(stderr, 'person') : undefined + onEvent?.({ + type: 'ended', + sessionId: session.sessionId, + reason, + failure: session.closeRequested + ? agentSessionFailureFact('hostFault') + : agentSessionFailureFact('providerExited', detail ? { detail } : {}), + cause: session.closeRequested ? 'requested-close' : 'unexpected-exit', + fence: session.fence, + acquisitionGeneration: session.acquisitionGeneration, + observedAt + }) +} diff --git a/src/main/acp/acp-structured-starts.ts b/src/main/acp/acp-structured-starts.ts new file mode 100644 index 000000000000..b87911174ca1 --- /dev/null +++ b/src/main/acp/acp-structured-starts.ts @@ -0,0 +1,70 @@ +// The adapter's starts: each acquire under way, stopped when the host aborts its signal (a close, +// a Stop that must not wait behind it, or quit), and each failed start's connection until its +// process's exit is proven, so the next start or quit retries that same connection's close instead +// of answering for a process it no longer knows (and never spawns a second one meanwhile). + +import type { AcpStructuredConnection } from './acp-structured-connection' + +export type AcpStartAttempt = { + /** The host's: the start's one canceller. */ + readonly signal: AbortSignal + connection: AcpStructuredConnection | null + /** What the signal's abort does while the start runs; detached once it ends. */ + readonly stopConnection: () => void +} + +export class AcpStructuredStarts { + private readonly failed = new Map() + + /** Registered before anything awaits, so an abort from here on stops this start. */ + begin(signal: AbortSignal | undefined): AcpStartAttempt { + const attempt: AcpStartAttempt = { + signal: signal ?? new AbortController().signal, + connection: null, + stopConnection: () => void attempt.connection?.close().catch(() => false) + } + attempt.signal.addEventListener('abort', attempt.stopConnection, { once: true }) + return attempt + } + + /** The start has its connection; one already aborted goes as soon as it exists. */ + track(attempt: AcpStartAttempt, connection: AcpStructuredConnection): void { + attempt.connection = connection + if (attempt.signal.aborted) { + void connection.close().catch(() => false) + } + } + + /** A connection the start handed over is the session's: an abort after this goes through its + * stop, which knows the close was asked for, not this listener, which would read as a crash. */ + end(attempt: AcpStartAttempt): void { + attempt.signal.removeEventListener('abort', attempt.stopConnection) + } + + /** A failed start whose process is not proven gone keeps its connection until its exit is. */ + retainFailed(sessionId: string, connection: AcpStructuredConnection): void { + this.failed.set(sessionId, connection) + connection.onExit(() => { + if (this.failed.get(sessionId) === connection) { + this.failed.delete(sessionId) + } + }) + } + + /** Asks a failed start's connection to close again: true once none is left unproven. */ + async stopFailed(sessionId: string): Promise { + const connection = this.failed.get(sessionId) + if (!connection) { + return true + } + const proven = await connection.close().catch(() => false) + if (proven && this.failed.get(sessionId) === connection) { + this.failed.delete(sessionId) + } + return proven + } + + failedSessionIds(): string[] { + return [...this.failed.keys()] + } +} diff --git a/src/main/acp/acp-structured-steer.test.ts b/src/main/acp/acp-structured-steer.test.ts new file mode 100644 index 000000000000..19a6ee5cf5e1 --- /dev/null +++ b/src/main/acp/acp-structured-steer.test.ts @@ -0,0 +1,362 @@ +// A message sent while a Grok prompt runs steers: the running prompt is cancelled (the session +// stays) and the message goes as the next prompt. The adapter holds a steer only while that cancel +// lands; the host's queue holds everything else. + +import { afterEach, describe, expect, it } from 'vitest' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import { DISPATCH_REJECTED_CANCELLED } from '../../shared/structured-agent-session-dispatch-rejection' +import { + closeProviderTimelineRigs, + SESSION as ADAPTER_SESSION +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { + CALLER, + envelope +} from '../native-chat/agent-session-wire/structured-agent-session-host-test-harness' +import { HOST_TEST_SESSION as SESSION } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import type { AcpScriptedAgent, FakeFrame } from './acp-scripted-agent.test-support' +import { + openAcpAdapterRig, + PROVIDER_SESSION, + replyChunk, + sendHello, + waitFor +} from './acp-structured-adapter.test-support' +import { + framesOf, + message, + openAttachedHostRig, + promptIdOf, + send, + stop +} from './acp-structured-host.test-support' + +afterEach(async () => { + await closeProviderTimelineRigs() +}) + +/** A turn Grok began itself, as a background task's completion wakes it. */ +const GROK_TURN = 'task-completed-background-1' + +function endsGrokTurn(agent: AcpScriptedAgent): void { + agent.notify('x.ai/session_notification', { + sessionId: PROVIDER_SESSION, + update: { sessionUpdate: 'turn_completed', prompt_id: GROK_TURN, stop_reason: 'cancelled' } + }) +} + +/** Grok answers each cancel by ending the prompt it is running, as `cancelled`. */ +function answersCancels(agent: AcpScriptedAgent): void { + agent.on('session/cancel', () => { + const answered = new Set(agent.frames.flatMap((frame) => (frame.method ? [] : [frame.id]))) + const running = agent.frames.findLast( + (frame): frame is FakeFrame => frame.method === 'session/prompt' && !answered.has(frame.id) + ) + if (running) { + agent.reply(running, { stopReason: 'cancelled' }) + } + }) +} + +describe('a send while a Grok prompt runs', () => { + it('cancels the running prompt, then sends as the next one', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await sendHello(rig, 'first') + const first = await rig.frame('session/prompt') + rig.child().agent.notify('session/update', replyChunk('prompt:first', 'working')) + await rig.settle() + await sendHello(rig, 'steer') + await rig.settle() + expect(rig.sent('session/cancel')).toHaveLength(1) + expect(rig.sent('session/prompt')).toHaveLength(1) + rig.child().agent.reply(first, { stopReason: 'cancelled' }) + const second = await rig.frame('session/prompt', 1) + expect(promptIdOf(second)).toBe('prompt:steer') + rig.child().agent.notify('session/update', replyChunk('prompt:steer', 'on it')) + await waitFor(async () => { + const turns = (await rig.rig.rows()).flatMap((row) => readAgentJournalTurn(row.body) ?? []) + expect(turns.map((turn) => turn.state)).toEqual(['interrupted', 'running']) + }) + // A cancel, not a Stop: the session stays. + expect(rig.child().closes).toBe(0) + }) + + it('runs the last of two quick steers; the one between is sent and cancelled', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await sendHello(rig, 'first') + await rig.frame('session/prompt') + await rig.settle() + await sendHello(rig, 'steer-1') + await sendHello(rig, 'steer-2') + await rig.settle() + // One cancel per running prompt, however many steers wait behind it. + expect(rig.sent('session/cancel')).toHaveLength(1) + answersCancels(rig.child().agent) + rig.child().agent.reply(rig.sent('session/prompt')[0]!, { stopReason: 'cancelled' }) + await rig.frame('session/prompt', 2) + await rig.settle() + expect(rig.sent('session/prompt').map(promptIdOf)).toEqual([ + 'prompt:first', + 'prompt:steer-1', + 'prompt:steer-2' + ]) + expect(rig.sent('session/cancel')).toHaveLength(2) + expect(rig.settled.map((settled) => settled.clientMessageId)).toEqual(['first', 'steer-1']) + }) + + it('asks again on the next steer when the first cancel was never written', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await sendHello(rig, 'first') + await rig.frame('session/prompt') + await rig.settle() + const child = rig.child() + const cancel = child.cancel.bind(child) + let failNext = true + child.cancel = (options) => { + if (failNext) { + failNext = false + return Promise.reject(new Error('write failed')) + } + return cancel(options) + } + await sendHello(rig, 'steer-1') + await rig.settle() + expect(rig.sent('session/cancel')).toHaveLength(0) + await sendHello(rig, 'steer-2') + await sendHello(rig, 'steer-3') + await rig.settle() + // The failed write freed the running prompt's one cancel; the next steer used it. + expect(rig.sent('session/cancel')).toHaveLength(1) + }) + + it('withdraws a steer a Stop reaches before its cancel lands', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await sendHello(rig, 'first') + const first = await rig.frame('session/prompt') + rig.child().agent.notify('session/update', replyChunk('prompt:first', 'working')) + await rig.settle() + await sendHello(rig, 'steer') + await expect(rig.adapter.cancelTurn({ sessionId: ADAPTER_SESSION, fence: 1 })).resolves.toEqual( + { + cancelled: true + } + ) + rig.child().agent.reply(first, { stopReason: 'cancelled' }) + await rig.settle() + expect(rig.sent('session/prompt')).toHaveLength(1) + expect(rig.settled).toEqual( + expect.arrayContaining([ + expect.objectContaining({ clientMessageId: 'steer', state: 'rejected' }) + ]) + ) + }) + + it('waits as long as Grok takes to answer a steer cancel, and a Stop then still ends it', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await sendHello(rig, 'first') + await rig.frame('session/prompt') + rig.child().agent.notify('session/update', replyChunk('prompt:first', 'working')) + await rig.settle() + await sendHello(rig, 'steer') + await new Promise((resolve) => setTimeout(resolve, 120)) + await rig.settle() + expect(rig.sent('session/cancel')).toHaveLength(1) + expect(rig.lifecycle).toEqual([]) + expect(rig.child().closes).toBe(0) + expect(rig.settled.map((settled) => settled.clientMessageId)).toEqual(['first']) + // A Stop sends its own cancel; the host's grace, not the cancel, ends Grok. + await rig.adapter.cancelTurn({ sessionId: ADAPTER_SESSION, fence: 1 }) + expect(rig.sent('session/cancel')).toHaveLength(2) + expect(rig.settled).toEqual( + expect.arrayContaining([ + expect.objectContaining({ clientMessageId: 'steer', state: 'rejected' }) + ]) + ) + }) +}) + +describe('a send while a turn Grok began itself runs', () => { + it('goes to Grok at once, with no cancel of that turn', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + const { agent } = rig.child() + agent.notify('session/update', replyChunk(GROK_TURN, 'Build finished; now')) + await rig.settle() + await sendHello(rig, 'during') + const prompt = await rig.frame('session/prompt') + expect(rig.sent('session/cancel')).toHaveLength(0) + agent.notify('x.ai/session_notification', { + sessionId: PROVIDER_SESSION, + update: { sessionUpdate: 'turn_completed', prompt_id: GROK_TURN, stop_reason: 'end_turn' } + }) + agent.notify('session/update', replyChunk(promptIdOf(prompt), 'on it')) + agent.reply(prompt, { stopReason: 'end_turn' }) + await rig.settle() + expect(rig.settled).toEqual([ + expect.objectContaining({ clientMessageId: 'during', providerIdentity: expect.anything() }) + ]) + const turns = (await rig.rig.rows()).flatMap((row) => readAgentJournalTurn(row.body) ?? []) + expect(turns.map((turn) => turn.state)).toEqual(['completed', 'completed']) + }) + + it("cuts Grok's turn on a second send and runs both messages, never ending Grok", async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + const { agent } = rig.child() + agent.notify('session/update', replyChunk(GROK_TURN, 'Build finished; now')) + await rig.settle() + await sendHello(rig, 'a') + const a = await rig.frame('session/prompt') + // Grok queues `a` behind its own turn; a cancel ends only the running turn and promotes `a`. + agent.on('session/cancel', () => { + endsGrokTurn(agent) + agent.notify('session/update', replyChunk(promptIdOf(a), 'working on a')) + }) + await sendHello(rig, 'b') + await rig.settle() + expect(rig.sent('session/cancel')).toHaveLength(1) + // `a` runs a whole turn: a steer's cancel never ends Grok. + await new Promise((resolve) => setTimeout(resolve, 120)) + await rig.settle() + expect(rig.lifecycle).toEqual([]) + expect(rig.child().closes).toBe(0) + agent.reply(a, { stopReason: 'end_turn' }) + const b = await rig.frame('session/prompt', 1) + expect(promptIdOf(b)).toBe('prompt:b') + agent.notify('session/update', replyChunk('prompt:b', 'on b')) + agent.reply(b, { stopReason: 'end_turn' }) + await waitFor(async () => { + const turns = (await rig.rig.rows()).flatMap((row) => readAgentJournalTurn(row.body) ?? []) + expect(turns.map((turn) => turn.state)).toEqual(['interrupted', 'completed', 'completed']) + }) + expect(rig.settled.map((settled) => settled.clientMessageId)).toEqual(['a', 'b']) + expect(rig.child().closes).toBe(0) + }) +}) + +describe('Steer on a Grok card, through the host', () => { + /** A card queued behind whatever turn runs. */ + async function queueCard(rig: Awaited>) { + const { host } = rig + await host.flushStreamedEvents(SESSION) + const body = message('and then this') + const delivery = 'queue-if-active' as const + const queued = await host.send(CALLER, { + envelope: envelope('agentSession.send', { body, delivery }), + body, + delivery, + userSend: true + }) + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued card') + } + const cardId = queued.value.queued.messageId + const sendNow = () => + host.queuedMessageSend(CALLER, { + envelope: envelope('agentSession.queuedMessageSend', { messageId: cardId }), + messageId: cardId + }) + const page = async () => { + await host.flushStreamedEvents(SESSION) + const history = await host.history({ sessionId: SESSION, direction: 'tail' }) + return history.ok ? history.page : null + } + const sends = async () => + (await host.journalSnapshot(SESSION)).submissions + .filter((entry) => entry.queuedMessageId === cardId) + .map((entry) => ({ state: entry.dispatchState, reason: entry.reason })) + return { cardId, sendNow, page, sends } + } + + /** A Grok turn running, and a card queued behind it. */ + async function cardBehindARunningTurn() { + const rig = await openAttachedHostRig({ stopGraceMs: 20 }) + await send(rig.host, 'hello') + const prompt = await rig.rig.frame('session/prompt') + rig.rig.child().agent.notify('session/update', replyChunk(promptIdOf(prompt), 'working')) + await rig.rig.settle() + return { ...rig, prompt, ...(await queueCard(rig)) } + } + + it('leaves the card in the host queue while the turn runs, and cancels then sends it on Steer', async () => { + const { rig, host, prompt, sendNow, page, sends } = await cardBehindARunningTurn() + const { agent } = rig.child() + await rig.settle() + // Queued, not sent: nothing reaches Grok and the card stays editable. + expect((await page())?.queuedMessages?.map((card) => card.state)).toEqual(['waiting']) + expect(framesOf(rig.child(), 'session/prompt')).toHaveLength(1) + expect(framesOf(rig.child(), 'session/cancel')).toHaveLength(0) + + expect(await sendNow()).toMatchObject({ ok: true }) + await waitFor(() => expect(framesOf(rig.child(), 'session/cancel')).toHaveLength(1)) + expect(framesOf(rig.child(), 'session/prompt')).toHaveLength(1) + agent.reply(prompt, { stopReason: 'cancelled' }) + const steered = await rig.frame('session/prompt', 1) + agent.notify('session/update', replyChunk(promptIdOf(steered), 'on it')) + await waitFor(async () => expect(await sends()).toMatchObject([{ state: 'accepted' }])) + expect((await page())?.queuedMessages ?? []).toEqual([]) + expect(rig.child().exited).toBe(false) + await host.close(SESSION, 'user-close') + }) + + it("steers an older client's send the same way", async () => { + const rig = await openAttachedHostRig() + const { agent } = rig.rig.child() + await send(rig.host, 'hello') + const prompt = await rig.rig.frame('session/prompt') + agent.notify('session/update', replyChunk(promptIdOf(prompt), 'working')) + await rig.rig.settle() + await rig.host.flushStreamedEvents(SESSION) + // No queued-message delivery: the host hands it over while the turn runs. + await send(rig.host, 'steer') + await waitFor(() => expect(framesOf(rig.rig.child(), 'session/cancel')).toHaveLength(1)) + agent.reply(prompt, { stopReason: 'cancelled' }) + await rig.rig.frame('session/prompt', 1) + await rig.host.close(SESSION, 'user-close') + }) + + it('brings the card back paused when a Stop reaches it before the cancel lands', async () => { + const { rig, host, sendNow, page, sends } = await cardBehindARunningTurn() + expect(await sendNow()).toMatchObject({ ok: true }) + await waitFor(() => expect(framesOf(rig.child(), 'session/cancel')).toHaveLength(1)) + const first = rig.child() + expect(await stop(host)).toMatchObject({ ok: true, value: { cancelled: true } }) + await waitFor(() => expect(first.exited).toBe(true)) + expect(framesOf(first, 'session/prompt')).toHaveLength(1) + await waitFor(async () => { + const current = await page() + expect({ + pause: current?.queuePause ?? null, + cards: (current?.queuedMessages ?? []).map((card) => card.state), + sends: await sends() + }).toEqual({ + pause: { reason: 'stopped' }, + cards: ['waiting'], + sends: [{ state: 'rejected', reason: DISPATCH_REJECTED_CANCELLED }] + }) + }) + await host.close(SESSION, 'user-close') + }) + + it('sends the card at once on Steer during a turn Grok began itself, with no cancel', async () => { + const rig = await openAttachedHostRig({ stopGraceMs: 20 }) + const child = rig.rig.child() + child.agent.notify('session/update', replyChunk(GROK_TURN, 'Build finished; now')) + await rig.rig.settle() + const { sendNow, sends } = await queueCard(rig) + expect(await sendNow()).toMatchObject({ ok: true }) + const steered = await rig.rig.frame('session/prompt') + expect(framesOf(child, 'session/cancel')).toHaveLength(0) + endsGrokTurn(child.agent) + child.agent.notify('session/update', replyChunk(promptIdOf(steered), 'on it')) + await waitFor(async () => expect(await sends()).toMatchObject([{ state: 'accepted' }])) + expect(framesOf(child, 'session/cancel')).toHaveLength(0) + expect(child.exited).toBe(false) + await rig.host.close(SESSION, 'user-close') + }) +}) diff --git a/src/main/acp/acp-structured-stop.ts b/src/main/acp/acp-structured-stop.ts new file mode 100644 index 000000000000..85e48625b92b --- /dev/null +++ b/src/main/acp/acp-structured-stop.ts @@ -0,0 +1,62 @@ +// How a running ACP turn ends before its child does, for a Stop and a requested close alike: the +// cancel goes out once, and the agent gets a bounded grace, counted from it, to end its turn. + +import type { AcpStructuredSession } from './acp-structured-session' + +type AcpStoppableSession = Pick + +/** A turn a cancel can end: Orca's prompt, or one the agent opened itself. */ +function acpTurnInFlight(session: AcpStoppableSession): boolean { + return session.turns.running || session.lane.openTurnId !== null +} + +/** As a Stop: held steers and the agent's open requests are withdrawn, then the cancel is written, + * never awaited; a failed write leaves the grace to run out. */ +export function interruptAcpTurn(session: AcpStoppableSession): void { + session.turns.stop(Date.now()) + if (!acpTurnInFlight(session)) { + return + } + session.prompts.withdrawAll() + void session.connection.cancel().catch(() => undefined) +} + +/** Resolves once the running turn ended, or `graceMs` after `stoppedAt` (`Date.now()` time). */ +export async function awaitAcpTurnEnd( + session: AcpStoppableSession, + stoppedAt: number, + graceMs: number +): Promise { + let timer: ReturnType | undefined + const elapsed = new Promise((resolve) => { + timer = setTimeout(resolve, Math.max(0, stoppedAt + graceMs - Date.now())) + }) + const turnId = session.lane.openTurnId + try { + await Promise.race([ + Promise.all([ + session.turns.whenIdle(), + turnId === null ? undefined : session.lane.whenTurnLeaves(turnId) + ]), + elapsed + ]) + } finally { + clearTimeout(timer) + } +} + +/** Before a requested close: a running turn is cancelled, unless a Stop already did, and waited on + * within the grace that cancel started. */ +export async function windDownAcpTurn( + session: AcpStoppableSession, + graceMs: number +): Promise { + const { connection, turns } = session + if (connection.exited || connection.closed || !acpTurnInFlight(session)) { + return + } + if (turns.stoppedAt === null) { + interruptAcpTurn(session) + } + await awaitAcpTurnEnd(session, turns.stoppedAt ?? Date.now(), graceMs) +} diff --git a/src/main/acp/acp-structured-turns.ts b/src/main/acp/acp-structured-turns.ts new file mode 100644 index 000000000000..df5495f65edc --- /dev/null +++ b/src/main/acp/acp-structured-turns.ts @@ -0,0 +1,270 @@ +// The sends of one ACP session. ACP runs one prompt at a time, so a message sent while Orca's prompt +// runs steers: that prompt is cancelled (the session stays) and the message goes as the next prompt +// once the agent answers the cancel, however long that takes. Each running prompt gets one cancel, +// however many steers arrive; steers wait here, the last one runs, and a Stop withdraws what waits. +// A turn the agent began itself is not Orca's to cut short: a message sent during it goes to the +// agent at once. Each send is settled exactly once: +// accepted when the agent's first event for its turn (or its answer) arrives, rejected when the +// agent refused it or it never left Orca, unknown when the agent died with it or its connection +// broke before it answered. + +import { + agentSessionFailureFact, + providerDiagnostic, + type SubmissionRejectionFact +} from '../../shared/agent-session-failure' +import { + agentSessionFailureWords, + type AgentJournalDispatchRejection +} from '../../shared/agent-session-failure-words' +import type { + AgentJournalItemIdentity, + AgentJournalMessageItem +} from '../../shared/agent-session-journal-types' +import { AcpAgentError, AcpConnectionClosedError } from './acp-errors' +import type { AcpStructuredConnection } from './acp-structured-connection' +import type { AcpStructuredLane } from './acp-structured-lane' +import type { ContentBlock } from './generated/acp-protocol.generated' + +export type AcpDispatchSettlement = { clientMessageId: string } & ( + | { providerIdentity: AgentJournalItemIdentity } + | ({ state: 'rejected' } & AgentJournalDispatchRejection) + | { state: 'unknown'; reason: string } +) + +/** A person's message as an ACP prompt; null when it carries what the agent cannot take. */ +export function acpPromptBlocks(body: AgentJournalMessageItem): ContentBlock[] | null { + const blocks: ContentBlock[] = [] + for (const block of body.blocks) { + if (block.type !== 'text') { + // Images wait for an ACP image path; the chat offers none while `imagePrompts` is off. + return null + } + blocks.push({ type: 'text', text: block.text }) + } + return blocks +} + +type Send = { clientMessageId: string; prompt: ContentBlock[]; requestedAt: number } + +export type AcpStructuredTurnsDeps = { + connection: Pick + /** Answers the agent's open requests cancelled, as a steer's cancel ends what they belong to. */ + withdrawRequests: () => void + lane: AcpStructuredLane + agentName: string + now: () => number + settle: (settlement: AcpDispatchSettlement) => void +} + +export class AcpStructuredTurns { + private active: Send | null = null + /** The running send a steer already cancelled: one cancel per prompt, retried if its write failed. */ + private steerCancelled: Send | null = null + /** Steers waiting for the prompt ahead of them to answer its cancel. */ + private readonly steers: Send[] = [] + private readonly unsettled = new Set() + private readonly idleWaiters = new Set<() => void>() + private ended = false + /** When a Stop or a requested close reached this session; it ends with the process. */ + private stopAt: number | null = null + + constructor(private readonly deps: AcpStructuredTurnsDeps) {} + + get running(): boolean { + return this.active !== null + } + + /** Whether an agent request may reach the person: Orca's prompt runs and nobody is cutting it + * short (no steer's cancel, no Stop). */ + get acceptsRequests(): boolean { + return ( + this.stopAt === null && + this.active !== null && + this.steerCancelled !== this.active && + !this.ended + ) + } + + /** Steers wait behind the running prompt's cancel. */ + get holdsSteers(): boolean { + return this.steers.length > 0 + } + + /** Whether a Stop reached this session. */ + get stopped(): boolean { + return this.stopAt !== null + } + + /** When the first Stop reached this session (`Date.now()` time); null before one did. */ + get stoppedAt(): number | null { + return this.stopAt + } + + /** A Stop: held steers never reach the agent, and nothing it asks from now on is shown. */ + stop(at: number): boolean { + this.stopAt ??= at + return this.withdrawSteers() + } + + /** Resolves once no prompt of Orca's is running. */ + whenIdle(): Promise { + if (!this.active) { + return Promise.resolve() + } + return new Promise((resolve) => this.idleWaiters.add(resolve)) + } + + dispatch(send: Send): void { + this.unsettled.add(send.clientMessageId) + if (this.active) { + this.steers.push(send) + this.cancelForSteer() + return + } + this.start(send) + } + + /** The agent took the send: its turn's first event, or its answer, arrived. */ + accept(clientMessageId: string): void { + if (this.unsettled.delete(clientMessageId)) { + this.deps.settle({ + clientMessageId, + providerIdentity: { provider: 'orca', clientMessageId } + }) + } + } + + private withdrawSteers(): boolean { + const withdrawn = this.steers.splice(0) + for (const send of withdrawn) { + this.reject(send.clientMessageId, agentSessionFailureFact('cancelled')) + } + return withdrawn.length > 0 + } + + /** The child is gone: held sends never left Orca, and the running one's fate is unknown. */ + end(reason: string): void { + this.ended = true + for (const send of this.steers.splice(0)) { + this.reject(send.clientMessageId, agentSessionFailureFact('providerExited')) + } + const active = this.active + if (active && this.unsettled.delete(active.clientMessageId)) { + this.deps.settle({ clientMessageId: active.clientMessageId, state: 'unknown', reason }) + } + this.active = null + this.steerCancelled = null + this.notifyIdle() + } + + private start(send: Send): void { + const { lane } = this.deps + this.active = send + const opened = lane.translator.openPrompt(send.clientMessageId, send.requestedAt) + lane.apply(opened.events) + // An agent whose dialect echoes this id on every event of the turn gets it, so its rows join the + // turn Orca opened; no other agent is sent the extension. + const meta = lane.translator.injectsPromptIdentity + ? { promptId: opened.promptId, requestId: opened.promptId } + : undefined + const answered = this.deps.connection.prompt(send.prompt, meta) + if (this.steers.length > 0) { + this.cancelForSteer() + } + answered.then( + (result) => { + if (this.active !== send) { + return + } + lane.apply(lane.translator.promptResult(send.clientMessageId, result, this.deps.now())) + this.accept(send.clientMessageId) + this.finish(send) + }, + (error: unknown) => { + if (this.active !== send || error instanceof AcpConnectionClosedError) { + // A lost connection: the session's end settles the send, as unknown. + return + } + if (!(error instanceof AcpAgentError)) { + // Answered, but not in a way Orca can read: the turn failed and the next send may go. + lane.apply( + lane.translator.promptFailed( + send.clientMessageId, + error instanceof Error ? error : new Error(String(error)), + this.deps.now() + ) + ) + this.accept(send.clientMessageId) + this.finish(send) + return + } + const refusal = lane.translator.promptRefused(send.clientMessageId, error) + if (refusal !== null) { + // The agent answered the prompt with an error before starting it: its own refusal. + const detail = providerDiagnostic(refusal, 'person') + this.reject( + send.clientMessageId, + agentSessionFailureFact('providerRejected', detail ? { detail } : {}) + ) + } else { + lane.apply(lane.translator.promptFailed(send.clientMessageId, error, this.deps.now())) + } + this.finish(send) + } + ) + } + + private finish(send: Send): void { + if (this.active === send) { + this.active = null + } + if (this.steerCancelled === send) { + this.steerCancelled = null + } + const next = this.ended ? undefined : this.steers.shift() + if (next) { + this.start(next) + } else if (!this.active) { + this.notifyIdle() + } + } + + /** Asks once per running prompt to end it; never bounded, so a slow answer only delays the steer, + * and a Stop still ends the session. */ + private cancelForSteer(): void { + const send = this.active + if (!send) { + return + } + this.deps.withdrawRequests() + if (this.steerCancelled === send) { + return + } + this.steerCancelled = send + this.deps.connection.cancel().catch(() => { + // Never written: the next steer may ask again. + if (this.steerCancelled === send) { + this.steerCancelled = null + } + }) + } + + private notifyIdle(): void { + for (const wake of this.idleWaiters) { + wake() + } + this.idleWaiters.clear() + } + + private reject(clientMessageId: string, fact: SubmissionRejectionFact): void { + if (!this.unsettled.delete(clientMessageId)) { + return + } + this.deps.settle({ + clientMessageId, + state: 'rejected', + ...agentSessionFailureWords(fact, { surface: 'rejection', agentName: this.deps.agentName }) + }) + } +} diff --git a/src/main/acp/acp-timeline-dialect.test.ts b/src/main/acp/acp-timeline-dialect.test.ts index 195ab2d0a91a..27ce8c83d8e5 100644 --- a/src/main/acp/acp-timeline-dialect.test.ts +++ b/src/main/acp/acp-timeline-dialect.test.ts @@ -82,7 +82,7 @@ describe('ACP dialect request and turn boundaries', () => { }) }) - it('encodes labels and free text under the question text, validates grouped choices, and gives exact plan replies', async () => { + it('encodes labels and free text under the question text, validates grouped choices, and settles a plan without a gate', async () => { const { translator } = await dialectRig() const ask = translator.request('_x.ai/ask_user_question', question, 0).presentation! expect( @@ -134,17 +134,17 @@ describe('ACP dialect request and turn boundaries', () => { outcome: 'accepted', answers: { 'Choose a test?': ['Unit', 'Integration'], 'Another?': 'Yes' } }) + // A plan is shown, never put to the person as an approval, and never approved for them. const plan = translator.request( '_x.ai/exit_plan_mode', - { sessionId: 'provider-1', toolCallId: 'plan-1', planContent: 'Plan' }, + { sessionId: 'provider-1', toolCallId: 'plan-1', planContent: '# Plan' }, 2 - ).presentation! - expect(plan.reply({ kind: 'option', optionId: 'approved' })).toEqual({ outcome: 'approved' }) - expect(plan.reply({ kind: 'option', optionId: 'request_changes' })).toEqual({ - outcome: 'request_changes' - }) - expect(plan.reply(null)).toEqual({ outcome: 'abandoned' }) - expect(() => plan.reply({ kind: 'option', optionId: 'allow' })).toThrow('offered option') + ) + expect(plan.presentation).toBeUndefined() + expect(plan.settled?.reply).toEqual({ outcome: 'abandoned', feedback: expect.any(String) }) + expect(plan.events).toMatchObject([ + { body: { kind: 'status', presentation: 'plan-document', text: '# Plan' } } + ]) }) it('keeps client prompt settlement on the response, and never gives an autonomous turn invented success', async () => { diff --git a/src/main/acp/acp-timeline-recordings.test.ts b/src/main/acp/acp-timeline-recordings.test.ts index e4402b37e864..017f53c3fec9 100644 --- a/src/main/acp/acp-timeline-recordings.test.ts +++ b/src/main/acp/acp-timeline-recordings.test.ts @@ -106,18 +106,20 @@ describe('recorded ACP traffic through the journal', () => { expect(await fixture.rig.turns()).toHaveLength(2) }) - it('answers questions and plan approval with the recorded exact reply shapes', async () => { + it('answers questions with the recorded reply shape and shows the plan as a plan, not a gate', async () => { const fixture = await openAcpFixtureRig() const rows = await fixture.feed(await readAcpFixture('s5-plan-approved')) expect(rows.flatMap((row) => (row.body.kind === 'question' ? [row.body] : []))).toMatchObject([ { resolution: { state: 'resolved', answers: [{ questionId: 'q1', optionIds: ['o1'] }] } } ]) - expect(rows.flatMap((row) => (row.body.kind === 'approval' ? [row.body] : []))).toMatchObject([ - { - subject: { kind: 'plan', text: '# Plan\n\nUpdate the example and run its tests.' }, - resolution: { state: 'resolved', selectedOptionId: 'approved' } - } - ]) + expect(rows.filter((row) => row.body.kind === 'approval')).toEqual([]) + expect(rows.map((row) => row.body)).toContainEqual( + expect.objectContaining({ + kind: 'status', + presentation: 'plan-document', + text: '# Plan\n\nUpdate the example and run its tests.' + }) + ) expect((await fixture.rig.turns())[0]!.outcome).toBe('success') }) diff --git a/src/main/acp/acp-timeline-recovery.test.ts b/src/main/acp/acp-timeline-recovery.test.ts index 6bc11b594756..84c1cbfcc2cc 100644 --- a/src/main/acp/acp-timeline-recovery.test.ts +++ b/src/main/acp/acp-timeline-recovery.test.ts @@ -33,24 +33,25 @@ describe('ACP reviewed traffic and recovery', () => { }) it.each([undefined, null])( - 'accepts an empty plan request and replies with the valid approval shape (%s)', + 'accepts an empty plan request: placeholder plan row, answered at once without approving (%s)', async (planContent) => { const f = await openAcpFixtureRig() for (const method of ['_x.ai/exit_plan_mode', 'x.ai/exit_plan_mode']) { - const presentation = f + const translated = f .lane() - .request( - method, - { sessionId: 'session-1', toolCallId: 'plan', planContent }, - 1 - ).presentation - expect(presentation?.body).toMatchObject({ - kind: 'approval', - subject: { kind: 'plan', text: 'The agent exited plan mode without writing a plan.' } - }) - expect(presentation?.reply({ kind: 'option', optionId: 'approved' })).toEqual({ - outcome: 'approved' - }) + .request(method, { sessionId: 'session-1', toolCallId: 'plan', planContent }, 1) + expect(translated.presentation).toBeUndefined() + expect(translated.events).toMatchObject([ + { + type: 'item.update', + body: { + kind: 'status', + presentation: 'plan-document', + text: 'The agent exited plan mode without writing a plan.' + } + } + ]) + expect(translated.settled?.reply).toMatchObject({ outcome: 'abandoned' }) } } ) diff --git a/src/main/acp/acp-timeline-requests.ts b/src/main/acp/acp-timeline-requests.ts index aa7e396c1368..176967409c9e 100644 --- a/src/main/acp/acp-timeline-requests.ts +++ b/src/main/acp/acp-timeline-requests.ts @@ -1,4 +1,8 @@ import { z } from 'zod' +import { + boundInlineText, + DEFAULT_JOURNAL_PAYLOAD_LIMITS +} from '../native-chat/agent-session-journal/journal-payload-bounds' import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' import type { AcpDialect, AcpRequestPresentation } from './acp-dialects/acp-dialect' import type { AcpToolTimeline } from './acp-tool-timeline' @@ -57,19 +61,49 @@ export function translateAcpRequest( dialect: AcpDialect tools: AcpToolTimeline } -): { events: ProviderTimelineEvent[]; presentation?: AcpRequestPresentation } { +): { + events: ProviderTimelineEvent[] + presentation?: AcpRequestPresentation + /** Answered at once: nobody is asked. */ + settled?: { reply: unknown } +} { const session = requestSessionSchema.safeParse(params) if (!session.success || session.data.sessionId !== options.sessionId) { throw new AcpRpcError(-32602, 'ACP request belongs to an unknown session') } - const presentation = - method === 'session/request_permission' - ? acpPermissionPresentation(params) - : options.dialect.request?.(method, params) const tool = requestToolSchema.safeParse(params) const callId = tool.success ? (tool.data.toolCall?.toolCallId ?? tool.data.toolCallId) : undefined const turn = callId ? options.tools.turn(callId) : undefined const join = { thread: options.sessionId, ...(turn === undefined ? {} : { turn }) } + const settlement = + method === 'session/request_permission' + ? undefined + : options.dialect.settleRequest?.(method, params) + if (settlement) { + const plan = settlement.plan + return { + settled: { reply: settlement.reply }, + events: + plan === undefined + ? [] + : [ + { + type: 'item.update', + item: `${method}:${JSON.stringify(id)}`, + body: { + kind: 'status', + presentation: 'plan-document', + text: boundInlineText(plan, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text + }, + join + } + ] + } + } + const presentation = + method === 'session/request_permission' + ? acpPermissionPresentation(params) + : options.dialect.request?.(method, params) if (!presentation) { return { events: [{ type: 'provider.frame', frameKind: `request:${method}`, payload: params, join }] diff --git a/src/main/acp/acp-timeline-translator.ts b/src/main/acp/acp-timeline-translator.ts index 9f927bceed0f..30af2bf56444 100644 --- a/src/main/acp/acp-timeline-translator.ts +++ b/src/main/acp/acp-timeline-translator.ts @@ -3,12 +3,8 @@ import { BoundedMap } from '../../shared/bounded-map' import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' import { acpNotificationEnvelopeSchema, AcpContextTimeline } from './acp-context-usage' import { AcpBackgroundTaskTimeline } from './acp-background-task-timeline' -import { - GENERIC_ACP_DIALECT, - type AcpDialect, - type AcpRequestPresentation -} from './acp-dialects/acp-dialect' -import type { AcpAgentError } from './acp-errors' +import { GENERIC_ACP_DIALECT, type AcpDialect } from './acp-dialects/acp-dialect' +import { AcpAgentError } from './acp-errors' import { acpTurnEnd, AcpPromptTurns } from './acp-prompt-turns' import { readAcpSessionEvent, type AcpSessionEvent } from './acp-session-events' import { translateAcpRequest } from './acp-timeline-requests' @@ -61,6 +57,11 @@ export class AcpTimelineTranslator { ) } + /** Whether the agent's dialect echoes an injected prompt identity on the turn's events. */ + get injectsPromptIdentity(): boolean { + return this.dialect.injectedPromptIdentity === true + } + /** The host injects promptId as session/prompt._meta.promptId (and requestId). */ openPrompt( clientMessageId: string, @@ -80,10 +81,12 @@ export class AcpTimelineTranslator { return this.finishPrompt(clientMessageId, result.stopReason, at) } - /** The agent's own error answer to the prompt; Orca's errors about it (a timeout, an unreadable - * answer, a closed connection) are no provider words and never reach here. */ - promptFailed(clientMessageId: string, error: AcpAgentError, at: number): ProviderTimelineEvent[] { - const detail = acpPromptErrorDetail(this.dialect, error) + /** The prompt failed: the agent's own error answer, or an answer Orca could not read (then no + * words are the agent's, and the row says only that the turn failed). A closed connection never + * reaches here. */ + promptFailed(clientMessageId: string, error: Error, at: number): ProviderTimelineEvent[] { + const detail = + error instanceof AcpAgentError ? acpPromptErrorDetail(this.dialect, error) : undefined const ended = this.prompts.last if (this.prompts.current?.clientMessageId !== clientMessageId) { // The provider already ended this turn; its answer may carry the only copy of the reason. @@ -94,6 +97,12 @@ export class AcpTimelineTranslator { return this.finishPrompt(clientMessageId, 'error', at, detail) } + /** The agent refused the prompt before its turn began: forgets it and answers the agent's reason. + * Null once the turn opened, when the refusal ends that turn instead (`promptFailed`). */ + promptRefused(clientMessageId: string, error: AcpAgentError): string | null { + return this.prompts.refuse(clientMessageId) ? acpPromptErrorDetail(this.dialect, error) : null + } + private finishPrompt( clientMessageId: string, stopReason: string, @@ -212,7 +221,7 @@ export class AcpTimelineTranslator { method: string, params: unknown, id: string | number - ): { events: ProviderTimelineEvent[]; presentation?: AcpRequestPresentation } { + ): ReturnType { return translateAcpRequest(method, params, id, { sessionId: this.options.sessionId, dialect: this.dialect, diff --git a/src/main/agent-hooks/server-pane-authority.test.ts b/src/main/agent-hooks/server-pane-authority.test.ts index 7d622b5a8c91..57c3f8f8718f 100644 --- a/src/main/agent-hooks/server-pane-authority.test.ts +++ b/src/main/agent-hooks/server-pane-authority.test.ts @@ -53,6 +53,23 @@ describe('AgentHookServer pane authority', () => { ]) }) + // A pane move is announced by main and again by the renderer; the repeat must change nothing. + it('ignores a transfer that is already in place', () => { + const server = new AgentHookServer() + const listener = vi.fn() + server.setPaneKeyAliasPersistenceListener(listener) + + server.transferPaneAuthority(SOURCE, TARGET, 'pty-1', 10, { authorityVerified: true }) + server.transferPaneAuthority(SOURCE, TARGET, 'pty-1', 20) + server.transferPaneAuthority(SOURCE, TARGET, undefined, 30) + + expect(listener).toHaveBeenCalledOnce() + server.transferPaneAuthority(SOURCE, TARGET, 'pty-2', 40) + expect(listener).toHaveBeenLastCalledWith([ + { legacyPaneKey: SOURCE, stablePaneKey: TARGET, ptyId: 'pty-2', updatedAt: 40 } + ]) + }) + it('persists one physical alias while chained transfers advance its owner', () => { const server = new AgentHookServer() const listener = vi.fn() diff --git a/src/main/agent-hooks/server/server-authority-aliases.ts b/src/main/agent-hooks/server/server-authority-aliases.ts index fb4890e10a2f..d5d3b0666d13 100644 --- a/src/main/agent-hooks/server/server-authority-aliases.ts +++ b/src/main/agent-hooks/server/server-authority-aliases.ts @@ -144,6 +144,14 @@ export abstract class AgentHookServerAuthorityAliases extends AgentHookServerAut } const previousOwnerPaneKey = this.resolvePaneKeyAlias(fromPaneKey) const physicalPaneKey = this.getPhysicalPaneKeyForAuthority(fromPaneKey, ptyId) + const requestedPtyId = ptyId?.trim() + // Why: a repeat would clear the owner's own polls; a pane move announces from main and renderer. + if ( + previousOwnerPaneKey === toPaneKey && + (!requestedPtyId || this.legacyPaneKeyAliases.get(physicalPaneKey)?.ptyId === requestedPtyId) + ) { + return + } for (const key of [fromPaneKey, previousOwnerPaneKey, physicalPaneKey, toPaneKey]) { this.takeRetiredPaneRestartId(key) } diff --git a/src/main/agent-hooks/wsl-hook-relay-launch.ts b/src/main/agent-hooks/wsl-hook-relay-launch.ts index ae1ea9c20d70..c60f4364bf21 100644 --- a/src/main/agent-hooks/wsl-hook-relay-launch.ts +++ b/src/main/agent-hooks/wsl-hook-relay-launch.ts @@ -6,7 +6,7 @@ import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' import { existsSync, readFileSync } from 'node:fs' import { join } from 'node:path' -import { getAppEnvironment } from '../../shared/app-environment' +import { relayBundleCandidates } from '../ssh/relay-bundle-paths' import type { MultiplexerTransport } from '../ssh/ssh-channel-multiplexer' import { @@ -33,24 +33,7 @@ const INSTALL_TIMEOUT_MS = 30_000 export type WslHookRelayBundle = { jsPath: string; version: string } export function resolveWslHookRelayBundle(): WslHookRelayBundle | null { - // Mirrors getLocalRelayCandidates in ssh-relay-deploy: env override for - // tests/dev, then packaged extraResources, then dev out/ paths. - const candidates: string[] = [] - if (process.env.ORCA_RELAY_PATH) { - candidates.push(join(process.env.ORCA_RELAY_PATH, 'wsl')) - } - if (process.resourcesPath) { - candidates.push(join(process.resourcesPath, 'relay', 'wsl')) - candidates.push(join(process.resourcesPath, 'app.asar.unpacked', 'out', 'relay', 'wsl')) - } - try { - const appPath = getAppEnvironment().getAppPath() - candidates.push(join(appPath, 'resources', 'relay', 'wsl')) - candidates.push(join(appPath, 'out', 'relay', 'wsl')) - } catch { - // app not ready in some test contexts — env/resources candidates suffice. - } - for (const dir of candidates) { + for (const dir of relayBundleCandidates('wsl')) { const jsPath = join(dir, WSL_HOOK_RELAY_BUNDLE_NAME) const versionPath = join(dir, WSL_HOOK_RELAY_VERSION_FILE) if (existsSync(jsPath) && existsSync(versionPath)) { diff --git a/src/main/agent-launch/agent-launch-executor.test.ts b/src/main/agent-launch/agent-launch-executor.test.ts index 326a8d322eee..30e652a6cb4c 100644 --- a/src/main/agent-launch/agent-launch-executor.test.ts +++ b/src/main/agent-launch/agent-launch-executor.test.ts @@ -252,10 +252,21 @@ describe('a launch into a workspace that already exists', () => { describe('an agent with no structured session', () => { it('stays a terminal without asking the host', async () => { const h = harness({}) - const result = await h.run({ agent: 'grok', target: { kind: 'existing', worktree: 'wt-7' } }) + const result = await h.run({ agent: 'gemini', target: { kind: 'existing', worktree: 'wt-7' } }) expect(h.calls).toEqual(['createTerminalAgent']) expect(result.receipt).toMatchObject({ reason: 'agent_without_structured_session' }) }) + + it('asks the host for an agent it registered beyond Claude and Codex', async () => { + const h = harness({}) + const result = await h.run({ ...CREATE_INTENT, agent: 'grok' }) + expect(h.calls).toEqual([ + 'createWorktree(startupAgent=undefined)', + 'createSupport', + 'createStructuredSession' + ]) + expect(result.receipt).toMatchObject({ mode: 'structured' }) + }) }) describe('the prompt receipt', () => { diff --git a/src/main/agent-launch/agent-launch-executor.ts b/src/main/agent-launch/agent-launch-executor.ts index 500329a9da6e..3b5794a0ba74 100644 --- a/src/main/agent-launch/agent-launch-executor.ts +++ b/src/main/agent-launch/agent-launch-executor.ts @@ -42,7 +42,6 @@ import { settledAtCreation, settleLaunchPromptDisposal } from './agent-launch-prompt-delivery' -import type { TuiAgent } from '../../shared/tui-agent' import { workspaceKindForWorktreeId, type WorkspaceLaunchKind @@ -71,6 +70,8 @@ export type AgentLaunchExecution = { surfaces: AgentLaunchSurfaceFactory workspaces?: AgentLaunchWorkspaceFactory vocabulary?: AgentLaunchModeVocabulary + /** False when the calling client cannot show the agent's chat; absent for the host's own callers. */ + callerRendersStructured?: boolean /** Attributes a throw to the step that was running, the way a dispatch's own stages do. */ onStage?: (stage: 'worktree_create' | 'mode_settle' | 'surface_create') => void /** The surface exists and its tab is published; runs before any prompt delivery. Must not throw. */ @@ -102,7 +103,8 @@ export async function executeAgentLaunch( ...(intent.cwd ? { cwd: intent.cwd } : {}), ...(intent.target.kind === 'existing' && intent.target.workspacePath ? { workspacePath: intent.target.workspacePath } - : {}) + : {}), + ...(execution.callerRendersStructured === false ? { callerRendersStructured: false } : {}) }, settings, vocabulary @@ -283,7 +285,7 @@ async function createSurface( settled: AgentLaunchModeReceipt ): Promise { const { intent, surfaces } = execution - if (settled.mode === 'structured' && isStructuredProvider(intent.agent)) { + if (settled.mode === 'structured') { // One reservation serves either route: the tab half of the reserved pane is the chat's tab. const reservedTabId = intent.paneKey ? parsePaneKey(intent.paneKey)?.tabId : undefined const session = await surfaces.createStructuredSession({ @@ -384,10 +386,6 @@ function combineLaunchWarnings( return `${create} Also ${surface[0].toLowerCase()}${surface.slice(1)}` } -function isStructuredProvider(agent: TuiAgent): agent is 'claude' | 'codex' { - return agent === 'claude' || agent === 'codex' -} - function existingWorktreeId(target: AgentLaunchTarget): string { return target.kind === 'existing' ? target.worktree : '' } diff --git a/src/main/agent-launch/agent-launch-mode.ts b/src/main/agent-launch/agent-launch-mode.ts index 7b3105b07d89..2a96c4f58088 100644 --- a/src/main/agent-launch/agent-launch-mode.ts +++ b/src/main/agent-launch/agent-launch-mode.ts @@ -24,6 +24,7 @@ import type { AgentLaunchModeReceipt } from '../../shared/agent-launch-intent' import { RUNTIME_CAPABILITIES } from '../../shared/protocol-version' +import { STRUCTURED_AGENT_RUNTIME_REGISTRATIONS } from '../runtime/structured-agent-runtime-registrations' import { prefersStructuredNativeChatByDefault, resolveStructuredNativeChatSupport, @@ -77,8 +78,15 @@ export type AgentLaunchModePlacement = { /** The root of the workspace the launch lands in, when the host has resolved it. Without it a * requested `cwd` cannot be proven to name the root and is read as custom. */ workspacePath?: string + /** False when the client asking for the launch cannot show this agent's chat; absent for the + * host's own callers, which can. */ + callerRendersStructured?: boolean } +const REGISTERED_STRUCTURED_AGENTS: readonly string[] = STRUCTURED_AGENT_RUNTIME_REGISTRATIONS.map( + ({ definition }) => definition.agent +) + const DOWNGRADE_DETAIL: Record, string> = { remote_execution_host: 'this launch runs on a remote execution host', reused_terminal: 'it reuses a running terminal agent', @@ -123,6 +131,9 @@ export function decideAgentLaunchMode(args: { placement: AgentLaunchModePlacement settings: AgentLaunchModeSettings | null | undefined vocabulary?: AgentLaunchModeVocabulary + /** Registered agents (beyond Claude and Codex) this surface can open as structured; defaults to + * every agent this host registers. */ + registeredStructuredAgents?: readonly string[] }): AgentLaunchModeReceipt { const { placement, settings } = args const vocabulary = args.vocabulary ?? DEFAULT_LAUNCH_VOCABULARY @@ -139,13 +150,15 @@ export function decideAgentLaunchMode(args: { if (placement.on) { return downgraded('remote_execution_host', vocabulary) } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: an unrecognized agent name is handled rather than trusted; isAgentSessionHandleProvider rejects it and the launch downgrades to a terminal. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: an unrecognized agent name is handled rather than trusted; the registered-agent check rejects it and the launch downgrades to a terminal. const agent = placement.agent as TuiAgent const support = resolveStructuredNativeChatSupport({ agent, executionHostId: 'local', reusesTerminal: Boolean(placement.terminal), hostCapabilities: RUNTIME_CAPABILITIES, + // This host is the one that will run the agent, so its own registrations answer. + hostStructuredAgents: args.registeredStructuredAgents ?? REGISTERED_STRUCTURED_AGENTS, // The host resolves the floating workspace to its configured directory; create-support still // answers for the resolved workspace, including whether it uses WSL. ...(placement.workspaceKind ? { workspaceKind: placement.workspaceKind } : {}), @@ -159,6 +172,10 @@ export function decideAgentLaunchMode(args: { if (!support.supported) { return downgraded(BLOCKER_REASON[support.blocker], vocabulary) } + // A chat its caller can neither show nor close is no launch; that caller gets the terminal. + if (placement.callerRendersStructured === false) { + return downgraded(BLOCKER_REASON['client-capability'], vocabulary) + } return { mode: 'structured', preferred: 'structured', @@ -195,7 +212,7 @@ async function readStructuredCreateSupport( worktreeId: string, agent: TuiAgent | undefined ): Promise<{ supported: boolean; reason?: 'agent' | 'remote' | 'wsl' } | null> { - if (agent !== 'claude' && agent !== 'codex') { + if (!agent || !REGISTERED_STRUCTURED_AGENTS.includes(agent)) { return { supported: false, reason: 'agent' } } try { diff --git a/src/main/agent-launch/agent-launch-surface-factories.ts b/src/main/agent-launch/agent-launch-surface-factories.ts index cf8736408610..b9cf2e8c6b5c 100644 --- a/src/main/agent-launch/agent-launch-surface-factories.ts +++ b/src/main/agent-launch/agent-launch-surface-factories.ts @@ -5,6 +5,7 @@ */ import type { AgentLaunchPrompt } from '../../shared/agent-launch-intent' +import type { StructuredAgentId } from '../../shared/agent-session-provider-handle' import type { TuiAgent } from '../../shared/tui-agent' /** How a surface is built once the executor has decided which one. Injected because an @@ -13,7 +14,8 @@ import type { TuiAgent } from '../../shared/tui-agent' export type AgentLaunchSurfaceFactory = { createStructuredSession(args: { worktreeId: string - agent: 'claude' | 'codex' + /** An agent this host registered as structured; the launch mode already checked it. */ + agent: StructuredAgentId options?: Readonly> /** The caller-minted session id; refused with `AgentLaunchSessionAlreadyExistsError` if taken. */ sessionId?: string diff --git a/src/main/ai-vault/cached-session-list.ts b/src/main/ai-vault/cached-session-list.ts index 3dff9a9bd8e3..7fd66d7bfe0e 100644 --- a/src/main/ai-vault/cached-session-list.ts +++ b/src/main/ai-vault/cached-session-list.ts @@ -1,4 +1,5 @@ import { join } from 'node:path' +import { claudeProfileHistoryDirs } from '../claude-accounts/claude-profile-installed-router' import { clearAiVaultBackgroundRestartCircuit, resetAiVaultScannerBackgroundForTests, @@ -58,7 +59,7 @@ export function configureAiVaultSessionSources(next: AiVaultSessionSources): voi */ export async function localAiVaultScanRoots(): Promise< Required> & - Pick + Pick > { const [additionalCodexHomes, wslHomeDirs] = await Promise.all([ filterPathsToRunningWslDistrosAsync(configuredAdditionalCodexHomePaths()), @@ -67,6 +68,8 @@ export async function localAiVaultScanRoots(): Promise< return { additionalCodexSessionsDirs: additionalCodexHomes.map((homePath) => join(homePath, 'sessions')), wslHomeDirs, + // Why: the scan runs in a worker or child process, which has no account router. + claudeProfileProjectsDirs: claudeProfileHistoryDirs('projects'), wslOpenCodeReaders: await prepareOpenCodeWslReaders(wslHomeDirs), // Why: this scan is always host-local; callers addressing this host by a // runtime id get the result restamped at the RPC edge, never rescanned. diff --git a/src/main/ai-vault/session-scanner-agent-sources.ts b/src/main/ai-vault/session-scanner-agent-sources.ts index a262595dbab8..18ae71095765 100644 --- a/src/main/ai-vault/session-scanner-agent-sources.ts +++ b/src/main/ai-vault/session-scanner-agent-sources.ts @@ -107,7 +107,8 @@ export const AI_VAULT_AGENT_SOURCES: AiVaultAgentSourceTable = { rootDirs: (options, wslHomeDirs) => claudeProjectsRootDirs({ claudeProjectsDir: options.claudeProjectsDir, - wslHomeDirs + wslHomeDirs, + claudeProfileProjectsDirs: options.claudeProfileProjectsDirs }), extensions: ['.jsonl'], // Why: Task subagent transcripts under `/subagents/` share the parent diff --git a/src/main/ai-vault/session-scanner-roots.ts b/src/main/ai-vault/session-scanner-roots.ts index db0b28fe2cd1..6f949476430d 100644 --- a/src/main/ai-vault/session-scanner-roots.ts +++ b/src/main/ai-vault/session-scanner-roots.ts @@ -1,3 +1,4 @@ +import { claudeProfileHistoryDirs } from '../claude-accounts/claude-profile-installed-router' import { homedir } from 'node:os' import { join } from 'node:path' import { resolveOmpSessionsDir } from './omp-session-root' @@ -12,10 +13,13 @@ const CLAUDE_PROJECTS_DIR = join(homedir(), '.claude', 'projects') export function claudeProjectsRootDirs(args: { claudeProjectsDir?: string wslHomeDirs?: readonly string[] + /** Passed in by a scan worker, which has no account router of its own. */ + claudeProfileProjectsDirs?: readonly string[] }): string[] { return [ args.claudeProjectsDir ?? CLAUDE_PROJECTS_DIR, - ...(args.wslHomeDirs ?? []).map((homeDir) => join(homeDir, '.claude', 'projects')) + ...(args.wslHomeDirs ?? []).map((homeDir) => join(homeDir, '.claude', 'projects')), + ...(args.claudeProfileProjectsDirs ?? claudeProfileHistoryDirs('projects')) ] } diff --git a/src/main/ai-vault/session-scanner-types.ts b/src/main/ai-vault/session-scanner-types.ts index add33752ccde..c73461b3d524 100644 --- a/src/main/ai-vault/session-scanner-types.ts +++ b/src/main/ai-vault/session-scanner-types.ts @@ -14,6 +14,8 @@ import type { OpenCodeWslRuntime } from './session-scanner-opencode-wsl-runtime' export type AiVaultScanOptions = { claudeProjectsDir?: string + /** Account `projects` folders the System default does not link; resolved by the host process. */ + claudeProfileProjectsDirs?: readonly string[] codebuddyProjectsDir?: string qoderProjectsDir?: string codexSessionsDir?: string diff --git a/src/main/ai-vault/session-title-resolver.ts b/src/main/ai-vault/session-title-resolver.ts index 8ebd3800a987..80518000e095 100644 --- a/src/main/ai-vault/session-title-resolver.ts +++ b/src/main/ai-vault/session-title-resolver.ts @@ -1,10 +1,7 @@ -import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' -import { listStructuredProviderSessionOwnership } from '../native-chat/agent-session-wire/structured-provider-session-ownership' import { extname } from 'node:path' import { hasUnsafeProviderSessionIdChars } from '../../shared/agent-session-resume' import { AI_VAULT_SESSION_TITLE_REQUEST_MAX_COUNT, - type AiVaultSessionTitle, type AiVaultSessionTitleRequest, type AiVaultSessionTitlesResult } from '../../shared/ai-vault-session-title' @@ -17,22 +14,6 @@ function normalizeRequest(request: AiVaultSessionTitleRequest): AiVaultSessionTi if (!sessionId || sessionId.length > 512 || hasUnsafeProviderSessionIdChars(sessionId)) { return null } - if (request.structuredSession !== undefined) { - const owner = request.structuredSession - if ( - !owner || - typeof owner.workspaceId !== 'string' || - !owner.workspaceId.trim() || - owner.workspaceId.length > 4096 || - typeof owner.sessionId !== 'string' || - !owner.sessionId.trim() || - owner.sessionId.length > 512 || - hasUnsafeProviderSessionIdChars(owner.sessionId) - ) { - return null - } - return { agent: request.agent, sessionId, structuredSession: owner } - } const transcriptPath = request.transcriptPath?.trim() if ( !transcriptPath || @@ -55,50 +36,11 @@ export async function resolveLocalAiVaultSessionTitles( if (!normalized) { continue } - const key = JSON.stringify([ - normalized.agent, - normalized.sessionId, - normalized.structuredSession - ]) + const key = `${normalized.agent}\0${normalized.sessionId}` const previous = deduped.get(key) if (!previous?.transcriptPath || normalized.transcriptPath) { deduped.set(key, normalized) } } - if (signal?.aborted) { - return { titles: [] } - } - const titles: AiVaultSessionTitle[] = [] - const legacy: AiVaultSessionTitleRequest[] = [] - for (const request of deduped.values()) { - const owner = request.structuredSession - if (!owner) { - legacy.push(request) - continue - } - const record = getStructuredAgentSessionHost()?.deps.store.getRecord(owner.sessionId) - if ( - !record || - record.location.executionHostId !== 'local' || - record.location.wslDistro !== null || - record.location.workspaceId !== owner.workspaceId || - record.provider !== request.agent || - !record.conversationName || - !listStructuredProviderSessionOwnership([record]).some( - (ownership) => ownership.providerSessionId === request.sessionId - ) - ) { - continue - } - titles.push({ - agent: request.agent, - sessionId: request.sessionId, - title: record.conversationName, - structuredSession: owner - }) - } - if (legacy.length) { - titles.push(...(await resolveAiVaultSessionTitlesInBackground(legacy, signal)).titles) - } - return signal?.aborted ? { titles: [] } : { titles } + return resolveAiVaultSessionTitlesInBackground([...deduped.values()], signal) } diff --git a/src/main/ai-vault/session-title-result-validation.ts b/src/main/ai-vault/session-title-result-validation.ts index 50acb0068622..79768b12b6dc 100644 --- a/src/main/ai-vault/session-title-result-validation.ts +++ b/src/main/ai-vault/session-title-result-validation.ts @@ -31,33 +31,9 @@ function parseTitle(value: unknown): AiVaultSessionTitle { ) { throw new Error('invalid session title') } - const owner: unknown = record.structuredSession - if ( - owner !== undefined && - (typeof owner !== 'object' || - owner === null || - !('workspaceId' in owner) || - typeof owner.workspaceId !== 'string' || - !owner.workspaceId.trim() || - owner.workspaceId.length > 4096 || - !('sessionId' in owner) || - typeof owner.sessionId !== 'string' || - !owner.sessionId.trim() || - owner.sessionId.length > 512) - ) { - throw new Error('invalid structured session title owner') - } return { agent: record.agent, sessionId: record.sessionId, - title: record.title.trim(), - ...(owner && - typeof owner === 'object' && - 'workspaceId' in owner && - typeof owner.workspaceId === 'string' && - 'sessionId' in owner && - typeof owner.sessionId === 'string' - ? { structuredSession: { workspaceId: owner.workspaceId, sessionId: owner.sessionId } } - : {}) + title: record.title.trim() } } diff --git a/src/main/ai-vault/session-title-structured-record.test.ts b/src/main/ai-vault/session-title-structured-record.test.ts deleted file mode 100644 index d2d80f11f065..000000000000 --- a/src/main/ai-vault/session-title-structured-record.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -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 { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' -import { resolveLocalAiVaultSessionTitles } from './session-title-resolver' -import { parseAiVaultSessionTitlesResult } from './session-title-result-validation' -import { AiVaultSessionTitlesParams } from '../../shared/rpc-contract/ai-vault-params' -import type { AgentSessionRecord } from '../../shared/agent-session-record' -import type { AiVaultSessionTitleRequest } from '../../shared/ai-vault-session-title' - -const mocks = vi.hoisted(() => ({ scan: vi.fn(async () => ({ titles: [] })) })) -vi.mock('./session-scanner-background', () => ({ - resolveAiVaultSessionTitlesInBackground: mocks.scan -})) -const owner = { workspaceId: 'workspace-1', sessionId: 'session-alpha-1' } -const request: AiVaultSessionTitleRequest = { - agent: 'claude', - sessionId: 'provider-session-alpha-1', - structuredSession: owner -} -function install(overrides: Partial = {}): void { - const record = { - ...agentSessionRecordFixture(), - conversationName: 'Saved record name', - ...overrides - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: native title resolution only calls this getRecord member. - setStructuredAgentSessionHost({ - deps: { store: { getRecord: (id: string) => (id === record.sessionId ? record : null) } } - } as never) -} -afterEach(() => { - setStructuredAgentSessionHost(null) - mocks.scan.mockClear() -}) -it('resolves an exact record with explicit ownership and preserves it through wire parsing', async () => { - install() - const parsed = AiVaultSessionTitlesParams.parse({ requests: [request] }) - const resolved = await resolveLocalAiVaultSessionTitles(parsed.requests) - expect(resolved).toEqual({ - titles: [ - { - agent: 'claude', - sessionId: request.sessionId, - title: 'Saved record name', - structuredSession: owner - } - ] - }) - expect(parseAiVaultSessionTitlesResult(resolved)).toEqual(resolved) - expect(mocks.scan).not.toHaveBeenCalled() -}) -it.each([ - { ...request, agent: 'codex' as const }, - { ...request, sessionId: 'other-provider-id' }, - { ...request, structuredSession: { ...owner, workspaceId: 'other-workspace' } }, - { ...request, structuredSession: { ...owner, sessionId: 'other-record' } } -])('refuses a different exact owner without transcript fallback: %j', async (different) => { - install() - expect(await resolveLocalAiVaultSessionTitles([different])).toEqual({ titles: [] }) - expect(mocks.scan).not.toHaveBeenCalled() -}) -it.each(['ssh:remote', 'runtime:other'] as const)( - 'refuses a record owned by %s', - async (executionHostId) => { - install({ location: { ...agentSessionRecordFixture().location, executionHostId } }) - expect(await resolveLocalAiVaultSessionTitles([request])).toEqual({ titles: [] }) - expect(mocks.scan).not.toHaveBeenCalled() - } -) -it('refuses WSL, unnamed and malformed owners and cancelled requests', async () => { - install({ location: { ...agentSessionRecordFixture().location, wslDistro: 'Ubuntu' } }) - expect(await resolveLocalAiVaultSessionTitles([request])).toEqual({ titles: [] }) - install({ conversationName: undefined }) - expect(await resolveLocalAiVaultSessionTitles([request])).toEqual({ titles: [] }) - const malformed = { ...request } - Reflect.set(malformed, 'structuredSession', null) - expect(await resolveLocalAiVaultSessionTitles([malformed])).toEqual({ titles: [] }) - install() - const controller = new AbortController() - controller.abort() - expect(await resolveLocalAiVaultSessionTitles([request], controller.signal)).toEqual({ - titles: [] - }) - expect(mocks.scan).not.toHaveBeenCalled() -}) -it('keeps old requests unchanged and caps native requests at the existing batch size', async () => { - install() - const legacy = { - agent: 'claude' as const, - sessionId: 'legacy-session', - transcriptPath: '/legacy.jsonl' - } - await resolveLocalAiVaultSessionTitles([legacy, request]) - expect(mocks.scan).toHaveBeenCalledExactlyOnceWith([legacy], undefined) - expect( - AiVaultSessionTitlesParams.safeParse({ requests: Array.from({ length: 65 }, () => request) }) - .success - ).toBe(false) - expect( - await resolveLocalAiVaultSessionTitles([...Array.from({ length: 64 }, () => legacy), request]) - ).toEqual({ titles: [] }) -}) -it('retains ownership for historical provider handles without matching an unrelated chain', async () => { - const record = agentSessionRecordFixture() - install({ - providerHandleChain: [ - ...record.providerHandleChain, - { - ...record.providerHandleChain[0]!, - linkId: 'next', - handle: claudeProviderHandle('next-provider-id', null) - } - ] - }) - expect((await resolveLocalAiVaultSessionTitles([request])).titles[0]?.structuredSession).toEqual( - owner - ) - expect( - parseAiVaultSessionTitlesResult({ - titles: [{ agent: 'claude', sessionId: request.sessionId, title: 'Old host prompt' }] - }).titles[0]?.structuredSession - ).toBeUndefined() - expect(() => - parseAiVaultSessionTitlesResult({ - titles: [ - { - agent: 'claude', - sessionId: request.sessionId, - title: 'Name', - structuredSession: { workspaceId: '', sessionId: owner.sessionId } - } - ] - }) - ).toThrow() -}) diff --git a/src/main/ai-vault/structured-session-ownership.test.ts b/src/main/ai-vault/structured-session-ownership.test.ts index ebfa13ffceaa..f708023c087a 100644 --- a/src/main/ai-vault/structured-session-ownership.test.ts +++ b/src/main/ai-vault/structured-session-ownership.test.ts @@ -9,8 +9,10 @@ import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire import { assertLegacyAiVaultResumeAllowed, assertLegacyAiVaultResumeCommandAllowed, + projectStructuredAiVaultSearchResponse, projectStructuredAiVaultSessions } from './structured-session-ownership' +import type { AiVaultSearchHit } from '../../shared/ai-vault-search-types' import { claudeProviderHandle, codexProviderHandle @@ -43,6 +45,39 @@ describe('structured AI Vault ownership', () => { expect(projected.sessions[1]).toBe(unowned) }) + it('names and owns an indexed search hit as its list row is, leaving other hits alone', () => { + installOwnership({ conversationName: 'auth/login' }) + const hit: AiVaultSearchHit = { + agent: 'codex', + sessionId: PROVIDER_SESSION, + title: 'First prompt', + cwd: '/repo', + branch: null, + updatedAt: null, + messageCount: 1, + score: 1, + source: { presence: 'present' }, + evidence: null + } + const other = { ...hit, sessionId: 'different-session', title: 'Original' } + const response = projectStructuredAiVaultSearchResponse({ + kind: 'results', + hits: [hit, other], + page: { cursor: null, hasMore: false }, + generation: 1, + truncated: { candidates: false, snippets: 0, query: false, freshness: false }, + durationMs: 1 + }) + expect(response.kind === 'results' && response.hits).toEqual([ + { + ...hit, + title: 'auth/login', + structuredSession: { sessionId: 'session-alpha', workspaceId: 'workspace-1' } + }, + other + ]) + }) + it.each(['claude', 'codex'] as const)( 'keeps an unnamed %s chat at its ordinary label', (provider) => { diff --git a/src/main/ai-vault/structured-session-ownership.ts b/src/main/ai-vault/structured-session-ownership.ts index b21c68099f1a..5a24b3254308 100644 --- a/src/main/ai-vault/structured-session-ownership.ts +++ b/src/main/ai-vault/structured-session-ownership.ts @@ -1,7 +1,8 @@ import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { agentSessionLeaseAdmitsWriter } from '../../shared/agent-session-lease-adjudication' import { defaultAgentChatLabel } from '../../shared/agent-session-chat-label' -import type { AiVaultListResult, AiVaultSession } from '../../shared/ai-vault-types' +import type { AiVaultListResult } from '../../shared/ai-vault-types' +import type { AiVaultSearchResponse } from '../../shared/ai-vault-search-types' import type { AiVaultPrepareSessionResumeArgs } from '../../shared/ai-vault-resume-preparation' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import { ensureStructuredAgentSessionHostUnlessRefused } from '../runtime/structured-agent-session-host-refusal' @@ -14,29 +15,19 @@ export function projectStructuredAiVaultSessions( result: AiVaultListResult, structuredSupported: boolean ): AiVaultListResult { - const host = getStructuredAgentSessionHost() - if (!host) { + const owner = ownershipLookup() + if (!owner) { return result } const sessions = result.sessions.flatMap((session) => { - const ownership = - session.executionHostId === LOCAL_EXECUTION_HOST_ID ? findSessionOwnership(session) : null + const ownership = session.executionHostId === LOCAL_EXECUTION_HOST_ID ? owner(session) : null if (!ownership) { return [session] } if (!structuredSupported) { return [] } - return [ - { - ...session, - title: ownership.conversationName ?? defaultAgentChatLabel(ownership.provider), - structuredSession: { - sessionId: ownership.sessionId, - workspaceId: ownership.workspaceId - } - } - ] + return [{ ...session, ...ownedTitle(ownership) }] }) return sessions.length === result.sessions.length && sessions.every((row, index) => row === result.sessions[index]) @@ -44,6 +35,69 @@ export function projectStructuredAiVaultSessions( : { ...result, sessions } } +/** Indexed hits name and own a native chat exactly as list rows do; only this host's index can. */ +export function projectStructuredAiVaultSearchResponse( + response: AiVaultSearchResponse +): AiVaultSearchResponse { + const owner = response.kind === 'results' ? ownershipLookup() : null + if (!owner || response.kind !== 'results') { + return response + } + return { + ...response, + hits: response.hits.map((hit) => { + const ownership = owner(hit) + return ownership ? { ...hit, ...ownedTitle(ownership) } : hit + }) + } +} + +/** The hits once the chat host is installed; plain hits if it will not install, since owning and + * naming them must never cost the user the search. */ +export async function searchWithStructuredOwners( + search: Promise, + ensureHost?: () => Promise +): Promise { + const hostReady = ensureHost + ? ensureStructuredAgentSessionHostUnlessRefused(ensureHost).then( + () => true, + (error: unknown) => { + console.warn('[ai-vault-search] returning hits without native chat owners', error) + return false + } + ) + : true + const response = await search + return (await hostReady) ? projectStructuredAiVaultSearchResponse(response) : response +} + +function ownedTitle(ownership: StructuredProviderSessionOwnership) { + return { + title: ownership.conversationName ?? defaultAgentChatLabel(ownership.provider), + structuredSession: { sessionId: ownership.sessionId, workspaceId: ownership.workspaceId } + } +} + +/** One index per projection: a list holds hundreds of rows, each once searched every record. */ +function ownershipLookup(): + | ((row: { agent: string; sessionId: string }) => StructuredProviderSessionOwnership | null) + | null { + if (!getStructuredAgentSessionHost()) { + return null + } + const byProviderSession = new Map() + for (const ownership of listOwnership()) { + const key = `${ownership.provider}\0${ownership.providerSessionId}` + if (!byProviderSession.has(key)) { + byProviderSession.set(key, ownership) + } + } + return (row) => + row.agent === 'codex' || row.agent === 'claude' + ? (byProviderSession.get(`${row.agent}\0${row.sessionId}`) ?? null) + : null +} + export function assertLegacyAiVaultResumeAllowed(args: AiVaultPrepareSessionResumeArgs): void { const ownership = findResumeOwnership(args) if (ownership) { @@ -75,13 +129,6 @@ function isPotentialStructuredResumeCommand(command: string): boolean { return parseResumeInvocation(command) !== null } -function findSessionOwnership(session: AiVaultSession): StructuredProviderSessionOwnership | null { - if (session.agent !== 'codex' && session.agent !== 'claude') { - return null - } - return findOwnership(session.agent, session.sessionId) -} - function findResumeOwnership( args: AiVaultPrepareSessionResumeArgs ): StructuredProviderSessionOwnership | null { diff --git a/src/main/browser/wsl-browser-network-relay-launch.ts b/src/main/browser/wsl-browser-network-relay-launch.ts index 8949f21e0746..89e1c964c14f 100644 --- a/src/main/browser/wsl-browser-network-relay-launch.ts +++ b/src/main/browser/wsl-browser-network-relay-launch.ts @@ -1,7 +1,7 @@ import { spawnProcess } from '../../shared/child-process/run-process' import { existsSync, readFileSync } from 'node:fs' import { join } from 'node:path' -import { getAppEnvironment } from '../../shared/app-environment' +import { relayBundleCandidates } from '../ssh/relay-bundle-paths' import { WSL_BROWSER_NETWORK_RELAY_BUNDLE_NAME, WSL_BROWSER_NETWORK_RELAY_DIR, @@ -28,22 +28,7 @@ export type WslBrowserNetworkRelayChild = ReturnType & { type WslBrowserNetworkRelayBundle = { jsPath: string; version: string } export function resolveWslBrowserNetworkRelayBundle(): WslBrowserNetworkRelayBundle | null { - const candidates: string[] = [] - if (process.env.ORCA_RELAY_PATH) { - candidates.push(join(process.env.ORCA_RELAY_PATH, 'wsl')) - } - if (process.resourcesPath) { - candidates.push(join(process.resourcesPath, 'relay', 'wsl')) - candidates.push(join(process.resourcesPath, 'app.asar.unpacked', 'out', 'relay', 'wsl')) - } - try { - const appPath = getAppEnvironment().getAppPath() - candidates.push(join(appPath, 'resources', 'relay', 'wsl')) - candidates.push(join(appPath, 'out', 'relay', 'wsl')) - } catch { - // Tests, early startup and plain-Node hosts have no app path — env/resources candidates suffice. - } - for (const dir of candidates) { + for (const dir of relayBundleCandidates('wsl')) { const jsPath = join(dir, WSL_BROWSER_NETWORK_RELAY_BUNDLE_NAME) const versionPath = join(dir, WSL_BROWSER_NETWORK_RELAY_VERSION_FILE) if (!existsSync(jsPath) || !existsSync(versionPath)) { diff --git a/src/main/claude-accounts/claude-profile-history-windows.test.ts b/src/main/claude-accounts/claude-profile-history-windows.test.ts new file mode 100644 index 000000000000..b060b9c5328f --- /dev/null +++ b/src/main/claude-accounts/claude-profile-history-windows.test.ts @@ -0,0 +1,199 @@ +import * as fs from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type * as FsUtils from '../codex-accounts/fs-utils' +vi.mock('electron', () => ({ app: { getPath: () => '/unused-test-path' } })) +vi.mock('node:fs', async (original) => { + const actual = await original() + return { + ...actual, + linkSync: vi.fn(actual.linkSync), + statSync: vi.fn(actual.statSync), + symlinkSync: vi.fn(actual.symlinkSync) + } +}) +vi.mock('../codex-accounts/fs-utils', async (original) => { + const actual = await original() + return { ...actual, writeFileAtomically: vi.fn(actual.writeFileAtomically) } +}) +import { writeFileAtomically } from '../codex-accounts/fs-utils' +import { shareClaudeProfileHistory } from './claude-profile-history' + +// Junctions and hardlinks need no Windows privilege, so these run on real Windows too; on POSIX +// Node ignores the junction type and makes a symlink. +const roots: string[] = [] +function fixture() { + const root = fs.realpathSync(fs.mkdtempSync(join(tmpdir(), 'claude-profile-history-win-'))) + roots.push(root) + const profileHome = join(root, 'profile') + const userHome = join(root, 'user') + const defaultHome = join(userHome, '.claude') + fs.mkdirSync(profileHome) + fs.mkdirSync(defaultHome, { recursive: true }) + const share = () => shareClaudeProfileHistory({ profileHome, userHome, platform: 'win32' }) + const history = (): string => fs.readFileSync(join(defaultHome, 'history.jsonl'), 'utf8') + const isHardlinked = (): boolean => { + const own = fs.lstatSync(join(profileHome, 'history.jsonl'), { bigint: true }) + const shared = fs.lstatSync(join(defaultHome, 'history.jsonl'), { bigint: true }) + return !own.isSymbolicLink() && own.dev === shared.dev && own.ino === shared.ino + } + const scrub = (content: string): void => { + fs.writeFileSync(join(defaultHome, 'scrubbed'), content) + fs.renameSync(join(defaultHome, 'scrubbed'), join(defaultHome, 'history.jsonl')) + } + return { profileHome, defaultHome, share, history, isHardlinked, scrub } +} +afterEach(() => { + vi.resetAllMocks() + for (const dir of roots.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }) + } +}) + +describe('Claude profile history sharing on Windows', () => { + it('links session trees as junctions and prompt history as a hardlink', async () => { + const f = fixture() + fs.mkdirSync(join(f.profileHome, 'projects')) + fs.writeFileSync(join(f.profileHome, 'projects/session.jsonl'), 'own') + fs.writeFileSync(join(f.defaultHome, 'history.jsonl'), 'd1\n') + fs.writeFileSync(join(f.profileHome, 'history.jsonl'), 'p1\n') + const report = await f.share() + expect(report.warnings).toEqual([]) + expect(report.surfaces.projects).toBe('linked') + expect(report.surfaces['history.jsonl']).toBe('linked') + expect(fs.symlinkSync).toHaveBeenCalledWith( + join(f.defaultHome, 'projects'), + join(f.profileHome, 'projects'), + 'junction' + ) + // Node reports a junction as a symlink. + expect(fs.lstatSync(join(f.profileHome, 'projects')).isSymbolicLink()).toBe(true) + expect(fs.readFileSync(join(f.defaultHome, 'projects/session.jsonl'), 'utf8')).toBe('own') + expect(f.isHardlinked()).toBe(true) + fs.appendFileSync(join(f.profileHome, 'history.jsonl'), 'p2\n') + expect(f.history()).toBe('d1\np1\np2\n') + const again = await f.share() + expect(again.surfaces.projects).toBe('unchanged') + expect(again.surfaces['history.jsonl']).toBe('unchanged') + expect(f.history()).toBe('d1\np1\np2\n') + }) + it('adds only the new lines when the CLI rewrites the profile copy', async () => { + const f = fixture() + fs.writeFileSync(join(f.defaultHome, 'history.jsonl'), 'd1\nd2\n') + await f.share() + fs.writeFileSync(join(f.profileHome, 'rewrite'), 'd1\nd2\nnew\n') + fs.renameSync(join(f.profileHome, 'rewrite'), join(f.profileHome, 'history.jsonl')) + await f.share() + expect(f.history()).toBe('d1\nd2\nnew\n') + expect(f.isHardlinked()).toBe(true) + }) + it('does not bring back history the user removed from the shared file', async () => { + const f = fixture() + fs.writeFileSync(join(f.defaultHome, 'history.jsonl'), 'a\nSECRET\nb\n') + await f.share() + f.scrub('a\nb\n') + const report = await f.share() + expect(f.history()).toBe('a\nb\n') + expect(report.warnings).toContainEqual( + expect.objectContaining({ surface: 'history.jsonl', code: 'retained-conflict' }) + ) + expect( + fs.readFileSync(join(f.profileHome, 'history.jsonl.orca-profile-conflict'), 'utf8') + ).toBe('a\nSECRET\nb\n') + expect(f.isHardlinked()).toBe(true) + await f.share() + expect(f.history()).toBe('a\nb\n') + }) + it('fails closed when the link record cannot be read', async () => { + const f = fixture() + fs.writeFileSync(join(f.defaultHome, 'history.jsonl'), 'a\nSECRET\nb\n') + await f.share() + const record = join(f.profileHome, 'history.jsonl.orca-profile-link') + fs.rmSync(record) + fs.mkdirSync(record) + f.scrub('a\nb\n') + const report = await f.share() + expect(report.warnings).toContainEqual( + expect.objectContaining({ surface: 'history.jsonl', code: 'unreadable' }) + ) + expect(f.history()).toBe('a\nb\n') + }) + it('keeps prompt history private across volumes and moves nothing', async () => { + const f = fixture() + fs.writeFileSync(join(f.profileHome, 'history.jsonl'), 'private\n') + const actual = vi.mocked(fs.statSync).getMockImplementation()! + vi.mocked(fs.statSync).mockImplementation((file, options) => { + const stats = actual(file, options) + return file === f.profileHome && stats ? Object.assign(stats, { dev: -1 }) : stats + }) + const report = await f.share() + expect(report.warnings).toContainEqual( + expect.objectContaining({ surface: 'history.jsonl', code: 'cross-filesystem' }) + ) + expect(fs.readFileSync(join(f.profileHome, 'history.jsonl'), 'utf8')).toBe('private\n') + expect(fs.readdirSync(f.profileHome).filter((name) => name.startsWith('history'))).toEqual([ + 'history.jsonl' + ]) + expect(f.history()).toBe('') + }) + it('leaves a set-aside copy in place when the share is refused across volumes', async () => { + const f = fixture() + fs.writeFileSync(join(f.defaultHome, 'history.jsonl'), 'd1\n') + fs.writeFileSync(join(f.profileHome, 'history.jsonl.orca-profile-merge'), 'saved\n') + const actual = vi.mocked(fs.statSync).getMockImplementation()! + vi.mocked(fs.statSync).mockImplementation((file, options) => { + const stats = actual(file, options) + return file === f.profileHome && stats ? Object.assign(stats, { dev: -1 }) : stats + }) + const report = await f.share() + expect(report.warnings).toContainEqual( + expect.objectContaining({ surface: 'history.jsonl', code: 'cross-filesystem' }) + ) + expect(f.history()).toBe('d1\n') + expect(fs.readFileSync(join(f.profileHome, 'history.jsonl.orca-profile-merge'), 'utf8')).toBe( + 'saved\n' + ) + }) + it('keeps prompt history private when the hardlink cannot be made', async () => { + const f = fixture() + fs.writeFileSync(join(f.profileHome, 'history.jsonl'), 'private\n') + vi.mocked(fs.linkSync).mockImplementationOnce(() => { + throw Object.assign(new Error('not supported'), { code: 'EPERM' }) + }) + const report = await f.share() + expect(report.warnings).toContainEqual( + expect.objectContaining({ surface: 'history.jsonl', code: 'link-failed' }) + ) + expect(fs.lstatSync(join(f.profileHome, 'history.jsonl')).nlink).toBe(1) + expect(fs.readFileSync(join(f.profileHome, 'history.jsonl'), 'utf8')).toBe('private\n') + }) + it('undoes a hardlink whose record could not be written', async () => { + const f = fixture() + fs.writeFileSync(join(f.profileHome, 'history.jsonl'), 'private\n') + vi.mocked(writeFileAtomically).mockImplementationOnce(() => { + throw Object.assign(new Error('busy'), { code: 'EBUSY' }) + }) + const report = await f.share() + expect(report.warnings).toContainEqual( + expect.objectContaining({ surface: 'history.jsonl', code: 'link-failed' }) + ) + expect(f.isHardlinked()).toBe(false) + expect(fs.readFileSync(join(f.profileHome, 'history.jsonl'), 'utf8')).toBe('private\n') + }) + it('finishes a share that was interrupted before the links were made', async () => { + const f = fixture() + fs.writeFileSync(join(f.defaultHome, 'history.jsonl'), 'd1\n') + fs.writeFileSync(join(f.profileHome, 'history.jsonl.orca-profile-merge'), 'd1\nsaved\n') + fs.mkdirSync(join(f.profileHome, 'todos.orca-profile-merge')) + fs.writeFileSync(join(f.profileHome, 'todos.orca-profile-merge/t.json'), 't') + const report = await f.share() + expect(report.warnings).toEqual([]) + expect(f.history()).toBe('d1\nsaved\n') + expect(f.isHardlinked()).toBe(true) + expect(fs.readFileSync(join(f.defaultHome, 'todos/t.json'), 'utf8')).toBe('t') + expect(fs.realpathSync(join(f.profileHome, 'todos'))).toBe( + fs.realpathSync(join(f.defaultHome, 'todos')) + ) + }) +}) diff --git a/src/main/claude-accounts/claude-profile-history.test.ts b/src/main/claude-accounts/claude-profile-history.test.ts new file mode 100644 index 000000000000..6727996886ff --- /dev/null +++ b/src/main/claude-accounts/claude-profile-history.test.ts @@ -0,0 +1,243 @@ +import * as fs from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +vi.mock('electron', () => ({ app: { getPath: () => '/unused-test-path' } })) +vi.mock('node:fs', async (original) => { + const actual = await original() + return { + ...actual, + renameSync: vi.fn(actual.renameSync), + statSync: vi.fn(actual.statSync) + } +}) +import { shareClaudeProfileHistory } from './claude-profile-history' +const roots: string[] = [] +function fixture() { + const root = fs.realpathSync(fs.mkdtempSync(join(tmpdir(), 'claude-profile-history-'))) + roots.push(root) + const profileHome = join(root, 'profile') + const userHome = join(root, 'user') + const defaultHome = join(userHome, '.claude') + fs.mkdirSync(profileHome) + fs.mkdirSync(defaultHome, { recursive: true }) + const share = (platform: NodeJS.Platform = 'linux') => + shareClaudeProfileHistory({ profileHome, userHome, platform }) + const history = (): string => fs.readFileSync(join(defaultHome, 'history.jsonl'), 'utf8') + return { profileHome, userHome, defaultHome, share, history } +} +afterEach(() => { + vi.resetAllMocks() + for (const dir of roots.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }) + } +}) + +// Why: these create real symlinks, which Windows needs privilege for. +const itLinks = it.skipIf(process.platform === 'win32') + +describe('Claude profile history sharing', () => { + itLinks( + 'merges session trees without overwriting conflicts and shares future default writes', + async () => { + const f = fixture() + for (const home of [f.profileHome, f.defaultHome]) { + fs.mkdirSync(join(home, 'projects')) + } + fs.writeFileSync(join(f.profileHome, 'projects/session.jsonl'), 'new') + fs.writeFileSync(join(f.profileHome, 'projects/conflict.jsonl'), 'private') + fs.writeFileSync(join(f.defaultHome, 'projects/conflict.jsonl'), 'existing') + const report = await f.share() + expect(report.surfaces.projects).toBe('linked') + expect(report.warnings).toContainEqual( + expect.objectContaining({ surface: 'projects', code: 'retained-conflict' }) + ) + expect(fs.readFileSync(join(f.defaultHome, 'projects/session.jsonl'), 'utf8')).toBe('new') + expect(fs.readFileSync(join(f.defaultHome, 'projects/conflict.jsonl'), 'utf8')).toBe( + 'existing' + ) + expect( + fs.readFileSync(join(f.profileHome, 'projects.orca-profile-merge/conflict.jsonl'), 'utf8') + ).toBe('private') + fs.writeFileSync(join(f.defaultHome, 'projects/later.jsonl'), 'later') + expect(fs.readFileSync(join(f.profileHome, 'projects/later.jsonl'), 'utf8')).toBe('later') + } + ) + itLinks('recovers a directory swap interrupted before link publication', async () => { + const f = fixture() + fs.mkdirSync(join(f.profileHome, 'projects.orca-profile-merge')) + fs.writeFileSync(join(f.profileHome, 'projects.orca-profile-merge/session.jsonl'), 'saved') + await f.share() + expect(fs.realpathSync(join(f.profileHome, 'projects'))).toBe( + fs.realpathSync(join(f.defaultHome, 'projects')) + ) + expect(fs.readFileSync(join(f.defaultHome, 'projects/session.jsonl'), 'utf8')).toBe('saved') + }) + itLinks('keeps moving entries past one that fails and reports it', async () => { + const f = fixture() + fs.mkdirSync(join(f.profileHome, 'todos')) + for (const name of ['a.json', 'b.json', 'c.json']) { + fs.writeFileSync(join(f.profileHome, 'todos', name), name) + } + const actual = vi.mocked(fs.renameSync).getMockImplementation()! + vi.mocked(fs.renameSync).mockImplementation((from, to) => { + if (String(from).endsWith('b.json')) { + throw Object.assign(new Error('busy'), { code: 'EBUSY' }) + } + actual(from, to) + }) + const report = await f.share() + expect(fs.readdirSync(join(f.defaultHome, 'todos')).sort()).toEqual(['a.json', 'c.json']) + expect(report.warnings).toContainEqual( + expect.objectContaining({ surface: 'todos', code: 'failed' }) + ) + vi.mocked(fs.renameSync).mockReset() + await f.share() + expect(fs.readdirSync(join(f.defaultHome, 'todos')).sort()).toEqual([ + 'a.json', + 'b.json', + 'c.json' + ]) + }) + itLinks('keeps a session tree private across filesystems', async () => { + const f = fixture() + fs.mkdirSync(join(f.profileHome, 'plans')) + fs.writeFileSync(join(f.profileHome, 'plans/p.md'), 'plan') + const actual = vi.mocked(fs.statSync).getMockImplementation()! + vi.mocked(fs.statSync).mockImplementation((file, options) => { + const stats = actual(file, options) + return file === join(f.profileHome, 'plans') && stats + ? Object.assign(stats, { dev: -1 }) + : stats + }) + const report = await f.share() + expect(report.warnings).toContainEqual( + expect.objectContaining({ surface: 'plans', code: 'cross-filesystem' }) + ) + expect(fs.lstatSync(join(f.profileHome, 'plans')).isDirectory()).toBe(true) + expect(fs.readFileSync(join(f.profileHome, 'plans/p.md'), 'utf8')).toBe('plan') + }) + itLinks('retains prompt cursors, drains late appends and repairs a CLI replacement', async () => { + const f = fixture() + fs.writeFileSync(join(f.defaultHome, 'history.jsonl'), 'default') + fs.writeFileSync(join(f.profileHome, 'history.jsonl'), 'profile\n') + await f.share() + const pending = join(f.profileHome, 'history.jsonl.orca-profile-merge') + fs.appendFileSync(pending, 'late\n') + await f.share() + await f.share() + expect(f.history()).toBe('default\nprofile\nlate\n') + fs.writeFileSync(join(f.profileHome, 'replacement'), 'replacement\n') + fs.renameSync(join(f.profileHome, 'replacement'), join(f.profileHome, 'history.jsonl')) + await f.share() + expect(fs.realpathSync(join(f.profileHome, 'history.jsonl'))).toBe( + fs.realpathSync(join(f.defaultHome, 'history.jsonl')) + ) + expect(f.history()).toBe('default\nprofile\nlate\nreplacement\n') + }) + itLinks('terminates merged records so the next append starts its own line', async () => { + const f = fixture() + fs.writeFileSync(join(f.defaultHome, 'history.jsonl'), 'd1\n') + fs.writeFileSync(join(f.profileHome, 'history.jsonl'), 'p1\np2') + await f.share() + fs.appendFileSync(join(f.profileHome, 'history.jsonl'), 'after-link\n') + expect(f.history()).toBe('d1\np1\np2\nafter-link\n') + }) + itLinks('adds only the new lines of a CLI rewrite of the shared file', async () => { + const f = fixture() + fs.writeFileSync(join(f.defaultHome, 'history.jsonl'), 'd1\nd2\n') + await f.share() + fs.writeFileSync(join(f.profileHome, 'rewrite'), 'd1\nd2\nnew\n') + fs.renameSync(join(f.profileHome, 'rewrite'), join(f.profileHome, 'history.jsonl')) + await f.share() + expect(f.history()).toBe('d1\nd2\nnew\n') + }) + itLinks( + 'does not re-append lines after a purge drops one the shared file still has', + async () => { + const f = fixture() + fs.writeFileSync(join(f.defaultHome, 'history.jsonl'), 'a\nb\nc\n') + await f.share() + fs.writeFileSync(join(f.profileHome, 'rewrite'), 'a\nc\n') + fs.renameSync(join(f.profileHome, 'rewrite'), join(f.profileHome, 'history.jsonl')) + await f.share() + expect(f.history()).toBe('a\nb\nc\n') + } + ) + itLinks('deletes a retained copy once a later run finds nothing new in it', async () => { + const f = fixture() + fs.writeFileSync(join(f.profileHome, 'history.jsonl'), 'p1\n') + await f.share() + const pending = join(f.profileHome, 'history.jsonl.orca-profile-merge') + expect(fs.existsSync(`${pending}.offset`)).toBe(true) + fs.appendFileSync(pending, 'late\n') + await f.share() + expect(fs.existsSync(pending)).toBe(true) + await f.share() + expect(fs.existsSync(pending)).toBe(false) + expect(fs.existsSync(`${pending}.offset`)).toBe(false) + expect(f.history()).toBe('p1\nlate\n') + }) + itLinks('drains retained generations in numeric order', async () => { + const f = fixture() + for (const generation of [0, 1, 2, 10, 11]) { + const suffix = generation === 0 ? '' : `-${generation}` + fs.writeFileSync( + join(f.profileHome, `history.jsonl.orca-profile-merge${suffix}`), + `g${generation}\n` + ) + } + await f.share() + expect(f.history()).toBe('g0\ng1\ng2\ng10\ng11\n') + }) + itLinks( + 'drains a copy left by an interrupted share without replaying the shared history', + async () => { + const f = fixture() + fs.writeFileSync(join(f.defaultHome, 'history.jsonl'), 'd1\nd2\n') + fs.writeFileSync(join(f.profileHome, 'history.jsonl.orca-profile-merge'), 'd1\nd2\nnew\n') + await f.share() + expect(f.history()).toBe('d1\nd2\nnew\n') + } + ) + itLinks('still links a session tree when its old leftover cannot be read', async () => { + const f = fixture() + const leftover = join(f.profileHome, 'projects.orca-profile-merge') + fs.mkdirSync(leftover) + fs.writeFileSync(join(leftover, 's.jsonl'), 'x') + fs.chmodSync(leftover, 0) + const report = await f.share() + fs.chmodSync(leftover, 0o700) + expect(report.surfaces.projects).toBe('linked') + expect(report.warnings).toContainEqual(expect.objectContaining({ surface: 'projects' })) + expect(fs.realpathSync(join(f.profileHome, 'projects'))).toBe( + fs.realpathSync(join(f.defaultHome, 'projects')) + ) + }) + itLinks('still links the profile when an old retained copy cannot be read', async () => { + const f = fixture() + const old = join(f.profileHome, 'history.jsonl.orca-profile-merge') + fs.writeFileSync(old, 'old\n') + fs.chmodSync(old, 0) + fs.writeFileSync(join(f.profileHome, 'history.jsonl'), 'private\n') + const report = await f.share() + fs.chmodSync(old, 0o600) + expect(report.warnings).toContainEqual(expect.objectContaining({ surface: 'history.jsonl' })) + expect(fs.lstatSync(join(f.profileHome, 'history.jsonl')).isSymbolicLink()).toBe(true) + expect(f.history()).toBe('private\n') + }) + itLinks("pools into the user's own CLAUDE_CONFIG_DIR when they set one", async () => { + const f = fixture() + const userConfigDir = join(f.userHome, 'custom-claude') + fs.writeFileSync(join(f.profileHome, 'history.jsonl'), 'p1\n') + await shareClaudeProfileHistory({ + profileHome: f.profileHome, + userHome: f.userHome, + userConfigDir, + platform: 'linux' + }) + expect(fs.realpathSync(join(f.profileHome, 'projects'))).toBe(join(userConfigDir, 'projects')) + expect(fs.readFileSync(join(userConfigDir, 'history.jsonl'), 'utf8')).toBe('p1\n') + expect(fs.readdirSync(f.defaultHome)).toEqual([]) + }) +}) diff --git a/src/main/claude-accounts/claude-profile-history.ts b/src/main/claude-accounts/claude-profile-history.ts new file mode 100644 index 000000000000..03f13644fcff --- /dev/null +++ b/src/main/claude-accounts/claude-profile-history.ts @@ -0,0 +1,163 @@ +import { mkdirSync, readdirSync, realpathSync, rmdirSync, statSync, symlinkSync } from 'node:fs' +import { join } from 'node:path' +import { renameFileWithWindowsRetry } from '../codex-accounts/fs-utils' +import { resolveClaudeDefaultHome } from './claude-profile-paths' +import { + ClaudeProfileSurfaceError, + createClaudeProfileReport, + runClaudeProfileSurface, + warnClaudeProfile, + type ClaudeProfileReport, + type ClaudeProfileSurfaceOutcome +} from './claude-profile-report' +import { + CLAUDE_PROFILE_MERGE_SUFFIX, + crossFilesystem, + lstatIfPresent, + mergeClaudeProfilePromptHistory +} from './claude-profile-prompt-history' + +export const CLAUDE_PROFILE_HISTORY_DIRS = [ + 'projects', + 'sessions', + 'session-env', + 'file-history', + 'shell-snapshots', + 'todos', + 'paste-cache', + 'tasks', + 'plans', + 'transcripts' +] as const + +type MoveResult = { retained: number; failed: unknown[] } + +/** Keeps going past a failed entry so one locked file never hides the rest. */ +function moveHistoryTree(source: string, destination: string, result: MoveResult): void { + for (const item of readdirSync(source, { withFileTypes: true })) { + const from = join(source, item.name) + const to = join(destination, item.name) + try { + const existing = lstatIfPresent(to) + if (!existing) { + renameFileWithWindowsRetry(from, to) + } else if (item.isDirectory() && existing.isDirectory()) { + moveHistoryTree(from, to, result) + } else { + result.retained += 1 + } + } catch (error) { + result.failed.push(error) + } + } + if (readdirSync(source).length === 0) { + rmdirSync(source) + } +} + +function drainDirectory( + pending: string, + destination: string, + report: ClaudeProfileReport, + name: (typeof CLAUDE_PROFILE_HISTORY_DIRS)[number] +): void { + const result: MoveResult = { retained: 0, failed: [] } + moveHistoryTree(pending, destination, result) + if (result.failed.length > 0) { + warnClaudeProfile(report, name, result.failed[0]) + } + if (result.retained > 0) { + const detail = `${result.retained} conflicting entries kept in ${pending}` + warnClaudeProfile(report, name, new ClaudeProfileSurfaceError('retained-conflict', detail)) + } +} + +function mergeDirectory( + profile: string, + home: string, + name: (typeof CLAUDE_PROFILE_HISTORY_DIRS)[number], + platform: NodeJS.Platform, + report: ClaudeProfileReport +): ClaudeProfileSurfaceOutcome { + const source = join(profile, name) + const destination = join(home, name) + const pending = `${source}${CLAUDE_PROFILE_MERGE_SUFFIX}` + mkdirSync(destination, { recursive: true }) + const sameDevice = (file: string): boolean => statSync(file).dev === statSync(destination).dev + if (lstatIfPresent(pending)?.isDirectory()) { + if (!sameDevice(pending)) { + if (!lstatIfPresent(source)) { + renameFileWithWindowsRetry(pending, source) + } + throw crossFilesystem() + } + try { + drainDirectory(pending, destination, report, name) + } catch (error) { + // Why: an unreadable leftover is reported and kept; it must not stop the share itself. + warnClaudeProfile(report, name, error) + } + } + const current = lstatIfPresent(source) + if (current?.isSymbolicLink()) { + return realpathSync(source) === realpathSync(destination) ? 'unchanged' : 'user-owned' + } + if (current && !current.isDirectory()) { + return 'user-owned' + } + if (current) { + if (!sameDevice(source)) { + throw crossFilesystem() + } + if (lstatIfPresent(pending)) { + throw new ClaudeProfileSurfaceError( + 'retained-conflict', + `Earlier conflicts kept in ${pending}` + ) + } + renameFileWithWindowsRetry(source, pending) + } + try { + // Why: a junction needs no Windows privilege; Node's lstat reports it as a symlink. + symlinkSync(destination, source, platform === 'win32' ? 'junction' : 'dir') + } catch (error) { + if (current) { + if (!lstatIfPresent(source)) { + renameFileWithWindowsRetry(pending, source) + } else if (lstatIfPresent(source)?.isDirectory()) { + moveHistoryTree(pending, source, { retained: 0, failed: [] }) + } + } + throw new ClaudeProfileSurfaceError('link-failed', String(error)) + } + if (current) { + drainDirectory(pending, destination, report, name) + } + return 'linked' +} + +/** + * Pools a profile's sessions and prompt history into the default home. Execution-host paths only; + * callers go through provisionClaudeAccountProfile, which gates and creates the profile. + */ +export async function shareClaudeProfileHistory(args: { + profileHome: string + userHome: string + /** The user's own CLAUDE_CONFIG_DIR; `~/.claude` when unset. */ + userConfigDir?: string + platform?: NodeJS.Platform +}): Promise { + const report = createClaudeProfileReport() + const platform = args.platform ?? process.platform + const defaultHome = resolveClaudeDefaultHome(args.userHome, args.userConfigDir) + mkdirSync(defaultHome, { recursive: true, mode: 0o700 }) + for (const name of CLAUDE_PROFILE_HISTORY_DIRS) { + await runClaudeProfileSurface(report, name, () => + mergeDirectory(args.profileHome, defaultHome, name, platform, report) + ) + } + await runClaudeProfileSurface(report, 'history.jsonl', () => + mergeClaudeProfilePromptHistory(args.profileHome, defaultHome, platform, report) + ) + return report +} diff --git a/src/main/claude-accounts/claude-profile-installed-router.ts b/src/main/claude-accounts/claude-profile-installed-router.ts new file mode 100644 index 000000000000..a0c04e3c9baa --- /dev/null +++ b/src/main/claude-accounts/claude-profile-installed-router.ts @@ -0,0 +1,54 @@ +import { lstatSync, readdirSync } from 'node:fs' +import { join } from 'node:path' +// Why type-only: scan workers import this module, and the router's setup graph must not load there. +import type { ClaudeProfileRouter } from './claude-profile-router' +import type { ClaudeAccountSelectionTarget } from './runtime-selection' + +/** A real directory; a link is false, so a history folder shared by link is not read twice. */ +export function isDirectory(path: string): boolean { + try { + return lstatSync(path).isDirectory() + } catch { + return false + } +} + +export function listClaudeProfileHomes(dataRoot: string): string[] { + const root = join(dataRoot, 'claude-profiles') + let ids: string[] + try { + ids = readdirSync(root) + } catch { + return [] + } + return ids.map((id) => join(root, id, 'home')).filter(isDirectory) +} + +let installed: ClaudeProfileRouter | undefined + +/** 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 +} + +export function getClaudeProfileRouter(): ClaudeProfileRouter | undefined { + return installed +} + +/** A local or WSL pane's env with the routed account's pointer added; SSH panes keep theirs. */ +export function withClaudeProfileTerminalEnv | undefined>( + env: Env, + connectionId: string | null | undefined, + target: ClaudeAccountSelectionTarget +): Env | Record { + const profileEnv = connectionId ? undefined : installed?.terminalEnv(target) + return profileEnv ? { ...env, ...profileEnv } : env +} + +/** + * 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 new file mode 100644 index 000000000000..f0d04f0fd734 --- /dev/null +++ b/src/main/claude-accounts/claude-profile-paths.test.ts @@ -0,0 +1,175 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +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, + readUserClaudeConfigDir +} from './claude-profile-paths' + +// Case-only aliases exist only on a case-insensitive filesystem (default APFS, NTFS). +const caseInsensitive = (() => { + const dir = mkdtempSync(join(tmpdir(), 'claude-case-probe-')) + try { + mkdirSync(join(dir, 'probe')) + return existsSync(join(dir, 'PROBE')) + } finally { + rmSync(dir, { recursive: true, force: true }) + } +})() +const roots: string[] = [] +function root(): string { + const dir = mkdtempSync(join(tmpdir(), 'claude-profile-paths-')) + roots.push(dir) + return dir +} +afterEach(() => { + for (const dir of roots.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } +}) +const local = { runtime: 'host', executionHostId: 'local' } as const + +describe('Claude profile namespace', () => { + // A WSL profile's data root is a POSIX path; a Windows temp dir cannot be one. + it.skipIf(process.platform === 'win32')( + 'binds the new namespace to an account and execution target without touching legacy auth', + () => { + const dir = root() + const userHome = root() + const target = { executionHostId: 'runtime:env-1', runtime: 'wsl', distro: 'Ubuntu' } as const + const profile = describeClaudeProfile(dir, 'account-a', target) + expect(profile).toEqual({ + version: 1, + accountId: 'account-a', + target, + home: join(dir, 'claude-profiles/account-a/home') + }) + 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, + { ...profile, target: { distro: 'Ubuntu', runtime: 'wsl', executionHostId: 'local' } }, + userHome + ) + expect(() => + prepareClaudeProfileDirectory( + dir, + { ...profile, home: join(dir, 'claude-accounts/account-a/auth') }, + userHome + ) + ).toThrow() + // One spelling everywhere: Claude names the profile's Keychain entry from the exact text. + expect(describeClaudeProfile(`${dir}/./x/../`, 'account-a', target).home).toBe(profile.home) + expect(() => describeClaudeProfile(dir, '../escape', target)).toThrow() + expect(() => describeClaudeProfile('C:\\orca', 'a', target)).toThrow() + } + ) + it('refuses a profile whose marker names another account or target, creating nothing', () => { + const dir = root() + const userHome = root() + mkdirSync(join(dir, 'claude-profiles/a'), { recursive: true }) + const marker = join(dir, 'claude-profiles/a/profile.json') + for (const other of [ + { version: 1, accountId: 'b', runtime: 'host' }, + { version: 1, accountId: 'a', runtime: 'wsl', distro: 'Ubuntu' } + ]) { + writeFileSync(marker, JSON.stringify(other)) + expect(() => + prepareClaudeProfileDirectory(dir, describeClaudeProfile(dir, 'a', local), userHome) + ).toThrow('another account') + } + writeFileSync(marker, '{') + expect(() => + prepareClaudeProfileDirectory(dir, describeClaudeProfile(dir, 'a', local), userHome) + ).toThrow('unreadable') + expect(existsSync(join(dir, 'claude-profiles/a/home'))).toBe(false) + expect(readFileSync(marker, 'utf8')).toBe('{') + }) + it('rejects a linked account parent before creating a profile outside the namespace', () => { + const dir = root() + mkdirSync(join(dir, 'claude-profiles')) + const outside = root() + symlinkSync(outside, join(dir, 'claude-profiles/a'), 'junction') + expect(() => + prepareClaudeProfileDirectory(dir, describeClaudeProfile(dir, 'a', local), root()) + ).toThrow('link') + }) + it('refuses a data root inside the default Claude home', () => { + const userHome = root() + const dataRoot = join(userHome, '.claude', 'orca') + expect(() => + prepareClaudeProfileDirectory(dataRoot, describeClaudeProfile(dataRoot, 'a', local), userHome) + ).toThrow('separate directories') + expect(existsSync(join(userHome, '.claude'))).toBe(false) + }) + it.runIf(caseInsensitive)('refuses a case-only alias of the default home', () => { + const userHome = root() + mkdirSync(join(userHome, '.claude')) + expect(() => assertOutsideDefaultClaudeHomes(join(userHome, '.CLAUDE'), userHome)).toThrow( + 'separate directories' + ) + expect(() => + assertOutsideDefaultClaudeHomes(join(userHome, '.CLAUDE', 'nested'), userHome) + ).toThrow('separate directories') + }) + it("reads the user's own CLAUDE_CONFIG_DIR but never Orca's injected one", () => { + expect(readUserClaudeConfigDir({})).toBeUndefined() + expect(readUserClaudeConfigDir({ CLAUDE_CONFIG_DIR: ' ' })).toBeUndefined() + expect(readUserClaudeConfigDir({ CLAUDE_CONFIG_DIR: '/cfg/claude/' })).toBe( + resolve('/cfg/claude') + ) + expect( + readUserClaudeConfigDir({ + CLAUDE_CONFIG_DIR: '/data/claude-profiles/a/home', + ORCA_CLAUDE_INJECTED_CONFIG_DIR: '/data/claude-profiles/a/home' + }) + ).toBeUndefined() + expect( + readUserClaudeConfigDir({ + CLAUDE_CONFIG_DIR: '/cfg/claude', + ORCA_CLAUDE_INJECTED_CONFIG_DIR: '/data/claude-profiles/a/home' + }) + ).toBe(resolve('/cfg/claude')) + }) + it("refuses a profile at or around the user's own CLAUDE_CONFIG_DIR", () => { + const userHome = root() + const dataRoot = root() + const profile = describeClaudeProfile(dataRoot, 'a', local) + for (const userConfigDir of [profile.home, dataRoot]) { + expect(() => + prepareClaudeProfileDirectory(dataRoot, profile, userHome, userConfigDir) + ).toThrow('separate directories') + } + expect(existsSync(profile.home)).toBe(false) + }) + it('distinguishes missing from empty, malformed, nonobject and inaccessible JSON', () => { + const file = join(root(), 'state.json') + expect(readClaudeProfileObject(file).kind).toBe('absent') + for (const value of ['', '{', '[]', 'null']) { + writeFileSync(file, value) + expect(readClaudeProfileObject(file).kind).toBe('unavailable') + } + // ENOTDIR is a definitive absence, as everywhere else in Orca. + expect(readClaudeProfileObject(join(file, 'child')).kind).toBe('absent') + mkdirSync(join(file, '..', 'dir.json')) + expect(readClaudeProfileObject(join(file, '..', 'dir.json')).kind).toBe('unavailable') + writeFileSync(file, '{"theme":"dark"}') + expect(readClaudeProfileObject(file)).toEqual({ kind: 'present', value: { theme: 'dark' } }) + }) +}) diff --git a/src/main/claude-accounts/claude-profile-paths.ts b/src/main/claude-accounts/claude-profile-paths.ts new file mode 100644 index 000000000000..74ee5222c8e0 --- /dev/null +++ b/src/main/claude-accounts/claude-profile-paths.ts @@ -0,0 +1,233 @@ +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 { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' +import type { ExecutionHostId } from '../../shared/execution-host' +import { writeFileAtomically } from '../codex-accounts/fs-utils' +import { ClaudeProfileSurfaceError } from './claude-profile-report' + +/** `executionHostId` routes calls to the host; it is the caller's view, so it is never persisted. */ +export type ClaudeProfileTarget = + | { executionHostId: ExecutionHostId; runtime: 'host' } + | { executionHostId: ExecutionHostId; runtime: 'wsl'; distro: string } + +export type ClaudeProfileDescriptor = { + version: 1 + accountId: string + target: ClaudeProfileTarget + home: string +} + +export type ClaudeProfileRead = + | { kind: 'present'; value: T } + | { kind: 'absent' } + | { kind: 'unavailable'; error: unknown } + +function isProfileObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +export function readClaudeProfileObject(file: string): ClaudeProfileRead> { + try { + const value: unknown = JSON.parse(readFileSync(file, 'utf8')) + if (!isProfileObject(value)) { + throw new Error('Expected a profile JSON object') + } + return { kind: 'present', value } + } catch (error) { + return isDefinitiveAbsence(error) ? { kind: 'absent' } : { kind: 'unavailable', error } + } +} + +/** + * dataRoot belongs to the execution host: for WSL it is the guest's Linux data root. `home` is the + * one spelling (absolute, normalized, no trailing separator) sign-in and launch must pass as + * CLAUDE_CONFIG_DIR: Claude names the profile's Keychain entry from that exact text. + */ +export function describeClaudeProfile( + dataRoot: string, + accountId: string, + target: ClaudeProfileTarget +): ClaudeProfileDescriptor { + const path = target.runtime === 'wsl' ? hostPath.posix : hostPath + if (!path.isAbsolute(dataRoot) || !/^[a-zA-Z0-9_-]+$/.test(accountId)) { + throw new Error('Invalid Claude profile location') + } + if (!target.executionHostId || (target.runtime === 'wsl' && !target.distro)) { + throw new Error('Claude profile requires an execution target') + } + return { + version: 1, + accountId, + target, + home: path.join(dataRoot, 'claude-profiles', accountId, 'home') + } +} + +// Host-local facts only, in a fixed key order: the marker sits on the execution host's own disk. +function ownershipRecord( + version: unknown, + accountId: unknown, + runtime: unknown, + distro: unknown +): string { + return JSON.stringify({ version, accountId, runtime, distro }) +} + +function readOwnershipMarker(file: string): string | null { + try { + if (!lstatSync(file).isFile()) { + throw new ClaudeProfileSurfaceError('invalid-profile', 'Claude profile marker is not a file') + } + } catch (error) { + if (isDefinitiveAbsence(error)) { + return null + } + throw error + } + const marker = readClaudeProfileObject(file) + if (marker.kind !== 'present') { + throw new ClaudeProfileSurfaceError('unreadable', 'Claude profile marker is unreadable') + } + const { version, accountId, runtime, distro } = marker.value + return ownershipRecord(version, accountId, runtime, distro) +} + +/** + * 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') +} + +export function prepareClaudeProfileDirectory( + dataRoot: string, + profile: ClaudeProfileDescriptor, + userHome: string, + userConfigDir?: string +): void { + let expected: ClaudeProfileDescriptor + try { + expected = describeClaudeProfile(dataRoot, profile.accountId, profile.target) + } catch (error) { + throw new ClaudeProfileSurfaceError('invalid-profile', String(error)) + } + if (profile.version !== 1 || profile.home !== expected.home) { + throw new ClaudeProfileSurfaceError( + 'invalid-profile', + 'Claude profile does not match its account namespace' + ) + } + assertClaudeProfileDescendant(dataRoot, profile.home) + assertOutsideDefaultClaudeHomes(profile.home, userHome, userConfigDir) + const markerPath = claudeProfileMarkerPath(profile) + const distro = profile.target.runtime === 'wsl' ? profile.target.distro : undefined + const record = ownershipRecord(profile.version, profile.accountId, profile.target.runtime, distro) + const marker = readOwnershipMarker(markerPath) + if (marker !== null && marker !== record) { + throw new ClaudeProfileSurfaceError( + 'invalid-profile', + 'Claude profile belongs to another account or target' + ) + } + mkdirSync(profile.home, { recursive: true, mode: 0o700 }) + if (marker === null) { + writeFileAtomically(markerPath, `${record}\n`, { mode: 0o600 }) + } +} + +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( + 'invalid-profile', + 'Claude profile destination escapes its root' + ) + } + // The caller owns root; links below it must not redirect profile writes. + let cursor = resolve(root) + for (const part of suffix.split(sep)) { + cursor = join(cursor, part) + try { + if (lstatSync(cursor).isSymbolicLink()) { + throw new ClaudeProfileSurfaceError( + 'invalid-profile', + 'Claude profile path contains a link' + ) + } + } catch (error) { + if (!isDefinitiveAbsence(error)) { + throw error + } + } + } +} + +/** + * Resolves links in the deepest existing ancestor so a not-yet-created path cannot hide behind one; + * the native call also returns on-disk case, so a case-only alias compares equal. + */ +function canonicalPath(file: string): string { + const resolved = resolve(file) + try { + return realpathSync.native(resolved) + } catch (error) { + if (!isDefinitiveAbsence(error)) { + throw error + } + const parent = dirname(resolved) + return parent === resolved ? resolved : join(canonicalPath(parent), basename(resolved)) + } +} + +export function assertDistinctClaudeProfile(profile: string, defaultHome: string): void { + const left = canonicalPath(profile) + const right = canonicalPath(defaultHome) + for (const [root, destination] of [ + [left, right], + [right, left] + ] as const) { + const suffix = relative(root, destination) + if (!suffix || (!suffix.startsWith(`..${sep}`) && suffix !== '..' && !isAbsolute(suffix))) { + throw new ClaudeProfileSurfaceError( + 'invalid-profile', + 'Claude profile and default home must be separate directories' + ) + } + } +} + +/** Claude's default homes: a profile may never be, contain, or sit inside one. */ +export function assertOutsideDefaultClaudeHomes( + profileHome: string, + userHome: string, + userConfigDir?: string +): void { + assertDistinctClaudeProfile(profileHome, join(userHome, '.claude')) + assertDistinctClaudeProfile(profileHome, join(userHome, '.config', 'claude')) + if (userConfigDir !== undefined) { + assertDistinctClaudeProfile(profileHome, userConfigDir) + } +} + +/** 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 || (injected && resolve(injected) === resolve(configDir))) { + return undefined + } + return resolve(configDir) +} + +/** The folder profiles share from: the user's own CLAUDE_CONFIG_DIR, else `~/.claude`. */ +export function resolveClaudeDefaultHome(userHome: string, userConfigDir?: string): string { + return userConfigDir ?? join(userHome, '.claude') +} diff --git a/src/main/claude-accounts/claude-profile-prompt-history.ts b/src/main/claude-accounts/claude-profile-prompt-history.ts new file mode 100644 index 000000000000..4446e8c489f1 --- /dev/null +++ b/src/main/claude-accounts/claude-profile-prompt-history.ts @@ -0,0 +1,244 @@ +import { + appendFileSync, + closeSync, + linkSync, + lstatSync, + openSync, + readFileSync, + readdirSync, + readSync, + realpathSync, + statSync, + symlinkSync, + unlinkSync, + writeFileSync, + type BigIntStats, + type Stats +} from 'node:fs' +import { join } from 'node:path' +import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' +import { renameFileWithWindowsRetry, writeFileAtomically } from '../codex-accounts/fs-utils' +import { + ClaudeProfileSurfaceError, + warnClaudeProfile, + type ClaudeProfileReport, + type ClaudeProfileSurfaceOutcome +} from './claude-profile-report' + +export const CLAUDE_PROFILE_MERGE_SUFFIX = '.orca-profile-merge' +const HISTORY = 'history.jsonl' +const PENDING = `${HISTORY}${CLAUDE_PROFILE_MERGE_SUFFIX}` +// Windows only: identity of the shared file Orca last hardlinked, so a replaced one is told apart. +const LINK_RECORD = `${HISTORY}.orca-profile-link` + +export function lstatIfPresent(file: string): Stats | undefined { + try { + return lstatSync(file) + } catch (error) { + if (!isDefinitiveAbsence(error)) { + throw error + } + return undefined + } +} + +function appendHistory(destination: string, lines: string[]): void { + if (lines.length === 0) { + return + } + const size = statSync(destination).size + let separator = false + if (size > 0) { + const fd = openSync(destination, 'r') + try { + const tail = Buffer.alloc(1) + readSync(fd, tail, 0, 1, size - 1) + separator = tail[0] !== 10 + } finally { + closeSync(fd) + } + } + // Why: every record ends its line so it cannot fuse with Claude's next append. + appendFileSync(destination, `${separator ? '\n' : ''}${lines.join('\n')}\n`) +} + +/** + * Keeps the renamed file and its cursor: a Claude that opened the old path just before the swap + * appends there, and the next run drains it; a later run that finds nothing new deletes it. + */ +function drainHistory(pending: string, destination: string, shared: Set): void { + const content = readFileSync(pending) + const cursor = `${pending}.offset` + let offset = 0 + let saved = false + try { + const stored = Number(readFileSync(cursor, 'utf8')) + if (Number.isSafeInteger(stored) && stored >= 0 && stored <= content.length) { + offset = stored + saved = true + } + } catch (error) { + if (!isDefinitiveAbsence(error)) { + throw error + } + } + if (saved && offset === content.length) { + // Why: cursor first; a copy without one re-drains to nothing because its lines are present. + unlinkSync(cursor) + unlinkSync(pending) + return + } + // Why: `claude purge` rewrites the shared file through the link; only lines it lacks are new. + const lines = content + .subarray(offset) + .toString('utf8') + .split('\n') + .filter((line) => line !== '' && !shared.has(line)) + appendHistory(destination, lines) + for (const line of lines) { + shared.add(line) + } + // Advance only after append; a crash re-drains, and lines already present are skipped. + writeFileSync(cursor, `${content.length}\n`, { mode: 0o600 }) +} + +function pendingGeneration(name: string): number | null { + if (name === PENDING) { + return 0 + } + const suffix = name.slice(PENDING.length + 1) + return name.startsWith(`${PENDING}-`) && /^\d+$/.test(suffix) ? Number(suffix) : null +} + +function nextFreePath(base: string): string { + for (let generation = 0; ; generation++) { + const candidate = generation === 0 ? base : `${base}-${generation}` + if (!lstatIfPresent(candidate)) { + return candidate + } + } +} + +function fileIdentity(stats: BigIntStats): string { + return `${stats.dev}:${stats.ino}` +} + +export function crossFilesystem(): ClaudeProfileSurfaceError { + return new ClaudeProfileSurfaceError( + 'cross-filesystem', + 'History stays private across filesystems' + ) +} + +/** Fails closed: an unreadable record must not read as "no link", which would drain a replaced default back. */ +function readLinkRecord(profile: string): string | null { + try { + return readFileSync(join(profile, LINK_RECORD), 'utf8').trim() + } catch (error) { + if (isDefinitiveAbsence(error)) { + return null + } + throw new ClaudeProfileSurfaceError( + 'unreadable', + `Prompt history link record: ${String(error)}` + ) + } +} + +/** + * Links the profile's `history.jsonl` to the shared one (a same-volume hardlink on Windows, which + * needs no privilege) and appends its own lines. + */ +export function mergeClaudeProfilePromptHistory( + profile: string, + home: string, + platform: NodeJS.Platform, + report: ClaudeProfileReport +): ClaudeProfileSurfaceOutcome { + const source = join(profile, HISTORY) + const destination = join(home, HISTORY) + if (!lstatIfPresent(destination)) { + writeFileSync(destination, '', { flag: 'wx', mode: 0o600 }) + } + const shared = new Set(readFileSync(destination, 'utf8').split('\n')) + const pendings: { name: string; generation: number }[] = [] + for (const item of readdirSync(profile, { withFileTypes: true })) { + const generation = pendingGeneration(item.name) + if (item.isFile() && generation !== null) { + pendings.push({ name: item.name, generation }) + } + } + // Why: directory order is not creation order; generations keep prompts in the order they were written. + pendings.sort((left, right) => left.generation - right.generation) + // Why: a cross-volume share is refused below; draining first would leak this account's prompts. + if (platform === 'win32' && pendings.length > 0) { + if (statSync(profile).dev !== statSync(destination).dev) { + throw crossFilesystem() + } + } + for (const { name } of pendings) { + try { + drainHistory(join(profile, name), destination, shared) + } catch (error) { + // Why: an old retained copy is bookkeeping; it must not keep the profile from being linked. + warnClaudeProfile(report, HISTORY, error) + } + } + const current = lstatIfPresent(source) + if (current?.isSymbolicLink()) { + return realpathSync(source) === realpathSync(destination) ? 'unchanged' : 'user-owned' + } + if (current && !current.isFile()) { + return 'user-owned' + } + const record = join(profile, LINK_RECORD) + let sharedIdentity: string | undefined + let replaced = false + if (platform === 'win32') { + sharedIdentity = fileIdentity(statSync(destination, { bigint: true })) + const identity = current ? fileIdentity(lstatSync(source, { bigint: true })) : undefined + if (identity === sharedIdentity) { + if (readLinkRecord(profile) !== sharedIdentity) { + writeFileAtomically(record, `${sharedIdentity}\n`, { mode: 0o600 }) + } + return 'unchanged' + } + if (statSync(profile).dev !== statSync(destination).dev) { + throw crossFilesystem() + } + // Why: the old shared file still held under Orca's link means the default was replaced or + // cleared; draining it would bring back history the user removed. + replaced = identity !== undefined && identity === readLinkRecord(profile) + } + let aside: string | undefined + if (current) { + aside = nextFreePath(join(profile, replaced ? `${HISTORY}.orca-profile-conflict` : PENDING)) + renameFileWithWindowsRetry(source, aside) + } + try { + if (sharedIdentity === undefined) { + symlinkSync(destination, source) + } else { + linkSync(destination, source) + try { + writeFileAtomically(record, `${sharedIdentity}\n`, { mode: 0o600 }) + } catch (error) { + // Why: a link without its record would let a later default replacement drain the old copy back. + unlinkSync(source) + throw error + } + } + } catch (error) { + if (aside && !lstatIfPresent(source)) { + renameFileWithWindowsRetry(aside, source) + } + throw new ClaudeProfileSurfaceError('link-failed', String(error)) + } + if (aside && replaced) { + const detail = `Shared prompt history was replaced; the old copy is kept at ${aside}` + warnClaudeProfile(report, HISTORY, new ClaudeProfileSurfaceError('retained-conflict', detail)) + } else if (aside) { + drainHistory(aside, destination, shared) + } + return 'linked' +} diff --git a/src/main/claude-accounts/claude-profile-provisioning.test.ts b/src/main/claude-accounts/claude-profile-provisioning.test.ts new file mode 100644 index 000000000000..66bf0346d90d --- /dev/null +++ b/src/main/claude-accounts/claude-profile-provisioning.test.ts @@ -0,0 +1,425 @@ +import * as fs from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type * as FsUtils from '../codex-accounts/fs-utils' +vi.mock('electron', () => ({ app: { getPath: () => '/unused-test-path' } })) +vi.mock('node:fs', async (original) => { + const actual = await original() + return { ...actual, symlinkSync: vi.fn(actual.symlinkSync) } +}) +vi.mock('../codex-accounts/fs-utils', async (original) => { + const actual = await original() + return { ...actual, writeFileAtomically: vi.fn(actual.writeFileAtomically) } +}) +import { writeFileAtomically } from '../codex-accounts/fs-utils' +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"' }] +} +const roots: string[] = [] +function fixture() { + const root = fs.realpathSync(fs.mkdtempSync(join(tmpdir(), 'claude-profile-setup-'))) + roots.push(root) + const userHome = join(root, 'user') + const profileHome = join(root, 'profile') + const source = join(userHome, '.claude') + fs.mkdirSync(source, { recursive: true }) + fs.mkdirSync(profileHome) + const json = (file: string, value: unknown): void => fs.writeFileSync(file, JSON.stringify(value)) + 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 }, trustKeys?: string[]) => + provisionClaudeProfile({ + profileHome: f.profileHome, + userHome: f.userHome, + trustKeys, + platform: 'linux' + }) +afterEach(() => { + vi.clearAllMocks() + for (const dir of roots.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }) + } +}) + +// Why: these create real symlinks, which Windows needs privilege for. +const itLinks = it.skipIf(process.platform === 'win32') + +describe('dormant Claude profile provisioning', () => { + itLinks('links resources, keeps private directories and sees later global installs', async () => { + const f = fixture() + const linked = [ + 'skills', + 'plugins', + 'commands', + 'output-styles', + 'themes', + 'workflows' + ] as const + for (const name of [...linked, 'agents', 'rules']) { + fs.mkdirSync(join(f.source, name)) + } + for (const name of ['agents', 'rules']) { + fs.mkdirSync(join(f.profileHome, name)) + fs.writeFileSync(join(f.profileHome, name, 'mine.md'), 'private') + } + const report = await provision(f) + expect(report.surfaces.agents).toBe('user-owned') + expect(report.surfaces.rules).toBe('user-owned') + for (const name of linked) { + expect(report.surfaces[name]).toBe('linked') + expect(fs.realpathSync(join(f.profileHome, name))).toBe(fs.realpathSync(join(f.source, name))) + } + fs.writeFileSync(join(f.source, 'skills/new.md'), 'new skill') + expect(fs.readFileSync(join(f.profileHome, 'skills/new.md'), 'utf8')).toBe('new skill') + for (const name of ['agents', 'rules']) { + expect(fs.readFileSync(join(f.profileHome, name, 'mine.md'), 'utf8')).toBe('private') + } + }) + itLinks( + 'shares personal rules, themes and workflows that later appear in the default home', + async () => { + const f = fixture() + await provision(f) + fs.mkdirSync(join(f.source, 'rules')) + fs.writeFileSync(join(f.source, 'rules/style.md'), 'use tabs') + fs.mkdirSync(join(f.source, 'themes')) + fs.writeFileSync(join(f.source, 'themes/dusk.json'), '{"base":"dark"}') + fs.mkdirSync(join(f.source, 'workflows')) + fs.writeFileSync(join(f.source, 'workflows/review.js'), 'export const meta = {}') + const report = await provision(f) + expect(report.surfaces).toMatchObject({ + rules: 'linked', + themes: 'linked', + workflows: 'linked' + }) + expect(fs.readFileSync(join(f.profileHome, 'rules/style.md'), 'utf8')).toBe('use tabs') + expect(fs.readFileSync(join(f.profileHome, 'themes/dusk.json'), 'utf8')).toBe( + '{"base":"dark"}' + ) + expect(fs.readFileSync(join(f.profileHome, 'workflows/review.js'), 'utf8')).toBe( + 'export const meta = {}' + ) + fs.writeFileSync(join(f.profileHome, 'workflows/saved.js'), 'saved in profile') + expect(fs.readFileSync(join(f.source, 'workflows/saved.js'), 'utf8')).toBe('saved in profile') + } + ) + it('copies keybindings, keeps a profile edit and follows the default while unedited', async () => { + const f = fixture() + const source = join(f.source, 'keybindings.json') + const copy = join(f.profileHome, 'keybindings.json') + fs.writeFileSync(source, '{"bindings":[]}') + expect((await provision(f)).surfaces['keybindings.json']).toBe('synced') + expect(fs.lstatSync(copy).isSymbolicLink()).toBe(false) + expect(fs.readFileSync(copy, 'utf8')).toBe('{"bindings":[]}') + fs.writeFileSync(source, '{"bindings":[1]}') + expect((await provision(f)).surfaces['keybindings.json']).toBe('synced') + expect(fs.readFileSync(copy, 'utf8')).toBe('{"bindings":[1]}') + fs.writeFileSync(copy, '{"bindings":["profile"]}') + fs.writeFileSync(source, '{"bindings":[2]}') + expect((await provision(f)).surfaces['keybindings.json']).toBe('user-owned') + expect(fs.readFileSync(copy, 'utf8')).toBe('{"bindings":["profile"]}') + expect(fs.readFileSync(source, 'utf8')).toBe('{"bindings":[2]}') + }) + it('shares future settings keys and hooks but excludes auth; profile edits survive reprovision', async () => { + const f = fixture() + f.json(join(f.source, 'settings.json'), { + futureFeature: true, + model: 'a', + hooks: { Stop: [USER_HOOK, ORCA_HOOK], SessionStart: [ORCA_HOOK] }, + apiKeyHelper: 'secret', + awsAuthRefresh: 'secret', + awsCredentialExport: 'secret', + forceLoginMethod: 'secret', + forceLoginOrgUUID: 'secret', + env: { + ANTHROPIC_API_KEY: 'secret', + ANTHROPIC_AUTH_TOKEN: 'secret', + CLAUDE_CODE_OAUTH_TOKEN: 'secret', + NORMAL: 'yes' + } + }) + fs.writeFileSync(join(f.source, 'CLAUDE.md'), 'source') + await provision(f) + expect(f.read(join(f.profileHome, 'settings.json'))).toEqual({ + futureFeature: true, + model: 'a', + hooks: { Stop: [USER_HOOK, ORCA_HOOK], SessionStart: [ORCA_HOOK] }, + env: { NORMAL: 'yes' } + }) + f.json(join(f.profileHome, 'settings.json'), { + futureFeature: true, + model: 'private', + env: { NORMAL: 'yes' } + }) + fs.writeFileSync(join(f.profileHome, 'CLAUDE.md'), 'private instructions') + f.json(join(f.source, 'settings.json'), { model: 'b', futureFeature: false }) + fs.writeFileSync(join(f.source, 'CLAUDE.md'), 'updated source') + await provision(f) + expect(f.read(join(f.profileHome, 'settings.json'))).toMatchObject({ + model: 'private', + futureFeature: false + }) + expect(fs.readFileSync(join(f.profileHome, 'CLAUDE.md'), 'utf8')).toBe('private instructions') + }) + itLinks( + 're-adds a shared key the profile deleted and leaves a linked settings file alone', + async () => { + const f = fixture() + f.json(join(f.source, 'settings.json'), { model: 'a', theme: 'x' }) + await provision(f) + f.json(join(f.profileHome, 'settings.json'), { theme: 'x' }) + await provision(f) + expect(f.read(join(f.profileHome, 'settings.json'))).toEqual({ model: 'a', theme: 'x' }) + const elsewhere = join(f.root, 'elsewhere.json') + f.json(elsewhere, { mine: true }) + fs.rmSync(join(f.profileHome, 'settings.json')) + fs.symlinkSync(elsewhere, join(f.profileHome, 'settings.json')) + expect((await provision(f)).surfaces['settings.json']).toBe('user-owned') + expect(f.read(elsewhere)).toEqual({ mine: true }) + } + ) + it('removes a key the default dropped unless the profile changed it, and only keys Orca shared', async () => { + const f = fixture() + const settings = join(f.source, 'settings.json') + f.json(settings, { model: 'a', theme: 'x', apiKeyHelper: 'source-secret' }) + await provision(f) + f.json(join(f.profileHome, 'settings.json'), { + ...f.read(join(f.profileHome, 'settings.json')), + theme: 'mine', + local: true, + apiKeyHelper: 'profile-helper' + }) + f.json(settings, { apiKeyHelper: 'source-secret' }) + expect((await provision(f)).surfaces['settings.json']).toBe('merged') + expect(f.read(join(f.profileHome, 'settings.json'))).toEqual({ + theme: 'mine', + local: true, + apiKeyHelper: 'profile-helper' + }) + expect((await provision(f)).surfaces['settings.json']).toBe('unchanged') + f.json(settings, { model: 'b' }) + await provision(f) + fs.rmSync(settings) + await provision(f) + expect(f.read(join(f.profileHome, 'settings.json'))).toEqual({ + theme: 'mine', + local: true, + apiKeyHelper: 'profile-helper' + }) + const ledger = f.read(join(f.profileHome, '.orca-profile.json')) + expect(JSON.stringify(ledger)).not.toContain('apiKeyHelper') + }) + it('removes nothing while the default settings or state are unreadable', async () => { + 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' }) + await provision(f) + fs.writeFileSync(join(f.source, 'settings.json'), '{bad') + fs.writeFileSync(join(f.userHome, '.claude.json'), '{bad') + await provision(f) + expect(f.read(join(f.profileHome, 'settings.json'))).toEqual({ model: 'a' }) + expect(f.read(join(f.profileHome, '.claude.json'))).toMatchObject({ + mcpServers: { a: {} }, + theme: 'dark' + }) + f.json(join(f.userHome, '.claude.json'), { theme: 'dark' }) + await provision(f) + expect(f.read(join(f.profileHome, '.claude.json'))).toEqual({ + userID: 'p', + theme: 'dark', + hasCompletedOnboarding: true + }) + }) + 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: { email: 'source' }, + userID: 'source-id' + }) + await provision(f) + expect(fs.existsSync(join(f.profileHome, '.claude.json'))).toBe(false) + 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: { email: 'profile' }, + userID: 'profile-id', + projects: { '/work': { allowedTools: ['Read'] } } + }) + await provision(f, ['/work']) + expect(f.read(join(f.profileHome, '.claude.json'))).toEqual({ + oauthAccount: { email: 'profile' }, + userID: 'profile-id', + mcpServers: { local: { command: 'example' } }, + theme: 'dark', + hasCompletedOnboarding: true, + projects: { '/work': { allowedTools: ['Read'], hasTrustDialogAccepted: true } } + }) + expect(fs.readFileSync(join(f.source, '.credentials.json'), 'utf8')).toBe( + 'SOURCE_CREDENTIAL_BYTES' + ) + expect(fs.readFileSync(join(f.profileHome, '.credentials.json'), 'utf8')).toBe( + 'PROFILE_CREDENTIAL_BYTES' + ) + for (const name of fs.readdirSync(f.profileHome)) { + const file = join(f.profileHome, name) + if (fs.lstatSync(file).isFile()) { + expect(fs.readFileSync(file, 'utf8')).not.toContain('SOURCE_CREDENTIAL_BYTES') + } + } + }) + it('still forces onboarding and trust when the personal state is unreadable', async () => { + const f = fixture() + 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({ + userID: 'p', + hasCompletedOnboarding: true, + projects: { '/work': { hasTrustDialogAccepted: true } } + }) + expect(report.warnings).toContainEqual( + expect.objectContaining({ surface: '.claude.json', code: 'unreadable' }) + ) + }) + it('skips only folder trust when the profile projects value is malformed', async () => { + const f = fixture() + 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', + projects: 'bad', + theme: 'dark', + hasCompletedOnboarding: true + }) + }) + 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' }) + 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' }) + 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') + }) + it('records a shared value only after its write succeeded', async () => { + const f = fixture() + f.json(join(f.source, 'settings.json'), { theme: 'dark' }) + await provision(f) + f.json(join(f.source, 'settings.json'), { theme: 'light' }) + vi.mocked(writeFileAtomically).mockImplementationOnce(() => { + throw Object.assign(new Error('busy'), { code: 'EBUSY' }) + }) + expect((await provision(f)).surfaces['settings.json']).toBe('failed') + expect((await provision(f)).surfaces['settings.json']).toBe('merged') + expect(f.read(join(f.profileHome, 'settings.json')).theme).toBe('light') + }) + itLinks('resets an unreadable ledger instead of blocking every surface', async () => { + const f = fixture() + fs.mkdirSync(join(f.source, 'skills')) + f.json(join(f.source, 'settings.json'), { model: 'a' }) + fs.writeFileSync(join(f.profileHome, '.orca-profile.json'), '') + const report = await provision(f) + expect(report.surfaces.skills).toBe('linked') + expect(report.surfaces['settings.json']).toBe('merged') + expect(report.warnings).toEqual([]) + expect(f.read(join(f.profileHome, '.orca-profile.json')).keys).toEqual({ + 'settings.json': { model: '"a"' } + }) + }) + itLinks( + 'keys shared values by surface, so another spelling of the profile keeps sharing', + async () => { + const f = fixture() + fs.writeFileSync(join(f.source, 'keybindings.json'), 'v1') + f.json(join(f.source, 'settings.json'), { model: 'a' }) + await provision(f) + fs.writeFileSync(join(f.source, 'keybindings.json'), 'v2') + f.json(join(f.source, 'settings.json'), { model: 'b' }) + const aliasRoot = fs.mkdtempSync(join(tmpdir(), 'claude-profile-alias-')) + roots.push(aliasRoot) + const alias = join(aliasRoot, 'link') + fs.symlinkSync(f.root, alias) + const report = await provisionClaudeProfile({ + profileHome: join(alias, 'profile'), + userHome: f.userHome, + platform: 'linux' + }) + expect(report.surfaces['keybindings.json']).toBe('synced') + expect(f.read(join(f.profileHome, 'settings.json')).model).toBe('b') + } + ) + it('imports the personal CLAUDE.md instead of copying it, so Claude loads it once', async () => { + const f = fixture() + await provision(f) + 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_PROFILE_MEMORY_IMPORT + ) + fs.writeFileSync(join(f.source, 'CLAUDE.md'), 'edited personal instructions') + expect((await provision(f)).surfaces['CLAUDE.md']).toBe('unchanged') + }) + itLinks( + "shares from the user's own CLAUDE_CONFIG_DIR, copying its CLAUDE.md and state", + async () => { + const f = fixture() + const userConfigDir = join(f.userHome, 'custom-claude') + fs.mkdirSync(join(userConfigDir, 'skills'), { recursive: true }) + fs.writeFileSync(join(userConfigDir, 'CLAUDE.md'), 'custom instructions') + 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' }) + 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') + expect(f.read(join(f.profileHome, 'settings.json'))).toEqual({ model: 'custom' }) + expect(f.read(join(f.profileHome, '.claude.json')).theme).toBe('custom') + } + ) + itLinks("links to the default home's own entry, not where a user link of it points", async () => { + const f = fixture() + fs.mkdirSync(join(f.root, 'dotfiles-skills')) + fs.symlinkSync(join(f.root, 'dotfiles-skills'), join(f.source, 'skills')) + expect((await provision(f)).surfaces.skills).toBe('linked') + expect(fs.readlinkSync(join(f.profileHome, 'skills'))).toBe(join(f.source, 'skills')) + expect((await provision(f)).surfaces.skills).toBe('unchanged') + }) + it('uses Windows junctions through platform injection (native Windows remains unverified)', async () => { + const f = fixture() + fs.mkdirSync(join(f.source, 'skills')) + await provisionClaudeProfile({ ...f, platform: 'win32' }) + expect(fs.symlinkSync).toHaveBeenCalledWith( + join(f.source, 'skills'), + join(f.profileHome, 'skills'), + 'junction' + ) + }) + it('leaves malformed profile state unchanged', async () => { + const f = fixture() + fs.writeFileSync(join(f.profileHome, '.claude.json'), '{bad') + expect((await provision(f)).warnings).toContainEqual( + expect.objectContaining({ surface: '.claude.json', code: 'unreadable' }) + ) + expect(fs.readFileSync(join(f.profileHome, '.claude.json'), 'utf8')).toBe('{bad') + }) +}) diff --git a/src/main/claude-accounts/claude-profile-provisioning.ts b/src/main/claude-accounts/claude-profile-provisioning.ts new file mode 100644 index 000000000000..752f7fee3f38 --- /dev/null +++ b/src/main/claude-accounts/claude-profile-provisioning.ts @@ -0,0 +1,225 @@ +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 { readClaudeProfileObject, resolveClaudeDefaultHome } from './claude-profile-paths' +import { lstatIfPresent } from './claude-profile-prompt-history' +import { + ClaudeProfileSurfaceError, + createClaudeProfileReport, + runClaudeProfileSurface, + warnClaudeProfile, + type ClaudeProfileReport, + type ClaudeProfileSurfaceOutcome +} from './claude-profile-report' +import { + claudeProfileLedgerPath, + dropClaudeProfileKeys, + linkClaudeProfileDirectory, + mergeClaudeProfileKeys, + readClaudeProfileLedger, + syncClaudeProfileFile, + writeClaudeProfileLedger, + type ClaudeProfileLedger +} from './claude-profile-sharing' + +export const CLAUDE_PROFILE_RESOURCE_DIRS = [ + 'skills', + 'plugins', + 'agents', + 'commands', + 'output-styles', + 'rules', + 'themes', + 'workflows' +] 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 +export const CLAUDE_PROFILE_MEMORY_IMPORT = '@~/.claude/CLAUDE.md\n' +const PRIVATE_KEYS = new Set([ + 'apiKeyHelper', + 'awsAuthRefresh', + 'awsCredentialExport', + 'forceLoginMethod', + 'forceLoginOrgUUID' +]) +const PRIVATE_ENV = new Set([ + 'ANTHROPIC_API_KEY', + 'ANTHROPIC_AUTH_TOKEN', + 'CLAUDE_CODE_OAUTH_TOKEN' +]) +const SHARED_STATE_KEYS = ['mcpServers', 'theme'] + +// Why `hooks` is shared: Orca writes identical entries into every folder, so the installer that +// runs after the merge finds them present and the user's own hooks keep running in every account. +function pickSettings(source: Record): Record { + return Object.fromEntries( + Object.entries(source) + .filter(([key]) => !PRIVATE_KEYS.has(key)) + .map(([key, value]) => { + if (key === 'env' && value && typeof value === 'object' && !Array.isArray(value)) { + return [ + key, + Object.fromEntries(Object.entries(value).filter(([name]) => !PRIVATE_ENV.has(name))) + ] + } + return [key, value] + }) + ) +} + +function mergeSettings( + source: string, + target: string, + ledger: ClaudeProfileLedger +): ClaudeProfileSurfaceOutcome { + if (lstatIfPresent(target)?.isSymbolicLink()) { + return 'user-owned' + } + const existing = readClaudeProfileObject(target) + const input = readClaudeProfileObject(source) + if (existing.kind === 'unavailable' || input.kind === 'unavailable') { + throw new ClaudeProfileSurfaceError('unreadable', 'Claude settings.json is unreadable') + } + const config: Record = existing.kind === 'present' ? { ...existing.value } : {} + const desired = pickSettings(input.kind === 'present' ? input.value : {}) + const written = { ...ledger.keys['settings.json'] } + const changed = + mergeClaudeProfileKeys(config, desired, written).length + + dropClaudeProfileKeys(config, desired, written).length + if (changed > 0) { + writeFileAtomically(target, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 }) + } + // Committed only after the write, so a failed write never marks an unwritten value as shared. + ledger.keys['settings.json'] = written + return changed > 0 ? 'merged' : existing.kind === 'absent' ? 'absent' : 'unchanged' +} + +async function mergeState(args: { + source: string + target: string + ledger: ClaudeProfileLedger + trustKeys: readonly string[] + report: ClaudeProfileReport +}): Promise { + if (lstatIfPresent(args.target)?.isSymbolicLink()) { + return 'user-owned' + } + const input = readClaudeProfileObject(args.source) + if (input.kind === 'unavailable') { + // 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) + } + const source = input.kind === 'present' ? input.value : {} + const desired = Object.fromEntries( + SHARED_STATE_KEYS.filter((key) => key in source).map((key) => [key, source[key]]) + ) + let written: Record = {} + const outcome = await updateClaudeGlobalConfig(args.target, (current) => { + const config = { ...current } + written = { ...args.ledger.keys['.claude.json'] } + let changed = mergeClaudeProfileKeys(config, desired, written).length > 0 + if ( + input.kind !== 'unavailable' && + dropClaudeProfileKeys(config, desired, written).length > 0 + ) { + changed = true + } + // Why: otherwise first launch opens the onboarding wizard, where a stray Enter starts a login that rebinds the profile. + if (config.hasCompletedOnboarding !== true) { + 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') { + // Why: no state file means no completed login; writing one would fabricate an account. + return 'absent' + } + if (outcome === 'locked' || outcome === 'unreadable') { + throw new ClaudeProfileSurfaceError(outcome, `Profile Claude state is ${outcome}`) + } + args.ledger.keys['.claude.json'] = written + return outcome === 'updated' ? 'merged' : 'unchanged' +} + +/** + * Shares the default home's config into a profile. Execution-host paths; never touches credentials. + * Callers go through provisionClaudeAccountProfile, which gates and creates the profile. + */ +export async function provisionClaudeProfile(args: { + profileHome: string + userHome: string + /** 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) + const report = createClaudeProfileReport() + const ledgerPath = claudeProfileLedgerPath(args.profileHome) + const ledger = readClaudeProfileLedger(ledgerPath) + const recorded = JSON.stringify(ledger) + for (const name of CLAUDE_PROFILE_RESOURCE_DIRS) { + await runClaudeProfileSurface(report, name, () => + linkClaudeProfileDirectory(join(defaultHome, name), join(args.profileHome, name), platform) + ) + } + // Why only for ~/.claude: Claude also loads it as a parent folder's memory for projects under + // home, so a copy would load twice; a custom CLAUDE_CONFIG_DIR is copied, as superset does. + const imported = resolve(defaultHome) === resolve(args.userHome, '.claude') + const memory = imported ? () => CLAUDE_PROFILE_MEMORY_IMPORT : undefined + for (const name of CLAUDE_PROFILE_RESOURCE_FILES) { + await runClaudeProfileSurface(report, name, () => + syncClaudeProfileFile( + join(defaultHome, name), + join(args.profileHome, name), + name, + ledger, + name === 'CLAUDE.md' ? memory : undefined + ) + ) + } + await runClaudeProfileSurface(report, 'settings.json', () => + mergeSettings( + join(defaultHome, 'settings.json'), + join(args.profileHome, 'settings.json'), + ledger + ) + ) + const statePath = (configDir: string | undefined): string => + resolveClaudeGlobalConfigFile({ + env: { CLAUDE_CONFIG_DIR: configDir }, + homeDir: args.userHome, + style: platform === 'win32' ? 'win32' : 'posix', + exists: existsSync + }) + await runClaudeProfileSurface(report, '.claude.json', () => + mergeState({ + source: statePath(args.userConfigDir), + target: statePath(args.profileHome), + ledger, + trustKeys: args.trustKeys ?? [], + report + }) + ) + await runClaudeProfileSurface(report, 'ledger', () => { + if (JSON.stringify(ledger) === recorded) { + return 'unchanged' + } + writeClaudeProfileLedger(ledgerPath, ledger) + return 'synced' + }) + return report +} diff --git a/src/main/claude-accounts/claude-profile-report.ts b/src/main/claude-accounts/claude-profile-report.ts new file mode 100644 index 000000000000..2a5baae068ed --- /dev/null +++ b/src/main/claude-accounts/claude-profile-report.ts @@ -0,0 +1,86 @@ +import type { CLAUDE_PROFILE_HISTORY_DIRS } from './claude-profile-history' +import type { + CLAUDE_PROFILE_RESOURCE_DIRS, + CLAUDE_PROFILE_RESOURCE_FILES +} from './claude-profile-provisioning' + +export type ClaudeProfileSurface = + | 'profile' + | (typeof CLAUDE_PROFILE_HISTORY_DIRS)[number] + | 'history.jsonl' + | (typeof CLAUDE_PROFILE_RESOURCE_DIRS)[number] + | (typeof CLAUDE_PROFILE_RESOURCE_FILES)[number] + | 'settings.json' + | '.claude.json' + | 'ledger' + | 'hooks' + +export type ClaudeProfileSurfaceOutcome = + | 'linked' + | 'synced' + | 'merged' + | 'unchanged' + | 'user-owned' + | 'absent' + | 'failed' + +/** Closed so callers branch on a code, never on message text. */ +export type ClaudeProfileWarningCode = + | 'invalid-profile' + | 'unreadable' + | 'locked' + | 'cross-filesystem' + | 'retained-conflict' + | 'link-failed' + | 'failed' + +export type ClaudeProfileWarning = { + surface: ClaudeProfileSurface + code: ClaudeProfileWarningCode + /** For logs only. */ + detail: string +} + +export type ClaudeProfileReport = { + surfaces: Partial> + warnings: ClaudeProfileWarning[] +} + +export class ClaudeProfileSurfaceError extends Error { + readonly code: ClaudeProfileWarningCode + + constructor(code: ClaudeProfileWarningCode, message: string) { + super(message) + this.code = code + } +} + +export function createClaudeProfileReport(): ClaudeProfileReport { + return { surfaces: {}, warnings: [] } +} + +export function warnClaudeProfile( + report: ClaudeProfileReport, + surface: ClaudeProfileSurface, + error: unknown +): void { + report.warnings.push({ + surface, + code: error instanceof ClaudeProfileSurfaceError ? error.code : 'failed', + detail: error instanceof Error ? error.message : String(error) + }) +} + +/** One surface's failure is reported and never stops the surfaces after it. */ +export async function runClaudeProfileSurface( + report: ClaudeProfileReport, + surface: ClaudeProfileSurface, + operation: () => ClaudeProfileSurfaceOutcome | Promise +): Promise { + try { + report.surfaces[surface] = await operation() + } catch (error) { + report.surfaces[surface] = 'failed' + warnClaudeProfile(report, surface, error) + } +} diff --git a/src/main/claude-accounts/claude-profile-router.test.ts b/src/main/claude-accounts/claude-profile-router.test.ts new file mode 100644 index 000000000000..5391072d0bbe --- /dev/null +++ b/src/main/claude-accounts/claude-profile-router.test.ts @@ -0,0 +1,175 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import type { ClaudeManagedAccount } from '../../shared/managed-account-types' +import type { ClaudeProfileSetupReport } from './claude-profile-setup' +import { + CLAUDE_PROFILE_MISSING_MESSAGE, + CLAUDE_PROFILE_SETUP_FAILED_MESSAGE, + ClaudeProfileRouter, + type ClaudeProfileRouterSettings +} from './claude-profile-router' +import { + claudeProfileHistoryDirs, + installClaudeProfileRouter +} from './claude-profile-installed-router' + +const roots: string[] = [] +afterEach(() => { + installClaudeProfileRouter(undefined) + roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true })) +}) + +function fixture(env: NodeJS.ProcessEnv = {}) { + const root = mkdtempSync(join(tmpdir(), 'claude-router-')) + roots.push(root) + const userHome = join(root, 'personal') + const dataRoot = join(root, 'data') + mkdirSync(join(userHome, '.claude'), { recursive: true }) + const account = (id: string): ClaudeManagedAccount => ({ + id, + email: `${id}@example.test`, + authMethod: 'subscription-oauth', + managedAuthPath: '/unused-legacy', + createdAt: 0, + updatedAt: 0, + lastAuthenticatedAt: 0 + }) + const settings: ClaudeProfileRouterSettings = { + claudeManagedAccounts: [account('a'), account('b')], + activeClaudeManagedAccountId: 'a', + activeClaudeManagedAccountIdsByRuntime: undefined, + // Hooks off: setup must not probe a real Claude here. + agentStatusHooksEnabled: false, + disabledTuiAgents: [] + } + // 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 = () => { + setup.calls += 1 + return new Promise((resolve) => { + setup.settle = () => resolve({ outcome: setup.outcome, warnings: [], surfaces: {} }) + }) + } + const router = new ClaudeProfileRouter({ + getSettings: () => settings, + dataRoot, + userHome, + env, + runSetup + }) + const home = (id: string) => join(dataRoot, 'claude-profiles', id, 'home') + return { root, userHome, dataRoot, settings, router, home, setup } +} + +describe('ClaudeProfileRouter', () => { + 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. + expect(f.setup.calls).toBe(1) + + f.settings.activeClaudeManagedAccountId = null + f.router.publish() + expect(readFileSync(f.router.pointerPath, 'utf8')).toBe('') + + f.settings.claudeManagedAccounts = [] + f.router.publish() + expect(existsSync(f.router.pointerPath)).toBe(false) + }) + + 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 Promise.resolve() + expect(launched).toBe(false) + expect(f.setup.calls).toBe(1) + f.setup.settle() + await expect(launch).resolves.toMatchObject({ configDir: f.home('a') }) + + f.setup.outcome = 'refused' + const refused = f.router.prepareLaunch() + f.setup.settle() + 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('names a never-signed-in folder without creating it, and refuses to launch it', async () => { + const f = fixture() + f.settings.activeClaudeManagedAccountId = 'b' + f.router.publish() + expect(readFileSync(f.router.pointerPath, 'utf8')).toBe(f.home('b')) + 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', () => { + const f = fixture({ CLAUDE_CONFIG_DIR: resolve('/user/own') }) + mkdirSync(f.home('a'), { recursive: true }) + expect(f.router.preparation()).toMatchObject({ + configDir: f.home('a'), + stripAuthEnv: true, + envPatch: { + ORCA_CLAUDE_PROFILE_POINTER: f.router.pointerPath, + CLAUDE_CONFIG_DIR: f.home('a'), + ORCA_CLAUDE_INJECTED_CONFIG_DIR: f.home('a') + } + }) + f.settings.activeClaudeManagedAccountId = null + expect(f.router.preparation()).toMatchObject({ + configDir: resolve('/user/own'), + stripAuthEnv: false, + envPatch: { ORCA_CLAUDE_PROFILE_POINTER: f.router.pointerPath } + }) + expect(f.router.preparation().envPatch).not.toHaveProperty('CLAUDE_CONFIG_DIR') + }) + + 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')) + }) + + // Why not win32: creating the link needs privileges there. + it.skipIf(process.platform === 'win32')( + 'lists account history the System default cannot see, skipping linked folders', + () => { + const f = fixture() + expect(claudeProfileHistoryDirs('projects')).toEqual([]) + installClaudeProfileRouter(f.router) + mkdirSync(join(f.home('a'), 'projects'), { recursive: true }) + mkdirSync(f.home('b'), { recursive: true }) + symlinkSync(join(f.userHome, '.claude'), join(f.home('b'), 'projects')) + // The pointer file sits among the account folders and must not read as one. + writeFileSync(join(f.dataRoot, 'claude-profiles', 'selected-host'), '') + expect(claudeProfileHistoryDirs('projects')).toEqual([join(f.home('a'), 'projects')]) + } + ) +}) diff --git a/src/main/claude-accounts/claude-profile-router.ts b/src/main/claude-accounts/claude-profile-router.ts new file mode 100644 index 000000000000..3b12f6f7c82d --- /dev/null +++ b/src/main/claude-accounts/claude-profile-router.ts @@ -0,0 +1,189 @@ +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_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 { + CLAUDE_INJECTED_CONFIG_DIR_ENV, + claudeProfileMarkerPath, + describeClaudeProfile, + type ClaudeProfileDescriptor, + readUserClaudeConfigDir, + resolveClaudeDefaultHome +} from './claude-profile-paths' +import type { ClaudeProfileSetupReport } from './claude-profile-setup' +import { runClaudeProfileSetupInWorker } from './claude-profile-setup-worker' +import type { ClaudeEnvPatch } from './environment' +import type { ClaudeRuntimeAuthPreparation } from './runtime-auth/runtime-auth-types' +import { + getSelectedClaudeAccountIdForTarget, + type ClaudeAccountSelectionTarget +} from './runtime-selection' +import { wslClaudeProfilePointer } from './claude-profile-wsl-paths' +import { isDirectory, listClaudeProfileHomes } from './claude-profile-installed-router' + +export type ClaudeProfileRouterSettings = Pick< + GlobalSettings, + | 'claudeManagedAccounts' + | 'activeClaudeManagedAccountId' + | 'activeClaudeManagedAccountIdsByRuntime' + | 'agentStatusHooksEnabled' + | '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. + */ +export class ClaudeProfileRouter { + readonly pointerPath: string + private readonly setups = new Map>() + constructor( + private readonly args: { + getSettings: () => ClaudeProfileRouterSettings + dataRoot: string + userHome?: string + env?: NodeJS.ProcessEnv + /** Tests replace the worker. */ + runSetup?: typeof runClaudeProfileSetupInWorker + } + ) { + this.pointerPath = join(args.dataRoot, 'claude-profiles', 'selected-host') + } + + private get userHome(): string { + return this.args.userHome ?? homedir() + } + + private selectedProfile(): ClaudeProfileDescriptor | null { + const id = getSelectedClaudeAccountIdForTarget(this.args.getSettings(), { runtime: 'host' }) + 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, + 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 new Error(CLAUDE_PROFILE_MISSING_MESSAGE) + } + return home + } + + /** Pointer first, then setup in the background, as superset does. No saved accounts means no pointer. */ + publish(): void { + if (this.args.getSettings().claudeManagedAccounts.length === 0) { + rmSync(this.pointerPath, { force: true }) + return + } + const profile = this.selectedProfile() + mkdirSync(dirname(this.pointerPath), { recursive: true, mode: 0o700 }) + writeFileAtomically(this.pointerPath, profile?.home ?? '', { mode: 0o600 }) + // 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 setup only for a folder that was never set up; otherwise launches at once. */ + async prepareLaunch(): Promise { + const profile = this.selectedProfile() + if (profile && isDirectory(profile.home) && !existsSync(claudeProfileMarkerPath(profile))) { + const report = await this.setUp(profile).catch(() => null) + if (report?.outcome !== 'prepared') { + throw new Error(CLAUDE_PROFILE_SETUP_FAILED_MESSAGE) + } + } + return this.preparation() + } + + /** 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) + if (running) { + return running + } + const run = this.runSetup(profile).finally(() => this.setups.delete(profile.accountId)) + this.setups.set(profile.accountId, run) + return run + } + + private async runSetup(profile: ClaudeProfileDescriptor): Promise { + 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: readUserClaudeConfigDir(this.args.env ?? process.env), + hooks, + claudeVersion: claudeVersion ?? undefined + }) + if (report.outcome === 'refused' || report.warnings.length > 0) { + console.warn('[claude-profile] Account setup was incomplete:', report) + } + return report + } + + /** Env for a launch Orca makes itself. Throws like selectedHome. */ + launchEnv(): ClaudeEnvPatch { + const home = this.selectedHome() + return { + [CLAUDE_PROFILE_POINTER_ENV]: this.pointerPath, + // Why nothing for System default: the user's inherited CLAUDE_CONFIG_DIR must pass through. + ...(home ? { CLAUDE_CONFIG_DIR: home, [CLAUDE_INJECTED_CONFIG_DIR_ENV]: home } : {}) + } + } + + /** A pane's spawn env. Never throws, so a broken selection cannot stop a terminal opening. */ + terminalEnv(target?: ClaudeAccountSelectionTarget): ClaudeEnvPatch { + // Why only the pointer: the guest's `claude` reads it, so a pane never waits on the guest. + if (target?.runtime === 'wsl') { + return { [CLAUDE_PROFILE_POINTER_ENV]: `~/${wslClaudeProfilePointer(this.args.dataRoot)}` } + } + try { + return this.launchEnv() + } catch { + return { [CLAUDE_PROFILE_POINTER_ENV]: this.pointerPath } + } + } + + preparation(): ClaudeRuntimeAuthPreparation { + const home = this.selectedHome() + return { + configDir: home ?? this.systemDefaultHome(), + runtime: 'host', + wslDistro: null, + wslLinuxConfigDir: null, + envPatch: this.launchEnv(), + stripAuthEnv: home !== null, + provenance: home ? `profile:${this.selectedProfile()?.accountId}` : 'system' + } + } + + /** Every account folder on this host, selected or not. */ + accountHomes(): string[] { + return listClaudeProfileHomes(this.args.dataRoot) + } +} diff --git a/src/main/claude-accounts/claude-profile-setup-worker-entry.ts b/src/main/claude-accounts/claude-profile-setup-worker-entry.ts new file mode 100644 index 000000000000..3ac5a3c92759 --- /dev/null +++ b/src/main/claude-accounts/claude-profile-setup-worker-entry.ts @@ -0,0 +1,27 @@ +import { parentPort, workerData } from 'node:worker_threads' +import { ClaudeHookService } from '../claude/hook-service' +import { provisionClaudeAccountProfile } from './claude-profile-setup' +import type { ClaudeProfileSetupJob } from './claude-profile-setup-worker' + +if (!parentPort) { + throw new Error('Claude account setup must run on a worker thread') +} +const port = parentPort +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: runClaudeProfileSetupInWorker is the only spawner and passes a ClaudeProfileSetupJob. +const job = workerData as ClaudeProfileSetupJob + +void provisionClaudeAccountProfile({ + dataRoot: job.dataRoot, + profile: job.profile, + userHome: job.userHome, + userConfigDir: job.userConfigDir, + installHooks: job.hooks + ? ({ configDir }) => + new ClaudeHookService().install({ configDir, claudeVersion: job.claudeVersion }) + : null +}) + .then( + (report) => port.postMessage({ ok: true, report }), + (error: unknown) => port.postMessage({ ok: false, error: String(error) }) + ) + .finally(() => port.close()) diff --git a/src/main/claude-accounts/claude-profile-setup-worker.test.ts b/src/main/claude-accounts/claude-profile-setup-worker.test.ts new file mode 100644 index 000000000000..46f5df088ac1 --- /dev/null +++ b/src/main/claude-accounts/claude-profile-setup-worker.test.ts @@ -0,0 +1,77 @@ +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { build } from 'esbuild' +import { afterEach, expect, it } from 'vitest' +import { describeClaudeProfile } from './claude-profile-paths' +import { runClaudeProfileSetupInWorker } from './claude-profile-setup-worker' + +const roots: string[] = [] +afterEach(() => roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true }))) + +it('runs account setup in the built worker entry, off the calling thread', async () => { + const root = mkdtempSync(join(tmpdir(), 'claude-setup-worker-')) + roots.push(root) + const userHome = join(root, 'personal') + const dataRoot = join(root, 'data') + mkdirSync(join(userHome, '.claude'), { recursive: true }) + const profile = describeClaudeProfile(dataRoot, 'a', { + executionHostId: 'local', + runtime: 'host' + }) + mkdirSync(profile.home, { recursive: true }) + const entry = join(root, 'claude-profile-setup-worker-entry.js') + await build({ + entryPoints: [resolve('src/main/claude-accounts/claude-profile-setup-worker-entry.ts')], + outfile: entry, + bundle: true, + platform: 'node', + format: 'cjs', + external: ['electron'], + logLevel: 'silent' + }) + + const report = await runClaudeProfileSetupInWorker( + { + dataRoot, + profile, + userHome, + userConfigDir: undefined, + hooks: false, + claudeVersion: undefined + }, + entry + ) + + expect(report.outcome).toBe('prepared') + expect(existsSync(join(dataRoot, 'claude-profiles', 'a', 'profile.json'))).toBe(true) + await expect( + runClaudeProfileSetupInWorker( + { + dataRoot, + profile, + userHome, + userConfigDir: undefined, + hooks: false, + claudeVersion: undefined + }, + join(root, 'missing-entry.js') + ) + ).rejects.toThrow() +}) + +it('rejects and stops a setup worker that never finishes', async () => { + const root = mkdtempSync(join(tmpdir(), 'claude-setup-worker-')) + roots.push(root) + const hung = join(root, 'hung.js') + writeFileSync(hung, 'setInterval(() => {}, 1000)\n') + const job = { + dataRoot: root, + profile: describeClaudeProfile(root, 'a', { executionHostId: 'local', runtime: 'host' }), + userHome: root, + userConfigDir: undefined, + hooks: false, + claudeVersion: undefined + } + await expect(runClaudeProfileSetupInWorker(job, hung, 50)).rejects.toThrow('timed out') +}) diff --git a/src/main/claude-accounts/claude-profile-setup-worker.ts b/src/main/claude-accounts/claude-profile-setup-worker.ts new file mode 100644 index 000000000000..410edb72066c --- /dev/null +++ b/src/main/claude-accounts/claude-profile-setup-worker.ts @@ -0,0 +1,55 @@ +import { existsSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { Worker } from 'node:worker_threads' +import { currentWorkerEntryLayout, resolveWorkerThreadEntryPath } from '../worker-thread-entry-path' +import type { ClaudeProfileDescriptor } from './claude-profile-paths' +import type { ClaudeProfileSetupReport } from './claude-profile-setup' + +export type ClaudeProfileSetupJob = { + dataRoot: string + profile: ClaudeProfileDescriptor + userHome: string + userConfigDir: string | undefined + hooks: boolean + claudeVersion: string | undefined +} + +const WORKER_FILENAME = 'claude-profile-setup-worker-entry.js' + +function workerPath(): string { + const entry = resolveWorkerThreadEntryPath(currentWorkerEntryLayout(__dirname), WORKER_FILENAME) + // Rollup can place this launcher in a shared chunk beside the worker entries. + return [entry, join(dirname(entry), '..', WORKER_FILENAME)].find(existsSync) ?? entry +} + +/** One worker per setup: a first setup can merge a large history tree with sync fs calls. */ +export function runClaudeProfileSetupInWorker( + job: ClaudeProfileSetupJob, + path = workerPath(), + timeoutMs = 60_000 +): Promise { + return new Promise((resolve, reject) => { + const worker = new Worker(path, { workerData: job }) + // Why: a hung setup would hold the router's in-flight entry, and every launch waiting on it, forever. + const timer = setTimeout(() => { + reject(new Error('Claude account setup timed out')) + void worker.terminate() + }, timeoutMs) + let result: + | { ok: true; report: ClaudeProfileSetupReport } + | { ok: false; error: string } + | null = null + worker.once('message', (message) => { + result = message + }) + worker.once('error', reject) + worker.once('exit', (code) => { + clearTimeout(timer) + if (result?.ok) { + resolve(result.report) + } else { + reject(new Error(result?.error ?? `Claude account setup worker exited (${code})`)) + } + }) + }) +} diff --git a/src/main/claude-accounts/claude-profile-setup.test.ts b/src/main/claude-accounts/claude-profile-setup.test.ts new file mode 100644 index 000000000000..0195805eff32 --- /dev/null +++ b/src/main/claude-accounts/claude-profile-setup.test.ts @@ -0,0 +1,160 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import type * as Os from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +const state = vi.hoisted(() => ({ home: '' })) +vi.mock('node:os', async (original) => ({ + ...(await original()), + homedir: () => state.home +})) +vi.mock('electron', () => ({ app: { getPath: () => state.home } })) +import { ClaudeHookService } from '../claude/hook-service' +import { describeClaudeProfile } from './claude-profile-paths' +import { provisionClaudeAccountProfile } from './claude-profile-setup' + +const roots: string[] = [] +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) +const local = { runtime: 'host', executionHostId: 'local' } as const +function fixture() { + const root = realpathSync(mkdtempSync(join(tmpdir(), 'claude-profile-setup-'))) + roots.push(root) + state.home = join(root, 'home') + const defaultHome = join(state.home, '.claude') + const dataRoot = join(root, 'data') + mkdirSync(defaultHome, { recursive: true }) + mkdirSync(dataRoot) + const service = new ClaudeHookService() + const installHooks = (target: { configDir: string }) => + service.install({ claudeVersion: '2.1.261', ...target }) + const setup = () => + provisionClaudeAccountProfile({ + dataRoot, + profile: describeClaudeProfile(dataRoot, 'a', local), + userHome: state.home, + installHooks, + platform: 'linux' + }) + return { root, defaultHome, dataRoot, service, setup } +} + +// Why: setup runs as 'linux', so it creates real symlinks, which Windows needs privilege for. +const itLinks = it.skipIf(process.platform === 'win32') + +describe('Claude account profile setup', () => { + itLinks('prepares, shares history, provisions and installs hooks in one call', async () => { + const f = fixture() + mkdirSync(join(f.defaultHome, 'skills')) + writeFileSync(join(f.defaultHome, 'settings.json'), '{"model":"opus"}') + f.service.install({ claudeVersion: '2.1.261' }) + const report = await f.setup() + const home = join(f.dataRoot, 'claude-profiles/a/home') + expect(report).toMatchObject({ outcome: 'prepared', warnings: [] }) + expect(report.surfaces).toMatchObject({ + projects: 'linked', + 'history.jsonl': 'linked', + skills: 'linked', + 'settings.json': 'merged', + '.claude.json': 'absent', + hooks: 'merged' + }) + expect(existsSync(join(f.dataRoot, 'claude-profiles/a/profile.json'))).toBe(true) + const settings = JSON.parse(readFileSync(join(home, 'settings.json'), 'utf8')) + const defaults = JSON.parse(readFileSync(join(f.defaultHome, 'settings.json'), 'utf8')) + expect(settings).toEqual({ + model: 'opus', + hooks: defaults.hooks, + statusLine: defaults.statusLine + }) + expect(realpathSync(join(home, 'projects'))).toBe(realpathSync(join(f.defaultHome, 'projects'))) + expect(existsSync(join(home, '.credentials.json'))).toBe(false) + expect((await f.setup()).warnings).toEqual([]) + }) + 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') + writeFileSync(settings, '{"model":"opus"}') + await f.setup() + const home = join(f.dataRoot, 'claude-profiles/a/home') + const read = (file: string) => JSON.parse(readFileSync(file, 'utf8')) + const orca = read(join(home, 'settings.json')).hooks + expect(orca).toBeDefined() + const mine = { matcher: '', hooks: [{ type: 'command', command: 'notify-me' }] } + writeFileSync(settings, JSON.stringify({ model: 'opus', hooks: { Notification: [mine] } })) + expect((await f.setup()).warnings).toEqual([]) + const hooks = read(join(home, 'settings.json')).hooks + expect(hooks.Notification).toContainEqual(mine) + expect(hooks.Stop).toEqual(orca.Stop) + }) + itLinks('refuses another account in the same slot without creating anything', async () => { + const f = fixture() + await f.setup() + writeFileSync( + join(f.dataRoot, 'claude-profiles/a/profile.json'), + JSON.stringify({ version: 1, accountId: 'b', runtime: 'host' }) + ) + rmSync(join(f.dataRoot, 'claude-profiles/a/home'), { recursive: true }) + const report = await f.setup() + expect(report.outcome).toBe('refused') + expect(report.warnings).toEqual([ + expect.objectContaining({ surface: 'profile', code: 'invalid-profile' }) + ]) + expect(existsSync(join(f.dataRoot, 'claude-profiles/a/home'))).toBe(false) + }) + itLinks('refuses a data root linked into the default home before writing there', async () => { + const f = fixture() + mkdirSync(join(f.defaultHome, 'inner')) + symlinkSync(join(f.defaultHome, 'inner'), join(f.dataRoot, 'claude-profiles')) + expect((await f.setup()).outcome).toBe('refused') + expect(existsSync(join(f.defaultHome, 'inner', 'a'))).toBe(false) + }) + it('refuses a WSL profile on the Windows host side', async () => { + fixture() + const report = await provisionClaudeAccountProfile({ + dataRoot: '/home/u/.local/share/orca', + profile: describeClaudeProfile('/home/u/.local/share/orca', 'a', { + runtime: 'wsl', + distro: 'Ubuntu', + executionHostId: 'local' + }), + userHome: state.home, + installHooks: null, + platform: 'win32' + }) + expect(report.outcome).toBe('refused') + expect(report.warnings).toEqual([ + expect.objectContaining({ surface: 'profile', code: 'invalid-profile' }) + ]) + }) + itLinks('reports skipped and failed hook installs without failing the rest', async () => { + const f = fixture() + const skipped = await provisionClaudeAccountProfile({ + dataRoot: f.dataRoot, + profile: describeClaudeProfile(f.dataRoot, 'a', local), + userHome: state.home, + installHooks: null, + platform: 'linux' + }) + expect(skipped.surfaces.hooks).toBe('absent') + writeFileSync(join(f.dataRoot, 'claude-profiles/a/home/settings.json'), '{bad') + const failed = await f.setup() + expect(failed.surfaces.hooks).toBe('failed') + expect(failed.surfaces.projects).toBe('unchanged') + expect(failed.warnings).toContainEqual( + expect.objectContaining({ surface: 'hooks', code: 'failed' }) + ) + }) +}) diff --git a/src/main/claude-accounts/claude-profile-setup.ts b/src/main/claude-accounts/claude-profile-setup.ts new file mode 100644 index 000000000000..1687d9cf0e8b --- /dev/null +++ b/src/main/claude-accounts/claude-profile-setup.ts @@ -0,0 +1,96 @@ +import { join } from 'node:path' +import type { AgentHookInstallStatus } from '../../shared/agent-hook-types' +import { shareClaudeProfileHistory } from './claude-profile-history' +import { + prepareClaudeProfileDirectory, + readClaudeProfileObject, + type ClaudeProfileDescriptor +} from './claude-profile-paths' +import { provisionClaudeProfile } from './claude-profile-provisioning' +import { + ClaudeProfileSurfaceError, + createClaudeProfileReport, + runClaudeProfileSurface, + warnClaudeProfile, + type ClaudeProfileReport +} from './claude-profile-report' +import { + claudeProfileLedgerPath, + readClaudeProfileLedger, + writeClaudeProfileLedger +} from './claude-profile-sharing' + +/** Without this the installed `hooks` look like a profile edit, so the user's own hooks never arrive. */ +function recordInstalledHooks(home: string): void { + const settings = readClaudeProfileObject(join(home, 'settings.json')) + if (settings.kind !== 'present' || !('hooks' in settings.value)) { + return + } + const ledgerPath = claudeProfileLedgerPath(home) + const ledger = readClaudeProfileLedger(ledgerPath) + const written = (ledger.keys['settings.json'] ??= {}) + const hooks = JSON.stringify(settings.value.hooks) + if (written.hooks !== hooks) { + written.hooks = hooks + writeClaudeProfileLedger(ledgerPath, ledger) + } +} + +/** `refused`: the profile failed its ownership gate and no surface was touched. */ +export type ClaudeProfileSetupReport = ClaudeProfileReport & { outcome: 'refused' | 'prepared' } + +/** + * The one entry for setting up a managed Claude profile. Runs on the execution host that owns the + * profile; for WSL that is inside the guest, never across a UNC path. + */ +export async function provisionClaudeAccountProfile(args: { + dataRoot: string + profile: ClaudeProfileDescriptor + userHome: string + /** The user's own CLAUDE_CONFIG_DIR (see readUserClaudeConfigDir); `~/.claude` when unset. */ + 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() + try { + if (args.profile.target.runtime === 'wsl' && platform === 'win32') { + throw new ClaudeProfileSurfaceError('invalid-profile', 'WSL profiles are set up in the guest') + } + prepareClaudeProfileDirectory(args.dataRoot, args.profile, args.userHome, args.userConfigDir) + } catch (error) { + report.surfaces.profile = 'failed' + warnClaudeProfile(report, 'profile', error) + return { outcome: 'refused', ...report } + } + const home = args.profile.home + const shared = { profileHome: home, userHome: args.userHome, userConfigDir: args.userConfigDir } + for (const step of [ + () => shareClaudeProfileHistory({ ...shared, platform }), + () => provisionClaudeProfile({ ...shared, platform, trustKeys: args.trustKeys }) + ]) { + try { + const part = await step() + Object.assign(report.surfaces, part.surfaces) + report.warnings.push(...part.warnings) + } catch (error) { + warnClaudeProfile(report, 'profile', error) + } + } + const installHooks = args.installHooks + await runClaudeProfileSurface(report, 'hooks', () => { + if (!installHooks) { + return 'absent' + } + const status = installHooks({ configDir: home }) + if (status.state !== 'installed') { + throw new Error(status.detail ?? `Claude hooks ${status.state}`) + } + recordInstalledHooks(home) + return 'merged' + }) + return { outcome: 'prepared', ...report } +} diff --git a/src/main/claude-accounts/claude-profile-sharing.ts b/src/main/claude-accounts/claude-profile-sharing.ts new file mode 100644 index 000000000000..69201b62f5b9 --- /dev/null +++ b/src/main/claude-accounts/claude-profile-sharing.ts @@ -0,0 +1,189 @@ +import { createHash } from 'node:crypto' +import { + lstatSync, + mkdirSync, + readFileSync, + readdirSync, + realpathSync, + rmdirSync, + symlinkSync, + unlinkSync +} from 'node:fs' +import { dirname, join } from 'node:path' +import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' +import { writeFileAtomically } from '../codex-accounts/fs-utils' +import { readClaudeProfileObject } from './claude-profile-paths' +import type { ClaudeProfileSurfaceOutcome } from './claude-profile-report' + +/** Keyed by surface name, not path, so another spelling of the same profile keeps its history. */ +export type ClaudeProfileLedger = { + version: 1 + files: Record + keys: Record> +} + +function stringEntries(value: unknown): Record { + const entries: Record = {} + if (value && typeof value === 'object') { + for (const [key, entry] of Object.entries(value)) { + if (typeof entry === 'string') { + entries[key] = entry + } + } + } + return entries +} + +export function claudeProfileLedgerPath(profileHome: string): string { + return join(profileHome, '.orca-profile.json') +} + +export function writeClaudeProfileLedger(file: string, ledger: ClaudeProfileLedger): void { + writeFileAtomically(file, `${JSON.stringify(ledger, null, 2)}\n`, { mode: 0o600 }) +} + +/** Orca's own bookkeeping: an unreadable ledger starts empty, so nothing shared is overwritten. */ +export function readClaudeProfileLedger(file: string): ClaudeProfileLedger { + const ledger: ClaudeProfileLedger = { version: 1, files: {}, keys: {} } + const result = readClaudeProfileObject(file) + if (result.kind !== 'present') { + return ledger + } + ledger.files = stringEntries(result.value.files) + const keys = result.value.keys + if (keys && typeof keys === 'object') { + for (const [surface, entries] of Object.entries(keys)) { + ledger.keys[surface] = stringEntries(entries) + } + } + return ledger +} + +export function linkClaudeProfileDirectory( + source: string, + target: string, + platform: NodeJS.Platform +): ClaudeProfileSurfaceOutcome { + let canonical: string + try { + canonical = realpathSync(source) + } catch (error) { + if (isDefinitiveAbsence(error)) { + return 'absent' + } + throw error + } + let entry: ReturnType | undefined + try { + entry = lstatSync(target) + } catch (error) { + if (!isDefinitiveAbsence(error)) { + throw error + } + } + if (entry?.isSymbolicLink()) { + try { + return realpathSync(target) === canonical ? 'unchanged' : 'user-owned' + } catch (error) { + if (!isDefinitiveAbsence(error)) { + throw error + } + unlinkSync(target) + } + } else if (entry) { + if (!entry.isDirectory() || readdirSync(target).length > 0) { + return 'user-owned' + } + rmdirSync(target) + } + mkdirSync(dirname(target), { recursive: true }) + // Why: link the path itself so a user who re-points their own link is followed. + symlinkSync(source, target, platform === 'win32' ? 'junction' : 'dir') + return 'linked' +} + +export function syncClaudeProfileFile( + source: string, + target: string, + surface: string, + ledger: ClaudeProfileLedger, + render: (sourceText: string) => string = (sourceText) => sourceText +): ClaudeProfileSurfaceOutcome { + let desired: string + try { + desired = render(readFileSync(source, 'utf8')) + } catch (error) { + if (isDefinitiveAbsence(error)) { + return 'absent' + } + throw error + } + const hash = (value: string): string => createHash('sha256').update(value).digest('hex') + try { + if (lstatSync(target).isSymbolicLink()) { + return 'user-owned' + } + const current = hash(readFileSync(target, 'utf8')) + if (current === hash(desired)) { + ledger.files[surface] = current + return 'unchanged' + } + if (ledger.files[surface] !== current) { + return 'user-owned' + } + } catch (error) { + if (!isDefinitiveAbsence(error)) { + throw error + } + } + writeFileAtomically(target, desired, { mode: 0o600 }) + ledger.files[surface] = hash(desired) + return 'synced' +} + +/** Returns the keys it changed in `target`. */ +export function mergeClaudeProfileKeys( + target: Record, + desired: Record, + written: Record +): string[] { + const changed: string[] = [] + for (const [key, value] of Object.entries(desired)) { + const serialized = JSON.stringify(value) + const current = key in target ? JSON.stringify(target[key]) : undefined + if (current !== serialized && current !== undefined && written[key] !== current) { + continue + } + if (current !== serialized) { + target[key] = value + changed.push(key) + } + written[key] = serialized + } + return changed +} + +/** + * A key the default home dropped leaves the profile when the profile still holds what Orca last + * shared; a value changed inside the profile stays. Callers skip this when the source was unreadable. + */ +export function dropClaudeProfileKeys( + target: Record, + desired: Record, + written: Record +): string[] { + const dropped: string[] = [] + for (const key of Object.keys(written)) { + if (key in desired) { + continue + } + if (!(key in target)) { + delete written[key] + } else if (JSON.stringify(target[key]) === written[key]) { + delete target[key] + delete written[key] + dropped.push(key) + } + } + return dropped +} diff --git a/src/main/claude-accounts/claude-profile-shell-composition.test.ts b/src/main/claude-accounts/claude-profile-shell-composition.test.ts new file mode 100644 index 000000000000..4bf99fdac515 --- /dev/null +++ b/src/main/claude-accounts/claude-profile-shell-composition.test.ts @@ -0,0 +1,120 @@ +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, 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 { + const init = args?.[2] + if (!init) { + throw new Error('fish launch was not wrapped') + } + return init +} +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') + const localBash = await import('../providers/local-pty-shell-ready-bash-rcfile') + const daemonBash = await import('../daemon/daemon-bash-shell-ready-rcfile') + const zsh = await import('../zsh-startup-wrapper-builder') + const localZsh = await import('../providers/local-pty-shell-ready-wrapper-fileset') + const daemonZsh = await import('../daemon/daemon-zsh-shell-ready-wrapper-spec') + return { + zsh: [ + zsh.buildZshStartupHook(localZsh.getLocalZshWrapperSpec()), + zsh.buildZshStartupHook(daemonZsh.getDaemonZshWrapperSpec()) + ], + bash: [ + localBash.getBashShellReadyRcfileContent(), + daemonBash.getDaemonBashShellReadyRcfileContent() + ], + fish: [ + fishInit(local.getShellLaunchConfig(FISH, ['ready'])), + fishInit(daemon.getShellLaunchConfig(FISH, ['ready'])) + ], + powershell: powershell.getPowerShellOsc133Bootstrap() + } +} + +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') + } +}) + +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(on.getFishVendorConfSnippet()).toContain(getFishClaudeShellFunction()) +}) + +it('starts the PowerShell claude function on its own line after the codex fragment', async () => { + const on = await composed(true) + expect(on.powershell).toContain( + 'Remove-Variable orcaCodexCommand -ErrorAction SilentlyContinue\n$orcaClaudeCommand' + ) +}) + +it.skipIf(!existsSync(FISH))( + 'defines both the ready hook and the claude function from the composed fish init', + async () => { + const root = mkdtempSync(join(tmpdir(), 'claude-fish-compose-')) + roots.push(root) + mkdirSync(join(root, 'bin')) + writeFileSync(join(root, 'bin', 'claude'), '#!/bin/sh\nexit 0\n') + chmodSync(join(root, 'bin', 'claude'), 0o700) + const on = await composed(true) + for (const init of on.fish) { + const result = spawnSync( + '/usr/bin/env', + [ + '-i', + `HOME=${root}`, + `PATH=${join(root, 'bin')}:/usr/bin:/bin`, + `ORCA_CLAUDE_PROFILE_POINTER=${join(root, 'selected')}`, + FISH, + '--no-config', + '-c', + `test (command -s claude) = '${join(root, 'bin', 'claude')}'; or exit 97\n${init}\nfunctions -q claude; and echo CLAUDE-FN\nfunctions -q __orca_shell_ready_marker; and echo READY-HOOK` + ], + { encoding: 'utf8' } + ) + expect(result.status).not.toBe(97) + expect(result.stderr).toBe('') + expect(result.stdout).toContain('CLAUDE-FN') + expect(result.stdout).toContain('READY-HOOK') + } + } +) diff --git a/src/main/claude-accounts/claude-profile-wsl-entry.ts b/src/main/claude-accounts/claude-profile-wsl-entry.ts new file mode 100644 index 000000000000..47629865c540 --- /dev/null +++ b/src/main/claude-accounts/claude-profile-wsl-entry.ts @@ -0,0 +1,25 @@ +import { provisionClaudeAccountProfile } from './claude-profile-setup' +import { wslClaudeProfile } from './claude-profile-wsl-paths' + +// Runs inside a WSL distro on Orca's pinned Node: ` `. +// Hooks are not installed here: they reach the account through the settings merge from ~/.claude. +async function main(): Promise { + const [userHome = '', distro = '', accountId = ''] = process.argv.slice(2) + const { dataRoot, profile } = wslClaudeProfile(userHome, distro, accountId) + const report = await provisionClaudeAccountProfile({ + dataRoot, + profile, + userHome, + installHooks: null + }) + if (report.outcome !== 'prepared') { + console.error(JSON.stringify(report)) + process.exitCode = 2 + } else if (report.warnings.length > 0) { + process.stdout.write(JSON.stringify(report)) + } +} +void main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : String(error)) + process.exitCode = 1 +}) diff --git a/src/main/claude-accounts/claude-profile-wsl-paths.ts b/src/main/claude-accounts/claude-profile-wsl-paths.ts new file mode 100644 index 000000000000..a5096c040eea --- /dev/null +++ b/src/main/claude-accounts/claude-profile-wsl-paths.ts @@ -0,0 +1,27 @@ +import { basename, posix } from 'node:path' +import { describeClaudeProfile, type ClaudeProfileDescriptor } from './claude-profile-paths' + +/** A WSL account folder in the guest: the one spelling setup, launch and sign-in use. */ +export function wslClaudeProfile( + guestHome: string, + distro: string, + accountId: string +): { dataRoot: string; profile: ClaudeProfileDescriptor } { + const dataRoot = posix.join(guestHome, '.local/share/orca') + const profile = describeClaudeProfile(dataRoot, accountId, { + executionHostId: 'local', + runtime: 'wsl', + distro + }) + return { dataRoot, profile } +} + +/** + * The guest's which-account file, relative to the guest home: a pane spawn cannot ask the guest + * for its home, so the pane value is `~/` plus this and the `claude` function expands it. Named + * after the host data folder so a dev build and the packaged app never share one. + */ +export function wslClaudeProfilePointer(hostDataRoot: string): string { + const build = basename(hostDataRoot).replace(/[^\w.-]/g, '_') + return `.local/share/orca/claude-profiles/selected-wsl-${build}` +} diff --git a/src/main/claude-accounts/claude-profile-wsl-router.test.ts b/src/main/claude-accounts/claude-profile-wsl-router.test.ts new file mode 100644 index 000000000000..decde2f9d35a --- /dev/null +++ b/src/main/claude-accounts/claude-profile-wsl-router.test.ts @@ -0,0 +1,215 @@ +import { spawnSync } from 'node:child_process' +import { + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { build } from 'esbuild' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { ClaudeManagedAccount } from '../../shared/managed-account-types' +import type { WslSpec } from '../wsl/wsl-runner' +import type * as WslPaths from '../../shared/wsl-paths' + +const guest = vi.hoisted(() => ({ home: '' })) +// The guest is this machine: its "UNC" paths are the Linux paths, and scripts run in /bin/sh. +vi.mock('../../shared/wsl-paths', async (original) => ({ + ...(await original()), + toWindowsWslPath: (linuxPath: string) => linuxPath +})) +vi.mock('../wsl', () => ({ + getWslHomeAsync: async (distro: string) => `\\\\wsl.localhost\\${distro}${guest.home}`, + listRunningWslDistrosAsync: async () => ['Ubuntu'] +})) +vi.mock('../wsl/wsl-runner', () => ({ + runWslProcess: async (spec: WslSpec) => { + const result = spawnSync('/bin/sh', ['-c', spec.script ?? '', 'sh', ...(spec.args ?? [])], { + encoding: 'utf8' + }) + return { code: result.status, stdout: result.stdout, stderr: result.stderr, timedOut: false } + } +})) + +import { + CLAUDE_PROFILE_MISSING_MESSAGE, + CLAUDE_PROFILE_SETUP_FAILED_MESSAGE, + type ClaudeProfileRouterSettings +} from './claude-profile-router' +import { ClaudeWslProfileRouter } from './claude-profile-wsl-router' + +// Why skipped on Windows: the guest is Linux; these run its scripts and Node bundle as the guest. +const posixHost = process.platform !== 'win32' +const roots: string[] = [] +afterEach(() => roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true }))) + +function fixture() { + const root = mkdtempSync(join(tmpdir(), 'claude-wsl-router-')) + roots.push(root) + guest.home = join(root, 'home') + mkdirSync(join(guest.home, '.claude'), { recursive: true }) + const account = (id: string): ClaudeManagedAccount => ({ + id, + email: `${id}@example.test`, + authMethod: 'subscription-oauth', + managedAuthPath: '/unused-legacy', + managedAuthRuntime: 'wsl', + wslDistro: 'Ubuntu', + createdAt: 0, + updatedAt: 0, + lastAuthenticatedAt: 0 + }) + const wsl: Record = { Ubuntu: 'a' } + const settings: ClaudeProfileRouterSettings = { + claudeManagedAccounts: [account('a')], + activeClaudeManagedAccountId: null, + activeClaudeManagedAccountIdsByRuntime: { host: null, wsl }, + agentStatusHooksEnabled: false, + disabledTuiAgents: [] + } + const setup = { calls: 0, fail: false, gate: Promise.resolve() } + const router = new ClaudeWslProfileRouter({ + getSettings: () => settings, + dataRoot: join(root, 'orca-dev'), + runSetup: async () => { + setup.calls += 1 + await setup.gate + if (setup.fail) { + throw new Error('refused') + } + writeFileSync(join(profileHome, '..', 'profile.json'), '{}') + } + }) + const profileHome = join(guest.home, '.local/share/orca/claude-profiles/a/home') + const pointer = join(guest.home, '.local/share/orca/claude-profiles/selected-wsl-orca-dev') + return { settings, wsl, setup, router, profileHome, pointer, account } +} + +describe.skipIf(!posixHost)('ClaudeWslProfileRouter', () => { + 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 + await f.router.publish('Ubuntu') + expect(readFileSync(f.pointer, 'utf8')).toBe('') + + f.settings.claudeManagedAccounts = [] + await f.router.publish('Ubuntu') + expect(existsSync(f.pointer)).toBe(false) + }) + + it('refuses a missing folder, waits for a never-set-up one, then launches at once', async () => { + const f = fixture() + 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({ + configDir: f.profileHome, + runtime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxConfigDir: f.profileHome, + envPatch: { + ORCA_CLAUDE_PROFILE_POINTER: '~/.local/share/orca/claude-profiles/selected-wsl-orca-dev', + CLAUDE_CONFIG_DIR: f.profileHome, + ORCA_CLAUDE_INJECTED_CONFIG_DIR: f.profileHome + } + }) + await f.router.prepareLaunch('Ubuntu') + expect(f.setup.calls).toBe(2) + }) + + it('writes a missing guest pointer before a launch returns', async () => { + const f = fixture() + mkdirSync(f.profileHome, { recursive: true }) + writeFileSync(join(f.profileHome, '..', 'profile.json'), '{}') + expect(existsSync(f.pointer)).toBe(false) + await f.router.prepareLaunch('Ubuntu') + expect(readFileSync(f.pointer, 'utf8')).toBe(f.profileHome) + }) + + it('a launch waiting on setup leaves a selection made meanwhile in the pointer', async () => { + const f = fixture() + mkdirSync(f.profileHome, { recursive: 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)) + + f.settings.claudeManagedAccounts = [f.account('a'), f.account('b')] + f.wsl.Ubuntu = 'b' + await f.router.publish('Ubuntu') + const homeB = join(f.profileHome, '../../b/home') + expect(readFileSync(f.pointer, 'utf8')).toBe(homeB) + release() + await launch + expect(readFileSync(f.pointer, 'utf8')).toBe(homeB) + }) + + it('overwrites a guest pointer that names another account before a launch returns', async () => { + const f = fixture() + mkdirSync(f.profileHome, { recursive: true }) + writeFileSync(join(f.profileHome, '..', 'profile.json'), '{}') + mkdirSync(join(f.pointer, '..'), { recursive: true }) + writeFileSync(f.pointer, join(f.profileHome, '../../b/home')) + await f.router.prepareLaunch('Ubuntu') + expect(readFileSync(f.pointer, 'utf8')).toBe(f.profileHome) + }) + + it('launches System default from the guest ~/.claude with no account env', async () => { + const f = fixture() + f.wsl.Ubuntu = null + const prepared = await f.router.preparation('Ubuntu') + expect(prepared.wslLinuxConfigDir).toBe(join(guest.home, '.claude')) + expect(prepared.envPatch).toEqual({ + ORCA_CLAUDE_PROFILE_POINTER: '~/.local/share/orca/claude-profiles/selected-wsl-orca-dev' + }) + expect(await f.router.runningDistros()).toEqual(['Ubuntu']) + }) +}) + +it.skipIf(!posixHost)( + 'the guest helper runs Step 1 setup as a standalone Linux Node bundle', + async () => { + const root = mkdtempSync(join(tmpdir(), 'claude-wsl-helper-')) + roots.push(root) + const home = join(root, 'home') + mkdirSync(join(home, '.claude', 'projects'), { recursive: true }) + const profileHome = join(home, '.local/share/orca/claude-profiles/a/home') + mkdirSync(profileHome, { recursive: true }) + const helper = join(root, 'claude-profile-wsl.cjs') + await build({ + entryPoints: [resolve('src/main/claude-accounts/claude-profile-wsl-entry.ts')], + outfile: helper, + bundle: true, + platform: 'node', + format: 'cjs', + external: ['electron'], + logLevel: 'silent' + }) + const run = (accountId: string) => + spawnSync(process.execPath, [helper, home, 'Ubuntu', accountId], { encoding: 'utf8' }) + + expect(run('a').status).toBe(0) + expect(existsSync(join(profileHome, '..', 'profile.json'))).toBe(true) + // History is a Linux link into the guest's own ~/.claude. + expect(lstatSync(join(profileHome, 'projects')).isSymbolicLink()).toBe(true) + expect(run('../escape').status).not.toBe(0) + } +) diff --git a/src/main/claude-accounts/claude-profile-wsl-router.ts b/src/main/claude-accounts/claude-profile-wsl-router.ts new file mode 100644 index 000000000000..0fba0eb59a68 --- /dev/null +++ b/src/main/claude-accounts/claude-profile-wsl-router.ts @@ -0,0 +1,234 @@ +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_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 { + 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' +import type { ClaudeRuntimeAuthPreparation } from './runtime-auth/runtime-auth-types' +import { getClaudeWslSelectionKey, getSelectedClaudeAccountIdForTarget } from './runtime-selection' + +type WslSetup = (distro: string, guestHome: string, accountId: string) => Promise + +/** + * The host router's rules for one WSL distro: account folders, setup and the which-account file + * all live in the guest, so links are Linux links and history shares within the guest. + */ +export class ClaudeWslProfileRouter { + private readonly setups = new Map>() + constructor( + private readonly args: { + getSettings: () => ClaudeProfileRouterSettings + /** The host's data folder; it names the guest pointer per Orca build. */ + dataRoot: string + /** Tests replace the guest helper. */ + runSetup?: WslSetup + } + ) {} + + private accountIn(distro: string): boolean { + const key = getClaudeWslSelectionKey(distro) + return this.args + .getSettings() + .claudeManagedAccounts.some( + (account) => + account.managedAuthRuntime === 'wsl' && + getClaudeWslSelectionKey(account.wslDistro) === key + ) + } + + /** Running distros that hold an Orca account; startup must not boot a stopped one. */ + async runningDistros(): Promise { + const running = await listRunningWslDistrosAsync({ requireConfirmed: true }) + return running.filter((distro) => this.accountIn(distro)) + } + + private async resolve(distro: string) { + const home = parseWslUncPath((await getWslHomeAsync(distro)) ?? '')?.linuxPath + if (!home?.startsWith('/')) { + throw new Error(`Could not read the home folder of WSL distro ${distro}.`) + } + return { home, profile: this.selectedProfile(home, distro) } + } + + private selectedProfile(home: string, distro: string): ClaudeProfileDescriptor | null { + const id = getSelectedClaudeAccountIdForTarget(this.args.getSettings(), { + runtime: 'wsl', + wslDistro: distro + }) + return id ? wslClaudeProfile(home, distro, id).profile : null + } + + private pointerIn(home: string): string { + return posix.join(home, wslClaudeProfilePointer(this.args.dataRoot)) + } + + /** Pointer first, then setup in the background, as on the host. No accounts here means no pointer. */ + async publish(distro: string): Promise { + const { home, profile } = await this.resolve(distro) + if (!this.accountIn(distro)) { + await runGuest(distro, { + script: 'rm -f -- "$1"', + args: [this.pointerIn(home)], + loginPath: 'none' + }) + return + } + await writePointer(distro, this.pointerIn(home), profile?.home ?? '') + // 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 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) + 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 new Error(CLAUDE_PROFILE_SETUP_FAILED_MESSAGE) + }) + } + // Why: a missing or stale guest pointer would run the pane's `claude` under another account. + // Re-read the selection: one made during setup has already published its own pointer. + if (this.accountIn(distro)) { + const selected = this.selectedProfile(home, distro) + await writePointer(distro, this.pointerIn(home), selected?.home ?? '') + } + return this.preparationFor(distro, home, profile) + } + + async preparation(distro: string): Promise { + const { home, profile } = await this.resolve(distro) + await this.assertPresent(distro, profile) + return this.preparationFor(distro, home, profile) + } + + /** Falling back would run the wrong account. */ + private async assertPresent(distro: string, profile: ClaudeProfileDescriptor | null) { + if (profile && !(await guestStat(distro, profile.home))?.isDirectory()) { + throw new Error(CLAUDE_PROFILE_MISSING_MESSAGE) + } + } + + /** The shape main's WSL account path returns, so trust and rate limits need nothing new. */ + private preparationFor( + distro: string, + home: string, + profile: ClaudeProfileDescriptor | null + ): ClaudeRuntimeAuthPreparation { + const configHome = profile?.home ?? posix.join(home, '.claude') + return { + configDir: toWindowsWslPath(configHome, distro), + runtime: 'wsl', + wslDistro: distro, + wslLinuxConfigDir: configHome, + envPatch: { + [CLAUDE_PROFILE_POINTER_ENV]: `~/${wslClaudeProfilePointer(this.args.dataRoot)}`, + ...(profile + ? { CLAUDE_CONFIG_DIR: profile.home, [CLAUDE_INJECTED_CONFIG_DIR_ENV]: profile.home } + : {}) + }, + stripAuthEnv: true, + provenance: profile ? `profile:${profile.accountId}:wsl:${distro}` : `wsl:${distro}:system` + } + } + + /** One setup per account at a time; a later request reuses the running one. */ + private setUp(distro: string, home: string, accountId: string): Promise { + const running = this.setups.get(accountId) + if (running) { + return running + } + const run = (this.args.runSetup ?? runWslSetup)(distro, home, accountId).finally(() => + this.setups.delete(accountId) + ) + this.setups.set(accountId, run) + return run + } +} + +// Why over the distro's share: a launch must not wait on a guest process for two stats. +async function guestStat(distro: string, linuxPath: string) { + return lstat(toWindowsWslPath(linuxPath, distro)).catch(() => null) +} + +/** 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. + const current = await Promise.race([ + readFile(toWindowsWslPath(pointer, distro), 'utf8').catch(() => null), + new Promise((resolve) => setTimeout(resolve, 2_000, null).unref()) + ]) + if (current === home) { + return + } + await runGuest(distro, { + script: + 'umask 077; mkdir -p -- "${1%/*}" && printf %s "$2" > "$1.tmp" && mv -f -- "$1.tmp" "$1"', + args: [pointer, home], + loginPath: 'none' + }) +} + +async function runGuest(distro: string, spec: WslSpec, timeoutMs = 15_000): Promise { + const result = await runWslProcess({ ...spec, distro, timeoutMs, maxOutputBytes: 256 * 1024 }) + if (result.code !== 0 || result.timedOut) { + throw new Error( + `WSL ${distro}: ${result.stderr.trim() || (result.timedOut ? 'timed out' : 'command failed')}` + ) + } + return result.stdout.trim() +} + +/** Step 1's setup, run as Linux inside the distro on Orca's pinned Node. */ +const runWslSetup: WslSetup = async (distro, home, accountId) => { + const helper = relayBundleCandidates('wsl') + .map((dir) => join(dir, WSL_CLAUDE_PROFILE_HELPER_FILENAME)) + .find(existsSync) + if (!helper) { + throw new Error('The bundled WSL Claude account helper is missing. Reinstall Orca.') + } + const run = (spec: WslSpec, timeoutMs?: number) => runGuest(distro, spec, timeoutMs) + const node = await ensureWslPinnedRuntime( + run, + join(getAppEnvironment().getPath('userData'), 'orcad-artifacts'), + AbortSignal.timeout(180_000) + ) + const guestHelper = await run({ + program: 'wslpath', + args: ['-a', '-u', helper], + loginPath: 'none' + }) + // Prints its report only when setup was incomplete; a refusal exits non-zero. + const report = await run( + { + program: '/usr/bin/env', + args: ['-u', 'NODE_OPTIONS', node, guestHelper, home, distro, accountId], + loginPath: 'none' + }, + 120_000 + ) + if (report) { + console.warn('[claude-profile] WSL account setup was incomplete:', report) + } +} diff --git a/src/main/claude-accounts/environment.ts b/src/main/claude-accounts/environment.ts index 3f7e18934629..3fd7871c70d7 100644 --- a/src/main/claude-accounts/environment.ts +++ b/src/main/claude-accounts/environment.ts @@ -8,6 +8,8 @@ export const CLAUDE_AUTH_ENV_VARS = [ ] as const export type ClaudeEnvPatch = { + ORCA_CLAUDE_PROFILE_POINTER?: string + ORCA_CLAUDE_INJECTED_CONFIG_DIR?: string CLAUDE_CONFIG_DIR?: string ANTHROPIC_CUSTOM_HEADERS?: string } @@ -33,6 +35,12 @@ export function applyClaudeEnvPatch( } } + 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 } 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 index 31b4acc41bd0..61c3323b6327 100644 --- a/src/main/claude-accounts/runtime-auth-service-wsl-runtime.test.ts +++ b/src/main/claude-accounts/runtime-auth-service-wsl-runtime.test.ts @@ -15,6 +15,7 @@ import { 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()) @@ -272,4 +273,40 @@ describe('ClaudeRuntimeAuthService', () => { } } }) + + 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.ts b/src/main/claude-accounts/runtime-auth-service.ts index dead38a7780f..adbbce30da01 100644 --- a/src/main/claude-accounts/runtime-auth-service.ts +++ b/src/main/claude-accounts/runtime-auth-service.ts @@ -1,3 +1,11 @@ +import { getAppEnvironment } from '../../shared/app-environment' +import { claudeProfileRoutingEnabled } from '../../shared/claude-profile-routing' +import { ClaudeProfileRouter } from './claude-profile-router' +import { ClaudeWslProfileRouter } from './claude-profile-wsl-router' +import { + getClaudeProfileRouter, + installClaudeProfileRouter +} from './claude-profile-installed-router' import type { Store } from '../persistence' import { getSelectedClaudeAccountIdForTarget, @@ -8,9 +16,23 @@ import type { ClaudeRuntimeAuthPreparation } from './runtime-auth/runtime-auth-t export type { ClaudeRuntimeAuthPreparation } from './runtime-auth/runtime-auth-types' +function routerFor(target: ClaudeAccountSelectionTarget): ClaudeProfileRouter | undefined { + return target.runtime === 'wsl' ? undefined : getClaudeProfileRouter() +} + export class ClaudeRuntimeAuthService extends ClaudeRuntimeAuthSync { + private readonly wslRouter?: ClaudeWslProfileRouter + 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() } @@ -19,6 +41,14 @@ export class ClaudeRuntimeAuthService extends ClaudeRuntimeAuthSync { target?: ClaudeAccountSelectionTarget ): Promise { 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) } @@ -27,18 +57,67 @@ export class ClaudeRuntimeAuthService extends ClaudeRuntimeAuthSync { target?: ClaudeAccountSelectionTarget ): Promise { 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) } async syncForCurrentSelection(target?: ClaudeAccountSelectionTarget): Promise { - await this.serializeMutation(() => - this.doSyncForCurrentSelection(target ?? this.getDefaultAccountSelectionTarget()) - ) + await this.serializeMutation(async () => { + 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 { + await this.doSyncForCurrentSelection(effectiveTarget) + } + }) + } + + /** 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) + }) + } + + /** 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))) + } } 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( @@ -67,7 +146,16 @@ export class ClaudeRuntimeAuthService extends ClaudeRuntimeAuthSync { private async safeSyncForCurrentSelection(): Promise { try { - await this.syncForCurrentSelection() + 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) } diff --git a/src/main/claude-usage/scanner.ts b/src/main/claude-usage/scanner.ts index b40f6869920d..13652648afe2 100644 --- a/src/main/claude-usage/scanner.ts +++ b/src/main/claude-usage/scanner.ts @@ -42,13 +42,14 @@ async function getProcessedFileStat( export async function scanClaudeUsageFiles( worktrees: ClaudeUsageWorktreeRef[], previousProcessedFiles: ClaudeUsagePersistedFile[] = [], - onFilesScanned?: (count: number) => void + onFilesScanned?: (count: number) => void, + profileDirs?: string[] ): Promise<{ processedFiles: ClaudeUsagePersistedFile[] sessions: ClaudeUsageSession[] dailyAggregates: ClaudeUsageDailyAggregate[] }> { - const files = await listClaudeTranscriptFiles() + const files = await listClaudeTranscriptFiles(profileDirs) const previousByPath = new Map(previousProcessedFiles.map((file) => [file.path, file])) const worktreeLookup = await buildWorktreeLookup(worktrees) diff --git a/src/main/claude-usage/transcript-file-discovery.ts b/src/main/claude-usage/transcript-file-discovery.ts index 7ca1e770cae7..3b3a6bf76bc3 100644 --- a/src/main/claude-usage/transcript-file-discovery.ts +++ b/src/main/claude-usage/transcript-file-discovery.ts @@ -1,3 +1,4 @@ +import { claudeProfileHistoryDirs } from '../claude-accounts/claude-profile-installed-router' import { homedir } from 'node:os' import { join } from 'node:path' import { readdir } from 'node:fs/promises' @@ -31,8 +32,15 @@ function appendDiscoveredFiles(target: string[], source: readonly string[]): voi } } -export async function listClaudeTranscriptFiles(): Promise { - const roots = [CLAUDE_PROJECTS_DIR, CLAUDE_TRANSCRIPTS_DIR] +/** Resolved by the host process: a scan worker has no account router of its own. */ +export function claudeProfileTranscriptDirs(): string[] { + return [...claudeProfileHistoryDirs('projects'), ...claudeProfileHistoryDirs('transcripts')] +} + +export async function listClaudeTranscriptFiles( + profileDirs = claudeProfileTranscriptDirs() +): Promise { + const roots = [CLAUDE_PROJECTS_DIR, CLAUDE_TRANSCRIPTS_DIR, ...profileDirs] const files = await Promise.all( roots.map(async (root) => { try { diff --git a/src/main/claude/claude-adapter-capture-replay.test.ts b/src/main/claude/claude-adapter-capture-replay.test.ts index 8e877a135a83..14cc590e2b2f 100644 --- a/src/main/claude/claude-adapter-capture-replay.test.ts +++ b/src/main/claude/claude-adapter-capture-replay.test.ts @@ -24,7 +24,8 @@ import type { import { fakeClaude, identityFor, - PROVIDER_SESSION_ID + PROVIDER_SESSION_ID, + claudeStartupSettled } from './claude-structured-session-test-support' type CapturedEvent = @@ -153,7 +154,7 @@ async function replayCapture(name: string): Promise { if (event.kind === 'dispatch') { if (!startedAwaited) { // The proof frames have been delivered by now; startup can settle. - await adapter.awaitStarted('session-1') + await claudeStartupSettled(adapter, 'session-1') startedAwaited = true } const body: AgentJournalMessageItem = { diff --git a/src/main/claude/claude-command-lifecycle.test.ts b/src/main/claude/claude-command-lifecycle.test.ts index f4182a601747..88aa6d4a12fb 100644 --- a/src/main/claude/claude-command-lifecycle.test.ts +++ b/src/main/claude/claude-command-lifecycle.test.ts @@ -23,7 +23,8 @@ import { fakeClaude, identityFor, PROVIDER_SESSION_ID, - type FakeConnection + type FakeConnection, + claudeStartupSettled } from './claude-structured-session-test-support' type CapturedEvent = @@ -195,7 +196,7 @@ async function replayCapture( proofDelivered = true } else if (event.kind === 'dispatch') { if (proofDelivered) { - await adapter.awaitStarted('session-1') + await claudeStartupSettled(adapter, 'session-1') } await expect( adapter.dispatch({ diff --git a/src/main/claude/claude-fold-steer-receipt.test.ts b/src/main/claude/claude-fold-steer-receipt.test.ts index 4726dc55d823..bc15a52cecd5 100644 --- a/src/main/claude/claude-fold-steer-receipt.test.ts +++ b/src/main/claude/claude-fold-steer-receipt.test.ts @@ -23,7 +23,8 @@ import type { ClaudeStructuredLaunch } from './claude-structured-launch-resoluti import { fakeClaude, identityFor, - PROVIDER_SESSION_ID + PROVIDER_SESSION_ID, + claudeStartupSettled } from './claude-structured-session-test-support' import { assistantText, @@ -100,7 +101,7 @@ async function riggedAdapter( } const adapter = new ClaudeStructuredSessionAdapter(deps) await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9', events: sink }) - await adapter.awaitStarted('session-1') + await claudeStartupSettled(adapter, 'session-1') return { adapter, claude, diff --git a/src/main/claude/claude-folder-trust-file.ts b/src/main/claude/claude-folder-trust-file.ts index 5ec9bccebccb..dc755bf3d2d5 100644 --- a/src/main/claude/claude-folder-trust-file.ts +++ b/src/main/claude/claude-folder-trust-file.ts @@ -37,9 +37,9 @@ type ClaudeConfigEnv = { // half-stale refresh timer stays inside setTimeout's 32-bit range. const NEVER_STALE_MS = 2 ** 30 const LOCK_RETRIES = { retries: 4, factor: 2, minTimeout: 50, maxTimeout: 250 } -// Why: concurrent grants in one process retry the file lock in lockstep, so a launch burst +// Why: concurrent updates in one process retry the file lock in lockstep, so a launch burst // would lose most of them to `locked`; queue them so only Claude itself contends for the lock. -const grantQueueByConfigFile = new Map>() +const updateQueueByConfigFile = new Map>() function pathApi(style: ClaudeTrustPathStyle): typeof posix { return style === 'win32' ? win32 : posix @@ -75,7 +75,7 @@ function isPlainObject(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } -export type ClaudeFolderTrustChange = +export type ClaudeGlobalConfigChange = | { kind: 'unchanged' } | { kind: 'refuse' } | { kind: 'changed'; config: Record } @@ -83,7 +83,7 @@ export type ClaudeFolderTrustChange = export function applyClaudeFolderTrust( config: Record, folderKeys: readonly string[] -): ClaudeFolderTrustChange { +): ClaudeGlobalConfigChange { if (config.projects !== undefined && !isPlainObject(config.projects)) { return { kind: 'refuse' } } @@ -193,30 +193,45 @@ function replaceConfig(replacement: ReplacementFile, config: Record].hasTrustDialogAccepted` in Claude's global config. Never - * creates the file, never breaks Claude's lock, and never rewrites a file it could - * not read and parse. - */ -export function grantClaudeFolderTrust(args: { +/** Sets `projects[].hasTrustDialogAccepted` in Claude's global config. */ +export async function grantClaudeFolderTrust(args: { configFile: string folderKeys: readonly string[] }): Promise { - return runKeyedSerializedOperation(grantQueueByConfigFile, args.configFile, () => - grantClaudeFolderTrustNow(args) + const outcome = await updateClaudeGlobalConfig(args.configFile, (config) => + applyClaudeFolderTrust(config, args.folderKeys) + ) + return outcome === 'updated' ? 'granted' : outcome +} + +export type ClaudeGlobalConfigUpdateOutcome = + | Exclude + | 'updated' + +/** + * Orca's one writer of a Claude global config. Never creates the file, never breaks Claude's + * lock, and never rewrites a file it could not read and parse. `change` must be pure: it runs + * once to plan and again under the lock. + */ +export function updateClaudeGlobalConfig( + configFile: string, + change: (config: Record) => ClaudeGlobalConfigChange +): Promise { + return runKeyedSerializedOperation(updateQueueByConfigFile, configFile, () => + updateClaudeGlobalConfigNow({ configFile, change }) ) } -async function grantClaudeFolderTrustNow(args: { +async function updateClaudeGlobalConfigNow(args: { configFile: string - folderKeys: readonly string[] -}): Promise { + change: (config: Record) => ClaudeGlobalConfigChange +}): Promise { const probe = readConfigAt(resolveConfigTarget(args.configFile)) if (typeof probe === 'string') { return probe } // Why: most launches need nothing, so skip Claude's lock unless a write is due. - const planned = applyClaudeFolderTrust(probe.config, args.folderKeys).kind + const planned = args.change(probe.config).kind if (planned !== 'changed') { return planned === 'refuse' ? 'unreadable' : 'unchanged' } @@ -249,7 +264,7 @@ async function grantClaudeFolderTrustNow(args: { if (current.path !== replacement.target) { return 'unreadable' } - const change = applyClaudeFolderTrust(current.config, args.folderKeys) + const change = args.change(current.config) if (change.kind === 'refuse') { return 'unreadable' } @@ -257,7 +272,7 @@ async function grantClaudeFolderTrustNow(args: { return 'unchanged' } replaceConfig(replacement, change.config) - return 'granted' + return 'updated' } finally { await release().catch(() => {}) } diff --git a/src/main/claude/claude-model-catalog-probe.test.ts b/src/main/claude/claude-model-catalog-probe.test.ts index 3f7792b33de6..e77d63d90b05 100644 --- a/src/main/claude/claude-model-catalog-probe.test.ts +++ b/src/main/claude/claude-model-catalog-probe.test.ts @@ -59,8 +59,8 @@ function listedResult(): DiscoverResult { } describe('claude model catalog probe', () => { - it.each([`"${process.execPath}"`, 'npx claude', '/missing/claude', './claude'])( - 'lists with the session executable for Command %s', + it.each([`"${process.execPath}"`, ''])( + 'lists with the session executable for Command %j', async (command) => { const deps = probeDeps(command) const captured: DiscoverInput[] = [] @@ -134,6 +134,16 @@ describe('claude model catalog probe', () => { await expect(probe('/homes/a')).rejects.toThrow(/listed no models/) }) + it.each(['npx claude', 'claude --verbose', '/missing/claude', './claude'])( + 'lists nothing, and never probes the stock CLI, for Command %s', + async (command) => { + const discover = vi.fn() + const probe = createClaudeModelCatalogProbe({ ...probeDeps(command), discover }) + await expect(probe('/homes/a')).rejects.toMatchObject({ reason: 'agentCommandNotRunnable' }) + expect(discover).not.toHaveBeenCalled() + } + ) + it.runIf(process.platform !== 'win32')( 'lists through a supervised one-shot that answers after its input ends', async () => { diff --git a/src/main/claude/claude-profile-hook-target.ts b/src/main/claude/claude-profile-hook-target.ts new file mode 100644 index 000000000000..6e206f480d96 --- /dev/null +++ b/src/main/claude/claude-profile-hook-target.ts @@ -0,0 +1,42 @@ +import { statSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import type { AgentHookInstallStatus } from '../../shared/agent-hook-types' +import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' +import { getConfigPath, type ClaudeCompatibleHookSettings } from './hook-settings' + +function sameFile(left: string, right: string): boolean { + if (resolve(left) === resolve(right)) { + return true + } + try { + // Why: file identity, not spelling, so links and case-only aliases both compare equal. + const leftStats = statSync(left, { bigint: true }) + const rightStats = statSync(right, { bigint: true }) + return leftStats.dev === rightStats.dev && leftStats.ino === rightStats.ino + } catch (error) { + // Why: only a definitive absence proves they differ; any other failure refuses. + return !isDefinitiveAbsence(error) + } +} + +/** A profile destination that is, or links into, the default home would edit System Default's hooks. */ +export function refuseProfileAtDefaultHome( + service: { agent: AgentHookInstallStatus['agent']; settings: ClaudeCompatibleHookSettings }, + configDir: string | undefined +): AgentHookInstallStatus | null { + if (configDir === undefined) { + return null + } + const configPath = getConfigPath(service.settings, configDir) + const defaultSettings = getConfigPath(service.settings) + if (!sameFile(configDir, dirname(defaultSettings)) && !sameFile(configPath, defaultSettings)) { + return null + } + return { + agent: service.agent, + state: 'error', + configPath, + managedHooksPresent: false, + detail: 'Profile settings resolve to the default home' + } +} diff --git a/src/main/claude/claude-profile-hooks.test.ts b/src/main/claude/claude-profile-hooks.test.ts new file mode 100644 index 000000000000..35db45340b0c --- /dev/null +++ b/src/main/claude/claude-profile-hooks.test.ts @@ -0,0 +1,155 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import type * as Os from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +const state = vi.hoisted(() => ({ home: '' })) +vi.mock('node:os', async (original) => ({ + ...(await original()), + homedir: () => state.home +})) +vi.mock('electron', () => ({ app: { getPath: () => state.home } })) +import { ClaudeHookService } from './hook-service' +import { provisionClaudeProfile } from '../claude-accounts/claude-profile-provisioning' + +// Case-only aliases exist only on a case-insensitive filesystem (default APFS, NTFS). +const caseInsensitive = (() => { + const dir = mkdtempSync(join(tmpdir(), 'claude-case-probe-')) + try { + mkdirSync(join(dir, 'probe')) + return existsSync(join(dir, 'PROBE')) + } finally { + rmSync(dir, { recursive: true, force: true }) + } +})() +const roots: string[] = [] +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) +const CURRENT = { claudeVersion: '2.1.261' } +function fixture() { + state.home = mkdtempSync(join(tmpdir(), 'claude-profile-hooks-')) + roots.push(state.home) + const defaultDir = join(state.home, '.claude') + const profile = join(state.home, 'profile') + mkdirSync(defaultDir) + mkdirSync(profile) + const settings = (dir: string): Record => + JSON.parse(readFileSync(join(dir, 'settings.json'), 'utf8')) + const edit = (dir: string, change: (value: Record) => void): void => { + const value = settings(dir) + change(value) + writeFileSync(join(dir, 'settings.json'), JSON.stringify(value)) + } + const service = new ClaudeHookService() + const installProfile = () => service.install({ ...CURRENT, configDir: profile }) + const provision = () => + provisionClaudeProfile({ profileHome: profile, userHome: state.home, platform: 'linux' }) + return { defaultDir, profile, settings, edit, service, installProfile, provision } +} + +function stopHooks(f: ReturnType) { + const hook = (command: string) => ({ matcher: '', hooks: [{ type: 'command', command }] }) + const stopOf = (value: Record): unknown[] => { + const hooks: unknown = value.hooks + return hooks && typeof hooks === 'object' && 'Stop' in hooks && Array.isArray(hooks.Stop) + ? hooks.Stop + : [] + } + const isOrca = (entry: unknown) => JSON.stringify(entry).includes('agent-hooks') + const setStop = (dir: string, commands: string[]) => + f.edit(dir, (value) => { + const hooks = typeof value.hooks === 'object' ? value.hooks : {} + value.hooks = { ...hooks, Stop: [...commands.map(hook), ...stopOf(value).filter(isOrca)] } + }) + const stop = (dir: string) => stopOf(f.settings(dir)) + const orcaCount = (dir: string) => stop(dir).filter(isOrca).length + return { hook, setStop, stop, orcaCount } +} +const sync = async (f: ReturnType) => { + const report = await f.provision() + expect(f.installProfile().state).toBe('installed') + return report +} + +// Why: these create real symlinks, which Windows needs privilege for. +const itLinks = it.skipIf(process.platform === 'win32') + +describe('Claude hooks at an explicit profile', () => { + it('installs managed hooks at a profile without editing default settings or adding a statusline', () => { + const f = fixture() + writeFileSync(join(f.defaultDir, 'settings.json'), '{"model":"default"}') + writeFileSync(join(f.profile, 'settings.json'), '{"model":"profile"}') + expect(f.service.install(CURRENT).state).toBe('installed') + const before = readFileSync(join(f.defaultDir, 'settings.json'), 'utf8') + const result = f.installProfile() + expect(result.configPath).toBe(join(f.profile, 'settings.json')) + expect(result.state).toBe('installed') + expect(readFileSync(join(f.defaultDir, 'settings.json'), 'utf8')).toBe(before) + expect(f.settings(f.profile)).toEqual({ model: 'profile', hooks: JSON.parse(before).hooks }) + }) + it("shares the user's hooks, Orca's hooks and statusline as plain keys that installs leave in sync", async () => { + const f = fixture() + const { hook, setStop, stop, orcaCount } = stopHooks(f) + f.service.install(CURRENT) + setStop(f.defaultDir, ['notify-me']) + await sync(f) + expect(f.settings(f.profile).hooks).toEqual(f.settings(f.defaultDir).hooks) + expect(f.settings(f.profile).statusLine).toEqual(f.settings(f.defaultDir).statusLine) + expect((await sync(f)).surfaces['settings.json']).toBe('unchanged') + setStop(f.defaultDir, ['notify-v2']) + await sync(f) + expect(stop(f.profile)[0]).toEqual(hook('notify-v2')) + expect(orcaCount(f.profile)).toBe(1) + setStop(f.profile, ['profile-only']) + setStop(f.defaultDir, ['notify-v3']) + await sync(f) + expect(stop(f.profile)[0]).toEqual(hook('profile-only')) + expect(orcaCount(f.profile)).toBe(1) + }) + it('carries a default-home statusline opt-out and an old-Claude retire to the profile', async () => { + const f = fixture() + f.service.install(CURRENT) + await sync(f) + expect(f.settings(f.profile).statusLine).toBeDefined() + f.edit(f.defaultDir, (value) => delete value.statusLine) + await sync(f) + expect(f.settings(f.profile).statusLine).toBeUndefined() + const old = fixture() + old.service.install(CURRENT) + await sync(old) + old.service.install({ claudeVersion: '1.0.0' }) + await old.provision() + old.service.install({ claudeVersion: '1.0.0', configDir: old.profile }) + expect(old.settings(old.defaultDir).statusLine).toBeUndefined() + expect(old.settings(old.profile).statusLine).toBeUndefined() + }) + itLinks('refuses a profile destination that is or links into the default home', () => { + const f = fixture() + f.service.install(CURRENT) + const defaults = readFileSync(join(f.defaultDir, 'settings.json'), 'utf8') + rmSync(join(f.profile, 'settings.json'), { force: true }) + symlinkSync(join(f.defaultDir, 'settings.json'), join(f.profile, 'settings.json')) + expect(f.installProfile().state).toBe('error') + expect(f.service.install({ ...CURRENT, configDir: f.defaultDir }).state).toBe('error') + expect(readFileSync(join(f.defaultDir, 'settings.json'), 'utf8')).toBe(defaults) + }) + it.runIf(caseInsensitive)('refuses a case-only alias of the default home', () => { + const f = fixture() + f.service.install(CURRENT) + const defaults = readFileSync(join(f.defaultDir, 'settings.json'), 'utf8') + const alias = join(state.home, '.CLAUDE') + expect(f.service.install({ ...CURRENT, configDir: alias }).state).toBe('error') + expect(readFileSync(join(f.defaultDir, 'settings.json'), 'utf8')).toBe(defaults) + }) +}) diff --git a/src/main/claude/claude-stream-json-connection-close.test.ts b/src/main/claude/claude-stream-json-connection-close.test.ts index c1078a82a57a..5bb0a3754745 100644 --- a/src/main/claude/claude-stream-json-connection-close.test.ts +++ b/src/main/claude/claude-stream-json-connection-close.test.ts @@ -27,13 +27,19 @@ vi.mock('./claude-agent-sdk-exit-proof', () => ({ proveClaudeChildExit: (...args: unknown[]) => mocks.proveClaudeChildExit(...args) })) -function fakeChild(): ChildProcessWithoutNullStreams { +function fakeChild( + streams: { stdout: PassThrough; stderr: PassThrough } = { + stdout: new PassThrough(), + stderr: new PassThrough() + } +): ChildProcessWithoutNullStreams { const child = new EventEmitter() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The connection reads only pid, the three pipes and kill, which this fake carries. return Object.assign(child, { pid: 424242, stdin: new PassThrough(), - stdout: new PassThrough(), - stderr: new PassThrough(), + stdout: streams.stdout, + stderr: streams.stderr, kill: vi.fn() }) as unknown as ChildProcessWithoutNullStreams } @@ -134,6 +140,52 @@ describe('Claude stream-json close ordering', () => { await expect(connection.close()).resolves.toBe(true) }) + // Stdout can end before the exit is seen, as when the provider supervisor's own exit closes it. + it('reports an exit whose stdout ended first once the exit is seen, with its usual reason', async () => { + mocks.refresh.mockReset() + mocks.proveClaudeChildExit.mockReset() + mocks.refresh.mockResolvedValue(undefined) + mocks.proveClaudeChildExit.mockResolvedValue(true) + const stdout = new PassThrough() + const stderr = new PassThrough() + const child = fakeChild({ stdout, stderr }) + // The SDK's stream ends with the child's stdout. + const next = vi + .fn<() => Promise>>>() + .mockResolvedValue({ value: undefined, done: true }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This injected query exercises only the async iterator used by the connection. + const queryImpl = ((params: Parameters[0]) => { + params.options?.spawnClaudeCodeProcess?.({ + command: 'claude', + args: [], + env: {}, + signal: new AbortController().signal + }) + return { + [Symbol.asyncIterator]: () => ({ next }) + } + }) as unknown as typeof query + const exits: string[] = [] + const connection = await openClaudeStreamJsonConnection( + { pathToClaudeCodeExecutable: 'claude', options: {}, cwd: '/work/repo' }, + { onExit: (error, exit) => exits.push(`${exit?.expected}: ${error.message}`) }, + () => child, + queryImpl + ) + + stderr.write('session limit reached\n') + stdout.end() + await vi.waitFor(() => expect(next).toHaveBeenCalledOnce()) + await new Promise((resolve) => setImmediate(resolve)) + expect(exits).toEqual([]) + + child.emit('exit', 1, null) + await vi.waitFor(() => + expect(exits).toEqual(['false: claude stream-json exited (code 1): session limit reached']) + ) + await connection.close() + }) + it('returns an unproven close without waiting on a live output reader', async () => { mocks.refresh.mockReset() mocks.proveClaudeChildExit.mockReset() diff --git a/src/main/claude/claude-stream-json-connection.test.ts b/src/main/claude/claude-stream-json-connection.test.ts index 5359b0577986..f9c61b9b8e4b 100644 --- a/src/main/claude/claude-stream-json-connection.test.ts +++ b/src/main/claude/claude-stream-json-connection.test.ts @@ -575,11 +575,11 @@ describe('Claude stream-json connection', () => { const init = { providerSessionId: SESSION_ID, uuid: null, model: null, message: {} } // With no ambient auth, every true below can only have come from the CLI's settings. - expect(claudeAuthDiagnostic(init, null)).toMatchObject({ + expect(claudeAuthDiagnostic(null, init, null)).toMatchObject({ baseUrlConfigured: false, authTokenConfigured: false }) - const diagnostic = claudeAuthDiagnostic(init, await connection.getSettings()) + const diagnostic = claudeAuthDiagnostic(null, init, await connection.getSettings()) expect(diagnostic).toMatchObject({ baseUrlConfigured: true, authTokenConfigured: true, diff --git a/src/main/claude/claude-structured-acquisition-launch.ts b/src/main/claude/claude-structured-acquisition-launch.ts index 4de526c7c4db..66f0bf0d6651 100644 --- a/src/main/claude/claude-structured-acquisition-launch.ts +++ b/src/main/claude/claude-structured-acquisition-launch.ts @@ -7,6 +7,10 @@ import { import { stopAgentSessionProviderRoot } from '../native-chat/agent-session-wire/structured-agent-session-provider-exit-proof' import { withAgentSessionCreatePhase } from '../observability/agent-session-instrumentation' import type { ClaudeStructuredLaunch } from './claude-structured-launch-resolution' +import { + claudeStructuredSpawnOptions, + type ClaudeStructuredSpawnOptions +} from './claude-structured-spawn-options' import { cancelClaudeAcquisitionAttempt, type ClaudeAcquisitionAttempt, @@ -21,6 +25,11 @@ import { closeClaudePublishedSessionForDeps } from './claude-structured-session-close' +/** The launch with the chat's saved options in its spawn options. */ +export type ClaudeAcquisitionLaunch = ClaudeStructuredLaunch & { + savedOptions: Omit +} + export async function resolveClaudeAcquisitionLaunch(args: { input: StructuredAgentSessionAcquireInput deps: ClaudeStructuredSessionAdapterDeps @@ -30,7 +39,7 @@ export async function resolveClaudeAcquisitionLaunch(args: { callbacks: ClaudeAcquireCallbacks previous: ClaudeAcquisitionAttempt | undefined attempt: ClaudeAcquisitionAttempt -}): Promise { +}): Promise { const { input, deps, sessions, acquisitions, exits, callbacks, previous, attempt } = args const sessionId = input.identity.sessionId return withAgentSessionCreatePhase('auth_settle', input.recordPhase, async () => { @@ -87,6 +96,15 @@ export async function resolveClaudeAcquisitionLaunch(args: { : new AgentSessionPreSpawnError(error) }) acquisitions.assertCurrent(sessionId, attempt) - return launch + const spawn = claudeStructuredSpawnOptions({ launch, saved: input.options }) + return { + ...launch, + options: spawn.sdkOptions, + savedOptions: { + options: spawn.options, + skipped: spawn.skipped, + fastModeAtStart: spawn.fastModeAtStart + } + } }) } diff --git a/src/main/claude/claude-structured-child-env.ts b/src/main/claude/claude-structured-child-env.ts new file mode 100644 index 000000000000..5f787ca60604 --- /dev/null +++ b/src/main/claude/claude-structured-child-env.ts @@ -0,0 +1,79 @@ +// What a structured Claude child's environment is built from, shared by its launch and the +// `--version` probe that runs the same binary. + +import { withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution' +import { applyClaudeEnvPatch } from '../claude-accounts/environment' +import { resolveClaudeCommand } from '../codex-cli/command' +import { withoutInheritedClaudeConfigDir } from './claude-config-dir-pin' +import type { ClaudeStructuredLaunchResolverDeps } from './claude-structured-launch-resolution' + +function cloneDefinedEnv(env: NodeJS.ProcessEnv | Record): Record { + const next: Record = {} + for (const [key, value] of Object.entries(env)) { + if (value !== undefined) { + next[key] = value + } + } + return next +} + +export type ClaudeEnvDeps = Pick< + ClaudeStructuredLaunchResolverDeps, + 'resolveCommand' | 'resolveEnv' | 'resolveInheritedEnv' +> + +/** What a child's env is built from, before any auth policy applies to it. */ +export type ClaudeChildEnvSources = { + command: string + overlay: Record | undefined + inheritedEnv: Record +} + +export async function resolveClaudeChildEnvSources( + deps: ClaudeEnvDeps +): Promise { + const command = (deps.resolveCommand ?? resolveClaudeCommand)() + const overlay = await deps.resolveEnv?.() + const inheritedEnv = deps.resolveInheritedEnv + ? await deps.resolveInheritedEnv() + : cloneDefinedEnv(process.env) + return { command, overlay: overlay ? cloneDefinedEnv(overlay) : undefined, inheritedEnv } +} + +// Why the overlay merges onto the inherited env rather than replacing it: the child +// still needs PATH and the rest of the shell environment, and withCliRuntimeOnPath +// derives PATH from what it is handed. Ambient Anthropic auth is stripped from the +// inherited half only when a managed account owns the credential; a system-auth +// user's own key is their sign-in and must reach the child. +export function claudeChildEnv( + sources: ClaudeChildEnvSources, + stripAuthEnv: boolean, + decorateEnv: (env: Record) => Record = (env) => env +): Record { + return withCliRuntimeOnPath( + sources.command, + decorateEnv({ + ...applyClaudeEnvPatch( + withoutInheritedClaudeConfigDir(sources.inheritedEnv, process.platform), + {}, + { stripAuthEnv, platform: process.platform } + ), + ...sources.overlay + }), + { platform: process.platform } + ) +} + +/** The env a `--version` probe runs with: the launch's own env, PATH and shims included, minus the + * Claude auth variables, auth-like custom headers and an inherited CLAUDE_CONFIG_DIR, which asking + * a version needs none of. Everything else is what this same binary receives at launch anyway. */ +export function claudeProbeEnv(sources: ClaudeChildEnvSources): Record { + return applyClaudeEnvPatch( + claudeChildEnv(sources, true), + {}, + { + stripAuthEnv: true, + platform: process.platform + } + ) +} diff --git a/src/main/claude/claude-structured-dispatch-test-support.ts b/src/main/claude/claude-structured-dispatch-test-support.ts index 5af0f17243b5..80a774e648bc 100644 --- a/src/main/claude/claude-structured-dispatch-test-support.ts +++ b/src/main/claude/claude-structured-dispatch-test-support.ts @@ -29,6 +29,8 @@ export function sessionFor(send: Mock = vi.fn().mockResolvedValue(undefined)): C reportedModelMutation: 0, confirmedOptions: new Set(), restoreSkippedOptions: new Set(), + launchedModel: null, + fastModeAtStart: false, capabilities: [], events: undefined, translator: null, 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 92cf562b9dcd..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 @@ -25,7 +25,8 @@ import { PROVIDER_SESSION_ID, fakeClaude, identityFor, - recordingJournalSink + recordingJournalSink, + claudeStartupSettled } from './claude-structured-session-test-support' const SESSION = 'session-1' @@ -120,7 +121,7 @@ async function startChild( events: recordingJournalSink(), ...(options ? { options } : {}) }) - await adapter.awaitStarted(SESSION) + await claudeStartupSettled(adapter, SESSION) return adapter } diff --git a/src/main/claude/claude-structured-effort-reporting.test.ts b/src/main/claude/claude-structured-effort-reporting.test.ts index 2c319c86ee72..3305a40d77a3 100644 --- a/src/main/claude/claude-structured-effort-reporting.test.ts +++ b/src/main/claude/claude-structured-effort-reporting.test.ts @@ -1,9 +1,6 @@ import { describe, expect, it } from 'vitest' import { AgentSessionOptionRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-option-error' -import { - restoreClaudeStructuredSessionOptions, - setClaudeStructuredOption -} from './claude-structured-options' +import { setClaudeStructuredOption } from './claude-structured-options' import { readClaudeSettingsEffort } from './claude-structured-session-options' import type { ClaudeSession } from './claude-structured-session-state' import type { ClaudeStructuredSessionEvent } from './claude-structured-session-adapter' @@ -238,29 +235,4 @@ describe('Claude effort against the model that must run it', () => { setClaudeStructuredOption(session, { key: 'effort', value: 'high' }, undefined) ).rejects.toBeInstanceOf(AgentSessionOptionRejectedError) }) - - it('keeps a disagreeing effort through restore instead of skipping it', async () => { - const calls: string[] = [] - const { session } = sessionWith('high', calls, { model: 'sonnet', catalog: [SONNET] }) - session.options.set('effort', 'low') - - await restoreClaudeStructuredSessionOptions(session, undefined) - - expect(session.options.get('effort')).toBe('low') - expect(session.restoreSkippedOptions.has('effort')).toBe(false) - expect(session.confirmedOptions.has('effort')).toBe(false) - }) - - it('drops a stale effort on restore instead of replaying it onto the new model', async () => { - const calls: string[] = [] - const { session } = sessionWith('high', calls, { model: 'sonnet', catalog: [HAIKU, SONNET] }) - session.options.set('model', 'haiku') - session.options.set('effort', 'high') - - await restoreClaudeStructuredSessionOptions(session, undefined) - - expect(session.options.has('effort')).toBe(false) - expect(session.restoreSkippedOptions.has('effort')).toBe(true) - expect(calls.filter((call) => call.startsWith('apply:'))).toEqual([]) - }) }) diff --git a/src/main/claude/claude-structured-event-delivery.ts b/src/main/claude/claude-structured-event-delivery.ts index 6db9f38b48a3..9e543a37a658 100644 --- a/src/main/claude/claude-structured-event-delivery.ts +++ b/src/main/claude/claude-structured-event-delivery.ts @@ -1,4 +1,8 @@ import { claudePromptCardWritten } from './claude-child-work-evidence' +import { + resetClaudeRetiredModel, + retireClaudeLaunchedModel +} from './claude-structured-retired-model' import type { ClaudeSession, ClaudeStructuredSessionAdapterDeps, @@ -8,7 +12,7 @@ import type { type ClaudeEventDelivery = { session: ClaudeSession | null event: ClaudeStructuredSessionEvent - deps: Pick + deps: Pick publishChildWork: ( sessionId: string, session?: ClaudeSession | null, @@ -38,6 +42,18 @@ export function emitClaudeStructuredSessionEvent({ } session?.translator?.handle(event) deps.onEvent?.(event) + const retired = + event.type === 'message' && session ? retireClaudeLaunchedModel(session, event.message) : null + if (session && retired !== null) { + deps.onEvent?.({ + type: 'options-skipped', + sessionId: event.sessionId, + fence: session.fence, + acquisitionGeneration: session.acquisitionGeneration, + options: { model: retired } + }) + resetClaudeRetiredModel(session, deps, event.sessionId, retired) + } publishChildWork(event.sessionId, session, event.type === 'message' ? event.message : null) // A prompt blocks its child only after the journal has written its card. void claudePromptCardWritten(session, event)?.then(() => publishChildWork(event.sessionId)) diff --git a/src/main/claude/claude-structured-init-proof.ts b/src/main/claude/claude-structured-init-proof.ts index e138abd3641e..869bc4535776 100644 --- a/src/main/claude/claude-structured-init-proof.ts +++ b/src/main/claude/claude-structured-init-proof.ts @@ -84,11 +84,16 @@ export function claudeInitializationAuthError( } export function claudeAuthDiagnostic( - init: ClaudeInitObservation, + initialization: unknown, + init: ClaudeInitObservation | null, settings: unknown ): ClaudeAuthDiagnostic { const env = isRecord(settings) && isRecord(settings.env) ? settings.env : {} - const apiKeySource = readClaudeFrameString(init.message, 'apiKeySource') + const account = + isRecord(initialization) && isRecord(initialization.account) ? initialization.account : {} + const apiKeySource = + readClaudeFrameString(account, 'apiKeySource') ?? + (init ? readClaudeFrameString(init.message, 'apiKeySource') : null) const configured = (key: string): boolean => (typeof env[key] === 'string' && (env[key] as string).trim().length > 0) || Boolean(process.env[key]?.trim()) diff --git a/src/main/claude/claude-structured-launch-account-routing.test.ts b/src/main/claude/claude-structured-launch-account-routing.test.ts new file mode 100644 index 000000000000..031559a44387 --- /dev/null +++ b/src/main/claude/claude-structured-launch-account-routing.test.ts @@ -0,0 +1,144 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +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_MISSING_MESSAGE, + ClaudeProfileRouter, + type ClaudeProfileRouterSettings +} from '../claude-accounts/claude-profile-router' +import { installClaudeProfileRouter } from '../claude-accounts/claude-profile-installed-router' +import { createClaudeStructuredLaunchResolver } from './claude-structured-launch-resolution' + +const roots: string[] = [] +afterEach(() => { + installClaudeProfileRouter(undefined) + roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true })) +}) + +it('launches each acquisition under the current selection, not the account it was created under', async () => { + const root = mkdtempSync(join(tmpdir(), 'claude-launch-routing-')) + roots.push(root) + const settings: ClaudeProfileRouterSettings = { + claudeManagedAccounts: [], + activeClaudeManagedAccountId: 'a', + activeClaudeManagedAccountIdsByRuntime: undefined, + agentStatusHooksEnabled: false, + disabledTuiAgents: [] + } + const router = new ClaudeProfileRouter({ + getSettings: () => settings, + dataRoot: root, + userHome: join(root, 'personal'), + env: { CLAUDE_CONFIG_DIR: resolvePath('/user/own') } + }) + installClaudeProfileRouter(router) + const record = { + ...agentSessionRecordFixture(), + providerHandleChain: [], + accountHome: { variable: 'CLAUDE_CONFIG_DIR' as const, path: '/created/under/b' } + } + const resolve = createClaudeStructuredLaunchResolver({ + store: { getRecord: () => record, pinLaunchDirectory: vi.fn() }, + resolveLaunchArgs: () => [], + resolveWorkspacePath: async (id) => `/repos/${id}`, + resolveCommand: () => '/usr/local/bin/claude', + resolveInheritedEnv: async () => ({ PATH: '/usr/bin' }), + resolveAuthPolicy: () => ({ stripAuthEnv: false }) + }) + const identity = { + sessionId: record.sessionId, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'claude' as const, + providerHandle: claudeProviderHandle('unused', null) + } + + await expect(resolve({ identity })).rejects.toThrow(CLAUDE_PROFILE_MISSING_MESSAGE) + + const home = join(root, 'claude-profiles', 'a', 'home') + mkdirSync(home, { recursive: true }) + // Already set up, so the launch does not wait for setup. + writeFileSync(join(root, 'claude-profiles', 'a', 'profile.json'), '{}') + const routed = await resolve({ identity }) + expect(routed.claudeConfigDir).toBe(home) + expect(routed.env).toMatchObject({ + CLAUDE_CONFIG_DIR: home, + ORCA_CLAUDE_INJECTED_CONFIG_DIR: home + }) + + settings.activeClaudeManagedAccountId = null + expect((await resolve({ identity })).claudeConfigDir).toBe(resolvePath('/user/own')) +}) + +function routedResumeFixture() { + const root = mkdtempSync(join(tmpdir(), 'claude-launch-routing-')) + roots.push(root) + const home = join(root, 'claude-profiles', 'a', 'home') + mkdirSync(home, { recursive: true }) + writeFileSync(join(root, 'claude-profiles', 'a', 'profile.json'), '{}') + installClaudeProfileRouter( + new ClaudeProfileRouter({ + getSettings: () => ({ + claudeManagedAccounts: [], + activeClaudeManagedAccountId: 'a', + activeClaudeManagedAccountIdsByRuntime: undefined, + agentStatusHooksEnabled: false, + disabledTuiAgents: [] + }), + dataRoot: root, + userHome: join(root, 'personal'), + env: {} + }) + ) + const handle = claudeProviderHandle('ran-under-b', 'leaf-1') + const record = { + ...agentSessionRecordFixture(), + providerHandleChain: [ + { linkId: 'link-1', origin: 'created' as const, mintedAtFence: 1, observedAt: 1, handle } + ] + } + const transcriptHomes = new Set() + const resolve = createClaudeStructuredLaunchResolver({ + store: { getRecord: () => record, pinLaunchDirectory: vi.fn() }, + resolveLaunchArgs: () => [], + resolveWorkspacePath: async (id) => `/repos/${id}`, + resolveCommand: () => '/usr/local/bin/claude', + resolveInheritedEnv: async () => ({ PATH: '/usr/bin' }), + resolveAuthPolicy: () => ({ stripAuthEnv: false }), + hasTranscript: async ({ claudeConfigDir }) => transcriptHomes.has(claudeConfigDir) + }) + const identity = { + sessionId: record.sessionId, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'claude' as const, + providerHandle: handle + } + return { root, home, transcriptHomes, launch: () => resolve({ identity }) } +} + +it('refuses to resume a chat whose transcript is in another account instead of starting fresh', async () => { + const { root, home, transcriptHomes, launch } = routedResumeFixture() + transcriptHomes.add(join(root, 'personal', '.claude')) + + await expect(launch()).rejects.toMatchObject({ + name: 'AgentSessionPreSpawnError', + reason: 'historyInOtherAccount' + }) + + transcriptHomes.add(home) + const resumed = await launch() + expect(resumed.options).toMatchObject({ resume: 'ran-under-b' }) + expect(resumed.resumeLeafUuid).toBe('leaf-1') +}) + +it('resumes a chat with a stored leaf whose transcript is in no known folder', async () => { + const { launch } = routedResumeFixture() + + const resumed = await launch() + expect(resumed.options).toMatchObject({ resume: 'ran-under-b' }) + expect(resumed.resumeLeafUuid).toBe('leaf-1') +}) diff --git a/src/main/claude/claude-structured-launch-home.ts b/src/main/claude/claude-structured-launch-home.ts new file mode 100644 index 000000000000..0df1b2f599c1 --- /dev/null +++ b/src/main/claude/claude-structured-launch-home.ts @@ -0,0 +1,78 @@ +import { join } from 'node:path' +import type { ClaudeProfileRouter } from '../claude-accounts/claude-profile-router' +import { applyClaudeEnvPatch } from '../claude-accounts/environment' +import { AgentSessionPreSpawnError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { resolveSessionFilePath } from '../native-chat/session-file-resolver' +import { resolveStructuredClaudeAccountHomePath } from '../runtime/structured-agent-account-home' + +/** + * The folder a structured launch runs under, and the env patch that pins it there. + * With account routing every launch follows the current selection, as a typed `claude` does. + */ +export async function resolveClaudeStructuredLaunchHome( + router: ClaudeProfileRouter | undefined, + env: Record, + recordHome: string +): Promise { + if (!router) { + return recordHome + } + const preparation = await router.prepareLaunch() + // Why read before the patch: System default keeps the launch env's own CLAUDE_CONFIG_DIR. + const launchHome = + preparation.provenance === 'system' + ? resolveStructuredClaudeAccountHomePath({ + launchEnv: env, + wslDistro: null, + getClaudeConfigDirectory: () => router.systemDefaultHome() + }) + : preparation.configDir + applyClaudeEnvPatch(env, preparation.envPatch) + return launchHome +} + +/** Whether Claude wrote a transcript for this id under the given config folder. */ +export async function claudeTranscriptExists(input: { + providerSessionId: string + claudeConfigDir: string +}): Promise { + const path = await resolveSessionFilePath('claude', input.providerSessionId, { + claudeProjectsDir: join(input.claudeConfigDir, 'projects') + }) + return path !== null +} + +/** + * Whether a chat with a recorded session resumes it. Unrouted, a stored leaf proves a transcript; + * routed, the selected account may not be the one holding it, and starting fresh would drop the chat. + */ +export async function claudeLaunchResumesTranscript(input: { + router: ClaudeProfileRouter | undefined + leafUuid: string | null + providerSessionId: string + claudeConfigDir: string + hasTranscript?: typeof claudeTranscriptExists +}): Promise { + const { router, providerSessionId, claudeConfigDir } = input + if (!router && input.leafUuid !== null) { + return true + } + const hasTranscript = input.hasTranscript ?? claudeTranscriptExists + if (await hasTranscript({ providerSessionId, claudeConfigDir })) { + return true + } + if (input.leafUuid === null) { + return false + } + const otherHomes = router ? [...router.accountHomes(), router.systemDefaultHome()] : [] + for (const home of otherHomes.filter((home) => home !== claudeConfigDir)) { + if (await hasTranscript({ providerSessionId, claudeConfigDir: home })) { + throw new AgentSessionPreSpawnError( + new Error('claude transcript is not in the selected account'), + { reason: 'historyInOtherAccount' } + ) + } + } + // Found nowhere Orca knows of (e.g. a folder since removed): the stored leaf still says it ran. + return true +} diff --git a/src/main/claude/claude-structured-launch-resolution.ts b/src/main/claude/claude-structured-launch-resolution.ts index 4b03c9410186..de64c7e648d1 100644 --- a/src/main/claude/claude-structured-launch-resolution.ts +++ b/src/main/claude/claude-structured-launch-resolution.ts @@ -1,5 +1,5 @@ +import { getClaudeProfileRouter } from '../claude-accounts/claude-profile-installed-router' import { createHash } from 'node:crypto' -import { join } from 'node:path' import type { Options as ClaudeAgentSdkOptions, PermissionMode @@ -11,12 +11,10 @@ import { } from '../../shared/agent-session-provider-handle' import { claudeProviderHandleLeafUuid } from '../../shared/agent-session-provider-handle-encoding' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' -import { withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution' import { structuredSessionChildIdentityEnv } from '../runtime/structured-session-child-identity-env' import { CLAUDE_AUTH_ENV_CONFLICT_MESSAGE, CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE, - applyClaudeEnvPatch, hasClaudeAuthEnvConflict } from '../claude-accounts/environment' import type { ClaudeStructuredAuthPolicy } from '../claude-accounts/claude-structured-auth-policy' @@ -30,10 +28,18 @@ import { structuredClaudeMatchesActiveManagedAccount, type ClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support' -import { resolveClaudeCommand } from '../codex-cli/command' -import { resolveSessionFilePath } from '../native-chat/session-file-resolver' -import { withoutInheritedClaudeConfigDir } from './claude-config-dir-pin' +import { + claudeChildEnv, + claudeProbeEnv, + resolveClaudeChildEnvSources, + type ClaudeChildEnvSources, + type ClaudeEnvDeps +} from './claude-structured-child-env' import { claudeStructuredLaunchArgs } from './claude-structured-launch-args' +import { + claudeLaunchResumesTranscript, + resolveClaudeStructuredLaunchHome +} from './claude-structured-launch-home' import type { ClaudeThinkingDisplaySupport } from './claude-thinking-display-support' import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' import { resolveAgentSessionLaunchDirectory } from '../runtime/agent-session-launch-directory' @@ -54,6 +60,7 @@ export type ClaudeStructuredSdkOptions = Pick< | 'effort' | 'permissionMode' | 'allowDangerouslySkipPermissions' + | 'settings' | 'sessionId' | 'resume' > @@ -75,16 +82,6 @@ export const CLAUDE_STRUCTURED_BASE_OPTIONS: ClaudeStructuredSdkOptions = { extraArgs: { 'replay-user-messages': null } } -function cloneDefinedEnv(env: NodeJS.ProcessEnv | Record): Record { - const next: Record = {} - for (const [key, value] of Object.entries(env)) { - if (value !== undefined) { - next[key] = value - } - } - return next -} - /** * Agent Permissions as query-start options. * @@ -147,79 +144,8 @@ export type ClaudeStructuredLaunchResolverDeps = { }) => Promise } -async function claudeTranscriptExists(input: { - providerSessionId: string - claudeConfigDir: string -}): Promise { - const path = await resolveSessionFilePath('claude', input.providerSessionId, { - claudeProjectsDir: join(input.claudeConfigDir, 'projects') - }) - return path !== null -} - export type ClaudeStructuredInvocation = { command: string; env: Record } -type ClaudeEnvDeps = Pick< - ClaudeStructuredLaunchResolverDeps, - 'resolveCommand' | 'resolveEnv' | 'resolveInheritedEnv' -> - -/** What a child's env is built from, before any auth policy applies to it. */ -export type ClaudeChildEnvSources = { - command: string - overlay: Record | undefined - inheritedEnv: Record -} - -export async function resolveClaudeChildEnvSources( - deps: ClaudeEnvDeps -): Promise { - const command = (deps.resolveCommand ?? resolveClaudeCommand)() - const overlay = await deps.resolveEnv?.() - const inheritedEnv = deps.resolveInheritedEnv - ? await deps.resolveInheritedEnv() - : cloneDefinedEnv(process.env) - return { command, overlay: overlay ? cloneDefinedEnv(overlay) : undefined, inheritedEnv } -} - -// Why the overlay merges onto the inherited env rather than replacing it: the child -// still needs PATH and the rest of the shell environment, and withCliRuntimeOnPath -// derives PATH from what it is handed. Ambient Anthropic auth is stripped from the -// inherited half only when a managed account owns the credential; a system-auth -// user's own key is their sign-in and must reach the child. -function claudeChildEnv( - sources: ClaudeChildEnvSources, - stripAuthEnv: boolean, - decorateEnv: (env: Record) => Record = (env) => env -): Record { - return withCliRuntimeOnPath( - sources.command, - decorateEnv({ - ...applyClaudeEnvPatch( - withoutInheritedClaudeConfigDir(sources.inheritedEnv, process.platform), - {}, - { stripAuthEnv, platform: process.platform } - ), - ...sources.overlay - }), - { platform: process.platform } - ) -} - -/** The env a `--version` probe runs with: the launch's own env, PATH and shims included, minus the - * Claude auth variables, auth-like custom headers and an inherited CLAUDE_CONFIG_DIR, which asking - * a version needs none of. Everything else is what this same binary receives at launch anyway. */ -export function claudeProbeEnv(sources: ClaudeChildEnvSources): Record { - return applyClaudeEnvPatch( - claudeChildEnv(sources, true), - {}, - { - stripAuthEnv: true, - platform: process.platform - } - ) -} - /** * The one place a structured Claude child's binary and environment are * resolved. The session launch and the session-less catalog probe both build @@ -307,7 +233,8 @@ export function createClaudeStructuredLaunchResolver( // 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 gate = deps.readManagedAccountGate?.() + 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( @@ -337,21 +264,26 @@ export function createClaudeStructuredLaunchResolver( cwd, env: claudeProbeEnv(sources) }) - // 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 resumesTranscript = - head !== null && - (claudeProviderHandleLeafUuid(head) !== null || - (await (deps.hasTranscript ?? claudeTranscriptExists)({ - providerSessionId, - claudeConfigDir: record.accountHome.path - }))) const configured = claudeStructuredLaunchArgs(await deps.resolveLaunchArgs()) const { additionalDirectories } = configured const permission = claudeStructuredPermissionOptions( (await deps.resolvePermissionMode?.()) ?? 'default' ) const thinkingDisplayArgs = (await thinkingDisplay) ?? {} + // 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(record.accountHome.path) // Last: it rechecks the account switch, which may have begun during any await above. const { command, env } = await resolveClaudeStructuredInvocation( deps, @@ -364,6 +296,8 @@ export function createClaudeStructuredLaunchResolver( }), sources ) + const launchHome = await resolveClaudeStructuredLaunchHome(router, env, record.accountHome.path) + const resumesTranscript = resumedWithoutRouter ?? (await resumes(launchHome)) return { pathToClaudeCodeExecutable: command, options: { @@ -381,9 +315,9 @@ export function createClaudeStructuredLaunchResolver( }, cwd, env, - claudeConfigDir: record.accountHome.path, + claudeConfigDir: launchHome, providerSessionId, - resumeLeafUuid: resumesTranscript && head ? claudeProviderHandleLeafUuid(head) : null, + resumeLeafUuid: resumesTranscript ? leafUuid : null, resumesTranscript, continuesChain } diff --git a/src/main/claude/claude-structured-model-preflight.test.ts b/src/main/claude/claude-structured-model-preflight.test.ts index 930947e112db..a44ca0232b87 100644 --- a/src/main/claude/claude-structured-model-preflight.test.ts +++ b/src/main/claude/claude-structured-model-preflight.test.ts @@ -1,9 +1,6 @@ import { describe, expect, it } from 'vitest' import { AgentSessionOptionRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-option-error' -import { - restoreClaudeStructuredSessionOptions, - setClaudeStructuredOption -} from './claude-structured-options' +import { setClaudeStructuredOption } from './claude-structured-options' import type { ClaudeSession } from './claude-structured-session-state' /** Verbatim row shapes from Claude Code 2.1.260's list_models response. */ @@ -58,18 +55,6 @@ describe('Claude model pre-flight against the catalog the CLI listed', () => { expect(session.options.has('model')).toBe(false) }) - it('refuses an unlisted model replayed by restore, and skips it', async () => { - // Needs no user error: a model valid when it was persisted can be retired. - const { session, calls } = sessionWith([DEFAULT_ROW, SONNET]) - session.options.set('model', 'claude-opus-4-retired') - - await restoreClaudeStructuredSessionOptions(session, undefined) - - expect(calls).toEqual(['list_models']) - expect(session.options.has('model')).toBe(false) - expect([...session.restoreSkippedOptions]).toEqual(['model']) - }) - it('applies a model the provider lists', async () => { const { session, calls } = sessionWith([DEFAULT_ROW, SONNET, HAIKU]) @@ -89,8 +74,7 @@ describe('Claude model pre-flight against the catalog the CLI listed', () => { }) it('refuses nothing when list_models is unavailable', async () => { - // A CLI predating list_models would otherwise have every model refused, and - // restore swallows the rejection, so the user's pick would vanish silently. + // A CLI predating list_models would otherwise have every model refused. const { session, calls } = sessionWith('unavailable') await expect( @@ -118,15 +102,4 @@ describe('Claude model pre-flight against the catalog the CLI listed', () => { ).resolves.toEqual({ model: 'sonnet' }) expect(calls).toEqual(['list_models', 'set_model:sonnet']) }) - - it('leaves a restored model the provider lists in place', async () => { - const { session, calls } = sessionWith([DEFAULT_ROW, SONNET]) - session.options.set('model', 'sonnet') - - await restoreClaudeStructuredSessionOptions(session, undefined) - - expect(calls).toEqual(['list_models', 'set_model:sonnet']) - expect(session.options.get('model')).toBe('sonnet') - expect([...session.restoreSkippedOptions]).toEqual([]) - }) }) diff --git a/src/main/claude/claude-structured-options-context-window.test.ts b/src/main/claude/claude-structured-options-context-window.test.ts index 200bb0830690..fb901fb90852 100644 --- a/src/main/claude/claude-structured-options-context-window.test.ts +++ b/src/main/claude/claude-structured-options-context-window.test.ts @@ -4,10 +4,8 @@ import { selectStructuredAgentContextUsage } from '../../shared/structured-agent import { assistantFrame, journal, userFrame } from './claude-context-usage-test-support' import { sessionFor } from './claude-structured-dispatch-test-support' import { createClaudeJournalTranslator } from './claude-structured-journal-translation' -import { - restoreClaudeStructuredSessionOptions, - setClaudeStructuredOption -} from './claude-structured-options' +import { setClaudeStructuredOption } from './claude-structured-options' +import { adoptClaudeStructuredSpawnOptions } from './claude-structured-spawn-options' import type { ClaudeSession } from './claude-structured-session-state' function ringSession(catalog: unknown[] = []) { @@ -38,10 +36,13 @@ function ringSession(catalog: unknown[] = []) { } describe('the context ring after a session option write', () => { - it('sizes a new session from the model its restore applied', async () => { - const s = ringSession([{ value: 'opus[1m]', displayName: 'Opus (1M)' }]) - s.session.options.set('model', 'opus[1m]') - await restoreClaudeStructuredSessionOptions(s.session, undefined) + it('sizes a new session from the model it was launched with', () => { + const s = ringSession() + adoptClaudeStructuredSpawnOptions(s.session, { + options: new Map([['model', 'opus[1m]']]), + skipped: [], + fastModeAtStart: false + }) expect(s.respond('turn-a', 1_000)).toMatchObject({ windowTokens: 1_000_000, percentage: 10 }) }) @@ -53,15 +54,18 @@ describe('the context ring after a session option write', () => { expect(s.respond('turn-b', 2_000)).toMatchObject({ windowTokens: 1_000_000, percentage: 10 }) }) - it('implies no window for a model the child refused or a restore could not put back', async () => { + it('implies no window for a model the child refused or a mode the launch left out', async () => { const refused = ringSession() refused.setModel.mockRejectedValueOnce(new ClaudeControlRequestError('set_model', 'refused')) await expect(refused.write('model', 'opus[1m]')).rejects.toThrow() expect(refused.respond('turn-a', 1_000)).toBeNull() - const skipped = ringSession([{ value: 'sonnet', displayName: 'Sonnet' }]) - skipped.session.options.set('model', 'retired-model[1m]') - await restoreClaudeStructuredSessionOptions(skipped.session, undefined) + const skipped = ringSession() + adoptClaudeStructuredSpawnOptions(skipped.session, { + options: new Map([['model', 'sonnet[1m]']]), + skipped: ['permissionMode'], + fastModeAtStart: false + }) expect(skipped.respond('turn-a', 1_000)).toBeNull() }) diff --git a/src/main/claude/claude-structured-options.test.ts b/src/main/claude/claude-structured-options.test.ts index e5b7a7ea8a77..2843d4f7ddd7 100644 --- a/src/main/claude/claude-structured-options.test.ts +++ b/src/main/claude/claude-structured-options.test.ts @@ -1,19 +1,12 @@ import { describe, expect, it, vi } from 'vitest' -import { - restoreClaudeStructuredSessionOptions, - setClaudeStructuredOption -} from './claude-structured-options' +import { setClaudeStructuredOption } from './claude-structured-options' import type { ClaudeSession } from './claude-structured-session-state' -import { - ClaudeControlRequestError, - ClaudeControlRequestTimeoutError -} from './claude-agent-sdk-control-requests' +import { ClaudeControlRequestTimeoutError } from './claude-agent-sdk-control-requests' import { ClaudeBackgroundTaskTracker } from './claude-background-task-tracker' import { ClaudeChildWorkDecoder } from './claude-child-work-decoder' import { ClaudeSlashCommandCatalog } from './claude-slash-command-catalog' import { createClaudeSessionStartup } from './claude-structured-session-startup-state' import { - claudeStructuredSessionOptionsFrom, observeClaudeFastModeFacts, readClaudeStructuredSessionOptions } from './claude-structured-session-options' @@ -45,6 +38,8 @@ function sessionFor(setModel: ClaudeSession['connection']['setModel']): ClaudeSe reportedModelMutation: 0, confirmedOptions: new Set(), restoreSkippedOptions: new Set(), + launchedModel: null, + fastModeAtStart: false, capabilities: [], events: undefined, translator: null, @@ -145,8 +140,7 @@ describe('Claude structured Fast mode', () => { } ) - // Turning Fast off needs no support evidence, so it must not pay a catalog round - // trip — restore replays a stored `false` on every acquire. + // Turning Fast off needs no support evidence, so it must not pay a catalog round trip. it('reads no catalog to turn Fast off, but does to turn it on', async () => { const { session } = fastModeSession(true) const listed = session.connection.supportedModels @@ -167,17 +161,6 @@ describe('Claude structured Fast mode', () => { expect(reads).toBe(1) }) - it('restores explicit Fast off when model support is unknown', async () => { - const { session, applyFlagSettings } = fastModeSession(undefined) - session.options.set('fastMode', 'false') - - await restoreClaudeStructuredSessionOptions(session, undefined) - - expect(session.options.get('fastMode')).toBe('false') - expect(session.restoreSkippedOptions.has('fastMode')).toBe(false) - expect(applyFlagSettings).toHaveBeenCalledWith({ fastMode: false }, { timeoutMs: undefined }) - }) - it('resolves the running CLI default model before applying Fast', async () => { const { session, applyFlagSettings } = fastModeSession(true) session.options.delete('model') @@ -439,37 +422,7 @@ describe('Claude Fast mode reported by the session frame alone', () => { }) }) -describe('Claude structured option restore under the request deadline', () => { - it('keeps a saved choice the CLI never answered as wanted but unconfirmed, and drops a refused one', async () => { - const session = sessionFor(async () => { - throw new ClaudeControlRequestTimeoutError('set_model') - }) - session.connection.applyFlagSettings = async () => { - throw new ClaudeControlRequestTimeoutError('apply_flag_settings') - } - session.connection.setPermissionMode = async () => { - throw new ClaudeControlRequestError('set_permission_mode', 'unknown mode') - } - // Startup already read the CLI's own model and effort, and vouched for them. - session.reportedOptions = { model: 'claude-sonnet-5', effort: 'medium' } - session.confirmedOptions.add('effort') - session.options = new Map([ - ['model', 'sonnet'], - ['effort', 'high'], - ['permissionMode', 'plan'] - ]) - vi.spyOn(console, 'warn').mockImplementation(() => {}) - - await expect(restoreClaudeStructuredSessionOptions(session, 10)).resolves.toBeUndefined() - - expect(Object.fromEntries(session.options)).toEqual({ model: 'sonnet', effort: 'high' }) - expect([...session.restoreSkippedOptions]).toEqual(['permissionMode']) - expect(claudeStructuredSessionOptionsFrom(session, null).current).toEqual({ - model: 'sonnet', - effort: 'high' - }) - }) - +describe('Claude structured option write under the request deadline', () => { it("keeps a timed-out client write as the deadline's own error, not a rejection", async () => { const session = sessionFor(async () => { throw new ClaudeControlRequestTimeoutError('set_model') diff --git a/src/main/claude/claude-structured-options.ts b/src/main/claude/claude-structured-options.ts index 2f0b7ac4b707..15f33ea1b01f 100644 --- a/src/main/claude/claude-structured-options.ts +++ b/src/main/claude/claude-structured-options.ts @@ -1,10 +1,6 @@ import type { EffortLevel, PermissionMode } from '@anthropic-ai/claude-agent-sdk' import { ClaudeControlRequestError } from './claude-stream-json-connection' -import { ClaudeControlRequestTimeoutError } from './claude-agent-sdk-control-requests' -import { - AgentSessionOptionRejectedError, - isAgentSessionOptionRejectedError -} from '../native-chat/agent-session-wire/structured-agent-session-option-error' +import { AgentSessionOptionRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-option-error' import { claudeCatalogAdmitsModel, claudeModelEffortLevels, @@ -48,7 +44,7 @@ export function isClaudeStructuredOptionKey(key: string): boolean { return CLAUDE_STRUCTURED_OPTION_KEYS.has(key) } -/** A client's write; the startup restore writes through `setClaudeStructuredOption` directly. */ +/** A client's write to a live session. */ export function setClaudeStructuredSessionOption( session: ClaudeSession, input: { key: string; value: string }, @@ -69,9 +65,7 @@ export function setClaudeStructuredSessionOption( export async function setClaudeStructuredOption( session: ClaudeSession, input: { key: string; value: string }, - timeoutMs: number | undefined, - /** What the key held before a restore cleared the map; a live write reads the map. */ - heldBeforeRestore?: string + timeoutMs: number | undefined ): Promise>> { const fastMode = input.key === 'fastMode' @@ -99,17 +93,15 @@ export async function setClaudeStructuredOption( // One read answers every catalog question this write asks, so the guards below // cannot each pay a round trip for the same list nor disagree about the model. // Two writes ask nothing of it and so read nothing: an effort write with no current - // model has nothing to look up, and turning Fast off needs no support evidence — - // which is every restore replaying a stored `false`. + // model has nothing to look up, and turning Fast off needs no support evidence. const needsCatalog = input.key === 'model' || (input.key === 'fastMode' && fastMode === true) || (input.key === 'effort' && readClaudeCurrentModel(session).id !== undefined) const listed = needsCatalog ? await readClaudeListedModels(session, timeoutMs) : [] // The child stores an effort its model has no control for and keeps it across - // every later model switch and restore, so refuse before the write rather than - // read the acceptance back as adoption. Refused here, restore drops the stale - // value instead of replaying it onto a model that cannot use it. + // every later model switch, so refuse before the write rather than read the + // acceptance back as adoption. if (input.key === 'effort') { const { modelId, levels } = claudeModelEffortLevels(session, listed) if (levels && !levels.has(input.value)) { @@ -144,8 +136,7 @@ export async function setClaudeStructuredOption( } // set_model resolves for a model the provider never lists and the session then // fails every turn with zero tokens, so the acceptance proves nothing and only - // the catalog does. Restore replays a pick the provider may since have retired, - // which reaches here with no user error at all. + // the catalog does. if (input.key === 'model' && !claudeCatalogAdmitsModel(listed, input.value)) { throw new AgentSessionOptionRejectedError(`claude does not list a model named ${input.value}`) } @@ -156,7 +147,7 @@ export async function setClaudeStructuredOption( const modelWasConfirmed = readClaudeCurrentModel(session).confirmed const mutationSequence = ++session.optionMutationSequence // Read with the fence bump, so a write that lands after an earlier one's bookkeeping compares against it. - const held = heldBeforeRestore ?? session.options.get(input.key) + const held = session.options.get(input.key) // Only a model write can stale the model report — an effort or permission-mode // write does not change what the child is running. Leaving the stamp behind // would drop the session back to the written model and refuse, on the next @@ -247,44 +238,3 @@ export async function setClaudeStructuredOption( } return Object.fromEntries(session.options) } - -export async function restoreClaudeStructuredSessionOptions( - session: ClaudeSession, - timeoutMs: number | undefined -): Promise { - // Any write that was already in flight belongs to the previous acquisition - // state and must not repopulate this map after restore starts. - session.optionMutationSequence += 1 - // The fence bump is not a write, so the report the session already holds is still - // current as of this instant; leaving the stamp behind would make every restored - // session read as unconfirmed until its next turn. - session.reportedModelMutation = session.optionMutationSequence - const options = [...session.options.entries()] - session.options.clear() - for (const [key, value] of options) { - try { - await setClaudeStructuredOption(session, { key, value }, timeoutMs, value) - } catch (error) { - // A write the CLI never answered must not fault a start that is otherwise fine. Silence is - // not a refusal, so the choice stays wanted, unconfirmed, and the next start retries it. - if (error instanceof ClaudeControlRequestTimeoutError) { - console.warn( - `[claude-structured] restore of ${key} for ${session.providerSessionId} was not answered in time; keeping it unconfirmed` - ) - session.options.set(key, value) - session.confirmedOptions.delete(key) - continue - } - if (!isAgentSessionOptionRejectedError(error)) { - throw error - } - // A stale or unavailable preference must not poison every future acquire; - // the provider's current value remains authoritative and is re-persisted. - session.restoreSkippedOptions.add(key) - // The journal's window was measured under the value this child did not take. - if (CONTEXT_WINDOW_KEYS.has(key)) { - session.translator?.modelMayHaveChanged() - } - } - } -} diff --git a/src/main/claude/claude-structured-prompt-ownership.ts b/src/main/claude/claude-structured-prompt-ownership.ts index 07e3960c6ed6..a7d1c41c8bfe 100644 --- a/src/main/claude/claude-structured-prompt-ownership.ts +++ b/src/main/claude/claude-structured-prompt-ownership.ts @@ -63,7 +63,8 @@ export async function cancelClaudeStructuredTurn(input: { const timeoutMs = Math.min(input.timeoutMs ?? CLAUDE_STOP_GRACE_MS, CLAUDE_STOP_GRACE_MS) const session = requireSession(sessions, request.sessionId) const acquisitionGeneration = session.acquisitionGeneration - // Before startup lands nothing was written, so there is nothing to interrupt. + // A CLI still starting answers no interrupt: Stop ends its child next (`stopEndsSession`), and + // that end settles every message it was handed and never echoed as stopped. if (request.prompt || session.startup.state === 'pending') { return { cancelled: false } } diff --git a/src/main/claude/claude-structured-real-cli-fold.test.ts b/src/main/claude/claude-structured-real-cli-fold.test.ts index 13e4ee5e6720..8abcc0de2c9b 100644 --- a/src/main/claude/claude-structured-real-cli-fold.test.ts +++ b/src/main/claude/claude-structured-real-cli-fold.test.ts @@ -27,6 +27,7 @@ import { realClaudeCommand, realClaudeLaunchHome } from './claude-real-cli-availability-test-support' +import { claudeStartupSettled } from './claude-structured-session-test-support' import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const SESSION_ID = 'real-cli-fold' @@ -108,8 +109,8 @@ describe.skipIf(!realClaudeAvailable)('Claude structured real CLI fold', () => { spawnToken: 'real-cli-fold', events: sink }) - await adapter.awaitStarted(SESSION_ID) - // Startup proved from the SessionStart hook frame, with no init yet. + await claudeStartupSettled(adapter, SESSION_ID) + // Startup landed on the initialize answer, with no turn and so no init yet. expect( events.some( (event) => diff --git a/src/main/claude/claude-structured-real-cli.test.ts b/src/main/claude/claude-structured-real-cli.test.ts index b12ca6ff1e08..0e9345537bb1 100644 --- a/src/main/claude/claude-structured-real-cli.test.ts +++ b/src/main/claude/claude-structured-real-cli.test.ts @@ -8,6 +8,7 @@ import * as claudeConnection from './claude-stream-json-connection' import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' import { resolveSessionFilePath } from '../native-chat/session-file-resolver' import { CLAUDE_STRUCTURED_BASE_OPTIONS } from './claude-structured-launch-resolution' +import { readClaudeInit } from './claude-structured-init-proof' import { realClaudeAuthenticated, realClaudeAuthStatus, @@ -21,6 +22,7 @@ import { type ClaudeStructuredSessionEvent } from './claude-structured-session-adapter' import type { ClaudeStructuredSessionAdapterDeps } from './claude-structured-session-state' +import { claudeStartupSettled } from './claude-structured-session-test-support' import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const command = realClaudeCommand @@ -32,7 +34,8 @@ function realAdapter( events: ClaudeStructuredSessionEvent[] = [], cwd = process.cwd(), onDispatchSettledLate?: ClaudeStructuredSessionAdapterDeps['onDispatchSettledLate'], - env = realClaudeLaunchHome().env + env = realClaudeLaunchHome().env, + waitsForStartup = true ): ClaudeStructuredSessionAdapter { const adapter = new ClaudeStructuredSessionAdapter({ resolveLaunch: async () => ({ @@ -51,11 +54,14 @@ function realAdapter( readProcessStartTime: async () => 1, now: () => 2 }) + if (!waitsForStartup) { + return adapter + } // These proofs read startup facts, which land after the session is published. const acquire = adapter.acquire adapter.acquire = async (input) => { const acquisition = await acquire(input) - await adapter.awaitStarted(input.identity.sessionId) + await claudeStartupSettled(adapter, input.identity.sessionId) return acquisition } return adapter @@ -332,6 +338,69 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => { 90_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)( + 'runs a message written before initialize answers, under the saved options it was launched with', + 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 messages = (): Record[] => + events.flatMap((event) => (event.type === 'message' ? [event.message] : [])) + + try { + await adapter.acquire({ + identity: identity(providerSessionId), + fence: 1, + spawnToken: 'real-cli-saved-options', + options: { model: 'sonnet', permissionMode: 'plan', effort: 'low' } + }) + const dispatched = await adapter.dispatch({ + sessionId: 'real-cli-handshake', + clientMessageId: 'real-cli-saved-options-1', + body: { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'Reply with exactly: OK. Do not use any tools.' }] + }, + fence: 1 + }) + // Read as the write resolves: the CLI had not answered initialize, so nothing held it. + const answeredAtDispatch = adapter['sessions'].get('real-cli-handshake')?.startup.answered + const startedAtDispatch = events.some((event) => event.type === 'started') + expect(dispatched).toEqual({ state: 'admitted' }) + expect(answeredAtDispatch).toBe(false) + expect(startedAtDispatch).toBe(false) + const deadline = Date.now() + 90_000 + while (!messages().some((m) => m.type === 'result') && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 250)) + } + + // The echo of the message Orca wrote, then a turn that ended in success. + expect(messages()).toContainEqual(expect.objectContaining({ type: 'user', isReplay: true })) + expect(messages().find((m) => m.type === 'result')).toMatchObject({ is_error: false }) + // The turn's own init names what the child was launched with. + expect(messages().find((m) => m.type === 'system' && m.subtype === 'init')).toMatchObject({ + model: 'claude-sonnet-5', + permissionMode: 'plan' + }) + } finally { + await adapter.closeAll() + } + }, + 120_000 + ) + // The window a Stop naming no turn exists for: Orca has written the message, and Claude has not // started its reply, so no turn id exists. Only the live binary can say the interrupt lands there. it.skipIf(!realClaudeAuthenticated)( @@ -487,6 +556,54 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => { 150_000 ) + // With no SessionStart hook (Orca's status hooks off), the CLI names no session before the + // first turn; the chat must start on the initialize answer and take the message. + it.skipIf(!realClaudeAuthenticated)( + 'starts and answers with every hook disabled, so no start frame precedes the turn', + async () => { + const providerSessionId = randomUUID() + const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), '.claude') + const cwd = await mkdtemp(join(tmpdir(), 'orca-no-hooks-')) + await mkdir(join(cwd, '.claude'), { recursive: true }) + await writeFile( + join(cwd, '.claude', 'settings.json'), + JSON.stringify({ disableAllHooks: true }) + ) + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = realAdapter(providerSessionId, claudeConfigDir, events, cwd) + const frames = (): Record[] => + events.flatMap((event) => (event.type === 'message' ? [event.message] : [])) + try { + await adapter.acquire({ + identity: identity(providerSessionId), + fence: 1, + spawnToken: 'real-cli-no-hooks' + }) + expect(frames().filter((frame) => readClaudeInit(frame) !== null)).toEqual([]) + await expect( + adapter.dispatch({ + sessionId: 'real-cli-handshake', + clientMessageId: 'real-cli-no-hooks-1', + body: { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'Reply with the single word ok.' }] + }, + fence: 1 + }) + ).resolves.toEqual({ state: 'admitted' }) + await vi.waitFor( + () => expect(frames()).toContainEqual(expect.objectContaining({ type: 'result' })), + { timeout: 120_000, interval: 200 } + ) + } finally { + await adapter.closeAll() + await rm(cwd, { recursive: true, force: true }) + } + }, + 150_000 + ) + it('turns a real silent unauthenticated startup into sign-in guidance', async () => { const claudeConfigDir = await mkdtemp(join(tmpdir(), 'orca-claude-no-auth-')) const providerSessionId = randomUUID() diff --git a/src/main/claude/claude-structured-retired-model.test.ts b/src/main/claude/claude-structured-retired-model.test.ts new file mode 100644 index 000000000000..86397cc59f61 --- /dev/null +++ b/src/main/claude/claude-structured-retired-model.test.ts @@ -0,0 +1,205 @@ +import { describe, expect, it } from 'vitest' +import { claudeAcquireCatalogAccess } from './claude-structured-acquire-catalog' +import { AgentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' +import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state' +import { ClaudeStructuredSessionAdapter } from './claude-structured-session-adapter' +import { + claudeCatalogRowsOfAccount, + retireClaudeLaunchedModel +} from './claude-structured-retired-model' +import { + adapterFor, + claudeFrame, + claudeStartupSettled, + fakeClaude, + tick, + identityFor, + PROVIDER_SESSION_ID, + recordingJournalSink +} from './claude-structured-session-test-support' + +const ACCOUNT_HOME = '/accounts/claude' + +function catalogAccess(store: AgentModelCatalogStore) { + const access = claudeAcquireCatalogAccess(store, ACCOUNT_HOME) + if (!access) { + throw new Error('a store and an account home always give access') + } + return access +} +const RETIRED = 'claude-retired-1' +/** The rows Claude Code 2.1.280 lists for a child launched with `--model claude-bogus-9`: its own + * native rows, then one for the launched id, named by that id. */ +const NATIVE_OPUS = { value: 'opus', resolvedModel: 'claude-opus-5', displayName: 'Opus' } +const LAUNCHED_ROW = { + value: RETIRED, + resolvedModel: RETIRED, + displayName: RETIRED, + description: 'Custom model' +} + +function modelNotFound(parentToolUseId: string | null = null): Record { + return { + type: 'assistant', + uuid: 'reply-1', + session_id: PROVIDER_SESSION_ID, + parent_tool_use_id: parentToolUseId, + error: 'model_not_found', + is_api_error_message: true, + message: { model: '', role: 'assistant', content: [] } + } +} + +function sessionLaunchedWith(model: string | null, picked: string | null = model) { + return { + launchedModel: model, + options: new Map(picked === null ? [] : [['model', picked]]), + restoreSkippedOptions: new Set() + } +} + +describe("the CLI's word that a launched model does not exist", () => { + it('drops the launched model once, as a skipped saved option', () => { + const session = sessionLaunchedWith(RETIRED) + + expect(retireClaudeLaunchedModel(session, modelNotFound())).toBe(RETIRED) + expect(session.options.has('model')).toBe(false) + expect([...session.restoreSkippedOptions]).toEqual(['model']) + expect(retireClaudeLaunchedModel(session, modelNotFound())).toBeNull() + }) + + it.each([ + ['a subagent reply', sessionLaunchedWith(RETIRED), modelNotFound('toolu_1')], + ['another error', sessionLaunchedWith(RETIRED), { ...modelNotFound(), error: 'rate_limit' }], + ['a model picked since the launch', sessionLaunchedWith(RETIRED, 'opus'), modelNotFound()], + ['a launch that named no model', sessionLaunchedWith(null), modelNotFound()] + ])('keeps the saved model for %s', (_case, session, message) => { + expect(retireClaudeLaunchedModel(session, message)).toBeNull() + expect([...session.restoreSkippedOptions]).toEqual([]) + }) +}) + +describe("the account catalog a child's listing writes through", () => { + const rows = [ + { id: 'opus', label: 'Opus' }, + { id: RETIRED, label: RETIRED } + ] + + function access(cached: string[] | null) { + const store = new AgentModelCatalogStore() + const handle = catalogAccess(store) + if (cached) { + store.recordSuccess(handle.fingerprint, 'claude', { + models: cached.map((id) => ({ id, label: id, isDefault: false, efforts: [] })), + fastModeTierByModel: new Map(), + origin: 'probe' + }) + } + return handle + } + + it('leaves out the row the launch added for its own model', () => { + expect(claudeCatalogRowsOfAccount(access(null), rows, RETIRED)).toEqual([rows[0]]) + }) + + it('keeps that row when the account already listed the model', () => { + expect(claudeCatalogRowsOfAccount(access([RETIRED]), rows, RETIRED)).toEqual(rows) + }) + + it('keeps a launched native model, which carries its own display name', () => { + expect(claudeCatalogRowsOfAccount(access(null), rows, 'opus')).toEqual(rows) + }) + + it('keeps every row for a launch that named no model', () => { + expect(claudeCatalogRowsOfAccount(access(null), rows, null)).toEqual(rows) + }) + + it('never writes a launched-only model into the account catalog at start', async () => { + const store = new AgentModelCatalogStore() + const handle = catalogAccess(store) + const claude = fakeClaude({ initModels: [NATIVE_OPUS, LAUNCHED_ROW] }) + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = new ClaudeStructuredSessionAdapter({ + resolveLaunch: async () => ({ + pathToClaudeCodeExecutable: 'claude', + options: {}, + cwd: '/work/repo', + claudeConfigDir: ACCOUNT_HOME, + providerSessionId: PROVIDER_SESSION_ID, + resumeLeafUuid: null, + resumesTranscript: true, + continuesChain: true + }), + onEvent: (event) => events.push(event), + openConnection: claude.openConnection, + readProcessStartTime: async () => 1_700_000_000_000, + now: () => 1_700_000_000_500, + persistHandle: async () => {}, + modelCatalog: store + }) + await adapter.acquire({ + identity: identityFor(), + fence: 7, + spawnToken: 'spawn-9', + events: recordingJournalSink(), + options: { model: RETIRED } + }) + await claudeStartupSettled(adapter, 'session-1') + + expect(events.some((event) => event.type === 'started')).toBe(true) + expect(store.get(handle.fingerprint)?.models.map((model) => model.id)).toEqual(['opus']) + await adapter.closeAll() + }) +}) + +describe('a live child still launched with a retired model', () => { + async function healed(holdReset: boolean) { + let answerReset: () => void = () => {} + const claude = fakeClaude({ + initModels: [NATIVE_OPUS, LAUNCHED_ROW], + routes: { + list_models: () => [NATIVE_OPUS], + set_model: (params) => + holdReset && params?.model === undefined + ? new Promise((resolve) => (answerReset = resolve)) + : undefined + } + }) + const adapter = adapterFor(claude) + await adapter.acquire({ + identity: identityFor(), + fence: 7, + spawnToken: 'spawn-9', + options: { model: RETIRED } + }) + claudeFrame(claude.connections[0]!, modelNotFound()) + return { claude, adapter, answerReset: () => answerReset() } + } + + function setModelCalls(claude: ReturnType) { + return claude.connections[0]!.calls.filter((call) => call.subtype === 'set_model') + } + + it("goes back to the CLI's own default, so the next turn runs", async () => { + const { claude, adapter } = await healed(false) + await tick() + + expect(setModelCalls(claude)).toEqual([{ subtype: 'set_model', params: { model: undefined } }]) + const options = await adapter.readOptions({ sessionId: 'session-1', fence: 7 }) + expect(options.current.model).not.toBe(RETIRED) + await adapter.closeAll() + }) + + it('keeps a model the user picks before the reset lands', async () => { + const { claude, adapter, answerReset } = await healed(true) + await adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'opus', fence: 7 }) + answerReset() + await tick() + + // The pick goes out after the reset, so the CLI runs it; the reset's bookkeeping stands aside. + expect(setModelCalls(claude).map((call) => call.params?.model)).toEqual([undefined, 'opus']) + const options = await adapter.readOptions({ sessionId: 'session-1', fence: 7 }) + expect(options.current.model).toBe('opus') + await adapter.closeAll() + }) +}) diff --git a/src/main/claude/claude-structured-retired-model.ts b/src/main/claude/claude-structured-retired-model.ts new file mode 100644 index 000000000000..5c25ad461811 --- /dev/null +++ b/src/main/claude/claude-structured-retired-model.ts @@ -0,0 +1,90 @@ +// A chat's saved model is passed to the CLI as `--model` unchecked, so one the provider has since +// retired fails the turn. The CLI says so itself: a synthetic reply whose `error` is +// `model_not_found` (Claude Code 2.1.280). That reply is the evidence the saved model cannot run. + +import type { AgentModelCatalogSessionAccess } from '../native-chat/agent-model-catalog/agent-model-catalog-store' +import type { + ClaudeSession, + ClaudeStructuredSessionAdapterDeps +} from './claude-structured-session-state' + +/** The launched model, once, when a root reply says it does not exist and the chat still has it + * picked: the session stops holding it, so the record drops it. Null otherwise. */ +export function retireClaudeLaunchedModel( + session: Pick, + message: Record +): string | null { + const launched = session.launchedModel + if ( + launched === null || + message.type !== 'assistant' || + (message.parent_tool_use_id ?? null) !== null || + message.error !== 'model_not_found' || + session.options.get('model') !== launched + ) { + return null + } + session.options.delete('model') + session.restoreSkippedOptions.add('model') + return launched +} + +/** Moves the live child to the CLI's account default (`set_model` with no model), which unlike a + * fresh launch ignores ANTHROPIC_MODEL and a settings `model`. A user's pick made meanwhile owns + * the model; a refused or unanswered reset changes nothing but the log. */ +export function resetClaudeRetiredModel( + session: ClaudeSession, + deps: Pick, + sessionId: string, + retired: string +): void { + const sequence = session.optionMutationSequence + void session.connection.setModel(undefined, { timeoutMs: deps.requestTimeoutMs }).then( + () => { + if (sequence !== session.optionMutationSequence) { + return + } + // The retired id is no longer what runs; the next turn's own report names the default. + if (session.reportedOptions.model === retired) { + delete session.reportedOptions.model + } + if (session.appliedOptions?.model === retired) { + const { model: _retired, ...applied } = session.appliedOptions + session.appliedOptions = applied + } + }, + (error: unknown) => + deps.logger?.warn('putting a retired Claude model back on the default failed', { + scope: 'claude-retired-model-reset', + sessionId, + error + }) + ) +} + +/** The saved values this child showed it cannot run. Only a retired launch model is one: a launch + * never skips a model, so a skipped model is that. */ +export function claudeRetiredOptions( + session: Pick +): { retiredOptions?: Record } { + return session.restoreSkippedOptions.has('model') && session.launchedModel !== null + ? { retiredOptions: { model: session.launchedModel } } + : {} +} + +/** The rows of a listing the account's catalog keeps. A child launched with `--model X` lists X + * itself (Claude Code 2.1.280 adds a row named by the raw id, "Custom model"), whether or not X + * exists: that row is the launch talking, not the account, unless the account already listed X. + * Native rows carry a display name of their own, so a row named by its id is the tell. */ +export function claudeCatalogRowsOfAccount( + access: AgentModelCatalogSessionAccess, + rows: readonly T[], + launchedModel: string | null +): T[] { + const listedBefore = access.store + .get(access.fingerprint) + ?.models.some((model) => model.id === launchedModel) + return rows.filter( + (row) => listedBefore === true || row.id !== launchedModel || row.label !== row.id + ) +} diff --git a/src/main/claude/claude-structured-session-acquisition-options.ts b/src/main/claude/claude-structured-session-acquisition-options.ts index 7557419dd08b..fff9cb4dfc3c 100644 --- a/src/main/claude/claude-structured-session-acquisition-options.ts +++ b/src/main/claude/claude-structured-session-acquisition-options.ts @@ -3,7 +3,6 @@ import { readClaudeSettingsFastMode, readClaudeSettingsFastModePerSessionOptIn } from './claude-structured-session-options' -import { restoredClaudeStructuredSessionOptions } from './claude-structured-options' import type { ClaudeStreamJsonConnection } from './claude-stream-json-connection' export async function readClaudeStructuredSessionSettings( @@ -16,22 +15,12 @@ export async function readClaudeStructuredSessionSettings( export function prepareClaudeStructuredSessionAcquisitionOptions(args: { settings: unknown initialization: unknown - inputOptions: Readonly> | undefined - /** A fresh CLI session has not carried a per-session Fast opt-in over from anywhere. */ - resumesTranscript: boolean }) { - const fastMode = readClaudeSettingsFastMode(args.settings) - const fastModePerSessionOptIn = readClaudeSettingsFastModePerSessionOptIn(args.settings) - const fastModeFacts = readClaudeFastModeFacts(args.initialization) - const options = restoredClaudeStructuredSessionOptions(args.inputOptions) - if ( - !args.resumesTranscript && - fastModePerSessionOptIn === true && - options.get('fastMode') === 'true' - ) { - options.delete('fastMode') + return { + fastMode: readClaudeSettingsFastMode(args.settings), + fastModePerSessionOptIn: readClaudeSettingsFastModePerSessionOptIn(args.settings), + fastModeFacts: readClaudeFastModeFacts(args.initialization) } - return { fastMode, fastModePerSessionOptIn, fastModeFacts, options } } export function claudeStructuredSessionPublicationOptions(input: { diff --git a/src/main/claude/claude-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts index 285606fb8bfc..0e10d929fa05 100644 --- a/src/main/claude/claude-structured-session-acquisition.ts +++ b/src/main/claude/claude-structured-session-acquisition.ts @@ -18,7 +18,7 @@ import { import { claudeConfigDirEnvPatch } from './claude-config-dir-pin' import { CLAUDE_SPAWN_TOKEN_ENV, claudeProcessIdentity } from './claude-structured-owner-identity' import { ClaudePromptRegistry } from './claude-structured-prompt-replies' -import { restoredClaudeStructuredSessionOptions } from './claude-structured-options' +import { adoptClaudeStructuredSpawnOptions } from './claude-structured-spawn-options' import { createClaudeSessionJournalTranslator } from './claude-structured-journal-translation' import { observeClaudeFastModeFacts } from './claude-structured-session-options' import { @@ -91,10 +91,10 @@ export async function acquireClaudeSession({ const onMessage = (message: Record): void => { const init = readClaudeInit(message) if (readClaudeFrameString(message, 'session_id') !== expectedProviderSessionId) { - // An init proof for another (or unnamed) provider must fail acquisition + // An init proof for another (or unnamed) provider must fail the start or end the session // promptly, while ordinary foreign frames stay quarantined silently. if (init || (message.type === 'system' && message.subtype === 'init')) { - initProof.reject(new Error('claude provider session expected')) + initProof.refuse() } return } @@ -251,12 +251,13 @@ export async function acquireClaudeSession({ ...(unbindReadingControl ? { unbindReadingControl } : {}), process, acquisitionGeneration: mintClaudeAcquisitionGeneration(deps), - options: restoredClaudeStructuredSessionOptions(input.options), + options: launch.savedOptions.options, ...(deps.mintLinkId ? { linkId: deps.mintLinkId() } : {}), observedAt: deps.now?.() ?? Date.now() }) const session = publication.session liveSession = session + adoptClaudeStructuredSpawnOptions(session, launch.savedOptions) const catalogAccess = claudeAcquireCatalogAccess(deps.modelCatalog, launch.claudeConfigDir) if (catalogAccess) { session.catalogAccess = catalogAccess @@ -279,21 +280,19 @@ export async function acquireClaudeSession({ initProof, sessionId, providerSessionId: launch.providerSessionId, + startup: session.startup, resumesTranscript: launch.resumesTranscript, - inputOptions: input.options, requestTimeoutMs: deps.requestTimeoutMs, emit }), isCurrent: () => sessions.get(sessionId) === session, - requestTimeoutMs: deps.requestTimeoutMs, fault: (error) => callbacks.handleExit(sessionId, attempt, error), - onStarted: (options) => + report: (event) => emit({ - type: 'started', + ...event, sessionId, fence: input.fence, - acquisitionGeneration: session.acquisitionGeneration, - ...options + acquisitionGeneration: session.acquisitionGeneration }) }) ]) @@ -304,8 +303,8 @@ export async function acquireClaudeSession({ exits.get(sessionId)?.error ?? new Error('claude session ended before acquisition returned') ) } - // The start applies its facts and restores saved options only after publish, so the child - // is `starting` until `started` says otherwise. + // The start reads its facts only after publish, so the child is `starting` until `started` + // says otherwise; it already takes input. 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 cd5b4841de6d..cc59dfde3017 100644 --- a/src/main/claude/claude-structured-session-adapter.test.ts +++ b/src/main/claude/claude-structured-session-adapter.test.ts @@ -7,8 +7,11 @@ import { AgentSessionPromptAnswerRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { ClaudeStreamJsonConnection } from './claude-stream-json-connection' -import { ClaudeControlRequestError } from './claude-stream-json-connection' import { CLAUDE_SPAWN_TOKEN_ENV } from './claude-structured-owner-identity' +import { + ClaudeControlRequestError, + ClaudeControlRequestTimeoutError +} from './claude-agent-sdk-control-requests' import type { ClaudeStructuredSessionAdapter, ClaudeStructuredSessionEvent @@ -67,7 +70,7 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { expect(events[0]).toMatchObject({ type: 'message', message: { subtype: 'hook_started' } }) }) - it('restores persisted model and effort before publishing a reacquired session', async () => { + it('launches a reacquired session with its persisted model and effort, and writes neither', async () => { const claude = fakeClaude() const adapter = adapterFor(claude, { resumesTranscript: true, continuesChain: true }) @@ -78,28 +81,21 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { options: { model: 'opus', effort: 'high' } }) - expect(claude.connections[0].calls.slice(-4)).toEqual([ - { subtype: 'set_model', params: { model: 'opus' } }, - // The restored model's advertised levels gate the replay, so a stale effort - // is dropped rather than re-applied to a model with no effort control. - { subtype: 'list_models' }, - { subtype: 'apply_flag_settings', params: { settings: { effortLevel: 'high' } } }, - // The effort is only recorded once the child reports having adopted it. - { subtype: 'get_settings' } + expect(claude.connections[0].launch.options).toMatchObject({ model: 'opus', effort: 'high' }) + expect(claude.connections[0].calls.map((call) => call.subtype)).toEqual([ + 'initialize', + 'get_settings' ]) await expect(adapter.readOptions({ sessionId: 'session-1', fence: 7 })).resolves.toMatchObject({ current: { model: 'opus', effort: 'high' } }) }) - it('restores an encoded Fast preference through the absolute flag setting', async () => { + it('launches a resumed conversation with its saved Fast as a flag setting', async () => { const claude = fakeClaude({ - settings: { effective: { fastMode: false, fastModePerSessionOptIn: false } }, - routes: { - list_models: () => [{ value: 'opus', displayName: 'Opus', supportsFastMode: true }] - } + settings: { effective: { fastMode: true, fastModePerSessionOptIn: false } } }) - const adapter = adapterFor(claude) + const adapter = adapterFor(claude, { resumesTranscript: true, continuesChain: true }) await adapter.acquire({ identity: identityFor(), @@ -108,20 +104,18 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { options: { model: 'opus', fastMode: 'true' } }) - expect(claude.connections[0].calls).toContainEqual({ - subtype: 'apply_flag_settings', - params: { settings: { fastMode: true } } - }) + expect(claude.connections[0].launch.options.settings).toEqual({ fastMode: true }) + expect( + claude.connections[0].calls.filter((call) => call.subtype === 'apply_flag_settings') + ).toEqual([]) }) - it('does not carry a saved opt-in into a new per-session-opt-in child', async () => { + it('does not carry a saved Fast into a new conversation whose settings opt in per session', async () => { const claude = fakeClaude({ - settings: { effective: { fastMode: false, fastModePerSessionOptIn: true } }, - routes: { - list_models: () => [{ value: 'opus', displayName: 'Opus', supportsFastMode: true }] - } + settings: { effective: { fastMode: false, fastModePerSessionOptIn: true } } }) - const adapter = adapterFor(claude) + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = adapterFor(claude, {}, events) await adapter.acquire({ identity: identityFor(), @@ -130,19 +124,26 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { options: { model: 'opus', fastMode: 'true' } }) + expect(claude.connections[0].launch.options.settings).toBeUndefined() expect( claude.connections[0].calls.filter((call) => call.subtype === 'apply_flag_settings') ).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({ + reportedOptions: { fastMode: false } + }) }) - it('restores Fast when reacquiring the same per-session-opt-in conversation', async () => { + // No message waits on it: the launch carries no Fast, and the start applies it once the CLI's + // settings show no per-session opt-in, as the live CLI reports with no settings file at all. + it('applies a saved Fast on to a new conversation once its settings allow it, and keeps the pick', async () => { const claude = fakeClaude({ - settings: { effective: { fastMode: false, fastModePerSessionOptIn: true } }, - routes: { - list_models: () => [{ value: 'opus', displayName: 'Opus', supportsFastMode: true }] - } + settings: { applied: {}, effective: {}, sources: {} }, + initModels: [{ value: 'opus', displayName: 'Opus', supportsFastMode: true }] }) - const adapter = adapterFor(claude, { resumesTranscript: true, continuesChain: true }) + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = adapterFor(claude, {}, events) await adapter.acquire({ identity: identityFor(), @@ -151,85 +152,101 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { options: { model: 'opus', fastMode: 'true' } }) - expect(claude.connections[0].calls).toContainEqual({ + expect(claude.connections[0].launch.options.settings).toBeUndefined() + expect(claude.connections[0].calls.map((call) => call.subtype)).toEqual([ + 'initialize', + 'get_settings', + 'apply_flag_settings' + ]) + expect(claude.connections[0].calls.at(-1)).toEqual({ subtype: 'apply_flag_settings', params: { settings: { fastMode: true } } }) - }) - - it('self-heals a Fast preference the running model no longer supports', async () => { - const claude = fakeClaude({ - settings: { effective: { fastMode: false } }, - routes: { - list_models: () => [{ value: 'opus', displayName: 'Opus', supportsFastMode: false }] - } + expect(events.find((event) => event.type === 'started')).toMatchObject({ + reportedOptions: { fastMode: true }, + restoreSkippedOptions: [] }) - const adapter = adapterFor(claude) - - await expect( - adapter.acquire({ - identity: identityFor(), - fence: 7, - spawnToken: 'spawn-9', - options: { model: 'opus', fastMode: 'true' } - }) - ).resolves.toBeDefined() - - expect(adapter.readOptionRestoreFailures('session-1')).toContain('fastMode') - expect( - claude.connections[0].calls.filter((call) => call.subtype === 'apply_flag_settings') - ).toEqual([]) }) it.each([ - ['model', 'set_model', { model: 'retired-model' }], - ['effort', 'apply_flag_settings', { effort: 'retired-effort' }], - ['permissionMode', 'set_permission_mode', { permissionMode: 'retired-mode' }] + ['refuses', new ClaudeControlRequestError('apply_flag_settings', 'not for this org'), false], + ['never answers', new ClaudeControlRequestTimeoutError('apply_flag_settings'), true] ] as const)( - 'self-heals a persisted %s rejected during restore', - async (key, subtype, options) => { + 'settles a saved Fast on a new conversation when the CLI %s its apply', + async (_case, failure, kept) => { + let answerApply: (error: Error) => void = () => {} const claude = fakeClaude({ + settings: { applied: {}, effective: {}, sources: {} }, + initModels: [{ value: 'opus', displayName: 'Opus', supportsFastMode: true }], routes: { - [subtype]: () => { - throw new ClaudeControlRequestError(subtype, 'value is no longer available') - } + apply_flag_settings: () => new Promise((_, reject) => (answerApply = reject)) } }) - const adapter = adapterFor(claude) + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = adapterFor(claude, {}, events) - await expect( - adapter.acquire({ - identity: identityFor(), - fence: 7, - spawnToken: 'spawn-9', - options - }) - ).resolves.toBeDefined() - expect(adapter.readOptionRestoreFailures('session-1')).toEqual([key]) + await adapter.acquire({ + identity: identityFor(), + fence: 7, + spawnToken: 'spawn-9', + options: { model: 'opus', fastMode: 'true' } + }) + + // Started, with the pick, while the apply is still unanswered: nothing waits on it. + expect(claude.connections[0].calls.at(-1)?.subtype).toBe('apply_flag_settings') + expect(events.find((event) => event.type === 'started')).toMatchObject({ + reportedOptions: { fastMode: true }, + restoreSkippedOptions: [] + }) + answerApply(failure) + await tick() + + // A refusal drops the pick, as main's refused restore did, from the record too, after + // `started`; silence keeps it wanted and unconfirmed. + const skipped = events.filter((event) => event.type === 'options-skipped') + expect(skipped).toEqual( + kept ? [] : [expect.objectContaining({ fence: 7, options: { fastMode: 'true' } })] + ) + expect(adapter.readOptionRestoreFailures('session-1')).toEqual(kept ? [] : ['fastMode']) + const options = await adapter.readOptions({ sessionId: 'session-1', fence: 7 }) + expect(options.current.fastMode === true).toBe(kept) + expect(options.current.confirmed ?? []).not.toContain('fastMode') } ) - it('does not treat a transport timeout while restoring an option as recoverable', async () => { + it('drops a saved Fast on from a new conversation whose model the CLI lists without it', async () => { const claude = fakeClaude({ - routes: { - set_model: () => { - throw new Error('claude set_model request timed out') - } - } + settings: { applied: {}, effective: {}, sources: {} }, + initModels: [{ value: 'opus', displayName: 'Opus', supportsFastMode: false }] }) - const input = { + const adapter = adapterFor(claude) + + await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9', - options: { model: 'temporarily-unavailable' } - } - - // Restore runs after publish, so its failure ends the session rather than the create. - await expect(endedAtStartup(claude, input)).resolves.toMatchObject({ - reason: 'claude set_model request timed out', - startupUnproven: true + options: { model: 'opus', fastMode: 'true' } }) - expect(claude.connections[0]?.closeCount).toBe(1) + + expect( + claude.connections[0].calls.filter((call) => call.subtype === 'apply_flag_settings') + ).toEqual([]) + expect(adapter.readOptionRestoreFailures('session-1')).toEqual(['fastMode']) + }) + + it.each([ + ['effort', { effort: 'retired-effort' }], + ['permissionMode', { permissionMode: 'retired-mode' }] + ] as const)('self-heals a persisted %s the CLI would refuse at launch', async (key, options) => { + const claude = fakeClaude() + const adapter = adapterFor(claude) + + await expect( + adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9', options }) + ).resolves.toBeDefined() + expect(adapter.readOptionRestoreFailures('session-1')).toEqual([key]) + expect(claude.connections[0].launch.options).not.toHaveProperty('effort') + expect(claude.connections[0].launch.options).not.toHaveProperty('permissionMode') }) it('recovers a cancellable lifecycle when the replay arrives after dispatch returned', async () => { diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index 6d4ec1c31536..e79f3cab5d25 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -1,4 +1,3 @@ -import type { SubmissionRejectionFact } from '../../shared/agent-session-failure' import { dispatchClaudeCommand } from './claude-structured-command-dispatch' import type { AgentSessionAcquisition, @@ -13,10 +12,7 @@ import { acquireClaudeSession } from './claude-structured-session-acquisition' import { supportsClaudeStructuredLocation } from './claude-structured-location-support' import { setClaudeStructuredSessionOption } from './claude-structured-options' import { readClaudeStructuredSessionOptions } from './claude-structured-session-options' -import { - awaitClaudeSessionStarted, - claudeStartupSettledWithin -} from './claude-structured-session-startup-state' +import { claudeStartupSettledWithin } from './claude-structured-session-startup-state' import { CLAUDE_DEFAULT_REQUEST_TIMEOUT_MS } from './claude-agent-sdk-control-requests' import { ClaudeAcquisitionRegistry, @@ -129,11 +125,6 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda * apart without guessing at wall-clock. */ drainObservedExits = (): Promise => drainClaudeObservedExits(this.exits) - /** Resolves once a published session's startup has landed, faulted, or been ended by a close; - * with the reason when it did not land. */ - awaitStarted = (sessionId: string): Promise => - awaitClaudeSessionStarted(this.sessions.get(sessionId)) - /** Restart reconciliation reads the transcript a resume replays; these maps track liveness. */ providerHistoryWindow: NonNullable = ( input @@ -242,6 +233,8 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda input, this.deps.requestTimeoutMs ) + startAnswered = (sessionId: string): boolean | undefined => + this.sessions.get(sessionId)?.startup.answered awaitOptionWritable = (sessionId: string): Promise => claudeStartupSettledWithin( this.sessions.get(sessionId), diff --git a/src/main/claude/claude-structured-session-close.ts b/src/main/claude/claude-structured-session-close.ts index f066699c4f1f..baeda27ae2fc 100644 --- a/src/main/claude/claude-structured-session-close.ts +++ b/src/main/claude/claude-structured-session-close.ts @@ -143,7 +143,7 @@ async function finalizeClaudePublishedSession( } if (!session.closeEnded) { session.closeEnded = true - const ended = { + const ended: ClaudeStructuredSessionEvent = { type: 'ended', sessionId: input.sessionId, reason: 'claude session closed', @@ -151,8 +151,9 @@ async function finalizeClaudePublishedSession( cause: 'requested-close', fence: session.fence, acquisitionGeneration: session.acquisitionGeneration, - observedAt: Date.now() - } as const + observedAt: Date.now(), + ...(session.startup.answered ? {} : { startupUnanswered: true as const }) + } try { try { session.translator?.handle(ended) diff --git a/src/main/claude/claude-structured-session-exit-lifecycle.ts b/src/main/claude/claude-structured-session-exit-lifecycle.ts index 854646490a8c..c512d58c8677 100644 --- a/src/main/claude/claude-structured-session-exit-lifecycle.ts +++ b/src/main/claude/claude-structured-session-exit-lifecycle.ts @@ -106,7 +106,8 @@ export function settleClaudeUnexpectedExit( fence: exit.session.fence, acquisitionGeneration: exit.session.acquisitionGeneration, observedAt: deps.now?.() ?? Date.now(), - ...(exit.session.startup.state === 'proven' ? {} : { startupUnproven: true }) + ...(exit.session.startup.state === 'proven' ? {} : { startupUnproven: true }), + ...(exit.session.startup.answered ? {} : { startupUnanswered: true }) } try { lifecycle.emit(exit.session, ended) diff --git a/src/main/claude/claude-structured-session-options.ts b/src/main/claude/claude-structured-session-options.ts index 082f45726fe5..1718a10e4478 100644 --- a/src/main/claude/claude-structured-session-options.ts +++ b/src/main/claude/claude-structured-session-options.ts @@ -14,6 +14,7 @@ import { } from './claude-structured-model-catalog' import type { ClaudeSession } from './claude-structured-session-state' import { structuredAgentSessionOptionModels } from '../native-chat/agent-session-wire/structured-agent-session-option-models' +import { claudeCatalogRowsOfAccount } from './claude-structured-retired-model' import { decodeStructuredAgentSessionOptionValue } from '../../shared/structured-agent-session-option-codec' /** @@ -122,14 +123,6 @@ export function readClaudeCurrentModel(session: ClaudeSession): { } } -/** - * The effort levels the session's current model advertises, with the catalog id - * that matched so a refusal names the model the pill shows. Levels are null when - * nothing identified the model: `apply_flag_settings` accepts and stores any - * level for a model with no effort control, so the catalog is the only evidence - * of a refusal — and an absent or unlisted one is not evidence, or a live CLI - * that predates `list_models` would have every effort refused under it. - */ /** One catalog read serves a whole option write. The admit check, the effort guard * and the Fast guard all ask about the same list; each taking its own read made a * single model write pay for two `list_models` round trips and let two guards answer @@ -143,6 +136,14 @@ export async function readClaudeListedModels( return catalog ? listedModels({ models: catalog }) : [] } +/** + * The effort levels the session's current model advertises, with the catalog id + * that matched so a refusal names the model the pill shows. Levels are null when + * nothing identified the model: `apply_flag_settings` accepts and stores any + * level for a model with no effort control, so the catalog is the only evidence + * of a refusal — and an absent or unlisted one is not evidence, or a live CLI + * that predates `list_models` would have every effort refused under it. + */ export function claudeModelEffortLevels( session: ClaudeSession, models: readonly ListedModel[] @@ -270,9 +271,10 @@ function writeClaudeCatalogThrough(session: ClaudeSession, discovered: ListedMod if (discovered.length === 0 || !session.catalogAccess) { return } - const support = claudeFastModeSupport(discovered, undefined) + const rows = claudeCatalogRowsOfAccount(session.catalogAccess, discovered, session.launchedModel) + const support = claudeFastModeSupport(rows, undefined) session.catalogAccess.store.recordSuccess(session.catalogAccess.fingerprint, 'claude', { - models: catalogClaudeModels(session, discovered), + models: catalogClaudeModels(session, rows), ...(support ? { fastModeSupport: support } : {}), fastModeTierByModel: new Map(), origin: 'live-session' @@ -323,9 +325,9 @@ function observeClaudeSettingsReadback( } /** The options as main already holds them, over `catalog`; asks the CLI nothing. Startup's - * settings readback and restore's confirmations are applied by the time a start proves, and - * the SDK answers `list_models` from its initialize result, so a started session's snapshot - * passes that result here rather than paying two round trips for what it already read. */ + * settings readback is applied by the time a start proves, and the SDK answers `list_models` + * from its initialize result, so a started session's snapshot passes that result here rather + * than paying two round trips for what it already read. */ export function claudeStructuredSessionOptionsFrom( session: ClaudeSession, catalog: unknown[] | null, diff --git a/src/main/claude/claude-structured-session-publication.ts b/src/main/claude/claude-structured-session-publication.ts index c685c2cd2ae7..8c3b524086b4 100644 --- a/src/main/claude/claude-structured-session-publication.ts +++ b/src/main/claude/claude-structured-session-publication.ts @@ -64,6 +64,8 @@ export function createClaudeSessionPublication(input: { reportedModelMutation: 0, confirmedOptions: new Set(), restoreSkippedOptions: new Set(), + launchedModel: null, + fastModeAtStart: false, translator: input.translator, events: input.events, ...(input.unbindReadingControl ? { unbindReadingControl: input.unbindReadingControl } : {}), diff --git a/src/main/claude/claude-structured-session-recovery.test.ts b/src/main/claude/claude-structured-session-recovery.test.ts index 86598780b456..e84ff7dbe5fc 100644 --- a/src/main/claude/claude-structured-session-recovery.test.ts +++ b/src/main/claude/claude-structured-session-recovery.test.ts @@ -10,7 +10,8 @@ import { fakeClaude, identityFor, PROVIDER_SESSION_ID, - tick + tick, + claudeStartupSettled } from './claude-structured-session-test-support' import { invokeCanUseTool } from './claude-can-use-tool-test-support' import { @@ -349,7 +350,7 @@ describe('ClaudeStructuredSessionAdapter close and exit recovery', () => { spawnToken: 'spawn-9', events: journalSink }) - await adapter.awaitStarted('session-1') + await claudeStartupSettled(adapter, 'session-1') const first = claude.connections[0] const oldPrompt = invokeCanUseTool(first, 'Bash', 'permission-retained', 'tool-retained') const oldSession = ( diff --git a/src/main/claude/claude-structured-session-startup-state.ts b/src/main/claude/claude-structured-session-startup-state.ts index f0d3a252e20d..ba4107fa7f4c 100644 --- a/src/main/claude/claude-structured-session-startup-state.ts +++ b/src/main/claude/claude-structured-session-startup-state.ts @@ -1,7 +1,7 @@ -// Where a Claude start stands. A session is published once its child is spawned, before the CLI -// has answered initialize. Nothing is written to it until startup lands (init facts read and saved -// options restored): the host's delivery loop waits on `settled` before it hands a message over, -// so a first turn never runs under defaults the restore was about to replace. +// 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. import type { SubmissionRejectionFact } from '../../shared/agent-session-failure' import { providerStartupFailureFact } from '../native-chat/agent-session-wire/structured-agent-session-failure-text' @@ -9,10 +9,11 @@ import type { ClaudeSession } from './claude-structured-session-state' export type ClaudeSessionStartup = { state: 'pending' | 'proven' | 'failed' + /** The CLI answered initialize: it may have run what it was handed. Before that it ran nothing. */ + answered: boolean failure: Error | null /** Resolves once startup has landed or faulted, or the child exited or was closed; never - * rejects. A close must end it: the delivery loop waits here, and a start Stop cut short - * would otherwise hold that loop forever. */ + * rejects. A close must end it: an option write waits here. */ settled: Promise end: () => void } @@ -22,7 +23,7 @@ export function createClaudeSessionStartup(): ClaudeSessionStartup { const ended = new Promise((resolve) => { end = resolve }) - return { state: 'pending', failure: null, settled: ended, end } + return { state: 'pending', answered: false, failure: null, settled: ended, end } } export function claudeStartupFailureFact(session: ClaudeSession): SubmissionRejectionFact | null { @@ -31,16 +32,6 @@ export function claudeStartupFailureFact(session: ClaudeSession): SubmissionReje : null } -export async function awaitClaudeSessionStarted( - session: ClaudeSession | undefined -): Promise { - if (!session) { - return - } - await session.startup.settled - return claudeStartupFailureFact(session) ?? undefined -} - /** Resolves when startup lands or `timeoutMs` passes; a stuck start then refuses the write as before. */ export function claudeStartupSettledWithin( session: ClaudeSession | undefined, diff --git a/src/main/claude/claude-structured-session-startup.test.ts b/src/main/claude/claude-structured-session-startup.test.ts index a49120f53595..58a81a2ca6f4 100644 --- a/src/main/claude/claude-structured-session-startup.test.ts +++ b/src/main/claude/claude-structured-session-startup.test.ts @@ -5,10 +5,14 @@ import { adapterAtPublishFor, fakeClaude, identityFor, - USER_MESSAGE + USER_MESSAGE, + claudeStartupSettled } from './claude-structured-session-test-support' import { CLAUDE_DEFAULT_REQUEST_TIMEOUT_MS } from './claude-agent-sdk-control-requests' +/** Far past any deadline a start used to have. */ +const NEVER_MS = 10 * 60_000 + type LateSettlement = Parameters< NonNullable >[0] @@ -55,7 +59,7 @@ describe('Claude structured session publishes before the CLI answers initialize' expect(adapter.readCommands('session-1')).toBeUndefined() await vi.advanceTimersByTimeAsync(SLOW_INIT_MS) - await adapter.awaitStarted('session-1') + await claudeStartupSettled(adapter, 'session-1') expect(events.find((event) => event.type === 'options')).toMatchObject({ models: [{ value: 'claude-sonnet' }] @@ -65,19 +69,15 @@ describe('Claude structured session publishes before the CLI answers initialize' await adapter.closeAll() }) - it('reports `started` once saved options are restored, having written no prompt of its own', async () => { + 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) - const order: string[] = [] - claude.routes.set_model = () => { - order.push('set_model') - return undefined - } await adapter.acquire({ ...ACQUIRE, options: { model: 'opus' } }) + expect(claude.connections[0].launch.options.model).toBe('opus') expect(events.some((event) => event.type === 'started')).toBe(false) await vi.advanceTimersByTimeAsync(SLOW_INIT_MS) - await adapter.awaitStarted('session-1') + await claudeStartupSettled(adapter, 'session-1') const startedAt = events.findIndex((event) => event.type === 'started') expect(events[startedAt]).toEqual({ @@ -85,12 +85,14 @@ describe('Claude structured session publishes before the CLI answers initialize' sessionId: 'session-1', fence: 7, acquisitionGeneration: expect.any(String), - // What the restore just proved, carried so the host never asks the CLI again. + // What the child was launched with, carried so the host never asks the CLI again. reportedOptions: expect.objectContaining({ model: 'opus' }), restoreSkippedOptions: [] }) - // The restore wrote the saved model before `started`; no message waits inside the adapter. - expect(order).toEqual(['set_model']) + expect(claude.connections[0].calls.map(({ subtype }) => subtype)).toEqual([ + 'initialize', + 'get_settings' + ]) expect(claude.connections[0].sent).toEqual([]) expect(events.slice(0, startedAt).some((event) => event.type === 'options')).toBe(true) await adapter.closeAll() @@ -132,24 +134,63 @@ describe('Claude structured session publishes before the CLI answers initialize' await adapter.closeAll() }) - // The host's delivery loop waits here before it hands a message over, so the adapter no longer - // holds prompts of its own: nothing is written until startup lands because nothing is sent. - it('resolves awaitStarted only once startup lands', async () => { + // The child was launched with the chat's saved options, so nothing waits on initialize: the SDK + // streams the message to the CLI, which takes it behind its own start. + it('writes a message sent before initialize answers at once', async () => { const claude = fakeClaude({ initDelayMs: SLOW_INIT_MS }) - const { adapter } = startingAdapter(claude) - await adapter.acquire(ACQUIRE) - let started = false - const waited = adapter.awaitStarted('session-1').then(() => { - started = true - }) - - await vi.advanceTimersByTimeAsync(SLOW_INIT_MS - 1) - expect(started).toBe(false) - await vi.advanceTimersByTimeAsync(1) - await waited + const { adapter, events } = startingAdapter(claude) + await adapter.acquire({ ...ACQUIRE, options: { model: 'opus' } }) await expect(adapter.dispatch(PROMPT)).resolves.toEqual({ state: 'admitted' }) expect(claude.connections[0].sent).toEqual([expect.objectContaining({ type: 'user' })]) + expect(events.some((event) => event.type === 'started')).toBe(false) + + await vi.advanceTimersByTimeAsync(SLOW_INIT_MS) + await claudeStartupSettled(adapter, 'session-1') + expect(events.some((event) => event.type === 'started')).toBe(true) + await adapter.closeAll() + }) + + it('confirms a launched effort only when the settings readback reports that effort', async () => { + const reportsHigh = { + applied: { model: 'claude-sonnet-5', effort: 'high' }, + effective: { model: 'claude-sonnet-5', effortLevel: 'high', env: {} }, + sources: {} + } + const agreeing = fakeClaude({ settings: reportsHigh }) + const disagreeing = fakeClaude({ settings: reportsHigh }) + const started = async (claude: ReturnType, effort: string) => { + const { adapter } = startingAdapter(claude) + await adapter.acquire({ ...ACQUIRE, options: { effort } }) + await claudeStartupSettled(adapter, 'session-1') + return adapter + } + + const kept = await started(agreeing, 'high') + await expect(kept.readOptions({ sessionId: 'session-1', fence: 7 })).resolves.toMatchObject({ + current: { effort: 'high', confirmed: expect.arrayContaining(['effort']) } + }) + // The saved pick stays wanted; only the readback could vouch for it, and it did not. + const unconfirmed = await started(disagreeing, 'low') + const options = await unconfirmed.readOptions({ sessionId: 'session-1', fence: 7 }) + expect(options.current.effort).toBe('low') + expect(options.current.confirmed ?? []).not.toContain('effort') + await kept.closeAll() + await unconfirmed.closeAll() + }) + + // Measured on Claude Code 2.1.280: `--effort xhigh` reads back as `applied.effort: 'xhigh'` with + // `effective` empty, since `effective` holds only the settings files. + it('confirms a launched effort the CLI reports only as applied', async () => { + const claude = fakeClaude({ + settings: { applied: { model: 'claude-opus-5', effort: 'xhigh' }, effective: {}, sources: {} } + }) + const { adapter } = startingAdapter(claude) + await adapter.acquire({ ...ACQUIRE, options: { effort: 'xhigh' } }) + await claudeStartupSettled(adapter, 'session-1') + + const options = await adapter.readOptions({ sessionId: 'session-1', fence: 7 }) + expect(options.current).toMatchObject({ effort: 'xhigh', confirmed: ['effort'] }) await adapter.closeAll() }) @@ -162,7 +203,7 @@ describe('Claude structured session publishes before the CLI answers initialize' await adapter.acquire(ACQUIRE) await vi.advanceTimersByTimeAsync(SLOW_INIT_MS) - await adapter.awaitStarted('session-1') + await claudeStartupSettled(adapter, 'session-1') await adapter.drainObservedExits() expect(events.find((event) => event.type === 'ended')).toMatchObject({ @@ -184,7 +225,7 @@ describe('Claude structured session publishes before the CLI answers initialize' await adapter.acquire(ACQUIRE) await vi.advanceTimersByTimeAsync(SLOW_INIT_MS) - await adapter.awaitStarted('session-1') + await claudeStartupSettled(adapter, 'session-1') await adapter.drainObservedExits() // A failed start is released on the same evidence a failed create is. @@ -194,11 +235,97 @@ describe('Claude structured session publishes before the CLI answers initialize' }) }) + it('lands a start whose CLI answers initialize only after minutes', async () => { + const claude = fakeClaude({ initDelayMs: NEVER_MS - 1 }) + const { adapter, events } = startingAdapter(claude) + await adapter.acquire(ACQUIRE) + + await vi.advanceTimersByTimeAsync(NEVER_MS) + await claudeStartupSettled(adapter, 'session-1') + + expect(events.some((event) => event.type === 'started')).toBe(true) + expect(events.some((event) => event.type === 'ended')).toBe(false) + expect(claude.connections[0].closeCount).toBe(0) + await adapter.closeAll() + }) + + // No deadline ends it: a Stop or a close does, and every message is already written meanwhile. + it('keeps a start whose CLI never answers initialize, taking messages, until it is closed', async () => { + const claude = fakeClaude({ initDelayMs: 10 * NEVER_MS }) + const { adapter, events } = startingAdapter(claude) + await adapter.acquire(ACQUIRE) + await expect(adapter.dispatch(PROMPT)).resolves.toEqual({ state: 'admitted' }) + + await vi.advanceTimersByTimeAsync(NEVER_MS) + + expect(events.some((event) => event.type === 'ended' || event.type === 'started')).toBe(false) + expect(claude.connections[0].closeCount).toBe(0) + expect(claude.connections[0].sent).toEqual([expect.objectContaining({ type: 'user' })]) + await expect(adapter.closeSession('session-1')).resolves.toBe(true) + expect(claude.connections[0].closeCount).toBe(1) + }) + + it('fails a start at once when a frame names another session before initialize answers', async () => { + const claude = fakeClaude({ initDelayMs: NEVER_MS, initProof: 'none' }) + const { adapter, events } = startingAdapter(claude) + await adapter.acquire(ACQUIRE) + + claude.connections[0].handlers.onMessage?.({ + type: 'system', + subtype: 'hook_started', + hook_name: 'SessionStart:startup', + session_id: 'foreign-session' + }) + await vi.advanceTimersByTimeAsync(0) + await claudeStartupSettled(adapter, 'session-1') + await adapter.drainObservedExits() + + expect(events.find((event) => event.type === 'ended')).toMatchObject({ + reason: 'claude provider session expected', + failure: { kind: 'startFailed' }, + startupUnproven: true + }) + }) + + // Only a SessionStart hook sends a start frame before the first turn; without one, the + // initialize answer is the whole start. + it('lands a start whose CLI sends no start frame before its first turn', async () => { + const claude = fakeClaude({ initProof: 'none' }) + const { adapter, events } = startingAdapter(claude) + await adapter.acquire(ACQUIRE) + await claudeStartupSettled(adapter, 'session-1') + + expect(events.some((event) => event.type === 'started')).toBe(true) + await expect(adapter.dispatch(PROMPT)).resolves.toEqual({ state: 'admitted' }) + expect(claude.connections[0].sent).toEqual([expect.objectContaining({ type: 'user' })]) + await adapter.closeAll() + }) + + it('ends a started session whose first frame names another provider session', async () => { + const claude = fakeClaude({ initProof: 'none' }) + const { adapter, events } = startingAdapter(claude) + await adapter.acquire(ACQUIRE) + await claudeStartupSettled(adapter, 'session-1') + + claude.connections[0].handlers.onMessage?.({ + type: 'system', + subtype: 'init', + session_id: 'foreign-session' + }) + await adapter.drainObservedExits() + + expect(events.find((event) => event.type === 'ended')).toMatchObject({ + reason: 'claude provider session expected', + cause: 'unexpected-exit' + }) + expect(claude.connections[0].closeCount).toBe(1) + }) + it('ends an unauthenticated start with sign-in guidance', async () => { const claude = fakeClaude({ initAccount: { apiProvider: 'firstParty', tokenSource: 'none' } }) const { adapter, events } = startingAdapter(claude) await adapter.acquire(ACQUIRE) - await adapter.awaitStarted('session-1') + await claudeStartupSettled(adapter, 'session-1') await adapter.drainObservedExits() expect(events.find((event) => event.type === 'ended')).toMatchObject({ @@ -218,7 +345,7 @@ describe('Claude structured session publishes before the CLI answers initialize' const claude = fakeClaude({ initAccount: { apiProvider: 'firstParty', ...account } }) const { adapter, events } = startingAdapter(claude) await adapter.acquire(ACQUIRE) - await adapter.awaitStarted('session-1') + await claudeStartupSettled(adapter, 'session-1') expect(events.some((event) => event.type === 'started')).toBe(true) expect(events.some((event) => event.type === 'ended')).toBe(false) @@ -231,7 +358,7 @@ describe('Claude structured session publishes before the CLI answers initialize' }) const { adapter, events } = startingAdapter(claude) await adapter.acquire(ACQUIRE) - await adapter.awaitStarted('session-1') + await claudeStartupSettled(adapter, 'session-1') await adapter.drainObservedExits() expect(events.find((event) => event.type === 'ended')).toMatchObject({ @@ -239,14 +366,14 @@ describe('Claude structured session publishes before the CLI answers initialize' }) }) - // A Stop that closes a child still starting must end the wait the host's delivery loop is in, - // though initialize never answers; otherwise every later send joins a loop that never moves. + // A Stop that closes a child still starting must end the wait an option write is in, though + // initialize never answers. it('ends the wait on a start closed before init, without faulting it', async () => { const claude = fakeClaude({ initDelayMs: 10 * SLOW_INIT_MS }) const { adapter, events } = startingAdapter(claude) await adapter.acquire(ACQUIRE) let ended = false - const waited = adapter.awaitStarted('session-1').then(() => { + const waited = claudeStartupSettled(adapter, 'session-1').then(() => { ended = true }) @@ -263,17 +390,19 @@ describe('Claude structured session publishes before the CLI answers initialize' expect(events.some((event) => event.type === 'started')).toBe(false) }) - it('interrupts nothing when Stop lands before init: nothing was written', async () => { + // The CLI answers no control request before initialize; Claude's Stop ends the child instead. + it('sends no interrupt to a CLI still starting, though it was handed a message', async () => { const claude = fakeClaude({ initDelayMs: SLOW_INIT_MS }) const { adapter } = startingAdapter(claude) await adapter.acquire(ACQUIRE) + await adapter.dispatch(PROMPT) - await expect( - adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-1', fence: 7 }) - ).resolves.toEqual({ cancelled: false }) + await expect(adapter.cancelTurn({ sessionId: 'session-1', fence: 7 })).resolves.toEqual({ + cancelled: false + }) expect(claude.connections[0].calls.map(({ subtype }) => subtype)).not.toContain('interrupt') - expect(claude.connections[0].sent).toEqual([]) + expect(adapter.stopEndsSession()).toBe(true) await adapter.closeAll() }) }) diff --git a/src/main/claude/claude-structured-session-startup.ts b/src/main/claude/claude-structured-session-startup.ts index ce14a64dd2cf..e7e0c12f338e 100644 --- a/src/main/claude/claude-structured-session-startup.ts +++ b/src/main/claude/claude-structured-session-startup.ts @@ -1,9 +1,10 @@ -// What Claude reports at initialize, read after the session is already published. None of it -// gates the create: a slow start is still a start, and every way it can fail (exit, auth, -// a foreign session id) faults the published session through its exit path. +// 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. import type { - StructuredAgentSessionAcquireInput, + StructuredAgentSessionOptionsSkippedEvent, StructuredAgentSessionStartedEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { ClaudeStreamJsonConnection } from './claude-stream-json-connection' @@ -15,69 +16,124 @@ import { readClaudeModels, type ClaudeInitObservation } from './claude-structured-init-proof' -import { restoreClaudeStructuredSessionOptions } from './claude-structured-options' import { claudeStructuredSessionPublicationOptions, prepareClaudeStructuredSessionAcquisitionOptions, readClaudeStructuredSessionSettings } from './claude-structured-session-acquisition-options' +import { claudeRetiredOptions } from './claude-structured-retired-model' import { claudeStructuredSessionOptionsFrom, observeClaudeSettingsApplied, readClaudeSettingsEffort } from './claude-structured-session-options' -import { failClaudeStartup } from './claude-structured-session-startup-state' +import { + failClaudeStartup, + type ClaudeSessionStartup +} from './claude-structured-session-startup-state' import type { 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 resolve = (_init: ClaudeInitObservation): void => {} - let reject = (_error: Error): void => {} - const promise = new Promise((resolvePromise, rejectPromise) => { - resolve = resolvePromise - reject = rejectPromise + let resolvePromise = (_init: ClaudeInitObservation): void => {} + let rejectPromise = (_error: Error): void => {} + const promise = new Promise((resolve, reject) => { + resolvePromise = resolve + rejectPromise = reject }) void promise.catch(() => {}) - return { promise, resolve, reject } + 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' + 'reportedOptions' | 'restoreSkippedOptions' | 'retiredOptions' > +/** A lifecycle event the start reports, before the session's identity is stamped on it. */ +export type ClaudeStartupReport = + | ({ type: 'started' } & StructuredAgentSessionStartedOptions) + | Pick + export type ClaudeStartupFacts = { - init: ClaudeInitObservation + init: ClaudeInitObservation | null + initProof: ClaudeInitProof initialization: unknown settings: unknown prepared: ReturnType + resumesTranscript: boolean + requestTimeoutMs: number | undefined } -/** Settles on the CLI's answers or on its exit; there is no startup timer. */ +/** Settles on the CLI's initialize answer, or on its exit or a refused proof. */ export async function readClaudeStartupFacts(input: { connection: ClaudeStreamJsonConnection initProof: ClaudeInitProof sessionId: string providerSessionId: string + startup: Pick resumesTranscript: boolean - inputOptions: StructuredAgentSessionAcquireInput['options'] requestTimeoutMs: number | undefined emit: (event: ClaudeStructuredSessionEvent) => void }): Promise { - const [initialization, init] = await Promise.all([ + // 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) => { + input.startup.answered = true const authError = claudeInitializationAuthError(result) if (authError) { throw authError } return result }), - input.initProof.promise + input.initProof.promise.then(() => new Promise(() => {})) ]) if (input.connection.closed) { throw new Error('claude session closed before startup completed') @@ -87,30 +143,26 @@ export async function readClaudeStartupFacts(input: { sessionId: input.sessionId, models: readClaudeModels(initialization) }) - if (init.providerSessionId !== input.providerSessionId) { - throw new Error( - `claude proved session ${init.providerSessionId}, expected ${input.providerSessionId}` - ) - } + 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(init, settings) + diagnostic: claudeAuthDiagnostic(initialization, init, settings) }) return { init, + initProof: input.initProof, initialization, settings, - prepared: prepareClaudeStructuredSessionAcquisitionOptions({ - settings, - initialization, - inputOptions: input.inputOptions, - resumesTranscript: input.resumesTranscript - }) + prepared: prepareClaudeStructuredSessionAcquisitionOptions({ settings, initialization }), + resumesTranscript: input.resumesTranscript, + requestTimeoutMs: input.requestTimeoutMs } } @@ -119,29 +171,40 @@ function applyClaudeStartupFacts(session: ClaudeSession, facts: ClaudeStartupFac const effort = readClaudeSettingsEffort(settings) const published = claudeStructuredSessionPublicationOptions(prepared) // A turn's own init frame may already have reported the running model. - if (init.model && session.reportedOptions.model === undefined) { + if (init?.model && session.reportedOptions.model === undefined) { session.reportedOptions.model = init.model session.reportedModelMutation = session.optionMutationSequence } observeClaudeSettingsApplied(session, settings) + // 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 if (effort) { session.reportedOptions.effort = effort + if (agrees('effort', effort)) { + session.confirmedOptions.add('effort') + } + } + // A launch `--effort` shows only in `applied`, never in `effective` (measured on 2.1.280). + const launchedEffort = session.options.get('effort') + if (launchedEffort !== undefined && session.appliedOptions?.effort === launchedEffort) { session.confirmedOptions.add('effort') } if (published.fastMode !== null) { session.reportedOptions.fastMode = published.fastMode - session.confirmedOptions.add('fastMode') + if (agrees('fastMode', String(published.fastMode))) { + session.confirmedOptions.add('fastMode') + } } if (published.fastModePerSessionOptIn !== null) { session.fastModePerSessionOptIn = published.fastModePerSessionOptIn } session.fastModeState ??= published.fastModeState session.fastModeDisabledReason ??= published.fastModeDisabledReason - session.options = prepared.options - session.capabilities = readClaudeCapabilities(session.capabilities, initialization, init.message) + 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.commands = new ClaudeSlashCommandCatalog(init?.message, initialization) } session.events?.publish() } @@ -160,16 +223,16 @@ function claudeStartedReportedOptions( return persisted } -/** Applies startup facts to the published session and restores saved options; only then does the - * session take input. Any failure faults the session so the user sees why it never started. */ +/** Applies startup facts to the published session, which already takes input. Any failure faults + * the session so the user sees why it never started. */ export async function settleClaudeSessionStartup(input: { session: ClaudeSession facts: Promise isCurrent: () => boolean - requestTimeoutMs: number | undefined fault: (error: Error) => void - /** Startup has proven; `options` is what the child now reports, snapshotted from memory. */ - onStarted: (options: StructuredAgentSessionStartedOptions) => void + /** `started` once startup has proven, with what the child now reports, snapshotted from memory; + * then any saved option the child showed it cannot run. */ + report: (event: ClaudeStartupReport) => void }): Promise { const { session } = input const superseded = (): boolean => { @@ -184,20 +247,36 @@ export async function settleClaudeSessionStartup(input: { if (superseded()) { return } + // From here no read of the proof is pending, so a frame naming another session ends it. + facts.initProof.onRefusal = (error) => { + failClaudeStartup(session, error) + if (input.isCurrent()) { + input.fault(error) + } + } applyClaudeStartupFacts(session, facts) - await restoreClaudeStructuredSessionOptions(session, input.requestTimeoutMs) + const startFastMode = admitClaudeStartFastMode(session, facts) if (!superseded()) { - input.onStarted({ + 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] + 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) + } + }) + } } } catch (caught) { const error = caught instanceof Error ? caught : new Error(String(caught)) diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index 876090c07e4d..e327e2316219 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -6,7 +6,10 @@ import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' -import type { StructuredAgentSessionStartedEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { + StructuredAgentSessionOptionsSkippedEvent, + StructuredAgentSessionStartedEvent +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { ClaudeStreamJsonConnection, openClaudeStreamJsonConnection @@ -65,8 +68,9 @@ export type ClaudeStructuredSessionEvent = fence: number } | { type: 'auth-diagnostic'; sessionId: string; diagnostic: ClaudeAuthDiagnostic } - /** Startup facts applied and saved options restored; held prompts are about to be written. */ + /** Startup facts applied; the child already took any message written to it. */ | StructuredAgentSessionStartedEvent + | StructuredAgentSessionOptionsSkippedEvent | { type: 'ended' sessionId: string @@ -80,6 +84,8 @@ export type ClaudeStructuredSessionEvent = observedAt?: number /** The child ended before proving startup, so reacquiring would repeat the same start. */ startupUnproven?: true + /** The child ended before it answered initialize, so it ran nothing it was handed. */ + startupUnanswered?: true } export type ClaudeLateDispatchOutcome = @@ -179,6 +185,10 @@ export type ClaudeSession = { /** Options whose recorded value the provider reported, not merely accepted. */ confirmedOptions: Set restoreSkippedOptions: Set + /** The model the child was launched with, as `--model`; null when it runs the CLI's own. */ + launchedModel: string | null + /** The launch left the saved Fast out, so the start applies it once the settings are read. */ + fastModeAtStart: boolean /** Absent when the adapter runs without a host catalog store (tests). */ catalogAccess?: AgentModelCatalogSessionAccess /** CLI-advertised protocol capabilities from init; gates interrupt-receipt handling. */ @@ -206,7 +216,7 @@ export type ClaudeSession = { translator: ClaudeJournalTranslator | null events: StructuredAgentSessionEventSink | undefined unbindReadingControl?: () => void - /** Published at spawn; init facts, option restore and queued prompts land when startup does. */ + /** Published at spawn with its saved options; init facts land when startup does. */ startup: ClaudeSessionStartup } diff --git a/src/main/claude/claude-structured-session-test-support.ts b/src/main/claude/claude-structured-session-test-support.ts index 2b5d28c99bcb..c29c6693591f 100644 --- a/src/main/claude/claude-structured-session-test-support.ts +++ b/src/main/claude/claude-structured-session-test-support.ts @@ -16,6 +16,7 @@ import { } from './claude-structured-session-adapter' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' +export { claudeStartupSettled } from './claude-structured-startup-settled-test-support' export const PROVIDER_SESSION_ID = '819cf9f8-e43c-4ad7-b50f-54aa158a726a' @@ -52,8 +53,9 @@ export function fakeClaude( initSessionId?: string initUuid?: string initModel?: string - /** 'session-start' (default) mirrors live: a SessionStart hook frame proves the - * session and system/init arrives only when the first command starts a cycle. + /** 'session-start' (default) mirrors live with a SessionStart hook installed: its + * frame proves the session and system/init arrives only when the first command starts a + * cycle; 'none' mirrors live without one. * 'init' emits init at startup — an UNMEASURED shape, opt-in only. */ initProof?: 'init' | 'session-start' | 'none' initAccount?: unknown @@ -127,8 +129,9 @@ export function fakeClaude( // sessions prove startup with a SessionStart hook frame instead. emitCycleInit() } else if (options.initProof !== 'none') { - // The live proof order (measured through Orca's adapter): SessionStart - // hook frames arrive first; system/init only when a cycle starts. + // The live order with a SessionStart hook installed (Orca's status hooks): + // its frames arrive first; system/init only when a cycle starts. Without + // one, nothing arrives before the first turn ('none'). handlers.onMessage?.({ type: 'system', subtype: 'hook_started', @@ -159,12 +162,13 @@ export function fakeClaude( }, getSettings: async () => { connection.calls.push({ subtype: 'get_settings' }) - // Shape measured from Claude Code 2.1.258: {applied, effective, sources}, - // and the only place the session's current effort is reported. + // Shape measured from Claude Code 2.1.258: {applied, effective, sources}. A launch + // `--effort` shows only in `applied`, with no `effective.effortLevel` (2.1.280). + const launched = launch.options.effort return ( options.settings ?? { - applied: { model: 'claude-sonnet-5', effort: 'high', advisor: null, ultracode: false }, - effective: { model: 'claude-sonnet-5', effortLevel: 'high', env: {} }, + applied: { model: 'claude-sonnet-5', effort: launched ?? 'high', ultracode: false }, + effective: { model: 'claude-sonnet-5', ...(launched ? {} : { effortLevel: 'high' }) }, sources: {} } ) @@ -175,7 +179,7 @@ export function fakeClaude( }, setModel: async (model) => { connection.calls.push({ subtype: 'set_model', params: { model } }) - routed('set_model', { model }) + await routed('set_model', { model }) }, setPermissionMode: async (mode) => { connection.calls.push({ subtype: 'set_permission_mode', params: { mode } }) @@ -183,7 +187,7 @@ export function fakeClaude( }, applyFlagSettings: async (settings) => { connection.calls.push({ subtype: 'apply_flag_settings', params: { settings } }) - routed('apply_flag_settings', { settings }) + await routed('apply_flag_settings', { settings }) }, interrupt: async (interruptOptions) => { connection.calls.push({ @@ -248,7 +252,7 @@ export function adapterFor( const acquire = adapter.acquire adapter.acquire = async (input) => { const acquisition = await acquire(input) - await adapter.awaitStarted(input.identity.sessionId) + await adapter['sessions'].get(input.identity.sessionId)?.startup.settled return acquisition } return adapter diff --git a/src/main/claude/claude-structured-spawn-options.test.ts b/src/main/claude/claude-structured-spawn-options.test.ts new file mode 100644 index 000000000000..42780ae49986 --- /dev/null +++ b/src/main/claude/claude-structured-spawn-options.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it } from 'vitest' +import { + CLAUDE_STRUCTURED_BASE_OPTIONS, + claudeStructuredPermissionOptions, + type ClaudeStructuredSdkOptions +} from './claude-structured-launch-resolution' +import { claudeStructuredSpawnOptions } from './claude-structured-spawn-options' + +function launched( + saved: Record, + options: { resumesTranscript?: boolean; base?: ClaudeStructuredSdkOptions } = {} +) { + return claudeStructuredSpawnOptions({ + launch: { + options: options.base ?? CLAUDE_STRUCTURED_BASE_OPTIONS, + resumesTranscript: options.resumesTranscript ?? true + }, + saved + }) +} + +const BYPASS_LAUNCH: ClaudeStructuredSdkOptions = { + ...CLAUDE_STRUCTURED_BASE_OPTIONS, + extraArgs: { + ...CLAUDE_STRUCTURED_BASE_OPTIONS.extraArgs, + ...claudeStructuredPermissionOptions('bypassPermissions').extraArgs + } +} + +describe('a Claude chat launched with its saved options', () => { + it('passes the saved model, effort, Fast and permission mode as launch options', () => { + const spawn = launched({ + model: 'opus', + effort: 'high', + fastMode: 'true', + permissionMode: 'plan' + }) + + expect(spawn.sdkOptions).toMatchObject({ + model: 'opus', + effort: 'high', + settings: { fastMode: true }, + permissionMode: 'plan', + // The base launch is kept whole. + includePartialMessages: true, + extraArgs: { 'replay-user-messages': null } + }) + expect(Object.fromEntries(spawn.options)).toEqual({ + model: 'opus', + effort: 'high', + fastMode: 'true', + permissionMode: 'plan' + }) + expect(spawn.skipped).toEqual([]) + }) + + // A resolved id, a model newer than any listing Orca holds, a retired one: the CLI's own answer + // decides, and a turn's model-not-found error drops it then. + it.each(['claude-sonnet-5', 'claude-next-9', 'claude-retired-1'])( + 'passes the saved model %s as it was picked', + (model) => { + const spawn = launched({ model, effort: 'xhigh' }) + + expect(spawn.sdkOptions).toMatchObject({ model, effort: 'xhigh' }) + expect(spawn.skipped).toEqual([]) + } + ) + + it('leaves out an effort or permission mode no Claude can parse, and Fast it cannot decode', () => { + const spawn = launched({ effort: 'ludicrous', permissionMode: 'retired-mode', fastMode: 'yes' }) + + expect(spawn.sdkOptions).not.toHaveProperty('effort') + expect(spawn.sdkOptions).not.toHaveProperty('permissionMode') + expect(spawn.sdkOptions).not.toHaveProperty('settings') + expect(spawn.skipped).toEqual(['effort', 'fastMode', 'permissionMode']) + }) + + it('keeps a saved Fast on for a new conversation, for its start to apply', () => { + const spawn = launched({ fastMode: 'true' }, { resumesTranscript: false }) + + expect(spawn.sdkOptions).not.toHaveProperty('settings') + expect(spawn.options.get('fastMode')).toBe('true') + expect(spawn.fastModeAtStart).toBe(true) + expect(spawn.skipped).toEqual([]) + }) + + it('passes a saved Fast off to a new conversation', () => { + const spawn = launched({ fastMode: 'false' }, { resumesTranscript: false }) + + expect(spawn.sdkOptions.settings).toEqual({ fastMode: false }) + expect(spawn.fastModeAtStart).toBe(false) + }) + + // The SDK writes the agent Arguments after its own options, so left in, the Arguments' flag would + // reach the CLI as a second `--model` after the chat's pick. + it("replaces the agent Arguments' model and effort with the chat's saved ones", () => { + const base = { + ...CLAUDE_STRUCTURED_BASE_OPTIONS, + extraArgs: { ...CLAUDE_STRUCTURED_BASE_OPTIONS.extraArgs, model: 'opus', effort: 'max' } + } + + expect(launched({ model: 'sonnet', effort: 'low' }, { base }).sdkOptions).toMatchObject({ + model: 'sonnet', + effort: 'low', + extraArgs: { 'replay-user-messages': null } + }) + expect(launched({ model: 'sonnet', effort: 'low' }, { base }).sdkOptions.extraArgs).toEqual({ + 'replay-user-messages': null + }) + // With nothing saved, the Arguments decide. + expect(launched({}, { base }).sdkOptions.extraArgs).toEqual(base.extraArgs) + }) + + // One `--settings` reaches the CLI: the Arguments' file is kept, and the start applies Fast. + it.each(['true', 'false'])( + "keeps the agent Arguments' settings and leaves a saved Fast %s to the start", + (fastMode) => { + const base = { + ...CLAUDE_STRUCTURED_BASE_OPTIONS, + extraArgs: { ...CLAUDE_STRUCTURED_BASE_OPTIONS.extraArgs, settings: '/repo/claude.json' } + } + const spawn = launched({ fastMode }, { base }) + + expect(spawn.sdkOptions).not.toHaveProperty('settings') + expect(spawn.sdkOptions.extraArgs).toEqual(base.extraArgs) + expect(spawn.options.get('fastMode')).toBe(fastMode) + expect(spawn.fastModeAtStart).toBe(true) + } + ) + + it('launches a saved bypass under an Agent Permissions bypass with the owned bypass flag', () => { + const spawn = launched({ permissionMode: 'bypassPermissions' }, { base: BYPASS_LAUNCH }) + + expect(spawn.sdkOptions.extraArgs).toEqual({ + 'replay-user-messages': null, + 'dangerously-skip-permissions': null + }) + expect(spawn.sdkOptions).not.toHaveProperty('permissionMode') + expect(spawn.sdkOptions).not.toHaveProperty('allowDangerouslySkipPermissions') + }) + + it('never widens the Agent Permissions setting to a saved bypass', () => { + const spawn = launched({ permissionMode: 'bypassPermissions' }) + + expect(spawn.sdkOptions.extraArgs).toEqual({ 'replay-user-messages': null }) + expect(spawn.sdkOptions).not.toHaveProperty('allowDangerouslySkipPermissions') + expect(spawn.skipped).toEqual(['permissionMode']) + }) + + // Known limit: the allow flag that would keep bypass reachable is one older CLIs reject at start. + it('starts a saved narrower mode under an Agent Permissions bypass without any bypass flag', () => { + const spawn = launched({ permissionMode: 'acceptEdits' }, { base: BYPASS_LAUNCH }) + + expect(spawn.sdkOptions.permissionMode).toBe('acceptEdits') + expect(spawn.sdkOptions).not.toHaveProperty('allowDangerouslySkipPermissions') + expect(spawn.sdkOptions.extraArgs).toEqual({ 'replay-user-messages': null }) + }) + + it('starts a saved narrower mode without the allow flag when Agent Permissions prompts', () => { + const spawn = launched({ permissionMode: 'plan' }) + + expect(spawn.sdkOptions.permissionMode).toBe('plan') + expect(spawn.sdkOptions).not.toHaveProperty('allowDangerouslySkipPermissions') + }) +}) diff --git a/src/main/claude/claude-structured-spawn-options.ts b/src/main/claude/claude-structured-spawn-options.ts new file mode 100644 index 000000000000..be3414358646 --- /dev/null +++ b/src/main/claude/claude-structured-spawn-options.ts @@ -0,0 +1,166 @@ +// A chat's saved options as the Claude child's launch options. The child starts already running +// them, so its first message can be written at once instead of waiting for initialize to answer and +// a control request to apply each one. + +import type { EffortLevel, PermissionMode } from '@anthropic-ai/claude-agent-sdk' +import { decodeStructuredAgentSessionOptionValue } from '../../shared/structured-agent-session-option-codec' +import type { + ClaudeStructuredLaunch, + ClaudeStructuredSdkOptions +} from './claude-structured-launch-resolution' +import type { ClaudeSession } from './claude-structured-session-state' +import { restoredClaudeStructuredSessionOptions } from './claude-structured-options' + +const EFFORT_LEVELS: ReadonlySet = new Set([ + 'low', + 'medium', + 'high', + 'xhigh', + 'max' +]) +const PERMISSION_MODES: ReadonlySet = new Set([ + 'default', + 'acceptEdits', + 'bypassPermissions', + 'plan', + 'dontAsk', + 'auto' +]) + +/** Whether `options` launch with the Agent Permissions bypass. */ +function claudeStructuredOptionsBypassPermissions(options: ClaudeStructuredSdkOptions): boolean { + return options.extraArgs?.['dangerously-skip-permissions'] !== undefined +} + +/** `options` launched in `mode` instead of the mode they carry; never more than they allow. */ +function claudeStructuredOptionsWithPermissionMode( + options: ClaudeStructuredSdkOptions, + mode: PermissionMode +): ClaudeStructuredSdkOptions { + if (mode === 'bypassPermissions') { + return options + } + // Known limit: no switch back to bypass later; that needs the allow flag older CLIs reject. + const { 'dangerously-skip-permissions': _bypass, ...extraArgs } = options.extraArgs ?? {} + return { ...options, permissionMode: mode, extraArgs } +} + +/** The saved value stands in for the agent Arguments' own flag for it: the chat's pick wins, and + * the CLI is never handed the flag twice. */ +function withoutConfiguredFlag( + options: ClaudeStructuredSdkOptions, + flag: string +): ClaudeStructuredSdkOptions { + if (!options.extraArgs || !Object.hasOwn(options.extraArgs, flag)) { + return options + } + const { [flag]: _configured, ...extraArgs } = options.extraArgs + return { ...options, extraArgs } +} + +function isEffortLevel(value: string): value is EffortLevel { + return EFFORT_LEVELS.has(value) +} + +function isPermissionMode(value: string): value is PermissionMode { + return PERMISSION_MODES.has(value) +} + +export type ClaudeStructuredSpawnOptions = { + sdkOptions: ClaudeStructuredSdkOptions + /** The chat's options as the session holds them: what was launched, and a Fast the start applies. */ + options: Map + /** Saved options left out: the provider's own value wins and is re-persisted. */ + skipped: readonly string[] + /** A saved Fast the launch does not carry, which the start applies. */ + fastModeAtStart: boolean +} + +/** + * Every saved value is passed as the user chose it; the CLI's own answer (a turn's error, the + * settings readback) says what it ran. Only a value no Claude version can parse is left out, by + * the SDK's types, not the installed binary's: one that binary rejects fails its start. + */ +export function claudeStructuredSpawnOptions(input: { + launch: Pick + saved: Readonly> | undefined +}): ClaudeStructuredSpawnOptions { + const saved = restoredClaudeStructuredSessionOptions(input.saved) + const options = new Map() + const skipped: string[] = [] + let sdkOptions: ClaudeStructuredSdkOptions = { ...input.launch.options } + let fastModeAtStart = false + const model = saved.get('model') + if (model !== undefined) { + options.set('model', model) + sdkOptions = { ...withoutConfiguredFlag(sdkOptions, 'model'), model } + } + const effort = saved.get('effort') + if (effort !== undefined) { + if (isEffortLevel(effort)) { + options.set('effort', effort) + sdkOptions = { ...withoutConfiguredFlag(sdkOptions, 'effort'), effort } + } else { + skipped.push('effort') + } + } + const fastMode = saved.get('fastMode') + if (fastMode !== undefined) { + const decoded = decodeStructuredAgentSessionOptionValue('fastMode', fastMode) + if (typeof decoded !== 'boolean') { + skipped.push('fastMode') + } else { + options.set('fastMode', fastMode) + // A new CLI session may opt in to Fast per session, which only its settings say, and the + // agent Arguments' own `--settings` would be replaced by one carrying Fast: either way the + // start applies the saved Fast once it has read them (`applyClaudeStartFastMode`). + if ( + (!decoded || input.launch.resumesTranscript) && + !Object.hasOwn(sdkOptions.extraArgs ?? {}, 'settings') + ) { + sdkOptions.settings = { fastMode: decoded } + } else { + fastModeAtStart = true + } + } + } + const permissionMode = saved.get('permissionMode') + if (permissionMode !== undefined) { + // Bypass is the Agent Permissions setting's to grant; a saved pick never widens it. + if ( + isPermissionMode(permissionMode) && + (permissionMode !== 'bypassPermissions' || + claudeStructuredOptionsBypassPermissions(input.launch.options)) + ) { + options.set('permissionMode', permissionMode) + sdkOptions = claudeStructuredOptionsWithPermissionMode(sdkOptions, permissionMode) + } else { + skipped.push('permissionMode') + } + } + return { sdkOptions, options, skipped, fastModeAtStart } +} + +/** The published session takes on what its child was launched with. */ +export function adoptClaudeStructuredSpawnOptions( + session: Pick< + ClaudeSession, + 'restoreSkippedOptions' | 'translator' | 'launchedModel' | 'fastModeAtStart' + >, + spawn: Pick +): void { + session.launchedModel = spawn.options.get('model') ?? null + session.fastModeAtStart = spawn.fastModeAtStart + for (const key of spawn.skipped) { + session.restoreSkippedOptions.add(key) + } + const model = spawn.options.get('model') + if (model !== undefined) { + session.translator?.modelWritten(model) + } + // The journal's window was measured under a mode this child did not take (`opusplan`'s plan + // mode runs Opus); a launch never leaves a model out. + if (spawn.skipped.includes('permissionMode')) { + session.translator?.modelMayHaveChanged() + } +} diff --git a/src/main/claude/claude-structured-start-fast-mode.test.ts b/src/main/claude/claude-structured-start-fast-mode.test.ts new file mode 100644 index 000000000000..24057e865fca --- /dev/null +++ b/src/main/claude/claude-structured-start-fast-mode.test.ts @@ -0,0 +1,34 @@ +// A saved Fast the launch leaves out beside the agent Arguments' own `--settings`, applied by the +// start once the child answers. + +import { expect, it, vi } from 'vitest' +import { adapterFor, fakeClaude, identityFor } from './claude-structured-session-test-support' + +it("applies a resumed conversation's saved Fast off after start beside the Arguments' settings", async () => { + const claude = fakeClaude({ + settings: { effective: { fastMode: true, fastModePerSessionOptIn: false } } + }) + const adapter = adapterFor(claude, { + resumesTranscript: true, + continuesChain: true, + options: { extraArgs: { settings: '/repo/claude.json' } } + }) + + await adapter.acquire({ + identity: identityFor(), + fence: 7, + spawnToken: 'spawn-9', + options: { fastMode: 'false' } + }) + + expect(claude.connections[0].launch.options).not.toHaveProperty('settings') + expect(claude.connections[0].launch.options.extraArgs).toEqual({ + settings: '/repo/claude.json' + }) + await vi.waitFor(() => + expect(claude.connections[0].calls.at(-1)).toEqual({ + subtype: 'apply_flag_settings', + params: { settings: { fastMode: false } } + }) + ) +}) diff --git a/src/main/claude/claude-structured-start-fast-mode.ts b/src/main/claude/claude-structured-start-fast-mode.ts new file mode 100644 index 000000000000..df997380f1a7 --- /dev/null +++ b/src/main/claude/claude-structured-start-fast-mode.ts @@ -0,0 +1,72 @@ +// A saved Fast the Claude child was launched without, applied once its start has read the settings +// that decide whether it may run. + +import { ClaudeControlRequestError } from './claude-agent-sdk-control-requests' +import { readClaudeModels } from './claude-structured-init-proof' +import { listedModels } from './claude-structured-model-catalog' +import { claudeModelFastModeSupport } from './claude-structured-session-options' +import type { ClaudeStartupFacts, ClaudeStartupReport } from './claude-structured-session-startup' +import type { ClaudeSession } from './claude-structured-session-state' + +/** A saved Fast the launch left out (`fastModeAtStart`): on for a new conversation, whose settings + * may opt in to it per session, or either value beside the agent Arguments' own `--settings`. + * Those settings now read: a new conversation's opt-in drops it, as before, and so do the guards a + * live Fast write takes. The value still to be applied, or null. */ +export function admitClaudeStartFastMode( + session: ClaudeSession, + facts: ClaudeStartupFacts +): string | null { + const fastMode = session.options.get('fastMode') + if (!session.fastModeAtStart || fastMode === undefined) { + return null + } + if (fastMode !== 'true') { + return fastMode + } + if (!facts.resumesTranscript && facts.prepared.fastModePerSessionOptIn === true) { + session.options.delete('fastMode') + return null + } + // Over the listing this start already holds. + const listed = listedModels({ models: readClaudeModels(facts.initialization) }) + const blocked = + session.fastModeDisabledReason !== undefined && + !['preference', 'sdk_opt_in_required'].includes(session.fastModeDisabledReason) + if ( + blocked || + (listed.length > 0 && claudeModelFastModeSupport(session, listed).supported !== true) + ) { + session.options.delete('fastMode') + session.restoreSkippedOptions.add('fastMode') + return null + } + return fastMode +} + +/** Applies a saved Fast the launch left out after `started`, so nothing waits on it. A refusal + * drops it as main's refused restore did, from the record too; silence keeps it wanted and + * unconfirmed. A write the user made meanwhile owns the option. */ +export async function applyClaudeStartFastMode( + session: ClaudeSession, + facts: ClaudeStartupFacts, + fastMode: string, + report: (event: ClaudeStartupReport) => void +): Promise { + const sequence = session.optionMutationSequence + try { + await session.connection.applyFlagSettings( + { fastMode: fastMode === 'true' }, + { timeoutMs: facts.requestTimeoutMs } + ) + } catch (error) { + if (sequence !== session.optionMutationSequence) { + return + } + session.confirmedOptions.delete('fastMode') + if (error instanceof ClaudeControlRequestError) { + session.options.delete('fastMode') + session.restoreSkippedOptions.add('fastMode') + report({ type: 'options-skipped', options: { fastMode } }) + } + } +} diff --git a/src/main/claude/claude-structured-startup-settled-test-support.ts b/src/main/claude/claude-structured-startup-settled-test-support.ts new file mode 100644 index 000000000000..721e1acda5a1 --- /dev/null +++ b/src/main/claude/claude-structured-startup-settled-test-support.ts @@ -0,0 +1,10 @@ +import type { ClaudeStructuredSessionAdapter } from './claude-structured-session-adapter' + +/** Resolves once the session's background startup read has landed, faulted, or been ended. */ +export async function claudeStartupSettled( + adapter: ClaudeStructuredSessionAdapter, + sessionId: string +): Promise { + // Element access reaches the adapter's private map, which no production caller needs. + await adapter['sessions'].get(sessionId)?.startup.settled +} diff --git a/src/main/claude/claude-subagent-permission-request.test.ts b/src/main/claude/claude-subagent-permission-request.test.ts index 956d89becd2a..c3db6a4011b6 100644 --- a/src/main/claude/claude-subagent-permission-request.test.ts +++ b/src/main/claude/claude-subagent-permission-request.test.ts @@ -36,7 +36,8 @@ import type { ClaudeStructuredSessionEvent } from './claude-structured-session-s import { fakeClaude, identityFor, - PROVIDER_SESSION_ID + PROVIDER_SESSION_ID, + claudeStartupSettled } from './claude-structured-session-test-support' const SESSION = 'session-1' @@ -222,7 +223,7 @@ async function pipeline() { spawnToken: 'spawn-9', events: deferred.sink }) - await adapter.awaitStarted(SESSION) + await claudeStartupSettled(adapter, SESSION) const settle = async (): Promise => { for (let round = 0; round < 3; round += 1) { expect(await deferred.drained()).toEqual({ ok: true }) diff --git a/src/main/claude/hook-service.ts b/src/main/claude/hook-service.ts index 0031d8413f99..e71fd3b56739 100644 --- a/src/main/claude/hook-service.ts +++ b/src/main/claude/hook-service.ts @@ -24,6 +24,7 @@ import { export { getManagedScript } import { getManagedStatusLineScript } from './statusline-script' +import { refuseProfileAtDefaultHome } from './claude-profile-hook-target' import { applyManagedHooks, applyManagedStatusLine, @@ -64,6 +65,11 @@ type ClaudeHookInstallOptions = { claudeVersion?: string } +type ClaudeHookTargetOptions = ClaudeHookInstallOptions & { + /** An account profile on this host; omitted for the default home. */ + configDir?: string +} + const DEFAULT_CLAUDE_HOOK_SERVICE_OPTIONS: ClaudeHookServiceOptions = { agent: 'claude', displayName: 'Claude', @@ -97,8 +103,12 @@ export class ClaudeHookService { : (this.options.hookPlan ?? OPENCLAUDE_MANAGED_HOOK_PLAN) } - getStatus(options: ClaudeHookInstallOptions = {}): AgentHookInstallStatus { - const configPath = getConfigPath(this.options.settings) + getStatus(options: ClaudeHookTargetOptions = {}): AgentHookInstallStatus { + const refused = refuseProfileAtDefaultHome(this.options, options.configDir) + if (refused) { + return refused + } + const configPath = getConfigPath(this.options.settings, options.configDir) const scriptPath = getManagedScriptPath(this.options.settings) const config = readHooksJson(configPath) if (!config) { @@ -158,8 +168,12 @@ export class ClaudeHookService { ) } - install(options: ClaudeHookInstallOptions = {}): AgentHookInstallStatus { - const configPath = getConfigPath(this.options.settings) + install(options: ClaudeHookTargetOptions = {}): AgentHookInstallStatus { + const refused = refuseProfileAtDefaultHome(this.options, options.configDir) + if (refused) { + return refused + } + const configPath = getConfigPath(this.options.settings, options.configDir) const scriptPath = getManagedScriptPath(this.options.settings) const config = readHooksJson(configPath) if (!config) { @@ -186,9 +200,10 @@ export class ClaudeHookService { } else { writeManagedScript(scriptPath, payload) } - if (plan.statusLine === 'install') { + // Why: a profile's statusLine arrives with the settings merge from the default home. + if (options.configDir === undefined && plan.statusLine === 'install') { nextConfig = this.installManagedStatusLine(nextConfig) - } else if (plan.statusLine === 'retire') { + } else if (options.configDir === undefined && plan.statusLine === 'retire') { nextConfig = this.retireManagedStatusLine(nextConfig) } writeHooksJson(configPath, nextConfig) diff --git a/src/main/claude/hook-settings.ts b/src/main/claude/hook-settings.ts index 40449344f20e..ea418bc6bcc5 100644 --- a/src/main/claude/hook-settings.ts +++ b/src/main/claude/hook-settings.ts @@ -42,8 +42,8 @@ export const OPENCLAUDE_HOOK_SETTINGS: ClaudeCompatibleHookSettings = { usesWindowsCompatLauncher: false } -export function getConfigPath(settings = CLAUDE_HOOK_SETTINGS): string { - return join(homedir(), settings.configDirName, 'settings.json') +export function getConfigPath(settings = CLAUDE_HOOK_SETTINGS, configDir?: string): string { + return join(configDir ?? join(homedir(), settings.configDirName), 'settings.json') } export function getStatusLineScriptBaseName(settings = CLAUDE_HOOK_SETTINGS): string { diff --git a/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-interruption-source.test.ts b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-interruption-source.test.ts new file mode 100644 index 000000000000..5e6cd9af9b12 --- /dev/null +++ b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-interruption-source.test.ts @@ -0,0 +1,82 @@ +import { runInNewContext } from 'node:vm' +import { describe, expect, it, vi } from 'vitest' +import { DRAIN_APPLY_INTERRUPTION_JS } from './legacy-wsl-runtime-auth-drain-interruption-source' + +type CensusResult = { + status: number | null + stdout?: string + signal?: string + error?: Error +} + +function fixture(rows: CensusResult[], ownedPid = '100') { + const kill = vi.fn() + const census = vi.fn() + for (const row of rows) { + census.mockReturnValueOnce(row) + } + const context = { + process: { pid: 300, env: { ORCA_DRAIN_APPLY_PID: ownedPid }, kill }, + require: () => ({ spawnSync: census }) + } + return { + kill, + census, + interrupt: (depth = 2) => + runInNewContext(`${DRAIN_APPLY_INTERRUPTION_JS}\ninterruptDrainApply(${depth})`, context) + } +} + +describe('owned drain interruption', () => { + it.each([1, 2])('signals only the owned apply shell at ancestor depth %s', (depth) => { + const rows = depth === 1 ? ['100'] : ['200', '100'] + const target = fixture(rows.map((stdout) => ({ status: 0, stdout }))) + target.interrupt(depth) + expect(target.kill).toHaveBeenCalledExactlyOnceWith(100, 'SIGKILL') + expect(target.census.mock.calls.map(([, args]) => args)).toEqual( + depth === 1 + ? [['-o', 'ppid=', '-p', '300']] + : [ + ['-o', 'ppid=', '-p', '300'], + ['-o', 'ppid=', '-p', '200'] + ] + ) + }) + + it.each([ + { status: 0, stdout: '' }, + { status: 0, stdout: ' \n' }, + { status: 0, stdout: '0' }, + { status: 0, stdout: '-1' }, + { status: 0, stdout: '1' }, + { status: 0, stdout: '9007199254740992' }, + { status: 0, stdout: '100\n200' }, + { status: 1, stdout: '100' }, + { status: null, stdout: '100', signal: 'SIGTERM' }, + { status: 0, stdout: '100', error: new Error('census failed') }, + { status: 0 } + ])('refuses an invalid census without sending a signal: %j', (row) => { + const target = fixture([{ status: 0, stdout: '200' }, row]) + expect(() => target.interrupt()).toThrow() + expect(target.kill).not.toHaveBeenCalled() + }) + + it('refuses a valid ancestor that belongs to another process', () => { + const target = fixture([ + { status: 0, stdout: '200' }, + { status: 0, stdout: '99' } + ]) + expect(() => target.interrupt()).toThrow('Apply ancestor is not owned') + expect(target.kill).not.toHaveBeenCalled() + }) + + it.each(['', '0', '-1', '1', '300', '9007199254740992'])( + 'refuses an invalid owned shell PID %j before running the census', + (pid) => { + const target = fixture([], pid) + expect(() => target.interrupt()).toThrow() + expect(target.census).not.toHaveBeenCalled() + expect(target.kill).not.toHaveBeenCalled() + } + ) +}) diff --git a/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-interruption-source.ts b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-interruption-source.ts new file mode 100644 index 000000000000..fc6799712b9a --- /dev/null +++ b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-interruption-source.ts @@ -0,0 +1,25 @@ +/** Interruption fixtures must never signal the runner's inherited process group. */ +export const DRAIN_APPLY_INTERRUPTION_JS = String.raw` +function interruptDrainApply(ancestorDepth) { + const expectedText = process.env.ORCA_DRAIN_APPLY_PID || '' + if (!/^[1-9][0-9]*$/.test(expectedText)) throw new Error('Missing owned apply PID') + const expectedPid = Number(expectedText) + if (!Number.isSafeInteger(expectedPid) || expectedPid <= 1 || expectedPid === process.pid) { + throw new Error('Invalid owned apply PID') + } + let ancestor = process.pid + for (let depth = 0; depth < ancestorDepth; depth++) { + const result = require('node:child_process').spawnSync( + '/bin/ps', ['-o', 'ppid=', '-p', String(ancestor)], { encoding: 'utf8' } + ) + const text = typeof result.stdout === 'string' ? result.stdout.trim() : '' + if (result.status !== 0 || result.signal || result.error || !/^[1-9][0-9]*$/.test(text)) { + throw new Error('Unverified apply ancestry') + } + ancestor = Number(text) + if (!Number.isSafeInteger(ancestor) || ancestor <= 1) throw new Error('Invalid apply ancestor') + } + if (ancestor !== expectedPid) throw new Error('Apply ancestor is not owned') + process.kill(ancestor, 'SIGKILL') +} +` diff --git a/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-script-harness.ts b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-script-harness.ts index 1f35d4fcda09..fde9c5b37ec1 100644 --- a/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-script-harness.ts +++ b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-script-harness.ts @@ -15,6 +15,7 @@ import { import { tmpdir } from 'node:os' import { join } from 'node:path' import { installDrainInterferenceShims } from './legacy-wsl-runtime-auth-drain-script-interference-shims' +import { DRAIN_APPLY_INTERRUPTION_JS } from './legacy-wsl-runtime-auth-drain-interruption-source' import { INTRUDER_AUTH, NEWER_AUTH, @@ -69,6 +70,7 @@ export function runApplyScript(options: DrainApplyInterference = {}): DrainApply `#!/usr/bin/env node const { spawnSync } = require('node:child_process') const fs = require('node:fs') +${DRAIN_APPLY_INTERRUPTION_JS} const args = process.argv.slice(2) const result = spawnSync('/bin/rm', args, { stdio: 'inherit' }) const target = args.at(-1) ?? '' @@ -78,10 +80,7 @@ if ( target.includes('/account/sessions/') && target.includes('.orca-bridge-') ) { - const parent = spawnSync('/bin/ps', ['-o', 'ppid=', '-p', String(process.ppid)], { - encoding: 'utf8' - }) - process.kill(Number(parent.stdout.trim()), 'SIGKILL') + interruptDrainApply(2) } process.exit(result.status ?? 1) ` @@ -103,12 +102,13 @@ process.exit(result.status ?? 1) `#!/usr/bin/env node const { spawnSync } = require('node:child_process') const fs = require('node:fs') +${DRAIN_APPLY_INTERRUPTION_JS} const args = process.argv.slice(2) if ( process.env.KILL_DESTINATION_RECOVERY === '1' && args.at(-1)?.endsWith('.orca-drain-destination') ) { - process.kill(process.ppid, 'SIGKILL') + interruptDrainApply(1) process.exit(1) } if ( @@ -125,10 +125,7 @@ if ( args.at(-1)?.endsWith('/retired.jsonl') ) { if (process.env.KILL_SESSION_LINK === '1') { - const parent = spawnSync('/bin/ps', ['-o', 'ppid=', '-p', String(process.ppid)], { - encoding: 'utf8' - }) - process.kill(Number(parent.stdout.trim()), 'SIGKILL') + interruptDrainApply(2) } else if (process.env.REWRITE_AFTER_SESSION_LINK === '1') { if (process.env.REPLACE_TARGET === '1') { const replacement = process.env.REWRITE_SESSION_AUTH + '.replacement' @@ -154,7 +151,7 @@ process.exit(result.status ?? 1) '/bin/sh', [ '-c', - _internals.applyLegacyAuthScript, + `ORCA_DRAIN_APPLY_PID=$$; export ORCA_DRAIN_APPLY_PID;\n${_internals.applyLegacyAuthScript}`, 'sh', legacyHome, join(root, 'absent-active-home'), diff --git a/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-script-interference-shims.ts b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-script-interference-shims.ts index 735e95bec863..8fe8561e11d6 100644 --- a/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-script-interference-shims.ts +++ b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-script-interference-shims.ts @@ -2,6 +2,7 @@ // interfered with at exact points - a hash read, an install rename, a source removal. import { chmodSync, writeFileSync } from 'node:fs' import { join } from 'node:path' +import { DRAIN_APPLY_INTERRUPTION_JS } from './legacy-wsl-runtime-auth-drain-interruption-source' export function installDrainInterferenceShims( binDir: string, @@ -39,6 +40,7 @@ export function installDrainInterferenceShims( `#!/usr/bin/env node const { spawnSync } = require('node:child_process') const fs = require('node:fs') + ${DRAIN_APPLY_INTERRUPTION_JS} const args = process.argv.slice(2) const result = spawnSync('/bin/mv', args, { stdio: 'inherit' }) const from = args.at(-2) ?? '' @@ -52,7 +54,7 @@ export function installDrainInterferenceShims( from.includes('/account/auth.json.orca-drain-snapshot-') && to.endsWith('/account/auth.json') if (result.status === 0 && (sourceInstalled || destinationInstalled)) { - process.kill(process.ppid, 'SIGKILL') + interruptDrainApply(1) } process.exit(result.status ?? 1) ` diff --git a/src/main/codex/codex-app-server-connection-exit-order.test.ts b/src/main/codex/codex-app-server-connection-exit-order.test.ts new file mode 100644 index 000000000000..661d099df73c --- /dev/null +++ b/src/main/codex/codex-app-server-connection-exit-order.test.ts @@ -0,0 +1,47 @@ +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 { openCodexAppServerConnection } from './codex-app-server-connection' + +function stubChild() { + const child = Object.assign(new EventEmitter(), { + // Outside any real process table so teardown never mistakes an unrelated process for it. + pid: 9_999_999, + stdin: new PassThrough(), + stdout: new PassThrough(), + stderr: new PassThrough(), + kill: vi.fn(() => true) + }) + child.stdin.once('data', () => { + child.stdout.write(`${JSON.stringify({ id: 1, result: {} })}\n`) + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The connection reads only events, pid, streams and kill from this stub. + const spawnImpl = (() => child) as unknown as typeof spawnProcess + return { child, spawnImpl } +} + +describe('Codex app-server exit order', () => { + // Stdout can end before the exit is seen, as when the provider supervisor's own exit closes it. + it('reports an exit whose stdout ended first once the exit is seen, with its usual reason', async () => { + const { child, spawnImpl } = stubChild() + const exits: string[] = [] + const connection = await openCodexAppServerConnection( + { command: 'codex', args: ['app-server'] }, + { onExit: (error) => exits.push(error.message) }, + spawnImpl + ) + + child.stderr.write('codex crashed\n') + child.stdout.end() + // Stream writes land a tick later, so the stderr tail is only complete here. + await new Promise((resolve) => setImmediate(resolve)) + expect(exits).toEqual([]) + child.emit('exit', 1, null) + child.emit('close', 1, null) + + expect(exits).toHaveLength(1) + expect(exits[0]).toContain('codex crashed') + await connection.close() + }) +}) diff --git a/src/main/codex/codex-app-server-connection.test.ts b/src/main/codex/codex-app-server-connection.test.ts index 8e0920a2ba75..68c2726a4f58 100644 --- a/src/main/codex/codex-app-server-connection.test.ts +++ b/src/main/codex/codex-app-server-connection.test.ts @@ -14,6 +14,7 @@ import { } from './codex-app-server-connection' import { PROVIDER_SUPERVISOR_MAX_STOP_MS } from '../provider-process/provider-process-supervisor' import { isCodexAppServerUnsupportedError } from './codex-app-server-session' +import { isCodexAppServerHandshakeExitUnprovenError } from './codex-app-server-handshake-exit-proof' // close() waits out the supervisor's own stop before forcing the tree. const GRACEFUL_EXIT_MS = process.platform === 'win32' ? 1_500 : PROVIDER_SUPERVISOR_MAX_STOP_MS @@ -126,18 +127,11 @@ async function flushStreams(): Promise { await new Promise((resolve) => setImmediate(resolve)) } -async function closeWithoutObservedExit( - connection: CodexAppServerConnection, - child: StubChild -): Promise { +async function closeWithoutObservedExit(start: () => Promise, child: StubChild): Promise { const forcedKill = new Promise((resolve) => { - child.kill.mockImplementation((signal) => { - if (signal === 'SIGKILL') { - resolve() - } - }) + child.kill.mockImplementation((signal) => (signal === 'SIGKILL' ? resolve() : undefined)) }) - const closing = connection.close() + const closing = start() await flushStreams() await vi.advanceTimersByTimeAsync(GRACEFUL_EXIT_MS) await forcedKill @@ -461,25 +455,28 @@ describe('openCodexAppServerConnection', () => { }) it('exposes an unproven handshake child for later cleanup', async () => { - vi.useFakeTimers() + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) const { child, spawnImpl } = stubChild({ exitOnStdinEnd: false }) child.stdin.once('data', () => { child.stdout.write( `${JSON.stringify({ id: 1, error: { code: -32602, message: 'initialize failed' } })}\n` ) }) - const opening = rejection( - openCodexAppServerConnection({ command: 'codex', args: ['app-server'] }, {}, spawnImpl) + const error = await closeWithoutObservedExit( + () => + rejection( + openCodexAppServerConnection({ command: 'codex', args: ['app-server'] }, {}, spawnImpl) + ), + child ) - - await vi.advanceTimersByTimeAsync(GRACEFUL_EXIT_MS + 3_500) - const error = (await opening) as Error & { connection?: CodexAppServerConnection } - expect(error.name).toBe('CodexAppServerHandshakeExitUnprovenError') + if (!isCodexAppServerHandshakeExitUnprovenError(error)) { + throw error + } expect(error.connection).toBeDefined() child.emit('exit', 1, null) child.emit('close', 1, null) - await expect(error.connection?.close()).resolves.toBe(true) + await expect(error.connection.close()).resolves.toBe(true) }) it('times out one request without ending the connection', async () => { @@ -531,7 +528,7 @@ describe('openCodexAppServerConnection', () => { spawnImpl ) - await expect(closeWithoutObservedExit(connection, child)).resolves.toBe(false) + await expect(closeWithoutObservedExit(() => connection.close(), child)).resolves.toBe(false) expect(vi.getTimerCount()).toBe(0) }, 10_000) @@ -567,7 +564,7 @@ describe('openCodexAppServerConnection', () => { spawnImpl ) - await expect(closeWithoutObservedExit(connection, child)).resolves.toBe(false) + await expect(closeWithoutObservedExit(() => connection.close(), child)).resolves.toBe(false) child.emit('exit', 0, null) await expect(connection.close()).resolves.toBe(true) diff --git a/src/main/codex/codex-model-catalog-probe.test.ts b/src/main/codex/codex-model-catalog-probe.test.ts index d7f3edff47ba..a3aeeebc4af0 100644 --- a/src/main/codex/codex-model-catalog-probe.test.ts +++ b/src/main/codex/codex-model-catalog-probe.test.ts @@ -13,8 +13,8 @@ const MODEL_ROW = { } describe('codex model catalog probe', () => { - it.each([`"${process.execPath}"`, 'npx codex', '/missing/codex', './codex'])( - 'lists with the session executable for Command %s', + it.each([`"${process.execPath}"`, ''])( + 'lists with the session executable for Command %j', async (command) => { // The env a user's shell/config resolves for sessions, PATH included. const resolveEnvironment = async (): Promise => ({ @@ -111,4 +111,23 @@ describe('codex model catalog probe', () => { }) await expect(probe('/homes/a')).rejects.toThrow(/listed no models/) }) + + it.each(['npx codex', 'codex --profile work', '/missing/codex', './codex'])( + 'lists nothing, and never probes the stock CLI, for Command %s', + async (command) => { + const runSession = vi.fn() + const probe = createCodexModelCatalogProbe({ + resolveEnvironment: async () => ({ PATH: '/bin' }), + resolveCommand: (options) => + resolveStructuredAgentCommand( + 'codex', + { agentCmdOverrides: { codex: command } }, + options + ), + runSession + }) + await expect(probe('/homes/a')).rejects.toMatchObject({ reason: 'agentCommandNotRunnable' }) + expect(runSession).not.toHaveBeenCalled() + } + ) }) diff --git a/src/main/codex/codex-structured-dispatch-echo.ts b/src/main/codex/codex-structured-dispatch-echo.ts index 4ad6d58faedc..78c0b8c867ab 100644 --- a/src/main/codex/codex-structured-dispatch-echo.ts +++ b/src/main/codex/codex-structured-dispatch-echo.ts @@ -52,6 +52,10 @@ export type CodexDispatchEchoes = { answeredUnopenedTurn: (threadId: string, openTurnIds: ReadonlySet) => string | null /** Codex did not open this answered turn within a wait, so no later wait is spent on it. */ leftUnopened: (threadId: string, turnId: string) => void + /** Codex opened this turn, by its `turn/started`. */ + opened: (threadId: string, turnId: string) => void + /** Whether Codex opened this turn: an `error` naming one it never opened is that turn's end. */ + hasOpened: (threadId: string, turnId: string) => boolean /** The latest armed send's answered turn a wait left unopened, neither open nor ended: Codex may * still open it, though no wait is spent on it again. */ answeredTurnLeftUnopened: (threadId: string, openTurnIds: ReadonlySet) => string | null @@ -84,9 +88,19 @@ export function createCodexDispatchEchoes(): CodexDispatchEchoes { >() const endedTurns = new Map() const unopenedTurns = new Set() + const openedTurns = new Set() let nextSequence = 0 const turnKey = (threadId: string, turnId: string): string => JSON.stringify([threadId, turnId]) const settles = (end: CodexTurnEnd): boolean => end.status !== 'completed' + const leftUnopened = (threadId: string, turnId: string): void => { + unopenedTurns.add(turnKey(threadId, turnId)) + for (const oldest of unopenedTurns) { + if (unopenedTurns.size <= MAX_CODEX_RECORDED_TURN_ENDS) { + break + } + unopenedTurns.delete(oldest) + } + } const answeredTurn = ( threadId: string, openTurnIds: ReadonlySet, @@ -133,15 +147,17 @@ export function createCodexDispatchEchoes(): CodexDispatchEchoes { answeredTurn(threadId, openTurnIds, (key) => !unopenedTurns.has(key)), answeredTurnLeftUnopened: (threadId, openTurnIds) => answeredTurn(threadId, openTurnIds, (key) => unopenedTurns.has(key)), - leftUnopened: (threadId, turnId) => { - unopenedTurns.add(turnKey(threadId, turnId)) - for (const oldest of unopenedTurns) { - if (unopenedTurns.size <= MAX_CODEX_RECORDED_TURN_ENDS) { + leftUnopened, + opened: (threadId, turnId) => { + openedTurns.add(turnKey(threadId, turnId)) + for (const oldest of openedTurns) { + if (openedTurns.size <= MAX_CODEX_RECORDED_TURN_ENDS) { break } - unopenedTurns.delete(oldest) + openedTurns.delete(oldest) } }, + hasOpened: (threadId, turnId) => openedTurns.has(turnKey(threadId, turnId)), endTurn: (threadId, turnId, end) => { const turn = turnKey(threadId, turnId) endedTurns.delete(turn) @@ -176,6 +192,7 @@ export function createCodexDispatchEchoes(): CodexDispatchEchoes { armed.clear() endedTurns.clear() unopenedTurns.clear() + openedTurns.clear() nextSequence = 0 }, get size() { diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts index d55ba5c12bf7..a5fbf4628a2d 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -34,6 +34,7 @@ import { } from './codex-structured-provider-events' import { codexDispatchRejection, + noteCodexTurnOpened, settleCodexSendsInEndedTurn } from './codex-structured-turn-end-settlement' import { createCodexStructuredNotificationRetry } from './codex-structured-notification-retry' @@ -124,6 +125,9 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap if (event.type === 'notification' && !session.backgroundTasks.canObserve(event)) { return { accepted: false, reason: 'failed' } } + if (event.type === 'notification') { + noteCodexTurnOpened(session, event.method, event.params) + } const admission = session.translator?.handle(event) ?? { accepted: true } if (!admission.accepted) { return admission diff --git a/src/main/codex/codex-structured-session-state.ts b/src/main/codex/codex-structured-session-state.ts index 4400335030e5..83cd87a3b420 100644 --- a/src/main/codex/codex-structured-session-state.ts +++ b/src/main/codex/codex-structured-session-state.ts @@ -98,7 +98,7 @@ export type CodexStructuredSessionAdapterDeps = { | { providerIdentity: AgentJournalItemIdentity } | ({ state: 'rejected' - answeredInTurn: AgentJournalAnsweredTurnIdentity + answeredInTurn?: AgentJournalAnsweredTurnIdentity } & AgentJournalDispatchRejection) ) ) => void diff --git a/src/main/codex/codex-structured-turn-end-settlement.ts b/src/main/codex/codex-structured-turn-end-settlement.ts index 376d9adb6e1f..44416ed9b84d 100644 --- a/src/main/codex/codex-structured-turn-end-settlement.ts +++ b/src/main/codex/codex-structured-turn-end-settlement.ts @@ -5,7 +5,8 @@ // So it is withdrawn, as a Stop's host-side withdrawal is. Any other end records pending // input before `turn/completed`, a failed turn after its `error` frame, so only that // frame settles: a failed turn that never echoed the send refused it, in Codex's words, -// and a completed one leaves it pending for the journal's recovery on exit. +// and a completed one leaves it pending for the journal's recovery on exit. A turn Codex never +// opened has no `turn/completed`: before 0.148 its final `error` is its end (`unopenedTurnFailure`). import { agentSessionFailureFact, @@ -21,6 +22,7 @@ import type { AgentJournalAnsweredTurnIdentity } from '../../shared/agent-sessio import type { CodexTurnEnd } from './codex-structured-dispatch-echo' import { codexTurnLifecycleIdentity } from './codex-structured-journal-translation-turns' import type { CodexSession } from './codex-structured-session-state' +import { readCodexJournalRecord } from './codex-structured-journal-translation-values' import { readCodexThreadId, readCodexTurnErrorMessage, @@ -44,7 +46,7 @@ export type CodexTurnEndSettlement = { clientMessageId: string state: 'rejected' /** The turn Codex answered the send into, whose end settled it, and how the send joined it. */ - answeredInTurn: AgentJournalAnsweredTurnIdentity + answeredInTurn?: AgentJournalAnsweredTurnIdentity } & AgentJournalDispatchRejection function errorDetail(params: unknown): ProviderDiagnostic | undefined { @@ -52,6 +54,27 @@ function errorDetail(params: unknown): ProviderDiagnostic | undefined { return message ? providerDiagnostic(message, 'person') : undefined } +/** A final `error` naming a turn Codex never opened: before 0.148 that is the only end such a + * turn reports. One it opened still reports its own `turn/completed`. */ +function unopenedTurnFailure( + session: Pick, + method: string, + params: unknown, + turnId: string +): CodexTurnEnd | null { + if ( + method !== 'error' || + readCodexJournalRecord(params).willRetry !== false || + session.dispatchEchoes.hasOpened(session.threadId, turnId) + ) { + return null + } + const message = readCodexJournalRecord(readCodexJournalRecord(params).error).message + const detail = + typeof message === 'string' && message ? providerDiagnostic(message, 'person') : undefined + return { status: 'failed', ...(detail ? { detail } : {}) } +} + /** The end a primary-thread notification reports for its turn, or null for any other frame. */ export function readCodexTurnEnd(method: string, params: unknown): CodexTurnEnd | null { if (method !== 'turn/completed') { @@ -81,6 +104,18 @@ export function codexTurnEndRejection(end: CodexTurnEnd): AgentJournalDispatchRe return null } +/** Records a turn Codex opened, ahead of the frames that may report the thread idle before it ends. */ +export function noteCodexTurnOpened( + session: Pick, + method: string, + params: unknown +): void { + const turnId = method === 'turn/started' ? readCodexTurnId(params) : null + if (turnId && (readCodexThreadId(params) ?? session.threadId) === session.threadId) { + session.dispatchEchoes.opened(session.threadId, turnId) + } +} + /** Settles the sends bound to the turn this admitted notification ended. */ export function settleCodexSendsInEndedTurn( session: Pick, @@ -88,7 +123,10 @@ export function settleCodexSendsInEndedTurn( settle: (settlement: CodexTurnEndSettlement) => void ): void { const turnId = readCodexTurnId(frame.params) - const end = readCodexTurnEnd(frame.method, frame.params) + const end = turnId + ? (readCodexTurnEnd(frame.method, frame.params) ?? + unopenedTurnFailure(session, frame.method, frame.params, turnId)) + : null if ( !turnId || !end || @@ -97,14 +135,31 @@ export function settleCodexSendsInEndedTurn( return } const rejection = codexTurnEndRejection(end) - const turn = codexTurnLifecycleIdentity(frame.sessionId, turnId) for (const { clientMessageId, via } of session.dispatchEchoes.endTurn( session.threadId, turnId, end )) { if (rejection) { - settle({ clientMessageId, state: 'rejected', answeredInTurn: { turn, via }, ...rejection }) + settle({ + clientMessageId, + state: 'rejected', + ...codexAnsweredTurn(session, frame.sessionId, turnId, via), + ...rejection + }) } } } + +/** Names the turn a rejected send was answered into only when Codex opened it: one it never + * opened has no record in the journal, so the send was answered into no turn. */ +export function codexAnsweredTurn( + session: Pick, + sessionId: string, + turnId: string, + via: AgentJournalAnsweredTurnIdentity['via'] +): { answeredInTurn?: AgentJournalAnsweredTurnIdentity } { + return session.dispatchEchoes.hasOpened(session.threadId, turnId) + ? { answeredInTurn: { turn: codexTurnLifecycleIdentity(sessionId, turnId), via } } + : {} +} diff --git a/src/main/codex/codex-structured-turn-start.ts b/src/main/codex/codex-structured-turn-start.ts index 817d6aa2d7e2..85a4007d2f7b 100644 --- a/src/main/codex/codex-structured-turn-start.ts +++ b/src/main/codex/codex-structured-turn-start.ts @@ -11,13 +11,13 @@ import { } from './codex-app-server-connection' import { isCodexAppServerUnsupportedError } from './codex-app-server-session' import type { CodexDispatchEchoes } from './codex-structured-dispatch-echo' -import { codexTurnLifecycleIdentity } from './codex-structured-journal-translation-turns' import { readCodexTurnId } from './codex-structured-thread-facts' import { codexRunningOrOpeningTurn, type CodexTurnOpenWaits } from './codex-structured-turn-open-wait' import { + codexAnsweredTurn, codexDispatchRejection, codexTurnEndRejection } from './codex-structured-turn-end-settlement' @@ -234,10 +234,7 @@ export async function dispatchCodexTurn( return rejection && answer.turnId ? { state: 'rejected', - answeredInTurn: { - turn: codexTurnLifecycleIdentity(input.sessionId, answer.turnId), - via: answer.via - }, + ...codexAnsweredTurn(session, input.sessionId, answer.turnId, answer.via), ...rejection } : { state: 'admitted' } diff --git a/src/main/codex/codex-turn-lifecycle-fake.ts b/src/main/codex/codex-turn-lifecycle-fake.ts index e0747f38b0b0..51ee345f1441 100644 --- a/src/main/codex/codex-turn-lifecycle-fake.ts +++ b/src/main/codex/codex-turn-lifecycle-fake.ts @@ -73,6 +73,8 @@ export function codexTurnLifecycleFake( active = null pickedRuns = false } + // As Codex does: clearing `running` publishes the thread idle ahead of the turn's end. + notify()('thread/status/changed', { threadId, status: { type: 'idle' } }) notify()('turn/completed', { threadId, turn: { id: turnId, status, ...(errorMessage ? { error: { message: errorMessage } } : {}) } diff --git a/src/main/daemon/daemon-bash-shell-ready-rcfile.ts b/src/main/daemon/daemon-bash-shell-ready-rcfile.ts index bf2e25451020..b169be664909 100644 --- a/src/main/daemon/daemon-bash-shell-ready-rcfile.ts +++ b/src/main/daemon/daemon-bash-shell-ready-rcfile.ts @@ -2,6 +2,7 @@ import { getPosixOmpShellWrapper } from '../pty/omp-shell-wrapper' import { ORCA_CLI_POSIX_PATH_RESTORE } from '../../shared/orca-cli-shell-path' import { MANAGED_DATA_ACCOUNT_POSIX_RESTORE } from '../../shared/managed-data-account-shell' import { getPosixCodexShellLaunchPreflight } from '../../shared/codex-shell-function' +import { getPosixClaudeShellFunction } from '../../shared/claude-shell-function' import { BASH_PROMPT_COMMAND_COMPOSITION_BLOCK } from '../bash-prompt-command-composition' import { BASH_FEATURE_CHANNEL_BLOCK, SHELL_STARTUP_IDENTITY_MARKER_BLOCK } from '../shell-templates' @@ -45,7 +46,7 @@ ${MANAGED_DATA_ACCOUNT_POSIX_RESTORE} ${getPosixOmpShellWrapper()} # Why: Codex must keep using Orca's runtime CODEX_HOME after profile scripts. [[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}" -${getPosixCodexShellLaunchPreflight()} +${getPosixCodexShellLaunchPreflight() + getPosixClaudeShellFunction()} # 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. # Without this, bash users (default on most Linux distros) keep a stuck diff --git a/src/main/daemon/pty-subprocess-wsl-launch.test.ts b/src/main/daemon/pty-subprocess-wsl-launch.test.ts index 067dc729aadb..8c8922eafd13 100644 --- a/src/main/daemon/pty-subprocess-wsl-launch.test.ts +++ b/src/main/daemon/pty-subprocess-wsl-launch.test.ts @@ -415,6 +415,42 @@ describe('createPtySubprocess', () => { ) }) + it('imports the guest-relative Claude pointer and profile home verbatim into daemon WSL terminals', async () => { + spawnMock.mockReturnValue(mockPtyProcess()) + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { value: 'win32' }) + const home = '/home/jin/.local/share/orca/claude-profiles/a/home' + try { + await createPtySubprocess({ + sessionId: 'test', + cols: 80, + rows: 24, + cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo', + env: { + CLAUDE_CONFIG_DIR: home, + ORCA_CLAUDE_INJECTED_CONFIG_DIR: home, + ORCA_CLAUDE_PROFILE_POINTER: '~/.local/share/orca/claude-profiles/selected-wsl-orca' + } + }) + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + const env = spawnMock.mock.calls.at(-1)?.[2].env + // Why no flag: /p or /u would translate a guest path as if it were a Windows one. + expect(env.WSLENV.split(':')).toEqual( + expect.arrayContaining([ + 'CLAUDE_CONFIG_DIR', + 'ORCA_CLAUDE_PROFILE_POINTER', + 'ORCA_CLAUDE_INJECTED_CONFIG_DIR' + ]) + ) + expect(env.ORCA_CLAUDE_PROFILE_POINTER).toBe( + '~/.local/share/orca/claude-profiles/selected-wsl-orca' + ) + }) + it('does not mark deleted Powerlevel10k wizard env for daemon WSL import', async () => { const proc = mockPtyProcess() spawnMock.mockReturnValue(proc) diff --git a/src/main/daemon/pty-subprocess/shell-launch-plan.ts b/src/main/daemon/pty-subprocess/shell-launch-plan.ts index 073cdc867c77..e29fff5330ab 100644 --- a/src/main/daemon/pty-subprocess/shell-launch-plan.ts +++ b/src/main/daemon/pty-subprocess/shell-launch-plan.ts @@ -169,6 +169,9 @@ export function createPtyShellLaunchPlan( if (env.CLAUDE_CONFIG_DIR) { addWslEnvKeys(env, ['CLAUDE_CONFIG_DIR']) } + 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/daemon/shell-ready.ts b/src/main/daemon/shell-ready.ts index a484c9f18fef..69f112989658 100644 --- a/src/main/daemon/shell-ready.ts +++ b/src/main/daemon/shell-ready.ts @@ -7,6 +7,7 @@ import { isPowerShellExecutableName } from '../powershell-osc133-bootstrap' import { getFishCodexShellLaunchPreflight } from '../../shared/codex-shell-function' +import { getFishClaudeShellFunction } from '../../shared/claude-shell-function' import { getFishXdgDataDirsLaunchEnv } from '../fish-xdg-data-dirs-handoff' import { getFishShellReadyInitCommand } from '../shell-templates' import { @@ -190,7 +191,7 @@ export function getShellLaunchConfig( args: [ '-l', '-C', - `${getFishShellReadyInitCommand(SHELL_READY_MARKER, features.includes('ready'))}\n${getFishCodexShellLaunchPreflight()}` + `${getFishShellReadyInitCommand(SHELL_READY_MARKER, features.includes('ready'))}\n${getFishCodexShellLaunchPreflight() + getFishClaudeShellFunction()}` ], env: {}, supportsReadyMarker: features.includes('ready') diff --git a/src/main/fish-xdg-data-dirs-handoff.ts b/src/main/fish-xdg-data-dirs-handoff.ts index f189f37128bd..6efa9fab15db 100644 --- a/src/main/fish-xdg-data-dirs-handoff.ts +++ b/src/main/fish-xdg-data-dirs-handoff.ts @@ -4,6 +4,7 @@ * that dir's fish/vendor_conf.d, and the snippet's first act is to undo it. */ import { getFishCodexShellLaunchPreflight } from '../shared/codex-shell-function' +import { getFishClaudeShellFunction } from '../shared/claude-shell-function' import { MANAGED_DATA_ACCOUNT_FISH_RESTORE } from '../shared/managed-data-account-shell' import type { ShellWrapperFile } from './shell-wrapper-file-writer' @@ -48,9 +49,9 @@ export function getFishXdgDataDirsLaunchEnv( } // Why a function: its variables stay function-scoped, so nothing but the -// restored XDG_DATA_DIRS and the codex hook outlives this file. -// Why codex waits for fish_prompt: config.fish has not run yet, and the user's -// own codex function or PATH entry must be seen first, as in wrapped panes. +// restored XDG_DATA_DIRS and the codex and claude hooks outlives this file. +// Why they wait for fish_prompt: config.fish has not run yet, and the user's +// own codex/claude function or PATH entry must be seen first, as in wrapped panes. export function getFishVendorConfSnippet(): string { return `# Orca-generated. Loaded only because Orca put this directory on # XDG_DATA_DIRS for one fish launch; the first thing it does is take it off. @@ -71,7 +72,7 @@ function __orca_fish_xdg_handoff function __orca_define_codex --on-event fish_prompt functions -e __orca_define_codex ${MANAGED_DATA_ACCOUNT_FISH_RESTORE} -${getFishCodexShellLaunchPreflight()} +${getFishCodexShellLaunchPreflight()}${getFishClaudeShellFunction()} end end __orca_fish_xdg_handoff diff --git a/src/main/ipc/ai-vault-search.test.ts b/src/main/ipc/ai-vault-search.test.ts index 19191f9d8733..21c8b3ff6960 100644 --- a/src/main/ipc/ai-vault-search.test.ts +++ b/src/main/ipc/ai-vault-search.test.ts @@ -32,6 +32,9 @@ import { searchHit, searchResults } from '../../shared/ai-vault-search-test-fixture' +import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' +import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' +import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' beforeEach(() => { handlers.clear() sshSearch.mockReset() @@ -43,7 +46,10 @@ beforeEach(() => { callRuntimeSearch: runtimeSearch }) }) -afterEach(() => setSessionSearchService(null)) +afterEach(() => { + setSessionSearchService(null) + setStructuredAgentSessionHost(null) +}) describe('desktop IPC and preload search boundary', () => { it('round-trips local results and separate status through the actual preload', async () => { @@ -273,3 +279,48 @@ describe('desktop IPC and preload search boundary', () => { await expect(aiVaultApi.setSearchEnabled('runtime:env-1', true)).rejects.toThrow('host-too-old') }) }) + +describe('local search hits owned by a native chat', () => { + function installChatHost(): { sessionId: string; workspaceId: string } { + const record = { + ...agentSessionRecordFixture(), + provider: 'codex' as const, + conversationName: 'Login repair' + } + record.providerHandleChain = [ + { ...record.providerHandleChain[0]!, handle: codexProviderHandle('host-session') } + ] + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: ownership projection only reads this record-list member. + setStructuredAgentSessionHost({ deps: { store: { listRecords: () => [record] } } } as never) + return { sessionId: record.sessionId, workspaceId: record.location.workspaceId } + } + + it('names and owns a hit after installing the chat host', async () => { + setSessionSearchService(fakeSearchService()) + let owner: { sessionId: string; workspaceId: string } | null = null + registerAiVaultSearchHandlers({ + ensureStructuredSessionOwnership: async () => { + owner = installChatHost() + } + }) + const result = await aiVaultApi.searchSessions({ query: 'needle' }) + expect(result).toMatchObject({ + hits: [{ title: 'Login repair', structuredSession: owner! }] + }) + }) + + it('still answers with plain hits when the chat host will not install', async () => { + setSessionSearchService(fakeSearchService()) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + registerAiVaultSearchHandlers({ + ensureStructuredSessionOwnership: async () => { + throw new Error('chat journal unavailable') + } + }) + const result = await aiVaultApi.searchSessions({ query: 'needle' }) + expect(result).toMatchObject({ hits: [{ title: 'Indexed conversation' }] }) + expect(JSON.stringify(result)).not.toContain('structuredSession') + expect(warn).toHaveBeenCalled() + warn.mockRestore() + }) +}) diff --git a/src/main/ipc/ai-vault-search.ts b/src/main/ipc/ai-vault-search.ts index 75ce9fd87252..a175ffe1be93 100644 --- a/src/main/ipc/ai-vault-search.ts +++ b/src/main/ipc/ai-vault-search.ts @@ -35,6 +35,7 @@ import { getActiveSshAiVaultHostInfosResult } from './ai-vault' import { AI_VAULT_ALL_HOST_TIMEOUT_MS } from './ai-vault-all-host-timeouts' +import { searchWithStructuredOwners } from '../ai-vault/structured-session-ownership' export type RuntimeSessionSearchCall = ( environmentId: string, @@ -44,6 +45,7 @@ export type RuntimeSessionSearchCall = ( export type AiVaultSearchHandlerOptions = { callRuntimeSearch?: RuntimeSessionSearchCall + ensureStructuredSessionOwnership?: () => Promise } // One wording with the session list, which refuses the same unroutable scope. @@ -137,7 +139,7 @@ async function searchByExecutionHostScope( scope: ParsedExecutionHost ): Promise { if (scope.kind === 'local') { - return searchSessionService(request, 'ipc') + return searchLocalSessions(request) } const client = remoteSearchClient(scope, handlerOptions.callRuntimeSearch) if (!client) { @@ -150,6 +152,13 @@ async function searchByExecutionHostScope( : response } +async function searchLocalSessions(request: AiVaultSearchRequest): Promise { + return searchWithStructuredOwners( + searchSessionService(request, 'ipc'), + handlerOptions.ensureStructuredSessionOwnership + ) +} + /** * Every host the session list's `all` scope would enumerate, in one leg each. * A broken enumerator already degrades to an empty list rather than throwing, @@ -158,7 +167,7 @@ async function searchByExecutionHostScope( function allExecutionHostLegs(): SessionSearchHostLeg[] { const localLeg: SessionSearchHostLeg = { executionHostId: LOCAL_EXECUTION_HOST_ID, - search: (request) => searchSessionService(request, 'ipc') + search: searchLocalSessions } const sshLegs = getActiveSshAiVaultHostInfosResult().hostInfos.map(({ targetId }) => remoteHostLeg({ kind: 'ssh', id: toSshExecutionHostId(targetId), targetId }) diff --git a/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts b/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts index 96f182b45435..4fbbc7516ce9 100644 --- a/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts +++ b/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts @@ -31,7 +31,10 @@ import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/electr import { remoteRuntimeClientCapabilities } from '../../shared/remote-runtime-client-capabilities' import { supportsAgentLaunch } from '../runtime/rpc/methods/agent-launch' import { readsAgentLaunchTabClosed } from '../runtime/rpc/methods/agent-launch-replay' -import { createSupportFollowsHostSetting } from '../runtime/rpc/methods/structured-agent-session-policy' +import { + createSupportFollowsHostSetting, + structuredAgentsReadBy +} from '../runtime/rpc/methods/structured-agent-session-policy' import { DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES } from './desktop-renderer-runtime-capabilities' // Every paired transport sends the shared base plus the Electron list, so compare the union. @@ -121,6 +124,21 @@ describe('desktop renderer runtime client capabilities', () => { ) }) + // The renderer reads `agentSession.agents` (host-structured-agents.ts) and renders any listed + // agent's chat, so each host serves it every agent's tabs. + it.each([ + ['a paired host', PAIRED_HOST_RECEIVES], + ['its own main process', DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES] + ] as const)('reads every agent %s registered', (_host, clientCapabilities) => { + expect( + structuredAgentsReadBy({ clientKind: 'runtime', clientCapabilities }, [ + 'claude', + 'codex', + 'grok' + ]) + ).toBeUndefined() + }) + it('diverges from what a paired host receives only where a decision was recorded', () => { expect(missingFrom(PAIRED_HOST_RECEIVES, DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES)).toEqual( [...REMOTE_ONLY_BY_DECISION].sort() diff --git a/src/main/ipc/desktop-renderer-runtime-capabilities.ts b/src/main/ipc/desktop-renderer-runtime-capabilities.ts index c622f5a24e19..ab205b559758 100644 --- a/src/main/ipc/desktop-renderer-runtime-capabilities.ts +++ b/src/main/ipc/desktop-renderer-runtime-capabilities.ts @@ -7,6 +7,7 @@ import { CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, REPO_SEARCH_QUALIFIED_REFS_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY, + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, type RuntimeCapability } from '../../shared/protocol-version' @@ -39,6 +40,8 @@ export const DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES: readonly RuntimeCapab AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + // The renderer reads `agentSession.agents` and renders a chat tab of any agent its host lists. + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY, REPO_SEARCH_QUALIFIED_REFS_RUNTIME_CAPABILITY, // Without this `supportsAgentLaunch` refuses the renderer outright, while the same renderer diff --git a/src/main/ipc/folder-repo-git-upgrade.test.ts b/src/main/ipc/folder-repo-git-upgrade.test.ts index 836ff0e7a650..5fe485ee3a21 100644 --- a/src/main/ipc/folder-repo-git-upgrade.test.ts +++ b/src/main/ipc/folder-repo-git-upgrade.test.ts @@ -206,12 +206,16 @@ describe('folder repo git upgrade watch', () => { pollIntervalMs: POLL_MS, idlePollIntervalMs: IDLE_POLL_MS }) - await tick() - - expect(store.updateRepo).toHaveBeenCalledWith('folder-repo', { - kind: 'git', - folderUpgradeGitRootPath: join(root, 'symlinked-project').replaceAll('\\', '/') - }) + // A fixed tick wait can finish before the marker stat resolves under load. + await vi.waitFor( + () => { + expect(store.updateRepo).toHaveBeenCalledWith('folder-repo', { + kind: 'git', + folderUpgradeGitRootPath: join(root, 'symlinked-project').replaceAll('\\', '/') + }) + }, + { timeout: 5_000, interval: POLL_MS } + ) }) it('retries after the last extra folder workspace is removed', async () => { diff --git a/src/main/ipc/pty-spawn-placement-threading.test.ts b/src/main/ipc/pty-spawn-placement-threading.test.ts new file mode 100644 index 000000000000..76b6dd9ce87b --- /dev/null +++ b/src/main/ipc/pty-spawn-placement-threading.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, it, vi } from 'vitest' +import { setupPtyIpcSuite } from './pty-ipc-test-harness' +import { makePaneKey } from '../../shared/stable-pane-id' +import type { TerminalPanePlacement } from '../../shared/terminal-pane-placement' +import { registerPtyHandlers } from './pty' + +vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) +vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock())) +vi.mock('node-pty', () => import('./pty-ipc-mock-registry').then((m) => m.nodePtyModuleMock())) +vi.mock('node:child_process', async (importOriginal) => + (await import('./pty-ipc-mock-registry')).childProcessModuleMock(await importOriginal()) +) +vi.mock('../opencode/hook-service', () => + import('./pty-ipc-mock-registry').then((m) => m.openCodeHookServiceModuleMock()) +) +vi.mock('../mimo/hook-service', () => + import('./pty-ipc-mock-registry').then((m) => m.mimoHookServiceModuleMock()) +) +vi.mock('../agent-hooks/server', () => + import('./pty-ipc-mock-registry').then((m) => m.agentHookServerModuleMock()) +) +vi.mock('../pi/titlebar-extension-service', () => + import('./pty-ipc-mock-registry').then((m) => m.piTitlebarExtensionModuleMock()) +) +vi.mock('../pwsh', () => import('./pty-ipc-mock-registry').then((m) => m.pwshModuleMock())) +vi.mock('../wsl', async (importOriginal) => + (await import('./pty-ipc-mock-registry')).wslModuleMock(await importOriginal()) +) +vi.mock('../telemetry/client', () => + import('./pty-ipc-mock-registry').then((m) => m.telemetryClientModuleMock()) +) +vi.mock('../telemetry/classify-error', () => + import('./pty-ipc-mock-registry').then((m) => m.classifyErrorModuleMock()) +) +vi.mock('../cli/linux-terminal-orca-cli-shim', () => + import('./pty-ipc-mock-registry').then((m) => m.linuxCliShimModuleMock()) +) +vi.mock('../memory/pty-registry', () => + import('./pty-ipc-mock-registry').then((m) => m.ptyRegistryModuleMock()) +) +vi.mock('../agent-hooks/migration-unsupported-pty-state', () => + import('./pty-ipc-mock-registry').then((m) => m.migrationUnsupportedPtyModuleMock()) +) +vi.mock('../codex/codex-pane-account-registry', () => + import('./pty-ipc-mock-registry').then((m) => m.codexPaneAccountRegistryModuleMock()) +) +vi.mock('../codex/codex-state-db-backfill-recovery', () => + import('./pty-ipc-mock-registry').then((m) => m.codexBackfillRecoveryModuleMock()) +) + +const LEAF = '33333333-3333-4333-8333-333333333333' +const PARENT = '44444444-4444-4444-8444-444444444444' +const WORKTREE = 'repo-1::/tmp/placement' +const SPLIT: TerminalPanePlacement = { + kind: 'split', + parentLeafId: PARENT, + direction: 'horizontal' +} + +type RuntimeSpawnController = { + spawn(args: Record): Promise<{ id: string }> +} + +describe('pty spawn placement threading', () => { + const { handlers, mainWindow } = setupPtyIpcSuite() + + function register(): { + store: { persistPtyBinding: ReturnType } + controller: () => RuntimeSpawnController + } { + const store = { persistPtyBinding: vi.fn(async () => true) } + let controller: RuntimeSpawnController | null = null + const runtime = { + setPtyController: vi.fn((value) => { + controller = value + }), + createPreAllocatedTerminalHandle: vi.fn(() => 'term_trusted'), + preAllocateHandleForPty: vi.fn(() => 'term_trusted'), + registerPreAllocatedHandleForPty: vi.fn(), + registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn() + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: spawn reads only the window, runtime and store members these fakes define. + const args = [ + mainWindow, + runtime, + undefined, + undefined, + undefined, + store + ] as unknown as Parameters + registerPtyHandlers(...args) + return { + store, + controller: () => { + if (!controller) { + throw new Error('runtime controller not installed') + } + return controller + } + } + } + + async function ipcSpawn(extra: Record): Promise { + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp', + worktreeId: WORKTREE, + tabId: 'tab-placement', + leafId: LEAF, + env: { ORCA_TAB_ID: 'tab-placement', ORCA_WORKTREE_ID: WORKTREE }, + ...extra + }) + } + + it('passes a valid renderer placement to the binding write', async () => { + const { store } = register() + await ipcSpawn({ placement: SPLIT }) + expect(store.persistPtyBinding).toHaveBeenCalledWith( + expect.objectContaining({ tabId: 'tab-placement', leafId: LEAF, placement: SPLIT }) + ) + }) + + it('keeps an old client request (no placement) byte-for-byte as before', async () => { + const { store } = register() + await ipcSpawn({}) + expect(store.persistPtyBinding).toHaveBeenCalledOnce() + expect(store.persistPtyBinding.mock.calls[0]).toEqual([ + { + worktreeId: WORKTREE, + tabId: 'tab-placement', + leafId: LEAF, + ptyId: expect.any(String), + incarnationId: expect.any(String), + startupCwd: '/tmp', + origin: 'spawn' + } + ]) + }) + + it('drops a malformed or future-kind placement instead of failing the spawn', async () => { + const { store } = register() + await ipcSpawn({ placement: { kind: 'floating-window', anchor: 'x' } }) + await ipcSpawn({ placement: { kind: 'split', parentLeafId: 'pane-1', direction: 'vertical' } }) + expect(store.persistPtyBinding).toHaveBeenCalledTimes(2) + for (const [binding] of store.persistPtyBinding.mock.calls) { + expect(binding).not.toHaveProperty('placement') + } + }) + + it('passes a main runtime spawn placement through the host-admitted binding', async () => { + const { store, controller } = register() + await controller().spawn({ + cols: 80, + rows: 24, + worktreeId: WORKTREE, + tabId: 'tab-runtime', + leafId: LEAF, + env: { ORCA_PANE_KEY: makePaneKey('tab-runtime', LEAF) }, + persistHostSessionBinding: true, + placement: SPLIT + }) + expect(store.persistPtyBinding).toHaveBeenCalledWith({ + worktreeId: WORKTREE, + tabId: 'tab-runtime', + leafId: LEAF, + ptyId: expect.any(String), + incarnationId: expect.any(String), + hostAdmittedMembership: true, + placement: SPLIT, + origin: 'spawn' + }) + }) +}) diff --git a/src/main/ipc/pty/ipc/leaf-move.test.ts b/src/main/ipc/pty/ipc/leaf-move.test.ts new file mode 100644 index 000000000000..724a706a56bb --- /dev/null +++ b/src/main/ipc/pty/ipc/leaf-move.test.ts @@ -0,0 +1,70 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { agentHookServer } from '../../../agent-hooks/server' +import type { Store } from '../../../persistence' +import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' +import type { TerminalLeafMoveResult } from '../../../../shared/terminal-leaf-move' +import { commitLeafMoveAndRekey } from './leaf-move' + +const LEAF = '22222222-2222-4222-8222-222222222222' +const request = { + worktreeId: 'repo-1::/tmp/wt', + sourceTabId: 'tab-source', + targetTabId: 'tab-target', + leafId: LEAF, + ptyId: 'pty-agent' +} + +function deps(result: TerminalLeafMoveResult) { + const rekeyWorkerTerminalResourcePaneKey = vi.fn(() => 1) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the move reads only this Store method. + const store = { moveTerminalLeafToNewTab: vi.fn(async () => result) } as unknown as Store + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the move reads only the orchestration DB accessor. + const runtime = { + getExistingOrchestrationDb: () => ({ rekeyWorkerTerminalResourcePaneKey }) + } as unknown as OrcaRuntimeService + return { store, runtime, rekeyWorkerTerminalResourcePaneKey } +} + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('pty:moveLeafToNewTab', () => { + it('aliases agent status and re-keys worker resources after a committed move', async () => { + const transfer = vi.spyOn(agentHookServer, 'transferPaneAuthority').mockImplementation(() => {}) + const { store, runtime, rekeyWorkerTerminalResourcePaneKey } = deps({ + status: 'moved', + ptyId: 'pty-agent' + }) + + await expect(commitLeafMoveAndRekey({ store, runtime }, request)).resolves.toEqual({ + status: 'moved', + ptyId: 'pty-agent' + }) + + expect(transfer).toHaveBeenCalledWith( + `tab-source:${LEAF}`, + `tab-target:${LEAF}`, + 'pty-agent', + expect.any(Number), + { authorityVerified: true } + ) + expect(rekeyWorkerTerminalResourcePaneKey).toHaveBeenCalledWith({ + fromPaneKey: `tab-source:${LEAF}`, + toPaneKey: `tab-target:${LEAF}` + }) + }) + + it.each([ + { status: 'not_held' }, + { status: 'refused', reason: 'pty_mismatch' } + ])('leaves every pane key alone when the move did not commit (%o)', async (result) => { + const transfer = vi.spyOn(agentHookServer, 'transferPaneAuthority').mockImplementation(() => {}) + const { store, runtime, rekeyWorkerTerminalResourcePaneKey } = deps(result) + + await expect(commitLeafMoveAndRekey({ store, runtime }, request)).resolves.toEqual(result) + + expect(transfer).not.toHaveBeenCalled() + expect(rekeyWorkerTerminalResourcePaneKey).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/pty/ipc/leaf-move.ts b/src/main/ipc/pty/ipc/leaf-move.ts new file mode 100644 index 000000000000..c8b0cb9f07a7 --- /dev/null +++ b/src/main/ipc/pty/ipc/leaf-move.ts @@ -0,0 +1,58 @@ +import { getPtyIpc } from '../../pty-host-bindings' +import type { Store } from '../../../persistence' +import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' +import { agentHookServer } from '../../../agent-hooks/server' +import { + isTerminalLeafMoveRequest, + terminalLeafMovePaneKeys, + type TerminalLeafMoveRequest, + type TerminalLeafMoveResult +} from '../../../../shared/terminal-leaf-move' + +/** Durable move first; agent-status and orchestration keys follow only a committed move. */ +export async function commitLeafMoveAndRekey( + deps: { store?: Store; runtime?: OrcaRuntimeService }, + request: TerminalLeafMoveRequest +): Promise { + if (!deps.store) { + return { status: 'not_held' } + } + const result = await deps.store.moveTerminalLeafToNewTab(request) + if (result.status !== 'moved') { + return result + } + const { from: fromPaneKey, to: toPaneKey } = terminalLeafMovePaneKeys(request) + try { + // The process keeps the pane key baked into its env, so status must alias old to new. + agentHookServer.transferPaneAuthority( + fromPaneKey, + toPaneKey, + result.ptyId ?? undefined, + Date.now(), + { authorityVerified: true } + ) + } catch (error) { + console.warn('[pty] moved pane kept its old agent-status key:', error) + } + try { + deps.runtime?.getExistingOrchestrationDb()?.rekeyWorkerTerminalResourcePaneKey({ + fromPaneKey, + toPaneKey + }) + } catch (error) { + console.warn('[pty] moved pane kept its old orchestration resource key:', error) + } + return result +} + +export function installPtyLeafMoveIpcHandler(deps: { + store?: Store + runtime?: OrcaRuntimeService +}): void { + getPtyIpc().handle('pty:moveLeafToNewTab', async (_event, args: unknown) => { + if (!isTerminalLeafMoveRequest(args)) { + return { status: 'refused', reason: 'invalid_request' } satisfies TerminalLeafMoveResult + } + return commitLeafMoveAndRekey(deps, args) + }) +} diff --git a/src/main/ipc/pty/ipc/spawn-commit-persist.ts b/src/main/ipc/pty/ipc/spawn-commit-persist.ts index bde05540e27d..7ceddf8610dc 100644 --- a/src/main/ipc/pty/ipc/spawn-commit-persist.ts +++ b/src/main/ipc/pty/ipc/spawn-commit-persist.ts @@ -16,6 +16,7 @@ import { resolveCommittedPtySize, type PtyGrid } from '../delivery/attached-pty- import { discardUnpersistedPtySpawn } from '../pane/spawn-registration' import { spawnCommitBindingOrigin } from '../../../persistence/loading-store/pty-binding-span' import type { PtyIpcSpawnState } from './spawn-state' +import { parseTerminalPanePlacement } from '../../../../shared/terminal-pane-placement' export async function persistPtyIpcSpawnCommit(ctx: PtyIpcSpawnState): Promise { const args = ctx.args @@ -52,6 +53,7 @@ export async function persistPtyIpcSpawnCommit(ctx: PtyIpcSpawnState): Promise

{ + afterEach(() => { + installClaudeProfileRouter(undefined) + Object.defineProperty(process, 'platform', { configurable: true, value: hostPlatform }) + }) + + it('gives a wsl.exe pane the guest-relative pointer without touching the guest', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const runSetup = vi.fn() + installClaudeProfileRouter( + new ClaudeProfileRouter({ + getSettings: () => getDefaultSettings('/tmp'), + dataRoot: '/data/orca', + runSetup + }) + ) + const args: RuntimePtySpawnArgs = { + cols: 120, + rows: 40, + cwd: '\\\\wsl.localhost\\Ubuntu\\home\\u', + shellOverride: 'wsl.exe', + env: { KEEP: '1' } + } + const ctx = createRuntimePtySpawnState(makeDeps(), args) + await expect(prepareRuntimePtySpawn(ctx)).resolves.toBeNull() + expect(ctx.codexSelectionTarget).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + expect(args.env).toEqual({ + KEEP: '1', + ORCA_CLAUDE_PROFILE_POINTER: '~/.local/share/orca/claude-profiles/selected-wsl-orca' + }) + expect(runSetup).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/pty/runtime/spawn-preflight.ts b/src/main/ipc/pty/runtime/spawn-preflight.ts index 1198014513e1..c82b0656a377 100644 --- a/src/main/ipc/pty/runtime/spawn-preflight.ts +++ b/src/main/ipc/pty/runtime/spawn-preflight.ts @@ -1,3 +1,4 @@ +import { withClaudeProfileTerminalEnv } from '../../../claude-accounts/claude-profile-installed-router' import { inheritOmpLaunchEnvironment } from '../host-env/omp-launch-environment' import { getAppEnvironment } from '../../../../shared/app-environment' import type { PtySpawnResult } from '../../../providers/types' @@ -145,6 +146,7 @@ export async function prepareRuntimePtySpawn( // Why: the drop still applies here, but this controller's result has no field for // notifyResumeUnavailable — runtime/relay panes start fresh without the notice. ctx.launchCommand = codexResumeLaunch.command + args.env = withClaudeProfileTerminalEnv(args.env, args.connectionId, ctx.codexSelectionTarget) ctx.claudeAuth = ctx.isClaudeLaunch && ctx.deps.prepareClaudeAuth ? await ctx.deps.prepareClaudeAuth(ctx.codexSelectionTarget) @@ -173,7 +175,8 @@ export async function prepareRuntimePtySpawn( worktreeId: args.worktreeId, tabId: args.tabId, leafId: args.leafId, - ...(args.expectedSourceBinding ? { expectedSourceBinding: args.expectedSourceBinding } : {}) + ...(args.expectedSourceBinding ? { expectedSourceBinding: args.expectedSourceBinding } : {}), + ...(args.placement ? { placement: args.placement } : {}) } } const sshScopedEnv = stripRemotePaneEnvWhenHooksDisabled(args.connectionId, args.env) diff --git a/src/main/ipc/pty/runtime/spawn-state.ts b/src/main/ipc/pty/runtime/spawn-state.ts index 581d335ecdbc..d0e31889ad39 100644 --- a/src/main/ipc/pty/runtime/spawn-state.ts +++ b/src/main/ipc/pty/runtime/spawn-state.ts @@ -6,6 +6,7 @@ import type { StablePaneOwner } from '../pane/stable-owner' import type { PaneSpawnReservation } from '../pane/spawn-reservation' import type { AdoptStablePaneResult } from '../ipc/spawn-types' import type { PtyBindingSourceExpectation } from '../../../persistence' +import type { TerminalPanePlacement } from '../../../../shared/terminal-pane-placement' import type { PtyRuntimeControllerDeps } from './controller-deps' import type { TuiAgent } from '../../../../shared/tui-agent' import type { AgentProviderSessionMetadata } from '../../../../shared/agent-session-resume' @@ -47,6 +48,7 @@ export type RuntimePtySpawnState = { tabId: string leafId: string expectedSourceBinding?: PtyBindingSourceExpectation + placement?: TerminalPanePlacement } | undefined env: Record | undefined @@ -109,6 +111,7 @@ export type RuntimePtySpawnArgs = { initiallyHidden?: boolean persistHostSessionBinding?: boolean expectedSourceBinding?: PtyBindingSourceExpectation + placement?: TerminalPanePlacement terminalKittyKeyboardProtocol?: boolean terminalColorQueryReplies?: { foreground?: string; background?: string } agentSessionEnsure?: { diff --git a/src/main/ipc/register-core-handlers/register-core-handlers.ts b/src/main/ipc/register-core-handlers/register-core-handlers.ts index 45a3ecb390f9..9045aee3c22c 100644 --- a/src/main/ipc/register-core-handlers/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers/register-core-handlers.ts @@ -223,16 +223,16 @@ export function registerCoreHandlers( registerRuntimeHandlers(runtime) registerRuntimeEnvironmentHandlers(store) registerEphemeralVmHandlers(store, pluginService) + // Session history and terminal resume are not chats; a refused host leaves nothing to check. + const ensureStructuredSessionOwnership = () => + ensureStructuredAgentSessionHostUnlessRefused(() => runtime.ensureStructuredAgentSessionHost()) registerAiVaultSearchHandlers({ callRuntimeSearch: (environmentId, method, params) => - callRuntimeSessionSearch(app.getPath('userData'), environmentId, method, params) + callRuntimeSessionSearch(app.getPath('userData'), environmentId, method, params), + ensureStructuredSessionOwnership }) registerAiVaultHandlers({ - // Session history and terminal resume are not chats; a refused host leaves nothing to check. - ensureStructuredSessionOwnership: () => - ensureStructuredAgentSessionHostUnlessRefused(() => - runtime.ensureStructuredAgentSessionHost() - ), + ensureStructuredSessionOwnership, getAdditionalCodexHomePaths: lifecycleOptions.getAdditionalAiVaultCodexHomePaths, prepareSessionResume: lifecycleOptions.prepareAiVaultSessionResume, getActiveRuntimeAiVaultHostInfos: () => diff --git a/src/main/native-chat/agent-session-journal/journal-database-records-migration.test.ts b/src/main/native-chat/agent-session-journal/journal-database-records-migration.test.ts deleted file mode 100644 index 120588b306dd..000000000000 --- a/src/main/native-chat/agent-session-journal/journal-database-records-migration.test.ts +++ /dev/null @@ -1,227 +0,0 @@ -// Version 4: the chat records join the journal database, copied in by the migration's own -// transaction, so "copied" is exactly `user_version >= 4`. - -import { existsSync } from 'node:fs' -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 Database from '../../sqlite/sync-database' -import { - journalPragmaNumber, - NO_LEGACY_JOURNAL_RECORDS, - openJournalDatabase, - readJournalDatabaseVersion, - type JournalLegacyRecordImport -} from './journal-database' -import { createJournalTablesSql } from './journal-database-schema' -import { journalDatabasePath } from './journal-host-database' - -let root: string -let dbPath: string - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-journal-records-migration-')) - dbPath = journalDatabasePath(root) -}) - -afterEach(async () => { - vi.restoreAllMocks() - await rm(root, { recursive: true, force: true }) -}) - -/** What a build from before the records moved left: the released version-3 shape. */ -function seedVersion3(): void { - const db = new Database(dbPath) - db.pragma('journal_mode = WAL') - db.exec(createJournalTablesSql()) - db.prepare( - "INSERT INTO journal_sessions (session_id, workspace_id, epoch) VALUES ('s1', 'ws', 'e1')" - ).run() - db.pragma('user_version = 3') - db.close() -} - -/** A copy of one record row, counting how many times the migration ran it. */ -function importOf(sessionId: string): JournalLegacyRecordImport & { runs: number } { - const copy = { - owed: false as const, - runs: 0, - write: (db: Database.Database) => { - copy.runs += 1 - db.prepare( - 'INSERT OR IGNORE INTO agent_session_records (session_id, record_json) VALUES (?, ?)' - ).run(sessionId, '{}') - } - } - return copy -} - -function inspect(read: (db: Database.Database) => T): T { - const db = new Database(dbPath) - try { - return read(db) - } finally { - db.close() - } -} - -const recordIds = (db: Database.Database): string[] => - db - .prepare('SELECT session_id FROM agent_session_records ORDER BY session_id') - .all() - .map((row) => String(row.session_id)) - -const hasTable = (db: Database.Database, name: string): boolean => - db - .prepare("SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = ?") - .get(name) !== undefined - -describe('the version-4 migration', () => { - it('moves a version-3 database to 4 with its rows and the copied records', () => { - seedVersion3() - const copy = importOf('session-a') - - const opened = openJournalDatabase(dbPath, copy) - opened.db.close() - - expect(opened).toMatchObject({ readOnly: false, legacyRecordImportOwed: false }) - expect(copy.runs).toBe(1) - inspect((db) => { - expect(journalPragmaNumber(db, 'user_version')).toBe(4) - expect(recordIds(db)).toEqual(['session-a']) - expect(db.prepare('SELECT epoch FROM journal_sessions').get()).toEqual({ epoch: 'e1' }) - }) - }) - - it('never runs the copy again once the database is at 4', () => { - seedVersion3() - openJournalDatabase(dbPath, importOf('session-a')).db.close() - const again = importOf('session-b') - - openJournalDatabase(dbPath, again).db.close() - - expect(again.runs).toBe(0) - inspect((db) => expect(recordIds(db)).toEqual(['session-a'])) - }) - - it('leaves version 3 and no record rows when it dies inside the migration, then copies once', () => { - seedVersion3() - const original = Database.prototype.pragma - const pragma = vi.spyOn(Database.prototype, 'pragma').mockImplementation(function ( - this: Database.Database, - sql: string, - options?: { simple?: boolean } - ) { - if (sql === 'user_version = 4') { - throw new Error('crash before the version is published') - } - return original.call(this, sql, options) - }) - - expect(() => openJournalDatabase(dbPath, importOf('session-a'))).toThrow( - 'crash before the version is published' - ) - pragma.mockRestore() - inspect((db) => { - expect(journalPragmaNumber(db, 'user_version')).toBe(3) - expect(hasTable(db, 'agent_session_records')).toBe(false) - }) - - const retry = importOf('session-a') - openJournalDatabase(dbPath, retry).db.close() - expect(retry.runs).toBe(1) - inspect((db) => { - expect(journalPragmaNumber(db, 'user_version')).toBe(4) - expect(recordIds(db)).toEqual(['session-a']) - }) - }) - - it('makes the tables but keeps the copy owed when the records file could not be read', () => { - seedVersion3() - - const opened = openJournalDatabase(dbPath, { owed: true }) - // A chat created while the copy is owed keeps its row through the copy that follows. - opened.db - .prepare("INSERT INTO agent_session_records (session_id, record_json) VALUES ('new', '{}')") - .run() - opened.db.close() - - expect(opened.legacyRecordImportOwed).toBe(true) - inspect((db) => expect(journalPragmaNumber(db, 'user_version')).toBe(3)) - openJournalDatabase(dbPath, importOf('session-a')).db.close() - inspect((db) => { - expect(journalPragmaNumber(db, 'user_version')).toBe(4) - expect(recordIds(db)).toEqual(['new', 'session-a']) - }) - }) - - it('stamps a fresh file with the released version while the copy is owed', () => { - openJournalDatabase(dbPath, { owed: true }).db.close() - - inspect((db) => { - expect(journalPragmaNumber(db, 'user_version')).toBe(3) - expect(hasTable(db, 'journal_rows')).toBe(true) - expect(hasTable(db, 'agent_session_records')).toBe(true) - }) - }) - - // An install's first probe must not leave an empty database that reads as a profile with chats. - it('reads a missing database as version 0 without creating it', () => { - expect(readJournalDatabaseVersion(dbPath)).toBe(0) - expect(existsSync(dbPath)).toBe(false) - }) - - it('creates every table on a fresh file at version 4', () => { - openJournalDatabase(dbPath, NO_LEGACY_JOURNAL_RECORDS).db.close() - - inspect((db) => { - expect(journalPragmaNumber(db, 'user_version')).toBe(4) - for (const table of [ - 'journal_rows', - 'agent_session_records', - 'agent_session_operations', - 'agent_session_retired_claim_keys', - 'agent_session_tabs', - 'agent_session_store_meta' - ]) { - expect(hasTable(db, table)).toBe(true) - } - }) - }) -}) - -/** - * The open of a build released before the records moved (version 3), pinned here: it latches any - * higher version read-only. Its records file stays its own and writable, so nothing it does can - * reach the records this database holds. - */ -function openAsVersion3Build(path: string): { db: Database.Database; readOnly: boolean } { - const probe = new Database(path) - const stored = journalPragmaNumber(probe, 'user_version') - if (stored > 3) { - probe.close() - return { db: new Database(path, { readonly: true, fileMustExist: true }), readOnly: true } - } - return { db: probe, readOnly: false } -} - -describe('a build from before the move', () => { - it('opens a version-4 database read-only, so it never writes beside the records', () => { - openJournalDatabase(dbPath, importOf('session-a')).db.close() - - const older = openAsVersion3Build(dbPath) - try { - expect(older.readOnly).toBe(true) - expect(() => - older.db - .prepare( - "INSERT INTO journal_sessions (session_id, workspace_id, epoch) VALUES ('s2', 'ws', 'e')" - ) - .run() - ).toThrow(/readonly/i) - } finally { - older.db.close() - } - }) -}) diff --git a/src/main/native-chat/agent-session-journal/journal-database-schema.ts b/src/main/native-chat/agent-session-journal/journal-database-schema.ts index 00538fe75f9b..28546de389a8 100644 --- a/src/main/native-chat/agent-session-journal/journal-database-schema.ts +++ b/src/main/native-chat/agent-session-journal/journal-database-schema.ts @@ -2,12 +2,10 @@ // // `journal_rows` is every chat's append-only log, keyed `(session_id, epoch, seq)`. // `journal_sessions` names each chat's live epoch, and is written only when that epoch changes, so -// an append is one INSERT. `journal_repairs` is only for an older build, which reads and writes it -// on this same schema version; this build does neither. `journal_imports` -// records which per-chat file each chat was copied from (journal-per-session-reimport.ts), and -// `journal_set_aside` each chat whose per-chat file is not this build's history and is never read -// again. The `agent_session_*` tables hold each chat's ownership record, its operation ledger, the -// retired claim keys and the chat tab index (agent-session-record-rows.ts). +// an append is one INSERT. `journal_repairs`, `journal_imports` and `journal_set_aside` are only +// for an older build, which reads and writes them on this same schema version; this build does +// neither. The `agent_session_*` tables hold each chat's ownership record, its operation ledger, +// the retired claim keys and the chat tab index (agent-session-record-rows.ts). /** DB shape version, carried in `PRAGMA user_version`. Independent of the row body version * (`JournalRow.v`): a newer build can change either alone. A newer version is opened read-only here @@ -51,8 +49,8 @@ CREATE TABLE IF NOT EXISTS journal_set_aside ( ` } -/** Version 4: the chat records, until then a JSON file beside the database. Each row is one JSON - * value as the file held it, so a row this build cannot read stays byte-identical. */ +/** Version 4: the chat records. Each row is one JSON value, so a row this build cannot read stays + * byte-identical. */ export function createAgentSessionRecordTablesSql(): string { return ` CREATE TABLE IF NOT EXISTS agent_session_records ( diff --git a/src/main/native-chat/agent-session-journal/journal-database.test.ts b/src/main/native-chat/agent-session-journal/journal-database.test.ts index 649cfccdf1a3..473504f51ecd 100644 --- a/src/main/native-chat/agent-session-journal/journal-database.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-database.test.ts @@ -8,15 +8,17 @@ import { JOURNAL_BUSY_TIMEOUT_MS, JOURNAL_SIZE_LIMIT_BYTES, journalPragmaNumber, - NO_LEGACY_JOURNAL_RECORDS, openJournalDatabase } from './journal-database' -import { JOURNAL_DB_SCHEMA_VERSION } from './journal-database-schema' +import { + createJournalTablesSql, + JOURNAL_DB_OLDEST_RELEASED_VERSION, + JOURNAL_DB_SCHEMA_VERSION +} from './journal-database-schema' import { journalDatabasePath } from './journal-host-database' import { JournalUnreleasedSchemaError } from './journal-open-failure' import { deleteJournalEpochRows, - deleteUnpublishedJournalRows, insertJournalRow, iterateJournalEpochRows, publishJournalSessionEpoch, @@ -66,7 +68,7 @@ afterEach(async () => { describe('the host journal database open', () => { it('creates every table and reads back every load-bearing pragma', () => { - const db = openJournalDatabase(dbPath, NO_LEGACY_JOURNAL_RECORDS).db + const db = openJournalDatabase(dbPath).db try { const tables = db .prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name") @@ -97,14 +99,14 @@ describe('the host journal database open', () => { // T5: a newer build's database opens read-only, its rows readable, and is left byte-identical. it('opens a newer user_version read-only without touching the file', async () => { - const seeded = openJournalDatabase(dbPath, NO_LEGACY_JOURNAL_RECORDS).db + const seeded = openJournalDatabase(dbPath).db publishJournalSessionEpoch(seeded, SESSION, 'epoch-1') insertJournalRow(seeded, 'session-1', epochRow(1)) seeded.pragma(`user_version = ${JOURNAL_DB_SCHEMA_VERSION + 5}`) seeded.close() const before = await digest(dbPath) - const opened = openJournalDatabase(dbPath, NO_LEGACY_JOURNAL_RECORDS) + const opened = openJournalDatabase(dbPath) try { expect(opened.readOnly).toBe(true) expect(readJournalSessionEpoch(opened.db, 'session-1')).toBe('epoch-1') @@ -121,7 +123,7 @@ describe('the host journal database open', () => { it('closes the raw connection when schema setup throws', async () => { const failing = join(root, 'nested', 'agent-session-journal.db') - expect(() => openJournalDatabase(failing, NO_LEGACY_JOURNAL_RECORDS)).toThrow() + expect(() => openJournalDatabase(failing)).toThrow() await expect(stat(`${failing}-wal`)).rejects.toThrow() await expect(rm(root, { recursive: true, force: true })).resolves.toBeUndefined() root = await mkdtemp(join(tmpdir(), 'orca-journal-db-')) @@ -130,7 +132,7 @@ describe('the host journal database open', () => { describe('journal row statements', () => { it('serves replay, resume and an epoch discard', () => { - const db = openJournalDatabase(dbPath, NO_LEGACY_JOURNAL_RECORDS).db + const db = openJournalDatabase(dbPath).db try { db.exec('BEGIN IMMEDIATE') for (let seq = 1; seq <= 5; seq += 1) { @@ -157,27 +159,8 @@ describe('journal row statements', () => { } }) - it('deletes only the rows no pointer names', () => { - const db = openJournalDatabase(dbPath, NO_LEGACY_JOURNAL_RECORDS).db - try { - insertJournalRow(db, 'session-1', epochRow(1, 'epoch-copying')) - insertJournalRow(db, 'session-2', epochRow(1, 'epoch-live')) - insertJournalRow(db, 'session-2', epochRow(1, 'epoch-stale')) - publishJournalSessionEpoch(db, { sessionId: 'session-2', workspaceId: 'ws-1' }, 'epoch-live') - - deleteUnpublishedJournalRows(db, 'session-1') - deleteUnpublishedJournalRows(db, 'session-2') - - expect(rowsOf(db, 'session-1', 'epoch-copying')).toEqual([]) - expect(rowsOf(db, 'session-2', 'epoch-live')).toEqual([1]) - expect(rowsOf(db, 'session-2', 'epoch-stale')).toEqual([]) - } finally { - db.close() - } - }) - it('refuses a duplicate sequence inside one epoch of one chat', () => { - const db = openJournalDatabase(dbPath, NO_LEGACY_JOURNAL_RECORDS).db + const db = openJournalDatabase(dbPath).db try { insertJournalRow(db, 'session-1', epochRow(1)) expect(() => insertJournalRow(db, 'session-1', epochRow(1))).toThrow() @@ -189,7 +172,7 @@ describe('journal row statements', () => { }) it('moves the epoch pointer in place', () => { - const db = openJournalDatabase(dbPath, NO_LEGACY_JOURNAL_RECORDS).db + const db = openJournalDatabase(dbPath).db try { publishJournalSessionEpoch(db, SESSION, 'epoch-1') publishJournalSessionEpoch(db, SESSION, 'epoch-2') @@ -217,9 +200,7 @@ describe('schema creation', () => { return original.call(this, sql, options) }) - expect(() => openJournalDatabase(dbPath, NO_LEGACY_JOURNAL_RECORDS)).toThrow( - 'crash before the version is published' - ) + expect(() => openJournalDatabase(dbPath)).toThrow('crash before the version is published') pragma.mockRestore() const inspected = new Database(dbPath) @@ -235,6 +216,25 @@ describe('schema creation', () => { } }) + it('gives a version 3 database the chat record tables, keeping its rows', () => { + const released = new Database(dbPath) + released.exec(createJournalTablesSql()) + released.exec("INSERT INTO journal_sessions VALUES ('s1', 'ws', 'e1')") + released.pragma(`user_version = ${JOURNAL_DB_OLDEST_RELEASED_VERSION}`) + released.close() + + const { db } = openJournalDatabase(dbPath) + try { + expect(journalPragmaNumber(db, 'user_version')).toBe(JOURNAL_DB_SCHEMA_VERSION) + expect(db.prepare('SELECT session_id FROM journal_sessions').all()).toEqual([ + { session_id: 's1' } + ]) + expect(db.prepare('SELECT count(*) AS n FROM agent_session_records').get()).toEqual({ n: 0 }) + } finally { + db.close() + } + }) + // Versions 1 and 2 were written only by unreleased builds; neither is migrated. it.each([1, 2])('refuses a version %i database without touching the file', async (version) => { const earlier = new Database(dbPath) @@ -249,12 +249,8 @@ INSERT INTO journal_sessions VALUES ('s1', 'ws', 'e1', 0, NULL, NULL);`) earlier.close() const before = await digest(dbPath) - expect(() => openJournalDatabase(dbPath, NO_LEGACY_JOURNAL_RECORDS)).toThrow( - `unreleased schema ${version}` - ) - expect(() => openJournalDatabase(dbPath, NO_LEGACY_JOURNAL_RECORDS)).toThrow( - JournalUnreleasedSchemaError - ) + expect(() => openJournalDatabase(dbPath)).toThrow(`unreleased schema ${version}`) + expect(() => openJournalDatabase(dbPath)).toThrow(JournalUnreleasedSchemaError) expect(await digest(dbPath)).toBe(before) await expect(stat(`${dbPath}-wal`)).rejects.toThrow() diff --git a/src/main/native-chat/agent-session-journal/journal-database.ts b/src/main/native-chat/agent-session-journal/journal-database.ts index a306ad6e564e..04483d099583 100644 --- a/src/main/native-chat/agent-session-journal/journal-database.ts +++ b/src/main/native-chat/agent-session-journal/journal-database.ts @@ -4,7 +4,6 @@ // no DDL: a database written by a newer schema must be left byte-identical, and // `journal_mode = WAL` writes the file header. -import { existsSync } from 'node:fs' import Database from '../../sqlite/sync-database' import { hardenSqliteDatabaseFiles } from '../../sqlite/harden-database-files' import { @@ -19,62 +18,21 @@ import { ensureQueuedMessagesTable } from './queued-message-schema' export const JOURNAL_BUSY_TIMEOUT_MS = 5000 /** Bounds the WAL a checkpoint leaves behind; SQLite truncates it back to this after a reset. */ export const JOURNAL_SIZE_LIMIT_BYTES = 32 * 1024 * 1024 -/** Every commit but a first-use copy's batches, which no reader follows until a synced commit. */ -export const JOURNAL_SYNCHRONOUS = 'FULL' +/** Every commit is fsynced before its caller continues. */ +const JOURNAL_SYNCHRONOUS = 'FULL' export type OpenJournalDatabase = { db: Database.Database /** A newer `user_version` was met: this build reads and never writes. */ readOnly: boolean - /** The chat records file could not be read this launch: version 4's copy of it is still owed. */ - legacyRecordImportOwed: boolean -} - -/** - * What version 4's migration copies in from the chat records file that preceded it, read before - * the open so no transaction waits on a file read. `owed` is a read that can clear: the tables are - * made, and the copy and the version bump wait for a launch whose read succeeds. - */ -export type JournalLegacyRecordImport = - | { owed: true } - | { owed: false; write: (db: Database.Database) => void } - -export const NO_LEGACY_JOURNAL_RECORDS: JournalLegacyRecordImport = { - owed: false, - write: () => undefined } export function journalPragmaNumber(db: Database.Database, name: string): number { return Number(db.pragma(name, { simple: true }) ?? 0) } -/** Whether an open of a database at `stored` runs version 4's migration, and so reads the records - * file first. */ -export function journalDatabaseMigratesRecords(stored: number): boolean { - return ( - stored === 0 || - (stored >= JOURNAL_DB_OLDEST_RELEASED_VERSION && stored < JOURNAL_DB_SCHEMA_VERSION) - ) -} - -/** 0 for a database not created yet: the probe never creates the file. */ -export function readJournalDatabaseVersion(dbPath: string): number { - if (!existsSync(dbPath)) { - return 0 - } - const probe = new Database(dbPath) - try { - return journalPragmaNumber(probe, 'user_version') - } finally { - probe.close() - } -} - -/** - * Whether the database holds any chat record or tab, read-only. `undefined` while version 4's copy - * of the records file is still owed, so the file answers for the chats it holds. - */ -export function journalDatabaseHoldsAgentSessions(dbPath: string): boolean | undefined { +/** Whether the database holds any chat record or tab, read-only. */ +export function journalDatabaseHoldsAgentSessions(dbPath: string): boolean { const db = new Database(dbPath, { readonly: true, fileMustExist: true }) try { const tables = new Set( @@ -83,23 +41,16 @@ export function journalDatabaseHoldsAgentSessions(dbPath: string): boolean | und .all() .map(({ name }) => name) ) - const holds = ['agent_session_records', 'agent_session_tabs'].some( + return ['agent_session_records', 'agent_session_tabs'].some( (table) => tables.has(table) && db.prepare(`SELECT 1 FROM ${table} LIMIT 1`).get() !== undefined ) - if (holds || journalPragmaNumber(db, 'user_version') >= JOURNAL_DB_SCHEMA_VERSION) { - return holds - } - return undefined } finally { db.close() } } -export function openJournalDatabase( - dbPath: string, - legacyRecords: JournalLegacyRecordImport -): OpenJournalDatabase { +export function openJournalDatabase(dbPath: string): OpenJournalDatabase { const probe = new Database(dbPath) let stored: number try { @@ -112,8 +63,7 @@ export function openJournalDatabase( probe.close() return { db: new Database(dbPath, { readonly: true, fileMustExist: true }), - readOnly: true, - legacyRecordImportOwed: false + readOnly: true } } let transferred = false @@ -125,14 +75,14 @@ export function openJournalDatabase( ) } configureJournalPragmas(probe, stored) - const legacyRecordImportOwed = migrateJournalSchema(probe, stored, legacyRecords) + migrateJournalSchema(probe, stored) // Outside `migrateJournalSchema` on purpose: its early return skips a db // already at the current version, and this table must exist at EVERY // writable open with no `user_version` bump (see `ensureQueuedMessagesTable`). ensureQueuedMessagesTable(probe) hardenSqliteDatabaseFiles(dbPath) transferred = true - return { db: probe, readOnly: false, legacyRecordImportOwed } + return { db: probe, readOnly: false } } finally { if (!transferred) { probe.close() @@ -159,35 +109,20 @@ function configureJournalPragmas(db: Database.Database, stored: number): void { } /** - * Table creation, the records copy and the `user_version` bump are ONE transaction. Creating the - * tables first left a shaped database still reporting version 0, which an older build does not - * latch read-only; and "copied" is `user_version >= 4`, so no other marker can disagree with it. - * Returns whether the copy is still owed. + * Table creation and the `user_version` bump are ONE transaction: creating the tables first left a + * shaped database still reporting version 0, which an older build does not latch read-only. */ -function migrateJournalSchema( - db: Database.Database, - stored: number, - legacyRecords: JournalLegacyRecordImport -): boolean { +function migrateJournalSchema(db: Database.Database, stored: number): void { if (stored >= JOURNAL_DB_SCHEMA_VERSION) { - return false + return } runJournalTransaction(db, () => { if (stored === 0) { db.exec(createJournalTablesSql()) } db.exec(createAgentSessionRecordTablesSql()) - if (legacyRecords.owed) { - // A fresh file still takes a released version, so an older build opens it as one. - if (stored === 0) { - db.pragma(`user_version = ${JOURNAL_DB_OLDEST_RELEASED_VERSION}`) - } - return - } - legacyRecords.write(db) db.pragma(`user_version = ${JOURNAL_DB_SCHEMA_VERSION}`) }) - return legacyRecords.owed } /** diff --git a/src/main/native-chat/agent-session-journal/journal-file-format-remnant.test.ts b/src/main/native-chat/agent-session-journal/journal-file-format-remnant.test.ts deleted file mode 100644 index eed26b20388d..000000000000 --- a/src/main/native-chat/agent-session-journal/journal-file-format-remnant.test.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' -// An empty chat beside a pre-SQLite journal explains itself. -// -// The SQLite move shipped no importer, so a session whose history is a -// `log.jsonl` founds a fresh empty journal beside it and looks exactly like a -// chat created seconds ago. One status row is the difference. - -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' -import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' -import { projectStructuredItemsToNativeChat } from '../../../shared/structured-agent-session-projection' -import { JOURNAL_FILE_FORMAT_REMNANT_DISCLOSURE_IDENTITY } from './journal-file-format-remnant' -import { journalDirectoryFor } from './journal-paths' -import type { AgentSessionJournal } from './journal-store' -import type { openAgentSessionJournal } from './journal-store-factory' -import { - createTrackedJournalOpener, - openTestJournalHostDatabase, - loadTestJournal, - deleteTestJournalRow, - insertTestJournalRowJson, - liveTestJournalRows, - SAVED_BY_NEWER_ORCA -} from './journal-host-database-test-support' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' - -const IDENTITY: AgentSessionJournalIdentity = { - sessionId: 'session-1', - workspaceId: 'ws-1', - hostId: 'host-1', - agent: 'codex', - providerHandle: codexProviderHandle('thread-1') -} - -const DISCLOSURE_ITEM_ID = agentJournalItemKey(JOURNAL_FILE_FORMAT_REMNANT_DISCLOSURE_IDENTITY) - -let root: string -let clock = 1_000 -const journals = createTrackedJournalOpener() - -function open(overrides: Partial[0]> = {}) { - return journals.open({ - identity: IDENTITY, - stateDirectory: root, - now: () => (clock += 1), - mintEpoch: () => `epoch-${clock}`, - ...overrides - }) -} - -/** Where this chat's history lived before the journal was one database per host. */ -function legacyDir(): string { - return journalDirectoryFor(root, IDENTITY) -} - -async function writeRemnant(name = 'log.jsonl'): Promise { - await mkdir(legacyDir(), { recursive: true }) - await writeFile(join(legacyDir(), name), '{"kind":"epoch","v":1,"seq":1}\n', 'utf8') -} - -function disclosure(journal: AgentSessionJournal): string | null { - const row = journal.snapshot().items.find((entry) => entry.itemId === DISCLOSURE_ITEM_ID) - return row?.body.kind === 'status' ? row.body.text : null -} - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-journal-remnant-')) - clock = 1_000 -}) - -afterEach(async () => { - await journals.closeAll() - await rm(root, { recursive: true, force: true }) -}) - -describe('a chat whose history is still in the pre-SQLite format', () => { - it('says how to carry on, and where the transcript is', async () => { - await writeRemnant() - - const journal = await open() - - expect(disclosure(journal)).toContain('send a message to pick up where you left off') - expect(disclosure(journal)).toContain(join(legacyDir(), 'log.jsonl')) - expect(disclosure(journal)).toContain('Codex') - }) - - // Both files is the normal shape of a pre-SQLite directory: every epoch roll - // staged a snapshot whether or not anything compacted into it, so preferring - // the snapshot would name an empty file for ~every affected chat. - it('names the log, not the snapshot staged beside it', async () => { - await writeRemnant('log.jsonl') - await writeRemnant('snapshot.json') - - const journal = await open() - - expect(disclosure(journal)).toContain(join(legacyDir(), 'log.jsonl')) - expect(disclosure(journal)).not.toContain('snapshot.json') - }) - - it('falls back to the snapshot when a chat has no log beside it', async () => { - await writeRemnant('snapshot.json') - - const journal = await open() - - expect(disclosure(journal)).toContain(join(legacyDir(), 'snapshot.json')) - }) - - it('says nothing to a chat that is genuinely new', async () => { - const journal = await open() - - expect(journal.snapshot().items).toEqual([]) - }) - - // Counting rows proves nothing here — the append upserts by identity, so a - // second append would still leave exactly one. The revision is what moves. - it('does not re-append the row on a later open', async () => { - await writeRemnant() - const first = await open() - const firstRevision = first - .snapshot() - .items.find((e) => e.itemId === DISCLOSURE_ITEM_ID)?.revision - await first.close() - - const reopened = await open() - - const row = reopened.snapshot().items.find((e) => e.itemId === DISCLOSURE_ITEM_ID) - expect(firstRevision).toBe(1) - expect(row?.revision).toBe(1) - expect(reopened.cursor().sequence).toBe(2) - }) - - // The epoch commit and this append are separate transactions; if the append is - // lost the epoch exists but holds nothing, and every later open takes the - // adopt branch. The offer has to survive that. - it('offers the message again when a committed epoch holds nothing', async () => { - const founded = await open() - await founded.close() - await writeRemnant() - - const reopened = await open() - - expect(disclosure(reopened)).toContain(join(legacyDir(), 'log.jsonl')) - }) - - // A damaged journal fails its open before this branch, so nothing is appended to it. - it('writes nothing into a damaged journal', async () => { - const journal = await open() - await journal.appendItem( - { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 0 }, - { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'history' }] }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - await journal.close() - const opened = openTestJournalHostDatabase(root) - try { - deleteTestJournalRow(opened.db, IDENTITY.sessionId, 1) - } finally { - opened.close() - } - await writeRemnant() - - await expect(open()).rejects.toMatchObject({ - refusal: { details: { reason: 'journalCorrupt' } } - }) - expect(loadTestJournal(root, IDENTITY.sessionId)).toMatchObject({ - damage: { sequence: 1, cause: 'no-epoch-row' } - }) - const stored = openTestJournalHostDatabase(root) - try { - expect(liveTestJournalRows(stored.db, IDENTITY.sessionId).map((row) => row.seq)).toEqual([2]) - } finally { - stored.close() - } - }) - - // A newer row fails the load before the empty-epoch branch would write a notice. - it("writes nothing into a newer Orca's journal, whose open is refused", async () => { - const founded = await open() - const epoch = founded.epoch - await founded.close() - insertTestJournalRowJson( - openTestJournalHostDatabase(root).db, - IDENTITY.sessionId, - 2, - JSON.stringify({ - v: 99, - kind: 'item', - epoch, - seq: 2, - fence: 1, - ts: 1, - itemId: 'future', - revision: 1, - body: { kind: 'status', text: 'from a newer build' } - }) - ) - await writeRemnant() - - const before = liveTestJournalRows(openTestJournalHostDatabase(root).db, IDENTITY.sessionId) - - await expect(open()).rejects.toMatchObject(SAVED_BY_NEWER_ORCA) - expect(liveTestJournalRows(openTestJournalHostDatabase(root).db, IDENTITY.sessionId)).toEqual( - before - ) - }) - - // A row nothing projects is a row nobody reads. - it('renders in the transcript as a system line', async () => { - await writeRemnant() - - const journal = await open() - - const messages = projectStructuredItemsToNativeChat(journal.snapshot().items) - expect(messages).toHaveLength(1) - expect(messages[0]?.role).toBe('system') - }) -}) diff --git a/src/main/native-chat/agent-session-journal/journal-file-format-remnant.ts b/src/main/native-chat/agent-session-journal/journal-file-format-remnant.ts deleted file mode 100644 index 69dcc04bf640..000000000000 --- a/src/main/native-chat/agent-session-journal/journal-file-format-remnant.ts +++ /dev/null @@ -1,60 +0,0 @@ -// A journal directory left behind by the pre-SQLite file format. -// -// Not `journal-legacy-import.ts`, which reads the PROVIDER's own transcript. -// This is Orca's own `log.jsonl`, which no build after the SQLite move reads. -// Nothing imports it, so the session it belonged to opens empty and is -// indistinguishable from a chat created seconds ago — same `session_created` -// epoch, same empty timeline. The remnant is the one durable fact that tells -// them apart, so the empty session says where its history went and how to carry -// on instead of silently claiming it never had any. - -import { existsSync } from 'node:fs' -import { join } from 'node:path' -import type { AgentJournalItemIdentity } from '../../../shared/agent-session-journal-types' -import { boundJournalStatusText } from './journal-prompt-body-bounds' -import { formatAgentTypeLabel } from '../../../shared/agent-type-label' -import type { AgentType } from '../../../shared/agent-status-types' - -/** The remnant's transcript, or null when the directory never held one. - * - * `log.jsonl` first, and the order matters: every epoch roll staged a - * `snapshot.json` whether or not anything was ever compacted into it, so the - * file's existence says nothing about where the history lives. Measured across - * a real profile, `compactedThrough` was 0 in all 80 — the log holds the - * transcript and the snapshot is the fallback for a session that has no log. */ -export function findJournalFileFormatRemnant(journalDir: string): string | null { - for (const name of ['log.jsonl', 'snapshot.json']) { - const path = join(journalDir, name) - if (existsSync(path)) { - return path - } - } - return null -} - -/** One stable identity, so a reopen upserts the same row instead of adding one. */ -export const JOURNAL_FILE_FORMAT_REMNANT_DISCLOSURE_IDENTITY: AgentJournalItemIdentity = { - provider: 'orca', - clientMessageId: 'journal-file-format-remnant' -} - -/** How to carry on. The session attaches on the record's own provider handle, so it - * still names the conversation the transcript no longer shows — whether the provider - * itself still holds that thread is its own business, hence "points at". */ -export function journalFileFormatRemnantDisclosure(input: { - transcriptPath: string - agent: AgentType -}): { identity: AgentJournalItemIdentity; body: { kind: 'status'; text: string } } { - return { - identity: JOURNAL_FILE_FORMAT_REMNANT_DISCLOSURE_IDENTITY, - body: { - kind: 'status', - text: boundJournalStatusText( - `This chat's history was saved in an older format Orca no longer reads, so it starts ` + - `empty. The session still points at the same ${formatAgentTypeLabel(input.agent)} ` + - `conversation — send a message to pick up where you left off. The original ` + - `transcript is on the session's host at \`${input.transcriptPath}\`` - ) - } - } -} diff --git a/src/main/native-chat/agent-session-journal/journal-host-database-test-support.ts b/src/main/native-chat/agent-session-journal/journal-host-database-test-support.ts index ad151d10ec72..32b0c846571f 100644 --- a/src/main/native-chat/agent-session-journal/journal-host-database-test-support.ts +++ b/src/main/native-chat/agent-session-journal/journal-host-database-test-support.ts @@ -3,7 +3,6 @@ // same directory reads the same database the way a restarted host would. import { resolve } from 'node:path' -import { NO_LEGACY_JOURNAL_RECORDS } from './journal-database' import { JournalHostDatabase } from './journal-host-database' import { replayJournal, type JournalLoad } from './journal-open' import { serializeJournalRow, type JournalRow } from './journal-row-schema' @@ -26,7 +25,7 @@ export function openTestJournalHostDatabase(stateDirectory: string): JournalHost if (existing && !existing.isClosed) { return existing } - const database = JournalHostDatabase.openWith(directory, NO_LEGACY_JOURNAL_RECORDS) + const database = JournalHostDatabase.open(directory) opened.set(directory, database) return database } diff --git a/src/main/native-chat/agent-session-journal/journal-host-database.test.ts b/src/main/native-chat/agent-session-journal/journal-host-database.test.ts index 4f209c2f2027..dc2980d4833a 100644 --- a/src/main/native-chat/agent-session-journal/journal-host-database.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-host-database.test.ts @@ -184,14 +184,14 @@ setTimeout(() => { db.exec('COMMIT'); db.close() }, 200)`, vi.spyOn(console, 'warn').mockImplementation(() => undefined) const stopFailing = failCommits(connection) - // A first-use copy's batch, under the unsynced level. Its caller gets the COMMIT's own error. + // Its caller gets the COMMIT's own error. expect(() => - database.unsyncedTransaction((db) => + database.transaction((db) => db .prepare( 'INSERT INTO journal_rows (session_id, epoch, seq, ts, row_json) VALUES (?, ?, ?, ?, ?)' ) - .run('copying', 'epoch-copying', 1, 1, '{}') + .run('unknown', 'epoch-unknown', 1, 1, '{}') ) ).toThrow('FOREIGN KEY constraint failed') expect(connection.isTransaction).toBe(true) @@ -210,8 +210,6 @@ setTimeout(() => { db.exec('COMMIT'); db.close() }, 200)`, rollbackFails = false expect(database.db.isTransaction).toBe(false) stopFailing() - // Restored with the ROLLBACK: no later commit runs at the copy's unsynced level. - expect(Number(connection.pragma('synchronous', { simple: true }))).toBe(2) await expect( other.appendItem(item(1), text('served'), { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }) ).resolves.toBeDefined() diff --git a/src/main/native-chat/agent-session-journal/journal-host-database.ts b/src/main/native-chat/agent-session-journal/journal-host-database.ts index ca6036d3a9d0..e87de366fff2 100644 --- a/src/main/native-chat/agent-session-journal/journal-host-database.ts +++ b/src/main/native-chat/agent-session-journal/journal-host-database.ts @@ -6,20 +6,13 @@ import { mkdirSync } from 'node:fs' import { join } from 'node:path' -import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import type Database from '../../sqlite/sync-database' import { - JOURNAL_SYNCHRONOUS, - journalDatabaseMigratesRecords, - NO_LEGACY_JOURNAL_RECORDS, openJournalDatabase, - readJournalDatabaseVersion, runJournalTransaction, - type JournalLegacyRecordImport, type OpenJournalDatabase } from './journal-database' import { journalOpenRefusalError } from './journal-open-failure' -import { journalDirectoryFor } from './journal-paths' import { AgentSessionJournalError } from './journal-write-guards' const JOURNAL_DATABASE_FILE = 'agent-session-journal.db' @@ -32,45 +25,17 @@ export class JournalHostDatabase { private connection: Database.Database | null /** A newer Orca wrote the database: every chat's history reads, and no chat writes. */ readonly readOnly: boolean - /** The chat records file could not be read this launch, so its copy waits for a later one. */ - readonly legacyRecordImportOwed: boolean /** A failed transaction's ROLLBACK failed too, so the transaction may still be open. */ private stranded = false - private constructor( - readonly stateDirectory: string, - opened: OpenJournalDatabase - ) { + private constructor(opened: OpenJournalDatabase) { this.connection = opened.db this.readOnly = opened.readOnly - this.legacyRecordImportOwed = opened.legacyRecordImportOwed } - /** `readLegacyRecords` runs only when this open migrates to version 4, before any transaction. */ - static async open( - stateDirectory: string, - readLegacyRecords: () => Promise - ): Promise { + static open(stateDirectory: string): JournalHostDatabase { mkdirSync(stateDirectory, { recursive: true }) - const migrates = journalDatabaseMigratesRecords( - readJournalDatabaseVersion(journalDatabasePath(stateDirectory)) - ) - return JournalHostDatabase.openWith( - stateDirectory, - migrates ? await readLegacyRecords() : NO_LEGACY_JOURNAL_RECORDS - ) - } - - /** The same open with the records file already read; tests pass `NO_LEGACY_JOURNAL_RECORDS`. */ - static openWith( - stateDirectory: string, - legacyRecords: JournalLegacyRecordImport - ): JournalHostDatabase { - mkdirSync(stateDirectory, { recursive: true }) - return new JournalHostDatabase( - stateDirectory, - openJournalDatabase(journalDatabasePath(stateDirectory), legacyRecords) - ) + return new JournalHostDatabase(openJournalDatabase(journalDatabasePath(stateDirectory))) } get isClosed(): boolean { @@ -95,31 +60,6 @@ export class JournalHostDatabase { }) } - /** - * The same transaction, committed without an fsync: for rows no reader follows until a later - * synced commit, which under WAL makes every earlier frame durable too. The setting is restored - * in the same task, so no other chat's commit runs under it. - */ - unsyncedTransaction(run: (db: Database.Database) => T): T { - const db = this.db - db.pragma('synchronous = NORMAL') - try { - return this.transaction(run) - } finally { - // SQLite refuses the change inside a transaction; freeing a stranded one restores it. - if (!db.isTransaction) { - db.pragma(`synchronous = ${JOURNAL_SYNCHRONOUS}`) - } - } - } - - /** Where this chat's history lived before the journal was one database per host. */ - legacyDirectoryFor( - identity: Pick - ): string { - return journalDirectoryFor(this.stateDirectory, identity) - } - /** Last, after every store has drained. A close that fails keeps the handle, so the retried * teardown closes this same connection. */ close(): void { @@ -140,7 +80,6 @@ export class JournalHostDatabase { throw journalOpenRefusalError(error) } } - connection.pragma(`synchronous = ${JOURNAL_SYNCHRONOUS}`) this.stranded = false } } diff --git a/src/main/native-chat/agent-session-journal/journal-open-failure.test.ts b/src/main/native-chat/agent-session-journal/journal-open-failure.test.ts index c61057ac1b90..696059ecc44e 100644 --- a/src/main/native-chat/agent-session-journal/journal-open-failure.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-open-failure.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { agentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' -import { NO_LEGACY_JOURNAL_RECORDS, openJournalDatabase } from './journal-database' +import { openJournalDatabase } from './journal-database' import { classifyJournalOpenFailure, createJournalOpenReadRefusals, @@ -31,7 +31,7 @@ afterEach(async () => { /** What the journal's own open, then a chat's replay, throws for the file as it stands. */ function openFailure(): unknown { try { - const db = openJournalDatabase(journalDatabasePath(root), NO_LEGACY_JOURNAL_RECORDS).db + const db = openJournalDatabase(journalDatabasePath(root)).db try { replayJournal(db, 'session-1') } finally { @@ -62,7 +62,7 @@ describe('classifyJournalOpenFailure', () => { it('calls a journal whose pages are damaged corrupt', async () => { const path = journalDatabasePath(root) - const opened = openJournalDatabase(path, NO_LEGACY_JOURNAL_RECORDS).db + const opened = openJournalDatabase(path).db opened.exec('PRAGMA journal_mode = DELETE') opened.close() const bytes = await readFile(path) diff --git a/src/main/native-chat/agent-session-journal/journal-open-failure.ts b/src/main/native-chat/agent-session-journal/journal-open-failure.ts index 8ae45ca4a311..73349ab09391 100644 --- a/src/main/native-chat/agent-session-journal/journal-open-failure.ts +++ b/src/main/native-chat/agent-session-journal/journal-open-failure.ts @@ -20,11 +20,6 @@ type JournalRefusalReason = AgentSessionRefusalReason<'agent_session_journal_unr /** Why an open failed, as the storage shows it; a newer Orca's journal is told apart first. */ export type JournalOpenFailure = Exclude -/** A per-chat file whose copy did not read back as the file: the history is not usable here. */ -export class JournalImportMismatchError extends Error { - override readonly name = 'JournalImportMismatchError' -} - /** A history whose rows are not what they promise: a row no build wrote, a gap, no epoch row. */ export class JournalDamageError extends Error { override readonly name = 'JournalDamageError' @@ -59,14 +54,13 @@ export class JournalUnreleasedSchemaError extends Error { // Bounds a cause chain that loops back on itself. const MAX_CAUSE_DEPTH = 8 -/** Unusable only where proven: damage the storage or the rows show, a copy that did not verify, - * or a file only an unreleased build wrote. Anything unproven can clear. */ +/** Unusable only where proven: damage the storage or the rows show, or a file only an unreleased + * build wrote. Anything unproven can clear. */ export function classifyJournalOpenFailure(error: unknown): JournalOpenFailure { let current = error for (let depth = 0; depth < MAX_CAUSE_DEPTH && current !== undefined; depth += 1) { if ( isSqliteCorruption(current) || - current instanceof JournalImportMismatchError || current instanceof JournalDamageError || current instanceof JournalUnreleasedSchemaError ) { diff --git a/src/main/native-chat/agent-session-journal/journal-owed-import-test-support.ts b/src/main/native-chat/agent-session-journal/journal-owed-import-test-support.ts deleted file mode 100644 index 6f1b3e9dbc3e..000000000000 --- a/src/main/native-chat/agent-session-journal/journal-owed-import-test-support.ts +++ /dev/null @@ -1,112 +0,0 @@ -// A chat still in the per-chat file an earlier build left, opened the way startup restore opens -// one: its copy into the host's database is owed, and the chat's first write pays it before that -// write lands. It is the one backlog a chat's write queue really holds. - -import { mkdir } from 'node:fs/promises' -import { dirname, join } from 'node:path' -import { - AGENT_JOURNAL_THREAD_SCOPE, - type AgentSessionJournalIdentity -} from '../../../shared/agent-session-journal-types' -import Database from '../../sqlite/sync-database' -import { - openTestJournalHostDatabase, - readTestJournalRows -} from './journal-host-database-test-support' -import { journalDirectoryFor, legacyJournalDatabaseFile } from './journal-paths' -import type { JournalStoredRow } from './journal-row-table' -import type { AgentSessionJournal } from './journal-store' -import { openAgentSessionJournal } from './journal-store-factory' - -const OWED_IMPORT_HISTORY_TEXT = 'history from the earlier build' - -/** Opens `identity`'s chat from a per-chat file of real history rows, its copy still owed. */ -export async function openJournalOwingImport(input: { - stateDirectory: string - identity: AgentSessionJournalIdentity - now?: () => number -}): Promise<{ journal: AgentSessionJournal; history: JournalStoredRow[] }> { - const { stateDirectory, identity } = input - const history = await historyRows(input) - await writePerChatFile(journalDirectoryFor(stateDirectory, identity), identity, history) - const journal = await openAgentSessionJournal({ - identity, - database: openTestJournalHostDatabase(stateDirectory), - ...(input.now ? { now: input.now } : {}), - deferPerSessionImport: true - }) - if (!journal.importPending) { - throw new Error('the chat opened with its copy already made') - } - return { journal, history } -} - -/** Real rows, written by today's store into a scratch database, as an earlier build wrote them. */ -async function historyRows(input: { - stateDirectory: string - identity: AgentSessionJournalIdentity - now?: () => number -}): Promise { - const scratch = join(input.stateDirectory, `scratch-${input.identity.sessionId}`) - const journal = await openAgentSessionJournal({ - identity: input.identity, - database: openTestJournalHostDatabase(scratch), - ...(input.now ? { now: input.now } : {}) - }) - await journal.appendSubmission({ - clientMessageId: 'client-history', - payloadFingerprint: 'fp-history', - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'add a retry' }] }, - fence: 1, - handoverRecorded: true - }) - await journal.appendItem( - { provider: 'codex', threadId: 'thread-history', turnId: 'turn-history', ordinal: 1 }, - { - kind: 'message', - role: 'assistant', - blocks: [{ type: 'text', text: OWED_IMPORT_HISTORY_TEXT }] - }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - await journal.close() - return readTestJournalRows( - openTestJournalHostDatabase(scratch).db, - input.identity.sessionId, - journal.epoch - ) -} - -/** The per-chat file an earlier build left, in its own schema. */ -async function writePerChatFile( - directory: string, - identity: AgentSessionJournalIdentity, - rows: readonly JournalStoredRow[] -): Promise { - const path = legacyJournalDatabaseFile(directory) - await mkdir(dirname(path), { recursive: true }) - const db = new Database(path) - try { - db.pragma('journal_mode = WAL') - db.exec(` -CREATE TABLE journal_rows (session_id TEXT NOT NULL, epoch TEXT NOT NULL, seq INTEGER NOT NULL, - ts INTEGER NOT NULL, row_json TEXT NOT NULL, PRIMARY KEY (session_id, epoch, seq)); -CREATE TABLE journal_sessions (session_id TEXT PRIMARY KEY, epoch TEXT NOT NULL, updated_at INTEGER NOT NULL); -CREATE TABLE journal_repairs (session_id TEXT PRIMARY KEY, epoch TEXT NOT NULL, - content_from INTEGER NOT NULL, repaired_at INTEGER NOT NULL);`) - db.pragma('user_version = 2') - const insert = db.prepare( - 'INSERT INTO journal_rows (session_id, epoch, seq, ts, row_json) VALUES (?, ?, ?, ?, ?)' - ) - for (const row of rows) { - insert.run(identity.sessionId, row.epoch, row.seq, row.ts, row.rowJson) - } - db.prepare('INSERT INTO journal_sessions VALUES (?, ?, ?)').run( - identity.sessionId, - rows[0]!.epoch, - 1 - ) - } finally { - db.close() - } -} diff --git a/src/main/native-chat/agent-session-journal/journal-paths.ts b/src/main/native-chat/agent-session-journal/journal-paths.ts deleted file mode 100644 index cb7d44e8c83f..000000000000 --- a/src/main/native-chat/agent-session-journal/journal-paths.ts +++ /dev/null @@ -1,37 +0,0 @@ -// Where a chat's history lived when the journal was one database per chat. -// -// Nothing writes here any more: the host's one database replaced it. The importer copies a file it -// finds here on that chat's first use and deletes it once the copy verifies; the pre-SQLite format -// remnant check looks here too. -// Host-side per-workspace state, keyed by hashed ids — never inside the user's working tree. - -import { createHash } from 'node:crypto' -import { join } from 'node:path' -import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' - -const JOURNAL_DIR_NAME = 'agent-session-journal' - -/** Filesystem-safe, collision-resistant segment for an arbitrary id. */ -export function journalPathSegment(value: string): string { - return createHash('sha256').update(value, 'utf8').digest('hex').slice(0, 32) -} - -/** `/agent-session-journal//`. */ -export function journalDirectoryFor( - root: string, - identity: Pick -): string { - return join( - root, - JOURNAL_DIR_NAME, - journalPathSegment(identity.workspaceId), - journalPathSegment(identity.sessionId) - ) -} - -const LEGACY_JOURNAL_DATABASE_FILE = 'journal.db' - -/** The per-chat SQLite file inside the directory `journalDirectoryFor` names. */ -export function legacyJournalDatabaseFile(journalDir: string): string { - return join(journalDir, LEGACY_JOURNAL_DATABASE_FILE) -} diff --git a/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts b/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts index b52e3648b72d..9e9b1f6c57cd 100644 --- a/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts +++ b/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts @@ -38,9 +38,9 @@ export async function markJournalPendingSubmissionsUnknown( return unresolved.map((entry) => entry.clientMessageId) } -/** Settles every submission a child that never proved its start left unanswered as `rejected`: - * such a child accepted nothing, so each is provably unwritten and safe to send again. A queued - * submission was never handed to that child; the delivery loop settles it. */ +/** Settles as `rejected` every submission a child handed over and never echoed, when that child + * ended in its start: one that never answered initialize ran nothing, so each is safe to send + * again. A queued submission was never handed to that child; the delivery loop settles it. */ export async function rejectJournalPendingSubmissions( journal: AgentSessionJournal, fence: number, diff --git a/src/main/native-chat/agent-session-journal/journal-per-session-import.test.ts b/src/main/native-chat/agent-session-journal/journal-per-session-import.test.ts deleted file mode 100644 index 53397f87044b..000000000000 --- a/src/main/native-chat/agent-session-journal/journal-per-session-import.test.ts +++ /dev/null @@ -1,835 +0,0 @@ -// A chat's per-chat journal file from an earlier build is copied into the host's one database on -// that chat's open: verbatim, and deleted only once the copy reads back as the file. A file that -// reappears after a downgrade is set aside on disk, and the chat keeps this build's history. - -import { - AGENT_JOURNAL_THREAD_SCOPE, - AGENT_SESSION_JOURNAL_SCHEMA_VERSION -} from '../../../shared/agent-session-journal-types' -import type * as NodeFs from 'node:fs' -import { existsSync, rmSync } from 'node:fs' -import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { - AgentJournalItemIdentity, - AgentSessionJournalIdentity -} from '../../../shared/agent-session-journal-types' -import type { AgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker' -import type { SqliteRow } from '../../sqlite/sqlite-statement' -import Database from '../../sqlite/sync-database' -import { createStructuredAgentSessionRestartOfferWithdrawal } from '../agent-session-wire/structured-agent-session-restart-offer-withdrawal' -import { - closeTestJournalHostDatabases, - createTrackedJournalOpener, - liveTestJournalRows, - loadTestJournal, - openTestJournalHostDatabase, - readTestJournalRows, - SAVED_BY_NEWER_ORCA -} from './journal-host-database-test-support' -import { journalDirectoryFor, legacyJournalDatabaseFile } from './journal-paths' -import { importPerSessionJournal, previewPerSessionJournal } from './journal-per-session-import' -import { readJournalSessionEpoch, type JournalStoredRow } from './journal-row-table' -import { createStructuredAgentSessionLogger } from '../agent-session-wire/structured-agent-session-logger' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' - -vi.mock('node:fs', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, rmSync: vi.fn(actual.rmSync) } -}) - -const IDENTITY: AgentSessionJournalIdentity = { - sessionId: 'session-legacy', - workspaceId: 'ws-1', - hostId: 'local', - agent: 'codex', - providerHandle: codexProviderHandle('thread-1') -} - -let root: string -let clock = 1_000 -const journals = createTrackedJournalOpener() - -function item(ordinal: number): AgentJournalItemIdentity { - return { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal } -} - -function legacyDir(): string { - return journalDirectoryFor(root, IDENTITY) -} - -/** Real rows, written by today's store into a scratch database, as an earlier build wrote them. */ -async function historyRows( - epoch = 'epoch-from-the-earlier-build', - reply = 'On it.' -): Promise<{ epoch: string; rows: JournalStoredRow[] }> { - const scratch = join(root, `scratch-${epoch}`) - const journal = await journals.open({ - identity: IDENTITY, - stateDirectory: scratch, - now: () => (clock += 1), - mintEpoch: () => epoch - }) - await journal.appendSubmission({ - clientMessageId: 'client-1', - payloadFingerprint: 'fp-1', - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'add a retry' }] }, - fence: 1, - handoverRecorded: true - }) - await journal.appendItem( - item(1), - { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: reply }] }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - const rows = readTestJournalRows( - openTestJournalHostDatabase(scratch).db, - IDENTITY.sessionId, - journal.epoch - ) - return { epoch: journal.epoch, rows } -} - -/** The per-chat file an earlier build left, in its own schema. */ -async function writeLegacyJournal(epoch: string, rows: readonly JournalStoredRow[]): Promise { - const path = legacyJournalDatabaseFile(legacyDir()) - await mkdir(dirname(path), { recursive: true }) - const db = new Database(path) - try { - db.pragma('journal_mode = WAL') - db.exec(` -CREATE TABLE journal_rows (session_id TEXT NOT NULL, epoch TEXT NOT NULL, seq INTEGER NOT NULL, - ts INTEGER NOT NULL, row_json TEXT NOT NULL, PRIMARY KEY (session_id, epoch, seq)); -CREATE TABLE journal_sessions (session_id TEXT PRIMARY KEY, epoch TEXT NOT NULL, updated_at INTEGER NOT NULL); -CREATE TABLE journal_repairs (session_id TEXT PRIMARY KEY, epoch TEXT NOT NULL, - content_from INTEGER NOT NULL, repaired_at INTEGER NOT NULL);`) - db.pragma('user_version = 2') - const insert = db.prepare( - 'INSERT INTO journal_rows (session_id, epoch, seq, ts, row_json) VALUES (?, ?, ?, ?, ?)' - ) - for (const row of rows) { - insert.run(IDENTITY.sessionId, row.epoch, row.seq, row.ts, row.rowJson) - } - if (rows.length > 0) { - db.prepare('INSERT INTO journal_sessions VALUES (?, ?, ?)').run(IDENTITY.sessionId, epoch, 1) - } - } finally { - db.close() - } -} - -/** Whatever is left of the chat's per-chat directory, or a copy of it, beside it. */ -async function leftovers(): Promise { - const parent = dirname(legacyDir()) - const name = legacyDir().slice(parent.length + 1) - return existsSync(parent) ? (await readdir(parent)).filter((entry) => entry.startsWith(name)) : [] -} - -function texts(journal: { snapshot: () => { items: { body: unknown }[] } }): string { - return JSON.stringify(journal.snapshot().items.map((entry) => entry.body)) -} - -function rowCount(db: Database.Database): number { - return Number(db.prepare('SELECT count(*) AS total FROM journal_rows').get()?.total) -} - -function removeFails(): void { - vi.mocked(rmSync).mockImplementation(() => { - throw Object.assign(new Error('resource busy'), { code: 'EBUSY' }) - }) -} - -async function removeWorks(): Promise { - const actual = await vi.importActual('node:fs') - vi.mocked(rmSync).mockImplementation(actual.rmSync) -} - -/** The file as it is, except that the copy's first read of rows comes back through `alter`. */ -function alteringFirstCopiedRead( - alter: (rows: SqliteRow[]) => SqliteRow[] -): (path: string) => Database.Database { - return (path) => { - const source = new Database(path, { readonly: true, fileMustExist: true }) - const prepare = source.prepare.bind(source) - let altered = false - source.prepare = (sql: string) => { - const statement = prepare(sql) - if (altered || !sql.includes('seq > ?')) { - return statement - } - const all = statement.all.bind(statement) - // Why: prepare caches statements, so this one is handed out again after it was altered. - statement.all = (...args: Parameters) => { - const rows = all(...args) - if (altered) { - return rows - } - altered = true - return alter(rows) - } - return statement - } - return source - } -} - -const losingLastCopiedRow = alteringFirstCopiedRead((rows) => rows.slice(0, -1)) - -/** Every row still there and still parsing, but the reply's words changed. */ -const garblingCopiedReply = alteringFirstCopiedRead((rows) => - rows.map((row) => ({ ...row, row_json: String(row.row_json).replace('On it.', 'On in.') })) -) - -function openChat() { - return journals.open({ identity: IDENTITY, stateDirectory: root, now: () => (clock += 1) }) -} - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-per-session-import-')) - clock = 1_000 -}) - -afterEach(async () => { - vi.restoreAllMocks() - await removeWorks() - await journals.closeAll() - await rm(root, { recursive: true, force: true }) -}) - -describe('importing a per-chat journal', () => { - it('copies the history verbatim on first open and deletes the file', async () => { - const { epoch, rows } = await historyRows() - await writeLegacyJournal(epoch, rows) - - const journal = await openChat() - - expect(journal.epoch).toBe(epoch) - expect(journal.cursor()).toEqual({ epoch, sequence: rows.length }) - expect( - readTestJournalRows(openTestJournalHostDatabase(root).db, IDENTITY.sessionId, epoch) - ).toEqual(rows) - // Verified, then deleted with its WAL files: no copy of it is kept. - expect(await leftovers()).toEqual([]) - }) - - // T-B3: the upgrade restart is the restart that produced the offers. A new epoch or renumbered - // rows would silently withdraw every "resume after update" offer. - it('keeps a restart offer taken before the upgrade', async () => { - const { epoch, rows } = await historyRows() - await writeLegacyJournal(epoch, rows) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: `movedOn` reads only the session id and the journal cursor. - const marker = { - sessionId: IDENTITY.sessionId, - journalCursor: { epoch, sequence: rows.length } - } as AgentSessionResumeMarker - - const journal = await openChat() - const withdrawal = createStructuredAgentSessionRestartOfferWithdrawal({ - logger: createStructuredAgentSessionLogger(), - sessions: new Map([[IDENTITY.sessionId, { journal, child: null }]]), - now: () => clock, - enqueue: (operation) => operation() - }) - - expect(withdrawal.movedOn(marker)).toBe(false) - }) - - it('copies in batches between turns of the event loop, and publishes the chat with the last', async () => { - const { epoch, rows } = await historyRows() - await writeLegacyJournal(epoch, rows) - const database = openTestJournalHostDatabase(root) - const turns: { published: boolean; copied: number }[] = [] - const tick = (): void => { - turns.push({ - published: readJournalSessionEpoch(database.db, IDENTITY.sessionId) !== null, - copied: rowCount(database.db) - }) - pending = setImmediate(tick) - } - let pending = setImmediate(tick) - - try { - await importPerSessionJournal({ - database, - identity: IDENTITY, - legacyDirectory: legacyDir(), - batchRows: 1 - }) - } finally { - clearImmediate(pending) - } - - // Other work ran while rows were copied, and none of it could see a partly copied chat. - expect(turns.filter((turn) => !turn.published && turn.copied > 0).length).toBeGreaterThan(0) - expect(turns.every((turn) => !turn.published || turn.copied === rows.length)).toBe(true) - expect(readTestJournalRows(database.db, IDENTITY.sessionId, epoch)).toEqual(rows) - }) - - // A quit between two batches: no reader sees the rows copied so far, and the next open copies the - // chat again from the start, with no duplicate or leftover row. - it('copies a chat again cleanly after a copy stopped midway', async () => { - const { epoch, rows } = await historyRows() - await writeLegacyJournal(epoch, rows) - const database = openTestJournalHostDatabase(root) - setImmediate(() => closeTestJournalHostDatabases()) - - await expect( - importPerSessionJournal({ - database, - identity: IDENTITY, - legacyDirectory: legacyDir(), - batchRows: 1 - }) - ).rejects.toMatchObject({ code: 'journal_closed' }) - const reopened = openTestJournalHostDatabase(root) - expect(rowCount(reopened.db)).toBe(1) - expect(readJournalSessionEpoch(reopened.db, IDENTITY.sessionId)).toBeNull() - expect(loadTestJournal(root, IDENTITY.sessionId)).toBeNull() - - const other = await journals.open({ - identity: { ...IDENTITY, sessionId: 'session-other' }, - stateDirectory: root - }) - await other.appendItem( - item(1), - { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'another chat' }] }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - const journal = await openChat() - - expect(journal.cursor()).toEqual({ epoch, sequence: rows.length }) - expect(readTestJournalRows(reopened.db, IDENTITY.sessionId, epoch)).toEqual(rows) - expect(rowCount(reopened.db)).toBe( - rows.length + liveTestJournalRows(reopened.db, 'session-other').length - ) - }) - - it('copies a chat once when two opens of it import at the same time', async () => { - const { epoch, rows } = await historyRows() - await writeLegacyJournal(epoch, rows) - const database = openTestJournalHostDatabase(root) - const input = { database, identity: IDENTITY, legacyDirectory: legacyDir(), batchRows: 1 } - - const outcomes = await Promise.all([ - importPerSessionJournal(input), - importPerSessionJournal(input) - ]) - - expect(outcomes).toEqual(['imported', 'absent']) - expect(readTestJournalRows(database.db, IDENTITY.sessionId, epoch)).toEqual(rows) - expect(rowCount(database.db)).toBe(rows.length) - }) - - // Only the copy's own batches skip the fsync: a live chat's write between them, and the publish - // that makes the batches durable, commit fully synced. - it('commits copy batches unsynced, and every other commit synced', async () => { - const { epoch, rows } = await historyRows() - await writeLegacyJournal(epoch, rows) - const database = openTestJournalHostDatabase(root) - const synchronous = () => Number(database.db.pragma('synchronous', { simple: true })) - const transaction = database.transaction.bind(database) - const commits: number[] = [] - vi.spyOn(database, 'transaction').mockImplementation((run) => - transaction((db) => { - commits.push(synchronous()) - return run(db) - }) - ) - const between: number[] = [] - const tick = (): void => { - // What a live chat's append would commit under, between two copy batches. - between.push(synchronous()) - pending = setImmediate(tick) - } - - let pending = setImmediate(tick) - await importPerSessionJournal({ - database, - identity: IDENTITY, - legacyDirectory: legacyDir(), - batchRows: 1 - }) - // A flag would still let the queued tick read the database after teardown closes it. - clearImmediate(pending) - - // 2 is FULL, 1 is NORMAL. - expect(commits.slice(0, rows.length)).toEqual(rows.map(() => 1)) - expect(commits.at(-1)).toBe(2) - expect(between.length).toBeGreaterThan(0) - expect(between.every((value) => value === 2)).toBe(true) - expect(synchronous()).toBe(2) - }) - - it('ends a copy on a turn of its own, so the open that replays it starts a new task', async () => { - const { epoch, rows } = await historyRows() - await writeLegacyJournal(epoch, rows) - let turns = 0 - const tick = (): void => { - turns += 1 - } - - setImmediate(tick) - await importPerSessionJournal({ - database: openTestJournalHostDatabase(root), - identity: IDENTITY, - legacyDirectory: legacyDir() - }) - - // One batch copies, and both verify reads are one batch each: only the final yield turns. - expect(turns).toBe(1) - }) - - it('keeps the file and refuses the chat when the copy does not read back as the file', async () => { - const { epoch, rows } = await historyRows() - await writeLegacyJournal(epoch, rows) - const database = openTestJournalHostDatabase(root) - const errors = vi.spyOn(console, 'error').mockImplementation(() => undefined) - const input = { - database, - identity: IDENTITY, - legacyDirectory: legacyDir(), - openSource: losingLastCopiedRow - } - const before = await readFile(legacyJournalDatabaseFile(legacyDir())) - - for (let attempt = 0; attempt < 2; attempt += 1) { - await expect(importPerSessionJournal(input)).rejects.toMatchObject({ - refusal: { message: 'Unable to load this chat.', details: { reason: 'journalCorrupt' } } - }) - } - - expect((await readFile(legacyJournalDatabaseFile(legacyDir()))).equals(before)).toBe(true) - expect(readJournalSessionEpoch(database.db, IDENTITY.sessionId)).toBeNull() - expect( - database.db.prepare('SELECT count(*) AS total FROM journal_imports').get() - ).toMatchObject({ - total: 0 - }) - expect(errors).toHaveBeenCalledOnce() - // A copy that reads back whole then imports it, over what the refused ones left. - const journal = await openChat() - expect(readTestJournalRows(database.db, IDENTITY.sessionId, epoch)).toEqual(rows) - expect(journal.cursor()).toEqual({ epoch, sequence: rows.length }) - expect(rowCount(database.db)).toBe(rows.length) - }) - - it('opens a file whose chat has no rows as a fresh chat, losing nothing', async () => { - const { epoch } = await historyRows() - await writeLegacyJournal(epoch, []) - const database = openTestJournalHostDatabase(root) - const db = new Database(legacyJournalDatabaseFile(legacyDir())) - db.prepare('INSERT INTO journal_sessions VALUES (?, ?, ?)').run(IDENTITY.sessionId, epoch, 1) - db.close() - - await expect( - previewPerSessionJournal({ database, identity: IDENTITY, legacyDirectory: legacyDir() }) - ).resolves.toBeNull() - const journal = await openChat() - - expect(journal.snapshot().items).toEqual([]) - expect(journal.cursor().sequence).toBe(1) - }) - - it('keeps a damaged file whole, copies none of it, and refuses the chat on every open', async () => { - const { epoch, rows } = await historyRows() - // A gap: the reply's row is gone from the file. - await writeLegacyJournal( - epoch, - rows.filter((row) => row.seq !== 2) - ) - const database = openTestJournalHostDatabase(root) - const before = await readFile(legacyJournalDatabaseFile(legacyDir())) - const unloadable = { - refusal: { message: 'Unable to load this chat.', details: { reason: 'journalCorrupt' } } - } - const input = { database, identity: IDENTITY, legacyDirectory: legacyDir() } - - await expect(previewPerSessionJournal(input)).rejects.toMatchObject(unloadable) - for (let attempt = 0; attempt < 2; attempt += 1) { - await expect(importPerSessionJournal(input)).rejects.toMatchObject(unloadable) - } - await expect(openChat()).rejects.toMatchObject(unloadable) - - expect((await readFile(legacyJournalDatabaseFile(legacyDir()))).equals(before)).toBe(true) - expect(readJournalSessionEpoch(database.db, IDENTITY.sessionId)).toBeNull() - expect(rowCount(database.db)).toBe(0) - expect( - database.db.prepare('SELECT count(*) AS total FROM journal_imports').get() - ).toMatchObject({ total: 0 }) - }) - - it("keeps a newer Orca's file whole, copies none of it, and refuses the chat as a newer Orca's", async () => { - const { epoch, rows } = await historyRows() - const last = rows.at(-1)! - const newer = { - ...last, - rowJson: JSON.stringify({ - ...JSON.parse(last.rowJson), - v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION + 1 - }) - } - await writeLegacyJournal(epoch, [...rows.slice(0, -1), newer]) - const database = openTestJournalHostDatabase(root) - const before = await readFile(legacyJournalDatabaseFile(legacyDir())) - const input = { database, identity: IDENTITY, legacyDirectory: legacyDir() } - - await expect(previewPerSessionJournal(input)).rejects.toMatchObject(SAVED_BY_NEWER_ORCA) - await expect(importPerSessionJournal(input)).rejects.toMatchObject(SAVED_BY_NEWER_ORCA) - await expect(openChat()).rejects.toMatchObject(SAVED_BY_NEWER_ORCA) - - expect((await readFile(legacyJournalDatabaseFile(legacyDir()))).equals(before)).toBe(true) - expect(readJournalSessionEpoch(database.db, IDENTITY.sessionId)).toBeNull() - expect(rowCount(database.db)).toBe(0) - }) - - // A copy that keeps every count but not every byte is no copy: the file is all there is. - it('keeps the file and refuses the chat when a copied row differs but every count matches', async () => { - const { epoch, rows } = await historyRows() - expect(rows.filter((row) => row.rowJson.includes('On it.'))).toHaveLength(1) - await writeLegacyJournal(epoch, rows) - const database = openTestJournalHostDatabase(root) - const errors = vi.spyOn(console, 'error').mockImplementation(() => undefined) - const input = { - database, - identity: IDENTITY, - legacyDirectory: legacyDir(), - openSource: garblingCopiedReply - } - const before = await readFile(legacyJournalDatabaseFile(legacyDir())) - - await expect(importPerSessionJournal(input)).rejects.toMatchObject({ - refusal: { message: 'Unable to load this chat.', details: { reason: 'journalCorrupt' } } - }) - - expect((await readFile(legacyJournalDatabaseFile(legacyDir()))).equals(before)).toBe(true) - expect(readJournalSessionEpoch(database.db, IDENTITY.sessionId)).toBeNull() - expect(errors).toHaveBeenCalledOnce() - }) - - // T-R2B1: the copy committed and only the delete failed (a crash between them is the same). Rows appended since, a restart, and a - // reopen with the file still there: nothing is copied again, and nothing is lost. - it('never copies the same file again after a failed delete, and deletes it on the next open', async () => { - const { epoch, rows } = await historyRows() - await writeLegacyJournal(epoch, rows) - removeFails() - vi.spyOn(console, 'warn').mockImplementation(() => undefined) - - const journal = await openChat() - await journal.appendItem( - item(2), - { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'after the upgrade' }] }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - expect(existsSync(legacyJournalDatabaseFile(legacyDir()))).toBe(true) - // The process exits and the database closes. - await journals.closeAll() - await removeWorks() - - const reopened = await openChat() - expect(reopened.cursor()).toEqual({ epoch, sequence: rows.length + 1 }) - expect(texts(reopened)).toContain('after the upgrade') - expect(await leftovers()).toEqual([]) - }) - - // T-import-transient: a read that fails leaves the file for the next open, which imports it. - it('leaves the file in place on a failed read, and refuses the open rather than serve it empty', async () => { - const { epoch, rows } = await historyRows() - await writeLegacyJournal(epoch, rows) - const database = openTestJournalHostDatabase(root) - const io = Object.assign(new Error('disk I/O error'), { code: 'ERR_SQLITE_ERROR', errcode: 10 }) - - await expect( - importPerSessionJournal({ - database, - identity: IDENTITY, - legacyDirectory: legacyDir(), - openSource: () => { - throw io - } - }) - ).rejects.toThrow(io) - expect(existsSync(legacyJournalDatabaseFile(legacyDir()))).toBe(true) - expect( - database.db.prepare('SELECT count(*) AS total FROM journal_sessions').get() - ).toMatchObject({ total: 0 }) - - const journal = await openChat() - expect(journal.cursor()).toEqual({ epoch, sequence: rows.length }) - expect(existsSync(legacyDir())).toBe(false) - }) - - // A file that is not a database stays where it is, and the chat says it cannot be loaded. - it('refuses the open of a legacy file that is not a database, and keeps the file', async () => { - await mkdir(legacyDir(), { recursive: true }) - await writeFile(legacyJournalDatabaseFile(legacyDir()), 'not a database '.repeat(512)) - - await expect(openChat()).rejects.toMatchObject({ errcode: 26 }) - - expect(await readdir(legacyDir())).toEqual(['journal.db']) - }) - - it('leaves a never-written file in place until the chat it belongs to is founded', async () => { - await writeLegacyJournal('unused', []) - await mkdir(legacyDir(), { recursive: true }) - await writeFile(join(legacyDir(), 'log.jsonl'), '{"kind":"epoch","v":1,"seq":1}\n', 'utf8') - - const journal = await openChat() - - // The pre-SQLite transcript beside it is still there to explain the empty chat. - expect(JSON.stringify(journal.snapshot().items)).toContain('log.jsonl') - await journals.closeAll() - await openChat() - // The journal file goes; the transcript is the user's, and stays. - expect(await readdir(legacyDir())).toEqual(['log.jsonl']) - }) - - // A crash between creating the file and giving it the schema: the chat opens with no history, as - // it did when each chat opened its own file, and the file is deleted like any never-written one. - it.each([ - ['empty', async (path: string) => writeFile(path, '')], - [ - 'schema-less', - async (path: string) => { - const db = new Database(path) - db.pragma('journal_mode = WAL') - db.close() - } - ] - ])('opens a chat whose per-chat file is %s as having no history', async (_fileState, create) => { - await mkdir(legacyDir(), { recursive: true }) - await create(legacyJournalDatabaseFile(legacyDir())) - - const journal = await openChat() - - expect(journal.snapshot().items).toEqual([]) - await journals.closeAll() - await openChat() - expect(await leftovers()).toEqual([]) - }) - - // F-L6-1: the copy deleted the file, so the older build found none and started the chat over. - // The re-upgrade must neither let that start replace the history nor delete what it wrote. - it('keeps the history, and the older build’s file untouched, after a downgrade round trip', async () => { - const first = await historyRows('epoch-original', 'ORIGINAL HISTORY') - await writeLegacyJournal(first.epoch, first.rows) - expect(texts(await openChat())).toContain('ORIGINAL HISTORY') - await journals.closeAll() - expect(existsSync(legacyDir())).toBe(false) - const older = await historyRows('epoch-older-fresh', 'typed in the older build') - await writeLegacyJournal(older.epoch, older.rows) - const file = legacyJournalDatabaseFile(legacyDir()) - const bytes = await readFile(file) - - for (let open = 0; open < 2; open += 1) { - const reopened = await openChat() - expect(reopened.epoch).toBe(first.epoch) - expect(texts(reopened)).toContain('ORIGINAL HISTORY') - expect(texts(reopened)).not.toContain('typed in the older build') - await journals.closeAll() - } - const database = openTestJournalHostDatabase(root) - expect(readTestJournalRows(database.db, IDENTITY.sessionId, first.epoch)).toEqual(first.rows) - expect((await readFile(file)).equals(bytes)).toBe(true) - const kept = new Database(file, { readonly: true }) - const keptRows = kept.prepare('SELECT seq, ts, row_json FROM journal_rows ORDER BY seq').all() - kept.close() - expect(keptRows.map((row) => row.row_json)).toEqual(older.rows.map((row) => row.rowJson)) - }) - - // The older build started over and then rewound, so its file's epoch opens `handle_forked`: it - // still never held this build's history, and is set aside like any other epoch. - it('keeps the history when the older build rewound the chat it started over', async () => { - const first = await historyRows('epoch-original', 'ORIGINAL HISTORY') - await writeLegacyJournal(first.epoch, first.rows) - await openChat() - await journals.closeAll() - const older = await historyRows('epoch-older-rewound', 'typed in the older build') - const opening = JSON.parse(older.rows[0]!.rowJson) - expect(opening).toMatchObject({ kind: 'epoch', reason: 'session_created' }) - const rewound = [ - { ...older.rows[0]!, rowJson: JSON.stringify({ ...opening, reason: 'handle_forked' }) }, - ...older.rows.slice(1) - ] - await writeLegacyJournal(older.epoch, rewound) - const bytes = await readFile(legacyJournalDatabaseFile(legacyDir())) - - const reopened = await openChat() - - expect(reopened.epoch).toBe(first.epoch) - expect(texts(reopened)).toContain('ORIGINAL HISTORY') - expect(texts(reopened)).not.toContain('typed in the older build') - expect((await readFile(legacyJournalDatabaseFile(legacyDir()))).equals(bytes)).toBe(true) - }) - - // The one file at another epoch that did descend from the copy: its delete failed and the older - // build rolled the epoch of the file it kept. It is set aside too; nothing this build has is lost. - it('sets aside a kept file the older build rolled to a new epoch', async () => { - const first = await historyRows('epoch-original', 'ORIGINAL HISTORY') - await writeLegacyJournal(first.epoch, first.rows) - removeFails() - vi.spyOn(console, 'warn').mockImplementation(() => undefined) - await openChat() - await journals.closeAll() - await removeWorks() - const rolled = await historyRows('epoch-rolled-by-older', 'rolled in the older build') - await rm(legacyDir(), { recursive: true, force: true }) - await writeLegacyJournal(rolled.epoch, rolled.rows) - - const reopened = await openChat() - - expect(reopened.epoch).toBe(first.epoch) - expect(texts(reopened)).toContain('ORIGINAL HISTORY') - expect(texts(reopened)).not.toContain('rolled in the older build') - expect(existsSync(legacyJournalDatabaseFile(legacyDir()))).toBe(true) - }) - - // A chat this build founded has a pointer and no import marker: an older build that then starts - // a per-chat file for it never held this build's history, so the file is set aside. - it('keeps a chat founded in this build when an older build starts a per-chat file for it', async () => { - const founded = await openChat() - await founded.appendItem( - item(1), - { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'FOUNDED HERE' }] }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - const foundedEpoch = founded.epoch - await journals.closeAll() - const older = await historyRows('epoch-older-fresh', 'typed in the older build') - await writeLegacyJournal(older.epoch, older.rows) - const bytes = await readFile(legacyJournalDatabaseFile(legacyDir())) - - const reopened = await openChat() - - expect(reopened.epoch).toBe(foundedEpoch) - expect(texts(reopened)).toContain('FOUNDED HERE') - expect(texts(reopened)).not.toContain('typed in the older build') - expect((await readFile(legacyJournalDatabaseFile(legacyDir()))).equals(bytes)).toBe(true) - }) - - // Decided once and recorded: no later open reads the file again, after a restart or after the - // older build ran again and wrote more to it. - it('never reads a set-aside file again', async () => { - const first = await historyRows('epoch-original', 'ORIGINAL HISTORY') - await writeLegacyJournal(first.epoch, first.rows) - await openChat() - await journals.closeAll() - const older = await historyRows('epoch-older-fresh', 'typed in the older build') - await writeLegacyJournal(older.epoch, older.rows) - const reads: string[] = [] - const countingSource = (path: string) => { - reads.push(path) - return new Database(path, { readonly: true, fileMustExist: true }) - } - const importAgain = () => - importPerSessionJournal({ - database: openTestJournalHostDatabase(root), - identity: IDENTITY, - legacyDirectory: legacyDir(), - openSource: countingSource - }) - - expect(await importAgain()).toBe('kept') - expect(reads).toHaveLength(1) - closeTestJournalHostDatabases() - expect(await importAgain()).toBe('kept') - const row = { ...JSON.parse(older.rows.at(-1)!.rowJson), seq: older.rows.length + 1 } - const legacy = new Database(legacyJournalDatabaseFile(legacyDir())) - legacy - .prepare('INSERT INTO journal_rows VALUES (?, ?, ?, ?, ?)') - .run(IDENTITY.sessionId, older.epoch, row.seq, 1, JSON.stringify(row)) - legacy.close() - expect(await importAgain()).toBe('kept') - - expect(reads).toHaveLength(1) - expect(texts(await openChat())).toContain('ORIGINAL HISTORY') - expect(existsSync(legacyJournalDatabaseFile(legacyDir()))).toBe(true) - }) - - // T-B5: a downgrade, an older build starting the chat over in a new per-chat file, and a - // re-upgrade — twice. This build's history stays whole each time, and the file stays on disk. - it('keeps this build’s history on every re-upgrade after an older build started the chat over', async () => { - const first = await historyRows('epoch-original', 'ORIGINAL HISTORY') - await writeLegacyJournal(first.epoch, first.rows) - const upgraded = await openChat() - await upgraded.appendItem( - item(2), - { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'this build' }] }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - await journals.closeAll() - const older = await historyRows('epoch-downgrade', 'older build, cycle 1') - await writeLegacyJournal(older.epoch, older.rows) - - for (const cycle of [1, 2]) { - if (cycle === 2) { - // The second downgrade finds the file it wrote the first time, and carries it on. - const row = { ...JSON.parse(older.rows.at(-1)!.rowJson), seq: older.rows.length + 1 } - row.body = { - kind: 'message', - role: 'assistant', - blocks: [{ type: 'text', text: 'cycle 2' }] - } - const legacy = new Database(legacyJournalDatabaseFile(legacyDir())) - legacy - .prepare('INSERT INTO journal_rows VALUES (?, ?, ?, ?, ?)') - .run(IDENTITY.sessionId, older.epoch, row.seq, 1, JSON.stringify(row)) - legacy.close() - } - const reopened = await openChat() - expect(reopened.epoch).toBe(first.epoch) - expect(texts(reopened)).toContain('ORIGINAL HISTORY') - expect(texts(reopened)).toContain('this build') - expect(texts(reopened)).not.toContain('older build, cycle 1') - expect(texts(reopened)).not.toContain('cycle 2') - expect(existsSync(legacyJournalDatabaseFile(legacyDir()))).toBe(true) - await journals.closeAll() - } - }) - - // The delete failed, so the older build found the copied file and carried its epoch on, while - // this build wrote nothing past the copy. This build's history still wins: the file is set aside. - it('sets aside a file an older build carried on under the copied epoch', async () => { - const { epoch, rows } = await historyRows() - await writeLegacyJournal(epoch, rows) - removeFails() - vi.spyOn(console, 'warn').mockImplementation(() => undefined) - await openChat() - await journals.closeAll() - appendOlderRow(epoch, rows, rows.length + 1) - const bytes = await readFile(legacyJournalDatabaseFile(legacyDir())) - await removeWorks() - - const reopened = await openChat() - - expect(reopened.epoch).toBe(epoch) - expect(reopened.cursor()).toEqual({ epoch, sequence: rows.length }) - expect(texts(reopened)).not.toContain('older') - expect(sharedRowsContaining('older')).toBe(0) - expect((await readFile(legacyJournalDatabaseFile(legacyDir()))).equals(bytes)).toBe(true) - }) - - /** The older build appends one row to the per-chat file the failed delete left, at its epoch. */ - function appendOlderRow(epoch: string, rows: readonly JournalStoredRow[], seq: number): void { - const olderRow = { ...JSON.parse(rows.at(-1)!.rowJson), seq } - olderRow.body = { - kind: 'message', - role: 'assistant', - blocks: [{ type: 'text', text: 'older' }] - } - olderRow.itemId = `${olderRow.itemId}-older` - const legacy = new Database(legacyJournalDatabaseFile(legacyDir())) - legacy - .prepare('INSERT INTO journal_rows VALUES (?, ?, ?, ?, ?)') - .run(IDENTITY.sessionId, epoch, seq, 1, JSON.stringify(olderRow)) - legacy.close() - } - - function sharedRowsContaining(text: string): number { - return openTestJournalHostDatabase(root) - .db.prepare('SELECT row_json FROM journal_rows') - .all() - .filter((row) => String(row.row_json).includes(text)).length - } -}) diff --git a/src/main/native-chat/agent-session-journal/journal-per-session-import.ts b/src/main/native-chat/agent-session-journal/journal-per-session-import.ts deleted file mode 100644 index 66f1f139ea32..000000000000 --- a/src/main/native-chat/agent-session-journal/journal-per-session-import.ts +++ /dev/null @@ -1,304 +0,0 @@ -// Copying a chat's per-chat journal file into the host's one database, on that chat's open. -// -// Not `journal-legacy-import.ts`, which reads the PROVIDER's own transcript. This reads Orca's own -// earlier `/journal.db`, verbatim: the same epoch UUID and every sequence number, so a -// cursor, an `acceptedSequence` or a restart offer taken before the upgrade still points at the -// same row after it. A file that reappears after a downgrade is set aside, never read again (see -// journal-per-session-reimport.ts). -// -// The copy runs in bounded batches, each its own transaction, yielding the event loop between them. -// The rows go in under the file's epoch, which the chat's pointer does not name yet, so no reader -// sees them. Once they read back as the file does (every row's sequence, time and bytes), one -// transaction publishes the chat's pointer with its import marker, so the chat is imported all at -// once or not at all. A try that stops midway leaves only unpublished rows, which the next try -// deletes before it copies again. A copy that does not read back as the file, or a file whose -// history is damaged, is never published: the file stays, and the chat is refused as unloadable. -// -// Only after that commit is the file deleted, its connection closed first. A read that fails -// leaves the file where it is for the next open, and the open is refused rather than served empty: -// an empty chat founded here would take a new epoch the next open's import could not reconcile. - -import { createHash } from 'node:crypto' -import { existsSync } from 'node:fs' -import { setImmediate as yieldToEventLoop } from 'node:timers/promises' -import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' -import type Database from '../../sqlite/sync-database' -import type { JournalHostDatabase } from './journal-host-database' -import { startJournalRowFold, type JournalLoad } from './journal-open' -import { - failLoadOnUnloadableJournal, - JournalImportMismatchError, - journalOpenRefusalError -} from './journal-open-failure' -import { legacyJournalDatabaseFile } from './journal-paths' -import { - planPerSessionImport, - isPerSessionJournalSetAside, - readPerSessionImportMarker, - setAsidePerSessionJournal, - writePerSessionImportMarker, - type PerSessionImportPlan, - type PerSessionJournalHead -} from './journal-per-session-reimport' -import { - foldLegacyJournal, - IMPORT_BATCH_ROWS, - legacyRowBatches, - openLegacySource, - readLegacyHead, - retireLegacyJournal, - type ImportBatch -} from './journal-per-session-source' -import { parseJournalRow } from './journal-row-schema' -import { applyJournalRow, createJournalReducerState } from './journal-reducer' -import { - deleteUnpublishedJournalRows, - publishJournalSessionEpoch, - readJournalRowsAfter, - readJournalSessionEpoch -} from './journal-row-table' - -const INSERT_ROW = - 'INSERT INTO journal_rows (session_id, epoch, seq, ts, row_json) VALUES (?, ?, ?, ?, ?)' - -type PerSessionJournalImportDeps = { - openSource?: (path: string) => Database.Database - /** Deletes one of the per-chat files. */ - remove?: (path: string) => void - batchRows?: number -} - -export type PerSessionJournalImportOutcome = 'absent' | 'imported' | 'already-imported' | 'kept' - -type ImportInput = { - database: JournalHostDatabase - identity: AgentSessionJournalIdentity - legacyDirectory: string -} & PerSessionJournalImportDeps - -/** Imports in flight, by database and chat: a second open of the same chat waits for the first. */ -const importsInFlight = new WeakMap>>() - -export function importPerSessionJournal( - input: ImportInput -): Promise { - let inFlight = importsInFlight.get(input.database) - if (!inFlight) { - inFlight = new Map() - importsInFlight.set(input.database, inFlight) - } - const { sessionId } = input.identity - const run = (inFlight.get(sessionId) ?? Promise.resolve()).then(() => importOnce(input)) - const settled = run.catch(() => undefined) - inFlight.set(sessionId, settled) - void settled.then(() => { - if (inFlight.get(sessionId) === settled) { - inFlight.delete(sessionId) - } - }) - return run -} - -async function importOnce(input: ImportInput): Promise { - const sourcePath = legacyJournalDatabaseFile(input.legacyDirectory) - if (!existsSync(sourcePath)) { - return 'absent' - } - const { sessionId } = input.identity - if (isPerSessionJournalSetAside(input.database.db, sessionId)) { - return 'kept' - } - const published = readJournalSessionEpoch(input.database.db, sessionId) !== null - const source = (input.openSource ?? openLegacySource)(sourcePath) - let legacy: PerSessionJournalHead | null - let plan: PerSessionImportPlan | null = null - try { - legacy = readLegacyHead(source, sessionId) - if (legacy) { - plan = planPerSessionImport({ db: input.database.db, sessionId, legacy, published }) - if (plan.kind === 'first') { - await copyLegacyJournal(input, source, legacy) - } - } - } finally { - source.close() - } - if (!legacy) { - // Never written. Left in place while its chat is unfounded: that open's empty chat may still - // owe the notice about a pre-SQLite transcript beside it. - if (!published) { - return 'absent' - } - retireLegacyJournal(input.legacyDirectory, input.remove) - return 'already-imported' - } - if (plan?.kind === 'kept') { - setAsidePerSessionJournal(input.database.db, sessionId, legacy) - return 'kept' - } - // Also a file a crash left after its copy was recorded (`copied`): deleted now, not copied again. - retireLegacyJournal(input.legacyDirectory, input.remove) - if (plan?.kind === 'copied') { - return 'already-imported' - } - // The open's replay of what was just copied is a long task of its own; don't add this one to it. - await yieldToEventLoop() - return 'imported' -} - -/** - * The chat a first copy would import, folded straight from its per-chat file and copying nothing: - * for a restore, which must not import. Null when the open has to import now instead: the chat is - * already in the host's database or was copied before (the reimport rules decide), its file holds - * no chat or no rows, or a newer build wrote it. A damaged file fails the load, copied nowhere. The - * file is closed before this returns. - */ -export async function previewPerSessionJournal( - input: Pick -): Promise { - const { sessionId } = input.identity - const db = input.database.db - const sourcePath = legacyJournalDatabaseFile(input.legacyDirectory) - if ( - readJournalSessionEpoch(db, sessionId) !== null || - readPerSessionImportMarker(db, sessionId) || - !existsSync(sourcePath) - ) { - return null - } - const source = (input.openSource ?? openLegacySource)(sourcePath) - try { - const legacy = readLegacyHead(source, sessionId) - if (!legacy) { - return null - } - const loaded = await foldLegacyJournal(source, sessionId, legacy) - failLoadOnUnloadableJournal(sessionId, loaded) - // An empty one is copied now, so its open founds the chat's epoch in the host's database. - return loaded.state.lastSequence === 0 ? null : loaded - } finally { - source.close() - } -} - -/** - * Batches under the file's epoch, which no reader follows until the chat's pointer names it. Each - * batch is folded as it is copied, so a damaged file is refused before anything is published. Once - * the rows read back as the file does, one transaction publishes the pointer and the import marker. - */ -async function copyLegacyJournal( - input: ImportInput, - source: Database.Database, - legacy: PerSessionJournalHead -): Promise { - const { sessionId } = input.identity - const { epoch } = legacy - const batchRows = input.batchRows ?? IMPORT_BATCH_ROWS - const fold = startJournalRowFold({ sessionId, epoch }) - let folding = true - let first = true - for (const batch of legacyRowBatches(source, sessionId, epoch, batchRows)) { - if (!first) { - await yieldToEventLoop() - } - folding &&= batch.rows.every(fold.add) - // Unsynced: no reader follows these rows, and the publish's synced commit covers them. - input.database.unsyncedTransaction((db) => { - if (first) { - // What an earlier try that stopped midway left. - deleteUnpublishedJournalRows(db, sessionId) - } - const insert = db.prepare(INSERT_ROW) - for (const row of batch.rows) { - // Copied as stored: the bytes are the row, its epoch and sequence included. - insert.run(sessionId, epoch, row.seq, row.ts, row.rowJson) - } - }) - first = false - } - const copied = fold.finish() - if (copied.damage || copied.newer) { - // Never published, so the copy goes; the file it came from stays. - input.database.transaction((db) => deleteUnpublishedJournalRows(db, sessionId)) - } - failLoadOnUnloadableJournal(sessionId, copied) - await verifyCopiedJournal(input, legacyRowBatches(source, sessionId, epoch, batchRows), epoch) - input.database.transaction((db) => { - publishJournalSessionEpoch(db, input.identity, epoch) - writePerSessionImportMarker(db, sessionId, legacy) - }) -} - -/** Mismatches already logged, so a chat refused on every open logs once. */ -const loggedMismatches = new Set() - -/** - * The copied rows, read back from the host's database, against a second read of what was copied: - * the same rows, byte for byte, and the same epoch, tip, row count, items and submissions, or the - * copy is refused and never published. Both reads go a batch at a time, so no check holds the main - * thread longer than a copy batch does. - */ -async function verifyCopiedJournal( - input: ImportInput, - expected: Iterable, - epoch: string -): Promise { - const { sessionId } = input.identity - const want = await copyFacts(sessionId, expected) - const got = await copyFacts(sessionId, copiedBatches(input, epoch)) - if (want === got) { - return - } - const error = new JournalImportMismatchError( - `per-chat journal of ${sessionId} read back as ${got} after its copy, not ${want}` - ) - if (!loggedMismatches.has(`${sessionId}\n${want}\n${got}`)) { - loggedMismatches.add(`${sessionId}\n${want}\n${got}`) - console.error(`[agent-session-journal] ${error.message}; ${input.legacyDirectory} is kept`) - } - throw journalOpenRefusalError(error) -} - -/** Epoch, tip, row count, items, submissions and a digest of every row, folded a batch at a time. */ -async function copyFacts(sessionId: string, batches: Iterable): Promise { - const state = createJournalReducerState(sessionId, '') - const content = createHash('sha256') - let epoch: string | null = null - let tip = 0 - let rows = 0 - let first = true - for (const batch of batches) { - if (!first) { - await yieldToEventLoop() - } - first = false - rows += batch.rows.length - for (const row of batch.rows) { - tip = Math.max(tip, row.seq) - // Length-framed, so no two different rows hash the same stream. - content.update(`${row.seq}:${row.ts}:${row.rowJson.length}:`).update(row.rowJson) - const parsed = parseJournalRow(row.rowJson) - if (parsed.ok) { - epoch ??= parsed.row.epoch - applyJournalRow(state, parsed.row) - } - } - } - return `${epoch}:${tip}:${rows}:${state.items.size}:${state.submissions.size}:${content.digest('hex')}` -} - -function* copiedBatches(input: ImportInput, epoch: string): Generator { - const { sessionId } = input.identity - const batchRows = input.batchRows ?? IMPORT_BATCH_ROWS - let afterSeq = Number.MIN_SAFE_INTEGER - for (;;) { - const rows = readJournalRowsAfter(input.database.db, sessionId, epoch, afterSeq, batchRows) - const lastSeq = rows.at(-1)?.seq - const last = rows.length < batchRows || lastSeq === undefined - yield { rows, last } - if (last) { - return - } - afterSeq = lastSeq - } -} diff --git a/src/main/native-chat/agent-session-journal/journal-per-session-reimport.ts b/src/main/native-chat/agent-session-journal/journal-per-session-reimport.ts deleted file mode 100644 index 61ddbfc98f1b..000000000000 --- a/src/main/native-chat/agent-session-journal/journal-per-session-reimport.ts +++ /dev/null @@ -1,73 +0,0 @@ -// A per-chat file that reappears after its chat was copied in: an older build, run after a -// downgrade, attached the chat and wrote its history there. -// -// `journal_imports` records which file each chat was copied from — its epoch and tip — in the -// transaction that publishes the verified copy. A file still at that epoch and tip was already -// copied (only its delete failed, or a crash came first, across any number of restarts): it is -// deleted, never copied again. This build's history always wins: once a chat has been copied or -// founded here, any other file is set aside, left on disk as it is, and recorded in -// `journal_set_aside`, so no later open reads it again. - -import type Database from '../../sqlite/sync-database' - -export type PerSessionJournalHead = { epoch: string; tip: number } - -const SELECT_MARKER = 'SELECT epoch, tip FROM journal_imports WHERE session_id = ?' -const UPSERT_MARKER = `INSERT INTO journal_imports (session_id, epoch, tip) VALUES (?, ?, ?) -ON CONFLICT(session_id) DO UPDATE SET epoch = excluded.epoch, tip = excluded.tip` - -export function readPerSessionImportMarker( - db: Database.Database, - sessionId: string -): PerSessionJournalHead | null { - const row = db.prepare(SELECT_MARKER).get(sessionId) - return typeof row?.epoch === 'string' && typeof row.tip === 'number' - ? { epoch: row.epoch, tip: row.tip } - : null -} - -const SELECT_SET_ASIDE = 'SELECT 1 AS present FROM journal_set_aside WHERE session_id = ?' -const INSERT_SET_ASIDE = `INSERT INTO journal_set_aside (session_id, epoch, tip) VALUES (?, ?, ?) -ON CONFLICT(session_id) DO NOTHING` - -export function isPerSessionJournalSetAside(db: Database.Database, sessionId: string): boolean { - return db.prepare(SELECT_SET_ASIDE).get(sessionId) !== undefined -} - -/** Records a file that is not this build's history, as it was when set aside. */ -export function setAsidePerSessionJournal( - db: Database.Database, - sessionId: string, - head: PerSessionJournalHead -): void { - db.prepare(INSERT_SET_ASIDE).run(sessionId, head.epoch, head.tip) -} - -export function writePerSessionImportMarker( - db: Database.Database, - sessionId: string, - head: PerSessionJournalHead -): void { - db.prepare(UPSERT_MARKER).run(sessionId, head.epoch, head.tip) -} - -export type PerSessionImportPlan = - | { kind: 'first' } - | { kind: 'copied' } - /** Not this build's history: set aside, neither copied nor deleted. */ - | { kind: 'kept' } - -/** What a present per-chat file owes this chat, judged against what was last copied from it. */ -export function planPerSessionImport(input: { - db: Database.Database - sessionId: string - legacy: PerSessionJournalHead - /** The chat already has an epoch in the host's database. */ - published: boolean -}): PerSessionImportPlan { - const marker = readPerSessionImportMarker(input.db, input.sessionId) - if (marker?.epoch === input.legacy.epoch && marker.tip === input.legacy.tip) { - return { kind: 'copied' } - } - return !marker && !input.published ? { kind: 'first' } : { kind: 'kept' } -} diff --git a/src/main/native-chat/agent-session-journal/journal-per-session-source.ts b/src/main/native-chat/agent-session-journal/journal-per-session-source.ts deleted file mode 100644 index a58a1de3bf5d..000000000000 --- a/src/main/native-chat/agent-session-journal/journal-per-session-source.ts +++ /dev/null @@ -1,126 +0,0 @@ -// Reading a chat's per-chat journal file, the one each chat had before the host's one database. -// The importer copies through this reader, and a restore folds through it without copying. - -import { rmdirSync, rmSync } from 'node:fs' -import { setImmediate as yieldToEventLoop } from 'node:timers/promises' -import Database from '../../sqlite/sync-database' -import { startJournalRowFold, type JournalLoad } from './journal-open' -import { legacyJournalDatabaseFile } from './journal-paths' -import type { PerSessionJournalHead } from './journal-per-session-reimport' - -/** The newest per-chat file shape any build wrote. */ -const LEGACY_JOURNAL_SCHEMA_VERSION = 2 -/** Rows per batch: at most 31 ms per batch copying the largest real chat (68 MB, 3.3 KB rows). */ -export const IMPORT_BATCH_ROWS = 512 - -const SELECT_LEGACY_EPOCH = 'SELECT epoch FROM journal_sessions WHERE session_id = ?' -const SELECT_LEGACY_TIP = - 'SELECT max(seq) AS tip FROM journal_rows WHERE session_id = ? AND epoch = ?' -const SELECT_LEGACY_ROWS = `SELECT seq, ts, row_json FROM journal_rows -WHERE session_id = ? AND epoch = ? AND seq > ? ORDER BY seq ASC LIMIT ?` -const HAS_LEGACY_TABLE = "SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = ?" - -type ImportedRow = { seq: number; ts: number; rowJson: string } -export type ImportBatch = { rows: ImportedRow[]; last: boolean } - -/** A plain read-only connection: it sees committed WAL frames without checkpointing them. */ -export function openLegacySource(path: string): Database.Database { - const source = new Database(path, { readonly: true, fileMustExist: true }) - try { - const version = Number(source.pragma('user_version', { simple: true }) ?? 0) - if (version > LEGACY_JOURNAL_SCHEMA_VERSION) { - throw new Error(`per-chat journal ${path} uses schema ${version}, which no build wrote`) - } - return source - } catch (error) { - source.close() - throw error - } -} - -export function readLegacyHead( - source: Database.Database, - sessionId: string -): PerSessionJournalHead | null { - // Created but never given its schema (a crash between the two): no history, as an empty file. - if (!source.prepare(HAS_LEGACY_TABLE).get('journal_sessions')) { - return null - } - const epoch = source.prepare(SELECT_LEGACY_EPOCH).get(sessionId)?.epoch - if (typeof epoch !== 'string' || epoch.length === 0) { - return null - } - const tip = source.prepare(SELECT_LEGACY_TIP).get(sessionId, epoch)?.tip - return { epoch, tip: typeof tip === 'number' ? tip : 0 } -} - -/** The file's rows, one bounded page per batch, read as each batch is written. */ -export function* legacyRowBatches( - source: Database.Database, - sessionId: string, - epoch: string, - batchRows: number -): Generator { - const select = source.prepare(SELECT_LEGACY_ROWS) - let afterSeq = Number.MIN_SAFE_INTEGER - for (;;) { - const rows = select - .all(sessionId, epoch, afterSeq, batchRows) - .map((row) => ({ seq: Number(row.seq), ts: Number(row.ts), rowJson: String(row.row_json) })) - const lastSeq = rows.at(-1)?.seq - const last = rows.length < batchRows || lastSeq === undefined - yield { rows, last } - if (last) { - return - } - afterSeq = lastSeq - } -} - -/** The chat as its file holds it, folded the way a replay folds the host's database. */ -export async function foldLegacyJournal( - source: Database.Database, - sessionId: string, - legacy: PerSessionJournalHead -): Promise { - const fold = startJournalRowFold({ sessionId, epoch: legacy.epoch }) - let first = true - for (const batch of legacyRowBatches(source, sessionId, legacy.epoch, IMPORT_BATCH_ROWS)) { - // A batch per turn: a large chat's file read in one task holds up everything else at startup. - if (!first) { - await yieldToEventLoop() - } - first = false - if (!batch.rows.every(fold.add)) { - break - } - } - return fold.finish() -} - -/** - * Deletes the per-chat file and SQLite's WAL files beside it, then the directory if nothing else is - * in it: a pre-SQLite transcript there is the user's, and stays. Its connection must be closed. - * Best effort: the copy is committed, so a file left behind is deleted by the next open. - */ -export function retireLegacyJournal( - legacyDirectory: string, - remove: (path: string) => void = (path) => rmSync(path, { force: true }) -): void { - const file = legacyJournalDatabaseFile(legacyDirectory) - try { - // The database first: a WAL left without it is never read, but a database left without its WAL - // would read back short of the tip it was copied at, and be set aside rather than deleted. - for (const path of [file, `${file}-wal`, `${file}-shm`]) { - remove(path) - } - } catch (error) { - console.warn(`[agent-session-journal] deleting imported ${legacyDirectory} failed`, error) - return - } - try { - rmdirSync(legacyDirectory) - } catch { - // Not empty: something beside the journal is kept. - } -} diff --git a/src/main/native-chat/agent-session-journal/journal-prompt-body-bounds.ts b/src/main/native-chat/agent-session-journal/journal-prompt-body-bounds.ts index 4aa275994342..d8c5c1737b74 100644 --- a/src/main/native-chat/agent-session-journal/journal-prompt-body-bounds.ts +++ b/src/main/native-chat/agent-session-journal/journal-prompt-body-bounds.ts @@ -36,10 +36,6 @@ export function cancelledJournalPromptBody( } } -export function boundJournalStatusText(text: string): string { - return boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text -} - export function boundJournalPromptBody(body: AgentJournalApprovalItem): AgentJournalApprovalItem export function boundJournalPromptBody(body: AgentJournalQuestionItem): AgentJournalQuestionItem export function boundJournalPromptBody( diff --git a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts index 72f2b2fec0a4..bb6eb2ef4d78 100644 --- a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts +++ b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts @@ -344,7 +344,7 @@ export class JournalQueuedMessages { * no hook), then retention runs. */ repairAndPrune(): Promise { - // No draft, no work, and no write: a chat whose first-use copy is still owed stays uncopied. + // No draft, no work, and no write. if (this.deps.readOnly() || this.list().length === 0) { return Promise.resolve() } diff --git a/src/main/native-chat/agent-session-journal/journal-row-table.ts b/src/main/native-chat/agent-session-journal/journal-row-table.ts index a558252a8994..9074ae0019e6 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-table.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-table.ts @@ -21,8 +21,6 @@ const SELECT_ROWS_AFTER = `SELECT seq, ts, row_json FROM journal_rows WHERE session_id = ? AND epoch = ? AND seq > ? ORDER BY seq ASC` const SELECT_ROWS_AFTER_LIMITED = `${SELECT_ROWS_AFTER} LIMIT ?` const DELETE_EPOCH = 'DELETE FROM journal_rows WHERE session_id = ? AND epoch = ?' -const DELETE_UNPUBLISHED = `DELETE FROM journal_rows WHERE session_id = ? -AND epoch IS NOT (SELECT epoch FROM journal_sessions WHERE session_id = ?)` export function readJournalSessionEpoch(db: Database.Database, sessionId: string): string | null { const epoch = db.prepare(SELECT_EPOCH).get(sessionId)?.epoch @@ -106,8 +104,3 @@ export function deleteJournalEpochRows( ): void { db.prepare(DELETE_EPOCH).run(sessionId, epoch) } - -/** Rows of this chat under any epoch its pointer does not name: a copy that never published. */ -export function deleteUnpublishedJournalRows(db: Database.Database, sessionId: string): void { - db.prepare(DELETE_UNPUBLISHED).run(sessionId, sessionId) -} diff --git a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts index 395ad50a6ee4..600d673b6d81 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts @@ -30,15 +30,9 @@ export type JournalStoreHost = { * same way they learn of a row. */ notifyCommitted: () => void identity: AgentSessionJournalIdentity - /** Where the chat's per-chat history lived, for the importer and the format-remnant notice. */ - legacyDirectory: string now: () => number mintEpoch: () => string serialize: (run: JournalWriteBody) => Promise - /** Leave a chat still in its per-chat file uncopied until its first use. */ - deferPerSessionImport: boolean - /** Work the chat's next write waits for. */ - owe: (work: () => Promise) => void database: () => JournalHostDatabase state: () => JournalReducerState readOnly: () => boolean diff --git a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts index 2d7b56d06b1f..7492ea7e461c 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts @@ -21,8 +21,6 @@ export type AgentSessionJournalOptions = { database: JournalHostDatabase now?: () => number mintEpoch?: () => string - /** A restore's open: see `AgentSessionJournal.whenImported`. */ - deferPerSessionImport?: boolean } export type JournalReadSince = diff --git a/src/main/native-chat/agent-session-journal/journal-store-open.ts b/src/main/native-chat/agent-session-journal/journal-store-open.ts index cac1517c4091..1a5db4fe0d27 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-open.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-open.ts @@ -2,20 +2,12 @@ import type { AgentJournalItemBody, AgentJournalItemIdentity } from '../../../shared/agent-session-journal-types' -import type { AgentType } from '../../../shared/agent-status-types' -import { - findJournalFileFormatRemnant, - journalFileFormatRemnantDisclosure -} from './journal-file-format-remnant' import type { JournalLoad } from './journal-open' import { failLoadOnUnloadableJournal } from './journal-open-failure' import { staleSubagentRosterRevisions } from './journal-subagent-liveness' -type JournalDisclosure = ReturnType - export async function openJournalStoreState(input: { sessionId: string - legacyDirectory: string replay: () => JournalLoad | null start: () => void adopt: (loaded: JournalLoad) => void @@ -24,7 +16,6 @@ export async function openJournalStoreState(input: { body: AgentJournalItemBody, fence: number ) => Promise - agent: AgentType highestFence: () => number }): Promise { const loaded = input.replay() @@ -32,41 +23,11 @@ export async function openJournalStoreState(input: { // keep, so it is founded afresh like a chat with no journal; no row is deleted. if (!loaded || (!loaded.newer && !loaded.damage && loaded.state.lastSequence === 0)) { input.start() - await discloseFileFormatRemnant(input) return } failLoadOnUnloadableJournal(input.sessionId, loaded) input.adopt(loaded) await settleStaleSubagentRosters(input, loaded) - // Founding the epoch and appending the row are two transactions, and a - // committed epoch sends every later open down this branch instead. Anything - // that interrupts between them — a quit during startup restore, a failed - // append — would otherwise lose the message for good. An epoch holding nothing - // is exactly the state that append was owed, so offer it again. - if (loaded.state.items.size === 0 && loaded.state.submissions.size === 0) { - await discloseFileFormatRemnant(input) - } -} - -/** Says what happened to a chat whose history is in the abandoned file format. - * Upserts by a constant identity, so the offer above is exactly-once in effect: - * once the row exists the epoch is no longer empty. */ -async function discloseFileFormatRemnant(input: { - legacyDirectory: string - agent: AgentType - appendItem: ( - identity: JournalDisclosure['identity'], - body: JournalDisclosure['body'], - fence: number - ) => Promise - highestFence: () => number -}): Promise { - const transcriptPath = findJournalFileFormatRemnant(input.legacyDirectory) - if (!transcriptPath) { - return - } - const disclosure = journalFileFormatRemnantDisclosure({ transcriptPath, agent: input.agent }) - await input.appendItem(disclosure.identity, disclosure.body, input.highestFence()) } /** Retires a `working` subagent roster the previous host never got to settle. */ diff --git a/src/main/native-chat/agent-session-journal/journal-store-restore.ts b/src/main/native-chat/agent-session-journal/journal-store-restore.ts index d8c44f964bdb..0f5208dea9bb 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-restore.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-restore.ts @@ -1,7 +1,7 @@ // Bringing a store's in-memory state up from disk. // // Split out of the store for the same reason its collaborators were: this is the -// ORDERING between import, replay and disclosure, and none of it belongs +// ORDERING between replay and the notices an open owes, and none of it belongs // to the store's public surface. Every step here reads or writes through the // same host the collaborators use, so the store keeps the state and this owns // the sequence. @@ -9,22 +9,17 @@ import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import type { JournalEpochController } from './journal-epoch-controller' import { replayJournal } from './journal-open' -import { failLoadOnUnloadableJournal, journalOpenRefusalError } from './journal-open-failure' +import { journalOpenRefusalError } from './journal-open-failure' import type { JournalStoreHost } from './journal-store-collaborators' import { openJournalStoreState } from './journal-store-open' -import { importPerSessionJournal, previewPerSessionJournal } from './journal-per-session-import' import { AgentSessionJournalError } from './journal-write-guards' export async function restoreJournalStore( host: JournalStoreHost, collaborators: { epochController: JournalEpochController } ): Promise { - const source = { - database: host.database(), - identity: host.identity, - legacyDirectory: host.legacyDirectory - } - if (source.database.readOnly) { + const database = host.database() + if (database.readOnly) { // A newer Orca's database: nothing in it is read as this build's, and nothing is written. throw journalOpenRefusalError( new AgentSessionJournalError( @@ -33,32 +28,14 @@ export async function restoreJournalStore( ) ) } - // A restore reads a chat still in its per-chat file from there, and copies it before its first use. - const preview = host.deferPerSessionImport ? await previewPerSessionJournal(source) : null - if (preview) { - host.owe(async () => { - await importPerSessionJournal(source) - const imported = replayJournal(source.database.db, host.identity.sessionId) - if (!imported) { - throw new Error(`per-chat journal of ${host.identity.sessionId} was gone before its copy`) - } - failLoadOnUnloadableJournal(host.identity.sessionId, imported) - host.adopt(imported) - }) - } else { - // A per-chat file left by an earlier build is this chat's newest history: copied in first. - await importPerSessionJournal(source) - } return openJournalStoreState({ sessionId: host.identity.sessionId, - legacyDirectory: host.legacyDirectory, - replay: () => preview ?? replayJournal(host.database().db, host.identity.sessionId), + replay: () => replayJournal(database.db, host.identity.sessionId), start: () => collaborators.epochController.start('session_created', 0), adopt: host.adopt, - // File-format and roster notices are about the conversation, not any turn in it. + // Roster notices are about the conversation, not any turn in it. appendItem: (identity, body, fence) => host.journal().appendItem(identity, body, { fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE }), - agent: host.identity.agent, highestFence: () => host.state().highestFence }) } diff --git a/src/main/native-chat/agent-session-journal/journal-store.test.ts b/src/main/native-chat/agent-session-journal/journal-store.test.ts index a3bd5be348c2..5c5637de2dfa 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.test.ts @@ -19,7 +19,6 @@ import { DEFAULT_JOURNAL_PAYLOAD_LIMITS } from './journal-payload-bounds' import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-live-turn' -import { journalDirectoryFor, journalPathSegment } from './journal-paths' import { AgentSessionJournalError, type AgentSessionJournal } from './journal-store' import type { openAgentSessionJournal } from './journal-store-factory' import { @@ -441,27 +440,6 @@ describe('lifecycle batches', () => { }) }) -describe('journal location', () => { - it('keys by workspace and session id rather than by a path in the working tree', () => { - const dir = journalDirectoryFor('/state', { workspaceId: 'ws/1', sessionId: 'sess:2' }) - expect(dir).toBe( - join( - '/state', - 'agent-session-journal', - journalPathSegment('ws/1'), - journalPathSegment('sess:2') - ) - ) - expect(dir).not.toContain('ws/1') - }) - - it('separates two sessions in one workspace', () => { - const a = journalDirectoryFor('/state', { workspaceId: 'ws', sessionId: 'a' }) - const b = journalDirectoryFor('/state', { workspaceId: 'ws', sessionId: 'b' }) - expect(a).not.toBe(b) - }) -}) - describe('on-disk layout', () => { it('keeps every chat of the state directory in its one database, and no per-chat file', async () => { const journal: AgentSessionJournal = await open() diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts index 9b676cb26b38..7548f6e5502e 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -107,12 +107,9 @@ export class AgentSessionJournal { this.queue = new JournalWriteQueue(options.identity.sessionId) const collaborators = createJournalStoreCollaborators({ identity: this.identity, - legacyDirectory: this.database.legacyDirectoryFor(this.identity), now: this.now, mintEpoch: this.mintEpoch, serialize: (run) => this.queue.serialize(run), - deferPerSessionImport: options.deferPerSessionImport === true, - owe: (work) => this.queue.owe(work), database: () => this.database, state: () => this.state, // A newer Orca's database: nothing it holds opens, and every write is refused. @@ -177,20 +174,6 @@ export class AgentSessionJournal { this.onCommitted = listener } - /** - * Resolves once the chat's rows are in the host's database. A restore's open serves a chat still - * in its per-chat file from a read-only fold of it; the copy runs before the chat's first write. - * A reader that needs rows (forward pages, catch-up) and every mutation's open await it here, so - * each reads the fold after every earlier write. - */ - whenImported(): Promise { - return this.queue.serialize(() => undefined) - } - - get importPending(): boolean { - return this.queue.owing - } - cursor = (): AgentJournalCursor => ({ epoch: this.state.epoch, sequence: this.state.lastSequence @@ -264,7 +247,7 @@ export class AgentSessionJournal { canonicalItemId = (itemId: string): string => resolveJournalItemId(this.state, itemId) /** Reads the fold with every write issued before this call committed, and none issued after: at - * once unless writes still wait behind an owed import or a running write. */ + * once unless a write is running or writes wait in line. */ readInOrder(read: () => T): Promise { return this.queue.readInOrder(read) } @@ -338,7 +321,7 @@ export class AgentSessionJournal { return markJournalPendingSubmissionsUnknown(this, fence, reason) } - /** Reject unanswered sends after an owner that never proved its start ended: none was written. */ + /** Reject sends a child that ended in its start was handed and never echoed: none ran. */ async rejectPendingSubmissions( fence: number, rejection: AgentJournalDispatchRejection diff --git a/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.ts b/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.ts index f2bf6c953f08..9341fbce6e11 100644 --- a/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.ts +++ b/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.ts @@ -54,6 +54,21 @@ export function unsentSendKeptAsCard( return persons ? body : null } +/** A send handed to the agent that it has neither echoed nor refused. Handed to a child that never + * answered its start, it ran nowhere. */ +export function isUnansweredHandedOverSubmission( + entry: Pick< + AgentJournalSubmission, + 'handoverRecorded' | 'dispatchState' | 'handedOverAt' | 'recovered' + > +): boolean { + return ( + !isQueuedAgentJournalSubmission(entry) && + (entry.dispatchState === 'pending' || + (entry.dispatchState === 'unknown' && entry.recovered !== true)) + ) +} + /** * Settles every send `hold` names. Each is rejected with the hold's cause in its own row, and a * kept one becomes a card in that row's transaction, so a crash between them can never leave @@ -64,17 +79,25 @@ export function unsentSendKeptAsCard( */ export async function holdUnsentSends( journal: AgentSessionJournal, - input: { fence: number; hostInstance: string; hold: UnsentSendHold } + input: { + fence: number + hostInstance: string + hold: UnsentSendHold + /** In place of the queued sends: those a child that ended before it answered its start was + * handed and never echoed. It ran none, so each is unsent as surely as a queued one. */ + unrun?: true + } ): Promise { const { hold } = input const unsent = journal .submissions() - .filter( - (entry) => - isQueuedAgentJournalSubmission(entry) && - (hold.cause === 'hostRestarted' - ? journal.wroteBeforeOpen(entry.acceptedSequence) - : (hold.which?.(entry) ?? true)) + .filter((entry) => + input.unrun + ? isUnansweredHandedOverSubmission(entry) + : isQueuedAgentJournalSubmission(entry) && + (hold.cause === 'hostRestarted' + ? journal.wroteBeforeOpen(entry.acceptedSequence) + : (hold.which?.(entry) ?? true)) ) .sort((a, b) => (a.acceptedSequence ?? 0) - (b.acceptedSequence ?? 0)) if (unsent.length === 0) { diff --git a/src/main/native-chat/agent-session-journal/journal-write-queue.test.ts b/src/main/native-chat/agent-session-journal/journal-write-queue.test.ts index 9b5653b060f8..47764033180f 100644 --- a/src/main/native-chat/agent-session-journal/journal-write-queue.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-write-queue.test.ts @@ -1,5 +1,5 @@ -// A write has landed in the fold when its call returns, except during an owed import, when it lands -// in queue order. A write issued from inside a running write joins the line behind it, never nested. +// A write has landed in the fold when its call returns. A write issued from inside a running write +// joins the line behind it, never nested. // A read in the queue always settles: behind a write that failed, and refused once closed. import { mkdtemp, rm } from 'node:fs/promises' @@ -16,7 +16,6 @@ import { closeTestJournalHostDatabases, openTestJournalHostDatabase } from './journal-host-database-test-support' -import { openJournalOwingImport } from './journal-owed-import-test-support' import type { AgentSessionJournal } from './journal-store' import { openAgentSessionJournal } from './journal-store-factory' import { JournalWriteQueue } from './journal-write-queue' @@ -67,15 +66,6 @@ async function idleJournal(): Promise { return journal } -async function journalOwingImport(): Promise<{ journal: AgentSessionJournal; history: number }> { - const { journal, history } = await openJournalOwingImport({ - stateDirectory: root, - identity: IDENTITY - }) - opened.push(journal) - return { journal, history: history.length } -} - function sequenceOf(journal: AgentSessionJournal, ordinal: number): number | undefined { const key = agentJournalItemKey(item(ordinal)) let found: number | undefined @@ -101,25 +91,6 @@ describe('when a journal write lands', () => { await expect(Promise.all([first, second])).resolves.toHaveLength(2) }) - it('lands in queue order, behind the history it copies, while an import is owed', async () => { - const { journal, history } = await journalOwingImport() - - const first = journal.appendItem(item(1), reply('one'), OPTIONS) - const second = journal.appendItem(item(2), reply('two'), OPTIONS) - // Neither has landed: both wait behind the copy the first write pays. - expect(journal.itemBody(agentJournalItemKey(item(1)))).toBeNull() - expect(journal.importPending).toBe(true) - - await second - await first - expect(journal.importPending).toBe(false) - expect(sequenceOf(journal, 1)).toBe(history + 1) - expect(sequenceOf(journal, 2)).toBe(history + 2) - // The copy is paid: the next write lands at its call again. - journal.appendItem(item(3), reply('three'), OPTIONS).catch(() => undefined) - expect(sequenceOf(journal, 3)).toBe(history + 3) - }) - it('joins the line behind a write it was issued from inside, never nested in it', async () => { const journal = await idleJournal() let inner: Promise | null = null @@ -142,7 +113,7 @@ describe('when a journal write lands', () => { }) describe('the journal write queue', () => { - it('runs a write before it returns when nothing is owed or running', () => { + it('runs a write before it returns when nothing is running', () => { const queue = new JournalWriteQueue('session-1') const ran: string[] = [] void queue.serialize(() => { @@ -187,27 +158,6 @@ describe('the journal write queue', () => { await Promise.all([outer, nested, after]) expect(ran).toEqual(['outer', 'nested', 'after']) }) - - it('runs every write admitted while work is owed after it, in order', async () => { - const queue = new JournalWriteQueue('session-1') - const ran: string[] = [] - const owed = Promise.withResolvers() - queue.owe(async () => { - await owed.promise - ran.push('owed') - }) - const first = queue.serialize(() => { - ran.push('first') - }) - const read = queue.readInOrder(() => ran.push('read')) - owed.resolve() - const second = queue.serialize(() => { - ran.push('second') - }) - await Promise.all([first, read, second]) - expect(ran).toEqual(['owed', 'first', 'read', 'second']) - expect(queue.owing).toBe(false) - }) }) describe('a read in the journal write queue', () => { @@ -218,29 +168,17 @@ describe('a read in the journal write queue', () => { expect(ran).toEqual(['read']) }) - it('does not pay owed work, and runs behind a write that pays it', async () => { - const queue = new JournalWriteQueue('session-1') - const ran: string[] = [] - queue.owe(async () => { - ran.push('owed') - }) - void queue.readInOrder(() => ran.push('read before')) - expect(ran).toEqual(['read before']) - const write = queue.serialize(() => { - ran.push('write') - }) - const read = queue.readInOrder(() => ran.push('read after')) - await Promise.all([write, read]) - expect(ran).toEqual(['read before', 'owed', 'write', 'read after']) - }) - it('runs behind a write that failed', async () => { const queue = new JournalWriteQueue('session-1') - queue.owe(async () => undefined) - const failed = queue.serialize(() => { - throw new Error('disk I/O error') + let failed: Promise = Promise.resolve() + let read: Promise = Promise.resolve('') + // Issued from inside a running write, so both join the line. + await queue.serialize(() => { + failed = queue.serialize(() => { + throw new Error('disk I/O error') + }) + read = queue.readInOrder(() => 'read') }) - const read = queue.readInOrder(() => 'read') await expect(failed).rejects.toThrow('disk I/O error') await expect(read).resolves.toBe('read') }) diff --git a/src/main/native-chat/agent-session-journal/journal-write-queue.ts b/src/main/native-chat/agent-session-journal/journal-write-queue.ts index 74ef33472bd4..92eb2dcd590f 100644 --- a/src/main/native-chat/agent-session-journal/journal-write-queue.ts +++ b/src/main/native-chat/agent-session-journal/journal-write-queue.ts @@ -12,14 +12,13 @@ export type JournalWriteResult = T extends PromiseLike ? never : T export type JournalWriteBody = () => JournalWriteResult /** - * A write has landed in the fold when its call returns, except during an owed import, when it - * lands in queue order. + * A write has landed in the fold when its call returns, unless it was issued from inside another + * write or behind one still waiting in line. * - * A write finds the queue idle unless work is owed, a write is running, or writes wait in line; - * then it runs before `serialize` returns. Every write body is synchronous (`JournalWriteBody` - * refuses a promise), so it has committed by then. Otherwise it joins the line: behind the owed - * import it pays first, or behind the running write it was issued from, never nested inside it. - * Admission is checked at ENQUEUE and is permanent. + * A write finds the queue idle unless a write is running or writes wait in line; then it runs + * before `serialize` returns. Every write body is synchronous (`JournalWriteBody` refuses a + * promise), so it has committed by then. Otherwise it joins the line behind the writes ahead of + * it, never nested inside the running one. Admission is checked at ENQUEUE and is permanent. */ export class JournalWriteQueue { /** Settles once every write admitted so far has, whatever its outcome. */ @@ -28,8 +27,6 @@ export class JournalWriteQueue { private waiting = 0 private running = false private closed = false - /** Runs before the next write, and stays owed until it succeeds. */ - private owed: (() => Promise) | null = null constructor(private readonly sessionId: string) {} @@ -41,19 +38,17 @@ export class JournalWriteQueue { if (this.closed) { return Promise.reject(this.closedError()) } - return this.owed !== null || this.lineBusy - ? this.join(run, this.owed !== null) - : this.runNow(run) + return this.lineBusy ? this.join(run) : this.runNow(run) } /** Runs `read` after every write admitted before it, whatever each one's outcome, and ahead of any - * admitted after: at once when none waits. Owed work is not paid for a read. A closed queue - * refuses it, as it refuses a write: its fold may be replaced. */ + * admitted after: at once when none waits. A closed queue refuses it, as it refuses a write: its + * fold may be replaced. */ readInOrder(read: () => T): Promise { if (this.closed) { return Promise.reject(this.closedError()) } - return this.lineBusy ? this.join(read, false) : this.runNow(read) + return this.lineBusy ? this.join(read) : this.runNow(read) } private get lineBusy(): boolean { @@ -67,22 +62,6 @@ export class JournalWriteQueue { ) } - owe(work: () => Promise): void { - this.owed = work - } - - get owing(): boolean { - return this.owed !== null - } - - private payOwed = async (): Promise => { - const owed = this.owed - if (owed) { - await owed() - this.owed = null - } - } - /** Resolves once every write admitted so far has settled, whatever its outcome. */ drain(): Promise { return this.writes.then(() => undefined) @@ -107,8 +86,8 @@ export class JournalWriteQueue { return result } - private join(run: () => T, paysOwed: boolean): Promise { - const started = paysOwed ? this.writes.then(this.payOwed).then(run) : this.writes.then(run) + private join(run: () => T): Promise { + const started = this.writes.then(run) this.writes = started.catch(() => undefined) this.waiting++ const settle = (): void => { diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-identity.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-identity.ts index 6dd47f03ec53..f1c0038b4d86 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-identity.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-identity.ts @@ -76,6 +76,22 @@ export function spellProviderTimelineKey( : `m:${providerTimelineKeyPart(key.value)}` } +/** Whether a turn id names a turn the provider keyed inside session `namespace`. */ +export function isProviderTimelineTurnInNamespace(turnId: string, namespace: string): boolean { + return turnId.startsWith(`p:${providerTimelineKeyPart(namespace)}:`) +} + +/** The spelled key of a provider-keyed item's record id, whichever session and thread wrote it; + * null for any other row. Compare it with `providerTimelineKeyPart(key)`. */ +export function spelledProviderTimelineItemKey(recordId: string): string | null { + if (!recordId.startsWith('item:p:')) { + return null + } + // Every part is URI-encoded, so `:` and `/` only ever separate parts. + const scoped = recordId.slice(recordId.lastIndexOf(':') + 1) + return scoped.slice(scoped.lastIndexOf('/') + 1) +} + /** The existing `legacy` arm. Turn rows keep the `turn-lifecycle:` record prefix the other lanes * write; provider keys are spelled inside their namespace, and apart from minted ones. */ export function createLegacyProviderTimelineIdentityScheme(input: { 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 fb0150c7aba2..263de102f532 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 @@ -595,20 +595,22 @@ describe('what an earlier host process left behind', () => { describe('a child that exits before its message is handed over', () => { it('rejects the message with the exit reason instead of starting another child (W24)', async () => { - // Each child the loop starts dies between its start step and its handover step. - const awaitStarted = vi.fn(async (sessionId: string) => { - await host.handleAdapterEvent({ + await host.close(SESSION, 'evict') + await startHost() + // Each child the loop starts dies as its start step returns: its exit, asked for on the lane + // then, lands before the handover step. + acquire.mockImplementation(async (input) => { + const child = await spawnChild(input) + void host.handleAdapterEvent({ type: 'ended', - sessionId, - fence: store.getRecord(sessionId)!.lease.runtimeFence, + sessionId: SESSION, + fence: input.fence, acquisitionGeneration: `generation-${acquire.mock.calls.length}`, reason: 'codex app-server crashed', cause: 'unexpected-exit' }) + return child }) - adapterExtras = { awaitStarted } - await host.close(SESSION, 'evict') - await startHost() const id = await accept('hello') @@ -622,11 +624,11 @@ describe('a child that exits before its message is handed over', () => { }) }) -describe('a start whose failure the delivery loop settles before the exit is published', () => { +describe('a start that fails after it was handed messages', () => { it('keeps one row in the words its rejected messages carry', async () => { - // The adapter's startup answer and its later exit event word the same start differently. - const awaitStarted = vi.fn(async () => agentSessionFailureFact('startFailed')) - adapterExtras = { awaitStarted } + // 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 })) acquire.mockImplementation(async (input) => ({ ...(await spawnChild(input)), providerChildPhase: 'starting' as const @@ -636,7 +638,7 @@ describe('a start whose failure the delivery loop settles before the exit is pub const first = await accept('first') const second = await accept('second') - await eventually(async () => expect((await submission(second))?.dispatchState).toBe('rejected')) + await eventually(() => expect(dispatch).toHaveBeenCalledTimes(1)) await host.handleAdapterEvent({ type: 'ended', sessionId: SESSION, @@ -651,21 +653,22 @@ describe('a start whose failure the delivery loop settles before the exit is pub }) await host.flushStreamedEvents(SESSION) + // The held message is rejected by the delivery loop's next step, after the start's own. + await eventually(async () => expect((await submission(second))?.dispatchState).toBe('rejected')) const rows = (await host.journalSnapshot(SESSION)).items.filter((item) => item.itemId.includes('start-failure') ) expect(rows).toHaveLength(1) - const words = agentSessionFailureWords(agentSessionFailureFact('startFailed'), { - surface: 'rejection', - agentName: 'Codex', - provider: 'codex' + // Worded once, from the exit's own diagnostic, for the row and every message sent to that start. + const row = rows[0].body + expect(row).toMatchObject({ + failure: { kind: 'providerStartFailed', detail: { text: 'codex: config.toml is invalid' } } }) - expect(rows[0].body).toMatchObject({ text: words.reason, failure: words.rejection }) for (const id of [first, second]) { expect(await submission(id)).toMatchObject({ dispatchState: 'rejected', - reason: words.reason, - rejection: words.rejection + reason: row.kind === 'status' ? row.text : null, + rejection: row.kind === 'status' ? row.failure : null }) } }) @@ -721,22 +724,19 @@ describe('Stop withdraws what is queued', () => { expect(dispatch).not.toHaveBeenCalled() }) - it('stops a child still proving its start, and the delivery loop ends with it (W17c)', async () => { - const ended = deferred() - const awaitStarted = vi.fn(() => ended.promise) - const closeSession = vi.fn(async () => { - ended.resolve() - return true - }) - adapterExtras = { awaitStarted, closeSession } + it('stops a child still proving its start, rejecting what it was handed, and the next send starts another (W17c)', async () => { + const closeSession = vi.fn(async () => true) + adapterExtras = { closeSession } await host.close(SESSION, 'evict') await startHost() acquire.mockImplementationOnce(async (input) => ({ ...(await spawnChild(input)), providerChildPhase: 'starting' as const })) + // Written to the starting child at once; it never answers. + dispatch.mockResolvedValueOnce({ state: 'admitted' }) const id = await accept('hello') - await eventually(async () => expect(awaitStarted).toHaveBeenCalled()) + await eventually(async () => expect((await submission(id))?.handedOverAt).toBeDefined()) expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) @@ -745,25 +745,27 @@ describe('Stop withdraws what is queued', () => { reason: DISPATCH_REJECTED_CANCELLED }) expect(closeSession).toHaveBeenCalled() - // A loop still waiting on that start would swallow this send; it starts a new child instead. - awaitStarted.mockImplementation(async () => undefined) const next = await accept('after stop') await eventually(async () => expect((await submission(next))?.dispatchState).toBe('accepted')) - expect(dispatch).toHaveBeenCalledTimes(1) + expect(dispatch).toHaveBeenCalledTimes(2) }) }) describe('an eviction between acceptance and handover', () => { it('rejects the message as not sent, never leaves it in doubt (W24)', async () => { const started = deferred() - adapterExtras = { awaitStarted: () => started.promise } await host.close(SESSION, 'evict') await startHost() + acquire.mockImplementationOnce(async (input) => { + await started.promise + return spawnChild(input) + }) const id = await accept('hello') await eventually(async () => expect(acquire).toHaveBeenCalledTimes(2)) - // Between the delivery loop's start step and its handover step. - await host.close(SESSION, 'evict') + // Asked for during the delivery loop's start step, the close runs ahead of its handover step. + const closing = host.close(SESSION, 'evict') started.resolve() + await closing expect(await reopened(id)).toMatchObject({ dispatchState: 'rejected', @@ -773,20 +775,19 @@ describe('an eviction between acceptance and handover', () => { }) it('rejects a queued message behind a handed-over one, which alone stays in doubt (W24)', async () => { - const second = deferred() - const awaitStarted = vi.fn(async (): Promise => undefined) - adapterExtras = { awaitStarted } await host.close(SESSION, 'evict') await startHost() dispatch.mockResolvedValueOnce({ state: 'admitted' }) const handed = await accept('handed over') await eventually(async () => expect((await submission(handed))?.handedOverAt).toBeDefined()) - awaitStarted.mockImplementation(() => second.promise) - const queued = await accept('still queued') - await eventually(async () => expect(awaitStarted).toHaveBeenCalledTimes(2)) - - await host.close(SESSION, 'evict') + // Held, the second send is accepted and the close runs next, ahead of its handover. + const second = deferred() + void host['tasks'].serialize(SESSION, () => second.promise) + const accepting = accept('still queued') + const closing = host.close(SESSION, 'evict') second.resolve() + const queued = await accepting + await closing expect(await reopened(queued)).toMatchObject({ dispatchState: 'rejected', @@ -799,9 +800,12 @@ describe('an eviction between acceptance and handover', () => { it('does not stop an idle child while a message is still queued for it (W24)', async () => { idleMs = 0 const started = deferred() - adapterExtras = { awaitStarted: () => started.promise } await host.close(SESSION, 'evict') await startHost() + acquire.mockImplementationOnce(async (input) => { + await started.promise + return spawnChild(input) + }) const id = await accept('hello') await eventually(async () => expect(acquire).toHaveBeenCalledTimes(2)) // The idle sweep ticks every few milliseconds meanwhile. @@ -831,7 +835,6 @@ describe('a close that stops the child and then a wind-down step fails', () => { async ({ end, starting, kind, verdict }) => { const started = deferred() adapterExtras = { - awaitStarted: () => started.promise, // Once: the host's own teardown acknowledges again. acknowledgeSessionRelease: vi.fn().mockImplementationOnce(() => { throw new Error('release acknowledgement failed') @@ -839,15 +842,20 @@ describe('a close that stops the child and then a wind-down step fails', () => { } await host.close(SESSION, 'evict') await startHost() - acquire.mockImplementationOnce(async (input) => ({ - ...(await spawnChild(input)), - ...(starting ? { providerChildPhase: 'starting' as const } : {}) - })) + acquire.mockImplementationOnce(async (input) => { + await started.promise + return { + ...(await spawnChild(input)), + ...(starting ? { providerChildPhase: 'starting' as const } : {}) + } + }) const id = await accept('hello') await eventually(() => expect(acquire).toHaveBeenCalledTimes(2)) - await expect(END_CHILD[end]()).resolves.toBeUndefined() + // Asked for during the start step, the close runs ahead of the handover step. + const ending = END_CHILD[end]() started.resolve() + await expect(ending).resolves.toBeUndefined() await eventually(async () => expect((await submission(id))?.dispatchState).toBe('rejected')) const rejected = (await submission(id))! 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 new file mode 100644 index 000000000000..a3eabd09f0e5 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquire-aborts.ts @@ -0,0 +1,40 @@ +// 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. + +export class StructuredAgentSessionAcquireAborts { + private readonly inFlight = new Map() + /** Set by quit: the host is going away, so an attach that begins after it starts aborted. */ + private quitReason: Error | null = null + + /** For the attach about to run; `end` once it settles. */ + begin(sessionId: string): { signal: AbortSignal; end: () => void } { + const controller = new AbortController() + if (this.quitReason) { + controller.abort(this.quitReason) + } + this.inFlight.set(sessionId, controller) + return { + signal: controller.signal, + end: () => { + if (this.inFlight.get(sessionId) === controller) { + this.inFlight.delete(sessionId) + } + } + } + } + + /** A no-op when the session has nothing in flight. */ + abort(sessionId: string, reason: string): void { + this.inFlight.get(sessionId)?.abort(new Error(reason)) + } + + /** Quit: every start under way stops, and so does any the attach drain still runs. */ + abortAll(reason: string): void { + this.quitReason ??= new Error(reason) + for (const controller of this.inFlight.values()) { + controller.abort(this.quitReason) + } + } +} 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 af986ae7e442..3389bc615e58 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 @@ -29,6 +29,8 @@ export async function acquireOwner( try { try { await input.onAcquiring?.() + // A close or Stop that landed while the attach was still reconciling launches nothing. + input.acquireSignal?.throwIfAborted() } catch (error) { throw new AgentSessionPreSpawnError(error) } @@ -40,6 +42,7 @@ export async function acquireOwner( ...(record.options ? { options: record.options } : {}), ...(input.eventSink ? { events: input.eventSink } : {}), ...(input.recordPhase ? { recordPhase: input.recordPhase } : {}), + ...(input.acquireSignal ? { signal: input.acquireSignal } : {}), onSpawned: async (process) => { record = await input.store.commitProcessIdentity({ sessionId: record.sessionId, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts index 6f879a0fab9f..ee8e13847502 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts @@ -1,4 +1,3 @@ -import type { SubmissionRejectionFact } from '../../../shared/agent-session-failure' import type { StructuredAgentSessionAtRestCommands } from './structured-agent-session-at-rest-commands' import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import type { @@ -163,8 +162,8 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi awaitOptionWritable = (sessionId: string): Promise => this.liveOwnerOrNull(sessionId)?.awaitOptionWritable?.(sessionId) ?? Promise.resolve() - awaitStarted = (sessionId: string): Promise => - this.liveOwnerOrNull(sessionId)?.awaitStarted?.(sessionId) ?? Promise.resolve() + startAnswered = (sessionId: string): boolean | undefined => + this.liveOwnerOrNull(sessionId)?.startAnswered?.(sessionId) prepareReadOptions = (input: { sessionId: string; fence: number }) => this.liveOwnerOrNull(input.sessionId)?.prepareReadOptions?.(input) 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 8acac49cb68c..cb2dc19465a5 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 @@ -133,6 +133,8 @@ export type AgentSessionPreSpawnReason = Extract< | 'accountSwitchInProgress' | 'managedAccountUnsupported' | 'launchFolderMissing' + | 'historyInOtherAccount' + | 'agentCommandNotRunnable' > /** Acquisition failed with first-hand proof that no provider process existed. */ @@ -205,10 +207,12 @@ export type StructuredAgentSessionEndedEvent = { observedAt?: number /** The provider ended before it finished starting, so resuming it would repeat the failure. */ startupUnproven?: true + /** The provider ended before it answered its start, so it ran nothing it was handed. */ + startupUnanswered?: true } -/** The child a publish-first acquire handed over has now proven its start: startup facts applied - * and saved options restored. What it reports from here on is fact, not a catalog guess. */ +/** The child a publish-first acquire handed over has now proven its start: startup facts applied. + * What it reports from here on is fact, not a catalog guess. */ export type StructuredAgentSessionStartedEvent = { type: 'started' sessionId: string @@ -217,16 +221,31 @@ export type StructuredAgentSessionStartedEvent = { /** What the child proved, snapshotted by the adapter from what startup already read. The host * handles this inside the session's serialized step, so it must not ask the CLI. */ reportedOptions: AgentSessionOptionsResult['current'] - /** Saved options the restore could not apply; the host drops them rather than persist them. */ + /** Saved options the child could not take; the host drops them rather than persist them. */ restoreSkippedOptions: readonly string[] + /** Values the child showed it cannot run: a report naming the same value is not persisted. */ + retiredOptions?: Readonly> +} + +/** A running child showed saved options it cannot run, as a model the provider reports missing. + * The record drops them, so the next start uses the provider's own. */ +export type StructuredAgentSessionOptionsSkippedEvent = { + type: 'options-skipped' + sessionId: string + fence: number + acquisitionGeneration: string + /** Each saved value the child showed it cannot run; a record holding another value keeps it. */ + options: Readonly> } export type StructuredAgentSessionLifecycleEvent = | StructuredAgentSessionEndedEvent | StructuredAgentSessionStartedEvent + | StructuredAgentSessionOptionsSkippedEvent /** Whether the provider child behind an acquisition has proven its start. A publish-first - * acquire hands over a `starting` child and the `started` lifecycle event flips it. */ + * acquire hands over a `starting` child, which already takes input, and the `started` lifecycle + * event flips it. */ export type StructuredAgentSessionProviderChildPhase = 'starting' | 'ready' export type StructuredAgentSessionAcquireInput = { @@ -240,6 +259,10 @@ export type StructuredAgentSessionAcquireInput = { /** 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 } export type StructuredAgentSessionSetOptionInput = { @@ -247,6 +270,9 @@ export type StructuredAgentSessionSetOptionInput = { key: string value: string fence: number + /** Aborted by a close, a Stop admitted now, or quit, which must not wait behind a pick the + * provider never answers: the write fails. An adapter that bounds its own write may ignore it. */ + signal?: AbortSignal } export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & { @@ -365,10 +391,9 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & ): Promise>> /** Resolves once a live session can take an option write, or after a bound; never rejects. */ awaitOptionWritable?(sessionId: string): Promise - /** Resolves once a session published before it proved its start has proven it, failed, or been - * closed; at once for any other. A start that did not land resolves with the chat's words for - * why. Never rejects. */ - awaitStarted?(sessionId: string): Promise + /** False while the live child has not answered its start, so it has run nothing it was handed. + * Absent or undefined reads as answered. */ + startAnswered?(sessionId: string): boolean | undefined /** Fetch off the lane, then apply the result under the same child's fence. */ prepareReadOptions?(input: { sessionId: string 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 85501dd8a319..9046e40dbbe8 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 @@ -50,6 +50,8 @@ export type StructuredAgentSessionResumeOutcome = /** What the provider said about the failed start, for the chat's own record; host-side * only, never on the refusal. */ diagnostic?: ProviderDiagnostic + /** The host's own close, Stop or quit aborted the start, so it failed nothing it was for. */ + aborted?: true argumentProblem?: AgentSessionArgumentProblem } @@ -165,11 +167,15 @@ async function startStructuredAgentSessionAgent( } let attached: AgentSessionMutationResult let acquisitionError: unknown + let aborted = false try { attached = await attachStructuredAgentSessionUnderSerialize(context, callerKey, params, { ...(startedFor === undefined ? {} : { startedFor }), onAcquisitionFailed: (error) => { acquisitionError = error + }, + onAborted: () => { + aborted = true } }) } catch (error) { @@ -184,16 +190,17 @@ async function startStructuredAgentSessionAgent( error ) if (settled) { - return withDiagnostic(settled.refusal, error) + return withDiagnostic(settled.refusal, error, aborted) } throw error } - return attached.ok ? { ok: true } : withDiagnostic(attached.refusal, acquisitionError) + return attached.ok ? { ok: true } : withDiagnostic(attached.refusal, acquisitionError, aborted) } function withDiagnostic( refusal: AgentSessionWireRefusal, - error: unknown + error: unknown, + aborted: boolean ): StructuredAgentSessionResumeOutcome { const diagnostic = providerDiagnosticOf(error) const argumentProblem = argumentProblemOf(error) @@ -201,6 +208,7 @@ function withDiagnostic( ok: false, refusal, ...(diagnostic ? { diagnostic } : {}), + ...(aborted ? { aborted: true as const } : {}), ...(argumentProblem ? { argumentProblem } : {}) } } 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 453a40075aed..9c287da62e2c 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 @@ -61,6 +61,8 @@ export type AttachFlowInput = { params: AgentSessionAttachParams now: () => number recordPhase?: AgentSessionCreatePhaseRecorder + /** Aborted when a close, or a Stop admitted now, must not wait behind this attach's acquire. */ + acquireSignal?: AbortSignal /** 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. */ 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 3a13e1424203..e801a99042da 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 @@ -49,6 +49,8 @@ export type StructuredAgentSessionAttachOptions = { onAcquisitionFailed?: AttachFlowInput['onAcquisitionFailed'] /** The queued message a start is for; see `StructuredAgentSessionProviderChild.startedFor`. */ startedFor?: string + /** A close, an admitted Stop or quit aborted this attach: its refusal is that abort's. */ + onAborted?: () => void } /** @@ -105,6 +107,25 @@ async function runAttach( callerKey: string, params: AgentSessionAttachParams, options: StructuredAgentSessionAttachOptions +): Promise> { + // Begun first, so a close or Stop during any phase below still stops the start. + const acquire = context.runtimeState.acquireAborts.begin(params.envelope.sessionId) + try { + return await runAttachUnderAbort(context, callerKey, params, options, acquire.signal) + } finally { + acquire.end() + if (acquire.signal.aborted) { + options.onAborted?.() + } + } +} + +async function runAttachUnderAbort( + context: StructuredAgentSessionAttachContext, + callerKey: string, + params: AgentSessionAttachParams, + options: StructuredAgentSessionAttachOptions, + acquireSignal: AbortSignal ): Promise> { const sessionId = params.envelope.sessionId const recordPhase = options.recordPhase @@ -171,6 +192,7 @@ async function runAttach( params, now: () => context.now(), recordPhase, + acquireSignal, ...(options.onAcquisitionFailed ? { onAcquisitionFailed: options.onAcquisitionFailed } : {}), openConversation: async (record) => { const conversation = await context.openConversation(record.sessionId, { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts index edfe42f6d858..b6cd948fa4dc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts @@ -78,7 +78,7 @@ export function mutateWithChatStop( // Read before the withdrawal it decides on is issued. const hadQueued = ctx.journal.submissions().some(isQueuedAgentJournalSubmission) // Stop withdraws every queued SUBMISSION first, whatever the start or the child is doing. - // Issued, not awaited: the interrupt never waits on bookkeeping. (The open paid any owed import.) + // Issued, not awaited: the interrupt never waits on bookkeeping. const withdrew = withdrawQueuedForStop(ctx, () => ctx.journal.rejectQueuedSubmissions( ctx.fence, @@ -102,8 +102,9 @@ export function mutateWithChatStop( return { ok: true, value: { ...named, cancelled: await withdrew } } } if (child?.phase === 'starting') { - // A start that may never land is the one thing here Stop has to end; the chat stays. - // The event is issued first and lands behind the withdrawal, in the journal's queue order. + // A start that may never land takes no interrupt, so Stop ends it; the chat stays. Its end + // settles what the child was handed as stopped (`unrunRejection`). The event is + // issued first and lands behind the withdrawal, in the journal's queue order. const effect = tookEffect() try { await stopChild() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-child-close.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-child-close.ts index 0022df7c1565..524b4fd4d3f2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-child-close.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-child-close.ts @@ -25,10 +25,16 @@ export async function joinStructuredAgentSessionChildClose( sessionId: string, child: StructuredAgentSessionProviderChild ): Promise { + // Read before the close: once the child is gone the provider can no longer say. + const unanswered = context.deps.adapter.startAnswered?.(sessionId) === false if (!(await closeProviderRoot(context, sessionId))) { return 'unverifiable' } - await context.endExitedChild(sessionId, child, { expected: true, reason: 'closed by Orca' }) + await context.endExitedChild(sessionId, child, { + expected: true, + reason: 'closed by Orca', + ...(unanswered ? { startupUnanswered: true } : {}) + }) context.restartWitness?.stopped(sessionId) return 'exited' } 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 c606a73e310f..a983753e2a99 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 @@ -3,7 +3,10 @@ // every step after it is bookkeeping: each is attempted and reported, none keeps the child on // record, and the record ends in `finally`. `expected` changes only what the chat is told. -import type { SubmissionRejectionFact } from '../../../shared/agent-session-failure' +import { + agentSessionFailureFact, + type SubmissionRejectionFact +} from '../../../shared/agent-session-failure' import { PROVIDER_EXIT_ROW_PREFIX } from '../../../shared/agent-session-stop-row-identity' import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' @@ -18,12 +21,12 @@ import { type StructuredAgentSessionLeaseStore } from './structured-agent-session-lease-release' import type { StructuredAgentSessionSinkBarrier } from './structured-agent-session-event-sink' +import { settleStructuredAgentSessionDeadGeneration } from './structured-agent-session-dead-generation-settlement' import { captureUnfinishedStructuredAgentSessionWork, - settleStructuredAgentSessionDeadGeneration, type DeadGenerationJournal, unfinishedStructuredAgentSessionWorkWasInterrupted -} from './structured-agent-session-dead-generation-settlement' +} from './structured-agent-session-unfinished-work' 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' @@ -37,6 +40,7 @@ export type StructuredAgentSessionChildExit = { /** Host receipt of the exit: the end time of a turn it interrupted. */ observedAt?: number startupUnproven?: true + startupUnanswered?: true } export type StructuredAgentSessionChildExitSession = Pick< @@ -57,6 +61,13 @@ export type StructuredAgentSessionChildExitContext< serialize: (sessionId: string, task: () => Promise) => Promise now: () => number logger: StructuredAgentSessionLogger + /** Settles what a child that ended before it answered its start was handed and never echoed, as + * the chat settles a queued send for the same end (`holdUnsentSends`). */ + holdUnrunSends?: ( + sessionId: string, + fence: number, + cause: 'chatClosed' | 'hostRestarted' + ) => Promise /** Lets the child's sink and the adapter's route for it go; absent leaves both to the next attach. */ route?: { runtimeState: Pick @@ -81,7 +92,8 @@ export function settleStructuredAgentSessionChildExit< reason: event.reason, ...(event.failure ? { failure: event.failure } : {}), ...(event.observedAt === undefined ? {} : { observedAt: event.observedAt }), - ...(event.startupUnproven ? { startupUnproven: event.startupUnproven } : {}) + ...(event.startupUnproven ? { startupUnproven: event.startupUnproven } : {}), + ...(event.startupUnanswered ? { startupUnanswered: event.startupUnanswered } : {}) }) }) } @@ -106,6 +118,29 @@ export async function endExitedStructuredAgentSessionChildUnderSerialize< // The host's own phase decides, so a provider that omits the flag still gets a start that // failed told as one: the row says so. const exitedDuringStartup = exit.startupUnproven === true || child.phase === 'starting' + // A close during startup settles what the child was handed, never echoed, by who asked: a + // person's Stop stops it; the host's stop fails the start, as an exit of its own would. + const startClose = expected && exitedDuringStartup ? close?.cause : undefined + const startFailure = expected + ? startClose === 'host-stop' + ? agentSessionFailureFact('hostStopped') + : undefined + : exit.failure + // A send the child was handed and never echoed cannot have run when a person's Stop ended its + // start, or any close ended it before it answered its start: settled as the chat settles a queued + // send for the same end. A Stop withdraws it as cancelled; a quit or close keeps a person's + // message as a held card. A host stop fails the start (`startFailure`). + const unrunRejection = + startClose === 'user-stop' ? agentSessionFailureFact('cancelled') : undefined + const unrunHold = + expected && + exit.startupUnanswered && + close?.cause !== 'host-stop' && + close?.cause !== 'user-stop' + ? close?.quit + ? ('hostRestarted' as const) + : ('chatClosed' as const) + : undefined const endChild = (): void => { endProviderChild(session, { generation: child.generation, @@ -138,10 +173,22 @@ export async function endExitedStructuredAgentSessionChildUnderSerialize< } catch (error) { logExitFailure(context, sessionId, 'exit-lifecycle-barrier', error) } + if (unrunHold && context.holdUnrunSends) { + await context + .holdUnrunSends(sessionId, child.fence, unrunHold) + .catch((error: unknown) => logExitFailure(context, sessionId, 'exit-unrun-hold', error)) + } const unfinishedWork = captureUnfinishedStructuredAgentSessionWork(session.journal) + // The host's stop fails only a start something was handed to; an idle one goes quietly. + const startFailed = + exitedDuringStartup && + (!expected || (startClose === 'host-stop' && unfinishedWork.hadUnsettledSubmissions)) // Folded before the fallback's end is built, so the end reads it (`turnEndAfterStop`). await close?.recorded const generation = child.generation ?? 'unknown' + // The exit proves this child gone, as the record's death evidence later says: what it left + // `unverifiable` is revised now, not at the next open. + const watched = { ownerFence: child.fence, observedAt } const settled = await settleStructuredAgentSessionDeadGeneration({ journal: session.journal, sessionId, @@ -156,17 +203,20 @@ export async function endExitedStructuredAgentSessionChildUnderSerialize< failureTextContext: structuredAgentSessionFailureWordsContext(record, session.journal), // A failed start always says why: no response was running to carry the reason. showUnexpectedExitOutcome: - !expected && - (exitedDuringStartup || + startFailed || + (!expected && unfinishedStructuredAgentSessionWorkWasInterrupted( unfinishedWork, session.journal, - observedAt + observedAt, + watched )), - ...(!expected && exit.failure ? { exitFailure: exit.failure } : {}), - ...(!expected && exitedDuringStartup && child.generation + ...(startFailure ? { exitFailure: startFailure } : {}), + ...(startFailed && child.generation ? { exitedDuringStartup: { generation: child.generation } } - : {}) + : {}), + exit: watched, + ...(unrunRejection ? { unrunRejection } : {}) }) if (!settled.ok) { logExitFailure(context, sessionId, 'exit-settlement', settled.error) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts index cfb7dfd54834..861e7fc7b9f4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts @@ -12,7 +12,8 @@ import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-s import { fakeClaude, PROVIDER_SESSION_ID, - type FakeConnection + type FakeConnection, + claudeStartupSettled } from '../../claude/claude-structured-session-test-support' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' @@ -92,7 +93,7 @@ beforeEach(async () => { }) ) expect(attached).toMatchObject({ ok: true }) - await adapter.awaitStarted(SESSION) + await claudeStartupSettled(adapter, SESSION) }) afterEach(async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts index de44f4f8a2ba..33fe1fe57511 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts @@ -12,7 +12,8 @@ import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/struct import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' import { fakeClaude, - PROVIDER_SESSION_ID + PROVIDER_SESSION_ID, + claudeStartupSettled } from '../../claude/claude-structured-session-test-support' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' @@ -84,7 +85,7 @@ beforeEach(async () => { providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } }) expect(await host.attach(CALLER, params)).toMatchObject({ ok: true }) - await adapter.awaitStarted(SESSION) + await claudeStartupSettled(adapter, SESSION) await Promise.all(lifecycle) }) 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 new file mode 100644 index 000000000000..595e18177541 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-hung-start-stop.test.ts @@ -0,0 +1,223 @@ +// 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. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { DISPATCH_REJECTED_CANCELLED } from '../../../shared/structured-agent-session-dispatch-rejection' +import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-live-turn' +import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' +import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' +import { + fakeClaude, + PROVIDER_SESSION_ID +} from '../../claude/claude-structured-session-test-support' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import { structuredClaudeLifecycleEvent } from '../../runtime/structured-claude-runtime-adapter' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' + +const CALLER = { callerKey: 'client-1' } + +let root: string +let host: StructuredAgentSessionHost +let adapter: ClaudeStructuredSessionAdapter +let store: AgentSessionRecordStore +let claude: ReturnType +/** Answers initialize, which the CLI otherwise never does; it echoes nothing either way. */ +let answerInitialize: () => void = () => {} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-claude-hung-start-stop-')) + resetHostTestOperationIds() + // Alive, and silent: no initialize answer, no start frame, no echo of what it is sent. + claude = fakeClaude({ initProof: 'none', replayUuid: null }) + const openConnection = claude.openConnection + adapter = new ClaudeStructuredSessionAdapter({ + resolveLaunch: async () => ({ + pathToClaudeCodeExecutable: 'claude', + options: {}, + cwd: root, + claudeConfigDir: join(root, 'claude-home'), + providerSessionId: PROVIDER_SESSION_ID, + resumeLeafUuid: null, + resumesTranscript: false, + continuesChain: false + }), + onEvent: (event) => { + const mapped = structuredClaudeLifecycleEvent(event) + if (mapped) { + void host.handleAdapterEvent(mapped) + } + }, + onDispatchSettledLate: (settlement) => void host.settleLateDispatch(settlement), + openConnection: async (...args) => { + const connection = await openConnection(...args) + const fake = claude.connections.at(-1) + connection.initializationResult = () => { + fake?.calls.push({ subtype: 'initialize' }) + return new Promise((resolve) => { + answerInitialize = () => resolve({ models: [] }) + }) + } + return connection + }, + readProcessStartTime: async () => 1_700_000_000_000, + now: () => NOW + }) + store = await openTestAgentSessionRecordStore(root) + host = new StructuredAgentSessionHost({ + agents: claudeAndCodexDeclared(), + store, + adapter: Object.assign(adapter, { supportsCreate: () => true }), + journalDatabase: openTestJournalHostDatabase(root), + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + logger: createStructuredAgentSessionLogger(), + now: () => NOW + }) + const params = hostTestAttachParams(null, { + provider: 'claude', + agent: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: join(root, 'claude-home') }, + providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } + }) + expect(await host.attach(CALLER, params)).toMatchObject({ ok: true }) +}) + +afterEach(async () => { + await adapter.closeAll() + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +function envelope( + method: 'agentSession.send' | 'agentSession.cancel', + fields: Parameters[0]['fields'] +) { + return { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: store.getRecord(SESSION)!.lease.runtimeFence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method, + sessionId: SESSION, + fields + }) + } +} + +/** The one rule every session list's Working and the chat's own Stop read. */ +async function readsWorking(): Promise { + const snapshot = await host.journalSnapshot(SESSION) + return isStructuredAgentSessionMainAgentWorking( + activeStructuredAgentSessionTurnId(snapshot.items), + snapshot.submissions, + store.getRecord(SESSION)!.lease.runtimeFence + ) +} + +it('writes the message to 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 }) + if (!sent.ok) { + throw new Error('send refused') + } + 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) + ) + expect(connection!.calls).toEqual([{ subtype: 'initialize' }]) + expect(host['sessions'].get(SESSION)?.child?.phase).toBe('starting') + expect(await readsWorking()).toBe(true) + + const stopped = await host.cancel(CALLER, { envelope: envelope('agentSession.cancel', {}) }) + + 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. + await vi.waitFor(async () => + expect( + (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === id + ) + ).toMatchObject({ dispatchState: 'rejected', reason: DISPATCH_REJECTED_CANCELLED }) + ) + expect(await readsWorking()).toBe(false) +}) + +async function sendHello(): Promise { + const body = hostTestMessage('hello') + // A person's send, which a close keeps as a held card. + const sent = await host.send(CALLER, { + envelope: envelope('agentSession.send', { body }), + body, + userSend: true + }) + 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) + ) + return sent.value.clientMessageId +} + +async function submission(id: string) { + return (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === id + ) +} + +// 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. +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', + async (cause) => { + const id = await sendHello() + + await host.close(SESSION, cause) + + await vi.waitFor(async () => + expect(await submission(id)).toMatchObject({ + dispatchState: 'rejected', + reason: 'The chat closed before this message was sent.', + keptAsQueuedMessageId: id + }) + ) + } +) + +// Once it answered, the CLI may have taken the message without echoing it yet: a close leaves it in +// doubt, as for any running child. +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() + + await host.close(SESSION, 'user-close') + + await vi.waitFor(async () => + expect(await submission(id)).toMatchObject({ dispatchState: 'unknown' }) + ) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-opening-send-hold.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-opening-send-hold.test.ts new file mode 100644 index 000000000000..4eeea9ed3b15 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-opening-send-hold.test.ts @@ -0,0 +1,397 @@ +// A follow-up sent before Claude echoes the first message waits for the turn that echo opens, then +// goes in scoped to it: the CLI folds it into that turn, and the client draws it there, never as a +// message of its own ahead of the reply. On the shipping adapter and host, fed the CLI's captured +// early-steer frame order (CLI 2.1.280). + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { projectStructuredAgentSessionMessages } from '../../../shared/structured-agent-session-message-projection' +import { projectNativeChatTranscriptMessages } from '../../../shared/native-chat-transcript-projection' +import { nativeChatRowsInDrawOrder } from '../../../shared/native-chat-turn-grouping' +import { nativeChatTurnMembership } from '../../../shared/native-chat-turn-membership' +import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' +import { + claudeStartupSettled, + fakeClaude, + PROVIDER_SESSION_ID, + type FakeConnection +} from '../../claude/claude-structured-session-test-support' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import { structuredClaudeLifecycleEvent } from '../../runtime/structured-claude-runtime-adapter' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' +import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' +import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' +import { DISPATCH_DOUBT_PROVIDER_IDLE } from '../agent-session-journal/journal-dispatch-doubt-reasons' +import { claudeUnwrittenUserMessageError } from '../../claude/claude-agent-sdk-user-message-queue' + +const CALLER = { callerKey: 'client-1' } +const CAPABILITIES = ['interrupt_receipt_v1', 'interrupt_cancel_queued_v1', 'msg_lifecycle_v1'] + +let root: string +let host: StructuredAgentSessionHost +let adapter: ClaudeStructuredSessionAdapter +let store: AgentSessionRecordStore +let claude: ReturnType + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-claude-opening-send-')) + resetHostTestOperationIds() + const log = recordingStructuredAgentSessionLogger() + claude = fakeClaude({ + replayUuid: null, + routes: { interrupt: () => ({ still_queued: [], cancelled: [] }) } + }) + const lifecycle: Promise[] = [] + adapter = new ClaudeStructuredSessionAdapter({ + resolveLaunch: async () => ({ + pathToClaudeCodeExecutable: 'claude', + options: {}, + cwd: root, + claudeConfigDir: join(root, 'claude-home'), + providerSessionId: PROVIDER_SESSION_ID, + resumeLeafUuid: null, + // A child started after a Stop's close resumes the conversation that close ended. + resumesTranscript: (store.getRecord(SESSION)?.providerHandleChain.length ?? 0) > 0, + continuesChain: (store.getRecord(SESSION)?.providerHandleChain.length ?? 0) > 0 + }), + onEvent: (event) => { + const mapped = structuredClaudeLifecycleEvent(event) + if (mapped) { + lifecycle.push(host.handleAdapterEvent(mapped)) + } + }, + onDispatchSettledLate: (settlement) => void host.settleLateDispatch(settlement), + openConnection: claude.openConnection, + readProcessStartTime: async () => 1_700_000_000_000, + now: () => NOW + }) + store = await openTestAgentSessionRecordStore(root) + host = new StructuredAgentSessionHost({ + agents: claudeAndCodexDeclared(), + store, + adapter: Object.assign(adapter, { supportsCreate: () => true }), + journalDatabase: openTestJournalHostDatabase(root), + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + logger: log.logger, + now: () => NOW + }) + const params = hostTestAttachParams(null, { + provider: 'claude', + agent: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: join(root, 'claude-home') }, + providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } + }) + expect(await host.attach(CALLER, params)).toMatchObject({ ok: true }) + await claudeStartupSettled(adapter, SESSION) + await Promise.all(lifecycle) +}) + +afterEach(async () => { + await adapter.closeAll() + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +const eventually = (assertion: () => T | Promise) => + vi.waitFor(assertion, { timeout: 10_000 }) + +async function send(text: string): Promise { + const body = hostTestMessage(text) + const sent = await host.send(CALLER, { + envelope: { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: store.getRecord(SESSION)!.lease.runtimeFence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields: { body } + }) + }, + body + }) + if (!sent.ok) { + throw new Error(JSON.stringify(sent.refusal)) + } + return sent.value.clientMessageId +} + +function frame(connection: FakeConnection, message: Record): void { + connection.handlers.onMessage?.({ session_id: PROVIDER_SESSION_ID, ...message }) +} + +function written(connection: FakeConnection, text: string): Record | undefined { + return connection.sent.find((message) => JSON.stringify(message).includes(text)) +} + +async function snapshot() { + await host.flushStreamedEvents(SESSION) + return host.journalSnapshot(SESSION) +} + +/** Each row's text and the turn it is drawn in, in the order the client draws the conversation. */ +async function drawn(): Promise<{ text: string; turn: string | undefined }[]> { + const { items, submissions } = await snapshot() + const rows = projectNativeChatTranscriptMessages( + projectStructuredAgentSessionMessages(items, [], submissions, { rejectedInPlace: true }) + ) + const { drawOrder, turnKeys } = nativeChatTurnMembership(rows, { items, submissions }) + const keyed = rows.map((row, index) => ({ + text: row.blocks.map((block) => ('text' in block ? block.text : block.type)).join(''), + turn: turnKeys[index] + })) + return [...nativeChatRowsInDrawOrder(keyed, drawOrder)] +} + +it('holds a follow-up until the echo opens the turn, then folds it in, drawn inside that turn', async () => { + const connection = claude.connections[0]! + const first = await send('FIRST prompt') + await eventually(() => expect(written(connection, 'FIRST prompt')).toBeDefined()) + const steer = await send('STEER prompt') + await eventually(() => + expect(host.collaboratorsForTests().conversationDelivery.loop.isRunning(SESSION)).toBe(false) + ) + + // Not written to the CLI, and still queued, until the turn it would join has opened. + expect(written(connection, 'STEER prompt')).toBeUndefined() + const queued = (await snapshot()).submissions.find((entry) => entry.clientMessageId === steer) + expect(queued?.handedOverAt).toBeUndefined() + + const firstFrame = written(connection, 'FIRST prompt')! + frame(connection, { + type: 'system', + subtype: 'init', + uuid: 'init-1', + model: 'claude-sonnet-5', + capabilities: CAPABILITIES + }) + frame(connection, { ...firstFrame, isReplay: true, parent_tool_use_id: null }) + await eventually(() => expect(written(connection, 'STEER prompt')).toBeDefined()) + const steerFrame = written(connection, 'STEER prompt')! + frame(connection, { + type: 'assistant', + uuid: 'reply-tool-1', + parent_tool_use_id: null, + message: { + id: 'msg-reply-tool-1', + role: 'assistant', + content: [ + { type: 'tool_use', id: 'toolu_sleep_1', name: 'Bash', input: { command: 'sleep 5' } } + ] + }, + user_message_uuid: firstFrame.uuid, + user_message_uuids: [firstFrame.uuid] + }) + frame(connection, { + type: 'user', + uuid: 'tool-result-1', + parent_tool_use_id: null, + message: { + role: 'user', + content: [ + { type: 'tool_result', tool_use_id: 'toolu_sleep_1', content: 'done', is_error: false } + ] + } + }) + frame(connection, { ...steerFrame, isReplay: true, parent_tool_use_id: null }) + frame(connection, { + type: 'assistant', + uuid: 'reply-text-1', + parent_tool_use_id: null, + message: { + id: 'msg-reply-text-1', + role: 'assistant', + content: [{ type: 'text', text: 'FIRST DONE banana' }] + } + }) + frame(connection, { + type: 'result', + subtype: 'success', + uuid: 'result-1', + is_error: false, + terminal_reason: 'completed', + duration_ms: 7155, + num_turns: 2, + result: 'FIRST DONE', + user_message_uuid: firstFrame.uuid, + user_message_uuids: [firstFrame.uuid, steerFrame.uuid] + }) + frame(connection, { type: 'system', subtype: 'session_state_changed', state: 'idle' }) + await eventually(async () => + expect( + (await snapshot()).submissions.find((entry) => entry.clientMessageId === steer)?.dispatchState + ).toBe('accepted') + ) + + const { items } = await snapshot() + expect(items.filter((item) => item.body.kind === 'turn')).toHaveLength(1) + expect( + items.find((item) => item.itemId === agentJournalSubmissionKey(steer))?.turnScope + ).toMatchObject({ kind: 'turn' }) + const rows = await drawn() + const turnOf = (text: string) => rows.find((row) => row.text === text)?.turn + expect(rows.map((row) => row.text)).toContain('FIRST DONE banana') + expect(turnOf('STEER prompt')).toBe(turnOf('FIRST prompt')) + expect(rows.findIndex((row) => row.text === 'STEER prompt')).toBeLessThan( + rows.findIndex((row) => row.text === 'FIRST DONE banana') + ) + expect(first).not.toBe(steer) +}) + +// Claude's own facts end the wait: a send the CLI started and then went idle on without echoing is +// doubt (`releaseClaudeDispatchesUnansweredAtIdle`), with no clock in the host or the adapter. +it('releases the follow-up when the CLI goes idle on a started send it never echoed', async () => { + const connection = claude.connections[0]! + const first = await send('FIRST prompt') + await eventually(() => expect(written(connection, 'FIRST prompt')).toBeDefined()) + const steer = await send('STEER prompt') + await eventually(() => + expect(host.collaboratorsForTests().conversationDelivery.loop.isRunning(SESSION)).toBe(false) + ) + expect(written(connection, 'STEER prompt')).toBeUndefined() + + frame(connection, { + type: 'system', + subtype: 'init', + uuid: 'init-1', + model: 'claude-sonnet-5', + capabilities: CAPABILITIES + }) + const firstFrame = written(connection, 'FIRST prompt')! + frame(connection, { + type: 'command_lifecycle', + command_uuid: firstFrame.uuid, + state: 'started', + uuid: 'lifecycle-first' + }) + frame(connection, { type: 'system', subtype: 'session_state_changed', state: 'idle' }) + + await eventually(() => expect(written(connection, 'STEER prompt')).toBeDefined()) + const submissions = (await snapshot()).submissions + expect(submissions.find((entry) => entry.clientMessageId === first)).toMatchObject({ + dispatchState: 'unknown', + reason: DISPATCH_DOUBT_PROVIDER_IDLE + }) + expect(submissions.find((entry) => entry.clientMessageId === steer)?.handedOverAt).toBeDefined() +}) + +/** As the real connection: once a close begins it refuses every write; the first `failures` + * closes come back unproven. */ +function closeUnprovenFor(connection: FakeConnection, failures: number): void { + const close = connection.close + let left = failures + connection.close = async () => { + if (left === 0) { + return close() + } + left -= 1 + connection.closeCount += 1 + connection.closed = true + return false + } + const write = connection.send + connection.send = (message, beforeDispatch) => + connection.closed + ? Promise.reject( + claudeUnwrittenUserMessageError(new Error('claude stream-json connection is closed')) + ) + : write(message, beforeDispatch) +} + +/** A turn the CLI opened and is writing, whose Stop's close comes back unproven once. */ +async function stoppedWithUnprovenClose(): Promise { + const connection = claude.connections[0]! + const working = await send('Write a long reply.') + await eventually(() => expect(written(connection, 'Write a long reply.')).toBeDefined()) + frame(connection, { + type: 'system', + subtype: 'init', + uuid: 'init-1', + model: 'claude-sonnet-5', + capabilities: CAPABILITIES + }) + frame(connection, { ...written(connection, 'Write a long reply.')! }) + frame(connection, { + type: 'assistant', + uuid: 'stopped-turn-leaf', + parent_tool_use_id: null, + message: { id: 'msg-1', role: 'assistant', content: [{ type: 'text', text: 'Working on' }] } + }) + await eventually(async () => + expect( + (await snapshot()).submissions.find((entry) => entry.clientMessageId === working) + ?.dispatchState + ).toBe('accepted') + ) + closeUnprovenFor(connection, 1) + const stopped = await host.cancel(CALLER, { + envelope: { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: store.getRecord(SESSION)!.lease.runtimeFence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.cancel', + sessionId: SESSION, + fields: {} + }) + } + }) + expect(stopped).toMatchObject({ ok: true }) + frame(connection, { + type: 'result', + subtype: 'error_during_execution', + is_error: true, + terminal_reason: 'aborted_streaming', + uuid: 'interrupted-result' + }) + await eventually(() => expect(connection.closeCount).toBe(1)) + return connection +} + +// The first message joins the close the Stop could not prove and goes to the resumed child; one +// sent before that child echoes it waits for the echo, as any follow-up waits for its turn. +it('sends a message that joins an unproven Stop close to the resumed child, and holds the next until its echo', async () => { + const connection = await stoppedWithUnprovenClose() + + await send('Carry on.') + const resumed = await eventually(() => { + const started = claude.connections.at(-1)! + expect(started).not.toBe(connection) + expect(written(started, 'Carry on.')).toBeDefined() + return started + }) + const later = await send('And this.') + await eventually(() => + expect(host.collaboratorsForTests().conversationDelivery.loop.isRunning(SESSION)).toBe(false) + ) + expect(written(connection, 'Carry on.')).toBeUndefined() + expect(written(resumed, 'And this.')).toBeUndefined() + expect( + (await snapshot()).submissions.find((entry) => entry.clientMessageId === later)?.handedOverAt + ).toBeUndefined() + + frame(resumed, { + type: 'system', + subtype: 'init', + uuid: 'init-resumed', + model: 'claude-sonnet-5', + capabilities: CAPABILITIES + }) + frame(resumed, { ...written(resumed, 'Carry on.')!, isReplay: true, parent_tool_use_id: null }) + await eventually(() => expect(written(resumed, 'And this.')).toBeDefined()) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts index 6d4952670a45..e6e2524841ef 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts @@ -14,12 +14,14 @@ import { projectStructuredAgentSessionStatus } from '../../../shared/structured- import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' import { fakeClaude, - PROVIDER_SESSION_ID + PROVIDER_SESSION_ID, + claudeStartupSettled } from '../../claude/claude-structured-session-test-support' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { structuredClaudeLifecycleEvent } from '../../runtime/structured-claude-runtime-adapter' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { holdDelivery } from './structured-agent-session-delivery-hold.test-fixture' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { HOST_TEST_NOW as NOW, @@ -100,7 +102,7 @@ beforeEach(async () => { providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } }) expect(await host.attach(CALLER, params)).toMatchObject({ ok: true }) - await adapter.awaitStarted(SESSION) + await claudeStartupSettled(adapter, SESSION) await Promise.all(lifecycle) }) @@ -253,22 +255,19 @@ it('withdraws a follow-up still queued on the host when the turn the Stop names const connection = claude.connections[0]! const turnId = await openFirstTurn(connection) await endFirstTurn(connection) - // Holds the delivery loop between its start check and the handover, with the follow-up queued. - let release!: () => void - const held = new Promise((resolve) => (release = resolve)) - const awaitStarted = vi.spyOn(adapter, 'awaitStarted').mockImplementationOnce(async () => { - await held - }) + // Holds the delivery loop ahead of the handover, with the follow-up queued. + const { held, release } = holdDelivery() const followUp = await send('And then this.') - await eventually(() => expect(awaitStarted).toHaveBeenCalled()) + await held const submission = (await host.journalSnapshot(SESSION)).submissions.find( (entry) => entry.clientMessageId === followUp ) expect(submission && isQueuedAgentJournalSubmission(submission)).toBe(true) // Nothing reached Claude, so the host's withdrawal is the whole Stop, as with no turn named. - expect(await stop(turnId)).toMatchObject({ ok: true, value: { cancelled: true } }) + const stopped = stop(turnId) release() + expect(await stopped).toMatchObject({ ok: true, value: { cancelled: true } }) await eventually(async () => expect(await dispatch(followUp)).toEqual({ state: 'rejected', @@ -297,16 +296,13 @@ it('withdraws a host-queued follow-up but leaves a newer turn running when the S const newerTurnId = await liveTurnId() expect(newerTurnId).not.toBeNull() expect(newerTurnId).not.toBe(olderTurnId) - let release!: () => void - const held = new Promise((resolve) => (release = resolve)) - const awaitStarted = vi.spyOn(adapter, 'awaitStarted').mockImplementationOnce(async () => { - await held - }) + const { held, release } = holdDelivery() const followUp = await send('And then this.') - await eventually(() => expect(awaitStarted).toHaveBeenCalled()) + await held - expect(await stop(olderTurnId)).toMatchObject({ ok: true, value: { cancelled: false } }) + const stopped = stop(olderTurnId) release() + expect(await stopped).toMatchObject({ ok: true, value: { cancelled: false } }) await eventually(async () => expect(await dispatch(followUp)).toEqual({ state: 'rejected', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts index ed06f809c6d8..9e55397a38f5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts @@ -133,7 +133,7 @@ beforeEach(async () => { providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } }) expect(await host.attach(CALLER, params)).toMatchObject({ ok: true }) - await adapter.awaitStarted(SESSION) + await adapter['sessions'].get(SESSION)?.startup.settled await Promise.all(lifecycle) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts index afb4ac1c1c11..00a0644136c3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts @@ -13,7 +13,8 @@ import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-s import { fakeClaude, PROVIDER_SESSION_ID, - type FakeConnection + type FakeConnection, + claudeStartupSettled } from '../../claude/claude-structured-session-test-support' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' @@ -102,7 +103,7 @@ beforeEach(async () => { providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } }) expect(await host.attach(CALLER, params)).toMatchObject({ ok: true }) - await adapter.awaitStarted(SESSION) + await claudeStartupSettled(adapter, SESSION) await Promise.all(lifecycle) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-note-end.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-note-end.test.ts index cd7ccb1cab4b..7b005ffb8176 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-note-end.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-note-end.test.ts @@ -12,6 +12,7 @@ import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-a import { claudeUnwrittenUserMessageError } from '../../claude/claude-agent-sdk-user-message-queue' import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' import { + claudeStartupSettled, fakeClaude, PROVIDER_SESSION_ID, type FakeConnection @@ -108,7 +109,7 @@ beforeEach(async () => { providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } }) expect(await host.attach(CALLER, params)).toMatchObject({ ok: true }) - await adapter.awaitStarted(SESSION) + await claudeStartupSettled(adapter, SESSION) await Promise.all(lifecycle) await host.subscribe({ id: 'stop-note-live', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-turn-end.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-turn-end.test.ts index 6107c402b889..6221c2ae8858 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-turn-end.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-turn-end.test.ts @@ -14,7 +14,8 @@ import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-s import { fakeClaude, PROVIDER_SESSION_ID, - type FakeConnection + type FakeConnection, + claudeStartupSettled } from '../../claude/claude-structured-session-test-support' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' @@ -87,7 +88,7 @@ beforeEach(async () => { providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } }) expect(await host.attach(CALLER, params)).toMatchObject({ ok: true }) - await adapter.awaitStarted(SESSION) + await claudeStartupSettled(adapter, SESSION) await Promise.all(lifecycle) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts index 9c0c5b58b7bc..c369ce1a5602 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts @@ -15,10 +15,8 @@ import type { } from './structured-agent-session-host-types' import { AGENT_SESSION_NOT_ATTACHED } from './structured-agent-session-mutation-admission' import { StructuredAgentSessionSendSettlement } from './structured-agent-session-send-settlement' -import { - createStructuredAgentSessionHostStatusFeed, - type StructuredAgentSessionStatusSubscriber -} from './structured-agent-session-status-feed' +import type { StructuredAgentSessionStatusSubscriber } from './structured-agent-session-status-feed' +import { createStructuredAgentSessionHostStatusFeed } from './structured-agent-session-host-status-feed' import { StructuredAgentSessionTurnCompletionFeed, type StructuredAgentSessionTurnCompletionSubscriber @@ -93,6 +91,9 @@ export class StructuredAgentSessionClientDelivery { publishStatus = (sessionId: string): void => this.statusFeed.publish(sessionId) + publishConversationName = (sessionId: string): void => + this.statusFeed.publishConversationName(sessionId) + publishChildWork = (sessionId: string, evidence: AgentChildWorkEvidence[]): void => this.statusFeed.publishChildWork(sessionId, evidence) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-close-aborts-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-close-aborts-start.test.ts new file mode 100644 index 000000000000..2823e9c5d651 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-close-aborts-start.test.ts @@ -0,0 +1,74 @@ +// A close must not wait behind a start the provider never answers: the start runs under the +// session's queue, and the close's own stop is queued behind it. The close aborts the start's +// signal instead, at whatever phase the attach is in. + +import { expect, it, vi } from 'vitest' +import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + adapter, + attachParams, + CALLER, + hostTestState, + replaceHostTestState +} from './structured-agent-session-host-test-harness' +import { + HOST_TEST_NOW, + HOST_TEST_SESSION as SESSION +} from './structured-agent-session-host-test-data' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' + +function openHost(state: ReturnType): StructuredAgentSessionHost { + const host = new StructuredAgentSessionHost({ + agents: claudeAndCodexDeclared(), + logger: createStructuredAgentSessionLogger(), + store: state.store, + adapter: adapter(), + journalDatabase: openTestJournalHostDatabase(state.root), + recoveryCapsule: new AgentSessionRecoveryCapsule(state.root), + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + now: () => HOST_TEST_NOW + }) + replaceHostTestState({ store: state.store, host }) + return host +} + +it('a close stops a start the provider never answers instead of queueing behind it', async () => { + const state = hostTestState() + let signal: AbortSignal | undefined + // A start the provider never answers ends only when its acquire is aborted. + state.acquire.mockImplementation( + (input) => + new Promise((_resolve, reject) => { + signal = input.signal + input.signal?.addEventListener('abort', () => reject(new Error('closed while starting'))) + }) + ) + const host = openHost(state) + const attaching = host.attach(CALLER, attachParams()) + await vi.waitFor(() => expect(state.acquire).toHaveBeenCalled()) + await host.close(SESSION, 'user-close') + expect(signal?.aborted).toBe(true) + expect((await attaching).ok).toBe(false) +}) + +it('a close while the attach still probes the previous owner asks no adapter to start', async () => { + const state = hostTestState() + // An adapter that never reads the signal: only the host's own check keeps it from starting. + const host = openHost(state) + const runtimeState = host.collaboratorsForTests().runtimeState + const probe = runtimeState.probeOwner.bind(runtimeState) + let closing: Promise | undefined + runtimeState.probeOwner = async (sessionId) => { + closing ??= host.close(SESSION, 'user-close') + return probe(sessionId) + } + expect(await host.attach(CALLER, attachParams()).catch((error: unknown) => error)).toMatchObject({ + name: 'AgentSessionPreSpawnError' + }) + await closing + expect(state.acquire).not.toHaveBeenCalled() +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts index 7ee9d070f4c9..f6d7d6d4be7e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts @@ -19,6 +19,7 @@ import { codexTurnLifecycleFake } from '../../codex/codex-turn-lifecycle-fake' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' import { HOST_TEST_NOW as NOW, HOST_TEST_SESSION as SESSION, @@ -28,7 +29,6 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' -import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-1' } 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 e51b8e672f96..06b18e244a5e 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 @@ -18,8 +18,7 @@ import { } from '../../../shared/agent-session-failure-words' import { agentJournalItemKey, - agentJournalSubmissionKey, - parseAgentJournalItemKey + agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' import { AGENT_JOURNAL_THREAD_SCOPE, @@ -36,6 +35,10 @@ import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' +import { + isStructuredAgentSessionStopNote, + structuredAgentSessionStopNoteIdentity +} from '../../../shared/structured-agent-session-stop-note-key' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { AgentSessionCommandAdmission, @@ -119,18 +122,7 @@ export function isStructuredAgentSessionCommandTurnId(turnId: string): boolean { return turnId.startsWith('compact:') } -const STOP_NOTE_PREFIX = 'stop:' - -/** A Stop's note, keyed by the turn it stopped (or, with no turn, by its operation). */ -export function structuredAgentSessionStopNoteIdentity(stopKey: string): AgentJournalItemIdentity { - return { provider: 'orca', clientMessageId: `${STOP_NOTE_PREFIX}${stopKey}` } -} - -/** Whether a journal row is a Stop's note. */ -export function isStructuredAgentSessionStopNote(itemId: string): boolean { - const identity = parseAgentJournalItemKey(itemId) - return identity?.provider === 'orca' && identity.clientMessageId.startsWith(STOP_NOTE_PREFIX) -} +export { isStructuredAgentSessionStopNote, structuredAgentSessionStopNoteIdentity } /** Whether an earlier Stop already asked the running command `turnId` names to end. Read from the * journal, so nothing is held that could outlive the command. */ @@ -210,8 +202,8 @@ export async function handOverStructuredAgentSessionCommand( command: { clientMessageId, ...turn, running } }) } catch (error) { - // A child that had not proven its start took nothing, so the command provably did not run. Any - // other throw is a lost reply: the command may have run. + // 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' ? { 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 0133fbc9fedd..e19127fde7cd 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 @@ -2,7 +2,6 @@ // list and a send each see only the one that happened. Ticks are driven by hand. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' import { readStructuredSessionGateFacts } from '../../runtime/orchestration/structured-mailbox-pointer-host' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' @@ -222,38 +221,22 @@ describe('the sweep and the lease (P2-20)', () => { }) describe('a start that never finishes (P2-15)', () => { - it('is stopped by the sweep, and the delivery loop alone writes its one row and rejection', async () => { + it('is stopped by the sweep, and the message it was handed carries its one row and rejection', async () => { const stopReason = 'Codex never finished starting, so Orca stopped it.' - const order: string[] = [] - const started = Promise.withResolvers() - rig.adapter.closeSession.mockImplementation(async () => { - order.push('stopped') - started.resolve() - return true - }) const spawn = rig.adapter.acquire.getMockImplementation()! rig.adapter.acquire.mockImplementationOnce(async (input) => ({ ...(await spawn(input)), providerChildPhase: 'starting' as const })) - Object.assign(rig.host.deps.adapter, { awaitStarted: () => started.promise }) - // The loop rejects the queued messages in the same append as its row. - const append = AgentSessionJournal.prototype.appendLifecycleBatch - vi.spyOn(AgentSessionJournal.prototype, 'appendLifecycleBatch').mockImplementation(function ( - this: AgentSessionJournal, - ...args - ) { - if (args[0].rejectsQueued) { - order.push(`rejected: ${args[0].rejectsQueued.reason}`) - } - return append.apply(this, args) - }) + // Written to the starting child at once; it never answers. + rig.adapter.dispatch.mockResolvedValueOnce({ state: 'admitted' }) 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()) rig.clock.now += IDLE_MS + 1 await sweepOnce(rig.host) @@ -263,8 +246,6 @@ describe('a start that never finishes (P2-15)', () => { expect.objectContaining({ dispatchState: 'rejected', reason: stopReason }) ) ) - // One rejection, written after the stop by the loop, with the stop's own words. - expect(order).toEqual(['stopped', `rejected: ${stopReason}`]) const errorRows = reader.events.flatMap((event) => event.type === 'batch' ? event.batch.items.filter( @@ -275,11 +256,10 @@ describe('a start that never finishes (P2-15)', () => { expect(errorRows.map((item) => (item.body.kind === 'status' ? item.body.text : null))).toEqual([ stopReason ]) - expect(rig.adapter.dispatch).not.toHaveBeenCalled() const again = await rig.host.send(CALLER, restTestSend('try again', fence())) expect(again.ok).toBe(true) - await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledOnce(), COLD_START) + await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(2), 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 4033c70de1c3..f60b2f295834 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 @@ -49,11 +49,6 @@ export function createStructuredAgentSessionConversationLifetime(host: { const readRefusals = createJournalOpenReadRefusals( deferredStructuredAgentSessionLogger(() => deps().logger) ) - // The owed copy fails as an open does: the reader gets the classified refusal, never its text. - const whenImported = (sessionId: string, session: StructuredAgentSessionHostSession) => - session.journal.whenImported().catch((error: unknown) => { - throw readRefusals.refusal(sessionId, error) - }) const stopAgent = (sessionId: string, ending: StructuredAgentSessionStopEnding) => stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId, ending) @@ -89,8 +84,9 @@ export function createStructuredAgentSessionConversationLifetime(host: { providerHoldsDispatch: (sessionId) => deps().adapter.holdsDispatch?.(sessionId) === true, // The host puts an idle agent to rest: a turn it cuts short is news, not the user's Stop. stopAgent: (sessionId) => stopAgent(sessionId, { cause: 'evict', resting: true }), - // A host stop: the delivery loop waiting on this child writes the one error row and rejects - // what is queued with it, both worded from the hostStopped fact. + // A host stop: the child's end rejects what it was handed and writes the one error row, and + // the delivery loop rejects what is still queued under the same row, all worded from the + // hostStopped fact. stopStartingAgent: (sessionId) => stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId, { cause: 'host-stop' @@ -117,11 +113,6 @@ export function createStructuredAgentSessionConversationLifetime(host: { conversation: async (sessionId: string): Promise => { const open = sessions.get(sessionId) if (open) { - // Restore left its per-chat file uncopied; a reader gets the chat from the one database. - // Awaited only then: an open conversation otherwise answers in the same turn. - if (open.journal.importPending) { - await whenImported(sessionId, open) - } readRefusals.forget(sessionId) return open } @@ -144,18 +135,16 @@ export function createStructuredAgentSessionConversationLifetime(host: { reason: 'recordMissing' }) } - // Restore may have opened it while this waited. - if (session.journal.importPending) { - await whenImported(sessionId, session) - } readRefusals.forget(sessionId) return session }) }, /** Ends a chat's resources, not the chat: its record and journal stay on disk, and what is * still queued will not be sent; a person's message stays as a held card. */ - close: (sessionId: string, cause: StructuredAgentSessionCloseCause): Promise => - serialize(sessionId, async () => { + close: (sessionId: string, cause: StructuredAgentSessionCloseCause): Promise => { + // Outside the queue: a start the provider never answers must not hold the close behind it. + host.context().runtimeState.acquireAborts.abort(sessionId, 'closed while starting') + return serialize(sessionId, async () => { readRefusals.forget(sessionId) const session = sessions.get(sessionId) if (session) { @@ -165,5 +154,6 @@ export function createStructuredAgentSessionConversationLifetime(host: { await stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId, { cause }) await closeConversation(sessionId) }) + } } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts index 9c556916a39b..aa1a5c3d6191 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts @@ -43,8 +43,6 @@ export type StructuredAgentSessionConversationOpenDeps = { * what the gone generation left running itself, from what it read before. */ export type StructuredAgentSessionConversationOpenOptions = { acquisition?: boolean - /** A restore's open, which copies no per-chat file: see `AgentSessionJournal.whenImported`. */ - deferPerSessionImport?: boolean } export type StructuredAgentSessionConversationOpenContext = { @@ -90,11 +88,7 @@ export async function openStructuredAgentSessionConversationJournal( expectedRuntimeFence: fence }) const identity = journalIdentityFor(record, params) - const journal = await openAgentSessionJournal({ - identity, - database: deps.journalDatabase, - deferPerSessionImport: options.deferPerSessionImport - }) + const journal = await openAgentSessionJournal({ identity, database: deps.journalDatabase }) try { // A handed-over row found here is only doubt, which provider history decides under a won lease. await journal.markPendingSubmissionsUnknown(fence) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts index cb3a1bbe5838..a44e6b7c0caa 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts @@ -16,6 +16,8 @@ import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-rec import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { isStructuredAgentSessionStopNote } from './structured-agent-session-command-turn' +import { holdLane } from './structured-agent-session-delivery-hold.test-fixture' import { HOST_TEST_NOW as NOW, HOST_TEST_SESSION as SESSION, @@ -46,7 +48,6 @@ let store: AgentSessionRecordStore let host: StructuredAgentSessionHost let dispatch: Mock let cancelTurn: Mock -let awaitStarted: Mock> let closeSession: Mock> let setOption: Mock let acknowledgeSessionRelease: Mock< @@ -64,10 +65,10 @@ function eventually(assertion: () => void | Promise): Promise { beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-conversation-stop-')) resetHostTestOperationIds() + events = undefined // Admitted, not accepted: the message is written and its turn has not opened. dispatch = vi.fn(async () => ({ state: 'admitted' as const })) cancelTurn = vi.fn(async () => ({ cancelled: true })) - awaitStarted = vi.fn(async () => undefined) closeSession = vi.fn(async () => true) setOption = vi.fn(async () => undefined) acknowledgeSessionRelease = vi.fn() @@ -99,7 +100,6 @@ beforeEach(async () => { } }, dispatch, - awaitStarted, closeSession, acknowledgeSessionRelease, releaseAcquisition: vi.fn(async () => true), @@ -121,6 +121,14 @@ afterEach(async () => { await rm(root, { recursive: true, force: true }) }) +/** The event sink the host handed this test's child at attach. */ +function hostEvents(): StructuredAgentSessionEventSink { + if (!events) { + throw new Error('the host attached no event sink') + } + return events +} + function send(text: string) { const body = hostTestMessage(text) const clientOperationId = hostTestOperationId() @@ -199,7 +207,9 @@ describe('a Stop that names no turn', () => { identity: identityFor(SESSION), fence: 1, spawnToken: 'spawn-catalog', - options: { fastMode: 'true' } + options: { fastMode: 'true' }, + // Codex's turn events reach the host's journal, as the runtime wires them. + events: hostEvents() }) dispatch.mockImplementation((input) => adapter.dispatch(input)) cancelTurn.mockImplementation((input) => adapter.cancelTurn(input)) @@ -304,6 +314,30 @@ describe('a Stop that names no turn', () => { expect(dispatch.mock.calls[1]![0].clientMessageId).toBe(replacement.id) }) + // Codex can take a Stop as the turn starts, with that turn's start still buffered when the answer + // lands. A bound sink hands each event to the journal as it is read, so the note lands after it. + it("writes a taken Stop's note after the turn record the provider sent before its answer", async () => { + const first = send('first send') + await first.result + await eventually(() => expect(dispatch).toHaveBeenCalledOnce()) + cancelTurn.mockImplementationOnce(async () => { + events?.appendItem( + { provider: 'legacy', agent: 'codex', sessionId: SESSION, recordId: 'turn:turn-1' }, + { kind: 'turn', turnId: 'turn-1', state: 'running' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + return { cancelled: true, turnId: 'turn-1' } + }) + + expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + + const items = (await host.journalSnapshot(SESSION)).items + const record = items.find((item) => item.body.kind === 'turn') + const note = items.find((item) => isStructuredAgentSessionStopNote(item.itemId)) + expect(note?.sequence).toBeGreaterThan(record?.sequence ?? Infinity) + expect(note?.turnScope).toEqual({ kind: 'turn', turnItemId: record?.itemId }) + }) + it('is a valid cancel, and only a plain Stop may omit the turn', () => { const envelope = { sessionId: SESSION, @@ -343,14 +377,14 @@ describe('a Stop that names no turn', () => { }) it('withdraws what is queued on a ready child and asks the provider for nothing more', async () => { - const started = Promise.withResolvers() - awaitStarted.mockImplementationOnce(() => started.promise) + // Held, the send is accepted and the Stop runs next, ahead of the handover the send asks for. + const release = holdLane(host, SESSION) const { id, result } = send('hello') + const stopped = stop() + release() await result - await eventually(() => expect(awaitStarted).toHaveBeenCalled()) - expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) - started.resolve(undefined) + expect(await stopped).toMatchObject({ ok: true, value: { cancelled: true } }) expect(await submission(id)).toMatchObject({ dispatchState: 'rejected', @@ -570,21 +604,20 @@ describe('a Stop that names its turn, as an older client sends it', () => { expect(await statusRows()).toEqual([]) }) - async function queueOnHost(): Promise<{ id: string; release: () => void }> { - const started = Promise.withResolvers() - awaitStarted.mockImplementationOnce(() => started.promise) - const { id, result } = send('hello') - await result - await eventually(() => expect(awaitStarted).toHaveBeenCalled()) - return { id, release: () => started.resolve(undefined) } + /** A send queued on the host and not yet handed over: the lane is held until `release`, so a + * step asked for before it runs ahead of the handover. */ + function queueOnHost(): { id: string; release: () => void } { + const release = holdLane(host, SESSION) + return { id: send('hello').id, release } } it('reports success with no row when it withdrew a queued message and the turn had ended', async () => { - const queued = await queueOnHost() + const queued = queueOnHost() cancelTurn.mockResolvedValueOnce({ cancelled: false }) - expect(await stop('turn-1')).toMatchObject({ ok: true, value: { cancelled: true } }) + const stopped = stop('turn-1') queued.release() + expect(await stopped).toMatchObject({ ok: true, value: { cancelled: true } }) expect(await submission(queued.id)).toMatchObject({ dispatchState: 'rejected' }) expect(await statusRows()).toEqual([]) @@ -592,14 +625,15 @@ describe('a Stop that names its turn, as an older client sends it', () => { // Codex refuses an interrupt for a turn that has ended. it('reports success with no row when the provider refused a turn that had ended', async () => { - const queued = await queueOnHost() + const queued = queueOnHost() cancelTurn.mockResolvedValueOnce({ cancelled: false, refusal: { detail: { text: 'no such turn', audience: 'person' } } }) - expect(await stop('turn-1')).toMatchObject({ ok: true, value: { cancelled: true } }) + const stopped = stop('turn-1') queued.release() + expect(await stopped).toMatchObject({ ok: true, value: { cancelled: true } }) expect(await submission(queued.id)).toMatchObject({ dispatchState: 'rejected' }) expect(await statusRows()).toEqual([]) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts index 1df56dc5228a..18d76e5cd093 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts @@ -11,11 +11,11 @@ import { providerDiagnostic } from '../../../shared/agent-session-failure' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { settleStructuredAgentSessionDeadGeneration } from './structured-agent-session-dead-generation-settlement' import { captureUnfinishedStructuredAgentSessionWork, - settleStructuredAgentSessionDeadGeneration, unfinishedStructuredAgentSessionWorkWasInterrupted -} from './structured-agent-session-dead-generation-settlement' +} from './structured-agent-session-unfinished-work' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' @@ -248,7 +248,9 @@ describe('dead structured-session generation settlement', () => { | 'rejectPendingSubmissions' | 'rejectQueuedSubmissions' | 'appendLifecycleBatch' + | 'itemFence' > = { + itemFence: () => undefined, snapshot: () => ({ ...settledSnapshot, items: [settledItem] diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts index 071de4b41571..ce11c2dc34c9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts @@ -3,19 +3,21 @@ import { MAX_PROVIDER_DIAGNOSTIC_CHARS, type SubmissionRejectionFact } from '../../../shared/agent-session-failure' -import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { + agentJournalItemKey, + parseAgentJournalItemKey +} from '../../../shared/agent-session-journal-item-key' import { STALE_SESSION_ROW_PREFIX } from '../../../shared/agent-session-stop-row-identity' import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import { AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemBody, type AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' -import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import { partitionJournalLifecycleMutations } from '../agent-session-journal/journal-lifecycle-batch-partition' import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' import { endedUnseenMessageBody, - requiresTerminalSettlement, runningCallEnd, terminalAgentJournalBody } from '../agent-session-journal/journal-terminal-settlement' @@ -37,76 +39,25 @@ import { runningTurnLifecycleRevisions, stopFoundTurnLiveAt, turnVerdictFromDeathEvidence, - type StructuredAgentSessionTurnVerdict + watchedExitRevisions, + type StructuredAgentSessionTurnVerdict, + type StructuredAgentSessionWatchedExit } from './structured-agent-session-stale-turn-verdict' import { exitedRootTurnScope, runningRootTurnScope } from './structured-agent-session-exit-turn-scope' import { - withdrawCodexSendsNoTurnOpenedFor, - type UnopenedSendJournal -} from './structured-agent-session-unopened-send-withdrawal' + hasUnfinishedStructuredAgentSessionWork, + isInProgressStructuredAgentSessionItem, + type DeadGenerationJournal +} from './structured-agent-session-unfinished-work' +import { withdrawCodexSendsNoTurnOpenedFor } from './structured-agent-session-unopened-send-withdrawal' /** Bounds the exit reason the lease keeps as log evidence; a provider diagnostic is held to the * same cap. */ export const MAX_UNEXPECTED_EXIT_REASON_CHARS = MAX_PROVIDER_DIAGNOSTIC_CHARS -export type DeadGenerationJournal = UnopenedSendJournal & { - appendLifecycleBatch: AgentSessionJournal['appendLifecycleBatch'] - markPendingSubmissionsUnknown: AgentSessionJournal['markPendingSubmissionsUnknown'] - rejectPendingSubmissions: AgentSessionJournal['rejectPendingSubmissions'] - pendingSubmissions?: AgentSessionJournal['pendingSubmissions'] -} - -export type StructuredAgentSessionUnfinishedWork = { - items: AgentJournalRenderItem[] - hadUnsettledSubmissions: boolean -} - -export function captureUnfinishedStructuredAgentSessionWork( - journal: DeadGenerationJournal -): StructuredAgentSessionUnfinishedWork { - return { - items: journal.snapshot().items.filter(isUnfinishedItem), - hadUnsettledSubmissions: hasUnsettledSubmission(journal) - } -} - -function hasUnfinishedStructuredAgentSessionWork(journal: DeadGenerationJournal): boolean { - const work = captureUnfinishedStructuredAgentSessionWork(journal) - return work.hadUnsettledSubmissions || work.items.length > 0 -} - -export function unfinishedStructuredAgentSessionWorkWasInterrupted( - before: StructuredAgentSessionUnfinishedWork, - journal: DeadGenerationJournal, - observedExitAt: number -): boolean { - const currentSnapshot = journal.snapshot() - if (hasUnsettledSubmission(journal) || currentSnapshot.items.some(isInProgressItem)) { - return true - } - if ( - currentSnapshot.items.some((item) => { - const turn = readAgentJournalTurn(item.body) - return turn?.state === 'interrupted' && turn.completedAt === observedExitAt - }) - ) { - return true - } - const inProgressBefore = before.items.filter(isInProgressItem) - if (inProgressBefore.length === 0) { - return false - } - const currentItems = new Map(currentSnapshot.items.map((item) => [item.itemId, item])) - const runningTurns = inProgressBefore.filter( - (item) => readAgentJournalTurn(item.body)?.state === 'running' - ) - const outcomeItems = runningTurns.length > 0 ? runningTurns : inProgressBefore - return outcomeItems.some((item) => !isCleanlySettled(currentItems.get(item.itemId))) -} - /** Whether the settlement was written, and what stopped it when it was not. */ export type StructuredAgentSessionDeadGenerationSettlement = | { ok: true } @@ -127,27 +78,36 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { /** The provider never finished starting: the start that failed, keyed by the child's * generation. Its row is the one the delivery loop writes for the same start. */ exitedDuringStartup?: { generation: string | null } + /** The exit, watched: what that child's own translator could only end `unverifiable` (its stream + * closed before the exit was proven) is revised in this batch. */ + exit?: StructuredAgentSessionWatchedExit + /** A person's Stop ended a starting child before what it was handed could run: each is rejected so. */ + unrunRejection?: SubmissionRejectionFact }): Promise { try { - const hasUnfinishedWork = hasUnfinishedStructuredAgentSessionWork(input.journal) + const hasUnfinishedWork = hasUnfinishedStructuredAgentSessionWork(input.journal, input.exit) const showUnexpectedExitOutcome = input.showUnexpectedExitOutcome ?? hasUnfinishedWork if (!showUnexpectedExitOutcome && !hasUnfinishedWork) { return { ok: true } } - // A queued message is the delivery loop's to settle: it was never handed to this child. A - // child that never proved its start accepted nothing either — input is written only after it - // initializes — so every send it was handed is rejected with the child's own diagnostic. A - // proven child's handed-over sends stay in doubt. + // A queued message is the delivery loop's to settle: it was never handed to this child. A send + // a child still starting was handed and never echoed did not run, and its root is gone: it is + // rejected, with the child's own diagnostic or as the Stop that ended it. A proven child's + // handed-over sends stay in doubt. const startupFailure = input.exitedDuringStartup ? structuredAgentSessionStartFailure({ exit: input.exitFailure }, input.failureTextContext) : null - if (!startupFailure) { + const closed = input.unrunRejection + const unrun = + startupFailure ?? (closed && agentSessionFailureWords(closed, { surface: 'rejection' })) + if (!unrun) { await withdrawCodexSendsNoTurnOpenedFor(input.journal, input.fence) } - await (startupFailure - ? input.journal.rejectPendingSubmissions(input.fence, startupFailure) + await (unrun + ? input.journal.rejectPendingSubmissions(input.fence, unrun) : input.journal.markPendingSubmissionsUnknown(input.fence, input.pendingSubmissionReason)) const items = input.journal.snapshot().items + const proven = watchedExitRevisions(items, input.exit, input.journal) const mutations: JournalLifecycleMutationInput[] = [] if (showUnexpectedExitOutcome && input.exitedDuringStartup && startupFailure) { const startKey = input.exitedDuringStartup.generation ?? input.settlementId @@ -176,7 +136,7 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { ), tone: 'error' }, - turnScope: exitedRootTurnScope(items, input.verdict) + turnScope: exitedRootTurnScope(withRevisions(items, proven), input.verdict) }) } const bodies = new Map(items.map((item) => [item.itemId, item.body])) @@ -195,8 +155,7 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { }) } } - const turnEnds = runningTurnLifecycleRevisions(items, input.verdict) - mutations.push(...turnEnds) + mutations.push(...runningTurnLifecycleRevisions(items, input.verdict), ...proven) const batchId = `dead-generation:${input.settlementId}` for (const chunk of partitionJournalLifecycleMutations(batchId, mutations)) { await input.journal.appendLifecycleBatch({ @@ -269,7 +228,10 @@ export async function settleStaleStructuredAgentSessionState(input: { if ( evidence && (proven.length > 0 || - items.some((item) => isInProgressItem(item) && verdictFor(item).state === 'interrupted')) && + items.some( + (item) => + isInProgressStructuredAgentSessionItem(item) && verdictFor(item).state === 'interrupted' + )) && !endedByPersonsStop(journal, turnEnds) ) { mutations.unshift({ @@ -303,43 +265,14 @@ export async function settleStaleStructuredAgentSessionState(input: { return mutations.length } -function isUnfinishedItem(item: AgentJournalRenderItem): boolean { - return requiresTerminalSettlement(item.body) -} - -/** Work that means the provider was MID-RESPONSE. A pending approval or question is the provider - * waiting on the user, so dying while one sits there interrupted nothing — it still needs - * cancelling, but it must not claim a response was in progress. */ -function isInProgressItem(item: AgentJournalRenderItem): boolean { - return ( - readAgentJournalTurn(item.body)?.state === 'running' || - (item.body.kind === 'tool-call' && item.body.state === 'running') +function withRevisions( + items: readonly AgentJournalRenderItem[], + revisions: readonly JournalLifecycleMutationInput[] +): AgentJournalRenderItem[] { + const bodies = new Map( + revisions.flatMap((revision): [string, AgentJournalItemBody][] => + revision.kind === 'item' ? [[agentJournalItemKey(revision.identity), revision.body]] : [] + ) ) -} - -function isCleanlySettled(item: AgentJournalRenderItem | undefined): boolean { - const turn = readAgentJournalTurn(item?.body) - if (turn) { - return turn.state === 'completed' - } - if (item?.body.kind === 'tool-call') { - return item.body.state === 'completed' - } - if (item?.body.kind === 'approval' || item?.body.kind === 'question') { - return item.body.resolution.state === 'resolved' - } - return false -} - -function hasUnsettledSubmission(journal: DeadGenerationJournal): boolean { - const submissions = journal.submissions?.() - return submissions - ? submissions.some( - (submission) => - // A queued message is not work in progress: nothing has it yet. - (submission.dispatchState === 'pending' && - !(submission.handoverRecorded && submission.handedOverAt === undefined)) || - (submission.dispatchState === 'unknown' && submission.recovered !== true) - ) - : (journal.pendingSubmissions?.().length ?? 0) > 0 + return items.map((item) => ({ ...item, body: bodies.get(item.itemId) ?? item.body })) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-hold.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-hold.test-fixture.ts new file mode 100644 index 000000000000..33f0f6a20594 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-hold.test-fixture.ts @@ -0,0 +1,37 @@ +// Ways a test holds a session's work at a chosen step, so a Stop or close asked for meanwhile runs +// in a known order against the delivery loop's handover. + +import { vi } from 'vitest' +import { StructuredAgentSessionDeliveryLoop } from './structured-agent-session-delivery-loop' +import type { StructuredAgentSessionHost } from './structured-agent-session-host' + +/** Holds the delivery loop's next step as it begins, inside the session's lane, until `release`: + * a Stop or close asked for meanwhile runs ahead of the handover. `held` resolves once the step + * has reached the hold. */ +export function holdDelivery(): { held: Promise; release: () => void } { + const gate = Promise.withResolvers() + const reached = Promise.withResolvers() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: `prepare` is a prototype method whose privacy is compile-time only; the spy calls it unchanged. + const loop = StructuredAgentSessionDeliveryLoop.prototype as unknown as { + prepare: (...args: unknown[]) => Promise + } + const prepare = loop.prepare + const step = vi.spyOn(loop, 'prepare').mockImplementation(async function ( + this: unknown, + ...args: unknown[] + ) { + step.mockRestore() + reached.resolve() + await gate.promise + return prepare.apply(this, args) + }) + return { held: reached.promise, release: () => gate.resolve() } +} + +/** Holds the session's lane until the returned release: whatever is asked for meanwhile runs in + * the order it was asked, ahead of a handover the delivery loop asks for after it. */ +export function holdLane(host: StructuredAgentSessionHost, sessionId: string): () => void { + const held = Promise.withResolvers() + void host['tasks'].serialize(sessionId, () => held.promise) + return () => held.resolve() +} 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 5ba5e8f95392..a7989f940ef2 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 @@ -15,10 +15,7 @@ import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' -import { - agentSessionFailureFact, - type SubmissionRejectionFact -} from '../../../shared/agent-session-failure' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import type { AgentSessionFailureWordsContext } from '../../../shared/agent-session-failure-words' import { holdUnsentSends } from '../agent-session-journal/journal-unsent-send-hold' import { structuredAgentSessionHostInstance } from './structured-agent-session-queued-pause' @@ -41,6 +38,7 @@ import { } from './structured-agent-session-start-failure-row' import { failedProviderChildStart } from './structured-agent-session-provider-child' import { handOverSubmission } from './structured-agent-session-turns' +import { structuredAgentSessionNextHandover } from './structured-agent-session-opening-send' import { structuredAgentSessionCommandRunning } from './structured-agent-session-command-turn' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' @@ -76,9 +74,11 @@ export type StructuredAgentSessionDeliveryLoopDeps = { type Step = 'continue' | 'stop' +type RefusedStart = Extract + type Prepared = | 'stop' - | Extract + | RefusedStart | { ok: true; awaited: StructuredAgentSessionProviderChildIdentity | null } /** A failed start before it is worded; `fail` words it once, through the one wording point. */ @@ -119,16 +119,18 @@ export class StructuredAgentSessionDeliveryLoop { return } if (!prepared.ok) { + if (prepared.aborted) { + // A close, Stop or quit stopped this start on purpose, and settled what it was for; a + // message accepted since gets its own start, and quit's next step stops the loop. + continue + } await this.deps.serialize(sessionId, () => this.fail(sessionId, this.refusedStart(sessionId, prepared)) ) return } - // A child published before it proved its start takes no input yet; waited for outside - // the queue so a Stop can reach it meanwhile. - const failure = await this.deps.adapter.awaitStarted?.(sessionId) const handed = await this.deps.serialize(sessionId, () => - this.handOver(sessionId, prepared.awaited, failure || null) + this.handOver(sessionId, prepared.awaited) ) if (handed === 'stop') { return @@ -203,8 +205,7 @@ export class StructuredAgentSessionDeliveryLoop { private async handOver( sessionId: string, - awaited: StructuredAgentSessionProviderChildIdentity | null, - startFailure: SubmissionRejectionFact | null + awaited: StructuredAgentSessionProviderChildIdentity | null ): Promise { const session = this.deps.sessions.get(sessionId) if (!session || this.disposed) { @@ -217,10 +218,9 @@ export class StructuredAgentSessionDeliveryLoop { child && awaited && child.generation === awaited.generation && child.fence === awaited.fence ? child : null - // The host's `starting` trails the adapter's `started` by one serialized step, so for the child - // waited on, the adapter's own answer decides whether its start landed. - if (!awaitedChild || (awaitedChild.phase === 'starting' && startFailure !== null)) { - // The child waited on is gone, replaced by another, or settled its start without proving it. + // 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 const endedFailure = ended ? structuredAgentSessionEndedChildFailure(ended) : undefined // A user's Stop or close is not a failure: the next step starts, or waits on, a child for @@ -232,16 +232,17 @@ export class StructuredAgentSessionDeliveryLoop { startKey: awaited?.generation ?? null, cause: endedFailure ?? // Gone with no end observed: nothing says the provider stopped. - { failure: startFailure ?? agentSessionFailureFact('startFailed') } + { failure: agentSessionFailureFact('startFailed') } }) } // 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 land while this step waits on the child's start. + // because a Stop can take the lane between the step that judged the send and this one. if (this.deps.stopping(sessionId)) { return this.stop(sessionId) } - const next = oldestQueuedSubmission(session) + // None while a turn is still opening: joining it would record the message ahead of it. + const next = structuredAgentSessionNextHandover(session, awaitedChild.fence) if (!next) { return this.stop(sessionId) } @@ -266,11 +267,7 @@ 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 - }: Extract + { refusal, diagnostic, argumentProblem }: RefusedStart ): StartFailure { // 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 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 398d04873d41..bf4c74bd3086 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 @@ -1,5 +1,7 @@ import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import type { StructuredAgentSessionLifecycleEvent } from './structured-agent-session-adapter' +import { holdUnsentSends } from '../agent-session-journal/journal-unsent-send-hold' +import { structuredAgentSessionHostInstance } from './structured-agent-session-queued-pause' import { stopAgentSessionProviderRoot } from './structured-agent-session-provider-exit-proof' import type { StructuredAgentSessionHostDeps, @@ -7,7 +9,10 @@ import type { StructuredAgentSessionProviderChild } from './structured-agent-session-host-types' import type { StructuredAgentSessionSinkBarrier } from './structured-agent-session-event-sink' -import { settleStructuredAgentSessionProviderStarted } from './structured-agent-session-provider-started' +import { + settleStructuredAgentSessionOptionsSkipped, + settleStructuredAgentSessionProviderStarted +} from './structured-agent-session-provider-started' import { endExitedStructuredAgentSessionChildUnderSerialize, settleStructuredAgentSessionChildExit, @@ -39,6 +44,17 @@ export class StructuredAgentSessionEventRecovery { return { ...this.context, logger: deps.logger, + holdUnrunSends: async (sessionId, fence, cause) => { + const journal = this.context.sessions.get(sessionId)?.journal + if (journal) { + await holdUnsentSends(journal, { + fence, + hostInstance: structuredAgentSessionHostInstance(), + hold: { cause }, + unrun: true + }) + } + }, route: { runtimeState, acknowledgeRelease: (sessionId) => deps.adapter.acknowledgeSessionRelease?.(sessionId) @@ -95,6 +111,9 @@ export class StructuredAgentSessionEventRecovery { if (event.type === 'started') { return settleStructuredAgentSessionProviderStarted(this.context, event) } + if (event.type === 'options-skipped') { + return settleStructuredAgentSessionOptionsSkipped(this.context, event) + } await settleStructuredAgentSessionChildExit(this.exitContext, event) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts index 16f636af0937..9a0637c99bc7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts @@ -214,7 +214,7 @@ export class StructuredAgentSessionSinkQueue { outcome = runNow(() => operation.run(bound)) } else { this.waitingPublications.set(key, operation) - // At handover, unless writes still wait behind an owed import; then at its place in line, so + // At handover, unless a write is running or writes wait in line; then at its place in it, so // it never announces ahead of the writes issued before it. outcome = runNow(() => bound.journal.readInOrder(() => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts index 5f9d2c216fe4..476089292a93 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts @@ -21,7 +21,6 @@ import { import { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { openJournalOwingImport } from '../agent-session-journal/journal-owed-import-test-support' import { createCodexJournalTranslator } from '../../codex/codex-structured-journal-translation' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { testEventSinkLogging } from './structured-agent-session-logger-test-support' @@ -120,41 +119,6 @@ function target( } } -/** A real journal whose copy from an earlier build's per-chat file is still owed: its first write - * pays it, and every write issued meanwhile waits in its queue. The one real backlog a chat has. */ -async function owingTarget(fence: number) { - const root = await mkdtemp(join(tmpdir(), 'orca-event-sink-owed-')) - const { journal, history } = await openJournalOwingImport({ - stateDirectory: root, - identity: JOURNAL_IDENTITY - }) - const published: (AgentSessionTurnActivity | null | undefined)[] = [] - const bound: StructuredAgentSessionEventTarget = { - journal, - fence, - publish: (activity) => published.push(activity) - } - /** The row each of these ordinals created, in journal order. */ - const landed = (): number[] => - journal.snapshot().items.flatMap((item) => { - const ordinal = [0, 1, 2, 3, 4].find( - (index) => item.itemId === agentJournalItemKey(identity(index)) - ) - return ordinal === undefined ? [] : [ordinal] - }) - return { - bound, - journal, - history: history.length, - published, - landed, - dispose: async () => { - await journal.close() - await rm(root, { recursive: true, force: true }) - } - } -} - describe('deferred structured agent-session event sink', () => { it('buffers writes made before the journal exists and drains them in arrival order', async () => { const log: Recorded[] = [] @@ -261,29 +225,6 @@ describe('deferred structured agent-session event sink', () => { expect(log).toEqual([{ call: 'appendItem', fence: 1, ordinal: 0 }]) }) - it('lands writes already handed to the journal when closed; only never-bound ones drop', async () => { - const owed = await owingTarget(3) - const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) - deferred.bind(owed.bound) - - deferred.sink.appendItem(identity(0), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) - deferred.sink.appendItem(identity(1), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) - deferred.sink.appendItem(identity(2), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) - // Handed over, waiting behind the copy: none has landed when the sink closes. - expect(owed.landed()).toEqual([]) - const written = deferred.sink.written?.() - deferred.close() - expect( - deferred.sink.tryAppendItem?.(identity(3), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) - ).toEqual({ accepted: false, reason: 'closed' }) - await expect(deferred.drained()).resolves.toEqual({ ok: true }) - - expect(owed.landed()).toEqual([0, 1, 2]) - await expect(written).resolves.toEqual({ ok: true }) - expect(owed.journal.cursor().sequence).toBe(owed.history + 3) - await owed.dispose() - }) - it('reports one refused append, fails the barrier, and stops later writes', async () => { const log: Recorded[] = [] const errors: unknown[] = [] @@ -376,47 +317,6 @@ describe('deferred structured agent-session event sink', () => { expect(log).toHaveLength(2) }) - it('pauses and resumes the reader at the same watermarks over writes the journal has not landed', async () => { - const changes: boolean[] = [] - const readingControl = { pauseReading: vi.fn(), resumeReading: vi.fn() } - const deferred = createDeferredStructuredAgentSessionEventSink({ - ...testEventSinkLogging(), - watermarks: { - pauseQueuedBytes: 1_000_000, - maxQueuedBytes: 1_000_000, - lowQueuedBytes: 1_000_000, - pauseQueuedOperations: 3, - maxQueuedOperations: 4, - lowQueuedOperations: 1 - }, - readingControl, - onBackpressureChange: (paused) => changes.push(paused) - }) - const owed = await owingTarget(5) - deferred.bind(owed.bound) - const append = (ordinal: number) => - deferred.sink.tryAppendItem?.(identity(ordinal), BODY, { - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - - expect([append(0), append(1)]).toEqual([{ accepted: true }, { accepted: true }]) - expect(readingControl.pauseReading).not.toHaveBeenCalled() - expect(append(2)).toEqual({ accepted: true }) - expect(deferred.state()).toMatchObject({ backpressured: true, queuedOperations: 3 }) - expect(readingControl.pauseReading).toHaveBeenCalledOnce() - expect(append(3)).toEqual({ accepted: true }) - expect(append(4)).toEqual({ accepted: false, reason: 'backpressure' }) - - // Nothing has landed yet: every admitted write waits behind the copy it owes. - expect(owed.landed()).toEqual([]) - await expect(deferred.drained()).resolves.toEqual({ ok: true }) - expect(changes).toEqual([true, false]) - expect(readingControl.resumeReading).toHaveBeenCalledOnce() - expect(deferred.state()).toMatchObject({ backpressured: false, queuedOperations: 0 }) - expect(owed.landed()).toEqual([0, 1, 2, 3]) - await owed.dispose() - }) - it('admits a resolved append and publication as one bounded operation', async () => { const log: Recorded[] = [] const deferred = createDeferredStructuredAgentSessionEventSink({ @@ -654,28 +554,6 @@ describe('deferred structured agent-session event sink', () => { } ]) }) - - it('skips a publication handed over and then replaced, and runs its replacement after the write between them', async () => { - const owed = await owingTarget(6) - const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) - deferred.bind(owed.bound) - const seen: string[] = [] - owed.bound.publish = (activity) => - seen.push(`${activity?.text ?? 'none'} over ${owed.landed().join(',')}`) - - deferred.sink.appendItem(identity(0), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) - deferred.sink.setActivity?.({ turnId: 'turn-1', text: 'Thinking' }) - deferred.sink.appendItem(identity(1), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) - deferred.sink.setActivity?.({ turnId: 'turn-1', text: 'Checking the result' }) - // The first publication was handed over and waits behind the copy; the second replaced it. - expect(deferred.state().queuedOperations).toBe(3) - expect(seen).toEqual([]) - - await expect(deferred.drained()).resolves.toEqual({ ok: true }) - expect(seen).toEqual(['Checking the result over 0,1']) - expect(deferred.state().queuedOperations).toBe(0) - await owed.dispose() - }) }) describe('producer linkage reaches the journal through every append path', () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.test.ts index b508cc9e7571..c49b79b06e64 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.test.ts @@ -99,6 +99,10 @@ describe('structuredAgentSessionStartFailure', () => { [ 'managedAccountUnsupported', 'While a Claude account is added in WSL, Claude chats need a Windows Claude account. Choose or add one in Claude Accounts settings, then send your message again.' + ], + [ + 'agentCommandNotRunnable', + "Claude'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." ] ] as const)('words a start refused for %s by that situation', (reason, sentence) => { const code = 'agent_session_operation_invalid' 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 75f7769e7a7c..979044625181 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 @@ -29,7 +29,9 @@ const TYPED_START_REFUSALS = [ 'managedAccountEnvOverride', 'accountSwitchInProgress', 'managedAccountUnsupported', - 'launchFolderMissing' + 'launchFolderMissing', + 'historyInOtherAccount', + 'agentCommandNotRunnable' ] as const satisfies readonly (AgentSessionFailureKind & AgentSessionRefusalReason<'agent_session_operation_invalid'>)[] diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts index 93c86fc16df1..6fad454e993f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts @@ -20,6 +20,7 @@ import { createTrackedJournalOpener, openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { StructuredAgentSessionAcquireAborts } from './structured-agent-session-acquire-aborts' import { attachStructuredAgentSession } from './structured-agent-session-attach-orchestration' import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' @@ -189,7 +190,8 @@ function attachContext( mintEventSink: () => eventSink, adoptEventSink: () => undefined, probeOwner: async () => ({ outcome: 'pid-absent' }), - discardEventSink: () => undefined + discardEventSink: () => undefined, + acquireAborts: new StructuredAgentSessionAcquireAborts() }, sessions, subscribers: { snapshot: () => undefined, publish: () => undefined }, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts index fda7bc9c27e6..84dec78d0717 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts @@ -114,6 +114,7 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( : Promise.resolve(null) child.close = { cause, + ...('quit' in ending && ending.quit ? { quit: true as const } : {}), reason: ('reason' in ending ? ending.reason : undefined) ?? null, recorded, requestedAt: session.journal.cursor() 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 8055af641583..5a0537e5a5ac 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 @@ -34,11 +34,14 @@ import { cancelPlan, promptPlan, sendPlan, - setOptionPlan + setOptionPlan, + type MutationPlan } from './structured-agent-session-mutation-plans' +import { agentSessionMutationAdmitsNow } from './structured-agent-session-mutation-admits-now' import { runQueueableStructuredAgentSessionSend } from './structured-agent-session-queued-send' import { cancelStructuredAgentSessionPrompt } from './structured-agent-session-prompt-cancel' import { mutateWithChatStop } from './structured-agent-session-chat-stop' +import { performSetOption } from './structured-agent-session-turns-options' export type { StructuredAgentSessionMutationContext } from './structured-agent-session-mutation-context' import type { StructuredAgentSessionCaller } from './structured-agent-session-host-types' import { @@ -110,6 +113,9 @@ export function cancelStructuredAgentSessionTurn( } const plan = cancelPlan(params) const { prompt } = params + if (!prompt && params.turnId === undefined) { + abortAcquireForStop(context, caller, params.envelope, plan) + } // A card's Cancel stops whatever the chat has in flight, as the Stop button does; it reaches the // Stop only for a card the live turn raised (`cancelStructuredAgentSessionPrompt`). const stopped = prompt ? { envelope: params.envelope } : params @@ -124,6 +130,31 @@ export function cancelStructuredAgentSessionTurn( ) } +/** A Stop must not wait behind a start the provider may never answer: the start the session's + * queue is waiting on stops now, as a close's does, and the Stop's own step then finds no child. + * Only a Stop admission would run now; one naming a turn is about a child already gone, so it + * leaves a newer start alone. */ +function abortAcquireForStop( + context: StructuredAgentSessionMutationContext, + caller: StructuredAgentSessionCaller, + envelope: AgentSessionMutationEnvelope, + plan: MutationPlan +): void { + const { store } = context.deps + if ( + !agentSessionMutationAdmitsNow({ + store, + callerKey: caller.callerKey, + envelope, + plan, + now: context.now + }) + ) { + return + } + context.acquireAborts.abort(envelope.sessionId, 'stopped while starting') +} + export function respondToStructuredAgentSessionPrompt( context: StructuredAgentSessionMutationContext, caller: StructuredAgentSessionCaller, @@ -158,10 +189,18 @@ export async function setStructuredAgentSessionOption( get conversationWrite() { return atRest() ? (true as const) : undefined }, - run: (ctx) => - atRest() - ? recordStructuredAgentSessionOptionIntent(context.deps, ctx, params) - : plan.run(ctx) + run: async (ctx) => { + if (atRest()) { + return recordStructuredAgentSessionOptionIntent(context.deps, ctx, params) + } + // Held where a start is, so a close, a Stop admitted now or quit ends the wait from outside. + const wait = context.acquireAborts.begin(params.envelope.sessionId) + try { + return await performSetOption(ctx, params, wait.signal) + } finally { + wait.end() + } + } }, openForProviderWrite(context, params.envelope) ) 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 83b53777d4e5..cc5be644cc13 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 @@ -6,6 +6,7 @@ import { type StructuredAgentSessionSinkBarrier } from './structured-agent-session-event-sink' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host' +import { StructuredAgentSessionAcquireAborts } from './structured-agent-session-acquire-aborts' import { StructuredAgentSessionLeaseRenewer } from './structured-agent-session-lease-renewer' import { heldProviderChildReader, @@ -15,6 +16,7 @@ import { resolveStructuredSessionRecovery } from './structured-agent-session-rec export class StructuredAgentSessionHostRuntimeState { private readonly eventSinks = new Map() + readonly acquireAborts = new StructuredAgentSessionAcquireAborts() private readonly leaseRenewer: StructuredAgentSessionLeaseRenewer private readonly onEventSinkFailure?: (sessionId: string, error: unknown) => void diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-status-feed.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-status-feed.ts new file mode 100644 index 000000000000..25d475abb460 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-status-feed.ts @@ -0,0 +1,39 @@ +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { + deferredStructuredAgentSessionLogger, + type StructuredAgentSessionLogger +} from './structured-agent-session-logger' +import { + StructuredAgentSessionStatusFeed, + type StructuredAgentSessionStatusFeedDeps, + type StructuredAgentSessionStatusSink +} from './structured-agent-session-status-feed' + +/** Wire the host's own deps into a feed; keeps the host at one call site. + * `deps` is a thunk because the host builds the feed in a field initializer, + * before its constructor parameters are assigned. */ +export function createStructuredAgentSessionHostStatusFeed(args: { + sessions: StructuredAgentSessionStatusFeedDeps['sessions'] + now: () => number + deps: () => { + store: { getRecord: (sessionId: string) => AgentSessionRecord | null } + logger: StructuredAgentSessionLogger + onSessionStatusChanged?: StructuredAgentSessionStatusFeedDeps['onStatusChanged'] + statusSink?: StructuredAgentSessionStatusSink + } + onAgentStarted?: (sessionId: string) => void + onChildWorkChanged?: (sessionId: string) => void +}): StructuredAgentSessionStatusFeed { + return new StructuredAgentSessionStatusFeed({ + sessions: args.sessions, + getRecord: (sessionId) => args.deps().store.getRecord(sessionId), + now: args.now, + logger: deferredStructuredAgentSessionLogger(() => args.deps().logger), + onStatusChanged: (summary, options) => args.deps().onSessionStatusChanged?.(summary, options), + // Resolved per call for the same reason the other deps are: the host builds this feed in a + // field initializer, before its constructor parameters are assigned. + statusSink: () => args.deps().statusSink, + ...(args.onAgentStarted ? { onAgentStarted: args.onAgentStarted } : {}), + ...(args.onChildWorkChanged ? { onChildWorkChanged: args.onChildWorkChanged } : {}) + }) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-tabs.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-tabs.ts index 763df3080c38..2fc2bc28d735 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-tabs.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-tabs.ts @@ -19,6 +19,7 @@ export function setStructuredAgentSessionTabVisibility( tabId?: string ) => Promise } + onSessionTabHidden?: (sessionId: string) => void } restartResume: { dismiss: (sessionIds: readonly string[]) => Promise } }, @@ -97,14 +98,20 @@ export function createStructuredAgentSessionTabSurface( getSessionTabId: (sessionId: string): string | null => host.deps.store.getSessionTabId(sessionId), showSessionTabs: (sessionIds: readonly string[]) => host.deps.store.showSessionTabs(sessionIds), + notifySessionTabHidden: (sessionId: string): void => notifyTabHidden(host, sessionId), setSessionTabVisibility: async ( sessionId: string, visible: boolean, - tabId?: string + tabId?: string, + /** A close that may still put the tab back sends the hidden notice once it settles. */ + options?: { deferHiddenNotice?: boolean } ): Promise => { await setStructuredAgentSessionTabVisibility(host, sessionId, visible, tabId) if (!visible) { unopened.delete(sessionId) + if (!options?.deferHiddenNotice) { + notifyTabHidden(host, sessionId) + } } // The tab edge of the row's lifetime; the handle close is the other. if (!visible && !sessions.get(sessionId)?.child) { @@ -113,3 +120,19 @@ export function createStructuredAgentSessionTabSurface( } } } + +function notifyTabHidden( + host: Parameters[0], + sessionId: string +): void { + try { + host.deps.onSessionTabHidden?.(sessionId) + } catch (error) { + // Bookkeeping never gates closing a chat. + host.deps.logger.warn('a chat tab close listener failed', { + scope: 'tab-hidden-listener', + sessionId, + error + }) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.test.ts index dde5f82dae59..152d84a3bee6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.test.ts @@ -23,7 +23,11 @@ describe('structured agent-session host teardown', () => { const phases = structuredAgentSessionHostTeardownPhases({ logger: createStructuredAgentSessionLogger(), idleSweep: { dispose: noop }, - runtimeState: { stopLeaseRenewal: () => undefined, flushAllEventSinks: noop }, + runtimeState: { + stopLeaseRenewal: () => undefined, + flushAllEventSinks: noop, + acquireAborts: { abortAll: () => {} } + }, tasks: { drainAttaches: noop }, evictOwnedSessions: noop, beginResumeMarkers: () => {}, @@ -33,6 +37,7 @@ describe('structured agent-session host teardown', () => { 'begin-resume-markers', 'dispose-idle-sweep', 'stop-lease-renewal', + 'abort-acquires', 'drain-attaches', 'evict-owned-sessions', 'record-resume-markers', @@ -67,7 +72,11 @@ describe('structured agent-session host teardown', () => { const evict = structuredAgentSessionHostTeardownPhases({ logger: createStructuredAgentSessionLogger(), idleSweep: { dispose: noop }, - runtimeState: { stopLeaseRenewal: () => undefined, flushAllEventSinks: noop }, + runtimeState: { + stopLeaseRenewal: () => undefined, + flushAllEventSinks: noop, + acquireAborts: { abortAll: () => {} } + }, tasks: { drainAttaches: noop }, evictOwnedSessions: noop, beginResumeMarkers: () => {}, @@ -97,7 +106,11 @@ describe('structured agent-session host teardown', () => { const phases = structuredAgentSessionHostTeardownPhases({ logger: log.logger, idleSweep: { dispose: cleaned }, - runtimeState: { stopLeaseRenewal: () => {}, flushAllEventSinks: flush }, + runtimeState: { + stopLeaseRenewal: () => {}, + flushAllEventSinks: flush, + acquireAborts: { abortAll: () => {} } + }, tasks: { drainAttaches: cleaned }, evictOwnedSessions: cleaned, beginResumeMarkers: () => {}, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts index fc93e4a43d44..0c9e59fba766 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts @@ -60,6 +60,7 @@ export function structuredAgentSessionHostTeardownPhases(collaborators: { runtimeState: { stopLeaseRenewal: () => Promise | void flushAllEventSinks: () => Promise + acquireAborts: { abortAll: (reason: string) => void } } tasks: { drainAttaches: () => Promise } evictOwnedSessions: () => Promise @@ -83,6 +84,11 @@ export function structuredAgentSessionHostTeardownPhases(collaborators: { }, { name: 'dispose-idle-sweep', run: () => collaborators.idleSweep.dispose() }, { name: 'stop-lease-renewal', run: () => collaborators.runtimeState.stopLeaseRenewal() }, + // Before the drain, which would otherwise wait out a start the provider never answers. + { + name: 'abort-acquires', + run: () => collaborators.runtimeState.acquireAborts.abortAll('closed by quit') + }, { name: 'drain-attaches', run: () => collaborators.tasks.drainAttaches() }, { name: 'evict-owned-sessions', 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 bbc80c17fdf7..2230efa15c9b 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 @@ -52,6 +52,8 @@ export type StructuredAgentSessionProviderChildIdentity = { export type StructuredAgentSessionChildClose = { /** The first stop's, which the child's end keeps however many asks join it. */ readonly cause: StructuredAgentSessionStopCause + /** Asked for by quit, whose leftovers the next open settles as a crash's. */ + readonly quit?: true readonly reason: string | null /** The Stop event that stop wrote, folded before the work it ends is settled, with the settle a * person's close that named no turn opens; a repeated ask reopens it. */ @@ -141,6 +143,8 @@ export type StructuredAgentSessionHostDeps = { idleSweep?: { intervalMs?: number; idleMs?: number } /** 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. */ + onSessionTabHidden?: (sessionId: string) => void /** Where every failure the host carries on past is reported. Required: a host without one would * drop exactly the failures nobody sees in the UI. */ logger: StructuredAgentSessionLogger 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 786577a37998..e0f193ba0b49 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 @@ -14,10 +14,7 @@ import { createRestartReconciler } from './structured-agent-session-restart-reco import type { AgentSessionSubscribeInput } from './structured-agent-session-subscribers' import { StructuredAgentSessionTaskQueue } from './structured-agent-session-task-queue' import * as providerSupport from './structured-agent-session-provider-support' -import { - createStructuredAgentSessionHostRestore, - revealStructuredAgentSession -} from './structured-agent-session-reveal' +import * as reveal from './structured-agent-session-reveal' import { structuredAgentSessionOwnerStatus } from './structured-agent-session-owner-status' import { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' import { attachStructuredAgentSession } from './structured-agent-session-attach-orchestration' @@ -50,8 +47,7 @@ import { type StructuredAgentSessionRestartResume } from './structured-agent-session-restart-resume-host' import { structuredAgentSessionRestartResumeSurfaces } from './structured-agent-session-restart-resume-wiring' -import { createStructuredAgentSessionConversationDelivery } from './structured-agent-session-host-delivery' -import type * as conversation from './structured-agent-session-host-delivery' +import * as conversation from './structured-agent-session-host-delivery' import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' import { wireStructuredAgentSessionQueuedMessages } from './structured-agent-session-queued-wiring' import * as sessionLogger from './structured-agent-session-logger' @@ -89,7 +85,7 @@ export class StructuredAgentSessionHost { private readonly tasks = new StructuredAgentSessionTaskQueue() private readonly runtimeState: StructuredAgentSessionHostRuntimeState private readonly reconcileLeases: ReturnType - private readonly restore: ReturnType + private readonly restore: ReturnType private readonly lifetime: StructuredAgentSessionConversationLifetime private readonly conversationDelivery: conversation.StructuredAgentSessionConversationDelivery private readonly eventRecovery: StructuredAgentSessionEventRecovery @@ -117,7 +113,7 @@ export class StructuredAgentSessionHost { ...(deps.probeOwners ? { probeMany: deps.probeOwners } : {}), now: () => this.now() }) - this.conversationDelivery = createStructuredAgentSessionConversationDelivery({ + this.conversationDelivery = conversation.createStructuredAgentSessionConversationDelivery({ deps, sessions: this.sessions, serialize: (sessionId, task) => this.serialize(sessionId, task), @@ -127,7 +123,7 @@ export class StructuredAgentSessionHost { agentStart.ensureStructuredAgentSessionAgent(this.attachContext(), sessionId, startedFor), clientDelivery: this.clientDelivery }) - this.restore = createStructuredAgentSessionHostRestore(deps, { + this.restore = reveal.createStructuredAgentSessionHostRestore(deps, { reconcileLeases: this.reconcileLeases, resolveRecovery: (sessionId) => this.runtimeState.resolveRecovery(sessionId), serialize: (sessionId, task) => this.serialize(sessionId, task), @@ -232,10 +228,9 @@ export class StructuredAgentSessionHost { getSessionTabId = this.tabs.getSessionTabId showSessionTabs = this.tabs.showSessionTabs setSessionTabVisibility = this.tabs.setSessionTabVisibility - /** The records file could not be read this launch, so chats it holds are not listed yet. */ - legacyRecordImportOwed = (): boolean => this.deps.journalDatabase.legacyRecordImportOwed === true - /** This runtime holds a chat: a record, or the records file's chats still owed their copy. */ - holdsSessions = (): boolean => this.deps.store.holdsRecords() || this.legacyRecordImportOwed() + notifySessionTabHidden = this.tabs.notifySessionTabHidden + /** This runtime holds a chat record, readable or not. */ + holdsSessions = (): boolean => this.deps.store.holdsRecords() onSessionsHeld = (listener: () => void): (() => void) => this.deps.store.onFirstRecord(listener) reconcileRestartLeases = (): Promise => this.restore.reconcileRestartLeases() @@ -245,7 +240,7 @@ export class StructuredAgentSessionHost { /** Make one persisted session addressable again; see `structured-agent-session-reveal`. */ revealSession = (sessionId: string): Promise => - revealStructuredAgentSession(this.deps, sessionId, (id) => this.lifetime.conversation(id)) + reveal.revealStructuredAgentSession(this.deps, sessionId, this.lifetime.conversation) private serialize = this.tasks.serialize.bind(this.tasks) @@ -295,6 +290,7 @@ export class StructuredAgentSessionHost { wakeDelivery: (sessionId) => this.conversationDelivery.loop.wake(sessionId), stopAgent: (sessionId, ending) => this.lifetime.stopAgent(sessionId, ending), wakeQueuedDrain: (sessionId) => this.queued.drain.schedule(sessionId), + acquireAborts: this.runtimeState.acquireAborts, now: () => this.now() } } @@ -335,8 +331,7 @@ export class StructuredAgentSessionHost { journalSnapshot = async (sessionId: string): Promise => (await this.lifetime.conversation(sessionId)).journal.snapshot() - subscribe = (input: AgentSessionSubscribeInput): Promise<() => void> => - this.backgroundTasks.subscribe(input) + subscribe = (input: AgentSessionSubscribeInput) => this.backgroundTasks.subscribe(input) settleLateDispatch = (input: Parameters[1]) => settleStructuredAgentSessionLateDispatch(this.mutationContext(), input) @@ -350,6 +345,7 @@ export class StructuredAgentSessionHost { /** Every session's projected status for session lists; unlike `subscribe`, retains nothing. */ subscribeStatus = this.clientDelivery.subscribeStatus + publishConversationName = this.clientDelivery.publishConversationName /** Turns that settle, and prompts raised, from now on. Live-only: nothing missed is replayed. */ subscribeTurnCompletions = this.clientDelivery.subscribeTurnCompletions diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts index 9cb6dbab4ef2..7de8ad28581f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts @@ -15,6 +15,7 @@ import { REST_TEST_SESSION as SESSION, REST_TEST_THREAD as THREAD, restTestSend, + sweepOnce, sweepTicks, type RestTestRig } from './structured-agent-session-rest-test-rig' @@ -96,20 +97,25 @@ describe('the idle sweep', () => { it('never stops an agent a message is queued for, and hands the message over (P2-08)', async () => { await foundRestTestChat(rig) rig.adapter.closeSession.mockClear() - // The loop takes the message and waits on the child's start, outside the lock. - const started = Promise.withResolvers() - const awaitStarted = vi.fn(() => started.promise) - Object.assign(rig.host.deps.adapter, { awaitStarted }) + // Only the sweep this test asks for runs, at the step it is asked for. + rig.host.collaboratorsForTests().lifetime.idleSweep.dispose() + await sweepTicks(2) const reader = collectSubscriber() await rig.host.subscribe({ id: 'reader', sessionId: SESSION, emit: reader.emit }) - const sent = await rig.host.send(CALLER, restTestSend('queued one', fence())) - expect(sent.ok).toBe(true) - await vi.waitFor(() => expect(awaitStarted).toHaveBeenCalled()) - rig.clock.now += IDLE_MS + 1 + // Held, the send is accepted, the agent goes idle past the window, and the sweep runs, all + // ahead of the handover the send asks for. + const held = Promise.withResolvers() + void rig.host['tasks'].serialize(SESSION, () => held.promise) + const sending = rig.host.send(CALLER, restTestSend('queued one', fence())) + void rig.host['tasks'].serialize(SESSION, async () => { + rig.clock.now += IDLE_MS + 1 + }) + const swept = sweepOnce(rig.host) + held.resolve() + expect((await sending).ok).toBe(true) - await sweepTicks() + await swept expect(rig.adapter.closeSession).not.toHaveBeenCalled() - started.resolve() await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(2)) const settled = (await rig.host.journalSnapshot(SESSION)).submissions.at(-1) expect(settled?.dispatchState).toBe('accepted') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-import-mismatch.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-import-mismatch.test.ts deleted file mode 100644 index a8f45664a1d8..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-import-mismatch.test.ts +++ /dev/null @@ -1,138 +0,0 @@ -// A chat whose per-chat file did not read back as its copy is refused as history that cannot be -// loaded: on a send and a Stop as on a read. Each retry runs the same copy, so "try again" is wrong. - -import { cp, rm } from 'node:fs/promises' -import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { - JournalImportMismatchError, - journalOpenRefusalError -} from '../agent-session-journal/journal-open-failure' -import { replayJournal } from '../agent-session-journal/journal-open' -import type * as PerSessionImport from '../agent-session-journal/journal-per-session-import' -import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { - adapter, - attach, - CALLER, - envelope, - hostTestState, - replaceHostTestState -} from './structured-agent-session-host-test-harness' -import { - HOST_TEST_NOW as NOW, - HOST_TEST_SESSION as SESSION, - hostTestMessage -} from './structured-agent-session-host-test-data' -import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' -import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' - -const { copy } = vi.hoisted(() => ({ copy: { mismatched: false } })) - -vi.mock('../agent-session-journal/journal-per-session-import', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - // What the copy's verify throws for a file that does not read back as copied. - importPerSessionJournal: async ( - input: Parameters[0] - ) => { - if (!copy.mismatched) { - return actual.importPerSessionJournal(input) - } - throw journalOpenRefusalError( - new JournalImportMismatchError( - `per-chat journal of ${input.identity.sessionId} read back short` - ) - ) - }, - // Restore lists a chat still in its per-chat file from a fold of it, and owes the copy. - previewPerSessionJournal: async ( - input: Parameters[0] - ) => - copy.mismatched - ? replayJournal(input.database.db, input.identity.sessionId) - : actual.previewPerSessionJournal(input) - } -}) - -const UNLOADABLE = { - ok: false, - refusal: { - code: 'agent_session_journal_unreadable', - message: 'Unable to load this chat.', - details: { reason: 'journalCorrupt' } - } -} - -const relaunchedRoots: string[] = [] - -beforeEach(() => { - copy.mismatched = false - vi.spyOn(console, 'warn').mockImplementation(() => undefined) -}) - -afterEach(async () => { - vi.restoreAllMocks() - await Promise.all( - relaunchedRoots.splice(0).map((dir) => rm(dir, { recursive: true, force: true })) - ) -}) - -/** A restarted process over the chat's files, holding no chat open; its copy now mismatches. */ -async function relaunchWithMismatchedCopy(): Promise { - const before = hostTestState() - await attach() - await before.host.flushStreamedEvents(SESSION) - await before.store.renewLeases([]) - const relaunched = `${before.root}-relaunched` - relaunchedRoots.push(relaunched) - // A dead process holds no lock. - await cp(before.root, relaunched, { - recursive: true, - filter: (source) => !source.includes('.lock') - }) - const store = await openTestAgentSessionRecordStore(relaunched) - const host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: adapter(), - journalDatabase: openTestJournalHostDatabase(relaunched), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-next', - now: () => NOW - }) - replaceHostTestState({ store, host }) - copy.mismatched = true - return host -} - -function send(host: StructuredAgentSessionHost) { - const body = hostTestMessage('sent to a chat that did not copy') - return host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) -} - -it('refuses a send and a Stop that open the chat as unloadable', async () => { - const host = await relaunchWithMismatchedCopy() - - await expect(send(host)).resolves.toMatchObject(UNLOADABLE) - await expect( - host.cancel(CALLER, { - envelope: envelope('agentSession.cancel', { turnId: 'turn-1' }), - turnId: 'turn-1' - }) - ).resolves.toMatchObject(UNLOADABLE) - await host.flushAllStreamedEvents() -}) - -// Startup listed the chat from its per-chat file; the send's write pays the copy it owes. -it('refuses a send to a restored chat as unloadable when the copy it owes does not verify', async () => { - const host = await relaunchWithMismatchedCopy() - await host.restoreReadableSessions([SESSION]) - expect(host.hasSession(SESSION)).toBe(true) - - await expect(send(host)).resolves.toMatchObject(UNLOADABLE) - await host.flushAllStreamedEvents() -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts index 5719dd172cfa..763acc16c781 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts @@ -89,7 +89,6 @@ beforeEach(async () => { } }, dispatch, - awaitStarted: vi.fn(async () => undefined), closeSession: vi.fn(async () => true), releaseAcquisition: vi.fn(async () => true), cancelTurn: vi.fn(async () => ({ cancelled: true })), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admits-now.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admits-now.ts new file mode 100644 index 000000000000..cc1e2bd5076c --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admits-now.ts @@ -0,0 +1,38 @@ +import { + admitAgentSessionMutation, + computeAgentSessionPayloadFingerprint +} from '../../../shared/agent-session-mutation-envelope' +import type { AgentSessionMutationRequest } from './structured-agent-session-mutation-admission' +import type { MutationPlan } from './structured-agent-session-mutation-plans' + +/** Whether admission would run `plan` for the first time now, placing nothing: for an effect that + * must not wait in the session's queue for its turn. */ +export function agentSessionMutationAdmitsNow( + request: Pick, 'store' | 'callerKey' | 'envelope' | 'now'> & { + plan: Pick, 'method' | 'fields' | 'operationIdScope' | 'conversationWrite'> + } +): boolean { + const { plan, envelope } = request + const hostFingerprint = computeAgentSessionPayloadFingerprint({ + method: plan.method, + sessionId: envelope.sessionId, + fields: plan.fields + }) + const evaluated = request.store.evaluateMutationOperation({ + callerKey: request.callerKey, + envelope, + hostFingerprint, + now: request.now(), + ...(plan.operationIdScope ? { operationIdScope: plan.operationIdScope } : {}) + }) + return ( + evaluated !== null && + admitAgentSessionMutation({ + envelope, + hostFingerprint, + ledger: evaluated.decision, + lease: evaluated.record.lease, + ...(plan.conversationWrite ? { conversationWrite: true } : {}) + }).decision === 'admit' + ) +} 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 fb12399c2370..099b356cb5e3 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 @@ -13,6 +13,7 @@ import { } from './structured-agent-session-mutation-admission' 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 { StructuredAgentSessionCaller, StructuredAgentSessionHostDeps, @@ -44,6 +45,9 @@ export type StructuredAgentSessionMutationContext = { * journal commit (a conversation command). Draft-table changes need no call: * the draft store notifies through the journal's own commit listener. */ wakeQueuedDrain?: (sessionId: string) => void + /** The provider wait each session's serialize is on (a start, an option write), which a caller + * outside that serialize aborts. */ + acquireAborts: Pick now: () => number } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-owed-import.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-owed-import.test.ts deleted file mode 100644 index b995b1a373fd..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-owed-import.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -// A mutation reads the fold after every write issued before it, even while a restore's import is -// owed: the open pays the import first, so provider rows queued behind it have landed. A failed -// import is reported and never refuses the mutation. - -import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' -import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' -import type { StructuredAgentSessionHost } from './structured-agent-session-host' -import { - attach, - CALLER, - envelope, - hostTestState -} from './structured-agent-session-host-test-harness' -import { - HOST_TEST_SESSION as SESSION, - HOST_TEST_THREAD as THREAD -} from './structured-agent-session-host-test-data' -import { CODEX_STRUCTURED_AGENT } from '../../codex/codex-structured-agent-definition' - -let host: StructuredAgentSessionHost -let acquire: Mock -let cancelTurn: Mock -let warned: ReturnType - -beforeEach(() => { - ;({ host, acquire, cancelTurn } = hostTestState()) - warned = vi.spyOn(console, 'warn').mockImplementation(() => undefined) -}) - -afterEach(() => { - vi.restoreAllMocks() -}) - -function journal(): AgentSessionJournal { - const open = host.collaboratorsForTests().sessions.get(SESSION)?.journal - if (!open) { - throw new Error('no open journal') - } - return open -} - -function providerEvents(): StructuredAgentSessionEventSink { - const events = acquire.mock.calls.at(-1)?.[0].events - if (!events) { - throw new Error('no provider bound') - } - return events -} - -/** Owed work standing in for a restore's import: every write waits behind it until it settles. */ -function oweImport(): PromiseWithResolvers { - const owed = Promise.withResolvers() - journal()['queue'].owe(() => owed.promise) - return owed -} - -/** A turn the provider opened by itself, its row queued behind the owed import. */ -function providerOpensTurn(turnId: string, ordinal: number): void { - providerEvents().appendItem( - { provider: 'codex', threadId: THREAD, turnId, ordinal }, - { kind: 'turn', turnId, state: 'running' }, - { turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) -} - -function stopNamingNoTurn() { - return host.cancel(CALLER, { envelope: envelope('agentSession.cancel', {}) }) -} - -describe('a mutation while an import is owed', () => { - it('a Stop naming no turn sees the turn the provider opened, and interrupts it', async () => { - await attach() - const owed = oweImport() - providerOpensTurn('turn-p', 900) - expect(journal().activeTurnId()).toBeNull() - - const stopping = stopNamingNoTurn() - await new Promise((resolve) => setTimeout(resolve, 50)) - // It waits for the import, as a reader does, then reads the turn. - expect(cancelTurn).not.toHaveBeenCalled() - owed.resolve() - - expect(await stopping).toMatchObject({ ok: true, value: { cancelled: true } }) - expect(cancelTurn).toHaveBeenCalledOnce() - }) - - it('a goal set sees the goal and the turn the provider wrote, and replaces it in that turn', async () => { - await attach() - const changeThreadGoal = vi.fn(async () => ({ ok: true as const })) - Object.assign(host.deps.adapter, { - changeThreadGoal, - capabilities: () => CODEX_STRUCTURED_AGENT.capabilities - }) - const owed = oweImport() - providerOpensTurn('turn-g', 901) - providerEvents().appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'turn-g', ordinal: 902 }, - { - kind: 'status', - text: 'Goal', - threadGoal: { - state: 'set', - goal: { - objective: 'Earlier goal', - status: 'active', - tokenBudget: null, - tokensUsed: 1, - timeUsedSeconds: 2, - createdAt: 3_000, - updatedAt: 4_000 - } - } - }, - { turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - - const change = { kind: 'set' as const, objective: 'New goal' } - const setting = host.changeThreadGoal(CALLER, { - envelope: envelope('agentSession.threadGoal', { change }), - change - }) - // Long enough for the goal set to reach its read before the import lands. - await new Promise((resolve) => setTimeout(resolve, 50)) - expect(changeThreadGoal).not.toHaveBeenCalled() - owed.resolve() - - expect(await setting).toMatchObject({ ok: true }) - expect(changeThreadGoal).toHaveBeenCalledWith(expect.objectContaining({ replacesGoal: true })) - const objective = journal() - .snapshot() - .items.find((item) => item.body.kind === 'message' && item.body.sentAs === 'goal') - expect(objective?.turnScope).toEqual({ - kind: 'turn', - turnItemId: expect.stringContaining(':turn-g:') - }) - }) - - it('a failed import is reported and the Stop still answers', async () => { - await attach() - const owed = oweImport() - owed.reject(new Error('disk I/O error')) - - expect(await stopNamingNoTurn()).toMatchObject({ ok: true }) - expect(warned).toHaveBeenCalledWith( - '[agent-session] open-for-write: the import owed before a write failed', - expect.objectContaining({ error: new Error('disk I/O error') }) - ) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-opening-send.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-opening-send.ts new file mode 100644 index 000000000000..355d61bf3d77 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-opening-send.ts @@ -0,0 +1,33 @@ +// The next queued message to hand over: none while a send ahead is still opening its turn. + +import { structuredAgentSessionSendOpeningTurn } from '../../../shared/structured-agent-session-opening-send' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' +import { oldestQueuedSubmission } from './structured-agent-session-start-failure-row' + +/** Whether a send this child (`fence`) was handed is still opening its turn + * (`structuredAgentSessionSendOpeningTurn`), read off the journal's fold without a snapshot. */ +export function structuredAgentSessionSendOpeningTurnInJournal( + journal: Pick, + fence: number +): boolean { + return structuredAgentSessionSendOpeningTurn( + journal.submissions(), + (visit) => + journal.visitItemsWithLinkage((itemId, sequence, body, linkage) => + visit({ itemId, sequence, body, turnScope: linkage.turnScope, agentId: linkage.agentId }) + ), + fence + ) +} + +/** The queued message to hand over now: the oldest, unless a send ahead is still opening its turn, + * when none is, and the commit that ends that wakes the delivery loop again. */ +export function structuredAgentSessionNextHandover( + session: Pick, + fence: number +): ReturnType { + return structuredAgentSessionSendOpeningTurnInJournal(session.journal, fence) + ? undefined + : oldestQueuedSubmission(session) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-option-restoration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-option-restoration.ts index d3461a7e9ee3..a9cdc2d14183 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-option-restoration.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-option-restoration.ts @@ -21,11 +21,13 @@ export async function readNativeSessionOptions(input: { } /** The record's options once the provider has reported: its model, effort and Fast replace the - * saved ones, other saved options stay, and any the restore could not apply are dropped. */ + * saved ones, other saved options stay, and any the child could not take are dropped. */ export function nativeSessionOptionsFromReport(input: { reported: AgentSessionOptionsResult['current'] restoreSkipped: readonly string[] priorOptions?: Readonly> + /** Values the child showed it cannot run: its report of the same value is not kept. */ + retired?: Readonly> }): Readonly> { const { reported, priorOptions } = input const restored = priorOptions ? { ...priorOptions } : {} @@ -39,10 +41,16 @@ export function nativeSessionOptionsFromReport(input: { reported.fastMode === undefined ? undefined : encodeStructuredAgentSessionOptionValue('fastMode', reported.fastMode) - return { + const options: Record = { ...restored, model: reported.model, ...(reported.effort ? { effort: reported.effort } : {}), ...(fastMode !== undefined && fastMode !== null ? { fastMode } : {}) } + for (const [key, value] of Object.entries(input.retired ?? {})) { + if (options[key] === value) { + delete options[key] + } + } + return options } 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 73887ceef69d..c906785c5418 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 @@ -17,7 +17,8 @@ import { AgentHookServer, _internals } from '../../agent-hooks/server' import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' import { fakeClaude, - PROVIDER_SESSION_ID + PROVIDER_SESSION_ID, + claudeStartupSettled } from '../../claude/claude-structured-session-test-support' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' @@ -161,8 +162,8 @@ async function dispatchState(clientMessageId: string): Promise { for (let turn = 0; turn < 2000 && connection.sent.length === 0; turn += 1) { await new Promise((resolve) => setImmediate(resolve)) @@ -175,45 +176,43 @@ function waitOutSeveralSweeps(): Promise { } describe('a Claude chat whose CLI is still starting', () => { - it('keeps a message queued inside the idle window, and delivers it once startup lands', async () => { + it('writes a message at once, and keeps the agent inside the idle window while startup is still out', async () => { await attachStarting() - const held = await send('sent while starting') + 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')) clock += IDLE_MS - 1 await waitOutSeveralSweeps() expect(host.hasSession(SESSION)).toBe(true) expect(claude.connections[0].closeCount).toBe(0) - expect(await dispatchState(held)).toBe('pending') landInit() - await adapter.awaitStarted(SESSION) - - await vi.waitFor( - () => expect(claude.connections[0].sent).toEqual([expect.objectContaining({ type: 'user' })]), - { timeout: 3000 } - ) - await vi.waitFor(async () => expect(await dispatchState(held)).toBe('accepted')) + await claudeStartupSettled(adapter, SESSION) + expect(host.hasSession(SESSION)).toBe(true) }) - it('gives the message it hands over at startup a full idle window to open its turn', async () => { + it('gives a message written while starting a full idle window after startup lands to open its turn', async () => { await attachStarting() - const held = await send('sent while starting') const connection = claude.connections[0] // Claude echoes a prompt only when it starts that turn, which a loaded machine delays. connection.send = async (message) => { connection.sent.push(message) } + const held = await send('sent while starting') + await untilSent(connection) + expect(connection.sent).toEqual([expect.objectContaining({ type: 'user' })]) clock += IDLE_MS - 1 await waitOutSeveralSweeps() expect(host.hasSession(SESSION)).toBe(true) - // Startup lands just before the window closes; the handover's publish starts it again. + // Startup lands just before the window closes; the agent stays while its message waits. landInit() - await adapter.awaitStarted(SESSION) + 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() @@ -229,7 +228,7 @@ describe('a Claude chat whose CLI is still starting', () => { it('stops the agent and closes the conversation once its turn has finished and it idled', async () => { await attachStarting() landInit() - await adapter.awaitStarted(SESSION) + await claudeStartupSettled(adapter, SESSION) const answered = await send('answered') await vi.waitFor(async () => expect(await dispatchState(answered)).toBe('accepted')) claude.connections[0].handlers.onMessage?.({ @@ -267,7 +266,7 @@ describe('a chat whose settled lead still has background work running', () => { async function settleTurnLeavingTask(taskType: string): Promise { await attachStarting() landInit() - await adapter.awaitStarted(SESSION) + await claudeStartupSettled(adapter, SESSION) const fanOut = await send('fan out') await vi.waitFor(async () => expect(await dispatchState(fanOut)).toBe('accepted')) frame({ 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 e6c3cead9313..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 @@ -163,6 +163,18 @@ describe('a create that fails before any process spawns', () => { 'launchFolderMissing', 'The folder this chat ran in no longer exists. Restore it to continue this chat.' ], + [ + 'a resume whose transcript is in another Claude account', + 'claude transcript is not in the selected account', + 'historyInOtherAccount', + "This chat's history is in another Claude account. Switch back to that account to continue it." + ], + [ + 'a Command setting that names no runnable program', + 'the claude Command setting is not a runnable program', + 'agentCommandNotRunnable', + "Claude'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." + ], [ "Orca's own reason", 'claude sessions pin CLAUDE_CONFIG_DIR, not CODEX_HOME', 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 2551cd33237c..13edb107a4b4 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 @@ -274,21 +274,17 @@ function deferred() { describe('Stop on a child still proving its start', () => { it('ends the child and keeps the conversation and its readers (R1)', async () => { - const ended = deferred() - adapterExtras = { - awaitStarted: vi.fn(() => ended.promise), - closeSession: vi.fn(async () => { - ended.resolve() - return true - }) - } + 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' }) 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 stop()).toMatchObject({ ok: true, value: { cancelled: true } }) @@ -309,7 +305,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([next]) + expect(dispatch.mock.calls.map(([input]) => input.clientMessageId)).toEqual([first, next]) // The reader opened before the Stop saw the next message delivered on the same stream. expect( events.some( @@ -386,53 +382,39 @@ describe('a published child that dies while it proves its start', () => { const EXIT = START_EXIT const TEXT = START_TEXT - it.each([['the loop sees the start fail first'], ['the exit is processed first']])( - 'leaves one error row keyed by the start, and every queued message rejected with it: %s (R2)', - async (order) => { - const settled = deferred() - adapterExtras = { awaitStarted: vi.fn(() => settled.promise) } - await restartHost() - acquire.mockImplementation(spawnStartingChild) - const first = await accept('first') - const events = await subscribe() - const second = await accept('second') - await eventually(() => expect(adapterExtras.awaitStarted).toHaveBeenCalled()) - const child = currentChild() - - if (order === 'the exit is processed first') { - await exit(child, EXIT, true) - settled.resolve(START_FAILURE) - } else { - settled.resolve(START_FAILURE) - await eventually(async () => - expect((await submission(second))?.dispatchState).toBe('rejected') - ) - await exit(child, EXIT, true) - } + it('leaves one error row keyed by the start, and every message it was handed 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 })) + const first = await accept('first') + const events = await subscribe() + const second = await accept('second') + await eventually(() => expect(dispatch).toHaveBeenCalledTimes(1)) + const child = currentChild() - await eventually(async () => - expect((await submission(second))?.dispatchState).toBe('rejected') - ) - expect(await statusRows()).toEqual([ - { - itemId: `orca:${encodeURIComponent(`start-failure:${child.acquisitionGeneration}`)}`, - text: TEXT, - tone: 'error', - failure: START_FAILURE - } - ]) - for (const id of [first, second]) { - expect(await submission(id)).toMatchObject({ - dispatchState: 'rejected', - reason: TEXT, - rejection: START_FAILURE - }) + await exit(child, EXIT, true) + + await eventually(async () => expect((await submission(second))?.dispatchState).toBe('rejected')) + expect(await statusRows()).toEqual([ + { + itemId: `orca:${encodeURIComponent(`start-failure:${child.acquisitionGeneration}`)}`, + text: TEXT, + tone: 'error', + failure: START_FAILURE } - expect(rejectedIn(events, second)).toBe(true) - expect(dispatch).not.toHaveBeenCalled() - expect(acquire).toHaveBeenCalledTimes(2) + ]) + for (const id of [first, second]) { + expect(await submission(id)).toMatchObject({ + dispatchState: 'rejected', + reason: TEXT, + rejection: START_FAILURE + }) } - ) + expect(rejectedIn(events, second)).toBe(true) + expect(acquire).toHaveBeenCalledTimes(2) + }) }) /** A start by an operation that needs the agent, such as a goal change, outside the loop. */ @@ -447,7 +429,6 @@ describe('a start another operation made that dies while a sent message waits on const TEXT = START_TEXT it("is the message's own failed start: one error row, the message rejected, no second start (R2)", async () => { - adapterExtras = { awaitStarted: vi.fn(async () => START_FAILURE) } await restartHost() acquire.mockImplementation(spawnStartingChild) // An operation that needs the agent starts a child that has not proven its start. @@ -512,8 +493,7 @@ describe('a start another operation made that dies while a sent message waits on expect(acquire).toHaveBeenCalledTimes(3) }) - it('waits on a child started since the failed one, not on the failure (R2)', async () => { - adapterExtras = { awaitStarted: vi.fn(async () => undefined) } + it('hands over to a child started since the failed one, not to the failure (R2)', async () => { await restartHost() acquire.mockImplementation(spawnStartingChild) await startForOperation() @@ -536,13 +516,29 @@ describe('a start another operation made that dies while a sent message waits on describe('a child that ends before its message is handed over', () => { it('starts one child for the message, then rejects it and stops (R2)', async () => { - // The child the loop starts exits between its start step and its handover step. - adapterExtras = { - awaitStarted: vi.fn(async () => { - await exit(currentChild(), 'codex app-server crashed') - }) - } await restartHost() + // The child the loop starts exits as its start step returns: that exit, asked for on the lane + // then, lands before the handover step. + acquire.mockImplementation(async (input) => { + const child = await spawnChild(input) + const { acquisitionGeneration } = child + if (acquisitionGeneration === undefined) { + throw new Error('spawnChild always names a generation') + } + void host.handleAdapterEvent({ + type: 'ended', + sessionId: SESSION, + fence: input.fence, + acquisitionGeneration, + reason: 'codex app-server crashed', + failure: { + kind: 'providerExited', + detail: { text: 'codex app-server crashed', audience: 'log' } + }, + cause: 'unexpected-exit' + }) + return child + }) const id = await accept('hello') const events = await subscribe() @@ -566,47 +562,34 @@ describe('a child that ends before its message is handed over', () => { }) }) -describe('another child indexed while the loop waits on the one it started', () => { - it('hands nothing over until the child now there has proven its start (R2)', async () => { - const starts = new Map>>() - const startOf = (generation: string) => { - const start = starts.get(generation) ?? deferred() - starts.set(generation, start) - return start - } - adapterExtras = { - awaitStarted: vi.fn(() => startOf(currentChild().acquisitionGeneration).promise), - // The stop ends the child without settling the start the loop is waiting on. - closeSession: vi.fn(async () => true) - } +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 () => { + 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' }) const first = await accept('first') - await eventually(() => expect(adapterExtras.awaitStarted).toHaveBeenCalledTimes(1)) + await eventually(() => expect(dispatch).toHaveBeenCalledTimes(1)) const stopped = currentChild() expect(await stop()).toMatchObject({ ok: true }) - const second = await accept('second') - // An operation that needs the agent starts its own child before the loop's handover step runs. + // An operation that needs the agent starts its own child before the second message is sent. await host['serialize'](SESSION, () => ensureStructuredAgentSessionAgent(host['attachContext'](), SESSION) ) const replacement = currentChild() expect(replacement.acquisitionGeneration).not.toBe(stopped.acquisitionGeneration) + const secondId = await accept('second') - startOf(stopped.acquisitionGeneration).resolve() - await eventually(() => expect(adapterExtras.awaitStarted).toHaveBeenCalledTimes(2)) - expect(dispatch).not.toHaveBeenCalled() - - await host.handleAdapterEvent({ - type: 'started', - ...replacement, - reportedOptions: { model: 'sonnet' }, - restoreSkippedOptions: [] + await eventually(async () => + expect((await submission(secondId))?.dispatchState).toBe('accepted') + ) + expect(conversation()?.child).toMatchObject({ + generation: replacement.acquisitionGeneration, + phase: 'starting' }) - startOf(replacement.acquisitionGeneration).resolve() - - await eventually(async () => expect((await submission(second))?.dispatchState).toBe('accepted')) - expect(dispatch.mock.calls.map(([input]) => input.clientMessageId)).toEqual([second]) + expect(dispatch.mock.calls.map(([input]) => input.clientMessageId)).toEqual([first, secondId]) + expect(acquire).toHaveBeenCalledTimes(3) expect(await submission(first)).toMatchObject({ reason: DISPATCH_REJECTED_CANCELLED }) }) }) @@ -651,7 +634,7 @@ describe('a quit with a message still queued', () => { expect(await keptCards()).toEqual([]) }) - it('waits for the start already in flight and stops the child it produced (R2)', async () => { + it('stops a start already in flight before it launches a child (R2)', async () => { const starting = deferred() const closeSession = vi.fn(async () => true) adapterExtras = { closeSession } @@ -665,13 +648,20 @@ describe('a quit with a message still queued', () => { }) const id = await accept('hello', { person: true }) await eventually(() => expect(recovering).toHaveBeenCalled()) + const acquiresBefore = acquire.mock.calls.length const quit = host.flushAllStreamedEvents() starting.resolve() await quit - expect(closeSession).toHaveBeenCalledWith(SESSION) - expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released' }) + // Quit aborts the start, so nothing is launched behind it and nothing is left to stop. + expect(acquire).toHaveBeenCalledTimes(acquiresBefore) + expect(closeSession).not.toHaveBeenCalled() + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'released', + ownerProcess: null, + deathEvidence: { detail: 'reservation failed before spawn' } + }) expect(dispatch).not.toHaveBeenCalled() expect(await afterRelaunch(id)).toMatchObject({ dispatchState: 'rejected', ...HOST_RESTARTED }) expect(await keptCards()).toEqual([id]) @@ -712,23 +702,17 @@ async function settleLoop(): Promise { } describe('how a stopped child ends the start its loop was waiting on', () => { - /** A child the loop waits on, whose start the stop below does not settle, so a message sent - * after the stop is queued when the loop next looks. */ + /** 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. */ async function stoppedWhileStarting(stop: () => Promise) { - const start = deferred() - adapterExtras = { - awaitStarted: vi.fn(() => start.promise), - closeSession: vi.fn(async () => true) - } + adapterExtras = { closeSession: vi.fn(async () => true) } await restartHost() acquire.mockImplementationOnce(spawnStartingChild) + dispatch.mockResolvedValueOnce({ state: 'admitted' }) await accept('first') - await eventually(() => expect(adapterExtras.awaitStarted).toHaveBeenCalledTimes(1)) + await eventually(() => expect(dispatch).toHaveBeenCalledTimes(1)) await stop() - const second = await accept('second') - adapterExtras.awaitStarted = undefined - start.resolve() - return second + return accept('second') } it("goes on after a user's Stop and delivers what was sent since (R2)", async () => { @@ -750,22 +734,29 @@ describe('how a stopped child ends the start its loop was waiting on', () => { ) } - /** A message queued behind a starting child when the close's stop cut it. `beforeStart` runs - * after the stop and before the loop looks again. */ + /** A message queued for a starting child when the close's stop cut it: asked for during the + * start step, the stop runs before the handover. `beforeStart` runs after the stop and before + * the loop looks again. */ async function closedWhileStarting(beforeStart: () => void = () => undefined) { const start = deferred() - adapterExtras = { - awaitStarted: vi.fn(() => start.promise), - closeSession: vi.fn(async () => true) - } + adapterExtras = { closeSession: vi.fn(async () => true) } await restartHost() - acquire.mockImplementationOnce(spawnStartingChild) + acquire.mockImplementationOnce(async (input) => { + await start.promise + return spawnStartingChild(input) + }) const first = await accept('first') - await eventually(() => expect(adapterExtras.awaitStarted).toHaveBeenCalledTimes(1)) - await closeStopOnly() - const starts = acquire.mock.calls.length - beforeStart() + await eventually(() => expect(acquire).toHaveBeenCalledTimes(2)) + let starts = 0 + const closed = host['serialize'](SESSION, async () => { + await stopStructuredAgentSessionAgentUnderSerialize(host['lifetimeContext'](), SESSION, { + cause: 'user-close' + }) + starts = acquire.mock.calls.length + beforeStart() + }) start.resolve() + await closed await settleLoop() return { first, starts } } @@ -784,17 +775,21 @@ describe('how a stopped child ends the start its loop was waiting on', () => { it('keeps a person’s message the user closed as a held card, and starts no child for it', async () => { const start = deferred() - adapterExtras = { - awaitStarted: vi.fn(() => start.promise), - closeSession: vi.fn(async () => true) - } + adapterExtras = { closeSession: vi.fn(async () => true) } await restartHost() - acquire.mockImplementationOnce(spawnStartingChild) + // The start step holds the queue: the message is still queued when the close's stop runs. + acquire.mockImplementationOnce(async (input) => { + await start.promise + return spawnStartingChild(input) + }) const first = await accept('first', { person: true }) - await eventually(() => expect(adapterExtras.awaitStarted).toHaveBeenCalledTimes(1)) - await closeStopOnly() - const starts = acquire.mock.calls.length + await eventually(() => expect(acquire).toHaveBeenCalledTimes(2)) + let starts = 0 + const closed = closeStopOnly().then(() => { + starts = acquire.mock.calls.length + }) start.resolve() + await closed await settleLoop() expect(await submission(first)).toMatchObject({ dispatchState: 'rejected', ...CHAT_CLOSED }) @@ -857,25 +852,31 @@ describe('how a stopped child ends the start its loop was waiting on', () => { it("fails the start after a host stop, as a start Orca stopped rather than the provider's (R2)", async () => { const reason = 'the start watchdog fired' - const second = await stoppedWhileStarting(() => - host['serialize'](SESSION, () => - stopStructuredAgentSessionAgentUnderSerialize(host['lifetimeContext'](), SESSION, { - cause: 'host-stop', - reason - }) - ) + 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 host['serialize'](SESSION, () => + stopStructuredAgentSessionAgentUnderSerialize(host['lifetimeContext'](), SESSION, { + cause: 'host-stop', + reason + }) ) - await eventually(async () => expect((await submission(second))?.dispatchState).toBe('rejected')) + // What the stopped start was handed 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.' - expect(await submission(second)).toMatchObject({ + expect(await submission(first)).toMatchObject({ reason: text, rejection: { kind: 'hostStopped' } }) expect(await statusRows()).toEqual([ { itemId: expect.any(String), text, tone: 'error', failure: { kind: 'hostStopped' } } ]) - expect(dispatch).not.toHaveBeenCalled() + const second = await accept('second') + await eventually(async () => expect((await submission(second))?.dispatchState).toBe('accepted')) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts index 18cfaba38fd0..734f262f7b57 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts @@ -1,6 +1,6 @@ // A publish-first create proves nothing about the model until Claude answers startup. The record // must never hold the catalog's default in the meantime: an owner handoff or a reopen would -// replay it as a `set_model` and silently move a user whose CLI default is not Sonnet. +// launch it as `--model` and silently move a user whose CLI default is not Sonnet. import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -10,7 +10,8 @@ import type { AgentSessionStatusEvent } from '../../../shared/agent-session-wire import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' import { fakeClaude, - PROVIDER_SESSION_ID + PROVIDER_SESSION_ID, + claudeStartupSettled } from '../../claude/claude-structured-session-test-support' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' @@ -118,7 +119,7 @@ describe('a publish-first Claude create whose init is slow', () => { expect(store.getRecord(SESSION)?.options?.model).toBeUndefined() expect(lastPhase()).toBe('starting') - await adapter.awaitStarted(SESSION) + await claudeStartupSettled(adapter, SESSION) await Promise.all(lifecycle) expect(store.getRecord(SESSION)?.options?.model).toBe('claude-opus-9') @@ -132,7 +133,7 @@ describe('a publish-first Claude create whose init is slow', () => { ).resolves.toMatchObject({ ok: true }) expect(store.getRecord(SESSION)?.options?.model).toBe('opus') - await adapter.awaitStarted(SESSION) + await claudeStartupSettled(adapter, SESSION) await Promise.all(lifecycle) expect(store.getRecord(SESSION)?.options?.model).toBe('opus') @@ -142,7 +143,7 @@ describe('a publish-first Claude create whose init is slow', () => { it('keeps the picked model across a resume whose new child starts on its own default', async () => { const params = claudeParams() await host.attach(CALLER, { ...params, options: { model: 'opus' } }) - await adapter.awaitStarted(SESSION) + await claudeStartupSettled(adapter, SESSION) await Promise.all(lifecycle) await host.close(SESSION, 'evict') const releasedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 @@ -152,11 +153,11 @@ describe('a publish-first Claude create whose init is slow', () => { ok: true }) expect(store.getRecord(SESSION)?.lease.runtimeFence).toBeGreaterThan(releasedFence) - // The new child's init reports its CLI default; the saved pick is restored over it. + // The new child is launched with the saved pick, whatever its CLI default. expect(store.getRecord(SESSION)?.options?.model).toBe('opus') expect(lastPhase()).toBe('starting') - await adapter.awaitStarted(SESSION) + await claudeStartupSettled(adapter, SESSION) await Promise.all(lifecycle) expect(store.getRecord(SESSION)?.options?.model).toBe('opus') 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 acc2e6488c3d..589bf37c2616 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 @@ -10,7 +10,10 @@ // asks the provider nothing: the event carries what the child proved. import { agentSessionLeaseAdmitsWriter } from '../../../shared/agent-session-lease-adjudication' -import type { StructuredAgentSessionStartedEvent } from './structured-agent-session-adapter' +import type { + StructuredAgentSessionOptionsSkippedEvent, + StructuredAgentSessionStartedEvent +} from './structured-agent-session-adapter' import type { StructuredAgentSessionHostDeps, StructuredAgentSessionHostSession @@ -75,8 +78,51 @@ async function persistStartedOptions( options: nativeSessionOptionsFromReport({ reported: event.reportedOptions, restoreSkipped: event.restoreSkippedOptions, + ...(event.retiredOptions ? { retired: event.retiredOptions } : {}), ...(record.options ? { priorOptions: record.options } : {}) }), now: context.now() }) } + +/** A running child showed saved options it cannot run: the record drops them, as a start that + * skipped them would, so the next start runs the provider's own. Reported, never thrown. */ +export function settleStructuredAgentSessionOptionsSkipped( + context: StructuredAgentSessionProviderStartedContext, + event: StructuredAgentSessionOptionsSkippedEvent +): Promise { + return context.serialize(event.sessionId, async () => { + const { store } = context.deps + const record = store.getRecord(event.sessionId) + if ( + !record?.options || + record.lease.runtimeFence !== event.fence || + !agentSessionLeaseAdmitsWriter(record.lease) + ) { + return + } + const options = { ...record.options } + for (const [key, value] of Object.entries(event.options)) { + // A pick made since the child launched is the user's, whatever the child showed. + if (options[key] === value) { + delete options[key] + } + } + try { + await store.replaceSessionOptions({ + sessionId: event.sessionId, + fence: event.fence, + options, + now: context.now() + }) + } catch (error) { + context.deps.logger.warn('dropping a saved option the provider cannot run failed', { + scope: 'provider-options-skipped', + sessionId: event.sessionId, + error + }) + } finally { + context.publishStatus?.(event.sessionId) + } + }) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-holds.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-holds.test.ts index b169bbe4e5e7..10b48321bc9a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-holds.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-holds.test.ts @@ -385,16 +385,13 @@ describe('after a restart, nothing sends by itself', () => { await rig.settleAccepted(working, 'stopped') // The agent at rest goes; Resume's hand-off of A must start a new one, which never starts. await rig.host.collaboratorsForTests().lifetime.idleSweep.tick() - let release: () => void = () => undefined - rig.awaitStarted.mockImplementation( - () => new Promise((resolve) => (release = () => resolve(undefined))) - ) + // The hand-off is recorded, never handed over: the new child stays in its spawn. + const release = rig.holdNextStart() expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) await eventually(async () => expect(await rig.handoff(first)).toBeDefined()) const cutShort = await rig.handoffId(first) expect((await rig.handoff(first))?.handedOverAt).toBeUndefined() rig.crashRestartHostProcess() - rig.awaitStarted.mockImplementation(async () => undefined) release() await published() // The new host refuses the leftover hand-off, and A waits again in its own place. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig-provider.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig-provider.test-fixture.ts index 864ad55f13cf..ccabd39503e6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig-provider.test-fixture.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig-provider.test-fixture.ts @@ -20,6 +20,8 @@ export type QueuedRigProviderOptions = { starting?: true /** The provider's Stop ends its child, as Claude's does. */ stopEndsSession?: true + /** Its child never answers its start, so it runs nothing it is handed. */ + startUnanswered?: true } export function createQueuedRigProvider( @@ -31,9 +33,9 @@ export function createQueuedRigProvider( const dispatch: Mock = vi.fn(async () => ({ state: 'admitted' as const })) - const awaitStarted: Mock> = vi.fn( - async () => undefined - ) + // Every start the host asks for; one `holdNextStart` holds stays in its spawn until released. + const starts: Mock<() => void> = vi.fn() + let startHold: Promise | null = null // The provider's receipt of a /compact; its end arrives later, as `finishCompact` writes it. const compact: Mock> = vi.fn(async () => ({ state: 'accepted' as const, @@ -49,6 +51,10 @@ export function createQueuedRigProvider( const adapter: StructuredAgentSessionAdapter = { acquire: async ({ identity, fence, spawnToken, events: sink }) => { + starts() + const hold = startHold + startHold = null + await hold events = sink const resumes = options.restartable === true && @@ -72,12 +78,12 @@ export function createQueuedRigProvider( } }, dispatch, - awaitStarted, closeSession, releaseAcquisition: vi.fn(async () => true), compact, cancelTurn, ...(options.stopEndsSession ? { stopEndsSession: () => true } : {}), + ...(options.startUnanswered ? { startAnswered: () => false } : {}), answerPrompt: vi.fn(async () => undefined), setOption: vi.fn(async () => undefined) } @@ -99,6 +105,13 @@ export function createQueuedRigProvider( ) } + /** Holds the next start in its spawn, with what it was asked for still queued, until released. */ + function holdNextStart(): () => void { + let release = (): void => {} + startHold = new Promise((resolve) => (release = resolve)) + return () => release() + } + /** The event sink the provider writes through. */ function providerEvents(): StructuredAgentSessionEventSink { if (!events) { @@ -110,7 +123,8 @@ export function createQueuedRigProvider( return { adapter, dispatch, - awaitStarted, + starts, + holdNextStart, compact, cancelTurn, closeSession, 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 58ef7a607ba0..a0e3af88c49b 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 @@ -13,6 +13,7 @@ import type { AgentSessionQueuePause } from '../../../shared/agent-session-wire' import type { AgentMessageSource } from '../../../shared/agent-session-message-source' import { agentSessionMessagePayload } from '../../../shared/structured-agent-session-send-mutation' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { rotateStructuredAgentSessionHostInstanceForTests } from './structured-agent-session-queued-pause' import { @@ -45,13 +46,15 @@ export async function createQueuedMessageTestRig( options: QueuedRigProviderOptions & { /** Lets a test sweep idle chats on its own `tick`. */ idleSweep?: { idleMs: number; intervalMs: number } + /** Teardown records its restart offers, which `restartOffers` lists. */ + recoveryCapsule?: true } = {} ) { const root = await mkdtemp(join(tmpdir(), 'orca-queued-messages-')) resetHostTestOperationIds() const store = await openTestAgentSessionRecordStore(root) const provider = createQueuedRigProvider(store, options) - const { dispatch, awaitStarted, compact, cancelTurn, closeSession } = provider + const { dispatch, compact, cancelTurn, closeSession } = provider const makeHost = () => new StructuredAgentSessionHost({ agents: claudeAndCodexDeclared(), @@ -62,7 +65,8 @@ export async function createQueuedMessageTestRig( claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-1', now: () => NOW, - ...(options.idleSweep ? { idleSweep: options.idleSweep } : {}) + ...(options.idleSweep ? { idleSweep: options.idleSweep } : {}), + ...(options.recoveryCapsule ? { recoveryCapsule: new AgentSessionRecoveryCapsule(root) } : {}) }) let host = makeHost() expect(await host.attach(QUEUED_RIG_CALLER, hostTestAttachParams(null))).toMatchObject({ @@ -229,6 +233,11 @@ export async function createQueuedMessageTestRig( return page.page.queuePause ?? null } + /** The restart offers this host lists for the chats an earlier one stopped. */ + async function restartOffers() { + return (await host.restartResume.list()).map(({ sessionId, work }) => ({ sessionId, work })) + } + function resume(clientOperationId = hostTestOperationId()) { return host.queuedMessagesResume(QUEUED_RIG_CALLER, { envelope: envelope({}, 'agentSession.queuedMessagesResume', clientOperationId) @@ -249,8 +258,9 @@ export async function createQueuedMessageTestRig( dispatch, cancelTurn, closeSession, - awaitStarted, compact, + starts: provider.starts, + holdNextStart: provider.holdNextStart, finishCompact: provider.finishCompact, providerEvents: provider.providerEvents, envelope, @@ -269,6 +279,7 @@ export async function createQueuedMessageTestRig( crashRestartHostProcess, quitRestartHostProcess, queuePause, + restartOffers, resume, dispose } 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 7418e7eb8df0..686940c4fa77 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 @@ -31,16 +31,16 @@ import { hostTestMessage, hostTestOperationId } from './structured-agent-session-host-test-data' +import { holdDelivery } from './structured-agent-session-delivery-hold.test-fixture' let rig: QueuedMessageTestRig let host: QueuedMessageTestRig['host'] let store: QueuedMessageTestRig['store'] let dispatch: QueuedMessageTestRig['dispatch'] -let awaitStarted: QueuedMessageTestRig['awaitStarted'] beforeEach(async () => { rig = await createQueuedMessageTestRig() - ;({ host, store, dispatch, awaitStarted } = rig) + ;({ host, store, dispatch } = rig) }) afterEach(() => rig.dispose()) @@ -378,29 +378,27 @@ describe('Stop and Delete', () => { expect(await drafts()).toHaveLength(2) }) - /** A draft consumed into a submission the delivery loop has not handed over: - * the loop is held at the child's start proof until the returned release. */ + /** A draft consumed into a submission the delivery loop has not handed over: its delivery step + * is held until the returned release, so a step asked for meanwhile runs ahead of the handover. */ async function consumedButNotHandedOver(): Promise<{ draftId: string; release: () => void }> { const working = await workingSend() const queued = await send('stopped in flight', 'queue-if-active').result if (!queued.ok || !('queued' in queued.value)) { throw new Error('expected a queued receipt') } - let release: () => void = () => undefined - awaitStarted.mockImplementationOnce( - () => new Promise((resolve) => (release = () => resolve(undefined))) - ) + const { release } = holdDelivery() await settleAccepted(working, 'a') const draftId = queued.value.queued.messageId await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) expect((await rig.handoff(draftId))?.handedOverAt).toBeUndefined() - return { draftId, release: () => release() } + return { draftId, release } } it("a Stop between consume and the agent's receipt sends the draft back to waiting, paused like the rest", async () => { const { draftId, release } = await consumedButNotHandedOver() - const stopped = await stop() + const stopping = stop() release() + const stopped = await stopping expect(stopped).toMatchObject({ ok: true }) expect(await drafts()).toEqual([{ messageId: draftId, state: 'waiting' }]) expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts index cf1746eaaf0a..d55f7cea3fb0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts @@ -11,12 +11,14 @@ import { hostTestMessage, hostTestOperationId } from './structured-agent-session-host-test-data' +import { holdDelivery } from './structured-agent-session-delivery-hold.test-fixture' import { QUEUED_RIG_CALLER, createQueuedMessageTestRig, eventually, type QueuedMessageTestRig } from './structured-agent-session-queued-message-rig.test-fixture' +import { openRigTurnFor } from './structured-agent-session-queued-rig-turn.test-fixture' import { sameQueuePause } from './structured-agent-session-queued-publication' import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' import { @@ -237,6 +239,7 @@ describe('a card queued after a Stop is a new instruction', () => { it('a card sent now before the Stop and taken anyway lifts nothing, and holds nothing typed later', async () => { const working = await rig.workingSend() + await openRigTurnFor(rig, working) const sentId = await queuedDraft('sent now into the turn') await rig.sendNow(sentId) await handedOver(sentId) @@ -245,6 +248,7 @@ describe('a card queued after a Stop is a new instruction', () => { expect(await rig.queuePause()).toBeNull() await rig.settleAccepted(await rig.handoffId(sentId), 'sent-now') await rig.settleAccepted(working, 'stopped') + await openRigTurnFor(rig, working, 'interrupted') const mail = rig.send('coordinator mail') await mail.result await eventually(async () => @@ -393,13 +397,16 @@ describe('a card handed off after a restart', () => { ) await rig.restartHostProcess() // Sent again in this process, then withdrawn by a Stop before the agent had it. - let release: () => void = () => undefined - rig.awaitStarted.mockImplementationOnce( - () => new Promise((resolve) => (release = () => resolve(undefined))) - ) - expect(await rig.sendNow(draftId)).toMatchObject({ ok: true }) - await rig.stop() + // Its delivery is held, so the Stop runs ahead of the handover. + const { held, release } = holdDelivery() + const handedOver = rig.dispatch.mock.calls.length + const sending = rig.sendNow(draftId) + await held + const stopping = rig.stop() release() + expect(await sending).toMatchObject({ ok: true }) + await stopping + expect(rig.dispatch).toHaveBeenCalledTimes(handedOver) await eventually(async () => expect(await rig.drafts()).toEqual([{ messageId: draftId, state: 'waiting' }]) ) @@ -509,50 +516,4 @@ describe('a failed Stop', () => { } await expectPaused(draftId) }) - - // The drain shares the Stop's lane, so a failed Stop must hold it until its pause lands. - it('keeps its pause when it fails while its writes wait behind owed work', async () => { - const turn = { provider: 'codex' as const, threadId: THREAD, turnId: 'turn-w', ordinal: 900 } - const turnRow = (state: 'running' | 'completed') => { - rig - .providerEvents() - .appendItem( - turn, - { kind: 'turn', turnId: 'turn-w', state, startedAt: 1 }, - { turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - } - const working = await rig.workingSend() - turnRow('running') - await rig.settleAccepted(working, 'w') - const draftId = await queuedDraft('paused by stop') - // The interrupt ends the turn, so the drain runs as soon as the lane frees. - vi.mocked(rig.host.deps.adapter.cancelTurn).mockImplementationOnce(async () => { - turnRow('completed') - return { cancelled: true } - }) - const journal = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal - if (!journal) { - throw new Error('no open journal') - } - const append = journal.appendItem.bind(journal) - vi.spyOn(journal, 'appendItem').mockImplementation((identity, body, options) => - body.kind === 'status' - ? Promise.reject(new Error('disk full')) - : append(identity, body, options) - ) - // Owed from the Stop's first write, after the open paid any import: its writes wait behind it. - const owed = Promise.withResolvers() - const withdraw = journal.rejectQueuedSubmissions.bind(journal) - vi.spyOn(journal, 'rejectQueuedSubmissions').mockImplementation((...args) => { - journal['queue'].owe(() => owed.promise) - return withdraw(...args) - }) - const stopping = rig.stop() - await eventually(() => expect(rig.host.deps.adapter.cancelTurn).toHaveBeenCalledOnce()) - owed.resolve() - await expect(stopping).rejects.toThrow('disk full') - expect(journal.activeTurnId()).toBeNull() - await expectPaused(draftId) - }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-repeat-press.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-repeat-press.test.ts index cada58ceecbb..82e750b908be 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-repeat-press.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-repeat-press.test.ts @@ -8,6 +8,7 @@ import { eventually, type QueuedMessageTestRig } from './structured-agent-session-queued-message-rig.test-fixture' +import { openRigTurnFor } from './structured-agent-session-queued-rig-turn.test-fixture' import { HOST_TEST_SESSION as SESSION } from './structured-agent-session-host-test-data' let rig: QueuedMessageTestRig @@ -28,7 +29,7 @@ async function queuedDraft(text: string): Promise { describe('a repeated draft press under a fresh operation id', () => { it('Send-now pressed twice sends the draft once and answers both presses with its submission', async () => { - await rig.workingSend() + await openRigTurnFor(rig, await rig.workingSend()) const draftId = await queuedDraft('send me now') const [first, second] = await Promise.all([rig.sendNow(draftId), rig.sendNow(draftId)]) expect(first).toMatchObject({ ok: true, replayed: false }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-rig-turn.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-rig-turn.test-fixture.ts new file mode 100644 index 000000000000..e5eda972aee3 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-rig-turn.test-fixture.ts @@ -0,0 +1,49 @@ +// What a provider does with a rig's send that the rig's adapter never models: the turn it opened, +// as the provider's turn record writes it, or its withdrawal by a Stop. A message sent while that +// send's turn is still opening waits for one of them. + +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import { + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD +} from './structured-agent-session-host-test-data' +import type { QueuedMessageTestRig } from './structured-agent-session-queued-message-rig.test-fixture' + +/** Opens the turn `id` started; called again with `interrupted` to end it. */ +export async function openRigTurnFor( + rig: QueuedMessageTestRig, + id: string, + state: 'running' | 'interrupted' = 'running' +): Promise { + const journal = rig.host.collaboratorsForTests().sessions.get(SESSION)?.journal + if (!journal) { + throw new Error('expected the conversation open') + } + await journal.appendItem( + { provider: 'codex', threadId: THREAD, turnId: `turn-of-${id}`, ordinal: 0 }, + { + kind: 'turn', + turnId: `turn-of-${id}`, + startedAt: Date.now(), + userItemId: agentJournalSubmissionKey(id), + ...(state === 'running' ? { state } : { state, completedAt: Date.now() }) + }, + { + fence: rig.store.getRecord(SESSION)?.lease.runtimeFence ?? 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + ) +} + +/** A Stop took back `id`, whose turn never opened, as the provider's Stop settles it. */ +export async function withdrawnByStop(rig: QueuedMessageTestRig, id: string): Promise { + await rig.host.settleLateDispatch({ + sessionId: SESSION, + clientMessageId: id, + state: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }) + }) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts index 4e45ab4a6cc3..001e0b10786b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts @@ -19,6 +19,7 @@ import { QUEUED_RIG_CALLER, type QueuedMessageTestRig } from './structured-agent-session-queued-message-rig.test-fixture' +import { openRigTurnFor } from './structured-agent-session-queued-rig-turn.test-fixture' import { structuredQueuePauses } from './structured-agent-session-queued-pause' let rig: QueuedMessageTestRig @@ -147,6 +148,7 @@ describe("Stop's event", () => { it('a card the Stop withdrew comes back once, under the pause, and is never sent again on its own', async () => { const working = await rig.workingSend() + await openRigTurnFor(rig, working) const sentId = await queuedDraft('sent now into the turn') await rig.sendNow(sentId) await eventually(async () => expect((await rig.handoff(sentId))?.handedOverAt).toBeDefined()) @@ -155,6 +157,7 @@ describe("Stop's event", () => { await withdraw(handoffId) await withdraw(handoffId) await rig.settleAccepted(working, 'stopped') + await openRigTurnFor(rig, working, 'interrupted') await new Promise((resolve) => setTimeout(resolve, 250)) expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) expect(await rig.drafts()).toEqual([{ messageId: sentId, state: 'waiting' }]) @@ -167,7 +170,7 @@ describe("Stop's event", () => { }) it('survives a host crash, unshown: the next open marks the hand-off unknown, nothing sends, Resume releases', async () => { - await rig.workingSend() + await openRigTurnFor(rig, await rig.workingSend()) const sentId = await queuedDraft('sent now into the turn') const waiting = await queuedDraft('waiting behind it') await rig.sendNow(sentId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-withdrawn-draft.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-withdrawn-draft.test.ts index 5e23fbe1a84a..6cf71ea894c1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-withdrawn-draft.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-withdrawn-draft.test.ts @@ -8,6 +8,7 @@ import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { JournalQueuedMessages } from '../agent-session-journal/journal-queued-messages' import { AgentSessionJournal } from '../agent-session-journal/journal-store' import { HOST_TEST_SESSION as SESSION } from './structured-agent-session-host-test-data' +import { holdDelivery } from './structured-agent-session-delivery-hold.test-fixture' import { createQueuedMessageTestRig, eventually, @@ -43,11 +44,8 @@ it('Stop, then a user send: the withdrawn draft and the paused cards behind it d const a = await queuedDraft('A') const b = await queuedDraft('B') const c = await queuedDraft('C') - // The turn ends and the drain consumes A; the agent's start is held, so A is not handed over. - let release: () => void = () => undefined - rig.awaitStarted.mockImplementationOnce( - () => new Promise((resolve) => (release = () => resolve(undefined))) - ) + // The turn ends and the drain consumes A; its delivery is held, so A is not handed over. + const { release } = holdDelivery() await rig.settleAccepted(working, 'working') await eventually(async () => expect(await rig.handoff(a)).toBeDefined()) // Never under the draft's own id: the submission names A by its link. @@ -55,8 +53,10 @@ it('Stop, then a user send: the withdrawn draft and the paused cards behind it d expect(firstA).not.toBe(a) expect((await rig.submission(firstA))?.handedOverAt).toBeUndefined() - expect(await rig.stop()).toMatchObject({ ok: true }) + // The Stop runs once the held step returns, ahead of A's handover. + const stopped = rig.stop() release() + expect(await stopped).toMatchObject({ ok: true }) // A is back in its place, behind the same queue pause as B and C: no returned card blocks them. const paused = [a, b, c].map((messageId) => ({ messageId, state: 'waiting' })) expect(await rig.drafts()).toEqual(paused) @@ -104,10 +104,7 @@ it('Stop, then a user send: the withdrawn draft and the paused cards behind it d it('a Stop whose withdrawal throws after landing still answers; the draft it released sends again under a fresh id', async () => { const working = await rig.workingSend() const a = await queuedDraft('A') - let release: () => void = () => undefined - rig.awaitStarted.mockImplementationOnce( - () => new Promise((resolve) => (release = () => resolve(undefined))) - ) + const { release } = holdDelivery() await rig.settleAccepted(working, 'working') await eventually(async () => expect(await rig.handoff(a)).toBeDefined()) const firstA = await rig.handoffId(a) @@ -124,7 +121,9 @@ it('a Stop whose withdrawal throws after landing still answers; the draft it rel }) const warned = vi.spyOn(console, 'warn').mockImplementation(() => undefined) try { - expect(await rig.stop()).toMatchObject({ ok: true }) + const stopped = rig.stop() + release() + expect(await stopped).toMatchObject({ ok: true }) expect(warned).toHaveBeenCalledWith( "[agent-session] stop-queued-bookkeeping: Stop's withdrawal failed", expect.objectContaining({ step: 'withdrawal', error: new Error('disk full') }) @@ -142,15 +141,12 @@ it('a Stop whose withdrawal throws after landing still answers; the draft it rel }) }) -/** A consumed card whose delivery is held at the agent's start, so a Stop withdraws it; - * the settlement hook throws on that withdrawal's row, leaving the card owed a return. */ +/** A consumed card whose delivery is held, so a Stop withdraws it ahead of its handover; the + * settlement hook throws on that withdrawal's row, leaving the card owed a return. */ async function stopWithSkippedSettlement(): Promise<{ a: string; working: string }> { const working = await rig.workingSend() const a = await queuedDraft('A') - let release: () => void = () => undefined - rig.awaitStarted.mockImplementationOnce( - () => new Promise((resolve) => (release = () => resolve(undefined))) - ) + const { release } = holdDelivery() await rig.settleAccepted(working, 'working') await eventually(async () => expect(await rig.handoff(a)).toBeDefined()) const settle = JournalQueuedMessages.prototype.onRowInTransaction @@ -166,7 +162,9 @@ async function stopWithSkippedSettlement(): Promise<{ a: string; working: string }) const warned = vi.spyOn(console, 'warn').mockImplementation(() => undefined) try { - expect(await rig.stop()).toMatchObject({ ok: true }) + const stopped = rig.stop() + release() + expect(await stopped).toMatchObject({ ok: true }) } finally { hook.mockRestore() warned.mockRestore() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts index 38b2453c3bf1..be330810097d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts @@ -1,22 +1,18 @@ -// Read restore decides whether a session comes back at all. -// -// A chat still in the pre-SQLite format has no `journal.db`, so the probe that -// loads one reports nothing. Reading that as "no session" is what removed these -// chats: an unpublished session is also what prunes its tab out of the saved -// workspace, so the tab is gone before anything can explain itself. +// Read restore decides whether a session comes back at all: only one with a record and a journal +// to read is published, and publishing it starts no agent. -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { journalDirectoryFor } from '../agent-session-journal/journal-paths' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { closeTestJournalHostDatabases, openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { publishJournalSessionEpoch } from '../agent-session-journal/journal-row-table' import { restoreStructuredAgentSessionRead } from './structured-agent-session-read-restore' import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' @@ -62,16 +58,6 @@ const openDeps = () => ({ }) const opened: AgentSessionJournal[] = [] -async function writeRemnant(name: string): Promise { - const dir = journalDirectoryFor(journalRoot, { - workspaceId: WORKSPACE_ID, - sessionId: SESSION_ID - }) - await mkdir(dir, { recursive: true }) - await writeFile(join(dir, name), '{"kind":"epoch","v":1,"seq":1}\n', 'utf8') - return join(dir, name) -} - beforeEach(async () => { journalRoot = await mkdtemp(join(tmpdir(), 'orca-read-restore-')) }) @@ -82,40 +68,31 @@ afterEach(async () => { await rm(journalRoot, { recursive: true, force: true }) }) -describe('a session whose journal is still the pre-SQLite format', () => { - it('is published, carrying the message that explains it', async () => { - const transcript = await writeRemnant('log.jsonl') - - const restored = await restoreStructuredAgentSessionRead(openDeps(), SESSION_ID) - - expect(restored).not.toBeNull() - opened.push(restored!.session.journal) - const disclosed = restored!.session.journal - .snapshot() - .items.map((entry) => (entry.body.kind === 'status' ? entry.body.text : '')) - expect(disclosed.join('')).toContain(transcript) - // Publishing it costs no agent process; acquisition still waits for the user. - expect(restored!.session.child).toBeNull() - }) +describe('read restore', () => { + it('publishes a session with a journal, starting no agent', async () => { + const deps = openDeps() + const restored = await restoreStructuredAgentSessionRead(deps, SESSION_ID) + expect(restored).toBeNull() + publishJournalSessionEpoch( + deps.journalDatabase.db, + { sessionId: SESSION_ID, workspaceId: WORKSPACE_ID }, + 'epoch-1' + ) - it('is published for a remnant whose log is gone', async () => { - await writeRemnant('snapshot.json') + const published = await restoreStructuredAgentSessionRead(deps, SESSION_ID) - const restored = await restoreStructuredAgentSessionRead(openDeps(), SESSION_ID) - - expect(restored).not.toBeNull() - opened.push(restored!.session.journal) + expect(published).not.toBeNull() + opened.push(published!.session.journal) + expect(published!.session.child).toBeNull() }) - it('still drops a session with neither a journal nor a remnant', async () => { + it('drops a session with no journal', async () => { const restored = await restoreStructuredAgentSessionRead(openDeps(), SESSION_ID) expect(restored).toBeNull() }) - it('still drops a session with no record', async () => { - await writeRemnant('log.jsonl') - + it('drops a session with no record', async () => { const restored = await restoreStructuredAgentSessionRead(openDeps(), 'unknown-session') expect(restored).toBeNull() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.ts index cf80396d8204..8c19df9c6289 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.ts @@ -1,6 +1,3 @@ -import { existsSync } from 'node:fs' -import { findJournalFileFormatRemnant } from '../agent-session-journal/journal-file-format-remnant' -import { legacyJournalDatabaseFile } from '../agent-session-journal/journal-paths' import { readJournalSessionEpoch } from '../agent-session-journal/journal-row-table' import { openStructuredAgentSessionConversationJournal, @@ -11,7 +8,7 @@ import { /** * A reader's open: the conversation's own open, for a session that has a journal to read. One * with none — never written, or gone — stays unpublished rather than founding an empty one. - * Opening can still write: the crash boundary, and the row explaining an old-format history. + * Opening can still write: the crash boundary. */ export async function restoreStructuredAgentSessionRead( deps: StructuredAgentSessionConversationOpenDeps, @@ -21,22 +18,8 @@ export async function restoreStructuredAgentSessionRead( if (!record) { return null } - const database = deps.journalDatabase - if (readJournalSessionEpoch(database.db, sessionId) === null) { - // Not in the host's database yet: its history may still sit in a per-chat file the open - // imports, or in the pre-SQLite format the open explains. - const legacyDirectory = database.legacyDirectoryFor({ - workspaceId: record.location.workspaceId, - sessionId - }) - if ( - !existsSync(legacyJournalDatabaseFile(legacyDirectory)) && - !findJournalFileFormatRemnant(legacyDirectory) - ) { - return null - } + if (readJournalSessionEpoch(deps.journalDatabase.db, sessionId) === null) { + return null } - return openStructuredAgentSessionConversationJournal(deps, record, { - deferPerSessionImport: true - }) + return openStructuredAgentSessionConversationJournal(deps, record) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-reasoning-sweep.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-reasoning-sweep.test.ts index c54a65a5862c..2ea74bd77499 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-reasoning-sweep.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-reasoning-sweep.test.ts @@ -13,10 +13,10 @@ import { openAgentSessionJournal } from '../agent-session-journal/journal-store- import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { - captureUnfinishedStructuredAgentSessionWork, settleStaleStructuredAgentSessionState, settleStructuredAgentSessionDeadGeneration } from './structured-agent-session-dead-generation-settlement' +import { captureUnfinishedStructuredAgentSessionWork } from './structured-agent-session-unfinished-work' const SESSION = 'session-reasoning-sweep' const THREAD = 'thread-1' 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 5ff32f09e3c7..55489d66c268 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 @@ -32,6 +32,7 @@ import { openTestJournalHostDatabase } from '../agent-session-journal/journal-ho import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' +import { AgentSessionJournal } from '../agent-session-journal/journal-store' const CALLER = { callerKey: 'client-1' } const METHODS = ['agentSession.setOption', 'agentSession.send'] as const @@ -352,12 +353,9 @@ describe('agentSessionRefusalOperationState host oracle', () => { const unreadable = await createHarness() await unreadable.host.close(SESSION, 'evict') - // The journal's open asks where the chat's per-chat file lives before it reads anything. const unreadableOpen = vi - .spyOn(unreadable.host.deps.journalDatabase, 'legacyDirectoryFor') - .mockImplementation(() => { - throw new Error('journal path unreadable') - }) + .spyOn(AgentSessionJournal.prototype, 'open') + .mockRejectedValue(new Error('journal path unreadable')) const unreadableSend = { method: 'agentSession.send' as const, operationId: operationId() } record( await assertHostAgreement( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.test.ts index c14899af1128..c732a04b4580 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.test.ts @@ -9,7 +9,11 @@ import type { AgentJournalItemIdentity } from '../../../shared/agent-session-journal-types' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { openJournalOwingImport } from '../agent-session-journal/journal-owed-import-test-support' +import { + closeTestJournalHostDatabases, + openTestJournalHostDatabase +} from '../agent-session-journal/journal-host-database-test-support' +import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' import { createDeferredStructuredAgentSessionEventSink, type StructuredAgentSessionEventTarget, @@ -37,10 +41,8 @@ let journal: AgentSessionJournal beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-resolved-append-')) - // Its copy owed, so every write handed over waits in the queue: a resolver that read at submit - // would read the row before the revisions ahead of it had landed. - ;({ journal } = await openJournalOwingImport({ - stateDirectory: root, + journal = await openAgentSessionJournal({ + database: openTestJournalHostDatabase(root), identity: { sessionId: 'session-1', workspaceId: 'workspace-1', @@ -48,11 +50,12 @@ beforeEach(async () => { agent: 'codex', providerHandle: codexProviderHandle('thread-1') } - })) + }) }) afterEach(async () => { await journal.close() + closeTestJournalHostDatabases() await rm(root, { recursive: true, force: true }) }) @@ -90,7 +93,9 @@ describe('resolved revisions', () => { } // Three revisions of one row stay three operations; none replaces another. expect(deferred.state().queuedOperations).toBe(3) - deferred.bind(journalTarget()) + // Bound from inside a running read, so every revision waits in line: one resolved at + // handover would read the row before the revisions ahead of it had landed. + await journal.readInOrder(() => deferred.bind(journalTarget())) await expect(deferred.drained()).resolves.toEqual({ ok: true }) expect(rowText()).toBe('abc') }) 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 5f4e5bb6e797..6a868201baa1 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 @@ -104,6 +104,7 @@ export function createStructuredAgentSessionRestartResume( sessions, getRecord: deps.store.getRecord, childWork: surfaces.readChildWork, + startAnswered: (sessionId) => deps.adapter.startAnswered?.(sessionId), ...(deps.recoveryCapsule ? { capsule: deps.recoveryCapsule } : {}), teardownId: randomUUID(), now: surfaces.now, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.test.ts index 739ad2c5a33f..dc71bc09f071 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.test.ts @@ -20,6 +20,7 @@ function witnessed() { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: clear reads only the record's provider. AGENTS[sessionId] ? ({ provider: AGENTS[sessionId] } as AgentSessionRecord) : null, childWork: () => undefined, + startAnswered: () => undefined, capsule: { record: async (markers) => void recorded.push([...markers]) }, teardownId: 'teardown', now: () => 1, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.ts index 683fe79d9e87..648f92b6d595 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.ts @@ -33,6 +33,7 @@ export function createStructuredAgentSessionRestartWitnesses(deps: { sessions: ReadonlyMap> getRecord: (sessionId: string) => AgentSessionRecord | null childWork: WorkingAtStopInput['childWork'] + startAnswered: NonNullable capsule?: Pick teardownId: string now: () => number @@ -72,6 +73,7 @@ export function createStructuredAgentSessionRestartWitnesses(deps: { session: deps.sessions.get(sessionId), getRecord: deps.getRecord, childWork: deps.childWork, + startAnswered: deps.startAnswered, trigger, teardownId: deps.teardownId, now: deps.now() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts deleted file mode 100644 index 0cf019e72397..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts +++ /dev/null @@ -1,610 +0,0 @@ -// Startup restore lists a chat that is still in its per-chat file from a read-only fold of that -// file, and copies nothing: the copy waits for the chat's first real read or write, which is -// restore's own only for a chat the last run left mid-work. Restore stays the cost it was when -// every chat had its own file, and opens no file it does not restore. - -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' -import { QUEUED_MESSAGE_PAUSED_KEPT } from '../../../shared/agent-session-queued-message-wire' -import { existsSync } from 'node:fs' -import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import type { AgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker' -import { latestStructuredAgentSessionPrompt } from '../../../shared/structured-agent-session-latest-request' -import { AgentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import Database from '../../sqlite/sync-database' -import type * as SyncDatabaseModule from '../../sqlite/sync-database' -import { - closeTestJournalHostDatabases, - openTestJournalHostDatabase, - readTestJournalRows -} from '../agent-session-journal/journal-host-database-test-support' -import { - journalDirectoryFor, - legacyJournalDatabaseFile -} from '../agent-session-journal/journal-paths' -import { - readJournalSessionEpoch, - type JournalStoredRow -} from '../agent-session-journal/journal-row-table' -import { previewPerSessionJournal } from '../agent-session-journal/journal-per-session-import' -import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' -import { journalIdentityFor } from './structured-agent-session-attach' -import { attachParamsForRecord } from './structured-agent-session-conversation-open' -import { StructuredAgentSessionConversations } from './structured-agent-session-conversations' -import { createStructuredAgentSessionConversationLifetime } from './structured-agent-session-conversation-lifetime' -import type { StructuredAgentSessionLifetimeContext } from './structured-agent-session-host-lifetime' -import { createStructuredAgentSessionRestartOfferWithdrawal } from './structured-agent-session-restart-offer-withdrawal' -import { restoreStructuredAgentSessionsOnRestart } from './structured-agent-session-restart-restore' -import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' -import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { USER_MESSAGE_SOURCE } from '../../../shared/agent-session-message-source' - -const { readOnlyOpens, openReadOnly } = vi.hoisted(() => ({ - readOnlyOpens: new Array(), - openReadOnly: new Set() -})) - -// Every read-only open is of a per-chat file: the host's own database opens read-write. -vi.mock('../../sqlite/sync-database', async (importOriginal) => { - const actual = await importOriginal() - class RecordingDatabase extends actual.default { - constructor(...args: ConstructorParameters) { - super(...args) - if (args[1]?.readonly) { - readOnlyOpens.push(String(args[0])) - openReadOnly.add(this) - } - } - - override close(): void { - openReadOnly.delete(this) - super.close() - } - } - return { ...actual, default: RecordingDatabase } -}) - -const WORKSPACE_ID = 'repo-1::/tmp/workspace' -const PROMPT = 'add a retry' - -let root: string -let clock = 1_000 - -function recordFor(sessionId: string): AgentSessionRecord { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: restore reads only the id, location, provider, handle chain, account home and lease. - return { - schemaVersion: 2, - sessionId, - location: { - executionHostId: 'local', - wslDistro: null, - workspaceId: WORKSPACE_ID, - workspaceKind: 'git-worktree' - }, - provider: 'codex', - providerHandleChain: [ - { - linkId: `codex-1-${sessionId}`, - handle: codexProviderHandle(`thread-${sessionId}`), - origin: 'created', - mintedAtFence: 1, - observedAt: 1 - } - ], - accountHome: { variable: 'CODEX_HOME', path: '/tmp/codex-home' }, - createdAt: 1, - updatedAt: 2, - lease: { sessionId, runtimeKind: 'native', runtimeFence: 1 } - } as unknown as AgentSessionRecord -} - -function identityFor(sessionId: string) { - const record = recordFor(sessionId) - return journalIdentityFor( - record, - attachParamsForRecord(record, { clientOperationId: 'seed', expectedRuntimeFence: 1 }) - ) -} - -function legacyDirFor(sessionId: string): string { - return journalDirectoryFor(root, { workspaceId: WORKSPACE_ID, sessionId }) -} - -/** What the run before the upgrade left open in a chat, for its restore to settle. */ -type MidWork = 'running tool call' | 'unresolved send' | 'never handed over' - -/** A chat as an earlier build left it: real rows in its own per-chat file, nothing in the host's. */ -async function seedLegacyChat( - sessionId: string, - replies = 1, - midWork?: MidWork -): Promise { - const scratch = join(root, `scratch-${sessionId}`) - const identity = identityFor(sessionId) - const journal = await openAgentSessionJournal({ - identity, - database: openTestJournalHostDatabase(scratch), - now: () => (clock += 1) - }) - await journal.appendSubmission({ - clientMessageId: `client-${sessionId}`, - payloadFingerprint: 'fp-1', - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: PROMPT }] }, - fence: 1, - handoverRecorded: true, - // A person's send that run accepted and quit before handing over. - ...(midWork === 'never handed over' - ? { origin: 'client' as const, source: USER_MESSAGE_SOURCE } - : {}) - }) - if (midWork === 'running tool call') { - await journal.appendItem( - { provider: 'codex', threadId: `thread-${sessionId}`, turnId: 't', ordinal: 50 }, - { kind: 'tool-call', name: 'Read', input: {}, state: 'running' }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - } - if (midWork === 'never handed over') { - await journal.close() - return writeLegacyChat(sessionId, scratch, journal.epoch) - } - await journal.resolveDispatch( - midWork === 'unresolved send' - ? // Handed over, and never answered. - { - clientMessageId: `client-${sessionId}`, - fence: 1, - state: 'pending', - turnScope: AGENT_JOURNAL_THREAD_SCOPE - } - : { - clientMessageId: `client-${sessionId}`, - fence: 1, - state: 'accepted', - providerIdentity: { - provider: 'codex', - threadId: `thread-${sessionId}`, - turnId: 't', - ordinal: 0 - } - } - ) - for (let ordinal = 1; ordinal <= replies; ordinal += 1) { - await journal.appendItem( - { provider: 'codex', threadId: `thread-${sessionId}`, turnId: 't', ordinal }, - { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: `reply ${ordinal}` }] }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - } - await journal.close() - return writeLegacyChat(sessionId, scratch, journal.epoch) -} - -/** Moves the rows a scratch host wrote into the chat's own per-chat file, as an older build kept it. */ -async function writeLegacyChat( - sessionId: string, - scratch: string, - epoch: string -): Promise { - const rows = readTestJournalRows(openTestJournalHostDatabase(scratch).db, sessionId, epoch) - const path = legacyJournalDatabaseFile(legacyDirFor(sessionId)) - await mkdir(dirname(path), { recursive: true }) - const db = new Database(path) - try { - db.exec(` -CREATE TABLE journal_rows (session_id TEXT NOT NULL, epoch TEXT NOT NULL, seq INTEGER NOT NULL, - ts INTEGER NOT NULL, row_json TEXT NOT NULL, PRIMARY KEY (session_id, epoch, seq)); -CREATE TABLE journal_sessions (session_id TEXT PRIMARY KEY, epoch TEXT NOT NULL, updated_at INTEGER NOT NULL); -CREATE TABLE journal_repairs (session_id TEXT PRIMARY KEY, epoch TEXT NOT NULL, - content_from INTEGER NOT NULL, repaired_at INTEGER NOT NULL);`) - db.pragma('user_version = 2') - const insert = db.prepare( - 'INSERT INTO journal_rows (session_id, epoch, seq, ts, row_json) VALUES (?, ?, ?, ?, ?)' - ) - for (const row of rows) { - insert.run(sessionId, row.epoch, row.seq, row.ts, row.rowJson) - } - db.prepare('INSERT INTO journal_sessions VALUES (?, ?, ?)').run(sessionId, rows[0]!.epoch, 1) - } finally { - db.close() - } - return rows -} - -function legacyFile(sessionId: string): string { - return legacyJournalDatabaseFile(legacyDirFor(sessionId)) -} - -function hostDb(): Database.Database { - return openTestJournalHostDatabase(root).db -} - -function importCount(): number { - return Number(hostDb().prepare('SELECT count(*) AS n FROM journal_imports').get()?.n) -} - -type LifetimeHost = Parameters[0] - -function conversations(): StructuredAgentSessionConversations { - return new StructuredAgentSessionConversations({ - deliver: () => undefined, - logger: recordingStructuredAgentSessionLogger().logger, - now: () => clock - }) -} - -async function restore(sessionIds: readonly string[]) { - const sessions = conversations() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: restore and an open conversation's read touch only `getRecord` and `listRecords`. - const store = { - getRecord: (sessionId: string) => recordFor(sessionId), - listRecords: () => sessionIds.map(recordFor) - } as unknown as AgentSessionRecordStore - const log = recordingStructuredAgentSessionLogger() - const deps = { - store, - adapter: {}, - journalDatabase: openTestJournalHostDatabase(root), - logger: log.logger - } - await restoreStructuredAgentSessionsOnRestart({ - openDeps: deps, - records: sessionIds.map(recordFor), - reconcile: async () => true, - resolveRecovery: async () => true, - serialize: async (_sessionId, task) => task(), - hasSession: (sessionId) => sessions.has(sessionId), - onReadable: (sessionId, opened) => { - sessions.set(sessionId, opened.session) - } - }) - const context = (): StructuredAgentSessionLifetimeContext => - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: reaching an open conversation reads only `deps.store` and `deps.adapter`. - ({ deps, now: () => clock }) as unknown as StructuredAgentSessionLifetimeContext - const lifetimeOver = (listed: LifetimeHost['sessions'], open: LifetimeHost['open']) => - createStructuredAgentSessionConversationLifetime({ - context, - sessions: listed, - serialize: async (_sessionId, task) => task(), - open, - deliveryActive: () => false, - closeStatus: () => undefined, - readChildWork: () => undefined - }) - const lifetime = lifetimeOver(sessions, async (sessionId) => sessions.get(sessionId) ?? null) - return { sessions, lifetime, lifetimeOver, log } -} - -function texts(items: readonly { body: unknown }[]): string { - return JSON.stringify(items.map((entry) => entry.body)) -} - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-restore-without-import-')) - clock = 1_000 - readOnlyOpens.length = 0 -}) - -afterEach(async () => { - vi.restoreAllMocks() - closeTestJournalHostDatabases() - await rm(root, { recursive: true, force: true }) -}) - -describe('startup restore of chats still in their per-chat files', () => { - it('lists every restored chat without copying one, and opens only their files', async () => { - const restored = ['chat-a', 'chat-b', 'chat-c'] - const rows = new Map() - for (const sessionId of [...restored, 'chat-closed']) { - rows.set(sessionId, await seedLegacyChat(sessionId)) - } - readOnlyOpens.length = 0 - const before = new Map( - await Promise.all( - restored.map( - async (sessionId) => [sessionId, await readFile(legacyFile(sessionId))] as const - ) - ) - ) - - const { sessions } = await restore(restored) - - // Each file was closed by the fold that read it, so nothing holds it open for a later rename. - expect(openReadOnly.size).toBe(0) - // Read, never written: the same bytes, in the same place, beside only SQLite's own WAL files. - for (const sessionId of restored) { - expect((await readFile(legacyFile(sessionId))).equals(before.get(sessionId)!)).toBe(true) - const entries = await readdir(legacyDirFor(sessionId)) - expect(entries.filter((name) => !/^journal\.db(-wal|-shm)?$/.test(name))).toEqual([]) - } - - expect([...sessions.keys()].sort()).toEqual(restored) - for (const sessionId of restored) { - const journal = sessions.get(sessionId)!.journal - expect(journal.cursor().sequence).toBe(rows.get(sessionId)!.length) - expect(texts(journal.snapshot().items)).toContain('reply 1') - expect(readJournalSessionEpoch(hostDb(), sessionId)).toBeNull() - expect(existsSync(legacyJournalDatabaseFile(legacyDirFor(sessionId)))).toBe(true) - } - for (const sessionId of restored) { - await sessions.get(sessionId)!.journal.close() - } - expect(importCount()).toBe(0) - // One read of each restored chat's file, and none of the chat that was not restored. - expect(readOnlyOpens.sort()).toEqual( - restored.map((sessionId) => legacyJournalDatabaseFile(legacyDirFor(sessionId))).sort() - ) - }) - - // Restore copies a chat only to write to it itself, settling what the last run left open (a - // turn, tool call, approval, question, send or subagent). A settled chat is never copied here. - it.each(['running tool call', 'unresolved send', 'never handed over'] as const)( - 'copies during restore only a chat it settles (%s)', - async (midWork) => { - const rows = await seedLegacyChat('chat-mid-work', 1, midWork) - await seedLegacyChat('chat-settled') - - const { sessions } = await restore(['chat-mid-work', 'chat-settled']) - - // Restore wrote a settlement to the chat left mid-work, so it copied that chat first. - const settled = sessions.get('chat-mid-work')!.journal - expect(settled.cursor().sequence).toBeGreaterThan(rows.length) - expect( - readTestJournalRows(hostDb(), 'chat-mid-work', rows[0]!.epoch).slice(0, rows.length) - ).toEqual(rows) - expect(existsSync(legacyFile('chat-mid-work'))).toBe(false) - expect(readJournalSessionEpoch(hostDb(), 'chat-settled')).toBeNull() - expect(existsSync(legacyFile('chat-settled'))).toBe(true) - expect(importCount()).toBe(1) - } - ) - - // A send the last run never handed over is kept as a card in the same database the copy wrote, - // after the copy: the card and the rejected send both land behind the chat's own rows. - it('keeps a send the last run never handed over as a card, after the copy', async () => { - const rows = await seedLegacyChat('chat-kept', 0, 'never handed over') - - const { sessions } = await restore(['chat-kept']) - - const journal = sessions.get('chat-kept')!.journal - await journal.whenImported() - const copied = readTestJournalRows(hostDb(), 'chat-kept', rows[0]!.epoch) - expect(copied.slice(0, rows.length)).toEqual(rows) - expect(copied).toHaveLength(rows.length + 1) - expect(JSON.parse(copied.at(-1)!.rowJson)).toMatchObject({ - kind: 'dispatch', - clientMessageId: 'client-chat-kept', - state: 'rejected' - }) - const cards = hostDb() - .prepare('SELECT message_id, hold_reason, state FROM queued_messages WHERE session_id = ?') - .all('chat-kept') - expect(cards).toEqual([ - { message_id: 'client-chat-kept', hold_reason: QUEUED_MESSAGE_PAUSED_KEPT, state: 'waiting' } - ]) - }) - - it('lets other work run while it reads a large per-chat file', async () => { - // Past one batch of the file's rows. - const rows = await seedLegacyChat('chat-a', 520) - let turns = 0 - let ticking = true - const tick = (): void => { - turns += 1 - if (ticking) { - setImmediate(tick) - } - } - - setImmediate(tick) - const folded = await previewPerSessionJournal({ - database: openTestJournalHostDatabase(root), - identity: identityFor('chat-a'), - legacyDirectory: legacyDirFor('chat-a') - }) - ticking = false - - expect(turns).toBeGreaterThan(0) - expect(folded?.state.lastSequence).toBe(rows.length) - expect(openReadOnly.size).toBe(0) - }) - - // T-B3 for a chat restore did not copy: the offer taken before the upgrade still stands. - it('offers the restorable turn of a chat it did not copy', async () => { - const rows = await seedLegacyChat('chat-a') - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: `movedOn` reads only the session id and the journal cursor. - const marker = { - sessionId: 'chat-a', - journalCursor: { epoch: rows[0]!.epoch, sequence: rows.length } - } as AgentSessionResumeMarker - - const { sessions } = await restore(['chat-a']) - const withdrawal = createStructuredAgentSessionRestartOfferWithdrawal({ - logger: createStructuredAgentSessionLogger(), - sessions, - now: () => clock, - enqueue: (operation) => operation() - }) - - expect(withdrawal.movedOn(marker)).toBe(false) - expect( - latestStructuredAgentSessionPrompt(sessions.get('chat-a')!.journal.snapshot().items) - ).toBe(PROMPT) - expect(importCount()).toBe(0) - }) - - it('copies a chat on its first read, and serves its history verbatim', async () => { - const rows = await seedLegacyChat('chat-a') - const { lifetime, sessions } = await restore(['chat-a']) - const listed = sessions.get('chat-a')!.journal.snapshot() - - const journal = (await lifetime.conversation('chat-a')).journal - const since = journal.readSince({ epoch: rows[0]!.epoch, sequence: 0 }) - - // The copied chat is the chat restore listed, item for item. - expect(journal.snapshot()).toEqual(listed) - - expect(importCount()).toBe(1) - expect(readTestJournalRows(hostDb(), 'chat-a', rows[0]!.epoch)).toEqual(rows) - expect(since.ok && since.rows.map((row) => row.seq)).toEqual(rows.map((row) => row.seq)) - expect(existsSync(legacyDirFor('chat-a'))).toBe(false) - }) - - // A read queued behind restore's open of the same chat reaches it through its own open, not the - // listing: it still waits for the copy, and reads the chat from the one database. - it('makes a read whose open lands on the chat restore opened wait for its copy', async () => { - const rows = await seedLegacyChat('chat-a') - const { sessions, lifetimeOver } = await restore(['chat-a']) - const restored = sessions.get('chat-a')! - const lifetime = lifetimeOver(conversations(), async () => restored) - - const { journal } = await lifetime.conversation('chat-a') - const since = journal.readSince({ epoch: rows[0]!.epoch, sequence: 0 }) - - expect(importCount()).toBe(1) - expect(since.ok && since.rows.map((row) => row.seq)).toEqual(rows.map((row) => row.seq)) - }) - - // Whether the read finds the chat restore listed, or opens it and lands on the one restore opened. - it.each(['listed', 'opened'] as const)( - 'refuses a read of the chat restore %s when its copy meets damage, never with the storage text', - async (reach) => { - await seedLegacyChat('chat-a') - const { sessions, lifetime, lifetimeOver, log } = await restore(['chat-a']) - const restored = sessions.get('chat-a')! - await rm(legacyDirFor('chat-a'), { recursive: true, force: true }) - await mkdir(legacyDirFor('chat-a'), { recursive: true }) - await writeFile(legacyFile('chat-a'), 'not a database, and never was one') - const reader = - reach === 'listed' ? lifetime : lifetimeOver(conversations(), async () => restored) - - const read = reader.conversation('chat-a') - - await expect(read).rejects.toBeInstanceOf(AgentSessionRefusalError) - await expect(read).rejects.toMatchObject({ - message: 'agent_session_journal_unreadable', - refusal: { details: { reason: 'journalCorrupt' } } - }) - // The storage text the reader never sees goes to the host's log. - expect(log.entries.filter((entry) => entry.fields.scope === 'open-for-read')).toEqual([ - expect.objectContaining({ - fields: { scope: 'open-for-read', sessionId: 'chat-a', error: expect.any(Error) } - }) - ]) - } - ) - - it('copies a chat before its first write, and the write lands after its history', async () => { - const rows = await seedLegacyChat('chat-a') - const { sessions } = await restore(['chat-a']) - const journal = sessions.get('chat-a')!.journal - - await journal.appendItem( - { provider: 'codex', threadId: 'thread-chat-a', turnId: 't', ordinal: 9 }, - { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'after' }] }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - - const stored = readTestJournalRows(hostDb(), 'chat-a', rows[0]!.epoch) - expect(stored.slice(0, rows.length)).toEqual(rows) - expect(stored).toHaveLength(rows.length + 1) - expect(importCount()).toBe(1) - }) - - it("copies a chat before a Stop's event, which lands before the turn's end and the kill after it", async () => { - const rows = await seedLegacyChat('chat-a') - const { sessions } = await restore(['chat-a']) - const journal = sessions.get('chat-a')!.journal - expect(journal.importPending).toBe(true) - const scope = { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - - // In the Stop's order: its event, then the stopped turn's end, then a row the kill writes. - const [stopped, ended, killed] = await Promise.all([ - journal.appendStopEvent({ reason: 'user-stop' }, 1), - journal.appendItem( - { provider: 'codex', threadId: 'thread-chat-a', turnId: 't', ordinal: 9 }, - { kind: 'turn', turnId: 't', state: 'interrupted', startedAt: 1 }, - scope - ), - journal.appendItem( - { provider: 'codex', threadId: 'thread-chat-a', turnId: 't', ordinal: 10 }, - { kind: 'status', text: 'the agent ended' }, - scope - ) - ]) - - expect(readTestJournalRows(hostDb(), 'chat-a', rows[0]!.epoch).slice(0, rows.length)).toEqual( - rows - ) - expect([stopped.sequence, ended.cursor.sequence, killed.cursor.sequence]).toEqual([ - rows.length + 1, - rows.length + 2, - rows.length + 3 - ]) - }) - - it("copies a chat before its first queued message, which lands as that chat's draft", async () => { - const rows = await seedLegacyChat('chat-a') - const { sessions } = await restore(['chat-a']) - const journal = sessions.get('chat-a')!.journal - expect(journal.importPending).toBe(true) - - await journal.queuedMessages.insert({ - messageId: 'draft-1', - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'later' }] }, - fingerprint: 'fp-draft-1', - hostInstance: 'proc-1' - }) - - expect(journal.importPending).toBe(false) - expect(readTestJournalRows(hostDb(), 'chat-a', rows[0]!.epoch)).toEqual(rows) - expect(existsSync(legacyFile('chat-a'))).toBe(false) - expect(importCount()).toBe(1) - expect(journal.queuedMessages.list()).toMatchObject([ - { messageId: 'draft-1', state: 'waiting' } - ]) - }) - - it('never shows a read racing the copy a partly copied chat', async () => { - // Past one import batch, so the copy yields to other work between batches. - const rows = await seedLegacyChat('chat-a', 520) - const { lifetime, sessions } = await restore(['chat-a']) - const journal = sessions.get('chat-a')!.journal - const cursor = { epoch: rows[0]!.epoch, sequence: 0 } - const seen: { published: boolean; copied: number; folded: number }[] = [] - const reads: Promise[] = [] - let ticking = true - const tick = (): void => { - seen.push({ - published: readJournalSessionEpoch(hostDb(), 'chat-a') !== null, - copied: Number(hostDb().prepare('SELECT count(*) AS n FROM journal_rows').get()?.n), - folded: journal.cursor().sequence - }) - reads.push( - lifetime.conversation('chat-a').then(({ journal: read }) => { - const since = read.readSince(cursor) - return since.ok ? since.rows.length : -1 - }) - ) - if (ticking) { - setImmediate(tick) - } - } - setImmediate(tick) - - await lifetime.conversation('chat-a') - ticking = false - await new Promise((resolve) => setImmediate(resolve)) - - // The copy was under way while other work ran, and none of it saw part of the chat. - expect(seen.some((turn) => !turn.published && turn.copied > 0)).toBe(true) - expect(seen.every((turn) => !turn.published || turn.copied === rows.length)).toBe(true) - expect(seen.every((turn) => turn.folded === rows.length)).toBe(true) - expect(new Set(await Promise.all(reads))).toEqual(new Set([rows.length])) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts index 181971ea42e5..8221d257017a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts @@ -63,8 +63,7 @@ export function structuredAgentSessionSendBlock( return null } -/** The conversation a send or a Stop writes to, opened when this host holds it closed. Once it - * answers, every write issued before it has settled, so a mutation reads a whole fold. */ +/** The conversation a send or a Stop writes to, opened when this host holds it closed. */ export async function openConversationForWrite( openConversation: (sessionId: string) => Promise, envelope: AgentSessionMutationEnvelope, @@ -84,17 +83,6 @@ export async function openConversationForWrite( if (!session) { return { ok: false, refusal: AGENT_SESSION_NOT_ATTACHED } } - // Writes wait behind a restore's owed import. A failed import settled them too (they failed - // with it), so it is reported and never refuses the mutation: its own writes fail as they would. - if (session.journal.importPending) { - await session.journal.whenImported().catch((error: unknown) => { - logger.warn('the import owed before a write failed', { - scope: 'open-for-write', - sessionId: envelope.sessionId, - error - }) - }) - } return { ok: true } } 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 f41d2e190c7d..592f82653750 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 @@ -187,7 +187,7 @@ describe('a send into a published session whose child ended before startup', () it('restarts the child and admits the message against it before it has proven its start', async () => { const releasedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - await send('hello again', releasedFence) + const first = await send('hello again', releasedFence) // Accepted at the lost owner's fence and handed to the child delivery started, once. expect(acquire).toHaveBeenCalledTimes(2) @@ -196,8 +196,14 @@ describe('a send into a published session whose child ended before startup', () const current = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 expect(current).toBeGreaterThan(releasedFence) - // Once proven, the next send meets a live owner and restarts nothing. + // 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. await proveStarted() + await host.settleLateDispatch({ + sessionId: SESSION, + clientMessageId: first, + providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 0 } + }) await send('and again', current) expect(acquire).toHaveBeenCalledTimes(2) expect(dispatch).toHaveBeenCalledTimes(2) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts index 6981ff79cf13..f17ee460653e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts @@ -26,6 +26,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { AgentSessionJournal } from '../agent-session-journal/journal-store' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' @@ -135,10 +136,9 @@ function startAgent(): Promise { describe('settled attach retry', () => { it('settles a post-acquisition journal failure and retries without a restart', async () => { const journalDatabase = openTestJournalHostDatabase(root) - // The journal's open asks where the chat's per-chat file lives before it reads anything. - vi.spyOn(journalDatabase, 'legacyDirectoryFor').mockImplementationOnce(() => { - throw new Error('journal path unavailable') - }) + vi.spyOn(AgentSessionJournal.prototype, 'open').mockRejectedValueOnce( + new Error('journal path unavailable') + ) host = new StructuredAgentSessionHost({ agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts index ccd58b4af4ed..2491eb2f2be2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts @@ -124,6 +124,32 @@ export function provenUnverifiableTurnRevisions( }) } +/** An exit this host watched, of the child that holds `ownerFence`. */ +export type StructuredAgentSessionWatchedExit = { ownerFence: number; observedAt: number } + +/** What a watched exit revises of what its child left `unverifiable` (its stream closed before the + * exit was proven), calls and turns alike, as the record's death evidence later would. The exit's + * instant is the end, so no Stop mark is weighed. */ +export function watchedExitRevisions( + items: readonly AgentJournalRenderItem[], + exit: StructuredAgentSessionWatchedExit | undefined, + journal: Pick +): JournalLifecycleMutationInput[] { + if (!exit) { + return [] + } + const proof: AgentSessionDeathEvidence = { kind: 'exit-observed', detail: '', ...exit } + return [ + ...provenUnverifiedToolCallRevisions(items, proof, journal), + ...items.flatMap((item) => { + const turn = readAgentJournalTurn(item.body) + return turn?.state === 'unverifiable' && journal.itemFence(item.itemId) === exit.ownerFence + ? turnLifecycleRevision(item, turn, { state: 'interrupted', completedAt: exit.observedAt }) + : [] + }) + ] +} + /** The calls those settles closed with no proof, revised by the same proof: each only when it names * the owner that wrote the call, so a call that failed on its own stays failed. */ export function provenUnverifiedToolCallRevisions( 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 77b48a375a47..6cef8ef9a1f1 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 @@ -1,21 +1,17 @@ -// A start a queued message waited on can be seen failing twice: by the delivery loop, when the -// adapter settles the start without proving it, and by the exit settlement, when the child's exit -// lands. The chat gets one row for that start, the loop's, in the words the message was rejected -// with — whichever of the two reports first. +// A start that fails can be written up twice: by the exit settlement, for a message the starting +// child was handed, and by the delivery loop, for a message the exit found still queued. The chat +// gets one row for that start, in the words the message was rejected with. 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 { - agentSessionFailureFact, - type SubmissionRejectionFact -} from '../../../shared/agent-session-failure' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { HOST_TEST_NOW as NOW, @@ -33,9 +29,6 @@ import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router- const CALLER = { callerKey: 'client-1' } const EXIT_REASON = 'Claude Code is not signed in. Sign in with the Claude CLI' -const ADAPTER_FAILURE = agentSessionFailureFact('notSignedIn') -const ADAPTER_FAILURE_TEXT = - 'Codex is not signed in for the selected account. Sign in, then send your message again.' // The exit's reason is Orca's log text; the row says only that the start stopped. const EXIT_TEXT = 'Codex stopped before it finished starting. Send your message to try again.' // The first child (generation-1) is lost at setup; the send starts generation-2. @@ -52,8 +45,9 @@ let root: string let store: AgentSessionRecordStore let host: StructuredAgentSessionHost let generation = 0 -let settleStart: (failure: SubmissionRejectionFact | undefined) => void = () => {} -let awaitStarted = vi.fn<() => Promise>() +/** The next start's child exits as its start step returns, before the handover step. */ +let exitOnStart = false +let dispatch: Mock let frames: AgentSessionSubscribeEvent[] = [] function exitBeforeProof(): Promise { @@ -68,7 +62,7 @@ function exitBeforeProof(): Promise { }) } -async function sendQueued(text: string): Promise { +async function send(text: string): Promise { const body = hostTestMessage(text) const sent = await host.send(CALLER, { envelope: { @@ -84,9 +78,7 @@ async function sendQueued(text: string): Promise { body }) expect(sent).toMatchObject({ ok: true }) - // The loop started a child and waits on its start with the message still queued. - await eventually(() => expect(awaitStarted).toHaveBeenCalledOnce()) - expect(generation).toBe(2) + await eventually(() => expect(generation).toBe(2)) return sent.ok ? sent.value.clientMessageId : '' } @@ -118,31 +110,49 @@ beforeEach(async () => { resetHostTestOperationIds() generation = 0 frames = [] - awaitStarted = vi.fn( - () => new Promise((resolve) => (settleStart = resolve)) - ) + exitOnStart = false + dispatch = vi.fn(async () => ({ state: 'admitted' as const })) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: { - acquire: vi.fn(async ({ fence, spawnToken }) => ({ - process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: generation === 0 ? ('created' as const) : ('resumed' as const), - mintedAtFence: fence, - observedAt: NOW - }, - acquisitionGeneration: `generation-${++generation}`, - providerChildPhase: 'starting' as const - })), - awaitStarted, + acquire: vi.fn(async ({ fence, spawnToken }) => { + const child = { + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken + }, + link: { + linkId: `link-${fence}`, + handle: codexProviderHandle(THREAD), + origin: generation === 0 ? ('created' as const) : ('resumed' as const), + mintedAtFence: fence, + observedAt: NOW + }, + acquisitionGeneration: `generation-${++generation}`, + providerChildPhase: 'starting' as const + } + if (exitOnStart) { + // Asked for on the lane while the start step runs, so it lands before the handover. + void host.handleAdapterEvent({ + type: 'ended', + sessionId: SESSION, + fence, + acquisitionGeneration: child.acquisitionGeneration, + reason: EXIT_REASON, + cause: 'unexpected-exit', + startupUnproven: true + }) + } + return child + }), releaseAcquisition: vi.fn(async () => true), closeSession: vi.fn(async () => true), - dispatch: vi.fn(async () => ({ state: 'admitted' as const })), + dispatch: (...args) => dispatch(...args), cancelTurn: vi.fn(async () => ({ cancelled: true })), answerPrompt: vi.fn(async () => undefined), setOption: vi.fn(async () => undefined) @@ -164,36 +174,32 @@ afterEach(async () => { await rm(root, { recursive: true, force: true }) }) -describe('a queued message whose start fails and whose child then exits', () => { - it("keeps the loop's row when the loop saw the failure first", async () => { - const queued = await sendQueued('hello') +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 () => { + const sent = await send('hello') + // A starting child takes the message at once. + await eventually(() => expect(dispatch).toHaveBeenCalledOnce()) - settleStart(ADAPTER_FAILURE) + await exitBeforeProof() await eventually(async () => - expect(await submission(queued)).toMatchObject({ - dispatchState: 'rejected', - reason: ADAPTER_FAILURE_TEXT, - rejection: ADAPTER_FAILURE - }) + expect(await submission(sent)).toMatchObject({ dispatchState: 'rejected', reason: EXIT_TEXT }) ) - await exitBeforeProof() await host.flushStreamedEvents(SESSION) - expect(await startRows()).toEqual([ADAPTER_FAILURE_TEXT]) - expect(publishedStartRows()).toEqual([ADAPTER_FAILURE_TEXT]) + expect(await startRows()).toEqual([EXIT_TEXT]) + expect(publishedStartRows()).toEqual([EXIT_TEXT]) }) it('leaves the row to the loop when the exit lands while the message still waits', async () => { - const queued = await sendQueued('hello') + exitOnStart = true + const queued = await send('hello') - await exitBeforeProof() - expect(await submission(queued)).toMatchObject({ dispatchState: 'pending' }) - settleStart(undefined) await eventually(async () => expect(await submission(queued)).toMatchObject({ dispatchState: 'rejected' }) ) await host.flushStreamedEvents(SESSION) + expect(dispatch).not.toHaveBeenCalled() expect(await startRows()).toEqual([EXIT_TEXT]) // Written once, after the message was settled, not first by the exit and again by the loop. expect(publishedStartRows()).toEqual([EXIT_TEXT]) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts index 07f54bc2ab4a..0f11d0c6949a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts @@ -25,6 +25,7 @@ function startedSession(): StructuredAgentSessionChildExitSession & { journal: { cursor: () => ({ epoch: 'epoch-1', sequence: 0 }), itemBody: () => null, + itemFence: () => undefined, // Nothing ran: the start failed before any response or acknowledged prompt. snapshot: () => ({ items: [] }), appendLifecycleBatch: vi.fn(async () => ({ epoch: 'epoch-1', sequence: 1 })), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-conversation-name.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-conversation-name.test.ts new file mode 100644 index 000000000000..5aba4871f559 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-conversation-name.test.ts @@ -0,0 +1,96 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it } from 'vitest' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' +import { StructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed' +import { indexedStatusFeedSession } from './structured-agent-session-status-feed-test-session' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { agentSessionRecordFixture } from '../../../shared/agent-session-record.test-fixture' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import type { AgentSessionStatusEvent } from '../../../shared/agent-session-wire' + +const journals = createTrackedJournalOpener() +let directory: string +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'orca-conversation-name-')) +}) +afterEach(async () => { + await journals.closeAll() + await rm(directory, { recursive: true, force: true }) +}) + +async function namedFeed() { + const journal = await journals.open({ + identity: { + sessionId: 'session', + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: codexProviderHandle('provider') + }, + stateDirectory: directory, + now: () => 100 + }) + let record: AgentSessionRecord = agentSessionRecordFixture() + const sessions = new Map([['session', indexedStatusFeedSession({ journal })]]) + const feed = new StructuredAgentSessionStatusFeed({ + sessions, + getRecord: () => record, + now: () => 100, + logger: createStructuredAgentSessionLogger() + }) + const events: AgentSessionStatusEvent[] = [] + feed.subscribe({ id: 'vault', emit: (event) => events.push(event) }) + feed.publish('session') + const name = (conversationName: string) => { + record = { ...record, conversationName } + feed.publishConversationName('session') + } + return { feed, events, sessions, name } +} + +function lastName(events: readonly AgentSessionStatusEvent[]): string | undefined { + const event = events.at(-1) + return event?.type === 'status' ? event.session.conversationName : undefined +} + +it('publishes a held chat its saved name once, and not again for an unchanged record', async () => { + const { feed, events, name } = await namedFeed() + expect(lastName(events)).toBeUndefined() + name('Explain the parser') + expect(lastName(events)).toBe('Explain the parser') + const count = events.length + feed.publish('session') + feed.publishConversationName('session') + expect(events).toHaveLength(count) +}) + +it('names a chat whose tab closed before naming finished, and keeps it for later subscribers', async () => { + const { feed, events, sessions, name } = await namedFeed() + feed.revokeLive('session') + sessions.clear() + name('Explain the parser') + expect(lastName(events)).toBe('Explain the parser') + const late: AgentSessionStatusEvent[] = [] + feed.subscribe({ id: 'reloaded', emit: (event) => late.push(event) }) + expect(late.at(-1)).toMatchObject({ + type: 'snapshot', + sessions: [{ sessionId: 'session', conversationName: 'Explain the parser' }] + }) +}) + +it('keeps the name on the summary a closing chat retains', async () => { + const { feed, events, name } = await namedFeed() + name('Explain the parser') + feed.close('session') + expect(lastName(events)).toBe('Explain the parser') +}) + +it('publishes nothing for a chat this host never projected', async () => { + const { feed, events } = await namedFeed() + const count = events.length + feed.publishConversationName('unknown-session') + expect(events).toHaveLength(count) +}) 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 4340cf584c72..e053d401200f 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 @@ -27,10 +27,7 @@ import { type StructuredAgentSessionJournalProjection } from './structured-agent-session-status-journal-projection' import { structuredStatusSummariesEqual } from './structured-agent-session-status-summary-equality' -import { - deferredStructuredAgentSessionLogger, - type StructuredAgentSessionLogger -} from './structured-agent-session-logger' +import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' import { StructuredAgentSessionStatusOwnership, type StructuredAgentSessionStatusSink @@ -72,40 +69,12 @@ export type StructuredAgentSessionStatusFeedDeps = { onAgentStarted?: (sessionId: string) => void } -/** Wire the host's own deps into a feed; keeps the host at one call site. - * `deps` is a thunk because the host builds the feed in a field initializer, - * before its constructor parameters are assigned. */ -export function createStructuredAgentSessionHostStatusFeed(args: { - sessions: StructuredAgentSessionStatusFeedDeps['sessions'] - now: () => number - deps: () => { - store: { getRecord: (sessionId: string) => AgentSessionRecord | null } - logger: StructuredAgentSessionLogger - onSessionStatusChanged?: StructuredAgentSessionStatusFeedDeps['onStatusChanged'] - statusSink?: StructuredAgentSessionStatusSink - } - onAgentStarted?: (sessionId: string) => void - onChildWorkChanged?: (sessionId: string) => void -}): StructuredAgentSessionStatusFeed { - return new StructuredAgentSessionStatusFeed({ - sessions: args.sessions, - getRecord: (sessionId) => args.deps().store.getRecord(sessionId), - now: args.now, - logger: deferredStructuredAgentSessionLogger(() => args.deps().logger), - onStatusChanged: (summary, options) => args.deps().onSessionStatusChanged?.(summary, options), - // Resolved per call for the same reason the other deps are: the host builds this feed in a - // field initializer, before its constructor parameters are assigned. - statusSink: () => args.deps().statusSink, - ...(args.onAgentStarted ? { onAgentStarted: args.onAgentStarted } : {}), - ...(args.onChildWorkChanged ? { onChildWorkChanged: args.onChildWorkChanged } : {}) - }) -} - export class StructuredAgentSessionStatusFeed { private readonly ownership = new StructuredAgentSessionStatusOwnership(() => this.deps.statusSink?.() ) private readonly subscribers = new Map() + // Never evicted: chats are named only while in here, and AI Vault reads closed ones' names here. private readonly published = new Map< string, { @@ -208,6 +177,24 @@ export class StructuredAgentSessionStatusFeed { }) } + /** The record's name changed outside the journal. A closed chat's retained row follows it too, + * so every list still showing that conversation learns the name without a tab. */ + publishConversationName(sessionId: string): void { + if (this.deps.sessions.has(sessionId)) { + this.publish(sessionId) + return + } + const publication = this.published.get(sessionId) + const name = this.deps.getRecord(sessionId)?.conversationName + if (!publication || publication.summary.conversationName === name) { + return + } + const { conversationName: _previous, ...rest } = publication.summary + const summary = name ? { ...rest, conversationName: name } : rest + this.published.set(sessionId, { ...publication, summary }) + this.broadcast({ type: 'status', session: summary }) + } + /** The summary last published for the session, as every status subscriber last saw it. */ readPublished = (sessionId: string): AgentSessionStatusSummary | undefined => this.published.get(sessionId)?.summary 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 17b4a63dc4b9..f5c3aba19781 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 @@ -34,6 +34,7 @@ export function structuredStatusSummariesEqual( a.toolInput === b.toolInput && a.lastAssistantMessage === b.lastAssistantMessage && a.launchDirectory === b.launchDirectory && + a.conversationName === b.conversationName && a.turnOutcome === b.turnOutcome && a.stopping === b.stopping && agentSessionBackgroundTasksEqual(a.backgroundTasks, b.backgroundTasks) && 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 eb65136537e5..1b5c8d2b0924 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 @@ -57,6 +57,7 @@ export function structuredAgentSessionStatusSummary({ ...childWork, ...(providerSession ? { providerSession } : {}), ...(launchDirectory ? { launchDirectory } : {}), + ...(record?.conversationName ? { conversationName: record.conversationName } : {}), updatedAt: journal.lastActivityAt() || now() } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-steer-while-stopping.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-steer-while-stopping.test.ts index 1bc687e31e34..10ea0229843c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-steer-while-stopping.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-steer-while-stopping.test.ts @@ -15,6 +15,7 @@ import { eventually, type QueuedMessageTestRig } from './structured-agent-session-queued-message-rig.test-fixture' +import { holdDelivery } from './structured-agent-session-delivery-hold.test-fixture' let rig: QueuedMessageTestRig @@ -115,9 +116,10 @@ describe("a message sent while a person's Stop ends the turn", () => { await eventually(() => expect(rig.dispatch.mock.calls.length).toBe(dispatched + 1)) }) - // A Stop pressed before the turn showed holds a later send only while it settles; settling - // having stopped nothing hands it over, and the turn that then opens is not the Stop's. - it('hands over a send made after a Stop that stopped nothing, with no Stopping flip', async () => { + // A Stop pressed before the turn showed holds a later send only while it settles. Settling + // having stopped nothing, the send still waits for the first send's turn to open, then joins it; + // that turn is not the Stop's. + it('hands over a send made after a Stop that stopped nothing once the turn opens, with no Stopping flip', async () => { rig = await createQueuedMessageTestRig() const first = await rig.workingSend() const seen: (true | undefined)[] = [] @@ -140,10 +142,13 @@ describe("a message sent while a person's Stop ends the turn", () => { answer.resolve({ cancelled: false }) expect(await stopped).toMatchObject({ ok: true }) expect(await later.result).toMatchObject({ ok: true }) + const { loop } = rig.host.collaboratorsForTests().conversationDelivery + await eventually(() => expect(loop.isRunning(HOST_TEST_SESSION)).toBe(false)) + expect((await rig.submission(later.id))?.handedOverAt).toBeUndefined() - await eventually(() => expect(rig.dispatch.mock.calls.length).toBe(dispatched + 1)) await rig.settleAccepted(first, 'first') await turn('turn-1', first, 'running') + await eventually(() => expect(rig.dispatch.mock.calls.length).toBe(dispatched + 1)) await laneDrained() expect(seen.at(-1)).toBeUndefined() // Once Stopping ended it never came back. @@ -153,7 +158,8 @@ describe("a message sent while a person's Stop ends the turn", () => { // A settle edge writes no row, so nothing else wakes the handover once it closes. it('hands a held send over when the Stop settles with no row after it', async () => { rig = await createQueuedMessageTestRig() - await rig.workingSend() + // Working: its turn is open, so the held send is held only by the Stop's settle. + await turn('turn-1', await rig.workingSend(), 'running') await journal().appendStopEvent({ reason: 'user-stop' }, 1) const settle = journal().stopMarks.beginSettle() const later = rig.send('sent while the Stop settles') @@ -167,27 +173,27 @@ describe("a message sent while a person's Stop ends the turn", () => { await eventually(() => expect(rig.dispatch.mock.calls.length).toBe(dispatched + 1)) }) - // The delivery step that judged the send waits on the child's start outside the session's lane. - it('holds a send that a Stop overtook while its delivery step waited on the agent', async () => { + // The delivery step that judged the send and its handover are two turns of the session's lane. + it('holds a send that a Stop overtook between its delivery step and the handover', async () => { rig = await createQueuedMessageTestRig() const sent = await rig.workingSend() await rig.settleAccepted(sent, 'sent') await turn('turn-1', sent, 'running') + await laneDrained() const status = watchStatus() - const started = Promise.withResolvers() - const awaited = rig.awaitStarted.mock.calls.length - rig.awaitStarted.mockImplementationOnce(() => started.promise) + const step = holdDelivery() const first = rig.send('steer this in') expect(await first.result).toMatchObject({ ok: true }) - await eventually(() => expect(rig.awaitStarted.mock.calls.length).toBe(awaited + 1)) + await step.held const dispatched = rig.dispatch.mock.calls.length - // The Stop withdraws the first send; a second one arrives before the step resumes. - expect(await rig.stop()).toMatchObject({ ok: true }) - await eventually(() => expect(status()).toMatchObject({ stopping: true })) + // The Stop withdraws the first send; a second one arrives before the handover. + const stopped = rig.stop() const second = rig.send('and this one') + step.release() + expect(await stopped).toMatchObject({ ok: true }) expect(await second.result).toMatchObject({ ok: true }) - started.resolve(undefined) + await eventually(() => expect(status()).toMatchObject({ stopping: true })) await laneDrained() expect(rig.dispatch.mock.calls.length).toBe(dispatched) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-aborts-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-aborts-start.test.ts new file mode 100644 index 000000000000..d0eb9f953f02 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-aborts-start.test.ts @@ -0,0 +1,44 @@ +// A Stop the host admits aborts the start the queue is waiting on, for any agent: the host checks +// the start's signal before it asks the adapter. A message sent after that Stop is no part of the +// aborted start, so it gets a start of its own rather than that start's refusal. + +import { afterEach, expect, it } from 'vitest' +import { HOST_TEST_SESSION as SESSION } from './structured-agent-session-host-test-data' +import { + createQueuedMessageTestRig, + eventually, + type QueuedMessageTestRig +} from './structured-agent-session-queued-message-rig.test-fixture' + +let rig: QueuedMessageTestRig + +afterEach(() => rig.dispose()) + +it('starts a Codex chat afresh for a message sent right after a Stop reached its reconciling start', async () => { + rig = await createQueuedMessageTestRig({ restartable: true }) + // The chat's child is gone; the next send has to start one. + await rig.host.close(SESSION, 'evict') + const runtimeState = rig.host.collaboratorsForTests().runtimeState + const probe = runtimeState.probeOwner.bind(runtimeState) + let stopping: ReturnType | undefined + let second: ReturnType | undefined + runtimeState.probeOwner = async (sessionId) => { + if (!stopping) { + stopping = rig.stop() + // Accepted while the aborted start is still unwinding. + second = rig.send('second') + } + return probe(sessionId) + } + const first = rig.send('hello') + await first.result + await eventually(() => expect(second).toBeDefined()) + expect(await stopping).toMatchObject({ ok: true, value: { cancelled: true } }) + await second!.result + await eventually(async () => + expect((await rig.submission(second!.id))?.handedOverAt).toBeDefined() + ) + expect((await rig.submission(first.id))?.rejection).toMatchObject({ kind: 'cancelled' }) + expect((await rig.submission(second!.id))?.rejection).toBeUndefined() + expect(rig.dispatch).toHaveBeenCalledTimes(1) +}) 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 8d46b360b9fd..b5d33be6827d 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 @@ -19,6 +19,10 @@ import { QUEUED_RIG_CALLER, type QueuedMessageTestRig } from './structured-agent-session-queued-message-rig.test-fixture' +import { + openRigTurnFor, + withdrawnByStop +} from './structured-agent-session-queued-rig-turn.test-fixture' let rig: QueuedMessageTestRig @@ -103,27 +107,23 @@ 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 }) - let release: () => void = () => undefined - rig.awaitStarted.mockImplementation( - () => new Promise((resolve) => (release = () => resolve(undefined))) - ) + // Handed over at once to a child that never proves its start; nothing echoes it. rig.send('work on this') await eventually(() => expect(childPhase()).toBe('starting')) const held = options.held ? await queuedDraft('queued behind the start') : undefined expect(await rig.stop()).toMatchObject({ ok: true }) - release() expect(stopEvents()).toEqual([expect.objectContaining({ reason: 'user-stop' })]) expect(stopEvents()[0]).not.toHaveProperty('turnId') await eventually(() => expect(childPhase()).toBeUndefined()) - rig.awaitStarted.mockImplementation(async () => undefined) return held } /** Orchestration mail after the Stop starts a new child and its turn runs. */ async function mailTurn(): Promise { + const handedOver = rig.dispatch.mock.calls.length const mail = rig.send('mail for the worker') await mail.result - await eventually(() => expect(rig.dispatch).toHaveBeenCalled()) + await eventually(() => expect(rig.dispatch).toHaveBeenCalledTimes(handedOver + 1)) await turnOpenedBy(mail.id) } @@ -280,9 +280,11 @@ describe('a press that writes no Stop event of its own', () => { // A turnless Stop long since settled, then a later turn the person's own send opened. async function laterTurnAfterAnEarlierStop(): Promise { rig = await createQueuedMessageTestRig() - await rig.workingSend() + const stopped = await rig.workingSend() expect(await rig.stop()).toMatchObject({ ok: true }) expect(journal().stopMarks.latest()?.event).not.toHaveProperty('turnId') + // Its turn never opened, so the Stop took it back, as the provider's Stop settles one. + await withdrawnByStop(rig, stopped) const sent = await rig.workingSend() await rig.settleAccepted(sent, 'sent') await turnOpenedBy(sent) @@ -390,8 +392,11 @@ describe('a host stop with no turn running after a Stop that named none', () => it("writes the host's event when a send after the Stop is unanswered beside the stopped one", async () => { rig = await createQueuedMessageTestRig() - await rig.workingSend() + const stopped = await rig.workingSend() expect(await rig.stop()).toMatchObject({ ok: true }) + // The stopped send's turn opens after that turnless Stop, and ends interrupted, unanswered. + await openRigTurnFor(rig, stopped) + await openRigTurnFor(rig, stopped, 'interrupted') const mail = rig.send('mail for the lead') await mail.result await eventually(async () => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts index aa9ff4d041d5..3f1afc89d6bc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts @@ -77,15 +77,6 @@ function idleSweep() { return rig.host.collaboratorsForTests().lifetime.idleSweep } -/** Holds every start until the returned release. */ -function holdStart(): () => void { - let release: () => void = () => undefined - rig.awaitStarted.mockImplementation( - () => new Promise((resolve) => (release = () => resolve(undefined))) - ) - return () => release() -} - describe('every Stop entry writes its event, with its reason, before it ends the child', () => { it.each([ // A person closing this chat: its tab, its launch, or a /clear that replaces it. @@ -124,7 +115,6 @@ describe('every Stop entry writes its event, with its reason, before it ends the restartable: true, idleSweep: MANUAL_IDLE_SWEEP }) - holdStart() rig.send('work on this') await eventually(async () => expect(rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.child?.phase).toBe( @@ -419,7 +409,6 @@ describe("a person's Stop pause and the Stop events after it", () => { // The agent at rest goes, writing nothing; mail then starts a new child, which never lands. await idleSweep().tick() expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) - holdStart() rig.send('mail for the lead') await eventually(async () => expect(rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.child?.phase).toBe( @@ -431,7 +420,8 @@ describe("a person's Stop pause and the Stop events after it", () => { await idleSweep().tick() expect(atClose.events?.map((event) => event.reason)).toEqual(['user-stop', 'host-stop']) - expect(await rig.handoff(held)).toBeUndefined() expect(await rig.queuePause()).not.toEqual({ reason: 'stopped' }) + // The pause ended with the host's stop, so the card goes out to the next start. + await eventually(async () => expect(await rig.handoff(held)).toBeDefined()) }) }) 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 2a8c496e8b2a..1e29b5ad1113 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 @@ -8,6 +8,7 @@ import { agentSessionFailureWords } from '../../../shared/agent-session-failure- import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import type { JournalStopEvent } from '../agent-session-journal/journal-row-schema' import { HOST_TEST_SESSION, hostTestOperationId } from './structured-agent-session-host-test-data' +import { holdLane } from './structured-agent-session-delivery-hold.test-fixture' import { createQueuedMessageTestRig, eventually, @@ -64,15 +65,6 @@ function withdraw(clientMessageId: string) { }) } -/** Holds every start until the returned release. */ -function holdStart(): () => void { - let release: () => void = () => undefined - rig.awaitStarted.mockImplementation( - () => new Promise((resolve) => (release = () => resolve(undefined))) - ) - return () => release() -} - describe("a Stop's event", () => { it('reaches the journal before the interrupt, naming the turn it stopped and who asked', async () => { rig = await createQueuedMessageTestRig() @@ -134,8 +126,9 @@ 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 }) - const release = holdStart() + // A starting agent takes the send at once; it never proves its start. rig.send('work on this') + await eventually(() => expect(rig.dispatch).toHaveBeenCalledTimes(1)) const held = await queuedDraft('queued while it starts') let atEnd: JournalStopEvent[] | undefined rig.closeSession.mockImplementationOnce(async () => { @@ -143,7 +136,6 @@ describe("a Stop's event", () => { return true }) expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: true } }) - release() await new Promise((resolve) => setTimeout(resolve, 250)) expect(await rig.handoff(held)).toBeUndefined() expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) @@ -158,11 +150,14 @@ describe("a Stop's event", () => { rig = await createQueuedMessageTestRig() expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: false } }) expect(stopEvents()).toEqual([]) - const release = holdStart() - const waiting = rig.send('waits for the start') - await eventually(async () => expect(await rig.submission(waiting.id)).toBeDefined()) - expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + // Held, the send is accepted and the Stop runs next, ahead of the handover the send asks for. + const release = holdLane(rig.host, HOST_TEST_SESSION) + const waiting = rig.send('waits for its handover') + const stopped = rig.stop() release() + expect(await stopped).toMatchObject({ ok: true, value: { cancelled: true } }) + expect(await rig.submission(waiting.id)).toMatchObject({ dispatchState: 'rejected' }) + expect(rig.dispatch).not.toHaveBeenCalled() expect(rig.cancelTurn).not.toHaveBeenCalled() expect(stopEvents()).toEqual([ { reason: 'user-stop', caller: QUEUED_RIG_CALLER.callerKey, at: expect.any(Number) } @@ -197,6 +192,8 @@ describe("a Stop's event", () => { rig = await createQueuedMessageTestRig() await rig.workingSend() await rig.stop() + // The turn the working send started opens after that turnless Stop; the card goes into it. + await turnRow('turn-1', 'running') const steered = await queuedDraft('sent into the turn between the presses') await rig.sendNow(steered) await eventually(async () => expect((await rig.handoff(steered))?.handedOverAt).toBeDefined()) @@ -214,6 +211,8 @@ describe("a Stop's event", () => { rig = await createQueuedMessageTestRig() const working = await rig.workingSend() await rig.stop() + // The turn the working send started opens after that turnless Stop; the card goes into it. + await turnRow('turn-1', 'running') const steered = await queuedDraft('sent into the turn between the presses') await rig.sendNow(steered) await eventually(async () => expect((await rig.handoff(steered))?.handedOverAt).toBeDefined()) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-never-waits.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-never-waits.test.ts index f2f3ad7ad697..4cbc39073c17 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-never-waits.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-never-waits.test.ts @@ -1,6 +1,5 @@ // A Stop's interrupt never waits on the journal. Its withdrawal and its Stop event are bookkeeping: -// one that throws is reported and the Stop still interrupts. Its writes may wait behind owed work; -// they are issued, and the interrupt goes out before they land. +// one that throws is reported and the Stop still interrupts. import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' @@ -216,75 +215,4 @@ describe.each([ await vi.waitFor(() => expectReported(failed)) } ) - - // The open pays an import owed before the Stop, so the work falls owed at the Stop's first write: - // the one moment the Stop's own writes can wait behind it. - it('interrupts before owed work its writes wait behind is paid', async () => { - const journal = await runningTurn() - const owed = Promise.withResolvers() - const withdraw = journal.rejectQueuedSubmissions.bind(journal) - vi.spyOn(journal, 'rejectQueuedSubmissions').mockImplementation((...args) => { - journal['queue'].owe(() => owed.promise) - return withdraw(...args) - }) - let answered = false - const stopping = stop(fields).finally(() => { - answered = true - }) - try { - await vi.waitFor(() => expect(cancelTurn).toHaveBeenCalledOnce()) - expect(journal.importPending).toBe(true) - expect(answered).toBe(false) - } finally { - owed.resolve() - } - expect(await stopping).toMatchObject({ ok: true }) - expect(journal.importPending).toBe(false) - }) - - // Under owed work the event is issued before the interrupt but lands later, still in queue order: - // ahead of the stopped turn's end, which the provider hands over as it takes the interrupt. - it('issues its Stop event before the interrupt, and it lands ahead of the turn it ends', async () => { - const journal = await runningTurn() - const order: string[] = [] - const owed = Promise.withResolvers() - const withdraw = journal.rejectQueuedSubmissions.bind(journal) - vi.spyOn(journal, 'rejectQueuedSubmissions').mockImplementation((...args) => { - journal['queue'].owe(() => owed.promise) - return withdraw(...args) - }) - const appendStopEvent = journal.appendStopEvent.bind(journal) - vi.spyOn(journal, 'appendStopEvent').mockImplementation((...args) => { - order.push('event issued') - return appendStopEvent(...args) - }) - cancelTurn.mockImplementation(async () => { - order.push('interrupt') - acquire.mock.calls - .at(-1)![0] - .events!.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 900 }, - { kind: 'turn', turnId: 'turn-1', state: 'interrupted' }, - { turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - return { cancelled: true } - }) - - const stopping = stop(fields) - try { - await vi.waitFor(() => expect(cancelTurn).toHaveBeenCalledOnce(), INTERRUPT_WAIT) - } finally { - owed.resolve() - } - expect(await stopping).toMatchObject({ ok: true }) - - expect(order).toEqual(['event issued', 'interrupt']) - const since = journal.readSince({ epoch: journal.epoch, sequence: 0 }) - const rows = since.ok ? since.rows : [] - const stopRow = rows.find((row) => row.kind === 'tombstone' && row.stopEvent) - const turnEnd = rows.find( - (row) => row.kind === 'item' && row.body.kind === 'turn' && row.body.state === 'interrupted' - ) - expect(stopRow?.seq).toBeLessThan(turnEnd?.seq ?? 0) - }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts index 0948a86e8fd6..4af804263aec 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts @@ -55,8 +55,7 @@ export async function performThreadGoalChange( let replacesGoal = false if (change.kind === 'set') { // Read before the objective row lands: that row is a message, not a goal transition. A goal - // transition the host accepted is already in the fold: it landed at its call, or before the - // open's owed import finished. + // transition the host accepted is already in the fold: it landed at its call. replacesGoal = ctx.journal.threadGoal() !== null } // Journal first: an active goal starts provider work at once, and the objective diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts index d9a152d0e6ed..e9d6d04fec5d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts @@ -13,7 +13,6 @@ import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' -import { openJournalOwingImport } from '../agent-session-journal/journal-owed-import-test-support' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { createAgentSessionDeltaCoalescer } from './agent-session-delta-coalescer' import { @@ -87,36 +86,6 @@ async function rig(watermarks: Partial = { return { root, journal, deferred, sink: deferred.sink, publishes, bind } } -/** A bound sink over a chat whose copy into the host's database is still owed, so every write, - * the transition's included, waits in the journal's queue until the first one pays it. */ -async function owedRig() { - const root = await mkdtemp(join(tmpdir(), 'orca-transition-owed-')) - roots.push(root) - const { journal } = await openJournalOwingImport({ - stateDirectory: root, - identity: { - sessionId: SESSION, - workspaceId: 'workspace-1', - hostId: 'local', - agent: 'grok', - providerHandle: { transport: 'acp', agent: 'grok', nativeId: 'provider-session-1' } - }, - now: () => 1_000 - }) - const failures: unknown[] = [] - const deferred = createDeferredStructuredAgentSessionEventSink({ - ...testEventSinkLogging(SESSION), - onFailed: (error) => failures.push(error) - }) - deferred.bind({ journal, fence: 1, publish: () => undefined }) - const ownKeys = () => - journal - .snapshot() - .items.map((item) => item.itemId) - .filter((itemId) => itemId.includes(SESSION)) - return { journal, deferred, sink: deferred.sink, failures, ownKeys, close: () => journal.close() } -} - describe('structured agent-session transitions', () => { it('lands its steps back to back, each resolved after the one before it', async () => { const { journal, deferred, sink, publishes, bind } = await rig() @@ -409,68 +378,6 @@ BEGIN SELECT RAISE(ABORT, 'second chunk refused'); END`) ['first', 'second', 'nested'].map((recordId) => agentJournalItemKey(identity(recordId))) ) }) - - it('runs its steps back to back after an owed import, ahead of a write issued after it', async () => { - const { journal, deferred, sink, ownKeys, close } = await owedRig() - try { - sink.tryAppendTransition?.({ - lifecycle: false, - publish: false, - steps: [ - itemStep(() => ({ identity: identity('first'), body: tool('read', 'running') })), - itemStep((view) => - view.itemBody(agentJournalItemKey(identity('first'))) - ? { identity: identity('second'), body: tool('read', 'running') } - : null - ) - ] - }) - sink.tryAppendItem?.(identity('later'), tool('later', 'running'), { - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - expect(journal.importPending).toBe(true) - await expect(deferred.drained()).resolves.toEqual({ ok: true }) - - expect(journal.importPending).toBe(false) - expect(ownKeys()).toEqual( - ['first', 'second', 'later'].map((recordId) => agentJournalItemKey(identity(recordId))) - ) - } finally { - await close() - } - }) - - it('stops at a failed middle step after an owed import; a write accepted before the failure still lands', async () => { - const { deferred, sink, failures, ownKeys, close } = await owedRig() - try { - const third = vi.fn(() => ({ identity: identity('third'), body: tool('read', 'running') })) - sink.tryAppendTransition?.({ - lifecycle: false, - publish: false, - steps: [ - itemStep(() => ({ identity: identity('first'), body: tool('read', 'running') })), - itemStep( - () => ({ identity: identity('second'), body: tool('x'.repeat(10_000), 'running') }), - 64 - ), - itemStep(third) - ] - }) - // Accepted before the failure is known: the sink's existing rule lets it land. - sink.tryAppendItem?.(identity('later'), tool('later', 'running'), { - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - await expect(deferred.drained()).resolves.toMatchObject({ ok: false }) - - expect(ownKeys()).toEqual( - ['first', 'later'].map((recordId) => agentJournalItemKey(identity(recordId))) - ) - expect(third).not.toHaveBeenCalled() - expect(failures).toHaveLength(1) - } finally { - await close() - } - }) }) describe('resolved lifecycle batches', () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts index e6c3c608ae20..474b2211774b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts @@ -33,8 +33,8 @@ import { structuredAgentSessionFailedStopMark } from './structured-agent-session import { sendStopCanTakeBack } from './structured-agent-session-unopened-send-withdrawal' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' -/** Whether the fold reads working. Every write has landed by its call's return, and the open paid - * any owed import, so a Stop reads it without waiting on the write queue. */ +/** Whether the fold reads working. Every write has landed by its call's return, so a Stop reads it + * without waiting on the write queue. */ export function isMainAgentWorking( ctx: Pick ): boolean { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-options.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-options.ts index 9230e56cbe83..ffee03165f3f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-options.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-options.ts @@ -4,14 +4,16 @@ import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-se export async function performSetOption( ctx: AgentSessionTurnContext, - input: { key: string; value: string } + input: { key: string; value: string }, + signal?: AbortSignal ): Promise> { let applied: void | Readonly> try { applied = await ctx.adapter.setOption({ sessionId: ctx.sessionId, ...input, - fence: ctx.fence + fence: ctx.fence, + ...(signal ? { signal } : {}) }) } catch (error) { if (isAgentSessionOptionRejectedError(error)) { 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 8956e2d84fce..4f575c1473be 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 @@ -87,9 +87,9 @@ function invalid( } /** 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. Such a child has - * accepted nothing (input is written only after it initializes), so a dispatch it could not - * take is provably unwritten and is rejected with the cause the adapter gave. */ + * 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. */ async function dispatchSafely( ctx: AgentSessionHandoverContext, clientMessageId: string, @@ -161,7 +161,7 @@ export async function performSend( } catch (error) { // Damage SQLite proves is the chat's, and no retry writes past it: say so, as an open does. So // does a chat holding a newer Orca's rows, which only an update writes past, and a refusal the - // journal already classified (a copy that did not verify). + // journal already classified (a failed transaction that will not roll back). if ( isAgentSessionRefusalError(error) || classifyJournalOpenFailure(error) === 'journalCorrupt' || diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts index e5222eae1b6a..4d099a26394d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts @@ -171,6 +171,7 @@ describe('provider-exit settlement', () => { journal: { cursor: () => ({ epoch: 'epoch-1', sequence: 0 }), itemBody: () => null, + itemFence: () => undefined, snapshot: () => ({ items }), appendLifecycleBatch, markPendingSubmissionsUnknown: vi.fn(async () => []) @@ -290,6 +291,7 @@ describe('provider-exit settlement', () => { journal: { cursor: () => ({ epoch: 'epoch-1', sequence: 0 }), itemBody: () => null, + itemFence: () => undefined, snapshot: () => ({ items }), appendLifecycleBatch, markPendingSubmissionsUnknown: vi.fn(async () => []), @@ -349,6 +351,7 @@ describe('provider-exit settlement', () => { journal: { cursor: () => ({ epoch: 'epoch-1', sequence: 0 }), itemBody: () => null, + itemFence: () => undefined, snapshot: () => ({ items: [] }), appendLifecycleBatch: vi.fn(async () => ({ epoch: 'epoch-1', sequence: 1 })), markPendingSubmissionsUnknown, @@ -402,6 +405,7 @@ describe('provider-exit settlement', () => { journal: { cursor: () => ({ epoch: 'epoch-1', sequence: 0 }), itemBody: () => null, + itemFence: () => undefined, markPendingSubmissionsUnknown: vi.fn(async () => []), rejectPendingSubmissions: vi.fn(async () => []), snapshot: () => ({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unfinished-work.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unfinished-work.ts new file mode 100644 index 000000000000..26148f1c8d4f --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unfinished-work.ts @@ -0,0 +1,124 @@ +// What a gone child generation left unfinished in the journal, and whether its exit interrupted it. + +import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { requiresTerminalSettlement } from '../agent-session-journal/journal-terminal-settlement' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { + watchedExitRevisions, + type StructuredAgentSessionWatchedExit +} from './structured-agent-session-stale-turn-verdict' +import type { UnopenedSendJournal } from './structured-agent-session-unopened-send-withdrawal' + +export type DeadGenerationJournal = UnopenedSendJournal & { + appendLifecycleBatch: AgentSessionJournal['appendLifecycleBatch'] + markPendingSubmissionsUnknown: AgentSessionJournal['markPendingSubmissionsUnknown'] + rejectPendingSubmissions: AgentSessionJournal['rejectPendingSubmissions'] + pendingSubmissions?: AgentSessionJournal['pendingSubmissions'] + itemFence: AgentSessionJournal['itemFence'] +} + +export type StructuredAgentSessionUnfinishedWork = { + items: AgentJournalRenderItem[] + hadUnsettledSubmissions: boolean +} + +export function captureUnfinishedStructuredAgentSessionWork( + journal: DeadGenerationJournal +): StructuredAgentSessionUnfinishedWork { + return { + items: journal.snapshot().items.filter(isUnfinishedItem), + hadUnsettledSubmissions: hasUnsettledSubmission(journal) + } +} + +/** `exit`: see `unfinishedStructuredAgentSessionWorkWasInterrupted`. */ +export function hasUnfinishedStructuredAgentSessionWork( + journal: DeadGenerationJournal, + exit?: StructuredAgentSessionWatchedExit +): boolean { + const work = captureUnfinishedStructuredAgentSessionWork(journal) + return ( + work.hadUnsettledSubmissions || + work.items.length > 0 || + watchedExitRevisions(journal.snapshot().items, exit, journal).length > 0 + ) +} + +export function unfinishedStructuredAgentSessionWorkWasInterrupted( + before: StructuredAgentSessionUnfinishedWork, + journal: DeadGenerationJournal, + observedExitAt: number, + exit?: StructuredAgentSessionWatchedExit +): boolean { + const currentSnapshot = journal.snapshot() + if ( + hasUnsettledSubmission(journal) || + currentSnapshot.items.some(isInProgressStructuredAgentSessionItem) + ) { + return true + } + // A turn the exited child left `unverifiable` (its stream closed first) was running when it went. + if (watchedExitRevisions(currentSnapshot.items, exit, journal).length > 0) { + return true + } + if ( + currentSnapshot.items.some((item) => { + const turn = readAgentJournalTurn(item.body) + return turn?.state === 'interrupted' && turn.completedAt === observedExitAt + }) + ) { + return true + } + const inProgressBefore = before.items.filter(isInProgressStructuredAgentSessionItem) + if (inProgressBefore.length === 0) { + return false + } + const currentItems = new Map(currentSnapshot.items.map((item) => [item.itemId, item])) + const runningTurns = inProgressBefore.filter( + (item) => readAgentJournalTurn(item.body)?.state === 'running' + ) + const outcomeItems = runningTurns.length > 0 ? runningTurns : inProgressBefore + return outcomeItems.some((item) => !isCleanlySettled(currentItems.get(item.itemId))) +} + +function isUnfinishedItem(item: AgentJournalRenderItem): boolean { + return requiresTerminalSettlement(item.body) +} + +/** Work that means the provider was MID-RESPONSE. A pending approval or question is the provider + * waiting on the user, so dying while one sits there interrupted nothing — it still needs + * cancelling, but it must not claim a response was in progress. */ +export function isInProgressStructuredAgentSessionItem(item: AgentJournalRenderItem): boolean { + return ( + readAgentJournalTurn(item.body)?.state === 'running' || + (item.body.kind === 'tool-call' && item.body.state === 'running') + ) +} + +function isCleanlySettled(item: AgentJournalRenderItem | undefined): boolean { + const turn = readAgentJournalTurn(item?.body) + if (turn) { + return turn.state === 'completed' + } + if (item?.body.kind === 'tool-call') { + return item.body.state === 'completed' + } + if (item?.body.kind === 'approval' || item?.body.kind === 'question') { + return item.body.resolution.state === 'resolved' + } + return false +} + +function hasUnsettledSubmission(journal: DeadGenerationJournal): boolean { + const submissions = journal.submissions?.() + return submissions + ? submissions.some( + (submission) => + // A queued message is not work in progress: nothing has it yet. + (submission.dispatchState === 'pending' && + !(submission.handoverRecorded && submission.handedOverAt === undefined)) || + (submission.dispatchState === 'unknown' && submission.recovered !== true) + ) + : (journal.pendingSubmissions?.().length ?? 0) > 0 +} 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 new file mode 100644 index 000000000000..5112b6ff74de --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unrun-send-hold.test.ts @@ -0,0 +1,112 @@ +// A message handed to an agent whose start never answers provably never ran: its CLI takes no +// message before it answers initialize. So when the chat ends then, the message is settled as a +// queued one is for the same end (`journal-unsent-send-hold.ts`): a quit or a close keeps a +// person's words as a held card, a person's Stop withdraws it. Against the real host, store and +// journal, with an agent that stays starting. + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { QUEUED_MESSAGE_PAUSED_KEPT } from '../../../shared/agent-session-queued-message-wire' +import { + createQueuedMessageTestRig, + eventually, + type QueuedMessageTestRig +} from './structured-agent-session-queued-message-rig.test-fixture' +import { HOST_TEST_SESSION as SESSION } from './structured-agent-session-host-test-data' + +const words = (kind: 'hostRestarted' | 'chatClosed' | 'cancelled') => + agentSessionFailureWords(agentSessionFailureFact(kind), { surface: 'rejection' }) +const KEPT = { state: 'waiting', paused: true } + +let rig: QueuedMessageTestRig + +beforeEach(async () => { + rig = await createQueuedMessageTestRig({ + restartable: true, + starting: true, + startUnanswered: true, + stopEndsSession: true, + recoveryCapsule: true + }) +}) + +afterEach(async () => { + await rig.dispose() +}) + +/** Sent to the starting agent and handed over to it, never echoed. */ +async function handedToHungStart(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') + return sent.id +} + +describe('a message handed to a start that never answered, then the chat ends', () => { + it('is a held card after a quit, rejected as a restart, as a queued one is', async () => { + const id = await handedToHungStart('kept through quit') + await rig.quitRestartHostProcess() + + expect(await rig.submission(id)).toMatchObject({ + dispatchState: 'rejected', + ...words('hostRestarted'), + keptAsQueuedMessageId: id + }) + expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) + const [card] = + rig.host.collaboratorsForTests().sessions.get(SESSION)?.journal.queuedMessages.list() ?? [] + expect(card).toMatchObject({ holdReason: QUEUED_MESSAGE_PAUSED_KEPT }) + // Nothing ran, so there is nothing to resume: the card alone holds the words. + expect(await rig.restartOffers()).toEqual([]) + }) + + it('is offered for resume after a quit once the start answered, as the control', async () => { + await rig.dispose() + rig = await createQueuedMessageTestRig({ + restartable: true, + starting: true, + stopEndsSession: true, + recoveryCapsule: true + }) + const id = await handedToHungStart('may have run') + await rig.quitRestartHostProcess() + + expect(await rig.restartOffers()).toEqual([ + { sessionId: SESSION, work: { kind: 'submission', id } } + ]) + }) + + it.each(['user-close', 'evict'] as const)( + 'is a held card after a %s, rejected as closed, as a queued one is', + async (cause) => { + const id = await handedToHungStart('kept at close') + await rig.host.close(SESSION, cause) + rig.crashRestartHostProcess() + + expect(await rig.submission(id)).toMatchObject({ + dispatchState: 'rejected', + ...words('chatClosed'), + keptAsQueuedMessageId: id + }) + expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) + } + ) + + it("is withdrawn by a person's Stop, as a queued one is, and kept as no card", async () => { + const id = await handedToHungStart('stopped') + + expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + + await eventually(async () => + expect(await rig.submission(id)).toMatchObject({ + dispatchState: 'rejected', + ...words('cancelled') + }) + ) + expect(await rig.drafts()).toEqual([]) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unsent-send-hold.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unsent-send-hold.test.ts index defcc402af8c..d3a658d42657 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unsent-send-hold.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unsent-send-hold.test.ts @@ -73,22 +73,48 @@ function sendRequest(text: string, delivery?: 'queue-if-active') { } } -/** Accepted while the agent is starting, and never handed over: its start never finishes. */ +/** Lets the spawn `acceptWhileStarting` holds finish; until then the message stays queued. */ +let releaseStart = (): void => {} + +/** Accepted while the agent is starting, and never handed over: Orca stops during its spawn. */ async function acceptWhileStarting( - request: Parameters[1] + request: Parameters[1], + // Sent with it, so each is accepted before the spawn takes the chat's lane. + ...alsoQueued: Parameters[1][] ): Promise { // No child, so this send must start one. await rig.host.close(SESSION, 'evict') - const startsBefore = rig.awaitStarted.mock.calls.length - rig.awaitStarted.mockImplementationOnce(() => new Promise(() => undefined)) - expect(await rig.host.send(QUEUED_RIG_CALLER, request)).toMatchObject({ - ok: true, - value: { submission: { dispatchState: 'pending', handoverRecorded: true } } - }) - await eventually(() => expect(rig.awaitStarted.mock.calls.length).toBeGreaterThan(startsBefore)) + const startsBefore = rig.starts.mock.calls.length + releaseStart = rig.holdNextStart() + const sent = await Promise.all( + [request, ...alsoQueued].map((each) => rig.host.send(QUEUED_RIG_CALLER, each)) + ) + for (const result of sent) { + expect(result).toMatchObject({ + ok: true, + value: { submission: { dispatchState: 'pending', handoverRecorded: true } } + }) + } + await eventually(() => expect(rig.starts.mock.calls.length).toBeGreaterThan(startsBefore)) return request.envelope.clientOperationId } +/** Closes the chat while its spawn is held: the close takes the lane once the spawn returns. */ +async function closeWhileStarting(cause: 'user-close' | 'evict'): Promise { + const closed = rig.host.close(SESSION, cause) + releaseStart() + releaseStart = () => {} + await closed +} + +/** The app quits: delivery stops first, so the held spawn finishing hands nothing over. */ +async function quitRestart(): Promise { + const quit = rig.quitRestartHostProcess() + releaseStart() + releaseStart = () => {} + await quit +} + function journal(): AgentSessionJournal { const open = rig.host.collaboratorsForTests().sessions.get(SESSION)?.journal if (!open) { @@ -105,7 +131,7 @@ function dispatchedTexts(): string[] { describe('a message accepted while the agent starts, then Orca stops', () => { it.each([ - { how: 'quit', restart: () => rig.quitRestartHostProcess() }, + { how: 'quit', restart: () => quitRestart() }, { how: 'crash', restart: async () => rig.crashRestartHostProcess() } ])('is a held card after a $how, and its submission is rejected unseen', async ({ restart }) => { const id = await acceptWhileStarting(sendRequest('hello after restart')) @@ -137,7 +163,7 @@ describe('a message accepted while the agent starts, then Orca stops', () => { it('quit writes nothing for it: no closed-chat rejection, still queued until the next open', async () => { const id = await acceptWhileStarting(sendRequest('kept through quit')) - await rig.quitRestartHostProcess() + await quitRestart() // Read beside the host, without opening the chat through it: the row the quit left. const leftover = await peekSubmission(id) expect(leftover).toMatchObject({ dispatchState: 'pending', handoverRecorded: true }) @@ -145,9 +171,8 @@ describe('a message accepted while the agent starts, then Orca stops', () => { }) it('keeps several in the order they were accepted, ahead of the cards already queued', async () => { - const first = await acceptWhileStarting(sendRequest('first')) const second = sendRequest('second') - expect(await rig.host.send(QUEUED_RIG_CALLER, second)).toMatchObject({ ok: true }) + const first = await acceptWhileStarting(sendRequest('first'), second) await rig.crashRestartHostProcess() expect(await rig.drafts()).toEqual([ @@ -160,7 +185,7 @@ describe('a message accepted while the agent starts, then Orca stops', () => { describe('only an action on the card releases a kept card', () => { it('a later message is delivered alone; Resume sends nothing; its own Send sends it once', async () => { const id = await acceptWhileStarting(sendRequest('the kept words')) - await rig.quitRestartHostProcess() + await quitRestart() // The person types the same words again, as one who cannot see cards would. const retyped = rig.send('the kept words') @@ -187,10 +212,9 @@ describe('only an action on the card releases a kept card', () => { }) it('Send now sends it at once, and Delete removes it', async () => { - const sent = await acceptWhileStarting(sendRequest('send me now')) const deleted = sendRequest('delete me') - await rig.host.send(QUEUED_RIG_CALLER, deleted) - await rig.quitRestartHostProcess() + const sent = await acceptWhileStarting(sendRequest('send me now'), deleted) + await quitRestart() expect(await rig.deleteQueued(deleted.envelope.clientOperationId)).toMatchObject({ ok: true, @@ -209,7 +233,7 @@ describe('only an action on the card releases a kept card', () => { it.each(['Edit', 'Delete'] as const)('%s leaves no trace of the kept send', async (action) => { const request = sendRequest('the kept words') const id = await acceptWhileStarting(request) - await rig.quitRestartHostProcess() + await quitRestart() expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) expect(await rig.deleteQueued(id)).toMatchObject({ ok: true, value: { deleted: true } }) const edited = action === 'Edit' ? rig.send('the edited words') : null @@ -263,7 +287,7 @@ describe('only an action on the card releases a kept card', () => { // After a second restart the kept card is another process's, yet it pauses nothing else. it('a later restart leaves the cards queued after it unpaused', async () => { const id = await acceptWhileStarting(sendRequest('kept twice over')) - await rig.quitRestartHostProcess() + await quitRestart() expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) rig.crashRestartHostProcess() const working = await rig.workingSend() @@ -280,7 +304,7 @@ describe('only an action on the card releases a kept card', () => { it('stays kept when its own Send is cut short by another quit', async () => { const id = await acceptWhileStarting(sendRequest('the kept words')) - await rig.quitRestartHostProcess() + await quitRestart() expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) // A /compact runs, so the Send now waits behind it, and Orca quits before it is handed over. const fields = { command: 'compact' as const } @@ -294,7 +318,7 @@ describe('only an action on the card releases a kept card', () => { expect(await rig.sendNow(id)).toMatchObject({ ok: true }) await new Promise((resolve) => setTimeout(resolve, 100)) expect((await rig.handoff(id))?.handedOverAt).toBeUndefined() - await rig.quitRestartHostProcess() + await quitRestart() expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) expect(journal().queuedMessages.get(id)?.holdReason).toBe(QUEUED_MESSAGE_PAUSED_KEPT) @@ -328,7 +352,7 @@ describe('cards queued behind a working turn, then Orca stops', () => { value: { queued: { state: 'waiting' } } }) if (how === 'quit') { - await rig.quitRestartHostProcess() + await quitRestart() } else { rig.crashRestartHostProcess() } @@ -423,7 +447,7 @@ describe('the queue at a quit', () => { expect(await rig.sendNow(pushed.id)).toMatchObject({ ok: true }) await new Promise((resolve) => setTimeout(resolve, 100)) expect((await rig.handoff(pushed.id))?.handedOverAt).toBeUndefined() - await rig.quitRestartHostProcess() + await quitRestart() expect(await rig.drafts()).toEqual([ { messageId: pushed.id, ...KEPT }, @@ -444,7 +468,7 @@ describe('a message accepted while the agent starts, then the chat closes', () = 'after a %s it is a held card the reopened chat shows, rejected as closed', async (cause) => { const id = await acceptWhileStarting(sendRequest('kept at close')) - await rig.host.close(SESSION, cause) + await closeWhileStarting(cause) rig.crashRestartHostProcess() expect(await rig.submission(id)).toMatchObject({ dispatchState: 'rejected', ...CHAT_CLOSED }) @@ -478,7 +502,7 @@ describe('the same send arriving again after the restart', () => { it('a client that never asked to queue is answered from its own record: one card, still held, nothing sent', async () => { const request = sendRequest('sent again by the outbox') const id = await acceptWhileStarting(request) - await rig.quitRestartHostProcess() + await quitRestart() expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) const submissionsBefore = (await rig.host.journalSnapshot(SESSION)).submissions.length @@ -590,7 +614,7 @@ describe('what is not kept', () => { } return resolve.apply(this, args) }) - await rig.quitRestartHostProcess() + await quitRestart() // The open's write failed; a new send wakes the delivery loop, whose first step settles it. const next = rig.send('wakes the loop') await next.result @@ -603,7 +627,7 @@ describe('what is not kept', () => { describe('/clear carries a kept card still held', () => { it('a turn in the new conversation never releases it', async () => { const id = await acceptWhileStarting(sendRequest('carried through clear')) - await rig.quitRestartHostProcess() + await quitRestart() expect(await rig.drafts()).toHaveLength(1) const fields = { command: 'clear' as const } const cleared = await rig.host.conversationCommand(QUEUED_RIG_CALLER, { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts index 9f42431e7730..8279cf83d183 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts @@ -49,6 +49,7 @@ import { newestStructuredAgentSessionTurn } from '../../../shared/structured-agent-session-live-turn' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { isUnansweredHandedOverSubmission } from '../agent-session-journal/journal-unsent-send-hold' import { structuredAgentSessionShownStatus } from './structured-agent-session-shown-work' /** A send Orca journaled that the provider has neither opened a turn for nor refused. Mirrors the @@ -172,6 +173,8 @@ export function structuredAgentSessionWorkingAtStop(input: { getRecord: (sessionId: string) => AgentSessionRecord | null /** The session's child records, the same read the status feed publishes. */ childWork: (sessionId: string) => readonly AgentChildWorkView[] | undefined + /** False when the session's live child never answered its start; the exit reads the same fact. */ + startAnswered?: (sessionId: string) => boolean | undefined trigger: AgentSessionResumeTrigger /** Stable teardown identity for continuation deduplication, not launch ancestry. */ teardownId: string @@ -182,9 +185,13 @@ export function structuredAgentSessionWorkingAtStop(input: { return null } const snapshot = session.journal.snapshot() - // A queued message reached no agent, so it is no work to resume: the next open settles it. + // A queued message reached no agent, and one handed to a start that never answered ran nowhere: + // neither is work to resume. The next open or the exit keeps it as unsent. + const unanswered = input.startAnswered?.(sessionId) === false const handedOver = snapshot.submissions.filter( - (submission) => !isQueuedAgentJournalSubmission(submission) + (submission) => + !isQueuedAgentJournalSubmission(submission) && + !(unanswered && isUnansweredHandedOverSubmission(submission)) ) const childWork = input.childWork(sessionId) const status = structuredAgentSessionShownStatus( diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts index 56515bbe6c0e..9664aaf65573 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts @@ -371,23 +371,26 @@ it('refuses the command for an agent that does not declare compaction, whatever it('refuses the command at handover when the provider opened a turn meanwhile (B3)', async () => { await attach() const events = state.acquire.mock.calls.at(-1)?.[0].events - // The provider starts a turn of its own after acceptance, before the command is handed over. - Object.assign(state.host.deps.adapter, { - awaitStarted: vi.fn(async () => { - events?.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'provider-turn', ordinal: 0 }, - { kind: 'turn', turnId: 'provider-turn', state: 'running' }, - { turnScope: AGENT_JOURNAL_THREAD_SCOPE, lifecycle: true } - ) - }) - }) const params = compactParams() + // Held, the command is accepted, then the provider starts a turn of its own, both ahead of the + // handover the acceptance asks for. + const held = Promise.withResolvers() + void state.host['tasks'].serialize(SESSION, () => held.promise) + const commanded = state.host.conversationCommand(CALLER, params) + void state.host['tasks'].serialize(SESSION, async () => { + events?.appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'provider-turn', ordinal: 0 }, + { kind: 'turn', turnId: 'provider-turn', state: 'running' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE, lifecycle: true } + ) + }) + held.resolve() const refused = { kind: 'commandRefused', refusal: { code: 'agent_session_operation_invalid', details: { reason: 'turnActive' } } } - await expect(state.host.conversationCommand(CALLER, params)).resolves.toMatchObject({ + await expect(commanded).resolves.toMatchObject({ ok: true, value: { state: 'completed', error: "This command didn't run. Try it again.", failure: refused } }) diff --git a/src/main/native-chat/session-file-resolver.ts b/src/main/native-chat/session-file-resolver.ts index 08de49763b45..802cc22baffc 100644 --- a/src/main/native-chat/session-file-resolver.ts +++ b/src/main/native-chat/session-file-resolver.ts @@ -1,3 +1,4 @@ +import { claudeProfileHistoryDirs } from '../claude-accounts/claude-profile-installed-router' import { homedir } from 'node:os' import { basename, extname, join } from 'node:path' import type { AgentType } from '../../shared/native-chat-types' @@ -38,7 +39,8 @@ import { wslTranscriptFsRefusal, type WslTranscriptFsError } from './wsl-transcr function claudeProjectsDirs(): string[] { const candidates = [ join(process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), '.claude'), 'projects'), - join(homedir(), '.claude', 'projects') + join(homedir(), '.claude', 'projects'), + ...claudeProfileHistoryDirs('projects') ] return candidates.filter((dir, index) => candidates.indexOf(dir) === index) } diff --git a/src/main/native-chat/structured-agent-command-resolution.test.ts b/src/main/native-chat/structured-agent-command-resolution.test.ts index 3fa758cfe5b6..a2fc166a1a33 100644 --- a/src/main/native-chat/structured-agent-command-resolution.test.ts +++ b/src/main/native-chat/structured-agent-command-resolution.test.ts @@ -5,6 +5,11 @@ import { afterEach, describe, expect, it } from 'vitest' import { resolveStructuredAgentCommand } from './structured-agent-command-resolution' import { resolveCliCommand } from '../../shared/node-cli-command-resolution' +const NOT_RUNNABLE = expect.objectContaining({ + name: 'AgentSessionPreSpawnError', + reason: 'agentCommandNotRunnable' +}) + const scratch: string[] = [] afterEach(() => { for (const dir of scratch.splice(0)) { @@ -62,7 +67,12 @@ describe('structured agent executable resolution', () => { expect(resolveStructuredAgentCommand('claude', settings)).toBe(second.command) }) - it('uses the stock executable for missing files, directories, relative paths and shell lines', () => { + it.each(['', ' ', undefined])('uses the stock executable when the Command is %j', (command) => { + const settings = command === undefined ? {} : { agentCmdOverrides: { claude: command } } + expect(resolveStructuredAgentCommand('claude', settings)).toBe(resolveCliCommand('claude')) + }) + + it('refuses missing files, directories, relative paths and command lines instead of the stock executable', () => { const { directory } = executable() const folder = join(directory, 'folder') mkdirSync(folder) @@ -71,18 +81,89 @@ describe('structured agent executable resolution', () => { folder, './claude', 'npx claude', - 'wrapper --flag' + 'wrapper --flag', + 'FOO=1 claude', + '$HOME/bin/claude' ]) { const settings = { agentCmdOverrides: { claude: command } } - expect(resolveStructuredAgentCommand('claude', settings)).toBe(resolveCliCommand('claude')) + expect(() => resolveStructuredAgentCommand('claude', settings)).toThrow(NOT_RUNNABLE) } }) it.skipIf(process.platform === 'win32')('requires executable permission on Unix', () => { const { command } = executable() chmodSync(command, 0o644) - expect( + expect(() => resolveStructuredAgentCommand('claude', { agentCmdOverrides: { claude: `"${command}"` } }) - ).toBe(resolveCliCommand('claude')) + ).toThrow(NOT_RUNNABLE) + }) + + it('keeps the saved value out of the refusal, since a command line can carry a secret', () => { + expect(() => + resolveStructuredAgentCommand('codex', { + agentCmdOverrides: { codex: 'TOKEN=sk-secret codex' } + }) + ).toThrow(expect.not.objectContaining({ message: expect.stringContaining('sk-secret') })) + }) + + describe('on Windows', () => { + function fileIn(directory: string, name: string): string { + const file = join(directory, name) + writeFileSync(file, '') + return file + } + + it('refuses an extensionless path, which Windows cannot spawn', () => { + const { directory } = executable() + const command = fileIn(directory, 'claude') + fileIn(directory, 'claude.cmd') + expect(() => + resolveStructuredAgentCommand( + 'claude', + { agentCmdOverrides: { claude: command } }, + { platform: 'win32' } + ) + ).toThrow(NOT_RUNNABLE) + }) + + it('refuses a name whose only match on PATH is extensionless', () => { + const { directory } = executable() + fileIn(directory, 'my-claude') + expect(() => + resolveStructuredAgentCommand( + 'claude', + { agentCmdOverrides: { claude: 'my-claude' } }, + { platform: 'win32', pathEnv: directory, homePath: directory } + ) + ).toThrow(NOT_RUNNABLE) + }) + + it.each(['claude.exe', 'claude.com', 'claude.cmd', 'claude.bat'])( + 'runs an explicit %s path', + (name) => { + const { directory } = executable() + const command = fileIn(directory, name) + expect( + resolveStructuredAgentCommand( + 'claude', + { agentCmdOverrides: { claude: `"${command}"` } }, + { platform: 'win32' } + ) + ).toBe(command) + } + ) + + it('finds the .cmd shim for a bare name, as the stock lookup does', () => { + const { directory } = executable() + fileIn(directory, 'my-claude') + const shim = fileIn(directory, 'my-claude.cmd') + expect( + resolveStructuredAgentCommand( + 'claude', + { agentCmdOverrides: { claude: 'my-claude' } }, + { platform: 'win32', pathEnv: directory, homePath: directory } + ) + ).toBe(shim) + }) }) }) diff --git a/src/main/native-chat/structured-agent-command-resolution.ts b/src/main/native-chat/structured-agent-command-resolution.ts index 7ce9afcb77b1..9cc1f28bd010 100644 --- a/src/main/native-chat/structured-agent-command-resolution.ts +++ b/src/main/native-chat/structured-agent-command-resolution.ts @@ -4,7 +4,11 @@ import { resolveCliCommand, resolveExecutableCommand } from '../../shared/node-cli-command-resolution' -import { structuredAgentCommandToken } from '../../shared/tui-agent-launch-command-override' +import { + hasExplicitTuiLaunchCommand, + structuredAgentCommandToken +} from '../../shared/tui-agent-launch-command-override' +import { AgentSessionPreSpawnError } from './agent-session-wire/structured-agent-session-adapter' type CommandSettings = Partial> type CommandOptions = NonNullable[1]> @@ -24,11 +28,28 @@ function resolveOverride( : null } -/** Re-read the existing setting for every session acquisition and catalog probe. */ +// Windows starts .exe/.com directly and .cmd/.bat through Orca's shim handling; an extensionless +// file would fail to spawn with no word about the setting. +function spawnableOn(platform: NodeJS.Platform, command: string): boolean { + return platform !== 'win32' || /\.(exe|com|cmd|bat)$/i.test(command) +} + +/** Re-read the existing setting for every session acquisition and catalog probe. A set Command + * that names no runnable program refuses the start rather than quietly running the stock CLI. */ export function resolveStructuredAgentCommand( agent: 'claude' | 'codex', settings: CommandSettings, options: CommandOptions = {} ): string { - return resolveOverride(agent, settings, options) ?? resolveCliCommand(agent, options) + if (!hasExplicitTuiLaunchCommand(settings, agent)) { + return resolveCliCommand(agent, options) + } + const command = resolveOverride(agent, settings, options) + if (!command || !spawnableOn(options.platform ?? process.platform, command)) { + // The value stays out of the message: a saved command line can carry a secret. + throw new AgentSessionPreSpawnError(`the ${agent} Command setting is not a runnable program`, { + reason: 'agentCommandNotRunnable' + }) + } + return command } diff --git a/src/main/native-chat/transcript-watch-inplace-drain-race.test.ts b/src/main/native-chat/transcript-watch-inplace-drain-race.test.ts index c52de9a98962..0a09797b0be0 100644 --- a/src/main/native-chat/transcript-watch-inplace-drain-race.test.ts +++ b/src/main/native-chat/transcript-watch-inplace-drain-race.test.ts @@ -1,4 +1,4 @@ -import { writeFileSync } from 'node:fs' +import { utimesSync, writeFileSync } from 'node:fs' import { appendFile, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -15,52 +15,84 @@ function record(id: string, text: string): string { })}\n` } -it.each(['append', 'initial snapshot', 'replacement snapshot'] as const)( - 'delivers a larger rewrite while publishing a completed %s', - async (mode) => { - const before = getActiveNativeChatWatcherCount() - const root = await mkdtemp(join(tmpdir(), 'orca-transcript-drain-race-')) - const filePath = join(root, 'rollout.jsonl') - const seen: string[] = [] - let stop = (): void => {} - try { - await writeFile(filePath, record('old', 'old')) - const publish = (messages: NativeChatMessage[]): void => { - seen.push(...messages.map((message) => message.id)) - if (messages.some((message) => message.id === 'old')) { - writeFileSync( - filePath, - record( - mode === 'replacement snapshot' ? 'middle' : 'new', - 'a larger replacement transcript' - ) - ) - } - if (messages.some((message) => message.id === 'middle')) { - writeFileSync(filePath, record('new', 'a second still larger replacement transcript')) - } +it.each([ + 'append', + 'initial snapshot', + 'replacement snapshot', + 'metadata-only replacement replay' +] as const)('delivers a larger rewrite while publishing a completed %s', async (mode) => { + const before = getActiveNativeChatWatcherCount() + const root = await mkdtemp(join(tmpdir(), 'orca-transcript-drain-race-')) + const filePath = join(root, 'rollout.jsonl') + const seen: string[] = [] + const appendDeliveries: string[] = [] + let newReplacementCount = 0 + const usesReplacement = + mode === 'replacement snapshot' || mode === 'metadata-only replacement replay' + let stop = (): void => {} + try { + await writeFile(filePath, record('old', 'old')) + const publish = (messages: NativeChatMessage[], replacement: boolean): void => { + const ids = messages.map((message) => message.id) + if (replacement) { + // Full snapshots replace the client's view and may replay an existing message. + seen.splice(0, seen.length, ...ids) + } else { + seen.push(...ids) + appendDeliveries.push(...ids) } - const sub = await subscribeNativeChatTranscript({ - agent: 'claude', - sessionId: 'ignored', - filePath, - debounceMs: 0, - reconciliationIntervalMs: 20, - initialLimit: mode === 'append' ? undefined : 50, - onAppend: publish, - ...(mode === 'initial snapshot' ? { onInitialSnapshot: publish } : {}), - ...(mode === 'replacement snapshot' ? { onReplace: publish } : {}) - }) - stop = sub.unsubscribe - await expect.poll(() => seen, { timeout: 1_000 }).toContain('new') - await appendFile(filePath, record('followup', 'normal append')) - await expect.poll(() => seen, { timeout: 1_000 }).toContain('followup') - expect(seen.filter((id) => id === 'new')).toHaveLength(1) - expect(seen.filter((id) => id === 'followup')).toHaveLength(1) - } finally { - stop() - await rm(root, { recursive: true, force: true }) - expect(getActiveNativeChatWatcherCount()).toBe(before) + if (messages.some((message) => message.id === 'old')) { + writeFileSync( + filePath, + record(usesReplacement ? 'middle' : 'new', 'a larger replacement transcript') + ) + } + if (messages.some((message) => message.id === 'middle')) { + writeFileSync(filePath, record('new', 'a second still larger replacement transcript')) + } + } + const sub = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'ignored', + filePath, + debounceMs: 0, + reconciliationIntervalMs: 20, + initialLimit: mode === 'append' ? undefined : 50, + onAppend: (messages) => publish(messages, false), + ...(mode === 'initial snapshot' + ? { onInitialSnapshot: (messages) => publish(messages, true) } + : {}), + ...(usesReplacement + ? { + onReplace: (messages) => { + if (messages.some((message) => message.id === 'new')) { + newReplacementCount += 1 + } + publish(messages, true) + } + } + : {}) + }) + stop = sub.unsubscribe + await expect.poll(() => seen, { timeout: 1_000 }).toContain('new') + if (mode === 'metadata-only replacement replay') { + const beforeReplay = newReplacementCount + expect(beforeReplay).toBeGreaterThanOrEqual(1) + const updated = new Date(Date.now() + 2_000) + utimesSync(filePath, updated, updated) + await expect.poll(() => newReplacementCount, { timeout: 1_000 }).toBeGreaterThan(beforeReplay) + expect(seen).toEqual(['new']) + expect(appendDeliveries).not.toContain('new') } + await appendFile(filePath, record('followup', 'normal append')) + await expect.poll(() => seen, { timeout: 1_000 }).toContain('followup') + expect(seen.filter((id) => id === 'new')).toHaveLength(1) + expect(seen.filter((id) => id === 'followup')).toHaveLength(1) + expect(appendDeliveries.filter((id) => id === 'new')).toHaveLength(usesReplacement ? 0 : 1) + expect(appendDeliveries.filter((id) => id === 'followup').length).toBeLessThanOrEqual(1) + } finally { + stop() + await rm(root, { recursive: true, force: true }) + expect(getActiveNativeChatWatcherCount()).toBe(before) } -) +}) diff --git a/src/main/persistence/loading-store/pty-binding-persistence.ts b/src/main/persistence/loading-store/pty-binding-persistence.ts index c75d238dee8f..216cd4acafcf 100644 --- a/src/main/persistence/loading-store/pty-binding-persistence.ts +++ b/src/main/persistence/loading-store/pty-binding-persistence.ts @@ -16,6 +16,14 @@ import { evaluatePtyBindingFastLane } from './pty-binding-fast-lane' import { ptyBindingIsRefused } from './pty-binding-refusals' import { startPtyBindingSpan, type PtyBindingOrigin, type PtyBindingSpan } from './pty-binding-span' import { applyPtyBinding } from './pty-binding-session-update' +import type { TerminalPanePlacement } from '../../../shared/terminal-pane-placement' +import { terminalPanePlacementAgreement } from '../terminal-topology/terminal-pane-placement-agreement' +import type { + TerminalLeafMoveRequest, + TerminalLeafMoveResult +} from '../../../shared/terminal-leaf-move' +import { moveLeaf } from '../terminal-topology/terminal-topology-commit' +import { findTerminalBindingConflict } from '../terminal-topology/terminal-owner-invariants' type PtyBindingPersistenceOperationsRuntime = Pick< StoreRuntimeState, @@ -51,6 +59,8 @@ export type PersistPtyBindingArgs = { mayReviveRetiredSurface?: boolean /** Span metadata only; see `PtyBindingOrigin`. The write path never reads it. */ origin?: PtyBindingOrigin + /** Where a new leaf goes. Report-only for now: the span records whether it names today's tab. */ + placement?: TerminalPanePlacement } const ptyBindingPersistenceOperationsContext = Symbol('PtyBindingPersistenceOperations') @@ -149,11 +159,25 @@ export class PtyBindingPersistenceOperations { const session = sessions.getWorkspaceSession(resolvedHostId) const partitions = sessions .getWorkspaceSessionHostIds() - .map((hostId) => sessions.getWorkspaceSession(hostId)) + .map((hostId) => ({ hostId, session: sessions.getWorkspaceSession(hostId) })) if (ptyBindingIsRefused(args, session, bindingWorktreeId, paneKey, partitions)) { outcome = 'refused' return { value: false, persist: false } } + // Report-only: a malformed session must not fail the binding it is reporting on. + try { + span.setPlacement( + terminalPanePlacementAgreement( + args.placement, + session, + bindingWorktreeId, + args.tabId, + args.leafId + ) + ) + } catch { + span.setPlacement('check_threw') + } const verdict = evaluatePtyBindingFastLane( args, session, @@ -165,6 +189,16 @@ export class PtyBindingPersistenceOperations { outcome = 'fast_lane' return { value: true, persist: false } } + // Report-only: the binding is written even when it breaks an invariant, or the check throws. + // After the fast lane, so a no-op rebind skips the scan. + try { + const conflict = findTerminalBindingConflict(args, resolvedHostId, partitions) + if (conflict) { + span.setOwnerConflict(conflict.reason) + } + } catch { + span.setOwnerConflict('check_threw') + } return { value: true, rollback: writePtyBinding(this, args, session, resolvedHostId, bindingWorktreeId, paneKey) @@ -177,6 +211,22 @@ export class PtyBindingPersistenceOperations { throw error } } + + /** + * Detach-to-new-tab, committed before the renderer mounts the target tab (STA-9259). It lives on + * the binding domain only for its runtime and partition access; the commit module owns the write. + */ + moveTerminalLeafToNewTab(request: TerminalLeafMoveRequest): Promise { + const { runtime, sessions } = this[ptyBindingPersistenceOperationsContext] + return runtime.runDurableMutation( + moveLeaf(request, { + state: runtime.state, + hostIds: () => sessions.getWorkspaceSessionHostIds(), + getSession: (hostId) => sessions.getWorkspaceSession(hostId), + markDirty: (domain) => runtime.dirtyProfileStateDomains?.add(domain) + }) + ) + } } function writePtyBinding( diff --git a/src/main/persistence/loading-store/pty-binding-placement-inertness.test.ts b/src/main/persistence/loading-store/pty-binding-placement-inertness.test.ts new file mode 100644 index 000000000000..2ad1c882f23b --- /dev/null +++ b/src/main/persistence/loading-store/pty-binding-placement-inertness.test.ts @@ -0,0 +1,279 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { TEST_LEAF_1, TEST_LEAF_2 } from '../../persistence-session-fixtures' +import { _resetTracerForTests, setActiveSink } from '../../observability/tracer' +import type { TerminalPanePlacement } from '../../../shared/terminal-pane-placement' +import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR } from '../../../shared/worktree/id' +import { firstLayoutLeafId } from '../restoring-sessions/terminal-layout-normalization' +import { fixture } from './profile-state-delayed-authority-fixture' +import type * as AgreementModule from '../terminal-topology/terminal-pane-placement-agreement' +import type { PersistPtyBindingArgs } from './pty-binding-persistence' + +const agreementCheck = vi.hoisted(() => ({ throws: false })) +vi.mock('../terminal-topology/terminal-pane-placement-agreement', async (importOriginal) => { + const actual = await importOriginal() + return { + terminalPanePlacementAgreement: ( + ...args: Parameters + ) => { + if (agreementCheck.throws) { + throw new Error('malformed session') + } + return actual.terminalPanePlacementAgreement(...args) + } + } +}) +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const LOCAL_WORKTREE = 'repo-local::/fixture/local' +const REMOTE_WORKTREE = 'repo-remote::/fixture/remote' +const FOLDER_WORKTREE = `${LOCAL_WORKTREE}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}55555555-5555-4555-8555-555555555555` +const SSH_HOST = 'ssh:build-host' +const NOW = 1_800_000_000_000 + +const NEW_TAB: TerminalPanePlacement = { kind: 'new-tab' } +const ROOT: TerminalPanePlacement = { kind: 'root' } +const split = (parentLeafId: string): TerminalPanePlacement => ({ + kind: 'split', + parentLeafId, + direction: 'horizontal' +}) + +type Scenario = { + name: string + hostId?: string + binding: PersistPtyBindingArgs + /** Bound once before the measured write, so the measured write finds the leaf present. */ + prebind?: boolean + /** Each placement gets the existing tab's first leaf, whose id load normalization randomizes. */ + placements: (existingLeaf: string) => [TerminalPanePlacement, string][] +} + +const SCENARIOS: Scenario[] = [ + { + name: 'local fresh tab (mint)', + binding: { + worktreeId: LOCAL_WORKTREE, + tabId: 'tab-new', + leafId: TEST_LEAF_1, + ptyId: 'pty-new', + incarnationId: 'inc-new', + startupCwd: '/fixture/local/sub', + hostAdmittedMembership: true + }, + placements: (leaf) => [ + [NEW_TAB, 'agrees'], + [ROOT, 'tab_missing'], + [split(leaf), 'tab_missing'] + ] + }, + { + name: 'local existing tab, unknown leaf (graft)', + binding: { + worktreeId: LOCAL_WORKTREE, + tabId: 'tab-local', + leafId: TEST_LEAF_2, + ptyId: 'pty-split', + incarnationId: 'inc-split' + }, + placements: (leaf) => [ + [split(leaf), 'agrees'], + [split(TEST_LEAF_1), 'parent_missing'], + [NEW_TAB, 'tab_exists'], + [ROOT, 'root_occupied'] + ] + }, + { + name: 'local leaf already present (respawn)', + prebind: true, + binding: { + worktreeId: LOCAL_WORKTREE, + tabId: 'tab-local', + leafId: TEST_LEAF_2, + ptyId: 'pty-respawn', + incarnationId: 'inc-respawn' + }, + placements: (leaf) => [ + [split(leaf), 'leaf_present'], + [NEW_TAB, 'leaf_present'] + ] + }, + { + name: 'folder workspace fresh tab', + binding: { + worktreeId: FOLDER_WORKTREE, + tabId: 'tab-folder', + leafId: TEST_LEAF_1, + ptyId: 'pty-folder', + hostAdmittedMembership: true + }, + placements: () => [ + [NEW_TAB, 'agrees'], + [ROOT, 'tab_missing'] + ] + }, + { + name: 'ssh partition fresh tab', + hostId: SSH_HOST, + binding: { + worktreeId: REMOTE_WORKTREE, + tabId: 'tab-ssh-new', + leafId: TEST_LEAF_1, + ptyId: 'pty-ssh-new', + incarnationId: 'inc-ssh' + }, + placements: () => [ + [NEW_TAB, 'agrees'], + [ROOT, 'tab_missing'] + ] + }, + { + name: 'ssh partition existing tab (graft)', + hostId: SSH_HOST, + binding: { + worktreeId: REMOTE_WORKTREE, + tabId: 'tab-remote', + leafId: TEST_LEAF_2, + ptyId: 'pty-ssh-split' + }, + placements: (leaf) => [ + [split(leaf), 'agrees'], + [NEW_TAB, 'tab_exists'] + ] + } +] + +let placementAttributes: unknown[] = [] + +beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }) + placementAttributes = [] + setActiveSink({ + push: (record) => { + if ( + typeof record === 'object' && + record !== null && + 'name' in record && + record.name === 'persistence.pty-binding' && + 'attributes' in record && + typeof record.attributes === 'object' && + record.attributes !== null && + 'binding.placement' in record.attributes + ) { + placementAttributes.push(record.attributes['binding.placement']) + } + }, + flush: () => {}, + close: () => {} + }) +}) + +afterEach(() => { + agreementCheck.throws = false + vi.useRealTimers() + _resetTracerForTests() +}) + +async function bindAndSave( + scenario: Scenario, + placementFor: ((existingLeaf: string) => TerminalPanePlacement) | undefined +): Promise<{ result: boolean; memory: unknown; saved: unknown }> { + vi.setSystemTime(NOW) + const { store, readState } = await fixture() + const existingTab = scenario.hostId ? 'tab-remote' : 'tab-local' + const existingLeaf = firstLayoutLeafId( + store.getWorkspaceSession(scenario.hostId).terminalLayoutsByTabId[existingTab]?.root ?? null + ) + if (!existingLeaf) { + throw new Error('fixture lost its existing tab') + } + const binding = scenario.binding + const placement = placementFor?.(existingLeaf) + if (scenario.prebind) { + await store.persistPtyBinding({ ...binding, ptyId: 'pty-before' }, scenario.hostId) + } + placementAttributes = [] + const result = await store.persistPtyBinding( + placement ? { ...binding, placement } : binding, + scenario.hostId + ) + await store.flushPendingOrThrowAsync() + const state = readState() + // Load normalization gives each fixture's legacy leaves fresh random ids; compare everything else. + const stable = (value: unknown): unknown => { + let text = JSON.stringify(value) + for (const tabId of ['tab-local', 'tab-remote']) { + const leaf = firstLayoutLeafId( + store.getWorkspaceSession(tabId === 'tab-local' ? undefined : SSH_HOST) + .terminalLayoutsByTabId[tabId]?.root ?? null + ) + text = leaf ? text.replaceAll(leaf, `<${tabId} leaf>`) : text + } + return JSON.parse(text) + } + return { + result, + memory: stable({ + local: store.getWorkspaceSession(), + ssh: store.getWorkspaceSession(SSH_HOST) + }), + saved: stable({ local: state.workspaceSession, byHost: state.workspaceSessionsByHostId }) + } +} + +describe('placement on the binding write is inert', () => { + for (const scenario of SCENARIOS) { + it(`${scenario.name}: memory and saved state match a write without placement`, async () => { + const baseline = await bindAndSave(scenario, undefined) + expect(baseline.result).toBe(true) + expect(placementAttributes).toEqual(['absent']) + const cases = scenario.placements('') + for (const [index, [, agreement]] of cases.entries()) { + const placed = await bindAndSave(scenario, (leaf) => scenario.placements(leaf)[index][0]) + expect(placementAttributes).toEqual([agreement]) + expect(placed.result).toBe(baseline.result) + expect(placed.memory).toEqual(baseline.memory) + expect(placed.saved).toEqual(baseline.saved) + expect(JSON.stringify(placed.saved)).not.toContain('placement') + } + }) + } + + it('a throwing agreement check leaves the result and state as without placement', async () => { + const scenario = SCENARIOS[0] + const baseline = await bindAndSave(scenario, undefined) + agreementCheck.throws = true + const placed = await bindAndSave(scenario, () => NEW_TAB) + expect(placementAttributes).toEqual(['check_threw']) + expect(placed).toEqual(baseline) + }) + + it('a tombstoned pane is still refused with placement', async () => { + vi.setSystemTime(NOW) + const { store } = await fixture() + const session = store.getWorkspaceSession() + store.setWorkspaceSession({ + ...session, + closedTerminalTabTombstonesByTabId: { + 'tab-closed': { closedAt: NOW, worktreeId: LOCAL_WORKTREE } + } + }) + const before = structuredClone(store.getWorkspaceSession()) + await expect( + store.persistPtyBinding({ + worktreeId: LOCAL_WORKTREE, + tabId: 'tab-closed', + leafId: TEST_LEAF_1, + ptyId: 'pty-late', + placement: NEW_TAB + }) + ).resolves.toBe(false) + expect(store.getWorkspaceSession()).toEqual(before) + }) +}) diff --git a/src/main/persistence/loading-store/pty-binding-refusals.ts b/src/main/persistence/loading-store/pty-binding-refusals.ts index 636e69bd936e..6576ac3d0070 100644 --- a/src/main/persistence/loading-store/pty-binding-refusals.ts +++ b/src/main/persistence/loading-store/pty-binding-refusals.ts @@ -1,6 +1,7 @@ import { hasClosedTerminalTabRecord } from '../../../shared/closed-terminal-tab-tombstones' import { isTerminalLeafId } from '../../../shared/stable-pane-id' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import type { TerminalSessionPartition } from '../terminal-topology/terminal-owner-invariants' import { layoutContainsLeafId } from '../restoring-sessions/terminal-layout-normalization' import type { PtyBindingSourceExpectation } from './store' @@ -25,7 +26,7 @@ export function ptyBindingIsRefused( paneKey: string, /** Every host partition: a close is recorded where the tab lived, which need not be where this * binding lands (a relay reattach binds into `local`). */ - partitions: readonly WorkspaceSessionState[] = [session] + partitions: readonly TerminalSessionPartition[] ): boolean { if (args.expectedSourceBinding) { const expected = args.expectedSourceBinding @@ -68,7 +69,7 @@ export function ptyBindingIsRefused( if ( !existingTab && partitions.some((partition) => - hasClosedTerminalTabRecord(partition.closedTerminalTabTombstonesByTabId, args.tabId) + hasClosedTerminalTabRecord(partition.session.closedTerminalTabTombstonesByTabId, args.tabId) ) ) { return true diff --git a/src/main/persistence/loading-store/pty-binding-span.ts b/src/main/persistence/loading-store/pty-binding-span.ts index 903d93c4967f..04f6f0fc492f 100644 --- a/src/main/persistence/loading-store/pty-binding-span.ts +++ b/src/main/persistence/loading-store/pty-binding-span.ts @@ -1,5 +1,7 @@ import { startSpan } from '../../observability/tracer' import type { PtyBindingFastLaneMiss } from './pty-binding-fast-lane' +import type { TerminalPanePlacementAgreement } from '../terminal-topology/terminal-pane-placement-agreement' +import type { TerminalOwnerConflictReason } from '../terminal-topology/terminal-owner-invariants' export type PtyBindingSpanOutcome = 'fast_lane' | 'flushed' | 'refused' | 'threw' @@ -44,6 +46,9 @@ function admitFastLaneSpan(nowMs: number): boolean { export type PtyBindingSpan = { setEligibility(verdict: { eligible: boolean; misses: readonly PtyBindingFastLaneMiss[] }): void + setPlacement(agreement: TerminalPanePlacementAgreement): void + /** A binding that breaks a layout invariant (report-only: it is still written). */ + setOwnerConflict(reason: TerminalOwnerConflictReason | 'check_threw'): void finish(outcome: PtyBindingSpanOutcome, error?: unknown): void } @@ -78,6 +83,12 @@ export function startPtyBindingSpan(entry: { span.setAttribute('binding.eligible', verdict.eligible) span.setAttribute('binding.misses', verdict.misses.join(',')) }, + setPlacement(agreement) { + span.setAttribute('binding.placement', agreement) + }, + setOwnerConflict(reason) { + span.setAttribute('binding.owner_conflict', reason) + }, finish(outcome, error) { span.setAttribute('binding.outcome', outcome) if (outcome === 'threw') { diff --git a/src/main/persistence/terminal-topology/terminal-leaf-move-concurrent-close.test.ts b/src/main/persistence/terminal-topology/terminal-leaf-move-concurrent-close.test.ts new file mode 100644 index 000000000000..d5d0a89c4bcb --- /dev/null +++ b/src/main/persistence/terminal-topology/terminal-leaf-move-concurrent-close.test.ts @@ -0,0 +1,120 @@ +import { tmpdir } from 'node:os' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { TerminalSurfaceCloseTarget } from '../../../shared/terminal-surface-close-target' +import { makeRepo } from '../../persistence-test-harness' +import { + closeMoveTestStores, + MOVED, + moveRequest, + newDataFile, + openStore, + seedSplitSource, + SOURCE, + TARGET, + tabsHoldingLeaf, + WT +} from './terminal-leaf-move-fixture' +import { closeLeafOrTab } from './terminal-topology-commit' + +vi.mock('electron', () => ({ + app: { + getPath: () => tmpdir(), + getName: () => 'orca-test', + getVersion: () => '0.0.0-test', + isPackaged: false, + on: () => {}, + whenReady: () => Promise.resolve() + }, + safeStorage: { + isEncryptionAvailable: () => false, + encryptString: (value: string) => Buffer.from(value), + decryptString: (value: Buffer) => value.toString() + }, + ipcMain: { on: () => {}, handle: () => {} }, + BrowserWindow: { getAllWindows: () => [] } +})) + +afterEach(closeMoveTestStores) + +const request = { ...moveRequest, ptyId: 'pty-agent' } + +/** What `session:close-terminal-surface` commits for a close the renderer already performed. */ +async function rendererClose( + store: ReturnType, + target: TerminalSurfaceCloseTarget, + reason: 'user' | 'cleanup' +): Promise { + await store.runDurableMutation( + closeLeafOrTab({ + worktreeId: WT, + target, + options: { allowMissing: true, force: true, closedByLayoutOwner: true, reason }, + requestedSession: store.getWorkspaceSession(), + ownerMatches: () => true, + hostId: () => 'local', + getSession: (hostId) => store.getWorkspaceSession(hostId), + setSession: (session, hostId) => store.setWorkspaceSession(session, hostId), + onClosed: () => {} + }) + ) +} + +async function openMovedStore() { + const dataFile = newDataFile() + const store = openStore(dataFile) + store.addRepo(makeRepo({ id: 'repo-1', path: '/tmp/move-worktree' })) + await seedSplitSource(store) + await expect(store.moveTerminalLeafToNewTab(request)).resolves.toMatchObject({ + status: 'moved' + }) + return { dataFile, store } +} + +function restartedTabs(store: ReturnType, dataFile: string) { + store.flush() + const session = openStore(dataFile).getWorkspaceSession() + return { tabIds: (session.tabsByWorktree[WT] ?? []).map((tab) => tab.id), session } +} + +// The renderer rolls a committed move forward: when the pane is gone it closes main's new tab. +describe('a close that lands while main commits the move', () => { + it('leaves no dead pane after restart when the user closed the moved pane', async () => { + const { dataFile, store } = await openMovedStore() + // The pane close reaches main after the move, so main no longer has SOURCE:leaf. + await rendererClose(store, { kind: 'pane', tabId: SOURCE, leafId: MOVED }, 'user') + expect(tabsHoldingLeaf(store.getWorkspaceSession(), MOVED)).toEqual([TARGET]) + + await rendererClose(store, { kind: 'tab', tabId: TARGET }, 'cleanup') + + const { tabIds, session } = restartedTabs(store, dataFile) + expect(tabsHoldingLeaf(session, MOVED)).toEqual([]) + expect(tabIds).toEqual([SOURCE]) + }) + + it('leaves no ghost tab after restart when the user closed the source tab', async () => { + const { dataFile, store } = await openMovedStore() + await rendererClose(store, { kind: 'tab', tabId: SOURCE }, 'user') + + await rendererClose(store, { kind: 'tab', tabId: TARGET }, 'cleanup') + + const { tabIds, session } = restartedTabs(store, dataFile) + expect(tabIds).toEqual([]) + expect(tabsHoldingLeaf(session, MOVED)).toEqual([]) + }) +}) + +// Why the renderer refuses to drag a pane whose spawn is in flight: a move with no PTY id lets the +// late spawn result bind SOURCE:leaf, and that bind grafts the leaf back into the source tab. +it('grafts a late spawn result for the moved leaf back into its source tab', async () => { + const store = openStore(newDataFile()) + await seedSplitSource(store) + await store.moveTerminalLeafToNewTab({ ...request, ptyId: null }) + await store.persistPtyBinding({ + worktreeId: WT, + tabId: SOURCE, + leafId: MOVED, + ptyId: 'pty-late', + incarnationId: 'inc-late' + }) + expect(tabsHoldingLeaf(store.getWorkspaceSession(), MOVED).sort()).toEqual([SOURCE, TARGET]) +}) diff --git a/src/main/persistence/terminal-topology/terminal-leaf-move-fixture.ts b/src/main/persistence/terminal-topology/terminal-leaf-move-fixture.ts new file mode 100644 index 000000000000..413d6cecc41d --- /dev/null +++ b/src/main/persistence/terminal-topology/terminal-leaf-move-fixture.ts @@ -0,0 +1,86 @@ +import { mkdtempSync, realpathSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { vi } from 'vitest' +import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { closeTestStores, createSqliteTestStore } from '../../persistence-test-harness' +import { Store } from '../loading-store/store' +import { collectLayoutLeafIdsInOrder } from '../restoring-sessions/terminal-layout-normalization' + +/** Shared by the move tests; each test file mocks `electron` before importing this. */ + +export const WT = 'repo-1::/tmp/move-worktree' +export const SOURCE = 'tab-source' +export const TARGET = 'tab-target' +export const LEFT = '11111111-1111-4111-8111-111111111111' +export const MOVED = '22222222-2222-4222-8222-222222222222' +export const FROM = `${SOURCE}:${MOVED}` +export const TO = `${TARGET}:${MOVED}` + +const stores: Store[] = [] + +export async function closeMoveTestStores(): Promise { + for (const store of stores.splice(0)) { + store.freezeWrites() + } + await closeTestStores() + vi.restoreAllMocks() +} + +export function newDataFile(): string { + return join(realpathSync(mkdtempSync(join(tmpdir(), 'orca-leaf-move-'))), 'state.json') +} + +export function openStore(dataFile: string): Store { + const store = createSqliteTestStore(Store, { dataFile }) + stores.push(store) + return store +} + +export function sleeping(paneKey: string, tabId: string): SleepingAgentSessionRecord { + return { + paneKey, + tabId, + worktreeId: WT, + agent: 'codex', + providerSession: { key: 'session_id', id: 'codex-1' }, + prompt: 'go', + state: 'working', + capturedAt: 1, + updatedAt: 1 + } +} + +/** A split source tab whose second leaf runs the agent that gets moved. */ +export async function seedSplitSource(store: Store, hostId?: string): Promise { + await store.persistPtyBinding( + { worktreeId: WT, tabId: SOURCE, leafId: LEFT, ptyId: 'pty-left', incarnationId: 'inc-left' }, + hostId + ) + await store.persistPtyBinding( + { worktreeId: WT, tabId: SOURCE, leafId: MOVED, ptyId: 'pty-agent', incarnationId: 'inc-1' }, + hostId + ) +} + +export function ownersOf(session: WorkspaceSessionState, ptyId: string): string[] { + return Object.entries(session.terminalLayoutsByTabId).flatMap(([tabId, layout]) => + collectLayoutLeafIdsInOrder(layout.root) + .filter((leafId) => layout.ptyIdsByLeafId?.[leafId] === ptyId) + .map((leafId) => `${tabId}:${leafId}`) + ) +} + +export function tabsHoldingLeaf(session: WorkspaceSessionState, leafId: string): string[] { + return Object.entries(session.terminalLayoutsByTabId) + .filter(([, layout]) => collectLayoutLeafIdsInOrder(layout.root).includes(leafId)) + .map(([tabId]) => tabId) +} + +export const moveRequest = { + worktreeId: WT, + sourceTabId: SOURCE, + targetTabId: TARGET, + leafId: MOVED +} diff --git a/src/main/persistence/terminal-topology/terminal-leaf-move.test.ts b/src/main/persistence/terminal-topology/terminal-leaf-move.test.ts new file mode 100644 index 000000000000..14ff2d7b764a --- /dev/null +++ b/src/main/persistence/terminal-topology/terminal-leaf-move.test.ts @@ -0,0 +1,363 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { tmpdir } from 'node:os' +import { toSshExecutionHostId } from '../../../shared/execution-host' +import { _resetTracerForTests, setActiveSink } from '../../observability/tracer' +import { makeRepo, makeTerminalTab } from '../../persistence-test-harness' +import { planTerminalLeafMove } from './terminal-leaf-move' +import { + closeMoveTestStores, + FROM, + LEFT, + MOVED, + moveRequest, + newDataFile, + openStore, + ownersOf, + seedSplitSource, + sleeping, + SOURCE, + TARGET, + tabsHoldingLeaf, + TO, + WT +} from './terminal-leaf-move-fixture' + +vi.mock('electron', () => ({ + app: { + getPath: () => tmpdir(), + getName: () => 'orca-test', + getVersion: () => '0.0.0-test', + isPackaged: false, + on: () => {}, + whenReady: () => Promise.resolve() + }, + safeStorage: { + isEncryptionAvailable: () => false, + encryptString: (value: string) => Buffer.from(value), + decryptString: (value: Buffer) => value.toString() + }, + ipcMain: { on: () => {}, handle: () => {} }, + BrowserWindow: { getAllWindows: () => [] } +})) + +afterEach(closeMoveTestStores) + +describe('moving a pane to a new tab', () => { + it('moves the leaf and its binding in one write and re-keys pane-keyed records', async () => { + const store = openStore(newDataFile()) + await seedSplitSource(store) + store.setWorkspaceSession({ + ...store.getWorkspaceSession(), + sleepingAgentSessionsByPaneKey: { [FROM]: sleeping(FROM, SOURCE) } + }) + store.updateUI({ + acknowledgedAgentsByPaneKey: { [FROM]: 10 }, + activityClearedAtByPaneKey: { [FROM]: 11 }, + manuallyUnreadTurnsByPaneKey: { [FROM]: 12 } + }) + + await expect( + store.moveTerminalLeafToNewTab({ ...moveRequest, ptyId: 'pty-agent' }) + ).resolves.toEqual({ status: 'moved', ptyId: 'pty-agent' }) + + const session = store.getWorkspaceSession() + expect(session.tabsByWorktree[WT]?.map((tab) => tab.id)).toEqual([SOURCE, TARGET]) + expect(session.terminalLayoutsByTabId[SOURCE]).toMatchObject({ + root: { type: 'leaf', leafId: LEFT }, + ptyIdsByLeafId: { [LEFT]: 'pty-left' } + }) + expect(session.terminalLayoutsByTabId[TARGET]).toMatchObject({ + root: { type: 'leaf', leafId: MOVED }, + ptyIdsByLeafId: { [MOVED]: 'pty-agent' } + }) + expect(session.terminalPtyIncarnationsByPaneKey).toEqual({ + [`${SOURCE}:${LEFT}`]: 'inc-left', + [TO]: 'inc-1' + }) + expect(session.sleepingAgentSessionsByPaneKey).toEqual({ + [TO]: expect.objectContaining({ paneKey: TO, tabId: TARGET }) + }) + expect(store.getUI()).toMatchObject({ + acknowledgedAgentsByPaneKey: { [TO]: 10 }, + activityClearedAtByPaneKey: { [TO]: 11 }, + manuallyUnreadTurnsByPaneKey: { [TO]: 12 } + }) + expect(ownersOf(session, 'pty-agent')).toEqual([TO]) + }) + + it('re-keys the SSH lease and moves within the partition that holds the tab', async () => { + const store = openStore(newDataFile()) + const hostId = toSshExecutionHostId('ssh-1') + await seedSplitSource(store, hostId) + store.upsertSshRemotePtyLease({ + targetId: 'ssh-1', + ptyId: 'pty-agent', + worktreeId: WT, + tabId: SOURCE, + leafId: MOVED, + state: 'attached' + }) + + await expect( + store.moveTerminalLeafToNewTab({ ...moveRequest, ptyId: 'pty-agent' }) + ).resolves.toMatchObject({ status: 'moved' }) + + expect(tabsHoldingLeaf(store.getWorkspaceSession(hostId), MOVED)).toEqual([TARGET]) + expect(store.getWorkspaceSession().tabsByWorktree[WT]).toBeUndefined() + expect(store.getSshRemotePtyLeases('ssh-1')).toEqual([ + expect.objectContaining({ ptyId: 'pty-agent', tabId: TARGET, leafId: MOVED }) + ]) + }) + + it('reports a leaf main never held instead of inventing one', async () => { + const store = openStore(newDataFile()) + await expect( + store.moveTerminalLeafToNewTab({ ...moveRequest, ptyId: 'pty-agent' }) + ).resolves.toEqual({ status: 'not_held' }) + expect(store.getWorkspaceSession().tabsByWorktree[WT]).toBeUndefined() + }) + + it('refuses a move that names another terminal or an existing tab', async () => { + const store = openStore(newDataFile()) + await seedSplitSource(store) + await expect( + store.moveTerminalLeafToNewTab({ ...moveRequest, ptyId: 'pty-other' }) + ).resolves.toEqual({ status: 'refused', reason: 'pty_mismatch' }) + await expect( + store.moveTerminalLeafToNewTab({ ...moveRequest, targetTabId: SOURCE, ptyId: 'pty-agent' }) + ).resolves.toEqual({ status: 'refused', reason: 'target_tab_exists' }) + expect(tabsHoldingLeaf(store.getWorkspaceSession(), MOVED)).toEqual([SOURCE]) + }) + + it('moves a leaf that a stray layout of a removed tab still names', () => { + const planned = planTerminalLeafMove( + [ + { + hostId: 'local', + session: { + activeRepoId: 'repo-1', + activeWorktreeId: WT, + activeTabId: SOURCE, + tabsByWorktree: { [WT]: [makeTerminalTab({ id: SOURCE, worktreeId: WT })] }, + terminalLayoutsByTabId: { + [SOURCE]: { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: LEFT }, + second: { type: 'leaf', leafId: MOVED } + }, + activeLeafId: LEFT, + expandedLeafId: null + }, + 'tab-removed': { + root: { type: 'leaf', leafId: MOVED }, + activeLeafId: MOVED, + expandedLeafId: null + } + } + } + } + ], + { ...moveRequest, ptyId: 'pty-agent' } + ) + expect(planned.result).toEqual({ status: 'moved', ptyId: 'pty-agent' }) + }) + + it('records a persistence.terminal-topology span without pane keys or PTY ids', async () => { + const records: { name: string; attributes: Record }[] = [] + setActiveSink({ + push: (record) => { + records.push(JSON.parse(JSON.stringify(record))) + }, + flush: () => {}, + close: () => {} + }) + try { + const store = openStore(newDataFile()) + await seedSplitSource(store) + records.length = 0 + const request = { ...moveRequest, ptyId: 'pty-agent' } + await store.moveTerminalLeafToNewTab(request) + await store.moveTerminalLeafToNewTab(request) + await store.moveTerminalLeafToNewTab({ ...request, targetTabId: 'tab-other' }) + + const spans = records.filter((record) => record.name === 'persistence.terminal-topology') + expect(spans.map((span) => span.attributes)).toEqual([ + { kind: 'persistence', 'topology.kind': 'move_leaf', 'topology.outcome': 'committed' }, + { + kind: 'persistence', + 'topology.kind': 'move_leaf', + 'topology.outcome': 'refused', + 'topology.refusal': 'target_tab_exists' + }, + { + kind: 'persistence', + 'topology.kind': 'move_leaf', + 'topology.outcome': 'refused', + 'topology.refusal': 'leaf_in_other_tab' + } + ]) + expect(JSON.stringify(spans)).not.toMatch(/pty-|tab-source|tab-target|2222/) + } finally { + _resetTracerForTests() + } + }) +}) + +// After a restart the relay reattach writes the SSH pane into `local` as well as `ssh:`; a move +// that left either copy behind refused the moved pane and the next relay reattach. +describe('moving an SSH pane held by both partitions', () => { + it('moves every copy, so the target reattach and the next relay reattach both bind', async () => { + const store = openStore(newDataFile()) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const hostId = toSshExecutionHostId('ssh-1') + await seedSplitSource(store, hostId) + const relay = { worktreeId: WT, leafId: MOVED, ptyId: 'pty-agent', incarnationId: 'inc-1' } + expect( + await store.persistPtyBinding({ ...relay, tabId: SOURCE, origin: 'relay_reattach' }) + ).toBe(true) + + await expect( + store.moveTerminalLeafToNewTab({ ...moveRequest, ptyId: 'pty-agent' }) + ).resolves.toMatchObject({ status: 'moved' }) + + expect(tabsHoldingLeaf(store.getWorkspaceSession(), MOVED)).toEqual([TARGET]) + expect(tabsHoldingLeaf(store.getWorkspaceSession(hostId), MOVED)).toEqual([TARGET]) + expect( + await store.persistPtyBinding({ ...relay, tabId: TARGET, origin: 'reattach' }, hostId) + ).toBe(true) + expect( + await store.persistPtyBinding({ + ...relay, + tabId: TARGET, + origin: 'relay_reattach', + mayReviveRetiredSurface: false + }) + ).toBe(true) + }) + + // Relay reattach into local, move, target reattach in ssh:, next-start relay reattach. + it('keeps one holder per partition across a restart, and the next relay reattach binds', async () => { + const dataFile = newDataFile() + const store = openStore(dataFile) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + store.addRepo(makeRepo({ id: 'repo-1', path: '/tmp/move-worktree' })) + const hostId = toSshExecutionHostId('ssh-1') + await seedSplitSource(store, hostId) + const relay = { worktreeId: WT, leafId: MOVED, ptyId: 'pty-agent', incarnationId: 'inc-1' } + await store.persistPtyBinding({ ...relay, tabId: SOURCE, origin: 'relay_reattach' }) + await store.moveTerminalLeafToNewTab({ ...moveRequest, ptyId: 'pty-agent' }) + expect( + await store.persistPtyBinding({ ...relay, tabId: TARGET, origin: 'reattach' }, hostId) + ).toBe(true) + store.flush() + + const restarted = openStore(dataFile) + expect( + await restarted.persistPtyBinding({ + ...relay, + tabId: TARGET, + origin: 'relay_reattach', + mayReviveRetiredSurface: false + }) + ).toBe(true) + for (const session of [ + restarted.getWorkspaceSession(), + restarted.getWorkspaceSession(hostId) + ]) { + expect(tabsHoldingLeaf(session, MOVED)).toEqual([TARGET]) + expect(ownersOf(session, 'pty-agent')).toEqual([TO]) + } + }) + + it('follows the live PTY when an SSH respawn bound one partition before the other', async () => { + const store = openStore(newDataFile()) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const hostId = toSshExecutionHostId('ssh-1') + await seedSplitSource(store, hostId) + const relay = { worktreeId: WT, tabId: SOURCE, leafId: MOVED, incarnationId: 'inc-1' } + await store.persistPtyBinding({ ...relay, ptyId: 'pty-agent', origin: 'relay_reattach' }) + await store.persistPtyBinding( + { ...relay, ptyId: 'pty-respawn', incarnationId: 'inc-2' }, + hostId + ) + + await expect( + store.moveTerminalLeafToNewTab({ ...moveRequest, ptyId: 'pty-respawn' }) + ).resolves.toEqual({ status: 'moved', ptyId: 'pty-respawn' }) + + for (const session of [store.getWorkspaceSession(), store.getWorkspaceSession(hostId)]) { + expect(tabsHoldingLeaf(session, MOVED)).toEqual([TARGET]) + expect(session.terminalLayoutsByTabId[TARGET]?.ptyIdsByLeafId).toEqual({ + [MOVED]: 'pty-respawn' + }) + } + expect(store.getWorkspaceSession(hostId).terminalPtyIncarnationsByPaneKey?.[TO]).toBe('inc-2') + expect(store.getWorkspaceSession().terminalPtyIncarnationsByPaneKey?.[TO]).toBeUndefined() + }) + + it('refuses a move while another tab already holds the leaf', async () => { + const store = openStore(newDataFile()) + await seedSplitSource(store) + const session = store.getWorkspaceSession() + store.setWorkspaceSession({ + ...session, + tabsByWorktree: { + ...session.tabsByWorktree, + [WT]: [ + ...(session.tabsByWorktree[WT] ?? []), + { ...(session.tabsByWorktree[WT] ?? [])[0]!, id: 'tab-earlier-move' } + ] + }, + terminalLayoutsByTabId: { + ...session.terminalLayoutsByTabId, + 'tab-earlier-move': { + root: { type: 'leaf', leafId: MOVED }, + activeLeafId: MOVED, + expandedLeafId: null + } + } + }) + + await expect( + store.moveTerminalLeafToNewTab({ ...moveRequest, ptyId: 'pty-agent' }) + ).resolves.toEqual({ status: 'refused', reason: 'leaf_in_other_tab' }) + }) +}) + +// STA-9259: detach, then the moved pane's reattach binding, then a renderer snapshot that still +// shows the pre-move layout, then a restart. Before the move transaction this left one leaf and +// one PTY in both tabs. +describe('STA-9259 move sequence', () => { + it('keeps one owner through reattach, a stale renderer save and a restart', async () => { + const dataFile = newDataFile() + const store = openStore(dataFile) + // Load sweeps sessions of unregistered repos, so the restart needs a real owner. + store.addRepo(makeRepo({ id: 'repo-1', path: '/tmp/move-worktree' })) + await seedSplitSource(store) + const preMoveRendererSnapshot = structuredClone(store.getWorkspaceSession()) + + await store.moveTerminalLeafToNewTab({ ...moveRequest, ptyId: 'pty-agent' }) + // The moved pane mounts in the target tab and reattaches with its stable-owner fence. + const reattached = await store.persistPtyBinding({ + worktreeId: WT, + tabId: TARGET, + leafId: MOVED, + ptyId: 'pty-agent', + incarnationId: 'inc-1', + expectedBinding: { ptyId: 'pty-agent', incarnationId: 'inc-1' }, + origin: 'reattach' + }) + expect(reattached).toBe(true) + // A debounced renderer save that predates the move must not resurrect the source copy. + store.setWorkspaceSession(preMoveRendererSnapshot) + expect(ownersOf(store.getWorkspaceSession(), 'pty-agent')).toEqual([TO]) + expect(tabsHoldingLeaf(store.getWorkspaceSession(), MOVED)).toEqual([TARGET]) + + store.flush() + const restarted = openStore(dataFile) + expect(ownersOf(restarted.getWorkspaceSession(), 'pty-agent')).toEqual([TO]) + expect(tabsHoldingLeaf(restarted.getWorkspaceSession(), MOVED)).toEqual([TARGET]) + }) +}) diff --git a/src/main/persistence/terminal-topology/terminal-leaf-move.ts b/src/main/persistence/terminal-topology/terminal-leaf-move.ts new file mode 100644 index 000000000000..627e4a705e72 --- /dev/null +++ b/src/main/persistence/terminal-topology/terminal-leaf-move.ts @@ -0,0 +1,236 @@ +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { SshRemotePtyLease } from '../../../shared/ssh-types' +import { + terminalLeafMovePaneKeys, + type TerminalLeafMoveRequest, + type TerminalLeafMoveResult +} from '../../../shared/terminal-leaf-move' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../shared/terminal-tab-types' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { retireLeavesFromTerminalLayout } from '../../runtime/mobile-session-terminal-retirement' +import { createMinimalPersistedTerminalTab } from '../restoring-sessions/session-owner-fields' +import { layoutContainsLeafId } from '../restoring-sessions/terminal-layout-normalization' +import { + advanceTerminalTopologyRevision, + isTerminalOwnerPartition, + type TerminalSessionPartition +} from './terminal-topology-membership' + +type PlannedTerminalLeafMove = { + result: TerminalLeafMoveResult + /** Every partition the plan rewrote; empty when nothing changes. */ + sessions: TerminalSessionPartition[] +} + +function moveRecordKey( + record: Record | undefined, + fromKey: string, + toKey: string | null, + remap: (value: T) => T = (value) => value +): Record | undefined { + if (!record || !Object.hasOwn(record, fromKey)) { + return record + } + const next = { ...record } + const value = next[fromKey] + delete next[fromKey] + if (toKey !== null && value !== undefined) { + next[toKey] = remap(value) + } + return next +} + +function liveTabIds(session: WorkspaceSessionState): Set { + return new Set( + Object.values(session.tabsByWorktree ?? {}).flatMap((tabs) => tabs.map((tab) => tab.id)) + ) +} + +function hasTabId(session: WorkspaceSessionState, tabId: string): boolean { + return ( + liveTabIds(session).has(tabId) || Object.hasOwn(session.terminalLayoutsByTabId ?? {}, tabId) + ) +} + +type SourceHolder = TerminalSessionPartition & { + sourceTab: TerminalTab + sourceLayout: TerminalLayoutSnapshot +} + +function moveLeafInPartition( + { session, sourceTab, sourceLayout }: SourceHolder, + request: TerminalLeafMoveRequest, + ptyId: string | null +): WorkspaceSessionState { + const { worktreeId, sourceTabId, targetTabId, leafId } = request + const tabs = session.tabsByWorktree?.[worktreeId] ?? [] + const { from: fromPaneKey, to: toPaneKey } = terminalLeafMovePaneKeys(request) + const boundHere = sourceLayout.ptyIdsByLeafId?.[leafId] + const remainingLayout = retireLeavesFromTerminalLayout(sourceLayout, new Set([leafId])) + const remainingPtyIds = Object.values(remainingLayout?.ptyIdsByLeafId ?? {}) + const { pendingActivationSpawn, ...row } = createMinimalPersistedTerminalTab({ + worktreeId, + tabId: targetTabId, + ptyId: ptyId ?? '', + existingTabCount: tabs.length, + ...(sourceTab.startupCwd ? { startupCwd: sourceTab.startupCwd } : {}) + }) + const targetTab = { + ...row, + ptyId, + // A moved live pane reattaches; only an unbound one still spawns on activation. + ...(ptyId ? {} : { pendingActivationSpawn }), + ...(sourceTab.shellOverride ? { shellOverride: sourceTab.shellOverride } : {}) + } + const nextTabs = tabs.map((tab) => + tab.id === sourceTabId && tab.ptyId === ptyId + ? { ...tab, ptyId: remainingPtyIds[0] ?? null } + : tab + ) + const movedTitle = sourceLayout.titlesByLeafId?.[leafId] + const terminalLayoutsByTabId = { + ...session.terminalLayoutsByTabId, + [targetTabId]: { + root: { type: 'leaf' as const, leafId }, + activeLeafId: leafId, + expandedLeafId: null, + ...(ptyId ? { ptyIdsByLeafId: { [leafId]: ptyId } } : {}), + ...(movedTitle ? { titlesByLeafId: { [leafId]: movedTitle } } : {}), + ...(sourceLayout.chatLeafId === leafId ? { chatLeafId: leafId } : {}) + } + } + if (remainingLayout) { + terminalLayoutsByTabId[sourceTabId] = remainingLayout + } else { + // Its sibling was never bound here; the sibling's own binding mints the layout again. + delete terminalLayoutsByTabId[sourceTabId] + } + const remoteSessionIdsByTabId = { ...session.remoteSessionIdsByTabId } + if (ptyId && remoteSessionIdsByTabId[sourceTabId] === ptyId) { + remoteSessionIdsByTabId[targetTabId] = ptyId + if (remainingPtyIds[0]) { + remoteSessionIdsByTabId[sourceTabId] = remainingPtyIds[0] + } else { + delete remoteSessionIdsByTabId[sourceTabId] + } + } + return advanceTerminalTopologyRevision( + { + ...session, + tabsByWorktree: { ...session.tabsByWorktree, [worktreeId]: [...nextTabs, targetTab] }, + terminalLayoutsByTabId, + ...(session.remoteSessionIdsByTabId ? { remoteSessionIdsByTabId } : {}), + // A stale copy's incarnation belongs to the PTY it no longer names. + terminalPtyIncarnationsByPaneKey: moveRecordKey( + session.terminalPtyIncarnationsByPaneKey, + fromPaneKey, + boundHere && boundHere !== ptyId ? null : toPaneKey + ), + sleepingAgentSessionsByPaneKey: moveRecordKey( + session.sleepingAgentSessionsByPaneKey, + fromPaneKey, + toPaneKey, + (record) => ({ ...record, paneKey: toPaneKey, tabId: targetTabId }) + ) + }, + worktreeId + ) +} + +/** + * Moves one leaf and its binding into a new tab in a single session write (STA-9259). The leaf + * id and the PTY are kept; only the tab half of the pane key changes, so every pane-keyed record + * follows it here instead of being rebuilt later by a renderer save that main's membership rebase + * would discard. Every owner partition holding the pane moves together: a relay reattach writes an + * SSH pane into `local` as well as `ssh:`, and a copy left behind refuses the moved pane's bind. + */ +export function planTerminalLeafMove( + partitions: readonly TerminalSessionPartition[], + request: TerminalLeafMoveRequest +): PlannedTerminalLeafMove { + const { worktreeId, sourceTabId, leafId } = request + const refuse = ( + reason: Extract['reason'] + ): PlannedTerminalLeafMove => ({ result: { status: 'refused', reason }, sessions: [] }) + const owners = partitions.filter(({ hostId }) => isTerminalOwnerPartition(hostId)) + if (partitions.some(({ session }) => hasTabId(session, request.targetTabId))) { + return refuse('target_tab_exists') + } + // A layout left behind by a removed tab row owns nothing. + const leafElsewhere = owners.some(({ session }) => { + const tabIds = liveTabIds(session) + return Object.entries(session.terminalLayoutsByTabId ?? {}).some( + ([tabId, layout]) => + tabId !== sourceTabId && + tabIds.has(tabId) && + layoutContainsLeafId(layout?.root ?? null, leafId) + ) + }) + if (leafElsewhere) { + return refuse('leaf_in_other_tab') + } + const holders = owners.flatMap(({ hostId, session }) => { + const sourceTab = session.tabsByWorktree?.[worktreeId]?.find((tab) => tab.id === sourceTabId) + const sourceLayout = session.terminalLayoutsByTabId?.[sourceTabId] + return sourceTab && sourceLayout && layoutContainsLeafId(sourceLayout.root, leafId) + ? [{ hostId, session, sourceTab, sourceLayout }] + : [] + }) + // Main never saw this leaf, so no binding of it can be duplicated here. + if (holders.length === 0) { + return { result: { status: 'not_held' }, sessions: [] } + } + const boundPtyIds = new Set( + holders.flatMap(({ sourceLayout }) => sourceLayout.ptyIdsByLeafId?.[leafId] ?? []) + ) + // The renderer's live PTY id wins: after an SSH respawn one copy can still name the old PTY. + const ptyId = request.ptyId ?? (boundPtyIds.size === 1 ? [...boundPtyIds][0] : null) + if (boundPtyIds.size > 0 && !(ptyId && boundPtyIds.has(ptyId))) { + return refuse('pty_mismatch') + } + return { + result: { status: 'moved', ptyId }, + sessions: holders.map((holder) => ({ + hostId: holder.hostId, + session: moveLeafInPartition(holder, request, ptyId) + })) + } +} + +const PANE_KEYED_UI_RECORDS = [ + 'acknowledgedAgentsByPaneKey', + 'activityClearedAtByPaneKey', + 'manuallyUnreadTurnsByPaneKey' +] as const + +/** Pane-keyed UI marks and SSH lease leaf addresses that must follow a moved leaf. */ +export function rekeyMovedLeafProfileRecords( + state: Pick, + move: TerminalLeafMoveRequest +): { ui?: PersistedState['ui']; sshRemotePtyLeases?: SshRemotePtyLease[] } { + const { from: fromPaneKey, to: toPaneKey } = terminalLeafMovePaneKeys(move) + const ui = state.ui + let nextUi: PersistedState['ui'] | undefined + for (const key of PANE_KEYED_UI_RECORDS) { + const moved = moveRecordKey(ui?.[key], fromPaneKey, toPaneKey) + if (ui && moved !== ui[key]) { + nextUi = { ...(nextUi ?? ui), [key]: moved } + } + } + const leases = state.sshRemotePtyLeases ?? [] + const leasesChanged = leases.some( + (lease) => lease.tabId === move.sourceTabId && lease.leafId === move.leafId + ) + return { + ...(nextUi ? { ui: nextUi } : {}), + ...(leasesChanged + ? { + sshRemotePtyLeases: leases.map((lease) => + lease.tabId === move.sourceTabId && lease.leafId === move.leafId + ? { ...lease, tabId: move.targetTabId } + : lease + ) + } + : {}) + } +} diff --git a/src/main/persistence/terminal-topology/terminal-owner-invariants.test.ts b/src/main/persistence/terminal-topology/terminal-owner-invariants.test.ts new file mode 100644 index 000000000000..0ec8ab9e566b --- /dev/null +++ b/src/main/persistence/terminal-topology/terminal-owner-invariants.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, it } from 'vitest' +import { getDefaultWorkspaceSession } from '../../../shared/constants' +import type { ExecutionHostId } from '../../../shared/execution-host' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { TEST_LEAF_1, TEST_LEAF_2, TEST_LEAF_LIVE } from '../../persistence-session-fixtures' +import { findTerminalBindingConflict } from './terminal-owner-invariants' + +const WT = 'repo-1::/tmp/wt' + +/** One single-leaf tab per entry: [tabId, leafId, ptyId, incarnationId?]. */ +function partition( + hostId: ExecutionHostId, + tabs: [string, string, string, string?][] +): { hostId: ExecutionHostId; session: WorkspaceSessionState } { + return { + hostId, + session: { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [WT]: tabs.map(([id, , ptyId]) => ({ + id, + ptyId, + worktreeId: WT, + title: id, + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + })) + }, + terminalLayoutsByTabId: Object.fromEntries( + tabs.map(([id, leafId, ptyId]) => [ + id, + { + root: { type: 'leaf', leafId }, + activeLeafId: leafId, + expandedLeafId: null, + ptyIdsByLeafId: { [leafId]: ptyId } + } + ]) + ), + terminalPtyIncarnationsByPaneKey: Object.fromEntries( + tabs.flatMap(([id, leafId, , incarnation]) => + incarnation ? [[`${id}:${leafId}`, incarnation]] : [] + ) + ) + } + } +} + +const bind = (tabId: string, leafId: string, ptyId: string, incarnationId?: string) => ({ + tabId, + leafId, + ptyId, + ...(incarnationId ? { incarnationId } : {}) +}) + +describe('findTerminalBindingConflict', () => { + // STA-9417: the activation sweep bound the setup PTY to a second, minted leaf. + it('finds a terminal another tab already binds, naming its leaf', () => { + const conflict = findTerminalBindingConflict( + bind('tab-b', TEST_LEAF_2, 'pty-1', 'i1'), + 'local', + [partition('local', [['tab-a', TEST_LEAF_1, 'pty-1', 'i1']])] + ) + expect(conflict?.reason).toBe('pty_bound_to_other_leaf') + expect(conflict?.owner).toMatchObject({ hostId: 'local', leafId: TEST_LEAF_1, ptyId: 'pty-1' }) + expect(conflict?.owner.tab.id).toBe('tab-a') + }) + + it('finds a terminal another leaf of the same tab binds', () => { + const session = partition('local', [['tab-a', TEST_LEAF_1, 'pty-1']]) + session.session.terminalLayoutsByTabId['tab-a'] = { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: TEST_LEAF_1 }, + second: { type: 'leaf', leafId: TEST_LEAF_2 } + }, + activeLeafId: TEST_LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [TEST_LEAF_1]: 'pty-1' } + } + expect( + findTerminalBindingConflict(bind('tab-a', TEST_LEAF_2, 'pty-1'), 'local', [session])?.reason + ).toBe('pty_bound_to_other_leaf') + }) + + it('keys a terminal by incarnation, so a stale incarnation elsewhere is not an owner', () => { + expect( + findTerminalBindingConflict(bind('tab-b', TEST_LEAF_2, 'pty-1', 'i2'), 'local', [ + partition('local', [['tab-a', TEST_LEAF_1, 'pty-1', 'i1']]) + ]) + ).toBeNull() + }) + + // STA-9259: the moved pane's reattach bound the same leaf id under a second tab. + it('finds a leaf id another tab already holds', () => { + const conflict = findTerminalBindingConflict(bind('tab-b', TEST_LEAF_1, 'pty-9'), 'local', [ + partition('local', [['tab-a', TEST_LEAF_1, 'pty-1']]) + ]) + expect(conflict?.reason).toBe('leaf_in_other_tab') + expect(conflict?.owner.tab.id).toBe('tab-a') + }) + + it('accepts a rebind of the same leaf', () => { + expect( + findTerminalBindingConflict(bind('tab-a', TEST_LEAF_1, 'pty-1', 'i1'), 'local', [ + partition('local', [['tab-a', TEST_LEAF_1, 'pty-1', 'i1']]) + ]) + ).toBeNull() + }) + + it('checks the local and ssh partitions together', () => { + const ptyId = 'ssh:ssh-1@@relay-1' + const conflict = findTerminalBindingConflict(bind('tab-local', TEST_LEAF_2, ptyId), 'local', [ + partition('local', []), + partition('ssh:ssh-1', [['tab-ssh', TEST_LEAF_1, ptyId]]) + ]) + expect(conflict?.owner).toMatchObject({ hostId: 'ssh:ssh-1', leafId: TEST_LEAF_1 }) + }) + + // The relay reattach still binds an SSH pane into `local` under the same tab id. + it('treats the same tab:leaf in two partitions as one surface', () => { + const ptyId = 'ssh:ssh-1@@relay-1' + expect( + findTerminalBindingConflict(bind('tab-ssh', TEST_LEAF_1, ptyId), 'local', [ + partition('local', [['tab-ssh', TEST_LEAF_1, ptyId]]), + partition('ssh:ssh-1', [['tab-ssh', TEST_LEAF_1, ptyId]]) + ]) + ).toBeNull() + }) + + it('names a leaf held by another tab on another host apart', () => { + const conflict = findTerminalBindingConflict(bind('tab-b', TEST_LEAF_1, 'pty-9'), 'local', [ + partition('local', []), + partition('ssh:ssh-1', [['tab-a', TEST_LEAF_1, 'ssh:ssh-1@@relay-1']]) + ]) + expect(conflict?.reason).toBe('leaf_in_other_tab_on_other_host') + }) + + // Relay ids like `pty-1` repeat after a relay restart, so the id alone is not one terminal. + it('does not match a repeating relay id without both incarnations', () => { + const ptyId = 'ssh:ssh-1@@pty-1' + const saved = [partition('ssh:ssh-1', [['tab-a', TEST_LEAF_1, ptyId]])] + expect( + findTerminalBindingConflict(bind('tab-b', TEST_LEAF_2, ptyId, 'i2'), 'ssh:ssh-1', saved) + ).toBeNull() + const incarnated = [partition('ssh:ssh-1', [['tab-a', TEST_LEAF_1, ptyId, 'i1']])] + expect( + findTerminalBindingConflict(bind('tab-b', TEST_LEAF_2, ptyId, 'i1'), 'ssh:ssh-1', incarnated) + ?.reason + ).toBe('pty_bound_to_other_leaf') + }) + + it('ignores runtime partitions, layouts without a tab row, and legacy leaf ids', () => { + const orphanLayout = partition('local', []) + orphanLayout.session.terminalLayoutsByTabId['tab-gone'] = { + root: { type: 'leaf', leafId: TEST_LEAF_LIVE }, + activeLeafId: TEST_LEAF_LIVE, + expandedLeafId: null, + ptyIdsByLeafId: { [TEST_LEAF_LIVE]: 'pty-1' } + } + const partitions = [ + orphanLayout, + partition('runtime:env-1', [['tab-runtime', TEST_LEAF_1, 'pty-1']]) + ] + expect( + findTerminalBindingConflict(bind('tab-b', TEST_LEAF_2, 'pty-1'), 'local', partitions) + ).toBeNull() + expect( + findTerminalBindingConflict(bind('tab-b', 'pane:2', 'pty-1'), 'local', [ + partition('local', [['tab-a', TEST_LEAF_1, 'pty-1']]) + ]) + ).toBeNull() + }) +}) diff --git a/src/main/persistence/terminal-topology/terminal-owner-invariants.ts b/src/main/persistence/terminal-topology/terminal-owner-invariants.ts new file mode 100644 index 000000000000..a5311486a06f --- /dev/null +++ b/src/main/persistence/terminal-topology/terminal-owner-invariants.ts @@ -0,0 +1,114 @@ +import { parseExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' +import { parseAppSshPtyId } from '../../../shared/ssh-pty-id' +import { isTerminalLeafId } from '../../../shared/stable-pane-id' +import type { TerminalTab } from '../../../shared/terminal-tab-types' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { collectLayoutLeafIdsInOrder } from '../restoring-sessions/terminal-layout-normalization' + +// The two terminal-layout invariants: a terminal is bound to at most one leaf, and a leaf id is in +// at most one tab. The binding write reports breaches; nothing removes them yet. + +export type TerminalSessionPartition = { hostId: ExecutionHostId; session: WorkspaceSessionState } + +export type TerminalLeafOwner = { + hostId: ExecutionHostId + worktreeId: string + tab: TerminalTab + leafId: string + ptyId: string | undefined + incarnationId: string | undefined + /** Tree order across the partition, for deterministic tie-breaks. */ + order: number +} + +export type TerminalOwnerConflictReason = + | 'pty_bound_to_other_leaf' + | 'leaf_in_other_tab' + // Kept apart: the relay reattach writes SSH panes into `local`, so this may be one moved surface. + | 'leaf_in_other_tab_on_other_host' + +/** `runtime:` partitions belong to a remote Orca server and are written only by its tab sync. */ +export function isTerminalOwnerPartition(hostId: ExecutionHostId): boolean { + const kind = parseExecutionHostId(hostId)?.kind + return kind === 'local' || kind === 'ssh' +} + +/** Every leaf of every tab row; a layout left behind by a removed tab row owns nothing. */ +export function collectTerminalLeafOwners({ + hostId, + session +}: TerminalSessionPartition): TerminalLeafOwner[] { + const owners: TerminalLeafOwner[] = [] + for (const [worktreeId, tabs] of Object.entries(session.tabsByWorktree ?? {})) { + for (const tab of tabs) { + const layout = session.terminalLayoutsByTabId?.[tab.id] + for (const leafId of collectLayoutLeafIdsInOrder(layout?.root)) { + owners.push({ + hostId, + worktreeId, + tab, + leafId, + ptyId: layout?.ptyIdsByLeafId?.[leafId], + incarnationId: session.terminalPtyIncarnationsByPaneKey?.[`${tab.id}:${leafId}`], + order: owners.length + }) + } + } + } + return owners +} + +/** Relay ids like `pty-1` repeat across relay restarts, so without an incarnation they prove nothing. */ +function isRepeatingRelayPtyId(ptyId: string): boolean { + const relayPtyId = parseAppSshPtyId(ptyId)?.relayPtyId + return relayPtyId !== undefined && /^pty-\d+$/.test(relayPtyId) +} + +/** + * One terminal is one PTY incarnation; with either incarnation unrecorded, the PTY id alone, + * unless it is a repeating relay id. + */ +export function isSameTerminal( + left: { ptyId: string | undefined; incarnationId?: string }, + right: { ptyId: string | undefined; incarnationId?: string } +): boolean { + if (left.ptyId === undefined || left.ptyId !== right.ptyId) { + return false + } + if (left.incarnationId !== undefined && right.incarnationId !== undefined) { + return left.incarnationId === right.incarnationId + } + return !isRepeatingRelayPtyId(left.ptyId) +} + +/** + * The saved leaf a binding into `hostId` would duplicate, if any. The same tab:leaf in two + * partitions is one surface: the relay reattach still binds an SSH pane into `local`. + */ +export function findTerminalBindingConflict( + binding: { tabId: string; leafId: string; ptyId: string; incarnationId?: string }, + hostId: ExecutionHostId, + partitions: readonly TerminalSessionPartition[] +): { reason: TerminalOwnerConflictReason; owner: TerminalLeafOwner } | null { + // Legacy leaf ids are never written into leaf-keyed layout state, so they cannot own a terminal. + if (!isTerminalLeafId(binding.leafId)) { + return null + } + for (const partition of partitions) { + if (!isTerminalOwnerPartition(partition.hostId)) { + continue + } + for (const owner of collectTerminalLeafOwners(partition)) { + if (owner.leafId === binding.leafId) { + if (owner.tab.id !== binding.tabId) { + const reason = + partition.hostId === hostId ? 'leaf_in_other_tab' : 'leaf_in_other_tab_on_other_host' + return { reason, owner } + } + } else if (isSameTerminal(owner, binding)) { + return { reason: 'pty_bound_to_other_leaf', owner } + } + } + } + return null +} diff --git a/src/main/persistence/terminal-topology/terminal-owner-report-only.test.ts b/src/main/persistence/terminal-topology/terminal-owner-report-only.test.ts new file mode 100644 index 000000000000..ed724f368a55 --- /dev/null +++ b/src/main/persistence/terminal-topology/terminal-owner-report-only.test.ts @@ -0,0 +1,253 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { getDefaultWorkspaceSession } from '../../../shared/constants' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../shared/terminal-tab-types' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { _resetTracerForTests, setActiveSink } from '../../observability/tracer' +import { TEST_LEAF_1, TEST_LEAF_2, TEST_LEAF_LIVE } from '../../persistence-session-fixtures' +import { DelayedAuthority } from '../loading-store/profile-state-delayed-authority-fixture' +import { Store } from '../loading-store/store' +import { buildProfileStateCutoverFixture } from '../profile-state-cutover-fixture' +import { ProfileStateSqliteAuthority } from '../profile-state/profile-state-sqlite-authority' +import type * as OwnerInvariants from './terminal-owner-invariants' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +// Off = the binding write as it was before the report-only check. +const check = vi.hoisted(() => ({ enabled: true, throws: false, calls: 0 })) +vi.mock('./terminal-owner-invariants', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + findTerminalBindingConflict: ( + ...args: Parameters + ) => { + check.calls += 1 + if (check.throws) { + throw new Error('malformed session') + } + return check.enabled ? actual.findTerminalBindingConflict(...args) : null + } + } +}) + +const LOCAL_WT = 'repo-local::/fixture/local' +const FOLDER_WT = 'repo-local::/fixture/local::workspace:folder-1' +const REMOTE_WT = 'repo-remote::/fixture/remote' +const SSH_HOST = 'ssh:build-host' +const LEAF_MINTED = '55555555-5555-4555-8555-555555555555' +const LEAF_NEW = '66666666-6666-4666-8666-666666666666' +const LEAF_SSH = '88888888-8888-4888-8888-888888888888' +const LEAF_SSH_2 = '99999999-9999-4999-8999-999999999999' +const SSH_PTY = 'ssh:build-host@@relay-1' +const LEAF_MINTED_2 = '77777777-7777-4777-8777-777777777777' + +function tab(id: string, worktreeId: string, createdAt: number, ptyId: string): TerminalTab { + return { + id, + ptyId, + worktreeId, + title: id, + customTitle: null, + color: null, + sortOrder: 0, + createdAt + } +} + +function layout(ptyIdsByLeafId: Record): TerminalLayoutSnapshot { + const [first, second] = Object.keys(ptyIdsByLeafId) + return { + root: second + ? { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: first ?? '' }, + second: { type: 'leaf', leafId: second } + } + : { type: 'leaf', leafId: first ?? '' }, + activeLeafId: first ?? null, + expandedLeafId: null, + ptyIdsByLeafId + } +} + +/** Local holds a saved STA-9417 duplicate (`pty-setup` in two tabs) and a folder-workspace tab. */ +function seededProfile(): string { + const state = buildProfileStateCutoverFixture() + const local: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [LOCAL_WT]: [ + tab('tab-b', LOCAL_WT, 1, 'pty-primary'), + tab('tab-minted', LOCAL_WT, 9, 'pty-setup') + ], + [FOLDER_WT]: [tab('tab-folder', FOLDER_WT, 2, 'pty-folder')] + }, + terminalLayoutsByTabId: { + 'tab-b': layout({ [TEST_LEAF_1]: 'pty-primary', [TEST_LEAF_2]: 'pty-setup' }), + 'tab-minted': layout({ [LEAF_MINTED]: 'pty-setup' }), + 'tab-folder': layout({ [TEST_LEAF_LIVE]: 'pty-folder' }) + } + } + const ssh: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { [REMOTE_WT]: [tab('tab-ssh', REMOTE_WT, 3, SSH_PTY)] }, + terminalLayoutsByTabId: { 'tab-ssh': layout({ [LEAF_SSH]: SSH_PTY }) } + } + return JSON.stringify({ + ...state, + // A fixed install id: load mints a random one when it is missing. + settings: { + ...state.settings, + telemetry: { existedBeforeTelemetryRelease: true, optedIn: null, installId: 'install-1' } + }, + workspaceSession: local, + workspaceSessionsByHostId: { [SSH_HOST]: ssh } + }) +} + +const SEED = seededProfile() + +async function openStore() { + const directory = mkdtempSync(join(tmpdir(), 'orca-owner-report-')) + const inner = new ProfileStateSqliteAuthority(join(directory, 'profile-state.db'), 'owner-report') + inner.writeSerializedState(Buffer.from(SEED)) + const authority = new DelayedAuthority(inner) + const store = new Store({ + dataFile: join(directory, 'orca-data.json'), + profileStateAuthority: authority + }) + await store.flushPendingOrThrowAsync() + const close = async (): Promise => { + await store.freezeWritesAsync() + rmSync(directory, { recursive: true, force: true }) + } + return { store, authority, inner, close } +} + +type Binding = { worktreeId: string; tabId: string; leafId: string; ptyId: string } + +const BINDINGS: [Binding, string?][] = [ + // Clean fresh spawn into a new local tab. + [{ worktreeId: LOCAL_WT, tabId: 'tab-new', leafId: LEAF_NEW, ptyId: 'pty-new' }], + // STA-9417 again: the setup PTY bound to a second minted tab. + [{ worktreeId: LOCAL_WT, tabId: 'tab-minted-2', leafId: LEAF_MINTED_2, ptyId: 'pty-setup' }], + // STA-9259: a moved leaf rebound under a second tab. + [{ worktreeId: LOCAL_WT, tabId: 'tab-moved', leafId: TEST_LEAF_2, ptyId: 'pty-moved' }], + // Folder workspace rebind with a fresh PTY. + [{ worktreeId: FOLDER_WT, tabId: 'tab-folder', leafId: TEST_LEAF_LIVE, ptyId: 'pty-folder-2' }], + // Relay reattach writes the ssh: pane into `local` under the same tab:leaf: one surface. + [{ worktreeId: REMOTE_WT, tabId: 'tab-ssh', leafId: LEAF_SSH, ptyId: SSH_PTY }], + // A second ssh: leaf on the relay PTY. + [{ worktreeId: REMOTE_WT, tabId: 'tab-ssh-2', leafId: LEAF_SSH_2, ptyId: SSH_PTY }, SSH_HOST] +] + +/** Every write the store makes, as bytes, and the final profile, for one run of the bindings. */ +async function runBindings(enabled: boolean) { + check.enabled = enabled + const { store, authority, inner, close } = await openStore() + try { + const loaded = structuredClone(store.getWorkspaceSession()) + const results: boolean[] = [] + for (const [binding, hostId] of BINDINGS) { + results.push(await store.persistPtyBinding(binding, hostId)) + } + await store.flushPendingOrThrowAsync() + return { + loaded, + results, + writes: authority.captures.flat(), + saved: inner.readSerializedState() + } + } finally { + await close() + } +} + +describe('report-only terminal owner check', () => { + const records: { attributes: Record }[] = [] + afterEach(() => { + records.length = 0 + check.calls = 0 + check.throws = false + _resetTracerForTests() + vi.restoreAllMocks() + }) + + it('writes byte-identical state with and without the check, across local, ssh: and folder sessions', async () => { + vi.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000) + setActiveSink({ + push: (record) => { + const span: (typeof records)[number] = JSON.parse(JSON.stringify(record)) + records.push(span) + }, + flush: () => {}, + close: () => {} + }) + + const withCheck = await runBindings(true) + const reported = records + .filter((record) => record.attributes['binding.owner_conflict']) + .map((record) => record.attributes['binding.owner_conflict']) + const withoutCheck = await runBindings(false) + + // Precondition: the check ran and saw every breach the bindings made. + expect(reported).toEqual([ + 'pty_bound_to_other_leaf', + 'leaf_in_other_tab', + 'pty_bound_to_other_leaf' + ]) + expect(withCheck.results).toEqual([true, true, true, true, true, true]) + expect(withCheck.saved).toContain('tab-minted-2') + expect(withCheck.writes).toEqual(withoutCheck.writes) + expect(withCheck.saved).toEqual(withoutCheck.saved) + // Load leaves saved breaches in place: the duplicate and folder tab load as they were. + expect(withCheck.loaded.tabsByWorktree).toMatchObject({ + [LOCAL_WT]: [{ id: 'tab-b' }, { id: 'tab-minted' }], + [FOLDER_WT]: [{ id: 'tab-folder' }] + }) + }) + + it('skips the scan for a rebind the fast lane already admits', async () => { + check.enabled = true + const { store, close } = await openStore() + try { + const [[binding]] = BINDINGS + await expect(store.persistPtyBinding(binding)).resolves.toBe(true) + expect(check.calls).toBe(1) + await expect(store.persistPtyBinding(binding)).resolves.toBe(true) + expect(check.calls).toBe(1) + } finally { + await close() + } + }) + + it('writes the binding unchanged when the check throws', async () => { + vi.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000) + setActiveSink({ + push: (record) => { + const span: (typeof records)[number] = JSON.parse(JSON.stringify(record)) + records.push(span) + }, + flush: () => {}, + close: () => {} + }) + check.throws = true + const throwing = await runBindings(true) + expect(records.map((record) => record.attributes['binding.owner_conflict'])).toContain( + 'check_threw' + ) + check.throws = false + const withoutCheck = await runBindings(false) + expect(throwing.results).toEqual(withoutCheck.results) + expect(throwing.writes).toEqual(withoutCheck.writes) + expect(throwing.saved).toEqual(withoutCheck.saved) + }) +}) diff --git a/src/main/persistence/terminal-topology/terminal-pane-placement-agreement.ts b/src/main/persistence/terminal-topology/terminal-pane-placement-agreement.ts new file mode 100644 index 000000000000..0a104592d94f --- /dev/null +++ b/src/main/persistence/terminal-topology/terminal-pane-placement-agreement.ts @@ -0,0 +1,47 @@ +import type { TerminalPanePlacement } from '../../../shared/terminal-pane-placement' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { layoutContainsLeafId } from '../restoring-sessions/terminal-layout-normalization' + +/** Low-cardinality: it lands on the `persistence.pty-binding` span. */ +export type TerminalPanePlacementAgreement = + | 'absent' + | 'leaf_present' + | 'agrees' + | 'tab_exists' + | 'tab_missing' + | 'parent_missing' + | 'root_occupied' + | 'check_threw' + +/** + * Whether placement names the tab today's binding write picks, read before that write. Report-only + * until the mint and graft are deleted; the write never branches on it. + */ +export function terminalPanePlacementAgreement( + placement: TerminalPanePlacement | undefined, + session: WorkspaceSessionState, + worktreeId: string, + tabId: string, + leafId: string +): TerminalPanePlacementAgreement { + if (!placement) { + return 'absent' + } + const tabExists = session.tabsByWorktree?.[worktreeId]?.some((tab) => tab.id === tabId) === true + const root = session.terminalLayoutsByTabId?.[tabId]?.root ?? null + if (tabExists && layoutContainsLeafId(root, leafId)) { + return 'leaf_present' + } + switch (placement.kind) { + case 'new-tab': + return tabExists ? 'tab_exists' : 'agrees' + case 'root': + return !tabExists ? 'tab_missing' : root ? 'root_occupied' : 'agrees' + case 'split': + return !tabExists + ? 'tab_missing' + : layoutContainsLeafId(root, placement.parentLeafId) + ? 'agrees' + : 'parent_missing' + } +} diff --git a/src/main/persistence/terminal-topology/terminal-topology-commit.ts b/src/main/persistence/terminal-topology/terminal-topology-commit.ts index dee0f6283a9d..dc1b562a51da 100644 --- a/src/main/persistence/terminal-topology/terminal-topology-commit.ts +++ b/src/main/persistence/terminal-topology/terminal-topology-commit.ts @@ -1,15 +1,24 @@ +import type { ExecutionHostId } from '../../../shared/execution-host' +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { + TerminalLeafMoveRequest, + TerminalLeafMoveResult +} from '../../../shared/terminal-leaf-move' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import { startSpan } from '../../observability/tracer' import { terminalSurfaceCloseMutation, type TerminalSurfaceCloseCommit } from '../../runtime/terminal-surface-close' import type { DurableProfileStateMutation } from '../loading-store/store-runtime-state' +import { planTerminalLeafMove, rekeyMovedLeafProfileRecords } from './terminal-leaf-move' +import { assignWorkspaceSessionPartition } from './terminal-topology-membership' -// The commit boundary for terminal layout (tabs, panes, pane-to-PTY bindings). Today it wraps only -// the close, whose transform still lives in runtime/; the other writers move here later. +// The commit boundary for terminal layout (tabs, panes, pane-to-PTY bindings). Wraps the close and +// the pane move; the close transform still lives in runtime/ and other writers move here later. /** Bindings are not listed: `persistPtyBinding` already records `persistence.pty-binding`. */ -type TerminalTopologyCommitKind = 'close_leaf' | 'close_tab' +type TerminalTopologyCommitKind = 'close_leaf' | 'close_tab' | 'move_leaf' export function closeLeafOrTab( commit: TerminalSurfaceCloseCommit @@ -22,9 +31,21 @@ export function closeLeafOrTab( ) } +/** Moves a leaf, its binding and its pane-keyed records into a new tab in one durable mutation. */ +export function moveLeaf( + request: TerminalLeafMoveRequest, + context: TerminalTopologyCommitContext +): () => DurableProfileStateMutation { + return traced( + 'move_leaf', + () => commitLeafMove(request, context), + (result) => (result.status === 'refused' ? result.reason : undefined) + ) +} + /** * One `persistence.terminal-topology` span per commit, from admission to the in-memory write. - * Attributes stay low-cardinality: no pane key, PTY id or path. + * Attributes stay low-cardinality: no pane key, PTY id or path; `refusalOf` returns a fixed code. */ function traced( kind: TerminalTopologyCommitKind, @@ -55,3 +76,74 @@ function traced( return result } } + +type TopologyState = Pick< + PersistedState, + 'workspaceSession' | 'workspaceSessionsByHostId' | 'ui' | 'sshRemotePtyLeases' +> + +type TerminalTopologyCommitContext = { + state: TopologyState + hostIds: () => ExecutionHostId[] + getSession: (hostId: ExecutionHostId) => WorkspaceSessionState + markDirty: ( + domain: 'workspaceSession' | 'workspaceSessionsByHostId' | 'ui' | 'sshRemotePtyLeases' + ) => void +} + +/** Writes `next`, returning a restore that puts the prior value back unless a later write replaced it. */ +function writeRestorable(read: () => V, write: (value: V) => void, next: V): () => void { + const prior = read() + write(next) + return () => { + if (read() === next) { + write(prior) + } + } +} + +function commitLeafMove( + request: TerminalLeafMoveRequest, + context: TerminalTopologyCommitContext +): DurableProfileStateMutation { + const { state } = context + const planned = planTerminalLeafMove( + context.hostIds().map((hostId) => ({ hostId, session: context.getSession(hostId) })), + request + ) + if (planned.sessions.length === 0) { + return { value: planned.result, persist: false } + } + const restores = planned.sessions.map(({ hostId, session }) => + writeRestorable( + () => context.getSession(hostId), + (value) => context.markDirty(assignWorkspaceSessionPartition(state, hostId, value)), + session + ) + ) + const rekeyed = rekeyMovedLeafProfileRecords(state, request) + if (rekeyed.ui) { + restores.push( + writeRestorable( + () => state.ui, + (ui) => (state.ui = ui), + rekeyed.ui + ) + ) + context.markDirty('ui') + } + if (rekeyed.sshRemotePtyLeases) { + restores.push( + writeRestorable( + () => state.sshRemotePtyLeases, + (leases) => (state.sshRemotePtyLeases = leases), + rekeyed.sshRemotePtyLeases + ) + ) + context.markDirty('sshRemotePtyLeases') + } + return { + value: planned.result, + rollback: () => restores.forEach((restore) => restore()) + } +} diff --git a/src/main/persistence/terminal-topology/terminal-topology-membership.ts b/src/main/persistence/terminal-topology/terminal-topology-membership.ts index 99437157437b..358a1a34736a 100644 --- a/src/main/persistence/terminal-topology/terminal-topology-membership.ts +++ b/src/main/persistence/terminal-topology/terminal-topology-membership.ts @@ -1,3 +1,9 @@ +import { + LOCAL_EXECUTION_HOST_ID, + parseExecutionHostId, + type ExecutionHostId +} from '../../../shared/execution-host' +import type { PersistedState } from '../../../shared/persisted-state-types' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' import { layoutContainsLeafId } from '../restoring-sessions/terminal-layout-normalization' @@ -54,3 +60,29 @@ export function hasHostAuthoritativeTerminalMembership( ) ) } + +export type TerminalSessionPartition = { + hostId: ExecutionHostId + session: WorkspaceSessionState +} + +/** Partitions that own terminal panes; `runtime:` partitions mirror another host's. */ +export function isTerminalOwnerPartition(hostId: ExecutionHostId): boolean { + const kind = parseExecutionHostId(hostId)?.kind + return kind === 'local' || kind === 'ssh' +} + +/** Puts one partition's session in place and names the profile domain that now needs a write. */ +export function assignWorkspaceSessionPartition( + state: Pick, + hostId: ExecutionHostId, + session: WorkspaceSessionState +): 'workspaceSession' | 'workspaceSessionsByHostId' { + // Why: 'local' always lives in workspaceSession, never workspaceSessionsByHostId.local. + if (hostId === LOCAL_EXECUTION_HOST_ID) { + state.workspaceSession = session + return 'workspaceSession' + } + state.workspaceSessionsByHostId = { ...state.workspaceSessionsByHostId, [hostId]: session } + return 'workspaceSessionsByHostId' +} diff --git a/src/main/powershell-osc133-bootstrap.ts b/src/main/powershell-osc133-bootstrap.ts index 57d9c2959a0f..99f2a10a545d 100644 --- a/src/main/powershell-osc133-bootstrap.ts +++ b/src/main/powershell-osc133-bootstrap.ts @@ -1,6 +1,7 @@ import { getPowerShellOmpShellWrapper } from './pty/omp-shell-wrapper' import { MANAGED_DATA_ACCOUNT_POWERSHELL_RESTORE } from '../shared/managed-data-account-shell' import { getPowerShellCodexShellLaunchPreflight } from '../shared/codex-shell-function' +import { getPowerShellClaudeShellFunction } from '../shared/claude-shell-function' export { encodePowerShellCommand } from '../shared/powershell-command-encoding' /** @@ -58,7 +59,7 @@ if ($ExecutionContext.SessionState.LanguageMode -eq "FullLanguage" -and } catch { Write-Error $_ -ErrorAction Continue } ${getPowerShellOmpShellWrapper()} -${getPowerShellCodexShellLaunchPreflight()} +${getPowerShellCodexShellLaunchPreflight() + getPowerShellClaudeShellFunction()} $Global:__OrcaOsc133State = @{ OriginalPrompt = $function:prompt diff --git a/src/main/provider-process/provider-process-teardown.test.ts b/src/main/provider-process/provider-process-teardown.test.ts index 62aeb53ccba5..41ad22387d7c 100644 --- a/src/main/provider-process/provider-process-teardown.test.ts +++ b/src/main/provider-process/provider-process-teardown.test.ts @@ -7,7 +7,6 @@ import { import type { DescendantTreeVerdict } from '../pty-descendant-exit-verification' import { terminateProviderProcessTree } from './provider-process-teardown' -/** Above pid_max on every supported POSIX host, so the group signal is a real ESRCH. */ const UNREACHABLE_PGID = 2_147_483_647 function child() { @@ -68,6 +67,7 @@ describe('terminateProviderProcessTree', () => { it('waits for an owned POSIX snapshot before killing the wrapper', async () => { const target = child() + const signalProcessGroup = vi.fn() const snapshot = { rootPgid: 1234, descendants: [], capturedAtMs: 1 } const release = Promise.withResolvers() @@ -75,7 +75,8 @@ describe('terminateProviderProcessTree', () => { site: 'codex-app-server-teardown', platform: 'darwin', captureDescendants: async () => snapshot, - terminateDescendants: () => release.promise + terminateDescendants: () => release.promise, + signalProcessGroup }) await vi.waitFor(() => expect(target.kill).toHaveBeenCalledWith('SIGSTOP')) expect(target.kill).not.toHaveBeenCalledWith('SIGKILL') @@ -83,6 +84,7 @@ describe('terminateProviderProcessTree', () => { await expect(teardown).resolves.toBe('exited') expect(target.kill).toHaveBeenLastCalledWith('SIGKILL') + expect(signalProcessGroup).toHaveBeenCalledWith(1234, 'SIGKILL') }) it('signals a proven dedicated POSIX process group without scanning descendants', async () => { @@ -125,31 +127,32 @@ describe('terminateProviderProcessTree', () => { expect(target.kill).not.toHaveBeenCalled() }) - /** - * `selfInitiatedTreeKillCount` decides whether a `render-process-gone` was - * ours. A group that had already exited was killed by nobody, so crediting it - * puts a suspect in the five-second window that Orca never issued. Exercised - * through the real `process.kill(-pgid)` because the swallow being tested - * lives in the production default, not in an injectable seam. - */ + // An intercepted ESRCH must exercise the default signal path without creating a false kill breadcrumb. it('does not claim a snapshot group that was already gone', async () => { const target = { pid: UNREACHABLE_PGID, kill: vi.fn(() => true) } + const kill = vi.spyOn(process, 'kill').mockImplementation(() => { + throw Object.assign(new Error('already exited'), { code: 'ESRCH' }) + }) + try { + await expect( + terminateProviderProcessTree(target, { + site: 'codex-app-server-teardown', + platform: 'darwin', + captureDescendants: async () => ({ + rootPgid: UNREACHABLE_PGID, + descendants: [], + capturedAtMs: 1 + }), + terminateDescendants: async () => 'exited' + }) + ).resolves.toBe('exited') - await expect( - terminateProviderProcessTree(target, { - site: 'codex-app-server-teardown', - platform: 'darwin', - captureDescendants: async () => ({ - rootPgid: UNREACHABLE_PGID, - descendants: [], - capturedAtMs: 1 - }), - terminateDescendants: async () => 'exited' - }) - ).resolves.toBe('exited') - - expect(target.kill).toHaveBeenLastCalledWith('SIGKILL') - expect(findSelfInitiatedTreeKills(Date.now())).toEqual([]) + expect(kill).toHaveBeenCalledWith(-UNREACHABLE_PGID, 'SIGKILL') + expect(target.kill).toHaveBeenLastCalledWith('SIGKILL') + expect(findSelfInitiatedTreeKills(Date.now())).toEqual([]) + } finally { + kill.mockRestore() + } }) it('claims a snapshot group the signal actually reached', async () => { diff --git a/src/main/provider-process/provider-spawned-process-identity.ts b/src/main/provider-process/provider-spawned-process-identity.ts new file mode 100644 index 000000000000..b0f04b29f27c --- /dev/null +++ b/src/main/provider-process/provider-spawned-process-identity.ts @@ -0,0 +1,69 @@ +import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' +import type { AgentSessionProcessIdentity } from '../../shared/agent-session-record' +import { readProcessStartTimeMs } from '../runtime/agent-session-process-identity-probe' + +// What the lease records about a provider child: the process a later owner probe re-proves. + +/** The child echoes its spawn token here so the owner probe can tell a live child of THIS + * reservation from a same-pid stranger. */ +export const PROVIDER_SPAWN_TOKEN_ENV = 'ORCA_AGENT_SESSION_SPAWN_TOKEN' + +const START_TIME_READ_ATTEMPTS = 3 + +type SpawnedIdentityInput = { + identity: AgentSessionJournalIdentity + spawnToken: string + onSpawned?: (process: AgentSessionProcessIdentity) => Promise +} + +/** + * The child's identity, read once. A connection that reports its spawn before the handshake makes + * it durable there through `onSpawned`, so a crash mid-start leaves an owner recovery can stop; one + * that reports no spawn is identified once it is open. + */ +export function providerSpawnedProcessIdentity( + input: SpawnedIdentityInput, + label: string, + readStartTime?: (pid: number) => Promise +): { + onSpawned: (pid: number) => Promise + read: (pid: number | undefined) => Promise +} { + let spawned: Promise | undefined + return { + onSpawned: async (pid) => { + spawned = providerProcessIdentity({ ...input, pid }, label, readStartTime) + await input.onSpawned?.(await spawned) + }, + read: (pid) => spawned ?? providerProcessIdentity({ ...input, pid }, label, readStartTime) + } +} + +export async function providerProcessIdentity( + input: { identity: AgentSessionJournalIdentity; spawnToken: string; pid: number | undefined }, + label: string, + readStartTime: (pid: number) => Promise = readProcessStartTimeMs +): Promise { + if (input.pid === undefined) { + throw new Error(`${label} started without a pid`) + } + let processStartTimeMs: number | null = null + for ( + let attempt = 0; + attempt < START_TIME_READ_ATTEMPTS && processStartTimeMs === null; + attempt += 1 + ) { + processStartTimeMs = await readStartTime(input.pid) + } + if (processStartTimeMs === null) { + // Why: recording null makes every later owner probe indeterminate — a durable latch. + // Failing here reaps the child and leaves a retryable refusal instead. + throw new Error(`${label} start time for pid ${input.pid} could not be read`) + } + return { + hostId: input.identity.hostId, + pid: input.pid, + processStartTimeMs, + spawnToken: input.spawnToken + } +} diff --git a/src/main/provider-process/supervised-provider-child-location.ts b/src/main/provider-process/supervised-provider-child-location.ts new file mode 100644 index 000000000000..52a5f5b6c6df --- /dev/null +++ b/src/main/provider-process/supervised-provider-child-location.ts @@ -0,0 +1,16 @@ +import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' +import { isWindowsProcessStartTimeAvailable } from '../windows/windows-process-table' + +/** Where an agent child this runtime spawns and supervises itself can run: on this machine, outside + * WSL, and on Windows only where a process start time proves which process a record names. */ +export function supportsSupervisedProviderChildLocation( + location: AgentSessionExecutionLocation, + hasWindowsProcessStartTimeProof: () => boolean = isWindowsProcessStartTimeAvailable +): boolean { + return ( + location.executionHostId === LOCAL_EXECUTION_HOST_ID && + location.wslDistro === null && + (process.platform !== 'win32' || hasWindowsProcessStartTimeProof()) + ) +} diff --git a/src/main/providers/local-pty-provider-windows-shell-launch.test.ts b/src/main/providers/local-pty-provider-windows-shell-launch.test.ts index bc96cdb1d6af..0f985f561516 100644 --- a/src/main/providers/local-pty-provider-windows-shell-launch.test.ts +++ b/src/main/providers/local-pty-provider-windows-shell-launch.test.ts @@ -160,6 +160,34 @@ describe('LocalPtyProvider', () => { }) describe('spawn', () => { + it('passes the guest Claude pointer and injected-home marker through WSLENV', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + provider.configure({ + buildSpawnEnv: (_id, env) => ({ + ...env, + CLAUDE_CONFIG_DIR: '/home/fake/.local/share/orca/claude-profiles/a/home', + ORCA_CLAUDE_INJECTED_CONFIG_DIR: '/home/fake/.local/share/orca/claude-profiles/a/home', + ORCA_CLAUDE_PROFILE_POINTER: '~/.local/share/orca/claude-profiles/selected-wsl-orca' + }) + }) + await provider.spawn({ + cols: 80, + rows: 24, + cwd: '\\\\wsl.localhost\\Ubuntu\\home\\fake\\repo' + }) + const env = spawnMock.mock.calls.at(-1)?.[2].env + expect(env.WSLENV.split(':')).toEqual( + expect.arrayContaining([ + 'CLAUDE_CONFIG_DIR', + 'ORCA_CLAUDE_PROFILE_POINTER', + 'ORCA_CLAUDE_INJECTED_CONFIG_DIR' + ]) + ) + expect(env.ORCA_CLAUDE_PROFILE_POINTER).toBe( + '~/.local/share/orca/claude-profiles/selected-wsl-orca' + ) + }) + it('does not pass a Windows Codex home into WSL terminals', async () => { Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) provider.configure({ diff --git a/src/main/providers/local-pty-shell-ready-bash-rcfile.ts b/src/main/providers/local-pty-shell-ready-bash-rcfile.ts index 169773edbd59..3125eeef8a65 100644 --- a/src/main/providers/local-pty-shell-ready-bash-rcfile.ts +++ b/src/main/providers/local-pty-shell-ready-bash-rcfile.ts @@ -10,6 +10,7 @@ import { MANAGED_DATA_ACCOUNT_POSIX_RESTORE } from '../../shared/managed-data-ac import { WSL_MANAGED_CLI_PATH_RESTORE } from '../wsl-managed-cli-path-restore' import { getPosixOmpShellWrapper } from '../pty/omp-shell-wrapper' import { getPosixCodexShellLaunchPreflight } from '../../shared/codex-shell-function' +import { getPosixClaudeShellFunction } from '../../shared/claude-shell-function' import { getBashStartupCommandPromptBlock } from '../pty/posix-shell-startup-command' import { BASH_FEATURE_CHANNEL_BLOCK, SHELL_STARTUP_IDENTITY_MARKER_BLOCK } from '../shell-templates' import { SHELL_READY_MARKER_ESCAPED } from './local-pty-shell-ready-marker' @@ -58,7 +59,7 @@ ${MANAGED_DATA_ACCOUNT_POSIX_RESTORE} ${getPosixOmpShellWrapper()} # Why: Codex must keep using Orca's runtime CODEX_HOME after profile scripts. [[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}" -${getPosixCodexShellLaunchPreflight()} +${getPosixCodexShellLaunchPreflight() + getPosixClaudeShellFunction()} # 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) # exits — mirrors the zsh wrapper. Without this, bash users (default on most diff --git a/src/main/providers/local-pty-shell-ready.ts b/src/main/providers/local-pty-shell-ready.ts index 2a2183ac82cf..c87991683b8f 100644 --- a/src/main/providers/local-pty-shell-ready.ts +++ b/src/main/providers/local-pty-shell-ready.ts @@ -11,6 +11,7 @@ import { isPowerShellExecutableName } from '../powershell-osc133-bootstrap' import { getFishCodexShellLaunchPreflight } from '../../shared/codex-shell-function' +import { getFishClaudeShellFunction } from '../../shared/claude-shell-function' import { getFishXdgDataDirsLaunchEnv } from '../fish-xdg-data-dirs-handoff' import { POSIX_SHELL_STARTUP_COMMAND_ENV } from '../pty/posix-shell-startup-command' import { getFishShellReadyInitCommand } from '../shell-templates' @@ -147,7 +148,7 @@ export function getShellLaunchConfig( SHELL_READY_MARKER_ESCAPED, features.includes('ready'), startupCommand !== undefined - )}\n${getFishCodexShellLaunchPreflight()}` + )}\n${getFishCodexShellLaunchPreflight() + getFishClaudeShellFunction()}` ], env: startupCommand !== undefined ? { [POSIX_SHELL_STARTUP_COMMAND_ENV]: startupCommand } : {}, diff --git a/src/main/providers/local-pty-windows-spawn-environment.ts b/src/main/providers/local-pty-windows-spawn-environment.ts index f7967d710f78..63ad18883dc9 100644 --- a/src/main/providers/local-pty-windows-spawn-environment.ts +++ b/src/main/providers/local-pty-windows-spawn-environment.ts @@ -55,6 +55,9 @@ 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.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. addWslEnvKeys(env, [ORCA_HERMES_STARTUP_QUERY_ENV]) diff --git a/src/main/runtime/agent-session-acquisition-failure-settlement.ts b/src/main/runtime/agent-session-acquisition-failure-settlement.ts index 1be4974fc659..68db039d6ce1 100644 --- a/src/main/runtime/agent-session-acquisition-failure-settlement.ts +++ b/src/main/runtime/agent-session-acquisition-failure-settlement.ts @@ -9,7 +9,7 @@ import type { AgentSessionRecord } from '../../shared/agent-session-record' import { assertFence, withLease } from './agent-session-lease-transitions' -import type { AgentSessionStoreState } from './agent-session-record-store-file' +import type { AgentSessionStoreState } from './agent-session-store-state' /** * How the failed attempt's provider process was accounted for. diff --git a/src/main/runtime/agent-session-backup-recovery-fence.ts b/src/main/runtime/agent-session-backup-recovery-fence.ts deleted file mode 100644 index adbdf162cccf..000000000000 --- a/src/main/runtime/agent-session-backup-recovery-fence.ts +++ /dev/null @@ -1,42 +0,0 @@ -// Importing the records file's backup, without minting a second writer. Only the one-time import -// into the chat database (agent-session-legacy-record-import.ts) applies this now. -// -// The backup is the previous committed generation. The commit that never landed may have granted a -// fence chosen by `nextAgentSessionFence` from the backup lease, and -// `isAgentSessionFenceCurrent` compares with STRICT EQUALITY. That choice may already be above -// `runtimeFence + 1` after an earlier recovery; the new floor must strictly dominate it. -// -// The bound is one lost mint per backup generation: each mint site used `nextAgentSessionFence` -// once per transaction, and the file's save path aborted rather than advancing the primary past a -// stale backup. A source-level ratchet rejects direct `+ 1` mints; an indirected mint is not caught. -// -// This records a FLOOR for the next grant and leaves the current fence alone. Rewriting the current -// fence is what an earlier version did, and it corrupted exactly the records it meant to save: a -// `live` lease means a provider handle proven at exactly `lease.runtimeFence`, asserted by -// `isValidAgentSessionRecord`, so a fence bumped without a re-proof — which cannot happen offline — -// made the record invalid, quarantined it on the next load, and dropped back to the same backup. -// -// Ownership is deliberately untouched. `claimStatus` (a conflict must survive restart), -// `ownerProcess` (the identity evidence the owner probe needs — the lease owner is a child process -// that can outlive a main-process crash) and `handoffStage` all carry forward verbatim. Loading -// marks every lease unreconciled, and the restart reconciler re-adjudicates them by probe. Nulling that evidence is how you get two writers on one provider -// session; the fence protects the store, not the provider session. - -import { nextAgentSessionFence } from '../../shared/agent-session-next-fence' -import type { AgentSessionStoreState } from './agent-session-record-store-file' - -export function raiseAgentSessionFencesAfterBackupRecovery(state: AgentSessionStoreState): void { - for (const [sessionId, record] of state.records) { - const floor = nextAgentSessionFence(record.lease) + 1 - if (!Number.isSafeInteger(floor)) { - throw new Error('agent_session_fence_exhausted') - } - state.records.set(sessionId, { - ...record, - lease: { - ...record.lease, - minimumNextFence: floor - } - }) - } -} diff --git a/src/main/runtime/agent-session-claim-key-retention.ts b/src/main/runtime/agent-session-claim-key-retention.ts index 80d13aed2b6a..a447224b17b0 100644 --- a/src/main/runtime/agent-session-claim-key-retention.ts +++ b/src/main/runtime/agent-session-claim-key-retention.ts @@ -1,7 +1,7 @@ // Retired execution-claim keys. Split from the store on the same rule its ledger admission is: // the state transition lives here, the transaction stays in the store. -import type { AgentSessionStoreState } from './agent-session-record-store-file' +import type { AgentSessionStoreState } from './agent-session-store-state' /** Retired claim keys stay verifiable this long so a rotation cannot strand a running agent. */ export const AGENT_SESSION_CLAIM_KEY_RETENTION_MS = 30 * 24 * 60 * 60 * 1000 diff --git a/src/main/runtime/agent-session-conversation-command-record.ts b/src/main/runtime/agent-session-conversation-command-record.ts index 4aefce22d577..2cf5ee773767 100644 --- a/src/main/runtime/agent-session-conversation-command-record.ts +++ b/src/main/runtime/agent-session-conversation-command-record.ts @@ -1,5 +1,5 @@ import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' -import type { AgentSessionStoreState } from './agent-session-record-store-file' +import type { AgentSessionStoreState } from './agent-session-store-state' import { AgentSessionTabTable } from './agent-session-tab-table' import { foundAgentSessionRecord } from './agent-session-record-founding' import type { AgentSessionConversationCommandRecord } from '../../shared/agent-session-conversation-command' diff --git a/src/main/runtime/agent-session-lease-renewal.ts b/src/main/runtime/agent-session-lease-renewal.ts index 1a1f90dd7e1c..afde35d88d19 100644 --- a/src/main/runtime/agent-session-lease-renewal.ts +++ b/src/main/runtime/agent-session-lease-renewal.ts @@ -1,7 +1,7 @@ import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' import type { AgentSessionRecord } from '../../shared/agent-session-record' import { renewAgentSessionLease } from './agent-session-lease-transitions' -import type { AgentSessionStoreState } from './agent-session-record-store-file' +import type { AgentSessionStoreState } from './agent-session-store-state' export type AgentSessionLeaseRenewal = { sessionId: string diff --git a/src/main/runtime/agent-session-legacy-record-import.test.ts b/src/main/runtime/agent-session-legacy-record-import.test.ts deleted file mode 100644 index d9a1d0268ab2..000000000000 --- a/src/main/runtime/agent-session-legacy-record-import.test.ts +++ /dev/null @@ -1,469 +0,0 @@ -// The one-time copy of `agent-sessions.json` into the chat journal database: what it copies, what it -// leaves untouched, and how each fence stays clear of anything the file's writers could have granted. - -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { nextAgentSessionFence } from '../../shared/agent-session-next-fence' -import { - agentSessionOperationKey, - type AgentSessionOperationRow -} from '../../shared/agent-session-operation-ledger' -import type { AgentSessionLease, AgentSessionRecord } from '../../shared/agent-session-record' -import { - encodePersistedAgentSessionProviderHandle, - isAgentSessionProviderHandle -} from '../../shared/agent-session-provider-handle-encoding' -import { - agentSessionLeaseFixture, - agentSessionRecordFixture -} from '../../shared/agent-session-record.test-fixture' -import { journalPragmaNumber } from '../native-chat/agent-session-journal/journal-database' -import type { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' -import { AgentSessionRecordStore } from './agent-session-record-store' -import { legacyAgentSessionStorePath } from './agent-session-record-store-file' -import { openStructuredAgentSessionJournalDatabase } from './structured-agent-session-journal-open' -import { recordingStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' - -const NOW = 1_800_000_000_000 -const ALPHA = 'session-alpha' -const BETA = 'session-beta' -const OPERATION_ID = `${NOW}-000000000000000000000000000000aa` -const LEGACY_TAB_ID = `structured-agent-session-${ALPHA}` - -let root: string -const opened: JournalHostDatabase[] = [] - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-legacy-record-import-')) -}) - -afterEach(async () => { - opened.splice(0).forEach((database) => database.close()) - await rm(root, { recursive: true, force: true }) -}) - -const legacyPath = (): string => legacyAgentSessionStorePath(root) - -/** A released chat: no owner a restart probe must look for. */ -function record(sessionId: string, lease: Partial = {}): AgentSessionRecord { - return agentSessionRecordFixture( - agentSessionLeaseFixture({ - sessionId, - runtimeFence: 2, - ownerProcess: null, - reservedSpawnToken: null, - claimStatus: 'released', - ...lease - }) - ) -} - -const OPERATION: AgentSessionOperationRow = { - callerKey: 'client-1', - operationId: OPERATION_ID, - fingerprint: 'fp-1', - operationTimestamp: NOW, - recordedAt: NOW, - expiresAt: NOW + 60_000, - outcome: { status: 'succeeded', sessionId: ALPHA } -} - -/** The records file only ever held handles in their stored form. */ -function storedForm(value: Record): Record { - const chain = value.providerHandleChain - return Array.isArray(chain) - ? { - ...value, - providerHandleChain: chain.map((link: unknown) => - typeof link === 'object' && - link !== null && - 'handle' in link && - isAgentSessionProviderHandle(link.handle) - ? { ...link, handle: encodePersistedAgentSessionProviderHandle(link.handle) } - : link - ) - } - : value -} - -function legacyFile( - records: readonly (Record & { sessionId: string })[], - extra: Record = {} -): Record { - return { - schemaVersion: 2, - hostId: 'local', - records: Object.fromEntries(records.map((value) => [value.sessionId, storedForm(value)])), - operations: {}, - retiredClaimKeys: [], - unusableRecords: {}, - ...extra - } -} - -async function writeLegacy(file: unknown, backup?: unknown): Promise { - await mkdir(dirname(legacyPath()), { recursive: true }) - await writeFile(legacyPath(), typeof file === 'string' ? file : JSON.stringify(file)) - if (backup !== undefined) { - await writeFile( - `${legacyPath()}.bak`, - typeof backup === 'string' ? backup : JSON.stringify(backup) - ) - } -} - -/** What an install does: open the database, running the copy if it is owed, then the store. */ -async function install(): Promise<{ - database: JournalHostDatabase - store: AgentSessionRecordStore - reports: unknown[] -}> { - const log = recordingStructuredAgentSessionLogger() - const database = await openStructuredAgentSessionJournalDatabase({ - logger: log.logger, - stateDirectory: root, - hostId: 'local' - }) - opened.push(database) - // Each report reaches the log as one entry under its scope, its kind as the outcome. - const reports = log.entries - .filter((entry) => entry.fields.scope === 'legacy-record-import') - .map(({ fields: { scope: _scope, outcome, ...rest } }) => ({ kind: outcome, ...rest })) - return { - database, - store: AgentSessionRecordStore.open({ - journalDatabase: database, - hostId: 'local' - }), - reports - } -} - -function unreconciled(value: AgentSessionRecord): AgentSessionRecord { - return { ...value, lease: { ...value.lease, unreconciled: true } } -} - -describe('copying the records file into the chat database', () => { - it('copies every record, operation, key and tab, and leaves both files byte-identical', async () => { - const alpha = record(ALPHA) - const beta = record(BETA, { runtimeFence: 5 }) - const file = legacyFile([alpha, beta], { - operations: { [agentSessionOperationKey('client-1', OPERATION_ID)]: OPERATION }, - retiredClaimKeys: [{ keyId: 'key-0', retiredAt: NOW }], - sessionTabs: [{ tabId: 'tab-beta', sessionId: BETA }] - }) - await writeLegacy(file, file) - const [primary, backup] = await Promise.all([ - readFile(legacyPath()), - readFile(`${legacyPath()}.bak`) - ]) - - const { database, store, reports } = await install() - - expect(reports).toEqual([]) - expect(journalPragmaNumber(database.db, 'user_version')).toBe(4) - expect(store.getRecord(ALPHA)).toEqual(unreconciled(alpha)) - expect(store.getRecord(BETA)).toEqual(unreconciled(beta)) - expect(store.listOperationRows()).toEqual([OPERATION]) - expect(store.isClaimKeyVerifiable('key-0', NOW)).toBe(true) - expect(store.getVisibleSessionTabIndex()).toEqual({ present: true, sessionIds: [BETA] }) - expect(store.getSessionTabId(BETA)).toBe('tab-beta') - expect(await readFile(legacyPath())).toEqual(primary) - expect(await readFile(`${legacyPath()}.bak`)).toEqual(backup) - }) - - it('copies once: a later launch never reads the file again', async () => { - await writeLegacy(legacyFile([record(ALPHA)])) - const first = await install() - await first.store.setConversationName(ALPHA, 'kept') - first.database.close() - // A downgraded build changed its own file; the database keeps what it holds. - await writeLegacy(legacyFile([record(BETA)])) - - const { store } = await install() - - expect(store.getRecord(ALPHA)?.conversationName).toBe('kept') - expect(store.getRecord(BETA)).toBeNull() - }) - - it('reports a file no read will make usable, copies nothing, and leaves it untouched', async () => { - const adHoc = JSON.stringify({ ...legacyFile([record(ALPHA)]), schemaVersion: 1 }) - await writeLegacy(adHoc) - - const { database, store, reports } = await install() - - expect(reports).toEqual([ - { - kind: 'unusable', - error: expect.objectContaining({ message: 'agent_session_store_corrupt' }) - } - ]) - expect(journalPragmaNumber(database.db, 'user_version')).toBe(4) - expect(store.listRecords()).toEqual([]) - expect(await readFile(legacyPath(), 'utf-8')).toBe(adHoc) - }) - - it('reports a file from another host id and copies it, so its owners stay unresolved', async () => { - await writeLegacy({ ...legacyFile([record(ALPHA)]), hostId: 'ssh:elsewhere' }) - - const { store, reports } = await install() - - expect(reports).toEqual([ - { kind: 'host-mismatch', fileHostId: 'ssh:elsewhere', hostId: 'local' } - ]) - expect(store.hostId).toBe('local') - expect(store.getRecord(ALPHA)).not.toBeNull() - }) - - it('maps a lease the removed terminal handoff wrote, and stores it mapped', async () => { - const legacy = { - ...record(ALPHA, { - ownerProcess: { hostId: 'local', pid: 4242, processStartTimeMs: 1, spawnToken: 'spawn-a' } - }), - lease: { - ...record(ALPHA).lease, - ownerProcess: { hostId: 'local', pid: 4242, processStartTimeMs: 1, spawnToken: 'spawn-a' }, - claimStatus: 'live', - runtimeKind: 'tui', - handoffStage: 'old-owner-stopped', - handoffOperationId: 'op-handoff', - processlessAt: 5 - } - } - await writeLegacy(legacyFile([legacy])) - - const { database, store } = await install() - - expect(store.isSessionUnreadable(ALPHA)).toBe(false) - const lease = store.getRecord(ALPHA)?.lease - expect(lease).toMatchObject({ - runtimeKind: 'native', - handoffStage: 'recovering', - handoffOperationId: 'op-handoff', - claimStatus: 'conflicted' - }) - expect(lease).not.toHaveProperty('processlessAt') - const stored = database.db - .prepare('SELECT record_json FROM agent_session_records WHERE session_id = ?') - .get(ALPHA) - expect(JSON.parse(String(stored?.record_json)).lease).toMatchObject({ - runtimeKind: 'native', - handoffStage: 'recovering' - }) - expect(JSON.parse(String(stored?.record_json)).lease).not.toHaveProperty('processlessAt') - }) -}) - -describe('a record the file set aside', () => { - it('copies a record this build cannot read verbatim, and never grants it', async () => { - const unsupported = { ...record(ALPHA), schemaVersion: 1 } - await writeLegacy(legacyFile([unsupported])) - - const { database, store } = await install() - - expect(store.getRecord(ALPHA)).toBeNull() - expect(store.isSessionUnreadable(ALPHA)).toBe(true) - await expect(store.setConversationName(ALPHA, 'x')).rejects.toThrow( - 'execution_owner_reconciling' - ) - const stored = database.db - .prepare('SELECT record_json FROM agent_session_records WHERE session_id = ?') - .get(ALPHA) - expect(JSON.parse(String(stored?.record_json))).toEqual(storedForm(unsupported)) - }) - - // #23589: both copies exist only when an older build recovered the readable one from its backup, - // so the set-aside copy may already have granted fences the readable one cannot show. - it('keeps the readable copy and raises its floor above both: 8, never 3, never 7 twice', async () => { - const readable = record(ALPHA, { runtimeFence: 2 }) - const setAside = { ...record(ALPHA, { runtimeFence: 7 }), schemaVersion: 99 } - await writeLegacy( - legacyFile([readable], { - unusableRecords: { [ALPHA]: { reason: 'unsupported_schema', raw: setAside } } - }) - ) - - const { store } = await install() - - const lease = store.getRecord(ALPHA)?.lease - expect(lease?.runtimeFence).toBe(2) - expect(lease?.minimumNextFence).toBe(8) - expect(nextAgentSessionFence(lease!)).toBe(8) - }) - - it('takes a record the primary set aside from the backup, with its floor raised', async () => { - const readable = record(ALPHA, { runtimeFence: 3 }) - await writeLegacy( - legacyFile([], { - unusableRecords: { [ALPHA]: { reason: 'unsupported_schema', raw: { schemaVersion: 99 } } } - }), - legacyFile([readable]) - ) - - const { store } = await install() - - // The commit the backup lost may have granted 4, so the next grant clears it. - expect(store.getRecord(ALPHA)?.lease.minimumNextFence).toBe(5) - }) -}) - -describe('a primary the backup stands in for', () => { - it('copies the backup with every floor raised past what the lost commit could have granted', async () => { - await writeLegacy('{ truncated', legacyFile([record(ALPHA, { runtimeFence: 1 })])) - - const { store, reports } = await install() - - expect(reports).toEqual([]) - expect(store.getRecord(ALPHA)?.lease).toMatchObject({ runtimeFence: 1, minimumNextFence: 3 }) - expect(await readFile(legacyPath(), 'utf-8')).toBe('{ truncated') - }) - - it('reports both copies unusable and copies nothing', async () => { - await writeLegacy('{ truncated', '{ also truncated') - - const { store, reports } = await install() - - expect(reports).toMatchObject([{ kind: 'unusable' }]) - expect(store.listRecords()).toEqual([]) - }) - - // A read that can clear says nothing about the backup's contents, so the copy stays owed. - it('keeps the copy owed when the backup cannot be read behind a torn primary', async () => { - await writeLegacy('{ truncated') - await mkdir(`${legacyPath()}.bak`) - - const { database, store, reports } = await install() - - expect(reports).toMatchObject([{ kind: 'unavailable' }]) - expect(journalPragmaNumber(database.db, 'user_version')).toBe(3) - expect(store.listRecords()).toEqual([]) - }) - - // Falling back would replace the primary's newer state with the backup's older one for good. - it('never takes a valid backup when the primary cannot be read', async () => { - await mkdir(legacyPath(), { recursive: true }) - await writeFile(`${legacyPath()}.bak`, JSON.stringify(legacyFile([record(ALPHA)]))) - - const { database, store, reports } = await install() - - expect(reports).toMatchObject([{ kind: 'unavailable' }]) - expect(journalPragmaNumber(database.db, 'user_version')).toBe(3) - expect(store.listRecords()).toEqual([]) - }) -}) - -describe('the tab index from before the table', () => { - it('seeds the table from the visible list, keeping each chat on the id it has today', async () => { - const gamma = 'session-gamma' - await writeLegacy( - legacyFile( - [ - { ...record(ALPHA), surfaceTabId: 'tab-alpha' }, - { ...record(BETA), surfaceTabId: `structured-agent-session-${BETA}` }, - record(gamma) - ], - { visibleSessionIds: [ALPHA, BETA] } - ) - ) - - const { store } = await install() - - expect(store.getSessionTabId(ALPHA)).toBe('tab-alpha') - expect(store.getSessionTabId(BETA)).toBe(`structured-agent-session-${BETA}`) - expect(store.getSessionTabId(gamma)).toBeNull() - expect(store.listVisibleSessionIds()).toEqual([ALPHA, BETA]) - }) - - it('seeds a chat cleared before the upgrade under the id its tab opened with', async () => { - const cleared = (replacementSessionId: string) => ({ - command: 'clear', - state: 'completed', - phase: 'committed', - operationId: OPERATION_ID, - callerKey: 'client-1', - replacementSessionId - }) - await writeLegacy( - legacyFile( - [ - { ...record(ALPHA), conversationCommand: cleared('clear-one') }, - { ...record('clear-one'), conversationCommand: cleared('clear-two') }, - { ...record('clear-two'), surfaceTabId: 'structured-agent-session-clear-two' } - ], - { visibleSessionIds: [ALPHA, 'clear-two'] } - ) - ) - - const { store } = await install() - - expect(store.getSessionTabId('clear-two')).toBe(LEGACY_TAB_ID) - const reopenedTab = store.getSessionTabId(ALPHA) - expect(reopenedTab).not.toBe(LEGACY_TAB_ID) - expect(reopenedTab).not.toContain(':') - expect(store.listVisibleSessionIds()).toEqual([ALPHA, 'clear-two']) - }) - - it('leaves the index unrecorded when only a chat created while the copy was owed recorded one', async () => { - await mkdir(legacyPath(), { recursive: true }) - const owed = await install() - owed.database.db - .prepare('INSERT INTO agent_session_records (session_id, record_json) VALUES (?, ?)') - .run(BETA, JSON.stringify(storedForm(record(BETA)))) - await AgentSessionRecordStore.open({ - journalDatabase: owed.database, - hostId: 'local' - }).setSessionTabVisibility(BETA, true) - owed.database.close() - await rm(legacyPath(), { recursive: true }) - await writeLegacy(legacyFile([record(ALPHA)])) - - const { database, store } = await install() - - expect(journalPragmaNumber(database.db, 'user_version')).toBe(4) - expect(store.listRecords().map(({ sessionId }) => sessionId)).toEqual([BETA, ALPHA]) - // Restore then takes the profile's tabs, beside the tab the owed-era chat left. - expect(store.getVisibleSessionTabIndex()).toEqual({ present: false, sessionIds: [BETA] }) - }) - - it('reads a recorded table, never the record field', async () => { - await writeLegacy( - legacyFile([{ ...record(ALPHA), surfaceTabId: 'tab-stale' }], { - sessionTabs: [{ tabId: 'tab-alpha', sessionId: ALPHA }] - }) - ) - - expect((await install()).store.getSessionTabId(ALPHA)).toBe('tab-alpha') - }) - - it('keeps a record whose legacy tab id is malformed, seeding it under the derived id', async () => { - await writeLegacy( - legacyFile([{ ...record(ALPHA), surfaceTabId: `agent-session:${ALPHA}` }], { - visibleSessionIds: [ALPHA] - }) - ) - - const { store } = await install() - - expect(store.isSessionUnreadable(ALPHA)).toBe(false) - expect(store.getSessionTabId(ALPHA)).toBe(LEGACY_TAB_ID) - }) - - it('treats a malformed table as an unusable file rather than guessing', async () => { - await writeLegacy( - legacyFile([record(ALPHA)], { - sessionTabs: [ - { tabId: 'tab-alpha', sessionId: ALPHA }, - { tabId: 'tab-alpha', sessionId: BETA } - ] - }) - ) - - const { store, reports } = await install() - - expect(reports).toMatchObject([{ kind: 'unusable' }]) - expect(store.listRecords()).toEqual([]) - expect(store.getVisibleSessionTabIndex().present).toBe(false) - }) -}) diff --git a/src/main/runtime/agent-session-legacy-record-import.ts b/src/main/runtime/agent-session-legacy-record-import.ts deleted file mode 100644 index f632f008e764..000000000000 --- a/src/main/runtime/agent-session-legacy-record-import.ts +++ /dev/null @@ -1,166 +0,0 @@ -// The one-time copy of `agent-sessions.json` into the chat journal database, inside the version-4 -// migration (journal-database.ts). The file and its `.bak` are only ever read: an older build that -// still uses them finds them as it left them. -// -// Never blocks the host. A file no read will make usable is reported, nothing is copied, and the -// migration completes: the file is left untouched, but a later repair of it is never imported. A -// read that can clear leaves the copy owed, and every later launch retries it until one reads the -// file, finds it absent or unusable, or the import is retired. - -import { nextAgentSessionFence } from '../../shared/agent-session-next-fence' -import type { AgentSessionRecord } from '../../shared/agent-session-record' -import { encodeAgentSessionRecord } from '../../shared/agent-session-record-stored-form' -import { - NO_LEGACY_JOURNAL_RECORDS, - type JournalLegacyRecordImport -} from '../native-chat/agent-session-journal/journal-database' -import { raiseAgentSessionFencesAfterBackupRecovery } from './agent-session-backup-recovery-fence' -import { - legacyAgentSessionStorePath, - loadAgentSessionStore, - type AgentSessionStoreState -} from './agent-session-record-store-file' -import { - insertAgentSessionStoreRowsIfAbsent, - withoutRetiredLeaseLatches, - type AgentSessionStoreImportRows -} from './agent-session-record-rows' -import { isReadableAgentSessionStoreRecord } from './agent-session-store-row-rules' - -export type LegacyAgentSessionRecords = - | { kind: 'absent' } - /** The primary parsed, or it was unusable and `.bak` parsed (`fromBackup`). */ - | { kind: 'loaded'; state: AgentSessionStoreState; fromBackup: boolean } - /** Present, and neither the primary nor `.bak` parses: no read will change that. */ - | { kind: 'unusable'; error: unknown } - /** Reading the primary or `.bak` failed in a way that can clear (EACCES, EIO, EMFILE). */ - | { kind: 'unavailable'; error: unknown } - -/** Why the copy found nothing to take, or took less than the file holds. */ -export type LegacyAgentSessionRecordImportReport = - | { kind: 'unusable' | 'unavailable'; error: unknown } - | { kind: 'host-mismatch'; fileHostId: string; hostId: string } - -function readFailedTransiently(error: unknown): boolean { - const cause = error instanceof Error ? error.cause : undefined - return ( - !(error instanceof Error && error.message === 'agent_session_store_corrupt') || - (typeof cause === 'object' && cause !== null && 'code' in cause && cause.code !== 'ENOENT') - ) -} - -/** Never throws. */ -export async function readLegacyAgentSessionRecords( - stateDirectory: string, - hostId: string -): Promise { - try { - const loaded = await loadAgentSessionStore(legacyAgentSessionStorePath(stateDirectory), hostId) - return loaded.storeFound - ? { kind: 'loaded', state: loaded.state, fromBackup: loaded.recoveredFromBackup } - : { kind: 'absent' } - } catch (error) { - return readFailedTransiently(error) - ? { kind: 'unavailable', error } - : { kind: 'unusable', error } - } -} - -function safeFence(value: unknown): number | null { - return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : null -} - -/** - * A readable record whose id also has a set-aside copy: an older build recovered the readable one - * from its backup and could not read the newer copy, which may already have granted fences the - * readable one cannot show. The next grant must clear both. - */ -function withFloorAboveSetAsideCopy(record: AgentSessionRecord, raw: unknown): AgentSessionRecord { - let floor = Math.max(record.lease.minimumNextFence ?? 0, nextAgentSessionFence(record.lease) + 1) - const rawLease = typeof raw === 'object' && raw !== null && 'lease' in raw ? raw.lease : undefined - if (typeof rawLease === 'object' && rawLease !== null && 'runtimeFence' in rawLease) { - const runtimeFence = safeFence(rawLease.runtimeFence) - const minimumNextFence = - 'minimumNextFence' in rawLease && rawLease.minimumNextFence !== undefined - ? safeFence(rawLease.minimumNextFence) - : undefined - if (runtimeFence !== null && minimumNextFence !== null) { - floor = Math.max(floor, nextAgentSessionFence({ runtimeFence, minimumNextFence })) - } - } - if (!Number.isSafeInteger(floor)) { - throw new Error('agent_session_fence_exhausted') - } - return { ...record, lease: { ...record.lease, minimumNextFence: floor } } -} - -function importRows( - state: AgentSessionStoreState, - fromBackup: boolean -): AgentSessionStoreImportRows { - const records = new Map(state.records) - if (fromBackup) { - // The commit the backup lost may have granted a fence it cannot show: main's rule for this state. - raiseAgentSessionFencesAfterBackupRecovery({ ...state, records }) - } - const rows: AgentSessionStoreImportRows['records'] = [] - for (const [sessionId, loaded] of records) { - const setAside = state.unreadableRecords.get(sessionId) - const record = setAside ? withFloorAboveSetAsideCopy(loaded, setAside.raw) : loaded - const json = JSON.stringify( - encodeAgentSessionRecord({ ...record, lease: withoutRetiredLeaseLatches(record.lease) }) - ) - // A record the load rules refuse is kept as its bytes, which every load then sets aside. - rows.push([ - sessionId, - isReadableAgentSessionStoreRecord(sessionId, JSON.parse(json)) - ? json - : JSON.stringify(encodeAgentSessionRecord(loaded)) - ]) - } - for (const [sessionId, { raw }] of state.unreadableRecords) { - if (!records.has(sessionId)) { - // Verbatim: a build that can read it gets it back, and this one derives it as unreadable. - rows.push([sessionId, JSON.stringify(raw ?? null)]) - } - } - return { - records: rows, - operations: [...state.operations].map(([key, row]) => [key, JSON.stringify(row)]), - retiredClaimKeys: state.retiredClaimKeys, - sessionTabs: - state.sessionTabs?.entries().map(([tabId, sessionId]) => ({ tabId, sessionId })) ?? null - } -} - -/** What the migration runs for `legacy`, each failure reported once here. */ -export function legacyAgentSessionRecordImport( - legacy: LegacyAgentSessionRecords, - hostId: string, - report: (report: LegacyAgentSessionRecordImportReport) => void -): JournalLegacyRecordImport { - switch (legacy.kind) { - case 'absent': - return NO_LEGACY_JOURNAL_RECORDS - case 'unavailable': - report(legacy) - return { owed: true } - case 'unusable': - report(legacy) - return NO_LEGACY_JOURNAL_RECORDS - case 'loaded': - break - } - let rows: AgentSessionStoreImportRows - try { - rows = importRows(legacy.state, legacy.fromBackup) - } catch (error) { - report({ kind: 'unusable', error }) - return NO_LEGACY_JOURNAL_RECORDS - } - if (legacy.state.hostId !== hostId) { - // Owners it names answer `unresolved` against this host, the conservative verdict. - report({ kind: 'host-mismatch', fileHostId: legacy.state.hostId, hostId }) - } - return { owed: false, write: (db) => insertAgentSessionStoreRowsIfAbsent(db, rows) } -} diff --git a/src/main/runtime/agent-session-operation-admission.ts b/src/main/runtime/agent-session-operation-admission.ts index 6cc192adbe12..d6d478c01900 100644 --- a/src/main/runtime/agent-session-operation-admission.ts +++ b/src/main/runtime/agent-session-operation-admission.ts @@ -20,7 +20,7 @@ import { } from '../../shared/agent-session-mutation-envelope' import type { AgentSessionMutationEnvelope } from '../../shared/agent-session-wire' import type { AgentSessionRecord } from '../../shared/agent-session-record' -import type { AgentSessionStoreState } from './agent-session-record-store-file' +import type { AgentSessionStoreState } from './agent-session-store-state' export type AgentSessionOperationAdmission = { callerKey: string diff --git a/src/main/runtime/agent-session-record-rows.ts b/src/main/runtime/agent-session-record-rows.ts index bc691038a6b1..ecc9fd9d72c3 100644 --- a/src/main/runtime/agent-session-record-rows.ts +++ b/src/main/runtime/agent-session-record-rows.ts @@ -1,5 +1,5 @@ // The agent-session store's rows in the host's chat journal database: every row loaded once at open, -// exactly the rows a transaction changed written back, and the one-time copy of the records file. +// and exactly the rows a transaction changed written back. // // A record row this build cannot read is derived as unreadable at each load and never rewritten: a // write only touches changed rows, and every mutation of an unreadable id is refused. @@ -12,10 +12,7 @@ import { import { decodePersistedAgentSessionRecord } from '../../shared/agent-session-record-stored-form' import type Database from '../sqlite/sync-database' import type { SqliteRow } from '../sqlite/sqlite-statement' -import { - AGENT_SESSION_STORE_SCHEMA_VERSION, - type AgentSessionStoreState -} from './agent-session-record-store-file' +import type { AgentSessionStoreState } from './agent-session-store-state' import type { AgentSessionStoreRowWrites } from './agent-session-store-draft' import { isReadableAgentSessionStoreOperation, @@ -81,13 +78,8 @@ function unreadableRecordReason(value: unknown): string { * be alive, so nothing persisted grants a writer until this host adjudicates it. Operation, key and * tab rows this build cannot read are skipped, as a record row it cannot read is set aside. */ -export function loadAgentSessionStoreRows( - db: Database.Database, - hostId: string -): AgentSessionStoreState { +export function loadAgentSessionStoreRows(db: Database.Database): AgentSessionStoreState { const state: AgentSessionStoreState = { - schemaVersion: AGENT_SESSION_STORE_SCHEMA_VERSION, - hostId, records: new Map(), operations: new Map(), retiredClaimKeys: [], @@ -163,8 +155,6 @@ export function loadAgentSessionStoreRows( } if (recorded) { state.sessionTabs = table - } else if (table.sessionIds().length > 0) { - state.unrecordedSessionTabs = table } return state } @@ -224,56 +214,3 @@ export function writeAgentSessionStoreRows( writeTabIndex(db, writes.sessionTabs) } } - -/** What the one-time import copies in, each row already serialized. */ -export type AgentSessionStoreImportRows = { - records: [sessionId: string, json: string][] - operations: [key: string, json: string][] - retiredClaimKeys: { keyId: string; retiredAt: number }[] - /** Null when the file never recorded a tab index. */ - sessionTabs: PersistedAgentSessionTab[] | null -} - -/** Inserts each row whose key the database does not hold yet: a chat created while the import was - * owed keeps its own rows, and ids never collide with the file's. */ -export function insertAgentSessionStoreRowsIfAbsent( - db: Database.Database, - rows: AgentSessionStoreImportRows -): void { - const insertRecord = db.prepare( - 'INSERT OR IGNORE INTO agent_session_records (session_id, record_json) VALUES (?, ?)' - ) - for (const [sessionId, json] of rows.records) { - insertRecord.run(sessionId, json) - } - const insertOperation = db.prepare( - 'INSERT OR IGNORE INTO agent_session_operations (operation_key, row_json) VALUES (?, ?)' - ) - for (const [key, json] of rows.operations) { - insertOperation.run(key, json) - } - const insertKey = db.prepare( - 'INSERT OR IGNORE INTO agent_session_retired_claim_keys (key_id, retired_at) VALUES (?, ?)' - ) - for (const { keyId, retiredAt } of rows.retiredClaimKeys) { - insertKey.run(keyId, retiredAt) - } - const tabs = rows.sessionTabs - if (tabs) { - const insertTab = db.prepare( - 'INSERT OR IGNORE INTO agent_session_tabs (tab_id, session_id, position) VALUES (?, ?, ?)' - ) - // Ahead of any tab a chat created while the import was owed recorded: these were open first. - tabs.forEach(({ tabId, sessionId }, position) => - insertTab.run(tabId, sessionId, position - tabs.length) - ) - db.prepare('INSERT OR IGNORE INTO agent_session_store_meta (key, value) VALUES (?, ?)').run( - SESSION_TABS_RECORDED, - '1' - ) - } else { - // An index a chat created while the import was owed recorded holds none of the file's chats: - // "never recorded" sends restore to the profile's tabs, with that chat's tab row beside them. - db.prepare('DELETE FROM agent_session_store_meta WHERE key = ?').run(SESSION_TABS_RECORDED) - } -} diff --git a/src/main/runtime/agent-session-record-store-file.ts b/src/main/runtime/agent-session-record-store-file.ts deleted file mode 100644 index 6b5632a87abf..000000000000 --- a/src/main/runtime/agent-session-record-store-file.ts +++ /dev/null @@ -1,294 +0,0 @@ -/** - * The records file the agent-session store kept before it moved into the chat journal database, - * read once by the version-4 migration (agent-session-legacy-record-import.ts) and never written. - * Its `.bak` fallback and salvage survive here for that one read, until the import is retired. - */ - -import { readFile } from 'node:fs/promises' -import { join } from 'node:path' -import { - agentSessionOperationKey, - isAgentSessionOperationRow, - type AgentSessionOperationRow -} from '../../shared/agent-session-operation-ledger' -import { - AGENT_SESSION_RECORD_SCHEMA_VERSION, - isPersistedAgentSessionRecord, - type AgentSessionRecord -} from '../../shared/agent-session-record' -import { decodePersistedAgentSessionRecord } from '../../shared/agent-session-record-stored-form' -import { parseAgentSessionTabTable, type AgentSessionTabTable } from './agent-session-tab-table' - -export const AGENT_SESSION_STORE_SCHEMA_VERSION = 2 as const - -/** In a host's state directory, beside the journal database, where the records file lived. */ -export const AGENT_SESSION_STORE_DIR_NAME = 'agent-sessions' - -export const AGENT_SESSION_STORE_FILE_NAME = 'agent-sessions.json' - -export type RetiredAgentSessionClaimKey = { keyId: string; retiredAt: number } - -export type AgentSessionStoreState = { - schemaVersion: number - hostId: string - records: Map - operations: Map - retiredClaimKeys: RetiredAgentSessionClaimKey[] - /** Rows this build cannot validate, kept with a durable refusal reason. */ - unreadableRecords: Map - /** Chat tab id → the conversation it shows; null until this store first records a tab. */ - sessionTabs: AgentSessionTabTable | null - /** Tab rows an index never recorded holds (chats opened while the import was owed). */ - unrecordedSessionTabs?: AgentSessionTabTable -} - -export type LoadedAgentSessionStore = { - state: AgentSessionStoreState - storeFound: boolean - /** True when the file was written by a newer schema; this host reads but never writes it. */ - readOnly: boolean - /** True when the primary file was unusable and the previous committed copy was used. */ - recoveredFromBackup: boolean -} - -export function agentSessionStorePath(directory: string): string { - return join(directory, AGENT_SESSION_STORE_FILE_NAME) -} - -/** The records file of the host whose state directory this is. */ -export function legacyAgentSessionStorePath(stateDirectory: string): string { - return agentSessionStorePath(join(stateDirectory, AGENT_SESSION_STORE_DIR_NAME)) -} - -const backupPath = (filePath: string): string => `${filePath}.bak` - -function emptyState(hostId: string): AgentSessionStoreState { - return { - schemaVersion: AGENT_SESSION_STORE_SCHEMA_VERSION, - hostId, - records: new Map(), - operations: new Map(), - retiredClaimKeys: [], - unreadableRecords: new Map(), - sessionTabs: null - } -} - -function parseState(raw: string, hostId: string): Pick | null { - let parsed: unknown - try { - parsed = JSON.parse(raw) - } catch { - return null - } - if (typeof parsed !== 'object' || parsed === null) { - return null - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: every field is read back as `unknown` and validated below before use. - const file = parsed as { - schemaVersion?: unknown - hostId?: unknown - records?: unknown - operations?: unknown - retiredClaimKeys?: unknown - unusableRecords?: unknown - sessionTabs?: unknown - visibleSessionIds?: unknown - } - if ( - !Number.isSafeInteger(file.schemaVersion) || - (file.schemaVersion as number) < 0 || - typeof file.hostId !== 'string' - ) { - return null - } - const schemaVersion = file.schemaVersion as number - if (schemaVersion < AGENT_SESSION_STORE_SCHEMA_VERSION) { - return null - } - if ( - schemaVersion === AGENT_SESSION_STORE_SCHEMA_VERSION && - (typeof file.records !== 'object' || file.records === null || Array.isArray(file.records)) - ) { - return null - } - if ( - schemaVersion === AGENT_SESSION_STORE_SCHEMA_VERSION && - (typeof file.operations !== 'object' || - file.operations === null || - Array.isArray(file.operations) || - !Array.isArray(file.retiredClaimKeys) || - typeof file.unusableRecords !== 'object' || - file.unusableRecords === null || - Array.isArray(file.unusableRecords)) - ) { - return null - } - const state = emptyState(hostId) - state.schemaVersion = schemaVersion - state.hostId = file.hostId - if (typeof file.records === 'object' && file.records !== null) { - for (const [sessionId, value] of Object.entries(file.records)) { - const decoded = isPersistedAgentSessionRecord(value) - ? decodePersistedAgentSessionRecord(value) - : null - const record = decoded?.record ?? null - if (record?.sessionId === sessionId) { - state.records.set(sessionId, record) - } else { - const valueSchemaVersion = - typeof value === 'object' && - value !== null && - (value as { schemaVersion?: unknown }).schemaVersion - const reason = record - ? 'record_key_session_id_mismatch' - : valueSchemaVersion === AGENT_SESSION_RECORD_SCHEMA_VERSION - ? 'current_shape_invalid' - : 'unsupported_schema' - state.unreadableRecords.set(sessionId, { reason, raw: value }) - } - } - } - if (typeof file.unusableRecords === 'object' && file.unusableRecords !== null) { - for (const [sessionId, value] of Object.entries(file.unusableRecords)) { - if (typeof value !== 'object' || value === null) { - if (schemaVersion === AGENT_SESSION_STORE_SCHEMA_VERSION) { - return null - } - continue - } - const unusable = value as { reason?: unknown; raw?: unknown } - if (typeof unusable.reason !== 'string' || unusable.reason.length === 0) { - if (schemaVersion === AGENT_SESSION_STORE_SCHEMA_VERSION) { - return null - } - continue - } - state.unreadableRecords.set(sessionId, { reason: unusable.reason, raw: unusable.raw }) - } - } - if (typeof file.operations === 'object' && file.operations !== null) { - for (const [key, value] of Object.entries(file.operations)) { - if (!isAgentSessionOperationRow(value)) { - if (schemaVersion === AGENT_SESSION_STORE_SCHEMA_VERSION) { - return null - } - continue - } - if (key !== agentSessionOperationKey(value.callerKey, value.operationId)) { - if (schemaVersion === AGENT_SESSION_STORE_SCHEMA_VERSION) { - return null - } - continue - } - state.operations.set(key, value) - } - } - if (Array.isArray(file.retiredClaimKeys)) { - for (const entry of file.retiredClaimKeys) { - const key = entry as Partial - if ( - typeof key?.keyId !== 'string' || - key.keyId.length === 0 || - key.keyId.length > 512 || - !Number.isSafeInteger(key.retiredAt) || - (key.retiredAt as number) < 0 - ) { - if (schemaVersion === AGENT_SESSION_STORE_SCHEMA_VERSION) { - return null - } - continue - } - state.retiredClaimKeys.push({ keyId: key.keyId, retiredAt: key.retiredAt as number }) - } - } - const sessionTabs = parseAgentSessionTabTable( - file, - state.records, - schemaVersion === AGENT_SESSION_STORE_SCHEMA_VERSION - ) - if (!sessionTabs.valid) { - return null - } - state.sessionTabs = sessionTabs.table - return { state } -} - -/** A record the primary retained as unreadable may still have a valid copy in the previous - * committed state. Adopting it keeps the session reachable — the lease is re-adjudicated - * like any other — while the unreadable bytes stay quarantined verbatim. */ -async function salvageUnreadableRecordsFromBackup( - state: AgentSessionStoreState, - backupFilePath: string, - hostId: string -): Promise { - const missing = [...state.unreadableRecords.keys()].filter( - (sessionId) => !state.records.has(sessionId) - ) - if (missing.length === 0) { - return - } - let raw: string - try { - raw = await readFile(backupFilePath, 'utf-8') - } catch { - return - } - const backup = parseState(raw, hostId) - if (!backup) { - return - } - for (const sessionId of missing) { - const record = backup.state.records.get(sessionId) - if (record) { - state.records.set(sessionId, record) - } - } -} - -export async function loadAgentSessionStore( - filePath: string, - hostId: string -): Promise { - let unusableStoreFound = false - for (const [candidate, recoveredFromBackup] of [ - [filePath, false], - [backupPath(filePath), true] - ] as const) { - let raw: string - try { - raw = await readFile(candidate, 'utf-8') - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - // Only a missing or unparseable primary means "fall back". A transient read failure - // (EACCES, EIO, EMFILE) says nothing about either copy's contents: the primary is not - // replaced by a stale backup, and a backup that could not be read is not unusable. - throw new Error('agent_session_store_corrupt', { cause: error }) - } - continue - } - const parsed = parseState(raw, hostId) - if (!parsed) { - unusableStoreFound = true - continue - } - if (!recoveredFromBackup) { - await salvageUnreadableRecordsFromBackup(parsed.state, backupPath(filePath), hostId) - } - return { - ...parsed, - storeFound: true, - readOnly: parsed.state.schemaVersion > AGENT_SESSION_STORE_SCHEMA_VERSION, - recoveredFromBackup - } - } - if (unusableStoreFound) { - throw new Error('agent_session_store_corrupt') - } - return { - state: emptyState(hostId), - storeFound: false, - readOnly: false, - recoveredFromBackup: false - } -} diff --git a/src/main/runtime/agent-session-record-store-slot.ts b/src/main/runtime/agent-session-record-store-slot.ts index e40ddbd19b06..b19e0c8dfdab 100644 --- a/src/main/runtime/agent-session-record-store-slot.ts +++ b/src/main/runtime/agent-session-record-store-slot.ts @@ -74,7 +74,7 @@ export function peekOpenedAgentSessionRecordStore(): AgentSessionRecordStore | n async function openRecordStore( location: AgentSessionRecordStoreLocation ): Promise { - const journalDatabase = await openStructuredAgentSessionJournalDatabase(location) + const journalDatabase = openStructuredAgentSessionJournalDatabase(location) try { return { journalDatabase, diff --git a/src/main/runtime/agent-session-record-store-test-harness.ts b/src/main/runtime/agent-session-record-store-test-harness.ts index ab879fa159b0..4bab4ab79654 100644 --- a/src/main/runtime/agent-session-record-store-test-harness.ts +++ b/src/main/runtime/agent-session-record-store-test-harness.ts @@ -19,17 +19,12 @@ import { } from '../native-chat/agent-session-journal/journal-host-database-test-support' import type Database from '../sqlite/sync-database' import { AgentSessionRecordStore } from './agent-session-record-store' -import { - AGENT_SESSION_STORE_SCHEMA_VERSION, - type RetiredAgentSessionClaimKey -} from './agent-session-record-store-file' +import type { RetiredAgentSessionClaimKey } from './agent-session-store-state' const TEST_HOST_ID = 'local' /** One committed state of the store, as tests seed it and read it back. */ export type PersistedTestAgentSessionStore = { - schemaVersion: number - hostId: string /** Every record row as stored, including one this build cannot read. */ records: Record operations: Record @@ -118,20 +113,23 @@ export function storedTestAgentSessionRecord( } } -/** Leaves `records` behind as an earlier run of the app would have, before anything opens it. */ +/** Leaves `records`, and any tab index, behind as an earlier run of the app would have, before + * anything opens it. */ export async function seedTestAgentSessionRecordStore( stateDirectory: string, - seed: { records: readonly (AgentSessionRecord | PersistedAgentSessionRecord)[] } + seed: { + records: readonly (AgentSessionRecord | PersistedAgentSessionRecord)[] + sessionTabs?: { tabId: string; sessionId: string }[] + } ): Promise { writePersisted(databaseFor(stateDirectory), { - schemaVersion: AGENT_SESSION_STORE_SCHEMA_VERSION, - hostId: TEST_HOST_ID, records: Object.fromEntries( seed.records.map((record) => [record.sessionId, storedTestAgentSessionRecord(record)]) ), operations: {}, retiredClaimKeys: [], - unusableRecords: {} + unusableRecords: {}, + ...(seed.sessionTabs ? { sessionTabs: seed.sessionTabs } : {}) }) } @@ -154,8 +152,6 @@ export async function readPersistedTestAgentSessionStore( ): Promise { const db = databaseFor(stateDirectory) const persisted: PersistedTestAgentSessionStore = { - schemaVersion: AGENT_SESSION_STORE_SCHEMA_VERSION, - hostId: TEST_HOST_ID, records: {}, operations: {}, retiredClaimKeys: [], diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts index f05812ce9417..e4f73dd32119 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -65,7 +65,7 @@ import { type AgentSessionReserveRequest, type AgentSessionReserveResult } from './agent-session-reservation-admission' -import type { AgentSessionStoreState } from './agent-session-record-store-file' +import type { AgentSessionStoreState } from './agent-session-store-state' import { agentSessionVisibleTabIndex, listVisibleAgentSessionIds, @@ -101,7 +101,7 @@ export class AgentSessionRecordStore { journalDatabase: JournalHostDatabase hostId: string }): AgentSessionRecordStore { - const rows = loadAgentSessionStoreRows(args.journalDatabase.db, args.hostId) + const rows = loadAgentSessionStoreRows(args.journalDatabase.db) const transactions = new AgentSessionStoreTransactions(args.journalDatabase, rows) return new AgentSessionRecordStore(transactions, args.hostId) } diff --git a/src/main/runtime/agent-session-reservation-admission.test.ts b/src/main/runtime/agent-session-reservation-admission.test.ts index 7ae309560b02..3602aa29cd74 100644 --- a/src/main/runtime/agent-session-reservation-admission.test.ts +++ b/src/main/runtime/agent-session-reservation-admission.test.ts @@ -15,7 +15,7 @@ import { applyAgentSessionReservation, type AgentSessionReserveRequest } from './agent-session-reservation-admission' -import type { AgentSessionStoreState } from './agent-session-record-store-file' +import type { AgentSessionStoreState } from './agent-session-store-state' import { claudeProviderHandle, codexProviderHandle @@ -67,8 +67,6 @@ function reserveRequest( function storeState(records: readonly AgentSessionRecord[] = []): AgentSessionStoreState { return { - schemaVersion: 2, - hostId: 'local', records: new Map(records.map((record) => [record.sessionId, record])), operations: new Map(), retiredClaimKeys: [], diff --git a/src/main/runtime/agent-session-reservation-admission.ts b/src/main/runtime/agent-session-reservation-admission.ts index 1344cc128d0d..5fbf939d1afc 100644 --- a/src/main/runtime/agent-session-reservation-admission.ts +++ b/src/main/runtime/agent-session-reservation-admission.ts @@ -45,7 +45,7 @@ import { reserveAgentSessionOwner, type AgentSessionReservation } from './agent-session-lease-transitions' -import type { AgentSessionStoreState } from './agent-session-record-store-file' +import type { AgentSessionStoreState } from './agent-session-store-state' import { agentSessionRecordIdentityFields } from './agent-session-record-founding' export type AgentSessionReserveRequest = { diff --git a/src/main/runtime/agent-session-restart-reconciliation.ts b/src/main/runtime/agent-session-restart-reconciliation.ts index b3447ec85c85..3e05914c42e3 100644 --- a/src/main/runtime/agent-session-restart-reconciliation.ts +++ b/src/main/runtime/agent-session-restart-reconciliation.ts @@ -1,7 +1,7 @@ import { pruneAgentSessionOperationRows } from '../../shared/agent-session-operation-ledger' import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' import type { AgentSessionRecord } from '../../shared/agent-session-record' -import type { AgentSessionStoreState } from './agent-session-record-store-file' +import type { AgentSessionStoreState } from './agent-session-store-state' import { agentSessionReconciliationTargetMatches } from './agent-session-reconciliation-target' import { applyAgentSessionRestartAdjudication } from './agent-session-restart-lease-transitions' diff --git a/src/main/runtime/agent-session-store-draft.ts b/src/main/runtime/agent-session-store-draft.ts index e39027d0042a..ac7e13d63772 100644 --- a/src/main/runtime/agent-session-store-draft.ts +++ b/src/main/runtime/agent-session-store-draft.ts @@ -7,7 +7,7 @@ import { encodeAgentSessionRecord } from '../../shared/agent-session-record-stor import type { AgentSessionStoreState, RetiredAgentSessionClaimKey -} from './agent-session-record-store-file' +} from './agent-session-store-state' import { isReadableAgentSessionStoreOperation, isReadableAgentSessionStoreRecord, diff --git a/src/main/runtime/agent-session-store-row-rules.ts b/src/main/runtime/agent-session-store-row-rules.ts index d3e7ea1c038d..96549f1e1a47 100644 --- a/src/main/runtime/agent-session-store-row-rules.ts +++ b/src/main/runtime/agent-session-store-row-rules.ts @@ -12,7 +12,7 @@ import { import { isAgentSessionId, isPersistedAgentSessionRecord } from '../../shared/agent-session-record' import type { PersistedAgentSessionRecord } from '../../shared/agent-session-legacy-handoff-lease' import { isAgentSessionSurfaceTabId } from '../../shared/agent-session-surface-tab-id' -import type { RetiredAgentSessionClaimKey } from './agent-session-record-store-file' +import type { RetiredAgentSessionClaimKey } from './agent-session-store-state' import type { PersistedAgentSessionTab } from './agent-session-tab-table' /** Valid stored identity, independent of provider availability. */ diff --git a/src/main/runtime/agent-session-store-state.ts b/src/main/runtime/agent-session-store-state.ts new file mode 100644 index 000000000000..34d46a6bf1fd --- /dev/null +++ b/src/main/runtime/agent-session-store-state.ts @@ -0,0 +1,18 @@ +// The agent-session store's in-memory state: loaded from the chat journal database and written +// back to it row by row (agent-session-record-rows.ts). + +import type { AgentSessionOperationRow } from '../../shared/agent-session-operation-ledger' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import type { AgentSessionTabTable } from './agent-session-tab-table' + +export type RetiredAgentSessionClaimKey = { keyId: string; retiredAt: number } + +export type AgentSessionStoreState = { + records: Map + operations: Map + retiredClaimKeys: RetiredAgentSessionClaimKey[] + /** Rows this build cannot validate, kept with a durable refusal reason. */ + unreadableRecords: Map + /** Chat tab id → the conversation it shows; null until this store first records a tab. */ + sessionTabs: AgentSessionTabTable | null +} diff --git a/src/main/runtime/agent-session-store-transactions.ts b/src/main/runtime/agent-session-store-transactions.ts index 31bddace8731..778956762d2c 100644 --- a/src/main/runtime/agent-session-store-transactions.ts +++ b/src/main/runtime/agent-session-store-transactions.ts @@ -11,7 +11,7 @@ import type { JournalHostDatabase } from '../native-chat/agent-session-journal/j import type { JournalOperationReceipt } from '../native-chat/agent-session-journal/journal-row-writer' import { journalOpenRefusalError } from '../native-chat/agent-session-journal/journal-open-failure' import { AgentSessionJournalError } from '../native-chat/agent-session-journal/journal-write-guards' -import type { AgentSessionStoreState } from './agent-session-record-store-file' +import type { AgentSessionStoreState } from './agent-session-store-state' import { writeAgentSessionStoreRows } from './agent-session-record-rows' import { agentSessionStoreDraftRowWrites, diff --git a/src/main/runtime/agent-session-tab-table.ts b/src/main/runtime/agent-session-tab-table.ts index e95e80d9d1eb..3f8965c62f0b 100644 --- a/src/main/runtime/agent-session-tab-table.ts +++ b/src/main/runtime/agent-session-tab-table.ts @@ -1,8 +1,6 @@ -import { isAgentSessionId, type AgentSessionRecord } from '../../shared/agent-session-record' import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' -import { isAgentSessionSurfaceTabId } from '../../shared/agent-session-surface-tab-id' import { structuredAgentSessionTabId } from '../../shared/structured-agent-session-projection' -import type { AgentSessionStoreState } from './agent-session-record-store-file' +import type { AgentSessionStoreState } from './agent-session-store-state' /** * Which conversation each structured chat tab shows, keyed by the host tab id. @@ -119,19 +117,11 @@ export function listVisibleAgentSessionIds(state: AgentSessionStoreState): strin return (state.sessionTabs?.sessionIds() ?? []).filter((sessionId) => state.records.has(sessionId)) } -/** Unrecorded, `sessionIds` are the tab rows a chat opened while the import was owed left. */ export function agentSessionVisibleTabIndex(state: AgentSessionStoreState): { present: boolean sessionIds: string[] } { - return { - present: state.sessionTabs !== null, - sessionIds: state.sessionTabs - ? listVisibleAgentSessionIds(state) - : (state.unrecordedSessionTabs?.sessionIds() ?? []).filter((sessionId) => - state.records.has(sessionId) - ) - } + return { present: state.sessionTabs !== null, sessionIds: listVisibleAgentSessionIds(state) } } export function setAgentSessionTabVisibility( @@ -157,126 +147,9 @@ export function showAgentSessionTabs( ): void { for (const sessionId of sessionIds) { if (state.records.has(sessionId)) { - setAgentSessionTabVisibility( - state, - sessionId, - true, - state.unrecordedSessionTabs?.tabIdFor(sessionId) - ) + setAgentSessionTabVisibility(state, sessionId, true) } } } export type PersistedAgentSessionTab = { tabId: string; sessionId: string } - -/** - * Reads the records file's table, or seeds it from what older builds wrote: the visible session list - * and, for a chat created by a build that recorded one, the tab id on its record. That record field - * is read here and nowhere else, and only when the file carries no table. - */ -export function parseAgentSessionTabTable( - file: { sessionTabs?: unknown; visibleSessionIds?: unknown }, - records: ReadonlyMap, - strict: boolean -): { valid: boolean; table: AgentSessionTabTable | null } { - if (file.sessionTabs !== undefined) { - return parsePersistedTabs(file.sessionTabs, strict) - } - if (file.visibleSessionIds === undefined) { - return { valid: true, table: null } - } - if (!Array.isArray(file.visibleSessionIds)) { - return { valid: !strict, table: null } - } - return { valid: true, table: seedFromVisibleSessions(file.visibleSessionIds, records) } -} - -/** - * A cleared chat's tab was opened for the first conversation of its /clear chain and kept that id - * through every clear, so the chat now showing the chain's latest conversation seeds under the - * first one's id, as a /clear on this build would have left it. Those chats seed first: a cleared - * conversation reopened from history is the later tab, and takes a fresh id if its own is held. - */ -function seedFromVisibleSessions( - visible: readonly unknown[], - records: ReadonlyMap -): AgentSessionTabTable { - const sessionIds = [...new Set(visible.filter(isAgentSessionId))] - const clearedFrom = new Map() - for (const record of records.values()) { - const command = record.conversationCommand - if ( - command?.command === 'clear' && - command.phase === 'committed' && - command.replacementSessionId && - !clearedFrom.has(command.replacementSessionId) - ) { - clearedFrom.set(command.replacementSessionId, record.sessionId) - } - } - const clearedTo = new Set(clearedFrom.values()) - const chainRoot = (sessionId: string): string => { - const seen = new Set([sessionId]) - let current = sessionId - let prior = clearedFrom.get(current) - while (prior !== undefined && !seen.has(prior)) { - seen.add(prior) - current = prior - prior = clearedFrom.get(current) - } - return current - } - const recordedOrDerived = (sessionId: string): string[] => { - const record = records.get(sessionId) - const recorded = record && 'surfaceTabId' in record ? record.surfaceTabId : undefined - return [recorded, structuredAgentSessionTabId(sessionId)].filter(isAgentSessionSurfaceTabId) - } - const tabIds = new Map() - const taken = new Set() - const held = (tabId: string): boolean => taken.has(tabId) - const assign = (sessionId: string, candidates: readonly string[]): void => { - const tabId = candidates.find((candidate) => !held(candidate)) ?? reopenedTabId(sessionId, held) - taken.add(tabId) - tabIds.set(sessionId, tabId) - } - const holdsChainTab = (sessionId: string): boolean => - !clearedTo.has(sessionId) && chainRoot(sessionId) !== sessionId - for (const sessionId of sessionIds.filter(holdsChainTab)) { - assign(sessionId, [...recordedOrDerived(chainRoot(sessionId)), ...recordedOrDerived(sessionId)]) - } - for (const sessionId of sessionIds.filter((sessionId) => !holdsChainTab(sessionId))) { - assign(sessionId, recordedOrDerived(sessionId)) - } - // In the visible list's order, which is the order older builds restored tabs in. - return new AgentSessionTabTable( - sessionIds.flatMap((sessionId) => { - const tabId = tabIds.get(sessionId) - return tabId === undefined ? [] : [[tabId, sessionId] as const] - }) - ) -} - -function parsePersistedTabs( - raw: unknown, - strict: boolean -): { valid: boolean; table: AgentSessionTabTable | null } { - if (!Array.isArray(raw)) { - return { valid: !strict, table: null } - } - const table = new AgentSessionTabTable() - for (const entry of raw) { - const tabId: unknown = entry?.tabId - const sessionId: unknown = entry?.sessionId - const wellFormed = - isAgentSessionSurfaceTabId(tabId) && - isAgentSessionId(sessionId) && - table.sessionIdFor(tabId) === undefined && - table.tabIdFor(sessionId) === undefined - if (wellFormed) { - table.show(sessionId, tabId) - } else if (strict) { - return { valid: false, table: null } - } - } - return { valid: true, table } -} diff --git a/src/main/runtime/claude-structured-retired-saved-model.test.ts b/src/main/runtime/claude-structured-retired-saved-model.test.ts new file mode 100644 index 000000000000..138a240984dc --- /dev/null +++ b/src/main/runtime/claude-structured-retired-saved-model.test.ts @@ -0,0 +1,141 @@ +// A chat's saved model rides the launch unchecked. When the CLI answers a turn by saying that model +// does not exist, the record drops it, so the next start runs the CLI's own default; the user's +// message and the CLI's own answer stay as they are. 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' +import { claudeSessionIdForOrcaSession } from '../claude/claude-structured-launch-resolution' +import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import { createScriptedClaudeRuntime } from './structured-claude-scripted-runtime-test-support' + +const SESSION = 'claude-retired-saved-model' +const CALLER = { callerKey: 'client-1' } +const RETIRED = 'claude-retired-1' + +let claude = createScriptedClaudeRuntime([SESSION]) + +afterEach(async () => { + await claude.dispose() + claude = createScriptedClaudeRuntime([SESSION]) +}) + +/** The reply Claude Code 2.1.280 sends when its `--model` names a model that does not exist. */ +function modelNotFoundReply(uuid: string, parentToolUseId: string | null = null) { + return { + type: 'assistant', + uuid, + session_id: claudeSessionIdForOrcaSession(SESSION), + parent_tool_use_id: parentToolUseId, + error: 'model_not_found', + is_api_error_message: true, + message: { + model: '', + role: 'assistant', + content: [ + { + type: 'text', + text: `There's an issue with the selected model (${RETIRED}). It may not exist or you may not have access to it.` + } + ] + } + } +} + +async function startedWith(options: Record): Promise { + claude.behave(SESSION, { sendsNoStartFrame: true }) + const host = await claude.install() + await expect( + host.attach(CALLER, claude.attachParams(SESSION, null, { options })) + ).resolves.toMatchObject({ ok: true }) + await vi.waitFor(() => + expect(host.collaboratorsForTests().sessions.get(SESSION)?.child?.phase).toBe('ready') + ) + return host +} + +describe("a Claude chat whose saved model the CLI says doesn't exist", () => { + it('drops the model from the record, keeping the rest, so the next start runs the default', async () => { + const host = await startedWith({ model: RETIRED, effort: 'high' }) + expect(claude.child(SESSION).launch.options.model).toBe(RETIRED) + expect(host.deps.store.getRecord(SESSION)?.options?.model).toBe(RETIRED) + + claude.child(SESSION).handlers.onMessage?.(modelNotFoundReply('reply-1')) + + await vi.waitFor(() => + expect(host.deps.store.getRecord(SESSION)?.options).not.toHaveProperty('model') + ) + expect(host.deps.store.getRecord(SESSION)?.options).toMatchObject({ effort: 'high' }) + }) + + it("keeps a model the reply did not come from: a subagent's own error", async () => { + const host = await startedWith({ model: RETIRED }) + + claude.child(SESSION).handlers.onMessage?.(modelNotFoundReply('reply-1', 'toolu_subagent')) + await host.flushStreamedEvents(SESSION) + + expect(host.deps.store.getRecord(SESSION)?.options?.model).toBe(RETIRED) + }) + + // The CLI's word is about the model it was launched with; a pick made since is the user's. + it('keeps a model the user picked while the heal was on its way', async () => { + const host = await startedWith({ model: RETIRED }) + const record = () => host.deps.store.getRecord(SESSION) + const changed = await host.setOption(CALLER, { + envelope: { + sessionId: SESSION, + clientOperationId: `${Date.now()}-${'1'.padStart(32, '0')}`, + expectedRuntimeFence: record()?.lease.runtimeFence ?? 0, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.setOption', + sessionId: SESSION, + fields: { key: 'model', value: 'opus' } + }) + }, + key: 'model', + value: 'opus' + }) + expect(changed, JSON.stringify(changed)).toMatchObject({ ok: true }) + expect(record()?.options?.model).toBe('opus') + + await host.handleAdapterEvent({ + type: 'options-skipped', + sessionId: SESSION, + fence: record()?.lease.runtimeFence ?? 0, + acquisitionGeneration: 'generation-heal', + options: { model: RETIRED } + }) + + 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 () => { + claude.behave(SESSION, { + sendsNoStartFrame: true, + startupSettingsReadHangs: true, + controlTimeoutMs: 300 + }) + const host = await claude.install() + 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') + ) + claude.child(SESSION).handlers.onMessage?.({ + type: 'system', + subtype: 'init', + session_id: claudeSessionIdForOrcaSession(SESSION), + model: RETIRED + }) + claude.child(SESSION).handlers.onMessage?.(modelNotFoundReply('reply-1')) + + await vi.waitFor( + () => expect(host.collaboratorsForTests().sessions.get(SESSION)?.child?.phase).toBe('ready'), + { timeout: 5_000 } + ) + expect(host.deps.store.getRecord(SESSION)?.options).not.toHaveProperty('model') + }) +}) diff --git a/src/main/runtime/claude-structured-send-held-for-startup.test.ts b/src/main/runtime/claude-structured-send-during-startup.test.ts similarity index 69% rename from src/main/runtime/claude-structured-send-held-for-startup.test.ts rename to src/main/runtime/claude-structured-send-during-startup.test.ts index efb8d3cde16a..a50615fbed4a 100644 --- a/src/main/runtime/claude-structured-send-held-for-startup.test.ts +++ b/src/main/runtime/claude-structured-send-during-startup.test.ts @@ -1,9 +1,10 @@ -// A Claude chat is published the moment its child spawns, before the CLI has answered initialize. -// 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 accepted and stays queued until the child proves its start. -// When the CLI dies first, the queued 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. +// 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. import { afterEach, describe, expect, it, vi } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' @@ -43,7 +44,7 @@ async function send(host: StructuredAgentSessionHost, text: string): Promise { - it('restarts once, rejects the held message with the diagnostic when that start dies too, then delivers once the CLI is healthy', async () => { + it('restarts once, rejects the message it wrote 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({ @@ -89,10 +90,11 @@ 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 the message waits for its start; the CLI - // dies again first. + // The delivery loop asks for the child back and writes the message to it at once; the CLI + // dies again before it answers initialize. 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') ) @@ -111,7 +113,6 @@ describe('a send into a Claude chat whose CLI keeps failing at startup', () => { // The restart moved the fence twice: its acquisition, and the exit that released it. expect(fence(host)).toBe(releasedFence + 2) expect(claude.children(SESSION)).toHaveLength(2) - expect(claude.child(SESSION).calls).not.toContain('send') // The user signs in and retries: one restart, proven, written to the CLI. claude.behave(SESSION, {}) @@ -127,29 +128,53 @@ 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 queued, and written once the CLI proves its start', async () => { + it('is written to the CLI at once, before it answers', async () => { claude.behave(SESSION, { initHangs: true }) const host = await claude.install() await host.attach(CALLER, claude.attachParams(SESSION, null)) - await send(host, 'hello') - expect(claude.child(SESSION).calls).not.toContain('send') + const sent = await send(host, 'hello') + await vi.waitFor(() => expect(claude.child(SESSION).calls).toContain('send')) + expect(host.collaboratorsForTests().sessions.get(SESSION)?.child?.phase).toBe('starting') - // The CLI answers: startup lands and the queued message is written to the proven child. + // The CLI answers: the start lands under the message already written, and nothing fails. claude.child(SESSION).answerInit() + await vi.waitFor(() => + expect(host.collaboratorsForTests().sessions.get(SESSION)?.child?.phase).toBe('ready') + ) + 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) + expect(await statusRows(host)).toEqual([]) + }) + + // 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 () => { + 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')) + claude.child(SESSION).answerInit() + + await vi.waitFor(() => + expect(host.collaboratorsForTests().sessions.get(SESSION)?.child?.phase).toBe('ready') + ) expect(claude.children(SESSION)).toHaveLength(1) expect(await statusRows(host)).toEqual([]) }) - it('is rejected with the diagnostic when the CLI dies first, and restarts nothing', async () => { + it('is rejected with the diagnostic when the CLI dies before answering, and restarts nothing', async () => { claude.behave(SESSION, { initHangs: true }) const host = await claude.install() await host.attach(CALLER, claude.attachParams(SESSION, null)) const startedFence = fence(host) const held = await send(host, 'hello') + await vi.waitFor(() => expect(claude.child(SESSION).calls).toContain('send')) await failLatestStart(host, 1) await vi.waitFor(async () => @@ -163,6 +188,5 @@ describe('a send while the first Claude start is still answering initialize', () expect(await statusRows(host)).toEqual([STARTUP_TEXT]) expect(fence(host)).toBe(startedFence + 1) expect(claude.children(SESSION)).toHaveLength(1) - expect(claude.child(SESSION).calls).not.toContain('send') }) }) 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 c5b21f9027fa..3eb815bca50e 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. -// When that child dies before it proves its start, the message was never handed to it — delivery -// waits for the start — so the send settles `rejected` with the child's own diagnostic, never as a +// 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 // 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. @@ -140,6 +140,8 @@ describe('a send whose restarted Claude child dies before it proves its start', const sent = await send(host, 'hello?') // 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 failLatestStart(host, 2) // Provably not delivered, with the cause; not "unconfirmed". @@ -154,7 +156,6 @@ describe('a send whose restarted Claude child dies before it proves its start', expect(await statusRows(host)).toEqual([STARTUP_TEXT, STARTUP_TEXT]) expect(fence(host)).toBe(releasedFence + 2) expect(claude.children(SESSION)).toHaveLength(2) - expect(claude.child(SESSION).calls).not.toContain('send') // Retry under a new id: one restart, and once the CLI is healthy the message is written. claude.behave(SESSION, {}) 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 863cbaeaebf9..65215d609966 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 @@ -1,7 +1,7 @@ -// A Claude start can fail on Orca's side while the CLI is still running: a saved option it can't -// restore, or an init frame naming another session. Orca ends that child itself, so the chat must -// not say Claude stopped on its own; only an exit Orca saw says that. Against the production -// runtime, adapter, record store and host, with only the CLI process scripted. +// A Claude start can fail on Orca's side while the CLI is still running: an init frame naming +// another session. Orca ends that child itself, so the chat must not say Claude stopped on its own; +// only an exit Orca saw says that. 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' @@ -75,7 +75,8 @@ async function released(host: StructuredAgentSessionHost): Promise { } describe('a Claude start that Orca fails while the CLI is still running', () => { - it('reads as a start that could not happen when a saved option cannot be restored', async () => { + // Saved options ride the launch, so no option write runs at startup that could fail it. + it('starts with a saved option the CLI would not take as a write', async () => { claude.behave(SESSION, { optionWritesFail: true }) const host = await claude.install() await expect( @@ -84,21 +85,23 @@ describe('a Claude start that Orca fails while the CLI is still running', () => claude.attachParams(SESSION, null, { options: { permissionMode: 'plan' } }) ) ).resolves.toMatchObject({ ok: true }) - await released(host) + await vi.waitFor(() => + expect(host.collaboratorsForTests().sessions.get(SESSION)?.child?.phase).toBe('ready') + ) - expect(claude.child(SESSION).calls).toContain('set_permission_mode') - expect(await failureRows(host)).toEqual([ - { text: expect.stringMatching(/^Claude couldn't start\./), kind: 'startFailed' } - ]) + expect(claude.child(SESSION).launch.options.permissionMode).toBe('plan') + expect(claude.child(SESSION).calls).not.toContain('set_permission_mode') + expect(await failureRows(host)).toEqual([]) }) - it('rejects a held message as a start that could not happen when init names another session', async () => { + it('rejects a message it wrote 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')) claude.child(SESSION).answerInit() await released(host) @@ -113,7 +116,6 @@ 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/claude-structured-startup-unanswered-control-request.test.ts b/src/main/runtime/claude-structured-startup-unanswered-control-request.test.ts index 56ba00618836..e431658a078e 100644 --- a/src/main/runtime/claude-structured-startup-unanswered-control-request.test.ts +++ b/src/main/runtime/claude-structured-startup-unanswered-control-request.test.ts @@ -1,10 +1,8 @@ -// A Claude start has no deadline of its own, but each option write the restore replays, and -// startup's own settings read, is a control request under the ordinary request deadline. A CLI -// that answers initialize and then never answers one of those used to fault the whole session -// when that deadline fired: a start that was merely slow died with the deadline's error as its -// cause. Now the unanswered request is skipped and startup lands on the CLI's own values, while -// the saved choice stays saved for the next start to retry. Against -// the production runtime, adapter, record store and host, with only the CLI process scripted. +// A Claude start asks the CLI one control request after initialize, its settings read, under the +// ordinary request deadline; the chat's saved options ride the launch, so no option write runs at +// startup. A CLI that answers initialize and then answers no control request still starts, on its +// launch options, and the saved choices stay saved. 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' @@ -16,6 +14,8 @@ import { createScriptedClaudeRuntime } from './structured-claude-scripted-runtim const SESSION = 'claude-startup-unanswered-control' const CALLER = { callerKey: 'client-1' } const DEADLINE_MS = 50 +/** As live: no frame before the first turn names a model, so only a turn reports one. */ +const LIVE_START = { sendsNoStartFrame: true } let claude = createScriptedClaudeRuntime([SESSION]) let operations = 0 @@ -105,8 +105,8 @@ async function sendTo( } describe('a Claude start whose CLI answers initialize but not a control request', () => { - it('lands with the unanswered option write skipped instead of faulting at the deadline, and keeps the saved choice', async () => { - claude.behave(SESSION, { optionWritesHang: true, controlTimeoutMs: DEADLINE_MS }) + it('launches with the saved choices, writes none of them, and keeps them saved', async () => { + claude.behave(SESSION, { ...LIVE_START, optionWritesHang: true, controlTimeoutMs: DEADLINE_MS }) vi.spyOn(console, 'warn').mockImplementation(() => {}) const host = await claude.install() const saved = { model: 'sonnet', permissionMode: 'plan' } @@ -114,28 +114,29 @@ describe('a Claude start whose CLI answers initialize but not a control request' host.attach(CALLER, claude.attachParams(SESSION, null, { options: saved })) ).resolves.toMatchObject({ ok: true }) - // The restore asked; the CLI never answered; startup went on without it. - await vi.waitFor(() => expect(claude.child(SESSION).calls).toContain('set_model')) - await vi.waitFor(() => expect(claude.child(SESSION).calls).toContain('set_permission_mode')) await vi.waitFor(() => expect(record(host)?.lease.claimStatus).toBe('live'), { timeout: DEADLINE_MS * 40 }) - // The live child runs on the CLI's own model; silence is not a refusal, so the saved - // choice is neither replaced by that value nor dropped, and the next start retries it. + expect(claude.child(SESSION).launch.options).toMatchObject({ + model: 'sonnet', + permissionMode: 'plan' + }) + // The record keeps the saved choices through the start. await vi.waitFor(() => expect(record(host)?.options).toEqual({ ...saved, effort: 'high' }), { timeout: DEADLINE_MS * 40 }) expect(host.deps.adapter.readOptionRestoreFailures?.(SESSION)).toEqual([]) expect(await statusRows(host)).toEqual([]) expect(claude.children(SESSION)).toHaveLength(1) + expect(claude.child(SESSION).calls).not.toContain('set_model') + expect(claude.child(SESSION).calls).not.toContain('set_permission_mode') - // The proven child takes the next message. await send(host, 'hello') await vi.waitFor(() => expect(claude.child(SESSION).calls).toContain('send')) }) - it('keeps the unanswered saved choice when the user later changes a different option', async () => { - const behavior = { optionWritesHang: true, controlTimeoutMs: DEADLINE_MS } + it('keeps the launched saved choice when the user later changes a different option', async () => { + const behavior = { ...LIVE_START, optionWritesHang: true, controlTimeoutMs: DEADLINE_MS } claude.behave(SESSION, behavior) vi.spyOn(console, 'warn').mockImplementation(() => {}) const host = await claude.install() @@ -155,10 +156,10 @@ describe('a Claude start whose CLI answers initialize but not a control request' expect(record(host)?.options).toMatchObject({ model: 'sonnet', permissionMode: 'plan' }) }) - it('replays the unanswered saved model after a turn reports another model, another option changes, and the chat is cleared', async () => { + it('launches the saved model again after a turn reports another model, another option changes, and the chat is cleared', async () => { const sessions = [SESSION] claude = createScriptedClaudeRuntime(sessions) - const behavior = { optionWritesHang: true, controlTimeoutMs: DEADLINE_MS } + const behavior = { ...LIVE_START, optionWritesHang: true, controlTimeoutMs: DEADLINE_MS } claude.behave(SESSION, behavior) vi.spyOn(console, 'warn').mockImplementation(() => {}) const host = await claude.install() @@ -205,14 +206,17 @@ describe('a Claude start whose CLI answers initialize but not a control request' }) const replacement = cleared.ok ? cleared.value.replacementSessionId! : '' sessions.push(replacement) + claude.behave(replacement, LIVE_START) // The cleared chat starts nothing until its first message. expect(claude.children(replacement)).toEqual([]) await sendTo(host, replacement, 'first message') - // Started fresh under its own derived id, and it replays the saved model rather than the one + // Started fresh under its own derived id, launched with the saved model rather than the one // the turn reported. - await vi.waitFor(() => expect(claude.child(replacement).calls).toContain('set_model')) + await vi.waitFor(() => expect(claude.children(replacement)).toHaveLength(1)) expect(claude.child(replacement).launch.options).toMatchObject({ - sessionId: claudeSessionIdForOrcaSession(replacement) + sessionId: claudeSessionIdForOrcaSession(replacement), + model: 'sonnet', + permissionMode: 'plan' }) expect(claude.child(replacement).launch.options.resume).toBeUndefined() await vi.waitFor(() => @@ -225,8 +229,8 @@ describe('a Claude start whose CLI answers initialize but not a control request' expect(record(host)?.options).toEqual({ model: 'sonnet', permissionMode: 'plan' }) }) - it('replaces the unanswered saved model with the one the user then sets', async () => { - const behavior = { optionWritesHang: true, controlTimeoutMs: DEADLINE_MS } + it('replaces the launched saved model with the one the user then sets', async () => { + const behavior = { ...LIVE_START, optionWritesHang: true, controlTimeoutMs: DEADLINE_MS } claude.behave(SESSION, behavior) vi.spyOn(console, 'warn').mockImplementation(() => {}) const host = await claude.install() @@ -234,7 +238,7 @@ describe('a Claude start whose CLI answers initialize but not a control request' host.attach(CALLER, claude.attachParams(SESSION, null, { options: { model: 'sonnet' } })) ).resolves.toMatchObject({ ok: true }) // The record holds the saved model from creation; only the start's own report (effort) says - // startup finished, and an option write before then is refused as still starting. + // startup finished, and an option write before then waits for it. await vi.waitFor( () => expect(record(host)?.options).toEqual({ model: 'sonnet', effort: 'high' }), { timeout: DEADLINE_MS * 40 } diff --git a/src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts b/src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts index 119cf3deecd0..9a247177d09b 100644 --- a/src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts +++ b/src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts @@ -279,18 +279,17 @@ export class OrcaRuntimeWithBuildHeadlessMobileSessionBrowserTabs extends OrcaRu }) } - protected emitMobileSessionTabsSnapshot( - snapshot: RuntimeMobileSessionTabsSnapshot, - metadata: Pick = {} - ): void { + protected emitMobileSessionTabsSnapshot(snapshot: RuntimeMobileSessionTabsSnapshot): void { if (this.mobileSessionTabListeners.size === 0) { return } - const result = { ...this.toMobileSessionTabsResult(snapshot), ...metadata } - const sequence = ++this.mobileSessionTabsChangeSequence + const result = this.toMobileSessionTabsResult(snapshot) + const changeSequence = ++this.mobileSessionTabsChangeSequence for (const subscription of this.mobileSessionTabListeners) { - const navigationId = subscription.clientNavigationId - subscription.listener(this.projectMobileSessionTabsForClient(result, navigationId), sequence) + subscription.listener( + this.projectMobileSessionTabsForClient(result, subscription.clientNavigationId), + changeSequence + ) } } diff --git a/src/main/runtime/orca-runtime-create-terminal-dependencies.ts b/src/main/runtime/orca-runtime-create-terminal-dependencies.ts index 9175fa8dec57..b01e6f7f9a00 100644 --- a/src/main/runtime/orca-runtime-create-terminal-dependencies.ts +++ b/src/main/runtime/orca-runtime-create-terminal-dependencies.ts @@ -27,6 +27,7 @@ export { getTerminalViewerColors, setPairedViewerColors } from './terminal-view- export { normalizeColorQueryReplyColors } from '../../shared/pty-owner-color-query-colors' export type { RuntimePtyController } from './runtime-pty-controller-contract' export { getRuntimeDesktopSurface } from './runtime-desktop-surface' +export { runtimeNewTabPlacement } from './runtime-terminal-spawn-placement' export type { IpcMainEvent } from 'electron' // Why initiallyHidden: no renderer pane exists yet, so main must answer startup queries — Muse diff --git a/src/main/runtime/orca-runtime-create-terminal.ts b/src/main/runtime/orca-runtime-create-terminal.ts index eba6d03a29a7..8c4ed9ac7bf3 100644 --- a/src/main/runtime/orca-runtime-create-terminal.ts +++ b/src/main/runtime/orca-runtime-create-terminal.ts @@ -169,6 +169,7 @@ export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreate ? { onPtySpawnCommitted: reportPtySpawnCommitted } : {}), ...(adoptedBeforeLaunch ? { adoptedStablePane: adoptedBeforeLaunch } : {}), + placement: dependencies.runtimeNewTabPlacement(), ...(launchOpts.sessionId ? { sessionId: launchOpts.sessionId } : {}), ...(!adoptedBeforeLaunch && launchOpts.isNewSession ? { isNewSession: true } : {}), ...dependencies.BACKGROUND_TERMINAL_SPAWN_FLAGS diff --git a/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts b/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts index 2e52e6f07483..74301c16b013 100644 --- a/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts +++ b/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts @@ -15,6 +15,7 @@ import type { ProjectExecutionRuntimeResolution } from '../../shared/project-exe import { resolveLocalProjectRuntimeForWorktreeId } from '../local-project-runtime-resolution' import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' import { + localOrchestrationCliCommand, resolveTerminalOrchestrationCliCommand, runtimeOrchestrationCliCommand, type OrchestrationCliCommand @@ -22,6 +23,8 @@ import { import type { FleetAgentStatusEvidence } from '../../shared/orchestration-fleet-agent-status-evidence' import { readOrchestrationFleetAgentStatusSnapshot } from './orchestration-fleet-agent-status-snapshot' import { resolveStructuredWorkerAuthority } from './structured-worker-authority' +import { isStructuredWorkerHandle } from './structured-worker-identity' +import { parseOrcaSessionAddress } from '../../shared/orca-session-address' import { matchesProcessIncarnation } from './orchestration/worker-terminal-process-liveness' export class OrcaRuntimeWithGetOrchestrationDispatchAuthority extends OrcaRuntimeWithVerifyOrchestrationCompatibilityCaller { @@ -250,6 +253,10 @@ export class OrcaRuntimeWithGetOrchestrationDispatchAuthority extends OrcaRuntim } getTerminalOrchestrationCliCommand(handle: string): OrchestrationCliCommand { + // A structured session runs in this process: it is told what the structured mail lane types. + if (isStructuredWorkerHandle(handle) || parseOrcaSessionAddress(handle)) { + return localOrchestrationCliCommand() + } let pty: RuntimePtyWorktreeRecord | null = null try { const ptyId = this.resolveLeafForHandle(handle)?.ptyId 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 730b46346e26..f5ad8fa45b82 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 @@ -10,11 +10,14 @@ import { } from './structured-worker-authority' import type { StructuredPointerTarget } from './orchestration/structured-mailbox-pointer-delivery' import { + chatDispatchMailTarget, handleLessCoordinatorSessionId, structuredSessionAddressTarget, structuredSessionMailTarget, structuredSessionIdleEdgeMailboxes } from './orchestration/structured-session-mail-target' +import { exitedChatDispatchesForSession } from './orchestration/chat-assignee' +import { OPERATOR_CLOSE_EXIT_CAUSE } from '../../shared/terminal-exit-cause' import { resolveTerminalIdentityFromProbes, type RuntimeTerminalIdentity @@ -221,6 +224,24 @@ export class OrcaRuntimeWithGetPtyRecordForPaneKey extends OrcaRuntimeWithPruneM } } + /** + * A chat tab left the screen. If that chat worked a Dispatch and has now ended, the Dispatch + * settles as a terminal the operator closed does; a /clear moves the tab, so its successor keeps + * the work. + */ + onStructuredSessionTabHidden(sessionId: string): void { + try { + const db = this.getExistingOrchestrationDb() + for (const dispatch of db ? exitedChatDispatchesForSession(sessionId, db) : []) { + this.failActiveDispatchOnExit(dispatch.assignee_handle, null, 0, OPERATOR_CLOSE_EXIT_CAUSE) + } + } catch (error) { + console.warn('[orchestration] settling a closed chat worker failed', { + error: error instanceof Error ? error.message : String(error) + }) + } + } + /** Settlement drops anything parked for the session; nothing will ever redrive it again. */ forgetStructuredSessionMail(sessionId: string): void { this.orchestrationStructuredMailboxPointerDelivery.forgetSession(sessionId) @@ -247,10 +268,15 @@ export class OrcaRuntimeWithGetPtyRecordForPaneKey extends OrcaRuntimeWithPruneM return this.resolveStructuredWorkerDirectMailboxTarget(mailboxHandle) } const dispatchId = mailboxHandle.slice('dispatch:'.length) - const assignee = this._orchestrationDb?.getDispatchContextById?.(dispatchId)?.assignee_handle + const dispatch = this._orchestrationDb?.getDispatchContextById?.(dispatchId) + const assignee = dispatch?.assignee_handle if (!assignee) { return null } + const chat = chatDispatchMailTarget(dispatch, this._orchestrationDb) + if (chat !== undefined) { + return chat + } const sessionId = this.liveStructuredWorkerSessionId(assignee) return sessionId ? { sessionId, dispatchId } : null } diff --git a/src/main/runtime/orca-runtime-get-worktree-ps.ts b/src/main/runtime/orca-runtime-get-worktree-ps.ts index 59758f9ba2fe..174fcae70857 100644 --- a/src/main/runtime/orca-runtime-get-worktree-ps.ts +++ b/src/main/runtime/orca-runtime-get-worktree-ps.ts @@ -33,7 +33,11 @@ import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { buildWorktreeListingPage } from './worktree-listing-host-scope' import { structuredWorkerOwesWork } from './structured-worker-custody' -import { resolveTuiAgentLaunchEnv } from '../../shared/tui-agent-launch-defaults' +import { + resolvedTuiAgentArgsBypassPermissions, + resolveTuiAgentLaunchEnv +} from '../../shared/tui-agent-launch-defaults' +import { isTuiAgent } from '../../shared/tui-agent-config' import { nativeChatShellEnvironmentPolicy } from '../../shared/native-chat-shell-environment' import { claudeStructuredPermissionModeForSettings } from '../claude/claude-structured-permission-mode' import { codexStructuredPermissionPolicyForSettings } from '../codex/codex-structured-permission-policy' @@ -192,7 +196,7 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStartTuiIdleVis getAgentEnvResolvers: () => this.getCommitMessageAgentEnvironmentResolvers(), hasOpenDispatch: (record) => structuredWorkerOwesWork(this.getOrchestrationDbIfAvailable?.() ?? null, record), - onNamed: (workspaceId, sessionId) => + retitleOpenTab: (workspaceId, sessionId) => this.refreshStructuredConversationTabTitle(workspaceId, sessionId) }, logger @@ -230,6 +234,17 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStartTuiIdleVis claudeStructuredPermissionModeForSettings(this.requireStore().getSettings()), resolveCodexPermissionPolicy: () => codexStructuredPermissionPolicyForSettings(this.requireStore().getSettings()), + resolveAgentFullAccess: (agent) => + isTuiAgent(agent) && + resolvedTuiAgentArgsBypassPermissions( + agent, + this.requireStore().getSettings(), + process.platform + ), + resolveAgentLaunchEnv: (agent) => + isTuiAgent(agent) + ? resolveTuiAgentLaunchEnv(agent, this.requireStore().getSettings().agentDefaultEnv) + : {}, // Same gate and same settings as agentSession.createSupport, re-read on every acquisition. getClaudeManagedAccountGateSettings: () => this.requireStore().getSettings(), resolveAgentAccountHome: (agent) => this.resolveStructuredAgentAccountHome(agent), @@ -245,6 +260,8 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStartTuiIdleVis ) }, ...(this.structuredAgentStatusSinkFn ? { statusSink: this.structuredAgentStatusSinkFn } : {}), + // A closed chat settles the Dispatch it was working, as a closed terminal does. + onSessionTabHidden: (sessionId) => this.onStructuredSessionTabHidden(sessionId), attentionDelivery: createStructuredAttentionMobileDelivery({ readNotificationSettings: () => this.requireStore().getSettings().notifications, readWorkspaceLabels: (scope) => diff --git a/src/main/runtime/orca-runtime-hydrate-headless-mobile-session-tabs-from-workspace-session.ts b/src/main/runtime/orca-runtime-hydrate-headless-mobile-session-tabs-from-workspace-session.ts index ec310d74731a..49fdc91573a5 100644 --- a/src/main/runtime/orca-runtime-hydrate-headless-mobile-session-tabs-from-workspace-session.ts +++ b/src/main/runtime/orca-runtime-hydrate-headless-mobile-session-tabs-from-workspace-session.ts @@ -41,7 +41,8 @@ export class OrcaRuntimeWithHydrateHeadlessMobileSessionTabsFromWorkspaceSession // Why: report which worktrees were reconciled in place so callers don't // reconcile them a second time (see notifyMobileSessionTabsChanged). const reconciledWorktreeIds = new Set() - if (this.getAvailableAuthoritativeWindow() && options.allowAttachedWindow !== true) { + const hasAuthoritativeWindow = Boolean(this.getAvailableAuthoritativeWindow()) + if (hasAuthoritativeWindow && options.allowAttachedWindow !== true) { return reconciledWorktreeIds } const session = @@ -117,13 +118,17 @@ export class OrcaRuntimeWithHydrateHeadlessMobileSessionTabsFromWorkspaceSession reconciledWorktreeIds.add(entryWorktreeId) continue } + // Why: windowless, nothing publishes the other tabs, and a filtered seed would make the full pass skip this worktree. + const runtimeOwnedOnly = + options.onlyRuntimeOwnedTerminals === true && + (existing !== undefined || hasAuthoritativeWindow) const terminalTabs = buildHeadlessMobileSessionTerminalTabs( entryWorktreeId, persistedTabs, session ).filter( (tab) => - options.onlyRuntimeOwnedTerminals !== true || + !runtimeOwnedOnly || this.hasServeOrSshOwnedBinding(tab) || this.hasRecentExpiredSshLeasePane(entryWorktreeId, tab) ) @@ -132,10 +137,9 @@ export class OrcaRuntimeWithHydrateHeadlessMobileSessionTabsFromWorkspaceSession // filter, which is about terminal PTY ownership and never applies to browsers. const browserTabs = this.buildHeadlessMobileSessionBrowserTabs(entryWorktreeId) // Why not in the runtime-owned pass: that merges into a renderer's publication, which owns its editors. - const editorTabs = - options.onlyRuntimeOwnedTerminals === true - ? [] - : buildHeadlessMobileSessionEditorTabs(entryWorktreeId, session) + const editorTabs = runtimeOwnedOnly + ? [] + : buildHeadlessMobileSessionEditorTabs(entryWorktreeId, session) const tabs: RuntimeMobileSessionSnapshotTab[] = [ ...terminalTabs, ...editorTabs, @@ -174,9 +178,7 @@ export class OrcaRuntimeWithHydrateHeadlessMobileSessionTabsFromWorkspaceSession const persistedGroups = session.tabGroups?.[entryWorktreeId] const persistedLayout = session.tabGroupLayouts?.[entryWorktreeId] const hasPersistedSplit = - options.onlyRuntimeOwnedTerminals !== true && - persistedGroups !== undefined && - persistedGroups.length > 1 + !runtimeOwnedOnly && persistedGroups !== undefined && persistedGroups.length > 1 const activeTopLevelId = mergedActiveTab ? mergedActiveTab.type === 'terminal' ? mergedActiveTab.parentTabId diff --git a/src/main/runtime/orca-runtime-prune-mobile-session-tab-group-layout.ts b/src/main/runtime/orca-runtime-prune-mobile-session-tab-group-layout.ts index be5216050d50..a1a4f820a486 100644 --- a/src/main/runtime/orca-runtime-prune-mobile-session-tab-group-layout.ts +++ b/src/main/runtime/orca-runtime-prune-mobile-session-tab-group-layout.ts @@ -22,7 +22,6 @@ import type { } from '../../shared/agent-status-types' import { buildRuntimeMobileAgentStatus } from './runtime-mobile-agent-status-builder' import { FIRST_PANE_ID } from '../../shared/pane-key' -import { defaultAgentChatLabel } from '../../shared/agent-session-chat-label' import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../shared/stable-pane-id' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import { replaceConversationInSnapshot } from './structured-conversation-tab-replacement' @@ -100,23 +99,10 @@ export class OrcaRuntimeWithPruneMobileSessionTabGroupLayout extends OrcaRuntime } const record = getStructuredAgentSessionHost()?.deps?.store?.getRecord(sessionId) const name = record?.location.workspaceId === workspaceId ? record.conversationName : null - const next = retitleStructuredConversationTab(snapshot, sessionId, name) ?? { - ...snapshot, - snapshotVersion: snapshot.snapshotVersion + 1 + const next = retitleStructuredConversationTab(snapshot, sessionId, name) + if (next) { + this.emitMobileSessionTabsSnapshot(this.storeMobileSessionSnapshot(workspaceId, next)) } - const title = - record?.location.workspaceId === workspaceId && - (record.provider === 'claude' || record.provider === 'codex') - ? { - sessionId, - agent: record.provider, - title: name ?? defaultAgentChatLabel(record.provider) - } - : undefined - this.emitMobileSessionTabsSnapshot( - this.storeMobileSessionSnapshot(workspaceId, next), - title ? { structuredConversationTitle: title } : {} - ) } protected getMobileSessionProjectionHost(): RuntimeMobileSessionProjectionHost { diff --git a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts index a4a2e6e71a67..7872e4f23da8 100644 --- a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts +++ b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts @@ -61,11 +61,7 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu const profileIds = collectSavedStructuredAgentSessionIds( this.store?.getWorkspaceSession?.(LOCAL_EXECUTION_HOST_ID) ?? null ) - // Unrecorded, the profile's chats join the tabs chats opened while the import was owed left. - // First: after a /clear the profile's chat would take their tab id, so seeds hit tabIdTaken. - const targets = persistedVisibleIndex.present - ? persistedVisibleIndex.sessionIds - : [...new Set([...persistedVisibleIndex.sessionIds, ...profileIds])] + const targets = persistedVisibleIndex.present ? persistedVisibleIndex.sessionIds : profileIds await host?.restoreReadableSessions(targets) for (const worktreeId of this.getKnownWorkspaceSessionWorktreeIds()) { this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId, { @@ -95,12 +91,9 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu this.projectStructuredAgentSessionTab({ ...session, activate: false, notify: false }) } const wasUnverifiable = this.structuredAgentSessionInventoryUnverifiable - // No host, or one still owed the records file's chats, means no one can say which chats exist; - // with none on disk, empty is the answer. - const importOwed = - typeof host?.legacyRecordImportOwed === 'function' && host.legacyRecordImportOwed() + // No host means no one can say which chats exist; with none on disk, empty is the answer. this.structuredAgentSessionInventoryUnverifiable = - (!host || importOwed) && this.hasPersistedStructuredAgentSessionStore() + !host && this.hasPersistedStructuredAgentSessionStore() // This restore published quietly; subscribers still hold the frames that said "cannot tell". if (wasUnverifiable && !this.structuredAgentSessionInventoryUnverifiable) { this.notifyMobileSessionTabSnapshots() diff --git a/src/main/runtime/orca-runtime-split-pty-backed-terminal.ts b/src/main/runtime/orca-runtime-split-pty-backed-terminal.ts index 6584a35ebff1..de9890862abd 100644 --- a/src/main/runtime/orca-runtime-split-pty-backed-terminal.ts +++ b/src/main/runtime/orca-runtime-split-pty-backed-terminal.ts @@ -6,6 +6,7 @@ import type { RuntimeTerminalSplit } from '../../shared/runtime-types' import { makePaneKey, parsePaneKey } from '../../shared/stable-pane-id' import { recordPtySurface, spawnSurfaceClaimSequence } from './pty-recorded-surface-topology' import { randomUUID } from 'node:crypto' +import { runtimeSplitPlacement } from './runtime-terminal-spawn-placement' import { REJECTED_SPLIT_PTY_STOP_TIMEOUT_MS, ownerSurfacing } from './orca-runtime-core' import type { Worktree } from '../../shared/worktree/types' @@ -69,6 +70,7 @@ export class OrcaRuntimeWithSplitPtyBackedTerminal extends OrcaRuntimeWithSplitT tabId: parentTabId, leafId, persistHostSessionBinding: true, + placement: runtimeSplitPlacement(parsedPaneKey.leafId, direction), ...(sourceAuthority.persisted ? { expectedSourceBinding: { diff --git a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts index cfb9f26e4ad5..a8231eda03ee 100644 --- a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts +++ b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts @@ -4,6 +4,7 @@ import { createStructuredMailboxPointerHost } from './orchestration/structured-m import { localOrchestrationCliCommand } from './orchestration/cli-command' import { RuntimeOrchestrationSenderNames } from './runtime-orchestration-sender-names' import { isStructuredWorkerHandle } from './structured-worker-identity' +import { ORCA_SESSION_ADDRESS_PREFIX } from '../../shared/orca-session-address' import { resolveStructuredWorkerAuthority } from './structured-worker-authority' import { OrcaRuntimeWithRuntimeId } from './orca-runtime-runtime-id' import { RuntimeTerminalAgentPresence } from './runtime-terminal-agent-presence' @@ -224,7 +225,8 @@ export class OrcaRuntimeWithStopRequestedPtyIds extends OrcaRuntimeWithRuntimeId getDb: () => this._orchestrationDb, getTerminalHandleForPaneKey: (paneKey) => this.getTerminalHandleForPaneKey(paneKey), hasTerminalHandle: (handle) => this.handles.has(handle), - isStructuredWorkerHandle: (handle) => isStructuredWorkerHandle(handle), + isStructuredSessionOwner: (handle) => + isStructuredWorkerHandle(handle) || handle.startsWith(ORCA_SESSION_ADDRESS_PREFIX), canProbePtyLiveness: () => Boolean(this.ptyController?.probePtyLiveness), controllerKnowsPtyIsLive: (ptyId) => this.controllerKnowsPtyIsLive(ptyId), isLeafPtyProvenAbsent: (ptyId) => this.isLeafPtyProvenAbsent(ptyId) diff --git a/src/main/runtime/orca-runtime-structured-agent-registration-seam.test.ts b/src/main/runtime/orca-runtime-structured-agent-registration-seam.test.ts index 99e4670ead35..6e313e111af6 100644 --- a/src/main/runtime/orca-runtime-structured-agent-registration-seam.test.ts +++ b/src/main/runtime/orca-runtime-structured-agent-registration-seam.test.ts @@ -48,12 +48,14 @@ describe('the structured agent registration list', () => { it('refuses an agent it does not hold without installing the host', async () => { const { runtime, installHost } = runtimeAt() - - expect(await runtime.getStructuredAgentSessionCreateSupport('id:workspace-1', 'grok')).toEqual({ + // No structured registration exists for Cursor. + expect( + await runtime.getStructuredAgentSessionCreateSupport('id:workspace-1', 'cursor') + ).toEqual({ supported: false, reason: 'agent' }) - await expect(runtime.resolveStructuredAgentAccountHome('grok')).rejects.toMatchObject({ + await expect(runtime.resolveStructuredAgentAccountHome('cursor')).rejects.toMatchObject({ message: 'structured_agent_session_unsupported' }) expect(installHost).not.toHaveBeenCalled() diff --git a/src/main/runtime/orca-runtime-structured-conversation-title-publication.test.ts b/src/main/runtime/orca-runtime-structured-conversation-title-publication.test.ts deleted file mode 100644 index f2e3eda480c9..000000000000 --- a/src/main/runtime/orca-runtime-structured-conversation-title-publication.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { AgentSessionRecord } from '../../shared/agent-session-record' -import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' -import type { RuntimeMobileSessionTabsResult } from '../../shared/runtime-types' -import { createStructuredChatNamingHandler } from '../native-chat/structured-chat-naming' -import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' -import { setAgentSessionRecordConversationName } from './agent-session-record-conversation-name' -import { OrcaRuntimeService } from './orca-runtime' - -afterEach(() => setStructuredAgentSessionHost(null)) - -class NamingPublicationRuntime extends OrcaRuntimeService { - getStoredSnapshot(workspaceId: string) { - return this.mobileSessionTabsByWorktree.get(workspaceId) - } -} - -async function settle(): Promise { - for (let index = 0; index < 12; index++) { - await Promise.resolve() - } -} - -describe.each(['claude', 'codex'] as const)('saved %s title publication', (provider) => { - it('publishes a saved record name after the chat closes without persisting the notification', async () => { - const runtime = new NamingPublicationRuntime() - const workspaceId = 'folder:workspace' - const sessionId = 'native-session' - runtime.replaceStructuredAgentSessionTab({ - sourceSessionId: 'source', - sessionId, - workspaceId, - agent: provider - }) - let record: AgentSessionRecord = { - ...agentSessionRecordFixture(), - sessionId, - provider, - location: { executionHostId: 'local', wslDistro: null, workspaceId, workspaceKind: 'folder' } - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this runtime path reads only the record getter and replacement list from the host stub. - setStructuredAgentSessionHost({ - deps: { store: { getRecord: () => record } }, - conversationReplacements: () => [] - } as never) - const events: RuntimeMobileSessionTabsResult[] = [] - const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot)) - let finish: (name: string) => void = () => { - throw new Error('Generation was not initialized') - } - const generation = new Promise((resolve) => { - finish = resolve - }) - const handler = createStructuredChatNamingHandler({ - getStore: () => ({ - getRecord: () => record, - compareAndSetConversationName: vi.fn(async (_id, name) => { - record = setAgentSessionRecordConversationName(record, name, Date.now()) - return record - }) - }), - getSettings: () => ({}), - readFirstPrompt: async () => 'Explain the code', - hasOpenDispatch: () => false, - generate: () => generation, - onNamed: (workspace, id) => runtime.refreshStructuredConversationTabTitle(workspace, id), - logger: { warn: vi.fn(), error: vi.fn() } - }) - handler( - { - sessionId, - workspaceId, - agent: provider, - status: 'working', - latestPrompt: 'Explain the code', - updatedAt: Date.now() - }, - { replay: false } - ) - await settle() - runtime.retireStructuredAgentSessionTabFromSnapshot(sessionId) - const closed = await runtime.listMobileSessionTabs(`id:${workspaceId}`) - expect(closed.tabs).toHaveLength(0) - finish('Explain the parser') - await settle() - const named = events.at(-1) - expect(record.conversationName).toBe('Explain the parser') - expect(named?.structuredConversationTitle).toEqual({ - sessionId, - agent: provider, - title: 'Explain the parser' - }) - expect(named?.snapshotVersion).toBeGreaterThan(closed.snapshotVersion) - expect(named?.tabs).toHaveLength(0) - const stored = runtime.getStoredSnapshot(workspaceId) - expect(stored).toBeDefined() - expect(stored).not.toHaveProperty('structuredConversationTitle') - expect( - (await runtime.listMobileSessionTabs(`id:${workspaceId}`)).structuredConversationTitle - ).toBeUndefined() - unsubscribe() - }) -}) diff --git a/src/main/runtime/orca-runtime-structured-launch-settings-wiring.test.ts b/src/main/runtime/orca-runtime-structured-launch-settings-wiring.test.ts index e9738615b5f8..dd81e303b41d 100644 --- a/src/main/runtime/orca-runtime-structured-launch-settings-wiring.test.ts +++ b/src/main/runtime/orca-runtime-structured-launch-settings-wiring.test.ts @@ -37,9 +37,13 @@ describe('execution host structured launch settings wiring', () => { settings.agentDefaultArgs = { claude: '--model second', codex: '' } expect(await deps?.resolveLaunchArgs?.('claude')).toEqual(['--model', 'second']) expect(await deps?.resolveLaunchArgs?.('codex')).toEqual([]) + const notRunnable = expect.objectContaining({ reason: 'agentCommandNotRunnable' }) settings.agentCmdOverrides = { claude: 'wrapper --arg' } - expect(deps?.resolveClaudeCommand?.()).toBe(resolveCliCommand('claude')) + expect(() => deps?.resolveClaudeCommand?.()).toThrow(notRunnable) settings.agentCmdOverrides = { codex: '/missing/codex' } + expect(() => deps?.resolveCodexCommand?.()).toThrow(notRunnable) + settings.agentCmdOverrides = {} + expect(deps?.resolveClaudeCommand?.()).toBe(resolveCliCommand('claude')) expect(deps?.resolveCodexCommand?.()).toBe(resolveCliCommand('codex')) }) }) diff --git a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-cold-serve-hydrate.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-cold-serve-hydrate.spec.ts new file mode 100644 index 000000000000..81cc968e1848 --- /dev/null +++ b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-cold-serve-hydrate.spec.ts @@ -0,0 +1,132 @@ +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from '../orca-runtime-test-mocks.spec' +import type { WorkspaceSessionState } from '../orca-runtime-test-mocks.spec' +import { + HEADLESS_LEAF_ID, + HEADLESS_SECOND_LEAF_ID, + TEST_WORKTREE_ID, + makeHeadlessTerminalLayout, + makeRuntimeStoreWithWorkspaceSession, + makeWorkspaceSessionWithHeadlessTerminal +} from '../orca-runtime-test-fixtures.spec' + +const SERVE_PTY_ID = 'serve-mobile-created-pty' +const DAEMON_PTY_ID = 'daemon-agent-launch-pty' + +function makeTerminalTab(id: string, ptyId: string, sortOrder: number) { + return { + id, + ptyId, + worktreeId: TEST_WORKTREE_ID, + title: id, + customTitle: null, + color: null, + sortOrder, + createdAt: sortOrder + 1 + } +} + +// A serve host's persisted session after restart: one phone-created (serve-) terminal and one +// launched through terminal.create (daemon id), with nothing rebuilt in memory yet. +function makeRestartedServeRuntime(overrides: Partial = {}) { + const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession( + makeWorkspaceSessionWithHeadlessTerminal({ + activeTabId: 'serve-tab', + activeTabIdByWorktree: { [TEST_WORKTREE_ID]: 'serve-tab' }, + tabsByWorktree: { + [TEST_WORKTREE_ID]: [ + makeTerminalTab('serve-tab', SERVE_PTY_ID, 0), + makeTerminalTab('daemon-tab', DAEMON_PTY_ID, 1) + ] + }, + terminalLayoutsByTabId: { + 'serve-tab': makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: SERVE_PTY_ID }), + 'daemon-tab': makeHeadlessTerminalLayout({ [HEADLESS_SECOND_LEAF_ID]: DAEMON_PTY_ID }) + }, + ...overrides + }) + ) + const runtime = new OrcaRuntimeService(runtimeStore) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'unexpected-pty' }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [] + }) + return runtime +} + +function terminalPtyIds(tabs: readonly { type: string; ptyId?: string | null }[]): string[] { + return tabs.flatMap((tab) => (tab.type === 'terminal' && tab.ptyId ? [tab.ptyId] : [])).sort() +} + +describe('OrcaRuntimeService', () => { + it('lists every persisted terminal of a windowless host after a cold restart', async () => { + const runtime = makeRestartedServeRuntime() + + const listed = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + + expect(terminalPtyIds(listed.tabs)).toEqual([DAEMON_PTY_ID, SERVE_PTY_ID].sort()) + }) + + it('lists every persisted terminal in the all-worktrees inventory after a cold restart', async () => { + const runtime = makeRestartedServeRuntime() + + const snapshots = await runtime.listAllMobileSessionTabs() + const listed = snapshots.find((snapshot) => snapshot.worktree === TEST_WORKTREE_ID) + + expect(terminalPtyIds(listed?.tabs ?? [])).toEqual([DAEMON_PTY_ID, SERVE_PTY_ID].sort()) + }) + + it('lists persisted editor tabs beside the terminals after a cold restart', async () => { + const runtime = makeRestartedServeRuntime({ + openFilesByWorktree: { + [TEST_WORKTREE_ID]: [ + { + filePath: '/repo/README.md', + relativePath: 'README.md', + worktreeId: TEST_WORKTREE_ID, + language: 'markdown', + runtimeEnvironmentId: null + } + ] + } + }) + + const listed = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + + expect(terminalPtyIds(listed.tabs)).toEqual([DAEMON_PTY_ID, SERVE_PTY_ID].sort()) + expect(listed.tabs.filter((tab) => tab.type === 'markdown').map((tab) => tab.id)).toEqual([ + '/repo/README.md' + ]) + }) + + it('restores a persisted split group layout on the cold rebuild', async () => { + const runtime = makeRestartedServeRuntime({ + tabGroups: { + [TEST_WORKTREE_ID]: [ + { + id: 'group-left', + worktreeId: TEST_WORKTREE_ID, + activeTabId: 'serve-tab', + tabOrder: ['serve-tab'] + }, + { + id: 'group-right', + worktreeId: TEST_WORKTREE_ID, + activeTabId: 'daemon-tab', + tabOrder: ['daemon-tab'] + } + ] + } + }) + + const listed = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + + expect(listed.tabGroups?.map((group) => group.tabOrder)).toEqual([ + ['serve-tab'], + ['daemon-tab'] + ]) + }) +}) diff --git a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-04.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-04.spec.ts index 3b3a61546d97..bd1c09dded0e 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-04.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-04.spec.ts @@ -244,7 +244,8 @@ describe('OrcaRuntimeService', () => { expect(spawn).toHaveBeenCalledWith( expect.objectContaining({ worktreeId: TEST_WORKTREE_ID, - persistHostSessionBinding: true + persistHostSessionBinding: true, + placement: { kind: 'new-tab' } }) ) }) diff --git a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-05.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-05.spec.ts index 2aba44299f2f..4318cc1e2343 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-05.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-05.spec.ts @@ -339,6 +339,11 @@ describe('OrcaRuntimeService', () => { await expect(split).rejects.toThrow('terminal_split_source_not_found') expect(spawn.mock.calls[0]?.[0]).toMatchObject({ persistHostSessionBinding: true, + placement: { + kind: 'split', + parentLeafId: HEADLESS_LEAF_ID, + direction: 'vertical' + }, expectedSourceBinding: { worktreeId: TEST_WORKTREE_ID, tabId, diff --git a/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-03.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-03.spec.ts index ee2f98dced5f..8c3724365884 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-03.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-03.spec.ts @@ -248,6 +248,81 @@ describe('OrcaRuntimeService', () => { expect(spawn).toHaveBeenCalledTimes(1) }) + it('skips with a warning a setup hook only the new branch adds when an ask repo got no decision', async () => { + const metaById: Record = {} + const runtimeStore = { + ...store, + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], + setWorktreeMeta: (worktreeId: string, meta: Partial) => { + metaById[worktreeId] = { ...(metaById[worktreeId] ?? makeWorktreeMeta()), ...meta } + return metaById[worktreeId] + } + } + const runtime = new OrcaRuntimeService(runtimeStore as never) + const worktreesChanged = vi.fn() + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-branch-added-setup' }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.setNotifier({ + worktreesChanged, + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + revealTerminalSession: vi.fn().mockResolvedValue({ tabId: 'tab-branch-added-setup' }), + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + runtime.attachWindow(1) + + computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-branch-added-setup') + ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-branch-added-setup') + // The main checkout has no setup hook; only the new worktree's orca.yaml does. + vi.mocked(getEffectiveHooks).mockImplementation((_repo, worktreePath) => + worktreePath ? { scripts: { setup: 'pnpm worktree:setup' } } : null + ) + // An `ask` repo: an undecided create throws, as the real policy check does. + vi.mocked(shouldRunSetupForCreate).mockImplementation((_repo, decision) => { + if (decision === 'run' || decision === 'skip') { + return decision === 'run' + } + throw new Error('Setup decision required for this repository') + }) + vi.mocked(listWorktrees).mockResolvedValue([ + { + path: '/tmp/workspaces/runtime-branch-added-setup', + head: 'def', + branch: 'runtime-branch-added-setup', + isBare: false, + isMainWorktree: false + } + ]) + + const result = await runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'runtime-branch-added-setup', + setupDecision: 'inherit', + awaitTerminalProvisioning: true + }) + + expect(worktreesChanged).toHaveBeenCalledWith(TEST_REPO_ID) + expect(result.setupReceipt).toMatchObject({ + requested: 'inherit', + hookFound: true, + state: 'skipped' + }) + expect(result.warning).toContain('orca.yaml setup hook skipped') + expect(createSetupRunnerScript).not.toHaveBeenCalled() + }) + it('materializes default tabs for inactive local managed worktree creates', async () => { const metaById: Record = {} const runtimeStore = { diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 3a94e0d66fc8..a6ddef6650d1 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -82,6 +82,7 @@ await import('./orca-runtime-tests/mobile-session-tabs-part-11.spec') await import('./orca-runtime-tests/mobile-session-tabs-part-12.spec') await import('./orca-runtime-tests/mobile-session-tabs-part-13.spec') await import('./orca-runtime-tests/mobile-session-tabs-part-14.spec') +await import('./orca-runtime-tests/mobile-session-tabs-cold-serve-hydrate.spec') await import('./orca-runtime-tests/exit-retirement-activation.spec') await import('./orca-runtime-tests/mobile-creation-and-orchestration.spec') await import('./orca-runtime-tests/mobile-creation-and-orchestration-part-02.spec') diff --git a/src/main/runtime/orchestration/__snapshots__/preamble.test.ts.snap b/src/main/runtime/orchestration/__snapshots__/preamble.test.ts.snap index dabbaf462ecb..6815b5ecc7f4 100644 --- a/src/main/runtime/orchestration/__snapshots__/preamble.test.ts.snap +++ b/src/main/runtime/orchestration/__snapshots__/preamble.test.ts.snap @@ -1,5 +1,90 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[`buildDispatchPreamble > renders a stable snapshot of a chat worker preamble 1`] = ` +"You are working inside Orca, a multi-agent IDE. You are a dispatched worker. +Your coordinator's Orca session ID is: orca_session_id:COORD +Your task ID is: task_SNAP +Your Orca session ID is: orca_session_id:WORKER + +The coordinator cannot see this chat, so reach it with the \`orca orchestration\` +commands below; a question or result left only in this chat never gets to it. +Don't post to Slack, GitHub, or other channels during the run; report through these commands. + +=== CLI COMMANDS === + +\`\`\`sh + # Report the task outcome (required, exactly once). + # + # --body must be a 3-sentence executive summary (what you did, + # what you found, what's left). Never send an empty body; the coordinator + # reads the body first and only opens artifacts if it needs more detail. + # Append --files-modified only when files changed, and append --report-path + # only when you produced a durable report. Always pass real values; do not + # send the example placeholders literally. + # + # Send worker_done exactly once. Use --outcome succeeded when the + # requested work is done, or replace it with --outcome failed when it is not. + # Never encode failure only in prose and never silently exit. + # Include BOTH taskId and dispatchId in the payload so a late completion + # from a failed retry cannot complete the current dispatch. + orca orchestration send --from orca_session_id:WORKER --type worker_done --subject "" --body "<3-sentence summary: what you did, what you found, what's left>" --task-id task_SNAP --dispatch-id ctx_SNAP --outcome succeeded + + # Send a heartbeat every 5 minutes + # while actively working on the task. The coordinator uses this to + # distinguish "still thinking" from "hung / crashed." Skip heartbeats only + # while blocked inside \`check --wait\` or \`ask\` — those calls are + # themselves liveness signals. + # + # Include BOTH taskId and dispatchId in the payload: the coordinator + # attributes the heartbeat to the specific dispatch context, not just + # the task, so a straggler heartbeat from a previously-failed dispatch + # cannot mask a hung retry. + orca orchestration send --from orca_session_id:WORKER --type heartbeat --subject "alive" --task-id task_SNAP --dispatch-id ctx_SNAP --phase "" + + # Ask the coordinator a question and block until it answers. + # + # Use this instead of AskUserQuestion: that opens a local prompt the + # coordinator cannot see or answer, so the task would stall until someone + # happened to look at this chat. Send every question through \`ask\`. + # + # The \`ask\` verb durably records a question in this Dispatch's Run and + # blocks until the coordinator replies, then prints the reply body. If the + # call times out or disconnects, resume with the returned message ID instead + # of creating a duplicate question. + orca orchestration ask --from orca_session_id:WORKER --question "" --options "" --timeout-ms 600000 + + # Escalate a blocker or failure (pre-completion, when you need the + # coordinator to do something before you can continue): + orca orchestration send --from orca_session_id:WORKER --type escalation --subject "Blocked: " --body "
" --task-id task_SNAP --dispatch-id ctx_SNAP + + # Read coordinator follow-ups. Nothing interrupts you: a durable message only + # arrives when you look, so run this at each natural checkpoint — before you + # start a new file and after a test run — and once more immediately before + # you send worker_done, so a redirect lands before the task settles. + orca orchestration check --terminal orca_session_id:WORKER --json +\`\`\` + +=== AFTER YOU SEND worker_done === + +worker_done ends your turn for this task. Your dispatched work is complete: +stop, return to an idle prompt, and take no further actions — do NOT start +new or unrelated work, do NOT run a sleep/poll loop, and do NOT keep calling +\`orca orchestration check\`. The coordinator has already recorded your +completion and expects no further output. + +A direct instruction from the user takes precedence over this idle rule. +Treat it as new user-owned work: follow it without coordinator approval or a +fresh Dispatch, and do not send lifecycle messages using the settled task or +Dispatch IDs. Never refuse a direct user request because you were a worker. + +If the coordinator has more for you, it will send this chat a fresh +preamble + TASK block, which arrives as a new message. Treat that as supervised +work under the new Dispatch; ignore stale follow-ups from the settled task. + +=== TASK === +TASK_BODY" +`; + exports[`buildDispatchPreamble > renders a stable snapshot of the full preamble 1`] = ` "You are working inside Orca, a multi-agent IDE. You are a dispatched worker. Your coordinator's terminal handle is: term_COORD diff --git a/src/main/runtime/orchestration/agent-facing-parity.test.ts b/src/main/runtime/orchestration/agent-facing-parity.test.ts index 6aee5f6b955d..627b841e09dc 100644 --- a/src/main/runtime/orchestration/agent-facing-parity.test.ts +++ b/src/main/runtime/orchestration/agent-facing-parity.test.ts @@ -1,7 +1,8 @@ /** * A chat agent and a terminal agent run the same orchestration process: the same preamble, the - * same pointer text and the same guide. The one difference is how each is named: a terminal by its - * handle, exactly as on main, and a session by its Orca session ID. Orca absorbs everything else. + * same pointer text and the same guide. They differ in how each is named (a terminal by its handle, + * exactly as on main, a session by its Orca session ID) and in the preamble calling a chat a chat. + * Orca absorbs everything else. */ import { readdirSync, readFileSync } from 'node:fs' import { join } from 'node:path' @@ -23,7 +24,11 @@ import { import { OrchestrationDb } from './db' import { formatMessagePointer } from './formatter' import { OrchestrationStructuredMailboxPointerDelivery } from './structured-mailbox-pointer-delivery' -import { buildDispatchPreamble } from './preamble' +import { + buildDispatchPreamble, + CHAT_REDISPATCH_PARAGRAPH as CHAT_REDISPATCH, + TERMINAL_REDISPATCH_PARAGRAPH as TERMINAL_REDISPATCH +} from './preamble' import { ORCA_SESSION_ID_AS_ADDRESS } from '../../../shared/orca-session-id-wording-test-fixture' const sent = vi.hoisted((): { preambles: string[] } => ({ preambles: [] })) @@ -72,14 +77,16 @@ function runtime(prompts: string[]): OrcaRuntimeService { const fake: Pick< OrcaRuntimeService, 'getNestedWorkerMaxDepth' | 'getTerminalOrchestrationCliCommand' | 'sendTerminalAgentPrompt' - > = { - getNestedWorkerMaxDepth: () => 2, - getTerminalOrchestrationCliCommand: () => 'orca', - sendTerminalAgentPrompt: async (handle, text) => { - prompts.push(text) - return { handle, accepted: true, bytesWritten: text.length } + > & { orchestrationSenderNames: Pick } = + { + orchestrationSenderNames: { nameOf: () => null }, + getNestedWorkerMaxDepth: () => 2, + getTerminalOrchestrationCliCommand: () => 'orca', + sendTerminalAgentPrompt: async (handle, text) => { + prompts.push(text) + return { handle, accepted: true, bytesWritten: text.length } + } } - } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: preamble delivery reads only the members the fake implements. return fake as OrcaRuntimeService } @@ -101,6 +108,7 @@ async function renderPreamble(worker: 'chat' | 'terminal'): Promise { terminalHandle: worker === 'chat' ? CHAT_WORKER_HANDLE : TERMINAL_HANDLE, dispatchId: 'ctx_1', dispatchDepth: 1, + runId: 'run_1', taskId: 'task_1', taskSpec: 'do it', coordinatorHandle: 'term_coord', @@ -139,15 +147,26 @@ async function renderChatPointer(mailbox: string): Promise { return texts[0]! } -describe('a chat agent and a terminal agent see the same text but for how each is named', () => { - it("teaches a chat worker a terminal worker's preamble but for its identity lines", async () => { +describe('a chat agent and a terminal agent see the same text but for how each is named and called', () => { + it("teaches a chat worker a terminal worker's preamble but for its identity lines and the chat wording", async () => { const chat = await renderPreamble('chat') const terminal = await renderPreamble('terminal') + const withoutRedispatch = chat.replace(CHAT_REDISPATCH, TERMINAL_REDISPATCH) expect(terminal).not.toContain('Orca session ID') expect(chat).toContain(`Your task ID is: task_1${SELF_LINE}\n`) expect(chat).not.toContain(CHAT_WORKER_HANDLE) - expect(chat.replace(SELF_LINE, '').split(CHAT_ADDRESS).join(TERMINAL_HANDLE)).toBe(terminal) + expect(chat).not.toContain('this terminal') + expect(withoutRedispatch).not.toBe(chat) + expect(withoutRedispatch.split('this chat')).toHaveLength(4) + expect( + withoutRedispatch + .replace(SELF_LINE, '') + .split(CHAT_ADDRESS) + .join(TERMINAL_HANDLE) + .split('this chat') + .join('this terminal') + ).toBe(terminal) }) it.each([ diff --git a/src/main/runtime/orchestration/chat-assignee.test.ts b/src/main/runtime/orchestration/chat-assignee.test.ts new file mode 100644 index 000000000000..5b1ac6252a54 --- /dev/null +++ b/src/main/runtime/orchestration/chat-assignee.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { testOrcaSessionId } from '../../../shared/orca-session-address-test-fixture' +import { sessionRecord } from '../rpc/orchestration-session-caller-test-fixture' +import { chatAssigneeSessionId, observeChatAssignee } from './chat-assignee' +import type { AgentSessionRecordReader } from './structured-session-lineage' + +const CHAT = testOrcaSessionId('7e3b9d15-2c4a-4f86-a0b1-5c9e2d7f3b64') + +function store(record: AgentSessionRecord, visible: boolean): AgentSessionRecordReader { + return { + getRecord: (sessionId) => (sessionId === record.sessionId ? record : null), + listRecords: () => [record], + getVisibleSessionTabIndex: () => ({ present: true, sessionIds: visible ? [CHAT] : [] }) + } +} + +describe('a chat assignee', () => { + it('is named only by an Orca session ID handle', () => { + expect(chatAssigneeSessionId(`orca_session_id:${CHAT}`)).toBe(CHAT) + expect(chatAssigneeSessionId('term_worker')).toBeNull() + expect(chatAssigneeSessionId(null)).toBeNull() + }) + + it('is live at rest: a released lease is not an exit', () => { + const resting = sessionRecord(CHAT, { lease: { claimStatus: 'released' } }) + expect(observeChatAssignee(CHAT, null, store(resting, true))).toMatchObject({ + status: 'live', + session: { sessionId: CHAT } + }) + }) + + it('has exited once its chat is closed', () => { + expect(observeChatAssignee(CHAT, null, store(sessionRecord(CHAT), false))).toEqual({ + status: 'exited', + reason: 'The chat was closed.' + }) + }) + + it('is unverifiable on another host, with nothing to read, or with its successor unknown, never exited', () => { + const remote = sessionRecord(CHAT, { location: { executionHostId: 'ssh:box' } }) + expect(observeChatAssignee(CHAT, null, store(remote, true))).toMatchObject({ + status: 'unverifiable' + }) + expect(observeChatAssignee(CHAT, null, null)).toMatchObject({ status: 'unverifiable' }) + const cleared: AgentSessionRecord = { + ...sessionRecord(CHAT), + conversationCommand: { + command: 'clear', + state: 'completed', + replacementSessionId: 'clear-missing', + operationId: 'op', + callerKey: 'caller', + phase: 'committed' + } + } + expect(observeChatAssignee(CHAT, null, store(cleared, true))).toMatchObject({ + status: 'unverifiable', + reason: expect.stringContaining('after a /clear cannot be verified') + }) + const unknown = testOrcaSessionId('3f9a1c7e-6b2d-4e85-a0c4-9d1e7b3f5a26') + expect(observeChatAssignee(unknown, null, store(sessionRecord(CHAT), true))).toMatchObject({ + status: 'unverifiable' + }) + }) +}) diff --git a/src/main/runtime/orchestration/chat-assignee.ts b/src/main/runtime/orchestration/chat-assignee.ts new file mode 100644 index 000000000000..da1f7be01178 --- /dev/null +++ b/src/main/runtime/orchestration/chat-assignee.ts @@ -0,0 +1,109 @@ +/** + * A chat as a Dispatch assignee. Its Dispatch row names it by its `/clear` root address, + * `orca_session_id:`, and holds no pane or process: whether it can still work is read off the + * session records the same way mail to it is, so the two can never disagree. + */ + +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { + ORCA_SESSION_ADDRESS_PREFIX, + parseOrcaSessionAddress, + type OrcaSessionId +} from '../../../shared/orca-session-address' +import type { OrchestrationDb } from './db' +import { DISPATCH_CONTEXT_COLUMN_LIST } from './db/row-column-lists' +import type { DispatchContextRow } from './types' +import { structuredSessionMailReach } from './structured-session-mail-address' +import { + lineageLiveSession, + readAgentSessionRecordStore, + type AgentSessionRecordReader +} from './structured-session-lineage' + +/** The chat a Dispatch assignee handle names; null for a terminal or a structured worker. */ +export function chatAssigneeSessionId( + assigneeHandle: string | null | undefined +): OrcaSessionId | null { + return assigneeHandle ? parseOrcaSessionAddress(assigneeHandle) : null +} + +export type ChatAssigneeObservation = + /** `session` is the conversation's live session: its `/clear` successor, if it has one. */ + | { status: 'live'; session: AgentSessionRecord } + | { status: 'exited'; reason: string } + | { status: 'unverifiable'; reason: string } + +/** + * A chat at rest is live: the send that reaches it starts its agent. Only a closed chat has + * exited; a record this host lacks, or another host, is unverifiable: missing evidence is no exit. + */ +export function observeChatAssignee( + sessionId: OrcaSessionId, + db: OrchestrationDb | null | undefined, + store: AgentSessionRecordReader | null = readAgentSessionRecordStore() +): ChatAssigneeObservation { + if (!store) { + return { + status: 'unverifiable', + reason: 'The agent-session host is not installed in this runtime generation.' + } + } + const record = store.getRecord(sessionId) + if (!record) { + return { status: 'unverifiable', reason: 'No durable record backs this chat.' } + } + const reach = structuredSessionMailReach(store, record, db) + switch (reach.kind) { + case 'reachable': + return { status: 'live', session: reach.session } + case 'other-host': + return { status: 'unverifiable', reason: 'The chat runs on another host.' } + case 'unverifiable': + return { + status: 'unverifiable', + reason: `The session continuing this chat after a /clear cannot be verified: ${reach.reason}` + } + case 'ended': + switch (reach.reason) { + case 'closed': + return { status: 'exited', reason: 'The chat was closed.' } + case 'worker-identity-lost': + // Not reachable for a chat today (its Dispatch records no worker incarnation); still no exit. + return { + status: 'unverifiable', + reason: + 'The session that continues this chat is a worker whose identity this host lost.' + } + } + } +} + +/** + * The unsettled Dispatches of the chat whose current session's tab was hidden, once that chat has + * exited, re-derived from the records at the notice. Only that chat: another chat may be mid-close + * with its tab hidden, and a close can still put its tab back. + */ +export function exitedChatDispatchesForSession( + hiddenSessionId: string, + db: OrchestrationDb, + store: AgentSessionRecordReader | null = readAgentSessionRecordStore() +): DispatchContextRow[] { + if (!store) { + return [] + } + const rows = db.db + .prepare( + `SELECT ${DISPATCH_CONTEXT_COLUMN_LIST} FROM dispatch_contexts + WHERE status IN ('pending', 'dispatched') AND substr(assignee_handle, 1, ?) = ?` + ) + .all(ORCA_SESSION_ADDRESS_PREFIX.length, ORCA_SESSION_ADDRESS_PREFIX) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The schema-pinned complete Dispatch projection returns Dispatch rows; the adapter exposes unknown. + return (rows as DispatchContextRow[]).filter((dispatch) => { + const chat = chatAssigneeSessionId(dispatch.assignee_handle) + return ( + chat !== null && + lineageLiveSession(store, chat)?.sessionId === hiddenSessionId && + observeChatAssignee(chat, db, store).status === 'exited' + ) + }) +} diff --git a/src/main/runtime/orchestration/db/dispatch-context/dispatch-context-store.ts b/src/main/runtime/orchestration/db/dispatch-context/dispatch-context-store.ts index b51e5b4a8aa2..c2a63204a8c3 100644 --- a/src/main/runtime/orchestration/db/dispatch-context/dispatch-context-store.ts +++ b/src/main/runtime/orchestration/db/dispatch-context/dispatch-context-store.ts @@ -66,7 +66,10 @@ export function createDispatchContext( launchTokenHash: launchTokenHash ?? null, assigneeHandle, assigneePaneKey: assigneePaneKey ?? null, - assigneeOrcaSessionId: dispatchAssigneeOrcaSessionId(processIncarnation), + assigneeOrcaSessionId: dispatchAssigneeOrcaSessionId({ + handle: assigneeHandle, + processIncarnation + }), processIncarnation: processIncarnation ?? null, creatorDispatchId, ...recordedCreatorIdentity(params.creator), diff --git a/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-authority.ts b/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-authority.ts index fa826c7e1f69..e82ae3fad03e 100644 --- a/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-authority.ts +++ b/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-authority.ts @@ -7,8 +7,9 @@ export function prepareStartingWorkerAuthority( params: { dispatchId: string handle: string - paneKey: string - processIncarnation: string + /** Both null for a chat, which its Orca session ID identifies instead. */ + paneKey: string | null + processIncarnation: string | null launchTokenHash?: string worktreeId: string effects: unknown[] @@ -41,7 +42,7 @@ export function prepareStartingWorkerAuthority( `Dispatch ${params.dispatchId} already has a different launch-token commitment.` ) } - const existing = this.findActiveDispatchForAssignee(params.handle, params.paneKey) + const existing = this.findActiveDispatchForAssignee(params.handle, params.paneKey ?? undefined) if (existing && existing.id !== params.dispatchId) { throw new Error( `Terminal ${params.handle} already has an active dispatch (${existing.id} for task ${existing.task_id})` @@ -61,7 +62,10 @@ export function prepareStartingWorkerAuthority( .run( params.handle, params.paneKey, - dispatchAssigneeOrcaSessionId(params.processIncarnation), + dispatchAssigneeOrcaSessionId({ + handle: params.handle, + processIncarnation: params.processIncarnation + }), params.processIncarnation, params.hostScope ?? null, params.launchTokenHash ?? null, @@ -118,19 +122,24 @@ export function prepareStartingWorkerAuthority( ownership: 'owned' }) } else { - const transferable = this.findTransferableWorkerTerminalResource({ - terminalHandle: params.handle, - paneKey: params.paneKey, - processIncarnation: params.processIncarnation, - hostScope: params.hostScope ?? null - }) - if (transferable) { + // A chat has no process to take over: it is always the user's own, external resource. + const exact = + params.paneKey && params.processIncarnation + ? { paneKey: params.paneKey, processIncarnation: params.processIncarnation } + : null + const transferable = exact + ? this.findTransferableWorkerTerminalResource({ + terminalHandle: params.handle, + ...exact, + hostScope: params.hostScope ?? null + }) + : undefined + if (exact && transferable) { this.transferWorkerTerminalResourceStatement({ resourceId: transferable.id, toDispatchId: params.dispatchId, terminalHandle: params.handle, - paneKey: params.paneKey, - processIncarnation: params.processIncarnation, + ...exact, endpointId, endpointIncarnation: params.processIncarnation, hostScope: params.hostScope ?? null diff --git a/src/main/runtime/orchestration/db/worker-terminal/failed-start-dispatch-identity.ts b/src/main/runtime/orchestration/db/worker-terminal/failed-start-dispatch-identity.ts index 86657dfb0be4..36a45bf91b16 100644 --- a/src/main/runtime/orchestration/db/worker-terminal/failed-start-dispatch-identity.ts +++ b/src/main/runtime/orchestration/db/worker-terminal/failed-start-dispatch-identity.ts @@ -27,7 +27,10 @@ export function recordFailedStartDispatchIdentity( .run( resource.terminal_handle, resource.pane_key, - dispatchAssigneeOrcaSessionId(resource.process_incarnation), + dispatchAssigneeOrcaSessionId({ + handle: resource.terminal_handle, + processIncarnation: resource.process_incarnation + }), resource.process_incarnation, resource.host_scope, worker.dispatch_id diff --git a/src/main/runtime/orchestration/db/worker-terminal/worker-terminal-resource-store.ts b/src/main/runtime/orchestration/db/worker-terminal/worker-terminal-resource-store.ts index fe26380d5397..b5061de59508 100644 --- a/src/main/runtime/orchestration/db/worker-terminal/worker-terminal-resource-store.ts +++ b/src/main/runtime/orchestration/db/worker-terminal/worker-terminal-resource-store.ts @@ -244,10 +244,27 @@ export function retainReplacedWorkerTerminalResources( ) } +/** A detached pane keeps its process, so the resources it owns follow its new pane key. */ +export function rekeyWorkerTerminalResourcePaneKey( + this: OrchestrationDb, + params: { fromPaneKey: string; toPaneKey: string } +): number { + return Number( + this.db + .prepare( + `UPDATE worker_terminal_resources + SET pane_key = ?, updated_at = datetime('now') + WHERE pane_key = ? AND release_state != 'released'` + ) + .run(params.toPaneKey, params.fromPaneKey).changes + ) +} + // Finds an owned, settled, exact-match resource for an explicitly reused terminal. export type WorkerTerminalResourceStoreMethods = { retainReplacedWorkerTerminalResources: typeof retainReplacedWorkerTerminalResources + rekeyWorkerTerminalResourcePaneKey: typeof rekeyWorkerTerminalResourcePaneKey backfillWorkerTerminalResources: typeof backfillWorkerTerminalResources createWorkerTerminalResourceStatement: typeof createWorkerTerminalResourceStatement getWorkerTerminalResource: typeof getWorkerTerminalResource @@ -262,6 +279,7 @@ export type WorkerTerminalResourceStoreMethods = { export function attachWorkerTerminalResourceStore(ctor: { prototype: object }): void { Object.assign(ctor.prototype, { retainReplacedWorkerTerminalResources, + rekeyWorkerTerminalResourcePaneKey, backfillWorkerTerminalResources, createWorkerTerminalResourceStatement, getWorkerTerminalResource, diff --git a/src/main/runtime/orchestration/dispatch-assignee-orca-session-id.ts b/src/main/runtime/orchestration/dispatch-assignee-orca-session-id.ts index 52c75ece7329..5b70d2b2380e 100644 --- a/src/main/runtime/orchestration/dispatch-assignee-orca-session-id.ts +++ b/src/main/runtime/orchestration/dispatch-assignee-orca-session-id.ts @@ -1,11 +1,17 @@ -import type { OrcaSessionId } from '../../../shared/orca-session-address' +import { parseOrcaSessionAddress, type OrcaSessionId } from '../../../shared/orca-session-address' import { structuredWorkerOrcaSessionIdForIncarnation } from '../structured-worker-identity' import { canonicalOrcaSessionId } from './canonical-orca-session-id' -/** The Orca session id a Dispatch row stores for the structured worker a process incarnation names. */ -export function dispatchAssigneeOrcaSessionId( +/** + * The Orca session id a Dispatch row stores for its assignee: the structured worker its process + * incarnation names, or the chat its `orca_session_id:` handle names. A PTY has none. + */ +export function dispatchAssigneeOrcaSessionId(assignee: { + handle: string processIncarnation: string | null | undefined -): OrcaSessionId | null { - const orcaSessionId = structuredWorkerOrcaSessionIdForIncarnation(processIncarnation) +}): OrcaSessionId | null { + const orcaSessionId = + structuredWorkerOrcaSessionIdForIncarnation(assignee.processIncarnation) ?? + parseOrcaSessionAddress(assignee.handle) return orcaSessionId === null ? null : canonicalOrcaSessionId(orcaSessionId) } diff --git a/src/main/runtime/orchestration/dispatch-task-source.ts b/src/main/runtime/orchestration/dispatch-task-source.ts new file mode 100644 index 000000000000..865d2d931638 --- /dev/null +++ b/src/main/runtime/orchestration/dispatch-task-source.ts @@ -0,0 +1,26 @@ +import type { AgentMessageSource } from '../../../shared/agent-session-message-source' +import { agentMessageSender, type SenderNameResolver } from './agent-message-sender' +import type { OrchestrationDb } from './db' +import type { DispatchContextRow } from './types' + +/** Who a Dispatch's task is from, as the worker's chat shows it: the coordinator, and the + * records the task joins back to. */ +export function dispatchTaskSource(args: { + db: OrchestrationDb + dispatch: Pick + /** The coordinator's address; a Dispatch with no declared caller names no sender. */ + from: string | undefined + senderName: SenderNameResolver +}): AgentMessageSource { + const { dispatch } = args + return { + kind: 'agent', + senders: args.from ? [agentMessageSender(args.from, args.db, args.senderName)] : [], + orchestration: { + message: 'task', + runId: dispatch.run_id, + taskId: dispatch.task_id, + dispatchId: dispatch.id + } + } +} diff --git a/src/main/runtime/orchestration/mailbox-delivery-target.ts b/src/main/runtime/orchestration/mailbox-delivery-target.ts index 5d412bb12e84..4faba0773a25 100644 --- a/src/main/runtime/orchestration/mailbox-delivery-target.ts +++ b/src/main/runtime/orchestration/mailbox-delivery-target.ts @@ -5,8 +5,8 @@ type OrchestrationMailboxDeliveryTargetDependencies = { getDb: () => OrchestrationDb | null getTerminalHandleForPaneKey: (paneKey: string) => string | null hasTerminalHandle: (handle: string) => boolean - /** A structured worker has no PTY handle; its own lane delivers, so this must not claim it. */ - isStructuredWorkerHandle: (handle: string) => boolean + /** A structured worker or a chat has no PTY handle; its own lane delivers, so this must not claim it. */ + isStructuredSessionOwner: (handle: string) => boolean canProbePtyLiveness: () => boolean controllerKnowsPtyIsLive: (ptyId: string) => boolean isLeafPtyProvenAbsent: (ptyId: string) => Promise @@ -21,7 +21,7 @@ export class OrchestrationMailboxDeliveryTarget { if (this.deps.hasTerminalHandle(handle)) { return handle } - if (this.deps.isStructuredWorkerHandle(handle)) { + if (this.deps.isStructuredSessionOwner(handle)) { return null } const db = this.deps.getDb() @@ -39,7 +39,7 @@ export class OrchestrationMailboxDeliveryTarget { if (!ownerHandle) { return null } - if (this.deps.isStructuredWorkerHandle(ownerHandle)) { + if (this.deps.isStructuredSessionOwner(ownerHandle)) { // The structured lane owns this mailbox; nothing here can type into it. return null } diff --git a/src/main/runtime/orchestration/orchestration-party.ts b/src/main/runtime/orchestration/orchestration-party.ts index 26107a1474bc..962e94ff5f73 100644 --- a/src/main/runtime/orchestration/orchestration-party.ts +++ b/src/main/runtime/orchestration/orchestration-party.ts @@ -34,7 +34,8 @@ export const ORCHESTRATION_TARGET_PARAM: Readonly< 'orchestration.dispatch': 'to', 'orchestration.inbox': 'terminal', 'orchestration.sessionAddress': 'sessionId', - 'orchestration.partyLocation': 'address' + 'orchestration.partyLocation': 'address', + 'orchestration.workerStart': 'terminal' } /** The party an Orca session id names. Throws when it is a worker this host lost the identity of. */ @@ -104,18 +105,13 @@ export function resolveDeclaredCallerParty( return party } -/** A Dispatch assignee: a terminal or a structured worker, never a chat yet. */ +/** + * A Dispatch assignee: a terminal, a structured worker, or a chat named by its Orca session ID. A + * chat's party is its `/clear` root address, which its own session id resolves to when it reports. + */ export function resolveDispatchAssigneeParty( address: string, db: OrchestrationDb | null | undefined ): OrchestrationParty { - const party = resolveOrchestrationParty(address, db) - if (party.terminalHandle === null) { - throw new OrchestrationError( - CODES.chatNotDispatchable, - `Agent session ${party.orcaSessionId} is a chat, and a chat can't receive a dispatch yet. Start a worker with worker-start instead. No effects were applied.`, - NO_EFFECTS - ) - } - return party + return resolveOrchestrationParty(address, db) } diff --git a/src/main/runtime/orchestration/preamble.test.ts b/src/main/runtime/orchestration/preamble.test.ts index 8b7b28b5edf7..61d8c2731cad 100644 --- a/src/main/runtime/orchestration/preamble.test.ts +++ b/src/main/runtime/orchestration/preamble.test.ts @@ -2,7 +2,11 @@ import { spawnSync } from 'node:child_process' import remarkParse from 'remark-parse' import { unified } from 'unified' import { describe, expect, it } from 'vitest' -import { buildDispatchPreamble } from './preamble' +import { + buildDispatchPreamble, + CHAT_REDISPATCH_PARAGRAPH as CHAT_REDISPATCH, + TERMINAL_REDISPATCH_PARAGRAPH as TERMINAL_REDISPATCH +} from './preamble' function baseParams(overrides: Partial[0]> = {}) { return { @@ -349,6 +353,17 @@ describe('buildDispatchPreamble', () => { }) expect(result).toMatchSnapshot() }) + + it('renders a stable snapshot of a chat worker preamble', () => { + const result = buildDispatchPreamble({ + taskId: 'task_SNAP', + dispatchId: 'ctx_SNAP', + taskSpec: 'TASK_BODY', + coordinatorHandle: 'orca_session_id:COORD', + workerHandle: 'orca_session_id:WORKER' + }) + expect(result).toMatchSnapshot() + }) }) describe('sub-dispatch section', () => { @@ -417,15 +432,34 @@ describe('how the preamble names the worker and its coordinator, by kind', () => ) }) - it("teaches a session worker a terminal worker's text but for how it is named", () => { + it("teaches a chat worker a terminal worker's text but for its name and the chat wording", () => { const terminal = buildDispatchPreamble(baseParams()) - const session = buildDispatchPreamble(baseParams({ workerHandle: SESSION_WORKER })) + const chat = buildDispatchPreamble(baseParams({ workerHandle: SESSION_WORKER })) + const withoutRedispatch = chat.replace(CHAT_REDISPATCH, TERMINAL_REDISPATCH) + expect(withoutRedispatch).not.toBe(chat) + expect(withoutRedispatch.split('this chat')).toHaveLength(4) expect( - session + withoutRedispatch .replace(`\nYour Orca session ID is: ${SESSION_WORKER}`, '') .split(SESSION_WORKER) .join('term_worker') + .split('this chat') + .join('this terminal') ).toBe(terminal) }) + + it('tells a chat worker nothing about a terminal or a shell, even as a bare-shell worker', () => { + for (const workerKind of ['prompt-returning-agent', 'bare-shell'] as const) { + const chat = buildDispatchPreamble(baseParams({ workerHandle: SESSION_WORKER, workerKind })) + + expect(chat).not.toContain('this terminal') + expect(chat).not.toContain('exit the shell') + expect(chat).not.toContain('Exit the shell') + expect(chat).not.toContain('Your terminal') + expect(chat).toContain('return to an idle prompt') + expect(chat).toContain(`check --terminal ${SESSION_WORKER}`) + expect(afterWorkerDoneSection(chat).trimEnd().endsWith(CHAT_REDISPATCH)).toBe(true) + } + }) }) diff --git a/src/main/runtime/orchestration/preamble.ts b/src/main/runtime/orchestration/preamble.ts index 719fe50cb820..8af6aa756bb8 100644 --- a/src/main/runtime/orchestration/preamble.ts +++ b/src/main/runtime/orchestration/preamble.ts @@ -44,15 +44,21 @@ export type PreambleParams = { // cadence tuning is a single-line change (Q1 in DESIGN_DOC_PREAMBLE_FIX.md). const HEARTBEAT_INTERVAL_MIN = 5 +/** A session address names a chat worker; any other handle is a terminal. */ +function isSessionHandle(handle: string): boolean { + return handle.startsWith(ORCA_SESSION_ADDRESS_PREFIX) +} + /** Terminal agents keep their handle's wording; only a session is named by its Orca session ID. */ function dispatchIdentityLines(params: PreambleParams): string { - const isSession = (handle: string) => handle.startsWith(ORCA_SESSION_ADDRESS_PREFIX) return [ - isSession(params.coordinatorHandle) + isSessionHandle(params.coordinatorHandle) ? `Your coordinator's Orca session ID is: ${params.coordinatorHandle}` : `Your coordinator's terminal handle is: ${params.coordinatorHandle}`, `Your task ID is: ${params.taskId}`, - ...(isSession(params.workerHandle) ? [`Your Orca session ID is: ${params.workerHandle}`] : []) + ...(isSessionHandle(params.workerHandle) + ? [`Your Orca session ID is: ${params.workerHandle}`] + : []) ].join('\n') } @@ -66,9 +72,12 @@ export function buildDispatchPreamble(params: PreambleParams): string { // socket. Without this, agents inside the dev Electron app would call the // production CLI and talk to the wrong Orca instance (Section 6.4). const cli = params.devMode ? 'orca-dev' : (params.cliCommand ?? 'orca') + const chat = isSessionHandle(params.workerHandle) + const surface = chat ? 'chat' : 'terminal' const postDoneInstructions = buildPostWorkerDoneInstructions({ cli, - workerKind: params.workerKind ?? 'prompt-returning-agent' + workerKind: params.workerKind ?? 'prompt-returning-agent', + chat }) // Why: one-line recipes paste unchanged in POSIX shells, PowerShell, and cmd.exe. @@ -78,8 +87,8 @@ export function buildDispatchPreamble(params: PreambleParams): string { const header = `You are working inside Orca, a multi-agent IDE. You are a dispatched worker. ${dispatchIdentityLines(params)} -The coordinator cannot see this terminal, so reach it with the \`${cli} orchestration\` -commands below; a question or result left only in this terminal never gets to it. +The coordinator cannot see this ${surface}, so reach it with the \`${cli} orchestration\` +commands below; a question or result left only in this ${surface} never gets to it. Don't post to Slack, GitHub, or other channels during the run; report through these commands. === CLI COMMANDS === @@ -117,7 +126,7 @@ Don't post to Slack, GitHub, or other channels during the run; report through th # # Use this instead of AskUserQuestion: that opens a local prompt the # coordinator cannot see or answer, so the task would stall until someone - # happened to look at this terminal. Send every question through \`ask\`. + # happened to look at this ${surface}. Send every question through \`ask\`. # # The \`ask\` verb durably records a question in this Dispatch's Run and # blocks until the coordinator replies, then prints the reply body. If the @@ -170,16 +179,28 @@ export function dispatchPreambleSendOptions(requestId: string): DispatchPreamble } } +export const TERMINAL_REDISPATCH_PARAGRAPH = `Do not exit the shell. Your terminal stays available, and if the +coordinator has more for you it will re-engage this terminal with a fresh +preamble + TASK block, which arrives as new input. Treat that as supervised +work under the new Dispatch; ignore stale follow-ups from the settled task.` + +export const CHAT_REDISPATCH_PARAGRAPH = `If the coordinator has more for you, it will send this chat a fresh +preamble + TASK block, which arrives as a new message. Treat that as supervised +work under the new Dispatch; ignore stale follow-ups from the settled task.` + function buildPostWorkerDoneInstructions({ cli, - workerKind + workerKind, + chat }: { cli: string workerKind: NonNullable + chat: boolean }): string { - // Why: re-dispatch reaches idle agents as terminal input; inbox polling + // Why: re-dispatch reaches idle agents as new input; inbox polling // after completion cannot receive that new TASK block and looks hung. - if (workerKind === 'bare-shell') { + // Why chat first: a chat has no shell to exit, whatever workerKind says. + if (workerKind === 'bare-shell' && !chat) { return `=== AFTER YOU SEND worker_done === worker_done ends your turn for this task. Your dispatched work is complete: @@ -206,10 +227,7 @@ Treat it as new user-owned work: follow it without coordinator approval or a fresh Dispatch, and do not send lifecycle messages using the settled task or Dispatch IDs. Never refuse a direct user request because you were a worker. -Do not exit the shell. Your terminal stays available, and if the -coordinator has more for you it will re-engage this terminal with a fresh -preamble + TASK block, which arrives as new input. Treat that as supervised -work under the new Dispatch; ignore stale follow-ups from the settled task.` +${chat ? CHAT_REDISPATCH_PARAGRAPH : TERMINAL_REDISPATCH_PARAGRAPH}` } // Why the whole section is omitted rather than softened when nesting is off: a diff --git a/src/main/runtime/orchestration/send-agent-turn-boundary.test.ts b/src/main/runtime/orchestration/send-agent-turn-boundary.test.ts index 334195a5c88e..1c47ab967e89 100644 --- a/src/main/runtime/orchestration/send-agent-turn-boundary.test.ts +++ b/src/main/runtime/orchestration/send-agent-turn-boundary.test.ts @@ -112,7 +112,9 @@ describe('agent turn send boundary', () => { 'runtime/rpc/methods/orchestration/worker/deliver-worker-dispatch-preamble.ts', 'runtime/rpc/methods/orchestration/runs/dispatch-methods.ts', 'runtime/orchestration/coordinator-task-dispatch.ts', - 'runtime/rpc/methods/orchestration/federation/federation.ts' + 'runtime/rpc/methods/orchestration/federation/federation.ts', + // A chat assignee's task, from `dispatch --inject` and `worker-start --terminal`. + 'runtime/rpc/methods/orchestration/chat-task-delivery.ts' ].sort() ) }) @@ -155,8 +157,9 @@ describe('agent turn send boundary', () => { 'native-chat/agent-session-wire/structured-conversation-command-controller.ts', // The pointer lane's port, whose `send` is sendAgentTurn in structured-mailbox-pointer-host. 'runtime/orchestration/structured-mailbox-pointer-delivery.ts', - // A real-host test rig the shared scan does not count as a test file. + // Real-host test rigs the shared scan does not count as test files. 'native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts', + 'acp/acp-structured-host.test-support.ts', // An Electron WebContents IPC send, not a chat. 'browser/doc-preview-guest-policy.ts' ].sort() diff --git a/src/main/runtime/orchestration/send-agent-turn-host.test.ts b/src/main/runtime/orchestration/send-agent-turn-host.test.ts index df50c6fad03a..b92f4d6bb188 100644 --- a/src/main/runtime/orchestration/send-agent-turn-host.test.ts +++ b/src/main/runtime/orchestration/send-agent-turn-host.test.ts @@ -14,6 +14,7 @@ import { hostTestMessage, hostTestOperationId } from '../../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { openRigTurnFor } from '../../native-chat/agent-session-wire/structured-agent-session-queued-rig-turn.test-fixture' import { sendAgentTurn, type AgentTurnDelivery, @@ -175,7 +176,7 @@ describe('sendAgentTurn through the real host', () => { }) it('has a `now` send join the running turn, never the queue', async () => { - await rig.workingSend() + await openRigTurnFor(rig, await rig.workingSend()) await expect(sendTurnAccepted('now')).resolves.toMatchObject({ kind: 'sent', submission: { dispatchState: 'accepted' } diff --git a/src/main/runtime/orchestration/structured-session-mail-target.ts b/src/main/runtime/orchestration/structured-session-mail-target.ts index 2d512d58e023..c40de4899b0d 100644 --- a/src/main/runtime/orchestration/structured-session-mail-target.ts +++ b/src/main/runtime/orchestration/structured-session-mail-target.ts @@ -22,7 +22,8 @@ import { readAgentSessionRecordStore, type AgentSessionRecordReader } from './structured-session-lineage' -import type { RunRow } from './types' +import type { DispatchContextRow, RunRow } from './types' +import { chatAssigneeSessionId } from './chat-assignee' /** * The session a Run's coordinator binding names when that binding has no handle. A structured @@ -61,6 +62,22 @@ export function structuredSessionMailTarget( : null } +/** + * The target of a `dispatch:` mailbox whose assignee is a chat: its live session, under the + * Dispatch's budget. `undefined` when the assignee is not a chat, so a worker keeps its own lookup. + */ +export function chatDispatchMailTarget( + dispatch: Pick, + db: OrchestrationDb | null | undefined +): StructuredPointerTarget | null | undefined { + const sessionId = chatAssigneeSessionId(dispatch.assignee_handle) + if (!sessionId) { + return undefined + } + const target = structuredSessionMailTarget(sessionId, db) + return target ? { ...target, dispatchId: dispatch.id } : null +} + /** * The target of an `orca_session_id:` mailbox; `undefined` when the handle is not a session address at * all, so other address forms keep their own resolution. @@ -77,8 +94,8 @@ export function structuredSessionAddressTarget( } /** - * Every mailbox a session reads for itself: the Runs it coordinates, its own direct mail and, for a - * worker, its active Dispatch; a `/clear` successor reads its worker's. Re-derived from the database + * Every mailbox a session reads for itself: the Runs it coordinates, its own direct mail and its + * active Dispatch, a worker's or a chat's; a `/clear` successor reads its root's. Re-derived from the database * on each idle edge rather than remembered, so mail that arrived while the session could not take * it (mid-turn, closed) is found again. */ @@ -91,10 +108,9 @@ export function structuredSessionOwnedMailboxes(sessionId: string, db: Orchestra if (db.getUnreadDirectMessageTypes(party.address).length > 0) { mailboxes.push(party.address) } - // Why: Dispatch mail parked on a session a /clear ended waits for an edge that never comes. - const dispatch = party.terminalHandle - ? db.findActiveDispatchForAssignee(party.terminalHandle, party.paneKey ?? undefined) - : undefined + // Why: Dispatch mail parked on a session a /clear or restart ended waits for an edge that never + // comes. A party's address is what its Dispatch names it by: a worker's handle, a chat's root. + const dispatch = db.findActiveDispatchForAssignee(party.address, party.paneKey ?? undefined) if (dispatch) { mailboxes.push(`dispatch:${dispatch.id}`) } 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 new file mode 100644 index 000000000000..9ad8705529b6 --- /dev/null +++ b/src/main/runtime/rpc/methods/agent-launch-caller-renders.test.ts @@ -0,0 +1,103 @@ +/** + * `agent.launch` makes a chat only for a caller that can show it. `agent.launch.v2` vouches for + * Claude and Codex chats; any other agent's chat needs the client to read it, by the rule tabs and + * restart offers use, else it gets a terminal. The host's own callers (CLI, orchestration over the + * runtime socket) carry no capability list and are unaffected. With the structured-chat setting + * off, every agent, Grok included, opens as a terminal. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY +} from '../../../../shared/protocol-version' +import type { RpcContext } from '../core' +import { + CAPABLE_CLIENT, + methodNamed, + rpcContext, + runtimeStub, + STRUCTURED_PREFERENCE +} from './agent-launch.test-fixture' + +const createStructuredSession = vi.fn(async (_args: Record) => ({ + ok: true as const, + value: { sessionId: 'sess-1' } +})) + +vi.mock('./structured-agent-session-create', () => ({ + createStructuredAgentSessionForWorktree: (args: Record) => + createStructuredSession(args) +})) + +const { AGENT_LAUNCH_METHODS } = await import('./agent-launch') +const AGENT_LAUNCH = methodNamed(AGENT_LAUNCH_METHODS, 'agent.launch') + +async function launchInto( + agent: string, + context: Partial, + settings?: Record +) { + const runtime = runtimeStub(settings ? { settings } : {}) + const parsed = AGENT_LAUNCH.params.safeParse({ + agent, + target: { kind: 'existing', worktree: 'id:wt-7' } + }) + if (!parsed.success) { + throw new Error(parsed.error.issues[0]?.message ?? 'invalid') + } + const result = await AGENT_LAUNCH.handler(parsed.data, rpcContext(runtime, context)) + return { result, runtime } +} + +beforeEach(() => { + createStructuredSession.mockClear() +}) + +describe('a launch the caller cannot show as a chat', () => { + it('opens Grok as a terminal for a client that reads only Claude and Codex chats', async () => { + const { result, runtime } = await launchInto('grok', CAPABLE_CLIENT) + expect(result.outcome).toMatchObject({ kind: 'terminal', handle: 'term_1' }) + expect(result.receipt).toMatchObject({ mode: 'terminal', preferred: 'structured' }) + expect(createStructuredSession).not.toHaveBeenCalled() + expect(runtime.createTerminal).toHaveBeenCalledTimes(1) + }) + + it('still opens Claude and Codex as chats for that client', async () => { + for (const agent of ['claude', 'codex']) { + const { result } = await launchInto(agent, CAPABLE_CLIENT) + expect(result.outcome).toMatchObject({ kind: 'structured' }) + } + }) + + it('opens Grok as a chat for a client that reads registered agents', async () => { + const { result, runtime } = await launchInto('grok', { + ...CAPABLE_CLIENT, + clientCapabilities: [ + ...(CAPABLE_CLIENT.clientCapabilities ?? []), + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY + ] + }) + expect(result.outcome).toMatchObject({ kind: 'structured', sessionId: 'sess-1' }) + expect(runtime.createTerminal).not.toHaveBeenCalled() + }) + + it("opens Grok as a chat for the host's own callers, which carry no capability list", async () => { + const { result } = await launchInto('grok', {}) + expect(result.outcome).toMatchObject({ kind: 'structured', sessionId: 'sess-1' }) + }) +}) + +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 } + for (const agent of ['grok', 'claude']) { + const { result, runtime } = await launchInto(agent, {}, off) + expect(result.outcome).toMatchObject({ kind: 'terminal' }) + expect(result.receipt).toMatchObject({ mode: 'terminal', reason: 'user_default' }) + expect(runtime.createTerminal).toHaveBeenCalledTimes(1) + } + expect(createStructuredSession).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/rpc/methods/agent-launch.test.ts b/src/main/runtime/rpc/methods/agent-launch.test.ts index 910aeadc1966..4f15465565e6 100644 --- a/src/main/runtime/rpc/methods/agent-launch.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch.test.ts @@ -493,7 +493,7 @@ describe('the terminal factory', () => { it('takes an existing workspace without creating one', async () => { const runtime = runtimeStub() const result = await launch( - { agent: 'grok', target: { kind: 'existing', worktree: 'id:wt-7' } }, + { agent: 'gemini', target: { kind: 'existing', worktree: 'id:wt-7' } }, runtime ) @@ -504,8 +504,9 @@ describe('the terminal factory', () => { // Resolved to an id first: everything below re-prefixes it, so a raw selector reaches the // runtime as `id:id:wt-7`. expect(runtime.createTerminal).toHaveBeenCalledWith('id:wt-7', { - startupAgent: 'grok', - viewMode: 'chat', + startupAgent: 'gemini', + // No native chat renderer for this agent, so its tab opens as the terminal. + viewMode: 'terminal', onPtySpawnDispatched: expect.any(Function) }) expect(result.worktreeId).toBe('wt-7') diff --git a/src/main/runtime/rpc/methods/agent-launch.ts b/src/main/runtime/rpc/methods/agent-launch.ts index 09f55d4538a3..15aeb28bd125 100644 --- a/src/main/runtime/rpc/methods/agent-launch.ts +++ b/src/main/runtime/rpc/methods/agent-launch.ts @@ -54,6 +54,7 @@ import { selectAgentLaunchTabForCaller } from './agent-launch-caller-selection' import { agentLaunchWorkspaceFactory } from './agent-launch-worktree-creation' +import { clientRendersStructuredAgent } from './structured-agent-session-policy' import { resolveUnlaunchedIntent } from './agent-launch-intent-resolution' import { publishEarlyTab, @@ -78,6 +79,23 @@ export function supportsAgentLaunch( ) } +/** + * `agent.launch.v2` was defined when Claude and Codex were the only chats, so it vouches for those + * two. Any other agent's chat needs the client to say it reads it, by the rule tabs and restart + * offers use; a client that does not gets that agent as a terminal. + */ +function callerRendersLaunchedChat( + context: Pick, + agent: string +): boolean { + return ( + context.clientKind === undefined || + agent === 'claude' || + agent === 'codex' || + clientRendersStructuredAgent(context.clientCapabilities, agent) + ) +} + /** What a launch admitted under an operation id carries into its execution. */ type ReplaySafeLaunch = { attachOperationId: string @@ -108,6 +126,7 @@ async function runAgentLaunch( view.early ), workspaces: agentLaunchWorkspaceFactory(context, intent.agent), + ...(callerRendersLaunchedChat(context, intent.agent) ? {} : { callerRendersStructured: false }), // The tab is shown as it is published, not after a prompt that can take a minute to land. onSurfacePublished: (surface) => { view.early?.surfacePublished(surface) diff --git a/src/main/runtime/rpc/methods/ai-vault-structured-ownership.test.ts b/src/main/runtime/rpc/methods/ai-vault-structured-ownership.test.ts index 9df8e46c5892..c1eab28da0ab 100644 --- a/src/main/runtime/rpc/methods/ai-vault-structured-ownership.test.ts +++ b/src/main/runtime/rpc/methods/ai-vault-structured-ownership.test.ts @@ -9,6 +9,12 @@ import { setStructuredAgentSessionHost } from '../../../native-chat/agent-sessio import type { AiVaultSession } from '../../../../shared/ai-vault-types' import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +const searchMocks = vi.hoisted(() => ({ search: vi.fn() })) +vi.mock('../../../ai-vault-search/session-search-service-registry', () => ({ + searchSessionService: searchMocks.search, + sessionSearchServiceStatus: vi.fn() +})) + afterEach(() => setStructuredAgentSessionHost(null)) it('projects the serving record before returning paired-host row identity', async () => { const record = { ...agentSessionRecordFixture(), conversationName: 'Saved by serving host' } @@ -79,3 +85,82 @@ it('projects the serving record before returning paired-host row identity', asyn await dispatcher.dispatch(request, { clientKind: 'runtime', clientCapabilities: [] }) ).toMatchObject({ ok: true, result: { sessions: [] } }) }) + +it('owns indexed search hits only for a client that can open the native chat', async () => { + const record = { ...agentSessionRecordFixture(), conversationName: 'Saved by serving host' } + record.providerHandleChain = [ + { ...record.providerHandleChain[0]!, handle: claudeProviderHandle('provider-session', null) } + ] + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: ownership projection only reads this record-list member. + setStructuredAgentSessionHost({ deps: { store: { listRecords: () => [record] } } } as never) + const hit = { + agent: 'claude' as const, + sessionId: 'provider-session', + title: 'First prompt', + cwd: '/folder', + branch: null, + updatedAt: null, + messageCount: 1, + score: 1, + source: { presence: 'present' as const }, + evidence: null + } + searchMocks.search.mockResolvedValue({ + kind: 'results', + hits: [hit], + page: { cursor: null, hasMore: false }, + generation: 1, + truncated: { candidates: false, snippets: 0, query: false, freshness: false }, + durationMs: 1 + }) + const ensureHost = vi.fn(async () => {}) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this exact RPC method only reaches this runtime member. + const runtime = { + getRuntimeId: () => 'host-runtime', + ensureStructuredAgentSessionHost: ensureHost + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: AI_VAULT_METHODS }) + const request = { + id: 'request', + authToken: 'token', + method: 'aiVault.searchSessions', + params: { query: 'prompt' } + } + expect( + await dispatcher.dispatch(request, { + clientKind: 'runtime', + clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] + }) + ).toMatchObject({ + ok: true, + result: { + hits: [ + { + title: 'Saved by serving host', + structuredSession: { + sessionId: record.sessionId, + workspaceId: record.location.workspaceId + } + } + ] + } + }) + const legacy = await dispatcher.dispatch(request, { + clientKind: 'runtime', + clientCapabilities: [] + }) + expect(legacy).toMatchObject({ ok: true, result: { hits: [{ title: 'First prompt' }] } }) + expect(JSON.stringify(legacy)).not.toContain('structuredSession') + // A client that gets no owners never waits on the chat host. + expect(ensureHost).toHaveBeenCalledTimes(1) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + ensureHost.mockRejectedValueOnce(new Error('chat journal unavailable')) + const failedInstall = await dispatcher.dispatch(request, { + clientKind: 'runtime', + clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] + }) + expect(failedInstall).toMatchObject({ ok: true, result: { hits: [{ title: 'First prompt' }] } }) + expect(JSON.stringify(failedInstall)).not.toContain('structuredSession') + expect(warn).toHaveBeenCalled() + warn.mockRestore() +}) diff --git a/src/main/runtime/rpc/methods/ai-vault.ts b/src/main/runtime/rpc/methods/ai-vault.ts index 7fb8e544a5da..2b4c06019906 100644 --- a/src/main/runtime/rpc/methods/ai-vault.ts +++ b/src/main/runtime/rpc/methods/ai-vault.ts @@ -15,7 +15,8 @@ import { describeAiVaultScanError } from '../../../../shared/ai-vault-scan-error import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import { assertLegacyAiVaultResumeAllowed, - projectStructuredAiVaultSessions + projectStructuredAiVaultSessions, + searchWithStructuredOwners } from '../../../ai-vault/structured-session-ownership' import { ensureStructuredAgentSessionHostUnlessRefused } from '../../structured-agent-session-host-refusal' import { @@ -29,8 +30,14 @@ export const AI_VAULT_METHODS = [ defineMethod({ name: 'aiVault.searchSessions', params: AiVaultSearchRequestSchema, - handler: (params, { clientKind }) => - searchSessionService(params, clientKind ? 'relay' : 'runtime') + handler: (params, { runtime, clientKind, clientCapabilities }) => { + const response = searchSessionService(params, clientKind ? 'relay' : 'runtime') + // A client that cannot open the native owner keeps the transcript hit, as before. + return clientKind === undefined || + clientCapabilities?.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) + ? searchWithStructuredOwners(response, () => runtime.ensureStructuredAgentSessionHost()) + : response + } }), defineMethod({ name: 'aiVault.searchStatus', diff --git a/src/main/runtime/rpc/methods/orchestration-chat-assignee-journal.ts b/src/main/runtime/rpc/methods/orchestration-chat-assignee-journal.ts new file mode 100644 index 000000000000..3dc39e31e152 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-chat-assignee-journal.ts @@ -0,0 +1,41 @@ +import type { AgentType } from '../../../../shared/native-chat-types' +import { chatAssigneeSessionId } from '../../orchestration/chat-assignee' +import { OrchestrationError } from '../../orchestration/orchestration-error' +import type { OrchestrationDb } from '../../orchestration/db' +import { readAgentSessionRecordStore } from '../../orchestration/structured-session-lineage' +import type { StructuredWorkerIdentity } from '../../structured-worker-identity' + +/** What a worker transcript read needs of the session it reads, and what its cursor is bound to. */ +export type StructuredJournalSource = Pick< + StructuredWorkerIdentity, + 'sessionId' | 'processIncarnation' | 'paneKey' +> + +/** + * A chat assignee's journal, read as a worker's is: its whole `/clear` lineage from its root, which + * is also what the cursor is bound to. Null when the assignee is not a chat. + */ +export function chatAssigneeJournal( + db: OrchestrationDb, + dispatchId: string +): { source: StructuredJournalSource; agent: AgentType } | null { + const handle = + db.getWorkerDispatch(dispatchId)?.agent_terminal_handle ?? + db.getDispatchContextById(dispatchId)?.assignee_handle + const chat = chatAssigneeSessionId(handle) + if (!handle || !chat) { + return null + } + const root = readAgentSessionRecordStore()?.getRecord(chat) + if (!root) { + throw new OrchestrationError( + 'transcript_required', + `The transcript for Dispatch ${dispatchId} could not be read; this host has no record of its chat.` + ) + } + // The chat's address stands in for a worker's pane and incarnation: it is its whole identity. + return { + source: { sessionId: chat, processIncarnation: handle, paneKey: handle }, + agent: root.provider + } +} diff --git a/src/main/runtime/rpc/methods/orchestration-structured-worker-lifecycle.ts b/src/main/runtime/rpc/methods/orchestration-structured-worker-lifecycle.ts index 216763b73d1b..73cf06277692 100644 --- a/src/main/runtime/rpc/methods/orchestration-structured-worker-lifecycle.ts +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-lifecycle.ts @@ -47,6 +47,10 @@ import type { StructuredWorkerIdentity } from '../../structured-worker-identity' import type { WorkerTerminalReleaseState } from '../../orchestration/worker-terminal-ownership' import { releaseStructuredWorkerSession } from './orchestration-structured-worker-session' import { closeStructuredAgentSessionChild } from '../../structured-agent-session-close' +import { + chatAssigneeJournal, + type StructuredJournalSource +} from './orchestration-chat-assignee-journal' export { observeStructuredWorker, structuredWorkerOwned, type StructuredWorkerObservation } @@ -129,7 +133,10 @@ export async function readStructuredWorkerOutput(args: { limit?: number }): Promise { const identity = resolveStructuredWorkerForDispatch(args.db, args.dispatchId) - if (!identity) { + const journal = identity + ? { source: identity, agent: structuredWorkerAgent(identity) } + : chatAssigneeJournal(args.db, args.dispatchId) + if (!journal) { return null } if (args.source === 'terminal') { @@ -141,11 +148,11 @@ export async function readStructuredWorkerOutput(args: { ) } return readStructuredWorkerJournal({ - identity, + identity: journal.source, dispatchId: args.dispatchId, workerState: args.workerState, liveness: args.liveness, - agent: structuredWorkerAgent(identity), + agent: journal.agent, ...(args.cursor === undefined ? {} : { cursor: args.cursor }), ...(args.limit === undefined ? {} : { limit: args.limit }) }) @@ -153,7 +160,7 @@ export async function readStructuredWorkerOutput(args: { /** Journal page in the shape `worker-read --source transcript` already serves. */ export async function readStructuredWorkerJournal(args: { - identity: StructuredWorkerIdentity + identity: StructuredJournalSource dispatchId: string workerState: string liveness: StructuredWorkerObservation['status'] @@ -223,7 +230,7 @@ export async function readStructuredWorkerJournal(args: { * The oldest item stays in the anchor as the window-slide detector: a slide shifts every index. */ function structuredJournalPrefixIdentity(args: { - identity: StructuredWorkerIdentity + identity: StructuredJournalSource page: StructuredLineageJournalPage position: number }): string { diff --git a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts index 02797d54d6c8..390148eb6c4b 100644 --- a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts @@ -1,6 +1,7 @@ import { join } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' +import type { AgentMessageSource } from '../../../../shared/agent-session-message-source' import { getAppEnvironment } from '../../../../shared/app-environment' import { DISPATCH_REJECTED_WRITE_FAILED } from '../../../../shared/structured-agent-session-dispatch-rejection' @@ -270,8 +271,25 @@ describe('structured worker dispatch preamble', () => { } } + const TASK_FROM: AgentMessageSource = { + kind: 'agent', + senders: [ + { + party: { address: 'term_coord', terminalHandle: 'term_coord', orcaSessionId: null }, + name: 'Coordinator' + } + ], + orchestration: { message: 'task', runId: 'r1', taskId: 't1', dispatchId: 'd1' } + } + const send = (host: PreambleHost) => - sendStructuredWorkerPreamble({ host, sessionId: 's1', dispatchId: 'd1', preamble: 'spec' }) + sendStructuredWorkerPreamble({ + host, + sessionId: 's1', + dispatchId: 'd1', + preamble: 'spec', + from: TASK_FROM + }) // No source and no person: a restart or a close rejects it, and orchestration re-derives it. it('sends the preamble as no person’s', async () => { @@ -282,6 +300,13 @@ describe('structured worker dispatch preamble', () => { expect(sent.mock.calls[0]?.[1]).not.toHaveProperty('userSend') }) + it('names who the task is from on the turn it sends', async () => { + const host = hostWithSubmission({ dispatchState: 'accepted', reason: null }) + const sent = vi.spyOn(host, 'send') + await send(host) + expect(sent.mock.calls[0]?.[1]).toMatchObject({ body: { from: TASK_FROM } }) + }) + it('reports the preamble delivered only on an accepted submission', async () => { await expect( send(hostWithSubmission({ dispatchState: 'accepted', reason: null })) diff --git a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts index 8bec6cfee309..3b1bc09f51ec 100644 --- a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts @@ -13,6 +13,7 @@ import { randomUUID } from 'node:crypto' import { isDefinitiveAgentSessionCreateRefusal } from '../../../../shared/agent-session-definitive-refusal' +import type { AgentMessageSource } from '../../../../shared/agent-session-message-source' import type { AgentJournalMessageItem, AgentJournalSubmission @@ -227,11 +228,14 @@ export async function sendStructuredWorkerPreamble(args: { sessionId: string dispatchId: string preamble: string + /** Who the task is from (`dispatchTaskSource`), shown on the worker's turn. */ + from: AgentMessageSource }): Promise<'accepted' | 'pending'> { const body: AgentJournalMessageItem = { kind: 'message', role: 'user', - blocks: [{ type: 'text', text: args.preamble }] + blocks: [{ type: 'text', text: args.preamble }], + from: args.from } const fence = args.host.deps.store.getRecord(args.sessionId)?.lease.runtimeFence if (fence === undefined) { @@ -260,7 +264,8 @@ export async function sendStructuredWorkerPreamble(args: { } } -function preambleDispatchState( +/** A sent preamble's verdict: acknowledged, or held for its agent; anything else throws. */ +export function preambleDispatchState( submission: AgentJournalSubmission | undefined ): 'accepted' | 'pending' { if (submission?.dispatchState === 'accepted' || submission?.dispatchState === 'pending') { 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 eaaf7d6f4aad..7852626c0a18 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 @@ -3,8 +3,8 @@ * * Two properties are pinned here, because both were false at some point in this lane: * - * - a worker is TAUGHT the same thing whichever mode it runs in, byte for byte once how it is named - * and the dispatch id are normalised. The sub-dispatch section used to be withheld from a structured + * - a worker is TAUGHT the same thing whichever mode it runs in, byte for byte once how it is named, + * where it runs (chat or terminal) and the dispatch id are normalised. The sub-dispatch section used to be withheld from a structured * worker, which is a two-tier capability model dressed as a preamble tweak; * - a structured worker can actually BE a coordinator. `worker-start` used to resolve `--from` * through `showTerminal`, which needs a PTY, so the capability the preamble withheld was in fact @@ -19,6 +19,10 @@ import { structuredWorkerIdentities, structuredWorkerProcessIncarnation } from '../../structured-worker-identity' +import { + CHAT_REDISPATCH_PARAGRAPH, + TERMINAL_REDISPATCH_PARAGRAPH +} from '../../orchestration/preamble' import { ORCHESTRATION_METHODS } from './orchestration' import { readStructuredWorkerOutput } from './orchestration-structured-worker-lifecycle' import { inspectWorkerTerminal } from './orchestration/worker/worker-observation' @@ -107,6 +111,11 @@ function normalizePreamble(preamble: string, handle: string, dispatchId: string) .join('') .replace(/dcap_[\w-]+/g, '') .replace(/task_[0-9a-f]+/g, '') + .split(CHAT_REDISPATCH_PARAGRAPH) + .join('') + .split(TERMINAL_REDISPATCH_PARAGRAPH) + .join('') + .replace(/\bthis (chat|terminal)\b/g, 'this ') } describe('a worker cannot tell which mode it is running in', () => { @@ -224,6 +233,10 @@ describe('a worker cannot tell which mode it is running in', () => { expect(structuredPreamble).toContain(`${selfLine}\n`) expect(structuredPreamble).not.toContain(STRUCTURED_HANDLE) expect(terminalPreamble).not.toContain('Orca session ID') + // Only the surface words differ: a chat is told about its chat, a terminal keeps main's text. + expect(structuredPreamble).toContain(CHAT_REDISPATCH_PARAGRAPH) + expect(terminalPreamble).toContain(TERMINAL_REDISPATCH_PARAGRAPH) + expect(structuredPreamble).not.toContain('this terminal') // The section the structured lane used to withhold, asserted by name so the equality above // cannot pass by both preambles losing it. expect(structuredPreamble).toContain('=== SUB-DISPATCH ===') 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 a2beea9ea05c..271071d59704 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 @@ -9,15 +9,18 @@ import { OrchestrationDb } from '../../orchestration/db' import { ORCHESTRATION_METHODS } from './orchestration' const STRUCTURED_HANDLE = 'structworker_abc' +// A real Orca session ID: a minted worker's preamble names it by this, not its handle. +const STRUCTURED_SESSION_ID = '9c2e4b7a-1d3f-4a6e-8b5c-7f0a2d9e6c41' const TERMINAL_HANDLE = 'term_worker' const createStructuredWorkerSessionForWorktree = vi.fn( async (args: { effects: { kind: string }[] }) => { args.effects.push({ kind: 'terminal' }) - return { identity: { handle: STRUCTURED_HANDLE, sessionId: 'sess_1' }, host: {} } + return { identity: { handle: STRUCTURED_HANDLE, sessionId: STRUCTURED_SESSION_ID }, host: {} } } ) const createExistingWorktreeWorkerTerminal = vi.fn(async () => ({ handle: TERMINAL_HANDLE })) +const sendStructuredWorkerPreamble = vi.fn(async (_args: { preamble: string }) => 'accepted') vi.mock('./orchestration/worker/worker-topology', async (importOriginal) => ({ ...(await importOriginal>()), @@ -30,7 +33,7 @@ vi.mock('./orchestration/federation/federated-worker-start', () => ({ })) vi.mock('./orchestration-structured-worker-session', async (importOriginal) => ({ ...(await importOriginal>()), - sendStructuredWorkerPreamble: async () => 'accepted', + sendStructuredWorkerPreamble: (args: { preamble: string }) => sendStructuredWorkerPreamble(args), releaseStructuredWorkerSession: () => {}, discardStructuredWorkerSession: async () => {} })) @@ -53,6 +56,7 @@ describe('worker-start honours the settings default', () => { beforeEach(() => { createStructuredWorkerSessionForWorktree.mockClear() createExistingWorktreeWorkerTerminal.mockClear() + sendStructuredWorkerPreamble.mockClear() db = new OrchestrationDb(':memory:') runtime = new OrcaRuntimeService() runtime.setOrchestrationDb(db) @@ -157,6 +161,13 @@ describe('worker-start honours the settings default', () => { }) expect(createStructuredWorkerSessionForWorktree).toHaveBeenCalledTimes(1) expect(createExistingWorktreeWorkerTerminal).not.toHaveBeenCalled() + const preamble = sendStructuredWorkerPreamble.mock.calls[0]![0].preamble + expect(preamble).toContain(`Your Orca session ID is: orca_session_id:${STRUCTURED_SESSION_ID}`) + expect(preamble).toContain('The coordinator cannot see this chat') + expect(preamble).toContain('it will send this chat a fresh') + expect(preamble).not.toContain('this terminal') + expect(preamble).not.toMatch(/exit the shell/i) + expect(runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled() }) it('starts a terminal agent worker when it is not', async () => { @@ -171,6 +182,11 @@ describe('worker-start honours the settings default', () => { }) expect(createExistingWorktreeWorkerTerminal).toHaveBeenCalledTimes(1) expect(createStructuredWorkerSessionForWorktree).not.toHaveBeenCalled() + const preamble = vi.mocked(runtime.sendTerminalAgentPrompt).mock.calls[0]![1] + expect(preamble).toContain('The coordinator cannot see this terminal') + expect(preamble).toContain('Do not exit the shell. Your terminal stays available') + expect(preamble).not.toContain('this chat') + expect(sendStructuredWorkerPreamble).not.toHaveBeenCalled() }) it('starts a terminal worker rather than failing when the host refuses a structured session', async () => { 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 93e9bb25a8b1..aee3bfefb6d0 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 @@ -87,7 +87,7 @@ describe('a structured default this dispatch cannot honour', () => { expect(decide({ params: { agent: 'codex', worktree: 'current' } }).mode).toBe('structured') }) - // A custom launch command applies to terminal launches only; native chat ignores it. + // A custom launch command never decides the surface; native chat runs it as the program. it.each([ ['claude', 'claude-wrapper'], ['codex', 'codex-nightly'] diff --git a/src/main/runtime/rpc/methods/orchestration-worker-start-mode.ts b/src/main/runtime/rpc/methods/orchestration-worker-start-mode.ts index 9cd9f51e0ac6..fcdaade4702b 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-start-mode.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-start-mode.ts @@ -55,7 +55,10 @@ export function decideWorkerStartMode(args: { return decideAgentLaunchMode({ placement: args.params, settings: args.settings, - vocabulary: WORKER_START_VOCABULARY + vocabulary: WORKER_START_VOCABULARY, + // The structured worker factory creates Claude and Codex only; any other agent (Grok included) + // starts as a terminal worker until structured workers take registered agents. + registeredStructuredAgents: [] }) } 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 4a228ebc676c..4648880586b7 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 @@ -74,7 +74,7 @@ describe('worker-start mode receipt wording', () => { ], [ 'agent with no structured session', - { agent: 'grok' }, + { agent: 'gemini' }, 'agent_without_structured_session', 'this agent has no structured session' ] 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 new file mode 100644 index 000000000000..175f650c7a1f --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-worker-start-registered-agent.test.ts @@ -0,0 +1,64 @@ +// An orchestration worker for a registered agent (Grok) opens as a terminal worker, as before Grok +// had a structured chat: the structured worker factory creates Claude and Codex only, so a +// structured verdict for Grok would fail the start. Other launches still open Grok as a chat. + +import { describe, expect, it } from 'vitest' +import { decideAgentLaunchMode } from '../../../agent-launch/agent-launch-mode' +import { + decideWorkerStartMode, + resolveWorkerStartModeOnHost +} from './orchestration-worker-start-mode' +import { createStructuredWorkerSessionForWorktree } from './orchestration/worker/worker-topology' + +const STRUCTURED_PREFERENCE = { + experimentalNativeChat: true, + experimentalStructuredNativeChat: true, + openAgentTabsInChatByDefault: true +} as const + +describe('a Grok worker and the structured-chat setting', () => { + it('starts as a terminal worker while the setting is on; Claude starts structured', async () => { + const grok = decideWorkerStartMode({ + params: { agent: 'grok' }, + settings: STRUCTURED_PREFERENCE + }) + expect(grok).toMatchObject({ mode: 'terminal', reason: 'agent_without_structured_session' }) + const host = { getStructuredAgentSessionCreateSupport: async () => ({ supported: true }) } + expect(await resolveWorkerStartModeOnHost(host, grok, 'wt-1', 'grok')).toMatchObject({ + mode: 'terminal' + }) + expect( + decideWorkerStartMode({ params: { agent: 'claude' }, settings: STRUCTURED_PREFERENCE }) + ).toMatchObject({ mode: 'structured', reason: 'user_default' }) + }) + + it('still opens Grok as a structured chat for other launches', () => { + expect( + decideAgentLaunchMode({ placement: { agent: 'grok' }, settings: STRUCTURED_PREFERENCE }) + ).toMatchObject({ mode: 'structured' }) + }) + + it('would fail a structured Grok worker: the factory creates Claude and Codex only', async () => { + await expect( + createStructuredWorkerSessionForWorktree({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the factory refuses the agent before it reads the runtime. + runtime: {} as never, + worktreeId: 'wt-1', + agent: 'grok', + dispatchId: 'd-1', + effects: [] + }) + ).rejects.toThrow(/Structured workers support claude and codex/) + }) + + it('starts as a terminal agent while the setting is off, as Claude does', () => { + for (const agent of ['grok', 'claude']) { + expect( + decideWorkerStartMode({ + params: { agent }, + settings: { ...STRUCTURED_PREFERENCE, experimentalStructuredNativeChat: false } + }) + ).toMatchObject({ mode: 'terminal', preferred: 'terminal', reason: 'user_default' }) + } + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration/chat-assignee-admission.ts b/src/main/runtime/rpc/methods/orchestration/chat-assignee-admission.ts new file mode 100644 index 000000000000..7717413a9af6 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/chat-assignee-admission.ts @@ -0,0 +1,83 @@ +import type { AgentSessionRecord } from '../../../../../shared/agent-session-record' +import { + formatOrcaSessionAddress, + type OrcaSessionId +} from '../../../../../shared/orca-session-address' +import { ORCHESTRATION_SESSION_CALLER_ERROR_CODES as CODES } from '../../../../../shared/orchestration-session-caller-codes' +import type { OrcaRuntimeService } from '../../../orca-runtime' +import type { WorkerStartModeReceipt } from '../orchestration-worker-start-mode' +import { observeChatAssignee } from '../../../orchestration/chat-assignee' +import type { OrchestrationDb } from '../../../orchestration/db' +import { OrchestrationError } from '../../../orchestration/orchestration-error' +import type { OrchestrationParty } from '../../../orchestration/orchestration-party' +import { readAgentSessionRecordStore } from '../../../orchestration/structured-session-lineage' +import { refuseUndeliverableSessionRecipient } from './messaging/session-recipient' + +const NO_EFFECTS = 'No effects were applied.' + +/** A chat has no pane or process; its Dispatch names it by its Orca session ID alone. */ +export const CHAT_WORKER_AUTHORITY = { paneKey: null, processIncarnation: null } as const + +/** The chat a Dispatch assignee party names; null for a terminal or a structured worker. */ +export function chatAssigneeOf(party: OrchestrationParty): OrcaSessionId | null { + return party.terminalHandle === null ? party.orcaSessionId : null +} + +/** + * Refuses a chat assignee before anything is created, by the same reach rules as mail to it: its + * task and the coordinator's mail reach it the same way. Returns the session running it now. + */ +export async function admitChatAssignee( + runtime: Pick, + sessionId: OrcaSessionId, + db: OrchestrationDb +): Promise { + // A failed install leaves no store, which the refusal below names. + await runtime.ensureStructuredAgentSessionHost().catch(() => undefined) + const store = readAgentSessionRecordStore() + const refusal = refuseUndeliverableSessionRecipient( + { sessionId, address: formatOrcaSessionAddress(sessionId) }, + store, + db, + NO_EFFECTS + ) + if (refusal) { + throw new OrchestrationError(refusal.code, refusal.message, { effectsApplied: false }) + } + const observed = observeChatAssignee(sessionId, db, store) + if (observed.status !== 'live') { + throw new OrchestrationError( + CODES.notLive, + `Agent session ${sessionId} cannot take a task here: ${observed.reason} ${NO_EFFECTS}`, + { effectsApplied: false } + ) + } + return observed.session +} + +/** A coordinator can't be its own worker: it would answer its own task forever. */ +export function refuseChatSelfAssignment(args: { + sessionId: OrcaSessionId + coordinatorSessionId: OrcaSessionId | null | undefined + remedy: string +}): void { + if (args.sessionId === args.coordinatorSessionId) { + throw new OrchestrationError( + 'terminal_is_coordinator', + `${formatOrcaSessionAddress(args.sessionId)} is this coordinator's own Orca session ID. ${args.remedy}` + ) + } +} + +/** A chat adopted as a worker is given the task, not launched; the decided mode stands. */ +export function chatWorkerMode( + mode: WorkerStartModeReceipt, + chat: OrcaSessionId | null +): WorkerStartModeReceipt { + return chat + ? { + ...mode, + detail: `--terminal names the chat ${formatOrcaSessionAddress(chat)}; its task goes to that chat and no agent is launched.` + } + : mode +} diff --git a/src/main/runtime/rpc/methods/orchestration/chat-task-delivery.ts b/src/main/runtime/rpc/methods/orchestration/chat-task-delivery.ts new file mode 100644 index 000000000000..8070bd2508f7 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/chat-task-delivery.ts @@ -0,0 +1,85 @@ +/** + * A chat assignee's task, sent as any agent's message into a chat is: the composer's queued send, + * held as a card while the chat is busy, recording the Dispatch it is from. The operation id is + * derived from the Dispatch, so a second send for one Dispatch replays instead of queueing twice. + */ + +import { createHash } from 'node:crypto' +import type { AgentJournalMessageItem } from '../../../../../shared/agent-session-journal-types' +import type { AgentMessageSource } from '../../../../../shared/agent-session-message-source' +import { getStructuredAgentSessionHost } from '../../../../native-chat/agent-session-wire/structured-agent-session-registry' +import { chatAssigneeSessionId, observeChatAssignee } from '../../../orchestration/chat-assignee' +import type { OrchestrationDb } from '../../../orchestration/db' +import { exposeUtcTimestamp } from '../../../orchestration/db/utc-timestamp' +import { OrchestrationError } from '../../../orchestration/orchestration-error' +import { sendAgentTurn } from '../../../orchestration/send-agent-turn' +import { structuredPointerCallerKey } from '../../../orchestration/structured-mailbox-pointer-host' +import type { DispatchContextRow } from '../../../orchestration/types' +import { preambleDispatchState } from '../orchestration-structured-worker-session' + +/** Handed over in every case: started, still starting its agent, or held as a card. */ +export type ChatTaskDelivery = 'accepted' | 'pending' | 'queued' + +type ChatTaskDispatch = Pick + +/** The host's `<13-digit ms>-<32 hex>` shape, stamped at the Dispatch's creation. */ +export function chatTaskOperationId( + dispatch: Pick +): string { + const createdAt = Date.parse(exposeUtcTimestamp(dispatch.created_at) ?? dispatch.created_at) + if (!Number.isSafeInteger(createdAt) || createdAt < 0) { + throw new Error(`Dispatch ${dispatch.id} has an unreadable creation time.`) + } + const digest = createHash('sha256').update(dispatch.id).digest('hex').slice(0, 32) + return `${String(createdAt).padStart(13, '0')}-${digest}` +} + +/** Sends the task into the chat's live session. Throws when nothing was handed over. */ +export async function sendChatTask(args: { + db: OrchestrationDb + dispatch: ChatTaskDispatch + /** Who the task is from (`dispatchTaskSource`), shown on the chat's card or turn. */ + from: AgentMessageSource + preamble: string +}): Promise { + const sessionId = chatAssigneeSessionId(args.dispatch.assignee_handle) + const host = getStructuredAgentSessionHost() + const observed = + sessionId && host ? observeChatAssignee(sessionId, args.db, host.deps.store) : null + if (!host || observed?.status !== 'live') { + throw new OrchestrationError( + 'dispatch_preamble_undelivered', + `The task was not delivered: ${observed && observed.status !== 'live' ? observed.reason : 'the chat cannot be reached here.'}` + ) + } + const body: AgentJournalMessageItem = { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: args.preamble }], + from: args.from + } + const outcome = await sendAgentTurn({ + kind: 'structured-session', + host, + sessionId: observed.session.sessionId, + callerKey: structuredPointerCallerKey(args.dispatch.id), + turn: { + body, + // As a person's message is: a busy chat queues it as a card, sent when the queue reaches it. + delivery: 'queue', + operationId: chatTaskOperationId(args.dispatch), + expectedRuntimeFence: observed.session.lease.runtimeFence + } + }) + switch (outcome.kind) { + case 'refused': + throw new OrchestrationError( + 'dispatch_preamble_undelivered', + `The task was refused: ${outcome.refusal.message}` + ) + case 'queued': + return 'queued' + case 'sent': + return preambleDispatchState(outcome.submission) + } +} diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federated-fleet-snapshot.ts b/src/main/runtime/rpc/methods/orchestration/federation/federated-fleet-snapshot.ts index df6886448503..ab95c74f889f 100644 --- a/src/main/runtime/rpc/methods/orchestration/federation/federated-fleet-snapshot.ts +++ b/src/main/runtime/rpc/methods/orchestration/federation/federated-fleet-snapshot.ts @@ -227,7 +227,7 @@ export function applyFederatedFleetObservations( } // Recompute every projection derived from the host's verdict. -function refreshFleetWorkerVerdict( +export function refreshFleetWorkerVerdict( worker: OrchestrationFleetPage['workers'][number], durable: ReadonlyMap ): void { diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/session-recipient.ts b/src/main/runtime/rpc/methods/orchestration/messaging/session-recipient.ts index 58c4a0e4ba73..b5325d70b25b 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/session-recipient.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/session-recipient.ts @@ -67,40 +67,44 @@ export function readSessionRecipient( : null } -/** Null when mail to this session can be stored and delivered here; otherwise why not. */ +/** + * Null when mail to this session can be stored and delivered here; otherwise why not. A Dispatch + * to a chat asks the same question, since its task and its mail reach the chat the same way. + */ export function refuseUndeliverableSessionRecipient( recipient: SessionRecipient, store: AgentSessionRecordReader | null, - db: OrchestrationDb + db: OrchestrationDb, + noEffect = 'No message was sent.' ): SessionRecipientRefusal | null { const { sessionId } = recipient if (!store) { return { code: CODES.unknown, - message: `Agent session ${sessionId} cannot be verified: this Orca is not running its agent-session host. No message was sent.` + message: `Agent session ${sessionId} cannot be verified: this Orca is not running its agent-session host. ${noEffect}` } } const found = lookupOrcaAgentSession(store, sessionId) if (found.kind === 'provider-id') { - return providerIdRefusal(sessionId, found.orcaSessionId, store) + return providerIdRefusal(sessionId, found.orcaSessionId, store, noEffect) } if (found.kind === 'unknown') { return { code: CODES.unknown, - message: `No Orca agent session ${sessionId} exists on this host. No message was sent.` + message: `No Orca agent session ${sessionId} exists on this host. ${noEffect}` } } const reach = structuredSessionMailReach(store, found.record, db) if (reach.kind === 'other-host') { return { code: CODES.hostBoundary, - message: `Agent session ${sessionId} runs on another host; mail reaches a session only on the host that runs it. Send from that host. No message was sent.` + message: `Agent session ${sessionId} runs on another host; mail reaches a session only on the host that runs it. Send from that host. ${noEffect}` } } if (reach.kind === 'unverifiable') { return { code: CODES.notLive, - message: `The session continuing agent session ${sessionId} after a /clear cannot be verified: ${reach.reason} No message was sent.` + message: `The session continuing agent session ${sessionId} after a /clear cannot be verified: ${reach.reason} ${noEffect}` } } if (reach.kind === 'ended') { @@ -108,8 +112,8 @@ export function refuseUndeliverableSessionRecipient( code: CODES.notLive, message: reach.reason === 'worker-identity-lost' - ? `Agent session ${sessionId} is a structured worker whose worker identity this host no longer has, so it can never read that mail. No message was sent.` - : `Agent session ${sessionId} has ended: its chat was closed. No message was sent.` + ? `Agent session ${sessionId} is a structured worker whose worker identity this host no longer has, so it can never read that mail. ${noEffect}` + : `Agent session ${sessionId} has ended: its chat was closed. ${noEffect}` } } return null @@ -118,7 +122,8 @@ export function refuseUndeliverableSessionRecipient( function providerIdRefusal( id: string, orcaSessionId: string, - store: AgentSessionRecordReader | null + store: AgentSessionRecordReader | null, + noEffect = 'No message was sent.' ): SessionRecipientRefusal { // The conversation's Orca session ID, which a `/clear`ed session keeps; not the live session's. const root = isOrcaSessionId(orcaSessionId) @@ -127,6 +132,6 @@ function providerIdRefusal( const address = `${ORCA_SESSION_ADDRESS_PREFIX}${root}` return { code: CODES.providerId, - message: `${id} is the provider's own session id, which changes on /clear. This session's Orca session ID is ${address}; address it by that instead. No message was sent.` + message: `${id} is the provider's own session id, which changes on /clear. This session's Orca session ID is ${address}; address it by that instead. ${noEffect}` } } diff --git a/src/main/runtime/rpc/methods/orchestration/runs/dispatch-methods.ts b/src/main/runtime/rpc/methods/orchestration/runs/dispatch-methods.ts index 43686705b013..6834aeb22b9f 100644 --- a/src/main/runtime/rpc/methods/orchestration/runs/dispatch-methods.ts +++ b/src/main/runtime/rpc/methods/orchestration/runs/dispatch-methods.ts @@ -14,6 +14,13 @@ import { orcaSessionIdOrHandle, resolveDispatchAssigneeParty } from '../../../../orchestration/orchestration-party' +import { + admitChatAssignee, + chatAssigneeOf, + refuseChatSelfAssignment +} from '../chat-assignee-admission' +import { sendChatTask } from '../chat-task-delivery' +import { dispatchTaskSource } from '../../../../orchestration/dispatch-task-source' export const ORCHESTRATION_DISPATCH_METHODS = [ defineMethod({ @@ -49,7 +56,13 @@ export const ORCHESTRATION_DISPATCH_METHODS = [ runId: run.id }) } - const assignee = params.to ? resolveDispatchAssigneeParty(params.to, db).address : undefined + const assigneeParty = params.to ? resolveDispatchAssigneeParty(params.to, db) : undefined + const assignee = assigneeParty?.address + // A chat has no pane, process or agent to probe: it is reached as mail to it is. + const chatAssignee = assigneeParty ? chatAssigneeOf(assigneeParty) : null + if (chatAssignee) { + await admitChatAssignee(runtime, chatAssignee, db) + } // Why: dry-run previews the preamble without mutating state, so it skips the ready-status check and uses a placeholder dispatchId. if (params.dryRun) { @@ -84,9 +97,10 @@ export const ORCHESTRATION_DISPATCH_METHODS = [ ) } - const dispatchAuthority = runtime.getOrchestrationDispatchAuthority(to) - const assigneePaneKey = - dispatchAuthority?.paneKey ?? runtime.getTerminalPaneKey(to) ?? undefined + const dispatchAuthority = chatAssignee ? null : runtime.getOrchestrationDispatchAuthority(to) + const assigneePaneKey = chatAssignee + ? undefined + : (dispatchAuthority?.paneKey ?? runtime.getTerminalPaneKey(to) ?? undefined) const processIncarnation = dispatchAuthority?.paneKey && dispatchAuthority.processIncarnation ? dispatchAuthority.processIncarnation @@ -98,6 +112,14 @@ export const ORCHESTRATION_DISPATCH_METHODS = [ runtime.getTerminalPaneKey(params.from) ?? null) : null + if (params.inject && chatAssignee) { + // The session wins over `--from`, so a chat calling with none is still refused here. + refuseChatSelfAssignment({ + sessionId: chatAssignee, + coordinatorSessionId: orchestrationCaller?.orcaSessionId, + remedy: 'Dispatch to a different agent, or use worker-start to create one.' + }) + } if ( params.inject && params.from && @@ -113,14 +135,14 @@ export const ORCHESTRATION_DISPATCH_METHODS = [ } // Why: injecting the preamble into a bare shell dumps it as shell commands (gibberish), so require a detected agent first. - if (params.inject) { + if (params.inject && !chatAssignee) { const hasAgent = await runtime.isTerminalRunningAgent(to) if (!hasAgent) { throw injectRejectedError(to, 'no_agent_detected') } } - if (params.inject && (!assigneePaneKey || !processIncarnation)) { + if (params.inject && !chatAssignee && (!assigneePaneKey || !processIncarnation)) { throw new OrchestrationError( 'stable_pane_required', `Terminal ${to} has no stable pane/process incarnation for lifecycle authority.` @@ -154,19 +176,34 @@ export const ORCHESTRATION_DISPATCH_METHODS = [ let prompt if (params.inject) { try { - prompt = await sendAgentTurn({ - kind: 'terminal', - runtime, - handle: to, - turn: { - purpose: 'dispatch-preamble', - body: preamble, - operationId: orchestrationMutation?.requestId ?? ctx.id - } - }) + if (chatAssignee) { + const from = dispatchTaskSource({ + db, + dispatch: ctx, + from: params.from, + senderName: (party, reported) => + runtime.orchestrationSenderNames.nameOf(party, reported) + }) + await sendChatTask({ db, dispatch: ctx, from, preamble }) + } else { + prompt = await sendAgentTurn({ + kind: 'terminal', + runtime, + handle: to, + turn: { + purpose: 'dispatch-preamble', + body: preamble, + operationId: orchestrationMutation?.requestId ?? ctx.id + } + }) + } injected = true } catch (err) { - db.failDispatch(ctx.id, err instanceof Error ? err.message : String(err)) + // Unknown is not undelivered: the chat may be running it, so the Dispatch stays open, as + // an unknown worker-start does, for the worker's report or the coordinator's abandon. + if (!(err instanceof OrchestrationError && err.code === 'operation_unknown')) { + db.failDispatch(ctx.id, err instanceof Error ? err.message : String(err)) + } throw err } } diff --git a/src/main/runtime/rpc/methods/orchestration/runs/tasks-dispatch.test.ts b/src/main/runtime/rpc/methods/orchestration/runs/tasks-dispatch.test.ts index d4e0737ffdb5..67b7845e80e8 100644 --- a/src/main/runtime/rpc/methods/orchestration/runs/tasks-dispatch.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/runs/tasks-dispatch.test.ts @@ -393,6 +393,10 @@ describe('orchestration RPC methods', () => { expect.stringContaining('line one\nline two'), expect.objectContaining(dispatchPreambleSendOptions(expect.any(String))) ) + const preamble = agentPrompt.mock.calls[0]![1] + expect(preamble).toContain('The coordinator cannot see this terminal') + expect(preamble).toContain('Do not exit the shell. Your terminal stays available') + expect(preamble).not.toContain('this chat') expect(rawSend).not.toHaveBeenCalled() }) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/deliver-worker-dispatch-preamble.ts b/src/main/runtime/rpc/methods/orchestration/worker/deliver-worker-dispatch-preamble.ts index 7234da7be4bb..5fce3402cc5f 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/deliver-worker-dispatch-preamble.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/deliver-worker-dispatch-preamble.ts @@ -11,6 +11,11 @@ import { buildDispatchPreamble } from '../../../../orchestration/preamble' import { sendAgentTurn } from '../../../../orchestration/send-agent-turn' import { createWorkerBriefWriteGuard } from '../../../../launched-agent-write-guard' import { sendStructuredWorkerPreamble } from '../../orchestration-structured-worker-session' +import { chatAssigneeSessionId } from '../../../../orchestration/chat-assignee' +import { OrchestrationError } from '../../../../orchestration/orchestration-error' +import { sendChatTask, type ChatTaskDelivery } from '../chat-task-delivery' +import { dispatchTaskSource } from '../../../../orchestration/dispatch-task-source' +import type { AgentMessageSource } from '../../../../../../shared/agent-session-message-source' import type { WorkerTurnStartObservation } from './worker-start-turn-observation' import type { createStructuredWorkerSessionForWorktree } from './worker-topology' @@ -19,11 +24,12 @@ type StructuredSession = Awaited args.runtime.orchestrationSenderNames.nameOf(party, reported) + }) +} diff --git a/src/main/runtime/rpc/methods/orchestration/worker/explicit-worker-terminal-validation.ts b/src/main/runtime/rpc/methods/orchestration/worker/explicit-worker-terminal-validation.ts index f9b9e195e21a..a3ee27827f5b 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/explicit-worker-terminal-validation.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/explicit-worker-terminal-validation.ts @@ -2,6 +2,15 @@ import type { OrcaRuntimeService } from '../../../../orca-runtime' import { OrchestrationError } from '../../../../orchestration/orchestration-error' import { isStructuredWorkerHandle } from '../../../../structured-worker-identity' import type { OrchestrationCallerIdentity } from '../../../../orchestration/orchestration-caller-identity' +import type { OrchestrationDb } from '../../../../orchestration/db' +import type { OrcaSessionId } from '../../../../../../shared/orca-session-address' +import { formatOrcaSessionAddress } from '../../../../../../shared/orca-session-address' +import { + admitChatAssignee, + chatAssigneeOf, + refuseChatSelfAssignment +} from '../chat-assignee-admission' +import type { OrchestrationParty } from '../../../../orchestration/orchestration-party' /** * Admits a caller-supplied `--terminal` as this dispatch's worker pane. @@ -52,3 +61,40 @@ export async function assertExplicitWorkerTerminalUsable(args: { ) } } + +/** A `--terminal` party: a terminal pane, or a chat named by its Orca session ID. */ +export async function assertExplicitWorkerUsable(args: { + runtime: OrcaRuntimeService + db: OrchestrationDb + terminal: OrchestrationParty + from: string + coordinator: OrchestrationCallerIdentity | null + resolvedWorktreeId: string | undefined +}): Promise { + const chat = chatAssigneeOf(args.terminal) + await (chat + ? assertExplicitWorkerChatUsable({ ...args, chat }) + : assertExplicitWorkerTerminalUsable({ ...args, terminal: args.terminal.address })) +} + +/** The same refusals for a chat named by its Orca session ID: itself, unreachable, another worktree. */ +async function assertExplicitWorkerChatUsable(args: { + runtime: Pick + db: OrchestrationDb + chat: OrcaSessionId + coordinator: OrchestrationCallerIdentity | null + resolvedWorktreeId: string | undefined +}): Promise { + refuseChatSelfAssignment({ + sessionId: args.chat, + coordinatorSessionId: args.coordinator?.orcaSessionId, + remedy: 'Pass --terminal for a different agent, or omit it so worker-start creates one.' + }) + const session = await admitChatAssignee(args.runtime, args.chat, args.db) + if (session.location.workspaceId !== args.resolvedWorktreeId) { + throw new OrchestrationError( + 'terminal_worktree_mismatch', + `${formatOrcaSessionAddress(args.chat)} does not belong to worktree ${args.resolvedWorktreeId}.` + ) + } +} diff --git a/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts b/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts index 7a6168dbcd20..b46e8d744265 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts @@ -26,7 +26,9 @@ import { } from './worker-setup-gate' import { failWorkerStartWithReceipt } from './worker-start-receipt' import { parseTaskDeps } from './task-deps-argument' -import { assertExplicitWorkerTerminalUsable } from './explicit-worker-terminal-validation' +import { assertExplicitWorkerUsable } from './explicit-worker-terminal-validation' +import { resolveDispatchAssigneeParty } from '../../../../orchestration/orchestration-party' +import { CHAT_WORKER_AUTHORITY, chatAssigneeOf, chatWorkerMode } from '../chat-assignee-admission' import { recordCreatedWorkerTerminalCustody } from './created-worker-terminal-custody' import { tearDownFailedWorkerStart } from './failed-worker-start-teardown' import { requireWorkerAuthority, type WorkerEffect } from './worker-topology' @@ -52,8 +54,12 @@ export async function startLocalWorker(args: { /** Settings-driven; the executing host still gets to refuse below. */ mode: WorkerStartModeReceipt }): Promise { - const { params, runtime, db, run, coordinator, callerSession, existingTask } = args + const { runtime, db, run, coordinator, callerSession, existingTask } = args const { orchestrationMutation } = args + // `--terminal` names a party: a chat by its `/clear` root address, as its Dispatch does. + const terminal = args.params.terminal && resolveDispatchAssigneeParty(args.params.terminal, db) + const params = terminal ? { ...args.params, terminal: terminal.address } : args.params + const chat = terminal ? chatAssigneeOf(terminal) : null const coordinatorPane = coordinator?.paneKey ?? null const requestedWorktree = params.worktree ?? 'current' const createsWorktree = requestedWorktree === 'new-child' || requestedWorktree === 'new-top-level' @@ -113,16 +119,18 @@ export async function startLocalWorker(args: { : requestedWorktree === 'current' ? await runtime.showManagedTerminalWorkspace(`id:${coordinatorWorktreeId}`) : await runtime.showManagedTerminalWorkspace(requestedWorktree) - if (params.terminal) { - await assertExplicitWorkerTerminalUsable({ + if (terminal) { + await assertExplicitWorkerUsable({ runtime, - terminal: params.terminal, + db, + terminal, from: params.from, coordinator, resolvedWorktreeId: resolvedWorktree?.id }) } - let mode = await resolveWorkerStartModeOnHost(runtime, args.mode, resolvedWorktree?.id, agent) + const hostMode = resolveWorkerStartModeOnHost(runtime, args.mode, resolvedWorktree?.id, agent) + let mode = chatWorkerMode(await hostMode, chat) const startOptions = { worktree: requestedWorktree, @@ -225,11 +233,14 @@ export async function startLocalWorker(args: { effects, timeoutMs: params.timeoutMs ?? 60_000 }) - : await waitForWorkerAgentReady(runtime, terminalHandle, { - agent, - reusesTerminal: Boolean(params.terminal), - timeoutMs: params.timeoutMs ?? 60_000 - }) + : chat + ? // A chat takes its task as a queued send whatever it is doing, so nothing is waited on. + null + : await waitForWorkerAgentReady(runtime, terminalHandle, { + agent, + reusesTerminal: Boolean(params.terminal), + timeoutMs: params.timeoutMs ?? 60_000 + }) if (wait) { persistWorkerSetupWaitOutcome({ ...setupStage, wait }) if (!wait.satisfied) { @@ -245,7 +256,10 @@ export async function startLocalWorker(args: { ) } } - const terminalAuthority = requireWorkerAuthority(runtime, terminalHandle) + // A chat has no pane or process; its Dispatch names it by its Orca session ID alone. + const terminalAuthority = chat + ? CHAT_WORKER_AUTHORITY + : requireWorkerAuthority(runtime, terminalHandle) db.prepareStartingWorkerAuthority({ dispatchId: started.dispatch.id, handle: terminalHandle, diff --git a/src/main/runtime/rpc/methods/orchestration/worker/session-worker-observation.ts b/src/main/runtime/rpc/methods/orchestration/worker/session-worker-observation.ts new file mode 100644 index 000000000000..62f5635847bf --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/worker/session-worker-observation.ts @@ -0,0 +1,91 @@ +import type { OrcaRuntimeService } from '../../../../orca-runtime' +import type { OrcaSessionId } from '../../../../../../shared/orca-session-address' +import { + chatAssigneeSessionId, + observeChatAssignee, + type ChatAssigneeObservation +} from '../../../../orchestration/chat-assignee' +import type { OrchestrationDb } from '../../../../orchestration/db' +import { + holdStructuredWorker, + observeStructuredSession +} from '../../../../structured-worker-authority' +import { resolveStructuredWorkerForDispatch } from '../../orchestration-structured-worker-lifecycle' + +export type SessionWorkerObservation = { + terminal: null + exact: boolean + status: 'identity_changed' | 'live' | 'exited' | 'unverifiable' + reason?: string + addressable?: boolean + terminalHandle: null +} + +/** + * A worker that is an agent session, a structured worker or a chat, observed off the session + * records instead of a terminal; null for a PTY worker. + */ +export async function inspectSessionWorker( + runtime: Pick, + db: OrchestrationDb, + dispatchId: string, + terminalHandle: string +): Promise { + const chat = chatAssigneeSessionId(terminalHandle) + if (chat) { + // The address is the chat's whole identity, so it is always exact. After a restart nothing + // has installed the host yet, and its records are what this reads. + await runtime.ensureStructuredAgentSessionHost().catch(() => undefined) + const observed = observeChatAssignee(chat, db) + return { + terminal: null, + exact: true, + status: observed.status, + ...(observed.status === 'unverifiable' ? { reason: observed.reason } : {}), + terminalHandle: null + } + } + const structured = resolveStructuredWorkerForDispatch(db, dispatchId) + if (!structured) { + return null + } + // Exactness is the recorded pane and lineage, which the runtime getters answer from the + // structured registry; there is no terminal to show. + // + // `agentWait` is deliberately ABSENT rather than null. Null is the contract's "Orca looked and + // found no wait", and nothing here looks: a structured worker parks on a journal question item, + // which no terminal prompt scan can see. Reporting null would tell a coordinator the worker is + // not waiting, which is the one thing the field's own documentation forbids inferring. + const exact = db.isDispatchProcessCurrent({ + dispatchId, + paneKey: structured.paneKey, + processIncarnation: structured.processIncarnation + }) + // One lineage walk, so status and `addressable` judge the same session even mid-`/clear`. + const hold = holdStructuredWorker(structured, db) + const observation = + hold.kind === 'unverifiable' + ? { status: 'unverifiable' as const, reason: hold.reason } + : observeStructuredSession(hold.running.sessionId) + const addressable = hold.kind === 'unverifiable' ? null : hold.kind === 'held' + return { + terminal: null, + exact, + status: exact ? observation.status : 'identity_changed', + ...(exact && observation.reason ? { reason: observation.reason } : {}), + ...(exact && addressable !== null ? { addressable } : {}), + terminalHandle: null + } +} + +/** A fleet page's chat verdicts, read off the session records a restart leaves uninstalled. */ +export async function chatAssigneeObserver( + runtime: Pick, + db: OrchestrationDb, + rows: readonly { agentTerminalHandle: string | null }[] +): Promise<(sessionId: OrcaSessionId) => ChatAssigneeObservation> { + if (rows.some((row) => chatAssigneeSessionId(row.agentTerminalHandle))) { + await runtime.ensureStructuredAgentSessionHost().catch(() => undefined) + } + return (sessionId) => observeChatAssignee(sessionId, db) +} diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-list-method.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-list-method.ts index bf2fef1bd702..9c77fd8895fe 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-list-method.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-list-method.ts @@ -21,6 +21,7 @@ import { readWorkerListSnapshot } from './worker-list-snapshot-store' import { projectWorkerFleet, type WorkerListPageParams } from './worker-list-projection' +import { chatAssigneeObserver } from './session-worker-observation' import { exposeWorkerTerminalResource } from './worker-release-completion' import { WORKER_TERMINAL_LIST_STATES, WorkerListParams } from './worker-release-schemas' @@ -203,7 +204,8 @@ async function projectWorkerListPageWithFilteredSnapshot( statuses, limit, now: authorityNow, - completeProjection: args.completeProjection + completeProjection: args.completeProjection, + observeChat: await chatAssigneeObserver(runtime, db, pageRows) }) const federated = params.includeRemote ? await readFederatedFleetSnapshots({ diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-list-projection.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-list-projection.ts index fc3ce32567b3..63006a391245 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-list-projection.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-list-projection.ts @@ -1,11 +1,18 @@ import { ORCHESTRATION_FLEET_PAGE_MAX, projectOrchestrationFleet, - type FleetDurableWorker + type FleetDurableWorker, + type OrchestrationFleetWorker } from '../../../../../../shared/orchestration-fleet-projection' import { resolveFleetWorkerOutcome } from '../../../../../../shared/orchestration-fleet-outcome-resolution' import type { WorkerTerminalListState } from '../../../../orchestration/worker-terminal-ownership' import type { OrchestrationDb } from '../../../../orchestration/db' +import type { OrcaSessionId } from '../../../../../../shared/orca-session-address' +import { + chatAssigneeSessionId, + type ChatAssigneeObservation +} from '../../../../orchestration/chat-assignee' +import { refreshFleetWorkerVerdict } from '../federation/federated-fleet-snapshot' export type WorkerListPageParams = { run?: string @@ -21,6 +28,8 @@ export function projectWorkerFleet(args: { limit: number now: number completeProjection?: boolean + /** A chat assignee's verdict; no agent-status row names a chat, so its reach stands in. */ + observeChat?: (sessionId: OrcaSessionId) => ChatAssigneeObservation }) { const workers: FleetDurableWorker[] = args.rows.map((row) => { return { @@ -49,7 +58,7 @@ export function projectWorkerFleet(args: { }) const durable = new Map(workers.map((worker) => [worker.dispatchId, worker])) if (!args.completeProjection) { - return { + const fleet = { ...projectOrchestrationFleet({ workers, statuses: args.statuses, @@ -58,6 +67,8 @@ export function projectWorkerFleet(args: { }), durable } + applyChatAssigneeLiveness(fleet, args.observeChat, args.now) + return fleet } const projections: ReturnType['workers'] = [] @@ -71,9 +82,36 @@ export function projectWorkerFleet(args: { }).workers ) } - return { + const fleet = { workers: projections, page: { limit: workers.length, total: workers.length, hasMore: false, nextCursor: null }, durable } + applyChatAssigneeLiveness(fleet, args.observeChat, args.now) + return fleet +} + +/** Read off the session records, as worker-show and mail read it: at rest is live, closed exited. */ +function applyChatAssigneeLiveness( + fleet: { workers: OrchestrationFleetWorker[]; durable: ReadonlyMap }, + observeChat: ((sessionId: OrcaSessionId) => ChatAssigneeObservation) | undefined, + now: number +): void { + for (const worker of observeChat ? fleet.workers : []) { + const chat = chatAssigneeSessionId(fleet.durable.get(worker.dispatchId)?.agentTerminalHandle) + // A durable death certificate (a stop, a release) outranks a read of the records. + if (!chat || !observeChat || worker.liveness.verdict === 'exited') { + continue + } + const observed = observeChat(chat) + worker.liveness = + observed.status === 'live' + ? { verdict: 'live', observedAt: now, source: 'execution_host' } + : observed.status === 'exited' + ? { verdict: 'exited', source: 'execution_host' } + : { verdict: 'unverifiable', reason: 'host_unavailable' } + worker.evidence.liveStatus = observed.status === 'live' ? 'fresh' : 'unavailable' + worker.evidence.lastObservedAt = observed.status === 'live' ? now : null + refreshFleetWorkerVerdict(worker, fleet.durable) + } } diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-observation.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-observation.ts index 40b856ef1657..a47dca5ec937 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-observation.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-observation.ts @@ -5,11 +5,8 @@ import { OrchestrationError } from '../../../../orchestration/orchestration-erro import { parseWorkerTerminalHostScope } from '../../../../orchestration/worker-terminal-process-liveness' import type { OrchestrationFleetWorker } from '../../../../../../shared/orchestration-fleet-projection' import { projectWorkerFleet } from './worker-list-projection' -import { resolveStructuredWorkerForDispatch } from '../../orchestration-structured-worker-lifecycle' -import { - holdStructuredWorker, - observeStructuredSession -} from '../../../../structured-worker-authority' +import { observeChatAssignee } from '../../../../orchestration/chat-assignee' +import { inspectSessionWorker } from './session-worker-observation' import type { DispatchContextRow, FederatedDispatchRow, @@ -42,35 +39,9 @@ export async function inspectWorkerTerminal( if (!terminalHandle) { return { terminal: null, exact: false, status: 'unattached', terminalHandle: null } } - const structured = resolveStructuredWorkerForDispatch(db, dispatchId) - if (structured) { - // Exactness is the recorded pane and lineage, which the runtime getters answer from the - // structured registry; there is no terminal to show. - // - // `agentWait` is deliberately ABSENT rather than null. Null is the contract's "Orca looked and - // found no wait", and nothing here looks: a structured worker parks on a journal question item, - // which no terminal prompt scan can see. Reporting null would tell a coordinator the worker is - // not waiting, which is the one thing the field's own documentation forbids inferring. - const exact = db.isDispatchProcessCurrent({ - dispatchId, - paneKey: structured.paneKey, - processIncarnation: structured.processIncarnation - }) - // One lineage walk, so status and `addressable` judge the same session even mid-`/clear`. - const hold = holdStructuredWorker(structured, db) - const observation = - hold.kind === 'unverifiable' - ? { status: 'unverifiable' as const, reason: hold.reason } - : observeStructuredSession(hold.running.sessionId) - const addressable = hold.kind === 'unverifiable' ? null : hold.kind === 'held' - return { - terminal: null, - exact, - status: exact ? observation.status : 'identity_changed', - ...(exact && observation.reason ? { reason: observation.reason } : {}), - ...(exact && addressable !== null ? { addressable } : {}), - terminalHandle: null - } + const session = await inspectSessionWorker(runtime, db, dispatchId, terminalHandle) + if (session) { + return session } let effectiveHandle = terminalHandle let terminal = await runtime.showTerminal(effectiveHandle).catch(() => null) @@ -282,7 +253,8 @@ export function projectFleetWorkerPage( attentionFacts: db.getWorkerAttentionFactsForDispatches([dispatchId], now), statuses: runtime.getOrchestrationFleetAgentStatusSnapshot(), limit: 1, - now + now, + observeChat: (sessionId) => observeChatAssignee(sessionId, db) }) } diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.ts index 3075483f0b4f..87466499afe3 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.ts @@ -3,6 +3,7 @@ import type { OrchestrationDb } from '../../../../orchestration/db' import type { RunRow, TaskRow } from '../../../../orchestration/types' import type { WorkerStartModeReceipt } from '../../orchestration-worker-start-mode' import { deliverWorkerDispatchPreamble } from './deliver-worker-dispatch-preamble' +import { chatAssigneeSessionId } from '../../../../orchestration/chat-assignee' import type { OrchestrationWorkerLaunchReceipt } from './worker-launch-preferences' import { describeUnobservedWorkerTurnStart, @@ -55,6 +56,7 @@ export async function deliverAndSettleWorkerStartReadiness(args: { terminalHandle, dispatchId: args.dispatchId, dispatchDepth: args.dispatchDepth, + runId: run.id, taskId: task.id, taskSpec: task.spec, coordinatorHandle: args.coordinatorHandle, @@ -124,8 +126,10 @@ export async function deliverAndSettleWorkerStartReadiness(args: { residualResources: JSON.parse(worker.residual_resources) as unknown[], nextCommands: [ `orca orchestration worker-show --dispatch ${args.dispatchId} --json`, - // A structured worker has no screen to read. - ...(structuredSession ? [] : [`orca terminal read --terminal ${terminalHandle} --screen`]), + // A structured worker or a chat has no screen to read. + ...(structuredSession || chatAssigneeSessionId(terminalHandle) + ? [] + : [`orca terminal read --terminal ${terminalHandle} --screen`]), `orca orchestration worker-abandon --dispatch ${args.dispatchId} --json` ], ...(args.terminalRevealWarning ? { warning: args.terminalRevealWarning } : {}) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/workers.ts b/src/main/runtime/rpc/methods/orchestration/worker/workers.ts index be4e40ab2b64..5dd3f0c598b0 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/workers.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/workers.ts @@ -13,6 +13,8 @@ import { resolveWorkerStartReadinessTimeoutMs } from '../../../../../../shared/orchestration-timing-budgets' import { assertWorkerStartTaskSpecWithinPromptBudget } from './worker-start-prompt-budget' +import { ORCA_SESSION_ADDRESS_PREFIX } from '../../../../../../shared/orca-session-address' +import { ORCHESTRATION_SESSION_CALLER_ERROR_CODES as SESSION_CODES } from '../../../../../../shared/orchestration-session-caller-codes' export const ORCHESTRATION_WORKER_START_METHODS = [ defineMethod({ @@ -54,6 +56,14 @@ export const ORCHESTRATION_WORKER_START_METHODS = [ params, settings: readWorkerStartModeSettings(runtime) }) + if (params.on && params.terminal?.startsWith(ORCA_SESSION_ADDRESS_PREFIX)) { + // Refused here, before any remote call: an Orca session ID names a chat only on its own host. + throw new OrchestrationError( + SESSION_CODES.hostBoundary, + `${params.terminal} names a chat by its Orca session ID, which identifies it only on the host that runs it. Run worker-start on that host. No effects were applied.`, + { effectsApplied: false } + ) + } if (params.on) { // A remote worker is always a terminal agent; the mode receipt rides along so the // coordinator still learns why its structured default did not apply. diff --git a/src/main/runtime/rpc/methods/structured-agent-session-restart-dismiss-fence.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-restart-dismiss-fence.test.ts index 9937a3eb3d89..2750b8e62267 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-restart-dismiss-fence.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-restart-dismiss-fence.test.ts @@ -1,7 +1,6 @@ // "Dismiss all" from the desktop prompt, through the context the desktop renderer really dispatches -// with: a paired runtime client without the registered-agents capability. On a host whose agents it -// all shows, the dismissal is the unscoped, fenced one; one that cannot show some agent leaves that -// agent's offers alone. +// with. On a host whose agents it all shows, the dismissal is the unscoped, fenced one; a desktop +// from before registered agents, which cannot show some agent, leaves that agent's offers alone. import { readFile } from 'node:fs/promises' import { join } from 'node:path' @@ -11,6 +10,7 @@ import { AgentSessionRecoveryCapsule } from '../../agent-session-recovery-capsule' import { DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES } from '../../../ipc/desktop-renderer-runtime-capabilities' +import { STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import { CLAUDE_STRUCTURED_AGENT } from '../../../claude/claude-structured-agent-definition' import { CODEX_STRUCTURED_AGENT } from '../../../codex/codex-structured-agent-definition' import type { StructuredAgentSessionAdapter } from '../../../native-chat/agent-session-wire/structured-agent-session-adapter' @@ -30,6 +30,12 @@ const DESKTOP = { clientKind: 'runtime' as const, clientCapabilities: [...DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES] } +const OLDER_DESKTOP = { + ...DESKTOP, + clientCapabilities: DESKTOP.clientCapabilities.filter( + (capability) => capability !== STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY + ) +} // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a registry reads only the methods a declaration needs; these declare none. const NO_METHODS = {} as StructuredAgentSessionAdapter @@ -41,7 +47,7 @@ const declaring = (definition: typeof CLAUDE_STRUCTURED_AGENT) => ({ adapter: NO_METHODS }) -/** This build's two agents plus one the desktop does not render yet. */ +/** Claude and Codex plus one agent an older desktop does not render. */ function withPilot(): StructuredAgentRegistry { return new StructuredAgentRegistry([ declaring(CLAUDE_STRUCTURED_AGENT), @@ -57,8 +63,8 @@ async function storedFence(root: string) { return dismissedAt ? { dismissedAt } : {} } -async function dismissAllFromDesktop() { - return call('agentSession.restartResumableDismiss', {}, DESKTOP) +async function dismissAllFromDesktop(client = DESKTOP) { + return call('agentSession.restartResumableDismiss', {}, client) } it('fences a dismissed offer against a late teardown write when the desktop sees every agent', async () => { @@ -79,7 +85,7 @@ it('fences a dismissed offer against a late teardown write when the desktop sees }) }) -it('leaves offers it was not shown when the host runs an agent the desktop cannot show', async () => { +it('leaves offers it was not shown when the host runs an agent an older desktop cannot show', async () => { const { host, root, marker } = await interruptedRestart( undefined, undefined, @@ -92,7 +98,10 @@ it('leaves offers it was not shown when the host runs an agent the desktop canno const unreadable = { ...marker!, sessionId: 'unreadable-session' } await capsule.record([unreadable], NOW) - expect(await dismissAllFromDesktop()).toMatchObject({ ok: true, result: { dismissed: 1 } }) + expect(await dismissAllFromDesktop(OLDER_DESKTOP)).toMatchObject({ + ok: true, + result: { dismissed: 1 } + }) expect(await capsule.list(NOW)).toEqual([unreadable]) // A scoped dismissal writes no fence: nothing it did not clear may be dropped later. diff --git a/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.test.ts index aacdcdc7f74e..5662731c9a1d 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.test.ts @@ -22,11 +22,13 @@ afterEach(clearStructuredHostStub) const CLAUDE_OFFER = { sessionId: 'claude-session', agent: 'claude' } const GROK_OFFER = { sessionId: 'grok-session', agent: 'grok' } -// What the desktop renderer really dispatches with: every client that calls these methods -// advertises Claude support. +// A desktop from before registered agents: every client that calls these methods advertises Claude +// support, and this one nothing beyond Claude and Codex. const OLD_CLIENT = { ...STRUCTURED_CLIENT, - clientCapabilities: [...DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES] + clientCapabilities: DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES.filter( + (capability) => capability !== STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY + ) } const NEW_CLIENT = { ...OLD_CLIENT, diff --git a/src/main/runtime/rpc/methods/structured-agent-session-restart-unregistered-agent.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-restart-unregistered-agent.test.ts index c8154f530bd9..c66a6bcdf380 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-restart-unregistered-agent.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-restart-unregistered-agent.test.ts @@ -31,9 +31,12 @@ import { call, clearStructuredHostStub } from './structured-agent-session-rpc.te const NOW = 1_800_000_000_000 const FAILED = 'saved-grok-failure' const PENDING = 'saved-grok-offer' +// A desktop from before registered agents: today's list without that one capability. const OLD_CLIENT = { clientKind: 'runtime' as const, - clientCapabilities: [...DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES] + clientCapabilities: DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES.filter( + (capability) => capability !== STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY + ) } const NEW_CLIENT = { ...OLD_CLIENT, 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 e33c7800cabb..370c0bb3f55e 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.test.ts @@ -82,7 +82,7 @@ describe('agentSession.reveal', () => { }) it('publishes the tab even when the journal could not be read', async () => { - // A pre-SQLite chat restores to nothing, but attach still recovers it, so the tab is worth + // An unreadable chat restores to nothing, but attach still recovers it, so the tab is worth // publishing and the pane's hold finishes the job. Refusing here would strand it forever. hostCalls.revealSession.mockResolvedValueOnce({ sessionId: SESSION, diff --git a/src/main/runtime/rpc/orchestration-party-addressing.test.ts b/src/main/runtime/rpc/orchestration-party-addressing.test.ts index 0bdcbb0cbcc7..95c85720f78b 100644 --- a/src/main/runtime/rpc/orchestration-party-addressing.test.ts +++ b/src/main/runtime/rpc/orchestration-party-addressing.test.ts @@ -108,7 +108,8 @@ describe('every target param resolves both spellings of a party to one canonical 'orchestration.dispatch', 'orchestration.inbox', 'orchestration.sessionAddress', - 'orchestration.partyLocation' + 'orchestration.partyLocation', + 'orchestration.workerStart' ].sort() ) }) @@ -176,10 +177,31 @@ describe('every target param resolves both spellings of a party to one canonical } ) - it('dispatch: refuses a chat assignee with no row written', async () => { + it('dispatch: a chat named by its session address is the assignee, by its Orca session ID', async () => { const runId = await chatRun() const task = h.db.createTask({ runId, spec: 'work' }) + const { dispatch } = await as(SESSION_X, 'orchestration.dispatch', { + task: task.id, + to: ADDRESS_Z + }) + + expect(dispatch).toMatchObject({ + assignee_handle: ADDRESS_Z, + assignee_orca_session_id: SESSION_Z, + assignee_pane_key: null, + process_incarnation: null + }) + expect(await as(SESSION_Z, 'orchestration.check', { peek: true })).toMatchObject({ + dispatchId: idOf(dispatch) + }) + }) + + it('dispatch: refuses a chat on another host with no row written', async () => { + const runId = await chatRun() + const task = h.db.createTask({ runId, spec: 'work' }) + h.records.set(SESSION_Z, sessionRecord(SESSION_Z, { location: { executionHostId: 'ssh:box' } })) + const response = await call(SESSION_X, 'orchestration.dispatch', { task: task.id, to: ADDRESS_Z @@ -187,11 +209,7 @@ describe('every target param resolves both spellings of a party to one canonical expect(response).toMatchObject({ ok: false, - error: { - code: CODES.chatNotDispatchable, - message: `Agent session ${SESSION_Z} is a chat, and a chat can't receive a dispatch yet. Start a worker with worker-start instead. No effects were applied.`, - data: { effectsApplied: false } - } + error: { code: CODES.hostBoundary, data: { effectsApplied: false } } }) expect(h.db.db.prepare('SELECT COUNT(*) AS n FROM dispatch_contexts').get()).toEqual({ n: 0 }) expect(h.db.getTask(task.id)?.status).toBe('ready') diff --git a/src/main/runtime/rpc/orchestration-session-caller.test.ts b/src/main/runtime/rpc/orchestration-session-caller.test.ts index 38e26730a1e4..ba923c96329a 100644 --- a/src/main/runtime/rpc/orchestration-session-caller.test.ts +++ b/src/main/runtime/rpc/orchestration-session-caller.test.ts @@ -51,7 +51,6 @@ const NAMES_NO_RESOLVED_PARTY: Readonly> = { 'orchestration.run from': 'retired; refused before any handler', 'orchestration.runShow from': 'reads a Run by id; `from` is unused', 'orchestration.dispatchShow from': '`from` only fills the preview preamble text', - 'orchestration.workerStart terminal': 'adopts an existing PTY pane, which a session never has', 'orchestration.federationAttachStart terminal': 'names the remote worker terminal', 'orchestration.workerTerminalUserInput terminal': 'names the worker terminal', 'orchestration.workerTerminalUserInput sessionId': diff --git a/src/main/runtime/runtime-local-worktree-create.test.ts b/src/main/runtime/runtime-local-worktree-create.test.ts index c89ff71e996e..87f19ab07cd8 100644 --- a/src/main/runtime/runtime-local-worktree-create.test.ts +++ b/src/main/runtime/runtime-local-worktree-create.test.ts @@ -2,6 +2,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { resolve } from 'node:path' import type { Store } from '../persistence' import type { WorktreeMeta } from '../../shared/worktree/meta-types' +import type { Repo } from '../../shared/repo-types' +import { getDefaultRepoHookSettings } from '../../shared/constants' import type { RuntimeManagedWorktreeCreateArgs } from './runtime-managed-worktree-create-types' import type { AddWorktreeOptions } from '../git/worktree' import { @@ -32,6 +34,7 @@ const mocks = vi.hoisted(() => ({ resolveShared: vi.fn<() => Promise>(), resolveInclude: vi.fn<() => Promise>(), copyPaths: vi.fn<() => Promise>(), + effectiveHooks: vi.fn(), created: { path: '', head: 'abc123', @@ -52,6 +55,7 @@ vi.mock('../git/repo', () => ({ getBranchConflictKind: mocks.branchConflict })) vi.mock('../git/git-username', () => ({ resolveLocalGitUsername: async () => '' })) +vi.mock('../hooks', () => ({ getEffectiveHooks: mocks.effectiveHooks })) vi.mock('../git/worktree-base-ref-probe', () => ({ hasLocalWorktreeBaseRef: mocks.hasBase })) vi.mock('./runtime-worktree-create-git', () => ({ resolveCreateBranchName: mocks.branchName, @@ -92,7 +96,8 @@ const worktreePath = resolve('/worktrees', 'app') function createWorktree( request: Partial = {}, rearm: PreparationRearmHolder = { fire: () => {} }, - timing: WorktreeCreateTimingRecorder = createWorktreeCreateTimingRecorder() + timing: WorktreeCreateTimingRecorder = createWorktreeCreateTimingRecorder(), + repoOverrides: Partial = {} ) { const store = { getSettings: () => ({ @@ -105,7 +110,14 @@ function createWorktree( } return createRuntimeLocalManagedWorktree({ request: { repoSelector: 'repo-1', name: 'app', baseBranch: 'main', ...request }, - repo: { id: 'repo-1', path: '/repo', displayName: 'Repo', badgeColor: '#000000', addedAt: 0 }, + repo: { + id: 'repo-1', + path: '/repo', + displayName: 'Repo', + badgeColor: '#000000', + addedAt: 0, + ...repoOverrides + }, // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: All store methods reached by this isolated create path are supplied above. store: store as Store, createdWithAgent: undefined, @@ -149,6 +161,7 @@ beforeEach(() => { mocks.resolveShared.mockResolvedValue([]) mocks.resolveInclude.mockResolvedValue(['.env']) mocks.copyPaths.mockResolvedValue([]) + mocks.effectiveHooks.mockReturnValue(null) }) describe('runtime prepared-worktree replenishment', () => { @@ -347,3 +360,41 @@ describe('runtime create Git priority', () => { expect(mocks.consume).not.toHaveBeenCalled() }) }) + +describe('runtime create setup decision', () => { + const askRepo: Partial = { + hookSettings: { ...getDefaultRepoHookSettings(), setupRunPolicy: 'ask' } + } + + beforeEach(() => { + mocks.effectiveHooks.mockReturnValue({ scripts: { setup: 'pnpm install' } }) + }) + + it('refuses an ask repo with no decision before any git work', async () => { + const timing = createWorktreeCreateTimingRecorder() + // No requested base, so a create that got past the check would resolve the default base. + const request = { baseBranch: undefined } + await expect(createWorktree(request, undefined, timing, askRepo)).rejects.toThrow( + 'Setup decision required for this repository' + ) + // Hooks come from the main checkout (no worktree path): the new worktree doesn't exist yet. + expect(mocks.effectiveHooks.mock.calls).toEqual([[expect.objectContaining({ path: '/repo' })]]) + for (const gitWork of [mocks.defaultBase, mocks.remoteBase, mocks.hasBase, mocks.refresh]) { + expect(gitWork).not.toHaveBeenCalled() + } + expect(mocks.fetch).not.toHaveBeenCalled() + expect(mocks.branchName).not.toHaveBeenCalled() + expect(mocks.consume).not.toHaveBeenCalled() + expect(mocks.add).not.toHaveBeenCalled() + // The refusal's failure telemetry still says where the create ran. + expect(timing.finish().executionHost).toBe('local') + }) + + it.each([ + ['an explicit decision', { setupDecision: 'skip' as const }], + ['runHooks', { runHooks: true }] + ])('creates an ask repo once the request carries %s', async (_label, request) => { + await createWorktree(request, undefined, undefined, askRepo) + expect(mocks.consume).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/runtime/runtime-local-worktree-create.ts b/src/main/runtime/runtime-local-worktree-create.ts index a19a62ee29ec..c78ff86d41fc 100644 --- a/src/main/runtime/runtime-local-worktree-create.ts +++ b/src/main/runtime/runtime-local-worktree-create.ts @@ -1,4 +1,6 @@ import { worktreeCreateGit } from '../git/worktree-create-git-executor' +import { shouldRunSetupForCreate } from '../effective-hook-config' +import { getEffectiveHooks } from '../hooks' import type { Repo } from '../../shared/repo-types' import type { Worktree } from '../../shared/worktree/types' import type { Store } from '../persistence' @@ -19,6 +21,7 @@ import { hasLocalWorktreeBaseRef } from '../git/worktree-base-ref-probe' import { resolveRuntimeLocalWorktreeCreateCandidate } from './runtime-local-worktree-create-candidate' import { createRuntimeLocalGitWorktree } from './runtime-local-git-worktree-create' import { materializeRuntimeLocalWorktree } from './runtime-local-worktree-materialization' +import { resolveRuntimeSetupDecision } from './runtime-local-worktree-setup' import type { PreparationRearmHolder } from '../worktree-create-preparation' import { localWorktreeCreateExecutionHost, @@ -63,6 +66,11 @@ async function performRuntimeLocalWorktreeCreate(args: RuntimeLocalWorktreeCr const gitExecOptions = getLocalProjectGitExecOptions(store, repo) const worktreeGitOptions = getLocalProjectWorktreeGitOptions(store, repo) args.timing.recordExecutionHost(localWorktreeCreateExecutionHost(gitExecOptions)) + // Why before any git work: an `ask` repo with no decision must refuse with nothing created, as + // the desktop create does; checked after the add, it left an orphan worktree behind. + if (getEffectiveHooks(repo)?.scripts.setup) { + shouldRunSetupForCreate(repo, resolveRuntimeSetupDecision(request)) + } // Username and base resolution are independent read-only probes. Starting // both before awaiting removes one serial git/config round trip from create. const usernamePromise = diff --git a/src/main/runtime/runtime-local-worktree-setup.test.ts b/src/main/runtime/runtime-local-worktree-setup.test.ts new file mode 100644 index 000000000000..87716eec98f0 --- /dev/null +++ b/src/main/runtime/runtime-local-worktree-setup.test.ts @@ -0,0 +1,60 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Repo } from '../../shared/repo-types' +import { getDefaultRepoHookSettings } from '../../shared/constants' + +const mocks = vi.hoisted(() => ({ + effectiveHooks: vi.fn(), + runHook: vi.fn() +})) + +vi.mock('../hooks', () => ({ + getEffectiveHooks: mocks.effectiveHooks, + loadHooks: () => null, + runHook: mocks.runHook +})) + +import { prepareRuntimeLocalWorktreeSetup } from './runtime-local-worktree-setup' + +const askRepo: Repo = { + id: 'repo-1', + path: '/repo', + displayName: 'Repo', + badgeColor: '#000000', + addedAt: 0, + hookSettings: { ...getDefaultRepoHookSettings(), setupRunPolicy: 'ask' } +} + +function prepare(request: { setupDecision?: 'run' | 'skip' | 'inherit' } = {}) { + return prepareRuntimeLocalWorktreeSetup({ + request: { repoSelector: 'repo-1', name: 'app', ...request }, + repo: askRepo, + worktreePath: '/worktrees/app', + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: settings are read only by the setup runner, which this in-process path never builds. + settings: {} as never, + runtimeTarget: undefined, + shouldUseSetupRunner: false + }) +} + +describe('runtime create setup for a hook the new branch added', () => { + beforeEach(() => { + vi.resetAllMocks() + mocks.runHook.mockResolvedValue({ success: true, output: '' }) + mocks.effectiveHooks.mockReturnValue({ scripts: { setup: 'pnpm install' } }) + }) + + it('skips setup with a warning instead of failing a create that already added the worktree', async () => { + const result = await prepare() + + expect(result.shouldRunSetup).toBe(false) + expect(result.warning).toContain('pass --setup run to run it') + expect(mocks.runHook).not.toHaveBeenCalled() + }) + + it('still runs setup when the caller decided to', async () => { + const result = await prepare({ setupDecision: 'run' }) + + expect(result.shouldRunSetup).toBe(true) + expect(mocks.runHook).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/runtime/runtime-local-worktree-setup.ts b/src/main/runtime/runtime-local-worktree-setup.ts index eb01b1806602..f9da4312c346 100644 --- a/src/main/runtime/runtime-local-worktree-setup.ts +++ b/src/main/runtime/runtime-local-worktree-setup.ts @@ -1,4 +1,4 @@ -import type { CreateWorktreeResult } from '../../shared/worktree/create-types' +import type { CreateWorktreeResult, SetupDecision } from '../../shared/worktree/create-types' import type { Repo } from '../../shared/repo-types' import { getEffectiveHooks, loadHooks, runHook } from '../hooks' import { createSetupRunnerScript, resolveSetupRunnerShell } from '../worktree-runner-script' @@ -6,6 +6,14 @@ import { getDefaultTabsLaunch, shouldRunSetupForCreate } from '../effective-hook import type { RuntimeManagedWorktreeCreateArgs } from './runtime-managed-worktree-create-types' import type { RuntimeStore } from './runtime-store-contract' +/** Why one place: the pre-add refusal and the post-add setup must read the same decision, or a + * create the first allowed could fail the second with the worktree already on disk. */ +export function resolveRuntimeSetupDecision( + request: Pick +): SetupDecision { + return request.runHooks ? 'run' : (request.setupDecision ?? 'inherit') +} + export async function prepareRuntimeLocalWorktreeSetup(args: { request: RuntimeManagedWorktreeCreateArgs repo: Repo @@ -18,7 +26,7 @@ export async function prepareRuntimeLocalWorktreeSetup(args: { setup?: CreateWorktreeResult['setup'] defaultTabs?: CreateWorktreeResult['defaultTabs'] warning?: string - effectiveDecision: 'run' | 'skip' | 'inherit' + effectiveDecision: SetupDecision hookFound: boolean shouldRunSetup: boolean didStartInProcessSetupHook: boolean @@ -28,7 +36,7 @@ export async function prepareRuntimeLocalWorktreeSetup(args: { let setup: CreateWorktreeResult['setup'] const yamlHooks = loadHooks(worktreePath) const hooks = getEffectiveHooks(repo, worktreePath) - const effectiveDecision = request.runHooks ? 'run' : (request.setupDecision ?? 'inherit') + const effectiveDecision = resolveRuntimeSetupDecision(request) let defaultTabs: CreateWorktreeResult['defaultTabs'] try { defaultTabs = getDefaultTabsLaunch(yamlHooks, repo, effectiveDecision) @@ -38,9 +46,16 @@ export async function prepareRuntimeLocalWorktreeSetup(args: { ? { tabs: yamlHooks.defaultTabs, runCommands: false } : undefined } - const shouldRunSetup = Boolean( - hooks?.scripts.setup && shouldRunSetupForCreate(repo, effectiveDecision) - ) + let shouldRunSetup = false + if (hooks?.scripts.setup) { + try { + shouldRunSetup = shouldRunSetupForCreate(repo, effectiveDecision) + } catch { + // Why: the new branch may add a setup hook the caller never decided on; the worktree exists, + // so skip setup rather than fail the create, as the desktop create does. The skipped-hook + // branch below logs and returns the warning. + } + } let didStartInProcessSetupHook = false if (shouldRunSetup && hooks?.scripts.setup) { if (args.shouldUseSetupRunner) { diff --git a/src/main/runtime/runtime-pty-controller-contract.ts b/src/main/runtime/runtime-pty-controller-contract.ts index 306fe4f053c9..6cbd270a3ea8 100644 --- a/src/main/runtime/runtime-pty-controller-contract.ts +++ b/src/main/runtime/runtime-pty-controller-contract.ts @@ -8,6 +8,7 @@ import type { TuiAgent } from '../../shared/tui-agent' import type { WorktreeStartupLaunch } from '../../shared/worktree/launch-types' import type { PtyIncarnationId } from '../../shared/pty-incarnation' import type { PtyBindingSourceExpectation } from '../persistence' +import type { TerminalPanePlacement } from '../../shared/terminal-pane-placement' import type { ExecutionHostId } from '../../shared/execution-host' import type { PtyProviderBufferSnapshot, PtyProcessInfo, PtySpawnResult } from '../providers/types' import type { PtyProcessInspection } from '../providers/pty-process-inspection' @@ -71,6 +72,7 @@ export type RuntimePtyController = { initiallyHidden?: boolean persistHostSessionBinding?: boolean expectedSourceBinding?: PtyBindingSourceExpectation + placement?: TerminalPanePlacement terminalKittyKeyboardProtocol?: boolean terminalColorQueryReplies?: { foreground?: string; background?: string } agentSessionEnsure?: { diff --git a/src/main/runtime/runtime-rpc-dispatch-inject-long-poll.test.ts b/src/main/runtime/runtime-rpc-dispatch-inject-long-poll.test.ts new file mode 100644 index 000000000000..e609ef178a69 --- /dev/null +++ b/src/main/runtime/runtime-rpc-dispatch-inject-long-poll.test.ts @@ -0,0 +1,62 @@ +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' +import { readRuntimeMetadata } from './runtime-metadata' +import { classifyRuntimeLongPoll, OrcaRuntimeRpcServer } from './runtime-rpc' +import { openFramedSession, sleep } from './runtime-rpc-test-harness' + +// `dispatch --inject` into a chat waits for its agent to accept the turn, which can outlast the +// socket's idle timer; keepalives hold the connection open as they do for worker-start. A terminal +// inject doesn't wait, so it stays a short RPC. +describe('orchestration.dispatch --inject as a long poll', () => { + it('is a keepalive-backed wait only when it injects into a chat', () => { + const dispatch = (params: Record) => + classifyRuntimeLongPoll({ + id: 'req_dispatch', + authToken: 'token', + method: 'orchestration.dispatch', + params + }) + expect(dispatch({ task: 'task_1', to: 'orca_session_id:x', inject: true })).toBe('wait') + expect(dispatch({ task: 'task_1', to: 'orca_session_id:x' })).toBeNull() + // A terminal inject writes and returns; it keeps a short-RPC slot. + expect(dispatch({ task: 'task_1', to: 'term_worker', inject: true })).toBeNull() + }) + + it('emits keepalives while an injected dispatch blocks', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const runtime = new OrcaRuntimeService() + const server = new OrcaRuntimeRpcServer({ runtime, userDataPath, keepaliveIntervalMs: 30 }) + vi.spyOn(server['dispatcher'], 'dispatch').mockImplementation(async (request) => { + await sleep(120) + return { + id: request.id, + ok: true, + result: { dispatch: { id: 'ctx_1' }, injected: true }, + _meta: { runtimeId: runtime.getRuntimeId() } + } + }) + await server.start() + try { + const metadata = readRuntimeMetadata(userDataPath) + const session = openFramedSession(metadata!.transports[0]!.endpoint, { + id: 'req_dispatch', + authToken: metadata!.authToken, + method: 'orchestration.dispatch', + params: { task: 'task_1', to: 'orca_session_id:x', inject: true } + }) + await session.done + + expect( + session.frames.filter((frame) => frame._keepalive === true).length + ).toBeGreaterThanOrEqual(2) + expect(session.frames.filter((frame) => frame.ok !== undefined)).toEqual([ + expect.objectContaining({ id: 'req_dispatch', ok: true }) + ]) + } finally { + await server.stop() + } + }) +}) diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-long-poll.ts b/src/main/runtime/runtime-rpc/runtime-rpc-long-poll.ts index c1ddeb78a6ed..23befc86b8de 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-long-poll.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-long-poll.ts @@ -1,4 +1,5 @@ import type { RpcRequest } from '../rpc/core' +import { ORCA_SESSION_ADDRESS_PREFIX } from '../../../shared/orca-session-address' export const KEEPALIVE_INTERVAL_MS = 10_000 @@ -37,6 +38,21 @@ export function classifyRuntimeLongPoll(request: RpcRequest): RuntimeLongPollCla ) { return 'wait' } + // An injected task into a chat waits for its agent to accept the turn, up to 60 s; the reply + // must outlive the 30 s idle timer or a delivered task reads as a dead runtime. A terminal + // inject doesn't wait, so it keeps its short-RPC slot. + if ( + request.method === 'orchestration.dispatch' && + typeof request.params === 'object' && + request.params !== null && + 'inject' in request.params && + request.params.inject === true && + 'to' in request.params && + typeof request.params.to === 'string' && + request.params.to.startsWith(ORCA_SESSION_ADDRESS_PREFIX) + ) { + return 'wait' + } if (request.method === 'browser.clientHost.attach') { return 'browser-host' } diff --git a/src/main/runtime/runtime-terminal-spawn-placement.ts b/src/main/runtime/runtime-terminal-spawn-placement.ts new file mode 100644 index 000000000000..ae7503c712bf --- /dev/null +++ b/src/main/runtime/runtime-terminal-spawn-placement.ts @@ -0,0 +1,14 @@ +import type { TerminalPanePlacement } from '../../shared/terminal-pane-placement' +import type { TerminalPaneSplitDirection } from '../../shared/terminal-tab-types' + +/** A main-side create always opens its own tab. */ +export function runtimeNewTabPlacement(): TerminalPanePlacement { + return { kind: 'new-tab' } +} + +export function runtimeSplitPlacement( + parentLeafId: string, + direction: TerminalPaneSplitDirection +): TerminalPanePlacement { + return { kind: 'split', parentLeafId, direction } +} diff --git a/src/main/runtime/structured-agent-account-home.ts b/src/main/runtime/structured-agent-account-home.ts index 060fbea5b1fd..92c28d6dbe49 100644 --- a/src/main/runtime/structured-agent-account-home.ts +++ b/src/main/runtime/structured-agent-account-home.ts @@ -1,6 +1,8 @@ +import { getClaudeProfileRouter } from '../claude-accounts/claude-profile-installed-router' import { homedir } from 'node:os' import { join } from 'node:path' import { getSystemCodexHomePath } from '../codex/codex-home-paths' +import { resolveAbsoluteDirOverride } from '../../shared/absolute-dir-override' // The one resolver for "which account home would a structured launch pin right // now". The create path fills `record.accountHome` with it, and the model @@ -18,6 +20,11 @@ export type StructuredClaudeAccountHomeDeps = { export function resolveStructuredClaudeAccountHomePath( deps: StructuredClaudeAccountHomeDeps ): string { + // Why only an account: System default keeps the launch-env and configured homes below. + const accountHome = deps.wslDistro ? null : getClaudeProfileRouter()?.selectedHome() + if (accountHome) { + return accountHome + } return ( deps.launchEnv.CLAUDE_CONFIG_DIR?.trim() || deps @@ -54,3 +61,18 @@ export async function resolveStructuredCodexAccountHomePath( getSystemCodexHomePath() ) } + +/** An agent whose config directory is one environment variable with a default under the user's + * home: the launch's own value, then this runtime's, then the default. */ +export function resolveStructuredEnvAccountHomePath(input: { + launchEnv: NodeJS.ProcessEnv + variable: string + defaultPath: (homePath: string) => string + processEnv?: NodeJS.ProcessEnv + homePath?: string +}): string { + return resolveAbsoluteDirOverride( + input.launchEnv[input.variable] ?? (input.processEnv ?? process.env)[input.variable], + input.defaultPath(input.homePath ?? homedir()) + ) +} diff --git a/src/main/runtime/structured-agent-runtime-registrations-acp.test.ts b/src/main/runtime/structured-agent-runtime-registrations-acp.test.ts new file mode 100644 index 000000000000..5dfc2f7404d2 --- /dev/null +++ b/src/main/runtime/structured-agent-runtime-registrations-acp.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest' +import { + STRUCTURED_AGENT_RUNTIME_REGISTRATIONS, + structuredAgentRuntimeRegistration, + type StructuredAgentAccountHomeServices +} from './structured-agent-runtime-registrations' + +describe('ACP agents in the runtime registrations', () => { + it('registers Grok beside Claude and Codex with its declared capabilities', () => { + expect( + STRUCTURED_AGENT_RUNTIME_REGISTRATIONS.map(({ definition }) => definition.agent) + ).toEqual(['codex', 'claude', 'grok']) + expect(structuredAgentRuntimeRegistration('grok')?.definition).toMatchObject({ + handleTransport: 'acp', + accountHomeVariable: 'GROK_HOME', + capabilities: { + rewind: false, + compact: false, + threadGoal: false, + contextUsage: true, + imagePrompts: false, + steering: 'queue', + approvalEnforcement: 'orca' + } + }) + }) + + it('takes model and effort picks at rest, and keeps no model list of its own', () => { + const resting = structuredAgentRuntimeRegistration('grok')!.definition.restingOptions + expect(['model', 'effort'].map(resting.acceptsKey)).toEqual([true, true]) + expect(resting.acceptsKey('fastMode')).toBe(false) + expect(resting.fallbackModels()).toBeNull() + }) + + it('finds the account home on this runtime from the launch env, else the default', async () => { + const { resolveAccountHomePath } = structuredAgentRuntimeRegistration('grok')! + // Grok's resolver asks the runtime for nothing: its home is the launch env's, else the default. + const unused = (): never => { + throw new Error('Grok resolves its account home without the runtime') + } + const services: StructuredAgentAccountHomeServices = { + getClaudeConfigDirectory: unused, + prepareCodexLaunchHome: unused, + readCodexLaunchHome: unused, + workspaceTrustSettings: unused + } + const resolve = (launchEnv: NodeJS.ProcessEnv) => + resolveAccountHomePath( + { launchEnv, location: null, purpose: 'read', workspacePath: null }, + services + ) + await expect(resolve({ GROK_HOME: '/data/grok' })).resolves.toBe('/data/grok') + await expect(resolve({ GROK_HOME: 'relative/grok' })).resolves.toMatch(/\.grok$/) + }) + + it('runs Grok only where this runtime supervises the child itself', () => { + const { supportsLocation } = structuredAgentRuntimeRegistration('grok')! + const local = { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'folder' + } as const + expect(supportsLocation({ ...local, wslDistro: 'Ubuntu' })).toBe(false) + expect(supportsLocation({ ...local, executionHostId: 'ssh:box' })).toBe(false) + }) +}) diff --git a/src/main/runtime/structured-agent-runtime-registrations.ts b/src/main/runtime/structured-agent-runtime-registrations.ts index 5347acb89e13..a0c0089c8d33 100644 --- a/src/main/runtime/structured-agent-runtime-registrations.ts +++ b/src/main/runtime/structured-agent-runtime-registrations.ts @@ -3,6 +3,7 @@ import { createCodexStructuredLaunchResolver } from '../codex/codex-structured-launch-resolution' import { supportsCodexStructuredLocation } from '../codex/codex-structured-location-support' import { supportsClaudeStructuredLocation } from '../claude/claude-structured-location-support' +import { supportsSupervisedProviderChildLocation } from '../provider-process/supervised-provider-child-location' import { applyStructuredCodexWorkspaceTrust } from '../agent-workspace-trust-spawn' import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' import { CodexStructuredSessionAdapter } from '../codex/codex-structured-session-adapter' @@ -16,6 +17,8 @@ import type { StructuredAgentDefinition } from '../native-chat/agent-session-wir 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 type { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' +import { replayJournal } from '../native-chat/agent-session-journal/journal-open' import type { AgentSessionRecordStore } from './agent-session-record-store' import type { createStructuredAgentSessionDispatchFollowUps } from './structured-agent-session-dispatch-followups' import type { StructuredAgentSessionRuntimeDeps } from './structured-agent-session-runtime' @@ -24,14 +27,21 @@ import { createStructuredClaudeRuntimeAdapter } from './structured-claude-runtim import { resolveStructuredClaudeAccountHomePath, resolveStructuredCodexAccountHomePath, + resolveStructuredEnvAccountHomePath, type StructuredClaudeAccountHomeDeps, type StructuredCodexAccountHomeDeps } from './structured-agent-account-home' +import { ACP_LAUNCH_SPECS, type AcpLaunchSpec } from '../acp/acp-launch-specs' +import { acpStructuredAgentDefinition } from '../acp/acp-structured-agent-definitions' +import { createAcpAgentConnection } from '../acp/acp-agent-connection' +import { createAcpStructuredLaunchResolver } from '../acp/acp-structured-launch-resolution' +import { AcpStructuredSessionAdapter } from '../acp/acp-structured-session-adapter' /** What an agent's adapter is built from: the open store and the runtime around it. */ export type StructuredAgentAdapterContext = { deps: StructuredAgentSessionRuntimeDeps store: AgentSessionRecordStore + journalDatabase: JournalHostDatabase environment: ReturnType /** Hands the host an exit or other lifecycle event the agent observed. */ deliverLifecycle: (event: StructuredAgentSessionLifecycleEvent) => void @@ -142,6 +152,43 @@ function createClaudeAdapter( }) } +function acpRegistration(spec: AcpLaunchSpec): StructuredAgentRuntimeRegistration { + return { + definition: acpStructuredAgentDefinition(spec), + supportsLocation: (location) => supportsSupervisedProviderChildLocation(location), + resolveAccountHomePath: async ({ launchEnv }) => + resolveStructuredEnvAccountHomePath({ + launchEnv, + variable: spec.accountHomeVariable, + defaultPath: spec.defaultAccountHome + }), + createAdapter: (context) => { + const { deps, store, followUps } = context + return new AcpStructuredSessionAdapter({ + spec, + resolveLaunch: createAcpStructuredLaunchResolver(spec, { + store, + readJournal: (sessionId) => replayJournal(context.journalDatabase.db, sessionId), + resolveWorkspacePath: deps.resolveWorkspacePath, + resolveEnvironment: context.environment.resolveBaseEnvironment, + ...(deps.resolveAgentLaunchEnv ? { resolveLaunchEnv: deps.resolveAgentLaunchEnv } : {}), + ...(deps.resolveAgentFullAccess ? { resolveFullAccess: deps.resolveAgentFullAccess } : {}) + }), + connect: (launch, options) => createAcpAgentConnection(launch, options), + ...(deps.readProcessStartTime ? { readProcessStartTime: deps.readProcessStartTime } : {}), + onDispatchSettledLate: followUps.onDispatchSettledLate, + logger: deps.logger, + // Every exit, expected or not: the host ends that child's record. + onEvent: (event) => { + if (event.type === 'ended') { + context.deliverLifecycle(event) + } + } + }) + } + } +} + async function resolveCodexAccountHomePath( request: StructuredAgentAccountHomeRequest, services: StructuredAgentAccountHomeServices @@ -179,7 +226,8 @@ export const STRUCTURED_AGENT_RUNTIME_REGISTRATIONS: readonly StructuredAgentRun wslDistro: location?.wslDistro ?? null, getClaudeConfigDirectory: services.getClaudeConfigDirectory }) - } + }, + ...ACP_LAUNCH_SPECS.map(acpRegistration) ] /** The registration of `agent`; null for an agent this runtime does not drive. */ diff --git a/src/main/runtime/structured-agent-session-close.test.ts b/src/main/runtime/structured-agent-session-close.test.ts index 500a6cd1bebd..067e3b3b663e 100644 --- a/src/main/runtime/structured-agent-session-close.test.ts +++ b/src/main/runtime/structured-agent-session-close.test.ts @@ -58,6 +58,9 @@ type HostOptions = { indexThrows?: boolean } +/** The hide a close sends; its hidden notice waits for the close's outcome. */ +const HIDE_PENDING_CLOSE = [SESSION, false, undefined, { deferHiddenNotice: true }] + function installHost(options: HostOptions = {}) { const entry = record(SESSION) const held = new Set([SESSION]) @@ -72,6 +75,7 @@ function installHost(options: HostOptions = {}) { visible.delete(sessionId) } }) + const notifySessionTabHidden = vi.fn() const close = vi.fn(async (sessionId: string) => { if (options.closeThrows) { throw options.closeThrows @@ -102,9 +106,10 @@ function installHost(options: HostOptions = {}) { }, hasSession: (sessionId: string) => held.has(sessionId), setSessionTabVisibility, + notifySessionTabHidden, close } - return { close, setSessionTabVisibility, visible, log } + return { close, setSessionTabVisibility, notifySessionTabHidden, visible, log } } describe('closeStructuredAgentSessionChild tab-visibility rollback', () => { @@ -127,7 +132,7 @@ describe('closeStructuredAgentSessionChild tab-visibility rollback', () => { expect(host.visible.has(SESSION)).toBe(false) expect(retire).toHaveBeenCalledWith(SESSION) // The hide is the only visibility write a settled close performs. - expect(host.setSessionTabVisibility.mock.calls).toEqual([[SESSION, false]]) + expect(host.setSessionTabVisibility.mock.calls).toEqual([HIDE_PENDING_CLOSE]) }) it('restores the tab when the close throws and the child is still there', async () => { @@ -140,9 +145,14 @@ describe('closeStructuredAgentSessionChild tab-visibility rollback', () => { expect(outcome.reason).toBe('provider round trip failed') expect(host.visible.has(SESSION)).toBe(true) expect(host.setSessionTabVisibility.mock.calls).toEqual([ - [SESSION, false], + HIDE_PENDING_CLOSE, [SESSION, true, TAB_ID] ]) + // Sent once, after the tab is back: a chat worker's Dispatch reads the chat as open. + expect(host.notifySessionTabHidden.mock.calls).toEqual([[SESSION]]) + expect(host.notifySessionTabHidden.mock.invocationCallOrder[0]).toBeGreaterThan( + host.setSessionTabVisibility.mock.invocationCallOrder[1]! + ) }) it('restores the tab when the post-close observation is not `exited`', async () => { @@ -154,7 +164,7 @@ describe('closeStructuredAgentSessionChild tab-visibility rollback', () => { expect(outcome.closeAttempted).toBe(true) expect(host.visible.has(SESSION)).toBe(true) expect(host.setSessionTabVisibility.mock.calls).toEqual([ - [SESSION, false], + HIDE_PENDING_CLOSE, [SESSION, true, TAB_ID] ]) }) @@ -168,7 +178,7 @@ describe('closeStructuredAgentSessionChild tab-visibility rollback', () => { expect(outcome.stopped).toBe(false) expect(host.visible.has(SESSION)).toBe(false) - expect(host.setSessionTabVisibility.mock.calls).toEqual([[SESSION, false]]) + expect(host.setSessionTabVisibility.mock.calls).toEqual([HIDE_PENDING_CLOSE]) }) it('does not put the tab back when the caller is discarding the workspace anyway', async () => { @@ -183,7 +193,7 @@ describe('closeStructuredAgentSessionChild tab-visibility rollback', () => { expect(outcome.stopped).toBe(false) expect(host.visible.has(SESSION)).toBe(false) - expect(host.setSessionTabVisibility.mock.calls).toEqual([[SESSION, false]]) + expect(host.setSessionTabVisibility.mock.calls).toEqual([HIDE_PENDING_CLOSE]) }) it('does not publish a tab for a session that was already hidden', async () => { @@ -192,7 +202,7 @@ describe('closeStructuredAgentSessionChild tab-visibility rollback', () => { await closeStructuredAgentSessionChild(SESSION) expect(host.visible.has(SESSION)).toBe(false) - expect(host.setSessionTabVisibility.mock.calls).toEqual([[SESSION, false]]) + expect(host.setSessionTabVisibility.mock.calls).toEqual([HIDE_PENDING_CLOSE]) }) it('does not roll back a visibility write that never landed', async () => { @@ -206,7 +216,7 @@ describe('closeStructuredAgentSessionChild tab-visibility rollback', () => { reason: 'visibility write failed' }) expect(host.close).not.toHaveBeenCalled() - expect(host.setSessionTabVisibility.mock.calls).toEqual([[SESSION, false]]) + expect(host.setSessionTabVisibility.mock.calls).toEqual([HIDE_PENDING_CLOSE]) }) it('keeps the original failure when the restore itself throws', async () => { @@ -233,7 +243,7 @@ describe('closeStructuredAgentSessionChild tab-visibility rollback', () => { await closeStructuredAgentSessionChild(SESSION) - expect(host.setSessionTabVisibility.mock.calls).toEqual([[SESSION, false]]) + expect(host.setSessionTabVisibility.mock.calls).toEqual([HIDE_PENDING_CLOSE]) }) it('reports no close attempt when no host is installed', async () => { diff --git a/src/main/runtime/structured-agent-session-close.ts b/src/main/runtime/structured-agent-session-close.ts index 8a21f2a229b9..fad9b6b19bff 100644 --- a/src/main/runtime/structured-agent-session-close.ts +++ b/src/main/runtime/structured-agent-session-close.ts @@ -68,12 +68,31 @@ export async function closeStructuredAgentSessionChild( // started without a chat tab, or one the user had closed — which is a new side effect, not an undo. const restoreTabIfCloseFails = options.restoreTabOnUnprovenClose !== false ? readPersistedTabId(host, sessionId) : null + try { + return await closeHiddenStructuredAgentSessionChild( + host, + sessionId, + options, + restoreTabIfCloseFails + ) + } finally { + // After the outcome, not at the hide: a close that puts the tab back leaves its chat working. + host.notifySessionTabHidden?.(sessionId) + } +} + +async function closeHiddenStructuredAgentSessionChild( + host: StructuredAgentSessionHost, + sessionId: string, + options: StructuredAgentSessionCloseOptions, + restoreTabIfCloseFails: string | null +): Promise { // Set only once the close is actually issued: `setSessionTabVisibility` throwing first leaves a // running child, and a receipt that still said `closed_agent_terminal` for it would be the // close-that-never-happened this flag exists to rule out. let closeAttempted = false try { - await host.setSessionTabVisibility?.(sessionId, false) + await host.setSessionTabVisibility?.(sessionId, false, undefined, { deferHiddenNotice: true }) closeAttempted = true // A worktree teardown or an orchestration stop: not the user closing this chat. await host.close(sessionId, 'evict') diff --git a/src/main/runtime/structured-agent-session-codex-opening-send-hold.test.ts b/src/main/runtime/structured-agent-session-codex-opening-send-hold.test.ts new file mode 100644 index 000000000000..31dff2777569 --- /dev/null +++ b/src/main/runtime/structured-agent-session-codex-opening-send-hold.test.ts @@ -0,0 +1,581 @@ +// A message queued behind a send whose Codex turn has not opened yet waits for that turn: handed +// over once it opens, it goes in as a steer and its row belongs to that turn, never ahead of it. +// Driven through the shipped host, journal and Codex adapter; only the Codex child is fake, keeping +// Codex 0.157's turn bookkeeping, and the rows are drawn by the client's own projection. + +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 { agentJournalSubmissionKey } from '../../shared/agent-session-journal-item-key' +import { CODEX_TURN_OPEN_WAIT_MS } from '../codex/codex-structured-turn-open-wait' +import { projectStructuredAgentSessionMessages } from '../../shared/structured-agent-session-message-projection' +import { projectNativeChatTranscriptMessages } from '../../shared/native-chat-transcript-projection' +import { nativeChatRowsInDrawOrder } from '../../shared/native-chat-turn-grouping' +import { nativeChatTurnMembership } from '../../shared/native-chat-turn-membership' +import { + CodexAppServerRequestError, + type CodexAppServerConnection, + type CodexAppServerConnectionHandlers, + type openCodexAppServerConnection +} from '../codex/codex-app-server-connection' +import { codexTurnLifecycleFake } from '../codex/codex-turn-lifecycle-fake' +import { settledWithin } from '../codex/codex-structured-dispatch-test-support' +import type * as CodexTurnOpenWait from '../codex/codex-structured-turn-open-wait' +import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' +import type { AgentJournalSubmission } from '../../shared/agent-session-journal-types' +import { classifyDispatchRejection } from '../../shared/structured-agent-session-dispatch-rejection' +import { owesStructuredAgentSessionWork } from '../../shared/structured-agent-session-owed-work' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import { + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + hostTestMessage +} from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import { + ensureStructuredAgentSessionHost, + stopStructuredAgentSessionRuntime +} from './structured-agent-session-runtime' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' + +// The turns a send or Stop is waiting on to open, so a test knows the wait began. +const openWaits = vi.hoisted(() => { + const turnIds: string[] = [] + return { turnIds } +}) +vi.mock('../codex/codex-structured-turn-open-wait', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + createCodexTurnOpenWaits: () => { + const waits = actual.createCodexTurnOpenWaits() + return { + ...waits, + wait: (turnId: string, withinMs: number) => { + openWaits.turnIds.push(turnId) + return waits.wait(turnId, withinMs) + } + } + } + } +}) + +const CALLER = { callerKey: 'codex-opening-send-test' } +const MODEL = { + model: 'gpt-test', + displayName: 'GPT Test', + hidden: false, + supportedReasoningEfforts: [], + defaultReasoningEffort: null, + isDefault: true +} + +let root: string +let host: StructuredAgentSessionHost +let fence: number +let handlers: CodexAppServerConnectionHandlers | undefined +let answers: number +let steers: number +let interrupts: number +let childCloses: number +let connections: number +/** Closes that fail before one goes through, as a kill that times out. */ +let failingCloses: number +/** Codex fails every interrupt it is sent, as one it could not submit (-32603). */ +let interruptsFail: boolean +/** Codex takes the interrupt and answers it; the test sends that turn's end later. */ +let interruptEndHeld: boolean +let turns: ReturnType +let operations = 0 + +/** The durable ledger stamps its own clock and refuses an id far from it. */ +const operationId = (): string => `${Date.now()}-${(++operations).toString(16).padStart(32, '0')}` + +function envelope(method: string, fields: Record) { + return { + sessionId: SESSION, + clientOperationId: operationId(), + expectedRuntimeFence: fence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method, + sessionId: SESSION, + fields + }) + } +} + +async function send(text: string): Promise { + const body = hostTestMessage(text) + const sent = await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + if (!sent.ok) { + throw new Error(JSON.stringify(sent.refusal)) + } + return sent.value.clientMessageId +} + +async function stop(turnId?: string): Promise { + const stopped = await host.cancel(CALLER, { + envelope: envelope('agentSession.cancel', turnId === undefined ? {} : { turnId }), + ...(turnId === undefined ? {} : { turnId }) + }) + if (!stopped.ok) { + throw new Error(JSON.stringify(stopped.refusal)) + } +} + +/** `/compact` as the chat surface runs it; refused while the chat still owes work. */ +async function compact() { + const command = 'compact' as const + return host.conversationCommand(CALLER, { + command, + envelope: envelope('agentSession.conversationCommand', { command }) + }) +} + +async function settled(): Promise<{ + submissions: readonly AgentJournalSubmission[] + owesWork: boolean +}> { + await host.flushStreamedEvents(SESSION) + const snapshot = await host.journalSnapshot(SESSION) + return { + submissions: snapshot.submissions, + // The shared rule every surface reads "working" from. + owesWork: owesStructuredAgentSessionWork(snapshot.items, snapshot.submissions, fence) + } +} + +function verdictOf(submissions: readonly AgentJournalSubmission[], clientMessageId: string) { + const submission = submissions.find((entry) => entry.clientMessageId === clientMessageId) + return submission?.dispatchState === 'rejected' + ? classifyDispatchRejection(submission).category + : submission?.dispatchState +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-codex-opening-send-')) + openWaits.turnIds.length = 0 + answers = 0 + steers = 0 + interrupts = 0 + childCloses = 0 + connections = 0 + failingCloses = 0 + interruptsFail = false + interruptEndHeld = false + turns = codexTurnLifecycleFake( + THREAD, + () => (method, params) => handlers?.onNotification?.(method, params) + ) + const openConnection: typeof openCodexAppServerConnection = async ( + _launch, + connectionHandlers = {} + ) => { + handlers = connectionHandlers + connections += 1 + const connection: CodexAppServerConnection = { + pid: 4321, + closed: false, + request: async (method, params) => { + if (method === 'thread/start' || method === 'thread/resume') { + return { thread: { id: THREAD } } + } + if (method === 'model/list') { + return { data: [MODEL], nextCursor: null } + } + if (method === 'turn/start') { + answers += 1 + return turns.routes['turn/start']() + } + if (method === 'turn/steer') { + steers += 1 + return turns.routes['turn/steer'](params) + } + if (method === 'turn/interrupt' && interruptsFail) { + interrupts += 1 + throw new CodexAppServerRequestError( + 'turn/interrupt', + -32603, + 'codex app-server turn/interrupt failed: could not submit', + 'could not submit' + ) + } + if (method === 'turn/interrupt' && interruptEndHeld) { + interrupts += 1 + turns.takeInterrupt() + return {} + } + if (method === 'turn/interrupt') { + interrupts += 1 + return turns.routes['turn/interrupt'](params) + } + return {} + }, + notify: () => {}, + respond: () => {}, + respondWithError: () => {}, + close: async () => { + if (failingCloses > 0) { + failingCloses -= 1 + throw new Error('the kill timed out') + } + childCloses += 1 + return true + } + } + return connection + } + host = await ensureStructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), + stateDirectory: root, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async () => root, + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveCodexCommand: () => 'codex', + resolveLaunchArgs: () => [], + resolveEnvironment: async () => ({ PATH: process.env.PATH }), + openCodexConnection: openConnection, + readProcessStartTime: async () => 1_700_000_000_000 + }) + const attachParams = hostTestAttachParams(null, { providerHandle: undefined }) + attachParams.envelope.clientOperationId = operationId() + const attached = await host.attach(CALLER, attachParams) + if (!attached.ok) { + throw new Error(JSON.stringify(attached.refusal)) + } + fence = attached.value.fence +}) + +afterEach(async () => { + await stopStructuredAgentSessionRuntime() + await rm(root, { recursive: true, force: true }) +}) + +/** Each row's text and the turn it is drawn in, in the order the client draws the conversation. */ +async function drawn(): Promise<{ text: string; turn: string | undefined }[]> { + await host.flushStreamedEvents(SESSION) + const { items, submissions } = await host.journalSnapshot(SESSION) + const rows = projectNativeChatTranscriptMessages( + projectStructuredAgentSessionMessages(items, [], submissions, { rejectedInPlace: true }) + ) + const { drawOrder, turnKeys } = nativeChatTurnMembership(rows, { items, submissions }) + const keyed = rows.map((row, index) => ({ + text: row.blocks.map((block) => ('text' in block ? block.text : block.type)).join(''), + turn: turnKeys[index] + })) + return [...nativeChatRowsInDrawOrder(keyed, drawOrder)] +} + +/** The turn the client draws as live: its bar carries the running clock. */ +async function liveTurn(): Promise { + await host.flushStreamedEvents(SESSION) + const { items, submissions } = await host.journalSnapshot(SESSION) + const rows = projectNativeChatTranscriptMessages( + projectStructuredAgentSessionMessages(items, [], submissions, { rejectedInPlace: true }) + ) + return nativeChatTurnMembership(rows, { items, submissions }).liveTurnKey +} + +/** The delivery loop has stopped: it handed over what it could, and holds the rest. */ +async function deliveryAtRest(): Promise { + const { loop } = host.collaboratorsForTests().conversationDelivery + await vi.waitFor(() => expect(loop.isRunning(SESSION)).toBe(false)) +} + +async function submission(clientMessageId: string) { + return (await settled()).submissions.find((entry) => entry.clientMessageId === clientMessageId) +} + +/** The turn its row is in: the turn it was steered into, or none for a send that opens its own. */ +async function rowScope(clientMessageId: string) { + const { items } = await host.journalSnapshot(SESSION) + return items.find((item) => item.itemId === agentJournalSubmissionKey(clientMessageId))?.turnScope +} + +/** A finished first turn, then `/compact` running, so the sends that follow queue behind it. */ +async function compactRunning(): Promise<{ compacted: Promise }> { + const warmUp = await send('warm up') + await vi.waitFor(() => expect(answers).toBe(1)) + turns.start() + turns.echo(warmUp) + turns.end('completed') + const compacted = compact() + await vi.waitFor(async () => + expect( + (await host.journalSnapshot(SESSION)).items.some( + (item) => readAgentJournalTurn(item.body)?.state === 'running' + ) + ).toBe(true) + ) + return { compacted } +} + +/** Codex compacts and ends that turn; the host then hands the first queued send over. */ +async function finishCompaction({ compacted }: { compacted: Promise }): Promise { + handlers?.onNotification?.('turn/started', { + threadId: THREAD, + turn: { id: 'turn-compact', status: 'inProgress' } + }) + handlers?.onNotification?.('thread/compacted', { threadId: THREAD }) + handlers?.onNotification?.('turn/completed', { + threadId: THREAD, + turn: { id: 'turn-compact', status: 'completed' } + }) + expect(await settledWithin(compacted, 3_000)).not.toBe('held') +} + +function says(text: string, id: string): void { + handlers?.onNotification?.('item/completed', { + threadId: THREAD, + turn: { id: turns.turnId }, + item: { type: 'agentMessage', id, text } + }) +} + +describe("a message queued behind a send whose turn hasn't opened", () => { + it('waits for that turn, then joins it as a steer, drawn in it with the turn streaming on', async () => { + const compaction = await compactRunning() + const first = await send('queued first') + const second = await send('queued second') + await finishCompaction(compaction) + await vi.waitFor(async () => expect((await submission(first))?.handedOverAt).toBeDefined()) + await vi.waitFor(() => expect(answers).toBe(2)) + await deliveryAtRest() + + expect((await submission(second))?.handedOverAt).toBeUndefined() + expect(openWaits.turnIds).toEqual([]) + + turns.start() + await vi.waitFor(async () => expect((await submission(second))?.handedOverAt).toBeDefined()) + await vi.waitFor(() => expect(steers).toBe(1)) + turns.echo(first) + says('working on the first', 'reply-1') + says('more on both', 'reply-2') + + expect(answers).toBe(2) + expect(await rowScope(second)).toMatchObject({ kind: 'turn' }) + const rows = await drawn() + expect(rows.map((row) => row.text)).toEqual([ + 'warm up', + '/compact', + 'Context compacted', + 'queued first', + 'queued second', + 'working on the first', + 'more on both' + ]) + const turnOf = (text: string) => rows.find((row) => row.text === text)?.turn + expect(turnOf('queued second')).toBe(turnOf('queued first')) + expect(turnOf('more on both')).toBe(turnOf('queued first')) + }) + + it('lets each message behind it go in as a steer once the turn opens, in the order sent', async () => { + const compaction = await compactRunning() + const zero = await send('queued zero') + const a = await send('queued A') + const b = await send('queued B') + await finishCompaction(compaction) + await vi.waitFor(async () => expect((await submission(zero))?.handedOverAt).toBeDefined()) + await deliveryAtRest() + + expect((await submission(a))?.handedOverAt).toBeUndefined() + expect((await submission(b))?.handedOverAt).toBeUndefined() + + turns.start() + await vi.waitFor(() => expect(steers).toBe(2)) + turns.echo(zero) + says('working on zero', 'reply-0') + + expect(await rowScope(a)).toMatchObject({ kind: 'turn' }) + expect(await rowScope(b)).toMatchObject({ kind: 'turn' }) + const rows = await drawn() + expect(rows.map((row) => row.text).slice(3)).toEqual([ + 'queued zero', + 'queued A', + 'queued B', + 'working on zero' + ]) + expect(new Set(rows.slice(3).map((row) => row.turn)).size).toBe(1) + }) + + // A Stop withdraws what is still queued, so the held message is taken back, never sent. + it('is withdrawn with the queue by a Stop pressed before that turn opens', async () => { + const compaction = await compactRunning() + const zero = await send('queued zero') + const b = await send('queued B') + await finishCompaction(compaction) + await vi.waitFor(async () => expect((await submission(zero))?.handedOverAt).toBeDefined()) + await deliveryAtRest() + expect((await submission(b))?.handedOverAt).toBeUndefined() + + await stop() + + await vi.waitFor(async () => + expect(verdictOf((await settled()).submissions, b)).toBe('withdrawn') + ) + expect((await submission(b))?.handedOverAt).toBeUndefined() + expect(answers).toBe(2) + expect(steers).toBe(0) + }) + + // Its turn/start answer lost, the send ahead settles as doubt at once, and that releases the wait. + it('goes in at once when the send ahead lost its answer', async () => { + const compaction = await compactRunning() + const answer = turns.routes['turn/start'] + turns.routes['turn/start'] = () => { + turns.routes['turn/start'] = answer + throw new Error('codex app-server request timed out: turn/start') + } + const first = await send('queued first') + const second = await send('queued second') + await finishCompaction(compaction) + + await vi.waitFor(async () => expect((await submission(second))?.handedOverAt).toBeDefined()) + expect(verdictOf((await settled()).submissions, first)).toBe('unknown') + expect(answers).toBe(3) + }) + + // A steer still unanswered joined a turn that is open; it opens nothing, so nothing waits on it. + it('never holds a message sent into a turn already running, nor one behind such a steer', async () => { + const warmUp = await send('warm up') + await vi.waitFor(() => expect(answers).toBe(1)) + turns.start() + turns.echo(warmUp) + + const steered = await send('and also this') + const after = await send('and this too') + + await vi.waitFor(() => expect(steers).toBe(2)) + expect(await rowScope(steered)).toMatchObject({ kind: 'turn' }) + expect(await rowScope(after)).toMatchObject({ kind: 'turn' }) + expect(openWaits.turnIds).toEqual([]) + }) +}) + +describe('a send whose turn Codex answered and never opened', () => { + async function heldBehindAnAnsweredSend(): Promise<{ first: string; second: string }> { + const first = await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + const second = await send('and check the tests') + await deliveryAtRest() + expect((await submission(second))?.handedOverAt).toBeUndefined() + return { first, second } + } + + // Codex before 0.148 fails a turn before opening it with only an `error`: that is its end. + it('releases the message behind it once Codex fails that turn before opening it', async () => { + turns = codexTurnLifecycleFake( + THREAD, + () => (method, params) => handlers?.onNotification?.(method, params), + { legacyStartAnswers: true } + ) + const { first, second } = await heldBehindAnAnsweredSend() + + turns.failUnopened('invalid turn settings') + + await vi.waitFor(async () => expect((await submission(second))?.handedOverAt).toBeDefined()) + expect(await submission(first)).toMatchObject({ dispatchState: 'rejected' }) + expect(JSON.stringify(await submission(first))).toContain('invalid turn settings') + // A turn Codex never opened has no record in the journal to name. + expect((await submission(first))?.answeredInTurn).toBeNull() + await vi.waitFor(() => expect(answers).toBe(2)) + expect(openWaits.turnIds).toEqual([]) + }) + + // No clock: however long Codex takes to open the turn, the send ahead is never doubted for it. + it('never doubts a send whose turn is slow to open, and holds the next until it opens', async () => { + const { first, second } = await heldBehindAnAnsweredSend() + + vi.useFakeTimers() + await vi.advanceTimersByTimeAsync(CODEX_TURN_OPEN_WAIT_MS * 3) + vi.useRealTimers() + + expect(verdictOf((await settled()).submissions, first)).toBe('pending') + expect((await submission(second))?.handedOverAt).toBeUndefined() + turns.start() + await vi.waitFor(() => expect(steers).toBe(1)) + turns.echo(first) + await vi.waitFor(async () => + expect(verdictOf((await settled()).submissions, first)).toBe('accepted') + ) + }) + + // Steered in the moment the turn opens, it can be echoed before the send that opened the turn. + it('keeps the turn, and its live status, on the send that opened it when the steer echoes first', async () => { + const { first, second } = await heldBehindAnAnsweredSend() + turns.start() + await vi.waitFor(() => expect(steers).toBe(1)) + + turns.echo(second) + await vi.waitFor(async () => + expect(verdictOf((await settled()).submissions, second)).toBe('accepted') + ) + + expect(verdictOf((await settled()).submissions, first)).toBe('pending') + const rows = await drawn() + expect(rows.map((row) => row.text)).toEqual(['look around', 'and check the tests']) + expect(new Set(rows.map((row) => row.turn))).toEqual( + new Set([agentJournalSubmissionKey(first)]) + ) + expect(await liveTurn()).toBe(agentJournalSubmissionKey(first)) + }) +}) + +// Codex reports the thread idle ahead of the `turn/completed` of a turn it opened, as the fake does. +describe('a send in a turn Codex opened, when Codex reports the thread idle', () => { + function lateSettlements(clientMessageId: string) { + return vi + .mocked(host.settleLateDispatch) + .mock.calls.map(([settlement]) => settlement) + .filter((settlement) => settlement.clientMessageId === clientMessageId) + .map((settlement) => ('state' in settlement ? settlement.state : 'accepted')) + } + + it('is never doubted for the idle when it was steered in and a Stop takes it back', async () => { + vi.spyOn(host, 'settleLateDispatch') + const warmUp = await send('warm up') + await vi.waitFor(() => expect(answers).toBe(1)) + turns.start() + turns.echo(warmUp) + const steered = await send('and also this') + await vi.waitFor(() => expect(steers).toBe(1)) + + await stop() + + await vi.waitFor(async () => + expect(verdictOf((await settled()).submissions, steered)).toBe('withdrawn') + ) + expect(lateSettlements(steered)).toEqual(['rejected']) + }) + + it('is never doubted for the idle when it opened the turn and a Stop ends it before the echo', async () => { + vi.spyOn(host, 'settleLateDispatch') + const first = await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + turns.start() + + await stop() + + await vi.waitFor(async () => + expect(verdictOf((await settled()).submissions, first)).toBe('withdrawn') + ) + expect(lateSettlements(first)).toEqual(['rejected']) + }) + + it('raises no doubt when its turn completes with it echoed', async () => { + vi.spyOn(host, 'settleLateDispatch') + const first = await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + turns.start() + turns.echo(first) + turns.end('completed') + const second = await send('next') + await vi.waitFor(async () => expect((await submission(second))?.handedOverAt).toBeDefined()) + + expect(lateSettlements(first)).toEqual(['accepted']) + expect(verdictOf((await settled()).submissions, first)).toBe('accepted') + }) +}) diff --git a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts index 1c2a2d0267a3..9db9b37287ca 100644 --- a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts +++ b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts @@ -567,7 +567,11 @@ describe('a second send made after Codex answered the first, before it opened th const opening = await send('look around') await vi.waitFor(() => expect(answers).toBe(1)) const followUp = await send('and check the tests') - await vi.waitFor(() => expect(openWaits.turnIds).toEqual(['turn-1'])) + // Held until that turn opens, so Codex is never asked to steer into a turn it has not started: + // the delivery loop goes idle with no wait on turn-1, which a handover would have started. + const { loop } = host.collaboratorsForTests().conversationDelivery + await vi.waitFor(() => expect(loop.isRunning(SESSION)).toBe(false)) + expect(openWaits.turnIds).toEqual([]) turns.start() await vi.waitFor(() => expect(steers).toBe(1)) expect(answers).toBe(1) diff --git a/src/main/runtime/structured-agent-session-create-adoption.ts b/src/main/runtime/structured-agent-session-create-adoption.ts index 2bc7337fe3fd..ed6365083897 100644 --- a/src/main/runtime/structured-agent-session-create-adoption.ts +++ b/src/main/runtime/structured-agent-session-create-adoption.ts @@ -1,3 +1,4 @@ +import { getClaudeProfileRouter } from '../claude-accounts/claude-profile-installed-router' import { homedir } from 'node:os' import { join } from 'node:path' import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' @@ -101,7 +102,11 @@ function structuredAdoptionAccountHomeCandidates(input: { selectedAccountHomePath: string }): string[] { if (input.agent === 'claude') { - return [input.selectedAccountHomePath, join(homedir(), '.claude')] + return [ + input.selectedAccountHomePath, + join(homedir(), '.claude'), + ...(getClaudeProfileRouter()?.accountHomes() ?? []) + ] } return [ input.selectedAccountHomePath, diff --git a/src/main/runtime/structured-agent-session-journal-open.ts b/src/main/runtime/structured-agent-session-journal-open.ts index 54c27fbfe963..c232fd43db9b 100644 --- a/src/main/runtime/structured-agent-session-journal-open.ts +++ b/src/main/runtime/structured-agent-session-journal-open.ts @@ -1,13 +1,7 @@ -// Opening the chat journal database for the host install. The open that migrates it to version 4 -// first reads the records file it replaces, so the copy runs in the migration's transaction. +// Opening the chat journal database for the host install. import { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' import { journalOpenRefusalError } from '../native-chat/agent-session-journal/journal-open-failure' -import { - legacyAgentSessionRecordImport, - readLegacyAgentSessionRecords, - type LegacyAgentSessionRecordImportReport -} from './agent-session-legacy-record-import' import { recordStructuredAgentSessionHostInstallRefusal } from './structured-agent-session-host-refusal' import type { StructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' @@ -31,19 +25,12 @@ function logOpenFailureOnce(logger: StructuredAgentSessionLogger, error: unknown /** The journal database. A refusal is recorded for the gate and thrown to the caller; the next * install tries again. */ -export async function openStructuredAgentSessionJournalDatabase(args: { +export function openStructuredAgentSessionJournalDatabase(args: { stateDirectory: string - hostId: string logger: StructuredAgentSessionLogger -}): Promise { +}): JournalHostDatabase { try { - const opened = await JournalHostDatabase.open(args.stateDirectory, async () => - legacyAgentSessionRecordImport( - await readLegacyAgentSessionRecords(args.stateDirectory, args.hostId), - args.hostId, - (report) => reportLegacyRecordImport(args.logger, report) - ) - ) + const opened = JournalHostDatabase.open(args.stateDirectory) recordStructuredAgentSessionHostInstallRefusal(null) lastLoggedOpenFailure = null return opened @@ -55,15 +42,3 @@ export async function openStructuredAgentSessionJournalDatabase(args: { throw refusal } } - -function reportLegacyRecordImport( - logger: StructuredAgentSessionLogger, - report: LegacyAgentSessionRecordImportReport -): void { - const { kind, ...fields } = report - logger.warn('importing the chat records file did not complete', { - scope: 'legacy-record-import', - outcome: kind, - ...fields - }) -} diff --git a/src/main/runtime/structured-agent-session-lifecycle-delivery.ts b/src/main/runtime/structured-agent-session-lifecycle-delivery.ts index ba307c2cfb0a..f228b96ce35c 100644 --- a/src/main/runtime/structured-agent-session-lifecycle-delivery.ts +++ b/src/main/runtime/structured-agent-session-lifecycle-delivery.ts @@ -3,9 +3,9 @@ // Exit recovery runs on one chain so teardown can drain it: exit callbacks arrive from child // process tasks, and a fire-and-forget one could otherwise append after the host flushed and // removed its journal directory. That chain orders nothing across sessions, and a recovery on it -// can run a whole reacquisition, so `started` and the end of a close the host asked for stay off -// it: each takes only its own session's serialized step, and is tracked here so the same drain -// still waits for it. +// can run a whole reacquisition, so `started`, a dropped saved option and the end of a close the +// host asked for stay off it: each takes only its own session's serialized step, and is tracked +// here so the same drain still waits for it. import type { StructuredAgentSessionLifecycleEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { StructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' @@ -27,7 +27,7 @@ export function createStructuredAgentSessionLifecycleDelivery(input: { await input.handle(event) } catch (error) { input.logger.warn(`delivering a provider ${event.type} event to the host failed`, { - scope: event.type === 'started' ? 'lifecycle-started' : 'lifecycle-exit', + scope: event.type === 'ended' ? 'lifecycle-exit' : `lifecycle-${event.type}`, sessionId: event.sessionId, error }) @@ -35,7 +35,7 @@ export function createStructuredAgentSessionLifecycleDelivery(input: { } return { deliver: (event) => { - if (event.type === 'started' || event.cause === 'requested-close') { + if (event.type !== 'ended' || event.cause === 'requested-close') { // Called now, so the step is queued on its own session's lane, behind nothing of another's. const settling = settle(event) settlingStarts.add(settling) diff --git a/src/main/runtime/structured-agent-session-record-import-install.test.ts b/src/main/runtime/structured-agent-session-record-import-install.test.ts deleted file mode 100644 index 8495fb49f5de..000000000000 --- a/src/main/runtime/structured-agent-session-record-import-install.test.ts +++ /dev/null @@ -1,174 +0,0 @@ -// The records file's one-time copy never keeps the host from installing. A file no read will make -// usable is reported and left alone; a read that can clear leaves the copy owed, and until a later -// launch makes it, the chat list says it cannot tell rather than "none". - -import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' -import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' -import Database from '../sqlite/sync-database' -import { journalPragmaNumber } from '../native-chat/agent-session-journal/journal-database' -import { journalDatabasePath } from '../native-chat/agent-session-journal/journal-host-database' -import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' -import { legacyAgentSessionStorePath } from './agent-session-record-store-file' -import { OrcaRuntimeService } from './orca-runtime' -import { - ensureStructuredAgentSessionHost, - stopStructuredAgentSessionRuntime -} from './structured-agent-session-runtime' -import { recordingStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' -import { storedTestAgentSessionRecord } from './agent-session-record-store-test-harness' - -const NOW = 1_800_000_000_000 -const IMPORTED = 'session-alpha-1' -const CREATED_WHILE_OWED = 'session-new-0001' - -let root: string - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-record-import-install-')) -}) - -afterEach(async () => { - await stopStructuredAgentSessionRuntime().catch(() => undefined) - vi.restoreAllMocks() - await chmod(legacyAgentSessionStorePath(root), 0o600).catch(() => undefined) - await rm(root, { recursive: true, force: true }) -}) - -async function writeLegacy(contents: string): Promise { - await mkdir(dirname(legacyAgentSessionStorePath(root)), { recursive: true }) - await writeFile(legacyAgentSessionStorePath(root), contents) -} - -const legacyFile = (): string => - JSON.stringify({ - schemaVersion: 2, - hostId: 'local', - records: { [IMPORTED]: storedTestAgentSessionRecord(agentSessionRecordFixture()) }, - operations: {}, - retiredClaimKeys: [], - unusableRecords: {} - }) - -function databaseVersion(): number { - const db = new Database(journalDatabasePath(root)) - try { - return journalPragmaNumber(db, 'user_version') - } finally { - db.close() - } -} - -/** The runtime at startup, rooted at `root`, with its PTY daemon stubbed. */ -function startupRuntime(log: ReturnType) { - const runtime = new OrcaRuntimeService() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these are the runtime's own protected members; the test roots the host at `root` and stubs the PTY daemon. - const internal = runtime as unknown as { - hasPersistedStructuredAgentSessionStore(): boolean - ensureStructuredAgentSessionHost(): Promise - refreshMobileSessionPtyRecords(): Promise | null> - structuredAgentSessionInventoryUnverifiable: boolean - } - internal.hasPersistedStructuredAgentSessionStore = () => true - internal.ensureStructuredAgentSessionHost = () => - ensureStructuredAgentSessionHost({ - logger: log.logger, - stateDirectory: root, - hostId: 'local', - claimKeyId: 'key-1', - resolveWorkspacePath: async () => root, - resolveEnvironment: async () => ({}), - resolveLaunchArgs: () => [], - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }) - }) - internal.refreshMobileSessionPtyRecords = async () => new Set() - return { - runtime, - host: () => internal.ensureStructuredAgentSessionHost(), - unverifiable: () => internal.structuredAgentSessionInventoryUnverifiable - } -} - -/** Each import report the install logged, its kind as the entry's outcome. */ -function importReports(log: ReturnType): unknown[] { - return log.entries - .filter((entry) => entry.fields.scope === 'legacy-record-import') - .map(({ fields: { scope: _scope, outcome, ...rest } }) => ({ kind: outcome, ...rest })) -} - -// A read that can clear: the file's own permissions, as a locked-down or mid-restore profile has. -it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( - 'installs while the file cannot be read, says it cannot tell, and copies it once it can', - async () => { - await writeLegacy(legacyFile()) - await chmod(legacyAgentSessionStorePath(root), 0o000) - const log = recordingStructuredAgentSessionLogger() - const first = startupRuntime(log) - - await first.runtime.restoreStructuredAgentSessionTabs() - const host = await first.host() - - expect(importReports(log)).toEqual([ - { - kind: 'unavailable', - error: expect.objectContaining({ message: 'agent_session_store_corrupt' }) - } - ]) - expect(host.legacyRecordImportOwed()).toBe(true) - expect(first.unverifiable()).toBe(true) - expect(databaseVersion()).toBe(3) - // A chat started meanwhile lives in the database and keeps its row through the copy. - await host.deps.store.reserveOwner({ - sessionId: CREATED_WHILE_OWED, - location: { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'workspace-1', - workspaceKind: 'folder' - }, - provider: 'claude', - accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude' }, - expectedFence: null, - spawnToken: 'spawn-new', - claimKeyId: 'key-1', - handoffOperationId: null, - probe: { outcome: 'reservation-unused' }, - operation: { - callerKey: 'client-1', - operationId: `${NOW}-${'0'.repeat(31)}1`, - fingerprint: 'fp-new' - }, - now: NOW - }) - await stopStructuredAgentSessionRuntime() - - await chmod(legacyAgentSessionStorePath(root), 0o600) - const second = startupRuntime(recordingStructuredAgentSessionLogger()) - await second.runtime.restoreStructuredAgentSessionTabs() - const relaunched = await second.host() - - expect(relaunched.legacyRecordImportOwed()).toBe(false) - expect(second.unverifiable()).toBe(false) - expect(databaseVersion()).toBe(4) - expect(relaunched.deps.store.getRecord(IMPORTED)).not.toBeNull() - expect(relaunched.deps.store.getRecord(CREATED_WHILE_OWED)).not.toBeNull() - } -) - -it('installs over a file no read will make usable, reports it once, and leaves it untouched', async () => { - await writeLegacy('{ truncated') - const log = recordingStructuredAgentSessionLogger() - const { runtime, host } = startupRuntime(log) - - await expect(runtime.prepareStructuredAgentSessionStartupRestoration()).resolves.toBeUndefined() - - expect(importReports(log)).toEqual([ - { kind: 'unusable', error: expect.objectContaining({ message: 'agent_session_store_corrupt' }) } - ]) - expect((await host()).legacyRecordImportOwed()).toBe(false) - expect(databaseVersion()).toBe(4) - await stopStructuredAgentSessionRuntime() - expect(await readFile(legacyAgentSessionStorePath(root), 'utf-8')).toBe('{ truncated') -}) diff --git a/src/main/runtime/structured-agent-session-rollback-compatibility.test.ts b/src/main/runtime/structured-agent-session-rollback-compatibility.test.ts index 0b65c556db85..23249909806b 100644 --- a/src/main/runtime/structured-agent-session-rollback-compatibility.test.ts +++ b/src/main/runtime/structured-agent-session-rollback-compatibility.test.ts @@ -1,10 +1,9 @@ -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' import { safeParseWorkspaceSession } from '../../shared/workspace-session-schema' -import { journalDirectoryFor } from '../native-chat/agent-session-journal/journal-paths' import { openTestAgentSessionRecordStore, readPersistedTestAgentSessionStore, @@ -86,9 +85,6 @@ describe('structured session rollback compatibility', () => { runtimeKind: 'native' }) await seedTestAgentSessionRecordStore(root, { records: [record] }) - const journalDir = journalDirectoryFor(root, { workspaceId: WORKSPACE, sessionId: SESSION }) - await mkdir(journalDir, { recursive: true }) - await writeFile(join(journalDir, 'journal.log'), 'durable-journal-fixture\n') const target = await openTestAgentSessionRecordStore(root) expect(target.getVisibleSessionTabIndex()).toEqual({ present: false, sessionIds: [] }) @@ -120,9 +116,6 @@ describe('structured session rollback compatibility', () => { const reloaded = await openTestAgentSessionRecordStore(root) expect(reloaded.listVisibleSessionIds()).toEqual([SESSION]) expect(reloaded.getRecord(SESSION)?.providerHandleChain).toHaveLength(1) - await expect(readFile(join(journalDir, 'journal.log'), 'utf8')).resolves.toBe( - 'durable-journal-fixture\n' - ) expect((await readPersistedTestAgentSessionStore(root)).sessionTabs).toEqual([ { tabId: expect.any(String), sessionId: SESSION } ]) diff --git a/src/main/runtime/structured-agent-session-runtime.test.ts b/src/main/runtime/structured-agent-session-runtime.test.ts index 30a0c423bce2..c6b663bf0c14 100644 --- a/src/main/runtime/structured-agent-session-runtime.test.ts +++ b/src/main/runtime/structured-agent-session-runtime.test.ts @@ -1,6 +1,6 @@ -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' +import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionClaimStatus, @@ -10,15 +10,10 @@ import type { } from '../../shared/agent-session-record' import { __setWindowsProcessTreeLoaderForTests } from '../windows/windows-process-table' import { agentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' -import { - NO_LEGACY_JOURNAL_RECORDS, - type JournalLegacyRecordImport -} from '../native-chat/agent-session-journal/journal-database' import { JournalHostDatabase, journalDatabasePath } from '../native-chat/agent-session-journal/journal-host-database' -import { legacyAgentSessionStorePath } from './agent-session-record-store-file' import { createStructuredAgentSessionOwnerProbe, createStructuredAgentSessionOwnerProbes @@ -72,17 +67,9 @@ describe('structured agent-session store presence', () => { } }) - async function openProfileDatabase( - legacyRecords: JournalLegacyRecordImport = NO_LEGACY_JOURNAL_RECORDS - ): Promise { + async function openProfileDatabase(): Promise { profile = await mkdtemp(join(tmpdir(), 'orca-session-presence-')) - return JournalHostDatabase.openWith(profile, legacyRecords) - } - - async function writeRecordsFile(): Promise { - const filePath = legacyAgentSessionStorePath(profile) - await mkdir(dirname(filePath), { recursive: true }) - await writeFile(filePath, '{}') + return JournalHostDatabase.open(profile) } // Every host install creates the database, chats or not; startup restore must not wait on one. @@ -102,14 +89,6 @@ describe('structured agent-session store presence', () => { expect(hasPersistedStructuredAgentSessionStore(profile)).toBe(true) }) - it('lets the records file answer while the database still owes its copy', async () => { - ;(await openProfileDatabase({ owed: true })).close() - expect(hasPersistedStructuredAgentSessionStore(profile)).toBe(false) - - await writeRecordsFile() - expect(hasPersistedStructuredAgentSessionStore(profile)).toBe(true) - }) - it('reports a database it cannot read present', async () => { profile = await mkdtemp(join(tmpdir(), 'orca-session-presence-')) await writeFile(journalDatabasePath(profile), 'not a database') @@ -117,26 +96,11 @@ describe('structured agent-session store presence', () => { expect(hasPersistedStructuredAgentSessionStore(profile)).toBe(true) }) - // A profile from before the records moved into the database still holds a chat to import. - it('checks the records file and its backup when the database is absent', () => { - const fileExists = vi.fn((path: string) => path.endsWith('.bak')) - - expect(hasPersistedStructuredAgentSessionStore('/profile', fileExists)).toBe(true) - expect(fileExists).toHaveBeenNthCalledWith( - 2, - join('/profile', 'agent-sessions', 'agent-sessions.json') - ) - expect(fileExists).toHaveBeenNthCalledWith( - 3, - join('/profile', 'agent-sessions', 'agent-sessions.json.bak') - ) - }) - - it('reports a fresh profile absent after three bounded presence checks', () => { + it('reports a fresh profile absent after one presence check', () => { const fileExists = vi.fn(() => false) expect(hasPersistedStructuredAgentSessionStore('/profile', fileExists)).toBe(false) - expect(fileExists).toHaveBeenCalledTimes(3) + expect(fileExists).toHaveBeenCalledTimes(1) }) }) diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 46edfca9d47b..d8a5698b0254 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -37,7 +37,6 @@ import { releaseAgentSessionRecordStore, type OpenedAgentSessionRecordStore } from './agent-session-record-store-slot' -import { legacyAgentSessionStorePath } from './agent-session-record-store-file' import { journalDatabasePath } from '../native-chat/agent-session-journal/journal-host-database' import { journalDatabaseHoldsAgentSessions } from '../native-chat/agent-session-journal/journal-database' import { @@ -64,26 +63,21 @@ import { } from './structured-agent-model-catalog-wiring' import type { ClaudeThinkingDisplaySupport } from '../claude/claude-thinking-display-support' -/** Whether this profile holds a structured chat: a record or tab in the journal database, or the - * records file a profile from before it carries while the database still owes its copy. */ +/** Whether this profile holds a structured chat: a record or tab in the journal database. */ export function hasPersistedStructuredAgentSessionStore( stateDirectory: string, fileExists: (path: string) => boolean = existsSync ): boolean { const databasePath = journalDatabasePath(stateDirectory) - if (fileExists(databasePath)) { - try { - const holds = journalDatabaseHoldsAgentSessions(databasePath) - if (holds !== undefined) { - return holds - } - } catch { - // A database that cannot be read cannot say it is empty. - return true - } + if (!fileExists(databasePath)) { + return false + } + try { + return journalDatabaseHoldsAgentSessions(databasePath) + } catch { + // A database that cannot be read cannot say it is empty. + return true } - const filePath = legacyAgentSessionStorePath(stateDirectory) - return fileExists(filePath) || fileExists(`${filePath}.bak`) } export type StructuredAgentSessionRuntimeDeps = { @@ -115,6 +109,10 @@ export type StructuredAgentSessionRuntimeDeps = { resolveClaudePermissionMode?: () => Promise | PermissionMode /** The same setting for Codex, as app-server thread policy. */ resolveCodexPermissionPolicy?: () => CodexStructuredPermissionPolicy + /** The same setting for a protocol-driven (ACP) agent: whether it runs with full access. */ + resolveAgentFullAccess?: (agent: string) => boolean + /** The user's per-agent environment overlay, for agents with no lane-specific resolver. */ + resolveAgentLaunchEnv?: (agent: string) => Record /** Raw settings getter; the reader that fails closed around it is built here, in checked code. */ getClaudeManagedAccountGateSettings?: () => ClaudeManagedAccountGateSettings resolveEnvironment?: () => Promise @@ -131,6 +129,8 @@ export type StructuredAgentSessionRuntimeDeps = { statusSink?: StructuredAgentSessionHostDeps['statusSink'] /** See `StructuredAgentSessionHostDeps.hasOpenDispatch`. */ hasOpenDispatch?: StructuredAgentSessionHostDeps['hasOpenDispatch'] + /** See `StructuredAgentSessionHostDeps.onSessionTabHidden`. */ + onSessionTabHidden?: StructuredAgentSessionHostDeps['onSessionTabHidden'] /** Host-owned phone delivery and reconciliation from the current journal projection. */ attentionDelivery?: StructuredAttentionMobileDelivery /** The account home a structured launch would pin right now, for catalog @@ -270,6 +270,7 @@ async function installOnJournal( const context: StructuredAgentAdapterContext = { deps, store, + journalDatabase, environment: envResolvers, deliverLifecycle: lifecycle.deliver, followUps: createStructuredAgentSessionDispatchFollowUps({ @@ -299,6 +300,7 @@ async function installOnJournal( ...(deps.onSessionStatusChanged ? { onSessionStatusChanged: deps.onSessionStatusChanged } : {}), ...(deps.statusSink ? { statusSink: deps.statusSink } : {}), ...(deps.hasOpenDispatch ? { hasOpenDispatch: deps.hasOpenDispatch } : {}), + ...(deps.onSessionTabHidden ? { onSessionTabHidden: deps.onSessionTabHidden } : {}), ...(await modelCatalogHostDeps({ store, agents, deps, envResolvers })) }) if (deps.attentionDelivery) { diff --git a/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts b/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts index bed708ae29a8..0b3b8d9b307e 100644 --- a/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts +++ b/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts @@ -3,9 +3,9 @@ // bookkeeping: with a saved tab index it writes nothing, and a store that cannot be written costs // a bounded number of failed writes, not one per chat. -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, readFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' +import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionRecord } from '../../shared/agent-session-record' import type { RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-types' @@ -15,7 +15,9 @@ import { } from '../../shared/agent-session-record.test-fixture' import { journalDatabasePath } from '../native-chat/agent-session-journal/journal-host-database' import { + closeTestJournalHostDatabase, closeTestJournalHostDatabases, + openTestJournalHostDatabase, SAVED_BY_NEWER_ORCA } from '../native-chat/agent-session-journal/journal-host-database-test-support' import { openAgentSessionJournal } from '../native-chat/agent-session-journal/journal-store-factory' @@ -24,24 +26,19 @@ import { attachParamsForRecord } from '../native-chat/agent-session-wire/structu import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import { AgentSessionRecordStore } from './agent-session-record-store' import type * as AgentSessionRecordRows from './agent-session-record-rows' -import { - AGENT_SESSION_STORE_SCHEMA_VERSION, - legacyAgentSessionStorePath -} from './agent-session-record-store-file' import { readPersistedTestAgentSessionStore, - seedTestAgentSessionStoreFromNewerBuild, - storedTestAgentSessionRecord + seedTestAgentSessionRecordStore, + seedTestAgentSessionStoreFromNewerBuild } from './agent-session-record-store-test-harness' -import { openStructuredAgentSessionJournalDatabase } from './structured-agent-session-journal-open' import { OrcaRuntimeService } from './orca-runtime' import { ensureStructuredAgentSessionHost, stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' -import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' import { recordingStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' +import { structuredAgentSessionTabId } from '../../shared/structured-agent-session-projection' // `failing` fails every record write; `grants` lets that many more through, then fails. const writes = vi.hoisted(() => ({ failing: false, grants: Infinity, refused: 0 })) @@ -65,7 +62,6 @@ const PROMPT = 'add a retry' const CHAT_A = 'chat-a-0001' const CHAT_B = 'chat-b-0002' const CLEARED = 'chat-s-0003' -const OWED = 'chat-o-0004' let root: string @@ -119,34 +115,25 @@ function chatRecord( return { ...record, ...codex, ...clear } } -/** The records file a profile from before the chat database carries, which the install imports; - * `visible` is its saved tab index, absent on a legacy profile. `newer` instead leaves the records - * in a database a newer Orca wrote. Then each chat's history as the last run left it: one prompt, - * accepted, so nothing is left to send. */ +/** The chat records the last run left; `visible` is their saved tab index, absent on a legacy + * profile. `newer` leaves them in a database a newer Orca wrote. Then each chat's history as the + * last run left it: one prompt, accepted, so nothing is left to send. */ async function seedProfile( records: AgentSessionRecord[], options: { newer?: boolean; visible?: string[]; history?: AgentSessionRecord[] } = {} ) { - await mkdir(dirname(legacyAgentSessionStorePath(root)), { recursive: true }) - await writeFile( - legacyAgentSessionStorePath(root), - JSON.stringify({ - schemaVersion: AGENT_SESSION_STORE_SCHEMA_VERSION, - hostId: 'local', - records: Object.fromEntries( - records.map((record) => [record.sessionId, storedTestAgentSessionRecord(record)]) - ), - operations: {}, - retiredClaimKeys: [], - unusableRecords: {}, - ...(options.visible ? { visibleSessionIds: options.visible } : {}) - }) - ) - const database = await openStructuredAgentSessionJournalDatabase({ - logger: createStructuredAgentSessionLogger(), - stateDirectory: root, - hostId: 'local' + await seedTestAgentSessionRecordStore(root, { + records, + ...(options.visible + ? { + sessionTabs: options.visible.map((sessionId) => ({ + tabId: structuredAgentSessionTabId(sessionId), + sessionId + })) + } + : {}) }) + const database = openTestJournalHostDatabase(root) for (const record of options.history ?? records) { const fence = record.lease.runtimeFence const journal = await openAgentSessionJournal({ @@ -171,33 +158,13 @@ async function seedProfile( }) await journal.close() } - database.close() + closeTestJournalHostDatabase(root) if (options.newer) { await seedTestAgentSessionStoreFromNewerBuild(root) } return { path: journalDatabasePath(root) } } -/** A chat opened, with its tab, while the records file could not be read and the copy was owed. */ -async function seedChatOpenedWhileOwed(record: AgentSessionRecord, tabId: string): Promise { - // A directory where the file belongs: the read fails in a way that can clear. - await mkdir(legacyAgentSessionStorePath(root), { recursive: true }) - const database = await openStructuredAgentSessionJournalDatabase({ - logger: createStructuredAgentSessionLogger(), - stateDirectory: root, - hostId: 'local' - }) - database.db - .prepare('INSERT INTO agent_session_records (session_id, record_json) VALUES (?, ?)') - .run(record.sessionId, JSON.stringify(storedTestAgentSessionRecord(record))) - await AgentSessionRecordStore.open({ - journalDatabase: database, - hostId: 'local' - }).setSessionTabVisibility(record.sessionId, true, tabId) - database.close() - await rm(legacyAgentSessionStorePath(root), { recursive: true }) -} - function startupRuntime(options: { afterInstall?: () => void; profileChats?: string[] } = {}) { const log = recordingStructuredAgentSessionLogger() const runtime = new OrcaRuntimeService() @@ -403,26 +370,6 @@ describe('restoring the chat tabs open at quit', () => { expect(writes.refused - prepared).toBe(2) }) - // The profile never lists a Claude chat, so only the tab row that chat left brings it back. - it("restores a chat opened while the copy was owed beside the profile's chats", async () => { - const owed = chatRecord(OWED) - await seedChatOpenedWhileOwed(owed, 'tab-opened-while-owed') - const records = [chatRecord(CHAT_A, { codex: true })] - await seedProfile(records, { history: [owed, ...records] }) - const { runtime, published } = startupRuntime({ profileChats: [CHAT_A] }) - - await runtime.restoreStructuredAgentSessionTabs() - - expect(published().map((tab) => tab.id)).toEqual([ - `agent-session:${OWED}`, - `agent-session:${CHAT_A}` - ]) - expect((await readPersistedTestAgentSessionStore(root)).sessionTabs).toEqual([ - { tabId: 'tab-opened-while-owed', sessionId: OWED }, - { tabId: `structured-agent-session-${CHAT_A}`, sessionId: CHAT_A } - ]) - }) - it('still lists the chats when that write fails, and leaves the index absent', async () => { const records = legacyChats() await seedProfile(records) diff --git a/src/main/runtime/structured-agent-shell-environment.ts b/src/main/runtime/structured-agent-shell-environment.ts index 7233772445a6..ed0d628d3d11 100644 --- a/src/main/runtime/structured-agent-shell-environment.ts +++ b/src/main/runtime/structured-agent-shell-environment.ts @@ -91,6 +91,8 @@ export function createStructuredAgentEnvironmentResolvers( ): { resolveCodexEnvironment: () => Promise resolveClaudeInheritedEnv: () => Promise> + /** The shared base every agent's child env starts from, before its own overlay. */ + resolveBaseEnvironment: () => Promise> } { const shellEnvironment = (sources.resolveEnvironment ?? resolveLoginShellEnvironment)() const resolveBase = async (): Promise> => @@ -105,6 +107,7 @@ export function createStructuredAgentEnvironmentResolvers( ...(await sources.resolveLaunchEnvOverlay?.()), ...sources.resolveCodexOverrides?.() }), - resolveClaudeInheritedEnv: resolveBase + resolveClaudeInheritedEnv: resolveBase, + resolveBaseEnvironment: resolveBase } } diff --git a/src/main/runtime/structured-chat-coordinator-mail-rig.test-fixture.ts b/src/main/runtime/structured-chat-coordinator-mail-rig.test-fixture.ts index 8aca8f498038..6a9ac88504d7 100644 --- a/src/main/runtime/structured-chat-coordinator-mail-rig.test-fixture.ts +++ b/src/main/runtime/structured-chat-coordinator-mail-rig.test-fixture.ts @@ -261,7 +261,8 @@ beforeEach(async () => { openCodexConnection: codex.openConnection, readProcessStartTime: async () => 1_700_000_000_000, // The same calls the runtime's own host install makes. - onSessionStatusChanged: (summary) => runtime.onStructuredSessionStatusForMail(summary) + onSessionStatusChanged: (summary) => runtime.onStructuredSessionStatusForMail(summary), + onSessionTabHidden: (sessionId) => runtime.onStructuredSessionTabHidden(sessionId) }) dispatcher = new RpcDispatcher({ runtime, methods: ORCHESTRATION_METHODS }) }) diff --git a/src/main/runtime/structured-chat-dispatch-worker-lifecycle.test.ts b/src/main/runtime/structured-chat-dispatch-worker-lifecycle.test.ts new file mode 100644 index 000000000000..76e9330b8f91 --- /dev/null +++ b/src/main/runtime/structured-chat-dispatch-worker-lifecycle.test.ts @@ -0,0 +1,234 @@ +import './rpc/unused-default-rpc-methods.test-fixture' +// What happens to a chat assignee's Dispatch after the task is handed over, on the +// coordinator-mail rig: its reports, its liveness, a stop, and the chat being closed. + +import { describe, expect, it, vi } from 'vitest' +import { formatOrcaSessionAddress } from '../../shared/orca-session-address' +import { testOrcaSessionId } from '../../shared/orca-session-address-test-fixture' +import { idOf } from './rpc/orchestration-session-caller-test-fixture' +import { closeStructuredAgentSessionChild } from './structured-agent-session-close' +import type { FakeConnection } from './structured-chat-coordinator-fake-codex-fixture' +import { + COORDINATOR, + PEER_CHAT, + WAIT, + call, + clearChat, + coordinatorRunAndTask, + db, + dispatcher, + host, + openChat, + request, + sendUserMessage, + settleTurn, + startSuccessor +} from './structured-chat-coordinator-mail-rig.test-fixture' + +const WORKER = formatOrcaSessionAddress(testOrcaSessionId(PEER_CHAT)) + +/** A Dispatch of the coordinator's task to the peer chat, both chats open. */ +async function dispatchToChat(): Promise<{ + taskId: string + dispatchId: string + worker: FakeConnection +}> { + await openChat(COORDINATOR) + const worker = await openChat(PEER_CHAT) + const { taskId } = await coordinatorRunAndTask() + const { dispatch } = await call( + 'orchestration.dispatch', + { task: taskId, to: WORKER }, + { sessionId: COORDINATOR } + ) + return { taskId, dispatchId: idOf(dispatch), worker } +} + +function workerDone(taskId: string, dispatchId: string) { + return { + subject: 'Done', + type: 'worker_done', + payload: JSON.stringify({ taskId, dispatchId, outcome: 'succeeded' }) + } +} + +/** The chat is still open: its conversation attached and its tab listed. */ +function expectChatOpen(sessionId: string): void { + expect(host.hasSession(sessionId)).toBe(true) + expect(host.deps.store.getVisibleSessionTabIndex().sessionIds).toContain(sessionId) +} + +describe("a chat assignee's report", () => { + it('is admitted from the chat itself, and settles its Dispatch', async () => { + const { taskId, dispatchId } = await dispatchToChat() + + await call('orchestration.send', workerDone(taskId, dispatchId), { sessionId: PEER_CHAT }) + + expect(db.getDispatchContextById(dispatchId)?.status).toBe('completed') + expect(db.getTask(taskId)?.status).toBe('completed') + }) + + it('is admitted from the session a /clear continued the chat in, which keeps its Dispatch', async () => { + const { taskId, dispatchId } = await dispatchToChat() + const successor = await clearChat(PEER_CHAT) + expect(db.getDispatchContextById(dispatchId)?.status).toBe('dispatched') + await startSuccessor(successor) + + await call('orchestration.send', workerDone(taskId, dispatchId), { sessionId: successor }) + + expect(db.getDispatchContextById(dispatchId)?.status).toBe('completed') + }) + + it('is refused from a terminal that names the chat as its caller', async () => { + const { taskId, dispatchId } = await dispatchToChat() + + const response = await dispatcher.dispatch( + request('orchestration.send', { ...workerDone(taskId, dispatchId), from: WORKER }) + ) + + expect(response).toMatchObject({ + ok: false, + error: { code: 'session_caller_chat_not_declarable' } + }) + expect(db.getDispatchContextById(dispatchId)?.status).toBe('dispatched') + }) + + it("is refused from a terminal that carries the chat's Dispatch under its own handle", async () => { + const { taskId, dispatchId } = await dispatchToChat() + + const receipt = await call('orchestration.send', { + ...workerDone(taskId, dispatchId), + from: 'term_worker' + }) + + expect(receipt).toMatchObject({ lifecycle: { action: 'rejected' } }) + expect(db.getDispatchContextById(dispatchId)?.status).toBe('dispatched') + expect(db.getTask(taskId)?.status).toBe('dispatched') + }) +}) + +describe("a chat assignee's liveness, stop and close", () => { + it('reads live at rest, since the next send starts its agent', async () => { + const { dispatchId } = await dispatchToChat() + await host.close(PEER_CHAT, 'evict') + expect(host.deps.store.getRecord(PEER_CHAT)?.lease.claimStatus).toBe('released') + + const shown = await call('orchestration.workerShow', { dispatch: dispatchId }) + + expect(shown).toMatchObject({ + observation: { status: 'live', exactWorker: true }, + projection: { liveness: { verdict: 'live', source: 'execution_host' } } + }) + }) + + it('is stopped and abandoned without closing or interrupting the chat', async () => { + const stopped = await dispatchToChat() + expect(await call('orchestration.workerStop', { dispatch: stopped.dispatchId })).toMatchObject({ + processAction: 'none' + }) + expectChatOpen(PEER_CHAT) + + const more = await call( + 'orchestration.taskCreate', + { spec: 'more' }, + { sessionId: COORDINATOR } + ) + const { dispatch } = await call( + 'orchestration.dispatch', + { task: idOf(more.task), to: WORKER }, + { sessionId: COORDINATOR } + ) + expect(await call('orchestration.workerAbandon', { dispatch: idOf(dispatch) })).toMatchObject({ + state: 'abandoned', + processAction: 'none' + }) + expectChatOpen(PEER_CHAT) + }) + + it('fails its Dispatch when the chat is closed, as a closed terminal does, and reads exited', async () => { + const { taskId, dispatchId } = await dispatchToChat() + + // As the close RPC does when the person closes the chat's tab. + await host.setSessionTabVisibility(PEER_CHAT, false) + + expect(db.getDispatchContextById(dispatchId)).toMatchObject({ + status: 'failed', + termination_reason: 'operator_close' + }) + expect(db.getTask(taskId)?.status).not.toBe('dispatched') + expect(await call('orchestration.workerShow', { dispatch: dispatchId })).toMatchObject({ + observation: { status: 'exited' } + }) + }) + + it('keeps its Dispatch while a close puts the tab back, and fails it once a close is proven', async () => { + const { dispatchId } = await dispatchToChat() + vi.spyOn(host, 'close').mockRejectedValueOnce(new Error('the agent would not stop')) + + // As a refusable worktree removal closes each chat in it. + const outcome = await closeStructuredAgentSessionChild(PEER_CHAT, { + restoreTabOnUnprovenClose: true + }) + + expect(outcome).toMatchObject({ stopped: false, closeAttempted: true }) + expectChatOpen(PEER_CHAT) + expect(db.getDispatchContextById(dispatchId)?.status).toBe('dispatched') + + expect(await closeStructuredAgentSessionChild(PEER_CHAT)).toMatchObject({ stopped: true }) + expect(db.getDispatchContextById(dispatchId)).toMatchObject({ + status: 'failed', + termination_reason: 'operator_close' + }) + }) + + it("keeps its Dispatch when another chat's close lands while its own close is rolled back", async () => { + const { dispatchId } = await dispatchToChat() + let refuseClose: (error: Error) => void = () => undefined + const close = vi + .spyOn(host, 'close') + .mockImplementationOnce(() => new Promise((_resolve, reject) => (refuseClose = reject))) + const closing = closeStructuredAgentSessionChild(PEER_CHAT, { restoreTabOnUnprovenClose: true }) + await vi.waitFor(() => expect(close).toHaveBeenCalled(), WAIT) + + // Its tab is hidden pending the close when the other chat's notice re-derives. + await host.setSessionTabVisibility(COORDINATOR, false) + expect(db.getDispatchContextById(dispatchId)?.status).toBe('dispatched') + refuseClose(new Error('the agent would not stop')) + + expect(await closing).toMatchObject({ stopped: false, closeAttempted: true }) + expectChatOpen(PEER_CHAT) + expect(db.getDispatchContextById(dispatchId)?.status).toBe('dispatched') + }) + + it('reads unverifiable, never exited, when the session that continues the chat is unknown', async () => { + const { dispatchId } = await dispatchToChat() + const successor = await clearChat(PEER_CHAT) + const getRecord = host.deps.store.getRecord.bind(host.deps.store) + vi.spyOn(host.deps.store, 'getRecord').mockImplementation((sessionId) => + sessionId === successor ? null : getRecord(sessionId) + ) + + expect(await call('orchestration.workerShow', { dispatch: dispatchId })).toMatchObject({ + observation: { + status: 'unverifiable', + reason: expect.stringContaining('after a /clear cannot be verified') + }, + projection: { liveness: { verdict: 'unverifiable' } } + }) + expect(await call('orchestration.workerList', {})).toMatchObject({ + workers: [{ dispatchId, projection: { liveness: { verdict: 'unverifiable' } } }] + }) + }) + + it("reads the chat's transcript for worker-read", async () => { + const { dispatchId, worker } = await dispatchToChat() + expect(await sendUserMessage(PEER_CHAT, 'working on it')).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(worker.turns).toHaveLength(1), WAIT) + await settleTurn(PEER_CHAT, 0) + + const read = await call('orchestration.workerRead', { dispatch: dispatchId }) + + expect(read).toMatchObject({ source: 'transcript', status: { liveness: 'live' } }) + expect(JSON.stringify(read.transcript)).toContain('working on it') + }) +}) diff --git a/src/main/runtime/structured-chat-dispatch-worker-mail.test.ts b/src/main/runtime/structured-chat-dispatch-worker-mail.test.ts new file mode 100644 index 000000000000..5460520db9d1 --- /dev/null +++ b/src/main/runtime/structured-chat-dispatch-worker-mail.test.ts @@ -0,0 +1,91 @@ +import './rpc/unused-default-rpc-methods.test-fixture' +// A chat worker's coordinator mail lands in its Dispatch's mailbox. When the pointer to it was held +// (the provider died before taking it), the chat's next idle edge points it again, after a restart +// or in the session a /clear continued the chat in, as it does for a chat's own mail. + +import { describe, expect, it, vi } from 'vitest' +import { formatOrcaSessionAddress } from '../../shared/orca-session-address' +import { testOrcaSessionId } from '../../shared/orca-session-address-test-fixture' +import { idOf } from './rpc/orchestration-session-caller-test-fixture' +import { + COORDINATOR, + PEER_CHAT, + WAIT, + call, + clearChat, + codex, + connectionFor, + coordinatorRunAndTask, + db, + openChat, + restartRuntime, + runtime, + sendUserMessage, + settleTurn, + turnText +} from './structured-chat-coordinator-mail-rig.test-fixture' + +const WORKER = formatOrcaSessionAddress(testOrcaSessionId(PEER_CHAT)) +const POINTER = /orchestration message/ + +async function idleEdges(sessionId: string): Promise { + runtime.onStructuredSessionStatusForMail({ sessionId, status: null }) + runtime.onStructuredSessionStatusForMail({ sessionId, status: 'idle' }) + await new Promise((resolve) => setTimeout(resolve, 400)) +} + +/** Coordinator mail to the peer chat's Dispatch, its pointer lost with the provider. */ +async function heldDispatchMail(): Promise { + await openChat(COORDINATOR) + const chat = await openChat(PEER_CHAT) + const { taskId } = await coordinatorRunAndTask() + const { dispatch } = await call( + 'orchestration.dispatch', + { task: taskId, to: WORKER }, + { sessionId: COORDINATOR } + ) + const mailbox = `dispatch:${idOf(dispatch)}` + await call('orchestration.send', { to: mailbox, subject: 'more' }, { sessionId: COORDINATOR }) + await vi.waitFor(() => expect(chat.turns).toHaveLength(1), WAIT) + expect(turnText(chat.turns[0]!)).toMatch(POINTER) + chat.handlers.onExit?.(new Error('provider died before the echo')) + await idleEdges(PEER_CHAT) + expect(chat.turns).toHaveLength(1) + expect(db.getUndeliveredUnreadMessages(mailbox, undefined, {})).toHaveLength(1) + return mailbox +} + +/** The chat's next turn, the person's; resolves to the session's provider once its edges ran. */ +async function nextTurn(sessionId: string): Promise<{ turns: { text: string }[] }> { + const before = codex.connections.length + expect(await sendUserMessage(sessionId, 'again')).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(codex.connections.length).toBe(before + 1), WAIT) + const revived = connectionFor(sessionId) + await vi.waitFor(() => expect(revived.turns).toHaveLength(1), WAIT) + await settleTurn(sessionId, 0) + await idleEdges(sessionId) + return revived +} + +describe("a chat worker's held Dispatch mail is pointed again", () => { + // Also covered by the restored-mail repoint a restart schedules; this pins the end result. + it('after a restart', async () => { + await heldDispatchMail() + restartRuntime() + + const revived = await nextTurn(PEER_CHAT) + + await vi.waitFor(() => expect(revived.turns).toHaveLength(2), WAIT) + expect(turnText(revived.turns[1]!)).toMatch(POINTER) + }) + + it('in the session a /clear continued the chat in', async () => { + await heldDispatchMail() + const successor = await clearChat(PEER_CHAT) + + const revived = await nextTurn(successor) + + await vi.waitFor(() => expect(revived.turns).toHaveLength(2), WAIT) + expect(turnText(revived.turns[1]!)).toMatch(POINTER) + }) +}) diff --git a/src/main/runtime/structured-chat-dispatch-worker.test.ts b/src/main/runtime/structured-chat-dispatch-worker.test.ts new file mode 100644 index 000000000000..95d4bca31aa2 --- /dev/null +++ b/src/main/runtime/structured-chat-dispatch-worker.test.ts @@ -0,0 +1,292 @@ +import './rpc/unused-default-rpc-methods.test-fixture' +// A chat as the assignee of `orchestration dispatch`, end to end on the coordinator-mail rig: its +// task arrives as any agent's message into a chat does, and it then works the Dispatch as a +// terminal worker would. + +import { describe, expect, it, vi } from 'vitest' +import { formatOrcaSessionAddress } from '../../shared/orca-session-address' +import { testOrcaSessionId } from '../../shared/orca-session-address-test-fixture' +import { idOf } from './rpc/orchestration-session-caller-test-fixture' +import { sendChatTask } from './rpc/methods/orchestration/chat-task-delivery' +import { + providerFaults, + type FakeConnection +} from './structured-chat-coordinator-fake-codex-fixture' +import { + COORDINATOR, + PEER_CHAT, + WAIT, + call, + coordinatorRunAndTask, + db, + dispatcher, + host, + openChat, + queuedCardTexts, + request, + runtime, + sendUserMessage, + settleTurn, + turnText +} from './structured-chat-coordinator-mail-rig.test-fixture' + +const WORKER = formatOrcaSessionAddress(testOrcaSessionId(PEER_CHAT)) + +/** A person's turn in `sessionId`, started and still running; resolves to its end. */ +async function runningUserTurn( + sessionId: string, + chat: FakeConnection +): Promise<() => Promise> { + const index = chat.turns.length + expect(await sendUserMessage(sessionId, 'go')).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(chat.turns).toHaveLength(index + 1), WAIT) + const notify = (method: string, params: unknown) => chat.handlers.onNotification?.(method, params) + notify('turn/started', { turn: { id: `user-${index}` } }) + notify('item/completed', { + item: { + type: 'userMessage', + id: `echo-user-${index}`, + clientId: chat.turns[index]!.clientUserMessageId, + content: [{ type: 'text', text: 'go' }] + } + }) + await host.flushStreamedEvents(sessionId) + return async () => { + notify('turn/completed', { turn: { id: `user-${index}` } }) + await host.flushStreamedEvents(sessionId) + } +} + +function queuedRows(sessionId: string) { + return host.collaboratorsForTests().sessions.get(sessionId)?.journal.queuedMessages.list() ?? [] +} + +async function refusal(method: string, params: Record, sessionId?: string) { + const response = await dispatcher.dispatch( + request(method, params, sessionId ? { sessionId } : {}) + ) + if (response.ok) { + throw new Error(`expected a refusal, got ${JSON.stringify(response)}`) + } + return response.error +} + +/** Who each agent message in the chat's journal says it is from. */ +async function taskSenders(sessionId: string): Promise { + return (await host.journalSnapshot(sessionId)).items.flatMap((item) => + item.body.kind === 'message' && item.body.from ? [item.body.from] : [] + ) +} + +function dispatchCount(): unknown { + return db.db.prepare('SELECT COUNT(*) AS n FROM dispatch_contexts').get() +} + +describe('a chat as the assignee of orchestration dispatch', () => { + it('records the chat by its Orca session ID and sends an idle chat its task as a turn', async () => { + await openChat(COORDINATOR) + const worker = await openChat(PEER_CHAT) + const { runId, taskId } = await coordinatorRunAndTask() + + const dispatched = call( + 'orchestration.dispatch', + { task: taskId, to: WORKER, inject: true }, + { sessionId: COORDINATOR } + ) + await vi.waitFor(() => expect(worker.turns).toHaveLength(1), WAIT) + await settleTurn(PEER_CHAT, 0) + const result = await dispatched + + expect(result).toMatchObject({ injected: true }) + const dispatchId = idOf(result.dispatch) + expect(db.getDispatchContextById(dispatchId)).toMatchObject({ + run_id: runId, + assignee_handle: WORKER, + assignee_orca_session_id: PEER_CHAT, + assignee_pane_key: null, + process_incarnation: null, + status: 'dispatched' + }) + const task = turnText(worker.turns[0]!) + expect(task).toContain('build it') + expect(task).toContain(`Your Orca session ID is: ${WORKER}`) + expect(task).toContain('The coordinator cannot see this chat') + expect(task).toContain('it will send this chat a fresh') + expect(task).not.toContain('this terminal') + expect(task).not.toMatch(/exit the shell/i) + expect(task).not.toMatch(/address/i) + expect(task).toContain(dispatchId) + expect(await taskSenders(PEER_CHAT)).toMatchObject([ + { kind: 'agent', orchestration: { message: 'task', taskId, dispatchId } } + ]) + }) + + it("holds the task as a card in a busy chat's queue, naming the Dispatch, and sends it when the turn ends", async () => { + await openChat(COORDINATOR) + const worker = await openChat(PEER_CHAT) + const { runId, taskId } = await coordinatorRunAndTask() + const endTurn = await runningUserTurn(PEER_CHAT, worker) + + const result = await call( + 'orchestration.dispatch', + { task: taskId, to: WORKER, inject: true }, + { sessionId: COORDINATOR } + ) + + expect(result).toMatchObject({ injected: true }) + const dispatchId = idOf(result.dispatch) + expect(worker.turns).toHaveLength(1) + const from = { + kind: 'agent', + senders: [ + { + party: { + address: formatOrcaSessionAddress(testOrcaSessionId(COORDINATOR)), + terminalHandle: null, + orcaSessionId: COORDINATOR + } + } + ], + orchestration: { message: 'task', runId, taskId, dispatchId } + } + // On the card's body: the turn the queue sends carries it, and the provider never sees it. + expect(queuedRows(PEER_CHAT).map((card) => card.body.from)).toMatchObject([from]) + + await endTurn() + await vi.waitFor(() => expect(worker.turns).toHaveLength(2), WAIT) + expect(turnText(worker.turns[1]!)).toContain(dispatchId) + expect(await queuedCardTexts(PEER_CHAT)).toEqual([]) + expect(await taskSenders(PEER_CHAT)).toMatchObject([from]) + }) + + it('queues one card for one Dispatch however often its task is sent', async () => { + await openChat(COORDINATOR) + const worker = await openChat(PEER_CHAT) + const { taskId } = await coordinatorRunAndTask() + const endTurn = await runningUserTurn(PEER_CHAT, worker) + const result = await call( + 'orchestration.dispatch', + { task: taskId, to: WORKER }, + { sessionId: COORDINATOR } + ) + const dispatch = db.getDispatchContextById(idOf(result.dispatch))! + + const from = { kind: 'agent' as const, senders: [], orchestration: null } + const send = () => sendChatTask({ db, dispatch, from, preamble: 'the task' }) + expect(await send()).toBe('queued') + expect(await send()).toBe('queued') + + expect(await queuedCardTexts(PEER_CHAT)).toEqual(['the task']) + await endTurn() + }) + + it('keeps the Dispatch open when the chat may have taken its task but never acknowledged it', async () => { + await openChat(COORDINATOR) + const worker = await openChat(PEER_CHAT) + const { taskId } = await coordinatorRunAndTask() + providerFaults.dieBeforeEveryEcho = true + + const error = await refusal( + 'orchestration.dispatch', + { task: taskId, to: WORKER, inject: true }, + COORDINATOR + ) + + expect(error).toMatchObject({ code: 'operation_unknown' }) + expect(worker.turns.length).toBeGreaterThan(0) + expect(db.db.prepare('SELECT status FROM dispatch_contexts').all()).toEqual([ + { status: 'dispatched' } + ]) + expect(db.getTask(taskId)?.status).toBe('dispatched') + }) + + it('refuses a chat that injects a Dispatch into itself, naming no --from, with no row written', async () => { + await openChat(COORDINATOR) + const { taskId } = await coordinatorRunAndTask() + + const error = await refusal( + 'orchestration.dispatch', + { task: taskId, to: formatOrcaSessionAddress(testOrcaSessionId(COORDINATOR)), inject: true }, + COORDINATOR + ) + + expect(error).toMatchObject({ + code: 'terminal_is_coordinator', + message: expect.stringContaining("is this coordinator's own Orca session ID") + }) + expect(dispatchCount()).toEqual({ n: 0 }) + expect(db.getTask(taskId)?.status).toBe('ready') + }) + + it('refuses a chat that was closed, with no row written', async () => { + await openChat(COORDINATOR) + await openChat(PEER_CHAT) + const { taskId } = await coordinatorRunAndTask() + await host.setSessionTabVisibility(PEER_CHAT, false) + + const error = await refusal( + 'orchestration.dispatch', + { task: taskId, to: WORKER, inject: true }, + COORDINATOR + ) + + expect(error).toMatchObject({ + code: 'session_caller_not_live', + message: `Agent session ${PEER_CHAT} has ended: its chat was closed. No effects were applied.` + }) + expect(dispatchCount()).toEqual({ n: 0 }) + }) + + it("points the chat at its coordinator's mail to the Dispatch, which its own check reads", async () => { + await openChat(COORDINATOR) + const worker = await openChat(PEER_CHAT) + const { taskId } = await coordinatorRunAndTask() + const result = await call( + 'orchestration.dispatch', + { task: taskId, to: WORKER }, + { sessionId: COORDINATOR } + ) + const dispatchId = idOf(result.dispatch) + + await call( + 'orchestration.send', + { to: `dispatch:${dispatchId}`, subject: 'also check the tests' }, + { sessionId: COORDINATOR } + ) + + await vi.waitFor(() => expect(worker.turns).toHaveLength(1), WAIT) + expect(turnText(worker.turns[0]!)).toMatch( + /^You have 1 orchestration message\. Run `orca(-dev)? orchestration check`\.$/ + ) + expect(await queuedCardTexts(PEER_CHAT)).toEqual([]) + await settleTurn(PEER_CHAT, 0) + expect(await call('orchestration.check', {}, { sessionId: PEER_CHAT })).toMatchObject({ + messages: [expect.objectContaining({ subject: 'also check the tests' })] + }) + }) + + it("counts a chat worker's own Dispatch as depth 2, under the nesting cap", async () => { + await openChat(COORDINATOR) + await openChat(PEER_CHAT) + const { taskId } = await coordinatorRunAndTask() + const parent = await call( + 'orchestration.dispatch', + { task: taskId, to: WORKER }, + { sessionId: COORDINATOR } + ) + expect(parent.dispatch).toMatchObject({ depth: 1 }) + await call('orchestration.runCreate', { objective: 'sub' }, { sessionId: PEER_CHAT }) + const subTask = idOf( + (await call('orchestration.taskCreate', { spec: 'part' }, { sessionId: PEER_CHAT })).task + ) + + const subDispatch = { task: subTask, to: 'term_worker' } + + expect(await refusal('orchestration.dispatch', subDispatch, PEER_CHAT)).toMatchObject({ + code: 'nested_worker_depth_exceeded' + }) + vi.spyOn(runtime, 'getNestedWorkerMaxDepth').mockReturnValue(2) + const child = await call('orchestration.dispatch', subDispatch, { sessionId: PEER_CHAT }) + expect(child.dispatch).toMatchObject({ depth: 2, creator_dispatch_id: idOf(parent.dispatch) }) + }) +}) diff --git a/src/main/runtime/structured-chat-naming-runtime.test.ts b/src/main/runtime/structured-chat-naming-runtime.test.ts index c500349988e1..c5c0fcc6cdbc 100644 --- a/src/main/runtime/structured-chat-naming-runtime.test.ts +++ b/src/main/runtime/structured-chat-naming-runtime.test.ts @@ -8,7 +8,8 @@ import type * as TextGeneration from '../text-generation/commit-message-text-gen const mocks = vi.hoisted(() => ({ generate: vi.fn(async () => ({ success: true as const, name: 'Login repair' })), - target: vi.fn(async (cwd: string) => ({ kind: 'local' as const, cwd })) + target: vi.fn(async (cwd: string) => ({ kind: 'local' as const, cwd })), + publishConversationName: vi.fn<(sessionId: string) => void>() })) vi.mock('../text-generation/commit-message-text-generation', async (importOriginal) => { @@ -18,6 +19,11 @@ vi.mock('../text-generation/commit-message-text-generation', async (importOrigin vi.mock('../agent-hooks/first-work-generation-target', () => ({ resolveGenerationTarget: mocks.target })) +vi.mock('../native-chat/agent-session-wire/structured-agent-session-registry', () => ({ + getStructuredAgentSessionHost: () => ({ + publishConversationName: mocks.publishConversationName + }) +})) beforeEach(() => vi.clearAllMocks()) @@ -32,7 +38,7 @@ function rig() { })), getAgentEnvResolvers: vi.fn(() => undefined), hasOpenDispatch: vi.fn(() => false), - onNamed: vi.fn() + retitleOpenTab: vi.fn() } const logger = { warn: vi.fn(), error: vi.fn() } return { @@ -113,6 +119,26 @@ describe('structured chat naming runtime', () => { expect(mocks.generate).not.toHaveBeenCalled() }) + it('publishes a new name to the host feed and retitles the open tab', () => { + const state = rig() + state.deps.onNamed('workspace-1', 'session-1') + expect(mocks.publishConversationName).toHaveBeenCalledWith('session-1') + expect(state.runtime.retitleOpenTab).toHaveBeenCalledWith('workspace-1', 'session-1') + }) + + it('still retitles the open tab when the feed publication fails', () => { + const state = rig() + mocks.publishConversationName.mockImplementationOnce(() => { + throw new Error('journal unreadable') + }) + state.deps.onNamed('workspace-1', 'session-1') + expect(state.runtime.retitleOpenTab).toHaveBeenCalledWith('workspace-1', 'session-1') + expect(state.logger.warn).toHaveBeenCalledWith( + 'Chat name publication failed', + expect.objectContaining({ sessionId: 'session-1' }) + ) + }) + it('declines invalid agent configuration without launching anything', async () => { const state = rig() state.settings.sourceControlAi = { diff --git a/src/main/runtime/structured-chat-naming-runtime.ts b/src/main/runtime/structured-chat-naming-runtime.ts index debfdf38c7d3..cdaf82ad2842 100644 --- a/src/main/runtime/structured-chat-naming-runtime.ts +++ b/src/main/runtime/structured-chat-naming-runtime.ts @@ -18,7 +18,7 @@ type StructuredChatNamingRuntime = { ) => Promise<{ path: string; executionHostId: ExecutionHostId }> getAgentEnvResolvers: () => CommitMessageAgentEnvironmentResolvers | undefined hasOpenDispatch: StructuredChatNamingDeps['hasOpenDispatch'] - onNamed: StructuredChatNamingDeps['onNamed'] + retitleOpenTab: StructuredChatNamingDeps['onNamed'] } export function structuredChatNamingDeps( @@ -75,7 +75,19 @@ export function structuredChatNamingDeps( } return result.name }, - onNamed: runtime.onNamed, + onNamed: (workspaceId, sessionId) => { + try { + // Lists that show the chat without its tab, a closed one too, learn the name from the feed. + getStructuredAgentSessionHost()?.publishConversationName(sessionId) + } catch (error) { + logger.warn('Chat name publication failed', { + scope: 'conversation-name', + sessionId, + error + }) + } + runtime.retitleOpenTab(workspaceId, sessionId) + }, logger } } diff --git a/src/main/runtime/structured-chat-worker-start.test.ts b/src/main/runtime/structured-chat-worker-start.test.ts new file mode 100644 index 000000000000..e2f2b21a25b5 --- /dev/null +++ b/src/main/runtime/structured-chat-worker-start.test.ts @@ -0,0 +1,246 @@ +import './rpc/unused-default-rpc-methods.test-fixture' +// `worker-start --terminal orca_session_id:`: a chat adopted as a worker, on the +// coordinator-mail rig. Its task is a queued send, read the way the terminal path reads its own +// write; a stop or a failed start never touches the chat. + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { formatOrcaSessionAddress } from '../../shared/orca-session-address' +import { testOrcaSessionId } from '../../shared/orca-session-address-test-fixture' +import type { FakeConnection } from './structured-chat-coordinator-fake-codex-fixture' +import { + COORDINATOR, + PEER_CHAT, + WAIT, + call, + coordinatorRunAndTask, + db, + dispatcher, + host, + openChat, + request, + runtime, + sendUserMessage, + settleTurn, + turnText +} from './structured-chat-coordinator-mail-rig.test-fixture' + +const WORKER = formatOrcaSessionAddress(testOrcaSessionId(PEER_CHAT)) + +beforeEach(() => { + vi.spyOn(runtime, 'showManagedTerminalWorkspace').mockImplementation(async (selector) => ({ + id: selector.replace(/^id:/, ''), + repoId: 'repo' + })) +}) + +/** A person's turn in the worker chat, started and still running; resolves to its end. */ +async function runningUserTurn(chat: FakeConnection): Promise<() => Promise> { + expect(await sendUserMessage(PEER_CHAT, 'go')).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(chat.turns).toHaveLength(1), WAIT) + const notify = (method: string, params: unknown) => chat.handlers.onNotification?.(method, params) + notify('turn/started', { turn: { id: 'user-turn' } }) + notify('item/completed', { + item: { + type: 'userMessage', + id: 'echo-go', + clientId: chat.turns[0]!.clientUserMessageId, + content: [{ type: 'text', text: 'go' }] + } + }) + await host.flushStreamedEvents(PEER_CHAT) + return async () => { + notify('turn/completed', { turn: { id: 'user-turn' } }) + await host.flushStreamedEvents(PEER_CHAT) + } +} + +function startWorker(params: Record = {}) { + return call( + 'orchestration.workerStart', + { task: params.task, terminal: WORKER, ...params }, + { sessionId: COORDINATOR } + ) +} + +async function refusal(params: Record) { + const response = await dispatcher.dispatch( + request('orchestration.workerStart', params, { sessionId: COORDINATOR }) + ) + if (response.ok) { + throw new Error(`expected a refusal, got ${JSON.stringify(response)}`) + } + return response.error +} + +function expectChatOpen(): void { + expect(host.hasSession(PEER_CHAT)).toBe(true) + expect(host.deps.store.getVisibleSessionTabIndex().sessionIds).toContain(PEER_CHAT) +} + +describe('worker-start --terminal names a chat', () => { + it('is ready once an idle chat takes its task, and records the chat as an external worker', async () => { + await openChat(COORDINATOR) + const worker = await openChat(PEER_CHAT) + const { taskId } = await coordinatorRunAndTask() + + const started = startWorker({ task: taskId }) + await vi.waitFor(() => expect(worker.turns).toHaveLength(1), WAIT) + await settleTurn(PEER_CHAT, 0) + const receipt = await started + + expect(receipt).toMatchObject({ + state: 'ready', + turnStart: 'observed', + mode: { + detail: `--terminal names the chat ${WORKER}; its task goes to that chat and no agent is launched.` + } + }) + const dispatchId = String(receipt.dispatchId) + const task = turnText(worker.turns[0]!) + expect(task).toContain(`Your Orca session ID is: ${WORKER}`) + expect(task).toContain('The coordinator cannot see this chat') + expect(task).toContain('it will send this chat a fresh') + expect(task).not.toContain('this terminal') + expect(task).not.toMatch(/exit the shell/i) + expect(db.getDispatchContextById(dispatchId)).toMatchObject({ + assignee_handle: WORKER, + assignee_orca_session_id: PEER_CHAT, + assignee_pane_key: null, + process_incarnation: null + }) + expect(db.getWorkerTerminalResourceByOwner(dispatchId)).toMatchObject({ + terminal_handle: WORKER, + ownership_state: 'external' + }) + }) + + it('hands a busy chat its task as a queued card and reads the start as unknown, not failed', async () => { + await openChat(COORDINATOR) + const worker = await openChat(PEER_CHAT) + const { taskId } = await coordinatorRunAndTask() + const endTurn = await runningUserTurn(worker) + + const receipt = await startWorker({ task: taskId }) + + expect(receipt).toMatchObject({ + state: 'outcome_unknown', + turnStart: 'unobserved', + lastError: expect.stringMatching( + /waiting as a card in the chat's queue and is sent when the queue reaches it\..*worker-abandon .* does not remove the card/ + ), + effects: expect.arrayContaining([ + expect.objectContaining({ kind: 'dispatch_input', id: WORKER, state: 'accepted' }), + expect.objectContaining({ kind: 'dispatch_input', id: WORKER, state: 'turn_unobserved' }) + ]) + }) + expect(JSON.stringify(receipt.nextCommands)).not.toContain('terminal read') + expect(JSON.stringify(receipt.nextCommands)).toContain('worker-abandon') + const dispatchId = String(receipt.dispatchId) + expect(db.getWorkerDispatch(dispatchId)?.state).toBe('start_unknown') + expect(worker.turns).toHaveLength(1) + + await endTurn() + await vi.waitFor(() => expect(worker.turns).toHaveLength(2), WAIT) + expect(turnText(worker.turns[1]!)).toContain(dispatchId) + }) + + it("reads the start as unknown when the chat's agent has not taken the task within the wait", async () => { + await openChat(COORDINATOR) + await openChat(PEER_CHAT) + const { taskId } = await coordinatorRunAndTask() + // The settlement wait running out, as for an agent still starting. + vi.spyOn(host, 'waitForSendSettlement').mockResolvedValue(undefined) + + const receipt = await startWorker({ task: taskId }) + + expect(receipt).toMatchObject({ + state: 'outcome_unknown', + turnStart: 'unobserved', + lastError: expect.stringContaining("the chat's agent had not started to take it") + }) + expect(db.getWorkerDispatch(String(receipt.dispatchId))?.state).toBe('start_unknown') + }) + + it('is stopped and abandoned without closing or interrupting the chat', async () => { + await openChat(COORDINATOR) + const worker = await openChat(PEER_CHAT) + const { taskId } = await coordinatorRunAndTask() + const started = startWorker({ task: taskId }) + await vi.waitFor(() => expect(worker.turns).toHaveLength(1), WAIT) + await settleTurn(PEER_CHAT, 0) + const dispatchId = String((await started).dispatchId) + + expect(await call('orchestration.workerList', {})).toMatchObject({ + workers: [{ dispatchId, projection: { liveness: { verdict: 'live' } } }] + }) + // What the chat reports when the person types into it, a /clear included: a chat is no + // minted worker, so there is nothing to take over and its Dispatch is untouched. + expect(await call('orchestration.workerTerminalUserInput', { sessionId: PEER_CHAT })).toEqual({ + changed: 0 + }) + expect(db.getWorkerTerminalResourceByOwner(dispatchId)?.ownership_state).toBe('external') + expect(await call('orchestration.workerStop', { dispatch: dispatchId })).toMatchObject({ + state: 'stop_unknown', + processAction: 'none' + }) + expectChatOpen() + expect(await call('orchestration.workerAbandon', { dispatch: dispatchId })).toMatchObject({ + state: 'abandoned', + processAction: 'none' + }) + expectChatOpen() + }) +}) + +describe('worker-start --terminal refuses a chat before anything is created', () => { + async function expectNothingCreated(error: unknown, code: string) { + expect(error).toMatchObject({ code }) + expect(db.db.prepare('SELECT COUNT(*) AS n FROM dispatch_contexts').get()).toEqual({ n: 0 }) + } + + it('when it is the coordinator itself', async () => { + await openChat(COORDINATOR) + const { taskId } = await coordinatorRunAndTask() + const self = formatOrcaSessionAddress(testOrcaSessionId(COORDINATOR)) + + await expectNothingCreated( + await refusal({ task: taskId, terminal: self }), + 'terminal_is_coordinator' + ) + }) + + it('when it works in another worktree', async () => { + await openChat(COORDINATOR) + await openChat(PEER_CHAT) + const { taskId } = await coordinatorRunAndTask() + + await expectNothingCreated( + await refusal({ task: taskId, terminal: WORKER, worktree: 'id:other-worktree' }), + 'terminal_worktree_mismatch' + ) + }) + + it('when it was closed', async () => { + await openChat(COORDINATOR) + await openChat(PEER_CHAT) + const { taskId } = await coordinatorRunAndTask() + await host.setSessionTabVisibility(PEER_CHAT, false) + + await expectNothingCreated( + await refusal({ task: taskId, terminal: WORKER }), + 'session_caller_not_live' + ) + }) + + it('when it is named on another host, before any remote call', async () => { + await openChat(COORDINATOR) + const { taskId } = await coordinatorRunAndTask() + const remote = vi.spyOn(runtime, 'resolveOrchestrationWorkerServer') + + await expectNothingCreated( + await refusal({ task: taskId, terminal: WORKER, on: 'box' }), + 'session_caller_host_boundary' + ) + expect(remote).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/structured-claude-runtime-adapter.ts b/src/main/runtime/structured-claude-runtime-adapter.ts index 4212b9788b87..288c7a7ddd59 100644 --- a/src/main/runtime/structured-claude-runtime-adapter.ts +++ b/src/main/runtime/structured-claude-runtime-adapter.ts @@ -51,7 +51,7 @@ export type StructuredClaudeRuntimeAdapterDeps = { export function structuredClaudeLifecycleEvent( event: ClaudeStructuredSessionEvent ): StructuredAgentSessionLifecycleEvent | null { - if (event.type === 'started') { + if (event.type === 'started' || event.type === 'options-skipped') { return event } // Every exit of a child with an identity, expected or not: the host ends that child's record. @@ -71,7 +71,8 @@ export function structuredClaudeLifecycleEvent( acquisitionGeneration: event.acquisitionGeneration, // The instant the translator ended the open turn at; the host reads the exit's turn by it. ...(event.observedAt === undefined ? {} : { observedAt: event.observedAt }), - ...(event.startupUnproven ? { startupUnproven: event.startupUnproven } : {}) + ...(event.startupUnproven ? { startupUnproven: event.startupUnproven } : {}), + ...(event.startupUnanswered ? { startupUnanswered: event.startupUnanswered } : {}) } } return null diff --git a/src/main/runtime/structured-claude-scripted-runtime-test-support.ts b/src/main/runtime/structured-claude-scripted-runtime-test-support.ts index b379afbe1e23..77e66d31d639 100644 --- a/src/main/runtime/structured-claude-scripted-runtime-test-support.ts +++ b/src/main/runtime/structured-claude-scripted-runtime-test-support.ts @@ -43,11 +43,12 @@ export type ScriptedClaudeBehavior = { optionWritesHang?: boolean /** Startup's own settings read goes unanswered. */ startupSettingsReadHangs?: boolean - /** Every option write loses its answer while the CLI keeps running (not a refusal), so a - * start that restores one faults. */ + /** Every option write loses its answer while the CLI keeps running (not a refusal). */ optionWritesFail?: boolean /** The init frame names another provider session than the one launched. */ initNamesForeignSession?: boolean + /** No start frame before the first turn, as with no SessionStart hook configured. */ + sendsNoStartFrame?: boolean } export type ScriptedClaudeChild = { @@ -123,7 +124,10 @@ export function createScriptedClaudeRuntime(sessionIds: readonly string[]) { exitVerdict: { root: 'live', tree: 'unverifiable' }, initializationResult: () => { const initialized = { models: [{ value: 'sonnet', displayName: 'Sonnet' }] } - const announce = (): void => + const announce = (): void => { + if (behavior.sendsNoStartFrame) { + return + } handlers.onMessage?.({ type: 'system', subtype: 'init', @@ -131,6 +135,7 @@ export function createScriptedClaudeRuntime(sessionIds: readonly string[]) { model: 'claude-sonnet-5', apiKeySource: 'none' }) + } if (behavior.initHangs) { return new Promise((resolve, reject) => { failInit = reject diff --git a/src/main/ssh/relay-bundle-paths.test.ts b/src/main/ssh/relay-bundle-paths.test.ts new file mode 100644 index 000000000000..cfa4a5d138d7 --- /dev/null +++ b/src/main/ssh/relay-bundle-paths.test.ts @@ -0,0 +1,37 @@ +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +vi.mock('../../shared/app-environment', () => ({ + getAppEnvironment: () => { + throw new Error('AppEnvironment not initialized') + } +})) +import { relayBundleCandidates } from './relay-bundle-paths' + +afterEach(() => vi.unstubAllEnvs()) + +it('lists the WSL guest bundle dirs in the order the WSL relays searched them', () => { + vi.stubEnv('ORCA_RELAY_PATH', join('/env', 'relay')) + const resources = Object.getOwnPropertyDescriptor(process, 'resourcesPath') + Object.defineProperty(process, 'resourcesPath', { value: '/res', configurable: true }) + try { + expect(relayBundleCandidates('wsl', '/app')).toEqual([ + join('/env', 'relay', 'wsl'), + join('/res', 'relay', 'wsl'), + join('/res', 'app.asar.unpacked', 'out', 'relay', 'wsl'), + join('/app', 'resources', 'relay', 'wsl'), + join('/app', 'out', 'relay', 'wsl') + ]) + // No app environment (tests, early startup): the env and resources dirs still resolve. + expect(relayBundleCandidates('wsl')).toEqual([ + join('/env', 'relay', 'wsl'), + join('/res', 'relay', 'wsl'), + join('/res', 'app.asar.unpacked', 'out', 'relay', 'wsl') + ]) + } finally { + if (resources) { + Object.defineProperty(process, 'resourcesPath', resources) + } else { + Reflect.deleteProperty(process, 'resourcesPath') + } + } +}) diff --git a/src/main/ssh/relay-bundle-paths.ts b/src/main/ssh/relay-bundle-paths.ts index d20dccb4cdbd..d859c4df2c13 100644 --- a/src/main/ssh/relay-bundle-paths.ts +++ b/src/main/ssh/relay-bundle-paths.ts @@ -1,7 +1,21 @@ import { join } from 'node:path' +import { getAppEnvironment } from '../../shared/app-environment' import type { RelayPlatform } from './relay-protocol' -export function relayBundleCandidates(platform: RelayPlatform, appPath: string): string[] { +function currentAppPath(): string | undefined { + try { + return getAppEnvironment().getAppPath() + } catch { + // Tests, early startup and plain-Node hosts have no app path; env/resources candidates suffice. + return undefined + } +} + +/** `wsl` is the WSL-only guest bundle dir (`out/relay/wsl`), never uploaded to SSH hosts. */ +export function relayBundleCandidates( + platform: RelayPlatform | 'wsl', + appPath = currentAppPath() +): string[] { return [ ...new Set([ ...(process.env.ORCA_RELAY_PATH ? [join(process.env.ORCA_RELAY_PATH, platform)] : []), @@ -11,8 +25,9 @@ export function relayBundleCandidates(platform: RelayPlatform, appPath: string): join(process.resourcesPath, 'app.asar.unpacked', 'out', 'relay', platform) ] : []), - join(appPath, 'resources', 'relay', platform), - join(appPath, 'out', 'relay', platform) + ...(appPath + ? [join(appPath, 'resources', 'relay', platform), join(appPath, 'out', 'relay', platform)] + : []) ]) ] } diff --git a/src/main/usage/usage-scan-worker-entry.ts b/src/main/usage/usage-scan-worker-entry.ts index 03c056ea88c6..9c4c4b1880de 100644 --- a/src/main/usage/usage-scan-worker-entry.ts +++ b/src/main/usage/usage-scan-worker-entry.ts @@ -63,7 +63,12 @@ async function runScan( // provider's own record type, so nothing here needs a type assertion. switch (request.providerId) { case 'claude': { - const result = await scanClaudeUsageFiles(request.worktrees, request.previous, onFilesScanned) + const result = await scanClaudeUsageFiles( + request.worktrees, + request.previous, + onFilesScanned, + request.profileDirs + ) return { providerId: 'claude', source: result.processedFiles, diff --git a/src/main/usage/usage-scan-worker-protocol.ts b/src/main/usage/usage-scan-worker-protocol.ts index e4a32c3ac3c5..0d657cd535b4 100644 --- a/src/main/usage/usage-scan-worker-protocol.ts +++ b/src/main/usage/usage-scan-worker-protocol.ts @@ -37,6 +37,7 @@ export type UsageScanWorkerProviderId = 'claude' | 'codex' | 'opencode' | 'muse' export type UsageScanWorkerRequestBody = | { providerId: 'claude' + profileDirs?: string[] worktrees: UsageScanWorktreeRef[] previous: ClaudeUsagePersistedFile[] } diff --git a/src/main/usage/usage-scan-worker-spawn.ts b/src/main/usage/usage-scan-worker-spawn.ts index c19ab1c2cee5..b38b3cc44a9f 100644 --- a/src/main/usage/usage-scan-worker-spawn.ts +++ b/src/main/usage/usage-scan-worker-spawn.ts @@ -1,3 +1,4 @@ +import { claudeProfileTranscriptDirs } from '../claude-usage/transcript-file-discovery' import { existsSync } from 'node:fs' import { Worker } from 'node:worker_threads' import { currentWorkerEntryLayout, resolveWorkerThreadEntryPath } from '../worker-thread-entry-path' @@ -71,7 +72,12 @@ export async function scanClaudeUsageFilesViaWorker( dailyAggregates: ClaudeUsageDailyAggregate[] }> { const value = await scanClaudeUsageOnWorker( - (body) => getSharedClient().scan(body), + (body) => + getSharedClient().scan( + body.providerId === 'claude' + ? { ...body, profileDirs: claudeProfileTranscriptDirs() } + : body + ), worktrees, previous ) diff --git a/src/main/window/dropped-path-preparation.test.ts b/src/main/window/dropped-path-preparation.test.ts new file mode 100644 index 000000000000..d04aa90ef607 --- /dev/null +++ b/src/main/window/dropped-path-preparation.test.ts @@ -0,0 +1,180 @@ +import { join } from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { DragTempCopyItemResult } from './dragged-temp-file-copy' +import type * as DragTempFileCopy from './dragged-temp-file-copy' +import type { NativeFileDropPayload } from '../../shared/native-file-drop' +import { createDragTempCopyLane, MAX_PENDING_DRAG_TEMP_COPIES } from './dropped-path-preparation' +import { createNativeFileDropQueue } from './native-file-drop-relay' + +const materialize = vi.hoisted(() => vi.fn()) +vi.mock('electron', () => ({ app: {}, ipcMain: {} })) +vi.mock('./dragged-temp-file-copy', async (original) => ({ + ...(await original()), + materializeDragTempPaths: materialize +})) +const TEMP = join('/', 'var', 'T', 'TemporaryItems', 'NSIRD_one', 'Shot.png') +const ORDINARY = join('/', 'files', 'notes.txt') +const COPY = join('/', 'var', 'T', 'orca-drops', 'orca-drop-abc123', 'Shot.png') +const env = { platform: 'darwin' as const, sourceTempRoot: '/var/T', copyRoot: '/var/T/drops' } + +function queue(lane = createDragTempCopyLane(), copyTimeoutMs = 1000) { + const controller = new AbortController() + const forwarded: NativeFileDropPayload[] = [] + const dispose = vi.fn() + const enqueue = createNativeFileDropQueue( + { + platform: 'darwin', + getCopyEnvironment: async () => env, + watchRenderer: () => ({ signal: controller.signal, dispose }), + forward: (payload) => forwarded.push(payload), + copyTimeoutMs + }, + lane + ) + return { enqueue, controller, forwarded, dispose } +} +function deferred() { + let resolve!: (value: DragTempCopyItemResult[]) => void + const promise = new Promise((settle) => { + resolve = settle + }) + return { promise, resolve } +} +async function settle() { + await new Promise((resolve) => setTimeout(resolve, 0)) +} +beforeEach(() => { + materialize.mockReset() +}) + +describe('dropped path preparation through the shared relay queue', () => { + it.each(['agent', 'main-reader'] as const)( + 'passes ordinary paths without copying for %s', + async (consumer) => { + const { enqueue } = queue() + await expect(enqueue.prepare({ paths: [ORDINARY], consumer })).resolves.toEqual({ + paths: [ORDINARY], + failures: [] + }) + expect(materialize).not.toHaveBeenCalled() + } + ) + + it.each(['agent', 'main-reader'] as const)( + 'withholds uncopied originals only from agents: %s', + async (consumer) => { + materialize.mockResolvedValue([ + { sourcePath: ORDINARY, status: 'imported', destPath: ORDINARY }, + { sourcePath: TEMP, status: 'uncopied', reason: 'storage-full' } + ]) + const { enqueue } = queue() + const result = await enqueue.prepare({ paths: [ORDINARY, TEMP], consumer }) + expect(result.paths).toEqual(consumer === 'agent' ? [ORDINARY] : [ORDINARY, TEMP]) + expect(result.failures).toEqual( + consumer === 'agent' + ? [ + { + byteLength: 0, + pathCount: 1, + reason: 'temp-copy-failed', + target: 'rejected', + commonReason: 'storage-full' + } + ] + : [] + ) + } + ) + + it('preserves ordinary successes and copied successes when another file fails', async () => { + materialize.mockResolvedValue([ + { sourcePath: ORDINARY, status: 'imported', destPath: ORDINARY }, + { sourcePath: TEMP, status: 'imported', destPath: COPY }, + { sourcePath: TEMP, status: 'failed', reason: 'missing' } + ]) + const result = await queue().enqueue.prepare({ + paths: [ORDINARY, TEMP, TEMP], + consumer: 'agent' + }) + expect(result.paths).toEqual([ORDINARY, COPY]) + expect(result.failures[0]).toMatchObject({ pathCount: 1, commonReason: 'missing' }) + }) + + it('serializes requests and the legacy relay together', async () => { + const first = deferred() + materialize + .mockReturnValueOnce(first.promise) + .mockResolvedValueOnce([{ sourcePath: TEMP, status: 'imported', destPath: COPY }]) + const { enqueue, forwarded } = queue() + enqueue({ paths: [TEMP], target: 'terminal' }) + const requested = enqueue.prepare({ paths: [TEMP], consumer: 'agent' }) + await settle() + expect(materialize).toHaveBeenCalledTimes(1) + first.resolve([{ sourcePath: TEMP, status: 'imported', destPath: COPY }]) + await expect(requested).resolves.toEqual({ paths: [COPY], failures: [] }) + await settle() + expect(forwarded).toEqual([{ paths: [COPY], target: 'terminal' }]) + expect(materialize).toHaveBeenCalledTimes(2) + }) + + it('shares serialization and the pending cap across replacement-window queues', async () => { + materialize.mockReturnValue(new Promise(() => undefined)) + const lane = createDragTempCopyLane() + const first = queue(lane) + const second = queue(lane) + const results = Array.from({ length: MAX_PENDING_DRAG_TEMP_COPIES }, () => + first.enqueue.prepare({ paths: [TEMP], consumer: 'agent' }).catch(() => undefined) + ) + await expect( + second.enqueue.prepare({ paths: [ORDINARY, TEMP], consumer: 'agent' }) + ).resolves.toMatchObject({ paths: [ORDINARY], failures: [{ commonReason: 'busy' }] }) + first.controller.abort(new Error('test ended')) + await Promise.all(results) + await settle() + expect(materialize).toHaveBeenCalledTimes(1) + }) + + it('lets another owner prepare ordinary paths while a copy waits', async () => { + const copy = deferred() + materialize.mockReturnValueOnce(copy.promise) + const { enqueue } = queue() + const pending = enqueue.prepare({ paths: [TEMP], consumer: 'agent' }) + await expect(enqueue.prepare({ paths: [ORDINARY], consumer: 'main-reader' })).resolves.toEqual({ + paths: [ORDINARY], + failures: [] + }) + copy.resolve([{ sourcePath: TEMP, status: 'imported', destPath: COPY }]) + await pending + }) + + it('aborts running and queued requests on reload and releases their listeners', async () => { + materialize.mockReturnValue(new Promise(() => undefined)) + const { enqueue, controller, dispose } = queue() + const running = enqueue.prepare({ paths: [TEMP], consumer: 'agent' }) + const queued = enqueue.prepare({ paths: [TEMP], consumer: 'agent' }) + const assertions = [ + expect(running).rejects.toThrow('reloaded'), + expect(queued).rejects.toThrow('reloaded') + ] + await settle() + controller.abort(new Error('reloaded')) + await Promise.all(assertions) + await settle() + expect(materialize).toHaveBeenCalledTimes(1) + expect(dispose).toHaveBeenCalledTimes(2) + }) + + it('reports timeout, keeps ordinary paths, and serves the next copy', async () => { + materialize + .mockReturnValueOnce(new Promise(() => undefined)) + .mockResolvedValueOnce([{ sourcePath: TEMP, status: 'imported', destPath: COPY }]) + const { enqueue } = queue(undefined, 5) + const first = enqueue.prepare({ paths: [ORDINARY, TEMP], consumer: 'agent' }) + const next = enqueue.prepare({ paths: [TEMP], consumer: 'agent' }) + await expect(first).resolves.toMatchObject({ + paths: [ORDINARY], + failures: [{ commonReason: 'timed-out' }] + }) + await expect(next).resolves.toEqual({ paths: [COPY], failures: [] }) + }) +}) diff --git a/src/main/window/dropped-path-preparation.ts b/src/main/window/dropped-path-preparation.ts new file mode 100644 index 000000000000..1012989a8712 --- /dev/null +++ b/src/main/window/dropped-path-preparation.ts @@ -0,0 +1,161 @@ +import type { NativeFileDropCopyFailureReason } from '../../shared/native-file-drop' +import type { + PrepareDroppedPathsRequest, + PreparedDroppedPaths +} from '../../shared/native-file-drop-preparation' +import { + materializeDragTempPaths, + mayNeedDragTempCopy, + type DragTempCopyEnvironment +} from './dragged-temp-file-copy' + +const DRAG_TEMP_COPY_TIMEOUT_MS = 2 * 60 * 1000 +export const MAX_PENDING_DRAG_TEMP_COPIES = 8 + +export type RendererDropLifetime = { signal: AbortSignal; dispose: () => void } +export type DroppedPathPreparationDeps = { + platform: NodeJS.Platform + getCopyEnvironment: () => Promise + watchRenderer: () => RendererDropLifetime + copyTimeoutMs?: number +} + +export function createDragTempCopyLane(): { tail: Promise; pending: number } { + return { tail: Promise.resolve(), pending: 0 } +} + +// Why: replacement windows still share the retained-copy budget with work already in flight. +export const windowDragTempCopyLane = createDragTempCopyLane() + +export function createDroppedPathPreparationQueue( + deps: DroppedPathPreparationDeps, + lane = createDragTempCopyLane() +): (request: PrepareDroppedPathsRequest) => PreparedDroppedPaths | Promise { + return (request) => { + if (!request.paths.some((path) => mayNeedDragTempCopy(path, deps.platform))) { + return { paths: [...request.paths], failures: [] } + } + if (lane.pending >= MAX_PENDING_DRAG_TEMP_COPIES) { + return failDroppedPathPreparation(request.paths, deps.platform, 'busy') + } + const lifetime = deps.watchRenderer() + lane.pending += 1 + const prepared = lane.tail.then(() => { + lifetime.signal.throwIfAborted() + return copyDroppedPaths(request, lifetime.signal, deps) + }) + lane.tail = prepared + .then( + () => undefined, + () => undefined + ) + .finally(() => { + lane.pending -= 1 + lifetime.dispose() + }) + // Why: a reloaded document must stop waiting even while its request is queued. + return rejectOnDropAbort(prepared, lifetime.signal) + } +} + +export async function prepareDroppedPaths( + request: PrepareDroppedPathsRequest, + env: DragTempCopyEnvironment, + signal?: AbortSignal +): Promise { + const results = await materializeDragTempPaths(request.paths, env, signal) + const acceptsOriginal = request.consumer === 'main-reader' + const paths = results.flatMap((result) => + result.status === 'imported' + ? [result.destPath] + : result.status === 'uncopied' && acceptsOriginal + ? [result.sourcePath] + : [] + ) + const unprepared = results.flatMap((result) => + result.status === 'imported' || (result.status === 'uncopied' && acceptsOriginal) + ? [] + : [result] + ) + const commonReason = unprepared.every((item) => item.reason === unprepared[0]?.reason) + ? unprepared[0]?.reason + : undefined + return { + paths, + failures: unprepared.length > 0 ? [copyFailure(unprepared.length, commonReason)] : [] + } +} + +function failDroppedPathPreparation( + paths: readonly string[], + platform: NodeJS.Platform, + reason?: NativeFileDropCopyFailureReason +): PreparedDroppedPaths { + const ordinary = paths.filter((path) => !mayNeedDragTempCopy(path, platform)) + const lost = paths.length - ordinary.length + return { + paths: ordinary, + failures: lost > 0 ? [copyFailure(lost, reason)] : [] + } +} + +async function copyDroppedPaths( + request: PrepareDroppedPathsRequest, + rendererSignal: AbortSignal, + deps: DroppedPathPreparationDeps +): Promise { + const timeout = new AbortController() + const timer = setTimeout( + () => timeout.abort(new Error('Copying the dropped files took too long')), + deps.copyTimeoutMs ?? DRAG_TEMP_COPY_TIMEOUT_MS + ) + const signal = AbortSignal.any([rendererSignal, timeout.signal]) + try { + return await rejectOnDropAbort( + deps.getCopyEnvironment().then((env) => prepareDroppedPaths(request, env, signal)), + signal + ) + } catch (error) { + if (rendererSignal.aborted) { + throw error + } + return failDroppedPathPreparation( + request.paths, + deps.platform, + timeout.signal.aborted ? 'timed-out' : undefined + ) + } finally { + clearTimeout(timer) + } +} + +export function rejectOnDropAbort(work: Promise, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const onAbort = (): void => reject(signal.reason) + if (signal.aborted) { + onAbort() + } else { + signal.addEventListener('abort', onAbort, { once: true }) + } + void work.then( + (value) => { + signal.removeEventListener('abort', onAbort) + resolve(value) + }, + (error) => { + signal.removeEventListener('abort', onAbort) + reject(error) + } + ) + }) +} + +function copyFailure(pathCount: number, commonReason?: NativeFileDropCopyFailureReason) { + return { + byteLength: 0, + pathCount, + reason: 'temp-copy-failed' as const, + target: 'rejected' as const, + ...(commonReason ? { commonReason } : {}) + } +} diff --git a/src/main/window/native-file-drop-relay.test.ts b/src/main/window/native-file-drop-relay.test.ts index 276387bc6a48..7200be84eba4 100644 --- a/src/main/window/native-file-drop-relay.test.ts +++ b/src/main/window/native-file-drop-relay.test.ts @@ -6,16 +6,20 @@ import type * as DragTempFileCopy from './dragged-temp-file-copy' import type { DragTempCopyItemResult } from './dragged-temp-file-copy' type IpcListener = (event: { sender: unknown }, payload: unknown) => void +type InvokeHandler = (event: { sender: unknown }, payload: unknown) => Promise -const { materializeMock, sweepMock, ipcListeners } = vi.hoisted(() => ({ +const { materializeMock, sweepMock, ipcListeners, ipcHandlers } = vi.hoisted(() => ({ materializeMock: vi.fn(), sweepMock: vi.fn(), - ipcListeners: new Map() + ipcListeners: new Map(), + ipcHandlers: new Map() })) vi.mock('electron', () => ({ app: { getPath: () => '/app-temp' }, ipcMain: { + handle: (channel: string, handler: InvokeHandler) => ipcHandlers.set(channel, handler), + removeHandler: (channel: string) => ipcHandlers.delete(channel), on: (channel: string, listener: IpcListener) => ipcListeners.set(channel, listener), removeListener: (channel: string, listener: IpcListener) => { if (ipcListeners.get(channel) === listener) { @@ -418,6 +422,7 @@ function relay(): IpcListener { describe('registerFileDropRelay', () => { beforeEach(() => { ipcListeners.clear() + ipcHandlers.clear() sweepMock.mockReset() }) @@ -452,6 +457,61 @@ describe('registerFileDropRelay', () => { expect(first.webContents.send).not.toHaveBeenCalled() }) + it('prepares ordinary paths only for its own live renderer and validates the request', async () => { + const { window, webContents, close } = createWindow() + registerFileDropRelay(window) + const handler = ipcHandlers.get('fs:prepareDroppedPaths')! + const request = { paths: [FINDER], consumer: 'agent' } + expect(() => handler({ sender: {} }, request)).toThrow('owning window') + for (const invalid of [ + null, + { paths: [FINDER], consumer: 'other' }, + { paths: [42], consumer: 'agent' } + ]) { + expect(() => handler({ sender: webContents }, invalid)).toThrow('Invalid') + } + await expect(handler({ sender: webContents }, request)).resolves.toEqual({ + paths: [FINDER], + failures: [] + }) + expect(webContents.send).not.toHaveBeenCalled() + close() + expect(ipcHandlers.has('fs:prepareDroppedPaths')).toBe(false) + expect(() => handler({ sender: webContents }, request)).toThrow('owning window') + }) + + it('enforces both request caps before preparing any paths', async () => { + const { window, webContents } = createWindow() + registerFileDropRelay(window) + const handler = ipcHandlers.get('fs:prepareDroppedPaths')! + await expect( + handler( + { sender: webContents }, + { paths: Array.from({ length: 257 }, () => DRAG_TEMP), consumer: 'agent' } + ) + ).resolves.toMatchObject({ + paths: [], + failures: [{ reason: 'too-many-paths', pathCount: 257 }] + }) + await expect( + handler({ sender: webContents }, { paths: ['x'.repeat(256 * 1024 + 1)], consumer: 'agent' }) + ).resolves.toMatchObject({ paths: [], failures: [{ reason: 'paths-too-large' }] }) + expect(materializeMock).not.toHaveBeenCalled() + }) + + it('keeps the replacement window handler when an older window closes late', async () => { + const first = createWindow() + const second = createWindow() + registerFileDropRelay(first.window) + registerFileDropRelay(second.window) + const handler = ipcHandlers.get('fs:prepareDroppedPaths')! + first.close() + expect(ipcHandlers.get('fs:prepareDroppedPaths')).toBe(handler) + await expect( + handler({ sender: second.webContents }, { paths: [FINDER], consumer: 'main-reader' }) + ).resolves.toEqual({ paths: [FINDER], failures: [] }) + }) + it.skipIf(process.platform !== 'darwin')( 'copies from the macOS user temp dir and sends nothing once the window is gone', async () => { diff --git a/src/main/window/native-file-drop-relay.ts b/src/main/window/native-file-drop-relay.ts index b8af442e8950..3d7d4c8f50bb 100644 --- a/src/main/window/native-file-drop-relay.ts +++ b/src/main/window/native-file-drop-relay.ts @@ -1,251 +1,219 @@ import { app, ipcMain } from 'electron' import type { BrowserWindow } from 'electron' import { + createRejectedNativeFileDropPayload, isNativeFileDropPayload, NATIVE_FILE_DROP_TARGET, - type NativeFileDropCopyFailureReason, + validateNativeFileDropPaths, type NativeFileDropPayload, type NativeFileDropRejectedPayload } from '../../shared/native-file-drop' +import type { + PrepareDroppedPathsRequest, + PreparedDroppedPaths +} from '../../shared/native-file-drop-preparation' import { abortWhenRendererGone } from '../ipc/renderer-lifetime-abort' +import { getDragTempCopyRoot, scheduleDragTempCopySweep } from './dragged-temp-file-copy' import { - getDragTempCopyRoot, - materializeDragTempPaths, - mayNeedDragTempCopy, - scheduleDragTempCopySweep, - type DragTempCopyEnvironment -} from './dragged-temp-file-copy' + createDroppedPathPreparationQueue, + type createDragTempCopyLane, + prepareDroppedPaths, + windowDragTempCopyLane, + type DroppedPathPreparationDeps +} from './dropped-path-preparation' +import type { DragTempCopyEnvironment } from './dragged-temp-file-copy' import { getDarwinUserTempDir } from './darwin-user-temp-dir' -// Why: copies run one at a time, so a hung copy must not hold every later copy forever. -const DRAG_TEMP_COPY_TIMEOUT_MS = 2 * 60 * 1000 -// Why: a drop is one user gesture; more than this waiting means copies are stuck, not busy. -export const MAX_PENDING_DRAG_TEMP_COPIES = 8 +export { MAX_PENDING_DRAG_TEMP_COPIES } from './dropped-path-preparation' type AcceptedNativeFileDropPayload = Exclude - -type RendererLifetime = { signal: AbortSignal; dispose: () => void } - -type NativeFileDropQueueDeps = { +type NativeFileDropQueueDeps = DroppedPathPreparationDeps & { forward: (payload: NativeFileDropPayload) => void - platform: NodeJS.Platform - getCopyEnvironment: () => Promise - watchRenderer: () => RendererLifetime - copyTimeoutMs?: number +} +type NativeFileDropQueue = ((payload: NativeFileDropPayload) => void) & { + prepare: (request: PrepareDroppedPathsRequest) => Promise } export function registerFileDropRelay(mainWindow: BrowserWindow): void { const channel = 'terminal:file-dropped-from-preload' + const prepareChannel = 'fs:prepareDroppedPaths' const mainWebContents = mainWindow.webContents const isWindowGone = (): boolean => mainWindow.isDestroyed() || mainWebContents.isDestroyed() ipcMain.removeAllListeners(channel) - const enqueue = createNativeFileDropQueue({ - // Why: one IPC event per drop gesture so the renderer gets the full path batch without timer-based reconstruction. - forward: (payload) => { - if (!isWindowGone()) { - mainWebContents.send('terminal:file-drop', payload) - } - }, - platform: process.platform, - getCopyEnvironment: async () => ({ + ipcMain.removeHandler(prepareChannel) + const enqueue = createNativeFileDropQueue( + { + forward: (payload) => { + if (!isWindowGone()) { + mainWebContents.send('terminal:file-drop', payload) + } + }, platform: process.platform, - sourceTempRoot: await getDarwinUserTempDir(), - copyRoot: getDragTempCopyRoot(app.getPath('temp')) - }), - watchRenderer: () => abortWhenRendererGone(mainWebContents) - }) + getCopyEnvironment: async () => ({ + platform: process.platform, + sourceTempRoot: await getDarwinUserTempDir(), + copyRoot: getDragTempCopyRoot(app.getPath('temp')) + }), + watchRenderer: () => abortWhenRendererGone(mainWebContents) + }, + windowDragTempCopyLane + ) const relayFileDrop = (event: Electron.IpcMainEvent, args: NativeFileDropPayload): void => { if (isWindowGone() || event.sender !== mainWebContents) { return } - // Why: only main reports a failed copy; a renderer claiming one is ignored. if (!isNativeFileDropPayload(args) || isTempCopyFailure(args)) { return } enqueue(args) } ipcMain.on(channel, relayFileDrop) + const prepareHandler = ( + event: Electron.IpcMainInvokeEvent, + args: unknown + ): Promise => { + if (isWindowGone() || event.sender !== mainWebContents) { + throw new Error('Dropped paths must come from the owning window') + } + if (!isPrepareDroppedPathsRequest(args)) { + throw new Error('Invalid dropped paths request') + } + const validation = validateNativeFileDropPaths(args.paths) + if (validation.status === 'rejected') { + return Promise.resolve({ + paths: [], + failures: [createRejectedNativeFileDropPayload(validation)] + }) + } + return enqueue.prepare(args) + } + ipcMain.handle(prepareChannel, prepareHandler) + activePrepareHandler = prepareHandler mainWindow.on('closed', () => { - // Why: macOS keeps the process alive after window close; drop the closure so the destroyed window isn't retained. ipcMain.removeListener(channel, relayFileDrop) + // Why: an old window closing late must not remove the replacement window's handler. + if (activePrepareHandler === prepareHandler) { + ipcMain.removeHandler(prepareChannel) + activePrepareHandler = undefined + } }) scheduleDragTempCopySweep(() => getDragTempCopyRoot(app.getPath('temp'))) } -/** - * A drop holding a macOS drag-temp path waits for main to copy it, because the - * PTY daemon cannot open the original. Those copies run one at a time, in order, - * under one byte budget. Other drops are forwarded at once unless an earlier drop - * is still queued, so drops reach the renderer in the order they were made. - */ +let activePrepareHandler: unknown + +/** Legacy delivery stays ordered; new owners share only the serialized copy work. */ export function createNativeFileDropQueue( - deps: NativeFileDropQueueDeps -): (payload: NativeFileDropPayload) => void { - let tail = Promise.resolve() - let queued = 0 - let pendingCopies = 0 - const enqueue = ( - deliver: (lifetime: RendererLifetime) => Promise | void, - isCopy: boolean - ): void => { - queued += 1 - pendingCopies += isCopy ? 1 : 0 - // Why: bind to the document that dropped now, so a reload while this waits discards it. - const lifetime = deps.watchRenderer() - // Why: never reject, or one failed drop would stall every drop queued behind it. - tail = tail - .then(() => (lifetime.signal.aborted ? undefined : deliver(lifetime))) + deps: NativeFileDropQueueDeps, + lane?: ReturnType +): NativeFileDropQueue { + const prepare = createDroppedPathPreparationQueue(deps, lane) + let deliveryTail = Promise.resolve() + let queuedDeliveries = 0 + const deliver = (payloads: NativeFileDropPayload[]): void => { + for (const payload of payloads) { + deps.forward(payload) + } + } + const enqueueDelivery = (work: () => Promise | void): void => { + queuedDeliveries += 1 + deliveryTail = deliveryTail + .then(work) .catch(() => undefined) .finally(() => { - queued -= 1 - pendingCopies -= isCopy ? 1 : 0 - lifetime.dispose() + queuedDeliveries -= 1 }) } const forwardInOrder = (payload: NativeFileDropPayload): void => { - // Why: a rejection carries no paths, so only path drops wait their turn. - if (payload.target === 'rejected' || queued === 0) { + if (payload.target === 'rejected' || queuedDeliveries === 0) { deps.forward(payload) - } else { - enqueue(() => deps.forward(payload), false) + return } + const lifetime = deps.watchRenderer() + enqueueDelivery(() => { + try { + if (!lifetime.signal.aborted) { + deps.forward(payload) + } + } finally { + lifetime.dispose() + } + }) } - return (payload) => { - if (payload.target === 'rejected' || !needsDragTempCopy(payload, deps.platform)) { + const enqueue = (payload: NativeFileDropPayload): void => { + if (payload.target === 'rejected') { forwardInOrder(payload) return } - if (pendingCopies >= MAX_PENDING_DRAG_TEMP_COPIES) { - for (const item of failWholeDrop(payload, deps.platform, 'busy')) { + const prepared = prepare({ paths: payload.paths, consumer: legacyConsumer(payload) }) + if (!(prepared instanceof Promise)) { + if (payload.paths.length === 0) { + forwardInOrder(payload) + return + } + for (const item of legacyPayloads(payload, prepared)) { forwardInOrder(item) } return } - enqueue((lifetime) => copyAndForward(payload, lifetime, deps), true) + // Why: observe rejection now, even while legacy delivery waits behind an earlier drop. + const result = prepared.then( + (value) => legacyPayloads(payload, value), + () => [] + ) + enqueueDelivery(async () => deliver(await result)) } + return Object.assign(enqueue, { + prepare: async (request: PrepareDroppedPathsRequest) => prepare(request) + }) } -/** The payloads to forward for one drop: its prepared paths, then a rejection for any it lost. */ export async function prepareNativeFileDrop( payload: AcceptedNativeFileDropPayload, env: DragTempCopyEnvironment, signal?: AbortSignal ): Promise { - const results = await materializeDragTempPaths(payload.paths, env, signal) - // Why: agents run under the PTY daemon, which cannot open an uncopied original; - // other targets are read by main, so the original still works there. - const acceptsOriginal = - payload.target !== NATIVE_FILE_DROP_TARGET.terminal && - payload.target !== NATIVE_FILE_DROP_TARGET.composer - const paths = results.flatMap((result) => - result.status === 'imported' - ? [result.destPath] - : result.status === 'uncopied' && acceptsOriginal - ? [result.sourcePath] - : [] - ) - const unprepared = results.flatMap((result) => - result.status === 'imported' || (result.status === 'uncopied' && acceptsOriginal) - ? [] - : [result] + return legacyPayloads( + payload, + await prepareDroppedPaths( + { paths: payload.paths, consumer: legacyConsumer(payload) }, + env, + signal + ) ) - const prepared: NativeFileDropPayload[] = paths.length > 0 ? [{ ...payload, paths }] : [] - if (unprepared.length > 0) { - const commonReason = unprepared.every((item) => item.reason === unprepared[0].reason) - ? unprepared[0].reason - : undefined - prepared.push(copyFailure(unprepared.length, commonReason)) - } - return prepared } -/** When the copy stage fails as a whole, still deliver the paths that never needed a copy. */ -function failWholeDrop( +function legacyConsumer( + payload: AcceptedNativeFileDropPayload +): PrepareDroppedPathsRequest['consumer'] { + return payload.target === NATIVE_FILE_DROP_TARGET.terminal || + payload.target === NATIVE_FILE_DROP_TARGET.composer + ? 'agent' + : 'main-reader' +} + +function legacyPayloads( payload: AcceptedNativeFileDropPayload, - platform: NodeJS.Platform, - reason: NativeFileDropCopyFailureReason | undefined + prepared: PreparedDroppedPaths ): NativeFileDropPayload[] { - const ordinary = payload.paths.filter((path) => !mayNeedDragTempCopy(path, platform)) - const lost = payload.paths.length - ordinary.length return [ - ...(ordinary.length > 0 ? [{ ...payload, paths: ordinary }] : []), - ...(lost > 0 ? [copyFailure(lost, reason)] : []) + ...(prepared.paths.length > 0 ? [{ ...payload, paths: prepared.paths }] : []), + ...prepared.failures ] } -function forwardAll(deps: NativeFileDropQueueDeps, payloads: NativeFileDropPayload[]): void { - for (const payload of payloads) { - deps.forward(payload) - } -} - function isTempCopyFailure(payload: NativeFileDropPayload): boolean { return payload.target === 'rejected' && payload.reason === 'temp-copy-failed' } -function needsDragTempCopy( - payload: AcceptedNativeFileDropPayload, - platform: NodeJS.Platform -): boolean { - return payload.paths.some((path) => mayNeedDragTempCopy(path, platform)) -} - -async function copyAndForward( - payload: AcceptedNativeFileDropPayload, - lifetime: RendererLifetime, - deps: NativeFileDropQueueDeps -): Promise { - const timeout = new AbortController() - const timer = setTimeout( - () => timeout.abort(new Error('Copying the dropped files took too long')), - deps.copyTimeoutMs ?? DRAG_TEMP_COPY_TIMEOUT_MS +function isPrepareDroppedPathsRequest(value: unknown): value is PrepareDroppedPathsRequest { + return ( + !!value && + typeof value === 'object' && + 'consumer' in value && + (value.consumer === 'agent' || value.consumer === 'main-reader') && + 'paths' in value && + Array.isArray(value.paths) && + value.paths.every((path: unknown) => typeof path === 'string') ) - const signal = AbortSignal.any([lifetime.signal, timeout.signal]) - let prepared: NativeFileDropPayload[] - try { - // Why: race the signal too, since a hung fs call never reaches the copy's abort checks. - prepared = await rejectOnAbort( - deps.getCopyEnvironment().then((env) => prepareNativeFileDrop(payload, env, signal)), - signal - ) - } catch { - // Why: an aborted drop has no renderer to report to; anything else must not vanish silently. - if (lifetime.signal.aborted) { - return - } - prepared = failWholeDrop( - payload, - deps.platform, - timeout.signal.aborted ? 'timed-out' : undefined - ) - } finally { - clearTimeout(timer) - } - // Why: outside the try, so a failed forward is not reported a second time as a failed copy. - forwardAll(deps, prepared) -} - -function rejectOnAbort(work: Promise, signal: AbortSignal): Promise { - return new Promise((resolve, reject) => { - const onAbort = (): void => reject(signal.reason) - if (signal.aborted) { - onAbort() - } else { - signal.addEventListener('abort', onAbort, { once: true }) - } - work.then(resolve, reject).finally(() => signal.removeEventListener('abort', onAbort)) - }) -} - -function copyFailure( - pathCount: number, - commonReason?: NativeFileDropCopyFailureReason -): NativeFileDropRejectedPayload { - return { - byteLength: 0, - pathCount, - reason: 'temp-copy-failed', - target: 'rejected', - ...(commonReason ? { commonReason } : {}) - } } diff --git a/src/main/worktree-retirement-discovery.ts b/src/main/worktree-retirement-discovery.ts index 3392e4cae934..0fbf6739df21 100644 --- a/src/main/worktree-retirement-discovery.ts +++ b/src/main/worktree-retirement-discovery.ts @@ -1,3 +1,4 @@ +import { claudeProfileHistoryDirs } from './claude-accounts/claude-profile-installed-router' import { homedir } from 'node:os' import { join } from 'node:path' import { isWslUncPath, parseWslUncPath } from '../shared/wsl-paths' @@ -166,10 +167,12 @@ async function claudeProjectsSources(args: { let complete = true const hostParents = encodeParents(args.workspaceRoots) if (hostParents.length > 0) { - sources.push({ - projectsDir: getClaudeProjectsDir(args.home ?? homedir(), args.env ?? process.env), - encodedParents: hostParents - }) + for (const projectsDir of [ + getClaudeProjectsDir(args.home ?? homedir(), args.env ?? process.env), + ...claudeProfileHistoryDirs('projects') + ]) { + sources.push({ projectsDir, encodedParents: hostParents }) + } } const linuxRootsByDistro = new Map() for (const root of args.workspaceRoots) { diff --git a/src/main/wsl/wsl-pinned-runtime.test.ts b/src/main/wsl/wsl-pinned-runtime.test.ts new file mode 100644 index 000000000000..7a263f2b5302 --- /dev/null +++ b/src/main/wsl/wsl-pinned-runtime.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect, vi } from 'vitest' +import { ensureWslPinnedRuntime, type WslRuntimeCommand } from './wsl-pinned-runtime' +import { NODE_RUNTIME_ASSETS } from '../../shared/node-runtime-pin' +const mocks = vi.hoisted(() => ({ + download: vi.fn( + async (_target: string, _root: string, _options: { signal?: AbortSignal }) => + 'C:/cache/pinned.tar.gz' + ) +})) +vi.mock('../ssh/pinned-runtime-materializer', () => ({ + materializeNodeRuntimeArchive: mocks.download +})) +function runner(present = false, libc = 'glibc 2.31', promoted = 'ORCA_NODE_RUNTIME_READY') { + return vi.fn(async (spec) => { + if (spec.program === 'uname') { + return 'x86_64' + } + if (spec.program === 'wslpath') { + return '/mnt/c/cache/pinned.tar.gz' + } + if (spec.script?.startsWith('getconf')) { + return libc + } + if (spec.script?.startsWith('printf')) { + return '/home/fake' + } + if (spec.script?.includes('ORCA_NODE_RUNTIME_EXTRACT_FAILED')) { + return promoted + } + return present ? 'ORCA_NODE_RUNTIME_READY' : 'ORCA_NODE_RUNTIME_MISSING' + }) +} +describe('shared WSL pinned runtime', () => { + it('uses the existing materializer and verifies the pinned guest executable without a host Node prerequisite', async () => { + mocks.download.mockClear() + const run = runner() + const result = await ensureWslPinnedRuntime(run, '/fake/cache', new AbortController().signal) + expect(mocks.download).toHaveBeenCalledWith('linux-x64-glibc', '/fake/cache', expect.anything()) + expect(result).toContain(NODE_RUNTIME_ASSETS['linux-x64-glibc'].executableSha256) + const install = run.mock.calls.find(([spec]) => + spec.script?.includes('ORCA_NODE_RUNTIME_EXTRACT_FAILED') + )?.[0] + expect(install?.args).toEqual(['/mnt/c/cache/pinned.tar.gz']) + expect(install?.script).toContain('--version') + expect(run.mock.calls.some(([spec]) => spec.program === 'node')).toBe(false) + }) + it('reuses a verified old guest cache without downloading or replacing it', async () => { + mocks.download.mockClear() + const run = runner(true) + await ensureWslPinnedRuntime(run, '/fake/cache', new AbortController().signal) + expect(mocks.download).not.toHaveBeenCalled() + expect( + run.mock.calls.some(([spec]) => spec.script?.includes('ORCA_NODE_RUNTIME_EXTRACT_FAILED')) + ).toBe(false) + expect(run.mock.calls.some(([spec]) => spec.script?.includes('sha256sum'))).toBe(true) + }) + it('refuses download and guest verification failures without a system-node fallback', async () => { + mocks.download.mockRejectedValueOnce(new Error('offline')) + await expect( + ensureWslPinnedRuntime(runner(), '/fake/cache', new AbortController().signal) + ).rejects.toThrow('offline') + const run = runner() + run.mockImplementation(async (spec) => { + if (spec.program === 'uname') { + return 'x86_64' + } + if (spec.script?.startsWith('getconf')) { + return 'glibc 2.31' + } + if (spec.script?.startsWith('printf')) { + return '/home/fake' + } + return 'broken runtime' + }) + await expect( + ensureWslPinnedRuntime(run, '/fake/cache', new AbortController().signal) + ).rejects.toThrow('did not verify') + }) + it('refuses a distro below the pinned glibc floor before downloading, naming both versions', async () => { + mocks.download.mockClear() + const run = runner(false, 'glibc 2.27') + await expect( + ensureWslPinnedRuntime(run, '/fake/cache', new AbortController().signal) + ).rejects.toThrow('glibc 2.27 is older than 2.28') + expect(mocks.download).not.toHaveBeenCalled() + expect( + run.mock.calls.some(([spec]) => spec.script?.includes('ORCA_NODE_RUNTIME_EXTRACT_FAILED')) + ).toBe(false) + await expect( + ensureWslPinnedRuntime( + runner(false, 'musl libc (x86_64)'), + '/fake/cache', + new AbortController().signal + ) + ).resolves.toContain('/home/fake/.cache/orca') + }) + it('gives a shared download its own deadline and lets each caller leave on its own signal', async () => { + const archive = Promise.withResolvers() + let downloadSignal: AbortSignal | undefined + mocks.download.mockReset().mockImplementation((_target, _root, options) => { + downloadSignal = options.signal + return archive.promise + }) + const first = new AbortController() + const second = new AbortController() + const a = ensureWslPinnedRuntime(runner(), '/fake/cache', first.signal) + const b = ensureWslPinnedRuntime(runner(), '/fake/cache', second.signal) + await vi.waitFor(() => expect(mocks.download).toHaveBeenCalledTimes(1)) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(downloadSignal).not.toBe(first.signal) + first.abort(new Error('first caller deadline')) + await expect(a).rejects.toThrow('first caller deadline') + expect(downloadSignal?.aborted).toBe(false) + archive.resolve('C:/cache/pinned.tar.gz') + await expect(b).resolves.toContain('/home/fake/.cache/orca') + mocks.download.mockReset().mockImplementation(async () => 'C:/cache/pinned.tar.gz') + }) +}) diff --git a/src/main/wsl/wsl-pinned-runtime.ts b/src/main/wsl/wsl-pinned-runtime.ts new file mode 100644 index 000000000000..29fb5305b94c --- /dev/null +++ b/src/main/wsl/wsl-pinned-runtime.ts @@ -0,0 +1,96 @@ +import { randomBytes } from 'node:crypto' +import { basename } from 'node:path' +import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' +import { materializeNodeRuntimeArchive } from '../ssh/pinned-runtime-materializer' +import { parseGlibcVersion, parseOrcadLinuxLibc } from '../ssh/orcad-deployment-target' +import { + installNodeRuntimeFromHostArchiveCommand, + nodeRuntimeStoreDir, + posixNodeRuntimeExecutable, + probeRemoteNodeRuntimeCommand, + REMOTE_NODE_RUNTIME_READY +} from '../ssh/orcad-remote-node-runtime' +import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' +import { assertRemoteNodeRuntimePromoted } from '../ssh/orcad-remote-node-runtime-report' +import { isGlibcBelow, PINNED_NODE_GLIBC_FLOOR } from '../ssh/ssh-relay-pinned-node' +import type { WslSpec } from './wsl-runner' + +const downloads = new Map>() +const DOWNLOAD_TIMEOUT_MS = 180_000 +/** Runs one command in the distro and returns its trimmed stdout; throws on failure. */ +export type WslRuntimeCommand = (spec: WslSpec, timeoutMs?: number) => Promise + +/** + * Orca's pinned Node in the distro's guest store (the one OpenCode's WSL reader uses), installed + * from the host's archive cache on first use. Never substitutes a user-installed runtime. + */ +export async function ensureWslPinnedRuntime( + run: WslRuntimeCommand, + cacheRoot: string, + signal: AbortSignal +): Promise { + const arch = await run({ program: 'uname', args: ['-m'], loginPath: 'none' }) + if (arch !== 'x86_64' && arch !== 'aarch64' && arch !== 'arm64') { + throw new Error(`Unsupported WSL architecture: ${arch}`) + } + const libcProbe = await run({ + script: + 'getconf GNU_LIBC_VERSION 2>/dev/null || ldd --version 2>&1 || ' + + 'for loader in /lib/ld-musl-*.so.1; do [ ! -e "$loader" ] || { echo musl; break; }; done', + loginPath: 'none' + }) + const libc = parseOrcadLinuxLibc(libcProbe) + const glibc = libc === 'glibc' ? parseGlibcVersion(libcProbe) : null + // Why before any download: the pinned Node cannot load on an older glibc, as SSH hosts refuse. + if (glibc && isGlibcBelow(glibc, PINNED_NODE_GLIBC_FLOOR)) { + throw new Error( + `This WSL distro's glibc ${glibc.major}.${glibc.minor} is older than ` + + `${PINNED_NODE_GLIBC_FLOOR.major}.${PINNED_NODE_GLIBC_FLOOR.minor}, which Orca's Node runtime needs.` + ) + } + const target = `linux-${arch === 'x86_64' ? 'x64' : 'arm64'}-${libc}` as const + const home = await run({ script: 'printf %s "$HOME"', loginPath: 'none' }) + if (!home.startsWith('/')) { + throw new Error('WSL did not provide an absolute home directory.') + } + const host = getRemoteHostPlatform(arch === 'x86_64' ? 'linux-x64' : 'linux-arm64') + const runtimeDir = nodeRuntimeStoreDir(host, `${home}/.cache/orca`, target) + const executable = posixNodeRuntimeExecutable(host, runtimeDir) + const probe = await run({ + script: probeRemoteNodeRuntimeCommand(host, runtimeDir, target), + loginPath: 'none' + }) + if (probe === REMOTE_NODE_RUNTIME_READY) { + return executable + } + const key = `${cacheRoot}:${target}` + let download = downloads.get(key) + if (!download) { + // Why its own deadline: a joining caller's abort must not cancel another caller's download. + download = materializeNodeRuntimeArchive(target, cacheRoot, { + signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) + }).finally(() => downloads.delete(key)) + downloads.set(key, download) + } + const localArchive = await waitForPromiseWithSignal(download, signal) + const source = await run({ + program: 'wslpath', + args: ['-a', '-u', localArchive], + loginPath: 'none' + }) + const promoted = await run( + { + script: installNodeRuntimeFromHostArchiveCommand(host, { + runtimeDir, + archive: basename(localArchive), + target, + token: randomBytes(8).toString('hex') + }), + args: [source], + loginPath: 'none' + }, + 120_000 + ) + assertRemoteNodeRuntimePromoted(promoted) + return executable +} diff --git a/src/main/zsh-startup-wrapper-builder.ts b/src/main/zsh-startup-wrapper-builder.ts index 6ec28b647c2b..83e1ca1fac45 100644 --- a/src/main/zsh-startup-wrapper-builder.ts +++ b/src/main/zsh-startup-wrapper-builder.ts @@ -32,6 +32,7 @@ import { MANAGED_DATA_ACCOUNT_POSIX_RESTORE } from '../shared/managed-data-accou import { getPosixOmpShellWrapper } from './pty/omp-shell-wrapper' import { WSL_MANAGED_CLI_PATH_RESTORE } from './wsl-managed-cli-path-restore' import { getPosixCodexShellLaunchPreflight } from '../shared/codex-shell-function' +import { getPosixClaudeShellFunction } from '../shared/claude-shell-function' import { ZSH_DEFERRED_LINE_INIT_BLOCK, ZSH_DEFERRED_LINE_INIT_CLEANUP_BLOCK, @@ -185,7 +186,10 @@ ${joinBlocks([ indentBlock(ORCA_CLI_POSIX_PATH_RESTORE, ' ').replace(/\n$/, ''), featureGuard('overlay', getOverlayRestoreBlocks(spec)), // Why outside the overlay guard: a system-default Codex home carries no overlay key. - indentBlock(getPosixCodexShellLaunchPreflight(), ' ').replace(/\n$/, ''), + indentBlock(getPosixCodexShellLaunchPreflight() + getPosixClaudeShellFunction(), ' ').replace( + /\n$/, + '' + ), // Why no /etc/zshrc repair branch: ZDOTDIR was handed back before that file // ran, so the value it derives is the user's own path. #11044 is unreachable. ` if [[ -n "\${_orca_histfile:-}" ]]; then diff --git a/src/preload/api/filesystem-api.ts b/src/preload/api/filesystem-api.ts index 45feab77d238..c06580a42e40 100644 --- a/src/preload/api/filesystem-api.ts +++ b/src/preload/api/filesystem-api.ts @@ -27,6 +27,10 @@ import type { PythonEnvironments } from '../../shared/notebook-kernel-types' import type { RuntimeUploadFileStreamRequest } from '../../shared/runtime-upload-staging-contract' +import type { + PrepareDroppedPathsRequest, + PreparedDroppedPaths +} from '../../shared/native-file-drop-preparation' export type ExportApi = { htmlToPdf: (args: { @@ -39,6 +43,8 @@ export type ExportApi = { export type FilesystemApi = { fs: { + getPathForFile?: (file: File) => string + prepareDroppedPaths: (args: PrepareDroppedPathsRequest) => Promise readFileChunk: (args: { filePath: string connectionId?: string diff --git a/src/preload/api/fs-bridge.ts b/src/preload/api/fs-bridge.ts index eb2440d8ec89..c60917d427a1 100644 --- a/src/preload/api/fs-bridge.ts +++ b/src/preload/api/fs-bridge.ts @@ -1,5 +1,9 @@ import type { PathExistenceResult } from '../../shared/path-existence-batch' -import { ipcRenderer } from 'electron' +import { ipcRenderer, webUtils } from 'electron' +import type { + PrepareDroppedPathsRequest, + PreparedDroppedPaths +} from '../../shared/native-file-drop-preparation' import type { SshMutationExpectation } from '../../shared/ssh-types' import type { LocalFileAccess } from '../../shared/local-file-access' import type { RuntimeUploadFileStreamRequest } from '../../shared/runtime-upload-staging-contract' @@ -19,6 +23,9 @@ import type { import type { PreloadApi } from '../api-types' export const fsApi = { + getPathForFile: (file: File): string => webUtils.getPathForFile(file), + prepareDroppedPaths: (args: PrepareDroppedPathsRequest): Promise => + ipcRenderer.invoke('fs:prepareDroppedPaths', args), readFileChunk: (args: { filePath: string connectionId?: string diff --git a/src/preload/api/pty-api.ts b/src/preload/api/pty-api.ts index 8a7604b4ac25..229ef1a5414c 100644 --- a/src/preload/api/pty-api.ts +++ b/src/preload/api/pty-api.ts @@ -4,6 +4,10 @@ import type { } from '../../shared/agent-session-resume' import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' import type { TerminalInputKind } from '../../shared/terminal-input-kind' +import type { + TerminalLeafMoveRequest, + TerminalLeafMoveResult +} from '../../shared/terminal-leaf-move' import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime' import type { PtyListedSession, PtySessionListScope } from '../../shared/pty-listed-session' import type { PtyMainDeliveryDiagnostics } from '../../shared/pty-delivery-diagnostics' @@ -16,6 +20,7 @@ import type { AgentKind, LaunchSource, RequestKind } from '../../shared/telemetr import type { TerminalSideEffectBatch } from '../../shared/terminal-side-effect-facts' import type { TerminalViewAttributes } from '../../shared/terminal-view-attributes' import type { TuiAgent } from '../../shared/tui-agent' +import type { TerminalPanePlacement } from '../../shared/terminal-pane-placement' import type { PtyManagementApi } from './pty-management-api' import type { TerminalProcessInspection } from '../../shared/terminal-process-inspection' import type { CodexSharedServerStatus } from '../../shared/codex-shared-server-command' @@ -50,6 +55,8 @@ export type PtyApi = { leafId?: string // Why: a pane with a live owner is otherwise reattached; a restart names the PTY main must stop first. replacesPtyId?: string + // Which tab and leaf a fresh PTY joins; older mains ignore it. + placement?: TerminalPanePlacement // Why: main fires `agent_started` only on spawn success, so launch metadata rides this field (telemetry-plan.md §Agent launch semantics). telemetry?: { agent_kind: AgentKind; launch_source: LaunchSource; request_kind: RequestKind } }) => Promise<{ @@ -145,6 +152,7 @@ export type PtyApi = { ids: string[] ) => Promise<{ id: string; authoritative: boolean | null }[]> hasPty: (id: string) => Promise + moveLeafToNewTab: (request: TerminalLeafMoveRequest) => Promise getMainBufferSnapshot: ( id: string, opts?: { scrollbackRows?: number } diff --git a/src/preload/api/pty-bridge-session-control.ts b/src/preload/api/pty-bridge-session-control.ts index e407163e5f6f..3b996d61ea23 100644 --- a/src/preload/api/pty-bridge-session-control.ts +++ b/src/preload/api/pty-bridge-session-control.ts @@ -2,6 +2,10 @@ import { ipcRenderer } from 'electron' import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime' import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' import type { TerminalInputKind } from '../../shared/terminal-input-kind' +import type { + TerminalLeafMoveRequest, + TerminalLeafMoveResult +} from '../../shared/terminal-leaf-move' import type { AgentProviderSessionMetadata, SleepingAgentLaunchConfig @@ -16,6 +20,7 @@ import type { TerminalViewAttributes } from '../../shared/terminal-view-attribut import type { PtyMainDeliveryDiagnostics } from '../../shared/pty-delivery-diagnostics' import type { AgentKind, LaunchSource, RequestKind } from '../../shared/telemetry-events' import type { PreloadApi } from '../api-types' +import type { TerminalPanePlacement } from '../../shared/terminal-pane-placement' export const ptySessionControlApi = { spawn: (opts: { @@ -46,6 +51,8 @@ export const ptySessionControlApi = { leafId?: string // Why: a pane with a live owner is otherwise reattached; a restart names the PTY main must stop first. replacesPtyId?: string + // Which tab and leaf a fresh PTY joins; older mains ignore it. + placement?: TerminalPanePlacement // Why: loose typing on purpose — renderer owns launch metadata, main owns whether the launch happened and validates (telemetry-plan.md §Agent launch semantics). telemetry?: { agent_kind: AgentKind; launch_source: LaunchSource; request_kind: RequestKind } }): Promise<{ @@ -160,6 +167,8 @@ export const ptySessionControlApi = { ): Promise<{ id: string; authoritative: boolean | null }[]> => ipcRenderer.invoke('pty:getAuthoritativeBufferSnapshotCapabilities', { ids }), hasPty: (id: string): Promise => ipcRenderer.invoke('pty:hasPty', { id }), + moveLeafToNewTab: (request: TerminalLeafMoveRequest): Promise => + ipcRenderer.invoke('pty:moveLeafToNewTab', request), getMainBufferSnapshot: ( id: string, opts?: { scrollbackRows?: number } diff --git a/src/preload/app-restart-checkpoint-routing.test.ts b/src/preload/app-restart-checkpoint-routing.test.ts index 2c44b9a5b7fe..a62da40ea76a 100644 --- a/src/preload/app-restart-checkpoint-routing.test.ts +++ b/src/preload/app-restart-checkpoint-routing.test.ts @@ -6,14 +6,16 @@ import { } from '../shared/updater-renderer-events' import { KEYBOARD_LAYOUT_CHANGED_CHANNEL } from '../shared/keyboard-layout-events' -const { exposeInMainWorld, invoke, on, removeListener, send, sendSync } = vi.hoisted(() => ({ - exposeInMainWorld: vi.fn(), - invoke: vi.fn(), - on: vi.fn(), - removeListener: vi.fn(), - send: vi.fn(), - sendSync: vi.fn() -})) +const { exposeInMainWorld, invoke, on, removeListener, send, sendSync, getPathForFile } = + vi.hoisted(() => ({ + exposeInMainWorld: vi.fn(), + invoke: vi.fn(), + on: vi.fn(), + removeListener: vi.fn(), + send: vi.fn(), + sendSync: vi.fn(), + getPathForFile: vi.fn(() => '/files/notes.txt') + })) vi.mock('electron', () => ({ contextBridge: { exposeInMainWorld }, @@ -23,7 +25,7 @@ vi.mock('electron', () => ({ setZoomFactor: vi.fn(), setVisualZoomLevelLimits: vi.fn() }, - webUtils: { getPathForFile: vi.fn(() => '') } + webUtils: { getPathForFile } })) describe('native preload destructive app actions', () => { @@ -58,6 +60,21 @@ describe('native preload destructive app actions', () => { return exposeInMainWorld.mock.calls.find(([name]) => name === 'api')?.[1] as PreloadApi } + it('exposes synchronous file path resolution and a window-local preparation invoke', async () => { + const api = await loadApi() + const file = new File(['x'], 'notes.txt') + expect(api).not.toHaveProperty('getPathForFile') + expect(api.fs.getPathForFile?.(file)).toBe('/files/notes.txt') + expect(getPathForFile).toHaveBeenCalledWith(file) + const request = { paths: ['/files/notes.txt'], consumer: 'agent' as const } + invoke.mockResolvedValue({ paths: request.paths, failures: [] }) + await expect(api.fs.prepareDroppedPaths(request)).resolves.toEqual({ + paths: request.paths, + failures: [] + }) + expect(invoke).toHaveBeenCalledExactlyOnceWith('fs:prepareDroppedPaths', request) + }) + for (const action of ['reload', 'relaunch'] as const) { it(`prepares and awaits durability before ${action}`, async () => { const api = await loadApi() diff --git a/src/preload/preload-native-file-drop-release.test.ts b/src/preload/preload-native-file-drop-release.test.ts new file mode 100644 index 000000000000..dd556fb2e5ea --- /dev/null +++ b/src/preload/preload-native-file-drop-release.test.ts @@ -0,0 +1,122 @@ +// @vitest-environment happy-dom +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { OS_FILE_DROP_OWNER_ATTRIBUTE } from '../shared/native-file-drop-preparation' +import { ORCA_INTERNAL_FILE_DRAG_TYPE } from '../shared/native-file-drop' + +const electron = vi.hoisted(() => ({ + send: vi.fn(), + getPathForFile: vi.fn((file: File) => `/drop/${file.name}`), + on: vi.fn(), + removeListener: vi.fn() +})) +vi.mock('electron', () => ({ + ipcRenderer: electron, + webUtils: { getPathForFile: electron.getPathForFile } +})) +import { installNativeFileDropHandlers } from './preload-runtime-support' + +function dispatch(target: Element, type = 'drop', types = ['Files']) { + const event = new Event(type, { bubbles: true, cancelable: true, composed: true }) + const transfer = { types, files: [new File(['x'], 'notes.txt')], dropEffect: 'none' } + Object.defineProperty(event, 'dataTransfer', { value: transfer }) + const stop = vi.spyOn(event, 'stopPropagation') + target.dispatchEvent(event) + return { event, transfer, stop } +} + +function root(owner: boolean, legacy = false) { + const element = document.createElement('div') + if (owner) { + element.setAttribute(OS_FILE_DROP_OWNER_ATTRIBUTE, '') + } + if (legacy) { + element.dataset.nativeFileDropTarget = 'terminal' + } + document.body.append(element) + return element +} + +beforeAll(() => { + installNativeFileDropHandlers() + installNativeFileDropHandlers() +}) +beforeEach(() => { + document.body.replaceChildren() + electron.send.mockClear() + electron.getPathForFile.mockClear() +}) + +describe('preload migration release', () => { + it.each([false, true])( + 'keeps unmarked and legacy roots on the relay exactly once: legacy=%s', + (legacy) => { + const { event, stop } = dispatch(root(false, legacy)) + expect(event.defaultPrevented).toBe(true) + expect(stop).toHaveBeenCalledTimes(1) + expect(electron.send).toHaveBeenCalledExactlyOnceWith('terminal:file-dropped-from-preload', { + paths: ['/drop/notes.txt'], + target: legacy ? 'terminal' : 'editor' + }) + } + ) + + it.each(['drop', 'dragover'])('leaves %s inside a migrated owner untouched', (type) => { + const owner = root(true) + const child = document.createElement('span') + owner.append(child) + const { event, transfer, stop } = dispatch(child, type) + expect(event.defaultPrevented).toBe(false) + expect(stop).not.toHaveBeenCalled() + expect(transfer.dropEffect).toBe('none') + expect(electron.send).not.toHaveBeenCalled() + expect(electron.getPathForFile).not.toHaveBeenCalled() + }) + + it.each(['drop', 'dragover'])('keeps a nested legacy boundary on the legacy %s path', (type) => { + const owner = root(true) + const legacy = root(false, true) + owner.append(legacy) + const { event, transfer } = dispatch(legacy, type) + expect(event.defaultPrevented).toBe(true) + if (type === 'drop') { + expect(electron.send).toHaveBeenCalledExactlyOnceWith('terminal:file-dropped-from-preload', { + paths: ['/drop/notes.txt'], + target: 'terminal' + }) + } else { + expect(transfer.dropEffect).toBe('copy') + expect(electron.send).not.toHaveBeenCalled() + } + }) + + it('chooses an inner owner over an outer legacy boundary', () => { + const legacy = root(false, true) + const owner = root(true) + legacy.append(owner) + expect(dispatch(owner).event.defaultPrevented).toBe(false) + expect(electron.send).not.toHaveBeenCalled() + }) + + it.each(['drop', 'dragover'])( + 'leaves internal %s unchanged even inside a migrated owner', + (type) => { + const { event, transfer, stop } = dispatch(root(true), type, [ + 'Files', + ORCA_INTERNAL_FILE_DRAG_TYPE + ]) + expect(event.defaultPrevented).toBe(false) + expect(stop).not.toHaveBeenCalled() + expect(transfer.dropEffect).toBe('none') + expect(electron.send).not.toHaveBeenCalled() + } + ) + + it('keeps the existing non-file drop cancellation, regardless of owner markers', () => { + const event = new Event('drop', { bubbles: true, cancelable: true }) + Object.defineProperty(event, 'dataTransfer', { value: { types: ['text/plain'], files: [] } }) + root(true).dispatchEvent(event) + expect(event.defaultPrevented).toBe(true) + expect(electron.send).not.toHaveBeenCalled() + expect(dispatch(root(true), 'dragover', ['text/plain']).event.defaultPrevented).toBe(false) + }) +}) diff --git a/src/preload/preload-runtime-support.ts b/src/preload/preload-runtime-support.ts index 9861583b34a0..424e276418ed 100644 --- a/src/preload/preload-runtime-support.ts +++ b/src/preload/preload-runtime-support.ts @@ -4,6 +4,7 @@ import { createBrowserFindSubscriptions } from './browser-find-subscriptions' import { registerRendererRestartIpcRelays } from './renderer-restart-wiring' import { createUpdaterQuitAbortRelay } from '../shared/renderer-restart-preparation' import { ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT } from '../shared/updater-renderer-events' +import { OS_FILE_DROP_OWNER_ATTRIBUTE } from '../shared/native-file-drop-preparation' import { ORCA_INTERNAL_FILE_DRAG_TYPE, createNativeFileDropPayload, @@ -88,6 +89,21 @@ function resolveNativeFileDrop(event: DragEvent): NativeDropResolution | null { return resolveNativeFileDropPath(pathEntries) } +function nearestDropBoundaryIsOwner(event: DragEvent): boolean { + for (const entry of event.composedPath()) { + if (!(entry instanceof HTMLElement)) { + continue + } + if (entry.hasAttribute(OS_FILE_DROP_OWNER_ATTRIBUTE)) { + return true + } + if (entry.hasAttribute('data-native-file-drop-target')) { + return false + } + } + return false +} + /** Installs the one preload-side listener that converts native File objects to paths. */ export function installNativeFileDropHandlers(): void { // Preload entry points can be evaluated more than once in tests and during development reloads; @@ -101,6 +117,9 @@ export function installNativeFileDropHandlers(): void { if (event.dataTransfer && !hasNativeFileDragTypes(event.dataTransfer.types)) { return } + if (hasNativeFileDragTypes(event.dataTransfer?.types) && nearestDropBoundaryIsOwner(event)) { + return + } event.preventDefault() if (event.dataTransfer) { event.dataTransfer.dropEffect = 'copy' @@ -114,6 +133,9 @@ export function installNativeFileDropHandlers(): void { if (event.dataTransfer?.types.includes(ORCA_INTERNAL_FILE_DRAG_TYPE)) { return } + if (hasNativeFileDragTypes(event.dataTransfer?.types) && nearestDropBoundaryIsOwner(event)) { + return + } event.preventDefault() event.stopPropagation() const files = event.dataTransfer?.files 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 3bb29d17afca..d64925a8a3b3 100644 --- a/src/renderer/src/app-shell/use-app-shell-services.ts +++ b/src/renderer/src/app-shell/use-app-shell-services.ts @@ -17,6 +17,7 @@ import { useGitStatusPolling } from '../components/right-sidebar/useGitStatusPol import { useOsc52ClipboardDefaultOnNotice } from '../components/terminal-pane/osc52-clipboard-default-on-notice' import { useWebSessionTabsSync } from '../runtime/web-session-tabs-sync' import { useLocalStructuredSessionTabsSync } from '../runtime/local-structured-session-tabs-sync' +import { useHostStructuredAgentsSync } from '../runtime/host-structured-agents-sync' import { useRemoteRuntimeRecoveryTriggers } from '../runtime/use-remote-runtime-recovery-triggers' import { useTerminalViewerColorPublication } from './use-terminal-viewer-color-publication' import { useBrowserIdentityMigrationNotice } from '../components/browser-pane/browser-user-agent-migration-notice' @@ -41,6 +42,7 @@ export function useAppShellServices(): void { useRadixBodyPointerEventsRecovery() useWebSessionTabsSync() useLocalStructuredSessionTabsSync() + useHostStructuredAgentsSync() // Subscribe to IPC push events useIpcEvents() useRemoteRuntimeRecoveryTriggers() diff --git a/src/renderer/src/components/AiVaultTabTitleSyncGate.tsx b/src/renderer/src/components/AiVaultTabTitleSyncGate.tsx index 378a2d4d9a02..ddc56184837c 100644 --- a/src/renderer/src/components/AiVaultTabTitleSyncGate.tsx +++ b/src/renderer/src/components/AiVaultTabTitleSyncGate.tsx @@ -2,38 +2,26 @@ import { useEffect } from 'react' import { startAiVaultTabTitleSync } from '@/lib/ai-vault-tab-title-sync' import { scheduleAfterInputQuiet } from '@/lib/input-quiet-scheduler' import { useAppStore } from '@/store' -import { projectCachedAiVaultStructuredTitles } from './right-sidebar/ai-vault-session-result-cache' -import { aiVaultStructuredTitlesChanged } from './right-sidebar/ai-vault-structured-title-projection' const TITLE_SYNC_DELAY_MS = 1_000 const TITLE_SYNC_QUIET_MS = 1_500 const TITLE_SYNC_IDLE_TIMEOUT_MS = 3_000 export function AiVaultTabTitleSyncGate(): null { - useEffect(() => { - projectCachedAiVaultStructuredTitles(useAppStore.getState().unifiedTabsByWorktree) - const unsubscribe = useAppStore.subscribe((state, previous) => { - if ( - aiVaultStructuredTitlesChanged(state.unifiedTabsByWorktree, previous.unifiedTabsByWorktree) - ) { - projectCachedAiVaultStructuredTitles(state.unifiedTabsByWorktree) - } - }) - const stop = startAiVaultTabTitleSync({ - getState: useAppStore.getState, - subscribe: useAppStore.subscribe, - resolveSessionTitles: (args) => window.api.aiVault.resolveSessionTitles(args), - scheduleReconcile: (callback) => - scheduleAfterInputQuiet(callback, { - delayMs: TITLE_SYNC_DELAY_MS, - quietMs: TITLE_SYNC_QUIET_MS, - idleTimeoutMs: TITLE_SYNC_IDLE_TIMEOUT_MS - }) - }) - return () => { - unsubscribe() - stop() - } - }, []) + useEffect( + () => + startAiVaultTabTitleSync({ + getState: useAppStore.getState, + subscribe: useAppStore.subscribe, + resolveSessionTitles: (args) => window.api.aiVault.resolveSessionTitles(args), + scheduleReconcile: (callback) => + scheduleAfterInputQuiet(callback, { + delayMs: TITLE_SYNC_DELAY_MS, + quietMs: TITLE_SYNC_QUIET_MS, + idleTimeoutMs: TITLE_SYNC_IDLE_TIMEOUT_MS + }) + }), + [] + ) return null } diff --git a/src/renderer/src/components/QuickOpen.selection.test.tsx b/src/renderer/src/components/QuickOpen.selection.test.tsx index d1ac9b9c94ac..d0594f17066a 100644 --- a/src/renderer/src/components/QuickOpen.selection.test.tsx +++ b/src/renderer/src/components/QuickOpen.selection.test.tsx @@ -34,9 +34,7 @@ vi.mock('./right-sidebar/file-explorer-operation-owner', () => ({ })) vi.mock('./quick-open-file-navigation', () => ({ openQuickOpenFile: mocks.open })) vi.mock('@/lib/quick-open-file-history', () => ({ - quickOpenHistoryScope: () => 'workspace', - readQuickOpenHistory: () => history, - subscribeQuickOpenHistory: () => () => {} + useQuickOpenHistory: () => history })) vi.mock('@/hooks/useModalReturnFocus', () => ({ useModalReturnFocus: () => ({ captureReturnFocus: () => {}, skipReturnFocus: mocks.skip }) diff --git a/src/renderer/src/components/QuickOpen.tsx b/src/renderer/src/components/QuickOpen.tsx index 7fe1230e5739..4213866351c7 100644 --- a/src/renderer/src/components/QuickOpen.tsx +++ b/src/renderer/src/components/QuickOpen.tsx @@ -1,12 +1,5 @@ import { useQuickOpenInteraction } from './use-quick-open-interaction' -import React, { - useCallback, - useDeferredValue, - useEffect, - useMemo, - useState, - useSyncExternalStore -} from 'react' +import React, { useCallback, useDeferredValue, useEffect, useMemo, useState } from 'react' import { useAppStore } from '@/store' import { useActiveWorktree } from '@/store/selectors' import { getFileTypeIcon } from '@/lib/file-type-icons' @@ -24,11 +17,7 @@ import { } from '../../../shared/quick-open-query-target' import { openQuickOpenFile } from './quick-open-file-navigation' import { rankQuickOpenFilesWithHistory } from './quick-open-history-ranking' -import { - quickOpenHistoryScope, - readQuickOpenHistory, - subscribeQuickOpenHistory -} from '@/lib/quick-open-file-history' +import { useQuickOpenHistory } from '@/lib/quick-open-file-history' import { useRuntimeFileListForWorktree } from '@/components/quick-open-file-list' import { useModalReturnFocus } from '@/hooks/useModalReturnFocus' import { translate } from '@/i18n/i18n' @@ -79,11 +68,7 @@ function QuickOpenContent({ visible }: { visible: boolean }): React.JSX.Element const { opening, invalidate, begin } = useQuickOpenInteraction(activeWorktreeId) const [selectedPath, setSelectedPath] = useState('') const worktreePath = activeWorktree?.path ?? null - const scope = - activeWorktreeId && worktreePath - ? quickOpenHistoryScope(useAppStore.getState(), activeWorktreeId, worktreePath) - : null - const history = useSyncExternalStore(subscribeQuickOpenHistory, () => readQuickOpenHistory(scope)) + const history = useQuickOpenHistory(activeWorktreeId, worktreePath) const { files, loading, loadError, truncated, recentError } = useRuntimeFileListForWorktree({ enabled: visible && !absoluteQuery, worktreeId: activeWorktreeId, diff --git a/src/renderer/src/components/automations/automation-editor-prompt-options.test.ts b/src/renderer/src/components/automations/automation-editor-prompt-options.test.ts index 5cc4d29c628c..966eb568465f 100644 --- a/src/renderer/src/components/automations/automation-editor-prompt-options.test.ts +++ b/src/renderer/src/components/automations/automation-editor-prompt-options.test.ts @@ -12,6 +12,7 @@ describe('buildAutomationPromptEditorOptions', () => { }) expect(options.find).toEqual(monacoFindOptions) + expect(options.dropIntoEditor).toEqual({ enabled: false }) expect(options.wordWrap).toBe('on') expect(options.lineNumbers).toBe('off') expect(options.minimap).toEqual({ enabled: false }) diff --git a/src/renderer/src/components/automations/automation-editor-prompt-options.ts b/src/renderer/src/components/automations/automation-editor-prompt-options.ts index f43a3160e356..0d47b3aeec4c 100644 --- a/src/renderer/src/components/automations/automation-editor-prompt-options.ts +++ b/src/renderer/src/components/automations/automation-editor-prompt-options.ts @@ -11,6 +11,7 @@ export function buildAutomationPromptEditorOptions(args: { ariaLabel: args.ariaLabel, automaticLayout: true, contextmenu: true, + dropIntoEditor: { enabled: false }, folding: false, fontFamily: args.fontFamily, fontSize: args.fontSize, diff --git a/src/renderer/src/components/dashboard/use-agent-row-conversation-name.test.ts b/src/renderer/src/components/dashboard/use-agent-row-conversation-name.test.ts index 3de399a8a363..84f8f0eee469 100644 --- a/src/renderer/src/components/dashboard/use-agent-row-conversation-name.test.ts +++ b/src/renderer/src/components/dashboard/use-agent-row-conversation-name.test.ts @@ -26,6 +26,13 @@ vi.mock('@/store', () => ({ } as unknown as AppState) })) +// The host's saved names by chat id, as the status feed would publish them. +const savedNames = vi.hoisted(() => new Map()) +vi.mock('@/runtime/structured-conversation-name', () => ({ + useStructuredChatTabConversationName: (tab: { entityId: string } | undefined) => + (tab && savedNames.get(tab.entityId)) ?? null +})) + function makeAgent(overrides: Partial = {}): DashboardAgentRow { return { paneKey: 'tab-1:leaf-1', @@ -40,6 +47,7 @@ function makeAgent(overrides: Partial = {}): DashboardAgentRo beforeEach(() => { storeState.current = { settings: {}, tabsByWorktree: {} } + savedNames.clear() }) describe('useAgentRowConversationName', () => { @@ -99,6 +107,26 @@ describe('useAgentRowConversationName', () => { expect(useAgentRowConversationName(agent)).toBe('Claude Chat') }) + it('reads the host-published name ahead of the tab label, under a manual rename', () => { + const tab: { customLabel: string | null } & Record = { + id: 'tab-1', + entityId: 'native-session', + contentType: 'agent-session', + agentSessionAgent: 'claude', + label: 'Claude Chat', + customLabel: null + } + storeState.current = { + settings: {}, + tabsByWorktree: {}, + unifiedTabsByWorktree: { 'wt-1': [tab] } + } + savedNames.set('native-session', 'Explain the parser') + expect(useAgentRowConversationName(makeAgent())).toBe('Explain the parser') + tab.customLabel = 'Manual name' + expect(useAgentRowConversationName(makeAgent())).toBe('Manual name') + }) + it('ignores a retired stored opt-out value', () => { storeState.current = { settings: { agentRowsUseConversationName: false }, tabsByWorktree: {} } expect(useAgentRowConversationName(makeAgent())).toBe('Patient sync spike') diff --git a/src/renderer/src/components/dashboard/use-agent-row-conversation-name.ts b/src/renderer/src/components/dashboard/use-agent-row-conversation-name.ts index f03eed9f53ae..839ee80ec9aa 100644 --- a/src/renderer/src/components/dashboard/use-agent-row-conversation-name.ts +++ b/src/renderer/src/components/dashboard/use-agent-row-conversation-name.ts @@ -1,9 +1,10 @@ import { getAgentRowConversationName } from '../../../../shared/agent-row-conversation-name' import { parsePaneKey } from '../../../../shared/stable-pane-id' -import { structuredChatRowName } from '../../../../shared/structured-chat-row-name' +import { structuredChatDisplayName } from '../../../../shared/structured-chat-row-name' import { resolveAgentRowPaneLiveTitle } from './agent-row-pane-live-title' import { useAppStore } from '@/store' import { structuredChatTabById } from '@/lib/structured-chat-tab-index' +import { useStructuredChatTabConversationName } from '@/runtime/structured-conversation-name' import type { AppState } from '@/store/types' import type { DashboardAgentRow } from './useDashboardData' @@ -42,13 +43,12 @@ export function useAgentRowConversationName(agent: DashboardAgentRow): string | ? undefined : getIndexedTab(s.tabsByWorktree[agent.tab.worktreeId], agent.tab.id) ) - const structuredName = useAppStore((s) => + const structuredTab = useAppStore((s) => cannotOwnTabName - ? null - : structuredChatRowName( - structuredChatTabById(s.unifiedTabsByWorktree?.[agent.tab.worktreeId], agent.tab.id) - ) + ? undefined + : structuredChatTabById(s.unifiedTabsByWorktree, agent.tab.worktreeId, agent.tab.id) ) + const conversationName = useStructuredChatTabConversationName(structuredTab) // Why: parsed per render rather than inside the selector, which runs on every // store update and must stay allocation-free. const ownLeafId = cannotOwnTabName ? null : parsePaneKey(agent.paneKey)?.leafId @@ -68,6 +68,9 @@ export function useAgentRowConversationName(agent: DashboardAgentRow): string | if (cannotOwnTabName) { return null } + const structuredName = + structuredTab && + structuredChatDisplayName(structuredTab.customLabel, conversationName, structuredTab.label) if (structuredName) { return structuredName } diff --git a/src/renderer/src/components/editor/DiffSectionBody.tsx b/src/renderer/src/components/editor/DiffSectionBody.tsx index 02cb001093a8..0bf660fa3696 100644 --- a/src/renderer/src/components/editor/DiffSectionBody.tsx +++ b/src/renderer/src/components/editor/DiffSectionBody.tsx @@ -216,6 +216,7 @@ export function DiffSectionBody({ keepCurrentOriginalModel keepCurrentModifiedModel options={{ + dropIntoEditor: { enabled: false }, // Native EditContext focus scrolls its hidden input into the combined viewport. editContext: false, readOnly: !isEditable, diff --git a/src/renderer/src/components/editor/DiffViewer.tsx b/src/renderer/src/components/editor/DiffViewer.tsx index cf262bfb3bb0..94116d758b30 100644 --- a/src/renderer/src/components/editor/DiffViewer.tsx +++ b/src/renderer/src/components/editor/DiffViewer.tsx @@ -340,6 +340,7 @@ export default function DiffViewer({ keepCurrentOriginalModel keepCurrentModifiedModel options={{ + dropIntoEditor: { enabled: false }, readOnly: !editable, originalEditable: false, renderSideBySide: sideBySide, diff --git a/src/renderer/src/components/editor/IpynbCellEditor.tsx b/src/renderer/src/components/editor/IpynbCellEditor.tsx index e9b29768e441..4bd32f00232c 100644 --- a/src/renderer/src/components/editor/IpynbCellEditor.tsx +++ b/src/renderer/src/components/editor/IpynbCellEditor.tsx @@ -179,6 +179,7 @@ function IpynbSourceEditor({ const { lineHeight, paddingX, paddingY } = CODE_LAYOUT const model = monaco.editor.createModel(source, cell.language) const editorInstance = monaco.editor.create(container, { + dropIntoEditor: { enabled: false }, model, automaticLayout: true, fontFamily, diff --git a/src/renderer/src/components/editor/MonacoEditor.tsx b/src/renderer/src/components/editor/MonacoEditor.tsx index 3f1f27423cd8..10cb1bb7c808 100644 --- a/src/renderer/src/components/editor/MonacoEditor.tsx +++ b/src/renderer/src/components/editor/MonacoEditor.tsx @@ -252,6 +252,7 @@ export default function MonacoEditor({ onChange={contentSync.handleChange} onMount={handleMount} options={{ + dropIntoEditor: { enabled: false }, // `IGlobalEditorOptions`, not per-editor: setting it here pins it for every // Monaco surface (diff, Peek) too, so this is the only site that needs it. // Defense-in-depth only — it does NOT guard the Monarch embed recursion, diff --git a/src/renderer/src/components/file-path-cursor-tooltip.tsx b/src/renderer/src/components/file-path-cursor-tooltip.tsx index d156d3a93431..4d28b5cae888 100644 --- a/src/renderer/src/components/file-path-cursor-tooltip.tsx +++ b/src/renderer/src/components/file-path-cursor-tooltip.tsx @@ -32,6 +32,21 @@ export function splitTrailingSegment(path: string): { directory: string; filenam : { directory: path.slice(0, separatorIndex + 1), filename: path.slice(separatorIndex + 1) } } +export function FilenameFirstPath({ path }: { path: string }): React.JSX.Element { + const { directory, filename } = splitTrailingSegment(path) + + return ( + + {/* shrink-0 + max-w-full: the directory gives up all of its width before + the filename loses a character. */} + {filename} + {directory ? ( + {directory} + ) : null} + + ) +} + /** * Wraps a single result row so its full path appears in a system-styled tooltip * anchored under the cursor. The child is the trigger; it receives a ref and a diff --git a/src/renderer/src/components/native-chat-resume-on-restart-grouping.ts b/src/renderer/src/components/native-chat-resume-on-restart-grouping.ts index e29ffe9afd7b..9a49b4c389d3 100644 --- a/src/renderer/src/components/native-chat-resume-on-restart-grouping.ts +++ b/src/renderer/src/components/native-chat-resume-on-restart-grouping.ts @@ -4,6 +4,7 @@ import type { ExecutionHostId } from '../../../shared/execution-host' import { projectGroupIdFromRepoId } from '../../../shared/folder-workspace-worktree' import type { RepoIcon } from '../../../shared/repo-icon' import type { AgentSessionRestartActivity } from '../../../shared/agent-session-restart-activity' +import type { StructuredAgentId } from '../../../shared/agent-session-provider-handle' /** * The offered chats, arranged the way the sidebar arranges workspaces: project/repo, then workspace, @@ -16,7 +17,9 @@ import type { AgentSessionRestartActivity } from '../../../shared/agent-session- export type ResumeCandidate = { sessionId: string workspaceId: string - agent: 'claude' | 'codex' + /** Any agent the host registered; a client without the registered-agents capability gets + * only Claude's and Codex's offers. */ + agent: StructuredAgentId trigger: 'quit' | 'update' latestPrompt: string recordedAt: number diff --git a/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.test.tsx b/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.test.tsx index 1d985c701182..3fa393584b1c 100644 --- a/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.test.tsx @@ -2,7 +2,7 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' -import { NativeChatPickerMenu } from './NativeChatAutocompleteMenus' +import { NativeChatMentionMenu, NativeChatPickerMenu } from './NativeChatAutocompleteMenus' import { buildNativeChatPickerItems } from './native-chat-picker-items' import { sessionSlashCommandSuggestions } from '../../../../shared/native-chat-slash-commands' import type { ComposerAutocomplete } from './native-chat-composer-state' @@ -206,3 +206,44 @@ describe('NativeChatPickerMenu', () => { expect(screen.getAllByText('No matching skills')).toHaveLength(2) }) }) + +describe('NativeChatMentionMenu', () => { + afterEach(cleanup) + + const NO_ROWS = { files: [], loading: false, failed: false } + + it('lists files by name with their folder and inserts the one pressed', () => { + const onChoose = vi.fn() + render( + + ) + const options = screen.getAllByRole('option') + expect(options.map((option) => option.textContent)).toEqual(['app.tssrc/', 'README.md']) + expect(options[1].getAttribute('aria-selected')).toBe('true') + fireEvent.pointerDown(options[1], { button: 2 }) + expect(onChoose).not.toHaveBeenCalled() + fireEvent.pointerDown(options[0]) + expect(onChoose).toHaveBeenCalledWith('src/app.ts') + }) + + it.each([ + [NO_ROWS, 'No matching files'], + [{ ...NO_ROWS, loading: true, failed: true }, 'Loading files...'], + [{ ...NO_ROWS, failed: true }, "Couldn't load files"] + ])('explains an empty list: %#', (mention, text) => { + render( + + ) + expect(screen.getAllByText(text).length).toBeGreaterThan(0) + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.tsx b/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.tsx index 0ae77cafa9ab..0560f51fe429 100644 --- a/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.tsx +++ b/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.tsx @@ -3,7 +3,10 @@ import { Loader2, Package, RotateCcw } from 'lucide-react' import { translate } from '@/i18n/i18n' import { cn } from '@/lib/utils' import type { SkillSourceKind } from '../../../../shared/skills' +import { FilenameFirstPath } from '@/components/file-path-cursor-tooltip' +import { getFileTypeIcon } from '@/lib/file-type-icons' import type { ComposerAutocomplete, NativeChatPickerItem } from './native-chat-composer-state' +import type { NativeChatMentionFiles } from './use-native-chat-mention-files' export const NativeChatPickerMenu = memo(function NativeChatPickerMenu({ autocomplete, @@ -50,11 +53,7 @@ export const NativeChatPickerMenu = memo(function NativeChatPickerMenu({ let optionIndex = 0 return ( -
+ {showCommandsHeading ? : null} {commands.map((item) => { const index = optionIndex++ @@ -128,7 +127,7 @@ export const NativeChatPickerMenu = memo(function NativeChatPickerMenu({ .join('. ') : ''}
- + ) }) @@ -181,24 +180,12 @@ function PickerOption({ onChoose: (item: NativeChatPickerItem) => void }): React.JSX.Element { const annotation = getPickerAnnotation(item) - const selected = index === activeIndex return ( - ) } @@ -257,27 +300,60 @@ function scopeLabel(sourceKind: SkillSourceKind | undefined): string { return sourceKind ? (labels[sourceKind] ?? '') : '' } -export function NativeChatMentionHint({ - query, - onAccept +export const NativeChatMentionMenu = memo(function NativeChatMentionMenu({ + mention, + activeIndex, + listboxId, + onChoose }: { - query: string - onAccept: () => void + mention: NativeChatMentionFiles + activeIndex: number + listboxId: string + onChoose: (path: string) => void }): React.JSX.Element { + const activeItemRef = useRef(null) + const { files } = mention + const selectedIndex = Math.min(activeIndex, files.length - 1) + + useEffect(() => { + activeItemRef.current?.scrollIntoView({ block: 'nearest' }) + }, [selectedIndex, files]) + + const statusText = + files.length > 0 + ? null + : mention.loading + ? translate('components.native-chat.composer.loadingFiles', 'Loading files...') + : mention.failed + ? translate('components.native-chat.composer.filesLoadFailed', "Couldn't load files") + : translate('components.native-chat.composer.noFiles', 'No matching files') + return ( - + + {files.map((path, index) => { + const FileIcon = getFileTypeIcon(path) + return ( + onChoose(path)} + > + + + + ) + })} + {statusText ? ( + + {mention.loading ? : null} + {statusText} + + ) : null} +
+ {statusText ?? ''} +
+
) -} +}) diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index ead71a2a0ff2..dac43fa42c12 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -1,13 +1,11 @@ +import { useNativeChatComposerNotice } from './use-native-chat-composer-notice' import type { NativeChatComposerInput } from './native-chat-composer-input' import { forwardRef, useCallback, useState } from 'react' import { useNativeChatComposerInterrupt } from './use-native-chat-composer-interrupt' import { useNativeChatContextUsageSummary } from './use-native-chat-context-usage-summary' import { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft' -import { - applyMentionSuggestion, - EMPTY_HISTORY, - type HistoryState -} from './native-chat-composer-state' +import { EMPTY_HISTORY, type HistoryState } from './native-chat-composer-state' +import { useNativeChatMentionFiles } from './use-native-chat-mention-files' import { useNativeChatDraft } from './use-native-chat-draft' import { useNativeChatLaunchDraftAdoption } from './use-native-chat-launch-draft-adoption' import { NativeChatComposerField } from './NativeChatComposerField' @@ -73,7 +71,8 @@ const NativeChatComposerPane = forwardRef(EMPTY_HISTORY) const [activeSuggestion, setActiveSuggestion] = useState(0) - const [notice, setNotice] = useState(null) + const { notices, setNotice } = useNativeChatComposerNotice(chatNotices) const { textareaRef } = useNativeChatComposerAppMenuSelection(imeEnterGesture.isComposing) const { cancelPendingSends, trackPendingSend } = useNativeChatSendLifecycle( terminalTabId, @@ -119,6 +118,7 @@ const NativeChatComposerPane = forwardRef { - if (autocomplete.mode !== 'mention') { - return - } - const result = applyMentionSuggestion(draft, caret, autocomplete.query) - setDraft(result.draft) - setCaret(result.caret) - const textarea = textareaRef.current - textarea?.focus() - requestAnimationFrame(() => textarea?.setSelectionRange(result.caret, result.caret)) - }} + onChooseMentionFile={completeMention} onRemoveImageAttachment={(id) => removeImageAttachment(id)} onAttach={pickAttachments} onDictationToggle={dictation.toggleDictation} diff --git a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx index d9069c5af631..1e8726146a7e 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx @@ -2,13 +2,15 @@ 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 { ImageOff } from 'lucide-react' import type { useImeEnterGestureOwnership } from '@/lib/ime-composition-keyboard-event' import { cn } from '@/lib/utils' import { NATIVE_FILE_DROP_TARGET } from '../../../../shared/native-file-drop' import type { ComposerAutocomplete, NativeChatPickerItem } from './native-chat-composer-state' -import { NativeChatMentionHint, NativeChatPickerMenu } from './NativeChatAutocompleteMenus' +import { NativeChatMentionMenu, NativeChatPickerMenu } from './NativeChatAutocompleteMenus' +import type { NativeChatMentionFiles } from './use-native-chat-mention-files' import { NativeChatComposerActions } from './NativeChatComposerActions' +import { NativeChatComposerNotices } from './NativeChatComposerNotices' +import type { NativeChatComposerNotice } from './native-chat-composer-notice' import type { NativeChatContextUsageSummary } from './native-chat-context-usage-summary' import { nativeChatComposerPlaceholder, @@ -42,8 +44,9 @@ export type NativeChatComposerFieldProps = { hasPty: boolean canSend: boolean autocomplete: ComposerAutocomplete + mentionFiles: NativeChatMentionFiles activeSuggestion: number - notice: string | null + notices: readonly NativeChatComposerNotice[] imageAttachments: readonly NativeChatComposerImageAttachment[] sendButtonDisabled: boolean /** Why the send button is disabled, when the user can do something about it. */ @@ -67,7 +70,7 @@ export type NativeChatComposerFieldProps = { pickerListboxId: string onChoosePickerItem: (item: NativeChatPickerItem) => void onRetrySkills: () => void - onAcceptMention: () => void + onChooseMentionFile: (path: string) => void onRemoveImageAttachment: (id: string) => void onAttach: () => void onDictationToggle: () => void @@ -128,8 +131,9 @@ export function NativeChatComposerField({ hasPty, canSend, autocomplete, + mentionFiles, activeSuggestion, - notice, + notices, imageAttachments, sendButtonDisabled, sendBlockedReason, @@ -149,7 +153,7 @@ export function NativeChatComposerField({ pickerListboxId, onChoosePickerItem, onRetrySkills, - onAcceptMention, + onChooseMentionFile, onRemoveImageAttachment, onAttach, onDictationToggle, @@ -166,6 +170,12 @@ export function NativeChatComposerField({ goalMode }: NativeChatComposerFieldProps): React.JSX.Element { const draftNotSaved = useNativeChatComposerDraftUnsaved(draftScopeKey) + const optionCount = + autocomplete.mode === 'slash' + ? autocomplete.items.length + : autocomplete.mode === 'mention' + ? mentionFiles.files.length + : 0 // Value the IME started from, and whether a programmatic clear was dropped on top of it. const compositionBaseRef = useRef('') const droppedDraftClearRef = useRef(false) @@ -227,14 +237,28 @@ export function NativeChatComposerField({ /> ) : null} {autocomplete.mode === 'mention' ? ( - - ) : null} - {notice ? ( -
- - {notice} -
+ ) : null} + + // A dismissed row takes its focused × with it; the message box gets focus back. + notice.onDismiss + ? { + ...notice, + onDismiss: () => { + notice.onDismiss?.() + textareaRef.current?.focus() + } + } + : notice + )} + className="mb-1.5" + />
0 - ? `${pickerListboxId}-option-${Math.min(activeSuggestion, autocomplete.items.length - 1)}` + optionCount > 0 + ? `${pickerListboxId}-option-${Math.min(activeSuggestion, optionCount - 1)}` : undefined } placeholder={ diff --git a/src/renderer/src/components/native-chat/NativeChatComposerNotices.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposerNotices.test.tsx new file mode 100644 index 000000000000..767580f359c8 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatComposerNotices.test.tsx @@ -0,0 +1,132 @@ +// @vitest-environment happy-dom +import '@testing-library/jest-dom/vitest' +import { cleanup, fireEvent, render, screen, within } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { NativeChatComposerNotices } from './NativeChatComposerNotices' +import { CLIPBOARD_TEXT_TOO_LARGE_ERROR } from '../../../../shared/clipboard-text' +import { nativeChatNoticeFromError, setNativeChatPasteFailure } from './native-chat-composer-notice' + +afterEach(cleanup) + +describe('NativeChatComposerNotices', () => { + it('draws no card when there is nothing to say, but keeps its live region for the next notice', () => { + const { container } = render() + expect(container.querySelectorAll('li')).toHaveLength(0) + expect(container.querySelector('[aria-live="polite"]')).toBeInTheDocument() + }) + + it('puts errors first and keeps every notice, so one never hides another', () => { + const { container } = render( + + ) + const texts = screen.getAllByText(/./, { selector: 'p' }).map((node) => node.textContent) + expect(texts).toEqual([ + 'Orca couldn’t save your message.', + 'sonnet-9 is not an available model.', + 'Worktree not ready.' + ]) + expect(container.querySelectorAll('[data-notice-kind="error"]')).toHaveLength(2) + }) + + it('shows error text Orca did not write in its own block, with a copy button', () => { + const write = vi.fn().mockResolvedValue(undefined) + Object.assign(window, { api: { ui: { writeClipboardText: write } } }) + render( + + ) + const row = screen.getByRole('listitem') + expect(within(row).getByText('Your message was not sent.').tagName).toBe('P') + expect(within(row).getByText('connect ECONNREFUSED /tmp/agent-host.sock').tagName).toBe('PRE') + fireEvent.click(within(row).getByRole('button', { name: 'Copy error' })) + expect(write).toHaveBeenCalledWith('connect ECONNREFUSED /tmp/agent-host.sock') + }) + + it('offers the notice action and dismiss only where given', () => { + const retry = vi.fn() + const dismiss = vi.fn() + render( + + ) + expect(screen.getAllByRole('button', { name: 'Dismiss' })).toHaveLength(1) + fireEvent.click(screen.getByRole('button', { name: 'Retry' })) + fireEvent.click(screen.getByRole('button', { name: 'Dismiss' })) + expect(retry).toHaveBeenCalledTimes(1) + expect(dismiss).toHaveBeenCalledTimes(1) + }) +}) + +describe('nativeChatNoticeFromError', () => { + it('keeps a wrapped main-process error apart from the headline, without its wrapper', () => { + expect( + nativeChatNoticeFromError( + new Error( + "Error invoking remote method 'agentSession:send': Error: connect ECONNREFUSED /tmp/a.sock" + ), + 'Your message was not sent.' + ) + ).toEqual({ text: 'Your message was not sent.', errorText: 'connect ECONNREFUSED /tmp/a.sock' }) + }) + + it('keeps a local error apart unless the caller says it is already Orca’s words', () => { + const error = new Error('Pasted text is too large for this message.') + expect(nativeChatNoticeFromError(error, 'Paste failed.')).toEqual({ + text: 'Paste failed.', + errorText: 'Pasted text is too large for this message.' + }) + expect( + nativeChatNoticeFromError(error, 'Paste failed.', { localErrorIsOrcaWords: true }) + ).toEqual({ text: 'Pasted text is too large for this message.' }) + }) + + it("says a paste over the size limit in Orca's words, even when it comes back from the main process", () => { + const setNotice = vi.fn() + setNativeChatPasteFailure( + setNotice, + new Error( + `Error invoking remote method 'ui:readClipboardText': Error: ${CLIPBOARD_TEXT_TOO_LARGE_ERROR}` + ) + ) + expect(setNotice).toHaveBeenCalledWith(CLIPBOARD_TEXT_TOO_LARGE_ERROR) + }) + + it('keeps any other main-process paste failure apart under "Paste failed."', () => { + const setNotice = vi.fn() + setNativeChatPasteFailure( + setNotice, + new Error("Error invoking remote method 'ui:readClipboardText': Error: sftp down") + ) + expect(setNotice).toHaveBeenCalledWith('Paste failed.', 'sftp down') + }) + + it('says only the headline when the error carries no words', () => { + expect(nativeChatNoticeFromError(new Error(' '), 'Paste failed.')).toEqual({ + text: 'Paste failed.' + }) + expect(nativeChatNoticeFromError(undefined, 'Paste failed.')).toEqual({ text: 'Paste failed.' }) + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatComposerNotices.tsx b/src/renderer/src/components/native-chat/NativeChatComposerNotices.tsx new file mode 100644 index 000000000000..de15a5a5630e --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatComposerNotices.tsx @@ -0,0 +1,110 @@ +import { AlertCircle, Paperclip, X } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { translate } from '@/i18n/i18n' +import { cn } from '@/lib/utils' +import { NativeChatCopyButton } from './NativeChatCopyButton' +import type { NativeChatComposerNotice } from './native-chat-composer-notice' + +/** One card above the composer, one row per notice, errors first. The live region stays mounted so + * a screen reader hears the first notice too. */ +export function NativeChatComposerNotices({ + notices, + className +}: { + notices: readonly NativeChatComposerNotice[] + className?: string +}): React.JSX.Element { + if (notices.length === 0) { + return
    + } + const ordered = [...notices].sort( + (a, b) => Number(b.kind === 'error') - Number(a.kind === 'error') + ) + return ( +
      + {ordered.map((notice) => ( + + ))} +
    + ) +} + +/** The card where a question or approval card has taken the composer's place. */ +export function NativeChatPromptSlotNotices({ + notices +}: { + notices: readonly NativeChatComposerNotice[] +}): React.JSX.Element { + return ( +
    0 && 'mx-auto w-full max-w-(--chat-content-max-width) px-3 pt-2 sm:px-4' + )} + > + +
    + ) +} + +function NoticeRow({ notice }: { notice: NativeChatComposerNotice }): React.JSX.Element { + const isError = notice.kind === 'error' + const Icon = isError ? AlertCircle : Paperclip + const dismissLabel = translate('components.native-chat.notices.dismiss', 'Dismiss') + return ( +
  • +
    + +

    + {notice.text} +

    + {notice.action ? ( + + ) : null} + {notice.onDismiss ? ( + + ) : null} +
    + {notice.errorText ? ( +
    +
    +            {notice.errorText}
    +          
    + +
    + ) : null} +
  • + ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatContextUsageRing.test.tsx b/src/renderer/src/components/native-chat/NativeChatContextUsageRing.test.tsx index 86e62c14ddec..290c66355b50 100644 --- a/src/renderer/src/components/native-chat/NativeChatContextUsageRing.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatContextUsageRing.test.tsx @@ -254,6 +254,8 @@ describe('NativeChatContextUsageRing', () => { function ComposerWithRing(): React.JSX.Element { const onKeyDown = useNativeChatComposerKeyDown({ autocomplete: { mode: 'none' }, + mentionFiles: { files: [], loading: false, failed: false }, + completeMention: vi.fn(), activeSuggestion: 0, draft: '', history: EMPTY_HISTORY, diff --git a/src/renderer/src/components/native-chat/NativeChatHostOutageNotice.tsx b/src/renderer/src/components/native-chat/NativeChatHostOutageNotice.tsx new file mode 100644 index 000000000000..50b96f36f4cd --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatHostOutageNotice.tsx @@ -0,0 +1,64 @@ +import { useState } from 'react' +import { WifiOff } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { connectRuntimeHostAndReloadProjects } from '@/components/status-bar/runtime-environment-explicit-connect' +import { useMountedRef } from '@/hooks/useMountedRef' +import { translate } from '@/i18n/i18n' +import { cn } from '@/lib/utils' +import type { NativeChatHostOutage } from './use-native-chat-host-outage' + +/** One line naming the chat's host while Orca can't reach it; offline offers the status bar's + * Connect unless the host refused us. The live region stays mounted while empty, so it is announced. */ +export function NativeChatHostOutageNotice({ + outage +}: { + outage: NativeChatHostOutage | null +}): React.JSX.Element { + const [connecting, setConnecting] = useState(false) + const mountedRef = useMountedRef() + const reconnect = (environmentId: string): void => { + setConnecting(true) + void connectRuntimeHostAndReloadProjects(environmentId).finally(() => { + if (mountedRef.current) { + setConnecting(false) + } + }) + } + const offline = outage?.kind === 'offline' + return ( +
    + {outage ? ( +
    + +

    + {offline + ? translate('components.native-chat.hostOutage.offline', '{{hostName}} is offline', { + hostName: outage.hostLabel + }) + : translate( + 'components.native-chat.hostOutage.reconnecting', + '{{hostName}} is reconnecting…', + { hostName: outage.hostLabel } + )} +

    + {outage.canReconnect ? ( + + ) : null} +
    + ) : null} +
    + ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.opening-send.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.opening-send.test.tsx new file mode 100644 index 000000000000..6f3270700018 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.opening-send.test.tsx @@ -0,0 +1,333 @@ +// @vitest-environment happy-dom +// A message sent while the turn ahead is still opening waits at the tail, after that turn's live +// status, never as a bubble above it: the host holds it until the turn opens. The frames here are +// the ones a person sees during that wait, and through a Stop pressed during it. + +import '@testing-library/jest-dom/vitest' +import { cleanup, render, screen } from '@testing-library/react' +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import { agentSessionFailureFact } from '../../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../../shared/agent-session-failure-words' +import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key' +import type { + AgentJournalItemBody, + AgentJournalRenderItem, + AgentJournalSubmission, + AgentJournalTurnScope +} from '../../../../shared/agent-session-journal-types' +import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' +import { projectStructuredAgentSessionMessages } from '../../../../shared/structured-agent-session-message-projection' +import { selectStructuredAgentSettledTurns } from '../../../../shared/structured-agent-session-turn-timing' +import { NativeChatMessageList } from './NativeChatMessageList' +import { installNativeChatMessageListTestViewport } from './native-chat-message-list-test-viewport' + +let restoreViewport = (): void => {} +beforeAll(() => { + restoreViewport = installNativeChatMessageListTestViewport() +}) +afterAll(() => restoreViewport()) +afterEach(cleanup) + +const THREAD: AgentJournalTurnScope = { kind: 'thread' } +const NOW = 100_000 + +function item( + itemId: string, + sequence: number, + body: AgentJournalItemBody +): AgentJournalRenderItem { + return { itemId, revision: 0, sequence, observedAt: sequence, body, turnScope: THREAD } +} + +const userMessage = (id: string, sequence: number, text: string) => + item(agentJournalSubmissionKey(id), sequence, { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text }] + }) + +function submission( + clientMessageId: string, + overrides: Partial = {} +): AgentJournalSubmission { + return { + clientMessageId, + fence: 1, + payloadFingerprint: clientMessageId, + dispatchState: 'pending', + providerItemId: null, + reason: null, + submittedAt: NOW, + resolvedAt: null, + handoverRecorded: true, + ...overrides + } +} + +/** A finished warm-up, then "first" handed over (row 5) with no turn record yet: its turn opens. */ +function openingFirst(): { + items: AgentJournalRenderItem[] + submissions: AgentJournalSubmission[] +} { + return { + items: [ + userMessage('warm', 1, 'warm up'), + item('turn-warm', 2, { + kind: 'turn', + turnId: 'turn-warm', + state: 'completed', + outcome: 'success', + userItemId: agentJournalSubmissionKey('warm'), + startedAt: 1_000, + completedAt: 2_000 + }), + userMessage('first', 5, 'first') + ], + submissions: [ + submission('warm', { dispatchState: 'accepted', resolvedAt: 2_000 }), + submission('first', { acceptedSequence: 3, handedOverAt: NOW }) + ] + } +} + +/** The first send's turn record, written as its turn opens; Codex names its own provider key. */ +const firstTurnOpened = (sequence: number) => + item('turn-first', sequence, { + kind: 'turn', + turnId: 'turn-first', + state: 'running', + userItemId: FIRST_TURN_PROVIDER_KEY, + startedAt: NOW + }) +const FIRST_TURN_PROVIDER_KEY = 'codex:thread:turn-first:0' +const inFirstTurn: AgentJournalTurnScope = { kind: 'turn', turnItemId: 'turn-first' } + +/** This client's own send the host has not recorded yet: its lane still runs the first handover. */ +const unrecorded = (clientMessageId: string, text: string): StructuredAgentSessionOutboxEntry => ({ + clientMessageId, + sessionId: 'session-1', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] }, + previewUris: [], + state: 'dispatching', + queuedAt: NOW + 100, + lastAttemptAt: NOW + 100, + retryAfterUnknownSubmittedAt: null +}) + +function frame( + items: AgentJournalRenderItem[], + submissions: AgentJournalSubmission[], + outbox: StructuredAgentSessionOutboxEntry[], + stopping = false +): React.JSX.Element { + return ( + + ) +} + +function follows(later: HTMLElement, earlier: HTMLElement): boolean { + return Boolean(earlier.compareDocumentPosition(later) & Node.DOCUMENT_POSITION_FOLLOWING) +} + +/** The live turn's status, which carries the first send's working clock. */ +const liveStatus = () => screen.getByText(/^Working for/) +/** The live turn's activity line, the last thing the live turn draws. */ +const liveActivity = () => screen.getByText(/^(Working|Stopping)…$/) + +/** `text` is drawn after the live turn: its status and its activity line. */ +function waitsAtTheTail(text: string): void { + expect(follows(screen.getByText(text), liveStatus())).toBe(true) + expect(follows(screen.getByText(text), liveActivity())).toBe(true) +} + +describe('a message sent while the turn ahead is still opening', () => { + it('waits at the tail after the live status while the host has not recorded it yet', () => { + const { items, submissions } = openingFirst() + + render(frame(items, submissions, [unrecorded('second', 'second')])) + + expect(follows(liveStatus(), screen.getByText('first'))).toBe(true) + waitsAtTheTail('second') + }) + + // Accepted, and so placed at the row that accepted it, which is above the first send's handover. + it('waits at the tail after the live status once the host holds it queued', () => { + const { items, submissions } = openingFirst() + + render( + frame( + [...items, userMessage('second', 4, 'second')], + [...submissions, submission('second', { acceptedSequence: 4 })], + [] + ) + ) + + expect(follows(liveStatus(), screen.getByText('first'))).toBe(true) + waitsAtTheTail('second') + }) + + // A Stop takes back what is still queued: B stays after the live turn with its stop row, never + // drawn above it. Settled now, it sits with the transcript, above the live line. + it('stays after the live turn when a Stop takes it back, with no frame above the live status', () => { + const { items, submissions } = openingFirst() + const view = render(frame(items, submissions, [unrecorded('b', 'B')])) + waitsAtTheTail('B') + + // Pressed: Stopping, with B now recorded and queued behind the first send. + const queued = [...items, userMessage('b', 4, 'B')] + view.rerender( + frame(queued, [...submissions, submission('b', { acceptedSequence: 4 })], [], true) + ) + waitsAtTheTail('B') + + // Withdrawn with the queue. + const withdrawn = submission('b', { + acceptedSequence: 4, + dispatchState: 'rejected', + resolvedAt: NOW + 200, + ...agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }) + }) + view.rerender(frame(queued, [...submissions, withdrawn], [], true)) + const stopRow = screen.getByText('Stopped before the agent started') + expect(follows(screen.getByText('B'), liveStatus())).toBe(true) + expect(follows(stopRow, screen.getByText('B'))).toBe(true) + expect(follows(liveActivity(), stopRow)).toBe(true) + }) + + // The Q2 frame: Stop pressed while the host has not recorded B yet, its lane still busy. + it('keeps an unrecorded B after the Stopping line, and a message typed while Stopping too', () => { + const { items, submissions } = openingFirst() + const view = render(frame(items, submissions, [unrecorded('b', 'B')], true)) + waitsAtTheTail('B') + + const typedWhileStopping: StructuredAgentSessionOutboxEntry = { + ...unrecorded('c', 'C'), + queuedAt: NOW + 300, + lastAttemptAt: NOW + 300, + sentWhileStopping: true + } + view.rerender(frame(items, submissions, [unrecorded('b', 'B'), typedWhileStopping], true)) + waitsAtTheTail('B') + waitsAtTheTail('C') + }) + + /** One frame per commit: "second" stays under the first send's live status and above its + * activity line, where it lands, and the live status stays on "first". */ + function walkTurnOpening( + steps: [ + AgentJournalRenderItem[], + AgentJournalSubmission[], + StructuredAgentSessionOutboxEntry[] + ][] + ): void { + const view = render(
    ) + for (const step of steps) { + view.rerender(frame(...step)) + expect(follows(liveStatus(), screen.getByText('first'))).toBe(true) + expect(follows(screen.getByText('second'), liveStatus())).toBe(true) + expect(follows(liveActivity(), screen.getByText('second'))).toBe(true) + } + } + + function turnOpening() { + const { items, submissions } = openingFirst() + const [warm, first] = submissions + if (!warm || !first) { + throw new Error('expected the warm-up and the first send') + } + const opened = [...items, firstTurnOpened(6)] + return { + submissions, + warm, + first, + opened, + // Handed over as a steer once the turn opened (row 8). + steered: [...opened, { ...userMessage('second', 8, 'second'), turnScope: inFirstTurn }], + echoed: (sent: AgentJournalSubmission): AgentJournalSubmission => ({ + ...sent, + dispatchState: 'accepted', + providerItemId: FIRST_TURN_PROVIDER_KEY, + resolvedAt: NOW + 20 + }) + } + } + + it('draws it where it lands at every step of the turn opening, even with the steer echoed first', () => { + const { submissions, warm, first, opened, steered, echoed } = turnOpening() + const handedOver = submission('second', { acceptedSequence: 7, handedOverAt: NOW + 10 }) + walkTurnOpening([ + // The turn record is in; the host has not recorded "second" yet. + [opened, submissions, [unrecorded('second', 'second')]], + // Recorded after the turn record, not yet handed over. + [ + [...opened, userMessage('second', 7, 'second')], + [...submissions, submission('second', { acceptedSequence: 7 })], + [] + ], + [steered, [...submissions, handedOver], []], + // Codex echoes the steer before the send that opened the turn, then that send. + [steered, [warm, first, echoed(handedOver)], []], + [steered, [warm, echoed(first), echoed(handedOver)], []] + ]) + }) + + // Two messages queued behind /compact, or sent while Stopping: "second" is accepted above the + // first send's handover, and the first send's turn record lands before "second" is handed over. + it('draws it where it lands at every step when it was accepted above the first send', () => { + const { submissions, warm, first, opened, steered, echoed } = turnOpening() + const [warmUp, warmTurn, firstRow, turnRecord] = opened + if (!warmUp || !warmTurn || !firstRow || !turnRecord) { + throw new Error('expected the warm-up, the first send and its turn record') + } + const queuedAbove = [warmUp, warmTurn, userMessage('second', 4, 'second'), firstRow, turnRecord] + const queued = submission('second', { acceptedSequence: 4 }) + const handedOver = { ...queued, handedOverAt: NOW + 10 } + walkTurnOpening([ + [queuedAbove, [...submissions, queued], []], + // The first send echoes while "second" is still queued. + [queuedAbove, [warm, echoed(first), queued], []], + [steered, [...submissions, handedOver], []], + [steered, [warm, first, echoed(handedOver)], []], + [steered, [warm, echoed(first), echoed(handedOver)], []] + ]) + }) + + // The host holds nothing for a send only the user's Retry sends again, so it stays where it is. + it.each([ + ['unconfirmed', { state: 'unconfirmed' as const, retryAfterUnknownSubmittedAt: NOW - 50_000 }], + ['outlived by a Stop', { state: 'queued' as const, outlivedStop: true as const }] + ])('does not move a send waiting on Retry (%s) behind the opening turn', (_, retry) => { + const { items, submissions } = openingFirst() + const awaitingRetry: StructuredAgentSessionOutboxEntry = { + ...unrecorded('c', 'C'), + queuedAt: NOW - 50_000, + lastAttemptAt: NOW - 50_000, + ...retry + } + + render(frame(items, submissions, [awaitingRetry])) + + expect(follows(liveActivity(), screen.getByText('C'))).toBe(true) + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx index 6c9058213ba7..a7aad33bec9f 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx @@ -215,8 +215,14 @@ export function NativeChatMessageList({ }) // A message waiting behind the live turn draws after that turn's live activity, not inside it. const { slots, waitingSlots } = useMemo( - () => splitNativeChatSlotsWaitingBehindLiveTurn(allSlots, journalItems, stopping), - [allSlots, journalItems, stopping] + () => + splitNativeChatSlotsWaitingBehindLiveTurn( + allSlots, + journalItems, + stopping, + journalSubmissions + ), + [allSlots, journalItems, stopping, journalSubmissions] ) const transcriptWindow = useNativeChatTranscriptWindow({ scrollRef, diff --git a/src/renderer/src/components/native-chat/NativeChatModelCombobox.test.tsx b/src/renderer/src/components/native-chat/NativeChatModelCombobox.test.tsx new file mode 100644 index 000000000000..3521acd99f9a --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatModelCombobox.test.tsx @@ -0,0 +1,144 @@ +// @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) => fallback })) + +import { Button } from '@/components/ui/button' +import { PopoverTrigger } from '@/components/ui/popover' +import { NativeChatModelCombobox } from './NativeChatModelCombobox' + +const CHOICES = [ + { value: 'gpt-5.5', label: 'GPT-5.5', description: 'Frontier reasoning' }, + { value: 'gpt-5.2-codex', label: 'GPT-5.2 Codex' }, + { value: 'opus', label: 'Opus 4.8' } +] + +const settle = (): Promise => + act(async () => new Promise((resolve) => setTimeout(resolve, 30))) + +type ComboboxOptions = { defaultOpen?: boolean; readOnly?: boolean; readOnlyReason?: string | null } +type OnSelect = ReturnType void>> + +function renderCombobox(props: ComboboxOptions = {}): OnSelect { + const onSelect = vi.fn<(value: string) => void>() + render( + ( + + + + )} + /> + ) + return onSelect +} + +/** Renders the picker already open and returns its search field. */ +async function openCombobox( + props: ComboboxOptions = {} +): Promise<{ search: HTMLElement; onSelect: OnSelect }> { + const onSelect = renderCombobox(props) + await settle() + return { search: screen.getByRole('combobox'), onSelect } +} + +const listedModels = (): (string | null)[] => + screen.queryAllByRole('option').map((option) => option.getAttribute('data-value')) + +const highlightedModel = (): string | null => + screen.queryByRole('option', { selected: true })?.getAttribute('data-value') ?? null + +afterEach(() => cleanup()) + +describe('NativeChatModelCombobox', () => { + it('opens with the search focused and the current model highlighted and marked', async () => { + const { search } = await openCombobox() + expect(document.activeElement).toBe(search) + expect(screen.getByRole('combobox', { name: 'Search models…' })).toBe(search) + expect(listedModels()).toEqual(['gpt-5.5', 'gpt-5.2-codex', 'opus']) + expect(highlightedModel()).toBe('opus') + expect(screen.getByRole('option', { current: true }).getAttribute('data-value')).toBe('opus') + }) + + it('focuses the search when the pill is clicked', async () => { + renderCombobox({ defaultOpen: false }) + fireEvent.click(screen.getByRole('button', { name: 'Model' })) + await settle() + expect(document.activeElement).toBe(screen.getByRole('combobox')) + }) + + it('opens with the search focused when ArrowDown is pressed on the pill', async () => { + renderCombobox({ defaultOpen: false }) + const pill = screen.getByRole('button', { name: 'Model' }) + pill.focus() + fireEvent.keyDown(pill, { key: 'ArrowDown' }) + await settle() + expect(document.activeElement).toBe(screen.getByRole('combobox')) + }) + + it('filters by every typed term across label, id and description, in list order', async () => { + const { search } = await openCombobox() + + fireEvent.change(search, { target: { value: 'gpt' } }) + expect(listedModels()).toEqual(['gpt-5.5', 'gpt-5.2-codex']) + expect(highlightedModel()).toBe('gpt-5.5') + + fireEvent.change(search, { target: { value: 'GPT codex' } }) + expect(listedModels()).toEqual(['gpt-5.2-codex']) + + fireEvent.change(search, { target: { value: 'frontier' } }) + expect(listedModels()).toEqual(['gpt-5.5']) + + fireEvent.change(search, { target: { value: 'gemini' } }) + expect(listedModels()).toEqual([]) + expect(screen.getByText('No models match your search.')).not.toBeNull() + }) + + it('moves the highlight with the arrows while focus stays in the search', async () => { + const { search, onSelect } = await openCombobox() + fireEvent.change(search, { target: { value: 'gpt' } }) + fireEvent.keyDown(search, { key: 'ArrowDown' }) + expect(highlightedModel()).toBe('gpt-5.2-codex') + expect(document.activeElement).toBe(search) + + fireEvent.keyDown(search, { key: 'Enter' }) + await settle() + expect(onSelect).toHaveBeenCalledExactlyOnceWith('gpt-5.2-codex') + expect(screen.queryByRole('combobox')).toBeNull() + }) + + it('closes on Tab instead of trapping focus in the search', async () => { + const { search } = await openCombobox() + fireEvent.keyDown(search, { key: 'Tab' }) + await settle() + expect(screen.queryByRole('combobox')).toBeNull() + }) + + it('picks a clicked row and closes', async () => { + const { onSelect } = await openCombobox() + fireEvent.click(screen.getByRole('option', { name: /GPT-5.2 Codex/ })) + await settle() + expect(onSelect).toHaveBeenCalledExactlyOnceWith('gpt-5.2-codex') + expect(screen.queryByRole('combobox')).toBeNull() + }) + + it('lists but does not pick models it cannot set, and says why', async () => { + const { search, onSelect } = await openCombobox({ + readOnly: true, + readOnlyReason: 'Set when the session starts.' + }) + expect(screen.getByText('Set when the session starts.')).not.toBeNull() + fireEvent.change(search, { target: { value: 'codex' } }) + fireEvent.keyDown(search, { key: 'Enter' }) + fireEvent.click(screen.getByRole('option', { name: /GPT-5.2 Codex/ })) + expect(onSelect).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatModelCombobox.tsx b/src/renderer/src/components/native-chat/NativeChatModelCombobox.tsx new file mode 100644 index 000000000000..a6d1842cbf4f --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatModelCombobox.tsx @@ -0,0 +1,110 @@ +import { useState } from 'react' +import { Check } from 'lucide-react' +import { + Command, + CommandEmpty, + CommandInput, + CommandItem, + CommandList +} from '@/components/ui/command' +import { Popover, PopoverContent } from '@/components/ui/popover' +import { translate } from '@/i18n/i18n' +import type { SessionOptionSelectChoice } from '../../../../shared/native-chat-session-options' +import { nativeChatSessionChoiceLabel } from './native-chat-session-option-labels' +import { NativeChatSessionOptionChoiceBody } from './NativeChatSessionOptionChoiceBody' + +type NativeChatModelComboboxProps = { + choices: readonly SessionOptionSelectChoice[] + currentValue: string | undefined + /** Read on mount only: the owner remounts to open on request. */ + defaultOpen: boolean + /** Choices stay listed and searchable, but cannot be picked. */ + readOnly: boolean + readOnlyReason: string | null + onSelect: (value: string) => void + /** Renders the `PopoverTrigger` pill, which must take the key handler. */ + renderTrigger: (onKeyDown: React.KeyboardEventHandler) => React.ReactNode +} + +// Why not cmdk's scorer: it matches scattered letters and reorders the host's list. +function modelChoiceFilter(value: string, search: string, keywords?: string[]): number { + const haystack = [value, ...(keywords ?? [])].join(' ').toLowerCase() + const terms = search.toLowerCase().split(/\s+/).filter(Boolean) + return terms.every((term) => haystack.includes(term)) ? 1 : 0 +} + +export function NativeChatModelCombobox({ + choices, + currentValue, + defaultOpen, + readOnly, + readOnlyReason, + onSelect, + renderTrigger +}: NativeChatModelComboboxProps): React.JSX.Element { + const [open, setOpen] = useState(defaultOpen) + const searchLabel = translate('components.native-chat.composer.searchModels', 'Search models…') + return ( + + {/* Why: a popover trigger opens on Enter and Space only; the menu it replaced also took ArrowDown. */} + {renderTrigger((event) => { + if (event.key === 'ArrowDown') { + event.preventDefault() + setOpen(true) + } + })} + { + if (event.key === 'Tab') { + setOpen(false) + } + }} + > + + + {readOnly && readOnlyReason ? ( +
    {readOnlyReason}
    + ) : null} + + + {translate( + 'components.native-chat.composer.noModelsMatch', + 'No models match your search.' + )} + + {choices.map((choice) => { + const label = nativeChatSessionChoiceLabel(choice) + const current = choice.value === currentValue + return ( + { + setOpen(false) + onSelect(choice.value) + }} + > + + {current ? : null} + + + + ) + })} + +
    +
    +
    + ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatSessionOptionChoiceBody.tsx b/src/renderer/src/components/native-chat/NativeChatSessionOptionChoiceBody.tsx new file mode 100644 index 000000000000..f5b100ef5a88 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatSessionOptionChoiceBody.tsx @@ -0,0 +1,13 @@ +export function NativeChatSessionOptionChoiceBody(props: { + label: string + description?: string +}): React.JSX.Element { + return ( +
    +
    {props.label}
    + {props.description ? ( +
    {props.description}
    + ) : null} +
    + ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.menu-request.test.tsx b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.menu-request.test.tsx index 90abdc3ef25e..a6221046e466 100644 --- a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.menu-request.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.menu-request.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment happy-dom -// Against the real menu: a `/model` request opens the menu once, and a period while the host still -// lists models neither opens it later nor reopens one the user closed. +// Against the real picker: a `/model` request opens it once with the search focused, and a period +// while the host still lists models neither opens it later nor reopens one the user closed. import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -72,38 +72,38 @@ const settle = (): Promise => afterEach(() => cleanup()) -describe('model menu requests while the host lists models', () => { - it('spends a request made while pending without opening the menu once the list lands', async () => { +describe('model picker requests while the host lists models', () => { + it('spends a request made while pending without opening the picker once the list lands', async () => { const request = { id: 'model', sequence: 1 } const { rerender } = render(view(true, request)) const composer = screen.getByTestId('composer') composer.focus() await settle() - expect(screen.queryByRole('menu')).toBeNull() + expect(screen.queryByRole('combobox')).toBeNull() rerender(view(false, request)) await settle() - expect(screen.queryByRole('menu')).toBeNull() + expect(screen.queryByRole('combobox')).toBeNull() expect(document.activeElement).toBe(composer) // A new request after the list landed still opens it. rerender(view(false, { id: 'model', sequence: 2 })) await settle() - expect(screen.queryByRole('menu')).not.toBeNull() + expect(document.activeElement).toBe(screen.getByRole('combobox')) }) - it('does not reopen a menu the user closed when a pending period ends', async () => { + it('does not reopen a picker the user closed when a pending period ends', async () => { const request = { id: 'model', sequence: 1 } const { rerender } = render(view(false, request)) await settle() - fireEvent.keyDown(screen.getByRole('menu'), { key: 'Escape' }) + fireEvent.keyDown(screen.getByRole('combobox'), { key: 'Escape' }) await settle() - expect(screen.queryByRole('menu')).toBeNull() + expect(screen.queryByRole('combobox')).toBeNull() rerender(view(true, request)) await settle() rerender(view(false, request)) await settle() - expect(screen.queryByRole('menu')).toBeNull() + expect(screen.queryByRole('combobox')).toBeNull() }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx index 0c82460833f4..770547021d35 100644 --- a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx @@ -158,6 +158,35 @@ vi.mock('@/components/ui/dropdown-menu', () => { } }) +// Same test ids as the dropdown mock: the model pill is a popover, the options pill a menu. +vi.mock('@/components/ui/popover', () => ({ + Popover: ({ children, open }: { children: React.ReactNode; open?: boolean }) => ( +
    + {children} +
    + ), + PopoverTrigger: ({ children, disabled }: { children: React.ReactNode; disabled?: boolean }) => ( +
    {children}
    + ), + PopoverContent: ({ + children, + side, + collisionPadding + }: { + children: React.ReactNode + side?: string + collisionPadding?: number + }) => ( +
    + {children} +
    + ) +})) + import { NativeChatSessionOptionPickers } from './NativeChatSessionOptionPickers' const surface = { @@ -268,6 +297,19 @@ describe('NativeChatSessionOptionPickers', () => { ) }) + it('sends a picked model to the session surface', async () => { + const setOption = vi.fn().mockResolvedValue({ snapshot: [] }) + render( + + ) + screen.getByRole('option', { name: 'Sonnet 5' }).click() + await waitFor(() => expect(setOption).toHaveBeenCalledExactlyOnceWith('model', 'sonnet')) + }) + it('prefers collision-aware upward placement for model and option menus', () => { render( }): React.JSX.Element { // Why: value-only visible text must still include the category in the // accessible name (WCAG 2.5.3 Label in Name / voice control). @@ -81,18 +87,19 @@ function PickerTrigger(props: { return ( - + - + -
    {props.label}
    - {props.description ? ( -
    {props.description}
    - ) : null} -
    - ) -} - function DescriptorMenuRows(props: { descriptor: SessionOptionDescriptor pending: boolean @@ -179,7 +175,7 @@ function DescriptorMenuRows(props: { value={choice.value} disabled={!descriptor.settable || pending} > - @@ -241,7 +237,16 @@ function NativeChatSessionOptionPickersInner({ } const modelReason = nativeChatSessionOptionDisabledReason(model.disabledReason) - const modelTooltip = translate('components.native-chat.composer.model', 'Model') + const modelPickerKey = `model:${requestedModelSequence ?? 'idle'}` + // Read only when a request remounts the picker: one made while pending is spent shut. + const modelPickerDefaultOpen = requestedModelSequence !== null && !modelChoicesPending + const modelTrigger = { + label: nativeChatModelPillLabel(model), + tooltipLabel: translate('components.native-chat.composer.model', 'Model'), + disabled: isWorking || pendingId !== null || modelChoicesPending, + disabledReason: modelReason, + dispatched: sessionOptionDispatchUnconfirmed(model) + } const optionsTooltip = nativeChatOptionsPillTitle(options) const optionsReason = options.length > 0 && options.every((descriptor) => !descriptor.settable) @@ -250,36 +255,42 @@ function NativeChatSessionOptionPickersInner({ return (
    - - setOption(model, value)} + renderTrigger={(onKeyDown) => ( + + )} /> - - {modelReason && !model.settable ? ( - {modelReason} - ) : null} - setOption(model, value)} - invokeAction={() => invokeAction(model)} - /> - - + ) : ( + + + + {modelReason && !model.settable ? ( + {modelReason} + ) : null} + setOption(model, value)} + invokeAction={() => invokeAction(model)} + /> + + + )} {options.length > 0 ? ( + (await import('./NativeChatStructuredSession.test-harness')).createStructuredSessionMocks() +) +const reconnectHost = vi.hoisted(() => vi.fn<(environmentId: string) => Promise>()) + +vi.mock('@/runtime/structured-agent-session-client', () => + moduleFactories.structuredAgentSessionClient() +) +vi.mock('./use-structured-agent-session', () => moduleFactories.useStructuredAgentSession()) +vi.mock('./use-native-chat-font-size', () => moduleFactories.useNativeChatFontSize()) +vi.mock('./use-native-chat-file-link-context', () => moduleFactories.useNativeChatFileLinkContext()) +vi.mock('./use-native-chat-file-link-click', () => moduleFactories.useNativeChatFileLinkClick()) +vi.mock('./NativeChatMessageList', () => moduleFactories.nativeChatMessageList()) +vi.mock('./NativeChatComposer', () => moduleFactories.nativeChatComposer()) +vi.mock('./NativeChatApprovalCard', () => moduleFactories.nativeChatApprovalCard()) +vi.mock('./NativeChatQuestionCard', () => moduleFactories.nativeChatQuestionCard()) +vi.mock('@/components/status-bar/runtime-environment-explicit-connect', () => ({ + connectRuntimeHostAndReloadProjects: reconnectHost +})) + +import { useAppStore } from '@/store' +import { NativeChatStructuredSession } from './NativeChatStructuredSession' +import { NATIVE_CHAT_HOST_RECONNECTING_GRACE_MS } from './use-native-chat-host-outage' + +const initialState = useAppStore.getState() +let sequence = 0 + +function hostStatus( + verification: 'verified' | 'unavailable' | 'blocked', + transport: 'ready' | 'disconnected', + retired?: true +): RuntimeEnvironmentStatus { + const status = + verification === 'verified' + ? { + runtimeId: 'runtime-1', + rendererGraphEpoch: 1, + graphStatus: 'ready' as const, + authoritativeWindowId: 1, + desktopWindowStatus: 'available' as const, + liveTabCount: 0, + liveLeafCount: 0 + } + : null + return { + snapshot: { + environmentId: 'remote-host', + pairingRevision: 1, + sequence: ++sequence, + checkedAt: 1, + status, + verification, + transport, + ...(retired ? { retired } : {}) + }, + status, + checkedAt: 1 + } +} + +function setHost(status: RuntimeEnvironmentStatus): void { + act(() => { + useAppStore.setState({ runtimeStatusByEnvironmentId: new Map([['remote-host', status]]) }) + }) +} + +function renderPane(): void { + render( + + ) +} + +beforeEach(() => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + useAppStore.setState({ + runtimeEnvironments: [ + { + id: 'remote-host', + name: 'Build box', + createdAt: 1, + updatedAt: 1, + lastUsedAt: null, + runtimeId: null, + endpoints: [{ id: 'ws', kind: 'websocket', label: 'ws', endpoint: 'ws://host' }], + preferredEndpointId: 'ws' + } + ], + runtimeStatusByEnvironmentId: new Map([['remote-host', hostStatus('verified', 'ready')]]) + }) +}) + +afterEach(() => { + cleanup() + vi.useRealTimers() + resetStructuredSessionMocks() + reconnectHost.mockReset() + useAppStore.setState({ + runtimeEnvironments: initialState.runtimeEnvironments, + runtimeStatusByEnvironmentId: initialState.runtimeStatusByEnvironmentId + }) +}) + +// The chat's own stream retrying is not the host's outage: with the host up, nothing is said. +it('adds no host line and no read line to a loaded chat while its host stays connected', () => { + renderPane() + act(() => { + vi.advanceTimersByTime(NATIVE_CHAT_HOST_RECONNECTING_GRACE_MS * 2) + }) + + expect(screen.getByTestId('message-list')).toBeTruthy() + expect(screen.queryByText(/reconnect|offline|couldn't be loaded/i)).toBeNull() +}) + +it('names a reconnecting host above the composer only once the grace has passed', () => { + renderPane() + // Mounted before its words arrive, so a screen reader announces them. + const region = screen.getByRole('status') + expect(region.textContent).toBe('') + setHost(hostStatus('unavailable', 'disconnected')) + act(() => { + vi.advanceTimersByTime(NATIVE_CHAT_HOST_RECONNECTING_GRACE_MS - 1) + }) + expect(screen.queryByText(/Build box/)).toBeNull() + + act(() => { + vi.advanceTimersByTime(1) + }) + expect(region.textContent).toBe('Build box is reconnecting…') + expect(screen.queryByRole('button', { name: 'Reconnect' })).toBeNull() + expect(screen.getByTestId('structured-composer')).toBeTruthy() + + setHost(hostStatus('verified', 'ready')) + expect(screen.queryByText(/Build box/)).toBeNull() +}) + +it('says a disconnected host is offline at once and offers Reconnect', async () => { + mocks.hasOlder = true + renderPane() + setHost(hostStatus('blocked', 'disconnected', true)) + + expect(screen.getByText('Build box is offline')).toBeTruthy() + expect(mocks.messageListProps?.session?.hasMore).toBe(false) + + reconnectHost.mockResolvedValue(true) + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Reconnect' })) + }) + expect(reconnectHost).toHaveBeenCalledWith('remote-host') +}) + +it('offers no Reconnect for a host that refused us, and promises no delivery in the composer', () => { + renderPane() + setHost(hostStatus('blocked', 'disconnected')) + + expect(screen.getByText('Build box is offline')).toBeTruthy() + expect(screen.queryByRole('button', { name: 'Reconnect' })).toBeNull() + // A send now fails and waits for its own Retry, so nothing may say it will go out later. + expect(mocks.composerProps?.structuredTransport).not.toHaveProperty('placeholder') + expect(screen.queryByText(/Messages send when/)).toBeNull() +}) + +it('says the outage once: a lost read beside messages adds no line of its own', () => { + mocks.status = 'error' + renderPane() + setHost(hostStatus('blocked', 'disconnected')) + + expect(screen.getByTestId('message-list')).toBeTruthy() + expect(screen.getByText('Build box is offline')).toBeTruthy() + expect(screen.queryByText(/couldn't be loaded/)).toBeNull() +}) + +it('says the outage once: a chat that never loaded waits quietly instead of failing', () => { + mocks.status = 'error' + mocks.messages = [] + renderPane() + setHost(hostStatus('blocked', 'disconnected')) + + expect(screen.getByText('Build box is offline')).toBeTruthy() + expect(screen.queryByText('Could not load conversation')).toBeNull() + expect(screen.queryByText(/keeps trying/)).toBeNull() +}) + +it("still words a refusal the host itself sent beside the host's outage", () => { + mocks.status = 'error' + mocks.readRefusal = { + code: 'agent_session_journal_unreadable', + details: { reason: 'journalUnavailable' } + } as const + renderPane() + setHost(hostStatus('blocked', 'disconnected')) + + expect(screen.getByText('Build box is offline')).toBeTruthy() + expect(screen.getByText("Orca couldn't open this chat's history right now.")).toBeTruthy() +}) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.notice-card.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.notice-card.test.tsx new file mode 100644 index 000000000000..e2e762a3ec0e --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.notice-card.test.tsx @@ -0,0 +1,106 @@ +// @vitest-environment happy-dom + +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' + +const { mocks, moduleFactories, resetStructuredSessionMocks } = await vi.hoisted(async () => + (await import('./NativeChatStructuredSession.test-harness')).createStructuredSessionMocks() +) + +vi.mock('@/lib/structured-agent-session-launch', () => + moduleFactories.structuredAgentSessionLaunch() +) +vi.mock('@/runtime/structured-agent-session-client', () => + moduleFactories.structuredAgentSessionClient() +) +vi.mock('./use-structured-agent-session', () => moduleFactories.useStructuredAgentSession()) +vi.mock('./use-native-chat-font-size', () => moduleFactories.useNativeChatFontSize()) +vi.mock('./use-native-chat-file-link-context', () => moduleFactories.useNativeChatFileLinkContext()) +vi.mock('./use-native-chat-file-link-click', () => moduleFactories.useNativeChatFileLinkClick()) +vi.mock('./NativeChatMessageList', () => moduleFactories.nativeChatMessageList()) +vi.mock('./NativeChatComposer', () => moduleFactories.nativeChatComposer()) +vi.mock('./NativeChatApprovalCard', () => moduleFactories.nativeChatApprovalCard()) +vi.mock('./NativeChatQuestionCard', () => moduleFactories.nativeChatQuestionCard()) + +import { NativeChatStructuredSession } from './NativeChatStructuredSession' +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' + +const SESSION_ID = 'notice-card-session' + +afterEach(() => { + cleanup() + resetStructuredSessionMocks() +}) + +function renderPane(): void { + render( + + ) +} + +function composerOnError(): (text: string | null, errorText?: string) => void { + const onError = mocks.composerProps?.structuredTransport?.onError + if (!onError) { + throw new Error('composer transport has no onError') + } + return onError +} + +it("shows the chat's own error and a composer error together, where one used to hide the other", () => { + // A history read that failed beside a transcript it keeps: the chat's own line. + mocks.status = 'error' + mocks.readRefusal = { + code: 'agent_session_journal_unreadable', + details: { reason: 'journalUnavailable' } + } + renderPane() + act(() => { + composerOnError()('sonnet-9 is not an available model for this chat session.') + }) + expect(screen.getByText("Orca couldn't open this chat's history right now.")).toBeTruthy() + expect(screen.getByText('sonnet-9 is not an available model for this chat session.')).toBeTruthy() + expect(document.querySelectorAll('[data-notice-kind="error"]')).toHaveLength(2) +}) + +it('keeps a send failure’s raw error apart and lets the user dismiss it', () => { + renderPane() + act(() => { + composerOnError()('Your message was not sent.', 'connect ECONNREFUSED /tmp/agent-host.sock') + }) + expect(screen.getByText('connect ECONNREFUSED /tmp/agent-host.sock').tagName).toBe('PRE') + fireEvent.click(screen.getByRole('button', { name: 'Dismiss' })) + expect(screen.queryByText('Your message was not sent.')).toBeNull() +}) + +const APPROVAL: AgentJournalRenderItem = JSON.parse( + JSON.stringify({ + itemId: 'approval-item', + revision: 1, + sequence: 1, + observedAt: 1, + body: { + kind: 'approval', + title: 'Run the plan?', + detail: null, + subject: { kind: 'plan', text: 'x' }, + options: [{ id: 'allow', label: 'Approve' }], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } + } + }) +) + +it("keeps the notices above an approval card that takes the composer's place", () => { + mocks.launchLifecycle = 'failed' + mocks.promptItems = [APPROVAL] + renderPane() + expect(screen.queryByTestId('structured-composer')).toBeNull() + expect(screen.getByText('Chat could not be started.')).toBeTruthy() + expect(screen.getByRole('button', { name: 'Retry' })).toBeTruthy() +}) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.read-error.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.read-error.test.tsx index 3715910cbc3a..ccfba80707e6 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.read-error.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.read-error.test.tsx @@ -137,15 +137,25 @@ it('says once that a newer Orca saved the chat and only an update opens it, with expect(mocks.composerProps).toBeNull() }) -it('says only that it is reconnecting, not as an error, when a failure names nothing', () => { +// Only a chat that never loaded stores a failure with no host refusal (the read owner drops it after load). +it('says once that the history did not load beside a bubble of a chat that never loaded', () => { mocks.status = 'error' - renderPane() + render( + + ) expect(screen.getByTestId('message-list')).toBeTruthy() - const reconnecting = screen.getByText('Reconnecting to this chat…') - expect(reconnecting.className).not.toContain('text-destructive') - expect(screen.queryByText(/history couldn't be loaded/)).toBeNull() + expect(screen.getByTestId('structured-composer')).toBeTruthy() + expect(screen.getAllByText("This chat's history couldn't be loaded.")).toHaveLength(1) + expect(screen.queryByText(/reconnect/i)).toBeNull() }) it('words a failed reconnect beside a transcript it keeps', () => { diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-composer.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-composer.tsx new file mode 100644 index 000000000000..5a0a6fd5365f --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-composer.tsx @@ -0,0 +1,37 @@ +import { forwardRef, useImperativeHandle, useRef } from 'react' +import type { NativeChatComposerNotice } from './native-chat-composer-notice' +import { NativeChatComposerNotices } from './NativeChatComposerNotices' + +/** The structured session tests' composer: records its props, takes real DOM focus, and draws the + * chat's notices above its input as the real composer does. */ +export function createStructuredSessionComposerMock(mocks: { + composerProps: unknown + handlePasteEvent: unknown + pasteFromClipboard: unknown +}) { + return { + NativeChatComposer: forwardRef( + (props: { notices?: readonly NativeChatComposerNotice[] }, ref) => { + mocks.composerProps = props + const fieldRef = useRef(null) + useImperativeHandle(ref, () => ({ + // Real DOM focus: the reveal-focus loop retries until focus lands in the pane. + focus: () => { + fieldRef.current?.focus() + return true + }, + insertTypedText: () => true, + handlePasteEvent: mocks.handlePasteEvent, + pasteFromClipboard: mocks.pasteFromClipboard, + contains: (node: Node | null) => fieldRef.current?.contains(node) === true + })) + return ( + <> + +