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:
Brennan Benson
2026-10-15 01:21:24 -07:00
committed by GitHub
parent 8e35055448
commit 635d3853f1
42 changed files with 924 additions and 253 deletions
@@ -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')
})
})
+109 -129
View File
@@ -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
})
@@ -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,
+3 -1
View File
@@ -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'])
})
})
+4 -2
View File
@@ -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)
+1
View File
@@ -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
) {
+5 -2
View File
@@ -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 } : {})
}
}
+3
View File
@@ -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'
+19
View File
@@ -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 () => {