diff --git a/src/main/pi/titlebar-extension-service.test.ts b/src/main/pi/titlebar-extension-service.test.ts index 3c3347b3b4f..1240d1859a8 100644 --- a/src/main/pi/titlebar-extension-service.test.ts +++ b/src/main/pi/titlebar-extension-service.test.ts @@ -275,6 +275,8 @@ describe('PiTitlebarExtensionService', () => { const content = 'agent.db credentials' expect(env.PI_CODING_AGENT_DIR).toBeUndefined() + expect(readFileSync(env.ORCA_OMP_FRESH_CONFIG, 'utf8')).toBe('autoResume: false\n') + expect(env.ORCA_OMP_FRESH_CONFIG.startsWith(userDataDir)).toBe(true) expect(env.ORCA_OMP_SOURCE_AGENT_DIR).toBe(piHome) expect(env.ORCA_OMP_STATUS_EXTENSION).toBe(join(piHome, 'extensions', 'orca-agent-status.ts')) expect(existsSync(sourcePath)).toBe(false) diff --git a/src/main/pi/titlebar-extension-service.ts b/src/main/pi/titlebar-extension-service.ts index 0209c6232f5..1d642b0cf1a 100644 --- a/src/main/pi/titlebar-extension-service.ts +++ b/src/main/pi/titlebar-extension-service.ts @@ -1,3 +1,8 @@ +import { + OMP_FRESH_CONFIG_FILENAME, + OMP_FRESH_CONFIG_SOURCE, + ORCA_OMP_FRESH_CONFIG_ENV +} from '../../shared/omp-fresh-launch' import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { homedir } from 'node:os' import { join } from 'node:path' @@ -183,7 +188,19 @@ export class PiTitlebarExtensionService { // The caller resolves the effective launch environment. Reading the // daemon's ambient PI_CONFIG_DIR here can select the host profile for a // guest/WSL launch whose environment has not been hydrated yet. - const sourceAgentDir = existingAgentDir || getDefaultPiAgentDir(kind, options?.configDirName) + const freshConfigEnv: Record = {} + if (kind === 'omp') { + const configDir = join( + getAppEnvironment().getPath('userData'), + OMP_MANAGED_STATUS_EXTENSION_DIR + ) + mkdirSync(configDir, { recursive: true }) + const configPath = join(configDir, OMP_FRESH_CONFIG_FILENAME) + writeFileSync(configPath, OMP_FRESH_CONFIG_SOURCE) + freshConfigEnv[ORCA_OMP_FRESH_CONFIG_ENV] = configPath + } + const sourceAgentDir = + existingAgentDir || getDefaultPiAgentDir(kind, options?.configDirName) if (kind !== 'prime-agent') { try { this.safeRemoveOverlay(this.getPtyOverlayDir(ptyId, kind), kind) @@ -203,7 +220,9 @@ export class PiTitlebarExtensionService { if (kind === 'omp') { const statusSource = withOrcaManagedExtensionMarker(getPiAgentStatusExtensionSource(kind)) const statusExtensionPath = this.writeOmpFallbackStatusExtension(statusSource) - return statusExtensionPath ? { ORCA_OMP_STATUS_EXTENSION: statusExtensionPath } : {} + return statusExtensionPath + ? { ...freshConfigEnv, ORCA_OMP_STATUS_EXTENSION: statusExtensionPath } + : freshConfigEnv } return {} } @@ -213,7 +232,7 @@ export class PiTitlebarExtensionService { } const installed = this.installManagedExtensions(sourceAgentDir, kind) - const env: Record = {} + const env: Record = { ...freshConfigEnv } if (kind === 'omp') { env.ORCA_OMP_SOURCE_AGENT_DIR = installed.sourceAgentDir if (installed.statusExtensionPath) { diff --git a/src/main/pty/wsl-orca-env.test.ts b/src/main/pty/wsl-orca-env.test.ts index 4f655b1e33a..e9004b7df8d 100644 --- a/src/main/pty/wsl-orca-env.test.ts +++ b/src/main/pty/wsl-orca-env.test.ts @@ -63,6 +63,7 @@ describe('addOrcaWslInteropEnv', () => { ORCA_USER_DATA_PATH: 'C:\\Users\\jin\\AppData\\Roaming\\Orca', ORCA_CLI_COMMAND: 'orca-ide', ORCA_CODEX_LAUNCH_PREFLIGHT: 'C:\\Program Files\\Orca\\resources\\bin\\orca.exe', + ORCA_OMP_FRESH_CONFIG: 'C:\\Orca\\fresh-session.yml', ORCA_OMP_STATUS_EXTENSION: 'C:\\Users\\jin\\.omp\\agent\\extensions\\orca-agent-status.ts', ORCA_PRIME_AGENT_STATUS_EXTENSION: 'C:\\stale\\orca-agent-status.ts', ORCA_PANE_KEY: 'tab-1:leaf-1', @@ -87,6 +88,7 @@ describe('addOrcaWslInteropEnv', () => { expect(env.WSLENV).toContain('ORCA_CLI_COMMAND/u') expect(env.WSLENV).toContain('ORCA_CODEX_LAUNCH_PREFLIGHT/p') expect(env.WSLENV).toContain('ORCA_OMP_STATUS_EXTENSION/p') + expect(env.WSLENV).toContain('ORCA_OMP_FRESH_CONFIG/p') expect(env.WSLENV).not.toContain('ORCA_PRIME_AGENT_STATUS_EXTENSION') expect(env.WSLENV).toContain('ORCA_PANE_KEY/u') expect(env.WSLENV).toContain('ORCA_TAB_ID/u') diff --git a/src/main/pty/wsl-orca-env.ts b/src/main/pty/wsl-orca-env.ts index 612efd8b943..7b99ddc98ee 100644 --- a/src/main/pty/wsl-orca-env.ts +++ b/src/main/pty/wsl-orca-env.ts @@ -99,8 +99,8 @@ export function addOrcaWslInteropEnv(env: Record): void { 'ORCA_WSL_HOOK_INSTANCE/u', 'ORCA_OMP_SOURCE_AGENT_DIR/p', 'ORCA_OMP_STATUS_EXTENSION/p', - // A protocol name, never a path; in-guest agents read it to pick an image encoder. `${ORCA_IMAGE_PROTOCOL_ENV}/u`, + 'ORCA_OMP_FRESH_CONFIG/p', ...worktreeSetupWslenvEntries(env) ] applyWslenvPassthrough(env, passthroughEntries) diff --git a/src/shared/omp-fresh-launch.test.ts b/src/shared/omp-fresh-launch.test.ts new file mode 100644 index 00000000000..eda9e07026b --- /dev/null +++ b/src/shared/omp-fresh-launch.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest' +import { withFreshOmpLaunch } from './omp-fresh-launch' +import { buildAgentStartupPlan } from './tui-agent-startup' + +describe('OMP fresh launch intent', () => { + it.each(['omp', 'omp launch', 'omp --model provider/model', 'omp --config user.yml'])( + '%s adds the final overlay', + (command) => { + expect(withFreshOmpLaunch(command, 'posix')).toBe( + `${command} --config "$ORCA_OMP_FRESH_CONFIG"` + ) + } + ) + it.each([ + 'omp --resume id', + 'omp -r id', + 'omp --continue', + 'omp -c', + 'omp --session-dir /custom', + 'omp --no-session', + 'omp --fork id', + 'omp models', + 'omp config', + 'omp wt', + 'omp --help', + 'omp --unknown foo', + 'omp --model', + 'omp -- hello', + 'echo omp', + 'omp && echo hi' + ])('preserves %s', (command) => { + expect(withFreshOmpLaunch(command, 'posix')).toBe(command) + }) + it('quotes the host config path for each Windows shell', () => { + expect(withFreshOmpLaunch('omp', 'powershell')).toBe( + 'omp --config "$env:ORCA_OMP_FRESH_CONFIG"' + ) + expect(withFreshOmpLaunch('omp', 'cmd')).toBe('omp --config "%ORCA_OMP_FRESH_CONFIG%"') + }) + it('keeps fresh intent out of saved resume command and environment', () => { + const plan = buildAgentStartupPlan({ + agent: 'omp', + prompt: 'new task', + cmdOverrides: {}, + platform: 'linux' + }) + expect(plan?.launchCommand).toContain('--config "$ORCA_OMP_FRESH_CONFIG"') + expect(JSON.stringify(plan?.launchConfig)).not.toContain('ORCA_OMP_FRESH_CONFIG') + expect(plan?.env).toBeUndefined() + }) + it('does not require a new environment field from an older SSH relay', () => { + const plan = buildAgentStartupPlan({ + agent: 'omp', + prompt: 'new task', + cmdOverrides: {}, + platform: 'linux', + isRemote: true + }) + expect(plan?.launchCommand).not.toContain('ORCA_OMP_FRESH_CONFIG') + }) +}) diff --git a/src/shared/omp-fresh-launch.ts b/src/shared/omp-fresh-launch.ts new file mode 100644 index 00000000000..2cbddb969b4 --- /dev/null +++ b/src/shared/omp-fresh-launch.ts @@ -0,0 +1,55 @@ +import { tokenizeStartupCommand, type AgentStartupShell } from './tui-agent-startup-shell' + +export const ORCA_OMP_FRESH_CONFIG_ENV = 'ORCA_OMP_FRESH_CONFIG' +export const OMP_FRESH_CONFIG_FILENAME = 'fresh-session.yml' +export const OMP_FRESH_CONFIG_SOURCE = 'autoResume: false\n' + +// Unknown flags may consume values or select a subcommand; leave those commands intact. +const VALUE_FLAGS = new Set([ + '--model', + '--provider', + '--thinking', + '--config', + '--profile', + '--extension', + '-e', + '--system-prompt', + '--append-system-prompt', + '--tools', + '--skill', + '--theme', + '--api-key' +]) +const SWITCH_FLAGS = new Set(['--no-extensions', '--no-skills', '--no-prompt-templates']) + +/** Apply fresh intent to one launch command, never the saved resume configuration. */ +export function withFreshOmpLaunch(command: string, shell: AgentStartupShell): string { + const parsed = tokenizeStartupCommand(command, shell) + if (!parsed.ok) { + return command + } + const executable = parsed.tokens[0]?.split(/[\\/]/).at(-1)?.toLowerCase() + if (!['omp', 'omp.exe', 'omp.cmd', 'omp.bat', 'omp.sh', 'omp.js'].includes(executable ?? '')) { + return command + } + let index = parsed.tokens[1] === 'launch' ? 2 : 1 + for (; index < parsed.tokens.length; index++) { + const token = parsed.tokens[index] + const equals = token.indexOf('=') + const flag = equals === -1 ? token : token.slice(0, equals) + if (VALUE_FLAGS.has(flag)) { + if (equals === -1 && ++index >= parsed.tokens.length) { + return command + } + } else if (!SWITCH_FLAGS.has(token)) { + return command + } + } + const path = + shell === 'cmd' + ? `"%${ORCA_OMP_FRESH_CONFIG_ENV}%"` + : shell === 'powershell' + ? `"$env:${ORCA_OMP_FRESH_CONFIG_ENV}"` + : `"$${ORCA_OMP_FRESH_CONFIG_ENV}"` + return `${command} --config ${path}` +} diff --git a/src/shared/tui-agent-startup.test.ts b/src/shared/tui-agent-startup.test.ts index 97ee45ae425..7ef89709af4 100644 --- a/src/shared/tui-agent-startup.test.ts +++ b/src/shared/tui-agent-startup.test.ts @@ -667,7 +667,7 @@ describe('tui agent startup plans', () => { expect(plan?.env).toEqual({ ORCA_OMP_PREFILL: 'fix the omp regression' }) expect(plan?.expectedProcess).toBe('omp') expect(plan?.launchCommand).toBe( - `omp; command test -n "$fish_pid" && set --erase -g ORCA_OMP_PREFILL; command test -z "$fish_pid" && unset ORCA_OMP_PREFILL; true` + `omp --config "$ORCA_OMP_FRESH_CONFIG"; command test -n "$fish_pid" && set --erase -g ORCA_OMP_PREFILL; command test -z "$fish_pid" && unset ORCA_OMP_PREFILL; true` ) }) diff --git a/src/shared/tui-agent-startup.ts b/src/shared/tui-agent-startup.ts index fba16776378..f2d95d5652a 100644 --- a/src/shared/tui-agent-startup.ts +++ b/src/shared/tui-agent-startup.ts @@ -1,3 +1,4 @@ +import { withFreshOmpLaunch } from './omp-fresh-launch' import { isShellProcess } from './agent-detection' import type { SleepingAgentLaunchConfig } from './agent-session-resume' import { @@ -70,6 +71,10 @@ export function buildAgentStartupPlan(args: { if (!baseCommand.ok) { return null } + const launchCommand = + agent === 'omp' && !args.isRemote + ? withFreshOmpLaunch(baseCommand.command, shell) + : baseCommand.command const launchConfig = buildSleepingAgentLaunchConfig({ ...args, // Why: picker flags are a one-time launch choice; a resumed provider @@ -83,7 +88,7 @@ export function buildAgentStartupPlan(args: { } return { agent, - launchCommand: baseCommand.command, + launchCommand, expectedProcess: config.expectedProcess, followupPrompt: null, launchConfig, @@ -98,7 +103,7 @@ export function buildAgentStartupPlan(args: { const promptSeparator = config.argvPromptSeparator ? ` ${config.argvPromptSeparator}` : '' return { agent, - launchCommand: `${baseCommand.command}${promptSeparator} ${quotedPrompt}`, + launchCommand: `${launchCommand}${promptSeparator} ${quotedPrompt}`, expectedProcess: config.expectedProcess, followupPrompt: null, launchConfig, @@ -111,7 +116,7 @@ export function buildAgentStartupPlan(args: { if (config.promptInjectionMode === 'flag-prompt') { return { agent, - launchCommand: `${baseCommand.command} --prompt ${quotedPrompt}`, + launchCommand: `${launchCommand} --prompt ${quotedPrompt}`, expectedProcess: config.expectedProcess, followupPrompt: null, launchConfig, @@ -149,7 +154,7 @@ export function buildAgentStartupPlan(args: { if (config.promptInjectionMode === 'flag-prompt-interactive') { return { agent, - launchCommand: `${baseCommand.command} --prompt-interactive ${quotedPrompt}`, + launchCommand: `${launchCommand} --prompt-interactive ${quotedPrompt}`, expectedProcess: config.expectedProcess, followupPrompt: null, launchConfig, @@ -161,7 +166,7 @@ export function buildAgentStartupPlan(args: { if (config.promptInjectionMode === 'flag-interactive') { return { agent, - launchCommand: `${baseCommand.command} -i ${quotedPrompt}`, + launchCommand: `${launchCommand} -i ${quotedPrompt}`, expectedProcess: config.expectedProcess, followupPrompt: null, launchConfig, @@ -172,7 +177,7 @@ export function buildAgentStartupPlan(args: { return { agent, - launchCommand: baseCommand.command, + launchCommand, expectedProcess: config.expectedProcess, followupPrompt: trimmedPrompt, launchConfig, @@ -222,6 +227,10 @@ export function buildAgentDraftLaunchPlan(args: { if (!baseCommand.ok) { return null } + const launchCommand = + agent === 'omp' && !args.isRemote + ? withFreshOmpLaunch(baseCommand.command, shell) + : baseCommand.command const launchConfig = buildSleepingAgentLaunchConfig({ ...args, // Why: see the new-session path above — resume must not replay picker flags. @@ -232,7 +241,7 @@ export function buildAgentDraftLaunchPlan(args: { const quoted = quoteStartupArg(trimmed, shell) plan = { agent, - launchCommand: `${baseCommand.command} ${config.draftPromptFlag} ${quoted}`, + launchCommand: `${launchCommand} ${config.draftPromptFlag} ${quoted}`, expectedProcess: config.expectedProcess, launchConfig, ...appliedSessionOptionProps(baseCommand.appliedSessionOptions), @@ -244,7 +253,7 @@ export function buildAgentDraftLaunchPlan(args: { const clearVar = clearEnvCommand(config.draftPromptEnvVar, shell) plan = { agent, - launchCommand: `${baseCommand.command}${commandSeparator(shell)}${clearVar}`, + launchCommand: `${launchCommand}${commandSeparator(shell)}${clearVar}`, expectedProcess: config.expectedProcess, launchConfig, ...appliedSessionOptionProps(baseCommand.appliedSessionOptions), diff --git a/tests/tools/omp-fresh-session-runtime-smoke.mjs b/tests/tools/omp-fresh-session-runtime-smoke.mjs new file mode 100644 index 00000000000..84ca8f58d68 --- /dev/null +++ b/tests/tools/omp-fresh-session-runtime-smoke.mjs @@ -0,0 +1,75 @@ +// Bun, with a read-only OMP source checkout as argv[2]. No model requests. +import assert from 'node:assert/strict' +import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { OMP_FRESH_CONFIG_SOURCE } from '../../src/shared/omp-fresh-launch.ts' + +assert.ok(process.argv[2], 'Pass a read-only OMP checkout path') +const scratch = await mkdtemp(join(tmpdir(), 'orca-omp-fresh-proof-')) +process.env.HOME = join(scratch, 'home') +process.env.USERPROFILE = process.env.HOME +process.env.XDG_CONFIG_HOME = join(scratch, 'xdg-config') +process.env.XDG_DATA_HOME = join(scratch, 'xdg-data') +process.env.XDG_STATE_HOME = join(scratch, 'xdg-state') +process.env.OMP_CODING_AGENT_DIR = join(scratch, 'agent') +delete process.env.PI_CONFIG_FILES +const source = (path) => + pathToFileURL(join(resolve(process.argv[2]), 'packages/coding-agent/src', path)).href +const managers = [] +try { + await mkdir(process.env.HOME, { recursive: true }) + const { SessionManager } = await import(source('session/session-manager.ts')) + const { Settings, resetSettingsForTest } = await import(source('config/settings.ts')) + const { createSessionManager } = await import(source('main.ts')) + const cwd = join(scratch, 'project') + await mkdir(cwd) + const previous = SessionManager.create(cwd) + managers.push(previous) + previous.appendMessage({ role: 'user', content: 'previous task', timestamp: Date.now() }) + await previous.ensureOnDisk() + await previous.flush() + const config = join(scratch, 'fresh.yml') + const userConfig = join(scratch, 'user.yml') + await writeFile(config, OMP_FRESH_CONFIG_SOURCE) + await writeFile(userConfig, 'autoResume: true\n') + const settings = await Settings.init({ cwd, configFiles: [userConfig] }) + const resumed = await createSessionManager({}, cwd, settings) + managers.push(resumed) + assert.equal(resumed.getSessionId(), previous.getSessionId()) + resetSettingsForTest() + const freshSettings = await Settings.init({ cwd, configFiles: [userConfig, config] }) + assert.equal(freshSettings.get('autoResume'), false) + assert.equal(settings.get('autoResume'), true) + const defaultSelection = await createSessionManager({}, cwd, freshSettings) + assert.equal(defaultSelection, undefined, 'SDK creates a fresh session after undefined selection') + const fresh = SessionManager.create(cwd) + managers.push(fresh) + assert.notEqual(fresh.getSessionId(), previous.getSessionId()) + assert.equal(fresh.getSessionDir(), previous.getSessionDir()) + const explicit = await createSessionManager( + { resume: previous.getSessionFile() }, + cwd, + freshSettings + ) + managers.push(explicit) + assert.equal(explicit.getSessionId(), previous.getSessionId()) + console.log( + JSON.stringify({ + platform: process.platform, + autoResumeReproduced: true, + freshSelected: true, + storageDirectoryPreserved: true, + explicitResumePreserved: true, + modelCalls: 0, + scope: + 'Actual OMP Settings overlays, persistent SessionManager and createSessionManager; no rendered UI' + }) + ) +} finally { + for (const manager of managers) { + await manager?.close() + } + await rm(scratch, { recursive: true, force: true }) +}