Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions mobile/src/tasks/agent-launch-mobile-replay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,14 @@ import {
AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS,
AGENT_SESSION_OPERATION_FUTURE_SKEW_MS
} from '../../../src/shared/agent-session-host-authority'
import { setStructuredAgentSessionHost } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-registry'
import type { StructuredAgentSessionHost } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-host'
import type { AgentSessionRecordStore } from '../../../src/main/runtime/agent-session-record-store'
import { openTestAgentSessionRecordStore } from '../../../src/main/runtime/agent-session-record-store-test-harness'
import type { OrcaRuntimeService } from '../../../src/main/runtime/orca-runtime'
import { RpcDispatcher } from '../../../src/main/runtime/rpc/dispatcher'
import { runtimeStub } from '../../../src/main/runtime/rpc/methods/agent-launch.test-fixture'
import {
runtimeStub,
setAgentLaunchRecordStore
} from '../../../src/main/runtime/rpc/methods/agent-launch.test-fixture'
import {
AGENT_LAUNCH_REPLAY_REQUIRED_RUNTIME_CAPABILITY,
AGENT_LAUNCH_RUNTIME_CAPABILITY
Expand All @@ -40,13 +41,12 @@ beforeEach(async () => {
createStructuredSession.mockResolvedValue({ ok: true, value: { sessionId: 'session-1' } })
directory = await mkdtemp(join(tmpdir(), 'orca-mobile-launch-replay-'))
store = await openTestAgentSessionRecordStore(directory)
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the launch reads only deps.store; structured session creation is the injected boundary above.
setStructuredAgentSessionHost({ deps: { store } } as unknown as StructuredAgentSessionHost)
setAgentLaunchRecordStore(store)
})

afterEach(async () => {
vi.restoreAllMocks()
setStructuredAgentSessionHost(null)
setAgentLaunchRecordStore(null)
await rm(directory, { recursive: true, force: true })
})

Expand Down
18 changes: 8 additions & 10 deletions mobile/src/tasks/mobile-agent-launch-architecture.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@ import { DeviceRegistry } from '../../../src/main/runtime/device-registry'
import type { AuthenticatedMobileSocket } from '../../../src/main/runtime/rpc/mobile-socket-wiring'
import { RpcDispatcher } from '../../../src/main/runtime/rpc/dispatcher'
import { AgentLaunchFields } from '../../../src/main/runtime/rpc/methods/agent-launch-schemas'
import { runtimeStub } from '../../../src/main/runtime/rpc/methods/agent-launch.test-fixture'
import { setStructuredAgentSessionHost } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-registry'
import type { StructuredAgentSessionHost } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-host'
import {
runtimeStub,
setAgentLaunchRecordStore
} from '../../../src/main/runtime/rpc/methods/agent-launch.test-fixture'
import type { OrcaRuntimeService } from '../../../src/main/runtime/orca-runtime'
import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity'
import { createStableLogicalRpcClient } from '../transport/stable-logical-rpc-client'
Expand Down Expand Up @@ -39,12 +40,11 @@ beforeEach(async () => {
createStructuredSession.mockResolvedValue({ ok: true, value: { sessionId: 'session-1' } })
directory = await mkdtemp(join(tmpdir(), 'orca-launch-architecture-'))
store = await openTestAgentSessionRecordStore(directory)
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the launch reads deps.store; session creation is injected above.
setStructuredAgentSessionHost({ deps: { store } } as unknown as StructuredAgentSessionHost)
setAgentLaunchRecordStore(store)
})

afterEach(async () => {
setStructuredAgentSessionHost(null)
setAgentLaunchRecordStore(null)
await rm(directory, { recursive: true, force: true })
})

Expand Down Expand Up @@ -243,8 +243,7 @@ describe('mobile launch retry authority', () => {
const first = await dispatcher.dispatch(request)
expect(first.ok).toBe(true)
store = await openTestAgentSessionRecordStore(directory)
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: launch admission reads only deps.store from the installed host.
setStructuredAgentSessionHost({ deps: { store } } as unknown as StructuredAgentSessionHost)
setAgentLaunchRecordStore(store)
await expect(
dispatcher.dispatch({ ...request, method: 'agent.launchReplay' })
).resolves.toEqual(first)
Expand Down Expand Up @@ -293,8 +292,7 @@ describe('mobile launch retry authority', () => {
}
})
store = await openTestAgentSessionRecordStore(directory)
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: launch admission reads only deps.store from the installed host.
setStructuredAgentSessionHost({ deps: { store } } as unknown as StructuredAgentSessionHost)
setAgentLaunchRecordStore(store)
await expect(dispatcher.dispatch(request)).resolves.toMatchObject({
ok: false,
error: { code: 'worktree_create_collision' }
Expand Down
84 changes: 82 additions & 2 deletions src/main/agent-launch/agent-launch-executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
import { describe, expect, it, vi } from 'vitest'
import { executeAgentLaunch, type AgentLaunchExecution } from './agent-launch-executor'
import { AgentLaunchStructuredSessionRefusedError } from './agent-launch-surface-factories'
import type { AgentLaunchIntent } from '../../shared/agent-launch-intent'
import type { AgentLaunchIntent, AgentLaunchResult } from '../../shared/agent-launch-intent'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants'

const STRUCTURED_PREFERENCE = {
Expand All @@ -29,6 +29,7 @@ function harness(options: {
terminalPromptDelivered?: boolean
/** Whether the surface reports that its typed line took the offered prompt. */
lineCarriesPrompt?: boolean
onSurfacePublished?: AgentLaunchExecution['onSurfacePublished']
}) {
const calls: string[] = []
const carried = (startupPrompt: string | undefined) =>
Expand Down Expand Up @@ -96,7 +97,8 @@ function harness(options: {
deliverStructuredPrompt,
deliverTerminalPrompt
},
workspaces: { createWorktree }
workspaces: { createWorktree },
...(options.onSurfacePublished ? { onSurfacePublished: options.onSurfacePublished } : {})
})
}
}
Expand Down Expand Up @@ -607,3 +609,81 @@ describe('caller-supplied launch inputs', () => {
expect(result.warning).toBeUndefined()
})
})

describe('the surface is published as the launch stands, before its prompt is delivered', () => {
const PROMPTED_EXISTING: AgentLaunchIntent = {
agent: 'claude',
target: { kind: 'existing', worktree: 'wt-7' },
prompt: { text: 'fix the build', delivery: 'submit' }
}

function publishing(options: Parameters<typeof harness>[0]) {
const published: AgentLaunchResult[] = []
const launch = harness({
...options,
onSurfacePublished: (surface) => {
launch.calls.push('published')
published.push(surface)
}
})
return { launch, published }
}

it('records a prompt still owed as unconfirmed, then delivers it', async () => {
const { launch, published } = publishing({ settings: {}, lineCarriesPrompt: false })

const result = await launch.run(PROMPTED_EXISTING)

expect(launch.calls).toEqual(['createTerminalAgent', 'published', 'deliverTerminalPrompt'])
expect(published).toEqual([
{
outcome: { kind: 'terminal', handle: 'term_1' },
worktreeId: 'wt-7',
receipt: result.receipt,
// A host that stops mid-paste cannot say whether it landed, so it must not say "not sent".
prompt: { delivery: 'submit', outcome: 'unconfirmed' }
}
])
expect(result.prompt).toEqual({ delivery: 'submit', outcome: 'handed-to-terminal' })
})

it('records a draft as not delivered, since the host never delivers one', async () => {
const { launch, published } = publishing({ settings: {}, lineCarriesPrompt: false })

const result = await launch.run({
...PROMPTED_EXISTING,
prompt: { text: 'fix the build', delivery: 'draft' }
})

expect(published[0]?.prompt).toEqual({ delivery: 'draft', outcome: 'not-delivered' })
expect(result.prompt).toEqual({ delivery: 'draft', outcome: 'not-delivered' })
})

it('records a prompt the launch command carried as already handed over', async () => {
const { launch, published } = publishing({ settings: {}, lineCarriesPrompt: true })

const result = await launch.run(PROMPTED_EXISTING)

expect(published[0]?.prompt).toEqual({ delivery: 'submit', outcome: 'handed-to-terminal' })
expect(published[0]).toEqual(result)
expect(launch.deliverTerminalPrompt).not.toHaveBeenCalled()
})

it('records a chat before its first message is committed', async () => {
const { launch, published } = publishing({})

const result = await launch.run(PROMPTED_EXISTING)

expect(launch.calls).toEqual([
'createSupport',
'createStructuredSession',
'published',
'deliverStructuredPrompt'
])
expect(published[0]).toEqual({
...result,
prompt: { delivery: 'submit', outcome: 'unconfirmed' }
})
expect(result.prompt).toEqual({ delivery: 'submit', outcome: 'journaled', messageId: 'msg-1' })
})
})
31 changes: 22 additions & 9 deletions src/main/agent-launch/agent-launch-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
HANDED_TO_TERMINAL,
launchCommandPrompt,
promptReceipt,
settledAtCreation,
settleLaunchPromptDisposal
} from './agent-launch-prompt-delivery'
import type { TuiAgent } from '../../shared/tui-agent'
Expand Down Expand Up @@ -75,7 +76,13 @@ export type AgentLaunchExecution = {
onSurfacePublished?: (surface: AgentLaunchPublishedSurface) => void
}

