mirror of
https://github.com/stablyai/orca.git
synced 2026-10-09 08:02:35 +00:00
fix(agent-launch): readiness findings: the phone's closed-tab wording, a closed pane's outcome, a new worktree's view
- The phone said "closed on the computer before it started" as an error even when the phone closed the tab or the agent had started. A new tab's launch now ends silently when its tab is closed; the PR AI button says "The agent's tab was closed, so the agent was stopped." - A verdict for a launch pane the user already closed out of a split is no longer stored on the tab. - A launch that creates a local worktree and falls back to a terminal now opens its agent in the view a local workspace allows: the worktree factory reports the new workspace's connection. - A reveal-race kill of an already-retired PTY no longer reports an unhandled rejection.
This commit is contained in:
@@ -250,10 +250,10 @@ describe('launchAgentInExistingWorkspace', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('says the launch was stopped when its tab was closed on the computer, in its own words', async () => {
|
||||
it('reads a closed tab as the user stopping the launch, not as the launch failing', async () => {
|
||||
const { client } = scriptedClient(refused('agent_launch_tab_closed'))
|
||||
await expect(launch(client)).resolves.toEqual({
|
||||
kind: 'failed',
|
||||
kind: 'tab-closed',
|
||||
message: AGENT_LAUNCH_TAB_CLOSED_MESSAGE
|
||||
})
|
||||
})
|
||||
|
||||
@@ -44,8 +44,9 @@ export const AGENT_LAUNCH_UNCONFIRMED_MESSAGE =
|
||||
|
||||
// The host started nothing, and the next tap reserves new ids.
|
||||
export const AGENT_LAUNCH_RESERVATION_TAKEN_MESSAGE = "Couldn't start the agent. Try again."
|
||||
// Who closed it, and whether the agent had started, are unknown here: the phone or the computer.
|
||||
export const AGENT_LAUNCH_TAB_CLOSED_MESSAGE =
|
||||
"The agent's tab was closed on the computer before it started, so it was stopped."
|
||||
"The agent's tab was closed, so the agent was stopped."
|
||||
|
||||
/**
|
||||
* The tab a launch will create, named by this device before it asks, so it can land there as soon
|
||||
@@ -82,6 +83,8 @@ export type MobileExistingAgentLaunch =
|
||||
| { kind: 'failed'; message: string }
|
||||
/** The agent may or may not be running; the caller must not launch again on its own. */
|
||||
| { kind: 'unknown'; message: string }
|
||||
/** A user closed the launch's tab, which stopped it; not a failure of the launch. */
|
||||
| { kind: 'tab-closed'; message: string }
|
||||
|
||||
export function supportsMobileExistingAgentLaunch(
|
||||
hostCapabilities: readonly string[] | null | undefined
|
||||
@@ -169,7 +172,7 @@ function classifyLaunchRefusal(
|
||||
return { kind: 'failed', message: AGENT_LAUNCH_RESERVATION_TAKEN_MESSAGE }
|
||||
}
|
||||
if (error.code === AGENT_LAUNCH_TAB_CLOSED_CODE) {
|
||||
return { kind: 'failed', message: AGENT_LAUNCH_TAB_CLOSED_MESSAGE }
|
||||
return { kind: 'tab-closed', message: AGENT_LAUNCH_TAB_CLOSED_MESSAGE }
|
||||
}
|
||||
const message = error.message?.trim()
|
||||
return { kind: 'failed', message: message || "Couldn't start the agent." }
|
||||
|
||||
@@ -99,6 +99,11 @@ export async function launchNewTabAgentThroughHost(args: {
|
||||
args.reportCreateFailure(launched.message)
|
||||
return true
|
||||
}
|
||||
if (launched.kind === 'tab-closed') {
|
||||
// Why silent: the tab this "+" opened is gone, which is the answer; the close was a user's own.
|
||||
pendingSelectionRef.current = withoutUnansweredLaunch(pendingSelectionRef.current, args.lock)
|
||||
return true
|
||||
}
|
||||
if (launched.kind === 'unknown') {
|
||||
// Why: a listed tab proves the agent started, so only the prompt is in doubt; notes stay unsent.
|
||||
if (isLaunchedSurfaceListed(args.getSessionTabs(), reservation)) {
|
||||
|
||||
@@ -85,6 +85,7 @@ export async function launchAgentWithPrompt(args: {
|
||||
case 'unsupported':
|
||||
return { kind: 'not-started', message: AGENT_LAUNCH_UPDATE_REQUIRED_MESSAGE }
|
||||
case 'failed':
|
||||
case 'tab-closed':
|
||||
return { kind: 'not-started', message: launched.message }
|
||||
case 'unknown':
|
||||
return { kind: 'unconfirmed', message: launched.message }
|
||||
|
||||
@@ -26,6 +26,7 @@ import { releaseTerminalCreateLock } from './terminal-create-lock'
|
||||
import { useMobileSessionTerminalCreateActions } from './use-mobile-session-terminal-create-actions'
|
||||
|
||||
vi.mock('../platform/haptics', () => ({ triggerSuccess: vi.fn(), triggerError: vi.fn() }))
|
||||
const { triggerError } = await import('../platform/haptics')
|
||||
|
||||
const LAUNCH_CAPABILITIES = [
|
||||
'agent.launch.v2',
|
||||
@@ -357,6 +358,19 @@ describe('the + menu', () => {
|
||||
expect(methods(sendRequest)).toEqual(['agent.launchReplay', 'session.tabs.createTerminal'])
|
||||
})
|
||||
|
||||
it('says nothing when a user closed the tab the launch opened: the tab going is the answer', async () => {
|
||||
const { client } = scriptedClient(refusal('agent_launch_tab_closed'))
|
||||
const state = scope(client)
|
||||
vi.mocked(triggerError).mockClear()
|
||||
|
||||
await create_(state, 'claude')
|
||||
|
||||
expect(state.showToast).not.toHaveBeenCalled()
|
||||
expect(state.setCreateError).not.toHaveBeenCalledWith(expect.stringMatching(/./))
|
||||
expect(triggerError).not.toHaveBeenCalled()
|
||||
expect(state.pendingSelectionRef.current).toBeNull()
|
||||
})
|
||||
|
||||
it("shows the host's refusal even when the session already has tabs", async () => {
|
||||
const { client, sendRequest } = scriptedClient({
|
||||
id: 'x',
|
||||
|
||||
@@ -27,7 +27,7 @@ export const MOBILE_RUNTIME_CLIENT_CAPABILITIES = remoteRuntimeClientCapabilitie
|
||||
AGENT_LAUNCH_RUNTIME_CAPABILITY,
|
||||
// Reads a listed launch tab with no terminal yet as not started, so the host may show it early.
|
||||
AGENT_LAUNCH_UNSTARTED_TAB_CLIENT_CAPABILITY,
|
||||
// Reads `agent_launch_tab_closed` (its tab was closed on the computer) as a definite answer.
|
||||
// Reads `agent_launch_tab_closed` (a user closed its tab, which stopped it) as a definite answer.
|
||||
AGENT_LAUNCH_TAB_CLOSED_CLIENT_CAPABILITY
|
||||
])
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ function harness(options: {
|
||||
calls.push(`createWorktree(startupAgent=${String(args.startupAgent)})`)
|
||||
return {
|
||||
worktreeId: 'wt-new',
|
||||
connectionId: null,
|
||||
startupTerminalHandle: args.startupAgent ? 'term_agent_first' : undefined,
|
||||
...carried(args.startupPrompt)
|
||||
}
|
||||
@@ -691,3 +692,22 @@ describe('the surface is published as the launch stands, before its prompt is de
|
||||
expect(result.prompt).toEqual({ delivery: 'submit', outcome: 'journaled', messageId: 'msg-1' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('a new local worktree whose startup terminal did not come up', () => {
|
||||
it('opens its agent in the view a local workspace allows, as an existing one would', async () => {
|
||||
const h = harness({
|
||||
settings: { experimentalNativeChat: true, openAgentTabsInChatByDefault: true }
|
||||
})
|
||||
h.createWorktree.mockImplementationOnce(async () => ({
|
||||
worktreeId: 'wt-new',
|
||||
connectionId: null,
|
||||
startupTerminalHandle: undefined
|
||||
}))
|
||||
|
||||
await h.run({ ...CREATE_INTENT, agent: 'opencode' })
|
||||
|
||||
expect(h.createTerminalAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ viewMode: 'chat' })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -167,7 +167,7 @@ export async function executeAgentLaunch(
|
||||
execution.onStage?.('surface_create')
|
||||
let created: CreatedSurface
|
||||
try {
|
||||
created = await createSurface(execution, placed.worktreeId, settled)
|
||||
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
|
||||
@@ -180,7 +180,7 @@ export async function executeAgentLaunch(
|
||||
throw error
|
||||
}
|
||||
settled = downgradeAgentLaunchModeForStructuredRefusal(settled, vocabulary)
|
||||
created = await createTerminalSurface(execution, placed.worktreeId)
|
||||
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
|
||||
@@ -231,6 +231,7 @@ async function resolveWorkspace(
|
||||
preflight: AgentLaunchModeReceipt
|
||||
): Promise<{
|
||||
worktreeId: string
|
||||
connectionId: string | null | undefined
|
||||
startupTerminalHandle: string | undefined
|
||||
startupTerminalPaneKey?: string
|
||||
warning?: string
|
||||
@@ -240,7 +241,8 @@ async function resolveWorkspace(
|
||||
const { intent } = execution
|
||||
if (intent.target.kind === 'existing') {
|
||||
// Nothing was created, so there is no create warning to carry.
|
||||
return { worktreeId: intent.target.worktree, startupTerminalHandle: undefined }
|
||||
const { worktree: worktreeId, connectionId } = intent.target
|
||||
return { worktreeId, connectionId, startupTerminalHandle: undefined }
|
||||
}
|
||||
const workspaces = execution.workspaces
|
||||
if (!workspaces) {
|
||||
@@ -277,7 +279,7 @@ export type CreatedSurface = {
|
||||
|
||||
async function createSurface(
|
||||
execution: AgentLaunchExecution,
|
||||
worktreeId: string,
|
||||
workspace: { worktreeId: string; connectionId: string | null | undefined },
|
||||
settled: AgentLaunchModeReceipt
|
||||
): Promise<CreatedSurface> {
|
||||
const { intent, surfaces } = execution
|
||||
@@ -285,7 +287,7 @@ async function createSurface(
|
||||
// One reservation serves either route: the tab half of the reserved pane is the chat's tab.
|
||||
const reservedTabId = intent.paneKey ? parsePaneKey(intent.paneKey)?.tabId : undefined
|
||||
const session = await surfaces.createStructuredSession({
|
||||
worktreeId,
|
||||
worktreeId: workspace.worktreeId,
|
||||
agent: intent.agent,
|
||||
...(intent.sessionOptions ? { options: intent.sessionOptions } : {}),
|
||||
...(intent.sessionId ? { sessionId: intent.sessionId } : {}),
|
||||
@@ -302,7 +304,7 @@ async function createSurface(
|
||||
...ignoredStructuredAgentArgsWarning(intent)
|
||||
}
|
||||
}
|
||||
return createTerminalSurface(execution, worktreeId)
|
||||
return createTerminalSurface(execution, workspace)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -347,12 +349,12 @@ function terminalLaunchInputs(intent: AgentLaunchIntent) {
|
||||
*/
|
||||
async function createTerminalSurface(
|
||||
execution: AgentLaunchExecution,
|
||||
worktreeId: string
|
||||
workspace: { worktreeId: string; connectionId: string | null | undefined }
|
||||
): Promise<CreatedSurface> {
|
||||
const { intent, surfaces } = execution
|
||||
const startupPrompt = argvLaunchPrompt(intent)
|
||||
const terminal = await surfaces.createTerminalAgent({
|
||||
worktreeId,
|
||||
worktreeId: workspace.worktreeId,
|
||||
agent: intent.agent,
|
||||
...(startupPrompt ? { startupPrompt } : {}),
|
||||
...terminalLaunchInputs(intent),
|
||||
@@ -360,7 +362,7 @@ async function createTerminalSurface(
|
||||
settings: readAgentLaunchModeSettings(execution.runtime),
|
||||
agent: intent.agent,
|
||||
...(intent.prompt ? { prompt: intent.prompt } : {}),
|
||||
connectionId: intent.target.kind === 'existing' ? intent.target.connectionId : undefined
|
||||
connectionId: workspace.connectionId
|
||||
})
|
||||
})
|
||||
return {
|
||||
|
||||
@@ -13,6 +13,7 @@ function harness() {
|
||||
const deliverPrompt = vi.fn(async () => true)
|
||||
const createWorktree = vi.fn(async () => ({
|
||||
worktreeId: 'wt_new',
|
||||
connectionId: null,
|
||||
startupTerminalHandle: 'term_new'
|
||||
}))
|
||||
return {
|
||||
|
||||
@@ -119,6 +119,8 @@ export type AgentLaunchWorkspaceFactory = {
|
||||
options?: Readonly<Record<string, unknown>>
|
||||
}): Promise<{
|
||||
worktreeId: string
|
||||
/** The new workspace's SSH connection; `null` is local. Decides what its agent tab can show. */
|
||||
connectionId: string | null
|
||||
startupTerminalHandle: string | undefined
|
||||
/** The pane minted with the startup terminal, when the runtime reported one. */
|
||||
startupTerminalPaneKey?: string
|
||||
|
||||
@@ -107,6 +107,7 @@ export function agentLaunchWorkspaceFactory(
|
||||
finishAutomationWorkspaceProvenanceRequest(params.automationProvenanceRequest)
|
||||
return {
|
||||
worktreeId: result.worktree.id,
|
||||
connectionId: repo.connectionId ?? null,
|
||||
startupTerminalHandle: result.startupTerminal?.handle,
|
||||
...(promptRodeLaunchCommand ? { promptRodeLaunchCommand } : {}),
|
||||
...(result.startupTerminal?.paneKey
|
||||
|
||||
@@ -62,7 +62,8 @@ export function registerTerminalPresentationIpcBridge(unsubs: (() => void)[]): v
|
||||
if (ptyId && tabId && leafId && wasAgentLaunchPaneClosedByUser(tabId, leafId)) {
|
||||
// The user closed the launch's tab or pane while it waited. That close wins: it stays
|
||||
// closed, and its agent stops, as closing any tab stops what runs in it.
|
||||
void window.api.pty.kill(ptyId)
|
||||
// The host stops the same agent; a second kill of a retired PTY may reject, harmlessly.
|
||||
window.api.pty.kill(ptyId).catch(() => {})
|
||||
throw new Error('agent_launch_tab_closed')
|
||||
}
|
||||
// Why: a split pane revealed from mobile is only bound in the persisted
|
||||
|
||||
@@ -77,6 +77,17 @@ describe("a launch pane's verdict in the window", () => {
|
||||
expect(agentLaunchPanePrompt(tabId)).toBe('fix the build')
|
||||
})
|
||||
|
||||
it('final, for a launch pane the user already closed out of a split: nothing is kept', () => {
|
||||
store.getState().setTabLayout(tabId, {
|
||||
root: { type: 'leaf', leafId: '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d' },
|
||||
activeLeafId: '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d',
|
||||
expandedLeafId: null
|
||||
})
|
||||
apply({ kind: 'not-started', code: 'agent_launch_tab_closed' })
|
||||
expect(launchTab()?.agentLaunchPane).toBeUndefined()
|
||||
expect(agentLaunchPanePrompt(tabId)).toBeNull()
|
||||
})
|
||||
|
||||
it('withdrawn: a tab with only the launch pane goes', () => {
|
||||
apply({ kind: 'withdrawn' })
|
||||
expect(launchTab()).toBeUndefined()
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import type { AgentLaunchPaneVerdictEvent } from '../../../shared/agent-launch-pane-verdict'
|
||||
import { applyClosedTerminalLeafNotice } from '@/components/terminal-pane/closed-terminal-leaf-notice'
|
||||
import { collectLeafIdsInOrder } from '@/components/terminal-pane/terminal-layout-leaf-ids'
|
||||
import { useAppStore } from '@/store'
|
||||
import { forgetAgentLaunchPanePrompt } from './agent-launch-pane-prompt'
|
||||
|
||||
@@ -23,10 +24,18 @@ export function applyAgentLaunchPaneVerdict(event: AgentLaunchPaneVerdictEvent):
|
||||
state.setTabAgentLaunchPane(tabId, undefined)
|
||||
return
|
||||
case 'not-started':
|
||||
case 'unconfirmed':
|
||||
case 'unconfirmed': {
|
||||
const root = state.terminalLayoutsByTabId[tabId]?.root
|
||||
if (root && !collectLeafIdsInOrder(root).includes(leafId)) {
|
||||
// The user closed that pane: nothing is left for the outcome to describe.
|
||||
forgetAgentLaunchPanePrompt(tabId)
|
||||
state.setTabAgentLaunchPane(tabId, undefined)
|
||||
return
|
||||
}
|
||||
// Final for this pane, for the tab's life: no later spawn needs the launch record.
|
||||
state.setTabAgentLaunchPane(tabId, { ...tab.agentLaunchPane, outcome: verdict })
|
||||
return
|
||||
}
|
||||
case 'withdrawn': {
|
||||
forgetAgentLaunchPanePrompt(tabId)
|
||||
// The pane alone when the user split the tab while it waited; their split stays.
|
||||
|
||||
Reference in New Issue
Block a user