From c20971dbd109b096c4cfd8aa1ab5ae06e129fbcf Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 26 Oct 2026 10:45:40 -0700 Subject: [PATCH] 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 --- .../agent-launch-executor-cancel.test.ts | 5 - .../agent-launch-executor.test.ts | 78 ++-- .../agent-launch/agent-launch-executor.ts | 15 +- .../agent-launch-prompt-delivery.ts | 55 +-- .../agent-launch-surface-factories.ts | 12 +- ...t-session-launch-send-after-create.test.ts | 92 ----- .../rpc/methods/agent-launch-at-rest.test.ts | 332 +++++++++++++++++- .../agent-launch-caller-selection.test.ts | 11 +- .../agent-launch-structured-prompt.test.ts | 102 ------ .../methods/agent-launch-structured-prompt.ts | 57 --- .../rpc/methods/agent-launch-surfaces.ts | 34 +- ...hestration-structured-worker-retry.test.ts | 158 +++++++++ ...orchestration-structured-worker-session.ts | 1 + .../structured-agent-session-create.ts | 16 +- tests/e2e/agent-launch-cli.unit.test.ts | 26 +- 15 files changed, 609 insertions(+), 385 deletions(-) delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-launch-send-after-create.test.ts delete mode 100644 src/main/runtime/rpc/methods/agent-launch-structured-prompt.test.ts delete mode 100644 src/main/runtime/rpc/methods/agent-launch-structured-prompt.ts create mode 100644 src/main/runtime/rpc/methods/orchestration-structured-worker-retry.test.ts diff --git a/src/main/agent-launch/agent-launch-executor-cancel.test.ts b/src/main/agent-launch/agent-launch-executor-cancel.test.ts index 5fae9d69b6d..958ad1ce3a4 100644 --- a/src/main/agent-launch/agent-launch-executor-cancel.test.ts +++ b/src/main/agent-launch/agent-launch-executor-cancel.test.ts @@ -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 }, diff --git a/src/main/agent-launch/agent-launch-executor.test.ts b/src/main/agent-launch/agent-launch-executor.test.ts index 1719a4ce434..c506e58ae44 100644 --- a/src/main/agent-launch/agent-launch-executor.test.ts +++ b/src/main/agent-launch/agent-launch-executor.test.ts @@ -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[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' }) }) }) diff --git a/src/main/agent-launch/agent-launch-executor.ts b/src/main/agent-launch/agent-launch-executor.ts index 5f5ef3c7767..c98f4176967 100644 --- a/src/main/agent-launch/agent-launch-executor.ts +++ b/src/main/agent-launch/agent-launch-executor.ts @@ -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 { diff --git a/src/main/agent-launch/agent-launch-prompt-delivery.ts b/src/main/agent-launch/agent-launch-prompt-delivery.ts index 3ec9db50ce4..069c927d2e7 100644 --- a/src/main/agent-launch/agent-launch-prompt-delivery.ts +++ b/src/main/agent-launch/agent-launch-prompt-delivery.ts @@ -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): string | undefined { + return intent.prompt?.delivery === 'submit' && intent.prompt.text.trim().length > 0 + ? intent.prompt.text + : undefined +} + +export function launchPromptCreateInputs( + intent: Pick, + 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, - 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 { 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 { - 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, diff --git a/src/main/agent-launch/agent-launch-surface-factories.ts b/src/main/agent-launch/agent-launch-surface-factories.ts index d165b39dcf5..f623bcafda3 100644 --- a/src/main/agent-launch/agent-launch-surface-factories.ts +++ b/src/main/agent-launch/agent-launch-surface-factories.ts @@ -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 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 /** 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 } -/** 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 } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-send-after-create.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-send-after-create.test.ts deleted file mode 100644 index a96f7c8bab7..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-send-after-create.test.ts +++ /dev/null @@ -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() - 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' - }) - }) -}) diff --git a/src/main/runtime/rpc/methods/agent-launch-at-rest.test.ts b/src/main/runtime/rpc/methods/agent-launch-at-rest.test.ts index e7efee5de51..ba4a841e798 100644 --- a/src/main/runtime/rpc/methods/agent-launch-at-rest.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-at-rest.test.ts @@ -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, prompt?: string) { +function launchRuntime(folder = false) { const runtime = { ...runtimeStub(), resolveStructuredAgentSessionCreateIntent: vi.fn(async (params: { envelope: unknown }) => ({ @@ -49,7 +75,7 @@ function launch(caller: Partial, 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, 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, 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() +}) diff --git a/src/main/runtime/rpc/methods/agent-launch-caller-selection.test.ts b/src/main/runtime/rpc/methods/agent-launch-caller-selection.test.ts index 40c3b2a9ade..aa871ceabaf 100644 --- a/src/main/runtime/rpc/methods/agent-launch-caller-selection.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-caller-selection.test.ts @@ -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 }) diff --git a/src/main/runtime/rpc/methods/agent-launch-structured-prompt.test.ts b/src/main/runtime/rpc/methods/agent-launch-structured-prompt.test.ts deleted file mode 100644 index 0d45b71d957..00000000000 --- a/src/main/runtime/rpc/methods/agent-launch-structured-prompt.test.ts +++ /dev/null @@ -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, - journalSnapshot: ReturnType = 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() - }) -}) diff --git a/src/main/runtime/rpc/methods/agent-launch-structured-prompt.ts b/src/main/runtime/rpc/methods/agent-launch-structured-prompt.ts deleted file mode 100644 index 1fd8ce94a7b..00000000000 --- a/src/main/runtime/rpc/methods/agent-launch-structured-prompt.ts +++ /dev/null @@ -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 { - 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 - } -} diff --git a/src/main/runtime/rpc/methods/agent-launch-surfaces.ts b/src/main/runtime/rpc/methods/agent-launch-surfaces.ts index 5f647634240..486b9aab330 100644 --- a/src/main/runtime/rpc/methods/agent-launch-surfaces.ts +++ b/src/main/runtime/rpc/methods/agent-launch-surfaces.ts @@ -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, diff --git a/src/main/runtime/rpc/methods/orchestration-structured-worker-retry.test.ts b/src/main/runtime/rpc/methods/orchestration-structured-worker-retry.test.ts new file mode 100644 index 00000000000..0401cff96eb --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-retry.test.ts @@ -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) +}) diff --git a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts index fd773602c7a..716cff96e72 100644 --- a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts @@ -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) { diff --git a/src/main/runtime/rpc/methods/structured-agent-session-create.ts b/src/main/runtime/rpc/methods/structured-agent-session-create.ts index 20d91d07b3d..5e4d4750895 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-create.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-create.ts @@ -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> { // 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, diff --git a/tests/e2e/agent-launch-cli.unit.test.ts b/tests/e2e/agent-launch-cli.unit.test.ts index 16017aa086b..634ef698b62 100644 --- a/tests/e2e/agent-launch-cli.unit.test.ts +++ b/tests/e2e/agent-launch-cli.unit.test.ts @@ -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 )