export type AgentLaunchPublishedSurface = Pick<AgentLaunchResult, 'outcome' | 'worktreeId'>
/**
* The launch as it stands once its surface exists: a complete result whose prompt receipt says only
* what creation itself settled — carried on the launch command, a draft the host never delivers, or
* a submit still `unconfirmed`. Complete so a host that dies during the delivery still leaves a
* truthful answer behind.
*/
export type AgentLaunchPublishedSurface = AgentLaunchResult

export async function executeAgentLaunch(
execution: AgentLaunchExecution
Expand Down Expand Up @@ -105,11 +112,12 @@ export async function executeAgentLaunch(
if (intent.reuseTerminal) {
const reused = published(execution, {
outcome: { kind: 'terminal', handle: intent.reuseTerminal.handle },
worktreeId: existingWorktreeId(intent.target)
worktreeId: existingWorktreeId(intent.target),
receipt: preflight,
...promptReceipt(intent, settledAtCreation(intent, {}))
})
return {
...reused,
receipt: preflight,
...promptReceipt(
intent,
await deliverTerminalLaunchPrompt(execution, intent.reuseTerminal.handle, {
Expand All @@ -128,12 +136,13 @@ export async function executeAgentLaunch(
handle: placed.startupTerminalHandle,
...(placed.startupTerminalPaneKey ? { paneKey: placed.startupTerminalPaneKey } : {})
},
worktreeId: placed.worktreeId
worktreeId: placed.worktreeId,
receipt: preflight,
...(placed.warning ? { warning: placed.warning } : {}),
...promptReceipt(intent, settledAtCreation(intent, placed))
})
return {
...startup,
receipt: preflight,
...(placed.warning ? { warning: placed.warning } : {}),
...promptReceipt(
intent,
placed.promptRodeLaunchCommand
Expand Down Expand Up @@ -183,11 +192,15 @@ export async function executeAgentLaunch(
// 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 })
return {
...surface,
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))
}
}
Expand Down
24 changes: 21 additions & 3 deletions src/main/agent-launch/agent-launch-prompt-delivery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
* terminal, line fits -> folded into the command that execs the agent -> handed-to-terminal
* terminal, otherwise -> bracketed paste into the live PTY once it is ready -> handed-to-terminal
* anything unproven -> -> not-delivered
* host stopped mid-delivery (a replayed record only) -> unconfirmed
*
* argv has no readiness race, so it is offered wherever the agent's CLI takes a prompt argument
* (`agentPromptRidesLaunchCommand`). But that command is TYPED into the user's shell, and a long or
Expand All @@ -28,6 +29,23 @@ import type { AgentLaunchStructuredSurface } from './agent-launch-surface-factor

export const HANDED_TO_TERMINAL: AgentLaunchPromptDisposal = { outcome: 'handed-to-terminal' }
const NOT_DELIVERED: AgentLaunchPromptDisposal = { outcome: 'not-delivered' }
const UNCONFIRMED: AgentLaunchPromptDisposal = { outcome: 'unconfirmed' }

/**
* What the record may say before any delivery runs: only what creating the surface already settled.
* A launch command that carried the text has handed it over, and a draft is never delivered by the
* host. A submit still owed is `unconfirmed`: a host that stops mid-delivery cannot say whether the
* paste or the commit landed, and "not delivered" would invite a duplicate turn.
*/
export function settledAtCreation(
intent: Pick<AgentLaunchIntent, 'prompt'>,
created: { promptRodeLaunchCommand?: boolean }
): AgentLaunchPromptDisposal {
if (created.promptRodeLaunchCommand) {
return HANDED_TO_TERMINAL
}
return intent.prompt?.delivery === 'submit' ? UNCONFIRMED : NOT_DELIVERED
}

/** Each surface delivers its own way, so the disposal is decided where the surface is known. */
export async function settleLaunchPromptDisposal(
Expand Down Expand Up @@ -127,9 +145,9 @@ export function launchCommandPrompt(
* Each arm is a consequence of the act it names, never a write-ahead of it: `journaled` is
* reachable only from a committed message id, `handed-to-terminal` only from a launch command that
* carried the text or a PTY write that returned, and everything else under-claims as
* `not-delivered`. There is deliberately no arm for "maybe" — a caller holding one could neither
* resend nor drop the text. Dispatch doubt is not this tier's to report: the submission row
* carries it.
* `not-delivered`. A live answer has no "maybe": `unconfirmed` is written only into the record
* before delivery runs (`settledAtCreation`), and is read back only by a replay. Dispatch doubt is
* not this tier's to report: the submission row carries it.
*/
export function promptReceipt(
intent: AgentLaunchIntent,
Expand Down
15 changes: 12 additions & 3 deletions src/main/ipc/desktop-renderer-runtime-capabilities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@ import {
WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY,
type RuntimeCapability
} from '../../shared/protocol-version'
import { AGENT_LAUNCH_RUNTIME_CAPABILITY } from '../../shared/agent-launch-runtime-capability'
import {
AGENT_LAUNCH_PROMPT_UNCONFIRMED_RUNTIME_CAPABILITY,
AGENT_LAUNCH_RUNTIME_CAPABILITY
} from '../../shared/agent-launch-runtime-capability'
import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/electron-remote-runtime-client-capabilities'
import { remoteRuntimeClientCapabilities } from '../../shared/remote-runtime-client-capabilities'
import { supportsAgentLaunch } from '../runtime/rpc/methods/agent-launch'
Expand Down Expand Up @@ -63,6 +66,12 @@ const REMOTE_ONLY_BY_DECISION: readonly RuntimeCapability[] = [
SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY
]

/** Advertised to main and deliberately NOT to a remote host yet. */
const LOCAL_ONLY_BY_DECISION: readonly RuntimeCapability[] = [
// Read by the desktop's own launches first; a remote host is told when its launches move over.
AGENT_LAUNCH_PROMPT_UNCONFIRMED_RUNTIME_CAPABILITY
]

function missingFrom(
source: readonly RuntimeCapability[],
other: readonly RuntimeCapability[]
Expand Down Expand Up @@ -104,9 +113,9 @@ describe('desktop renderer runtime client capabilities', () => {
expect(missingFrom(PAIRED_HOST_RECEIVES, DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES)).toEqual(
[...REMOTE_ONLY_BY_DECISION].sort()
)
// The same renderer reads structured chats on either host, so it claims nothing only locally.
// The same renderer reads structured chats on either host, so only a recorded decision is local.
expect(missingFrom(DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES, PAIRED_HOST_RECEIVES)).toEqual(
[]
[...LOCAL_ONLY_BY_DECISION].sort()
)
})
})
10 changes: 8 additions & 2 deletions src/main/ipc/desktop-renderer-runtime-capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ import {
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
type RuntimeCapability
} from '../../shared/protocol-version'
import { AGENT_LAUNCH_RUNTIME_CAPABILITY } from '../../shared/agent-launch-runtime-capability'
import {
AGENT_LAUNCH_PROMPT_UNCONFIRMED_RUNTIME_CAPABILITY,
AGENT_LAUNCH_RUNTIME_CAPABILITY
} from '../../shared/agent-launch-runtime-capability'
import { AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY } from '../../shared/agent-session-background-task-child-views-capability'

/**
Expand Down Expand Up @@ -39,5 +42,8 @@ export const DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES: readonly RuntimeCapab
REPO_SEARCH_QUALIFIED_REFS_RUNTIME_CAPABILITY,
// Without this `supportsAgentLaunch` refuses the renderer outright, while the same renderer
// targeting a remote host is admitted — the asymmetry this constant exists to close.
AGENT_LAUNCH_RUNTIME_CAPABILITY
AGENT_LAUNCH_RUNTIME_CAPABILITY,
// A replay after a restart mid-delivery answers the running agent with an `unconfirmed` prompt,
// which the desktop reads, rather than refusing it as unknown.
AGENT_LAUNCH_PROMPT_UNCONFIRMED_RUNTIME_CAPABILITY
] as const
Loading
Loading