From 67dc092f18159202e9aa8687f883caa3bcbd340b Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 7 Oct 2026 00:09:40 -0700 Subject: [PATCH 1/8] fix(windows): keep generated skills and snapshots LF (#25972) Pin generated skill JSON and Vitest snapshots to LF so Windows autocrlf checkouts pass byte-for-byte verification. Cover the checkout behavior with a real Git regression test. Co-authored-by: Shuhei Konno --- .gitattributes | 4 ++ ...enerated-artifact-line-ending-pin.test.mjs | 49 +++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 config/scripts/generated-artifact-line-ending-pin.test.mjs 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 }) + } +}) From 4077fb4a8de80059bbb218b58d14ed1efdef113f Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Wed, 7 Oct 2026 03:10:17 -0400 Subject: [PATCH 2/8] fix(ci): actually exclude Electron probes from the headless node-server lanes (#26093) vitest 5 does not apply a CLI --exclude to inline projects, so the glibc floor container ran profile-state-writer-stall.electron.test.ts and failed with 'spawn xvfb-run ENOENT'. Resolve the selectors to files and filter them before vitest sees them. --- config/scripts/run-node-server-tests.mjs | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/config/scripts/run-node-server-tests.mjs b/config/scripts/run-node-server-tests.mjs index 1a9356bc0f39..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,12 +85,14 @@ run(runtimePath, [ ]) function defaultTestArgs() { - return [ - ...nodeServerTestPaths({ artifact, crossRuntime }), - // Electron probes run in desktop jobs; headless compatibility containers have no display. - '--exclude', - '**/*.electron.test.ts', - // A directory selector would otherwise pull them into lanes that lack their inputs. - ...(crossRuntime ? [] : CROSS_RUNTIME_TEST_PATHS.flatMap((path) => ['--exclude', path])) - ] + // 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 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() } From b6f34287143fe4137753cd878658eb09153883ab Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 7 Oct 2026 00:40:53 -0700 Subject: [PATCH 3/8] perf(tests): reuse Vitest transforms between test runs (#26095) * perf(tests): cache Vitest module transforms between runs * test(native-chat): align resume fixture with mention menu props --- config/vitest.config.ts | 1 + 1 file changed, 1 insertion(+) 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. From b06227834099b82661a9b44f94721c12da283833 Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Wed, 7 Oct 2026 07:47:05 +0000 Subject: [PATCH 4/8] fix(native-chat): let code blocks grow to their full height (#26021) The 320px cap made long code a scroll-within-a-scroll. --- src/renderer/src/components/native-chat/NativeChatCodeBlock.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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({

From 34274501867e47b1791b8ef8419e080175a48daf Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Wed, 7 Oct 2026 00:50:41 -0700
Subject: [PATCH 5/8] fix(agent-launch): a phone's launch leaves the desktop
 window where it is (#26025)

* fix(agent-launch): a paired device's launch leaves the desktop window where it is

A launch from the phone (or any paired client) revealed the workspace on the
host desktop: the early tab's viewer rule said `reveal-owner` for callers whose
navigation is their own, and with no early tab the spawn surfaced the owner by
default. One predicate, `agentLaunchMovesHostWindow`, now decides both: paired
callers get `none` for the shown tab and `surfaceOwner: false` at spawn. The
device still selects the tab for itself; the CLI and the desktop are unchanged.

* fix(agent-launch): a paired device's new-workspace launch leaves the desktop window where it is

An agent.launch that creates its workspace never asked agentLaunchMovesHostWindow:
the create payload's activate/runHooks passed straight through, so a paired
caller that set either would switch the desktop to the new workspace and reveal
the startup terminal there. No shipped client sends them through agent.launch
yet, so nothing changes today; it closes the gap before a phone's work-item
creates move here.

For a caller whose launch does not move the host window, the create now runs
unactivated, which provisions setup and default tabs in the background. runHooks
keeps its setup half as setupDecision 'run'. CLI and desktop creates are
unchanged. Also makes the CLI "still reveals" test assert that a terminal was
actually created, so it cannot pass with none.

* docs(agent-launch): a paired client's create no longer activates the host to run setup

The caller-selection header said a launch that creates its workspace relies on
worktree.create's host activation to run setup. Since the previous commit that
holds only for in-process callers: a paired client's create never activates the
host window, and its setup and default tabs are provisioned in the background.
Comment only.
---
 .../methods/agent-launch-caller-selection.ts  |  6 +-
 .../agent-launch-floating-workspace.test.ts   |  2 +
 .../methods/agent-launch-instant-tab.test.ts  | 39 ++++++++++-
 .../rpc/methods/agent-launch-surfaces.ts      |  8 ++-
 .../methods/agent-launch-tab-publication.ts   | 20 ++++--
 .../methods/agent-launch-worktree-creation.ts | 18 +++++-
 .../runtime/rpc/methods/agent-launch.test.ts  | 64 +++++++++++++++++++
 7 files changed, 143 insertions(+), 14 deletions(-)

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..178746facc2b 100644
--- a/src/main/runtime/rpc/methods/agent-launch.test.ts
+++ b/src/main/runtime/rpc/methods/agent-launch.test.ts
@@ -378,6 +378,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 +543,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 +570,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')

From e5ade4b868c1871249ba307f6a30d4543d87dd98 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Wed, 7 Oct 2026 00:55:21 -0700
Subject: [PATCH 6/8] fix(worktrees): when the host can't fetch a remote base,
 create from the local branch and say so (#26009)

* fix(worktrees): create from the usable local base when there is no tracking ref, and say so, on the host's create

The host's create (agent.launch, worktree.create, the phone and the CLI) asked for a remote base like
origin/main with no tracking ref yet fetched it, and threw offline, even when the local branch existed;
it also never reported a base it fell back to. Like the desktop create, it now uses the local branch
without fetching and returns baseFallback, which the desktop window already turns into a notice.

* fix(worktrees): fetch the remote base first and fall back to the local branch only when the fetch fails

The host's create skipped the fetch whenever a local branch matched a
remote base with no tracking ref, so online phone/CLI/agent.launch creates
silently built on a possibly stale local branch, every time. Fetch first,
as main does; only when that fetch fails use the local branch the remote
names and report it with baseFallback. With no local branch the existing
error is unchanged.

Decide the base before naming so branch reuse and conflict checks, the add
and the persisted baseRef all see the base actually used, and return
baseFallback as its own field of the create result instead of riding on the
git add result.

* fix(worktrees): report the fallback for a local ref of the requested name, as the desktop create does

The tracking ref is still missing there; only the fetch is skipped, as before.

* test(worktrees): pin that the host create names only after the base fetch settles

Also pins the existing not-found-after-fetching error, now owned by the base decision.

* fix(worktrees): fall back to the local base only for creates a person asked for; keep the exact-name ref silent

Scheduled automations, orchestration workers and federation keep main's
"Check your network" error offline: nobody is watching to be told the
workspace was built on a possibly stale local branch. The fallback is a
host-side create option (allowLocalBaseFallback, default off, not on the
wire) that the worktree.create RPC and agent.launch set unless the request
carries automation provenance.

A local ref of the exact requested name goes back to silent, as on main's
host: reporting it showed "may not include the latest remote changes"
online for plain local branches.

* test(worktrees): pin the local-base opt-in at agent.launch and its absence for an automation's worktree.create
---
 .../orca-runtime-create-managed-worktree.ts   |  40 +++---
 .../local-worktree-creation.spec.ts           |  67 +++++++++
 .../runtime/rpc/methods/agent-launch.test.ts  |   7 +
 .../rpc/methods/worktree-create-args.test.ts  |  24 ++++
 .../rpc/methods/worktree-create-args.ts       |   3 +
 src/main/runtime/rpc/methods/worktree.test.ts |   5 +
 .../runtime-local-git-worktree-create.ts      |  67 ++-------
 .../runtime-local-worktree-create-base.ts     |  88 ++++++++++++
 .../runtime-local-worktree-create.test.ts     | 132 ++++++++++++++++++
 .../runtime/runtime-local-worktree-create.ts  |  24 +++-
 .../runtime-managed-worktree-create-types.ts  |   5 +
 11 files changed, 383 insertions(+), 79 deletions(-)
 create mode 100644 src/main/runtime/runtime-local-worktree-create-base.ts

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.test.ts b/src/main/runtime/rpc/methods/agent-launch.test.ts
index 178746facc2b..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()
 
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

From 385fe8ab4eeed0ae819376bac59265f3945004fb Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Wed, 7 Oct 2026 01:04:36 -0700
Subject: [PATCH 7/8] perf(tests): avoid repeated persistence imports in
 automation fixtures (#26111)

* perf(tests): cache Vitest module transforms between runs

* test(native-chat): align resume fixture with mention menu props

* perf(tests): reuse the SQLite store fixture in automation suites
---
 .../automation-dispatch-host-fence.test.ts        | 13 +------------
 .../automation-skip-coalescing.test.ts            | 14 ++------------
 .../automation-zero-grace-tick-latency.test.ts    | 13 +------------
 .../retained-run-reconciliation.test.ts           | 13 +------------
 .../automations/run-completion-watcher.test.ts    | 12 +-----------
 src/main/automations/service.test.ts              | 15 ++-------------
 6 files changed, 8 insertions(+), 72 deletions(-)

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')

From 47f4d3f527311effad5bbd732f01272bb0c86376 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Wed, 7 Oct 2026 01:18:57 -0700
Subject: [PATCH 8/8] feat(agent-launch): the desktop AI buttons start their
 agent through agent.launch (#25624)

* feat(agent-launch): host-assigned caller identity and a launch record written when the surface exists

Step 1 of the agent-launch unification, on main.

- The dispatcher stamps every request's caller from what its connection proved (runtime socket:
  the local CLI; the desktop's IPC: the desktop; a paired socket: its device). Params never set it.
- The launch record is written twice: once when the tab exists (what creation settled: on the
  launch command, a draft, or a submit still `unconfirmed`), and again once the prompt's fate is
  known. A restart in between finds the running agent instead of answering "unknown".
- A replay re-derives its terminal handle from the pane key in the running host, and shows
  `unconfirmed` only to callers that advertise agent.launch.prompt-unconfirmed.v1.
- The record store opens in its own slot, without building the chat host; the chat host is built
  on that same store.

Rebuilt from this PR's own commits (b9adf88da04, 0d7d9b5b30e, c83f44dd724, 39d15d52c0f) onto
main, without #24080/#24081. Conflicts: the delivery doc table (main's "line fits" row plus the
`unconfirmed` row), and main's journal-database open in install(), which now goes through the
record-store slot.

* feat(agent-launch): show an agent's tab at once, where the caller asked for it

An agent.launch now shows its terminal tab before admission and spawn, in
the requested placement (group and/or anchor tab), and the pane attaches to
the agent as soon as it runs. A pane whose agent can't start, or whose start
can't be confirmed, says so instead of becoming a plain shell, and keeps
saying so across restarts. A user's close of the tab or its pane during the
launch stops it and answers agent_launch_tab_closed. Whose view moves is
unchanged from main for every caller.

Rebuilt on main (with #24934) from the previous branch head 8f62858e606.

* feat(agent-launch): the desktop AI buttons start their agent through agent.launch

Part 3 of 3 of the agent-launch unification, on main. The AI buttons (Fix
checks, the source-control actions, commit/push recovery, Explain commit,
notes and annotation sends, session continuation) started their agent from
the window. They now ask the host's agent.launch to start it, with no
prompt, into the tab the window makes at the click, under one operation id
per click; the window's pane waits for the host's agent instead of spawning
a shell. The window then pastes the prompt with main's own paste, moved out
verbatim and started only once the host's agent holds the tab, so delivery
and follow-ups are exactly main's. A typed new-tab prompt and launches while
chat is the default keep main's path. The source-control dialog drops its
launch-command preview, which the host builds now.

* style(runtime): one-line the tab-order map so the headless browser-tabs runtime stays under its line limit

The merge of main (#25724's emitMobileSessionTabsSnapshot metadata) plus this branch's close mark put
the file one line over max-lines. Formatting only.

* fix(agent-launch): watch an AI button's agent for readiness from its first output

The window started watching for the agent's ready signal only after the host
answered agent.launch, so an agent that had already turned on bracketed paste
by then was never seen ready and its prompt waited out the readiness budget.

Readiness is now watched from the moment the tab's terminal exists, as main
watches it; the paste is written, and the chat copy seeded, only once the host
says its agent started in that tab.
---
 .../SourceControlAgentActionDialog.test.tsx   |  13 +-
 .../SourceControlAgentActionDialogForm.tsx    |  48 +---
 .../buildSourceControlAgentDeliveryPlan.ts    |  30 +--
 .../useSourceControlAgentActionStart.ts       |   4 +-
 .../terminal-pane/ipc-pty-connect.ts          |   9 +
 .../pty-transport-connect-spawn.test.ts       |  28 ++
 ...c-events-terminal-create-scenario-types.ts |   5 +-
 .../terminal-presentation-ipc-bridge.ts       |   6 +
 ...eIpcEvents-agent-launch-closed-tab.test.ts |  34 +++
 src/renderer/src/i18n/locales/en.json         |   1 -
 src/renderer/src/i18n/locales/es.json         |   1 -
 src/renderer/src/i18n/locales/fr.json         |   1 -
 src/renderer/src/i18n/locales/ja.json         |   1 -
 src/renderer/src/i18n/locales/ko.json         |   1 -
 src/renderer/src/i18n/locales/zh.json         |   1 -
 .../agent-launch-caller-permissions.test.ts   |  43 +++-
 .../lib/agent-launch-caller-placement.test.ts |  27 ++
 ...ent-launch-caller-prompt-transport.test.ts |  35 ++-
 .../lib/agent-launch-funnel-test-harness.ts   |   7 +
 .../lib/agent-launch-pane-spawn-hold.test.ts  |  46 ++++
 .../src/lib/agent-launch-pane-spawn-hold.ts   |  58 +++++
 .../src/lib/agent-launch-prompt-delivery.ts   |  46 ++--
 .../lib/agent-launch-tab-publication.test.ts  |  21 ++
 .../src/lib/agent-launch-tab-publication.ts   |   5 +-
 .../src/lib/agent-launch-through-host.test.ts | 243 ++++++++++++++++++
 .../src/lib/agent-launch-through-host.ts      | 206 +++++++++++++++
 src/renderer/src/lib/agent-paste-draft.ts     |   9 +-
 .../src/lib/launch-agent-in-new-tab.test.ts   |   7 +
 .../src/lib/launch-agent-in-new-tab.ts        |  62 +++--
 ...agent-new-tab-host-route-readiness.test.ts | 156 +++++++++++
 .../launch-agent-new-tab-host-route.test.ts   | 118 +++++++++
 .../lib/launch-agent-new-tab-host-route.ts    |  94 +++++++
 .../src/lib/launch-agent-tab-prompt-paste.ts  |  52 ++++
 ...-control-agent-action-launch-check.test.ts |  56 ++++
 ...ource-control-agent-action-launch-check.ts | 100 +++++++
 .../source-control-agent-action-plan.test.ts  | 112 --------
 .../lib/source-control-agent-action-plan.ts   | 213 ---------------
 src/shared/agent-launch-tab-publication.ts    |   3 +-
 38 files changed, 1442 insertions(+), 460 deletions(-)
 create mode 100644 src/renderer/src/lib/agent-launch-pane-spawn-hold.test.ts
 create mode 100644 src/renderer/src/lib/agent-launch-pane-spawn-hold.ts
 create mode 100644 src/renderer/src/lib/agent-launch-through-host.test.ts
 create mode 100644 src/renderer/src/lib/agent-launch-through-host.ts
 create mode 100644 src/renderer/src/lib/launch-agent-new-tab-host-route-readiness.test.ts
 create mode 100644 src/renderer/src/lib/launch-agent-new-tab-host-route.test.ts
 create mode 100644 src/renderer/src/lib/launch-agent-new-tab-host-route.ts
 create mode 100644 src/renderer/src/lib/launch-agent-tab-prompt-paste.ts
 create mode 100644 src/renderer/src/lib/source-control-agent-action-launch-check.test.ts
 create mode 100644 src/renderer/src/lib/source-control-agent-action-launch-check.ts
 delete mode 100644 src/renderer/src/lib/source-control-agent-action-plan.test.ts
 delete mode 100644 src/renderer/src/lib/source-control-agent-action-plan.ts

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 d988e3b00197..d8eb86b05d21 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -13057,7 +13057,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 dbdc7c9b11f4..f8d9923227ad 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -12943,7 +12943,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 32b68602e37f..a57404afde0f 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -12828,7 +12828,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 c8576f0cc5d6..0ba0c0adef02 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -12828,7 +12828,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 f9f16bd79b00..cd6971d81a10 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -12828,7 +12828,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 }