diff --git a/.gitattributes b/.gitattributes index be42f7cfd68d..8157af1df2c1 100644 --- a/.gitattributes +++ b/.gitattributes @@ -97,3 +97,7 @@ # Generated ACP schemas are checked byte-for-byte against formatted output. /src/main/acp/generated/*.generated.ts linguist-generated=true text eol=lf +# Generated skill bundle artifacts are verified byte-for-byte against fresh output. +/resources/skills/*.json linguist-generated=true text eol=lf +# Vitest writes snapshots with LF, so checkout bytes must match on every host. +**/__snapshots__/*.snap text eol=lf diff --git a/config/scripts/generated-artifact-line-ending-pin.test.mjs b/config/scripts/generated-artifact-line-ending-pin.test.mjs new file mode 100644 index 000000000000..8b628099d35f --- /dev/null +++ b/config/scripts/generated-artifact-line-ending-pin.test.mjs @@ -0,0 +1,49 @@ +import { execFileSync } from 'node:child_process' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { expect, it } from 'vitest' + +const projectDir = resolve(import.meta.dirname, '../..') + +it('keeps byte-compared artifacts and source LF in an autocrlf checkout', () => { + const root = mkdtempSync(join(tmpdir(), 'orca-line-ending-checkout-')) + const lf = 'first\nsecond\n' + const crlf = 'first\r\nsecond\r\n' + const paths = new Map([ + ['resources/skills/current-manifest.json', lf], + ['resources/skills/snapshot-registry.json', lf], + ['resources/skills/release-mapping.json', lf], + ['src/main/__snapshots__/example.test.ts.snap', lf], + ['src/renderer/src/example.tsx', lf], + ['src/main/example.ts', lf], + ['config/scripts/example.mjs', lf], + ...['cmd', 'bat', 'ps1', 'nsh'].map((extension) => [`config/example.${extension}`, crlf]), + ['resources/skills-extra/example.json', crlf], + ['vendor/resources/skills/example.json', crlf], + ['scripts/example.mjs', crlf], + ['src/main/runtime/__fixtures__/example.txt', crlf], + ['src/main/daemon/__fixtures__/pty-transcripts/example.txt', crlf] + ]) + const git = (args) => execFileSync('git', args, { cwd: root, stdio: 'pipe' }) + + try { + git(['init', '--quiet']) + writeFileSync(join(root, '.gitattributes'), readFileSync(join(projectDir, '.gitattributes'))) + for (const path of paths.keys()) { + mkdirSync(dirname(join(root, path)), { recursive: true }) + writeFileSync(join(root, path), path.includes('__fixtures__') ? crlf : lf) + } + git(['-c', 'core.autocrlf=input', 'add', '--', '.']) + for (const path of paths.keys()) { + rmSync(join(root, path)) + } + git(['-c', 'core.autocrlf=true', 'checkout-index', '--all', '--force']) + + for (const [path, expected] of paths) { + expect(readFileSync(join(root, path), 'utf8'), path).toBe(expected) + } + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) diff --git a/config/scripts/run-node-server-tests.mjs b/config/scripts/run-node-server-tests.mjs index 8c06dd9c9156..2183b9665503 100644 --- a/config/scripts/run-node-server-tests.mjs +++ b/config/scripts/run-node-server-tests.mjs @@ -1,7 +1,7 @@ // Runs the headless-server suites under the pinned Node (design D4/D4a), not the host's. import { join, resolve } from 'node:path' import { randomUUID } from 'node:crypto' -import { readFileSync } from 'node:fs' +import { globSync, readFileSync } from 'node:fs' import { ORCAD_VERSION_FILENAME } from '../../src/shared/orcad-artifacts.ts' import { NODE_RUNTIME_PIN } from '../../src/shared/node-runtime-pin.ts' import { @@ -11,6 +11,7 @@ import { import { packagedNodeRuntimePath } from './build-orcad-node.mjs' import { ensurePinnedNodeExecutable } from './pinned-node-downloads.mjs' import { currentTarget } from './server-build-target.mjs' +import { UNIT_INCLUDE } from './ci-unit-files.mjs' import { describeProcessFailure, runProcessSync } from './script-child-process.mjs' import { CROSS_RUNTIME_TEST_PATHS, @@ -84,38 +85,14 @@ run(runtimePath, [ ]) function defaultTestArgs() { - // Why resolve the files: Vitest's per-project excludes override a CLI --exclude, so a directory - // selector would still run Electron probes, which need a display these containers lack. - const vitest = join(root, 'node_modules/vitest/vitest.mjs') + // Why: vitest 5 drops CLI --exclude for inline projects, so the selectors' substring matches + // are resolved to files here and filtered before vitest sees them. + // Electron probes run in desktop jobs; headless compatibility containers have no display. const selectors = nodeServerTestPaths({ artifact, crossRuntime }) - const result = runProcessSync({ - program: runtimePath, - args: [ - vitest, - 'list', - '--config', - 'config/vitest.config.ts', - ...selectors, - '--filesOnly', - '--json' - ], - cwd: root, - env, - timeoutMs: 120_000, - maxOutputBytes: 16 * 1024 * 1024 - }) - if (result.code !== 0 || result.timedOut || result.outputTruncated) { - throw new Error(`Listing node-server tests failed: ${describeProcessFailure(result)}`) - } - // A directory selector would otherwise pull cross-runtime tests into lanes that lack their inputs. - const skipped = crossRuntime ? [] : CROSS_RUNTIME_TEST_PATHS - const files = [...new Set(JSON.parse(result.stdout).map((entry) => entry.file))].filter( - (file) => - !file.endsWith('.electron.test.ts') && - !skipped.some((path) => file.replaceAll('\\', '/').endsWith(path)) - ) - if (files.length === 0) { - throw new Error('No node-server tests were selected') - } - return files + const skipped = new Set(crossRuntime ? [] : CROSS_RUNTIME_TEST_PATHS) + return globSync(UNIT_INCLUDE, { cwd: root }) + .map((path) => path.replaceAll('\\', '/')) + .filter((path) => selectors.some((selector) => path.includes(selector))) + .filter((path) => !path.endsWith('.electron.test.ts') && !skipped.has(path)) + .sort() } diff --git a/config/vitest.config.ts b/config/vitest.config.ts index c5c057bc64e6..4e49cfc175ef 100644 --- a/config/vitest.config.ts +++ b/config/vitest.config.ts @@ -23,6 +23,7 @@ const transforms = { const testOptions = { environment: 'node', clearMocks: false, + fsModuleCache: true, env: { ORCA_VITEST_RUNTIME: 'node' }, server: { deps: { inline: ['zod'] } }, // Node's storage globals and V8 retention checks require the existing child flags. diff --git a/src/main/automations/automation-dispatch-host-fence.test.ts b/src/main/automations/automation-dispatch-host-fence.test.ts index 1f28f5accd7b..2fbc3215485b 100644 --- a/src/main/automations/automation-dispatch-host-fence.test.ts +++ b/src/main/automations/automation-dispatch-host-fence.test.ts @@ -21,10 +21,7 @@ import { removeTreeSync } from '../../shared/windows-transient-lock-removal' import type { Store } from '../persistence' import { resolveAutomationRunTarget } from './run-target-resolution' import { AutomationService } from './service' -import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' -import { closeTestStores, createSqliteTestStore } from '../persistence-test-harness' - -const testState = { dir: '' } +import { closeTestStores, createStore, testState } from '../persistence-test-harness' vi.mock('electron', () => ({ app: { @@ -37,14 +34,6 @@ vi.mock('electron', () => ({ } })) -async function createStore(): Promise { - vi.resetModules() - installFakeAppEnvironment({ getPath: () => testState.dir }) - const { Store: StoreClass, initDataPath } = await import('../persistence') - initDataPath() - return createSqliteTestStore(StoreClass, { dataFile: join(testState.dir, 'orca-data.json') }) -} - /** The reachable shape: a runtime-owned id is derived, not minted per lifecycle. */ const TARGET_ID = 'runtime-ssh-recipe-1' diff --git a/src/main/automations/automation-skip-coalescing.test.ts b/src/main/automations/automation-skip-coalescing.test.ts index 089a0dab196c..1de193cd9c77 100644 --- a/src/main/automations/automation-skip-coalescing.test.ts +++ b/src/main/automations/automation-skip-coalescing.test.ts @@ -15,14 +15,12 @@ import { removeTreeSync } from '../../shared/windows-transient-lock-removal' import type { Store } from '../persistence' import { closeTestStores, - createSqliteTestStore, + createStore, readPersistedStateJson, + testState, writePersistedStateJson } from '../persistence-test-harness' import { AutomationService } from './service' -import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' - -const testState = { dir: '' } vi.mock('electron', () => ({ app: { @@ -35,14 +33,6 @@ vi.mock('electron', () => ({ } })) -async function createStore(): Promise { - vi.resetModules() - installFakeAppEnvironment({ getPath: () => testState.dir }) - const { Store: StoreClass, initDataPath } = await import('../persistence') - initDataPath() - return createSqliteTestStore(StoreClass, { dataFile: join(testState.dir, 'orca-data.json') }) -} - const makeRepo = (): Repo => ({ id: 'r1', path: '/repo', diff --git a/src/main/automations/automation-zero-grace-tick-latency.test.ts b/src/main/automations/automation-zero-grace-tick-latency.test.ts index 94020e03bef9..4d434c77d681 100644 --- a/src/main/automations/automation-zero-grace-tick-latency.test.ts +++ b/src/main/automations/automation-zero-grace-tick-latency.test.ts @@ -1,13 +1,10 @@ -import { closeTestStores, createSqliteTestStore } from '../persistence-test-harness' +import { closeTestStores, createStore, testState } from '../persistence-test-harness' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mkdtempSync, rmSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' import type { Repo } from '../../shared/repo-types' import { AutomationService } from './service' -import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' - -const testState = { dir: '' } vi.mock('electron', () => ({ app: { @@ -20,14 +17,6 @@ vi.mock('electron', () => ({ } })) -async function createStore() { - vi.resetModules() - installFakeAppEnvironment({ getPath: () => testState.dir }) - const { Store, initDataPath } = await import('../persistence') - initDataPath() - return createSqliteTestStore(Store, { dataFile: join(testState.dir, 'orca-data.json') }) -} - const makeRepo = (overrides: Partial = {}): Repo => ({ id: 'r1', path: '/repo', diff --git a/src/main/automations/retained-run-reconciliation.test.ts b/src/main/automations/retained-run-reconciliation.test.ts index f8f8b6e9017a..547729d2c55b 100644 --- a/src/main/automations/retained-run-reconciliation.test.ts +++ b/src/main/automations/retained-run-reconciliation.test.ts @@ -1,4 +1,4 @@ -import { closeTestStores, createSqliteTestStore } from '../persistence-test-harness' +import { closeTestStores, createStore, testState } from '../persistence-test-harness' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mkdtempSync, rmSync } from 'node:fs' import { join } from 'node:path' @@ -14,9 +14,6 @@ import type { AutomationRunCompletionObservation, AutomationRunTerminalObserver } from './run-completion-watcher' -import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' - -const testState = { dir: '' } vi.mock('electron', () => ({ app: { getPath: () => testState.dir }, @@ -28,14 +25,6 @@ vi.mock('electron', () => ({ } })) -async function createStore() { - vi.resetModules() - installFakeAppEnvironment({ getPath: () => testState.dir }) - const { Store, initDataPath } = await import('../persistence') - initDataPath() - return createSqliteTestStore(Store, { dataFile: join(testState.dir, 'orca-data.json') }) -} - type TestStore = Awaited> const makeRepo = (): Repo => ({ diff --git a/src/main/automations/run-completion-watcher.test.ts b/src/main/automations/run-completion-watcher.test.ts index 69eb7a17201c..fb1400a28861 100644 --- a/src/main/automations/run-completion-watcher.test.ts +++ b/src/main/automations/run-completion-watcher.test.ts @@ -1,4 +1,4 @@ -import { closeTestStores, createSqliteTestStore } from '../persistence-test-harness' +import { closeTestStores, createStore, testState } from '../persistence-test-harness' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mkdtempSync, rmSync } from 'node:fs' import { join } from 'node:path' @@ -11,9 +11,7 @@ import type { AutomationRunCompletionObservation, AutomationRunTerminalObserver } from './run-completion-watcher' -import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' -const testState = { dir: '' } const ipcHandlers = new Map unknown>() vi.mock('electron', () => ({ @@ -32,14 +30,6 @@ vi.mock('electron', () => ({ } })) -async function createStore() { - vi.resetModules() - installFakeAppEnvironment({ getPath: () => testState.dir }) - const { Store, initDataPath } = await import('../persistence') - initDataPath() - return createSqliteTestStore(Store, { dataFile: join(testState.dir, 'orca-data.json') }) -} - const makeRepo = (overrides: Partial = {}): Repo => ({ id: 'r1', path: '/repo', diff --git a/src/main/automations/service.test.ts b/src/main/automations/service.test.ts index 77aa8b8a6f30..092c774feb6d 100644 --- a/src/main/automations/service.test.ts +++ b/src/main/automations/service.test.ts @@ -1,6 +1,7 @@ import { closeTestStores, - createSqliteTestStore, + createStore, + testState, readPersistedStateJson, writePersistedStateJson } from '../persistence-test-harness' @@ -11,9 +12,6 @@ import { tmpdir } from 'node:os' import type { Repo } from '../../shared/repo-types' import { toRuntimeExecutionHostId } from '../../shared/execution-host' import { AutomationService } from './service' -import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' - -const testState = { dir: '' } vi.mock('electron', () => ({ app: { @@ -26,15 +24,6 @@ vi.mock('electron', () => ({ } })) -async function createStore() { - vi.resetModules() - // Why: userData resolves through AppEnvironment; point it at this file's temp dir. - installFakeAppEnvironment({ getPath: () => testState.dir }) - const { Store, initDataPath } = await import('../persistence') - initDataPath() - return createSqliteTestStore(Store, { dataFile: join(testState.dir, 'orca-data.json') }) -} - /** Simulate registry drift after a record was stored; the create path derives contexts itself. */ function mutateDataFile(mutate: (state: { automations: Record[] }) => void): void { const file = join(testState.dir, 'orca-data.json') diff --git a/src/main/runtime/orca-runtime-create-managed-worktree.ts b/src/main/runtime/orca-runtime-create-managed-worktree.ts index 8e55f10700d6..10eb7080e1f0 100644 --- a/src/main/runtime/orca-runtime-create-managed-worktree.ts +++ b/src/main/runtime/orca-runtime-create-managed-worktree.ts @@ -161,26 +161,27 @@ export class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWork : {}) } } + const localCreate = await createRuntimeLocalManagedWorktree({ + request: args, + repo, + store: this.requireStore(), + createdWithAgent: effectiveCreatedWithAgent, + hostedReviewExecutionContext: this.getHostedReviewExecutionOptions(repo), + resolveRemoteTrackingBase: (path, base, ...options) => + this.resolveRemoteTrackingBase(path, base, ...options), + hasRemoteTrackingRef: (path, base, ...options) => + this.hasRemoteTrackingRef(path, base, ...options), + refreshRemoteTrackingBase: (path, base, ...options) => + this.getOrStartRemoteTrackingBaseRefresh(path, base, ...options), + fetchRemote: (path, remote, ...options) => + this.fetchRemoteWithCache(path, remote, ...options), + onWorktreeMetadataPersisted: (persistedWorktree) => + this.recordCreatedWorktreeLineage(persistedWorktree, lineageResolution), + rearm, + timing + }) const { worktree, worktreePath, includeCopyWarning, created, addResult, metadataResult } = - await createRuntimeLocalManagedWorktree({ - request: args, - repo, - store: this.requireStore(), - createdWithAgent: effectiveCreatedWithAgent, - hostedReviewExecutionContext: this.getHostedReviewExecutionOptions(repo), - resolveRemoteTrackingBase: (path, base, ...options) => - this.resolveRemoteTrackingBase(path, base, ...options), - hasRemoteTrackingRef: (path, base, ...options) => - this.hasRemoteTrackingRef(path, base, ...options), - refreshRemoteTrackingBase: (path, base, ...options) => - this.getOrStartRemoteTrackingBaseRefresh(path, base, ...options), - fetchRemote: (path, remote, ...options) => - this.fetchRemoteWithCache(path, remote, ...options), - onWorktreeMetadataPersisted: (persistedWorktree) => - this.recordCreatedWorktreeLineage(persistedWorktree, lineageResolution), - rearm, - timing - }) + localCreate const settings = createSettings const { lineage, workspaceLineage, warnings: lineageWarnings } = metadataResult @@ -299,6 +300,7 @@ export class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWork : {}), ...(defaultTabs ? { defaultTabs } : {}), ...(warning ? { warning } : {}), + ...(localCreate.baseFallback ? { baseFallback: localCreate.baseFallback } : {}), ...(addResult.localBaseRefRefresh ? { localBaseRefRefresh: addResult.localBaseRefRefresh } : {}), diff --git a/src/main/runtime/orca-runtime-tests/local-worktree-creation.spec.ts b/src/main/runtime/orca-runtime-tests/local-worktree-creation.spec.ts index 0669ebe82798..c6837278de44 100644 --- a/src/main/runtime/orca-runtime-tests/local-worktree-creation.spec.ts +++ b/src/main/runtime/orca-runtime-tests/local-worktree-creation.spec.ts @@ -515,6 +515,73 @@ describe('OrcaRuntimeService', () => { } }) + it('creates from the local branch only when the caller opts in and the remote base cannot be fetched', async () => { + const runtime = new OrcaRuntimeService(store) + const createdWorktree = { + path: '/tmp/workspaces/cli-offline-base', + head: 'main-sha', + branch: 'cli-offline-base', + isBare: false, + isMainWorktree: false + } + computeWorktreePathMock.mockReturnValue(createdWorktree.path) + ensurePathWithinWorkspaceMock.mockReturnValue(createdWorktree.path) + vi.mocked(addWorktree).mockResolvedValueOnce({}) + vi.mocked(listWorktrees).mockResolvedValue([createdWorktree]) + const gitSpy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockImplementation(async (args) => { + if (args[0] === 'remote') { + return { stdout: 'origin\n', stderr: '' } + } + if (args[0] === 'rev-parse' && args.includes('--git-common-dir')) { + return { stdout: '/tmp/repo/.git\n', stderr: '' } + } + // Only the local branch exists; there is no `refs/remotes/origin/main` yet. + if (args[0] === 'rev-parse' && args.includes('refs/heads/main^{commit}')) { + return { stdout: 'main-sha\n', stderr: '' } + } + if (args[0] === 'rev-parse' && args[1] === '--verify') { + throw new Error('missing ref') + } + if (args.includes('fetch')) { + throw new Error('network unavailable') + } + return { stdout: '', stderr: '' } + }) + try { + await expect( + runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'cli-offline-base', + baseBranch: 'origin/main' + }) + ).rejects.toThrow( + 'Could not refresh base ref "origin/main" from "origin". Check your network and try again.' + ) + expect(addWorktree).not.toHaveBeenCalled() + + const result = await runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'cli-offline-base', + baseBranch: 'origin/main', + allowLocalBaseFallback: true + }) + + expect(addWorktree).toHaveBeenCalledWith( + TEST_REPO_PATH, + createdWorktree.path, + 'cli-offline-base', + 'main', + false, + false, + {} + ) + expect(result.baseFallback).toEqual({ requestedRef: 'origin/main', localRef: 'main' }) + expect(result.worktree).toMatchObject({ path: createdWorktree.path, baseRef: 'main' }) + } finally { + gitSpy.mockRestore() + } + }) + it('creates a branchNameOverride worktree from the selected matching remote base ref', async () => { const runtime = new OrcaRuntimeService(store) vi.spyOn(gitRunner, 'gitExecFileAsync').mockResolvedValue({ stdout: '', stderr: '' }) diff --git a/src/main/runtime/rpc/methods/agent-launch-caller-selection.ts b/src/main/runtime/rpc/methods/agent-launch-caller-selection.ts index 2260c1af2f2c..04480ac091c4 100644 --- a/src/main/runtime/rpc/methods/agent-launch-caller-selection.ts +++ b/src/main/runtime/rpc/methods/agent-launch-caller-selection.ts @@ -3,9 +3,9 @@ * * A paired client (a phone, or a desktop client of a remote server) that launches into an existing * workspace gets the new tab as its own selection, recorded the way `session.tabs.createTerminal` - * selects the tab it creates for its caller. In-process callers keep today's behaviour, and a - * launch that creates its workspace keeps `worktree.create`'s navigation, whose host activation is - * what runs the new workspace's setup. + * selects the tab it creates for its caller. In-process callers keep today's behaviour, and their + * launch that creates its workspace keeps `worktree.create`'s navigation. A paired client's create + * never activates the host window: its setup and default tabs are provisioned in the background. */ import type { AgentLaunchTarget } from '../../../../shared/agent-launch-intent' diff --git a/src/main/runtime/rpc/methods/agent-launch-floating-workspace.test.ts b/src/main/runtime/rpc/methods/agent-launch-floating-workspace.test.ts index 3331c8091816..f51e53369eae 100644 --- a/src/main/runtime/rpc/methods/agent-launch-floating-workspace.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-floating-workspace.test.ts @@ -132,6 +132,8 @@ describe('agent.launch with the real floating workspace resolver', () => { startupAgent: 'codex', // Derived on the host by the window's rule, from the same chat-view setting. viewMode: 'terminal', + // A paired device's launch never moves the desktop window. + surfaceOwner: false, onPtySpawnDispatched: expect.any(Function) } ) diff --git a/src/main/runtime/rpc/methods/agent-launch-instant-tab.test.ts b/src/main/runtime/rpc/methods/agent-launch-instant-tab.test.ts index 7d75ebcc56b7..75a801d75983 100644 --- a/src/main/runtime/rpc/methods/agent-launch-instant-tab.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-instant-tab.test.ts @@ -74,6 +74,15 @@ const PHONE: Partial = { ] } +const PAIRED_DESKTOP: Partial = { + clientKind: 'runtime', + pairedDeviceId: 'device-2', + clientCapabilities: [ + AGENT_LAUNCH_RUNTIME_CAPABILITY, + AGENT_LAUNCH_UNSTARTED_TAB_CLIENT_CAPABILITY + ] +} + const PHONE_READING_TAB_CLOSED: Partial = { ...OLD_PHONE, clientCapabilities: [ @@ -722,8 +731,9 @@ describe('whose view moves', () => { undefined, 'focus-in-workspace' ], - ['a phone reveals the workspace on the desktop, as before', PHONE, 'focused', 'reveal-owner'], - ['a phone by default reveals it too', PHONE, undefined, 'reveal-owner'], + ['a phone asking for focus leaves the desktop where it is', PHONE, 'focused', 'none'], + ['a phone by default leaves it too', PHONE, undefined, 'none'], + ['a paired desktop leaves the host window too', PAIRED_DESKTOP, 'focused', 'none'], ['background moves nobody', CLI, 'background', 'none'] ] as const)('%s', async (_name, context, presentation, viewer) => { const runtime = hostWithWindow() @@ -733,6 +743,31 @@ describe('whose view moves', () => { expect(runtime.published[0]?.viewer).toBe(viewer) }) + it.each([ + ['a phone that reads no early tab', OLD_PHONE, replayLaunch], + ['a phone launch with no operation id', PHONE, plainLaunch] + ] as const)( + 'leaves the desktop where it is when the agent spawns with no tab shown first: %s', + async (_name, context, run) => { + const runtime = hostWithWindow() + + await run(runtime, {}, context) + + expect(runtime.published).toHaveLength(0) + expect(terminalOptions(runtime)).toMatchObject({ surfaceOwner: false }) + } + ) + + it('still reveals a local launch that spawns with no tab shown first', async () => { + const runtime = hostWithWindow() + + await plainLaunch(runtime, {}, CLI) + + expect(runtime.published).toHaveLength(0) + expect(runtime.createTerminal).toHaveBeenCalledOnce() + expect(terminalOptions(runtime)).not.toHaveProperty('surfaceOwner') + }) + it("moves the phone's own selection unless it asked for the background", async () => { const focused = hostWithWindow({ terminalPaneKey: PANE_KEY }) await replayLaunch(focused, { paneKey: PANE_KEY }, PHONE) diff --git a/src/main/runtime/rpc/methods/agent-launch-surfaces.ts b/src/main/runtime/rpc/methods/agent-launch-surfaces.ts index d25fa7acd28d..0727f31bef0b 100644 --- a/src/main/runtime/rpc/methods/agent-launch-surfaces.ts +++ b/src/main/runtime/rpc/methods/agent-launch-surfaces.ts @@ -37,7 +37,10 @@ import { type TerminalSpawnDispatch } from '../../../agent-launch/agent-launch-not-started' import { AgentLaunchTabClosedError } from '../../../../shared/agent-launch-tab-closed' -import type { EarlyAgentLaunchTab } from './agent-launch-tab-publication' +import { + agentLaunchMovesHostWindow, + type EarlyAgentLaunchTab +} from './agent-launch-tab-publication' /** Replay-safe launches keep the nested attach in the same stable caller namespace as the launch. */ export function agentLaunchSurfaceFactory( @@ -51,6 +54,7 @@ export function agentLaunchSurfaceFactory( // close can both land after admission. earlyTab: Pick | null = null ): AgentLaunchSurfaceFactory { + const movesHostWindow = agentLaunchMovesHostWindow(context) return { createStructuredSession: async ({ worktreeId, @@ -154,7 +158,7 @@ export function agentLaunchSurfaceFactory( ...(paneKey ? { ...paneIdentity(paneKey), requireFreshPane: true } : {}), ...(launchSource ? { launchSource } : {}), ...(viewMode ? { viewMode } : {}), - ...(earlyTab?.windowShowsTab() ? { surfaceOwner: false as const } : {}), + ...(earlyTab?.windowShowsTab() || !movesHostWindow ? { surfaceOwner: false as const } : {}), onPtySpawnDispatched: terminalSpawn.onPtySpawnDispatched }) const terminal = await created.catch(terminalSpawn.rethrow) diff --git a/src/main/runtime/rpc/methods/agent-launch-tab-publication.ts b/src/main/runtime/rpc/methods/agent-launch-tab-publication.ts index c27e2edf8539..fd4d29bd5476 100644 --- a/src/main/runtime/rpc/methods/agent-launch-tab-publication.ts +++ b/src/main/runtime/rpc/methods/agent-launch-tab-publication.ts @@ -48,25 +48,33 @@ import type { RpcContext } from '../core' import type { AgentLaunchParams } from './agent-launch-schemas' import { agentLaunchOperationCallerKey } from './agent-launch-replay' +/** Whether a launch may move the host window at all: a paired device's launch moves only its own + * view, as its browser tabs do. The tab shown first and the spawn's own reveal both ask this. */ +export function agentLaunchMovesHostWindow( + context: Pick +): boolean { + return ( + context.caller?.kind === 'desktop' || + navigationTargetsHost(resolveRuntimeNavigationTarget({ clientKind: context.clientKind })) + ) +} + /** * Whose screen moves. `presentation` is `terminal.create`'s field with its meaning (focused: the * host selects the tab; background: nothing moves). The desktop's own view is the host window, - * which follows only while it still shows that workspace. A caller whose navigation is elsewhere (a - * paired device) gets the window's reveal it always got, as well as its own selection. + * which follows only while it still shows that workspace. A paired device's launch leaves the host + * window where it is; the device selects the tab for itself. */ export function agentLaunchTabViewerRule( context: Pick, presentation: AgentLaunchParams['presentation'] ): AgentLaunchTabViewerRule { - if (presentation === 'background') { + if (presentation === 'background' || !agentLaunchMovesHostWindow(context)) { return 'none' } if (context.caller?.kind === 'desktop') { return 'focus-in-workspace' } - if (!navigationTargetsHost(resolveRuntimeNavigationTarget({ clientKind: context.clientKind }))) { - return 'reveal-owner' - } return presentation === 'focused' ? 'focus-window' : 'reveal-owner' } diff --git a/src/main/runtime/rpc/methods/agent-launch-worktree-creation.ts b/src/main/runtime/rpc/methods/agent-launch-worktree-creation.ts index 72cc48c3bc3e..ff79e44b7642 100644 --- a/src/main/runtime/rpc/methods/agent-launch-worktree-creation.ts +++ b/src/main/runtime/rpc/methods/agent-launch-worktree-creation.ts @@ -23,6 +23,7 @@ import { resolveRpcWorkspaceCreatorProvenance } from '../workspace-creator-conte import { buildManagedWorktreeCreateArgs } from './worktree-create-args' import { toAgentLaunchPreferences } from '../../../../shared/agent-launch-preferences' import type { AgentLaunchParams } from './agent-launch-schemas' +import { agentLaunchMovesHostWindow } from './agent-launch-tab-publication' type WorktreeCreateParams = Extract< AgentLaunchParams['target'], @@ -63,7 +64,7 @@ export function agentLaunchWorkspaceFactory( const result = await runtime.createManagedWorktree({ ...buildManagedWorktreeCreateArgs( { - ...params, + ...(agentLaunchMovesHostWindow(context) ? params : withoutHostActivation(params)), ...(startupAgent ? { startupAgent } : {}), // Only ever set alongside `startupAgent`, which is what the create requires; the // executor offers it only to an agent that takes its prompt on argv, and it rides only @@ -125,6 +126,21 @@ export function agentLaunchWorkspaceFactory( } } +/** + * A paired device's create leaves the host window where it is, as the rest of its launch does: + * activating would switch the desktop to the new workspace and reveal the startup terminal there. + * Without it, setup and default tabs are provisioned in the background instead. `runHooks` means + * "run setup, and activate", so only its setup half is kept. + */ +function withoutHostActivation(params: WorktreeCreateParams): WorktreeCreateParams { + return { + ...params, + activate: false, + runHooks: false, + ...(params.runHooks === true ? { setupDecision: 'run' as const } : {}) + } +} + async function waitForStructuredSetup( runtime: Pick, receipt: Awaited>['setupReceipt'] diff --git a/src/main/runtime/rpc/methods/agent-launch.test.ts b/src/main/runtime/rpc/methods/agent-launch.test.ts index 4f15465565e6..581a86115602 100644 --- a/src/main/runtime/rpc/methods/agent-launch.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch.test.ts @@ -213,6 +213,13 @@ describe('the worktree factory', () => { expect(result.outcome.kind).toBe('structured') }) + it('lets the create fall back to a local base when the launch carries no automation provenance', async () => { + const runtime = runtimeStub() + await launch(CREATE_LAUNCH, runtime) + + expect(createArgs(runtime).allowLocalBaseFallback).toBe(true) + }) + it('deduplicates concurrent launches through surface creation', async () => { const runtime = runtimeStub() @@ -378,6 +385,67 @@ describe('the worktree factory', () => { }) }) +describe('whose window a new workspace moves', () => { + const ACTIVATING_CREATE_LAUNCH = { + agent: 'claude', + target: { + kind: 'create-worktree', + create: { ...CREATE_LAUNCH.target.create, activate: true, runHooks: true } + } + } + const PAIRED_DESKTOP: Partial = { ...CAPABLE_CLIENT, clientKind: 'runtime' } + + it.each([ + ['a phone', 'chat', CAPABLE_CLIENT], + ['a phone', 'terminal', CAPABLE_CLIENT], + ['a paired desktop', 'chat', PAIRED_DESKTOP], + ['a paired desktop', 'terminal', PAIRED_DESKTOP] + ] as const)( + '%s creates without activating the host, and its setup still runs (%s)', + async (_name, mode, context) => { + const runtime = runtimeStub(mode === 'terminal' ? { settings: {} } : {}) + await launch(ACTIVATING_CREATE_LAUNCH, runtime, context) + + // Unactivated, the create provisions setup and default tabs in the background. + expect(createArgs(runtime)).toMatchObject({ + activate: false, + runHooks: false, + setupDecision: 'run', + awaitTerminalProvisioning: true + }) + } + ) + + it("keeps a paired device's own setup decision when it asked only to activate", async () => { + const runtime = runtimeStub() + await launch( + { + agent: 'claude', + target: { + kind: 'create-worktree', + create: { ...CREATE_LAUNCH.target.create, activate: true, setupDecision: 'skip' } + } + }, + runtime + ) + + expect(createArgs(runtime)).toMatchObject({ + activate: false, + runHooks: false, + setupDecision: 'skip' + }) + }) + + it('still activates a create the CLI asked to activate', async () => { + const runtime = runtimeStub({ settings: {} }) + await launch(ACTIVATING_CREATE_LAUNCH, runtime, {}) + + const args = createArgs(runtime) + expect(args).toMatchObject({ activate: true, runHooks: true }) + expect(args.setupDecision).toBeUndefined() + }) +}) + describe('the structured session factory', () => { it('creates the session for the worktree the launch just made, and activates it', async () => { const runtime = runtimeStub() @@ -482,6 +550,8 @@ describe('the terminal factory', () => { startupAgent: 'claude', // The host derives the tab's first view by the window's rule; chat view is on by default here. viewMode: 'chat', + // A paired device's launch never moves the desktop window. + surfaceOwner: false, onPtySpawnDispatched: expect.any(Function) }) expect(createStructuredSession).not.toHaveBeenCalled() @@ -507,6 +577,7 @@ describe('the terminal factory', () => { startupAgent: 'gemini', // No native chat renderer for this agent, so its tab opens as the terminal. viewMode: 'terminal', + surfaceOwner: false, onPtySpawnDispatched: expect.any(Function) }) expect(result.worktreeId).toBe('wt-7') diff --git a/src/main/runtime/rpc/methods/worktree-create-args.test.ts b/src/main/runtime/rpc/methods/worktree-create-args.test.ts index af74848a1e17..49fab597781a 100644 --- a/src/main/runtime/rpc/methods/worktree-create-args.test.ts +++ b/src/main/runtime/rpc/methods/worktree-create-args.test.ts @@ -54,4 +54,28 @@ describe('buildManagedWorktreeCreateArgs', () => { }).lineage ).toMatchObject({ parentWorkspaceOrigin: 'manual' }) }) + + it('lets a person-driven create fall back to a local base, but not an automation run', () => { + expect(build({ repo: 'id:repo-1', name: 'feature' })).toMatchObject({ + allowLocalBaseFallback: true + }) + const automationRun = buildManagedWorktreeCreateArgs( + WorktreeCreate.parse({ repo: 'id:repo-1', name: 'nightly' }), + { + ...PROVENANCE, + automationProvenance: { + kind: 'created-by-automation', + automationId: 'automation-1', + automationNameSnapshot: 'Nightly', + automationRunId: 'run-1', + automationRunTitleSnapshot: 'Nightly run', + createdAt: 1, + executionTargetType: 'local', + executionTargetId: 'local', + projectId: 'repo-1' + } + } + ) + expect(automationRun).not.toHaveProperty('allowLocalBaseFallback') + }) }) diff --git a/src/main/runtime/rpc/methods/worktree-create-args.ts b/src/main/runtime/rpc/methods/worktree-create-args.ts index ac4c349d7562..623d553d3d61 100644 --- a/src/main/runtime/rpc/methods/worktree-create-args.ts +++ b/src/main/runtime/rpc/methods/worktree-create-args.ts @@ -66,6 +66,9 @@ export function buildManagedWorktreeCreateArgs( setupDecision: params.setupDecision, createdWithAgent: params.createdWithAgent ?? params.startupAgent, ...provenance, + // Why: a person initiated this create (the phone, CLI text and agent.launch don't surface the + // fallback yet); an automation run has no one watching, so it keeps the network error. + ...(provenance.automationProvenance ? {} : { allowLocalBaseFallback: true }), startup: params.startupCommand ? { command: params.startupCommand, diff --git a/src/main/runtime/rpc/methods/worktree.test.ts b/src/main/runtime/rpc/methods/worktree.test.ts index cfe5441ba7a3..3ebd27dd90a8 100644 --- a/src/main/runtime/rpc/methods/worktree.test.ts +++ b/src/main/runtime/rpc/methods/worktree.test.ts @@ -136,6 +136,7 @@ describe('worktree RPC methods', () => { setupDecision: 'skip', createdWithAgent: undefined, automationProvenance: undefined, + allowLocalBaseFallback: true, creatorProvenance: { kind: 'host' }, startup: undefined, startupDraft: undefined, @@ -254,6 +255,10 @@ describe('worktree RPC methods', () => { }) }) ) + // Why: an automation run has nobody to tell, so it keeps the network error offline. + expect(vi.mocked(runtime.createManagedWorktree).mock.calls[0]?.[0]).not.toHaveProperty( + 'allowLocalBaseFallback' + ) }) it('stamps automation provenance with the persisted runtime host from run context', async () => { diff --git a/src/main/runtime/runtime-local-git-worktree-create.ts b/src/main/runtime/runtime-local-git-worktree-create.ts index 353375999e7f..9e5d4cdfac33 100644 --- a/src/main/runtime/runtime-local-git-worktree-create.ts +++ b/src/main/runtime/runtime-local-git-worktree-create.ts @@ -14,7 +14,7 @@ import { import type { RuntimeStore } from './runtime-store-contract' import type { RuntimeManagedWorktreeCreateArgs } from './runtime-managed-worktree-create-types' import type { RemoteFetchResult, RemoteTrackingBase } from './runtime-remote-fetch-controller' -import { hasLocalWorktreeBaseRef } from '../git/worktree-base-ref-probe' +import type { RuntimeLocalWorktreeCreateBase } from './runtime-local-worktree-create-base' import { isGeneratedWorktreeCreateName } from '../worktree-create-candidates' import { consumePreparedWorktreeCreate, @@ -36,23 +36,13 @@ export async function createRuntimeLocalGitWorktree(args: { refreshLocalBaseRefOnWorktreeCreate: boolean localBaseRefSuggestionDismissed?: boolean } - baseBranch: string + base: RuntimeLocalWorktreeCreateBase workspaceRoot: string branchName: string worktreePath: string effectiveSanitizedName?: string checkoutExistingBranch: boolean localWorktreeGitOptions: LocalGitExecOptions - resolveRemoteTrackingBase: ( - repoPath: string, - baseBranch: string, - options?: LocalGitExecOptions - ) => Promise - hasRemoteTrackingRef: ( - repoPath: string, - base: RemoteTrackingBase, - options?: LocalGitExecOptions - ) => Promise refreshRemoteTrackingBase: ( repoPath: string, base: RemoteTrackingBase, @@ -62,49 +52,21 @@ export async function createRuntimeLocalGitWorktree(args: { rearm: PreparationRearmHolder timing: WorktreeCreateTimingRecorder }): Promise<{ - remoteTrackingBase: RemoteTrackingBase | null sparseDirectories: string[] configuredPushTarget?: GitPushTarget created: GitWorktreeInfo addResult: AddWorktreeResult }> { - let remoteTrackingBase = await args.resolveRemoteTrackingBase( - args.repo.path, - args.baseBranch, - args.localWorktreeGitOptions - ) - if (remoteTrackingBase) { - const [hadRemoteRef, hasNamedLocalBaseRef] = await Promise.all([ - args.hasRemoteTrackingRef(args.repo.path, remoteTrackingBase, args.localWorktreeGitOptions), - hasLocalWorktreeBaseRef(args.repo.path, args.baseBranch, args.localWorktreeGitOptions) - ]) - const hasLocalBase = hadRemoteRef || hasNamedLocalBaseRef - if (!hadRemoteRef && hasLocalBase) { - remoteTrackingBase = null - } else { - const base = remoteTrackingBase - const refresh = await args.timing.time('refresh_base_ref', () => - args.refreshRemoteTrackingBase(args.repo.path, base, args.localWorktreeGitOptions) + const { baseBranch, remoteTrackingBase, deferredRefresh } = args.base + if (remoteTrackingBase && deferredRefresh === 'tracking_ref') { + await args.timing.time('refresh_base_ref', () => + args.refreshRemoteTrackingBase( + args.repo.path, + remoteTrackingBase, + args.localWorktreeGitOptions ) - if (!refresh.ok && !hadRemoteRef) { - throw new Error( - `Could not refresh base ref "${args.baseBranch}" from "${remoteTrackingBase.remote}". Check your network and try again.` - ) - } - if ( - !hadRemoteRef && - !(await args.hasRemoteTrackingRef( - args.repo.path, - remoteTrackingBase, - args.localWorktreeGitOptions - )) - ) { - throw new Error(`Base ref "${args.baseBranch}" was not found after fetching.`) - } - } - } else if ( - !(await hasLocalWorktreeBaseRef(args.repo.path, args.baseBranch, args.localWorktreeGitOptions)) - ) { + ) + } else if (deferredRefresh === 'origin') { try { await args.timing.time('refresh_base_ref', () => args.fetchRemote(args.repo.path, 'origin', args.localWorktreeGitOptions) @@ -154,7 +116,7 @@ export async function createRuntimeLocalGitWorktree(args: { workspaceRoot: args.workspaceRoot, worktreePath: args.worktreePath, branch: args.branchName, - baseBranch: args.baseBranch, + baseBranch, refreshLocalBaseRef: args.settings.refreshLocalBaseRefOnWorktreeCreate, options: preparedWorktreeOptions, timing: args.timing @@ -176,7 +138,7 @@ export async function createRuntimeLocalGitWorktree(args: { args.worktreePath, args.branchName, sparseDirectories, - args.baseBranch, + baseBranch, args.settings.refreshLocalBaseRefOnWorktreeCreate, addOptions )) ?? {} @@ -187,7 +149,7 @@ export async function createRuntimeLocalGitWorktree(args: { args.repo.path, args.worktreePath, args.branchName, - args.baseBranch, + baseBranch, args.settings.refreshLocalBaseRefOnWorktreeCreate, false, addOptions @@ -241,7 +203,6 @@ export async function createRuntimeLocalGitWorktree(args: { args.timing.recordWorktreeCount(worktrees.length) } return { - remoteTrackingBase, sparseDirectories, ...(configuredPushTarget ? { configuredPushTarget } : {}), created, diff --git a/src/main/runtime/runtime-local-worktree-create-base.ts b/src/main/runtime/runtime-local-worktree-create-base.ts new file mode 100644 index 000000000000..b2764ccf6710 --- /dev/null +++ b/src/main/runtime/runtime-local-worktree-create-base.ts @@ -0,0 +1,88 @@ +import type { LocalGitExecOptions } from '../git/repo-default-base-ref' +import type { WorktreeCreateBaseFallback } from '../../shared/worktree/create-types' +import { hasLocalWorktreeBaseRef } from '../git/worktree-base-ref-probe' +import type { WorktreeCreateTimingRecorder } from '../worktree-create-timing' +import type { RemoteFetchResult, RemoteTrackingBase } from './runtime-remote-fetch-controller' + +export type RuntimeLocalWorktreeCreateBase = { + /** The base the add uses: the request's, or the local branch it fell back to. */ + baseBranch: string + remoteTrackingBase: RemoteTrackingBase | null + /** + * Best-effort refresh the git create runs after naming, since it cannot change the base: the + * existing tracking ref, or `fetch origin` for a plain base that is not local yet. + */ + deferredRefresh: 'tracking_ref' | 'origin' | null + baseFallback?: WorktreeCreateBaseFallback +} + +/** + * Decides the base before naming, because branch reuse and conflict checks must see the base the + * add will use, including the local fallback. + */ +export async function resolveRuntimeLocalWorktreeCreateBase(args: { + repoPath: string + baseBranch: string + localWorktreeGitOptions: LocalGitExecOptions + allowLocalBaseFallback: boolean + resolveRemoteTrackingBase: ( + repoPath: string, + baseBranch: string, + options?: LocalGitExecOptions + ) => Promise + hasRemoteTrackingRef: ( + repoPath: string, + base: RemoteTrackingBase, + options?: LocalGitExecOptions + ) => Promise + refreshRemoteTrackingBase: ( + repoPath: string, + base: RemoteTrackingBase, + options?: LocalGitExecOptions + ) => Promise + timing: WorktreeCreateTimingRecorder +}): Promise { + const { repoPath, baseBranch, localWorktreeGitOptions: options } = args + const remoteTrackingBase = await args.resolveRemoteTrackingBase(repoPath, baseBranch, options) + if (!remoteTrackingBase) { + const hasBase = await hasLocalWorktreeBaseRef(repoPath, baseBranch, options) + return { baseBranch, remoteTrackingBase: null, deferredRefresh: hasBase ? null : 'origin' } + } + const [hadRemoteRef, hasNamedLocalBaseRef] = await Promise.all([ + args.hasRemoteTrackingRef(repoPath, remoteTrackingBase, options), + hasLocalWorktreeBaseRef(repoPath, baseBranch, options) + ]) + if (hadRemoteRef) { + return { baseBranch, remoteTrackingBase, deferredRefresh: 'tracking_ref' } + } + if (hasNamedLocalBaseRef) { + // Why: a local ref of the requested name (often a plain local branch like `team/feature`) is + // the base asked for, so there is nothing to fetch and no fallback to report. + return { baseBranch, remoteTrackingBase: null, deferredRefresh: null } + } + const refresh = await args.timing.time('refresh_base_ref', () => + args.refreshRemoteTrackingBase(repoPath, remoteTrackingBase, options) + ) + if (!refresh.ok) { + // Why: fetch first so an online create still gets the fresh remote base; only when that fails + // (offline) does the local branch the remote names beat failing the create, and the result says so. + if ( + args.allowLocalBaseFallback && + (await hasLocalWorktreeBaseRef(repoPath, remoteTrackingBase.branch, options)) + ) { + return { + baseBranch: remoteTrackingBase.branch, + remoteTrackingBase: null, + deferredRefresh: null, + baseFallback: { requestedRef: remoteTrackingBase.base, localRef: remoteTrackingBase.branch } + } + } + throw new Error( + `Could not refresh base ref "${baseBranch}" from "${remoteTrackingBase.remote}". Check your network and try again.` + ) + } + if (!(await args.hasRemoteTrackingRef(repoPath, remoteTrackingBase, options))) { + throw new Error(`Base ref "${baseBranch}" was not found after fetching.`) + } + return { baseBranch, remoteTrackingBase, deferredRefresh: null } +} diff --git a/src/main/runtime/runtime-local-worktree-create.test.ts b/src/main/runtime/runtime-local-worktree-create.test.ts index 87f19ab07cd8..979b05b90907 100644 --- a/src/main/runtime/runtime-local-worktree-create.test.ts +++ b/src/main/runtime/runtime-local-worktree-create.test.ts @@ -361,6 +361,138 @@ describe('runtime create Git priority', () => { }) }) +describe('runtime create base without a tracking ref', () => { + const originMain = { + remote: 'origin', + branch: 'main', + ref: 'refs/remotes/origin/main', + base: 'origin/main' + } + + beforeEach(() => { + mocks.remoteBase.mockResolvedValue(originMain) + mocks.hasRemoteRef.mockResolvedValue(false) + // Only the local branch the remote names exists; `origin/main` itself does not resolve. + mocks.hasBase.mockImplementation(async (_repo: string, ref: string) => ref === 'main') + }) + + it('fetches the remote base first and creates from it when the fetch works', async () => { + mocks.refresh.mockImplementation(async () => { + mocks.hasRemoteRef.mockResolvedValue(true) + return { ok: true } + }) + + const result = await createWorktree({ baseBranch: 'origin/main' }) + + expect(mocks.refresh).toHaveBeenCalledOnce() + expect(mocks.consume).toHaveBeenCalledWith( + expect.objectContaining({ + baseBranch: 'origin/main', + options: expect.objectContaining({ remoteTrackingBase: originMain }) + }) + ) + expect(result).not.toHaveProperty('baseFallback') + expect(result.worktree.baseRef).toBe('refs/remotes/origin/main') + }) + + it('names the workspace only after the fetch has settled', async () => { + let refreshSettled = false + mocks.refresh.mockImplementation(async () => { + await Promise.resolve() + mocks.hasRemoteRef.mockResolvedValue(true) + refreshSettled = true + return { ok: true } + }) + const settledAtNaming: boolean[] = [] + mocks.canCheckout.mockImplementation(async () => { + settledAtNaming.push(refreshSettled) + return false + }) + + await createWorktree({ baseBranch: 'origin/main', branchNameOverride: 'app' }) + + expect(settledAtNaming).toEqual([true]) + expect(mocks.canCheckout).toHaveBeenCalledWith('/repo', 'app', 'origin/main', {}) + expect(mocks.branchConflict).toHaveBeenCalledWith('/repo', 'app', 'origin/main', {}, undefined) + }) + + it('keeps the not-found error when the fetch works but the tracking ref is still missing', async () => { + mocks.hasBase.mockResolvedValue(false) + mocks.refresh.mockResolvedValue({ ok: true }) + + await expect(createWorktree({ baseBranch: 'origin/main' })).rejects.toThrow( + 'Base ref "origin/main" was not found after fetching.' + ) + expect(mocks.consume).not.toHaveBeenCalled() + }) + + it('creates from the local branch and reports it when the fetch fails', async () => { + mocks.refresh.mockResolvedValue({ ok: false, errorKind: 'git_error' }) + + const result = await createWorktree({ baseBranch: 'origin/main', allowLocalBaseFallback: true }) + + expect(mocks.consume).toHaveBeenCalledWith( + expect.objectContaining({ + baseBranch: 'main', + options: expect.not.objectContaining({ remoteTrackingBase: expect.anything() }) + }) + ) + expect(result.baseFallback).toEqual({ requestedRef: 'origin/main', localRef: 'main' }) + expect(result.addResult).not.toHaveProperty('baseFallback') + expect(result.worktree.baseRef).toBe('main') + }) + + it('decides the base before branch reuse and conflict checks', async () => { + mocks.refresh.mockResolvedValue({ ok: false, errorKind: 'git_error' }) + + await createWorktree({ + baseBranch: 'origin/main', + branchNameOverride: 'app', + allowLocalBaseFallback: true + }) + + expect(mocks.refresh.mock.invocationCallOrder[0]).toBeLessThan( + mocks.canCheckout.mock.invocationCallOrder[0] + ) + expect(mocks.canCheckout).toHaveBeenCalledWith('/repo', 'app', 'main', {}) + expect(mocks.branchConflict).toHaveBeenCalledWith('/repo', 'app', 'main', {}, undefined) + }) + + it("keeps the network error when the fetch fails and there's no local branch", async () => { + mocks.hasBase.mockResolvedValue(false) + mocks.refresh.mockResolvedValue({ ok: false, errorKind: 'git_error' }) + + await expect( + createWorktree({ baseBranch: 'origin/main', allowLocalBaseFallback: true }) + ).rejects.toThrow( + 'Could not refresh base ref "origin/main" from "origin". Check your network and try again.' + ) + expect(mocks.branchName).not.toHaveBeenCalled() + expect(mocks.consume).not.toHaveBeenCalled() + }) + + it('keeps the network error for a create that did not opt into the fallback', async () => { + mocks.refresh.mockResolvedValue({ ok: false, errorKind: 'git_error' }) + + await expect(createWorktree({ baseBranch: 'origin/main' })).rejects.toThrow( + 'Could not refresh base ref "origin/main" from "origin". Check your network and try again.' + ) + expect(mocks.consume).not.toHaveBeenCalled() + }) + + it('uses a local ref of the requested name without fetching or reporting a fallback', async () => { + mocks.hasBase.mockResolvedValue(true) + + const result = await createWorktree({ baseBranch: 'origin/main' }) + + expect(mocks.refresh).not.toHaveBeenCalled() + expect(mocks.consume).toHaveBeenCalledWith( + expect.objectContaining({ baseBranch: 'origin/main' }) + ) + expect(result).not.toHaveProperty('baseFallback') + }) +}) + describe('runtime create setup decision', () => { const askRepo: Partial = { hookSettings: { ...getDefaultRepoHookSettings(), setupRunPolicy: 'ask' } diff --git a/src/main/runtime/runtime-local-worktree-create.ts b/src/main/runtime/runtime-local-worktree-create.ts index c78ff86d41fc..4821bd7092ab 100644 --- a/src/main/runtime/runtime-local-worktree-create.ts +++ b/src/main/runtime/runtime-local-worktree-create.ts @@ -20,6 +20,7 @@ import type { HostedReviewExecutionOptions } from '../source-control/hosted-revi import { hasLocalWorktreeBaseRef } from '../git/worktree-base-ref-probe' import { resolveRuntimeLocalWorktreeCreateCandidate } from './runtime-local-worktree-create-candidate' import { createRuntimeLocalGitWorktree } from './runtime-local-git-worktree-create' +import { resolveRuntimeLocalWorktreeCreateBase } from './runtime-local-worktree-create-base' import { materializeRuntimeLocalWorktree } from './runtime-local-worktree-materialization' import { resolveRuntimeSetupDecision } from './runtime-local-worktree-setup' import type { PreparationRearmHolder } from '../worktree-create-preparation' @@ -102,6 +103,16 @@ async function performRuntimeLocalWorktreeCreate(args: RuntimeLocalWorktreeCr 'Could not resolve a default base ref for this repo. Pass an explicit --base and try again.' ) } + const base = await resolveRuntimeLocalWorktreeCreateBase({ + repoPath: repo.path, + baseBranch, + localWorktreeGitOptions: worktreeGitOptions, + allowLocalBaseFallback: request.allowLocalBaseFallback === true, + resolveRemoteTrackingBase: args.resolveRemoteTrackingBase, + hasRemoteTrackingRef: args.hasRemoteTrackingRef, + refreshRemoteTrackingBase: args.refreshRemoteTrackingBase, + timing: args.timing + }) const candidate = await args.timing.time('resolve_name', () => resolveRuntimeLocalWorktreeCreateCandidate({ request, @@ -111,7 +122,7 @@ async function performRuntimeLocalWorktreeCreate(args: RuntimeLocalWorktreeCr workspaceRoot: computeWorkspaceRoot(repo.path, pathSettings), username, store, - baseBranch, + baseBranch: base.baseBranch, localWorktreeGitOptions: worktreeGitOptions, hostedReviewExecutionContext: args.hostedReviewExecutionContext }) @@ -121,15 +132,13 @@ async function performRuntimeLocalWorktreeCreate(args: RuntimeLocalWorktreeCr repo, store, settings, - baseBranch, + base, workspaceRoot: computeWorkspaceRoot(repo.path, pathSettings), branchName: candidate.branchName, worktreePath: candidate.worktreePath, effectiveSanitizedName: candidate.effectiveSanitizedName, checkoutExistingBranch: candidate.checkoutExistingBranch, localWorktreeGitOptions: worktreeGitOptions, - resolveRemoteTrackingBase: args.resolveRemoteTrackingBase, - hasRemoteTrackingRef: args.hasRemoteTrackingRef, refreshRemoteTrackingBase: args.refreshRemoteTrackingBase, fetchRemote: args.fetchRemote, rearm: args.rearm, @@ -141,11 +150,11 @@ async function performRuntimeLocalWorktreeCreate(args: RuntimeLocalWorktreeCr store, settings, created: git.created, - remoteTrackingBase: git.remoteTrackingBase, + remoteTrackingBase: base.remoteTrackingBase, sparseDirectories: git.sparseDirectories, configuredPushTarget: git.configuredPushTarget, checkoutExistingBranch: candidate.checkoutExistingBranch, - baseBranch, + baseBranch: base.baseBranch, branchName: candidate.branchName, effectiveRequestedName: candidate.effectiveRequestedName, requestedDisplayName: candidate.requestedDisplayName, @@ -160,6 +169,7 @@ async function performRuntimeLocalWorktreeCreate(args: RuntimeLocalWorktreeCr ...materialized, worktreePath: candidate.worktreePath, created: git.created, - addResult: git.addResult + addResult: git.addResult, + ...(base.baseFallback ? { baseFallback: base.baseFallback } : {}) } } diff --git a/src/main/runtime/runtime-managed-worktree-create-types.ts b/src/main/runtime/runtime-managed-worktree-create-types.ts index e92b8beba928..08a151bd88ee 100644 --- a/src/main/runtime/runtime-managed-worktree-create-types.ts +++ b/src/main/runtime/runtime-managed-worktree-create-types.ts @@ -63,6 +63,11 @@ export type RuntimeManagedWorktreeCreateArgs = { startupPaneKey?: string pendingFirstAgentMessageRename?: boolean automationProvenance?: AutomationWorkspaceProvenance + /** + * Host-side only, never on the wire: lets an offline create from a remote base use the local + * branch it names. Only creates a person initiated opt in. + */ + allowLocalBaseFallback?: boolean cliProvenance?: CliWorkspaceProvenance creatorProvenance?: Worktree['creatorProvenance'] startup?: WorktreeStartupLaunch diff --git a/src/renderer/src/components/native-chat/NativeChatCodeBlock.tsx b/src/renderer/src/components/native-chat/NativeChatCodeBlock.tsx index 36a6ac9e2eb5..208802c8538f 100644 --- a/src/renderer/src/components/native-chat/NativeChatCodeBlock.tsx +++ b/src/renderer/src/components/native-chat/NativeChatCodeBlock.tsx @@ -36,7 +36,7 @@ export function NativeChatCodeBlock({
diff --git a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.test.tsx b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.test.tsx
index ec087422f70e..a5592bb9fc0a 100644
--- a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.test.tsx
+++ b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.test.tsx
@@ -18,7 +18,7 @@ const mocks = vi.hoisted(() => ({
   onSaveAgentDefault: vi.fn(),
   onLaunched: vi.fn(),
   onStart: vi.fn(),
-  planSourceControlAgentActionLaunch: vi.fn(),
+  checkSourceControlAgentActionLaunch: vi.fn(),
   toastError: vi.fn()
 }))
 vi.mock('@/components/agent/AgentCombobox', () => ({
@@ -51,8 +51,8 @@ vi.mock('@/components/ui/select', () => ({
 vi.mock('../source-control/SourceControlActionVariableChips', () => ({
   SourceControlActionVariableChips: () => React.createElement('div')
 }))
-vi.mock('@/lib/source-control-agent-action-plan', () => ({
-  planSourceControlAgentActionLaunch: mocks.planSourceControlAgentActionLaunch
+vi.mock('@/lib/source-control-agent-action-launch-check', () => ({
+  checkSourceControlAgentActionLaunch: mocks.checkSourceControlAgentActionLaunch
 }))
 vi.mock('sonner', () => ({
   toast: { error: mocks.toastError }
@@ -181,12 +181,7 @@ describe('SourceControlAgentActionDialog', () => {
     mocks.ensureDetectedAgents.mockResolvedValue(['codex'])
     mocks.ensureRemoteDetectedAgents.mockResolvedValue(['codex'])
     mocks.onStart.mockResolvedValue(true)
-    mocks.planSourceControlAgentActionLaunch.mockReturnValue({
-      ok: true,
-      summary: 'Ready to launch.',
-      commandLabel: 'codex',
-      caveat: 'The prompt will be submitted after the agent is ready.'
-    })
+    mocks.checkSourceControlAgentActionLaunch.mockReturnValue({ ok: true })
     container = document.createElement('div')
     document.body.appendChild(container)
     root = createRoot(container)
diff --git a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.tsx b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.tsx
index dcaf2caf5841..cc0a2707de9f 100644
--- a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.tsx
+++ b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.tsx
@@ -1,13 +1,5 @@
 import React from 'react'
-import {
-  CheckCircle2,
-  Info,
-  RefreshCw,
-  RotateCcw,
-  Settings,
-  Sparkles,
-  TriangleAlert
-} from 'lucide-react'
+import { Info, RefreshCw, RotateCcw, Settings, Sparkles, TriangleAlert } from 'lucide-react'
 import AgentCombobox from '@/components/agent/AgentCombobox'
 import { Button } from '@/components/ui/button'
 import { DialogFooter } from '@/components/ui/dialog'
@@ -32,9 +24,9 @@ import { sourceControlActionRecipeMatchesTarget } from './source-control-action-
 import type { SourceControlAgentScopeNote } from './source-control-agent-action-dialog-result'
 import { translate } from '@/i18n/i18n'
 
+/** What the dialog shows before it starts an agent: only an error the user can fix. */
 export type SourceControlAgentActionDeliveryPlanState =
   | { status: 'idle' }
-  | { status: 'success'; summary: string; commandLabel: string; caveat: string }
   | { status: 'error'; error: string }
 
 type SourceControlAgentActionDialogFormProps = {
@@ -345,36 +337,12 @@ export function SourceControlAgentActionDialogForm({
           
         ) : null}
 
-        {deliveryPlan.status !== 'idle' ? (
-          
- {deliveryPlan.status === 'error' ? ( - - - {deliveryPlan.error} - - ) : ( -
-
- - {deliveryPlan.summary} -
-
- {translate( - 'auto.components.right.sidebar.SourceControlAgentActionDialogForm.1bc0bdbb5e', - 'Launch:' - )}{' '} - {deliveryPlan.commandLabel} -
-
{deliveryPlan.caveat}
-
- )} + {deliveryPlan.status === 'error' ? ( +
+ + + {deliveryPlan.error} +
) : null}
diff --git a/src/renderer/src/components/right-sidebar/buildSourceControlAgentDeliveryPlan.ts b/src/renderer/src/components/right-sidebar/buildSourceControlAgentDeliveryPlan.ts index d8320dd10750..cca65a8bb5c0 100644 --- a/src/renderer/src/components/right-sidebar/buildSourceControlAgentDeliveryPlan.ts +++ b/src/renderer/src/components/right-sidebar/buildSourceControlAgentDeliveryPlan.ts @@ -1,28 +1,25 @@ -import { planSourceControlAgentActionLaunch } from '@/lib/source-control-agent-action-plan' +import { checkSourceControlAgentActionLaunch } from '@/lib/source-control-agent-action-launch-check' import { useAppStore } from '@/store' import type { TuiAgent } from '../../../../shared/tui-agent' import type { SourceControlAgentActionDeliveryPlanState } from './SourceControlAgentActionDialogForm' import { buildSourceControlAgentConnectionErrorPlan } from './source-control-agent-action-dialog-support' -import { resolveInitialNativeChatSessionOptions } from '@/components/native-chat/native-chat-launch-session-options' type BuildSourceControlAgentDeliveryPlanArgs = { selectedAgent: TuiAgent | null commandInput: string agentArgs?: string | undefined - promptDelivery: 'auto-submit' | 'draft' | 'submit-after-ready' detectedAgents: TuiAgent[] connectionUnavailable: boolean launchPlatform?: NodeJS.Platform - /** Why: keep the previewed command label in sync with the real remote launch, - * which omits the Linux-only `orca-ide` rename for SSH hosts. */ + /** Why: SSH remotes run the plain `orca` shim, so the check builds the command they would. */ isRemote?: boolean } +/** The dialog's check before it starts an agent: an error the user can fix, else nothing to show. */ export function buildSourceControlAgentDeliveryPlan({ selectedAgent, commandInput, agentArgs, - promptDelivery, detectedAgents, connectionUnavailable, launchPlatform, @@ -32,19 +29,10 @@ export function buildSourceControlAgentDeliveryPlan({ return buildSourceControlAgentConnectionErrorPlan() } const settings = useAppStore.getState().settings - const result = planSourceControlAgentActionLaunch({ + const result = checkSourceControlAgentActionLaunch({ agent: selectedAgent, commandInput, agentArgs, - sessionOptions: selectedAgent - ? resolveInitialNativeChatSessionOptions(settings, { - agent: selectedAgent, - promptDelivery, - launchDraftText: commandInput.trim(), - nativeChatTranscriptIsLocalReadable: !isRemote - }) - : undefined, - promptDelivery, detectedAgents, disabledAgents: settings?.disabledTuiAgents, cmdOverrides: settings?.agentCmdOverrides, @@ -52,13 +40,5 @@ export function buildSourceControlAgentDeliveryPlan({ platform: launchPlatform, isRemote }) - if (!result.ok) { - return { status: 'error', error: result.error } - } - return { - status: 'success', - summary: result.summary, - commandLabel: result.commandLabel, - caveat: result.caveat - } + return result.ok ? { status: 'idle' } : { status: 'error', error: result.error } } diff --git a/src/renderer/src/components/right-sidebar/useSourceControlAgentActionStart.ts b/src/renderer/src/components/right-sidebar/useSourceControlAgentActionStart.ts index 2209c1621fef..4ca1519080cf 100644 --- a/src/renderer/src/components/right-sidebar/useSourceControlAgentActionStart.ts +++ b/src/renderer/src/components/right-sidebar/useSourceControlAgentActionStart.ts @@ -110,9 +110,8 @@ export function useSourceControlAgentActionStart({ return buildSourceControlAgentDeliveryPlan({ selectedAgent, commandInput, - // Why: the previewed command must show what the launch will really apply. + // Why: the check must judge the arguments the launch will really apply. agentArgs: agentArgsApply ? agentArgs : undefined, - promptDelivery, detectedAgents: currentDetectedAgents, connectionUnavailable, launchPlatform, @@ -124,7 +123,6 @@ export function useSourceControlAgentActionStart({ agentArgsApply, commandInput, connectionUnavailable, - promptDelivery, refreshDetectedAgents, selectedAgent, launchPlatform, diff --git a/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts b/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts index e8c0c2caf4f2..dfc7f71755a8 100644 --- a/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts +++ b/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts @@ -14,6 +14,7 @@ import { waitAtTerminalPtyPreSpawnE2EBarrier } from './terminal-pty-pre-spawn-e2 import type { IpcPtySessionHandlers } from './ipc-pty-session-handlers' import { isSshSessionGoneError } from './pty-connection/pty-connect-limits' import { spawnIpcPty } from './ipc-pty-spawn-request' +import { agentLaunchPaneSpawnHold } from '@/lib/agent-launch-pane-spawn-hold' import type { IpcPtyTransportOptions, PtyConnectResult, PtyTransport } from './pty-transport-types' const SSH_PTY_CONNECTION_MISMATCH_MARKER = 'belongs to SSH connection' @@ -81,6 +82,14 @@ export async function connectIpcPty( return } } + // A launch pane this window made spawns only once the host has taken it. + const launchPaneHold = agentLaunchPaneSpawnHold(transportOptions.tabId, transportOptions.leafId) + if (launchPaneHold) { + await launchPaneHold + if (context.isDestroyed()) { + return + } + } if (options.shouldContinue && !options.shouldContinue()) { return } diff --git a/src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts b/src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts index 42171d9368d1..4d6b0aa911b1 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts @@ -100,6 +100,34 @@ describe('createIpcPtyTransport', () => { expect(spawn).not.toHaveBeenCalled() }) + it("spawns a launch pane this window made only once the host has taken it, and never once it's gone", async () => { + const { createIpcPtyTransport } = await import('./pty-transport') + const { holdAgentLaunchPaneSpawn, releaseAgentLaunchPaneSpawn } = + await import('@/lib/agent-launch-pane-spawn-hold') + const spawn = window.api.pty.spawn as unknown as ReturnType + const pane = { worktreeId: 'wt', tabId: 'tab-held', leafId: 'leaf-held' } + + holdAgentLaunchPaneSpawn(pane.tabId, pane.leafId) + const transport = createIpcPtyTransport(pane) + const connecting = transport.connect({ url: '', callbacks: {} }) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(spawn).not.toHaveBeenCalled() + + releaseAgentLaunchPaneSpawn(pane.tabId, pane.leafId) + await connecting + expect(spawn).toHaveBeenCalledWith(expect.objectContaining({ tabId: 'tab-held' })) + transport.disconnect() + + spawn.mockClear() + const release = holdAgentLaunchPaneSpawn(pane.tabId, pane.leafId) + const closed = createIpcPtyTransport(pane) + const abandoned = closed.connect({ url: '', callbacks: {} }) + closed.destroy?.() + release() + await expect(abandoned).resolves.toBeUndefined() + expect(spawn).not.toHaveBeenCalled() + }) + it('keeps the recovery hint and raw diagnostic from a wrapped spawn error', async () => { const { createIpcPtyTransport } = await import('./pty-transport') vi.mocked(window.api.pty.spawn).mockRejectedValueOnce( diff --git a/src/renderer/src/hooks/ipc-events-terminal-create-scenario-types.ts b/src/renderer/src/hooks/ipc-events-terminal-create-scenario-types.ts index 63e827954333..af3ef297368d 100644 --- a/src/renderer/src/hooks/ipc-events-terminal-create-scenario-types.ts +++ b/src/renderer/src/hooks/ipc-events-terminal-create-scenario-types.ts @@ -79,7 +79,10 @@ export type TerminalCreateSurfacingStore = { clearAgentLaunchConfig: SpyMock updateTabPtyId: Mock<(tabId: string, ptyId: string) => void> setTabLayout: Mock<(tabId: string, layout: unknown) => void> - tabsByWorktree: Record + tabsByWorktree: Record< + string, + { id: string; ptyId?: string | null; title?: string; agentLaunchPane?: { leafId: string } }[] + > folderWorkspaces: unknown[] projectGroups: unknown[] repos: { id: string; connectionId: string | null; executionHostId: string }[] diff --git a/src/renderer/src/hooks/ipc-events/terminal-presentation-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/terminal-presentation-ipc-bridge.ts index 368bb66413e1..e978865a34ee 100644 --- a/src/renderer/src/hooks/ipc-events/terminal-presentation-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/terminal-presentation-ipc-bridge.ts @@ -1,3 +1,4 @@ +import { releaseAgentLaunchPaneSpawn } from '@/lib/agent-launch-pane-spawn-hold' import { requestBackgroundTerminalWorktreeMount } from '@/components/terminal/background-terminal-worktree-mount' import { hasRegisteredRuntimeTerminalTab } from '@/runtime/sync-runtime-graph' import { planMobileTerminalTabMount } from '@/lib/mobile-terminal-tab-mount' @@ -88,6 +89,11 @@ export function registerTerminalPresentationIpcBridge(unsubs: (() => void)[]): v throw new Error(`Terminal tab ${tabId} not found`) } const reusedTab = existingTab ?? splitTargetTab + if (ptyId && tabId && leafId && reusedTab?.agentLaunchPane?.leafId === leafId) { + // A launch pane this window made and the host never showed early: the host now holds it, + // so its spawn may attach instead of waiting out the whole launch. + releaseAgentLaunchPaneSpawn(tabId, leafId) + } const tab = reusedTab ?? (ptyId diff --git a/src/renderer/src/hooks/useIpcEvents-agent-launch-closed-tab.test.ts b/src/renderer/src/hooks/useIpcEvents-agent-launch-closed-tab.test.ts index 1730fb08497f..a108deb9b377 100644 --- a/src/renderer/src/hooks/useIpcEvents-agent-launch-closed-tab.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-agent-launch-closed-tab.test.ts @@ -62,3 +62,37 @@ describe('a launch tab while its agent is starting', () => { ) }) }) + +describe('a launch tab this window made, which the host never showed early', () => { + it('takes the agent the host reveals under its own id, and lets its pane attach at once', async () => { + const scenario = await setupTerminalCreateSurfacing(() => false) + const { createTab, updateTabPtyId, createTerminalListenerRef, storeState } = scenario + if (!createTerminalListenerRef.current) { + throw new Error('Expected the create-terminal listener to be registered') + } + Object.assign(window.api, { pty: { kill: vi.fn(async () => {}) } }) + // The harness resets modules; the bridge reads the instance it imported. + const { holdAgentLaunchPaneSpawn, agentLaunchPaneSpawnHold } = + await import('@/lib/agent-launch-pane-spawn-hold') + holdAgentLaunchPaneSpawn('tab-made', LEAF) + storeState.tabsByWorktree['wt-1'] = [ + { id: 'tab-made', ptyId: null, agentLaunchPane: { leafId: LEAF } } + ] + storeState.terminalLayoutsByTabId['tab-made'] = { root: { type: 'leaf', leafId: LEAF } } + const held = agentLaunchPaneSpawnHold('tab-made', LEAF) + + createTerminalListenerRef.current({ + requestId: 'reveal-window-made-launch-tab', + worktreeId: 'wt-1', + ptyId: 'pty-agent', + tabId: 'tab-made', + leafId: LEAF + }) + + // Never a second tab under the same id, and the waiting pane is let go to attach. + expect(createTab).not.toHaveBeenCalled() + expect(updateTabPtyId).toHaveBeenCalledWith('tab-made', 'pty-agent') + await expect(held).resolves.toBeUndefined() + expect(agentLaunchPaneSpawnHold('tab-made', LEAF)).toBeNull() + }) +}) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 23965a64068b..82cd96f85d97 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -13212,7 +13212,6 @@ "ea4788705e": "Cancel", "7ec6abbf2a": "Reset", "f4f3c9ca4a": "Prompt template", - "1bc0bdbb5e": "Launch:", "fe119187bb": "--model sonnet", "bc8dc39f4b": "CLI arguments", "b99c33cec5": "Settings", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index e1dbb80d724f..44af8babf19f 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -12838,7 +12838,6 @@ "ea4788705e": "Cancelar", "7ec6abbf2a": "Reiniciar", "f4f3c9ca4a": "Plantilla de prompt", - "1bc0bdbb5e": "Lanzamiento:", "fe119187bb": "--model sonnet", "bc8dc39f4b": "Argumentos CLI", "b99c33cec5": "Ajustes", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 53d7a8bded36..5fe44269db5c 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -12944,7 +12944,6 @@ "ea4788705e": "Annuler", "7ec6abbf2a": "Réinitialiser", "f4f3c9ca4a": "Modèle de prompt", - "1bc0bdbb5e": "Lancement :", "fe119187bb": "--model sonnet", "bc8dc39f4b": "Arguments CLI", "b99c33cec5": "Paramètres", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 1fe8d0e4e217..635a43c4edff 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -12829,7 +12829,6 @@ "38b899cc02": "すべてのリポジトリ" }, "SourceControlAgentActionDialogForm": { - "1bc0bdbb5e": "起動:", "7ec6abbf2a": "リセット", "f4f3c9ca4a": "コマンドテンプレート", "fe119187bb": "--model sonnet", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index bf732f454ada..a762bc3e1d8c 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -12829,7 +12829,6 @@ "38b899cc02": "모든 리포지토리" }, "SourceControlAgentActionDialogForm": { - "1bc0bdbb5e": "실행:", "7ec6abbf2a": "재설정", "f4f3c9ca4a": "프롬프트 템플릿", "fe119187bb": "--model sonnet", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 8ad2f1487177..d5627715fae7 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -12829,7 +12829,6 @@ "38b899cc02": "所有仓库" }, "SourceControlAgentActionDialogForm": { - "1bc0bdbb5e": "启动:", "7ec6abbf2a": "重置", "f4f3c9ca4a": "提示词模板", "fe119187bb": "--model sonnet", diff --git a/src/renderer/src/lib/agent-launch-caller-permissions.test.ts b/src/renderer/src/lib/agent-launch-caller-permissions.test.ts index 0c1b3066f171..dfc416260f84 100644 --- a/src/renderer/src/lib/agent-launch-caller-permissions.test.ts +++ b/src/renderer/src/lib/agent-launch-caller-permissions.test.ts @@ -10,10 +10,12 @@ import { } from './agent-launch-caller-profiles-test-harness' import { createLaunchFunnelStore, + hostLaunchRequest, queuedStartupCommand, queuedStartupPayload, resetLaunchFunnelStore } from './agent-launch-funnel-test-harness' +import { newTabPromptLaunchesThroughHost } from './launch-agent-new-tab-host-route' const store = createLaunchFunnelStore() @@ -45,6 +47,31 @@ vi.mock('@/lib/agent-ready-wait', () => ({ vi.mock('@/runtime/local-runtime-capabilities', () => ({ readLocalRuntimeCapabilitiesOrUnknown: () => [] })) +// A launch the host delivers waits on its reply; these tests read only what was sent. +const callRuntimeRpc = vi.hoisted(() => vi.fn(() => new Promise(() => {}))) +vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc, RuntimeRpcCallError: Error })) + +function launchesThroughHost(profile: AgentLaunchCallerProfile): boolean { + return newTabPromptLaunchesThroughHost({ + promptDelivery: profile.args.promptDelivery ?? 'auto-submit', + pastesPrompt: (profile.args.prompt?.trim() ?? '').length > 0 + }) +} + +/** + * A call site whose prompt the host delivers states its arguments in the request, and the host + * builds the command from them by the same rule (its startup plan inputs); absent means the + * setting, shipped bypass default included. + */ +function expectHostRequestArguments(profile: AgentLaunchCallerProfile): void { + const request = hostLaunchRequest(callRuntimeRpc) + expect(queuedStartupCommand(store)).toBeUndefined() + if (profile.args.agentArgs === undefined) { + expect(request).not.toHaveProperty('agentArgs') + } else { + expect(request?.agentArgs).toBe(profile.args.agentArgs) + } +} const CODEX_BYPASS = '--dangerously-bypass-approvals-and-sandbox' @@ -73,6 +100,10 @@ describe('agent launch caller arguments and permission bypass', () => { it.each(cases)('puts %s on the command line its own arguments describe', async (_id, profile) => { await launch(profile) + if (launchesThroughHost(profile)) { + expectHostRequestArguments(profile) + return + } const command = queuedStartupCommand(store) expect(command).toBeDefined() @@ -87,6 +118,10 @@ describe('agent launch caller arguments and permission bypass', () => { it.each(cases)('keeps %s on the bypass posture its arguments encode', async (_id, profile) => { await launch(profile) + if (launchesThroughHost(profile)) { + expectHostRequestArguments(profile) + return + } const command = queuedStartupCommand(store) ?? '' // Why: the three recipe-driven call sites hand in saved arguments, which REPLACE the shipped @@ -99,6 +134,10 @@ describe('agent launch caller arguments and permission bypass', () => { 'forwards an explicit argument override from %s to the tab', async (_id, profile) => { await launch(profile) + if (launchesThroughHost(profile)) { + expectHostRequestArguments(profile) + return + } const payload = queuedStartupPayload(store) if (profile.args.agentArgs === undefined) { @@ -221,7 +260,7 @@ describe('agent launch caller arguments and permission bypass', () => { worktreeId: 'wt-1' }) - expect(result?.startupPlan.sessionOptions).toEqual({ + expect(result?.startupPlan?.sessionOptions).toEqual({ model: 'gpt-5.2-codex', effort: 'medium' }) @@ -246,7 +285,7 @@ describe('agent launch caller arguments and permission bypass', () => { worktreeId: 'wt-1' }) - expect(result?.startupPlan.sessionOptions).toBeUndefined() + expect(result?.startupPlan?.sessionOptions).toBeUndefined() expect(queuedStartupCommand(store)).not.toContain("'-m'") }) }) diff --git a/src/renderer/src/lib/agent-launch-caller-placement.test.ts b/src/renderer/src/lib/agent-launch-caller-placement.test.ts index e20414efc5cd..3e070ee854aa 100644 --- a/src/renderer/src/lib/agent-launch-caller-placement.test.ts +++ b/src/renderer/src/lib/agent-launch-caller-placement.test.ts @@ -11,9 +11,11 @@ import { createdTabGroupId, createdTabOptions, createLaunchFunnelStore, + hostLaunchRequest, queuedStartupPayload, resetLaunchFunnelStore } from './agent-launch-funnel-test-harness' +import { newTabPromptLaunchesThroughHost } from './launch-agent-new-tab-host-route' const store = createLaunchFunnelStore() @@ -45,6 +47,16 @@ vi.mock('@/lib/agent-ready-wait', () => ({ vi.mock('@/runtime/local-runtime-capabilities', () => ({ readLocalRuntimeCapabilitiesOrUnknown: () => [] })) +// A launch the host delivers waits on its reply; these tests read only what was sent. +const callRuntimeRpc = vi.hoisted(() => vi.fn(() => new Promise(() => {}))) +vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc, RuntimeRpcCallError: Error })) + +function launchesThroughHost(profile: AgentLaunchCallerProfile): boolean { + return newTabPromptLaunchesThroughHost({ + promptDelivery: profile.args.promptDelivery ?? 'auto-submit', + pastesPrompt: (profile.args.prompt?.trim() ?? '').length > 0 + }) +} const cases = callerProfileCases() @@ -109,6 +121,12 @@ describe('agent launch caller placement and telemetry', () => { async (_id, profile) => { await launch(profile) + if (launchesThroughHost(profile)) { + // The host starts the agent where the request names, so nothing waits on the tab. + expect(store.queueTabInitialCwd).not.toHaveBeenCalled() + expect(hostLaunchRequest(callRuntimeRpc)?.cwd).toBe(profile.args.initialCwd) + return + } if (profile.args.initialCwd) { expect(store.queueTabInitialCwd).toHaveBeenCalledExactlyOnceWith( 'tab-1', @@ -127,6 +145,15 @@ describe('agent launch caller placement and telemetry', () => { it.each(cases)('stamps the launch %s started with its telemetry source', async (_id, profile) => { await launch(profile) + if (launchesThroughHost(profile)) { + // The host stamps `agent_started` from the request; the window queues no command of its own. + expect(queuedStartupPayload(store)).toBeUndefined() + expect(hostLaunchRequest(callRuntimeRpc)).toMatchObject({ + agent: profile.args.agent, + launchSource: profile.args.launchSource ?? 'tab_bar_quick_launch' + }) + return + } expect(queuedStartupPayload(store)?.telemetry).toEqual({ agent_kind: `kind:${profile.args.agent}`, // git-history-explain-commit names no source, so it reports as a tab-bar quick launch. diff --git a/src/renderer/src/lib/agent-launch-caller-prompt-transport.test.ts b/src/renderer/src/lib/agent-launch-caller-prompt-transport.test.ts index ed8d689c90df..3f3acb5e6d41 100644 --- a/src/renderer/src/lib/agent-launch-caller-prompt-transport.test.ts +++ b/src/renderer/src/lib/agent-launch-caller-prompt-transport.test.ts @@ -51,6 +51,20 @@ vi.mock('@/lib/agent-ready-wait', () => ({ vi.mock('@/runtime/local-runtime-capabilities', () => ({ readLocalRuntimeCapabilitiesOrUnknown: () => [] })) +// The host starts an AI button's agent in the tab's pane and answers; the window then pastes. +const callRuntimeRpc = vi.hoisted(() => + vi.fn(async (_target: unknown, _method: string, params: Record) => ({ + outcome: { kind: 'terminal', handle: 'term_1', paneKey: params.paneKey }, + worktreeId: 'wt-1', + receipt: { mode: 'terminal', preferred: 'terminal', reason: 'user_default', detail: 'x' } + })) +) +vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc, RuntimeRpcCallError: Error })) + +/** What the window's own launch command carries; a launch started by the host queues none. */ +function commandCarries(text: string): boolean { + return queuedStartupCommand(store)?.includes(text) ?? false +} const PROMPT = 'Explain the failing check and propose a fix.' @@ -147,14 +161,18 @@ describe('agent launch caller prompt transport', () => { if (profile.args.prompt === undefined) { expect(result?.pasteDraftAfterLaunch).toBe(false) expect(mockPasteDraftWhenAgentReady).not.toHaveBeenCalled() - expect(queuedStartupCommand(store)).not.toContain(PROMPT) + expect(commandCarries(PROMPT)).toBe(false) return } // quick-command is the only prompt-carrying call site that names no delivery mode, so its text // rides argv; every other one asks for draft or submit-after-ready and pastes. const ridesArgv = id === 'quick-command' expect(result?.pasteDraftAfterLaunch).toBe(!ridesArgv) - expect(queuedStartupCommand(store)?.includes(PROMPT)).toBe(ridesArgv) + expect(commandCarries(PROMPT)).toBe(ridesArgv) + // The host is never handed the text: the window pastes it, as main does. + for (const [, , params] of callRuntimeRpc.mock.calls) { + expect(params).not.toHaveProperty('prompt') + } } ) @@ -211,12 +229,15 @@ describe('agent launch caller prompt transport', () => { }) expect(result?.pasteDraftAfterLaunch).toBe(row.transport === 'paste') - expect(queuedStartupCommand(store)?.includes(PROMPT)).toBe(row.transport === 'argv') + expect(commandCarries(PROMPT)).toBe(row.transport === 'argv') if (row.transport === 'paste') { - expect(mockPasteDraftWhenAgentReady.mock.calls[0]?.[0]).toMatchObject({ - content: PROMPT, - submit: row.submits - }) + // Through the host, the paste waits for the agent to be started in the tab. + await vi.waitFor(() => + expect(mockPasteDraftWhenAgentReady.mock.calls[0]?.[0]).toMatchObject({ + content: PROMPT, + submit: row.submits + }) + ) } else { expect(mockPasteDraftWhenAgentReady).not.toHaveBeenCalled() } diff --git a/src/renderer/src/lib/agent-launch-funnel-test-harness.ts b/src/renderer/src/lib/agent-launch-funnel-test-harness.ts index 8292a054cb63..c1fc80cafa25 100644 --- a/src/renderer/src/lib/agent-launch-funnel-test-harness.ts +++ b/src/renderer/src/lib/agent-launch-funnel-test-harness.ts @@ -108,3 +108,10 @@ export function createdTabOptions(store: LaunchFunnelStore): Record +): Record | undefined { + return callRuntimeRpc.mock.calls.find(([, method]) => method === 'agent.launchReplay')?.[2] +} diff --git a/src/renderer/src/lib/agent-launch-pane-spawn-hold.test.ts b/src/renderer/src/lib/agent-launch-pane-spawn-hold.test.ts new file mode 100644 index 000000000000..bcbc87d7e9a0 --- /dev/null +++ b/src/renderer/src/lib/agent-launch-pane-spawn-hold.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import { + agentLaunchPaneSpawnHold, + holdAgentLaunchPaneSpawn, + isAgentLaunchPaneSpawnHeld, + releaseAgentLaunchPaneSpawn +} from './agent-launch-pane-spawn-hold' + +const TAB = '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d' +const LEAF = '3f2504e0-4f89-41d3-9a0c-0305e82c3301' + +describe('a launch pane this window made', () => { + it('holds its spawn until the host takes the pane', async () => { + holdAgentLaunchPaneSpawn(TAB, LEAF) + const held = agentLaunchPaneSpawnHold(TAB, LEAF) + let spawned = false + void held?.then(() => { + spawned = true + }) + await Promise.resolve() + expect(spawned).toBe(false) + expect(isAgentLaunchPaneSpawnHeld(TAB, LEAF)).toBe(true) + + expect(releaseAgentLaunchPaneSpawn(TAB, LEAF)).toBe(true) + await held + expect(spawned).toBe(true) + expect(isAgentLaunchPaneSpawnHeld(TAB, LEAF)).toBe(false) + expect(releaseAgentLaunchPaneSpawn(TAB, LEAF)).toBe(false) + }) + + it('ends the hold when the launch is over, and a second end is harmless', async () => { + const release = holdAgentLaunchPaneSpawn(TAB, LEAF) + const held = agentLaunchPaneSpawnHold(TAB, LEAF) + release() + release() + await expect(held).resolves.toBeUndefined() + expect(agentLaunchPaneSpawnHold(TAB, LEAF)).toBeNull() + }) + + it('holds nothing for any other pane', () => { + const release = holdAgentLaunchPaneSpawn(TAB, LEAF) + expect(agentLaunchPaneSpawnHold(TAB, 'another-leaf')).toBeNull() + expect(agentLaunchPaneSpawnHold(undefined, LEAF)).toBeNull() + release() + }) +}) diff --git a/src/renderer/src/lib/agent-launch-pane-spawn-hold.ts b/src/renderer/src/lib/agent-launch-pane-spawn-hold.ts new file mode 100644 index 000000000000..4278d413b7e0 --- /dev/null +++ b/src/renderer/src/lib/agent-launch-pane-spawn-hold.ts @@ -0,0 +1,58 @@ +/** + * Launch panes this window made before asking the host to launch into them. + * + * Such a pane must not spawn until the host has taken it: before that, main knows of no launch for + * it and would give it a shell, which the host's own spawn would then be refused over. The host + * takes the pane when it asks this window to show the launch's tab, so the hold ends there, or when + * the launch is over. Session-only: after a reload the pane's spawn reads the launch record like any + * launch pane. + */ + +type Hold = { released: Promise; release: () => void } + +const holds = new Map() + +// Not `makePaneKey`: that validates the leaf id, and a launch must never fail over bookkeeping. +function holdKey(tabId: string, leafId: string): string { + return JSON.stringify([tabId, leafId]) +} + +/** Holds the pane's spawn; the returned function ends the hold and is safe to call twice. */ +export function holdAgentLaunchPaneSpawn(tabId: string, leafId: string): () => void { + const key = holdKey(tabId, leafId) + let release!: () => void + const hold: Hold = { + released: new Promise((resolve) => { + release = resolve + }), + release: () => { + if (holds.get(key) === hold) { + holds.delete(key) + } + release() + } + } + holds.get(key)?.release() + holds.set(key, hold) + return hold.release +} + +/** The host has taken the pane: its spawn now waits in main for the host's agent. */ +export function releaseAgentLaunchPaneSpawn(tabId: string, leafId: string): boolean { + const hold = holds.get(holdKey(tabId, leafId)) + hold?.release() + return hold !== undefined +} + +/** What the pane's spawn awaits first, or null when nothing holds it. */ +export function agentLaunchPaneSpawnHold( + tabId: string | undefined, + leafId: string | undefined +): Promise | null { + return tabId && leafId ? (holds.get(holdKey(tabId, leafId))?.released ?? null) : null +} + +/** Still held: the host has not taken the pane. */ +export function isAgentLaunchPaneSpawnHeld(tabId: string, leafId: string): boolean { + return holds.has(holdKey(tabId, leafId)) +} diff --git a/src/renderer/src/lib/agent-launch-prompt-delivery.ts b/src/renderer/src/lib/agent-launch-prompt-delivery.ts index ad9e1f1d8c84..50b0a8796806 100644 --- a/src/renderer/src/lib/agent-launch-prompt-delivery.ts +++ b/src/renderer/src/lib/agent-launch-prompt-delivery.ts @@ -35,23 +35,38 @@ export function deliverLaunchPromptToAgentTab(args: { onTimeout?: () => void /** The paste was written without ever observing the agent's composer. */ onUnconfirmedDelivery?: () => void + /** Whether the paste may be written; the chat copy is seeded only once it opens. */ + sendGate?: Promise }): Promise { const { tabId, agent, content, submit, forcePaste, timeoutMs, onTimeout, onUnconfirmedDelivery } = args const shouldSeed = submit === true && content.trim().length > 0 && isNativeChatSupportedAgent(agent) - - if (shouldSeed) { - useAppStore.getState().seedNativeChatLaunchPrompt({ - tabId, - agent, - text: content, - createdAt: Date.now() - }) - } else if (submit !== true) { - // Why: an unsubmitted draft lives only in the TUI input buffer; seed the - // chat-composer copy so the context isn't invisible in the GUI view. - seedNativeChatLaunchDraftForAgentTab({ tabId, agent, text: content }) + let seeded = false + const seedChatCopy = (): void => { + if (shouldSeed) { + seeded = true + useAppStore.getState().seedNativeChatLaunchPrompt({ + tabId, + agent, + text: content, + createdAt: Date.now() + }) + } else if (submit !== true) { + // Why: an unsubmitted draft lives only in the TUI input buffer; seed the + // chat-composer copy so the context isn't invisible in the GUI view. + seedNativeChatLaunchDraftForAgentTab({ tabId, agent, text: content }) + } + } + // Chained ahead of the paste's own wait on the gate, so the copy always precedes the send. + const sendGate = args.sendGate?.then((open) => { + if (open) { + seedChatCopy() + } + return open + }) + if (!sendGate) { + seedChatCopy() } // Why: native-prefill agents (claude/openclaude etc.) get the prompt at launch, @@ -67,16 +82,17 @@ export function deliverLaunchPromptToAgentTab(args: { forcePaste, timeoutMs, onTimeout, - onUnconfirmedDelivery + onUnconfirmedDelivery, + ...(sendGate ? { sendGate } : {}) }).then( (delivered) => { - if (shouldSeed && !delivered && !deliversViaNativePrefill) { + if (seeded && !delivered && !deliversViaNativePrefill) { useAppStore.getState().markNativeChatLaunchPromptFailed(tabId) } return delivered || deliversViaNativePrefill }, (error) => { - if (shouldSeed && !deliversViaNativePrefill) { + if (seeded && !deliversViaNativePrefill) { useAppStore.getState().markNativeChatLaunchPromptFailed(tabId) } throw error diff --git a/src/renderer/src/lib/agent-launch-tab-publication.test.ts b/src/renderer/src/lib/agent-launch-tab-publication.test.ts index 183c4d95eb06..8d9ec4c3f0b8 100644 --- a/src/renderer/src/lib/agent-launch-tab-publication.test.ts +++ b/src/renderer/src/lib/agent-launch-tab-publication.test.ts @@ -122,6 +122,27 @@ describe('publishing a launch tab before its agent exists', () => { expect(launchPane()).toEqual({ leafId: LEAF_ID, operationId: 'op-2' }) }) + it('hands a tab this window made for the launch to the host, as the launch to take back', async () => { + const { holdAgentLaunchPaneSpawn, agentLaunchPaneSpawnHold } = + await import('./agent-launch-pane-spawn-hold') + holdAgentLaunchPaneSpawn(TAB_ID, LEAF_ID) + store.getState().createTab(WT, undefined, undefined, { + id: TAB_ID, + initialLeafId: LEAF_ID, + agentLaunchPane: { leafId: LEAF_ID } + }) + const held = agentLaunchPaneSpawnHold(TAB_ID, LEAF_ID) + + const published = publishAgentLaunchTab(request()) + + // The host now owns the pane, so a refused launch takes the tab back rather than leaving a shell. + expect(published).toMatchObject({ tabId: TAB_ID, created: true }) + await expect(held).resolves.toBeUndefined() + expect(agentLaunchPaneSpawnHold(TAB_ID, LEAF_ID)).toBeNull() + // A retry finds no hold: the tab is no longer only this launch's. + expect(publishAgentLaunchTab(request({ requestId: 'request-2' })).created).toBe(false) + }) + it("remounts a pane that showed an earlier launch's outcome, so it spawns for the new launch", () => { const generation = () => store.getState().tabsByWorktree[WT]?.find((tab) => tab.id === TAB_ID)?.generation ?? 0 diff --git a/src/renderer/src/lib/agent-launch-tab-publication.ts b/src/renderer/src/lib/agent-launch-tab-publication.ts index ac678d981a85..8ec7279e7477 100644 --- a/src/renderer/src/lib/agent-launch-tab-publication.ts +++ b/src/renderer/src/lib/agent-launch-tab-publication.ts @@ -20,6 +20,7 @@ import { useAppStore } from '../store' import { resolveUnifiedTabCreatePlacement } from '../store/slices/tabs/tabs-create-placement' import { insertUnifiedTabAfterAnchor } from './unified-tab-anchor-insertion' import { rememberAgentLaunchPanePrompt } from './agent-launch-pane-prompt' +import { releaseAgentLaunchPaneSpawn } from './agent-launch-pane-spawn-hold' function landedGroupId(worktreeId: string, tabId: string): string | undefined { return useAppStore @@ -79,7 +80,9 @@ export function publishAgentLaunchTab( remount: kept?.leafId === leafId && kept.outcome !== undefined }) } - return { tabId, created: false, placement: { groupId } } + // A tab this window made for this launch is the launch's to take back if it never runs. + const madeForThisLaunch = releaseAgentLaunchPaneSpawn(tabId, leafId) + return { tabId, created: madeForThisLaunch, placement: { groupId } } } const placement = resolveUnifiedTabCreatePlacement({ diff --git a/src/renderer/src/lib/agent-launch-through-host.test.ts b/src/renderer/src/lib/agent-launch-through-host.test.ts new file mode 100644 index 000000000000..1967e6d8097c --- /dev/null +++ b/src/renderer/src/lib/agent-launch-through-host.test.ts @@ -0,0 +1,243 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as AgentStatusModule from '@/lib/agent-status' +import { RuntimeRpcCallError } from '@/runtime/runtime-rpc-result' +import { createTabsSliceMockApi } from '../store/slices/tabs-slice-test-harness' +import { createTestStore } from '../store/slices/store-test-helpers' + +vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) +vi.mock('@/lib/agent-status', async (importOriginal) => ({ + ...(await importOriginal()), + detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) +})) + +const testStore = vi.hoisted(() => { + const ref: { current: ReturnType | null } = { current: null } + return ref +}) +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => { + if (!testStore.current) { + throw new Error('no test store') + } + return testStore.current.getState() + } + } +})) +const callRuntimeRpc = vi.hoisted(() => + vi.fn<(target: unknown, method: string, params: Record) => Promise>() +) +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => ({ + ...(await importOriginal()), + callRuntimeRpc +})) + +createTabsSliceMockApi() + +const { launchAgentThroughHost } = await import('./agent-launch-through-host') +const { agentLaunchPaneSpawnHold, releaseAgentLaunchPaneSpawn } = + await import('./agent-launch-pane-spawn-hold') +const { agentLaunchPanePrompt } = await import('./agent-launch-pane-prompt') + +const WT = 'repo1::/tmp/feature' +let store: ReturnType + +function deferred() { + let resolve!: (value: T) => void + let reject!: (error: unknown) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + +function rpcError(code: string): RuntimeRpcCallError { + return new RuntimeRpcCallError({ + id: 'desktop-ipc', + ok: false, + error: { code, message: code }, + _meta: { runtimeId: 'runtime-1' } + }) +} + +function terminalResult(paneKey: string) { + return { + outcome: { kind: 'terminal', handle: 'term_1', paneKey }, + worktreeId: WT, + receipt: { mode: 'terminal', preferred: 'terminal', reason: 'user_default', detail: 'x' } + } +} + +function launchTab(tabId: string) { + return store.getState().tabsByWorktree[WT]?.find((tab) => tab.id === tabId) +} + +function lastParams(): Record { + return callRuntimeRpc.mock.calls.at(-1)?.[2] ?? {} +} + +function lastPaneKey(): string { + return String(lastParams().paneKey) +} + +function launch() { + return launchAgentThroughHost({ + agent: 'claude', + worktreeId: WT, + groupId: store.getState().activeGroupIdByWorktree[WT], + prompt: 'fix the failing checks', + agentArgs: null, + launchSource: 'source_control_recovery' + }) +} + +/** What the host does first: it takes the pane, by asking this window to show the tab. */ +function hostTakesPane(tabId: string): void { + const tab = launchTab(tabId)! + releaseAgentLaunchPaneSpawn(tab.id, tab.agentLaunchPane!.leafId) +} + +beforeEach(() => { + store = createTestStore() + testStore.current = store + callRuntimeRpc.mockReset() + store.getState().setActiveWorktree(WT) + store.getState().createUnifiedTab(WT, 'terminal') +}) + +describe('a desktop launch through the host', () => { + it('shows its tab at the click, in its split, waiting for the host before it spawns', () => { + const reply = deferred() + callRuntimeRpc.mockReturnValue(reply.promise) + + const { tabId } = launch() + + const tab = launchTab(tabId)! + expect(tab).toMatchObject({ ptyId: null, launchAgent: 'claude' }) + const leafId = tab.agentLaunchPane!.leafId + expect(agentLaunchPaneSpawnHold(tab.id, leafId)).not.toBeNull() + expect(agentLaunchPanePrompt(tab.id)).toBe('fix the failing checks') + expect(store.getState().activeTabId).toBe(tabId) + // No prompt: the window pastes it, as main does, once the agent runs. + expect(callRuntimeRpc).toHaveBeenCalledWith({ kind: 'local' }, 'agent.launchReplay', { + agent: 'claude', + target: { kind: 'existing', worktree: `id:${WT}` }, + agentArgs: null, + launchSource: 'source_control_recovery', + placement: { groupId: store.getState().activeGroupIdByWorktree[WT] }, + presentation: 'focused', + operationId: expect.stringMatching(/^\d+-[0-9a-f]{32}$/), + paneKey: `${tabId}:${leafId}` + }) + }) + + it('names every click as its own operation', () => { + callRuntimeRpc.mockReturnValue(new Promise(() => {})) + launch() + const first = lastParams() + launch() + const second = lastParams() + expect(second.operationId).not.toBe(first.operationId) + expect(second.paneKey).not.toBe(first.paneKey) + }) + + it('says the agent started only once the host answers, with its pane attached', async () => { + const reply = deferred() + callRuntimeRpc.mockReturnValue(reply.promise) + const { tabId, outcome } = launch() + const settled = vi.fn() + void outcome.then(settled) + hostTakesPane(tabId) + await Promise.resolve() + expect(settled).not.toHaveBeenCalled() + + reply.resolve(terminalResult(lastPaneKey())) + + await expect(outcome).resolves.toEqual({ kind: 'started' }) + expect(launchTab(tabId)).toBeDefined() + }) + + it('takes its tab back on a refusal, before the pane ever spawns', async () => { + const reply = deferred() + callRuntimeRpc.mockReturnValue(reply.promise) + const { tabId, outcome } = launch() + + reply.reject(rpcError('agent_session_operation_conflict')) + + await expect(outcome).resolves.toEqual({ + kind: 'not-started', + unconfirmed: false, + code: 'agent_session_operation_conflict' + }) + expect(launchTab(tabId)).toBeUndefined() + }) + + it('leaves a launch the host took to its pane, which says how it ended', async () => { + const reply = deferred() + callRuntimeRpc.mockReturnValue(reply.promise) + const { tabId, outcome } = launch() + hostTakesPane(tabId) + + reply.reject(rpcError('agent_session_operation_unknown')) + + await expect(outcome).resolves.toEqual({ kind: 'pane-says' }) + expect(launchTab(tabId)).toBeDefined() + }) + + it('never leaves a pane the host did not take, which would open as a shell', async () => { + const reply = deferred() + callRuntimeRpc.mockReturnValue(reply.promise) + const { tabId, outcome } = launch() + + reply.reject(rpcError('worktree_not_found')) + + await expect(outcome).resolves.toEqual({ + kind: 'not-started', + unconfirmed: false, + code: 'worktree_not_found' + }) + expect(launchTab(tabId)).toBeUndefined() + }) + + // Why: the close was the user's own, as on the phone's "+". + it('says nothing more when the user closed the tab while it started', async () => { + callRuntimeRpc.mockRejectedValueOnce(rpcError('agent_launch_tab_closed')) + await expect(launch().outcome).resolves.toEqual({ kind: 'closed-by-user' }) + }) + + it('still starts the agent in the same tab when the launch record is full', async () => { + const unrecorded = deferred() + callRuntimeRpc + .mockRejectedValueOnce(rpcError('agent_session_operation_capacity')) + .mockReturnValueOnce(unrecorded.promise) + const { tabId, outcome } = launch() + const paneKey = lastPaneKey() + await vi.waitFor(() => expect(callRuntimeRpc).toHaveBeenCalledTimes(2)) + + const [, method, params] = callRuntimeRpc.mock.calls[1]! + expect(method).toBe('agent.launch') + expect(params).not.toHaveProperty('operationId') + // The same pane, held until the answer. + expect(params.paneKey).toBe(paneKey) + const leafId = launchTab(tabId)!.agentLaunchPane!.leafId + expect(agentLaunchPaneSpawnHold(tabId, leafId)).not.toBeNull() + + unrecorded.resolve(terminalResult(paneKey)) + await expect(outcome).resolves.toEqual({ kind: 'started' }) + }) + + it('takes its tab back when the unrecorded launch fails before the host revealed it', async () => { + callRuntimeRpc + .mockRejectedValueOnce(rpcError('agent_session_operation_capacity')) + .mockRejectedValueOnce(rpcError('worktree_not_found')) + const { tabId, outcome } = launch() + + await expect(outcome).resolves.toEqual({ + kind: 'not-started', + unconfirmed: false, + code: 'worktree_not_found' + }) + expect(launchTab(tabId)).toBeUndefined() + }) +}) diff --git a/src/renderer/src/lib/agent-launch-through-host.ts b/src/renderer/src/lib/agent-launch-through-host.ts new file mode 100644 index 000000000000..89a1c36fb268 --- /dev/null +++ b/src/renderer/src/lib/agent-launch-through-host.ts @@ -0,0 +1,206 @@ +/** + * A desktop launch started through the host's `agent.launch`, with no prompt. + * + * This window owns the workspace's tab layout, so it makes the agent's tab at the click, in the + * split it was asked for, as any new agent tab is made. The host records the launch under this + * click's operation id and starts the agent into that tab's pane. The pane's spawn waits until the + * host has taken it (`agent-launch-pane-spawn-hold`), then attaches to the agent or says why it could + * not start. The prompt stays this window's to paste, as main pastes it, once the agent has started. + */ + +import { useAppStore } from '@/store' +import { createBrowserUuid } from '@/lib/browser-uuid' +import { persistAgentLaunchTabOrder } from '@/lib/launch-agent-tab-order' +import { rememberAgentLaunchPanePrompt } from '@/lib/agent-launch-pane-prompt' +import { + holdAgentLaunchPaneSpawn, + isAgentLaunchPaneSpawnHeld +} from '@/lib/agent-launch-pane-spawn-hold' +import { seedNativeChatAppliedSessionOptions } from '@/components/native-chat/native-chat-session-option-cache' +import { callRuntimeRpc, RuntimeRpcCallError } from '@/runtime/runtime-rpc-client' +import { createAgentSessionOperationId } from '@/runtime/agent-session-operation-id' +import { isAgentLaunchResult } from '../../../shared/agent-launch-intent' +import { AGENT_LAUNCH_TAB_CLOSED_CODE } from '../../../shared/agent-launch-tab-closed' +import { makePaneKey } from '../../../shared/stable-pane-id' +import { prefersStructuredNativeChatByDefault } from '../../../shared/structured-native-chat-launch-route' +import type { TuiAgent } from '../../../shared/tui-agent' +import type { LaunchSource } from '../../../shared/telemetry-events' +import type { Tab } from '../../../shared/tab-types' +import type { SessionOptionValue } from '../../../shared/native-chat-session-options' + +export type HostAgentLaunchArgs = { + agent: TuiAgent + worktreeId: string + /** The split the launch was made from; the tab joins it. */ + groupId?: string + /** Kept by the window: a pane whose agent could not start offers to copy it. */ + prompt: string + /** Absent uses the settings default; `null` means no arguments. */ + agentArgs?: string | null + cwd?: string + /** The launch's session options; only string values reach the host, which reads no others. */ + sessionOptions?: Record + launchSource?: LaunchSource + quickCommandLabel?: string | null + /** The launch seeds a workspace being opened, so its spawn must not reshuffle Recent. */ + pendingActivationSpawn?: boolean + /** The view the tab opens in, decided as for any new agent tab. */ + viewMode?: Tab['viewMode'] +} + +/** What became of the launch, as this window must tell it. */ +export type HostAgentLaunchOutcome = + /** The agent was started in this tab's pane, which is attached to it. */ + | { kind: 'started' } + /** The pane shows how the launch ended: couldn't start, or couldn't confirm it started. */ + | { kind: 'pane-says' } + /** The user closed the tab while it started, and with it the launch: nothing more to say. */ + | { kind: 'closed-by-user' } + /** The tab is gone, so the window says it: nothing started, or whether it did is unknown. */ + | { kind: 'not-started'; unconfirmed: boolean; code?: string } + +/** Refused at admission: nothing ran under this click, and the host takes back the tab it was shown. */ +const ADMISSION_REFUSAL_CODES = new Set([ + 'agent_session_operation_invalid', + 'agent_session_operation_conflict', + 'agent_session_operation_expired', + 'agent_session_operation_capacity' +]) + +function tabExists(worktreeId: string, tabId: string): boolean { + return (useAppStore.getState().tabsByWorktree[worktreeId] ?? []).some((tab) => tab.id === tabId) +} + +function closeLaunchTab(worktreeId: string, tabId: string): void { + if (tabExists(worktreeId, tabId)) { + useAppStore.getState().closeTab(tabId, { recordInteraction: false }) + } +} + +// Only a terminal in this pane is one the window can paste into; anything else, its pane explains. +function outcomeFromResult(result: unknown): HostAgentLaunchOutcome { + return isAgentLaunchResult(result) && result.outcome.kind === 'terminal' + ? { kind: 'started' } + : { kind: 'pane-says' } +} + +function stringSessionOptions(options: Record | undefined): { + sessionOptions?: Record +} { + const strings = Object.entries(options ?? {}).filter( + (entry): entry is [string, string] => typeof entry[1] === 'string' + ) + return strings.length > 0 ? { sessionOptions: Object.fromEntries(strings) } : {} +} + +function launchParams(args: HostAgentLaunchArgs) { + return { + agent: args.agent, + target: { kind: 'existing', worktree: `id:${args.worktreeId}` }, + ...(args.agentArgs !== undefined ? { agentArgs: args.agentArgs } : {}), + ...(args.cwd ? { cwd: args.cwd } : {}), + ...stringSessionOptions(args.sessionOptions), + ...(args.launchSource ? { launchSource: args.launchSource } : {}), + ...(args.groupId ? { placement: { groupId: args.groupId } } : {}), + presentation: 'focused' + } +} + +/** + * The ledger is bookkeeping: a desktop past its per-caller row cap still gets its agent, through the + * host's unrecorded launch into the same pane. The refused launch already took back the tab it was + * shown, so the tab closes and returns at spawn (rare: 512 launches a day); the host's reveal at + * spawn hands the pane over (`terminal-presentation-ipc-bridge`). + */ +async function launchWithoutRecord( + args: HostAgentLaunchArgs, + pane: { tabId: string; leafId: string } +): Promise { + try { + return outcomeFromResult( + await callRuntimeRpc({ kind: 'local' }, 'agent.launch', { + ...launchParams(args), + paneKey: makePaneKey(pane.tabId, pane.leafId) + }) + ) + } catch (error) { + const code = error instanceof RuntimeRpcCallError ? error.code : undefined + // A pane the host never revealed would open as a shell; one it did holds the agent's terminal. + if (isAgentLaunchPaneSpawnHeld(pane.tabId, pane.leafId)) { + closeLaunchTab(args.worktreeId, pane.tabId) + } + return { kind: 'not-started', unconfirmed: false, ...(code ? { code } : {}) } + } +} + +async function settleLaunch( + args: HostAgentLaunchArgs, + pane: { tabId: string; leafId: string }, + send: Promise, + releaseHold: () => void +): Promise { + try { + return outcomeFromResult(await send) + } catch (error) { + const code = error instanceof RuntimeRpcCallError ? error.code : undefined + if (code === AGENT_LAUNCH_TAB_CLOSED_CODE) { + return { kind: 'closed-by-user' } + } + if (code === 'agent_session_operation_capacity') { + // Awaited: the pane stays held until the unrecorded launch has its answer. + return await launchWithoutRecord(args, pane) + } + // The host took the pane once it showed the tab; a pane it never took would open as a shell, + // and a refused one is the host's to take back. + const hostTookPane = !isAgentLaunchPaneSpawnHeld(pane.tabId, pane.leafId) + if (!hostTookPane || (code !== undefined && ADMISSION_REFUSAL_CODES.has(code))) { + closeLaunchTab(args.worktreeId, pane.tabId) + return { + kind: 'not-started', + unconfirmed: code === 'agent_session_operation_unknown', + ...(code ? { code } : {}) + } + } + return { kind: 'pane-says' } + } finally { + releaseHold() + } +} + +/** Where the host could turn a launch into a chat (chat is the default), this window's paste has no + * terminal to go to, so such a launch keeps main's own path. */ +export function windowMakesHostLaunchTab(): boolean { + return !prefersStructuredNativeChatByDefault(useAppStore.getState().settings) +} + +export function launchAgentThroughHost(args: HostAgentLaunchArgs): { + tabId: string + outcome: Promise +} { + const store = useAppStore.getState() + const tabId = createBrowserUuid() + const leafId = createBrowserUuid() + // Before the tab exists, so its first mount already waits. + const releaseHold = holdAgentLaunchPaneSpawn(tabId, leafId) + const send = callRuntimeRpc({ kind: 'local' }, 'agent.launchReplay', { + ...launchParams(args), + // A new click is a new operation; the pane is this click's too. + operationId: createAgentSessionOperationId(), + paneKey: makePaneKey(tabId, leafId) + }) + store.createTab(args.worktreeId, args.groupId, undefined, { + id: tabId, + initialLeafId: leafId, + agentLaunchPane: { leafId }, + launchAgent: args.agent, + quickCommandLabel: args.quickCommandLabel, + ...(args.pendingActivationSpawn ? { pendingActivationSpawn: true } : {}), + ...(args.viewMode ? { viewMode: args.viewMode } : {}) + }) + rememberAgentLaunchPanePrompt(tabId, args.prompt) + seedNativeChatAppliedSessionOptions(tabId, args.agent, args.sessionOptions) + // Why: without it an activated launch can stay hidden behind an editor. + store.setActiveTabType('terminal', args.worktreeId) + persistAgentLaunchTabOrder(args.worktreeId, tabId) + return { tabId, outcome: settleLaunch(args, { tabId, leafId }, send, releaseHold) } +} diff --git a/src/renderer/src/lib/agent-paste-draft.ts b/src/renderer/src/lib/agent-paste-draft.ts index 042fe0a6956c..8a6fd9629254 100644 --- a/src/renderer/src/lib/agent-paste-draft.ts +++ b/src/renderer/src/lib/agent-paste-draft.ts @@ -84,6 +84,8 @@ export async function pasteDraftWhenAgentReady(args: { timeoutMs?: number onTimeout?: () => void onUnconfirmedDelivery?: () => void + /** Whether the paste may be written at all; readiness is observed while it is pending. */ + sendGate?: Promise }): Promise { const { tabId, content, agent, submit, forcePaste, timeoutMs, onTimeout, onUnconfirmedDelivery } = args @@ -102,13 +104,18 @@ export async function pasteDraftWhenAgentReady(args: { const readySignal = resolvePasteReadySignal(agentConfig, submit === true) const settings = getSettingsForAgentTabRuntimeOwner(tabId) const readinessTimeoutMs = resolveDraftPasteReadyTimeoutMs(agent, timeoutMs) - const readiness = await waitForAgentDraftInputReadyOnTab({ + const readinessResult = waitForAgentDraftInputReadyOnTab({ tabId, spawnTimeoutMs: PTY_SPAWN_TIMEOUT_MS, readinessTimeoutMs, readySignal, settings }) + // Why: a closed gate is the caller's own outcome to report; the bounded wait above just lapses. + if (args.sendGate && !(await args.sendGate)) { + return false + } + const readiness = await readinessResult if (!readiness) { onTimeout?.() return false diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts index adf90ead68bf..f9c41da3ef01 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts @@ -116,6 +116,13 @@ vi.mock('@/lib/agent-paste-draft', () => ({ pasteDraftWhenAgentReady: mockPasteDraftWhenAgentReady })) +// Why: this file pins main's window launch and its paste, which chat-default and paired launches +// still take; an AI button's host launch reuses that paste and is pinned in its own tests. +vi.mock('@/lib/launch-agent-new-tab-host-route', () => ({ + newTabPromptLaunchesThroughHost: () => false, + launchNewTabPromptThroughHost: vi.fn() +})) + vi.mock('@/lib/agent-ready-wait', () => ({ waitForAgentReady: mockWaitForAgentReady })) diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index deabaa32a597..9fa639d00eda 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -3,11 +3,12 @@ import type { AgentStartupPlan } from '@/lib/tui-agent-startup' import { planLaunchAgentStartupPrompt } from '@/lib/launch-agent-startup-prompt-plan' import { persistAgentLaunchTabOrder } from '@/lib/launch-agent-tab-order' import { tuiAgentToAgentKind } from '@/lib/telemetry' -import { createPasteReadinessTimeoutNotice } from '@/lib/launch-agent-paste-timeout-notice' +import { seedNativeChatLaunchDraftForAgentTab } from '@/lib/agent-launch-prompt-delivery' +import { pasteAgentLaunchPromptOnceReady } from '@/lib/launch-agent-tab-prompt-paste' import { - deliverLaunchPromptToAgentTab, - seedNativeChatLaunchDraftForAgentTab -} from '@/lib/agent-launch-prompt-delivery' + launchNewTabPromptThroughHost, + newTabPromptLaunchesThroughHost +} from '@/lib/launch-agent-new-tab-host-route' import { initialAgentTabViewModeProps } from '@/lib/native-chat-initial-view-mode' import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' @@ -18,7 +19,6 @@ import { resolveTuiAgentLaunchEnv } from '../../../shared/tui-agent-launch-defaults' import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' -import { seedCommandCodeSubmittedPromptStatus } from '@/lib/command-code-prompt-status-seed' import type { TuiAgent } from '../../../shared/tui-agent' import type { LaunchSource } from '../../../shared/telemetry-events' import { resolveAgentLaunchExecutionContext } from '@/lib/launch-agent-execution-context' @@ -247,6 +247,35 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent if (beforeSurfaceOpen?.({ kind: 'local-terminal' }) === false) { return null } + if ( + pasteDraftAfterLaunch !== null && + newTabPromptLaunchesThroughHost({ promptDelivery, pastesPrompt: true }) + ) { + const launched = launchNewTabPromptThroughHost({ + agent, + worktreeId, + ...(groupId ? { groupId } : {}), + prompt: trimmedPrompt, + ...(agentArgs !== undefined ? { agentArgs } : {}), + ...(initialCwd?.trim() ? { cwd: initialCwd } : {}), + ...(startupPlan.sessionOptions ? { sessionOptions: startupPlan.sessionOptions } : {}), + // The same source main's window stamps on its own launches. + launchSource: launchSource ?? 'tab_bar_quick_launch', + quickCommandLabel, + ...(pendingActivationSpawn ? { pendingActivationSpawn: true } : {}), + ...(initialViewModeProps.viewMode ? { viewMode: initialViewModeProps.viewMode } : {}), + pasteContent: pasteDraftAfterLaunch, + submit: submitPastedPrompt, + ...(onPromptDelivered ? { onPromptDelivered } : {}), + ...(onPromptDeliveryUnconfirmed ? { onPromptDeliveryUnconfirmed } : {}) + }) + return { + surface: { kind: 'local-terminal', tabId: launched.tabId }, + startupPlan, + pasteDraftAfterLaunch: true, + promptDeliveryResult: launched.promptDeliveryResult + } + } // Why: queue startup BEFORE TerminalPane mounts — it snapshots pendingStartupByTabId in useState on first render. // Why: followup path pastes an unsubmitted draft, so gate the initial chat view like a draft launch, not auto-submit. const tab = store.createTab(worktreeId, groupId, undefined, { @@ -288,30 +317,15 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent seedNativeChatLaunchDraftForAgentTab({ tabId: tab.id, agent, text: trimmedPrompt }) } if (pasteDraftAfterLaunch !== null) { - const timeoutNotice = createPasteReadinessTimeoutNotice({ + const deliveryPromise = pasteAgentLaunchPromptOnceReady({ worktreeId, tabId: tab.id, agent, - submitted: submitPastedPrompt - }) - const deliveryPromise = deliverLaunchPromptToAgentTab({ - tabId: tab.id, content: pasteDraftAfterLaunch, - agent, submit: submitPastedPrompt, - forcePaste: true, - onTimeout: timeoutNotice.onTimeout, - ...(onPromptDeliveryUnconfirmed ? { onUnconfirmedDelivery: onPromptDeliveryUnconfirmed } : {}) - }).then((delivered) => { - if (delivered) { - if (agent === 'command-code' && submitPastedPrompt) { - // Why: Command Code has no prompt-submit hook; when Orca submits a - // generated prompt after readiness, seed working at delivery time. - seedCommandCodeSubmittedPromptStatus(worktreeId, tab.id, trimmedPrompt) - } - onPromptDelivered?.() - } - return { delivered, failureNotified: !delivered && timeoutNotice.wasNotified() } + prompt: trimmedPrompt, + ...(onPromptDelivered ? { onPromptDelivered } : {}), + ...(onPromptDeliveryUnconfirmed ? { onPromptDeliveryUnconfirmed } : {}) }) if (promptDelivery === 'submit-after-ready') { promptDeliveryResult = deliveryPromise diff --git a/src/renderer/src/lib/launch-agent-new-tab-host-route-readiness.test.ts b/src/renderer/src/lib/launch-agent-new-tab-host-route-readiness.test.ts new file mode 100644 index 000000000000..23206c874359 --- /dev/null +++ b/src/renderer/src/lib/launch-agent-new-tab-host-route-readiness.test.ts @@ -0,0 +1,156 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { HostAgentLaunchOutcome } from './agent-launch-through-host' + +const TAB = '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d' + +const testState = vi.hoisted(() => { + const ptyIdsByTabId: Record = {} + const writes: { at: number; data: string }[] = [] + return { + appState: { + settings: {}, + activeWorktreeId: 'wt-1', + tabsByWorktree: {}, + ptyIdsByTabId, + seedNativeChatLaunchPrompt: vi.fn(), + seedNativeChatLaunchDraft: vi.fn(), + markNativeChatLaunchPromptFailed: vi.fn() + }, + storeSubscribers: new Set<(state: unknown) => void>(), + ptyWatchers: new Map void>(), + writes, + launchAgentThroughHost: vi.fn() + } +}) + +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => testState.appState, + subscribe: (subscriber: (state: unknown) => void) => { + testState.storeSubscribers.add(subscriber) + return () => testState.storeSubscribers.delete(subscriber) + } + } +})) +vi.mock('@/lib/agent-launch-through-host', () => ({ + launchAgentThroughHost: testState.launchAgentThroughHost, + windowMakesHostLaunchTab: () => true +})) +vi.mock('@/components/terminal-pane/pty-data-sidecar-subscriptions', () => ({ + subscribeToPtyData: (ptyId: string, watcher: (data: string) => void) => { + testState.ptyWatchers.set(ptyId, watcher) + return () => testState.ptyWatchers.delete(ptyId) + } +})) +vi.mock('@/components/terminal-pane/pty-pre-handler-buffer', () => ({ + replayPreHandlerPtyData: vi.fn() +})) +vi.mock('@/runtime/runtime-terminal-inspection', () => ({ + isRemoteRuntimePtyId: () => false, + inspectRuntimeTerminalProcess: vi.fn(async () => null), + sendRuntimePtyInputVerified: async (_settings: unknown, _ptyId: string, data: string) => { + testState.writes.push({ at: Date.now(), data }) + return true + } +})) +vi.mock('@/runtime/runtime-terminal-stream', () => ({ subscribeToRuntimeTerminalData: vi.fn() })) +vi.mock('./agent-ready-wait', () => ({ waitForAgentReady: vi.fn(async () => ({ ready: false })) })) +vi.mock('@/lib/telemetry', () => ({ track: vi.fn(), tuiAgentToAgentKind: () => 'claude' })) +vi.mock('sonner', () => ({ toast: { error: vi.fn(), message: vi.fn() } })) + +const { launchNewTabPromptThroughHost } = await import('./launch-agent-new-tab-host-route') + +function attachHostPty(ptyId: string): void { + testState.appState.ptyIdsByTabId = { [TAB]: [ptyId] } + for (const subscriber of testState.storeSubscribers) { + subscriber(testState.appState) + } +} + +describe('an AI button whose agent is ready before the host answers', () => { + beforeEach(() => { + vi.useFakeTimers({ now: 0 }) + vi.stubGlobal('window', { + setTimeout: globalThis.setTimeout, + clearTimeout: globalThis.clearTimeout + }) + testState.appState.ptyIdsByTabId = {} + testState.storeSubscribers.clear() + testState.ptyWatchers.clear() + testState.writes = [] + testState.appState.seedNativeChatLaunchPrompt.mockClear() + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.useRealTimers() + }) + + // Regression: the paste watched the terminal only after the host's reply, so an agent that had + // already enabled bracketed paste was never seen ready and its prompt waited out the budget. + it('is pasted at its own ready signal plus the quiet window, not after the timeout', async () => { + let answer!: (outcome: HostAgentLaunchOutcome) => void + testState.launchAgentThroughHost.mockReturnValue({ + tabId: TAB, + outcome: new Promise((done) => (answer = done)) + }) + const onPromptDeliveryUnconfirmed = vi.fn() + const { promptDeliveryResult } = launchNewTabPromptThroughHost({ + agent: 'claude', + worktreeId: 'wt-1', + prompt: 'resolve the conflicts', + pasteContent: 'resolve the conflicts', + submit: true, + onPromptDeliveryUnconfirmed + }) + + await vi.advanceTimersByTimeAsync(50) + attachHostPty('pty-1') + await vi.advanceTimersByTimeAsync(10) + testState.ptyWatchers.get('pty-1')?.('claude drawn\r\n\x1b[?2004h> ') + await vi.advanceTimersByTimeAsync(240) + expect(testState.writes).toEqual([]) + expect(testState.appState.seedNativeChatLaunchPrompt).not.toHaveBeenCalled() + + answer({ kind: 'started' }) + await vi.advanceTimersByTimeAsync(1200) + expect(testState.writes).toEqual([]) + + // Ready at 60 ms + the 1.5 s quiet window. + await vi.advanceTimersByTimeAsync(60) + expect(testState.writes[0]).toEqual({ + at: 1560, + data: '\x1b[200~resolve the conflicts\x1b[201~' + }) + expect(testState.appState.seedNativeChatLaunchPrompt).toHaveBeenCalledOnce() + + await vi.advanceTimersByTimeAsync(2000) + await expect(promptDeliveryResult).resolves.toEqual({ delivered: true, failureNotified: false }) + expect(onPromptDeliveryUnconfirmed).not.toHaveBeenCalled() + }) + + it('writes nothing, and seeds no chat copy, when the host says its agent did not start', async () => { + let answer!: (outcome: HostAgentLaunchOutcome) => void + testState.launchAgentThroughHost.mockReturnValue({ + tabId: TAB, + outcome: new Promise((done) => (answer = done)) + }) + const { promptDeliveryResult } = launchNewTabPromptThroughHost({ + agent: 'claude', + worktreeId: 'wt-1', + prompt: 'resolve the conflicts', + pasteContent: 'resolve the conflicts', + submit: true + }) + + attachHostPty('pty-1') + testState.ptyWatchers.get('pty-1')?.('\x1b[?2004h> ') + answer({ kind: 'pane-says' }) + await vi.advanceTimersByTimeAsync(10_000) + + await expect(promptDeliveryResult).resolves.toEqual({ delivered: false, failureNotified: true }) + expect(testState.writes).toEqual([]) + expect(testState.appState.seedNativeChatLaunchPrompt).not.toHaveBeenCalled() + expect(testState.appState.markNativeChatLaunchPromptFailed).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/lib/launch-agent-new-tab-host-route.test.ts b/src/renderer/src/lib/launch-agent-new-tab-host-route.test.ts new file mode 100644 index 000000000000..54213717101b --- /dev/null +++ b/src/renderer/src/lib/launch-agent-new-tab-host-route.test.ts @@ -0,0 +1,118 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { HostAgentLaunchOutcome } from './agent-launch-through-host' + +const host = vi.hoisted(() => ({ + launchAgentThroughHost: vi.fn(), + windowMakesHostLaunchTab: vi.fn(() => true) +})) +vi.mock('@/lib/agent-launch-through-host', () => host) +const pasteAgentLaunchPromptOnceReady = vi.hoisted(() => + vi.fn(async (_args: { sendGate?: Promise }) => ({ + delivered: true, + failureNotified: false + })) +) +vi.mock('@/lib/launch-agent-tab-prompt-paste', () => ({ pasteAgentLaunchPromptOnceReady })) +const toast = vi.hoisted(() => ({ error: vi.fn() })) +vi.mock('sonner', () => ({ toast })) + +const { launchNewTabPromptThroughHost, newTabPromptLaunchesThroughHost } = + await import('./launch-agent-new-tab-host-route') + +const TAB = '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d' + +function deferredOutcome() { + let resolve!: (outcome: HostAgentLaunchOutcome) => void + const promise = new Promise((done) => (resolve = done)) + host.launchAgentThroughHost.mockReturnValue({ tabId: TAB, outcome: promise }) + return resolve +} + +function launch() { + return launchNewTabPromptThroughHost({ + agent: 'claude', + worktreeId: 'wt-1', + prompt: 'fix the failing checks', + pasteContent: 'fix the failing checks', + submit: true + }) +} + +beforeEach(() => { + vi.clearAllMocks() + host.windowMakesHostLaunchTab.mockReturnValue(true) +}) + +describe('an AI button launched through the host', () => { + function sendGate(): Promise { + const gate = pasteAgentLaunchPromptOnceReady.mock.calls.at(-1)?.[0].sendGate + if (!gate) { + throw new Error('the paste was set up without a send gate') + } + return gate + } + + // Why: readiness is watched from the tab's first output, as main watches it, but the paste must + // meet the host's agent, never a shell this window spawned first. + it('watches for readiness at once but writes only once the host has its agent in the tab', async () => { + const answer = deferredOutcome() + const { tabId, promptDeliveryResult } = launch() + expect(pasteAgentLaunchPromptOnceReady).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ tabId, worktreeId: 'wt-1', submit: true }) + ) + let gateOpen: boolean | undefined + void sendGate().then((open) => (gateOpen = open)) + await Promise.resolve() + expect(gateOpen).toBeUndefined() + + answer({ kind: 'started' }) + + await expect(sendGate()).resolves.toBe(true) + await expect(promptDeliveryResult).resolves.toEqual({ delivered: true, failureNotified: false }) + expect(tabId).toBe(TAB) + }) + + it('never pastes for a launch refused before the agent ran, and says so once', async () => { + deferredOutcome()({ kind: 'not-started', unconfirmed: false, code: 'worktree_not_found' }) + + await expect(launch().promptDeliveryResult).resolves.toEqual({ + delivered: false, + failureNotified: true + }) + await expect(sendGate()).resolves.toBe(false) + expect(toast.error).toHaveBeenCalledOnce() + }) + + it('says nothing and pastes nothing when the user closed the tab, or its pane explains', async () => { + for (const outcome of [{ kind: 'closed-by-user' }, { kind: 'pane-says' }] as const) { + deferredOutcome()(outcome) + await expect(launch().promptDeliveryResult).resolves.toEqual({ + delivered: false, + failureNotified: true + }) + await expect(sendGate()).resolves.toBe(false) + } + expect(toast.error).not.toHaveBeenCalled() + }) +}) + +describe('which new agent tabs start through the host', () => { + it('an AI button whose prompt is pasted once ready, in a terminal this window makes', () => { + expect( + newTabPromptLaunchesThroughHost({ promptDelivery: 'submit-after-ready', pastesPrompt: true }) + ).toBe(true) + }) + + it('never a typed prompt, nor a launch the host could turn into a chat', () => { + expect( + newTabPromptLaunchesThroughHost({ promptDelivery: 'auto-submit', pastesPrompt: true }) + ).toBe(false) + expect(newTabPromptLaunchesThroughHost({ promptDelivery: 'draft', pastesPrompt: true })).toBe( + false + ) + host.windowMakesHostLaunchTab.mockReturnValue(false) + expect( + newTabPromptLaunchesThroughHost({ promptDelivery: 'submit-after-ready', pastesPrompt: true }) + ).toBe(false) + }) +}) diff --git a/src/renderer/src/lib/launch-agent-new-tab-host-route.ts b/src/renderer/src/lib/launch-agent-new-tab-host-route.ts new file mode 100644 index 000000000000..47a7f2675e2f --- /dev/null +++ b/src/renderer/src/lib/launch-agent-new-tab-host-route.ts @@ -0,0 +1,94 @@ +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' +import { agentLaunchPaneNoticeText } from '@/components/terminal-pane/agent-launch-pane-notice-text' +import { + launchAgentThroughHost, + windowMakesHostLaunchTab, + type HostAgentLaunchArgs, + type HostAgentLaunchOutcome +} from '@/lib/agent-launch-through-host' +import { pasteAgentLaunchPromptOnceReady } from '@/lib/launch-agent-tab-prompt-paste' + +/** + * Whether a new agent tab starts through the host's `agent.launch`: an AI button's launch, whose + * prompt is pasted once the agent is ready, in a terminal this window makes. Temporary: the window + * keeps pasting the prompt as main does until the host delivers it. A typed prompt (`auto-submit`, + * `draft`) keeps main's launch, and so does a launch the host could turn into a chat. + */ +export function newTabPromptLaunchesThroughHost(args: { + promptDelivery: 'auto-submit' | 'draft' | 'submit-after-ready' + pastesPrompt: boolean +}): boolean { + return ( + args.promptDelivery === 'submit-after-ready' && args.pastesPrompt && windowMakesHostLaunchTab() + ) +} + +/** The tab is gone, so the pane's own words go in a notice, with its prompt to copy. */ +function showLaunchNotStartedNotice(outcome: HostAgentLaunchOutcome, prompt: string): void { + if (outcome.kind !== 'not-started') { + return + } + toast.error( + agentLaunchPaneNoticeText( + outcome.unconfirmed + ? { kind: 'unconfirmed' } + : { kind: 'not-started', code: outcome.code ?? '' } + ), + { + action: { + label: translate( + 'auto.components.terminal.pane.AgentLaunchPaneNotice.copyPrompt', + 'Copy prompt' + ), + onClick: () => void window.api.ui.writeClipboardText(prompt) + } + } + ) +} + +/** + * Starts the agent through the host with no prompt and pastes the prompt as main does. Readiness is + * watched from the moment the tab's terminal exists, as main watches it, so an agent that is ready + * before the host answers is not missed; the paste is written only once the host has started its + * agent in this tab, so it never meets a shell this window spawned. + */ +export function launchNewTabPromptThroughHost( + args: HostAgentLaunchArgs & { + /** What is pasted, which can differ from the prompt the user wrote. */ + pasteContent: string + submit: boolean + onPromptDelivered?: () => void + onPromptDeliveryUnconfirmed?: () => void + } +): { + tabId: string + promptDeliveryResult: Promise<{ delivered: boolean; failureNotified: boolean }> +} { + const { pasteContent, submit, onPromptDelivered, onPromptDeliveryUnconfirmed, ...launch } = args + const { tabId, outcome } = launchAgentThroughHost(launch) + // Only the host's agent can fill this tab's terminal while the window's own spawn is held. + const pasted = pasteAgentLaunchPromptOnceReady({ + worktreeId: args.worktreeId, + tabId, + agent: args.agent, + content: pasteContent, + submit, + prompt: args.prompt, + sendGate: outcome.then( + (launched) => launched.kind === 'started', + () => false + ), + ...(onPromptDelivered ? { onPromptDelivered } : {}), + ...(onPromptDeliveryUnconfirmed ? { onPromptDeliveryUnconfirmed } : {}) + }) + const promptDeliveryResult = outcome.then((launched) => { + if (launched.kind === 'started') { + return pasted + } + // The pane, or this notice for a tab that went, already says why: never a second notice. + showLaunchNotStartedNotice(launched, args.prompt) + return { delivered: false, failureNotified: true } + }) + return { tabId, promptDeliveryResult } +} diff --git a/src/renderer/src/lib/launch-agent-tab-prompt-paste.ts b/src/renderer/src/lib/launch-agent-tab-prompt-paste.ts new file mode 100644 index 000000000000..de3fa40cf452 --- /dev/null +++ b/src/renderer/src/lib/launch-agent-tab-prompt-paste.ts @@ -0,0 +1,52 @@ +import { createPasteReadinessTimeoutNotice } from '@/lib/launch-agent-paste-timeout-notice' +import { deliverLaunchPromptToAgentTab } from '@/lib/agent-launch-prompt-delivery' +import { seedCommandCodeSubmittedPromptStatus } from '@/lib/command-code-prompt-status-seed' +import type { TuiAgent } from '../../../shared/tui-agent' + +/** + * Pastes a new agent tab's prompt once its agent is ready, and says whether it landed. The one copy + * of this delivery, for a tab whose terminal this window spawned and for one an `agent.launch` + * spawned into it. + */ +export function pasteAgentLaunchPromptOnceReady(args: { + worktreeId: string + tabId: string + agent: TuiAgent + /** What is pasted, which can differ from the prompt the user wrote. */ + content: string + submit: boolean + /** The prompt as written, which Command Code's working row shows. */ + prompt: string + onPromptDelivered?: () => void + onPromptDeliveryUnconfirmed?: () => void + /** Whether the paste may be written; readiness is observed while it is pending. */ + sendGate?: Promise +}): Promise<{ delivered: boolean; failureNotified: boolean }> { + const { worktreeId, tabId, agent, submit, onPromptDelivered, onPromptDeliveryUnconfirmed } = args + const timeoutNotice = createPasteReadinessTimeoutNotice({ + worktreeId, + tabId, + agent, + submitted: submit + }) + return deliverLaunchPromptToAgentTab({ + tabId, + content: args.content, + agent, + submit, + forcePaste: true, + onTimeout: timeoutNotice.onTimeout, + ...(args.sendGate ? { sendGate: args.sendGate } : {}), + ...(onPromptDeliveryUnconfirmed ? { onUnconfirmedDelivery: onPromptDeliveryUnconfirmed } : {}) + }).then((delivered) => { + if (delivered) { + if (agent === 'command-code' && submit) { + // Why: Command Code has no prompt-submit hook; when Orca submits a + // generated prompt after readiness, seed working at delivery time. + seedCommandCodeSubmittedPromptStatus(worktreeId, tabId, args.prompt) + } + onPromptDelivered?.() + } + return { delivered, failureNotified: !delivered && timeoutNotice.wasNotified() } + }) +} diff --git a/src/renderer/src/lib/source-control-agent-action-launch-check.test.ts b/src/renderer/src/lib/source-control-agent-action-launch-check.test.ts new file mode 100644 index 000000000000..390af35ebe53 --- /dev/null +++ b/src/renderer/src/lib/source-control-agent-action-launch-check.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest' +import { checkSourceControlAgentActionLaunch } from './source-control-agent-action-launch-check' + +const BASE = { + agent: 'codex', + commandInput: 'Fix checks', + detectedAgents: ['codex'], + platform: 'linux' +} as const + +describe('checkSourceControlAgentActionLaunch', () => { + it('passes a launch the user has nothing to fix in', () => { + expect(checkSourceControlAgentActionLaunch({ ...BASE, detectedAgents: ['codex'] })).toEqual({ + ok: true + }) + }) + + it('rejects disabled agents', () => { + expect( + checkSourceControlAgentActionLaunch({ + ...BASE, + detectedAgents: ['codex'], + disabledAgents: ['codex'] + }) + ).toEqual({ ok: false, error: 'The selected agent is disabled in Settings.' }) + }) + + it('rejects agents not detected on the current host', () => { + expect( + checkSourceControlAgentActionLaunch({ ...BASE, agent: 'claude', detectedAgents: ['codex'] }) + ).toEqual({ ok: false, error: 'The selected agent was not detected on this workspace host.' }) + }) + + it('rejects an empty command input', () => { + expect( + checkSourceControlAgentActionLaunch({ + ...BASE, + detectedAgents: ['codex'], + commandInput: ' ' + }) + ).toEqual({ ok: false, error: 'Command input is empty.' }) + }) + + it('rejects invalid per-action CLI arguments', () => { + expect( + checkSourceControlAgentActionLaunch({ + ...BASE, + detectedAgents: ['codex'], + agentArgs: '--model "unterminated' + }) + ).toEqual({ + ok: false, + error: 'CLI arguments are invalid: Unclosed quote in command template.' + }) + }) +}) diff --git a/src/renderer/src/lib/source-control-agent-action-launch-check.ts b/src/renderer/src/lib/source-control-agent-action-launch-check.ts new file mode 100644 index 000000000000..cd45284f2000 --- /dev/null +++ b/src/renderer/src/lib/source-control-agent-action-launch-check.ts @@ -0,0 +1,100 @@ +import { buildAgentStartupPlan, planAgentCliArgsSuffix } from '@/lib/tui-agent-startup' +import { CLIENT_PLATFORM } from '@/lib/new-workspace' +import { isTuiAgentEnabled } from '../../../shared/tui-agent-selection' +import type { TuiAgent } from '../../../shared/tui-agent' +import { translate } from '@/i18n/i18n' +import { resolveLocalWindowsAgentStartupShell } from '../../../shared/windows-terminal-shell' + +/** + * What the source-control dialog checks before it starts an agent: what the user can fix here (the + * agent, its arguments, the input). How the prompt then reaches the agent is the host's to decide + * (`agent.launch`), so nothing here predicts it. + */ +export type SourceControlLaunchCheckResult = { ok: true } | { ok: false; error: string } + +export function checkSourceControlAgentActionLaunch(args: { + agent: TuiAgent | null + commandInput: string + detectedAgents: TuiAgent[] + disabledAgents?: TuiAgent[] + cmdOverrides?: Partial> + agentArgs?: string | null + platform?: NodeJS.Platform + terminalWindowsShell?: string | null + /** Why: SSH remotes deploy the CLI shim as plain `orca`, so the Linux-only + * `orca-ide` rename must not be applied for remote launches. */ + isRemote?: boolean +}): SourceControlLaunchCheckResult { + const agent = args.agent + if (!agent) { + return { + ok: false, + error: translate( + 'auto.lib.source.control.agent.action.plan.a7ac8717c7', + 'Choose an agent before starting.' + ) + } + } + if (!isTuiAgentEnabled(agent, args.disabledAgents)) { + return { + ok: false, + error: translate( + 'auto.lib.source.control.agent.action.plan.b96e091fc9', + 'The selected agent is disabled in Settings.' + ) + } + } + if (!args.detectedAgents.includes(agent)) { + return { + ok: false, + error: translate( + 'auto.lib.source.control.agent.action.plan.8eb541cc83', + 'The selected agent was not detected on this workspace host.' + ) + } + } + + if (!args.commandInput.trim()) { + return { + ok: false, + error: translate( + 'auto.lib.source.control.agent.action.plan.46f1a2c9bd', + 'Command input is empty.' + ) + } + } + + const platform = args.platform ?? CLIENT_PLATFORM + const isRemote = args.isRemote ?? false + const shell = + resolveLocalWindowsAgentStartupShell({ + platform, + isRemote, + terminalWindowsShell: args.terminalWindowsShell + }) ?? (platform === 'win32' ? 'powershell' : 'posix') + const plannedArgs = planAgentCliArgsSuffix(args.agentArgs, shell) + if (!plannedArgs.ok) { + return { ok: false, error: plannedArgs.error } + } + // The agent's own command, prompt aside: an override or arguments it cannot build are the user's. + const launchable = buildAgentStartupPlan({ + agent, + prompt: '', + cmdOverrides: args.cmdOverrides ?? {}, + platform, + shell, + isRemote, + agentArgs: args.agentArgs, + allowEmptyPromptLaunch: true + }) + if (!launchable) { + return { + ok: false, + error: translate( + 'auto.lib.source.control.agent.action.plan.3f0ea9aa0d', + 'Could not build the agent launch command.' + ) + } + } + return { ok: true } +} diff --git a/src/renderer/src/lib/source-control-agent-action-plan.test.ts b/src/renderer/src/lib/source-control-agent-action-plan.test.ts deleted file mode 100644 index a1cb9b6d3edd..000000000000 --- a/src/renderer/src/lib/source-control-agent-action-plan.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { planSourceControlAgentActionLaunch } from './source-control-agent-action-plan' - -describe('planSourceControlAgentActionLaunch', () => { - it('rejects disabled agents', () => { - expect( - planSourceControlAgentActionLaunch({ - agent: 'codex', - commandInput: 'Fix checks', - promptDelivery: 'submit-after-ready', - detectedAgents: ['codex'], - disabledAgents: ['codex'], - platform: 'darwin' - }) - ).toEqual({ ok: false, error: 'The selected agent is disabled in Settings.' }) - }) - - it('rejects agents not detected on the current host', () => { - expect( - planSourceControlAgentActionLaunch({ - agent: 'claude', - commandInput: 'Fix checks', - promptDelivery: 'submit-after-ready', - detectedAgents: ['codex'], - platform: 'linux' - }) - ).toEqual({ ok: false, error: 'The selected agent was not detected on this workspace host.' }) - }) - - it('mirrors submit-after-ready delivery without embedding the prompt in the command', () => { - const result = planSourceControlAgentActionLaunch({ - agent: 'codex', - commandInput: 'Fix checks', - promptDelivery: 'submit-after-ready', - detectedAgents: ['codex'], - platform: 'linux' - }) - - expect(result.ok && result.delivery).toBe('paste-submit') - expect(result.ok && result.commandLabel).toBe('codex') - expect(result.ok && result.summary).toContain('pastes and submits') - expect(result.ok && result.caveat).toContain('PATH') - }) - - it('includes per-action CLI arguments in submit-after-ready launch plans', () => { - const result = planSourceControlAgentActionLaunch({ - agent: 'codex', - commandInput: 'Fix checks', - agentArgs: '--model gpt-5.5', - promptDelivery: 'submit-after-ready', - detectedAgents: ['codex'], - platform: 'linux' - }) - - expect(result.ok && result.commandLabel).toBe("codex '--model' 'gpt-5.5'") - }) - - it.each([ - { - terminalWindowsShell: 'cmd.exe', - expectedCommand: 'powershell.exe -NoProfile -EncodedCommand' - }, - { - terminalWindowsShell: 'git-bash', - expectedCommand: 'ORCA_HERMES_STARTUP_QUERY' - } - ])( - 'uses $terminalWindowsShell quoting for Hermes source-control prompts', - ({ terminalWindowsShell, expectedCommand }) => { - const result = planSourceControlAgentActionLaunch({ - agent: 'hermes', - commandInput: 'Review the change', - promptDelivery: 'auto-submit', - detectedAgents: ['hermes'], - platform: 'win32', - terminalWindowsShell - }) - - expect(result.ok && result.plan.launchCommand).toContain(expectedCommand) - expect(result.ok && result.plan.env?.ORCA_HERMES_STARTUP_QUERY).toBe('Review the change') - } - ) - - it('rejects invalid per-action CLI arguments', () => { - expect( - planSourceControlAgentActionLaunch({ - agent: 'codex', - commandInput: 'Fix checks', - agentArgs: '--model "unterminated', - promptDelivery: 'submit-after-ready', - detectedAgents: ['codex'], - platform: 'linux' - }) - ).toEqual({ - ok: false, - error: 'CLI arguments are invalid: Unclosed quote in command template.' - }) - }) - - it('uses native draft launch when the selected agent supports it', () => { - const result = planSourceControlAgentActionLaunch({ - agent: 'claude', - commandInput: 'Fix checks', - promptDelivery: 'draft', - detectedAgents: ['claude'], - platform: 'darwin' - }) - - expect(result.ok && result.delivery).toBe('draft-native') - expect(result.ok && result.commandLabel).toContain('--prefill') - }) -}) diff --git a/src/renderer/src/lib/source-control-agent-action-plan.ts b/src/renderer/src/lib/source-control-agent-action-plan.ts deleted file mode 100644 index 5e6681975e6a..000000000000 --- a/src/renderer/src/lib/source-control-agent-action-plan.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { - buildAgentDraftLaunchPlan, - buildAgentStartupPlan, - planAgentCliArgsSuffix, - type AgentStartupPlan -} from '@/lib/tui-agent-startup' -import { CLIENT_PLATFORM } from '@/lib/new-workspace' -import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' -import { isTuiAgentEnabled } from '../../../shared/tui-agent-selection' -import type { TuiAgent } from '../../../shared/tui-agent' -import { translate } from '@/i18n/i18n' -import { resolveLocalWindowsAgentStartupShell } from '../../../shared/windows-terminal-shell' -import type { SessionOptionValue } from '../../../shared/native-chat-session-options' - -export type SourceControlLaunchPlanDelivery = - | 'argv' - | 'draft-native' - | 'draft-paste' - | 'paste-submit' - -export type SourceControlLaunchPlanResult = - | { - ok: true - plan: AgentStartupPlan - delivery: SourceControlLaunchPlanDelivery - commandLabel: string - summary: string - caveat: string - } - | { ok: false; error: string } - -export function planSourceControlAgentActionLaunch(args: { - agent: TuiAgent | null - commandInput: string - promptDelivery: 'auto-submit' | 'draft' | 'submit-after-ready' - detectedAgents: TuiAgent[] - disabledAgents?: TuiAgent[] - cmdOverrides?: Partial> - agentArgs?: string | null - sessionOptions?: Record - platform?: NodeJS.Platform - terminalWindowsShell?: string | null - /** Why: SSH remotes deploy the CLI shim as plain `orca`, so the Linux-only - * `orca-ide` rename must not be applied for remote launches. */ - isRemote?: boolean -}): SourceControlLaunchPlanResult { - const agent = args.agent - if (!agent) { - return { - ok: false, - error: translate( - 'auto.lib.source.control.agent.action.plan.a7ac8717c7', - 'Choose an agent before starting.' - ) - } - } - if (!isTuiAgentEnabled(agent, args.disabledAgents)) { - return { - ok: false, - error: translate( - 'auto.lib.source.control.agent.action.plan.b96e091fc9', - 'The selected agent is disabled in Settings.' - ) - } - } - if (!args.detectedAgents.includes(agent)) { - return { - ok: false, - error: translate( - 'auto.lib.source.control.agent.action.plan.8eb541cc83', - 'The selected agent was not detected on this workspace host.' - ) - } - } - - const trimmedInput = args.commandInput.trim() - if (!trimmedInput) { - return { - ok: false, - error: translate( - 'auto.lib.source.control.agent.action.plan.46f1a2c9bd', - 'Command input is empty.' - ) - } - } - - const cmdOverrides = args.cmdOverrides ?? {} - const platform = args.platform ?? CLIENT_PLATFORM - const isRemote = args.isRemote ?? false - const shell = - resolveLocalWindowsAgentStartupShell({ - platform, - isRemote, - terminalWindowsShell: args.terminalWindowsShell - }) ?? (platform === 'win32' ? 'powershell' : 'posix') - const plannedArgs = planAgentCliArgsSuffix(args.agentArgs, shell) - if (!plannedArgs.ok) { - return { ok: false, error: plannedArgs.error } - } - let startupPlan: AgentStartupPlan | null = null - let delivery: SourceControlLaunchPlanDelivery - - if (args.promptDelivery === 'submit-after-ready') { - startupPlan = buildAgentStartupPlan({ - agent, - prompt: '', - cmdOverrides, - platform, - shell, - isRemote, - agentArgs: args.agentArgs, - sessionOptions: args.sessionOptions, - allowEmptyPromptLaunch: true - }) - delivery = 'paste-submit' - } else if (args.promptDelivery === 'draft') { - const draftLaunchPlan = buildAgentDraftLaunchPlan({ - agent, - draft: trimmedInput, - cmdOverrides, - platform, - shell, - isRemote, - agentArgs: args.agentArgs, - sessionOptions: args.sessionOptions - }) - if (draftLaunchPlan) { - startupPlan = { - agent: draftLaunchPlan.agent, - launchCommand: draftLaunchPlan.launchCommand, - expectedProcess: draftLaunchPlan.expectedProcess, - followupPrompt: null, - launchConfig: draftLaunchPlan.launchConfig, - ...(draftLaunchPlan.sessionOptions - ? { sessionOptions: draftLaunchPlan.sessionOptions } - : {}), - ...(draftLaunchPlan.startupCommandDelivery - ? { startupCommandDelivery: draftLaunchPlan.startupCommandDelivery } - : {}), - ...(draftLaunchPlan.env ? { env: draftLaunchPlan.env } : {}) - } - delivery = 'draft-native' - } else { - startupPlan = buildAgentStartupPlan({ - agent, - prompt: '', - cmdOverrides, - platform, - shell, - isRemote, - agentArgs: args.agentArgs, - sessionOptions: args.sessionOptions, - allowEmptyPromptLaunch: true - }) - delivery = 'draft-paste' - } - } else if (TUI_AGENT_CONFIG[agent].promptInjectionMode === 'stdin-after-start') { - startupPlan = buildAgentStartupPlan({ - agent, - prompt: '', - cmdOverrides, - platform, - shell, - isRemote, - agentArgs: args.agentArgs, - sessionOptions: args.sessionOptions, - allowEmptyPromptLaunch: true - }) - delivery = 'draft-paste' - } else { - startupPlan = buildAgentStartupPlan({ - agent, - prompt: trimmedInput, - cmdOverrides, - platform, - shell, - isRemote, - agentArgs: args.agentArgs, - sessionOptions: args.sessionOptions, - allowEmptyPromptLaunch: false - }) - delivery = 'argv' - } - - if (!startupPlan) { - return { - ok: false, - error: translate( - 'auto.lib.source.control.agent.action.plan.3f0ea9aa0d', - 'Could not build the agent launch command.' - ) - } - } - - const summary = - delivery === 'paste-submit' - ? 'The agent starts with no prompt, then Orca pastes and submits the command input after the TUI is ready.' - : delivery === 'draft-native' - ? 'The command input is prefilled as an editable draft by the agent launch command.' - : delivery === 'draft-paste' - ? 'The agent starts with no prompt, then Orca pastes the command input as an editable draft after the TUI is ready.' - : 'The command input is included in the launch command and submitted as the first turn.' - - return { - ok: true, - plan: startupPlan, - delivery, - commandLabel: startupPlan.launchCommand, - summary, - caveat: - 'This check builds Orca’s launch plan only. PATH, binary availability, account setup, and terminal startup failures are still caught by the real launch watchdog.' - } -} diff --git a/src/shared/agent-launch-tab-publication.ts b/src/shared/agent-launch-tab-publication.ts index 2eb8741988d0..b4defa0e688b 100644 --- a/src/shared/agent-launch-tab-publication.ts +++ b/src/shared/agent-launch-tab-publication.ts @@ -43,7 +43,8 @@ export type AgentLaunchTabPublishReply = | { requestId: string tabId: string - /** False when a tab with this id already existed and was reused: a retry. */ + /** True when the tab exists only for this launch: the window made it now, or made it for this + * launch just before asking (a desktop launch). False for a retry that found the tab. */ created: boolean placement: AgentLaunchPlacementReceipt }