From eb43000b880b447c3984403e26699bfa83ed540a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:49:15 -0700 Subject: [PATCH 1/6] fix(native-chat): chats saved before permission modes run the new-chat default A Claude or Codex chat created before chats had their own permission mode has none saved, and it ran Ask, so every existing chat of a Yolo user started asking for approval after the update. A chat with no saved mode now runs Settings -> Chat -> Permissions, read from the host's settings on each launch, read and restart check. --- .../structured-agent-session-host-types.ts | 2 + ...ed-agent-session-launch-permission.test.ts | 44 +++++++++++++++++-- ...uctured-agent-session-launch-permission.ts | 6 ++- .../runtime/orca-runtime-get-worktree-ps.ts | 2 + .../structured-agent-session-runtime.ts | 5 +++ 5 files changed, 54 insertions(+), 5 deletions(-) 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 c87dec4421db..b3a339298511 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 @@ -169,4 +169,6 @@ export type StructuredAgentSessionHostDeps = { statusSink?: StructuredAgentSessionStatusSink /** Host model catalog surface; absent means every catalog read answers `unknown`. */ modelCatalog?: AgentModelCatalogService + /** The new-chat permission setting, read per call; absent, a chat with no saved mode asks. */ + newChatPermissionMode?: () => unknown } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.test.ts index 640ea42ed084..4666242add3b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.test.ts @@ -28,7 +28,8 @@ const listing = ( function launchPermission( provider: string, options: Record | undefined, - answer: AgentSessionModelCatalogResult | Error = { origin: 'unknown' } + answer: AgentSessionModelCatalogResult | Error = { origin: 'unknown' }, + newChatPermissionMode?: () => unknown ) { const read = vi.fn(async () => { if (answer instanceof Error) { @@ -46,7 +47,11 @@ function launchPermission( return { read, result: structuredAgentSessionLaunchPermission( - { agents: claudeAndCodexDeclared(), modelCatalog }, + { + agents: claudeAndCodexDeclared(), + modelCatalog, + ...(newChatPermissionMode ? { newChatPermissionMode } : {}) + }, record(provider, options) ) } @@ -62,10 +67,43 @@ describe('the permission mode a chat starts with', () => { expect(codex.read).not.toHaveBeenCalled() }) - it('asks for a chat saved before modes existed', async () => { + it('asks for a chat saved before modes existed when the host has no new-chat setting', async () => { await expect(launchPermission('claude', undefined).result).resolves.toBe('ask') }) + it('runs the new-chat setting for a chat saved before modes existed, read per call', async () => { + let setting = 'full-access' + const read = (): unknown => setting + await expect(launchPermission('claude', undefined, undefined, read).result).resolves.toBe( + 'full-access' + ) + await expect( + launchPermission('codex', { model: 'gpt-5' }, undefined, read).result + ).resolves.toBe('full-access') + setting = 'ask' + await expect(launchPermission('codex', undefined, undefined, read).result).resolves.toBe('ask') + }) + + it('narrows the new-chat setting to what the agent offers', async () => { + const acceptEdits = (): unknown => 'accept-edits' + await expect( + launchPermission('claude', undefined, undefined, acceptEdits).result + ).resolves.toBe('accept-edits') + await expect(launchPermission('codex', undefined, undefined, acceptEdits).result).resolves.toBe( + 'ask' + ) + // Approve for me still needs a model listed for it, as a saved choice does. + await expect( + launchPermission('claude', undefined, undefined, () => 'auto').result + ).resolves.toBe('ask') + }) + + it('keeps a chat saved choice over the new-chat setting', async () => { + await expect( + launchPermission('claude', { permissionMode: 'ask' }, undefined, () => 'full-access').result + ).resolves.toBe('ask') + }) + it('runs Claude auto on a model the saved catalog lists as supporting it', async () => { const supported = launchPermission( 'claude', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.ts index 3e9ac14a40bd..2d255140a799 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.ts @@ -35,14 +35,16 @@ async function listedAutoModeSupport( /** Null for an agent whose chats offer no modes, or one this runtime did not register. */ export async function structuredAgentSessionLaunchPermission( - deps: Pick, + deps: Pick, record: AgentSessionRecord ): Promise { const support = agentChatPermissionModes(record.provider) if (!support || !deps.agents.definition(record.provider)) { return null } - const intent = record.options?.[AGENT_CHAT_PERMISSION_MODE_OPTION_ID] + // A chat saved before chats had modes runs the new-chat default, not Ask. + const intent = + record.options?.[AGENT_CHAT_PERMISSION_MODE_OPTION_ID] ?? deps.newChatPermissionMode?.() const autoSupported = chatPermissionModeNeedsModelSupport(support, intent) && (await listedAutoModeSupport(deps.modelCatalog, record)) diff --git a/src/main/runtime/orca-runtime-get-worktree-ps.ts b/src/main/runtime/orca-runtime-get-worktree-ps.ts index 4cea4007910a..6e546e7ddd54 100644 --- a/src/main/runtime/orca-runtime-get-worktree-ps.ts +++ b/src/main/runtime/orca-runtime-get-worktree-ps.ts @@ -236,6 +236,8 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStartTuiIdleVis ? resolveTuiAgentLaunchEnv(agent, this.requireStore().getSettings().agentDefaultEnv) : {}, resolveAgentCommandSettings: () => this.requireStore().getSettings(), + resolveNewChatPermissionMode: () => + this.requireStore().getSettings().nativeChatPermissionMode, resolveCodexAccountKind: (home) => resolveStructuredCodexAccountKind(home, this.requireStore().getSettings()), resolveAgentAccountHome: (agent) => this.resolveStructuredAgentAccountHome(agent), diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 11afc2d16f46..f52d03d715d3 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -127,6 +127,8 @@ export type StructuredAgentSessionRuntimeDeps = { resolveAgentLaunchEnv?: (agent: string) => Record /** The settings a per-agent Command override is read from, for the same agents. */ resolveAgentCommandSettings?: () => StructuredAgentCommandSettings + /** Settings → Chat → Permissions, which a chat saved without a mode runs. */ + resolveNewChatPermissionMode?: () => unknown resolveEnvironment?: () => Promise /** Which login-shell variables Codex and Claude children inherit; absent inherits all. */ resolveShellEnvironmentPolicy?: () => NativeChatShellEnvironmentPolicy @@ -316,6 +318,9 @@ async function installOnJournal( ...(deps.statusSink ? { statusSink: deps.statusSink } : {}), ...(deps.hasOpenDispatch ? { hasOpenDispatch: deps.hasOpenDispatch } : {}), ...(deps.onSessionTabHidden ? { onSessionTabHidden: deps.onSessionTabHidden } : {}), + ...(deps.resolveNewChatPermissionMode + ? { newChatPermissionMode: deps.resolveNewChatPermissionMode } + : {}), ...(await modelCatalogHostDeps({ store, agents, From 509e512555883fbe2826692fa7cca05f63941aac Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 00:04:47 -0700 Subject: [PATCH 2/6] fix(native-chat): an older chat keeps the new-chat mode its first start ran with A chat saved before chats had permission modes ran the new-chat default on every start, so it kept following that setting. Its first start now writes the mode it ran into the chat's saved options, through the same write that already carries the chat's other options over a start; from then on it is a chat with its own mode. --- ...-agent-session-acquisition-options.test.ts | 73 +++++++++++++++++++ .../structured-agent-session-acquisition.ts | 3 +- .../structured-agent-session-attach-flow.ts | 2 + ...ured-agent-session-attach-orchestration.ts | 6 +- ...ed-agent-session-launch-permission.test.ts | 30 +++++++- ...uctured-agent-session-launch-permission.ts | 16 ++++ ...ructured-agent-session-provider-started.ts | 4 +- 7 files changed, 130 insertions(+), 4 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts index 495b7c629989..0e4d54889ffe 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts @@ -23,6 +23,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 { structuredAgentSessionStartOptions } from './structured-agent-session-launch-permission' const NOW = 1_800_000_000_000 const SESSION = 'legacy-session' @@ -361,6 +362,78 @@ describe('structured session acquisition options', () => { }) }) + it.each([ + [undefined, 'full-access'], + ['ask', 'ask'] + ] as const)( + 'a resumed chat saved with permission mode %s keeps %s after its start', + async (saved, expected) => { + root = await mkdtemp(join(tmpdir(), 'orca-acquisition-permission-')) + const store = await openTestAgentSessionRecordStore(root) + const created = await performAttach({ + agents: NO_STRUCTURED_AGENTS, + logger: createStructuredAgentSessionLogger(), + store, + adapter: adapter({ origin: 'created' }), + openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), + authority: { + spawnToken: 'spawn-a', + claimKeyId: 'key-1', + handoffOperationId: CREATE_OPERATION, + probe: { outcome: 'reservation-unused' } + }, + callerKey: 'client-1', + optionRevision: () => 0, + params: attachParams(CREATE_OPERATION, null, saved ? { permissionMode: saved } : undefined), + now: () => NOW, + onAttached: () => {} + }) + expect(created).toMatchObject({ ok: true }) + + const resumedStore = await openTestAgentSessionRecordStore(root) + await resumedStore.reconcileOnRestart({ + probe: async () => ({ outcome: 'pid-absent' }), + now: NOW + 1 + }) + const resumed = await performAttach({ + agents: NO_STRUCTURED_AGENTS, + logger: createStructuredAgentSessionLogger(), + store: resumedStore, + adapter: adapter({ + origin: 'resumed', + options: { current: { model: 'gpt-5.6-terra' }, models: [] } + }), + openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), + authority: { + spawnToken: 'spawn-b', + claimKeyId: 'key-1', + handoffOperationId: RESUME_OPERATION, + probe: { outcome: 'reservation-unused' } + }, + callerKey: 'client-1', + optionRevision: () => 0, + startOptions: (record) => + structuredAgentSessionStartOptions( + { newChatPermissionMode: () => 'full-access' }, + record + ), + params: attachParams( + RESUME_OPERATION, + resumedStore.getRecord(SESSION)?.lease.runtimeFence ?? 0 + ), + now: () => NOW + 1, + onAttached: () => {} + }) + + expect(resumed).toMatchObject({ ok: true }) + const reopened = await openTestAgentSessionRecordStore(root) + expect(reopened.getRecord(SESSION)?.options).toEqual({ + permissionMode: expected, + model: 'gpt-5.6-terra' + }) + } + ) + it('clears a rejected Fast restore instead of retaining the prior encoded value', async () => { root = await mkdtemp(join(tmpdir(), 'orca-acquisition-fast-restore-')) const store = await openTestAgentSessionRecordStore(root) 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 a8abf52f02bb..873abb98260f 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 @@ -101,10 +101,11 @@ export async function acquireOwner( providerChildPhase === 'starting' ? undefined : await withAgentSessionCreatePhase('restore_options', input.recordPhase, async () => { + const priorOptions = input.startOptions ? input.startOptions(record) : record.options const read = { sessionId: record.sessionId, fence, - ...(record.options ? { priorOptions: record.options } : {}) + ...(priorOptions ? { priorOptions } : {}) } // Still inside the start, so the limit or a Stop ends a read the provider never answers. return waitForPromiseWithSignal( 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 e4de3016b67c..727b185de3da 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 @@ -80,6 +80,8 @@ export type AttachFlowInput = { launchPermissionMode?: ( record: AgentSessionRecord ) => Promise + /** The saved options this start writes back; absent, the record's own. */ + startOptions?: (record: AgentSessionRecord) => Readonly> | undefined /** 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 2fdc842b335f..711f2106dc7c 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 @@ -32,7 +32,10 @@ import { } from './structured-agent-session-provider-child' import type { DeferredStructuredAgentSessionEventSink } from './structured-agent-session-event-sink' import { noteStructuredAgentSessionProviderStarted } from './structured-agent-session-provider-started' -import { structuredAgentSessionLaunchPermission } from './structured-agent-session-launch-permission' +import { + structuredAgentSessionLaunchPermission, + structuredAgentSessionStartOptions +} from './structured-agent-session-launch-permission' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { isStructuredAgentSessionStartupExpired } from './structured-agent-session-startup-attempt' import type { StructuredAgentSessionStartupAttempt } from './structured-agent-session-startup-attempt-contract' @@ -208,6 +211,7 @@ async function runAttachUnderAbort( optionRevision: () => context.runtimeState.optionRevisions.current(sessionId), launchPermissionMode: async (record) => (await structuredAgentSessionLaunchPermission(context.deps, record)) ?? undefined, + startOptions: (record) => structuredAgentSessionStartOptions(context.deps, record), ...(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-launch-permission.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.test.ts index 4666242add3b..ed6ec9a8c275 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.test.ts @@ -5,7 +5,10 @@ import { describe, expect, it, vi } from 'vitest' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionModelCatalogResult } from '../../../shared/agent-session-wire' import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' -import { structuredAgentSessionLaunchPermission } from './structured-agent-session-launch-permission' +import { + structuredAgentSessionLaunchPermission, + structuredAgentSessionStartOptions +} from './structured-agent-session-launch-permission' function record(provider: string, options: Record | undefined): AgentSessionRecord { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the launch mode reads only the record's id, agent and options. @@ -146,3 +149,28 @@ describe('the permission mode a chat starts with', () => { ) }) }) + +describe('the options a start writes back', () => { + const fullAccess = { newChatPermissionMode: () => 'full-access' } + + it('gives a chat saved before modes the new-chat mode, narrowed to its agent', () => { + expect( + structuredAgentSessionStartOptions(fullAccess, record('claude', { model: 'opus' })) + ).toEqual({ model: 'opus', permissionMode: 'full-access' }) + expect( + structuredAgentSessionStartOptions( + { newChatPermissionMode: () => 'accept-edits' }, + record('codex', undefined) + ) + ).toEqual({ permissionMode: 'ask' }) + }) + + it('keeps a saved mode, and adds none for an agent without modes or a host without the setting', () => { + const saved = record('claude', { permissionMode: 'ask' }) + expect(structuredAgentSessionStartOptions(fullAccess, saved)).toBe(saved.options) + expect( + structuredAgentSessionStartOptions(fullAccess, record('grok', undefined)) + ).toBeUndefined() + expect(structuredAgentSessionStartOptions({}, record('claude', undefined))).toBeUndefined() + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.ts index 2d255140a799..bd1573ddf0b6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.ts @@ -7,6 +7,7 @@ import { chatModelSupportsAutoMode, chatPermissionModeNeedsModelSupport, effectiveChatPermissionMode, + newChatPermissionMode, type AgentChatPermissionMode } from '../../../shared/agent-chat-permission-mode' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' @@ -50,3 +51,18 @@ export async function structuredAgentSessionLaunchPermission( (await listedAutoModeSupport(deps.modelCatalog, record)) return effectiveChatPermissionMode(support, intent, autoSupported) } + +/** The saved options a start writes back. A chat saved before chats had modes gets the new-chat + * mode its first start runs with, so from then on it keeps its own like any other chat. */ +export function structuredAgentSessionStartOptions( + deps: Pick, + record: AgentSessionRecord +): Readonly> | undefined { + if (record.options?.[AGENT_CHAT_PERMISSION_MODE_OPTION_ID] !== undefined) { + return record.options + } + const mode = deps.newChatPermissionMode + ? newChatPermissionMode(record.provider, deps.newChatPermissionMode()) + : null + return mode ? { ...record.options, [AGENT_CHAT_PERMISSION_MODE_OPTION_ID]: mode } : record.options +} 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 553015985e69..793d84a5fcfa 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 @@ -22,6 +22,7 @@ import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import { nativeSessionOptionsFromReport } from './structured-agent-session-option-restoration' +import { structuredAgentSessionStartOptions } from './structured-agent-session-launch-permission' import { markProviderChildStarted, sameProviderChild @@ -167,6 +168,7 @@ function persistReportedOptions( ) { return } + const priorOptions = structuredAgentSessionStartOptions(context.deps, record) try { await context.deps.store.replaceSessionOptions({ sessionId: event.sessionId, @@ -175,7 +177,7 @@ function persistReportedOptions( reported: event.reportedOptions, restoreSkipped: event.restoreSkippedOptions, ...(event.retiredOptions ? { retired: event.retiredOptions } : {}), - ...(record.options ? { priorOptions: record.options } : {}) + ...(priorOptions ? { priorOptions } : {}) }), now: context.now() }) From dbdf865ef778e2a94db2be1d0e67cfb484b4129b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:35:53 -0700 Subject: [PATCH 3/6] fix(native-chat): save older chats' permission mode once at host startup Replace the save-at-first-start path with one startup pass: before the chat host is exposed, every Claude and Codex record with no saved permissionMode gets the host's new-chat default, in one store transaction. Saved values, including invalid ones, and every other option are left alone; a read-only store is skipped and a failure is logged and retried next start. The runtime fallback in launch, acquisition, provider-started and attach is restored to main's behaviour. A client-supplied attach that creates a new record now gets the same new-chat seed as every other create path. --- .../scripts/vitest-sqlite-runtime-files.mjs | 1 + ...-agent-session-acquisition-options.test.ts | 73 ------- .../structured-agent-session-acquisition.ts | 3 +- .../structured-agent-session-attach-flow.ts | 2 - ...ured-agent-session-attach-orchestration.ts | 6 +- .../structured-agent-session-host-types.ts | 2 - ...ed-agent-session-launch-permission.test.ts | 74 +------ ...uctured-agent-session-launch-permission.ts | 22 +- ...ructured-agent-session-provider-started.ts | 4 +- ...agent-session-startup-attempt-host.test.ts | 23 +- .../agent-session-permission-backfill.test.ts | 205 ++++++++++++++++++ .../agent-session-permission-backfill.ts | 29 +++ .../runtime/agent-session-record-store.ts | 4 + .../runtime/orca-runtime-get-worktree-ps.ts | 3 +- ...-structured-launch-settings-wiring.test.ts | 4 + ...structured-agent-session-create-handler.ts | 12 +- ...ed-agent-session-permission-create.test.ts | 70 ++++++ ...ructured-agent-session-rpc.test-fixture.ts | 7 +- .../structured-agent-session-runtime.test.ts | 57 +++++ .../structured-agent-session-runtime.ts | 16 +- 20 files changed, 427 insertions(+), 190 deletions(-) create mode 100644 src/main/runtime/agent-session-permission-backfill.test.ts create mode 100644 src/main/runtime/agent-session-permission-backfill.ts create mode 100644 src/main/runtime/rpc/methods/structured-agent-session-permission-create.test.ts diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index 40a668f58ebf..a0d4c30c8e97 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -207,6 +207,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/runtime/agent-session-record-options.test.ts', 'src/main/runtime/agent-session-record-store-tab-table.test.ts', 'src/main/runtime/agent-session-record-store.test.ts', + 'src/main/runtime/agent-session-permission-backfill.test.ts', 'src/main/runtime/agent-session-released-reservation-replay.test.ts', 'src/main/runtime/agent-session-runtime-attribution.test.ts', 'src/main/runtime/agent-session-store-transactions.test.ts', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts index 0e4d54889ffe..495b7c629989 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts @@ -23,7 +23,6 @@ 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 { structuredAgentSessionStartOptions } from './structured-agent-session-launch-permission' const NOW = 1_800_000_000_000 const SESSION = 'legacy-session' @@ -362,78 +361,6 @@ describe('structured session acquisition options', () => { }) }) - it.each([ - [undefined, 'full-access'], - ['ask', 'ask'] - ] as const)( - 'a resumed chat saved with permission mode %s keeps %s after its start', - async (saved, expected) => { - root = await mkdtemp(join(tmpdir(), 'orca-acquisition-permission-')) - const store = await openTestAgentSessionRecordStore(root) - const created = await performAttach({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: adapter({ origin: 'created' }), - openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), - authority: { - spawnToken: 'spawn-a', - claimKeyId: 'key-1', - handoffOperationId: CREATE_OPERATION, - probe: { outcome: 'reservation-unused' } - }, - callerKey: 'client-1', - optionRevision: () => 0, - params: attachParams(CREATE_OPERATION, null, saved ? { permissionMode: saved } : undefined), - now: () => NOW, - onAttached: () => {} - }) - expect(created).toMatchObject({ ok: true }) - - const resumedStore = await openTestAgentSessionRecordStore(root) - await resumedStore.reconcileOnRestart({ - probe: async () => ({ outcome: 'pid-absent' }), - now: NOW + 1 - }) - const resumed = await performAttach({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store: resumedStore, - adapter: adapter({ - origin: 'resumed', - options: { current: { model: 'gpt-5.6-terra' }, models: [] } - }), - openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), - authority: { - spawnToken: 'spawn-b', - claimKeyId: 'key-1', - handoffOperationId: RESUME_OPERATION, - probe: { outcome: 'reservation-unused' } - }, - callerKey: 'client-1', - optionRevision: () => 0, - startOptions: (record) => - structuredAgentSessionStartOptions( - { newChatPermissionMode: () => 'full-access' }, - record - ), - params: attachParams( - RESUME_OPERATION, - resumedStore.getRecord(SESSION)?.lease.runtimeFence ?? 0 - ), - now: () => NOW + 1, - onAttached: () => {} - }) - - expect(resumed).toMatchObject({ ok: true }) - const reopened = await openTestAgentSessionRecordStore(root) - expect(reopened.getRecord(SESSION)?.options).toEqual({ - permissionMode: expected, - model: 'gpt-5.6-terra' - }) - } - ) - it('clears a rejected Fast restore instead of retaining the prior encoded value', async () => { root = await mkdtemp(join(tmpdir(), 'orca-acquisition-fast-restore-')) const store = await openTestAgentSessionRecordStore(root) 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 873abb98260f..a8abf52f02bb 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 @@ -101,11 +101,10 @@ export async function acquireOwner( providerChildPhase === 'starting' ? undefined : await withAgentSessionCreatePhase('restore_options', input.recordPhase, async () => { - const priorOptions = input.startOptions ? input.startOptions(record) : record.options const read = { sessionId: record.sessionId, fence, - ...(priorOptions ? { priorOptions } : {}) + ...(record.options ? { priorOptions: record.options } : {}) } // Still inside the start, so the limit or a Stop ends a read the provider never answers. return waitForPromiseWithSignal( 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 727b185de3da..e4de3016b67c 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 @@ -80,8 +80,6 @@ export type AttachFlowInput = { launchPermissionMode?: ( record: AgentSessionRecord ) => Promise - /** The saved options this start writes back; absent, the record's own. */ - startOptions?: (record: AgentSessionRecord) => Readonly> | undefined /** 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 711f2106dc7c..2fdc842b335f 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 @@ -32,10 +32,7 @@ import { } from './structured-agent-session-provider-child' import type { DeferredStructuredAgentSessionEventSink } from './structured-agent-session-event-sink' import { noteStructuredAgentSessionProviderStarted } from './structured-agent-session-provider-started' -import { - structuredAgentSessionLaunchPermission, - structuredAgentSessionStartOptions -} from './structured-agent-session-launch-permission' +import { structuredAgentSessionLaunchPermission } from './structured-agent-session-launch-permission' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { isStructuredAgentSessionStartupExpired } from './structured-agent-session-startup-attempt' import type { StructuredAgentSessionStartupAttempt } from './structured-agent-session-startup-attempt-contract' @@ -211,7 +208,6 @@ async function runAttachUnderAbort( optionRevision: () => context.runtimeState.optionRevisions.current(sessionId), launchPermissionMode: async (record) => (await structuredAgentSessionLaunchPermission(context.deps, record)) ?? undefined, - startOptions: (record) => structuredAgentSessionStartOptions(context.deps, record), ...(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-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index b3a339298511..c87dec4421db 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 @@ -169,6 +169,4 @@ export type StructuredAgentSessionHostDeps = { statusSink?: StructuredAgentSessionStatusSink /** Host model catalog surface; absent means every catalog read answers `unknown`. */ modelCatalog?: AgentModelCatalogService - /** The new-chat permission setting, read per call; absent, a chat with no saved mode asks. */ - newChatPermissionMode?: () => unknown } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.test.ts index ed6ec9a8c275..640ea42ed084 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.test.ts @@ -5,10 +5,7 @@ import { describe, expect, it, vi } from 'vitest' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionModelCatalogResult } from '../../../shared/agent-session-wire' import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' -import { - structuredAgentSessionLaunchPermission, - structuredAgentSessionStartOptions -} from './structured-agent-session-launch-permission' +import { structuredAgentSessionLaunchPermission } from './structured-agent-session-launch-permission' function record(provider: string, options: Record | undefined): AgentSessionRecord { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the launch mode reads only the record's id, agent and options. @@ -31,8 +28,7 @@ const listing = ( function launchPermission( provider: string, options: Record | undefined, - answer: AgentSessionModelCatalogResult | Error = { origin: 'unknown' }, - newChatPermissionMode?: () => unknown + answer: AgentSessionModelCatalogResult | Error = { origin: 'unknown' } ) { const read = vi.fn(async () => { if (answer instanceof Error) { @@ -50,11 +46,7 @@ function launchPermission( return { read, result: structuredAgentSessionLaunchPermission( - { - agents: claudeAndCodexDeclared(), - modelCatalog, - ...(newChatPermissionMode ? { newChatPermissionMode } : {}) - }, + { agents: claudeAndCodexDeclared(), modelCatalog }, record(provider, options) ) } @@ -70,43 +62,10 @@ describe('the permission mode a chat starts with', () => { expect(codex.read).not.toHaveBeenCalled() }) - it('asks for a chat saved before modes existed when the host has no new-chat setting', async () => { + it('asks for a chat saved before modes existed', async () => { await expect(launchPermission('claude', undefined).result).resolves.toBe('ask') }) - it('runs the new-chat setting for a chat saved before modes existed, read per call', async () => { - let setting = 'full-access' - const read = (): unknown => setting - await expect(launchPermission('claude', undefined, undefined, read).result).resolves.toBe( - 'full-access' - ) - await expect( - launchPermission('codex', { model: 'gpt-5' }, undefined, read).result - ).resolves.toBe('full-access') - setting = 'ask' - await expect(launchPermission('codex', undefined, undefined, read).result).resolves.toBe('ask') - }) - - it('narrows the new-chat setting to what the agent offers', async () => { - const acceptEdits = (): unknown => 'accept-edits' - await expect( - launchPermission('claude', undefined, undefined, acceptEdits).result - ).resolves.toBe('accept-edits') - await expect(launchPermission('codex', undefined, undefined, acceptEdits).result).resolves.toBe( - 'ask' - ) - // Approve for me still needs a model listed for it, as a saved choice does. - await expect( - launchPermission('claude', undefined, undefined, () => 'auto').result - ).resolves.toBe('ask') - }) - - it('keeps a chat saved choice over the new-chat setting', async () => { - await expect( - launchPermission('claude', { permissionMode: 'ask' }, undefined, () => 'full-access').result - ).resolves.toBe('ask') - }) - it('runs Claude auto on a model the saved catalog lists as supporting it', async () => { const supported = launchPermission( 'claude', @@ -149,28 +108,3 @@ describe('the permission mode a chat starts with', () => { ) }) }) - -describe('the options a start writes back', () => { - const fullAccess = { newChatPermissionMode: () => 'full-access' } - - it('gives a chat saved before modes the new-chat mode, narrowed to its agent', () => { - expect( - structuredAgentSessionStartOptions(fullAccess, record('claude', { model: 'opus' })) - ).toEqual({ model: 'opus', permissionMode: 'full-access' }) - expect( - structuredAgentSessionStartOptions( - { newChatPermissionMode: () => 'accept-edits' }, - record('codex', undefined) - ) - ).toEqual({ permissionMode: 'ask' }) - }) - - it('keeps a saved mode, and adds none for an agent without modes or a host without the setting', () => { - const saved = record('claude', { permissionMode: 'ask' }) - expect(structuredAgentSessionStartOptions(fullAccess, saved)).toBe(saved.options) - expect( - structuredAgentSessionStartOptions(fullAccess, record('grok', undefined)) - ).toBeUndefined() - expect(structuredAgentSessionStartOptions({}, record('claude', undefined))).toBeUndefined() - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.ts index bd1573ddf0b6..3e9ac14a40bd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.ts @@ -7,7 +7,6 @@ import { chatModelSupportsAutoMode, chatPermissionModeNeedsModelSupport, effectiveChatPermissionMode, - newChatPermissionMode, type AgentChatPermissionMode } from '../../../shared/agent-chat-permission-mode' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' @@ -36,33 +35,16 @@ async function listedAutoModeSupport( /** Null for an agent whose chats offer no modes, or one this runtime did not register. */ export async function structuredAgentSessionLaunchPermission( - deps: Pick, + deps: Pick, record: AgentSessionRecord ): Promise { const support = agentChatPermissionModes(record.provider) if (!support || !deps.agents.definition(record.provider)) { return null } - // A chat saved before chats had modes runs the new-chat default, not Ask. - const intent = - record.options?.[AGENT_CHAT_PERMISSION_MODE_OPTION_ID] ?? deps.newChatPermissionMode?.() + const intent = record.options?.[AGENT_CHAT_PERMISSION_MODE_OPTION_ID] const autoSupported = chatPermissionModeNeedsModelSupport(support, intent) && (await listedAutoModeSupport(deps.modelCatalog, record)) return effectiveChatPermissionMode(support, intent, autoSupported) } - -/** The saved options a start writes back. A chat saved before chats had modes gets the new-chat - * mode its first start runs with, so from then on it keeps its own like any other chat. */ -export function structuredAgentSessionStartOptions( - deps: Pick, - record: AgentSessionRecord -): Readonly> | undefined { - if (record.options?.[AGENT_CHAT_PERMISSION_MODE_OPTION_ID] !== undefined) { - return record.options - } - const mode = deps.newChatPermissionMode - ? newChatPermissionMode(record.provider, deps.newChatPermissionMode()) - : null - return mode ? { ...record.options, [AGENT_CHAT_PERMISSION_MODE_OPTION_ID]: mode } : record.options -} 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 793d84a5fcfa..553015985e69 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 @@ -22,7 +22,6 @@ import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import { nativeSessionOptionsFromReport } from './structured-agent-session-option-restoration' -import { structuredAgentSessionStartOptions } from './structured-agent-session-launch-permission' import { markProviderChildStarted, sameProviderChild @@ -168,7 +167,6 @@ function persistReportedOptions( ) { return } - const priorOptions = structuredAgentSessionStartOptions(context.deps, record) try { await context.deps.store.replaceSessionOptions({ sessionId: event.sessionId, @@ -177,7 +175,7 @@ function persistReportedOptions( reported: event.reportedOptions, restoreSkipped: event.restoreSkippedOptions, ...(event.retiredOptions ? { retired: event.retiredOptions } : {}), - ...(priorOptions ? { priorOptions } : {}) + ...(record.options ? { priorOptions: record.options } : {}) }), now: context.now() }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts index 02bed410f400..ce57a9030547 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts @@ -91,7 +91,7 @@ async function startHost(): Promise { ...(readAcquisitionOptions ? { readAcquisitionOptions } : {}) }, modelCatalog: { - read: vi.fn(), + read: vi.fn(async () => ({ origin: 'unknown' as const })), recordLiveListing: vi.fn(), prewarm: vi.fn(async () => {}), stop: vi.fn(), @@ -637,3 +637,24 @@ describe('the attempt an acquire runs under', () => { ) }) }) + +describe('a chat after startup permission backfill', () => { + it.each(['ask', 'full-access'] as const)( + 'keeps %s before and after the first start, regardless of later defaults', + async (mode) => { + await host.close(SESSION, 'evict') + await store.backfillChatPermissionModes(mode) + expect((await host.readOptions(SESSION)).current.permissionMode).toBe(mode) + await store.backfillChatPermissionModes(mode === 'ask' ? 'full-access' : 'ask') + await restartWith(publishFirst) + await heldBehindStart('hello') + expect(acquire.mock.calls.at(-1)![0].permissionMode).toBe(mode) + await host.handleAdapterEvent(startedEvent()) + await vi.advanceTimersByTimeAsync(50) + await host.flushStreamedEvents(SESSION) + expect(store.getRecord(SESSION)?.options?.permissionMode).toBe(mode) + await store.backfillChatPermissionModes(mode === 'ask' ? 'full-access' : 'ask') + expect(store.getRecord(SESSION)?.options?.permissionMode).toBe(mode) + } + ) +}) diff --git a/src/main/runtime/agent-session-permission-backfill.test.ts b/src/main/runtime/agent-session-permission-backfill.test.ts new file mode 100644 index 000000000000..15b78c6abfd2 --- /dev/null +++ b/src/main/runtime/agent-session-permission-backfill.test.ts @@ -0,0 +1,205 @@ +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 { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' +import { foundAgentSessionRecord } from './agent-session-record-founding' +import { + openTestAgentSessionRecordStore, + seedTestAgentSessionRecordStore, + seedTestAgentSessionStoreFromNewerBuild +} from './agent-session-record-store-test-harness' +import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' + +let root: string +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-permission-backfill-')) +}) +afterEach(async () => { + await rm(root, { recursive: true, force: true }) +}) + +function record(provider: string, options?: Record) { + return foundAgentSessionRecord( + { + sessionId: `session-${provider}`, + provider, + location: { + executionHostId: 'local', + workspaceId: 'folder-1', + workspaceKind: 'folder', + wslDistro: null + }, + accountHome: { + variable: provider === 'claude' ? 'CLAUDE_CONFIG_DIR' : 'CODEX_HOME', + path: '/account' + }, + ...(options ? { options } : {}) + }, + { claimKeyId: 'key-1', now: 1 } + ) +} + +describe('existing chat permission backfill', () => { + it.each(['full-access', 'ask'] as const)( + 'saves host default %s for old Claude and Codex rows, keeping other options', + async (mode) => { + await seedTestAgentSessionRecordStore(root, { + records: [record('claude', { model: 'opus', effort: 'high' }), record('codex')] + }) + const store = await openTestAgentSessionRecordStore(root) + const beforeClaude = store.getRecord('session-claude') + const beforeCodex = store.getRecord('session-codex') + await store.backfillChatPermissionModes(mode) + expect(store.getRecord('session-claude')?.options).toEqual({ + model: 'opus', + effort: 'high', + permissionMode: mode + }) + expect(store.getRecord('session-codex')?.options).toEqual({ permissionMode: mode }) + expect(store.getRecord('session-claude')).toEqual({ + ...beforeClaude, + options: { model: 'opus', effort: 'high', permissionMode: mode } + }) + expect(store.getRecord('session-codex')).toEqual({ + ...beforeCodex, + options: { permissionMode: mode } + }) + const reopened = await openTestAgentSessionRecordStore(root) + expect(reopened.getRecord('session-claude')?.options).toEqual( + store.getRecord('session-claude')?.options + ) + expect(reopened.getRecord('session-codex')?.options).toEqual( + store.getRecord('session-codex')?.options + ) + } + ) + + it.each(['ask', 'full-access', 'invalid-saved-mode'] as const)( + 'never replaces saved %s or options on other providers', + async (mode) => { + await seedTestAgentSessionRecordStore(root, { + records: [ + record('claude', { permissionMode: mode }), + record('codex', { permissionMode: mode }), + record('grok', { model: 'grok' }) + ] + }) + const store = await openTestAgentSessionRecordStore(root) + const before = store.listRecords() + await store.backfillChatPermissionModes(mode === 'full-access' ? 'ask' : 'full-access') + expect(store.listRecords()).toEqual(before) + expect(store.getRecord('session-grok')?.options).toEqual({ model: 'grok' }) + } + ) + + it('does no row write on its second pass, even with a changed default', async () => { + await seedTestAgentSessionRecordStore(root, { records: [record('claude'), record('codex')] }) + const store = await openTestAgentSessionRecordStore(root) + await store.backfillChatPermissionModes('full-access') + const before = store.listRecords() + const db = openTestJournalHostDatabase(root).db + db.exec( + "CREATE TRIGGER refuse_backfill_write BEFORE UPDATE ON agent_session_records BEGIN SELECT RAISE(ABORT, 'unexpected second write'); END" + ) + await expect(store.backfillChatPermissionModes('ask')).resolves.toBeUndefined() + expect(store.listRecords()).toEqual(before) + expect(store.getRecord('session-claude')).toBe(before.find((row) => row.provider === 'claude')) + expect(store.getRecord('session-codex')?.options?.permissionMode).toBe('full-access') + }) + + it('skips a store written by a newer build without an error or in-memory change', async () => { + await seedTestAgentSessionRecordStore(root, { records: [record('claude')] }) + await seedTestAgentSessionStoreFromNewerBuild(root) + const store = await openTestAgentSessionRecordStore(root) + expect(store.readOnly).toBe(true) + expect(store.getRecord('session-claude')).not.toBeNull() + await expect(store.backfillChatPermissionModes('full-access')).resolves.toBeUndefined() + expect(store.getRecord('session-claude')?.options).toBeUndefined() + }) + + it.each([ + ['auto', 'auto', 'auto'], + ['accept-edits', 'accept-edits', 'ask'] + ] as const)( + 'normalizes %s to provider support while retaining intent (%s, %s)', + async (mode, claude, codex) => { + await seedTestAgentSessionRecordStore(root, { + records: [record('claude', { model: 'unsupported' }), record('codex')] + }) + const store = await openTestAgentSessionRecordStore(root) + await store.backfillChatPermissionModes(mode) + expect(store.getRecord('session-claude')?.options).toEqual({ + model: 'unsupported', + permissionMode: claude + }) + expect(store.getRecord('session-codex')?.options).toEqual({ permissionMode: codex }) + } + ) + + it('preserves the existing owner identity, fence, timestamps and provider history', async () => { + const old = record('codex', { model: 'old-model', effort: 'high' }) + await seedTestAgentSessionRecordStore(root, { + records: [ + { + ...old, + lease: { + ...old.lease, + runtimeFence: 7, + claimStatus: 'reserved', + handoffStage: 'new-owner-proving', + reservedSpawnToken: 'old-spawn', + ownerProcess: { + hostId: 'local', + pid: 42, + processStartTimeMs: 1, + spawnToken: 'old-spawn' + } + }, + providerHandleChain: [ + { + linkId: 'old-link', + handle: codexProviderHandle('old-thread'), + origin: 'created', + mintedAtFence: 6, + observedAt: 1 + } + ] + } + ] + }) + const store = await openTestAgentSessionRecordStore(root) + const before = store.getRecord('session-codex') + expect(before).not.toBeNull() + await store.backfillChatPermissionModes('full-access') + expect(store.getRecord('session-codex')).toEqual({ + ...before, + options: { + model: 'old-model', + effort: 'high', + permissionMode: 'full-access' + } + }) + }) + + it('rolls back every row if saving fails, then succeeds on the next startup pass', async () => { + await seedTestAgentSessionRecordStore(root, { records: [record('claude'), record('codex')] }) + const store = await openTestAgentSessionRecordStore(root) + const db = openTestJournalHostDatabase(root).db + db.exec( + "CREATE TRIGGER refuse_backfill_write BEFORE UPDATE ON agent_session_records WHEN NEW.session_id = 'session-codex' BEGIN SELECT RAISE(ABORT, 'disk full'); END" + ) + await expect(store.backfillChatPermissionModes('full-access')).rejects.toThrow('disk full') + expect(store.listRecords().every((row) => row.options === undefined)).toBe(true) + expect( + (await openTestAgentSessionRecordStore(root)) + .listRecords() + .every((row) => row.options === undefined) + ).toBe(true) + db.exec('DROP TRIGGER refuse_backfill_write') + await store.backfillChatPermissionModes('full-access') + expect(store.listRecords().every((row) => row.options?.permissionMode === 'full-access')).toBe( + true + ) + }) +}) diff --git a/src/main/runtime/agent-session-permission-backfill.ts b/src/main/runtime/agent-session-permission-backfill.ts new file mode 100644 index 000000000000..1d80160826dc --- /dev/null +++ b/src/main/runtime/agent-session-permission-backfill.ts @@ -0,0 +1,29 @@ +import { + AGENT_CHAT_PERMISSION_MODE_OPTION_ID, + newChatPermissionMode +} from '../../shared/agent-chat-permission-mode' +import type { AgentSessionStoreTransactions } from './agent-session-store-transactions' + +/** One startup transaction; saved choices and other provider options stay authoritative. */ +export function backfillAgentSessionPermissionModes( + store: AgentSessionStoreTransactions, + defaultMode: unknown +): Promise { + if (store.readOnly) { + return Promise.resolve() + } + return store.transact((draft) => { + for (const [sessionId, record] of draft.records) { + if (record.options?.[AGENT_CHAT_PERMISSION_MODE_OPTION_ID] !== undefined) { + continue + } + const permissionMode = newChatPermissionMode(record.provider, defaultMode) + if (permissionMode) { + draft.records.set(sessionId, { + ...record, + options: { ...record.options, [AGENT_CHAT_PERMISSION_MODE_OPTION_ID]: permissionMode } + }) + } + } + }) +} diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts index 5601d2a2e00c..632b62f60ed2 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -1,6 +1,7 @@ /** Durable single-writer session records and their operation ledger, as rows in the host's chat * journal database. */ +import { backfillAgentSessionPermissionModes } from './agent-session-permission-backfill' import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' import { createAgentSessionConversationReceipts } from './agent-session-conversation-receipts' import { commitConversationCommandRecord } from './agent-session-conversation-command-record' @@ -115,6 +116,9 @@ export class AgentSessionRecordStore { return this.transactions.readOnly } + backfillChatPermissionModes = (mode: unknown): Promise => + backfillAgentSessionPermissionModes(this.transactions, mode) + getRecord = (sessionId: string): AgentSessionRecord | null => this.state.records.get(sessionId) ?? null diff --git a/src/main/runtime/orca-runtime-get-worktree-ps.ts b/src/main/runtime/orca-runtime-get-worktree-ps.ts index 6e546e7ddd54..c63e4606837e 100644 --- a/src/main/runtime/orca-runtime-get-worktree-ps.ts +++ b/src/main/runtime/orca-runtime-get-worktree-ps.ts @@ -236,8 +236,7 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStartTuiIdleVis ? resolveTuiAgentLaunchEnv(agent, this.requireStore().getSettings().agentDefaultEnv) : {}, resolveAgentCommandSettings: () => this.requireStore().getSettings(), - resolveNewChatPermissionMode: () => - this.requireStore().getSettings().nativeChatPermissionMode, + initialChatPermissionMode: this.requireStore().getSettings().nativeChatPermissionMode, resolveCodexAccountKind: (home) => resolveStructuredCodexAccountKind(home, this.requireStore().getSettings()), resolveAgentAccountHome: (agent) => this.resolveStructuredAgentAccountHome(agent), 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 dd81e303b41d..0188841fb8e8 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 @@ -23,6 +23,7 @@ describe('execution host structured launch settings wiring', () => { installed.mockClear() const settings: Partial = { agentCmdOverrides: { claude: `"${process.execPath}"`, codex: `"${process.execPath}"` }, + nativeChatPermissionMode: 'full-access', agentDefaultArgs: { claude: '--model "model one"', codex: '-c model_reasoning_effort=high' } } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the mocked installer only reads the store's getSettings method. @@ -30,6 +31,9 @@ describe('execution host structured launch settings wiring', () => { await runtime.ensureStructuredAgentSessionHost() const deps = installed.mock.calls[0]?.[0] expect(deps).toBeDefined() + expect(deps?.initialChatPermissionMode).toBe('full-access') + settings.nativeChatPermissionMode = 'ask' + expect(deps?.initialChatPermissionMode).toBe('full-access') expect(deps?.resolveClaudeCommand?.()).toBe(process.execPath) expect(deps?.resolveCodexCommand?.()).toBe(process.execPath) expect(await deps?.resolveLaunchArgs?.('claude')).toEqual(['--model', 'model one']) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-create-handler.ts b/src/main/runtime/rpc/methods/structured-agent-session-create-handler.ts index a917eff7eef4..9532ab4cca00 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-create-handler.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-create-handler.ts @@ -1,3 +1,4 @@ +import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID } from '../../../../shared/agent-chat-permission-mode' import type { z } from 'zod' import { agentSessionRefusalError } from '../../../../shared/agent-session-wire-refusals' import { agentSessionFingerprintConflict } from '../../../../shared/agent-session-mutation-envelope' @@ -32,7 +33,16 @@ async function resolveClientSuppliedAttach(params: z.infer, reason: 'hostUnsupported' }) } - const attachParams: AgentSessionAttachParams = params + const seed = host.deps.store.getRecord(params.envelope.sessionId) + ? undefined + : ctx.runtime.structuredAgentSessionLaunchSeedOptions(params.agent) + const permissionMode = seed?.[AGENT_CHAT_PERMISSION_MODE_OPTION_ID] + const attachParams: AgentSessionAttachParams = { + ...params, + ...(permissionMode + ? { options: { [AGENT_CHAT_PERMISSION_MODE_OPTION_ID]: permissionMode } } + : {}) + } return { host, attachParams } } diff --git a/src/main/runtime/rpc/methods/structured-agent-session-permission-create.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-permission-create.test.ts new file mode 100644 index 000000000000..2999667f244f --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-permission-create.test.ts @@ -0,0 +1,70 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' +import { HOST_TEST_LOCATION } from '../../../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { foundAgentSessionRecord } from '../../agent-session-record-founding' +import { + attachParams, + call, + clearStructuredHostStub, + hostCalls, + installStructuredHostStub, + STRUCTURED_CLIENT +} from './structured-agent-session-rpc.test-fixture' + +beforeEach(() => { + installStructuredHostStub() +}) +afterEach(() => { + clearStructuredHostStub() +}) + +describe('legacy attach-shaped permission creation', () => { + it.each(['agentSession.create', 'agentSession.ensure'] as const)( + '%s gives a new record only the host permission seed', + async (method) => { + const seed = vi.fn(() => ({ + permissionMode: 'full-access', + model: 'host-model', + effort: 'high' + })) + const params = attachParams() + expect( + await call(method, params, STRUCTURED_CLIENT, { + structuredAgentSessionLaunchSeedOptions: seed + }) + ).toMatchObject({ ok: true }) + expect(hostCalls.attach).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + options: { permissionMode: 'full-access' }, + envelope: params.envelope + }) + ) + } + ) + + it('does not derive a new seed for an existing record', async () => { + const host = getStructuredAgentSessionHost()! + const params = attachParams() + vi.mocked(host.deps.store.getRecord).mockReturnValue( + foundAgentSessionRecord( + { + sessionId: params.envelope.sessionId, + provider: params.provider, + location: HOST_TEST_LOCATION, + accountHome: params.accountHome, + options: { permissionMode: 'ask' } + }, + { claimKeyId: 'key-1', now: 1 } + ) + ) + const seed = vi.fn(() => ({ permissionMode: 'full-access' })) + expect( + await call('agentSession.ensure', params, STRUCTURED_CLIENT, { + structuredAgentSessionLaunchSeedOptions: seed + }) + ).toMatchObject({ ok: true }) + expect(seed).not.toHaveBeenCalled() + expect(hostCalls.attach).toHaveBeenCalledWith(expect.anything(), params) + }) +}) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts index dfda59b17b3f..978f44a4d154 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts @@ -213,7 +213,8 @@ export function hostStub(): StructuredAgentSessionHost { }) // Not a call: the logger the host hands a runtime caller that reports for it. const logger = recordingStructuredAgentSessionLogger().logger - Reflect.set(hostCalls, 'deps', { logger, store: { getOperationRow: vi.fn(() => null) } }) + const store = { getOperationRow: vi.fn(() => null), getRecord: vi.fn(() => null) } + Reflect.set(hostCalls, 'deps', { logger, store }) return hostCalls as unknown as StructuredAgentSessionHost } @@ -324,6 +325,4 @@ export function installStructuredHostStub(): void { setStructuredAgentSessionHost(hostStub()) } -export function clearStructuredHostStub(): void { - setStructuredAgentSessionHost(null) -} +export const clearStructuredHostStub = (): void => setStructuredAgentSessionHost(null) diff --git a/src/main/runtime/structured-agent-session-runtime.test.ts b/src/main/runtime/structured-agent-session-runtime.test.ts index 947b73dfdd0c..0d3867bb0063 100644 --- a/src/main/runtime/structured-agent-session-runtime.test.ts +++ b/src/main/runtime/structured-agent-session-runtime.test.ts @@ -23,6 +23,10 @@ import { hasPersistedStructuredAgentSessionStore, stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' +import { seedTestAgentSessionRecordStore } from './agent-session-record-store-test-harness' +import { foundAgentSessionRecord } from './agent-session-record-founding' +import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' +import { recordingStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' const HOST_ID = 'local' @@ -220,6 +224,59 @@ describe('structured agent-session runtime install', () => { vi.restoreAllMocks() }) + it.each([false, true])( + 'backfills before the host is readable and keeps startup nonfatal on a failed write (%s)', + async (failWrite) => { + stateDirectory = await mkdtemp(join(tmpdir(), 'orca-structured-runtime-backfill-')) + await seedTestAgentSessionRecordStore(stateDirectory, { + records: [ + foundAgentSessionRecord( + { + sessionId: 'old-chat', + provider: 'codex', + location: { + executionHostId: HOST_ID, + workspaceId: 'folder-1', + workspaceKind: 'folder', + wslDistro: null + }, + accountHome: { variable: 'CODEX_HOME', path: '/account' } + }, + { claimKeyId: 'key-1', now: 1 } + ) + ] + }) + if (failWrite) { + openTestJournalHostDatabase(stateDirectory).db.exec( + "CREATE TRIGGER fail_permission_backfill BEFORE UPDATE ON agent_session_records BEGIN SELECT RAISE(ABORT, 'disk full'); END" + ) + } + const logged = recordingStructuredAgentSessionLogger() + const host = await ensureStructuredAgentSessionHost({ + logger: logged.logger, + stateDirectory, + hostId: HOST_ID, + claimKeyId: 'key-1', + initialChatPermissionMode: 'full-access', + resolveWorkspacePath: async () => stateDirectory!, + resolveLaunchArgs: () => [], + resolveClaudeAuthPolicy: () => ({ account: 'managed' }), + resolveEnvironment: async () => ({}) + }) + expect(host.deps.store.getRecord('old-chat')?.options?.permissionMode).toBe( + failWrite ? undefined : 'full-access' + ) + expect((await host.readOptions('old-chat')).current.permissionMode).toBe( + failWrite ? 'ask' : 'full-access' + ) + if (failWrite) { + expect(logged.scopes()).toContain('permission-mode-backfill') + } else { + expect(logged.scopes()).not.toContain('permission-mode-backfill') + } + } + ) + it('holds stop until the model catalog has written its coalesced save', async () => { stateDirectory = await mkdtemp(join(tmpdir(), 'orca-structured-runtime-')) await ensureStructuredAgentSessionHost({ diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index f52d03d715d3..68d8a1cf3f66 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -127,8 +127,8 @@ export type StructuredAgentSessionRuntimeDeps = { resolveAgentLaunchEnv?: (agent: string) => Record /** The settings a per-agent Command override is read from, for the same agents. */ resolveAgentCommandSettings?: () => StructuredAgentCommandSettings - /** Settings → Chat → Permissions, which a chat saved without a mode runs. */ - resolveNewChatPermissionMode?: () => unknown + /** Host default captured at startup to backfill chats saved before permission modes. */ + initialChatPermissionMode?: unknown resolveEnvironment?: () => Promise /** Which login-shell variables Codex and Claude children inherit; absent inherits all. */ resolveShellEnvironmentPolicy?: () => NativeChatShellEnvironmentPolicy @@ -265,6 +265,15 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise Date: Tue, 13 Oct 2026 02:06:12 -0700 Subject: [PATCH 4/6] fix(native-chat): fill each older chat from its own agent's arguments An older Claude or Codex chat ran with that agent's own Arguments before permission modes existed, so the startup pass now saves full access or ask per agent instead of the combined new-chat default. A user with Claude on bypass and Codex on Manual keeps Claude chats on full access. The attach create path always passes its permission seed; the reservation already applies options only when it creates the record. --- ...agent-session-startup-attempt-host.test.ts | 8 +- .../agent-session-permission-backfill.test.ts | 42 ++++----- .../agent-session-permission-backfill.ts | 15 +++- .../agent-session-record-store.test.ts | 15 ++++ .../runtime/agent-session-record-store.ts | 4 +- .../runtime/orca-runtime-get-worktree-ps.ts | 1 - ...-structured-launch-settings-wiring.test.ts | 7 +- ...structured-agent-session-create-handler.ts | 8 +- ...ed-agent-session-permission-create.test.ts | 28 ------ ...ructured-agent-session-rpc.test-fixture.ts | 7 +- .../structured-agent-session-runtime.test.ts | 88 ++++++++++++------- .../structured-agent-session-runtime.ts | 11 +-- 12 files changed, 128 insertions(+), 106 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts index ce57a9030547..631ff303cbd6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts @@ -640,12 +640,12 @@ describe('the attempt an acquire runs under', () => { describe('a chat after startup permission backfill', () => { it.each(['ask', 'full-access'] as const)( - 'keeps %s before and after the first start, regardless of later defaults', + 'keeps %s before and after the first start, regardless of later Arguments', async (mode) => { await host.close(SESSION, 'evict') - await store.backfillChatPermissionModes(mode) + await store.backfillChatPermissionModes(() => mode === 'full-access') expect((await host.readOptions(SESSION)).current.permissionMode).toBe(mode) - await store.backfillChatPermissionModes(mode === 'ask' ? 'full-access' : 'ask') + await store.backfillChatPermissionModes(() => mode === 'ask') await restartWith(publishFirst) await heldBehindStart('hello') expect(acquire.mock.calls.at(-1)![0].permissionMode).toBe(mode) @@ -653,7 +653,7 @@ describe('a chat after startup permission backfill', () => { await vi.advanceTimersByTimeAsync(50) await host.flushStreamedEvents(SESSION) expect(store.getRecord(SESSION)?.options?.permissionMode).toBe(mode) - await store.backfillChatPermissionModes(mode === 'ask' ? 'full-access' : 'ask') + await store.backfillChatPermissionModes(() => mode === 'ask') expect(store.getRecord(SESSION)?.options?.permissionMode).toBe(mode) } ) diff --git a/src/main/runtime/agent-session-permission-backfill.test.ts b/src/main/runtime/agent-session-permission-backfill.test.ts index 15b78c6abfd2..5fef13b0ae8b 100644 --- a/src/main/runtime/agent-session-permission-backfill.test.ts +++ b/src/main/runtime/agent-session-permission-backfill.test.ts @@ -1,7 +1,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' import { foundAgentSessionRecord } from './agent-session-record-founding' import { @@ -42,7 +42,7 @@ function record(provider: string, options?: Record) { describe('existing chat permission backfill', () => { it.each(['full-access', 'ask'] as const)( - 'saves host default %s for old Claude and Codex rows, keeping other options', + "saves %s from the agent's own Arguments for old Claude and Codex rows, keeping other options", async (mode) => { await seedTestAgentSessionRecordStore(root, { records: [record('claude', { model: 'opus', effort: 'high' }), record('codex')] @@ -50,7 +50,7 @@ describe('existing chat permission backfill', () => { const store = await openTestAgentSessionRecordStore(root) const beforeClaude = store.getRecord('session-claude') const beforeCodex = store.getRecord('session-codex') - await store.backfillChatPermissionModes(mode) + await store.backfillChatPermissionModes(() => mode === 'full-access') expect(store.getRecord('session-claude')?.options).toEqual({ model: 'opus', effort: 'high', @@ -87,22 +87,22 @@ describe('existing chat permission backfill', () => { }) const store = await openTestAgentSessionRecordStore(root) const before = store.listRecords() - await store.backfillChatPermissionModes(mode === 'full-access' ? 'ask' : 'full-access') + await store.backfillChatPermissionModes(() => mode !== 'full-access') expect(store.listRecords()).toEqual(before) expect(store.getRecord('session-grok')?.options).toEqual({ model: 'grok' }) } ) - it('does no row write on its second pass, even with a changed default', async () => { + it('does no row write on its second pass, even with changed Arguments', async () => { await seedTestAgentSessionRecordStore(root, { records: [record('claude'), record('codex')] }) const store = await openTestAgentSessionRecordStore(root) - await store.backfillChatPermissionModes('full-access') + await store.backfillChatPermissionModes(() => true) const before = store.listRecords() const db = openTestJournalHostDatabase(root).db db.exec( "CREATE TRIGGER refuse_backfill_write BEFORE UPDATE ON agent_session_records BEGIN SELECT RAISE(ABORT, 'unexpected second write'); END" ) - await expect(store.backfillChatPermissionModes('ask')).resolves.toBeUndefined() + await expect(store.backfillChatPermissionModes(() => false)).resolves.toBeUndefined() expect(store.listRecords()).toEqual(before) expect(store.getRecord('session-claude')).toBe(before.find((row) => row.provider === 'claude')) expect(store.getRecord('session-codex')?.options?.permissionMode).toBe('full-access') @@ -114,26 +114,26 @@ describe('existing chat permission backfill', () => { const store = await openTestAgentSessionRecordStore(root) expect(store.readOnly).toBe(true) expect(store.getRecord('session-claude')).not.toBeNull() - await expect(store.backfillChatPermissionModes('full-access')).resolves.toBeUndefined() + await expect(store.backfillChatPermissionModes(() => true)).resolves.toBeUndefined() expect(store.getRecord('session-claude')?.options).toBeUndefined() }) it.each([ - ['auto', 'auto', 'auto'], - ['accept-edits', 'accept-edits', 'ask'] + ['claude', 'full-access', 'ask'], + ['codex', 'ask', 'full-access'] ] as const)( - 'normalizes %s to provider support while retaining intent (%s, %s)', - async (mode, claude, codex) => { + "fills each row from its own agent's Arguments when only %s skips prompts", + async (bypassing, claude, codex) => { await seedTestAgentSessionRecordStore(root, { - records: [record('claude', { model: 'unsupported' }), record('codex')] + records: [record('claude'), record('codex'), record('grok')] }) const store = await openTestAgentSessionRecordStore(root) - await store.backfillChatPermissionModes(mode) - expect(store.getRecord('session-claude')?.options).toEqual({ - model: 'unsupported', - permissionMode: claude - }) + const ranWithFullAccess = vi.fn((provider: string) => provider === bypassing) + await store.backfillChatPermissionModes(ranWithFullAccess) + expect(store.getRecord('session-claude')?.options).toEqual({ permissionMode: claude }) expect(store.getRecord('session-codex')?.options).toEqual({ permissionMode: codex }) + expect(store.getRecord('session-grok')?.options).toBeUndefined() + expect(ranWithFullAccess).toHaveBeenCalledTimes(3) } ) @@ -171,7 +171,7 @@ describe('existing chat permission backfill', () => { const store = await openTestAgentSessionRecordStore(root) const before = store.getRecord('session-codex') expect(before).not.toBeNull() - await store.backfillChatPermissionModes('full-access') + await store.backfillChatPermissionModes(() => true) expect(store.getRecord('session-codex')).toEqual({ ...before, options: { @@ -189,7 +189,7 @@ describe('existing chat permission backfill', () => { db.exec( "CREATE TRIGGER refuse_backfill_write BEFORE UPDATE ON agent_session_records WHEN NEW.session_id = 'session-codex' BEGIN SELECT RAISE(ABORT, 'disk full'); END" ) - await expect(store.backfillChatPermissionModes('full-access')).rejects.toThrow('disk full') + await expect(store.backfillChatPermissionModes(() => true)).rejects.toThrow('disk full') expect(store.listRecords().every((row) => row.options === undefined)).toBe(true) expect( (await openTestAgentSessionRecordStore(root)) @@ -197,7 +197,7 @@ describe('existing chat permission backfill', () => { .every((row) => row.options === undefined) ).toBe(true) db.exec('DROP TRIGGER refuse_backfill_write') - await store.backfillChatPermissionModes('full-access') + await store.backfillChatPermissionModes(() => true) expect(store.listRecords().every((row) => row.options?.permissionMode === 'full-access')).toBe( true ) diff --git a/src/main/runtime/agent-session-permission-backfill.ts b/src/main/runtime/agent-session-permission-backfill.ts index 1d80160826dc..80acd46da20d 100644 --- a/src/main/runtime/agent-session-permission-backfill.ts +++ b/src/main/runtime/agent-session-permission-backfill.ts @@ -1,23 +1,32 @@ import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID, + type AgentChatPermissionMode, newChatPermissionMode } from '../../shared/agent-chat-permission-mode' import type { AgentSessionStoreTransactions } from './agent-session-store-transactions' -/** One startup transaction; saved choices and other provider options stay authoritative. */ +/** One transaction as the chat host installs; before modes, chats ran their own agent's Arguments. */ export function backfillAgentSessionPermissionModes( store: AgentSessionStoreTransactions, - defaultMode: unknown + ranWithFullAccess: (provider: string) => boolean ): Promise { if (store.readOnly) { return Promise.resolve() } + const modes = new Map() + const modeFor = (provider: string): AgentChatPermissionMode | null => { + if (!modes.has(provider)) { + const ran = ranWithFullAccess(provider) ? 'full-access' : 'ask' + modes.set(provider, newChatPermissionMode(provider, ran)) + } + return modes.get(provider) ?? null + } return store.transact((draft) => { for (const [sessionId, record] of draft.records) { if (record.options?.[AGENT_CHAT_PERMISSION_MODE_OPTION_ID] !== undefined) { continue } - const permissionMode = newChatPermissionMode(record.provider, defaultMode) + const permissionMode = modeFor(record.provider) if (permissionMode) { draft.records.set(sessionId, { ...record, diff --git a/src/main/runtime/agent-session-record-store.test.ts b/src/main/runtime/agent-session-record-store.test.ts index cc0a83cc55f2..b3f19910cb4d 100644 --- a/src/main/runtime/agent-session-record-store.test.ts +++ b/src/main/runtime/agent-session-record-store.test.ts @@ -286,6 +286,21 @@ describe('acquisition path', () => { ) ).rejects.toThrow('agent_session_conflict') }) + + it("takes request options only for a record it creates, so an existing chat's are kept", async () => { + const store = await open() + await establishOwner(store, { options: { permissionMode: 'ask' } }) + await store.reserveOwner( + reserveRequest({ + options: { permissionMode: 'full-access' }, + expectedFence: 1, + probe: { outcome: 'pid-absent' }, + operation: { callerKey: 'client-1', operationId: operationId(), fingerprint: 'fp-2' } + }) + ) + expect(store.getRecord('session-alpha')?.lease.runtimeFence).toBe(2) + expect(store.getRecord('session-alpha')?.options).toEqual({ permissionMode: 'ask' }) + }) }) describe('concurrent claims', () => { diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts index 632b62f60ed2..02a1ab2f63a4 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -116,8 +116,8 @@ export class AgentSessionRecordStore { return this.transactions.readOnly } - backfillChatPermissionModes = (mode: unknown): Promise => - backfillAgentSessionPermissionModes(this.transactions, mode) + backfillChatPermissionModes = (ranWithFullAccess: (provider: string) => boolean): Promise => + backfillAgentSessionPermissionModes(this.transactions, ranWithFullAccess) getRecord = (sessionId: string): AgentSessionRecord | null => this.state.records.get(sessionId) ?? null diff --git a/src/main/runtime/orca-runtime-get-worktree-ps.ts b/src/main/runtime/orca-runtime-get-worktree-ps.ts index c63e4606837e..4cea4007910a 100644 --- a/src/main/runtime/orca-runtime-get-worktree-ps.ts +++ b/src/main/runtime/orca-runtime-get-worktree-ps.ts @@ -236,7 +236,6 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStartTuiIdleVis ? resolveTuiAgentLaunchEnv(agent, this.requireStore().getSettings().agentDefaultEnv) : {}, resolveAgentCommandSettings: () => this.requireStore().getSettings(), - initialChatPermissionMode: this.requireStore().getSettings().nativeChatPermissionMode, resolveCodexAccountKind: (home) => resolveStructuredCodexAccountKind(home, this.requireStore().getSettings()), resolveAgentAccountHome: (agent) => this.resolveStructuredAgentAccountHome(agent), 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 0188841fb8e8..f3442ce6f6e8 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 @@ -23,7 +23,6 @@ describe('execution host structured launch settings wiring', () => { installed.mockClear() const settings: Partial = { agentCmdOverrides: { claude: `"${process.execPath}"`, codex: `"${process.execPath}"` }, - nativeChatPermissionMode: 'full-access', agentDefaultArgs: { claude: '--model "model one"', codex: '-c model_reasoning_effort=high' } } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the mocked installer only reads the store's getSettings method. @@ -31,9 +30,6 @@ describe('execution host structured launch settings wiring', () => { await runtime.ensureStructuredAgentSessionHost() const deps = installed.mock.calls[0]?.[0] expect(deps).toBeDefined() - expect(deps?.initialChatPermissionMode).toBe('full-access') - settings.nativeChatPermissionMode = 'ask' - expect(deps?.initialChatPermissionMode).toBe('full-access') expect(deps?.resolveClaudeCommand?.()).toBe(process.execPath) expect(deps?.resolveCodexCommand?.()).toBe(process.execPath) expect(await deps?.resolveLaunchArgs?.('claude')).toEqual(['--model', 'model one']) @@ -41,6 +37,9 @@ 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([]) + settings.agentDefaultArgs = { claude: '--dangerously-skip-permissions', codex: '' } + expect(deps?.resolveAgentFullAccess?.('claude')).toBe(true) + expect(deps?.resolveAgentFullAccess?.('codex')).toBe(false) const notRunnable = expect.objectContaining({ reason: 'agentCommandNotRunnable' }) settings.agentCmdOverrides = { claude: 'wrapper --arg' } expect(() => deps?.resolveClaudeCommand?.()).toThrow(notRunnable) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-create-handler.ts b/src/main/runtime/rpc/methods/structured-agent-session-create-handler.ts index 9532ab4cca00..335648ecd87d 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-create-handler.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-create-handler.ts @@ -33,10 +33,10 @@ async function resolveClientSuppliedAttach(params: z.infer, reason: 'hostUnsupported' }) } - const seed = host.deps.store.getRecord(params.envelope.sessionId) - ? undefined - : ctx.runtime.structuredAgentSessionLaunchSeedOptions(params.agent) - const permissionMode = seed?.[AGENT_CHAT_PERMISSION_MODE_OPTION_ID] + // Reservation applies options only to a record it creates, so an existing chat is never reseeded. + const permissionMode = ctx.runtime.structuredAgentSessionLaunchSeedOptions(params.agent)?.[ + AGENT_CHAT_PERMISSION_MODE_OPTION_ID + ] const attachParams: AgentSessionAttachParams = { ...params, ...(permissionMode diff --git a/src/main/runtime/rpc/methods/structured-agent-session-permission-create.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-permission-create.test.ts index 2999667f244f..46a9b75fcd75 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-permission-create.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-permission-create.test.ts @@ -1,7 +1,4 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { getStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' -import { HOST_TEST_LOCATION } from '../../../native-chat/agent-session-wire/structured-agent-session-host-test-data' -import { foundAgentSessionRecord } from '../../agent-session-record-founding' import { attachParams, call, @@ -42,29 +39,4 @@ describe('legacy attach-shaped permission creation', () => { ) } ) - - it('does not derive a new seed for an existing record', async () => { - const host = getStructuredAgentSessionHost()! - const params = attachParams() - vi.mocked(host.deps.store.getRecord).mockReturnValue( - foundAgentSessionRecord( - { - sessionId: params.envelope.sessionId, - provider: params.provider, - location: HOST_TEST_LOCATION, - accountHome: params.accountHome, - options: { permissionMode: 'ask' } - }, - { claimKeyId: 'key-1', now: 1 } - ) - ) - const seed = vi.fn(() => ({ permissionMode: 'full-access' })) - expect( - await call('agentSession.ensure', params, STRUCTURED_CLIENT, { - structuredAgentSessionLaunchSeedOptions: seed - }) - ).toMatchObject({ ok: true }) - expect(seed).not.toHaveBeenCalled() - expect(hostCalls.attach).toHaveBeenCalledWith(expect.anything(), params) - }) }) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts index 978f44a4d154..dfda59b17b3f 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts @@ -213,8 +213,7 @@ export function hostStub(): StructuredAgentSessionHost { }) // Not a call: the logger the host hands a runtime caller that reports for it. const logger = recordingStructuredAgentSessionLogger().logger - const store = { getOperationRow: vi.fn(() => null), getRecord: vi.fn(() => null) } - Reflect.set(hostCalls, 'deps', { logger, store }) + Reflect.set(hostCalls, 'deps', { logger, store: { getOperationRow: vi.fn(() => null) } }) return hostCalls as unknown as StructuredAgentSessionHost } @@ -325,4 +324,6 @@ export function installStructuredHostStub(): void { setStructuredAgentSessionHost(hostStub()) } -export const clearStructuredHostStub = (): void => setStructuredAgentSessionHost(null) +export function clearStructuredHostStub(): void { + setStructuredAgentSessionHost(null) +} diff --git a/src/main/runtime/structured-agent-session-runtime.test.ts b/src/main/runtime/structured-agent-session-runtime.test.ts index 0d3867bb0063..c48b1d466746 100644 --- a/src/main/runtime/structured-agent-session-runtime.test.ts +++ b/src/main/runtime/structured-agent-session-runtime.test.ts @@ -224,49 +224,59 @@ describe('structured agent-session runtime install', () => { vi.restoreAllMocks() }) + function oldChat(provider: 'claude' | 'codex'): AgentSessionRecord { + return foundAgentSessionRecord( + { + sessionId: `old-${provider}`, + provider, + location: { + executionHostId: HOST_ID, + workspaceId: 'folder-1', + workspaceKind: 'folder', + wslDistro: null + }, + accountHome: { + variable: provider === 'claude' ? 'CLAUDE_CONFIG_DIR' : 'CODEX_HOME', + path: '/account' + } + }, + { claimKeyId: 'key-1', now: 1 } + ) + } + + async function installOver( + fullAccessAgents: readonly string[], + logger = recordingStructuredAgentSessionLogger().logger + ) { + return await ensureStructuredAgentSessionHost({ + logger, + stateDirectory: stateDirectory!, + hostId: HOST_ID, + claimKeyId: 'key-1', + resolveWorkspacePath: async () => stateDirectory!, + resolveLaunchArgs: () => [], + resolveClaudeAuthPolicy: () => ({ account: 'managed' }), + resolveAgentFullAccess: (agent) => fullAccessAgents.includes(agent), + resolveEnvironment: async () => ({}) + }) + } + it.each([false, true])( 'backfills before the host is readable and keeps startup nonfatal on a failed write (%s)', async (failWrite) => { stateDirectory = await mkdtemp(join(tmpdir(), 'orca-structured-runtime-backfill-')) - await seedTestAgentSessionRecordStore(stateDirectory, { - records: [ - foundAgentSessionRecord( - { - sessionId: 'old-chat', - provider: 'codex', - location: { - executionHostId: HOST_ID, - workspaceId: 'folder-1', - workspaceKind: 'folder', - wslDistro: null - }, - accountHome: { variable: 'CODEX_HOME', path: '/account' } - }, - { claimKeyId: 'key-1', now: 1 } - ) - ] - }) + await seedTestAgentSessionRecordStore(stateDirectory, { records: [oldChat('codex')] }) if (failWrite) { openTestJournalHostDatabase(stateDirectory).db.exec( "CREATE TRIGGER fail_permission_backfill BEFORE UPDATE ON agent_session_records BEGIN SELECT RAISE(ABORT, 'disk full'); END" ) } const logged = recordingStructuredAgentSessionLogger() - const host = await ensureStructuredAgentSessionHost({ - logger: logged.logger, - stateDirectory, - hostId: HOST_ID, - claimKeyId: 'key-1', - initialChatPermissionMode: 'full-access', - resolveWorkspacePath: async () => stateDirectory!, - resolveLaunchArgs: () => [], - resolveClaudeAuthPolicy: () => ({ account: 'managed' }), - resolveEnvironment: async () => ({}) - }) - expect(host.deps.store.getRecord('old-chat')?.options?.permissionMode).toBe( + const host = await installOver(['codex'], logged.logger) + expect(host.deps.store.getRecord('old-codex')?.options?.permissionMode).toBe( failWrite ? undefined : 'full-access' ) - expect((await host.readOptions('old-chat')).current.permissionMode).toBe( + expect((await host.readOptions('old-codex')).current.permissionMode).toBe( failWrite ? 'ask' : 'full-access' ) if (failWrite) { @@ -277,6 +287,22 @@ describe('structured agent-session runtime install', () => { } ) + it.each([ + [['claude'], 'full-access', 'ask'], + [['codex'], 'ask', 'full-access'] + ] as const)( + "fills each old chat from its own agent's arguments when %j skip prompts", + async (fullAccessAgents, claude, codex) => { + stateDirectory = await mkdtemp(join(tmpdir(), 'orca-structured-runtime-backfill-')) + await seedTestAgentSessionRecordStore(stateDirectory, { + records: [oldChat('claude'), oldChat('codex')] + }) + const host = await installOver(fullAccessAgents) + expect(host.deps.store.getRecord('old-claude')?.options?.permissionMode).toBe(claude) + expect(host.deps.store.getRecord('old-codex')?.options?.permissionMode).toBe(codex) + } + ) + it('holds stop until the model catalog has written its coalesced save', async () => { stateDirectory = await mkdtemp(join(tmpdir(), 'orca-structured-runtime-')) await ensureStructuredAgentSessionHost({ diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 68d8a1cf3f66..34a68277adc8 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -121,14 +121,12 @@ export type StructuredAgentSessionRuntimeDeps = { resolveLaunchArgs: (provider: AgentSessionRecord['provider']) => Promise | string[] /** Required, and asserted at install time — an absent policy must not degrade to a guess. */ resolveClaudeAuthPolicy: () => Promise | ClaudeStructuredAuthPolicy - /** Agent Permissions for an ACP agent; Claude and Codex chats run their own permission mode. */ + /** Agent Permissions for an ACP agent; also fills Claude and Codex chats saved before modes. */ resolveAgentFullAccess?: (agent: string) => boolean /** The user's per-agent environment overlay from settings, laid over every agent's base env. */ resolveAgentLaunchEnv?: (agent: string) => Record /** The settings a per-agent Command override is read from, for the same agents. */ resolveAgentCommandSettings?: () => StructuredAgentCommandSettings - /** Host default captured at startup to backfill chats saved before permission modes. */ - initialChatPermissionMode?: unknown resolveEnvironment?: () => Promise /** Which login-shell variables Codex and Claude children inherit; absent inherits all. */ resolveShellEnvironmentPolicy?: () => NativeChatShellEnvironmentPolicy @@ -266,9 +264,12 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise Date: Tue, 13 Oct 2026 02:30:54 -0700 Subject: [PATCH 5/6] refactor(native-chat): read each chat's agent arguments directly in the permission backfill --- .../agent-session-permission-backfill.test.ts | 6 ++---- .../runtime/agent-session-permission-backfill.ts | 14 ++++---------- 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/src/main/runtime/agent-session-permission-backfill.test.ts b/src/main/runtime/agent-session-permission-backfill.test.ts index 5fef13b0ae8b..f7bc9bda36af 100644 --- a/src/main/runtime/agent-session-permission-backfill.test.ts +++ b/src/main/runtime/agent-session-permission-backfill.test.ts @@ -1,7 +1,7 @@ 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 { afterEach, beforeEach, describe, expect, it } from 'vitest' import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' import { foundAgentSessionRecord } from './agent-session-record-founding' import { @@ -128,12 +128,10 @@ describe('existing chat permission backfill', () => { records: [record('claude'), record('codex'), record('grok')] }) const store = await openTestAgentSessionRecordStore(root) - const ranWithFullAccess = vi.fn((provider: string) => provider === bypassing) - await store.backfillChatPermissionModes(ranWithFullAccess) + await store.backfillChatPermissionModes((provider) => provider === bypassing) expect(store.getRecord('session-claude')?.options).toEqual({ permissionMode: claude }) expect(store.getRecord('session-codex')?.options).toEqual({ permissionMode: codex }) expect(store.getRecord('session-grok')?.options).toBeUndefined() - expect(ranWithFullAccess).toHaveBeenCalledTimes(3) } ) diff --git a/src/main/runtime/agent-session-permission-backfill.ts b/src/main/runtime/agent-session-permission-backfill.ts index 80acd46da20d..ca7088d75374 100644 --- a/src/main/runtime/agent-session-permission-backfill.ts +++ b/src/main/runtime/agent-session-permission-backfill.ts @@ -1,6 +1,5 @@ import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID, - type AgentChatPermissionMode, newChatPermissionMode } from '../../shared/agent-chat-permission-mode' import type { AgentSessionStoreTransactions } from './agent-session-store-transactions' @@ -13,20 +12,15 @@ export function backfillAgentSessionPermissionModes( if (store.readOnly) { return Promise.resolve() } - const modes = new Map() - const modeFor = (provider: string): AgentChatPermissionMode | null => { - if (!modes.has(provider)) { - const ran = ranWithFullAccess(provider) ? 'full-access' : 'ask' - modes.set(provider, newChatPermissionMode(provider, ran)) - } - return modes.get(provider) ?? null - } return store.transact((draft) => { for (const [sessionId, record] of draft.records) { if (record.options?.[AGENT_CHAT_PERMISSION_MODE_OPTION_ID] !== undefined) { continue } - const permissionMode = modeFor(record.provider) + const permissionMode = newChatPermissionMode( + record.provider, + ranWithFullAccess(record.provider) ? 'full-access' : 'ask' + ) if (permissionMode) { draft.records.set(sessionId, { ...record, From 3a7a4b98fcc1bdd2e4cf880152d2a915551e7f89 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 03:37:14 -0700 Subject: [PATCH 6/6] test(native-chat): give stub runtimes the launch seed the attach path reads --- src/main/runtime/claude-structured-session-integration.test.ts | 1 + src/main/runtime/structured-agent-session-integration.test.ts | 1 + src/main/runtime/structured-codex-session-rpc-test-harness.ts | 1 + 3 files changed, 3 insertions(+) diff --git a/src/main/runtime/claude-structured-session-integration.test.ts b/src/main/runtime/claude-structured-session-integration.test.ts index dedde5033497..45baed003b13 100644 --- a/src/main/runtime/claude-structured-session-integration.test.ts +++ b/src/main/runtime/claude-structured-session-integration.test.ts @@ -229,6 +229,7 @@ beforeEach(async () => { getRuntimeId: () => 'runtime-1', getClientSettings: () => ({ experimentalNativeChat: true }), getStructuredAgentSessionCreateSupport: async () => ({ supported: true }), + structuredAgentSessionLaunchSeedOptions: () => undefined, resolveStructuredAgentSessionCreateIntent: async (input: { envelope: unknown }) => ({ ...ensureParams(1), envelope: input.envelope, diff --git a/src/main/runtime/structured-agent-session-integration.test.ts b/src/main/runtime/structured-agent-session-integration.test.ts index deaee6763c34..6f6b38378cd2 100644 --- a/src/main/runtime/structured-agent-session-integration.test.ts +++ b/src/main/runtime/structured-agent-session-integration.test.ts @@ -330,6 +330,7 @@ beforeEach(async () => { getRuntimeId: () => 'runtime-1', getClientSettings: () => ({ experimentalNativeChat: true }), getStructuredAgentSessionCreateSupport: async () => ({ supported: true }), + structuredAgentSessionLaunchSeedOptions: () => undefined, resolveStructuredAgentSessionCreateIntent: async () => { const { envelope: _envelope, diff --git a/src/main/runtime/structured-codex-session-rpc-test-harness.ts b/src/main/runtime/structured-codex-session-rpc-test-harness.ts index 67ff1574b75f..50376db57370 100644 --- a/src/main/runtime/structured-codex-session-rpc-test-harness.ts +++ b/src/main/runtime/structured-codex-session-rpc-test-harness.ts @@ -236,6 +236,7 @@ export async function openStructuredCodexRpcHarness( getRuntimeId: () => 'runtime-1', getClientSettings: () => ({ experimentalStructuredNativeChat: true }), getStructuredAgentSessionCreateSupport: async () => ({ supported: true }), + structuredAgentSessionLaunchSeedOptions: () => undefined, resolveStructuredAgentSessionCreateIntent: async () => { const { envelope: _envelope,