fix(native-chat): send launch prompts and worker tasks through the retrying start (#33832)

* Make opening launch prompts own the retrying chat start

* test: observe launch inventory through surface publication

* fix: keep blank chat launch prompts unsent

* fix: bound launch seed filtering and update CLI replay fixture

* fix: preserve encoded launch selections after filtering
This commit is contained in:
Brennan Benson
2026-10-26 10:45:40 -07:00
committed by GitHub
parent dff068fe46
commit c20971dbd1
15 changed files with 609 additions and 385 deletions
@@ -58,10 +58,6 @@ function harness(options: {
calls.push('createTerminalAgent')
return { handle: 'term_1', ...carried(args.startupPrompt) }
})
const deliverStructuredPrompt = vi.fn(async () => {
calls.push('deliverStructuredPrompt')
return 'msg-1'
})
const deliverTerminalPrompt = vi.fn(async (_args: { signal?: AbortSignal }) => {
calls.push('deliverTerminalPrompt')
return true
@@ -84,7 +80,6 @@ function harness(options: {
surfaces: {
createStructuredSession,
createTerminalAgent,
deliverStructuredPrompt,
deliverTerminalPrompt
},
workspaces: { createWorktree, createRecoveryShell },
@@ -1,6 +1,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 { AgentLaunchSurfaceFactory } 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'
@@ -20,7 +21,6 @@ function harness(options: {
lineCarriesPrompt?: boolean
worktreeCreateError?: Error
onStructuredCreate?: () => void
onStructuredPromptDelivery?: () => void
onSurfacePublished?: AgentLaunchExecution['onSurfacePublished']
worktreeOnlyFactory?: boolean
}) {
@@ -63,14 +63,23 @@ function harness(options: {
}
return options.createSupport ?? { supported: true }
})
const createStructuredSession = vi.fn(async () => {
calls.push('createStructuredSession')
options.onStructuredCreate?.()
if (options.structuredCreateError) {
throw options.structuredCreateError
const createStructuredSession = vi.fn(
async (args: Parameters<AgentLaunchSurfaceFactory['createStructuredSession']>[0]) => {
calls.push('createStructuredSession')
options.onStructuredCreate?.()
if (options.structuredCreateError) {
throw options.structuredCreateError
}
return {
sessionId: 'sess-1',
handle: 'handle_structured',
fence: 4,
...(args.firstMessage && options.deliveredMessageId !== null
? { firstMessage: { clientMessageId: 'msg-1' } }
: {})
}
}
return { sessionId: 'sess-1', handle: 'handle_structured', fence: 4 }
})
)
const createTerminalAgent = vi.fn(async (args: { startupPrompt?: string }) => {
calls.push('createTerminalAgent')
if (options.terminalCreateError) {
@@ -78,11 +87,6 @@ function harness(options: {
}
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 () => {
calls.push('deliverTerminalPrompt')
return options.terminalPromptDelivered ?? true
@@ -101,7 +105,6 @@ function harness(options: {
createRecoveryShell,
createStructuredSession,
createTerminalAgent,
deliverStructuredPrompt,
deliverTerminalPrompt,
run: (intent: AgentLaunchIntent) =>
executeAgentLaunch({
@@ -111,7 +114,6 @@ function harness(options: {
surfaces: {
createStructuredSession,
createTerminalAgent,
deliverStructuredPrompt,
deliverTerminalPrompt
},
workspaces: options.worktreeOnlyFactory
@@ -128,17 +130,17 @@ 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 () => {
it('keeps its live inventory through publication and clears it after launch', async () => {
let duringCreate: unknown
let duringPrompt: unknown
let duringPublication: unknown
const h = harness({
onStructuredCreate: () => (duringCreate = readCreatedWorkspaceLaunch(h.runtime, 'wt-new')),
onStructuredPromptDelivery: () =>
(duringPrompt = readCreatedWorkspaceLaunch(h.runtime, 'wt-new'))
onSurfacePublished: () =>
(duringPublication = 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(duringPublication).toEqual(duringCreate)
expect(readCreatedWorkspaceLaunch(h.runtime, 'wt-new')).toBeUndefined()
})
@@ -426,20 +428,22 @@ describe('the prompt receipt', () => {
expect(h.calls).toEqual([
'createWorktree(startupAgent=undefined)',
'createSupport',
'createStructuredSession',
'deliverStructuredPrompt'
'createStructuredSession'
])
expect(h.deliverStructuredPrompt).toHaveBeenCalledWith({
sessionId: 'sess-1',
fence: 4,
prompt: SUBMIT
})
expect(h.createStructuredSession).toHaveBeenCalledWith(
expect.objectContaining({
firstMessage: {
clientMessageId: expect.any(String),
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: SUBMIT.text }] }
}
})
)
})
it('under-claims as not delivered when nothing was committed', async () => {
it('keeps a missing commit receipt unconfirmed', async () => {
const h = harness({ deliveredMessageId: null })
const result = await h.run({ ...CREATE_INTENT, prompt: SUBMIT })
expect(result.prompt).toEqual({ delivery: 'submit', outcome: 'not-delivered' })
expect(result.prompt).toEqual({ delivery: 'submit', outcome: 'unconfirmed' })
})
it('leaves a draft with the caller, because the host has no composer to hold one', async () => {
@@ -449,7 +453,7 @@ describe('the prompt receipt', () => {
prompt: { text: 'do the thing', delivery: 'draft' }
})
expect(result.prompt).toEqual({ delivery: 'draft', outcome: 'not-delivered' })
expect(h.deliverStructuredPrompt).not.toHaveBeenCalled()
expect(h.createStructuredSession.mock.calls[0]?.[0].firstMessage).toBeUndefined()
})
it('omits the receipt when no prompt was requested', async () => {
@@ -790,21 +794,13 @@ describe('the surface is published as the launch stands, before its prompt is de
expect(launch.deliverTerminalPrompt).not.toHaveBeenCalled()
})
it('records a chat before its first message is committed', async () => {
it('publishes a chat with its first message receipt', 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(launch.calls).toEqual(['createSupport', 'createStructuredSession', 'published'])
expect(published[0]).toEqual(result)
expect(result.prompt).toEqual({ delivery: 'submit', outcome: 'journaled', messageId: 'msg-1' })
})
})
+5 -10
View File
@@ -11,6 +11,7 @@ import {
} from './agent-launch-warnings'
import {
argvLaunchPrompt,
launchPromptCreateInputs,
deliverTerminalLaunchPrompt,
HANDED_TO_TERMINAL,
promptReceipt,
@@ -40,11 +41,7 @@ import {
type AgentLaunchStructuredSurface,
type AgentLaunchWorkspaceFactory
} from './agent-launch-surface-factories'
import type {
AgentLaunchExecution,
AgentLaunchPublishedSurface,
AgentLaunchSurfaceExecution
} from './agent-launch-execution'
import type { AgentLaunchExecution, AgentLaunchSurfaceExecution } from './agent-launch-execution'
import {
beginCreatedWorkspaceLaunch,
recoverCreatedWorkspace
@@ -210,10 +207,7 @@ export async function executeAgentLaunch(
}
}
function published(
execution: AgentLaunchExecution,
surface: AgentLaunchPublishedSurface
): AgentLaunchPublishedSurface {
function published(execution: AgentLaunchExecution, surface: AgentLaunchResult): AgentLaunchResult {
warnStructuredLaunchDowngrade(execution.intent.agent, surface.receipt)
execution.onSurfacePublished?.(surface)
return surface
@@ -257,7 +251,7 @@ async function resolveWorkspace(
: rest
}
/** Keep the structured surface’s creation fence for prompt delivery. */
/** Keep the structured create receipt for prompt settlement. */
export type CreatedSurface = {
outcome: AgentLaunchResult['outcome']
warning?: string
@@ -279,6 +273,7 @@ async function createSurface(
...(intent.sessionOptions ? { options: intent.sessionOptions } : {}),
...(intent.sessionId ? { sessionId: intent.sessionId } : {}),
...(reservedTabId ? { tabId: reservedTabId } : {}),
...launchPromptCreateInputs(intent, execution.signal),
...(intent.prompt ? {} : { withoutPrompt: true as const })
})
return {
@@ -1,3 +1,7 @@
import { randomUUID } from 'node:crypto'
import { createStructuredAgentSessionOperationId } from '../../shared/structured-agent-session-mutation'
import { structuredAgentSessionSendBody } from '../../shared/structured-agent-session-send-mutation'
import type { StructuredAgentSessionFirstMessage } from '../../shared/structured-agent-session-create'
import type {
AgentLaunchIntent,
AgentLaunchPromptDisposal,
@@ -9,6 +13,28 @@ import type { CreatedSurface } from './agent-launch-executor'
import type { AgentLaunchSurfaceExecution } from './agent-launch-execution'
import type { AgentLaunchStructuredSurface } from './agent-launch-surface-factories'
function structuredLaunchSubmitText(intent: Pick<AgentLaunchIntent, 'prompt'>): string | undefined {
return intent.prompt?.delivery === 'submit' && intent.prompt.text.trim().length > 0
? intent.prompt.text
: undefined
}
export function launchPromptCreateInputs(
intent: Pick<AgentLaunchIntent, 'prompt'>,
signal?: AbortSignal
): { firstMessage?: StructuredAgentSessionFirstMessage; signal?: AbortSignal } {
const text = structuredLaunchSubmitText(intent)
return text !== undefined
? {
firstMessage: {
clientMessageId: createStructuredAgentSessionOperationId(randomUUID),
body: structuredAgentSessionSendBody(text, [])
},
...(signal ? { signal } : {})
}
: {}
}
export const HANDED_TO_TERMINAL: AgentLaunchPromptDisposal = { outcome: 'handed-to-terminal' }
const NOT_DELIVERED: AgentLaunchPromptDisposal = { outcome: 'not-delivered' }
const UNCONFIRMED: AgentLaunchPromptDisposal = { outcome: 'unconfirmed' }
@@ -16,8 +42,14 @@ const UNCONFIRMED: AgentLaunchPromptDisposal = { outcome: 'unconfirmed' }
/** Persist owed submits as unconfirmed: a crash during delivery must not invite duplicate turns. */
export function settledAtCreation(
intent: Pick<AgentLaunchIntent, 'prompt'>,
created: { promptRodeLaunchCommand?: boolean }
created: { promptRodeLaunchCommand?: boolean; structured?: AgentLaunchStructuredSurface }
): AgentLaunchPromptDisposal {
if (created.structured?.firstMessage) {
return { outcome: 'journaled', messageId: created.structured.firstMessage.clientMessageId }
}
if (created.structured) {
return structuredLaunchSubmitText(intent) === undefined ? NOT_DELIVERED : UNCONFIRMED
}
if (created.promptRodeLaunchCommand) {
return HANDED_TO_TERMINAL
}
@@ -29,8 +61,7 @@ export async function settleLaunchPromptDisposal(
created: CreatedSurface
): Promise<AgentLaunchPromptDisposal> {
if (created.structured) {
const messageId = await deliverStructuredLaunchPrompt(execution, created.structured)
return messageId ? { outcome: 'journaled', messageId } : NOT_DELIVERED
return settledAtCreation(execution.intent, created)
}
if (created.promptRodeLaunchCommand) {
return HANDED_TO_TERMINAL
@@ -38,24 +69,6 @@ export async function settleLaunchPromptDisposal(
return deliverTerminalLaunchPrompt(execution, created.outcome.handle, { freshLaunch: true })
}
/** Drafts belong in the client composer, so only submits reach the transcript. */
async function deliverStructuredLaunchPrompt(
execution: AgentLaunchSurfaceExecution,
structured: AgentLaunchStructuredSurface
): Promise<string | null> {
const { intent, surfaces } = execution
if (!intent.prompt || intent.prompt.delivery !== 'submit' || execution.signal?.aborted) {
return null
}
return (
(await surfaces.deliverStructuredPrompt?.({
sessionId: structured.sessionId,
fence: structured.fence,
prompt: intent.prompt
})) ?? null
)
}
/** PTY writes prove handoff, not consumption; drafts remain undelivered because acceptance is unobservable. */
export async function deliverTerminalLaunchPrompt(
execution: AgentLaunchSurfaceExecution,
@@ -1,5 +1,6 @@
import type { AgentLaunchIntent, AgentLaunchPrompt } from '../../shared/agent-launch-intent'
import type { StructuredAgentId } from '../../shared/agent-session-provider-handle'
import type { StructuredAgentSessionFirstMessage } from '../../shared/structured-agent-session-create'
import type { TuiAgent } from '../../shared/tui-agent'
/** Inject creation so orchestration can add subscriptions without duplicating launch sequencing. */
@@ -14,6 +15,8 @@ export type AgentLaunchSurfaceFactory = {
tabId?: string
/** Keep chat idle until the caller submits its first message. */
withoutPrompt?: true
firstMessage?: StructuredAgentSessionFirstMessage
signal?: AbortSignal
}): Promise<AgentLaunchStructuredSurface>
createTerminalAgent(args: {
worktreeId: string
@@ -37,12 +40,6 @@ export type AgentLaunchSurfaceFactory = {
warning?: string
promptRodeLaunchCommand?: boolean
}>
/** Return the committed message ID, or null on failure; failed delivery must not fail a running launch. */
deliverStructuredPrompt?(args: {
sessionId: string
fence: number
prompt: AgentLaunchPrompt
}): Promise<string | null>
/** Paste into a live PTY; return false on failure without failing the running launch. */
deliverTerminalPrompt?(args: {
handle: string
@@ -55,11 +52,12 @@ export type AgentLaunchSurfaceFactory = {
}): Promise<boolean>
}
/** Carry the creation lease fence into the prompt envelope without rereading the session. */
/** The created chat and its committed opening submission. */
export type AgentLaunchStructuredSurface = {
sessionId: string
handle: string
fence: number
firstMessage?: { clientMessageId: string }
/** Older hosts may omit the host-owned tab ID. */
tabId?: string
}
@@ -1,92 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { StructuredAgentSessionHost } from './structured-agent-session-host'
import { commitStructuredAgentSessionLaunchPrompt } from '../../runtime/rpc/methods/agent-launch-structured-prompt'
import {
accepted,
attachParams,
CALLER,
hostTestState
} from './structured-agent-session-host-test-harness'
import { HOST_TEST_NOW as NOW } from './structured-agent-session-host-test-data'
let host: StructuredAgentSessionHost
/** Model adapter ordering: acquire publishes liveness; dispatch requires it. */
function modelAdapterLiveness(registerOnAcquire: boolean): void {
const { acquire, dispatch } = hostTestState()
const live = new Set<string>()
const acquireChild = acquire.getMockImplementation()!
acquire.mockImplementation(async (input) => {
const acquisition = await acquireChild(input)
if (registerOnAcquire) {
live.add(input.identity.sessionId)
}
return acquisition
})
dispatch.mockImplementation(async ({ sessionId }) => {
if (!live.has(sessionId)) {
throw new Error(`no live structured session for ${sessionId}`)
}
return accepted()
})
}
/** Match launch-surface ordering: create, then send at its returned fence. */
async function launchAndDeliver(): Promise<{
messageId: string | null
dispatchState: string
}> {
const send = vi.spyOn(host, 'send')
const created = await host.attach(CALLER, attachParams())
if (!created.ok) {
throw new Error(`expected a create, got ${created.refusal.code}`)
}
const messageId = await commitStructuredAgentSessionLaunchPrompt({
host,
caller: CALLER,
sessionId: created.value.sessionId,
fence: created.value.fence,
text: 'fix the failing test'
})
const sent = await send.mock.results[0]!.value
if (!sent.ok) {
return { messageId, dispatchState: `refused:${sent.refusal.code}:${sent.refusal.message}` }
}
let dispatchState = sent.value.submission.dispatchState
await vi.waitFor(async () => {
dispatchState =
(await host.journalSnapshot(created.value.sessionId)).submissions.find(
(entry) => entry.clientMessageId === sent.value.clientMessageId
)?.dispatchState ?? 'missing'
expect(dispatchState).not.toBe('pending')
})
return { messageId, dispatchState }
}
beforeEach(() => {
;({ host } = hostTestState())
// Match operation-ID timestamps to the pinned host clock to avoid stale-send refusal.
vi.spyOn(Date, 'now').mockReturnValue(NOW)
})
describe('a launch prompt sent the instant the create resolves', () => {
it('reaches a live provider, because attach returns only after acquire published it', async () => {
modelAdapterLiveness(true)
await expect(launchAndDeliver()).resolves.toEqual({
messageId: expect.any(String),
dispatchState: 'accepted'
})
expect(hostTestState().dispatch).toHaveBeenCalledTimes(1)
})
// Negative control: early acquire resolution would strand an accepted message.
it('would commit a row and strand it if acquire ever resolved before publication', async () => {
modelAdapterLiveness(false)
await expect(launchAndDeliver()).resolves.toEqual({
messageId: expect.any(String),
dispatchState: 'unknown'
})
})
})
@@ -9,9 +9,20 @@ import {
import { HOST_TEST_NOW } from '../../../native-chat/agent-session-wire/structured-agent-session-host-test-data'
import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry'
import type { RpcContext } from '../core'
import { methodNamed, rpcContext, runtimeStub } from './agent-launch.test-fixture'
import { AgentSessionPreSpawnError } from '../../../native-chat/agent-session-wire/structured-agent-session-adapter'
import { CodexAppServerRequestError } from '../../../codex/app-server/codex-app-server-request-error'
import { executeAgentLaunch } from '../../../agent-launch/agent-launch-executor'
import { agentLaunchSurfaceFactory } from './agent-launch-surfaces'
import * as structuredCreate from './structured-agent-session-create'
import {
methodNamed,
rpcContext,
runtimeStub,
setAgentLaunchRecordStore
} from './agent-launch.test-fixture'
const { AGENT_LAUNCH_METHODS } = await import('./agent-launch')
const AGENT_LAUNCH_REPLAY = methodNamed(AGENT_LAUNCH_METHODS, 'agent.launchReplay')
const AGENT_LAUNCH = methodNamed(AGENT_LAUNCH_METHODS, 'agent.launch')
// The phone: it renders either surface and sends a new chat's first message itself.
@@ -32,15 +43,30 @@ beforeEach(async () => {
// The launch mints its operation id from the wall clock; the rig's host reads a fixed one.
vi.useFakeTimers({ now: HOST_TEST_NOW, toFake: ['Date'] })
rig = await createRestTestRig({ idleSweep: { intervalMs: 60_000 } })
const acquire = rig.adapter.acquire.getMockImplementation()!
rig.adapter.acquire.mockImplementation(async (input) => {
const acquired = await acquire(input)
return {
...acquired,
link: {
...acquired.link!,
origin: rig.store.getRecord(input.identity.sessionId)?.providerHandleChain.length
? 'resumed'
: 'created'
}
}
})
setStructuredAgentSessionHost(rig.host)
setAgentLaunchRecordStore(rig.store)
})
afterEach(async () => {
setAgentLaunchRecordStore(null)
setStructuredAgentSessionHost(null)
await rig.dispose()
vi.useRealTimers()
})
function launch(caller: Partial<RpcContext>, prompt?: string) {
function launchRuntime(folder = false) {
const runtime = {
...runtimeStub(),
resolveStructuredAgentSessionCreateIntent: vi.fn(async (params: { envelope: unknown }) => ({
@@ -49,7 +75,7 @@ function launch(caller: Partial<RpcContext>, prompt?: string) {
executionHostId: 'local',
wslDistro: null,
workspaceId: 'workspace-1',
workspaceKind: 'git-worktree'
workspaceKind: folder ? 'folder' : 'git-worktree'
},
provider: 'codex',
agent: 'codex',
@@ -57,31 +83,321 @@ function launch(caller: Partial<RpcContext>, prompt?: string) {
options: { model: 'gpt-5.6-sol', effort: 'medium' },
runtimeKind: 'native'
})),
selectCreatedMobileSessionTabForClient: vi.fn(() => true),
publishStructuredAgentSessionTab: vi.fn(async () => {})
}
return runtime
}
function launch(caller: Partial<RpcContext>, prompt?: string, delivery = 'submit') {
const params = AGENT_LAUNCH.params.parse({
agent: 'codex',
target: { kind: 'existing', worktree: 'id:wt-7' },
sessionId: SESSION,
...(prompt ? { prompt: { text: prompt, delivery: 'submit' } } : {})
...(prompt ? { prompt: { text: prompt, delivery } } : {})
})
return AGENT_LAUNCH.handler(params, rpcContext(runtime, caller))
return AGENT_LAUNCH.handler(params, rpcContext(launchRuntime(), caller))
}
it.each([
{ name: 'a bare launch from the phone waits', caller: PHONE, startsAtCreation: false },
{
name: 'a launch from the phone with a prompt starts',
name: 'a draft launch keeps eager creation',
caller: PHONE,
prompt: 'fix the build',
delivery: 'draft',
startsAtCreation: true
},
{ name: 'a bare launch from the CLI starts', caller: CLI, startsAtCreation: true }
])('$name', async ({ caller, prompt, startsAtCreation }) => {
const result = await launch(caller, prompt)
])('$name', async ({ caller, prompt, delivery, startsAtCreation }) => {
const result = await launch(caller, prompt, delivery)
expect(result.outcome).toMatchObject({ kind: 'structured', sessionId: SESSION })
expect(rig.store.getRecord(SESSION)).toBeDefined()
expect(result.prompt).toEqual(
prompt ? { delivery: 'draft', outcome: 'not-delivered' } : undefined
)
expect((await rig.host.journalSnapshot(SESSION)).submissions).toEqual([])
// At rest, nothing is spawned until the chat's first message.
expect(rig.adapter.acquire.mock.calls.length > 0).toBe(startsAtCreation)
})
async function child(sessionId = SESSION) {
await vi.waitFor(() =>
expect(rig.host.collaboratorsForTests().sessions.get(sessionId)?.child).toBeTruthy()
)
const current = rig.host.collaboratorsForTests().sessions.get(sessionId)!.child!
if (!current.generation) {
throw new Error('expected a published child')
}
return { sessionId, fence: current.fence, acquisitionGeneration: current.generation }
}
async function endChild(sessionId = SESSION) {
const current = await child(sessionId)
await rig.host.handleAdapterEvent({
type: 'ended',
...current,
reason: 'exited during initialize',
cause: 'unexpected-exit'
})
await vi.waitFor(() =>
expect(rig.host.collaboratorsForTests().sessions.get(sessionId)?.child).toBeNull()
)
}
function holdStarts() {
const acquire = rig.adapter.acquire.getMockImplementation()!
rig.adapter.acquire.mockImplementation(rig.heldStart(acquire))
}
it('a whitespace-only submit creates an eager empty chat without sending a turn', async () => {
const result = await launch(PHONE, ' \t\n ')
expect(result.outcome).toMatchObject({ kind: 'structured', sessionId: SESSION })
expect(result.prompt).toEqual({ delivery: 'submit', outcome: 'not-delivered' })
expect(rig.adapter.acquire).toHaveBeenCalledOnce()
expect((await rig.host.journalSnapshot(SESSION)).submissions).toEqual([])
expect(rig.adapter.dispatch).not.toHaveBeenCalled()
})
it('the opening prompt owns retry for a legacy-capability CLI caller', async () => {
holdStarts()
const result = await launch(CLI, 'fix the build')
expect(result.prompt).toEqual({
delivery: 'submit',
outcome: 'journaled',
messageId: expect.any(String)
})
expect((await rig.host.journalSnapshot(SESSION)).submissions).toMatchObject([
{
clientMessageId: result.prompt?.outcome === 'journaled' ? result.prompt.messageId : undefined,
source: { kind: 'user' },
dispatchState: 'pending'
}
])
await endChild()
await vi.waitFor(() => expect(rig.adapter.acquire).toHaveBeenCalledTimes(2))
await rig.proveStart(await child())
await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledOnce())
expect(rig.adapter.dispatch.mock.calls[0]?.[0].clientMessageId).toBe(
result.prompt?.outcome === 'journaled' ? result.prompt.messageId : undefined
)
const snapshot = await rig.host.journalSnapshot(SESSION)
expect(snapshot.submissions).toMatchObject([{ dispatchState: 'accepted' }])
expect(snapshot.items.filter((item) => item.body.kind === 'status')).toEqual([])
})
it('rejects one requester with one failure row when all five children exit', async () => {
holdStarts()
const result = await launch(CLI, 'fix the build')
for (let attempt = 1; attempt <= 5; attempt++) {
await vi.waitFor(() => expect(rig.adapter.acquire).toHaveBeenCalledTimes(attempt))
await endChild()
}
await vi.waitFor(async () =>
expect((await rig.host.journalSnapshot(SESSION)).submissions).toMatchObject([
{
clientMessageId:
result.prompt?.outcome === 'journaled' ? result.prompt.messageId : undefined,
dispatchState: 'rejected'
}
])
)
const snapshot = await rig.host.journalSnapshot(SESSION)
expect(snapshot.submissions).toHaveLength(1)
expect(
snapshot.items.filter((item) => item.body.kind === 'status' && item.body.tone === 'error')
).toHaveLength(1)
expect(rig.adapter.dispatch).not.toHaveBeenCalled()
})
it.each(['spawn', 'open'] as const)(
'keeps a %s failure out of the accepted launch reply',
async (stage) => {
if (stage === 'spawn') {
rig.adapter.acquire.mockRejectedValueOnce(
new AgentSessionPreSpawnError(Object.assign(new Error('busy'), { code: 'EAGAIN' }))
)
} else {
vi.spyOn(rig.host.deps.adapter, 'open').mockRejectedValueOnce(
new CodexAppServerRequestError('initialize', -32603, 'temporarily unavailable')
)
}
const result = await launch(CLI, 'fix the build')
expect(result.prompt?.outcome).toBe('journaled')
if (stage === 'spawn') {
await vi.waitFor(async () =>
expect((await rig.host.journalSnapshot(SESSION)).submissions).toMatchObject([
{ dispatchState: 'rejected' }
])
)
expect(rig.adapter.dispatch).not.toHaveBeenCalled()
expect(rig.adapter.acquire).toHaveBeenCalledOnce()
} else {
await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledOnce())
expect(rig.adapter.acquire).toHaveBeenCalledTimes(2)
}
expect((await rig.host.journalSnapshot(SESSION)).submissions).toHaveLength(1)
}
)
it('replays a lost reply for a prompted folder launch without another message or child', async () => {
const runtime = launchRuntime(true)
const context = rpcContext(runtime, {
clientKind: 'mobile',
pairedDeviceId: 'old-phone',
clientCapabilities: [AGENT_LAUNCH_RUNTIME_CAPABILITY]
})
const params = AGENT_LAUNCH_REPLAY.params.parse({
agent: 'codex',
target: { kind: 'existing', worktree: 'id:folder:fw-1' },
sessionId: SESSION,
operationId: `${HOST_TEST_NOW}-000000000000000000000000000000ee`,
prompt: { text: 'fix the build', delivery: 'submit' },
sessionOptions: {
model: 'explicit-model',
effort: 'high',
serviceTier: 'fast',
fastMode: 'true'
}
})
const first = await AGENT_LAUNCH_REPLAY.handler(params, context)
await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledOnce())
const replay = await AGENT_LAUNCH_REPLAY.handler(params, context)
expect(replay).toEqual(first)
expect(first.prompt?.outcome).toBe('journaled')
expect(runtime.resolveStructuredAgentSessionCreateIntent).toHaveBeenCalledOnce()
expect(rig.adapter.acquire).toHaveBeenCalledOnce()
expect(rig.store.getRecord(SESSION)?.options).toEqual({
model: 'explicit-model',
effort: 'high',
serviceTier: 'fast'
})
expect((await rig.host.journalSnapshot(SESSION)).submissions).toHaveLength(1)
})
it.each([true, false])(
'keeps an explicit Claude Fast %s over the opposite saved selection',
async (fastMode) => {
const runtime = launchRuntime()
const resolve = runtime.resolveStructuredAgentSessionCreateIntent.getMockImplementation()!
runtime.resolveStructuredAgentSessionCreateIntent.mockImplementation(async (params) => ({
...(await resolve(params)),
provider: 'claude',
agent: 'claude',
accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/host/.claude' },
options: { model: 'claude-opus-4-6', effort: 'medium', fastMode: String(!fastMode) }
}))
const created = await agentLaunchSurfaceFactory(
rpcContext(runtime, PHONE)
).createStructuredSession({
worktreeId: 'wt-7',
agent: 'claude',
withoutPrompt: true,
options: { model: 'claude-opus-4-6', fastMode }
})
expect(rig.store.getRecord(created.sessionId)?.options).toEqual({
model: 'claude-opus-4-6',
effort: 'medium',
fastMode: String(fastMode)
})
expect(rig.adapter.acquire).not.toHaveBeenCalled()
}
)
it('omits unsupported-only Codex Fast overrides and retains saved defaults', async () => {
const runtime = launchRuntime()
const create = vi.spyOn(structuredCreate, 'createStructuredAgentSessionForWorktree')
const created = await agentLaunchSurfaceFactory(
rpcContext(runtime, PHONE)
).createStructuredSession({
worktreeId: 'wt-7',
agent: 'codex',
withoutPrompt: true,
options: { fastMode: true }
})
expect(create.mock.calls[0]?.[0]).not.toHaveProperty('options')
expect(rig.store.getRecord(created.sessionId)?.options).toEqual({
model: 'gpt-5.6-sol',
effort: 'medium'
})
expect(rig.adapter.acquire).not.toHaveBeenCalled()
create.mockRestore()
})
it.each(['before', 'after'] as const)(
'cancellation %s the create commit respects its receipt',
async (when) => {
holdStarts()
const controller = new AbortController()
const runtime = launchRuntime()
const context = rpcContext(runtime, CLI)
if (when === 'before') {
const resolve = runtime.resolveStructuredAgentSessionCreateIntent.getMockImplementation()!
runtime.resolveStructuredAgentSessionCreateIntent.mockImplementationOnce(async (input) => {
controller.abort()
return resolve(input)
})
} else {
runtime.publishStructuredAgentSessionTab.mockImplementationOnce(async () =>
controller.abort()
)
}
const running = executeAgentLaunch({
runtime,
signal: controller.signal,
surfaces: agentLaunchSurfaceFactory(context),
intent: {
agent: 'codex',
sessionId: SESSION,
target: { kind: 'existing', worktree: 'wt-7' },
prompt: { text: 'keep my text', delivery: 'submit' }
}
})
if (when === 'before') {
await expect(running).rejects.toThrow('request_aborted')
expect(rig.store.getRecord(SESSION)).toBeNull()
expect(rig.adapter.acquire).not.toHaveBeenCalled()
return
}
const result = await running
expect(result.prompt?.outcome).toBe('journaled')
await child()
await rig.host.close(SESSION, 'user-close')
await rig.host.revealSession(SESSION)
const snapshot = await rig.host.journalSnapshot(SESSION)
expect(snapshot.submissions).toMatchObject([
{
source: { kind: 'user' },
keptAsQueuedMessageId:
result.prompt?.outcome === 'journaled' ? result.prompt.messageId : undefined
}
])
expect(
rig.host.collaboratorsForTests().sessions.get(SESSION)?.journal.queuedMessages.list()
).toHaveLength(1)
expect(rig.adapter.dispatch).not.toHaveBeenCalled()
}
)
it('a tab publication failure after commit never calls the prompt undelivered', async () => {
const runtime = launchRuntime()
runtime.publishStructuredAgentSessionTab.mockRejectedValueOnce(
new Error('publication unavailable')
)
await expect(
AGENT_LAUNCH.handler(
AGENT_LAUNCH.params.parse({
agent: 'codex',
sessionId: SESSION,
target: { kind: 'existing', worktree: 'id:wt-7' },
prompt: { text: 'keep my text', delivery: 'submit' }
}),
rpcContext(runtime, CLI)
)
).rejects.toThrow()
expect((await rig.host.journalSnapshot(SESSION)).submissions).toMatchObject([
{ source: { kind: 'user' }, dispatchState: 'pending' }
])
expect(rig.adapter.acquire).not.toHaveBeenCalled()
})
@@ -25,11 +25,6 @@ const deliverTerminalPrompt = vi.hoisted(() => vi.fn(async () => true))
vi.mock('./agent-launch-terminal-prompt', () => ({
deliverTerminalAgentLaunchPrompt: deliverTerminalPrompt
}))
const commitChatPrompt = vi.hoisted(() => vi.fn(async () => 'message-1'))
vi.mock('./agent-launch-structured-prompt', () => ({
commitStructuredAgentSessionLaunchPrompt: commitChatPrompt
}))
const { AGENT_LAUNCH_METHODS } = await import('./agent-launch')
const AGENT_LAUNCH = methodNamed(AGENT_LAUNCH_METHODS, 'agent.launch')
const AGENT_LAUNCH_REPLAY = methodNamed(AGENT_LAUNCH_METHODS, 'agent.launchReplay')
@@ -70,7 +65,6 @@ function chatActivation(): unknown {
beforeEach(() => {
deliverTerminalPrompt.mockClear()
commitChatPrompt.mockClear()
createStructuredSession
.mockReset()
.mockResolvedValue({ ok: true, value: { sessionId: 'sess-1' } })
@@ -104,10 +98,7 @@ describe('a paired client launching into an existing workspace', () => {
})
// Prompt delivery can take a minute; caller navigation must not wait.
it.each([
['terminal', {}, deliverTerminalPrompt],
['chat', STRUCTURED_PREFERENCE, commitChatPrompt]
] as const)(
it.each([['terminal', {}, deliverTerminalPrompt]] as const)(
'selects the new %s for the caller before its prompt is delivered',
async (_surface, settings, deliver) => {
const runtime = selectionRuntime({ settings, terminalPaneKey: PANE_KEY })
@@ -1,102 +0,0 @@
/** Use client-shaped message identity/fingerprint and return an id only for a proven commit; claiming an uncommitted send loses text. */
import { describe, expect, it, vi } from 'vitest'
import { commitStructuredAgentSessionLaunchPrompt } from './agent-launch-structured-prompt'
import { structuredAgentSessionPayloadFingerprint } from '../../../../shared/structured-agent-session-mutation'
import { structuredAgentSessionSendBody } from '../../../../shared/structured-agent-session-send-mutation'
import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host'
import { recordingStructuredAgentSessionLogger } from '../../../native-chat/agent-session-wire/structured-agent-session-logger-test-support'
const CALLER = { callerKey: 'trusted-local:runtime' }
function hostWith(
send: ReturnType<typeof vi.fn>,
journalSnapshot: ReturnType<typeof vi.fn> = vi.fn(() => ({ submissions: [] })),
log = recordingStructuredAgentSessionLogger()
): StructuredAgentSessionHost {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub implements the only members this module reaches; any other would throw rather than read a wrong value.
return {
send,
journalSnapshot,
deps: { logger: log.logger }
} as unknown as StructuredAgentSessionHost
}
function commit(host: StructuredAgentSessionHost | null, text = 'do the thing') {
return commitStructuredAgentSessionLaunchPrompt({
host,
caller: CALLER,
sessionId: 'sess-1',
fence: 4,
text
})
}
describe('committing a launch prompt', () => {
it('sends the entry as its own client message id and names the committed row', async () => {
const send = vi.fn(async (_caller, params) => ({
ok: true as const,
value: { clientMessageId: params.envelope.clientOperationId, submission: {} }
}))
const messageId = await commit(hostWith(send))
const [caller, params] = send.mock.calls[0]
expect(caller).toEqual(CALLER)
expect(messageId).toBe(params.envelope.clientOperationId)
expect(params.envelope).toMatchObject({ sessionId: 'sess-1', expectedRuntimeFence: 4 })
expect(params.body).toEqual(structuredAgentSessionSendBody('do the thing', []))
// A restart keeps a launch's first prompt like a person's message, so the send says it is one.
expect(params.personsMessage).toBe(true)
// The host recomputes and compares this, so a launch send must fingerprint like a client send.
expect(params.envelope.payloadFingerprint).toBe(
structuredAgentSessionPayloadFingerprint({
method: 'agentSession.send',
sessionId: 'sess-1',
fields: { body: params.body }
})
)
})
it('claims nothing when the send is refused', async () => {
const send = vi.fn(async () => ({
ok: false as const,
refusal: { code: 'agent_session_operation_invalid', message: 'no' }
}))
await expect(commit(hostWith(send))).resolves.toBeNull()
})
it('recovers a committed row when settlement throws after append', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => {})
let clientMessageId = ''
const send = vi.fn(
async (_caller: unknown, params: { envelope: { clientOperationId: string } }) => {
clientMessageId = params.envelope.clientOperationId
throw new Error('host gone')
}
)
const journalSnapshot = vi.fn((_sessionId) => ({
submissions: [{ clientMessageId }]
}))
await expect(commit(hostWith(send, journalSnapshot))).resolves.toEqual(clientMessageId)
})
it('claims nothing, and does not fail the launch, when no row was committed', async () => {
const log = recordingStructuredAgentSessionLogger()
const failure = new Error('host gone')
const send = vi.fn(async () => {
throw failure
})
await expect(commit(hostWith(send, undefined, log))).resolves.toBeNull()
expect(log.entries.map((entry) => entry.fields)).toEqual([
{ scope: 'launch-prompt', sessionId: 'sess-1', error: failure }
])
})
it('sends nothing when there is no host or no text', async () => {
const send = vi.fn()
await expect(commit(null)).resolves.toBeNull()
await expect(commit(hostWith(send), ' ')).resolves.toBeNull()
expect(send).not.toHaveBeenCalled()
})
})
@@ -1,57 +0,0 @@
/** Send through the clients' body/envelope builders and the journal's existing submission row; a second host queue could disagree.
* The renderer still delivers its own prompts until its launcher uses agent.launch. */
import {
structuredAgentSessionMessageSendMutation,
structuredAgentSessionSendBody
} from '../../../../shared/structured-agent-session-send-mutation'
import { createStructuredAgentSessionOperationId } from '../../../../shared/structured-agent-session-mutation'
import { randomUUID } from 'node:crypto'
import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host'
import type { StructuredAgentSessionCaller } from '../../../native-chat/agent-session-wire/structured-agent-session-host-types'
/** Return an id only for a proven journal commit, reconciling throws after append to avoid duplicate turns.
* Do not wait for provider dispatch: the submission owns whether the provider took the committed turn. */
export async function commitStructuredAgentSessionLaunchPrompt(args: {
host: StructuredAgentSessionHost | null
caller: StructuredAgentSessionCaller
sessionId: string
fence: number
text: string
}): Promise<string | null> {
if (!args.host || args.text.trim().length === 0) {
return null
}
const clientMessageId = createStructuredAgentSessionOperationId(randomUUID)
try {
const result = await args.host.send(args.caller, {
...structuredAgentSessionMessageSendMutation({
sessionId: args.sessionId,
clientOperationId: clientMessageId,
expectedRuntimeFence: args.fence,
body: structuredAgentSessionSendBody(args.text, [])
}),
// A person's first prompt, sent for them: kept as a card if a restart or a close comes first.
personsMessage: true
})
return result.ok ? result.value.clientMessageId : null
} catch (error) {
// Settlement can fail after append; reconcile the journal before asking the caller to resend.
try {
const committed = (await args.host.journalSnapshot(args.sessionId)).submissions.find(
(submission) => submission.clientMessageId === clientMessageId
)
if (committed) {
return clientMessageId
}
} catch {
// The host may have gone away before the snapshot; the caller retains the text in that case.
}
args.host.deps.logger.warn("sending a created chat's launch prompt failed", {
scope: 'launch-prompt',
sessionId: args.sessionId,
error
})
return null
}
}
@@ -16,7 +16,7 @@ import type { RpcContext } from '../core'
import { STARTUP_CWD_FALLBACK_SENTENCE } from '../../../../shared/startup-cwd-fallback'
import { structuredCallerFor } from './structured-agent-session-gate'
import { createStructuredAgentSessionForWorktree } from './structured-agent-session-create'
import { commitStructuredAgentSessionLaunchPrompt } from './agent-launch-structured-prompt'
import { structuredAgentRuntimeMetadata } from '../../structured-agent-runtime-metadata'
import { deliverTerminalAgentLaunchPrompt } from './agent-launch-terminal-prompt'
import { AgentLaunchSessionAlreadyExistsError } from '../../../../shared/agent-launch-session-already-exists'
import { AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY } from '../../../../shared/agent-session-create-capabilities'
@@ -54,15 +54,24 @@ export function agentLaunchSurfaceFactory(
options,
sessionId: requested,
tabId,
withoutPrompt
withoutPrompt,
firstMessage,
signal
}) => {
const sessionId = requested ?? createStructuredAgentSessionId(agent, randomUUID)
const seeded = narrowStructuredLaunchSeedOptions(options)
const definition = structuredAgentRuntimeMetadata(agent)?.definition
const supported = Object.fromEntries(
Object.entries(narrowStructuredLaunchSeedOptions(options) ?? {}).filter(([key]) =>
definition?.restingOptions.acceptsKey(key)
)
)
const seeded = Object.keys(supported).length > 0 ? supported : undefined
// A client that creates chats without starting them starts this one on its first message.
const atRest =
withoutPrompt === true &&
context.clientCapabilities?.includes(AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY) ===
true
firstMessage !== undefined ||
(withoutPrompt === true &&
context.clientCapabilities?.includes(AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY) ===
true)
const created = await createStructuredAgentSessionForWorktree({
runtime: context.runtime,
ensureHost: async () => {
@@ -85,6 +94,8 @@ export function agentLaunchSurfaceFactory(
...(seeded ? { options: seeded } : {}),
...(tabId ? { tabId } : {}),
...(atRest ? { atRest: true as const } : {}),
...(firstMessage ? { firstMessage } : {}),
...(signal ? { signal } : {}),
activate:
!callerPresentsSurface &&
(presentation === undefined || (presentation === 'focused' && movesHostWindow))
@@ -103,19 +114,10 @@ export function agentLaunchSurfaceFactory(
sessionId: created.value.sessionId,
handle: structuredAgentSessionTabId(created.value.sessionId),
fence: created.value.fence,
...(created.value.firstMessage ? { firstMessage: created.value.firstMessage } : {}),
...(created.value.tabId ? { tabId: created.value.tabId } : {})
}
},
deliverStructuredPrompt: async ({ sessionId, fence, prompt }) =>
commitStructuredAgentSessionLaunchPrompt({
host: getStructuredAgentSessionHost(),
caller: operationCallerKey
? { callerKey: operationCallerKey }
: structuredCallerFor(context),
sessionId,
fence,
text: prompt.text
}),
createTerminalAgent: async ({
worktreeId,
agent,
@@ -0,0 +1,158 @@
import '../unused-default-rpc-methods.test-fixture'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import {
createRestTestRig,
type RestTestRig
} from '../../../native-chat/agent-session-wire/structured-agent-session-rest-test-rig'
import { HOST_TEST_NOW } from '../../../native-chat/agent-session-wire/structured-agent-session-host-test-data'
import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry'
import { CodexAppServerRequestError } from '../../../codex/app-server/codex-app-server-request-error'
import { OrchestrationDb } from '../../orchestration/db'
import {
createStructuredWorkerSession,
releaseStructuredWorkerSession
} from './orchestration-structured-worker-session'
import { deliverWorkerDispatchPreamble } from './orchestration/worker/deliver-worker-dispatch-preamble'
import { tearDownFailedWorkerStart } from './orchestration/worker/failed-worker-start-teardown'
import { structuredWorkerIdentities } from '../../structured-worker-identity'
import { rpcContext, runtimeStub } from './agent-launch.test-fixture'
let rig: RestTestRig
let db: OrchestrationDb
beforeEach(async () => {
vi.useFakeTimers({ now: HOST_TEST_NOW, toFake: ['Date'] })
rig = await createRestTestRig({ idleSweep: { intervalMs: 60_000 } })
const acquire = rig.adapter.acquire.getMockImplementation()!
rig.adapter.acquire.mockImplementation(async (input) => {
const acquired = await acquire(input)
return {
...acquired,
link: {
...acquired.link!,
origin: rig.store.getRecord(input.identity.sessionId)?.providerHandleChain.length
? 'resumed'
: 'created'
}
}
})
setStructuredAgentSessionHost(rig.host)
db = new OrchestrationDb(':memory:')
})
afterEach(async () => {
releaseStructuredWorkerSession('dispatch-retry')
setStructuredAgentSessionHost(null)
await rig.dispose()
db.close()
vi.useRealTimers()
})
async function createWorker() {
const workerRuntime = {
...runtimeStub(),
resolveStructuredAgentSessionCreateIntent: async (params: { envelope: unknown }) => ({
envelope: params.envelope,
location: {
executionHostId: 'local',
wslDistro: null,
workspaceId: 'workspace-1',
workspaceKind: 'git-worktree'
},
provider: 'codex',
agent: 'codex',
accountHome: { variable: 'CODEX_HOME', path: '/host/.codex' },
options: { model: 'gpt-5.6-sol', effort: 'medium' },
runtimeKind: 'native'
}),
publishStructuredAgentSessionTab: async () => {},
getNestedWorkerMaxDepth: () => 2,
getTerminalOrchestrationCliCommand: () => 'orca',
orchestrationSenderNames: { nameOf: () => 'Coordinator' },
forgetStructuredSessionMail: vi.fn(),
retireStructuredAgentSessionTabFromSnapshot: vi.fn(() => true)
}
const runtime = rpcContext(workerRuntime, {}).runtime
const created = await createStructuredWorkerSession({
runtime,
worktreeId: 'wt-7',
agent: 'codex',
dispatchId: 'dispatch-retry',
onJournalActivity: () => {}
})
const deliverPreamble = () =>
deliverWorkerDispatchPreamble({
runtime,
db,
structuredSession: created,
terminalHandle: created.identity.handle,
dispatchId: 'dispatch-retry',
dispatchDepth: 1,
runId: 'run-retry',
taskId: 'task-retry',
taskSpec: 'fix the build',
coordinatorHandle: 'term_coordinator',
devMode: false,
requestId: 'request-retry',
readinessTimeoutMs: 2000
})
return { runtime, created, workerRuntime, deliverPreamble }
}
it('starts only for its agent-sourced preamble and observes it after a transient open failure', async () => {
const { created, deliverPreamble } = await createWorker()
const sessionId = created.identity.sessionId
expect(rig.adapter.acquire).not.toHaveBeenCalled()
expect((await rig.host.journalSnapshot(sessionId)).submissions).toEqual([])
vi.spyOn(rig.host.deps.adapter, 'open').mockRejectedValueOnce(
new CodexAppServerRequestError('initialize', -32603, 'temporarily unavailable')
)
const result = await deliverPreamble()
expect(result.structuredTurnStart).toEqual({ verdict: 'observed' })
expect(rig.adapter.acquire).toHaveBeenCalledTimes(2)
expect(rig.adapter.dispatch).toHaveBeenCalledOnce()
const snapshot = await rig.host.journalSnapshot(sessionId)
expect(snapshot.submissions).toMatchObject([
{
dispatchState: 'accepted',
source: { kind: 'agent' }
}
])
expect(snapshot.submissions).toHaveLength(1)
const [input] = rig.adapter.dispatch.mock.calls[0]!
expect(input).toMatchObject({
body: {
from: {
kind: 'agent',
senders: [{ name: 'Coordinator' }],
orchestration: { message: 'task', dispatchId: 'dispatch-retry' }
}
}
})
expect(snapshot.items.filter((item) => item.body.kind === 'status')).toEqual([])
})
it('reports a preamble as undelivered after startup exhaustion and retires the failed worker', async () => {
const { runtime, created, workerRuntime, deliverPreamble } = await createWorker()
const sessionId = created.identity.sessionId
expect(rig.store.listVisibleSessionIds()).toContain(sessionId)
vi.spyOn(rig.host.deps.adapter, 'open').mockRejectedValue(
new CodexAppServerRequestError('initialize', -32603, 'temporarily unavailable')
)
await expect(deliverPreamble()).rejects.toMatchObject({ code: 'dispatch_preamble_undelivered' })
expect(rig.adapter.acquire).toHaveBeenCalledTimes(5)
expect(rig.adapter.dispatch).not.toHaveBeenCalled()
const snapshot = await rig.host.journalSnapshot(sessionId)
expect(snapshot.submissions).toHaveLength(1)
expect(snapshot.submissions).toMatchObject([
{ dispatchState: 'rejected', source: { kind: 'agent' } }
])
await tearDownFailedWorkerStart({
runtime,
structuredSession: created,
dispatchId: 'dispatch-retry'
})
expect(structuredWorkerIdentities.getBySessionId(sessionId)).toBeNull()
expect(rig.host.hasSession(sessionId)).toBe(false)
expect(rig.store.listVisibleSessionIds()).not.toContain(sessionId)
expect(workerRuntime.forgetStructuredSessionMail).toHaveBeenCalledWith(sessionId)
expect(workerRuntime.retireStructuredAgentSessionTabFromSnapshot).toHaveBeenCalledWith(sessionId)
})
@@ -106,6 +106,7 @@ export async function createStructuredWorkerSession(args: {
// Absent, the host seeds the user's saved selection — the same fallback a chat gets.
...(args.options ? { options: args.options } : {}),
// Dispatching a worker is background work; it must not pull the surface away from the user.
atRest: true,
activate: false
})
if (!created.ok) {
@@ -1,5 +1,6 @@
// Resolve the host launch identity, then commit an at-rest create or the compatible acquired path.
import { assertAgentPromptRequestActive } from '../../orca-runtime-core'
import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID } from '../../../../shared/agent-chat-permission-mode'
import { refuse, AgentSessionRefusalError } from '../../../../shared/agent-session-wire-refusals'
import { computeAgentSessionPayloadFingerprint } from '../../../../shared/agent-session-mutation-envelope'
@@ -27,10 +28,7 @@ import {
type StructuredAgentCliSourceEvidence
} from '../../../native-chat/structured-agent-cli-source'
import type { StructuredAgentId } from '../../../../shared/agent-session-provider-handle'
import {
resolveUncommittedStructuredCreate,
type StructuredCreateRefused
} from './structured-agent-session-precommit-refusal'
import { resolveUncommittedStructuredCreate } from './structured-agent-session-precommit-refusal'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
import { structuredAgentSessionClosedBeforeCreate } from './structured-agent-session-close-before-create'
import { structuredLaunchSeedOnSavedListing } from '../../session-tabs/structured-launch-seed-listing'
@@ -297,6 +295,8 @@ export async function createStructuredAgentSessionForWorktree(args: {
tabId?: string
/** Create without starting the agent; its first message starts it. */
atRest?: true
firstMessage?: StructuredAgentSessionFirstMessage
signal?: AbortSignal
}): Promise<AgentSessionMutationResult<AgentSessionAttachResult>> {
// An at-rest create's ledger row is keyed by its intent, as a client's create is.
const envelope = args.atRest
@@ -305,13 +305,13 @@ export async function createStructuredAgentSessionForWorktree(args: {
payloadFingerprint: structuredAgentSessionCreateIntentFingerprint(args)
}
: args.envelope
const prepared: PreparedStructuredAgentSessionCreate | StructuredCreateRefused =
await resolveUncommittedStructuredCreate(() =>
prepareStructuredAgentSessionCreateForWorktree({ ...args, envelope })
)
const prepared = await resolveUncommittedStructuredCreate(() =>
prepareStructuredAgentSessionCreateForWorktree({ ...args, envelope })
)
if ('refusal' in prepared) {
return { ok: false, refusal: prepared.refusal }
}
assertAgentPromptRequestActive(args.signal)
return commitStructuredAgentSessionCreate({
runtime: args.runtime,
caller: args.caller,
+18 -8
View File
@@ -3,6 +3,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { AGENT_LAUNCH_RUNTIME_CAPABILITIES } from '../../src/shared/agent-launch-runtime-capability'
import type { StructuredAgentSessionFirstMessage } from '../../src/shared/structured-agent-session-create'
import { openTestAgentSessionRecordStore } from '../../src/main/runtime/agent-session-record-store-test-harness'
import { RuntimeClient, RuntimeClientError } from '../../src/cli/runtime-client'
import { callAgentLaunch, readCliAgentLaunch } from '../../src/cli/agent-launch-request'
@@ -17,14 +18,10 @@ import {
} from '../../src/main/runtime/rpc/methods/agent-launch.test-fixture'
const chatCreate = vi.hoisted(() => vi.fn())
const promptCommit = vi.hoisted(() => vi.fn(async () => 'message_test'))
const terminalPrompt = vi.hoisted(() => vi.fn(async () => true))
vi.mock('../../src/main/runtime/rpc/methods/structured-agent-session-create', () => ({
createStructuredAgentSessionForWorktree: chatCreate
}))
vi.mock('../../src/main/runtime/rpc/methods/agent-launch-structured-prompt', () => ({
commitStructuredAgentSessionLaunchPrompt: promptCommit
}))
vi.mock('../../src/main/runtime/rpc/methods/agent-launch-terminal-prompt', () => ({
deliverTerminalAgentLaunchPrompt: terminalPrompt
}))
@@ -34,8 +31,17 @@ let directory: string
beforeEach(async () => {
directory = await mkdtemp(join(tmpdir(), 'orca-launch-cli-'))
setAgentLaunchRecordStore(await openTestAgentSessionRecordStore(directory))
chatCreate.mockReset().mockResolvedValue({ ok: true, value: { sessionId: 'codex_test' } })
promptCommit.mockClear()
chatCreate
.mockReset()
.mockImplementation(async (args: { firstMessage?: StructuredAgentSessionFirstMessage }) => ({
ok: true,
value: {
sessionId: 'codex_test',
...(args.firstMessage
? { firstMessage: { clientMessageId: args.firstMessage.clientMessageId } }
: {})
}
}))
terminalPrompt.mockClear()
})
afterEach(async () => {
@@ -163,12 +169,16 @@ describe('CLI launch through host surface creation and replay admission', () =>
{ target, presentation: 'background' },
AGENT_LAUNCH_RUNTIME_CAPABILITIES
)
const firstMessage = chatCreate.mock.calls[0]?.[0].firstMessage
expect(firstMessage).toMatchObject({
clientMessageId: expect.any(String),
body: { blocks: [{ type: 'text', text: launch.prompt.text }] }
})
expect(result.result.prompt).toMatchObject({
outcome: 'journaled',
messageId: 'message_test'
messageId: firstMessage.clientMessageId
})
expect(chatCreate).toHaveBeenCalledTimes(1)
expect(promptCommit).toHaveBeenCalledTimes(1)
expect(runtime.createManagedWorktree).toHaveBeenCalledTimes(
target.kind === 'create-worktree' ? 1 : 0
)