mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 08:37:49 +00:00
fix(agent.launch): avoid blank shells when launching new workspaces (#28451)
* fix: avoid blank shells beside new workspace chats * Carry promised agent chat through worktree creation * Use live agent launch inventory for new workspace surfaces * Type live agent launch owner from runtime contract * Assert one recovery shell after failed agent launch * Keep worktree activation adapters out of creation flow * Restore ordinary workspace setup handoff after launch correction * Cover the phone's agent.launch workspace create in the first-surface flow test
This commit is contained in:
@@ -241,6 +241,7 @@ export const SQLITE_RUNTIME_INCLUDE = [
|
||||
'src/main/runtime/rpc/methods/agent-launch-folder-workspace-create.test.ts',
|
||||
'src/main/runtime/rpc/methods/agent-launch-instant-tab.test.ts',
|
||||
'src/main/runtime/orca-runtime.test.ts',
|
||||
'src/main/runtime/worktree-agent-first-surface.test.ts',
|
||||
'tests/e2e/agent-launch-setup-choice.unit.test.ts',
|
||||
'src/main/runtime/rpc/methods/agent-launch-pane-reservation.test.ts',
|
||||
'src/main/runtime/rpc/methods/agent-launch-prestart-failure.test.ts',
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { TuiAgent } from '../../shared/tui-agent'
|
||||
import type { RuntimeMobileSessionTabsResult } from '../../shared/runtime-types'
|
||||
import type { AgentLaunchExecution } from './agent-launch-execution'
|
||||
import type { AgentLaunchWorkspaceFactory } from './agent-launch-surface-factories'
|
||||
|
||||
export type InitialSurfaceLaunch = NonNullable<
|
||||
RuntimeMobileSessionTabsResult['initialSurfaceLaunch']
|
||||
>
|
||||
|
||||
type AgentLaunchRuntimeOwner = Pick<
|
||||
AgentLaunchExecution['runtime'],
|
||||
'getStructuredAgentSessionCreateSupport'
|
||||
>
|
||||
|
||||
const liveLaunches = new WeakMap<AgentLaunchRuntimeOwner, Map<string, InitialSurfaceLaunch>>()
|
||||
|
||||
export function beginCreatedWorkspaceLaunch(
|
||||
runtime: AgentLaunchRuntimeOwner,
|
||||
agent: TuiAgent
|
||||
): {
|
||||
onWorkspaceCreated: (worktreeId: string) => void
|
||||
createdWorktreeId: () => string | undefined
|
||||
release: () => void
|
||||
} {
|
||||
const launch: InitialSurfaceLaunch = { launchId: randomUUID(), agent }
|
||||
let worktreeId: string | undefined
|
||||
return {
|
||||
onWorkspaceCreated: (createdId) => {
|
||||
worktreeId = createdId
|
||||
let byWorktree = liveLaunches.get(runtime)
|
||||
if (!byWorktree) {
|
||||
byWorktree = new Map()
|
||||
liveLaunches.set(runtime, byWorktree)
|
||||
}
|
||||
byWorktree.set(createdId, launch)
|
||||
},
|
||||
createdWorktreeId: () => worktreeId,
|
||||
release: () => {
|
||||
if (!worktreeId) {
|
||||
return
|
||||
}
|
||||
const byWorktree = liveLaunches.get(runtime)
|
||||
if (byWorktree?.get(worktreeId) !== launch) {
|
||||
return
|
||||
}
|
||||
byWorktree.delete(worktreeId)
|
||||
if (byWorktree.size === 0) {
|
||||
liveLaunches.delete(runtime)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function readCreatedWorkspaceLaunch(
|
||||
runtime: AgentLaunchRuntimeOwner,
|
||||
worktreeId: string
|
||||
): InitialSurfaceLaunch | undefined {
|
||||
return liveLaunches.get(runtime)?.get(worktreeId)
|
||||
}
|
||||
|
||||
export async function recoverCreatedWorkspace(
|
||||
workspaces: AgentLaunchWorkspaceFactory | undefined,
|
||||
worktreeId: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
await workspaces?.createRecoveryShell?.(worktreeId)
|
||||
} catch (error) {
|
||||
console.warn('[agent-launch] Failed to create a recovery shell:', error)
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { executeAgentLaunch, type AgentLaunchExecution } from './agent-launch-ex
|
||||
import { AgentLaunchStructuredSessionRefusedError } from './agent-launch-surface-factories'
|
||||
import type { AgentLaunchIntent, AgentLaunchResult } from '../../shared/agent-launch-intent'
|
||||
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants'
|
||||
import { readCreatedWorkspaceLaunch } from './agent-launch-created-workspace'
|
||||
|
||||
const STRUCTURED_PREFERENCE = {
|
||||
experimentalNativeChat: true
|
||||
@@ -23,10 +24,14 @@ function harness(options: {
|
||||
createSupport?: { supported: boolean; reason?: 'agent' | 'remote' | 'wsl' }
|
||||
createSupportThrows?: boolean
|
||||
structuredCreateError?: Error
|
||||
terminalCreateError?: Error
|
||||
deliveredMessageId?: string | null
|
||||
terminalPromptDelivered?: boolean
|
||||
/** Whether the surface reports that its typed line took the offered prompt. */
|
||||
lineCarriesPrompt?: boolean
|
||||
worktreeCreateError?: Error
|
||||
onStructuredCreate?: () => void
|
||||
onStructuredPromptDelivery?: () => void
|
||||
onSurfacePublished?: AgentLaunchExecution['onSurfacePublished']
|
||||
/** A worktree-only factory, as orchestration workers and `worktree.create` pass. */
|
||||
worktreeOnlyFactory?: boolean
|
||||
@@ -38,9 +43,15 @@ function harness(options: {
|
||||
async (args: {
|
||||
create: Record<string, unknown>
|
||||
startupAgent: string | undefined
|
||||
promisedAgentChat?: boolean
|
||||
startupPrompt?: string
|
||||
onWorkspaceCreated?: (worktreeId: string) => void
|
||||
}) => {
|
||||
calls.push(`createWorktree(startupAgent=${String(args.startupAgent)})`)
|
||||
args.onWorkspaceCreated?.('wt-new')
|
||||
if (options.worktreeCreateError) {
|
||||
throw options.worktreeCreateError
|
||||
}
|
||||
return {
|
||||
worktreeId: 'wt-new',
|
||||
connectionId: null,
|
||||
@@ -53,6 +64,9 @@ function harness(options: {
|
||||
calls.push('createFolderWorkspace')
|
||||
return { worktreeId: 'folder:fw-new', connectionId: null }
|
||||
})
|
||||
const createRecoveryShell = vi.fn(async (_worktreeId: string) => {
|
||||
calls.push('createRecoveryShell')
|
||||
})
|
||||
const getStructuredAgentSessionCreateSupport = vi.fn(async () => {
|
||||
calls.push('createSupport')
|
||||
if (options.createSupportThrows) {
|
||||
@@ -62,6 +76,7 @@ function harness(options: {
|
||||
})
|
||||
const createStructuredSession = vi.fn(async () => {
|
||||
calls.push('createStructuredSession')
|
||||
options.onStructuredCreate?.()
|
||||
if (options.structuredCreateError) {
|
||||
throw options.structuredCreateError
|
||||
}
|
||||
@@ -69,10 +84,14 @@ function harness(options: {
|
||||
})
|
||||
const createTerminalAgent = vi.fn(async (args: { startupPrompt?: string }) => {
|
||||
calls.push('createTerminalAgent')
|
||||
if (options.terminalCreateError) {
|
||||
throw options.terminalCreateError
|
||||
}
|
||||
return { handle: 'term_1', ...carried(args.startupPrompt) }
|
||||
})
|
||||
const deliverStructuredPrompt = vi.fn(async () => {
|
||||
calls.push('deliverStructuredPrompt')
|
||||
options.onStructuredPromptDelivery?.()
|
||||
return options.deliveredMessageId === undefined ? 'msg-1' : options.deliveredMessageId
|
||||
})
|
||||
const deliverTerminalPrompt = vi.fn(async () => {
|
||||
@@ -86,8 +105,10 @@ function harness(options: {
|
||||
}
|
||||
return {
|
||||
calls,
|
||||
runtime,
|
||||
createWorktree,
|
||||
createFolderWorkspace,
|
||||
createRecoveryShell,
|
||||
createStructuredSession,
|
||||
createTerminalAgent,
|
||||
deliverStructuredPrompt,
|
||||
@@ -104,8 +125,8 @@ function harness(options: {
|
||||
deliverTerminalPrompt
|
||||
},
|
||||
workspaces: options.worktreeOnlyFactory
|
||||
? { createWorktree }
|
||||
: { createWorktree, createFolderWorkspace },
|
||||
? { createWorktree, createRecoveryShell }
|
||||
: { createWorktree, createFolderWorkspace, createRecoveryShell },
|
||||
...(options.onSurfacePublished ? { onSurfacePublished: options.onSurfacePublished } : {})
|
||||
})
|
||||
}
|
||||
@@ -117,6 +138,29 @@ const CREATE_INTENT: AgentLaunchIntent = {
|
||||
}
|
||||
|
||||
describe('a structured launch that creates its own worktree', () => {
|
||||
it('keeps its live inventory through publication and clears it before prompt delivery', async () => {
|
||||
let duringCreate: unknown
|
||||
let duringPrompt: unknown
|
||||
const h = harness({
|
||||
onStructuredCreate: () => (duringCreate = readCreatedWorkspaceLaunch(h.runtime, 'wt-new')),
|
||||
onStructuredPromptDelivery: () =>
|
||||
(duringPrompt = readCreatedWorkspaceLaunch(h.runtime, 'wt-new'))
|
||||
})
|
||||
await h.run({ ...CREATE_INTENT, prompt: { text: 'start', delivery: 'submit' } })
|
||||
expect(duringCreate).toMatchObject({ agent: 'claude', launchId: expect.any(String) })
|
||||
expect(duringPrompt).toBeUndefined()
|
||||
expect(readCreatedWorkspaceLaunch(h.runtime, 'wt-new')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('recovers once and releases intent when creation fails after metadata registration', async () => {
|
||||
const failure = new Error('materialization failed')
|
||||
const h = harness({ worktreeCreateError: failure })
|
||||
await expect(h.run(CREATE_INTENT)).rejects.toBe(failure)
|
||||
expect(h.createRecoveryShell).toHaveBeenCalledExactlyOnceWith('wt-new')
|
||||
expect(h.createStructuredSession).not.toHaveBeenCalled()
|
||||
expect(readCreatedWorkspaceLaunch(h.runtime, 'wt-new')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('creates the worktree with no startup agent, then asks the host, then opens a session', async () => {
|
||||
const h = harness({})
|
||||
const result = await h.run(CREATE_INTENT)
|
||||
@@ -134,6 +178,9 @@ describe('a structured launch that creates its own worktree', () => {
|
||||
})
|
||||
expect(result.worktreeId).toBe('wt-new')
|
||||
expect(result.receipt.mode).toBe('structured')
|
||||
expect(h.createWorktree).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ promisedAgentChat: true })
|
||||
)
|
||||
})
|
||||
|
||||
it('asks the host only after the workspace exists, never before', async () => {
|
||||
@@ -213,20 +260,41 @@ describe('a structured launch that creates its own worktree', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('does not create a duplicate terminal when structured creation is unknown', async () => {
|
||||
const h = harness({
|
||||
structuredCreateError: new AgentLaunchStructuredSessionRefusedError(
|
||||
'agent_session_operation_unknown',
|
||||
'unknown'
|
||||
)
|
||||
})
|
||||
|
||||
await expect(h.run(CREATE_INTENT)).rejects.toThrow('unknown')
|
||||
expect(h.calls).toEqual([
|
||||
'createWorktree(startupAgent=undefined)',
|
||||
'createSupport',
|
||||
'createStructuredSession'
|
||||
])
|
||||
it.each([
|
||||
[
|
||||
'unknown chat creation',
|
||||
{
|
||||
structuredCreateError: new AgentLaunchStructuredSessionRefusedError(
|
||||
'agent_session_operation_unknown',
|
||||
'unknown'
|
||||
)
|
||||
},
|
||||
0
|
||||
],
|
||||
[
|
||||
'host downgrade terminal failure',
|
||||
{
|
||||
createSupport: { supported: false, reason: 'wsl' as const },
|
||||
terminalCreateError: new Error('spawn failed')
|
||||
},
|
||||
1
|
||||
],
|
||||
[
|
||||
'definitive refusal fallback failure',
|
||||
{
|
||||
structuredCreateError: new AgentLaunchStructuredSessionRefusedError(
|
||||
'structured_agent_session_unsupported',
|
||||
'unsupported'
|
||||
),
|
||||
terminalCreateError: new Error('spawn failed')
|
||||
},
|
||||
1
|
||||
]
|
||||
] as const)('leaves a shell after %s', async (_label, options, agentStarts) => {
|
||||
const h = harness(options)
|
||||
await expect(h.run(CREATE_INTENT)).rejects.toThrow()
|
||||
expect(h.createRecoveryShell).toHaveBeenCalledWith('wt-new')
|
||||
expect(h.createTerminalAgent).toHaveBeenCalledTimes(agentStarts)
|
||||
})
|
||||
|
||||
it('creates a folder workspace, then opens its structured session there', async () => {
|
||||
@@ -291,6 +359,7 @@ describe('a launch the user did not ask to be structured', () => {
|
||||
expect(h.calls).toEqual(['createWorktree(startupAgent=claude)'])
|
||||
expect(result.outcome).toEqual({ kind: 'terminal', handle: 'term_agent_first' })
|
||||
expect(result.receipt).toMatchObject({ mode: 'terminal', reason: 'user_default' })
|
||||
expect(h.createWorktree.mock.calls[0]?.[0]).not.toHaveProperty('promisedAgentChat')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,39 +1,9 @@
|
||||
/**
|
||||
* The one place an agent launch is sequenced — for the surfaces moved onto it: `agent.launch`,
|
||||
* `worktree.create` (CLI and mobile create) through `createWorktreeWithStartupAgent`, whose
|
||||
* `legacy-host` create still starts the agent itself, orchestration workers, local and
|
||||
* federated, and server-run automations. The desktop agent tab still starts agents its own way;
|
||||
* moving it here is later work.
|
||||
*
|
||||
* The mode decision is shared, not copied: `agent-launch-mode` owns it, and
|
||||
* `orchestration-worker-start-mode` is a thin adapter over it supplying orchestration's receipt
|
||||
* vocabulary. What this module adds is the *sequencing*, and the sequencing is where the bug
|
||||
* was:
|
||||
*
|
||||
* create the worktree agent-first -> its startup terminal IS the agent
|
||||
* -> the structured branch below it is unreachable
|
||||
*
|
||||
* so every new-worktree launch was a PTY no matter what the user's default said. The order here is
|
||||
* the inverse, and it is the whole point of the module: when the preference is structured the
|
||||
* worktree is created with NO startup agent, the executing host is then asked whether it can host
|
||||
* a session for the workspace that now exists, and only then is a surface created. A refusal
|
||||
* becomes a terminal agent in the worktree just created, never a failed launch.
|
||||
*
|
||||
* The host verdict cannot be hoisted above creation: `agentSession.createSupport` can only answer
|
||||
* for a workspace it can resolve. That is why the decision is in two halves rather than one.
|
||||
*
|
||||
* What genuinely differs per surface is only how a surface is *built* — an orchestration worker's
|
||||
* session takes a redrive subscription and a mailbox that a plain launch must not take — so that
|
||||
* is injected as a factory instead of branched on here.
|
||||
*/
|
||||
/** Structured launches create the workspace without an agent, then ask its host for chat support.
|
||||
* The host needs the created workspace to answer; a definitive refusal starts a terminal agent. */
|
||||
|
||||
import { assertOpenCodeModelLaunchPreferencesAbsent } from '../opencode/opencode-model-startup-plan'
|
||||
import { parsePaneKey } from '../../shared/stable-pane-id'
|
||||
import type {
|
||||
AgentLaunchIntent,
|
||||
AgentLaunchResult,
|
||||
AgentLaunchTarget
|
||||
} from '../../shared/agent-launch-intent'
|
||||
import type { AgentLaunchIntent, AgentLaunchResult } from '../../shared/agent-launch-intent'
|
||||
import { withoutReservedAgentCreateFields } from '../../shared/agent-launch-intent'
|
||||
import {
|
||||
combineLaunchWarnings,
|
||||
@@ -54,8 +24,8 @@ import {
|
||||
readAgentLaunchModeSettings,
|
||||
resolveAgentLaunchModeOnHost,
|
||||
type AgentLaunchModeReceipt,
|
||||
type AgentLaunchModeVocabulary,
|
||||
DEFAULT_LAUNCH_VOCABULARY,
|
||||
downgradeAgentLaunchModeForStructuredRefusal,
|
||||
warnStructuredLaunchDowngrade
|
||||
} from './agent-launch-mode'
|
||||
import {
|
||||
@@ -72,6 +42,10 @@ import type {
|
||||
AgentLaunchPublishedSurface,
|
||||
AgentLaunchSurfaceExecution
|
||||
} from './agent-launch-execution'
|
||||
import {
|
||||
beginCreatedWorkspaceLaunch,
|
||||
recoverCreatedWorkspace
|
||||
} from './agent-launch-created-workspace'
|
||||
|
||||
export type { AgentLaunchExecution, AgentLaunchPublishedSurface } from './agent-launch-execution'
|
||||
|
||||
@@ -112,7 +86,7 @@ export async function executeAgentLaunch(
|
||||
if (intent.reuseTerminal) {
|
||||
const reused = published(execution, {
|
||||
outcome: { kind: 'terminal', handle: intent.reuseTerminal.handle },
|
||||
worktreeId: existingWorktreeId(intent.target),
|
||||
worktreeId: intent.target.kind === 'existing' ? intent.target.worktree : '',
|
||||
receipt: preflight,
|
||||
...promptReceipt(intent, settledAtCreation(intent, {}))
|
||||
})
|
||||
@@ -127,85 +101,104 @@ export async function executeAgentLaunch(
|
||||
}
|
||||
}
|
||||
|
||||
const placed = await resolveWorkspace(execution, preflight)
|
||||
// Agent-first creation already produced the agent, so the pre-flight verdict is final.
|
||||
if (placed.startupTerminalHandle) {
|
||||
const startup = published(execution, {
|
||||
outcome: {
|
||||
kind: 'terminal',
|
||||
handle: placed.startupTerminalHandle,
|
||||
...(placed.startupTerminalPaneKey ? { paneKey: placed.startupTerminalPaneKey } : {})
|
||||
},
|
||||
worktreeId: placed.worktreeId,
|
||||
receipt: preflight,
|
||||
...(placed.warning ? { warning: placed.warning } : {}),
|
||||
...promptReceipt(intent, settledAtCreation(intent, placed))
|
||||
})
|
||||
return {
|
||||
...startup,
|
||||
...promptReceipt(
|
||||
intent,
|
||||
placed.promptRodeLaunchCommand
|
||||
? HANDED_TO_TERMINAL
|
||||
: await deliverTerminalLaunchPrompt(execution, placed.startupTerminalHandle, {
|
||||
freshLaunch: true
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
execution.onStage?.('mode_settle')
|
||||
// A caller's own decision about an existing workspace already carries that host's answer.
|
||||
let settled =
|
||||
execution.decidedMode && intent.target.kind === 'existing'
|
||||
? preflight
|
||||
: await resolveAgentLaunchModeOnHost(
|
||||
runtime,
|
||||
preflight,
|
||||
placed.worktreeId,
|
||||
intent.agent,
|
||||
vocabulary
|
||||
)
|
||||
|
||||
execution.onStage?.('surface_create')
|
||||
let created: CreatedSurface
|
||||
const launch =
|
||||
intent.target.kind === 'create-worktree' && preflight.mode === 'structured'
|
||||
? beginCreatedWorkspaceLaunch(runtime, intent.agent)
|
||||
: undefined
|
||||
let placed: Awaited<ReturnType<typeof resolveWorkspace>> | undefined
|
||||
let surfacePublished = false
|
||||
let recoverableWorktreeId: string | undefined
|
||||
try {
|
||||
created = await createSurface(execution, placed, settled)
|
||||
} catch (error) {
|
||||
// The structured create path distinguishes a definitive pre-commit refusal from an unknown
|
||||
// outcome. Only the former is safe to replace with a terminal in the same workspace; retrying
|
||||
// after an unknown attach outcome could create two agents.
|
||||
if (
|
||||
settled.mode !== 'structured' ||
|
||||
!(error instanceof AgentLaunchStructuredSessionRefusedError) ||
|
||||
!isDefinitiveAgentSessionCreateRefusal(error.code)
|
||||
) {
|
||||
throw error
|
||||
placed = await resolveWorkspace(execution, preflight, launch?.onWorkspaceCreated)
|
||||
// Agent-first creation already produced the agent, so the pre-flight verdict is final.
|
||||
if (placed.startupTerminalHandle) {
|
||||
const startup = published(execution, {
|
||||
outcome: {
|
||||
kind: 'terminal',
|
||||
handle: placed.startupTerminalHandle,
|
||||
...(placed.startupTerminalPaneKey ? { paneKey: placed.startupTerminalPaneKey } : {})
|
||||
},
|
||||
worktreeId: placed.worktreeId,
|
||||
receipt: preflight,
|
||||
...(placed.warning ? { warning: placed.warning } : {}),
|
||||
...promptReceipt(intent, settledAtCreation(intent, placed))
|
||||
})
|
||||
surfacePublished = true
|
||||
launch?.release()
|
||||
return {
|
||||
...startup,
|
||||
...promptReceipt(
|
||||
intent,
|
||||
placed.promptRodeLaunchCommand
|
||||
? HANDED_TO_TERMINAL
|
||||
: await deliverTerminalLaunchPrompt(execution, placed.startupTerminalHandle, {
|
||||
freshLaunch: true
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
settled = downgradeAgentLaunchModeForStructuredRefusal(settled, vocabulary)
|
||||
created = await createTerminalSurface(execution, placed)
|
||||
}
|
||||
// Both CAN be set, so neither may be dropped. The create warns precisely when it produced no
|
||||
// startup terminal — `didSpawnStartup` stays false when that spawn throws — and that is the same
|
||||
// condition which skips the early return above, so the launch goes on to build a second surface,
|
||||
// and that one can warn too. The other path is an untracked-copy warning followed by a structured
|
||||
// refusal downgrading to a terminal that warns. `??` kept the first and lost the second silently.
|
||||
//
|
||||
// KNOWN GAP, deliberately not fixed here: a create warning about a FAILED startup terminal is
|
||||
// stale once the launch recovers by building a working one, so the user can be told the agent did
|
||||
// not start while looking at it. Telling those apart needs `createManagedWorktree` to stop
|
||||
// multiplexing "couldn't copy untracked files" and "startup terminal failed" into one string.
|
||||
const warning = combineLaunchWarnings(placed.warning, created.warning)
|
||||
const surface = published(execution, {
|
||||
outcome: created.outcome,
|
||||
worktreeId: placed.worktreeId,
|
||||
receipt: settled,
|
||||
...(warning ? { warning } : {}),
|
||||
...promptReceipt(intent, settledAtCreation(intent, created))
|
||||
})
|
||||
return {
|
||||
...surface,
|
||||
...promptReceipt(intent, await settleLaunchPromptDisposal(execution, created))
|
||||
|
||||
execution.onStage?.('mode_settle')
|
||||
// A caller's own decision about an existing workspace already carries that host's answer.
|
||||
let settled =
|
||||
execution.decidedMode && intent.target.kind === 'existing'
|
||||
? preflight
|
||||
: await resolveAgentLaunchModeOnHost(
|
||||
runtime,
|
||||
preflight,
|
||||
placed.worktreeId,
|
||||
intent.agent,
|
||||
vocabulary
|
||||
)
|
||||
|
||||
execution.onStage?.('surface_create')
|
||||
recoverableWorktreeId = placed.worktreeId
|
||||
let created: CreatedSurface
|
||||
try {
|
||||
created = await createSurface(execution, placed, settled)
|
||||
} catch (error) {
|
||||
if (
|
||||
settled.mode !== 'structured' ||
|
||||
!(error instanceof AgentLaunchStructuredSessionRefusedError) ||
|
||||
!isDefinitiveAgentSessionCreateRefusal(error.code)
|
||||
) {
|
||||
throw error
|
||||
}
|
||||
settled = downgradeAgentLaunchModeForStructuredRefusal(settled, vocabulary)
|
||||
created = await createTerminalSurface(execution, placed)
|
||||
}
|
||||
// Both CAN be set, so neither may be dropped. The create warns precisely when it produced no
|
||||
// startup terminal — `didSpawnStartup` stays false when that spawn throws — and that is the same
|
||||
// condition which skips the early return above, so the launch goes on to build a second surface,
|
||||
// and that one can warn too. The other path is an untracked-copy warning followed by a structured
|
||||
// refusal downgrading to a terminal that warns. `??` kept the first and lost the second silently.
|
||||
//
|
||||
// KNOWN GAP, deliberately not fixed here: a create warning about a FAILED startup terminal is
|
||||
// stale once the launch recovers by building a working one, so the user can be told the agent did
|
||||
// not start while looking at it. Telling those apart needs `createManagedWorktree` to stop
|
||||
// multiplexing "couldn't copy untracked files" and "startup terminal failed" into one string.
|
||||
const warning = combineLaunchWarnings(placed.warning, created.warning)
|
||||
const surface = published(execution, {
|
||||
outcome: created.outcome,
|
||||
worktreeId: placed.worktreeId,
|
||||
receipt: settled,
|
||||
...(warning ? { warning } : {}),
|
||||
...promptReceipt(intent, settledAtCreation(intent, created))
|
||||
})
|
||||
surfacePublished = true
|
||||
launch?.release()
|
||||
return {
|
||||
...surface,
|
||||
...promptReceipt(intent, await settleLaunchPromptDisposal(execution, created))
|
||||
}
|
||||
} catch (error) {
|
||||
const createdWorktreeId = recoverableWorktreeId ?? launch?.createdWorktreeId()
|
||||
if (!surfacePublished && intent.target.kind !== 'existing' && createdWorktreeId) {
|
||||
await recoverCreatedWorkspace(execution.workspaces, createdWorktreeId)
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
launch?.release()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,21 +211,10 @@ function published(
|
||||
return surface
|
||||
}
|
||||
|
||||
function downgradeAgentLaunchModeForStructuredRefusal(
|
||||
receipt: AgentLaunchModeReceipt,
|
||||
vocabulary: AgentLaunchModeVocabulary
|
||||
): AgentLaunchModeReceipt {
|
||||
return {
|
||||
mode: 'terminal',
|
||||
preferred: receipt.preferred,
|
||||
reason: 'structured_unsupported_on_host',
|
||||
detail: `Your default is a structured chat session, but the host refused to create one here; started ${vocabulary.terminal} instead.`
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveWorkspace(
|
||||
execution: AgentLaunchExecution,
|
||||
preflight: AgentLaunchModeReceipt
|
||||
preflight: AgentLaunchModeReceipt,
|
||||
onWorkspaceCreated?: (worktreeId: string) => void
|
||||
): Promise<{
|
||||
worktreeId: string
|
||||
connectionId?: string | null
|
||||
@@ -265,6 +247,8 @@ async function resolveWorkspace(
|
||||
// in there would re-create the agent-first path this executor exists to replace. The launch
|
||||
// owns the prompt for the same reason, so it re-supplies its own rather than honouring theirs.
|
||||
create: withoutReservedAgentCreateFields(intent.target.create),
|
||||
...(onWorkspaceCreated ? { onWorkspaceCreated } : {}),
|
||||
...(preflight.mode === 'structured' ? { promisedAgentChat: true } : {}),
|
||||
startupAgent: preflight.mode === 'structured' ? undefined : intent.agent,
|
||||
...createLaunchPromptInputs(execution, preflight.mode),
|
||||
...(preflight.mode === 'structured' ? {} : terminalLaunchInputs(intent))
|
||||
@@ -358,7 +342,3 @@ async function createTerminalSurface(
|
||||
...(startupPrompt && terminal.promptRodeLaunchCommand ? { promptRodeLaunchCommand: true } : {})
|
||||
}
|
||||
}
|
||||
|
||||
function existingWorktreeId(target: AgentLaunchTarget): string {
|
||||
return target.kind === 'existing' ? target.worktree : ''
|
||||
}
|
||||
|
||||
@@ -242,6 +242,18 @@ export function downgradeAgentLaunchModeForHost(
|
||||
)
|
||||
}
|
||||
|
||||
export function downgradeAgentLaunchModeForStructuredRefusal(
|
||||
receipt: AgentLaunchModeReceipt,
|
||||
vocabulary: AgentLaunchModeVocabulary
|
||||
): AgentLaunchModeReceipt {
|
||||
return {
|
||||
mode: 'terminal',
|
||||
preferred: receipt.preferred,
|
||||
reason: 'structured_unsupported_on_host',
|
||||
detail: `Your default is a structured chat session, but the host refused to create one here; started ${vocabulary.terminal} instead.`
|
||||
}
|
||||
}
|
||||
|
||||
function downgraded(
|
||||
reason: Exclude<AgentLaunchModeReason, 'user_default'>,
|
||||
vocabulary: AgentLaunchModeVocabulary
|
||||
|
||||
@@ -105,6 +105,9 @@ export class AgentLaunchStructuredSessionRefusedError extends Error {
|
||||
export type AgentLaunchWorkspaceFactory = {
|
||||
createWorktree(args: {
|
||||
create: Readonly<Record<string, unknown>>
|
||||
/** Main-internal registration at the metadata boundary, before the workspace is announced. */
|
||||
onWorkspaceCreated?: (worktreeId: string) => void
|
||||
promisedAgentChat?: boolean
|
||||
/** Set only when the settled mode is a terminal agent: agent-first creation sequences the
|
||||
* agent's startup command behind the setup runner, which is how a PTY launch gets its
|
||||
* wait-for-setup gate for free. A structured launch has no startup command to sequence and
|
||||
@@ -138,6 +141,8 @@ export type AgentLaunchWorkspaceFactory = {
|
||||
/** Reported by the create that built the startup command's typed line. */
|
||||
promptRodeLaunchCommand?: boolean
|
||||
}>
|
||||
/** Leave a usable workspace when chat creation has an indeterminate outcome. */
|
||||
createRecoveryShell?: (worktreeId: string) => Promise<void>
|
||||
/** A folder workspace has no startup terminal: the launch starts its agent there afterwards.
|
||||
* Only `agent.launch` creates folders; callers that only create worktrees omit it. */
|
||||
createFolderWorkspace?(args: {
|
||||
|
||||
@@ -313,11 +313,21 @@ describe('a live-pane refusal under a named operation', () => {
|
||||
|
||||
const response = await dispatch(runtime, 'agent.launchReplay', params)
|
||||
|
||||
expect(runtime.createTerminal).toHaveBeenCalledTimes(1)
|
||||
expect(runtime.createTerminal).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'id:wt-new',
|
||||
expect.objectContaining({ startupAgent: 'claude' })
|
||||
)
|
||||
expect(runtime.createTerminal).toHaveBeenNthCalledWith(2, 'id:wt-new', {
|
||||
surfaceOwner: false
|
||||
})
|
||||
expect(response).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'agent_session_operation_unknown' }
|
||||
})
|
||||
expect(outcomeOf(OPERATION_ID)?.status).toBe('unknown')
|
||||
expect(await dispatch(runtime, 'agent.launchReplay', params)).toMatchObject(response)
|
||||
expect(runtime.createManagedWorktree).toHaveBeenCalledTimes(1)
|
||||
expect(runtime.createTerminal).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -172,11 +172,21 @@ describe('a launch whose terminal fails', () => {
|
||||
|
||||
const response = await replay(runtime, CREATE_LAUNCH)
|
||||
|
||||
expect(runtime.createTerminal).toHaveBeenCalledTimes(1)
|
||||
expect(runtime.createTerminal).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'id:wt-new',
|
||||
expect.objectContaining({ startupAgent: 'claude' })
|
||||
)
|
||||
expect(runtime.createTerminal).toHaveBeenNthCalledWith(2, 'id:wt-new', {
|
||||
surfaceOwner: false
|
||||
})
|
||||
expect(response).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'agent_session_operation_unknown' }
|
||||
})
|
||||
expect(await replay(runtime, CREATE_LAUNCH)).toMatchObject(response)
|
||||
expect(runtime.createManagedWorktree).toHaveBeenCalledTimes(1)
|
||||
expect(runtime.createTerminal).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
it.each([
|
||||
{ ...CREATE_LAUNCH, agent: 'opencode', sessionOptions: { model: 'private-proof/model-b' } },
|
||||
|
||||
@@ -40,9 +40,14 @@ export function agentLaunchWorkspaceFactory(
|
||||
): AgentLaunchWorkspaceFactory {
|
||||
return {
|
||||
createFolderWorkspace: agentLaunchFolderWorkspaceCreator(context, agent),
|
||||
createRecoveryShell: async (worktreeId) => {
|
||||
await context.runtime.createTerminal(`id:${worktreeId}`, { surfaceOwner: false })
|
||||
},
|
||||
createWorktree: async ({
|
||||
create,
|
||||
startupAgent,
|
||||
promisedAgentChat,
|
||||
onWorkspaceCreated,
|
||||
startupPrompt,
|
||||
agentArgs,
|
||||
cwd,
|
||||
@@ -101,6 +106,8 @@ export function agentLaunchWorkspaceFactory(
|
||||
// The launch owns the agent whichever surface it settles on, so the workspace records
|
||||
// it even when no startup terminal was created for it.
|
||||
createdWithAgent: agent,
|
||||
...(promisedAgentChat ? { promisedAgentChat } : {}),
|
||||
...(onWorkspaceCreated ? { onWorkspaceCreated } : {}),
|
||||
// Structured sessions have no startup command to sequence behind setup. Provision the
|
||||
// setup terminal synchronously and attach a completion token so the launch can wait
|
||||
// before creating the chat surface.
|
||||
|
||||
@@ -125,7 +125,19 @@ async function launchWorkerAgent(
|
||||
onStage: (stage) =>
|
||||
args.onStage(stage === 'worktree_create' ? 'worktree_create' : 'terminal_create'),
|
||||
workspaces: {
|
||||
createWorktree: async ({ startupAgent }) => {
|
||||
createRecoveryShell: async (worktreeId) => {
|
||||
const shell = await args.runtime.createTerminal(`id:${worktreeId}`, {
|
||||
surfaceOwner: false
|
||||
})
|
||||
args.effects.push({
|
||||
kind: 'terminal',
|
||||
role: 'recovery_shell',
|
||||
action: 'created',
|
||||
id: shell.handle,
|
||||
tabId: shell.tabId
|
||||
})
|
||||
},
|
||||
createWorktree: async ({ startupAgent, promisedAgentChat, onWorkspaceCreated }) => {
|
||||
created = await createWorkerWorktree({
|
||||
runtime: args.runtime,
|
||||
db: args.db,
|
||||
@@ -136,6 +148,8 @@ async function launchWorkerAgent(
|
||||
agent,
|
||||
// The executor withholds the startup agent exactly when the worker is to be a session.
|
||||
withAgentTerminal: startupAgent !== undefined,
|
||||
promisedAgentChat,
|
||||
onWorkspaceCreated,
|
||||
...(args.launchPreferences ? { launchPreferences: args.launchPreferences } : {}),
|
||||
effects: args.effects
|
||||
})
|
||||
|
||||
+2
-2
@@ -129,14 +129,14 @@ describe('a fresh worker through the launch executor', () => {
|
||||
expect(f.stages).toEqual(['terminal_creating'])
|
||||
})
|
||||
|
||||
it('fails a refused structured worker in the worktree it created, with no terminal', async () => {
|
||||
it('fails a refused structured worker in the worktree it created, leaving a shell', async () => {
|
||||
const f = fakes()
|
||||
sessionCreate.refusal = { code: 'structured_agent_session_unsupported' }
|
||||
|
||||
await expect(place(f, { mode: STRUCTURED })).rejects.toMatchObject({
|
||||
code: 'agent_unconfigured'
|
||||
})
|
||||
expect(f.runtime.createTerminal).not.toHaveBeenCalled()
|
||||
expect(f.runtime.createTerminal).toHaveBeenCalledWith('id:wt_new', { surfaceOwner: false })
|
||||
expect(f.failedStages).toEqual(['worktree_create', 'terminal_create', 'terminal_create'])
|
||||
})
|
||||
|
||||
|
||||
@@ -38,6 +38,8 @@ export async function createWorkerWorktree(args: {
|
||||
* does not need, since the trust preset exists so a PTY agent's menu does not eat the first
|
||||
* bracketed paste. The renderer's own structured worktree create skips both the same way. */
|
||||
withAgentTerminal: boolean
|
||||
promisedAgentChat?: boolean
|
||||
onWorkspaceCreated?: (worktreeId: string) => void
|
||||
launchPreferences?: AgentLaunchPreferences
|
||||
effects: WorkerEffect[]
|
||||
}): Promise<{
|
||||
@@ -61,6 +63,8 @@ export async function createWorkerWorktree(args: {
|
||||
awaitTerminalProvisioning: true,
|
||||
observeSetupCompletion: true,
|
||||
createdWithAgent: args.agent,
|
||||
...(args.promisedAgentChat ? { promisedAgentChat: true } : {}),
|
||||
...(args.onWorkspaceCreated ? { onWorkspaceCreated: args.onWorkspaceCreated } : {}),
|
||||
...(args.withAgentTerminal
|
||||
? {
|
||||
startupAgent: args.agent,
|
||||
|
||||
@@ -28,7 +28,9 @@ export const SESSION_TAB_METHODS = [
|
||||
handler: async (params, { runtime, pairedDeviceId, clientKind, clientCapabilities }) => {
|
||||
await restoreStructuredTabsIfSupported({ runtime, clientKind, clientCapabilities })
|
||||
return projectSessionTabsForClient(
|
||||
await runtime.listMobileSessionTabs(params.worktree, pairedDeviceId),
|
||||
await runtime.listMobileSessionTabs(params.worktree, pairedDeviceId, {
|
||||
includeInitialSurfaceLaunch: true
|
||||
}),
|
||||
clientKind,
|
||||
clientCapabilities
|
||||
)
|
||||
|
||||
@@ -58,6 +58,31 @@ async function startupTerminalOptions(startupPaneKey?: string): Promise<Record<s
|
||||
}
|
||||
|
||||
describe('a folder workspace create with a startup agent', () => {
|
||||
it('registers a folder launch before announcing its workspace', async () => {
|
||||
const { deps } = createDeps()
|
||||
const order: string[] = []
|
||||
vi.mocked(deps.notifyWorktreesChanged).mockImplementation(() => order.push('notify'))
|
||||
const result = await createRuntimeFolderWorktree({
|
||||
request: { repoSelector: `id:${repo.id}`, name: 'chat', activate: true },
|
||||
repo,
|
||||
promisedAgentChat: true,
|
||||
onWorkspaceCreated: (id) => order.push(`register:${id}`),
|
||||
deps
|
||||
})
|
||||
expect(order).toEqual([`register:${result.worktree.id}`, 'notify'])
|
||||
})
|
||||
|
||||
it('only promises a chat when the caller explicitly plans one', async () => {
|
||||
const { deps } = createDeps()
|
||||
await createRuntimeFolderWorktree({
|
||||
request: { repoSelector: `id:${repo.id}`, name: 'attributed', activate: true },
|
||||
repo,
|
||||
createdWithAgent: 'codex',
|
||||
deps
|
||||
})
|
||||
expect(deps.activate).toHaveBeenCalledWith(repo.id, expect.any(String), undefined, undefined)
|
||||
})
|
||||
|
||||
it('seeds a headless activated folder workspace without a paired viewer', async () => {
|
||||
const { deps, createTerminal } = createDeps()
|
||||
deps.provisionInBackground = () => true
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { paneIdentity } from './runtime-terminal-pane-identity'
|
||||
import { shouldSeedWorktreePrimaryTerminal } from '../../shared/worktree-first-surface'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { getProjectHostSetupWorktreeMeta } from '../../shared/project-host-setup-lookup'
|
||||
import { resolveWorktreeCreateDisplayNameRequest } from '../ipc/worktree-logic'
|
||||
@@ -42,7 +43,8 @@ type RuntimeFolderWorktreeCreateDeps = {
|
||||
repoId: string,
|
||||
worktreeId: string,
|
||||
setup?: undefined,
|
||||
startup?: WorktreeStartupLaunch
|
||||
startup?: WorktreeStartupLaunch,
|
||||
providesInitialSurface?: boolean
|
||||
) => void
|
||||
}
|
||||
|
||||
@@ -54,6 +56,8 @@ export async function createRuntimeFolderWorktree(args: {
|
||||
/** What a host-built startup was quoted for, or the repair error it fails with. */
|
||||
startupLaunchRuntime?: AgentLaunchRuntime | Error
|
||||
createdWithAgent?: TuiAgent
|
||||
promisedAgentChat?: boolean
|
||||
onWorkspaceCreated?: (worktreeId: string) => void
|
||||
draftPaste?: WorktreeStartupDraftPaste
|
||||
deps: RuntimeFolderWorktreeCreateDeps
|
||||
}): Promise<CreateWorktreeResult> {
|
||||
@@ -120,6 +124,7 @@ export async function createRuntimeFolderWorktree(args: {
|
||||
...(request.manualOrder !== undefined ? { manualOrder: request.manualOrder } : {}),
|
||||
...(request.workspaceStatus !== undefined ? { workspaceStatus: request.workspaceStatus } : {})
|
||||
})
|
||||
args.onWorkspaceCreated?.(worktreeId)
|
||||
const worktree = mergeRuntimeFolderWorkspace(repo, worktreeId, meta)
|
||||
deps.invalidateResolvedWorktrees()
|
||||
deps.notifyWorktreesChanged(repo.id)
|
||||
@@ -177,14 +182,18 @@ export async function createRuntimeFolderWorktree(args: {
|
||||
repo.id,
|
||||
worktree.id,
|
||||
undefined,
|
||||
args.startup && !didSpawnStartup ? args.startup : undefined
|
||||
args.startup && !didSpawnStartup ? args.startup : undefined,
|
||||
...(args.promisedAgentChat && !args.startup ? ([true] as const) : [])
|
||||
)
|
||||
}
|
||||
if (
|
||||
(!shouldActivate || deps.provisionInBackground?.() === true) &&
|
||||
deps.ptySpawnAvailable &&
|
||||
!didSpawnStartup &&
|
||||
!args.createdWithAgent
|
||||
shouldSeedWorktreePrimaryTerminal({
|
||||
hasPrimarySurface: didSpawnStartup,
|
||||
// Background attribution-only creates have historically omitted the shell.
|
||||
callerProvidesSurface: args.promisedAgentChat === true || args.createdWithAgent !== undefined
|
||||
})
|
||||
) {
|
||||
try {
|
||||
await deps.createTerminal(`id:${worktree.id}`, { surfaceOwner: false })
|
||||
|
||||
@@ -152,4 +152,50 @@ describe('startRuntimeLocalWorktreeTerminals default shell seeding', () => {
|
||||
expect(createTerminal).toHaveBeenCalledWith(`id:${worktree.id}`, { surfaceOwner: false })
|
||||
}
|
||||
})
|
||||
|
||||
it('hands failed startup and defaults to renderer before host provisioning', async () => {
|
||||
const { ports, createTerminal } = createPorts()
|
||||
createTerminal.mockRejectedValueOnce(new Error('pty unavailable'))
|
||||
const setup = { runnerScriptPath: '/repo/setup.sh', envVars: {} }
|
||||
const startup = { command: 'codex' }
|
||||
const defaultTabs = {
|
||||
runCommands: true,
|
||||
tabs: [{ title: 'Dev', command: 'pnpm dev' }]
|
||||
}
|
||||
await startRuntimeLocalWorktreeTerminals({
|
||||
request: {
|
||||
repoSelector: repo.id,
|
||||
name: 'recover',
|
||||
activate: true,
|
||||
awaitTerminalProvisioning: true
|
||||
},
|
||||
repo,
|
||||
worktree,
|
||||
startup,
|
||||
setup,
|
||||
defaultTabs,
|
||||
ports
|
||||
})
|
||||
expect(ports.provision).not.toHaveBeenCalled()
|
||||
expect(ports.activate).toHaveBeenCalledWith(repo.id, worktree.id, startup, setup, defaultTabs)
|
||||
expect(createTerminal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not treat agent attribution alone as a promised chat on activation', async () => {
|
||||
const { ports } = createPorts()
|
||||
await startRuntimeLocalWorktreeTerminals({
|
||||
request: { repoSelector: repo.id, name: 'attributed', activate: true },
|
||||
repo,
|
||||
worktree,
|
||||
createdWithAgent: 'codex',
|
||||
ports
|
||||
})
|
||||
expect(ports.activate).toHaveBeenCalledWith(
|
||||
repo.id,
|
||||
worktree.id,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { paneIdentity } from './runtime-terminal-pane-identity'
|
||||
import { shouldSeedWorktreePrimaryTerminal } from '../../shared/worktree-first-surface'
|
||||
import type { CreateWorktreeResult } from '../../shared/worktree/create-types'
|
||||
import type { Repo } from '../../shared/repo-types'
|
||||
import type { TuiAgent } from '../../shared/tui-agent'
|
||||
@@ -37,7 +38,8 @@ type Ports = {
|
||||
startup?: WorktreeStartupLaunch,
|
||||
// Only work main tried to start and failed; the host window retries it where the user sees it.
|
||||
failedSetup?: CreateWorktreeResult['setup'],
|
||||
failedDefaultTabs?: CreateWorktreeResult['defaultTabs']
|
||||
failedDefaultTabs?: CreateWorktreeResult['defaultTabs'],
|
||||
providesInitialSurface?: boolean
|
||||
) => void
|
||||
}
|
||||
|
||||
@@ -64,6 +66,7 @@ export async function startRuntimeLocalWorktreeTerminals(args: {
|
||||
/** What a host-built startup was quoted for, or the repair error it fails with. */
|
||||
startupLaunchRuntime?: AgentLaunchRuntime | Error
|
||||
createdWithAgent?: TuiAgent
|
||||
promisedAgentChat?: boolean
|
||||
draftPaste?: WorktreeStartupDraftPaste
|
||||
warning?: string
|
||||
ports: Ports
|
||||
@@ -140,15 +143,14 @@ export async function startRuntimeLocalWorktreeTerminals(args: {
|
||||
ports.activate(repo.id, worktree.id, startup, setup, defaultTabs)
|
||||
} else {
|
||||
let failedSetup: CreateWorktreeResult['setup']
|
||||
// Why: main alone creates the setup and default tabs; the window draws them from the reveals.
|
||||
if (ports.canSpawn && (provisionInBackground || setup || defaultTabs)) {
|
||||
const runtimeWillProvision =
|
||||
ports.canSpawn &&
|
||||
(request.awaitTerminalProvisioning === true ||
|
||||
provisionInBackground ||
|
||||
Boolean(setup || defaultTabs))
|
||||
if (runtimeWillProvision) {
|
||||
const provisioned = await ports.provision({
|
||||
...provisionArgs(
|
||||
args,
|
||||
startupTerminalHandle,
|
||||
didSpawnStartup,
|
||||
didSpawnStartup ? wrappedSetupCommand : undefined
|
||||
),
|
||||
...provisionArgs(args, startupTerminalHandle, didSpawnStartup, wrappedSetupCommand),
|
||||
...(provisionInBackground ? { surfaceOwner: false as const } : {})
|
||||
})
|
||||
didSpawnSetup = provisioned.setupSpawned
|
||||
@@ -164,7 +166,9 @@ export async function startRuntimeLocalWorktreeTerminals(args: {
|
||||
repo.id,
|
||||
worktree.id,
|
||||
startup && !didSpawnStartup ? startup : undefined,
|
||||
failedSetup
|
||||
failedSetup,
|
||||
undefined,
|
||||
...(args.promisedAgentChat && !startup ? ([true] as const) : [])
|
||||
)
|
||||
}
|
||||
} else if (ports.canSpawn && (setup || defaultTabs || didSpawnStartup)) {
|
||||
@@ -182,7 +186,14 @@ export async function startRuntimeLocalWorktreeTerminals(args: {
|
||||
didSpawnSetup = true
|
||||
}
|
||||
}
|
||||
} else if (ports.canSpawn && !args.createdWithAgent) {
|
||||
} else if (
|
||||
ports.canSpawn &&
|
||||
shouldSeedWorktreePrimaryTerminal({
|
||||
hasPrimarySurface: didSpawnStartup,
|
||||
// Preserve background create behavior for attribution-only callers.
|
||||
callerProvidesSurface: args.promisedAgentChat === true || args.createdWithAgent !== undefined
|
||||
})
|
||||
) {
|
||||
try {
|
||||
await ports.createTerminal(`id:${worktree.id}`, { surfaceOwner: false })
|
||||
} catch (error) {
|
||||
@@ -225,6 +236,8 @@ function provisionArgs(
|
||||
primaryTerminalHandle,
|
||||
hasStartupTerminal,
|
||||
...(args.request.setupOnly !== undefined ? { setupOnly: args.request.setupOnly } : {}),
|
||||
callerProvidesSurface: args.promisedAgentChat === true,
|
||||
startupNeedsTerminal: args.startup !== undefined,
|
||||
setupCommandPlatform: setupPlatform(args.setup, 'posix'),
|
||||
observeSetupCompletion: args.request.observeSetupCompletion,
|
||||
...(wrappedSetupCommand ? { wrappedSetupCommand } : {})
|
||||
|
||||
@@ -51,6 +51,10 @@ export type RuntimeManagedWorktreeCreateArgs = {
|
||||
awaitTerminalProvisioning?: boolean
|
||||
observeSetupCompletion?: boolean
|
||||
createdWithAgent?: TuiAgent
|
||||
/** Transient intent: this create will be followed by an agent chat surface. */
|
||||
promisedAgentChat?: boolean
|
||||
/** Main-internal launch registration before catalog notification or activation. */
|
||||
onWorkspaceCreated?: (worktreeId: string) => void
|
||||
startupAgent?: TuiAgent
|
||||
startupLaunchPreferences?: AgentLaunchPreferences
|
||||
startupPrompt?: string
|
||||
|
||||
@@ -47,7 +47,8 @@ export type RuntimeNotifier = {
|
||||
worktreeId: string,
|
||||
setup?: CreateWorktreeResult['setup'],
|
||||
startup?: WorktreeStartupLaunch,
|
||||
defaultTabs?: CreateWorktreeResult['defaultTabs']
|
||||
defaultTabs?: CreateWorktreeResult['defaultTabs'],
|
||||
providesInitialSurface?: boolean
|
||||
): void
|
||||
createTerminal(
|
||||
worktreeId: string,
|
||||
|
||||
@@ -124,7 +124,8 @@ function provisionWithSetup(
|
||||
host: WorktreeTerminalProvisioningHost,
|
||||
withSetup = true,
|
||||
setupOnly?: boolean,
|
||||
defaultTabs?: typeof DEFAULT_TABS
|
||||
defaultTabs?: typeof DEFAULT_TABS,
|
||||
callerProvidesSurface = false
|
||||
) {
|
||||
return provisionWorktreeTerminals(host, {
|
||||
worktreeSelector: 'id:wt-1',
|
||||
@@ -134,6 +135,7 @@ function provisionWithSetup(
|
||||
hasStartupTerminal: false,
|
||||
...(setupOnly !== undefined ? { setupOnly } : {}),
|
||||
...(defaultTabs ? { defaultTabs } : {}),
|
||||
callerProvidesSurface,
|
||||
setupCommandPlatform: 'posix'
|
||||
})
|
||||
}
|
||||
@@ -196,18 +198,23 @@ describe('provisionWorktreeTerminals with a cleared creation choice', () => {
|
||||
expect(titles()).toEqual([undefined])
|
||||
})
|
||||
|
||||
it('opens a blank terminal when the Setup tab fails to spawn', async () => {
|
||||
const { host, titles } = createHost(none)
|
||||
const createTerminal = vi.mocked(host.createTerminal)
|
||||
createTerminal.mockRejectedValueOnce(new Error('spawn failed'))
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
await expect(provisionWithSetup(host, true, true)).resolves.toEqual({
|
||||
setupSpawned: false,
|
||||
setupTerminalHandle: null
|
||||
})
|
||||
expect(titles()).toEqual(['Setup', undefined])
|
||||
warn.mockRestore()
|
||||
})
|
||||
it.each([false, true])(
|
||||
'opens a failed-Setup recovery shell only when the caller owes the surface (%s)',
|
||||
async (callerProvidesSurface) => {
|
||||
const { host, titles } = createHost(none)
|
||||
const createTerminal = vi.mocked(host.createTerminal)
|
||||
createTerminal.mockRejectedValueOnce(new Error('spawn failed'))
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
await expect(
|
||||
provisionWithSetup(host, true, true, undefined, callerProvidesSurface)
|
||||
).resolves.toEqual({
|
||||
setupSpawned: false,
|
||||
setupTerminalHandle: null
|
||||
})
|
||||
expect(titles()).toEqual(callerProvidesSurface ? ['Setup'] : ['Setup', undefined])
|
||||
warn.mockRestore()
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps the shell a split setup needs', async () => {
|
||||
const { host, titles } = createHost({ ...none, setupScriptLaunchMode: 'split-vertical' })
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { CreateWorktreeResult } from '../../shared/worktree/create-types'
|
||||
import { shouldSeedWorktreePrimaryTerminal } from '../../shared/worktree-first-surface'
|
||||
import { buildObservedSetupCommand } from './orchestration/setup-completion-signal'
|
||||
import { buildSetupRunnerCommand } from '../../shared/setup-runner-command'
|
||||
import type { RuntimeStore } from './runtime-store-contract'
|
||||
@@ -42,6 +43,8 @@ export type WorktreeTerminalProvisioningArgs = {
|
||||
primaryTerminalHandle?: string | null
|
||||
hasStartupTerminal: boolean
|
||||
setupOnly?: boolean
|
||||
callerProvidesSurface?: boolean
|
||||
startupNeedsTerminal?: boolean
|
||||
setupCommandPlatform: 'windows' | 'posix'
|
||||
observeSetupCompletion?: boolean
|
||||
// Why: setup and startup must use the same wrapper when startup waits for setup.
|
||||
@@ -135,12 +138,19 @@ export async function provisionWorktreeTerminals(
|
||||
args.hasStartupTerminal ? (args.primaryTerminalHandle ?? undefined) : undefined
|
||||
)
|
||||
let primaryHandle = args.primaryTerminalHandle ?? defaultHandles[0] ?? null
|
||||
if (!args.hasStartupTerminal && !primaryHandle) {
|
||||
if (setupOnly) {
|
||||
shellSkippedForSetup = true
|
||||
} else {
|
||||
primaryHandle = (await host.createTerminal(args.worktreeSelector, surfacing)).handle
|
||||
}
|
||||
if (!args.hasStartupTerminal && !primaryHandle && setupOnly) {
|
||||
shellSkippedForSetup = true
|
||||
}
|
||||
if (
|
||||
!setupOnly &&
|
||||
shouldSeedWorktreePrimaryTerminal({
|
||||
hasPrimarySurface: args.hasStartupTerminal || primaryHandle !== null,
|
||||
callerProvidesSurface: args.callerProvidesSurface === true,
|
||||
startupNeedsTerminal: args.startupNeedsTerminal,
|
||||
...(args.setup ? { setupLaunchMode } : {})
|
||||
})
|
||||
) {
|
||||
primaryHandle = (await host.createTerminal(args.worktreeSelector, surfacing)).handle
|
||||
}
|
||||
if (args.setup) {
|
||||
const completionToken =
|
||||
@@ -191,8 +201,16 @@ export async function provisionWorktreeTerminals(
|
||||
console.warn(
|
||||
`[worktree-create] Failed to create setup/default terminals for ${args.worktreePath}: ${message}`
|
||||
)
|
||||
// A cleared choice still needs a shell if Setup fails to spawn.
|
||||
if (shellSkippedForSetup && !setupSpawned) {
|
||||
// A cleared choice needs a shell after failed Setup only when this create owes the surface.
|
||||
if (
|
||||
shellSkippedForSetup &&
|
||||
!setupSpawned &&
|
||||
shouldSeedWorktreePrimaryTerminal({
|
||||
hasPrimarySurface: false,
|
||||
callerProvidesSurface: args.callerProvidesSurface === true,
|
||||
startupNeedsTerminal: args.startupNeedsTerminal
|
||||
})
|
||||
) {
|
||||
await host
|
||||
.createTerminal(args.worktreeSelector, surfacing)
|
||||
.catch((fallbackError: unknown) => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
} from '../../../shared/runtime-types'
|
||||
import { UNPUBLISHED_WORKTREE_PUBLICATION_EPOCH } from '../../../shared/runtime-types'
|
||||
import { getRuntimeBrowserPageRegistry } from '../runtime-browser-page-registry'
|
||||
import { readCreatedWorkspaceLaunch } from '../../agent-launch/agent-launch-created-workspace'
|
||||
|
||||
export abstract class OrcaRuntimeWithScheduleMobileSessionTabsChanged extends OrcaRuntimeWithStoredMobileSnapshotHasStalePreservedTab {
|
||||
protected scheduleMobileSessionTabsChanged(worktreeId: string): void {
|
||||
@@ -68,7 +69,8 @@ export abstract class OrcaRuntimeWithScheduleMobileSessionTabsChanged extends Or
|
||||
|
||||
protected getMobileSessionTabsForWorktree(
|
||||
worktreeId: string,
|
||||
clientNavigationId?: string
|
||||
clientNavigationId?: string,
|
||||
options?: { includeInitialSurfaceLaunch?: true }
|
||||
): RuntimeMobileSessionTabsResult {
|
||||
const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId)
|
||||
if (!snapshot) {
|
||||
@@ -76,15 +78,23 @@ export abstract class OrcaRuntimeWithScheduleMobileSessionTabsChanged extends Or
|
||||
if (publishedEpoch === null) {
|
||||
this.notifyEmptyWorktreeOnPublication(worktreeId)
|
||||
}
|
||||
return this.projectMobileSessionTabsForClient(
|
||||
const result = this.projectMobileSessionTabsForClient(
|
||||
this.emptyMobileSessionTabsResult(worktreeId, publishedEpoch),
|
||||
clientNavigationId
|
||||
)
|
||||
const launch = options?.includeInitialSurfaceLaunch
|
||||
? readCreatedWorkspaceLaunch(this, worktreeId)
|
||||
: undefined
|
||||
return launch ? { ...result, initialSurfaceLaunch: launch } : result
|
||||
}
|
||||
return this.projectMobileSessionTabsForClient(
|
||||
const result = this.projectMobileSessionTabsForClient(
|
||||
this.toMobileSessionTabsResult(snapshot),
|
||||
clientNavigationId
|
||||
)
|
||||
const launch = options?.includeInitialSurfaceLaunch
|
||||
? readCreatedWorkspaceLaunch(this, worktreeId)
|
||||
: undefined
|
||||
return launch ? { ...result, initialSurfaceLaunch: launch } : result
|
||||
}
|
||||
|
||||
// Why: only an unpublished graph is "ask me later"; once it publishes, a worktree with no entry
|
||||
|
||||
@@ -119,7 +119,8 @@ export abstract class OrcaRuntimeWithCollectMobileVisibleGraphChangedWorktrees e
|
||||
|
||||
async listMobileSessionTabs(
|
||||
worktreeSelector: string,
|
||||
clientNavigationId?: string
|
||||
clientNavigationId?: string,
|
||||
options?: { includeInitialSurfaceLaunch?: true }
|
||||
): Promise<RuntimeMobileSessionTabsResult> {
|
||||
const explicitWorktreeId = this.getValidatedExplicitWorktreeIdSelector(worktreeSelector)
|
||||
if (explicitWorktreeId) {
|
||||
@@ -130,7 +131,7 @@ export abstract class OrcaRuntimeWithCollectMobileVisibleGraphChangedWorktrees e
|
||||
this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(explicitWorktreeId)
|
||||
await this.refreshMobileSessionPtyRecords(explicitWorktreeId)
|
||||
this.restoreLivePairedRendererSessionOwnedMobileTerminals(explicitWorktreeId)
|
||||
return this.getMobileSessionTabsForWorktree(explicitWorktreeId, clientNavigationId)
|
||||
return this.getMobileSessionTabsForWorktree(explicitWorktreeId, clientNavigationId, options)
|
||||
}
|
||||
const worktree = await this.resolveWorktreeSelector(worktreeSelector)
|
||||
this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktree.id, {
|
||||
@@ -140,7 +141,7 @@ export abstract class OrcaRuntimeWithCollectMobileVisibleGraphChangedWorktrees e
|
||||
this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktree.id)
|
||||
await this.refreshMobileSessionPtyRecords()
|
||||
this.restoreLivePairedRendererSessionOwnedMobileTerminals(worktree.id)
|
||||
return this.getMobileSessionTabsForWorktree(worktree.id, clientNavigationId)
|
||||
return this.getMobileSessionTabsForWorktree(worktree.id, clientNavigationId, options)
|
||||
}
|
||||
|
||||
async listAllMobileSessionTabs(
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { RuntimeNavigationTarget } from '../../../shared/runtime-navigation'
|
||||
import type { CreateWorktreeResult } from '../../../shared/worktree/create-types'
|
||||
import type { WorktreeStartupLaunch } from '../../../shared/worktree/launch-types'
|
||||
import type { createRuntimeFolderWorktree } from '../runtime-folder-worktree-create'
|
||||
import type { startRuntimeLocalWorktreeTerminals } from '../runtime-local-worktree-terminal-startup'
|
||||
|
||||
type FolderActivation = Parameters<typeof createRuntimeFolderWorktree>[0]['deps']['activate']
|
||||
type LocalActivation = Parameters<typeof startRuntimeLocalWorktreeTerminals>[0]['ports']['activate']
|
||||
|
||||
type NotifyActivation = (
|
||||
repoId: string,
|
||||
worktreeId: string,
|
||||
setup?: CreateWorktreeResult['setup'],
|
||||
startup?: WorktreeStartupLaunch,
|
||||
defaultTabs?: CreateWorktreeResult['defaultTabs'],
|
||||
navigation?: RuntimeNavigationTarget,
|
||||
providesInitialSurface?: boolean
|
||||
) => void
|
||||
|
||||
export function createdWorkspaceActivationCallbacks(
|
||||
notify: NotifyActivation,
|
||||
navigation?: RuntimeNavigationTarget
|
||||
): { folder: FolderActivation; local: LocalActivation } {
|
||||
const folder: FolderActivation = (repoId, worktreeId, setup, startup, providesInitialSurface) =>
|
||||
notify(repoId, worktreeId, setup, startup, undefined, navigation, providesInitialSurface)
|
||||
const local: LocalActivation = (
|
||||
repoId,
|
||||
worktreeId,
|
||||
startup,
|
||||
failedSetup,
|
||||
failedDefaultTabs,
|
||||
providesInitialSurface
|
||||
) =>
|
||||
notify(
|
||||
repoId,
|
||||
worktreeId,
|
||||
failedSetup,
|
||||
startup,
|
||||
failedDefaultTabs,
|
||||
navigation,
|
||||
providesInitialSurface
|
||||
)
|
||||
return { folder, local }
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import { trackRuntimeWorkspaceCreate } from '../../workspace-create-telemetry'
|
||||
import { assertOpenCodeModelLaunchPreferencesAbsent } from '../../opencode/opencode-model-startup-plan'
|
||||
import { resolveWorktreeCreateAgentStartup } from '../runtime-worktree-agent-startup'
|
||||
import type { RuntimeWorkspaceCreateEvents } from '../../workspace-create-telemetry'
|
||||
import { createdWorkspaceActivationCallbacks } from './workspace-create-activation'
|
||||
import { assertWslMoveRepoWritable } from '../../persistence/moving-wsl-projects/wsl-move-scope-guard'
|
||||
|
||||
export abstract class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWorktreeTerminalProvisioningHost {
|
||||
@@ -98,6 +99,10 @@ export abstract class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWi
|
||||
draftStartup?.agent ??
|
||||
(requestedAgentEnabled ? requestedAgent : undefined))
|
||||
const effectiveDraftPaste = args.startupDraftPaste ?? draftStartup?.draftPaste
|
||||
const activationCallbacks = createdWorkspaceActivationCallbacks(
|
||||
this.notifyActivateWorktree.bind(this),
|
||||
args.navigation
|
||||
)
|
||||
// Match IPC routing: executionHostId-only SSH repos must not create locally.
|
||||
const createRoute = resolveWorktreeCreateRoute(repo)
|
||||
if (isFolderRepo(repo)) {
|
||||
@@ -109,6 +114,8 @@ export abstract class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWi
|
||||
startupFollowup: effectiveStartupFollowup,
|
||||
startupLaunchRuntime,
|
||||
createdWithAgent: effectiveCreatedWithAgent,
|
||||
promisedAgentChat: args.promisedAgentChat,
|
||||
onWorkspaceCreated: args.onWorkspaceCreated,
|
||||
draftPaste: effectiveDraftPaste,
|
||||
deps: {
|
||||
store: this.store,
|
||||
@@ -120,15 +127,7 @@ export abstract class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWi
|
||||
invalidateResolvedWorktrees: () => this.invalidateResolvedWorktreeCache(),
|
||||
notifyWorktreesChanged: (repoId) => this.notifyWorktreesChanged(repoId),
|
||||
emitCreated: (event) => this.emitWorktreeLifecycle(event),
|
||||
activate: (repoId, worktreeId, setup, startup) =>
|
||||
this.notifyActivateWorktree(
|
||||
repoId,
|
||||
worktreeId,
|
||||
setup,
|
||||
startup,
|
||||
undefined,
|
||||
args.navigation
|
||||
)
|
||||
activate: activationCallbacks.folder
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -158,8 +157,10 @@ export abstract class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWi
|
||||
this.getOrStartRemoteTrackingBaseRefresh(path, base, ...options),
|
||||
fetchRemote: (path, remote, ...options) =>
|
||||
this.fetchRemoteWithCache(path, remote, ...options),
|
||||
onWorktreeMetadataPersisted: (persistedWorktree) =>
|
||||
this.recordCreatedWorktreeLineage(persistedWorktree, lineageResolution),
|
||||
onWorktreeMetadataPersisted: (persistedWorktree) => {
|
||||
args.onWorkspaceCreated?.(persistedWorktree.id)
|
||||
return this.recordCreatedWorktreeLineage(persistedWorktree, lineageResolution)
|
||||
},
|
||||
rearm,
|
||||
timing
|
||||
})
|
||||
@@ -223,6 +224,7 @@ export abstract class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWi
|
||||
startupFollowup: effectiveStartupFollowup,
|
||||
startupLaunchRuntime,
|
||||
createdWithAgent: effectiveCreatedWithAgent,
|
||||
promisedAgentChat: args.promisedAgentChat,
|
||||
draftPaste: effectiveDraftPaste,
|
||||
warning,
|
||||
ports: {
|
||||
@@ -232,15 +234,7 @@ export abstract class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWi
|
||||
pasteDraft: (handle, draft) => this.pasteStartupDraftWhenReady(handle, draft),
|
||||
sendFollowup: (handle, followup) => this.sendStartupFollowupWhenReady(handle, followup),
|
||||
provision: (options) => this.provisionManagedWorktreeTerminals(options, worktree),
|
||||
activate: (repoId, worktreeId, startup, failedSetup, failedDefaultTabs) =>
|
||||
this.notifyActivateWorktree(
|
||||
repoId,
|
||||
worktreeId,
|
||||
failedSetup,
|
||||
startup,
|
||||
failedDefaultTabs,
|
||||
args.navigation
|
||||
)
|
||||
activate: activationCallbacks.local
|
||||
}
|
||||
})
|
||||
warning = terminalWarning
|
||||
|
||||
@@ -195,14 +195,28 @@ export abstract class OrcaRuntimeWithNotifySshStateChanged extends OrcaRuntimeWi
|
||||
setup?: CreateWorktreeResult['setup'],
|
||||
startup?: WorktreeStartupLaunch,
|
||||
defaultTabs?: CreateWorktreeResult['defaultTabs'],
|
||||
navigationTarget?: RuntimeNavigationTarget
|
||||
navigationTarget?: RuntimeNavigationTarget,
|
||||
providesInitialSurface?: boolean
|
||||
): void {
|
||||
const navigation = navigationTarget ?? 'host'
|
||||
if (navigationTargetsHost(navigation)) {
|
||||
this.notifyHostActivateWorktree(repoId, worktreeId, setup, startup, defaultTabs)
|
||||
this.notifyHostActivateWorktree(
|
||||
repoId,
|
||||
worktreeId,
|
||||
setup,
|
||||
startup,
|
||||
defaultTabs,
|
||||
providesInitialSurface
|
||||
)
|
||||
}
|
||||
if (navigationTargetsClients(navigation)) {
|
||||
this.notifyClientsActivateWorktree(repoId, worktreeId, startup, navigation)
|
||||
this.notifyClientsActivateWorktree(
|
||||
repoId,
|
||||
worktreeId,
|
||||
startup,
|
||||
navigation,
|
||||
providesInitialSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,19 +225,36 @@ export abstract class OrcaRuntimeWithNotifySshStateChanged extends OrcaRuntimeWi
|
||||
worktreeId: string,
|
||||
setup?: CreateWorktreeResult['setup'],
|
||||
startup?: WorktreeStartupLaunch,
|
||||
defaultTabs?: CreateWorktreeResult['defaultTabs']
|
||||
defaultTabs?: CreateWorktreeResult['defaultTabs'],
|
||||
providesInitialSurface?: boolean
|
||||
): void {
|
||||
this.notifier?.activateWorktree(repoId, worktreeId, setup, startup, defaultTabs)
|
||||
this.notifier?.activateWorktree(
|
||||
repoId,
|
||||
worktreeId,
|
||||
setup,
|
||||
startup,
|
||||
defaultTabs,
|
||||
...(providesInitialSurface ? ([true] as const) : [])
|
||||
)
|
||||
}
|
||||
|
||||
protected notifyClientsActivateWorktree(
|
||||
repoId: string,
|
||||
worktreeId: string,
|
||||
startup?: WorktreeStartupLaunch,
|
||||
navigation: RuntimeNavigationTarget = 'clients'
|
||||
navigation: RuntimeNavigationTarget = 'clients',
|
||||
providesInitialSurface?: boolean
|
||||
): void {
|
||||
this.emitClientEvent(
|
||||
toRuntimeActivateWorktreeEvent(repoId, worktreeId, undefined, startup, undefined, navigation)
|
||||
toRuntimeActivateWorktreeEvent(
|
||||
repoId,
|
||||
worktreeId,
|
||||
undefined,
|
||||
startup,
|
||||
undefined,
|
||||
navigation,
|
||||
providesInitialSurface
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { getDefaultWorkspaceSession } from '../../shared/constants'
|
||||
import type { RuntimePtyController } from './runtime-pty-controller-contract'
|
||||
import { beginCreatedWorkspaceLaunch } from '../agent-launch/agent-launch-created-workspace'
|
||||
import { AGENT_LAUNCH_RUNTIME_CAPABILITY } from '../../shared/agent-launch-runtime-capability'
|
||||
|
||||
const createStructured = vi.hoisted(() =>
|
||||
vi.fn(async (args: { envelope: { sessionId: string } }) => ({
|
||||
ok: true as const,
|
||||
value: { sessionId: args.envelope.sessionId }
|
||||
}))
|
||||
)
|
||||
vi.mock('./rpc/methods/structured-agent-session-create', () => ({
|
||||
createStructuredAgentSessionForWorktree: createStructured
|
||||
}))
|
||||
|
||||
const {
|
||||
OrcaRuntimeService,
|
||||
computeWorktreePathMock,
|
||||
ensurePathWithinWorkspaceMock,
|
||||
getEffectiveHooks,
|
||||
getDefaultTabsLaunch,
|
||||
createSetupRunnerScript,
|
||||
listWorktrees
|
||||
} = await import('./orca-runtime-test-mocks.spec')
|
||||
await import('./orca-runtime-test-lifecycle.spec')
|
||||
const { makeRuntimeStoreWithWorkspaceSession, store } =
|
||||
await import('./orca-runtime-test-fixtures.spec')
|
||||
|
||||
const DEFAULTS = {
|
||||
runCommands: true,
|
||||
tabs: [
|
||||
{ title: 'Dev', command: 'pnpm dev' },
|
||||
{ title: 'Tests', command: 'pnpm test' }
|
||||
]
|
||||
}
|
||||
|
||||
function chatCreateRuntime(args: {
|
||||
setupScriptLaunchMode: 'new-tab' | 'split-horizontal'
|
||||
withDefaults: boolean
|
||||
wait: boolean
|
||||
nativeChat?: true
|
||||
}) {
|
||||
const path = '/tmp/workspaces/chat-create'
|
||||
const baseRepo = store.getRepo('repo-1')
|
||||
if (!baseRepo) {
|
||||
throw new Error('missing test repo')
|
||||
}
|
||||
const repo = {
|
||||
...baseRepo,
|
||||
hookSettings: {
|
||||
mode: 'auto' as const,
|
||||
setupRunPolicy: 'run-by-default' as const,
|
||||
setupAgentStartupPolicy: args.wait
|
||||
? ('wait-for-setup' as const)
|
||||
: ('start-immediately' as const),
|
||||
scripts: { setup: '', archive: '' }
|
||||
}
|
||||
}
|
||||
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession(getDefaultWorkspaceSession())
|
||||
const runtime = new OrcaRuntimeService({
|
||||
...runtimeStore,
|
||||
getRepo: (id) => (id === repo.id ? repo : undefined),
|
||||
getRepos: () => [repo],
|
||||
getSettings: () => ({
|
||||
...runtimeStore.getSettings(),
|
||||
setupScriptLaunchMode: args.setupScriptLaunchMode,
|
||||
...(args.nativeChat ? { experimentalNativeChat: true } : {})
|
||||
})
|
||||
})
|
||||
const spawn = vi.fn<NonNullable<RuntimePtyController['spawn']>>(async () => {
|
||||
const ptyId = `pty-${spawn.mock.calls.length}`
|
||||
return { id: ptyId }
|
||||
})
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
computeWorktreePathMock.mockReturnValue(path)
|
||||
ensurePathWithinWorkspaceMock.mockReturnValue(path)
|
||||
vi.mocked(getEffectiveHooks).mockReturnValue({ scripts: { setup: 'pnpm install' } })
|
||||
vi.mocked(getDefaultTabsLaunch).mockReturnValue(args.withDefaults ? DEFAULTS : undefined)
|
||||
vi.mocked(createSetupRunnerScript).mockReturnValue({
|
||||
runnerScriptPath: '/repo/setup.sh',
|
||||
envVars: {},
|
||||
...(args.wait ? { waitForAgentStartup: true } : {})
|
||||
})
|
||||
vi.mocked(listWorktrees).mockResolvedValue([
|
||||
{ path, head: 'abc', branch: 'chat-create', isBare: false, isMainWorktree: false }
|
||||
])
|
||||
return { runtime, spawn }
|
||||
}
|
||||
|
||||
describe('host-created workspaces awaiting their agent chat', () => {
|
||||
it.each([
|
||||
['new-tab', false, false, false],
|
||||
['new-tab', true, true, false],
|
||||
['split-horizontal', false, false, false]
|
||||
] as const)(
|
||||
'provisions only the required terminals (%s, defaults=%s, wait=%s, activate=%s)',
|
||||
async (setupScriptLaunchMode, withDefaults, wait, activate) => {
|
||||
const { runtime, spawn } = chatCreateRuntime({ setupScriptLaunchMode, withDefaults, wait })
|
||||
const launch = beginCreatedWorkspaceLaunch(runtime, 'claude')
|
||||
const result = await runtime.createManagedWorktree({
|
||||
repoSelector: 'id:repo-1',
|
||||
name: 'chat-create',
|
||||
createdWithAgent: 'claude',
|
||||
promisedAgentChat: true,
|
||||
onWorkspaceCreated: launch.onWorkspaceCreated,
|
||||
setupDecision: 'run',
|
||||
awaitTerminalProvisioning: true,
|
||||
observeSetupCompletion: true,
|
||||
activate
|
||||
})
|
||||
const commandCount = withDefaults ? 3 : setupScriptLaunchMode === 'new-tab' ? 1 : 2
|
||||
expect(spawn).toHaveBeenCalledTimes(commandCount)
|
||||
expect(spawn.mock.calls.filter(([args]) => args.command === 'pnpm dev')).toHaveLength(
|
||||
withDefaults ? 1 : 0
|
||||
)
|
||||
expect(spawn.mock.calls.filter(([args]) => args.command === 'pnpm test')).toHaveLength(
|
||||
withDefaults ? 1 : 0
|
||||
)
|
||||
expect(result.setupReceipt).toMatchObject({
|
||||
state: 'running',
|
||||
startupPolicy: wait ? 'wait-for-setup' : 'start-immediately',
|
||||
terminalHandle: expect.any(String)
|
||||
})
|
||||
const snapshot = await runtime.listMobileSessionTabs(`id:${result.worktree.id}`)
|
||||
const query = await runtime
|
||||
.listMobileSessionTabs(`id:${result.worktree.id}`, undefined, {
|
||||
includeInitialSurfaceLaunch: true
|
||||
})
|
||||
.then((sampled) => {
|
||||
launch.release()
|
||||
return sampled
|
||||
})
|
||||
expect(query.initialSurfaceLaunch).toMatchObject({ agent: 'claude' })
|
||||
expect(snapshot.initialSurfaceLaunch).toBeUndefined()
|
||||
expect(
|
||||
(
|
||||
await runtime.listMobileSessionTabs(`id:${result.worktree.id}`, undefined, {
|
||||
includeInitialSurfaceLaunch: true
|
||||
})
|
||||
).initialSurfaceLaunch
|
||||
).toBeUndefined()
|
||||
expect(snapshot.tabs).toHaveLength(commandCount)
|
||||
if (setupScriptLaunchMode === 'new-tab' && !withDefaults) {
|
||||
expect(snapshot.tabs[0]?.title).toBe('Setup')
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// The phone creates through agent.launch; a reported bug left a plain "Terminal 1" beside Setup.
|
||||
it('gives a paired-device chat launch Setup and the chat with no plain shell', async () => {
|
||||
const { runtime, spawn } = chatCreateRuntime({
|
||||
setupScriptLaunchMode: 'new-tab',
|
||||
withDefaults: false,
|
||||
wait: false,
|
||||
nativeChat: true
|
||||
})
|
||||
const { AGENT_LAUNCH_METHODS } = await import('./rpc/methods/agent-launch')
|
||||
const method = AGENT_LAUNCH_METHODS.find((entry) => entry.name === 'agent.launch')
|
||||
if (!method) {
|
||||
throw new Error('missing agent.launch')
|
||||
}
|
||||
const params = method.params.parse({
|
||||
agent: 'claude',
|
||||
target: {
|
||||
kind: 'create-worktree',
|
||||
create: { repo: 'id:repo-1', name: 'chat-create', setupDecision: 'run' }
|
||||
}
|
||||
})
|
||||
const result = await method.handler(params, {
|
||||
runtime,
|
||||
clientKind: 'mobile',
|
||||
pairedDeviceId: 'device-1',
|
||||
caller: { kind: 'paired-device', deviceId: 'device-1' },
|
||||
clientCapabilities: [AGENT_LAUNCH_RUNTIME_CAPABILITY]
|
||||
})
|
||||
|
||||
expect(result.outcome).toMatchObject({ kind: 'structured' })
|
||||
expect(createStructured).toHaveBeenCalledOnce()
|
||||
expect(spawn).toHaveBeenCalledOnce()
|
||||
const tabs = (await runtime.listMobileSessionTabs(`id:${result.worktreeId}`)).tabs
|
||||
expect(tabs.map((tab) => tab.title)).toEqual(['Setup'])
|
||||
})
|
||||
})
|
||||
@@ -53,14 +53,16 @@ export function registerRuntimeWindowLifecycle(
|
||||
worktreeId,
|
||||
setup?: CreateWorktreeResult['setup'],
|
||||
startup?: WorktreeStartupLaunch,
|
||||
defaultTabs?: CreateWorktreeResult['defaultTabs']
|
||||
defaultTabs?: CreateWorktreeResult['defaultTabs'],
|
||||
providesInitialSurface?: boolean
|
||||
) => {
|
||||
send('ui:activateWorktree', {
|
||||
repoId,
|
||||
worktreeId,
|
||||
...(setup ? { setup } : {}),
|
||||
...(startup ? { startup } : {}),
|
||||
...(defaultTabs ? { defaultTabs } : {})
|
||||
...(defaultTabs ? { defaultTabs } : {}),
|
||||
...(providesInitialSurface ? { providesInitialSurface } : {})
|
||||
})
|
||||
},
|
||||
createTerminal: (worktreeId, opts) =>
|
||||
|
||||
@@ -198,6 +198,7 @@ export const uiTabAndBrowserCommandsApi = {
|
||||
setup?: WorktreeSetupLaunch
|
||||
startup?: { command: string; env?: Record<string, string> }
|
||||
defaultTabs?: WorktreeDefaultTabsLaunch
|
||||
providesInitialSurface?: boolean
|
||||
}) => void
|
||||
): (() => void) => {
|
||||
const listener = (
|
||||
@@ -208,6 +209,7 @@ export const uiTabAndBrowserCommandsApi = {
|
||||
setup?: WorktreeSetupLaunch
|
||||
startup?: { command: string; env?: Record<string, string> }
|
||||
defaultTabs?: WorktreeDefaultTabsLaunch
|
||||
providesInitialSurface?: boolean
|
||||
}
|
||||
) => callback(data)
|
||||
ipcRenderer.on('ui:activateWorktree', listener)
|
||||
|
||||
@@ -163,6 +163,7 @@ export type UiCommandEventApi = {
|
||||
setup?: WorktreeSetupLaunch
|
||||
startup?: WorktreeStartupLaunch
|
||||
defaultTabs?: WorktreeDefaultTabsLaunch
|
||||
providesInitialSurface?: boolean
|
||||
}) => void
|
||||
) => () => void
|
||||
onCreateTerminal: (
|
||||
|
||||
@@ -138,6 +138,18 @@ function gateAnsweredLater(): {
|
||||
// While this machine decides, a passive first-terminal seeder used to put "Terminal 1" beside the
|
||||
// chat, since no launch record exists until the host answers.
|
||||
describe('a local chat launch waiting on its host', () => {
|
||||
it('defers the watcher shell for a live host launch, then permits a later ordinary mount', async () => {
|
||||
mocks.gate.mockResolvedValueOnce('blocked').mockResolvedValueOnce('empty')
|
||||
root = createRoot(document.createElement('div'))
|
||||
await act(async () => root?.render(createElement(Watcher)))
|
||||
expect(mocks.createTab).not.toHaveBeenCalled()
|
||||
|
||||
await act(async () => root?.unmount())
|
||||
root = createRoot(document.createElement('div'))
|
||||
await act(async () => root?.render(createElement(Watcher)))
|
||||
expect(mocks.createTab).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps a watched create free of a stray shell until its chat opens', async () => {
|
||||
const { finish } = gateAnsweredLater()
|
||||
// A watched create reveals the workspace, then waits for this machine's answer.
|
||||
|
||||
@@ -102,21 +102,24 @@ export function installAppLifetimeIpcEvents(
|
||||
registerOsMarkdownFileOpenBridge(unsubs)
|
||||
registerWslAccountNoticeIpcBridge(unsubs)
|
||||
unsubs.push(
|
||||
window.api.ui.onActivateWorktree(({ repoId, worktreeId, setup, startup, defaultTabs }) => {
|
||||
void worktreeRuntime
|
||||
.activateNotifiedWorktree(
|
||||
{
|
||||
type: 'activateWorktree',
|
||||
repoId,
|
||||
worktreeId,
|
||||
...(setup ? { setup } : {}),
|
||||
...(startup ? { startup } : {}),
|
||||
...(defaultTabs ? { defaultTabs } : {})
|
||||
},
|
||||
{ allowRuntimeEnvironment: false }
|
||||
)
|
||||
.catch((error) => console.error('Failed to activate CLI-created worktree:', error))
|
||||
})
|
||||
window.api.ui.onActivateWorktree(
|
||||
({ repoId, worktreeId, setup, startup, defaultTabs, providesInitialSurface }) => {
|
||||
void worktreeRuntime
|
||||
.activateNotifiedWorktree(
|
||||
{
|
||||
type: 'activateWorktree',
|
||||
repoId,
|
||||
worktreeId,
|
||||
...(setup ? { setup } : {}),
|
||||
...(startup ? { startup } : {}),
|
||||
...(defaultTabs ? { defaultTabs } : {}),
|
||||
...(providesInitialSurface ? { providesInitialSurface } : {})
|
||||
},
|
||||
{ allowRuntimeEnvironment: false }
|
||||
)
|
||||
.catch((error) => console.error('Failed to activate CLI-created worktree:', error))
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
registerTerminalPresentationIpcBridge(unsubs)
|
||||
|
||||
@@ -182,7 +182,8 @@ export function createWorktreeEventRuntime(
|
||||
worktreeId,
|
||||
setup,
|
||||
startup,
|
||||
defaultTabs
|
||||
defaultTabs,
|
||||
providesInitialSurface
|
||||
}: Extract<RuntimeClientEvent, { type: 'activateWorktree' }>,
|
||||
options: WorktreeEventActivationOptions
|
||||
): Promise<void> => {
|
||||
@@ -212,6 +213,7 @@ export function createWorktreeEventRuntime(
|
||||
...(setup ? { setup } : {}),
|
||||
...(startup ? { startup } : {}),
|
||||
...(defaultTabs ? { defaultTabs } : {}),
|
||||
...(providesInitialSurface ? { providesInitialSurface } : {}),
|
||||
...(!existedBeforeFetch && existsAfterFetch ? { sidebarRevealBehavior: 'auto' } : {}),
|
||||
// Why: this activation came from the host runtime stream; echoing it back can create a selection loop.
|
||||
notifyHostRuntime: false,
|
||||
|
||||
@@ -267,6 +267,23 @@ describe('worktree agent activation gate', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('blocks seeding while the queried snapshot names a live launch', async () => {
|
||||
const { deps, resume } = testDeps({})
|
||||
const snapshot = {
|
||||
...runtimeSnapshot('tab', LIVE_LEAF_ID, []),
|
||||
tabs: [],
|
||||
initialSurfaceLaunch: { launchId: 'launch-1', agent: 'claude' as const }
|
||||
}
|
||||
await expect(
|
||||
runWorktreeAgentActivationGate(WORKTREE_ID, {
|
||||
...deps,
|
||||
hasStructuredSession: async () => ({ snapshot, ownerBySessionId: new Map() })
|
||||
})
|
||||
).resolves.toBe('blocked')
|
||||
expect(deps.listSessions).not.toHaveBeenCalled()
|
||||
expect(resume).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('blocks automatic resume when packaged restore never becomes ready', async () => {
|
||||
const { deps, createTab, resume } = testDeps({})
|
||||
|
||||
|
||||
@@ -152,6 +152,12 @@ export async function runWorktreeAgentActivationGate(
|
||||
let structuredInventory: StructuredActivationInventory | null = null
|
||||
try {
|
||||
const reportedStructuredSession = await deps.hasStructuredSession?.(worktreeId)
|
||||
if (
|
||||
typeof reportedStructuredSession === 'object' &&
|
||||
reportedStructuredSession.snapshot.initialSurfaceLaunch
|
||||
) {
|
||||
return 'blocked'
|
||||
}
|
||||
structuredInventory =
|
||||
typeof reportedStructuredSession === 'object' ? reportedStructuredSession : null
|
||||
structured = Boolean(
|
||||
|
||||
@@ -39,6 +39,17 @@ describe('structured activation inventory scope', () => {
|
||||
expect(call).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('retains a live launch with no chat row for the activation gate', async () => {
|
||||
const call = install({
|
||||
worktree,
|
||||
tabs: [],
|
||||
initialSurfaceLaunch: { launchId: 'launch-1', agent: 'claude' }
|
||||
})
|
||||
const result = await readWorktreeStructuredActivationInventory(worktree)
|
||||
expect(result && result.snapshot.initialSurfaceLaunch?.launchId).toBe('launch-1')
|
||||
expect(call).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it.each([null, {}, { worktree: 'other::/workspace', tabs: [] }, { worktree }])(
|
||||
'rejects unreadable or wrong-workspace replies',
|
||||
async (result) => {
|
||||
|
||||
@@ -29,7 +29,10 @@ export async function readWorktreeStructuredActivationInventory(
|
||||
) {
|
||||
throw new Error('structured session inventory scope unavailable')
|
||||
}
|
||||
if (!snapshot.tabs.some((tab) => tab.type === 'agent-session')) {
|
||||
if (
|
||||
!snapshot.initialSurfaceLaunch &&
|
||||
!snapshot.tabs.some((tab) => tab.type === 'agent-session')
|
||||
) {
|
||||
return false
|
||||
}
|
||||
const ownerBySessionId = new Map<string, { owner: 'native' }>()
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { FolderWorkspace } from '../../../shared/folder-workspace-types'
|
||||
import { folderWorkspaceKey } from '../../../shared/workspace-scope'
|
||||
import { shouldAutoCreateInitialTerminal } from '@/components/terminal/initial-terminal'
|
||||
import { isTerminalWorkspaceEmptiedOnPurpose } from '../../../shared/closed-terminal-tab-tombstones'
|
||||
import { shouldSeedWorktreePrimaryTerminal } from '../../../shared/worktree-first-surface'
|
||||
import { createSequencedSetupAgentCommands } from '../../../shared/setup-agent-sequencing'
|
||||
import { getSetupRunnerCommandPlatformForPath } from '../../../shared/setup-runner-command'
|
||||
import { agentKindToTuiAgent } from '../../../shared/agent-kind'
|
||||
@@ -169,18 +170,17 @@ export function ensureWorktreeHasInitialTerminal(
|
||||
}
|
||||
|
||||
const hasExplicitLaunchWork = Boolean(sequencedStartup || setup || issueCommand)
|
||||
// Why: a caller opening its own primary surface (a structured native chat) asked for that surface
|
||||
// alone. Setup launched in its own tab needs no shell to attach to, so seeding one leaves a stray
|
||||
// "Terminal 1" beside the chat. Splits and issue automation still need a pane to split from.
|
||||
const setupNeedsHostTerminal =
|
||||
setup !== undefined &&
|
||||
(useAppStore.getState().settings?.setupScriptLaunchMode ?? 'new-tab') !== 'new-tab'
|
||||
if (
|
||||
opts?.callerProvidesSurface === true &&
|
||||
renderableTabCount === 0 &&
|
||||
!sequencedStartup &&
|
||||
!issueCommand &&
|
||||
!setupNeedsHostTerminal &&
|
||||
!shouldSeedWorktreePrimaryTerminal({
|
||||
hasPrimarySurface: false,
|
||||
callerProvidesSurface: opts?.callerProvidesSurface === true,
|
||||
startupNeedsTerminal: sequencedStartup !== undefined,
|
||||
issueCommandNeedsTerminal: issueCommand !== undefined,
|
||||
...(setup
|
||||
? { setupLaunchMode: useAppStore.getState().settings?.setupScriptLaunchMode ?? 'new-tab' }
|
||||
: {})
|
||||
}) &&
|
||||
!defaultTabs?.tabs.length &&
|
||||
opts?.createNewTerminalForStartup !== true
|
||||
) {
|
||||
|
||||
@@ -46,6 +46,7 @@ export type RuntimeClientEvent =
|
||||
setup?: WorktreeSetupLaunch
|
||||
startup?: WorktreeStartupLaunch
|
||||
defaultTabs?: WorktreeDefaultTabsLaunch
|
||||
providesInitialSurface?: boolean
|
||||
/** Absent on older hosts; clients must not infer navigation intent from a broadcast. */
|
||||
navigation?: RuntimeNavigationTarget
|
||||
}
|
||||
@@ -75,7 +76,8 @@ export function toRuntimeActivateWorktreeEvent(
|
||||
setup?: CreateWorktreeResult['setup'],
|
||||
startup?: WorktreeStartupLaunch,
|
||||
defaultTabs?: CreateWorktreeResult['defaultTabs'],
|
||||
navigation?: RuntimeNavigationTarget
|
||||
navigation?: RuntimeNavigationTarget,
|
||||
providesInitialSurface?: boolean
|
||||
): RuntimeActivateWorktreeEvent {
|
||||
return {
|
||||
type: 'activateWorktree',
|
||||
@@ -84,6 +86,7 @@ export function toRuntimeActivateWorktreeEvent(
|
||||
...(navigation ? { navigation } : {}),
|
||||
...(setup ? { setup } : {}),
|
||||
...(startup ? { startup } : {}),
|
||||
...(defaultTabs ? { defaultTabs } : {})
|
||||
...(defaultTabs ? { defaultTabs } : {}),
|
||||
...(providesInitialSurface ? { providesInitialSurface } : {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
} from './runtime-mobile-session-tab-contracts'
|
||||
import type { CliStatusCaller } from './orchestration-caller-status'
|
||||
import type { RuntimeHostDescriptor } from './runtime-host-descriptor'
|
||||
import type { TuiAgent } from './tui-agent'
|
||||
|
||||
export type * from './runtime-mobile-session-tab-contracts'
|
||||
|
||||
@@ -262,6 +263,8 @@ export type RuntimeMobileSessionRetiredTerminalSurface = {
|
||||
|
||||
export type RuntimeMobileSessionTabsResult = {
|
||||
worktree: string
|
||||
/** Query-only truth about an executing launch that owns this workspace's first surface. */
|
||||
initialSurfaceLaunch?: { launchId: string; agent: TuiAgent }
|
||||
publicationEpoch: string
|
||||
snapshotVersion: number
|
||||
navigationIntent?: 'follow'
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { GlobalSettings } from './global-settings-types'
|
||||
|
||||
/** Creation intent belongs to this call, never to persisted agent provenance. */
|
||||
export function shouldSeedWorktreePrimaryTerminal(args: {
|
||||
hasPrimarySurface: boolean
|
||||
callerProvidesSurface: boolean
|
||||
startupNeedsTerminal?: boolean
|
||||
issueCommandNeedsTerminal?: boolean
|
||||
setupLaunchMode?: GlobalSettings['setupScriptLaunchMode']
|
||||
}): boolean {
|
||||
return (
|
||||
!args.hasPrimarySurface &&
|
||||
(!args.callerProvidesSurface ||
|
||||
args.startupNeedsTerminal === true ||
|
||||
args.issueCommandNeedsTerminal === true ||
|
||||
args.setupLaunchMode === 'split-horizontal' ||
|
||||
args.setupLaunchMode === 'split-vertical')
|
||||
)
|
||||
}
|
||||
@@ -195,11 +195,21 @@ describe('CLI setup choice across durable launch admission', () => {
|
||||
if (!row) {
|
||||
throw new Error('Missing uncertain launch row')
|
||||
}
|
||||
expect(runtime.createManagedWorktree).toHaveBeenCalledOnce()
|
||||
expect(runtime.createTerminal).toHaveBeenCalledTimes(2)
|
||||
expect(runtime.createTerminal).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'id:wt-new',
|
||||
expect.objectContaining({ startupAgent: 'codex' })
|
||||
)
|
||||
expect(runtime.createTerminal).toHaveBeenNthCalledWith(2, 'id:wt-new', {
|
||||
surfaceOwner: false
|
||||
})
|
||||
await expect(launch(client, { operationId: row.operationId })).rejects.toMatchObject({
|
||||
code: 'agent_session_operation_unknown'
|
||||
})
|
||||
expect(runtime.createManagedWorktree).toHaveBeenCalledOnce()
|
||||
expect(runtime.createTerminal).toHaveBeenCalledOnce()
|
||||
expect(runtime.createTerminal).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not infer an effect-free refusal from matching text alone', async () => {
|
||||
|
||||
Reference in New Issue
Block a user